TARIFF-24 round 3: a run planned with a forecast keeps its gap to its end
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:
co-authored by
Claude
parent
d424b862d8
commit
97686319f3
@@ -267,15 +267,17 @@ def threshold_eur_kwh(opts: dict) -> float:
|
||||
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.
|
||||
|
||||
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.
|
||||
One rule for the run plan, the charge gate and Home's mirror of it. See
|
||||
doubt is gone, and `held` (a run planned with a forecast is under way, 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.
|
||||
"""
|
||||
if modes_on and solar_fc is not None:
|
||||
if modes_on and (solar_fc is not None or held):
|
||||
return frozenset()
|
||||
return frozenset({Uncertainty.NO_WEATHER})
|
||||
|
||||
|
||||
@@ -279,7 +279,9 @@ def trade_verdict(c, plan, now):
|
||||
min_spread_eur_kwh=arbitrage.threshold_eur_kwh(c.o),
|
||||
uncertainty=arbitrage.weather_uncertainty(
|
||||
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)
|
||||
return verdict.match(
|
||||
allowed=lambda v: (v, "allowed"),
|
||||
|
||||
@@ -960,6 +960,9 @@ class Controller:
|
||||
# and whether last tick filed a mode trade (a run edge reseeds).
|
||||
self.mode_runs = 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._run_done = {}
|
||||
self._mode_trade_was = False
|
||||
@@ -2226,6 +2229,28 @@ class Controller:
|
||||
probing=probing))
|
||||
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):
|
||||
"""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
|
||||
@@ -2239,9 +2264,11 @@ class Controller:
|
||||
slot = int(now.timestamp() // 900)
|
||||
key = (id(plan), slot, tuple((p.name, p.use, p.mode, p.sell)
|
||||
for p in packs))
|
||||
held = self.weather_held(now)
|
||||
if self.fc_entity is not None:
|
||||
# P4: a forecast arriving or going rebuilds the plan at once.
|
||||
key += (self.solar_fc is None,)
|
||||
# P4: a forecast arriving or going rebuilds the plan at once (and
|
||||
# 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:
|
||||
done_c = self._run_done.get("charge", (None, set()))[1]
|
||||
done_s = self._run_done.get("sell", (None, set()))[1]
|
||||
@@ -2250,7 +2277,7 @@ class Controller:
|
||||
required = price_required_spread(
|
||||
arbitrage.threshold_eur_kwh(self.o),
|
||||
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
|
||||
if ref is not None and now - ref.end > REF_CHEAP_KEEP:
|
||||
ref = self._ref_cheap = None
|
||||
@@ -2319,6 +2346,7 @@ class Controller:
|
||||
quarter), exactly the gate `arbitrage.decide()` has."""
|
||||
floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o)
|
||||
packs = self._mode_packs(maint_results)
|
||||
self._wx_track(now)
|
||||
runs = self._price_runs(now, packs, floor_pct, ceiling_pct)
|
||||
guard = arbitrage.guard_state(self.peak_guard)
|
||||
plan = self.fresh_tariff_plan(now)
|
||||
@@ -2329,7 +2357,8 @@ class Controller:
|
||||
return plan.arbitrage(
|
||||
peak_guard_active=active,
|
||||
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(
|
||||
allowed=lambda _v: True, refused=lambda _v: False)
|
||||
if guard is not None and plan is not None and runs is not None:
|
||||
|
||||
Reference in New Issue
Block a user