diff --git a/goodwe_controller/DECISIONS/MAINT-13-full-pack-is-complete.md b/goodwe_controller/DECISIONS/MAINT-13-full-pack-is-complete.md index e28140a..b505b1e 100644 --- a/goodwe_controller/DECISIONS/MAINT-13-full-pack-is-complete.md +++ b/goodwe_controller/DECISIONS/MAINT-13-full-pack-is-complete.md @@ -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. diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 3b8a60a..97985f1 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -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 diff --git a/goodwe_controller/app/maintenance.py b/goodwe_controller/app/maintenance.py index 6883331..4edc5c9 100644 --- a/goodwe_controller/app/maintenance.py +++ b/goodwe_controller/app/maintenance.py @@ -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) diff --git a/goodwe_controller/test_maint13.py b/goodwe_controller/test_maint13.py index 0377498..7cad14d 100644 --- a/goodwe_controller/test_maint13.py +++ b/goodwe_controller/test_maint13.py @@ -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): diff --git a/goodwe_controller/test_safety93.py b/goodwe_controller/test_safety93.py index fa0ddda..6809a8e 100644 --- a/goodwe_controller/test_safety93.py +++ b/goodwe_controller/test_safety93.py @@ -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