HOLD's time check moves into Maintenance._hold(), used by step() and
_no_soc(). A hold that loses its SoC now ends at hold_min, writes
last_completed and logs the same COMPLETE line. The 15 min HOLD line says
the hold still ends at its normal time. Behaviour with a reading is
unchanged (differential trace against db9e231).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
404 lines
17 KiB
Python
404 lines
17 KiB
Python
"""MAINT-14: maintenance with no SoC reading.
|
|
`python3 test_maint14.py`
|
|
|
|
Graded here: charge and drain with SoC None at one and two packs - the charge
|
|
timeout and 7 day backstop still run (failure, or done when the last SoC was
|
|
>= target - 3), the drain holds at 0 W and times out, SoC coming back resumes
|
|
normally with no back-off, the log lines (once per episode), a restart while the
|
|
SoC is missing, and one pack's missing SoC leaving the other pack alone.
|
|
See DECISIONS/MAINT-14-no-soc-in-maintenance.md.
|
|
|
|
Mutating what this file grades? Read MUTATION.md first (stale bytecode).
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from app.maintenance import (
|
|
CHARGE, CHARGE_NEAR_FULL_PCT, CHARGE_TIMEOUT_MIN, CHARGE_WALL_TIMEOUT_MIN,
|
|
DRAIN, DRAIN_TIMEOUT_MIN, FAIL_BACKOFF_DAYS, HOLD, IDLE, NOSOC_NOTE_MIN,
|
|
MaintConfig, Maintenance, arbitrate,
|
|
)
|
|
from app.store import DEFAULTS, Store
|
|
|
|
UTC = timezone.utc
|
|
fails = []
|
|
|
|
|
|
def check(name, cond):
|
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
|
if not cond:
|
|
fails.append(name)
|
|
|
|
|
|
CFG = MaintConfig(enabled=True, interval_days=28, start_hour=19,
|
|
discharge_w=2000, charge_w=2000, soc_floor=11,
|
|
soc_target=100, hold_min=120)
|
|
T0 = datetime(2026, 10, 4, 18, 24, tzinfo=UTC)
|
|
MIN = timedelta(minutes=1)
|
|
|
|
|
|
class Mem:
|
|
def __init__(self, data=None):
|
|
self.data = dict(DEFAULTS, **(data or {}))
|
|
|
|
def set(self, k, v):
|
|
self.data[k] = v
|
|
|
|
def set_time(self, k, when):
|
|
self.data[k] = when.astimezone(UTC).isoformat() if when else None
|
|
|
|
def get_time(self, k):
|
|
raw = self.data.get(k)
|
|
return datetime.fromisoformat(raw) if raw else None
|
|
|
|
def save(self):
|
|
pass
|
|
|
|
|
|
def in_drain(store=None, unit=None, at=T0):
|
|
m = Maintenance(CFG, store or Mem(), unit=unit, name=unit or "")
|
|
m.force_start(at)
|
|
assert m.phase == DRAIN
|
|
return m
|
|
|
|
|
|
def in_charge(store=None, unit=None, at=T0):
|
|
m = in_drain(store, unit, at)
|
|
m.tick(at, 11.0)
|
|
assert m.phase == CHARGE
|
|
return m
|
|
|
|
|
|
def run(m, start, minutes, soc, gate=True, step=1):
|
|
"""Tick every `step` min; returns (last result, end time, first result that
|
|
is not the starting phase with its time, all events)."""
|
|
ph, t, res, out, ev = m.phase, start, None, None, []
|
|
for i in range(0, minutes, step):
|
|
t = start + i * MIN
|
|
s = soc(i) if callable(soc) else soc
|
|
res = m.tick(t, s, gate_open=gate)
|
|
ev += res.events
|
|
if out is None and res.phase != ph:
|
|
out = (res, t)
|
|
break
|
|
return res, start + minutes * MIN, out, ev
|
|
|
|
|
|
print("1. charge with no SoC")
|
|
m = in_charge()
|
|
res, _, out, _ = run(m, T0, 600, None)
|
|
check("it keeps charging: the loop owns the setpoint, no start of a new phase",
|
|
out is None and res.phase == CHARGE and res.charge_only
|
|
and not res.owns_setpoint)
|
|
|
|
m = in_charge()
|
|
run(m, T0, 60, 60.0)
|
|
res, _, out, ev = run(m, T0 + 60 * MIN, CHARGE_TIMEOUT_MIN + 10, None)
|
|
check("the charge timeout still runs: it fails once the time is up (last SoC 60)",
|
|
out and out[0].failed == "charge" and out[0].phase == IDLE)
|
|
check("...with the MAINT-13 back-off and its one plain line",
|
|
m.store.get_time("last_failed") is not None
|
|
and sum("did not finish" in e for e in ev) == 1
|
|
and f"{FAIL_BACKOFF_DAYS} days" in ev[-1])
|
|
check("...it took 36 h of allowed time from the charge start, not more",
|
|
out and out[1] <= T0 + timedelta(minutes=CHARGE_TIMEOUT_MIN + 10))
|
|
check("...and the pack does not want a start in the back-off",
|
|
not m.wants_start((T0 + timedelta(days=3)).replace(hour=19)))
|
|
|
|
m = in_charge()
|
|
res, _, out, _ = run(m, T0, CHARGE_TIMEOUT_MIN + 10, None)
|
|
check("never saw a SoC: the timeout is a failure, not 'done'",
|
|
out and out[0].failed == "charge")
|
|
|
|
for last in (97.0, 99.0):
|
|
m = in_charge()
|
|
run(m, T0, 10, last)
|
|
res, _, out, ev = run(m, T0 + 10 * MIN, CHARGE_TIMEOUT_MIN + 20, None)
|
|
check(f"last SoC {last:.0f} (>= target - {CHARGE_NEAR_FULL_PCT}): time up counts as done",
|
|
out and out[0].phase == HOLD and out[0].failed == ""
|
|
and m.store.get_time("last_failed") is None)
|
|
check("...one plain line says so",
|
|
sum("counts as done" in e for e in ev) == 1
|
|
and f"{last:.0f} %" in " ".join(ev))
|
|
m = in_charge()
|
|
run(m, T0, 10, 96.0)
|
|
res, _, out, _ = run(m, T0 + 10 * MIN, CHARGE_TIMEOUT_MIN + 20, None)
|
|
check("last SoC 96 (below target - 3): time up is a failure",
|
|
out and out[0].failed == "charge")
|
|
m = in_charge()
|
|
run(m, T0, 10, 99.0)
|
|
run(m, T0 + 10 * MIN, 10, 50.0)
|
|
res, _, out, _ = run(m, T0 + 20 * MIN, CHARGE_TIMEOUT_MIN + 20, None)
|
|
check("'last seen' is the last reading, not the best (99 then 50: failure)",
|
|
out and out[0].failed == "charge")
|
|
|
|
print("2. the 7 day backstop, with the gate shut")
|
|
m = in_charge()
|
|
run(m, T0, 10, 70.0)
|
|
res, _, out, _ = run(m, T0 + 10 * MIN, CHARGE_WALL_TIMEOUT_MIN + 30, None,
|
|
gate=False, step=5)
|
|
check("gate never opens, last SoC 70: failure at 7 days",
|
|
out and out[0].failed == "charge"
|
|
and out[1] >= T0 + timedelta(minutes=CHARGE_WALL_TIMEOUT_MIN)
|
|
and out[1] <= T0 + timedelta(minutes=CHARGE_WALL_TIMEOUT_MIN + 10))
|
|
m = in_charge()
|
|
run(m, T0, 10, 98.0)
|
|
res, _, out, _ = run(m, T0 + 10 * MIN, CHARGE_WALL_TIMEOUT_MIN + 30, None,
|
|
gate=False, step=5)
|
|
check("gate never opens, last SoC 98: done at 7 days",
|
|
out and out[0].phase == HOLD and out[0].failed == ""
|
|
and out[1] >= T0 + timedelta(minutes=CHARGE_WALL_TIMEOUT_MIN))
|
|
m = in_charge()
|
|
run(m, T0, 10, 70.0)
|
|
res, _, out, _ = run(m, T0 + 10 * MIN, 3000, None, gate=False, step=5)
|
|
check("gate shut, no SoC: the open-time clock does not run (no early end)",
|
|
out is None)
|
|
|
|
print("3. SoC comes back mid-charge")
|
|
m = in_charge()
|
|
run(m, T0, 30, 80.0)
|
|
run(m, T0 + 30 * MIN, 600, None)
|
|
res, _, out, _ = run(m, T0 + 630 * MIN, 5, 80.0)
|
|
check("normal behaviour resumes: still charging, nothing recorded",
|
|
out is None and res.phase == CHARGE
|
|
and m.store.get_time("last_failed") is None)
|
|
res, _, out, _ = run(m, T0 + 640 * MIN, 10, 100.0)
|
|
check("...and it completes at the target as usual",
|
|
out and out[0].phase == HOLD and m.store.get_time("last_failed") is None)
|
|
m = in_charge()
|
|
run(m, T0, 30, 98.0)
|
|
run(m, T0 + 30 * MIN, 600, None)
|
|
res, _, out, _ = run(m, T0 + 630 * MIN, 30, 98.0)
|
|
check("the stall wait paused while the SoC was missing (30 min flat after the "
|
|
"gap is not 60)", out is None)
|
|
res, _, out, _ = run(m, T0 + 660 * MIN, 40, 98.0)
|
|
check("...and completes once 60 min flat are in", out and out[0].phase == HOLD)
|
|
m = in_charge()
|
|
run(m, T0, 10, 70.0)
|
|
run(m, T0 + 10 * MIN, CHARGE_TIMEOUT_MIN - 100, None)
|
|
res, _, out, _ = run(m, T0 + (CHARGE_TIMEOUT_MIN - 90) * MIN, 5, 70.0)
|
|
check("a gap just before the limit then a reading: no failure, no back-off",
|
|
out is None and m.store.get_time("last_failed") is None)
|
|
|
|
print("4. drain with no SoC")
|
|
m = in_drain()
|
|
run(m, T0, 30, 60.0)
|
|
res, _, out, ev = run(m, T0 + 30 * MIN, 60, None)
|
|
check("DRAIN holds at 0 W, owned by maintenance, the same as HOLD",
|
|
out is None and res.phase == DRAIN and res.owns_setpoint
|
|
and res.setpoint_w == 0.0 and not res.charge_only)
|
|
hold = in_charge()
|
|
hold.tick(T0 + MIN, 100.0)
|
|
hr = hold.tick(T0 + 2 * MIN, None)
|
|
check("...the same answer as HOLD gives",
|
|
(hr.owns_setpoint, hr.setpoint_w) == (res.owns_setpoint, res.setpoint_w))
|
|
check("never above 0 W on any missing tick",
|
|
all(m.tick(T0 + (100 + i) * MIN, None).setpoint_w == 0.0 for i in range(30)))
|
|
check("one plain line that the drain holds, once, not per tick",
|
|
sum("drain holds at 0 W" in e for e in ev) == 1)
|
|
check("one plain line after the threshold, once",
|
|
sum("no battery level for" in e for e in ev) == 1
|
|
and f"{NOSOC_NOTE_MIN} min" in " ".join(ev))
|
|
m = in_drain()
|
|
res, _, out, ev = run(m, T0, NOSOC_NOTE_MIN - 1, None)
|
|
check("under the threshold the 'no level for long' line is not said",
|
|
not any("no battery level for" in e for e in ev))
|
|
m = in_drain()
|
|
res, _, out, ev = run(m, T0, DRAIN_TIMEOUT_MIN + 10, None)
|
|
check("the 12 h drain timeout still runs: failure with back-off",
|
|
out and out[0].failed == "drain" and out[0].phase == IDLE
|
|
and out[1] > T0 + timedelta(minutes=DRAIN_TIMEOUT_MIN)
|
|
and m.store.get_time("last_failed") is not None)
|
|
m = in_drain()
|
|
run(m, T0, 60, None)
|
|
res, _, out, _ = run(m, T0 + 60 * MIN, 5, 60.0)
|
|
check("SoC back: the drain continues at the drain rate",
|
|
out is None and res.phase == DRAIN and res.owns_setpoint
|
|
and res.setpoint_w == CFG.discharge_w)
|
|
res, _, out, _ = run(m, T0 + 70 * MIN, 5, 10.0)
|
|
check("...and ends at the floor as usual",
|
|
out and out[0].phase == CHARGE and m.store.get_time("last_failed") is None)
|
|
m = in_drain()
|
|
run(m, T0, 60, None)
|
|
run(m, T0 + 60 * MIN, 5, 60.0)
|
|
res, _, out, ev = run(m, T0 + 70 * MIN, 5, None)
|
|
check("a second episode says its lines again",
|
|
any("drain holds at 0 W" in e for e in ev))
|
|
|
|
print("5. the log is once per episode")
|
|
m = in_charge()
|
|
res, _, out, ev = run(m, T0, 300, None)
|
|
check("a 5 h charge episode: exactly one 'no level for long' line, none per tick",
|
|
len(ev) == 1 and "no battery level for" in ev[0])
|
|
run(m, T0 + 300 * MIN, 3, 70.0)
|
|
res, _, out, ev = run(m, T0 + 303 * MIN, 60, None)
|
|
check("...and one more for a new episode", len(ev) == 1)
|
|
m = in_charge()
|
|
res, _, out, ev = run(m, T0, 5, None)
|
|
check("a 5 min gap says nothing", ev == [])
|
|
|
|
print("5b. the words per phase")
|
|
m = in_charge()
|
|
m.tick(T0 + MIN, 100.0)
|
|
_, _, _, ev = run(m, T0 + 2 * MIN, 30, None)
|
|
note = [e for e in ev if "no battery level for" in e]
|
|
check("HOLD: the line exists and says the hold ends at its normal time (MAINT-15)",
|
|
len(note) == 1 and "normal time" in note[0] and "waits" not in note[0])
|
|
m = in_charge()
|
|
_, _, _, ev = run(m, T0, 30, None)
|
|
check("charge keeps its wording", any("time limit of the charge" in e for e in ev))
|
|
m = in_charge()
|
|
run(m, T0, 10, 70.0)
|
|
_, _, out, ev = run(m, T0 + 10 * MIN, CHARGE_WALL_TIMEOUT_MIN + 30, None,
|
|
gate=False, step=5)
|
|
fail = [e for e in ev if "ABORT" in e]
|
|
check("7-day backstop: the reason names the 7-day limit, not '0 h'",
|
|
len(fail) == 1 and "7-day limit" in fail[0] and "0 h" not in fail[0])
|
|
m = in_charge()
|
|
_, _, out, ev = run(m, T0, CHARGE_TIMEOUT_MIN + 10, None)
|
|
fail = [e for e in ev if "ABORT" in e]
|
|
check("charge-time limit: the reason gives the hours of charging time",
|
|
len(fail) == 1 and "36 h of charging time" in fail[0])
|
|
m = in_charge()
|
|
run(m, T0, 10, 98.0)
|
|
_, _, out, _ = run(m, T0 + 10 * MIN, CHARGE_TIMEOUT_MIN + 20, None)
|
|
_, _, _, ev = run(m, out[1] + MIN, 30, None)
|
|
check("charge done with no SoC, then HOLD with no SoC: a new episode, so the "
|
|
"HOLD line is said", out[0].phase == HOLD
|
|
and sum("normal time" in e for e in ev) == 1)
|
|
|
|
print("6. restart while the SoC is missing")
|
|
with tempfile.TemporaryDirectory() as d:
|
|
path = os.path.join(d, "state.json")
|
|
m = Maintenance(CFG, Store(path))
|
|
m.force_start(T0)
|
|
m.tick(T0, 11.0)
|
|
run(m, T0, 97, lambda i: 90.0 if i < 96 else 98.0) # 98 seen 1 min ago
|
|
run(m, T0 + 97 * MIN, 3, None) # SoC lost; state saved at once
|
|
t = T0 + 100 * MIN # restart 3 min after the loss, before the next 5 min save
|
|
m2 = Maintenance(CFG, Store(path))
|
|
r = m2.tick(t, None)
|
|
check("a fresh process reads charge, and still charges", m2.phase == CHARGE
|
|
and r.phase == CHARGE and r.charge_only)
|
|
check("...with the open minutes and the last SoC seen from the file",
|
|
m2._trk is not None and m2._trk["last"] == 98.0
|
|
and 92 <= m2._trk["open_min"] <= 105)
|
|
res, _, out, _ = run(m2, t + MIN, CHARGE_TIMEOUT_MIN, None, step=5)
|
|
check("...so the timeout counts from the first charge minute and, with the "
|
|
"last SoC 98, completes (no failure)",
|
|
out and out[0].phase == HOLD and out[0].failed == ""
|
|
and out[1] < T0 + timedelta(minutes=CHARGE_TIMEOUT_MIN + 60))
|
|
# drain: restart keeps it at 0 W and keeps the clock
|
|
path2 = os.path.join(d, "s2.json")
|
|
md = Maintenance(CFG, Store(path2))
|
|
md.force_start(T0)
|
|
md.tick(T0 + 100 * MIN, None)
|
|
md2 = Maintenance(CFG, Store(path2))
|
|
r = md2.tick(T0 + 101 * MIN, None)
|
|
check("a drain restarted with no SoC holds at 0 W", md2.phase == DRAIN
|
|
and r.setpoint_w == 0.0 and r.owns_setpoint)
|
|
res, _, out, _ = run(md2, T0 + 102 * MIN, DRAIN_TIMEOUT_MIN, None, step=5)
|
|
check("...and the 12 h clock is from the drain start, not the restart",
|
|
out and out[0].failed == "drain"
|
|
and out[1] <= T0 + timedelta(minutes=DRAIN_TIMEOUT_MIN + 10))
|
|
# a charge restarted with no record of any SoC (old state file)
|
|
old = {"phase": "charge", "phase_started": (T0 - timedelta(hours=35)).isoformat(),
|
|
"last_completed": None, "last_start_attempt": None, "auto": True}
|
|
import json
|
|
path3 = os.path.join(d, "s3.json")
|
|
json.dump(old, open(path3, "w"))
|
|
mo = Maintenance(CFG, Store(path3))
|
|
res, _, out, _ = run(mo, T0, 120, None)
|
|
check("an old state file with no SoC seen: the charge fails at 36 h",
|
|
out and out[0].failed == "charge")
|
|
# a MAINT-13 state file: charge_track with a best but no `last`
|
|
since = (T0 - timedelta(hours=35)).isoformat()
|
|
m13 = dict(old, phase_started=since, charge_track={
|
|
"since": since, "open_min": 2100.0, "flat_min": 0.0, "best": 99.0,
|
|
"at": (T0 - MIN).isoformat()})
|
|
path4 = os.path.join(d, "s4.json")
|
|
json.dump(m13, open(path4, "w"))
|
|
mo = Maintenance(CFG, Store(path4))
|
|
res, _, out, _ = run(mo, T0, 120, None)
|
|
check("a MAINT-13 state file (no last SoC): no crash, the charge fails at "
|
|
"36 h (best is not last)", out and out[0].failed == "charge")
|
|
|
|
print("7. two packs")
|
|
a, b = (Maintenance(CFG, Mem(), unit="inverter-a", name="inverter-a"),
|
|
Maintenance(CFG, Mem(), unit="inverter-b", name="inverter-b"))
|
|
a.force_start(T0)
|
|
# the twin site: the same two packs, A healthy (SoC 60, still above the floor)
|
|
a_ok, b_alone = (Maintenance(CFG, Mem(), unit="inverter-a", name="inverter-a"),
|
|
Maintenance(CFG, Mem(), unit="inverter-b", name="inverter-b"))
|
|
a_ok.force_start(T0)
|
|
same = True
|
|
for i in range(0, 120, 10):
|
|
t = T0 + i * MIN
|
|
plan = arbitrate([a, b], t)
|
|
plan0 = arbitrate([a_ok, b_alone], t)
|
|
same = same and plan[1] == plan0[1]
|
|
ra = a.tick(t, None, blocked_by=plan[0])
|
|
rb = b.tick(t, 55.0, blocked_by=plan[1])
|
|
a_ok.tick(t, 60.0, blocked_by=plan0[0])
|
|
rb0 = b_alone.tick(t, 55.0, blocked_by=plan0[1])
|
|
same = same and (rb.phase, rb.setpoint_w, rb.deferred) == \
|
|
(rb0.phase, rb0.setpoint_w, rb0.deferred)
|
|
check("A drains with no SoC at 0 W while B (SoC 55, idle) is as it is when A "
|
|
"is healthy", ra.setpoint_w == 0.0 and ra.owns_setpoint and rb.phase == IDLE
|
|
and rb.setpoint_w is None and not rb.owns_setpoint)
|
|
check("...B's answers are unchanged tick by tick", same)
|
|
res = None
|
|
for i in range(0, DRAIN_TIMEOUT_MIN + 30, 10):
|
|
t = T0 + 120 * MIN + i * MIN
|
|
plan = arbitrate([a, b], t)
|
|
res = a.tick(t, None, blocked_by=plan[0])
|
|
b.tick(t, 55.0, blocked_by=plan[1])
|
|
if res.failed:
|
|
break
|
|
check("A's drain times out and A backs off (A's keys only)",
|
|
res.failed == "drain" and a.store.get_time("last_failed") is not None
|
|
and b.store.get_time("last_failed") is None)
|
|
slot = (T0 + timedelta(days=1)).replace(hour=19, minute=5)
|
|
check("...then B, with its SoC, takes the slot as usual",
|
|
arbitrate([a, b], slot) == ["", ""] and b.wants_start(slot)
|
|
and not a.wants_start(slot))
|
|
b.force_start(slot)
|
|
rb = b.tick(slot + 5 * MIN, 50.0)
|
|
check("...and B's drain runs at the drain rate",
|
|
rb.phase == DRAIN and rb.setpoint_w == CFG.discharge_w)
|
|
# the other way: B charges with no SoC, A has a SoC
|
|
a = Maintenance(CFG, Mem(), unit="inverter-a", name="inverter-a")
|
|
b = Maintenance(CFG, Mem(), unit="inverter-b", name="inverter-b")
|
|
b.force_start(T0)
|
|
b.tick(T0, 11.0)
|
|
run(b, T0, 5, 97.0)
|
|
a_alone = Maintenance(CFG, Mem(), unit="inverter-a", name="inverter-a")
|
|
same, t = True, T0 + 10 * MIN
|
|
while t < T0 + timedelta(minutes=CHARGE_TIMEOUT_MIN + 30):
|
|
plan = arbitrate([a, b], t)
|
|
rb = b.tick(t, None, blocked_by=plan[1])
|
|
ra = a.tick(t, 60.0, blocked_by=plan[0])
|
|
ra0 = a_alone.tick(t, 60.0, blocked_by=plan[0])
|
|
same = same and (ra.phase, ra.setpoint_w, ra.deferred) == \
|
|
(ra0.phase, ra0.setpoint_w, ra0.deferred)
|
|
if rb.phase != CHARGE:
|
|
break
|
|
t += 5 * MIN
|
|
check("B charges with no SoC (last 97): A (SoC 60) is unchanged tick by tick",
|
|
same)
|
|
check("...B ends as done (HOLD), A untouched, no failure on either",
|
|
rb.phase == HOLD and rb.failed == ""
|
|
and b.store.get_time("last_failed") is None
|
|
and a.store.get_time("last_failed") is None and a.phase == IDLE)
|
|
|
|
print()
|
|
if fails:
|
|
print(f"{len(fails)} FAILED")
|
|
for f in fails:
|
|
print(" -", f)
|
|
sys.exit(1)
|
|
print("all passed")
|