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