Compare commits

...
8 Commits
Author SHA1 Message Date
glenn schrooyenandClaude 33cfc733d3 MAINT-25: clean test_maint25.py, split its DECISIONS note
checks / unit (pull_request) Successful in 26m39s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-12 11:08:30 +01:00
glenn schrooyenandClaude de0790e62d MAINT-25: the Home plan shows no cycle during the 7-day back-off
checks / unit (pull_request) Successful in 27m21s
infra-pin / fresh (pull_request) Successful in 1s
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>
2026-11-12 09:57:56 +01:00
glenn schrooyenandClaude b80cfa969f MAINT-24: test_peak_margin pins the maintenance checks to the guard's aim
checks / unit (pull_request) Successful in 29m32s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-12 08:22:13 +01:00
glenn schrooyenandClaude 6004bfad9a MAINT-24: read the last row without a bare index (test_named_absence)
checks / unit (pull_request) Successful in 26m37s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-12 08:15:01 +01:00
glenn schrooyenandClaude b44a648cab MAINT-24: test_maint24.py points at MUTATION.md
checks / unit (pull_request) Successful in 28m32s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-12 07:51:28 +01:00
glenn schrooyenandClaude 0f5e513f1d MAINT-24: keep the refusal probe beside a maintenance charge
checks / unit (pull_request) Failing after 45m44s
infra-pin / fresh (pull_request) Successful in 2s
The mode trade still lets a SAFETY-73 probe (PROBE_W alone) go on while a pack
is in its maintenance CHARGE phase; test_opt20.py g7 pins it. ch_ch_maint
digest moved (B no longer grid-charges beside A's charge).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-12 07:33:04 +01:00
glenn schrooyenandClaude 54a738dd87 MAINT-24: a started maintenance charge gets the cheap night
checks / unit (pull_request) Failing after 41m45s
infra-pin / fresh (pull_request) Successful in 2s
While a pack is in its maintenance CHARGE phase the mode trade files no grid
charge for the other packs (why "cycle", mirror of SAFETY-78). The maintenance
shaping check and charge floor aim at peakguard.quarter_target_w, month peak
included (SAFETY-80). Tests through cycle(): test_maint24.py.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-12 07:07:44 +01:00
admin 02eac3b84e Merge pull request #245: SAFETY-135 one guard stop does not cost the night charge the whole period
infra-pin / fresh (push) Successful in 2s
2026-11-11 20:55:30 +01:00
12 changed files with 552 additions and 13 deletions
@@ -0,0 +1,72 @@
# 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
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
CHARGE phase for more than 75 h and stalled at 49-50 %. At night B's battery-mode
grid charge took the cheap run: A is not idle, so `modetrade.chargers()` never
lists it, B is the only charger, and the allocator hands the whole negative
target to B. A's own shaping then paused or shrank (the check compared the peak
forecast with `cap - margin` = 3450 W, while B's trade and the guard aim at this
month's peak, about 4.9 kW), and A gave power back on guard stops against a peak
that B's own charge made. The timeout (MAINT-13) counted that starved time as
charging time and ended the cycle. Report: `reports/MAINT-24-investigation-report.md`.
## 1. The maintenance charge goes first in the cheap run
`modetrade.decide(maint_charging=)`, set in `_mode_roles()` when any pack is in
its maintenance CHARGE phase. It is the mirror of `draining` (SAFETY-78): no
grid-charge trade for the other packs, why `cycle`, the same Tick. The other
packs keep their solar roles, so they may still take spare solar. A refusal probe
(SAFETY-73, PROBE_W alone, OPT-20) still goes on: it places no charge on the
pack in maintenance, and `test_opt20.py` g7 pins it. The drain still blocks it. B's night
charge waits for the nights A needs. At N=1 the only pack is the one in
maintenance, so nothing changes.
## 2. The shaping aims where the guard aims (SAFETY-80)
The pause (`shaping_suspended()`) and the floor (`maintenance_charge_floor()`)
get `peakguard.quarter_target_w(cap, margin, month reading)` instead of
`peak_cap_w - peak_margin_w`. With no banked peak above the cap that is the same
number. SAFETY-63 (no forecast: no pause, configured rate) and SAFETY-125 (late
reading: `charge_room_w`) are untouched. `peak_aim_w()` stays for the day plan.
## 3. SAFETY-111 is not folded in
`peak_fc` holds the pack's own charge, so the floor `aim - peak_fc` settles at
(aim - house) / 2, here 1475 W instead of 2000 W. Taking the own charge off the
forecast was tried: it lifts A to 2000 W, but it changes the N=1 charge that
`test_safety119.py` pins (a static 800 W forecast then no longer shapes the
charge), so it is a change of its own. 1475 W fills a 10 kWh pack inside the
night (sim below). It stays on its card.
## 4. A gives nothing while no real peak exists
With 1 and 2 no grid charge of B makes a peak against the lower aim, and the pause
comes only when the forecast is over the month aim, so A is not made the only
battery that gives while B charges. A guard stop on a real peak
still lands on A (SAFETY-07 / MAINT-02, unchanged).
## Numbers (sim, house 3000 W, cap 3500, margin 50, month peak 6000 W)
| | base 02eac3b | MAINT-24 |
|---|---|---|
| A charge | 0 W (B charges 2500 W) | 1475 W = (5950 - 3000) / 2 |
| B grid trade | yes, quarters 5500 W | none, why `cycle` |
| quarter peak | 5500 W | 4475 W (aim 5950 W) |
| A at house 3800 W | 0 W | 1075 W, quarters 4876 W |
Whole night (sim, `tools`-style replay, N=2, cheap 21:00-06:00, A from 10 %, B
from 30 %, 9 h): base A takes 1.41 kWh (10 -> 23 %), B takes 6.84 kWh by grid
trade; MAINT-24 A takes 9.47 kWh and is full (100 %) inside the night, B takes
no grid charge. Worst 15-minute average 5500 W on base, 4475 W now, A never
gives.
## Not changed
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 and the back-off) has its own note: `MAINT-25-plan-backoff.md`.
@@ -0,0 +1,10 @@
# MAINT-25 - the Home plan honours the 7-day back-off
**Status: for review.** Added to the MAINT-24 PR by the owner (2026-11-12).
Tests: `test_maint25.py`. 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.
+7 -2
View File
@@ -559,8 +559,13 @@ class CycleMixin:
# peakguard.shaping_suspended(), NOT HERE. One suspension flag
# per SITE (`arbitrate()` allows one pack in CHARGE).
# (DECISIONS/MAIN-orchestration-archive.md)
# MAINT-24: the guard's aim, month peak included (SAFETY-80).
# DECISIONS/MAINT-24-charge-first.md
maint_aim = peakguard.quarter_target_w(
float(self.o.get("peak_cap_w", 3500)),
self.peak_margin_w, _reading)
if peakguard.shaping_suspended(
self.peak_guard, self.peak_fc, self.peak_aim_w()):
self.peak_guard, self.peak_fc, maint_aim):
# Edge-logged: level-logging here would write one line per
# cycle for as long as the event lasts and flush the 40-line
# action log of the very context that explains it.
@@ -582,7 +587,7 @@ class CycleMixin:
# (DECISIONS/MAIN-orchestration-archive.md)
floor = maintenance_charge_floor(
self.maint.cfg.charge_w,
self.peak_fc, self.peak_aim_w())
self.peak_fc, maint_aim)
floor = self._charge_headroom(floor, claims)
if floor > 0:
hi, reason = -floor, "cheap-window charge"
+3 -1
View File
@@ -422,7 +422,9 @@ class TradeMixin:
blocked=self._charge_blocked_q == quarter,
done_charge=done_c, done_sell=done_s, targets=targets,
draining=any(r.phase == DRAIN for r in maint_results or ()),
wait=wait)
wait=wait,
maint_charging=any(r.phase == CHARGE
for r in maint_results or ()))
self._latch_charge(now, runs, targets, packs)
# A charge the guard or the headroom stopped stays off this period.
# OPT-15: only a MEASURED stop latches; the first unmeasured headroom
+14
View File
@@ -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,
+13 -3
View File
@@ -56,7 +56,9 @@ Rules that keep this safe (tests: test_modes_p3.py, test_price_run_fixes.py):
the probe (none: PROBE_W alone), so a probe never places charge on a
battery in a maintenance phase or on one that rests;
* no grid charge while another battery drains for its monthly cycle
(`draining`, SAFETY-78): it would charge against the drain.
(`draining`, SAFETY-78): it would charge against the drain; nor while
one is in its maintenance CHARGE (`maint_charging`, MAINT-24): the cheap
night's room is that pack's.
"""
from dataclasses import dataclass
@@ -180,7 +182,8 @@ def energy(packs, floor_pct, ceiling_pct, done_charge=(), done_sell=(),
def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
headroom_w, floor_pct, ceiling_pct, house_w, charging_now=False,
blocked=False, done_charge=(), done_sell=(),
targets=None, draining=False, wait=None) -> Tick:
targets=None, draining=False, wait=None,
maint_charging=False) -> Tick:
"""This tick's ranges, roles and site ask.
`runs` is the kept `priceruns.RunPlan` or None (no fresh prices).
@@ -194,7 +197,10 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
this 15-minute period. `targets` is the day plan (`charge_to()`).
`draining` says a battery drains for its monthly cycle, so none may
charge from the grid (why "cycle"). `wait` ("wait" or "settle", the
why) says no charge may start yet (SAFETY-135).
why) says no charge may start yet (SAFETY-135). `maint_charging` says a
battery is in its maintenance CHARGE phase: no grid charge for the others
either, same why, but a refusal probe goes on (MAINT-24,
DECISIONS/MAINT-24-charge-first.md).
"""
plan_on = runs is not None and runs.on
in_cheap = plan_on and runs.cheap.holds(now) and runs.charge_useful()
@@ -206,6 +212,10 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
if in_cheap else []
if ch and draining:
ch, why = [], "cycle"
elif ch and maint_charging:
# MAINT-24: a refusal probe (PROBE_W alone, OPT-20) may go on.
ch = [p for p in ch if p.refused < 0]
why = None if ch else "cycle"
charging = False
if ch:
need = 0.0 if charging_now else START_W
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+257
View File
@@ -0,0 +1,257 @@
"""Runnable check for MAINT-24. `python3 test_maint24.py`
A started maintenance charge at N=2 must get the cheap night. On the live site
(2026-11-08..11) battery B's grid charge (battery modes) took the night, and
A's own charge was switched off or sized at 200-450 W: its pause and its floor
compared the peak forecast with `cap - margin`, not with this month's peak
that the guard and B's trade aim at (SAFETY-80), and its floor counted its own
charge in the forecast (SAFETY-111).
All on the real Controller through `cycle()` (the lagged N=2 replay, tools/
opt20_scen.py): cheap window, house 3000 W, cap 3500 / margin 50, a month peak
of 6000 W banked (aim 5950 W), a peak forecast that follows the grid.
1. A in maintenance CHARGE, B a Cheap hours charger: A charges at the room under
the month aim (half of it: the forecast holds A's own charge, SAFETY-111
stays on its card), B files no grid trade, B's why is "cycle", every quarter is at or
under the month aim.
2. Forecast above 3450 W but under the month aim: shaping is not paused and A
gives nothing. A real peak (house over the month aim) still pauses it.
3. The mirror: B in maintenance CHARGE, A the charger.
4. modetrade.decide(): `maint_charging` files no charge, why "cycle"; solar
roles stay.
5. Unchanged: N=1 with no forecast entity, the SAFETY-78 drain, battery modes
off (digests printed on release/1.0 02eac3b).
Mutations: DECISIONS/MAINT-24-charge-first.md.
Read MUTATION.md before you report a survivor.
"""
import logging
import os
import sys
from types import SimpleNamespace
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import opt11p3_replay as rp # noqa: E402
import opt20_scen as sc # noqa: E402
from app import modetrade, packmode, tariff # noqa: E402
from app.maintenance import DRAIN, MaintResult # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
A, B = sc.A, sc.B
MONTH_PEAK = 6000.0
AIM = MONTH_PEAK - 50.0 # the guard's aim this month
DRAIN_R = MaintResult(DRAIN, True, 2000.0, False, [])
def scen(*, a=None, b=None, soc=(49.0, 30.0), house_w=3000.0, peak=MONTH_PEAK,
fc=True, n=2, variant="p3_ch_sell", choice=None, hours=0.75):
"""`a` / `b`: that pack's forced maintenance result (or None). `peak`: the
banked month peak or None. `fc`: a peak forecast entity that follows the
grid. Returns (rows, stats, controller)."""
rows = []
extra = {"cycle_cost_threshold_eur": 0.10}
def patch(c):
for i, res in enumerate((a, b)):
if res is not None and i < len(c.maints):
c.maints[i].tick = (
lambda now, soc_, blocked_by=None, gate_open=True,
res=res: res)
if peak is not None:
orig_peak = c.peak.peak
def peak_(now):
r = orig_peak(now)
return tariff.PeakReading(tariff.OBSERVED,
getattr(r, "month", ""), peak)
c.peak.peak = peak_
orig = c.cycle
susp = []
c._m24_susp = susp
async def cyc():
c.cheap = True
if fc and c.grid is not None:
c.peak_fc = float(c.grid)
await orig()
susp.append(bool(c._maint_shape_suspended))
c.cycle = cyc
def cb(c, h, now, pr):
sh = dict(c.allocation.shares) if c.allocation is not None \
and c.allocation.shares else {}
rows.append(dict(
now=now, grid=h.grid(), shares=sh,
action=getattr(c.arb, "action", None),
reason=getattr(c.arb, "reason", None) or "",
roles=dict(c.mode_tick.roles) if c.mode_tick else {},
why=c.mode_tick.why if c.mode_tick else None,
p={u: p.p for u, p in h.plants.items()}))
stats, c = rp.run(variant, [(sc.UTC0.timestamp() - 10, house_w)], sc.WPTS,
sc.at(2.0), 3600 * hours, check=cb, lag=sc.LAG, n=n,
soc0={"a": soc[0], "b": soc[1]},
choice=choice or sc.CH_CH, refuse=(), patch=patch,
extra=extra)
return rows, stats, c
def mean(rows, u):
r = rows[len(rows) // 6:]
return sum(x["p"][u] for x in r) / len(r)
def worst_quarter(stats):
return max(a[0] / a[1] for a in stats["quarters"].values())
CH = sc.MAINT_CHARGE
# ============================================================================ #
print("1. A in maintenance CHARGE, B a charger in the cheap window")
rows, st, c = scen(a=CH)
ma, mb = mean(rows, "a"), mean(rows, "b")
last = rows[-1] if rows else {}
print(f" A {ma:+.0f} W, B {mb:+.0f} W, worst quarter {worst_quarter(st):.0f}"
f" W, why {last.get('why')}, roles {last.get('roles')}")
check("A charges at (5950 - 3000) / 2 = 1475 W, not 0 or a few hundred W",
-1500.0 <= ma <= -1440.0)
check("B files no grid trade: no trade action in the run, B near 0 W",
all("CHARGE" not in str(r["action"]) for r in rows) and mb > -50.0)
check("roles: B does not charge, and the why is 'cycle'",
all(r["roles"].get(B) != "charge" for r in rows)
and last.get("why") == "cycle")
check("every quarter at or under the month aim",
worst_quarter(st) <= AIM + 1.0)
check("A never gives back while there is no real peak",
max(r["p"]["a"] for r in rows) <= 0.0)
check("the shaping was never paused", not any(c._m24_susp))
# ============================================================================ #
print("\n2. forecast between 3450 W and the month aim; a real peak")
rows2, st2, c2 = scen(a=CH, house_w=3800.0)
ma2 = mean(rows2, "a")
print(f" house 3800: A {ma2:+.0f} W, worst quarter "
f"{worst_quarter(st2):.0f} W")
check("forecast 4900 W (over 3450, under 5950): no pause, A charges "
"(5950 - 3800) / 2 = 1075 W and gives nothing",
not any(c2._m24_susp) and -1100.0 <= ma2 <= -1040.0
and max(r["p"]["a"] for r in rows2) <= 0.0
and worst_quarter(st2) <= AIM + 1.0)
rows3, st3, c3 = scen(a=CH, house_w=6500.0)
print(f" house 6500: paused {sum(c3._m24_susp)}/{len(c3._m24_susp)} "
f"cycles, A max {max(r['p']['a'] for r in rows3):+.0f} W")
check("a real peak (house over the month aim) still pauses the shaping "
"(SAFETY-07)", sum(c3._m24_susp) > len(c3._m24_susp) // 2)
rows4, st4, c4 = scen(a=CH, house_w=3000.0, peak=None)
print(f" no banked peak: A {mean(rows4, 'a'):+.0f} W, worst quarter "
f"{worst_quarter(st4):.0f} W")
check("no banked peak: the aim is cap - margin (3450 W) as before, so A "
"charges (3450 - 3000) / 2 = 225 W, B does not trade, the quarter "
"stays under 3450 W",
-240.0 <= mean(rows4, "a") <= -210.0 and not any(c4._m24_susp)
and all("CHARGE" not in str(r["action"]) for r in rows4)
and worst_quarter(st4) <= 3450.0 + 1.0)
# ============================================================================ #
print("\n3. the mirror: B in maintenance CHARGE, A the charger")
rowsm, stm, cm = scen(b=CH, soc=(30.0, 49.0))
mam, mbm = mean(rowsm, "a"), mean(rowsm, "b")
lastm = rowsm[-1] if rowsm else {}
print(f" A {mam:+.0f} W, B {mbm:+.0f} W, worst quarter "
f"{worst_quarter(stm):.0f} W, why {lastm.get('why')}")
check("B charges at 1475 W, A files no grid trade, why 'cycle'",
-1500.0 <= mbm <= -1440.0 and mam > -50.0
and all("CHARGE" not in str(r["action"]) for r in rowsm)
and lastm.get("why") == "cycle"
and all(r["roles"].get(A) != "charge" for r in rowsm))
check("...every quarter at or under the month aim",
worst_quarter(stm) <= AIM + 1.0)
# ============================================================================ #
print("\n4. modetrade.decide(maint_charging=)")
_runs = SimpleNamespace(on=True, cheap=SimpleNamespace(holds=lambda n: True),
charge_useful=lambda: True, give_at=lambda n: None,
sell=None)
def _dec(packs, **kw):
args = dict(charge_ok=True, sell_ok=True, trade_why=None, price_now=0.1,
headroom_w=3000.0, floor_pct=20.0, ceiling_pct=95.0,
house_w=800.0)
args.update(kw)
return modetrade.decide(_runs, sc.UTC0, packs, **args)
_pa = modetrade.Pack(A, True, packmode.CHEAP_HOURS, False, 2500.0, 50.0, 10.0,
idle=False)
_pb = modetrade.Pack(B, True, packmode.CHEAP_HOURS, False, 2500.0, 30.0, 10.0)
_off = _dec([_pa, _pb])
_on = _dec([_pa, _pb], maint_charging=True)
_dr = _dec([_pa, _pb], draining=True)
check("without it B is the charger (as before)",
_off.action == modetrade.CHARGE and _off.roles[B] == modetrade.CHARGE)
check("with it: no charge, why 'cycle', B keeps a hold role, no ask",
_on.action is None and _on.why == "cycle" and _on.ask_w == 0.0
and _on.roles[B] == modetrade.HOLD)
check("same answer as the SAFETY-78 drain", _on == _dr)
_pr = modetrade.Pack(B, True, packmode.CHEAP_HOURS, False, 2500.0, 30.0, 10.0,
refused=-1, probing=True)
_tp = _dec([_pa, _pr], maint_charging=True)
check("a refusal probe beside the maintenance charge goes on: PROBE_W alone "
"(OPT-20); beside a drain it does not",
_tp.action == modetrade.CHARGE and _tp.ask_w == -modetrade.PROBE_W
and _dec([_pa, _pr], draining=True).action is None)
check("a probe does not carry a normal charger along",
_dec([_pa, _pr, modetrade.Pack("inverter-c", True, packmode.CHEAP_HOURS,
False, 2500.0, 30.0, 10.0)],
maint_charging=True).ask_w == -modetrade.PROBE_W)
check("it files nothing outside a cheap run (why None)",
_dec([_pa, _pb], maint_charging=True,
).why == "cycle" and modetrade.decide(
SimpleNamespace(on=False), sc.UTC0, [_pa, _pb], charge_ok=True,
sell_ok=True, trade_why=None, price_now=0.1, headroom_w=3000.0,
floor_pct=20.0, ceiling_pct=95.0, house_w=800.0,
maint_charging=True).why is None)
# ============================================================================ #
print("\n5. unchanged: N=1 (no forecast entity), the drain, modes off")
d1 = sc.digest(scen(a=CH, n=1, fc=False, peak=None, soc=(49.0, 30.0))[0])
dd = sc.digest(scen(a=DRAIN_R, soc=(60.0, 30.0), peak=None, fc=False)[0])
dm = sc.digest(scen(a=CH, variant="modes_off", peak=None, fc=False)[0])
AT_BASE = {
"n1": "44816d132984ecd64d5a2510223debe102f29decd1d5127ae2b255b45dbf8bd4",
"drain": "8fd86d0816626ee5ae86bc973cb0121b1b5108e56c3b0fdd4b4be6c1f403f748",
"off": "a007f962793611bce15b11f576a76aa52063adcfde82b2a2dc0b0c075b60e60b",
}
got = {"n1": d1, "drain": dd, "off": dm}
for k in AT_BASE:
print(f" {k} {'same' if got[k] == AT_BASE[k] else 'DIFFERS'} "
f"{got[k][:16]}")
check("N=1 with A in maintenance charge and no forecast entity: shares, "
"grid, trade, roles and plant powers equal release/1.0 02eac3b",
got["n1"] == AT_BASE["n1"])
check("the SAFETY-78 drain beside a charger: equal to 02eac3b",
got["drain"] == AT_BASE["drain"])
check("battery modes off: equal to 02eac3b", got["off"] == AT_BASE["off"])
print()
if fails:
print(f"{len(fails)} FAILED:")
for f in fails:
print(f" - {f}")
sys.exit(1)
print("all checks passed")
+162
View File
@@ -0,0 +1,162 @@
"""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 []
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")
+6 -3
View File
@@ -248,11 +248,14 @@ print("\n4. battery modes on, no probe: byte-identical to release/1.0 bf9019e")
# now counts those seconds, so ch_ch's quarters land on 3450.0 W where they
# landed 1.3-1.4 W over it (bf9019e: 15fc25dd...), and ch_ch_maint's first,
# partial quarter charges a little less at its end (bf9019e: e46e3c66...).
# MAINT-24 moved ch_ch_maint again: B no longer grid-charges beside A's
# maintenance charge, and A's charge is no longer paused (release/1.0 02eac3b:
# e56eddab...).
AT_BASE = {
"sf_ch": "422d6578a7decf1ae3749edd1898ff3cd0541d904c95f636cdd9b1de8c1a07c9",
"ch_ch": "2b5879ec95810c39a5f0f82c04b9f8e358ea03ddce4ba187cc2b7e0885dfcbfa",
"ch_ch_maint":
"e56eddabdb785f81793e7afa525f8e6da237b0b2165860aa8e0f8a6499b275a9",
"df86a451a3b2d365ca5d35b772cd1eb8502d164e6b63e6844b5cfb82f15aeb46",
}
_got = sc.no_probe_digests()
for _k in AT_BASE:
@@ -260,8 +263,8 @@ for _k in AT_BASE:
f"{_got[_k][:16]}")
check("with no refusal every share, grid, trade sentence, role and plant "
"power of a scripted cheap run equals bf9019e's (A Solar first, A "
"charging, A in maintenance charge; the last two as SAFETY-125 moved "
"them)", _got == AT_BASE)
"charging, A in maintenance charge; the last two as SAFETY-125 and "
"MAINT-24 moved them)", _got == AT_BASE)
print()
if fails:
+6 -4
View File
@@ -245,14 +245,16 @@ print(f" decide(cap_w={kw(_d, 'cap_w')}, margin_w={kw(_d, 'margin_w')})")
print(f" shaping_suspended(..., {arg(_s, 2)})")
print(f" maintenance_charge_floor(..., {arg(_m, 2)})")
print(f" PeakTracker(cap_w={kw(_t, 'cap_w')})")
# MAINT-24: the maintenance forecast checks get the guard's own aim, cap -
# margin with this month's peak in (`maint_aim`), not the plain cap - margin.
check("test_call_sites: the guard gets the real cap AND the margin; the "
"forecast checks get cap - margin; the month-peak tracker gets the "
"real cap",
"forecast checks get the guard's aim, cap - margin and the month "
"peak (MAINT-24); the month-peak tracker gets the real cap",
len(_dec) == 1 and "peak_cap_w" in kw(_d, "cap_w")
and kw(_d, "margin_w") == "self.peak_margin_w"
and len(_shape) == 1 and len(_maint) == 1
and arg(_s, 2) == "self.peak_aim_w()"
and arg(_m, 2) == "self.peak_aim_w()"
and arg(_s, 2) == "maint_aim" and arg(_m, 2) == "maint_aim"
and "maint_aim = peakguard.quarter_target_w(" in _src
and len(_tracker) == 1
and kw(_t, "cap_w") == 'float(opts.get("peak_cap_w", 3500))')
check("test_aim_w: `peak_aim_w()` is cap - margin, and the forecast sizing "