diff --git a/goodwe_controller/DECISIONS/SAFETY-93-charge-end.md b/goodwe_controller/DECISIONS/SAFETY-93-charge-end.md index 67653df..83e65f4 100644 --- a/goodwe_controller/DECISIONS/SAFETY-93-charge-end.md +++ b/goodwe_controller/DECISIONS/SAFETY-93-charge-end.md @@ -25,6 +25,13 @@ step a cycle. The ending battery's own step (-2000 -> 0 W) is maintenance's pin, as before; it lowers the grid import. The end is kept until the loop next runs, so a missed poll on that cycle (the input hold) does not lose it. +Review: a charge also ends, for the site, when the battery is still in +CHARGE but can no longer be placed: a stale link or a refusal (SAFETY-73) +drops it, and its -2000 W landed on B the same way (B 0 -> -1600 W at a +1200 W house). So the end is noted after the unit claims are filed: a +battery leaves the charge when it leaves CHARGE, is not live, or its own +claims contradict. The guard suspending the charge (SAFETY-84) is no end. + Why a start-point reseed, and not a per-battery slew in the allocator: the jump comes from one stale number at one edge, the kind of edge SAFETY-73 (`_unit_refusal_end`) and OPT-11 P3 (`_reseed_run_edge`) already reseed. @@ -39,6 +46,7 @@ The change stays at that edge; nothing else at N>=2 moves. | charge times out / A switched Off | B 0 -> -1600 | B 0 -> 400 | | guard suspended the charge, A gives 1825 | B step 675 W | B step 395 W | | missed poll on the end cycle | B 0 -> -1600 a cycle later | B 0 -> 400 | +| A dropped mid-charge (stale link, refusal), house 1200 | B 0 -> -1600 | B 0 -> 400 | | N=1, both charge ends above | | byte-identical | No pinned digest moved: the 46 digests of `tools/*identity*.py` (N=1 and diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 5f44eca..6aeb8fb 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -994,8 +994,8 @@ class Controller: self._drain_waits = set() # SAFETY-91: last cycle's held-back batteries (`_held_back()`). self._held_was = frozenset() - # SAFETY-93: the batteries in their maintenance charge last cycle, - # and those whose charge ended since the loop last ran. + # SAFETY-93: the batteries placed in their maintenance charge last + # cycle, and those whose charge ended since the loop last ran. self._charging_was = frozenset() self._charge_ended = frozenset() # Set by amain() once the transport is built, so the status page can show @@ -2372,6 +2372,21 @@ class Controller: self.target = want self.i_w = want - self.tuning.gain * (want - float(batt)) + def _charge_track(self, claims, maint_results) -> None: + """SAFETY-93, N>=2: a charge ends when its battery leaves CHARGE or can + no longer be placed (stale link, refusal). Kept until the loop runs.""" + if len(self.inverters) < 2: + return + live = set(self.live_units()) + now = set() + for inv, res in zip(self.inverters, maint_results): + u = self.actuator_of("maintenance", inv) + if res.phase == CHARGE and u in live and not pre_resolve( + [c for c in claims if c.actuator == u]).contradiction: + now.add(inv.name) + self._charge_ended |= self._charging_was - now + self._charging_was = frozenset(now) + def _reseed_charge_end(self, maint_results) -> None: """SAFETY-93, N>=2: when a battery's maintenance charge ends, the loop starts from what the others stood at plus that battery's new command @@ -3603,11 +3618,6 @@ class Controller: self.log_event(msg, hint=messages.hint("maintenance", phase=machine.phase)) maint_results.append(res) - # SAFETY-93: kept until the loop next runs (a missed poll holds). - _charging = frozenset(inv.name for inv, r in zip( - self.inverters, maint_results) if r.phase == CHARGE) - self._charge_ended |= self._charging_was - _charging - self._charging_was = _charging # ⚠️ CARRIED TO THE STATUS PAGE, NOT LEFT IN THE 40-LINE ACTION LOG. The # deferral is edge-logged (it lasts as long as the other pack's cycle), # so by the time an operator asks "why has pack B not recalibrated" the @@ -4583,6 +4593,7 @@ class Controller: if self.modes_on: mode_trade = self._mode_trade(claims, now) self._mode_trade_was = mode_trade + self._charge_track(claims, maint_results) if (self.auto or self.shadow) and self.inputs_ok: # ⚠️ prev is the ARBITER's last output, not the loop's own last # wish. If something outranked the loop, that is what the hardware diff --git a/goodwe_controller/test_safety93.py b/goodwe_controller/test_safety93.py index 16f479e..f9599dd 100644 --- a/goodwe_controller/test_safety93.py +++ b/goodwe_controller/test_safety93.py @@ -362,6 +362,59 @@ check("it fires once, at the charge end: not at the HOLD end or on any " "other cycle", [x for x in _seen if x] == [[A]] and len(_seen) > 300) +# ============================================================================ # +print("\n6. A still in CHARGE but no longer placed: stale link, refusal") + + +def link_drop(at): + """A's writes rejected from `at` s, so it is dropped as a stale link.""" + def before(c, t): + a = c.inverters[0] + if t >= at and a.rejected_since is None: + a.rejected_since = rig.BASE + timedelta(seconds=at) + return before + + +def refuses(at): + """A stops charging from `at` s (reads 0 W); SAFETY-73 drops it.""" + def before(c, t): + if not getattr(c.hass, "_s93r", False): + real = c.hass.power + + def power(u): + p = real(u) + return 0.0 if u == "a" and c.hass.t >= at and p < 0 else p + c.hass.power, c.hass._s93r = power, True + return before + + +for k, hook in (("stale link", link_drop), ("refusal", refuses)): + def fn(hook=hook): + return run(2, 1200.0, 9999.0, dur=1500.0, before=hook(1300.0)) + rows, _c6 = fn() + old = without(fn)[0] + drop = first((r[0] for r in rows if r[0] >= 1300 + and "dropped inverter-a" in str(r[7])), "A dropped") + print(f" {k}: A dropped at {drop:.0f} s; B " + f"{[cmd(r)['b'] for r in rows if drop - 2 <= r[0] <= drop + 4]}, " + f"without the fix {[cmd(r)['b'] for r in old if drop - 2 <= r[0] <= drop + 4]}") + check(f"⭐ {k}: A is dropped while still in CHARGE, and B never charges " + f"from the grid and moves at most one slew step a cycle", + phase_at(rows, drop) == "charge" + and min(cmd(r)["b"] for r in rows if r[0] >= 1300) >= 0.0 + and moves(rows, "b", 1296, 1500) <= SLEW) + check(f"non-vacuous ({k}): without the fix B jumps to -1600 W", + min(cmd(r)["b"] for r in old if r[0] >= 1300) <= -1600.0) + +_seen.clear() +Controller._reseed_charge_end = _spy +try: + run(2, 6000.0, 1530.0, step=1500.0) +finally: + Controller._reseed_charge_end = no_fix +check("the guard suspending the charge is no end: it fires once, at the HOLD", + [x for x in _seen if x] == [[A]]) + print() if fails: print(f"{len(fails)} FAILED: " + ", ".join(fails))