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
@@ -54,8 +54,10 @@ No forecast, or a stale or unusable one: 1.5 x, as before. It is read in three
places that must agree: the run plan (`_price_runs()`), the charge gate places that must agree: the run plan (`_price_runs()`), the charge gate
(`_mode_roles()`) and Home's mirror (`homeview.trade_verdict()`). The forecast (`_mode_roles()`) and Home's mirror (`homeview.trade_verdict()`). The forecast
is read at most every 60 s and the plan key already holds "forecast or not", so is read at most every 60 s and the plan key already holds "forecast or not", so
the gap follows in the cycle the read changes, both ways. A run already under the gap follows in the cycle the read changes, both ways, with one exception:
way is kept to its end (`priceruns.keep_started()`), as for any change. a run planned with a forecast that is under way keeps that gap to its end
(`Controller.weather_held()`), so a sensor that goes unavailable does not stop
or restart a charge. The wider gap applies from the next run.
**Not changed.** Modes off (the site trade) keeps the doubt always. The **Not changed.** Modes off (the site trade) keeps the doubt always. The
shipped cost 0.10 still needs a gap of 0.10 even with a forecast: a 0.08 shipped cost 0.10 still needs a gap of 0.10 even with a forecast: a 0.08
+6 -4
View File
@@ -267,15 +267,17 @@ def threshold_eur_kwh(opts: dict) -> float:
return v return v
def weather_uncertainty(modes_on: bool, solar_fc) -> frozenset: def weather_uncertainty(modes_on: bool, solar_fc, held: bool = False) -> frozenset:
"""The "no weather" doubt (x1.5) a trade must clear. TARIFF-24. """The "no weather" doubt (x1.5) a trade must clear. TARIFF-24.
Battery modes read a solar forecast; with one read (`solar_fc` not None) the Battery modes read a solar forecast; with one read (`solar_fc` not None) the
doubt is gone. Everything else, and the modes without a forecast, keep it. doubt is gone, and `held` (a run planned with a forecast is under way, see
One rule for the run plan, the charge gate and Home's mirror of it. See `Controller.weather_held()`) keeps it gone until that run ends. Everything
else, and the modes without a forecast, keep it. One rule for the run plan,
the charge gate and Home's mirror. See
DECISIONS/TARIFF-24-modes-need-the-tariff-task.md. DECISIONS/TARIFF-24-modes-need-the-tariff-task.md.
""" """
if modes_on and solar_fc is not None: if modes_on and (solar_fc is not None or held):
return frozenset() return frozenset()
return frozenset({Uncertainty.NO_WEATHER}) return frozenset({Uncertainty.NO_WEATHER})
+3 -1
View File
@@ -279,7 +279,9 @@ def trade_verdict(c, plan, now):
min_spread_eur_kwh=arbitrage.threshold_eur_kwh(c.o), min_spread_eur_kwh=arbitrage.threshold_eur_kwh(c.o),
uncertainty=arbitrage.weather_uncertainty( uncertainty=arbitrage.weather_uncertainty(
bool(getattr(c, "modes_on", False)), bool(getattr(c, "modes_on", False)),
getattr(c, "solar_fc", None)), getattr(c, "solar_fc", None),
bool(getattr(c, "weather_held",
lambda _n: False)(now))),
now=now) now=now)
return verdict.match( return verdict.match(
allowed=lambda v: (v, "allowed"), allowed=lambda v: (v, "allowed"),
+33 -4
View File
@@ -960,6 +960,9 @@ class Controller:
# and whether last tick filed a mode trade (a run edge reseeds). # and whether last tick filed a mode trade (a run edge reseeds).
self.mode_runs = None self.mode_runs = None
self._runs_key = None self._runs_key = None
# TARIFF-24: the end of the run that was under way while a forecast
# was read; until then losing the forecast does not widen the gap.
self._wx_free_to = None
self.mode_tick = None self.mode_tick = None
self._run_done = {} self._run_done = {}
self._mode_trade_was = False self._mode_trade_was = False
@@ -2226,6 +2229,28 @@ class Controller:
probing=probing)) probing=probing))
return out return out
def _wx_track(self, now) -> None:
"""TARIFF-24, once per cycle, before the plan: with a forecast read,
remember when the run now under way (cheap, give or sell) ends."""
if self.solar_fc is None:
return
rp = self.mode_runs
ends = []
if rp is not None and rp.on:
ends = [r.end for r in (rp.cheap, rp.sell)
if r is not None and r.holds(now)]
g = rp.give_at(now)
if g is not None:
ends.append(g.end)
self._wx_free_to = max(ends) if ends else None
def weather_held(self, now) -> bool:
"""TARIFF-24: no forecast now, but a run planned with one is under
way: it keeps its gap to its end. The wider gap applies from the next
run. Pure; Home asks the same (`homeview.trade_verdict()`)."""
return (self.solar_fc is None and self._wx_free_to is not None
and now < self._wx_free_to)
def _price_runs(self, now, packs, floor_pct, ceiling_pct): def _price_runs(self, now, packs, floor_pct, ceiling_pct):
"""The kept run plan, rebuilt at most once per price slot (15 min) or """The kept run plan, rebuilt at most once per price slot (15 min) or
when the prices or a battery's choice change; a run under way is when the prices or a battery's choice change; a run under way is
@@ -2239,9 +2264,11 @@ class Controller:
slot = int(now.timestamp() // 900) slot = int(now.timestamp() // 900)
key = (id(plan), slot, tuple((p.name, p.use, p.mode, p.sell) key = (id(plan), slot, tuple((p.name, p.use, p.mode, p.sell)
for p in packs)) for p in packs))
held = self.weather_held(now)
if self.fc_entity is not None: if self.fc_entity is not None:
# P4: a forecast arriving or going rebuilds the plan at once. # P4: a forecast arriving or going rebuilds the plan at once (and
key += (self.solar_fc is None,) # so does the end of a run that held the gap, TARIFF-24).
key += (self.solar_fc is None, held)
if key != self._runs_key: if key != self._runs_key:
done_c = self._run_done.get("charge", (None, set()))[1] done_c = self._run_done.get("charge", (None, set()))[1]
done_s = self._run_done.get("sell", (None, set()))[1] done_s = self._run_done.get("sell", (None, set()))[1]
@@ -2250,7 +2277,7 @@ class Controller:
required = price_required_spread( required = price_required_spread(
arbitrage.threshold_eur_kwh(self.o), arbitrage.threshold_eur_kwh(self.o),
price_uncertainty_of(plan.read.product) price_uncertainty_of(plan.read.product)
| arbitrage.weather_uncertainty(True, self.solar_fc)) | arbitrage.weather_uncertainty(True, self.solar_fc, held))
ref = self._ref_cheap ref = self._ref_cheap
if ref is not None and now - ref.end > REF_CHEAP_KEEP: if ref is not None and now - ref.end > REF_CHEAP_KEEP:
ref = self._ref_cheap = None ref = self._ref_cheap = None
@@ -2319,6 +2346,7 @@ class Controller:
quarter), exactly the gate `arbitrage.decide()` has.""" quarter), exactly the gate `arbitrage.decide()` has."""
floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o) floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o)
packs = self._mode_packs(maint_results) packs = self._mode_packs(maint_results)
self._wx_track(now)
runs = self._price_runs(now, packs, floor_pct, ceiling_pct) runs = self._price_runs(now, packs, floor_pct, ceiling_pct)
guard = arbitrage.guard_state(self.peak_guard) guard = arbitrage.guard_state(self.peak_guard)
plan = self.fresh_tariff_plan(now) plan = self.fresh_tariff_plan(now)
@@ -2329,7 +2357,8 @@ class Controller:
return plan.arbitrage( return plan.arbitrage(
peak_guard_active=active, peak_guard_active=active,
min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o), min_spread_eur_kwh=arbitrage.threshold_eur_kwh(self.o),
uncertainty=arbitrage.weather_uncertainty(True, self.solar_fc), uncertainty=arbitrage.weather_uncertainty(
True, self.solar_fc, self.weather_held(now)),
now=now).match( now=now).match(
allowed=lambda _v: True, refused=lambda _v: False) allowed=lambda _v: True, refused=lambda _v: False)
if guard is not None and plan is not None and runs is not None: if guard is not None and plan is not None and runs is not None:
+29
View File
@@ -614,6 +614,35 @@ check("sunny: a day plan is made, and says sunny",
for r in rows)) for r in rows))
check("sunny: no grid charge at all", not charge) check("sunny: no grid charge at all", not charge)
# TARIFF-24 round 2: the same day at the cycle cost 0.20 itself. With the
# forecast read the gap needed is 0.20 (not 1.5 x = 0.30), so the midday
# quarter 0.339 against the evening 0.551 (gap 0.21, between 1x and 1.5x) is
# a planned top-up for B (Leftover solar) up to its planned level.
rows_g, st_, c_g = run_day(SF_LS, fc=40.0, soc0={"a": 60.0, "b": 30.0})
charge_g = invariants("gap between 1x and 1.5x", rows_g, c_g)
_first_g = nth(charge_g, 0, "a charge cycle")
_gaps = [min(g.mean_eur_kwh for g in r["runs"].give)
- r["runs"].cheap.mean_eur_kwh for r in charge_g
if r["runs"] is not None and r["runs"].give]
print(f" gap 0.20 <= gap < 0.30: {len(charge_g)} charge cycles, run gap "
f"{min(_gaps, default=0):.3f}, B to "
f"{max((r['soc']['b'] for r in rows_g), default=0):.1f} %")
check("a forecast day whose price gap lies between 1x and 1.5x the cycle "
"cost (0.20 < 0.21 < 0.30) plans a cheap run: B charges from the grid "
"inside it, only B, and only up to its planned level",
present(_first_g) and len(_gaps) == len(charge_g)
and all(0.20 <= g < 0.30 for g in _gaps)
and all(r["runs"].cheap.holds(r["now"]) for r in charge_g)
and all(r["roles"][B] == modetrade.CHARGE for r in charge_g)
and all(r["roles"][A] != modetrade.CHARGE for r in charge_g)
and max(r["soc"]["b"] for r in rows_g) <= 45.64 + 2.0)
check("...and the same day with no forecast read does not (it needs 0.30): "
"the old gate, kept for a day without a forecast",
not [r for r in run_day(SF_LS, fc=None,
soc0={"a": 60.0, "b": 30.0})[0]
if r["action"] == arbitrage.CHARGE
and r["now"].astimezone(L).hour == 12])
# Dark: 1 kWh. B buys the evening's shortfall only, in the cheap run. # Dark: 1 kWh. B buys the evening's shortfall only, in the cheap run.
rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0}) rows, st_, c = run_day(SF_LS, fc=1.0, soc0={"a": 60.0, "b": 30.0})
charge = invariants("dark", rows, c) charge = invariants("dark", rows, c)
+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". 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 3. A dynamic day with a 0.12 gap at cost 0.10: on with a forecast, not worth
without. without.
4. The forecast going and coming mid-run: the gate follows in the same cycle 4. The forecast coming (the gate follows in the same cycle) and going mid-run:
the forecast read changes, both ways. 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 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).
@@ -69,6 +71,7 @@ NIGHT = datetime(2026, 10, 30, 23, 0, tzinfo=L).astimezone(timezone.utc)
CHOICE = {"a": (True, "leftover_solar", False), CHOICE = {"a": (True, "leftover_solar", False),
"b": (True, "cheap_hours", False)} "b": (True, "cheap_hours", False)}
FC_TODAY, FC_TOMORROW = "sensor.fc_today", "sensor.fc_tomorrow" FC_TODAY, FC_TOMORROW = "sensor.fc_today", "sensor.fc_tomorrow"
TARIFF_REFRESH_S = m.TARIFF_REFRESH_S
def opts(cost, *, arb=False, sensors=True): def opts(cost, *, arb=False, sensors=True):
@@ -108,8 +111,23 @@ def forecast_mode(mode, t, now):
return at 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, 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 """-> (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."""
store = rp._Store() store = rp._Store()
@@ -118,6 +136,7 @@ def drive(o, *, fc="fresh", points=None, minutes=20, tick=5.0,
("pack_sell", sell)): ("pack_sell", sell)):
store.data[unit_key(f"inverter-{u}", k)] = val store.data[unit_key(f"inverter-{u}", k)] = val
rows = [] rows = []
snaps = {}
class H(rp.Hass): class H(rp.Hass):
async def state(self, e): 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, "generic_dayahead", now), None,
"provider") "provider")
c.tariff_at = now 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): 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 break
await asyncio.sleep(0) await asyncio.sleep(0)
imp, exp = split_signed(hass.grid()) 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, 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 {},
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,
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 t += tick
c.home_plan = homeview.build_plan(c, clock[0], False, True) last = take(c, clock[0])
c.cards = homeview.battery_rows(c, False, True) c.home_plan, c.cards = last["home"], last["cards"]
# Home's mirror of the gate (it reads the trade switch, so ask it c.mirror, c.mirror_why = last["mirror"], last["mirror_why"]
# as a site that has it on next to the modes). c.snaps = snaps
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))
finally: finally:
if task is not None: if task is not None:
task.cancel() 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") 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 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): def going(t):
@@ -286,8 +315,73 @@ def coming(t):
return "gone" if t < CUT else "fresh" 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) 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): def flips(rows):
@@ -301,19 +395,10 @@ def flips(rows):
return out return out
fg, fcm = flips(rg), flips(rc) fcm = flips(rc)
print(f" going: forecast read lost at cycle/t {fg}; coming: {fcm}") print(f" coming: forecast read arrives at cycle/t {fcm}")
check("going: the plan is on with the forecast and not_worth the cycle the " check("coming (no run under way before): not_worth until the read is back, "
"read is lost (within one forecast read, 60 s, of the sensor change)", "then on in the same cycle, and the battery charges",
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",
len(fcm) == 1 and fcm[0][1] - CUT <= 60.0 and fcm[0][2] is True 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 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"]) and all(r["runs_on"] is True for r in rc if not r["fc_none"])