MAINT-13 round 3: provider site, hourly fixed-tariff check, faster tests
checks / unit (pull_request) Successful in 34m52s
infra-pin / fresh (pull_request) Successful in 1s

- A price-provider site with no fixed tariff is not a gate (its plan never
  sets `cheap`); DECISIONS §3 says so and a test pins it.
- The fixed-tariff check is worked out once an hour, not every tick.
- test_maint13: run() steps really are `step` minutes; the long loops use
  5-minute steps (the per-tick cap); wall backstop checked "not before".
- test_safety93: the drain bound is the rig's configured drain rate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-10-08 12:03:55 +02:00
co-authored by Claude Opus 5.5
parent f7b83cc75c
commit 6836f61bd4
4 changed files with 67 additions and 16 deletions
@@ -67,7 +67,13 @@ 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 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 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 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 count, which is the case the clock cannot judge. A site with a price provider
(TARIFF-11) and no fixed tariff counts as having no gate: the provider's plan
reaches only the Tariff row, never `cheap`, so no cheap window ever opens a grid
top-up there and only the sun charges the pack, as on a site with no tariff. Its
time counts in full. When a provider plan starts to drive `cheap`,
`_price_gated` must become true for it. Whether a fixed tariff is set up is
worked out once an hour, not on every tick. 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 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 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 every 5 min (`charge_track`); a restart loses at most that. A state file from
+14 -3
View File
@@ -1189,6 +1189,7 @@ class Controller:
self.peak_fc = None self.peak_fc = None
self.cheap = False self.cheap = False
self._price_gated = False # MAINT-13: is a price gate set up self._price_gated = False # MAINT-13: is a price gate set up
self._gated_key = None # MAINT-13: (options, hour) of the cache
# TARIFF-03. One line of prose for the status page saying WHICH tariff # TARIFF-03. One line of prose for the status page saying WHICH tariff
# window we are in and why it is or is not cheap - so "Cheap window: # window we are in and why it is or is not cheap - so "Cheap window:
# no" is an evidenced answer rather than a bare no. # no" is an evidenced answer rather than a bare no.
@@ -3337,9 +3338,9 @@ class Controller:
# ⚠️ AND IT CANNOT BE BOTH. validate.py refuses static_profile # ⚠️ AND IT CANNOT BE BOTH. validate.py refuses static_profile
# alongside the two Home Assistant entities, because this `if` picks # alongside the two Home Assistant entities, because this `if` picks
# one and the loser is silent. # one and the loser is silent.
self.cheap, self.tariff_words = static_status( _now = datetime.now(timezone.utc)
o, datetime.now(timezone.utc)) self.cheap, self.tariff_words = static_status(o, _now)
self._price_gated = static_gated(o, datetime.now(timezone.utc)) self._price_gated = self._static_gated(o, _now)
# ⚠️ TARIFF-11. THE PROVIDER PLAN REACHES THE *Tariff* ROW HERE, AND # ⚠️ TARIFF-11. THE PROVIDER PLAN REACHES THE *Tariff* ROW HERE, AND
# THIS IS THE ONLY PLACE THE TWO SOURCES MEET. Everything above is the # THIS IS THE ONLY PLACE THE TWO SOURCES MEET. Everything above is the
@@ -3878,6 +3879,16 @@ class Controller:
inv.refusal_note())] inv.refusal_note())]
if n] if n]
def _static_gated(self, o: dict, now: datetime) -> bool:
"""`static_gated()`, worked out once an hour: it builds the same
month-long plan as `static_status()` (about 20 ms with a profile), and
whether a fixed tariff is set up does not change from tick to tick
(options change only with a restart)."""
key = (id(o), now.strftime("%Y-%m-%d %H"))
if key != self._gated_key:
self._gated_key, self._gated = key, static_gated(o, now)
return self._gated
def _charge_gate_open(self, inv) -> bool: def _charge_gate_open(self, inv) -> bool:
"""MAINT-13: may this pack charge now, for the maintenance timeout? """MAINT-13: may this pack charge now, for the maintenance timeout?
+40 -10
View File
@@ -82,8 +82,8 @@ def run(m, start, minutes, soc, gate=True, step=1):
Returns (the last result, the time after the run, the first non-CHARGE result).""" Returns (the last result, the time after the run, the first non-CHARGE result)."""
t, res, out = start, None, None t, res, out = start, None, None
for i in range(0, minutes, step): for i in range(0, minutes, step):
t = start + i * step * MIN t = start + i * MIN
s = soc(i * step) if callable(soc) else soc s = soc(i) if callable(soc) else soc
res = m.tick(t, s, gate_open=gate) res = m.tick(t, s, gate_open=gate)
if out is None and res.phase != CHARGE: if out is None and res.phase != CHARGE:
out = (res, t) out = (res, t)
@@ -215,20 +215,23 @@ check("a drain that times out is a failure too, with the same back-off",
print("5. only the minutes the pack was allowed to charge count") print("5. only the minutes the pack was allowed to charge count")
m = in_charge() m = in_charge()
res, t, out = run(m, T0, CHARGE_TIMEOUT_MIN * 2, 70.0, gate=False) # 5 min steps: one tick counts at most 5 min, so this is the same count as
# 1 min steps in a fifth of the ticks.
res, t, out = run(m, T0, CHARGE_TIMEOUT_MIN * 2, 70.0, gate=False, step=5)
check("72 h with the gate closed: not timed out", out is None and m.phase == CHARGE) check("72 h with the gate closed: not timed out", out is None and m.phase == CHARGE)
res, t, out = run(m, t, CHARGE_TIMEOUT_MIN - 5, 70.0) res, t, out = run(m, t, CHARGE_TIMEOUT_MIN - 5, 70.0, step=5)
check("...and 35 h 55 min open on top: still charging", out is None) check("...and 35 h 55 min open on top: still charging", out is None)
res, t, out = run(m, t, 10, 70.0) res, t, out = run(m, t, 10, 70.0)
check("...36 h of open time in all: the charge fails", out and out[0].failed == "charge") check("...36 h of open time in all: the charge fails", out and out[0].failed == "charge")
m = in_charge() m = in_charge()
res, t, out = run(m, T0, CHARGE_WALL_TIMEOUT_MIN + 5, 70.0, gate=False, step=5) WALL = T0 + CHARGE_WALL_TIMEOUT_MIN * MIN
check("a gate that never opens still ends in a failure after 7 days", res, t, out = run(m, T0, CHARGE_WALL_TIMEOUT_MIN + 10, 70.0, gate=False, step=5)
out and out[0].failed == "charge") check("a gate that never opens still ends in a failure after 7 days (not before)",
out and out[0].failed == "charge" and WALL < out[1] <= WALL + 5 * MIN)
m = in_charge() m = in_charge()
res, t, out = run(m, T0, CHARGE_WALL_TIMEOUT_MIN + 5, 98.0, gate=False, step=5) res, t, out = run(m, T0, CHARGE_WALL_TIMEOUT_MIN + 10, 98.0, gate=False, step=5)
check("...or in a complete charge when the pack is near full", check("...or in a complete charge when the pack is near full",
out and out[0].phase == HOLD) out and out[0].phase == HOLD and WALL < out[1] <= WALL + 5 * MIN)
m = in_charge() m = in_charge()
res, t, out = run(m, T0, 10, 70.0) res, t, out = run(m, T0, 10, 70.0)
before = m._trk["open_min"] before = m._trk["open_min"]
@@ -256,7 +259,7 @@ with tempfile.TemporaryDirectory() as d:
m_b.phase == CHARGE m_b.phase == CHARGE
and open_before - 6 <= m_b._trk["open_min"] <= open_before + 2 and open_before - 6 <= m_b._trk["open_min"] <= open_before + 2
and m_b._trk["open_min"] < 60) and m_b._trk["open_min"] < 60)
_, t2, out = run(m_b, t + 2 * MIN, CHARGE_TIMEOUT_MIN - 30, 70.0) _, t2, out = run(m_b, t + 2 * MIN, CHARGE_TIMEOUT_MIN - 30, 70.0, step=5)
check("...so the timeout comes after 36 h in all, not 36 h from the restart", check("...so the timeout comes after 36 h in all, not 36 h from the restart",
out is not None and out[0].failed == "charge") out is not None and out[0].failed == "charge")
m = Maintenance(CFG, Store(path)) m = Maintenance(CFG, Store(path))
@@ -454,6 +457,19 @@ m = put_in_charge(c, 0, 40, CHARGE_TIMEOUT_MIN + 1)
one_cycle(c) one_cycle(c)
check("...and a pack past its charging time there fails at once (not 7 days)", check("...and a pack past its charging time there fails at once (not 7 days)",
m.phase == IDLE and len(notes(h)) == 1) m.phase == IDLE and len(notes(h)) == 1)
# a site with a price provider (TARIFF-11) and no fixed tariff: the provider's
# plan reaches only the Tariff row, never `cheap`, so no cheap window ever
# opens a grid top-up there. Like the site above, nothing gate-like holds the
# charge back, and the time counts.
PROVIDER = dict(NOTARIFF, price_provider="generic_dayahead", static_profile="")
c, h = controller(PROVIDER, False)
m = put_in_charge(c, 0, 5, 100.0)
one_cycle(c)
got = m._trk["open_min"] - 100.0
check("N=1, price provider and no fixed tariff: not a gate (`cheap` is never "
f"set from the provider), so the 3 minutes count (+{got:.1f})",
c._price_gated is False and not c.cheap
and c._charge_gate_open(c.inverters[0]) is True and 2.5 < got < 3.5)
def read(c): def read(c):
asyncio.run(c.read_inputs()) asyncio.run(c.read_inputs())
@@ -483,6 +499,20 @@ check("a fixed tariff set up counts as a gate: outside its cheap hours a pack "
"that is not charging is closed, inside them it is open", "that is not charging is closed, inside them it is open",
c._price_gated is True c._price_gated is True
and c._charge_gate_open(c.inverters[0]) is c.cheap) and c._charge_gate_open(c.inverters[0]) is c.cheap)
import app.main as main_mod # noqa: E402
_real_gated, _calls = main_mod.static_gated, []
main_mod.static_gated = lambda o, now: _calls.append(now) or _real_gated(o, now)
try:
c._gated_key = None
read(c)
read(c)
read(c)
c._static_gated(c.o, datetime.now(UTC) + timedelta(hours=1))
finally:
main_mod.static_gated = _real_gated
check("...worked out once an hour, not on every tick (3 reads in one hour = 1 "
f"build; a new hour = 1 more): {len(_calls)} builds",
len(_calls) == 2 and c._price_gated is True)
# the save timer is reset on a phase change # the save timer is reset on a phase change
mm = in_charge() mm = in_charge()
+6 -2
View File
@@ -265,13 +265,17 @@ check("timeout with B due: the charge ends at 1780 s and A is not restarted",
and c.maints[0].phase == IDLE and c.maints[0].phase == IDLE
and c.maints[0].backoff_until(rig.BASE + timedelta(seconds=1800)) and c.maints[0].backoff_until(rig.BASE + timedelta(seconds=1800))
is not None) is not None)
# A command below 0 W is a charge, so B's commands must stay at or above 0;
# the drain rate is the one this rig's controller was configured with.
drain_w = c.maints[1].cfg.discharge_w
print(f" B drain rate configured on the rig: {drain_w:.0f} W")
check("⭐ timeout with B due: no battery charges from the grid after it", check("⭐ timeout with B due: no battery charges from the grid after it",
charging_hi(rows, 1780, 1798, hi1) == 0 charging_hi(rows, 1780, 1798, hi1) == 0
and min(cmd(r)["b"] for r in rows if r[0] >= 1780) >= 0.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 " check("...B takes the slot and begins its own drain, which is the only large "
"step, to the drain rate and no higher", "step, to the configured drain rate and no higher",
c.maints[1].phase == DRAIN c.maints[1].phase == DRAIN
and max(cmd(r)["b"] for r in rows if r[0] >= 1780) <= 2000.0 and max(cmd(r)["b"] for r in rows if r[0] >= 1780) <= drain_w
and moves(rows, "a", 1780, 1798) <= SLEW) and moves(rows, "a", 1780, 1798) <= SLEW)
# ============================================================================ # # ============================================================================ #