Compare commits
8
Commits
SAFETY-135
...
MAINT-24
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
33cfc733d3 | ||
|
|
de0790e62d | ||
|
|
b80cfa969f | ||
|
|
6004bfad9a | ||
|
|
b44a648cab | ||
|
|
0f5e513f1d | ||
|
|
54a738dd87 | ||
|
|
02eac3b84e |
@@ -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.
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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")
|
||||
@@ -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")
|
||||
@@ -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:
|
||||
|
||||
@@ -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 "
|
||||
|
||||
Reference in New Issue
Block a user