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:
co-authored by
Claude
parent
5ac1017eed
commit
9dc22fb987
@@ -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:")
|
||||||
|
|||||||
Reference in New Issue
Block a user