MAINT-25: the Home plan shows no cycle during the 7-day back-off
homeview.maintenance_runs() ignored backoff_until(); it drew a cycle every night of the wait. Now no run and a plain line (EN, NL). test_maint25.py. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude
parent
b80cfa969f
commit
de0790e62d
@@ -1,6 +1,6 @@
|
||||
# MAINT-24 - a started maintenance charge gets the cheap night
|
||||
|
||||
**Status: for review.** Tests: `test_maint24.py` (`test_opt20.py`'s `ch_ch_maint` digest moved with it). No option added; the MAINT-13
|
||||
**Status: for review.** Also fixes MAINT-25 (last section). Tests: `test_maint24.py` (`test_opt20.py`'s `ch_ch_maint` digest moved with it). No option added; the MAINT-13
|
||||
timeout numbers are unchanged. Folds in SAFETY-80. SAFETY-111 stays on its card (section 3).
|
||||
|
||||
**What happened (live 0.21.0, A, 2026-11-08..11).** A sat in its maintenance
|
||||
@@ -68,3 +68,14 @@ gives.
|
||||
The MAINT-13 timeout, the peak aim itself, the month peak, `P_MAINT_SHAPE` and
|
||||
`P_ARBITRAGE`. The Home card for B does not say "the other battery is cycling"
|
||||
for a CHARGE phase (`other_cycle` reads "emptying"); that is a wording card.
|
||||
|
||||
## MAINT-25: the Home plan honours the 7-day back-off
|
||||
|
||||
After a failed cycle `wants_start()` waits 7 days (MAINT-13, `backoff_until()`),
|
||||
but `homeview.maintenance_runs()` only asked `due()`, so the plan drew a cycle at
|
||||
the start hour every night of the wait. During the back-off it now draws no run
|
||||
for that battery and shows one line instead: "Battery A: the monthly cycle waits
|
||||
until 19 November." (Dutch: "... wacht tot 19 november."). A cycle completed
|
||||
after the failure ends it, as in `backoff_until()`. The other battery is planned
|
||||
as before. Tests: `test_maint25.py` (N=1 and N=2, 1 day, 7 days, completion
|
||||
before and after the failure, Dutch).
|
||||
|
||||
@@ -630,6 +630,11 @@ def maintenance_runs(c, day0, day1, now, shadow):
|
||||
run_cycle_why(shadow),
|
||||
run_cycle_amount(hours)))
|
||||
continue
|
||||
until = m.backoff_until(now)
|
||||
if until is not None:
|
||||
# MAINT-25: a failed cycle waits (MAINT-13); no run is planned.
|
||||
lines.append(line_cycle_backoff(inv.name, until))
|
||||
continue
|
||||
if not m.due(now) and (m.next_due(now) is None
|
||||
or m.next_due(now) >= day1):
|
||||
continue
|
||||
@@ -1529,6 +1534,13 @@ def line_cycle_waits(name) -> str:
|
||||
return _t("cycle_waits", name=name)
|
||||
|
||||
|
||||
def line_cycle_backoff(name, until) -> str:
|
||||
"""MAINT-25: the failed cycle waits until `until` (MAINT-13)."""
|
||||
local = brussels(until)
|
||||
return _t("cycle_backoff", name=name, day=local.day,
|
||||
month=month_name(local))
|
||||
|
||||
|
||||
def line_no_prices(since, now) -> str:
|
||||
"""PRICE-01: no price covers now. `since` is an instant or None."""
|
||||
if since is None:
|
||||
@@ -1669,6 +1681,8 @@ def _samples() -> list:
|
||||
("plan", run_house_why()), ("plan", run_house_why(True)),
|
||||
("plan", run_house_amount()),
|
||||
("plan", line_cycle_waits("Battery B")),
|
||||
("plan", line_cycle_backoff("Battery B", datetime(
|
||||
2026, 11, 19, 12, 0, tzinfo=timezone.utc))),
|
||||
("plan", line_no_prices(None, datetime(2026, 10, 14, 9, 0,
|
||||
tzinfo=timezone.utc))),
|
||||
("plan", line_no_prices(datetime(2026, 10, 14, 0, 0,
|
||||
|
||||
@@ -430,6 +430,7 @@
|
||||
"give": "Tomorrow: gives power at {at}, {eur} per kWh.",
|
||||
"give_would": "Tomorrow: would give power at {at}, {eur} per kWh."
|
||||
},
|
||||
"cycle_backoff": "{name}: the monthly cycle waits until {day} {month}.",
|
||||
"cycle_waits": "{name} waits its turn for the monthly cycle.",
|
||||
"no_prices_yet": "No prices yet, maintenance waits.",
|
||||
"no_prices_since": "No prices since {at}, maintenance waits.",
|
||||
|
||||
@@ -365,6 +365,7 @@
|
||||
"no_level": "Nu geen handel op prijs: een laadniveau is onbekend."
|
||||
},
|
||||
"tomorrow_trade": {"grid": "Morgen: laadt om {at}, {eur} per kWh.", "grid_would": "Morgen: zou laden om {at}, {eur} per kWh.", "give": "Morgen: levert stroom om {at}, {eur} per kWh.", "give_would": "Morgen: zou stroom leveren om {at}, {eur} per kWh."},
|
||||
"cycle_backoff": "{name}: de maandelijkse cyclus wacht tot {day} {month}.",
|
||||
"cycle_waits": "{name} wacht zijn beurt af voor de maandelijkse cyclus.",
|
||||
"no_prices_yet": "Nog geen prijzen, onderhoud wacht.",
|
||||
"no_prices_since": "Geen prijzen sinds {at}, onderhoud wacht.",
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""Runnable check for MAINT-25. `python3 test_maint25.py`
|
||||
|
||||
After a failed monthly cycle the add-on waits 7 days (MAINT-13, `backoff_until`).
|
||||
The Home plan ignored that and drew a cycle at the start hour every night of
|
||||
the wait. Through the plan builder (`status()` as in test_home.py), N=1 and N=2:
|
||||
during the back-off no cycle run is drawn and a short line says when it waits
|
||||
until, in English and Dutch; after 7 days the run is drawn again; a cycle
|
||||
completed after the failure ends the back-off.
|
||||
Read MUTATION.md before you report a survivor.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
|
||||
from app import homeview # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
class _Store:
|
||||
def __init__(self):
|
||||
self.data = {}
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def get(self, k, d=None):
|
||||
return self.data.get(k, d)
|
||||
|
||||
def set_time(self, k, v):
|
||||
self.data[k] = v.isoformat()
|
||||
|
||||
def get_time(self, k):
|
||||
v = self.data.get(k)
|
||||
return None if v is None else datetime.fromisoformat(v)
|
||||
|
||||
|
||||
class _Pub:
|
||||
enabled = False
|
||||
|
||||
def publish(self, _v):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class _Hass:
|
||||
session = None
|
||||
token = ""
|
||||
|
||||
async def limits(self, _e):
|
||||
return (-5000.0, 5000.0, 1.0)
|
||||
|
||||
async def number(self, _e, invert=False):
|
||||
return None
|
||||
|
||||
async def state(self, _e):
|
||||
return None
|
||||
|
||||
async def set_number(self, _e, _v):
|
||||
return True
|
||||
|
||||
async def call(self, *_a):
|
||||
return True
|
||||
|
||||
async def states(self):
|
||||
return []
|
||||
|
||||
|
||||
ONE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "max_w": 5000, "shadow_mode": "off",
|
||||
"maintenance_enabled": False}
|
||||
TWO = dict(ONE, inverters=[
|
||||
{"name": "Battery A", "setpoint_entity": "number.a", "soc_entity":
|
||||
"sensor.soc_a", "batt_entity": "sensor.batt_a", "max_w": 2500,
|
||||
"capacity_kwh": 9.6},
|
||||
{"name": "Battery B", "setpoint_entity": "number.b", "soc_entity":
|
||||
"sensor.soc_b", "batt_entity": "sensor.batt_b", "max_w": 2500,
|
||||
"capacity_kwh": 5.4}])
|
||||
|
||||
# Sunday 27 September 2026, 11:40 in Brussels (09:40 UTC): the mockup's day.
|
||||
NOW = datetime(2026, 9, 27, 9, 40, tzinfo=timezone.utc)
|
||||
DAY0 = datetime(2026, 9, 26, 22, 0, tzinfo=timezone.utc)
|
||||
MOCK = [.24, .22, .21, .20, .21, .22, .24, .27, .26, .22, .18, .16, .15, .14,
|
||||
.16, .19, .23, .28, .33, .36, .34, .29, .26, .24]
|
||||
|
||||
|
||||
def make(opts, *, soc=(62.0, 35.0), batt=(-2500.0, -300.0), grid=10.0,
|
||||
auto=True, wrote=0.0):
|
||||
"""`wrote` is each inverter's last accepted power request (None: none yet)."""
|
||||
c = Controller(dict(opts), _Hass(), _Store(), _Pub(), stop=asyncio.Event())
|
||||
c.auto = auto
|
||||
for i, inv in enumerate(c.inverters):
|
||||
inv.soc, inv.batt = soc[i], batt[i]
|
||||
inv.last_write = wrote
|
||||
inv.dev_min, inv.dev_max = -5000.0, 5000.0
|
||||
c.grid = grid
|
||||
return c
|
||||
|
||||
|
||||
|
||||
NOW = datetime(2026, 9, 27, 9, 40, tzinfo=timezone.utc)
|
||||
ONE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "max_w": 5000, "shadow_mode": "off",
|
||||
"maintenance_enabled": True, "maintenance_start_hour": 12}
|
||||
TWO = dict(ONE, inverters=[
|
||||
{"name": "Battery A", "setpoint_entity": "number.a", "soc_entity":
|
||||
"sensor.soc_a", "batt_entity": "sensor.batt_a", "max_w": 2500,
|
||||
"capacity_kwh": 9.6},
|
||||
{"name": "Battery B", "setpoint_entity": "number.b", "soc_entity":
|
||||
"sensor.soc_b", "batt_entity": "sensor.batt_b", "max_w": 2500,
|
||||
"capacity_kwh": 5.4}])
|
||||
|
||||
|
||||
def make(opts, failed=None, done=None):
|
||||
c = Controller(dict(opts), _Hass(), _Store(), _Pub(), stop=asyncio.Event())
|
||||
c.auto = True
|
||||
for inv in c.inverters:
|
||||
inv.soc, inv.batt, inv.last_write = 50.0, 0.0, 0.0
|
||||
inv.dev_min, inv.dev_max = -5000.0, 5000.0
|
||||
c.grid = 10.0
|
||||
m = c.maints[0]
|
||||
if failed is not None:
|
||||
m.store.set_time("last_failed", failed)
|
||||
if done is not None:
|
||||
m.store.set_time("last_completed", done)
|
||||
return c
|
||||
|
||||
|
||||
def plan(c, lang=None):
|
||||
s = c.status_payload()
|
||||
s.update(homeview.build(c, level=s["level"], checks=s["checks"], now=NOW,
|
||||
**({} if lang is None else {"lang": lang})))
|
||||
p = s["plan"]
|
||||
return [r for r in p["runs"] if r["kind"] == "cycle"], p["lines"]
|
||||
|
||||
|
||||
D = timedelta(days=1)
|
||||
print("1. a cycle failed 1 day ago (N=2: Battery A failed, B never ran)")
|
||||
runs, lines = plan(make(TWO, failed=NOW - D))
|
||||
print(" ", [r["from"] for r in runs], lines)
|
||||
check("the run tonight is B's, not A's (A waits out the back-off)",
|
||||
len(runs) == 1 and not any("Battery A waits its turn" in x for x in lines))
|
||||
check("a plain line says Battery A waits until 3 October",
|
||||
any("Battery A: the monthly cycle waits until 3 October." == x
|
||||
for x in lines))
|
||||
|
||||
print("\n2. N=1")
|
||||
runs1, lines1 = plan(make(ONE, failed=NOW - D))
|
||||
check("no cycle run is drawn, one line says when it waits until",
|
||||
runs1 == [] and any("waits until 3 October" in x for x in lines1))
|
||||
runs1b, lines1b = plan(make(ONE))
|
||||
check("without a failure the run is drawn (non-vacuous)",
|
||||
len(runs1b) == 1 and not any("waits until" in x for x in lines1b))
|
||||
|
||||
print("\n3. after the 7 days, and after a cycle that completed")
|
||||
runs2, lines2 = plan(make(ONE, failed=NOW - timedelta(days=7, hours=1)))
|
||||
check("7 days and an hour after the failure the run is drawn again, no line",
|
||||
len(runs2) == 1 and not any("waits until" in x for x in lines2))
|
||||
runs3, lines3 = plan(make(ONE, failed=NOW - D, done=NOW - D / 2))
|
||||
check("a cycle completed after the failure ends the back-off (the run is "
|
||||
"not drawn only if it is no longer due)",
|
||||
not any("waits until" in x for x in lines3))
|
||||
runs3b, lines3b = plan(make(ONE, failed=NOW - 2 * D, done=NOW - 3 * D))
|
||||
check("a completion BEFORE the failure does not end it",
|
||||
runs3b == [] and any("waits until" in x for x in lines3b))
|
||||
|
||||
print("\n4. Dutch")
|
||||
_, lnl = plan(make(ONE, failed=NOW - D), lang="nl")
|
||||
print(" ", lnl)
|
||||
check("the Dutch line says it plainly",
|
||||
any("de maandelijkse cyclus wacht tot 3 oktober" in x for x in lnl))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
Reference in New Issue
Block a user