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 b505b1e..af3aa27 100644 --- a/goodwe_controller/DECISIONS/MAINT-13-full-pack-is-complete.md +++ b/goodwe_controller/DECISIONS/MAINT-13-full-pack-is-complete.md @@ -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 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 +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 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 diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 3eda31b..b7a3fef 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -1189,6 +1189,7 @@ class Controller: self.peak_fc = None self.cheap = False 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 # 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. @@ -3337,9 +3338,9 @@ class Controller: # ⚠️ AND IT CANNOT BE BOTH. validate.py refuses static_profile # alongside the two Home Assistant entities, because this `if` picks # one and the loser is silent. - self.cheap, self.tariff_words = static_status( - o, datetime.now(timezone.utc)) - self._price_gated = static_gated(o, datetime.now(timezone.utc)) + _now = datetime.now(timezone.utc) + self.cheap, self.tariff_words = static_status(o, _now) + self._price_gated = self._static_gated(o, _now) # ⚠️ TARIFF-11. THE PROVIDER PLAN REACHES THE *Tariff* ROW HERE, AND # THIS IS THE ONLY PLACE THE TWO SOURCES MEET. Everything above is the @@ -3878,6 +3879,16 @@ class Controller: inv.refusal_note())] 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: """MAINT-13: may this pack charge now, for the maintenance timeout? diff --git a/goodwe_controller/test_maint13.py b/goodwe_controller/test_maint13.py index 505c13a..c2bf93b 100644 --- a/goodwe_controller/test_maint13.py +++ b/goodwe_controller/test_maint13.py @@ -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).""" t, res, out = start, None, None for i in range(0, minutes, step): - t = start + i * step * MIN - s = soc(i * step) if callable(soc) else soc + t = start + i * MIN + s = soc(i) if callable(soc) else soc res = m.tick(t, s, gate_open=gate) if out is None and res.phase != CHARGE: 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") 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) -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) 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") m = in_charge() -res, t, out = run(m, T0, CHARGE_WALL_TIMEOUT_MIN + 5, 70.0, gate=False, step=5) -check("a gate that never opens still ends in a failure after 7 days", - out and out[0].failed == "charge") +WALL = T0 + CHARGE_WALL_TIMEOUT_MIN * MIN +res, t, out = run(m, T0, CHARGE_WALL_TIMEOUT_MIN + 10, 70.0, gate=False, step=5) +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() -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", - out and out[0].phase == HOLD) + out and out[0].phase == HOLD and WALL < out[1] <= WALL + 5 * MIN) m = in_charge() res, t, out = run(m, T0, 10, 70.0) before = m._trk["open_min"] @@ -256,7 +259,7 @@ with tempfile.TemporaryDirectory() as d: m_b.phase == CHARGE and open_before - 6 <= m_b._trk["open_min"] <= open_before + 2 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", out is not None and out[0].failed == "charge") m = Maintenance(CFG, Store(path)) @@ -454,6 +457,19 @@ 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) +# 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): 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", c._price_gated is True 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 mm = in_charge() diff --git a/goodwe_controller/test_safety93.py b/goodwe_controller/test_safety93.py index 6809a8e..7e27348 100644 --- a/goodwe_controller/test_safety93.py +++ b/goodwe_controller/test_safety93.py @@ -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].backoff_until(rig.BASE + timedelta(seconds=1800)) 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", 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", + "step, to the configured 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 max(cmd(r)["b"] for r in rows if r[0] >= 1780) <= drain_w and moves(rows, "a", 1780, 1798) <= SLEW) # ============================================================================ #