diff --git a/goodwe_controller/DECISIONS/SAFETY-96-dropped-battery.md b/goodwe_controller/DECISIONS/SAFETY-96-dropped-battery.md index 1bd68a4..94e9c03 100644 --- a/goodwe_controller/DECISIONS/SAFETY-96-dropped-battery.md +++ b/goodwe_controller/DECISIONS/SAFETY-96-dropped-battery.md @@ -35,7 +35,9 @@ take over one slew step a cycle. At a rejoin it enters at its reading. (`inputs_ok`), as before. A refused battery (SAFETY-73) stays in the sum: it is asked ~0 W and reads ~0 W. A watchdog-stopped battery (SAFETY-02/30) is pinned at 0 W and stays in the sum. New: no refusal is learned on a -battery whose writes are refused; its board going to 0 W is no refusal. +battery whose writes are refused; its board going to 0 W is no refusal. Its +count is kept, not reset, so a link that refuses 1 write in 3 does not hide +a battery that really refuses (review). **Before -> after** (2 x 2500 W, 10 s lag, 2 s meter; freeze cycles not about the remaining battery's own lag): diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 9da20aa..9b3059b 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -1402,7 +1402,8 @@ class Controller: if inv.rejected_since is not None: # SAFETY-96: its writes are refused, so its board may have # gone to 0 W on its own; that is no refusal by the battery. - inv.ref_count = 0 + # The count is kept, as across a gap, so a link that refuses + # now and then does not hide a battery that does refuse. continue sign = self._gave_nothing(inv) if sign: diff --git a/goodwe_controller/test_safety96.py b/goodwe_controller/test_safety96.py index 5195b44..af33b81 100644 --- a/goodwe_controller/test_safety96.py +++ b/goodwe_controller/test_safety96.py @@ -76,18 +76,22 @@ def without(fn): def run(n=2, *, house=lambda t: 2400.0, dur=600.0, store=None, opt=None, soc=None, reject=None, back=None, wd=None, before=None, lag=10.0, - blind=None): + blind=None, flaky=None, dead=None): """Rows of one run. Writes to the units in `reject` (name -> from s) are refused from that time until `back` s; the add-on sees the refusal at once (a heartbeat write). A refused unit holds its last command, or with `wd` its board goes to 0 W `wd` s after the first refused write. B's - reading is missing in the `blind` (from, to) window. + reading is missing in the `blind` (from, to) window. `flaky(u, t)`: + writes to u refused at t, each cycle's verdict seen (a heartbeat). + `dead` (u, from s): u gives nothing from then on, whatever it is told. Each row: (t, plant commands, add-on commands, loop reason, site target, grid, circulation tripped).""" reject = reject or {} real_p, real_s, real_tick = rig._Hass.power, rig._Hass.set_number, rig.TICK def refused(u, t): + if flaky is not None: + return flaky(u, t) return u in reject and t >= reject[u] and (back is None or t < back) def power(self, u): @@ -98,6 +102,8 @@ def run(n=2, *, house=lambda t: 2400.0, dur=600.0, store=None, opt=None, c = self.cmd[u] if wd is not None and refused(u, now) and now - reject[u] >= wd: c = 0.0 + if dead is not None and u == dead[0] and now >= dead[1]: + c = 0.0 d = c - tgt tgt += math.copysign(min(abs(d), 80.0), d) a += (tgt - a) * (1 - math.exp(-0.1 / lag)) @@ -114,7 +120,10 @@ def run(n=2, *, house=lambda t: 2400.0, dur=600.0, store=None, opt=None, def hook(c, t): for inv in c.inverters: - if refused(inv.name[-1], t) and inv.rejected_since is None: + if flaky is not None: + inv.note_write(not flaky(inv.name[-1], t), + rig.BASE + timedelta(seconds=t)) + elif refused(inv.name[-1], t) and inv.rejected_since is None: inv.rejected_since = rig.BASE + timedelta(seconds=t) if before is not None: before(c, t) @@ -323,6 +332,35 @@ for v, w in (STUCK, BOARD): >= 1000.0) + +# Review: a battery that does refuse, behind a link that refuses one write +# in three, is still found out (the count is kept over a refused write). +_fl_rows, _fl = run(dur=700.0, dead=("a", 300.0), + flaky=lambda u, t: u == "a" and t >= 300.0 + and int(t / 2) % 3 == 0) +_fl_at = next((r[0] for r in _fl_rows if r[2][0] == 0.0 and r[0] > 300.0), + None) +_fl_end = first((r[2] for r in _fl_rows[-1:]), "the last row") +print(f" A gives nothing from 300 s, 1 write in 3 refused: A's share is " + f"taken off at {_fl_at} s; A {_fl.inverters[0].refused}, last " + f"commands {_fl_end}") +check("⭐ a battery that refuses behind a link refusing 1 write in 3: its " + "refusal is still learned and B carries the house", + _fl.inverters[0].refused == 1 and _fl_at is not None + and _fl_at <= 400.0 and present(_fl_end) + and abs(_fl_end[1] - 2410.0) <= 20.0) + +# Review: a fast battery whose board goes to 0 W 1 s after the first refused +# write reads ~0 W long before its link is stale (30 s): no refusal either. +_ref5 = [] +_e5_rows, _e5 = run(reject={"a": 300.0}, back=420.0, wd=1.0, lag=1.0, + dur=700.0, + before=lambda c, t: _ref5.append(c.inverters[0].refused)) +check("fast battery, board 0 W 1 s after the first refused write: no " + "refusal is learned, A is back in the split within 4 s of its link " + "returning", + not any(_ref5) and rejoined(_e5_rows) <= 424.0) + _seen = []