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
+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)