MAINT-13 round 2: gate open when nothing holds the charge back; safety93 scenario restored
checks / unit (pull_request) Failing after 34m45s
infra-pin / fresh (pull_request) Successful in 2s

- gate_open is true with no price gate set up, in a cheap window, or while the
  pack is taking charge; the stall rule and timeout now work on a no-tariff site.
- reset the save timer on a phase change; exact 5 min gap-cap test.
- test_safety93: original timeout scenario kept (B finished) plus a case where a
  due B takes the slot after A's failure.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
This commit is contained in:
glenn schrooyen
2026-10-08 10:29:46 +02:00
co-authored by Claude Sonnet 5.5
parent 28eff568b5
commit 6f05b2e4be
5 changed files with 106 additions and 15 deletions
@@ -61,15 +61,20 @@ as long as the wait lasts. The operator may also see the log lines.
## 3. The timeout counts only allowed time
`Controller` passes `gate_open = self.cheap` (the same flag that lets the
cheap-window charge floor apply). Only those ticks add to `open_min`; one tick
`Controller._charge_gate_open()` says whether the pack may charge now. It is
true when no price gate is set up (no price entities, no fixed tariff: nothing
holds the charge back, and `cheap` is always false there), in a cheap window, or
while the pack is really taking charge (battery power at or below -50 W, which is
sun outside cheap hours). It is false only when a gate exists, is shut, and the
pack is not charging; a pack asked for charge that delivers nothing then does not
count, which is the case the clock cannot judge. Only open ticks add to `open_min`; one tick
adds at most 5 min, so a gap (add-on down) adds nothing. The stall wait pauses
while the gate is closed. The accounting is written to the state file at most
every 5 min (`charge_track`); a restart loses at most that. A state file from
before this change, or with no record for the current charge, starts from the
wall-clock minutes so far, which is the old behaviour.
**Backstop.** A site whose gate never opens (no tariff, no sun) would never time
**Backstop.** A gated site whose gate never opens (no cheap hours, no sun) would never time
out, so a charge also ends after 7 days of wall clock (`CHARGE_WALL_TIMEOUT_MIN`):
complete at >= target - 3, a failure below.
+23 -3
View File
@@ -3875,6 +3875,22 @@ class Controller:
inv.refusal_note())]
if n]
def _charge_gate_open(self, inv) -> bool:
"""MAINT-13: may this pack charge now, for the maintenance timeout?
True when no price gate is set up (no price entities, no fixed tariff:
nothing holds the charge back), in a cheap window, or while the pack is
really taking charge (sun outside cheap hours). Closed only when a gate
exists, it is shut, and the pack is not charging.
"""
o = self.o
gated = bool((o.get("price_now_entity") and o.get("price_avg_entity"))
or o.get("static_profile"))
if not gated or self.cheap:
return True
batt = inv.batt
return batt is not None and float(batt) <= -MAINT_CHARGING_W
async def cycle(self) -> None:
"""Gather claims, resolve precedence, write the winner.
@@ -3923,10 +3939,10 @@ class Controller:
if blocked is None:
maint_results.append(MaintResult(IDLE, False, None, False, []))
continue
# MAINT-13: the charge timeout counts only the minutes the price
# gate (`self.cheap`) let the pack charge.
# MAINT-13: the charge timeout counts only the minutes the pack was
# allowed to charge (see `_charge_gate_open`).
res = machine.tick(now, inv.soc, blocked_by=blocked,
gate_open=bool(self.cheap))
gate_open=self._charge_gate_open(inv))
for msg in res.events:
self.log_event(msg, hint=messages.hint(
"maint_failed" if res.failed else "maintenance",
@@ -7387,6 +7403,10 @@ class Controller:
return None
# MAINT-13: a battery power reading at or below minus this many W is a pack
# that is taking charge.
MAINT_CHARGING_W = 50.0
# SAFETY-61. Exit status when the control task dies; 70 is sysexits(3)
# EX_SOFTWARE. See DECISIONS/SAFETY-61-control-task-death.md.
EXIT_CONTROL_DIED = 70
+1
View File
@@ -375,6 +375,7 @@ class Maintenance:
def _enter(self, phase: str, now: datetime, events: list, msg: str) -> None:
self._trk = None
self._trk_saved = None
if self.store.get("charge_track") is not None:
self.store.set("charge_track", None)
self.store.set("phase", phase)
+44 -3
View File
@@ -231,10 +231,12 @@ check("...or in a complete charge when the pack is near full",
out and out[0].phase == HOLD)
m = in_charge()
res, t, out = run(m, T0, 10, 70.0)
before = m._trk["open_min"]
res = m.tick(t + 2 * timedelta(hours=1), 70.0)
trk = m._trk
check("a gap in the ticks (add-on down) counts for at most 5 minutes",
trk["open_min"] < 20, )
check("a gap in the ticks (add-on down) counts for exactly 5 minutes (the cap)",
abs(m._trk["open_min"] - before - 5.0) < 1e-9)
m.tick(t + 2 * timedelta(hours=1) + 2 * MIN, 70.0)
check("...and a 2 minute step counts 2", abs(m._trk["open_min"] - before - 7.0) < 1e-9)
print("6. restarts and old state files")
with tempfile.TemporaryDirectory() as d:
@@ -437,6 +439,45 @@ check("...and its failure is on B's keys",
c.maints[1].store.get_time("last_failed") is not None
and c.maints[0].store.get_time("last_failed") is None)
# a site with no price entities and no fixed tariff: nothing holds the charge back
NOTARIFF = {k: v for k, v in OPTS1.items()
if k not in ("price_now_entity", "price_avg_entity")}
c, h = controller(NOTARIFF, False)
m = put_in_charge(c, 0, 5, 100.0)
one_cycle(c)
got = m._trk["open_min"] - 100.0
check("N=1, no tariff and no price entities: the gate counts as open, so the "
f"3 minutes count (+{got:.1f}) although `cheap` is false",
not c.cheap and 2.5 < got < 3.5)
c, h = controller(NOTARIFF, False)
m = put_in_charge(c, 0, 40, CHARGE_TIMEOUT_MIN + 1)
one_cycle(c)
check("...and a pack past its charging time there fails at once (not 7 days)",
m.phase == IDLE and len(notes(h)) == 1)
c, h = controller(OPTS1, False)
c.inverters[0].batt = -800.0
check("price gate closed but the pack is taking 800 W (sun): gate open",
c._charge_gate_open(c.inverters[0]) is True)
c.inverters[0].batt = -10.0
check("...closed and about idle: gate closed",
c._charge_gate_open(c.inverters[0]) is False)
c.inverters[0].batt = None
check("...no battery reading: gate closed", c._charge_gate_open(c.inverters[0]) is False)
c, h = controller(OPTS1, True)
c.cheap = True
check("price gate open: gate open", c._charge_gate_open(c.inverters[0]) is True)
c, h = controller(dict(NOTARIFF, static_profile="x"), False)
check("a fixed tariff set up counts as a gate (closed outside its cheap hours)",
c._charge_gate_open(c.inverters[0]) is False)
# the save timer is reset on a phase change
mm = in_charge()
mm.tick(T0 + MIN, 70.0)
saved_before = mm._trk_saved
mm.abort(T0 + 2 * MIN, "operator")
check("leaving CHARGE resets the save timer along with the record",
saved_before is not None and mm._trk is None and mm._trk_saved is None)
print("9. the words")
en, nl = messages.maint_failed("charge", 7), messages.maint_failed("drain", 7)
for n_ in (en, nl):
+30 -6
View File
@@ -37,7 +37,7 @@ import safety79_identity as s79 # noqa: E402
import safety84_identity as s84 # noqa: E402
from app import packmode # noqa: E402
from app.main import Controller # noqa: E402
from app.maintenance import (CHARGE_TIMEOUT_MIN, HOLD, IDLE, # noqa: E402
from app.maintenance import (CHARGE_TIMEOUT_MIN, DRAIN, HOLD, IDLE, # noqa: E402
MaintResult)
from app.store import unit_key # noqa: E402
from named_absence import first, present # noqa: E402
@@ -203,10 +203,6 @@ def timed_out(end):
s.data[unit_key(A, "phase_started")] = (
rig.BASE + timedelta(seconds=end - CHARGE_TIMEOUT_MIN * 60 - 1)
).isoformat()
# MAINT-13: a timed-out charge now backs off 7 days instead of restarting
# at once, so a due B would start its own drain here. B has finished its
# cycle, as in b_done(), so only the charge end is graded.
s.data[unit_key("inverter-b", "last_completed")] = rig.BASE.isoformat()
return s
@@ -225,9 +221,16 @@ def switch_off(at):
return before
def timed_out_b_done(end):
"""timed_out(), with B's monthly cycle finished so it starts none."""
s = timed_out(end)
s.data[unit_key("inverter-b", "last_completed")] = rig.BASE.isoformat()
return s
ENDS = {
"timeout": lambda: run(2, 6000.0, 9999.0, step=1760.0, dur=2080.0,
store=timed_out(1780.0)),
store=timed_out_b_done(1780.0)),
"Off": lambda: run(2, 6000.0, 9999.0, step=1760.0, dur=2080.0,
store=b_done(), before=switch_off(1780.0),
extra={"battery_modes_enabled": True}),
@@ -250,6 +253,27 @@ for k, fn in ENDS.items():
f"the grid", at(old, 1780.0, "b") == -1600.0
and charging_hi(old, 1780, 1798, hi1) >= 1)
# MAINT-13: A's timed-out charge is a failed cycle and backs off, so A no longer
# restarts at once; a due B takes the slot instead and starts ITS drain. That is
# a drain start (a step to the drain rate by design), not a charge end.
rows, c = run(2, 6000.0, 9999.0, step=1760.0, dur=2080.0,
store=timed_out(1780.0))
print(f" timeout, B due: A {[cmd(r)['a'] for r in rows if 1776 <= r[0] <= 1790]}"
f" B {[cmd(r)['b'] for r in rows if 1776 <= r[0] <= 1790]}")
check("timeout with B due: the charge ends at 1780 s and A is not restarted",
phase_at(rows, 1778.0) == "charge" and phase_at(rows, 1780.0) == IDLE
and c.maints[0].phase == IDLE
and c.maints[0].backoff_until(rig.BASE + timedelta(seconds=1800))
is not None)
check("⭐ timeout with B due: no battery charges from the grid after it",
charging_hi(rows, 1780, 1798, hi1) == 0
and min(cmd(r)["b"] for r in rows if r[0] >= 1780) >= 0.0)
check("...B takes the slot and begins its own drain, which is the only large "
"step, to the drain rate and no higher",
c.maints[1].phase == DRAIN
and max(cmd(r)["b"] for r in rows if r[0] >= 1780) <= 2000.0
and moves(rows, "a", 1780, 1798) <= SLEW)
# ============================================================================ #
print("\n4. the charge ends while the guard has suspended it")
# House 6000 W from 1500 s: the guard suspends the shaping, the loop has both