diff --git a/goodwe_controller/DECISIONS/SAFETY-94-lagging-battery.md b/goodwe_controller/DECISIONS/SAFETY-94-lagging-battery.md new file mode 100644 index 0000000..fce3371 --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-94-lagging-battery.md @@ -0,0 +1,94 @@ +# SAFETY-94 - a battery that lags or reverses is not frozen or refusing + +**Status: implemented 2026-10-03, for review.** Tests: `test_safety94.py` +(rig: `tools/safety94_rig.py`). Also closes SAFETY-58. SAFETY-74 stays open. + +## The defects + +1. **Stale-reading gate (SAFETY-37).** Its 35 s ran from the register's last + change. After a rest that clock was already past 35 s, so the first read + after a new command tripped it, before the register could answer. Live + 2026-10-03 (0.14.0, N=2): 17:39:16 B and 17:39:25 A (load ramp after a + rest), 19:00:04 A (drain start), 21:21:22 B (drain -> charge). Each was + 1 s after its command; each register answered within 11 s. This is + SAFETY-58's shape. +2. **Refusal at N=1 (SAFETY-52).** `compute()` engaged after `SETTLE_CYCLES` + frozen cycles if the reading was under 100 W at that moment. The frozen + cycles include a reversal through the battery's lag. At a 1 s meter the + 12th cycle comes as a battery reversing out of a charge passes 0 W + (SAFETY-85 rig, O1: "gave -20 W for 13 cycles"). The refusal then held + 0 W, the battery settled at 0 W, never gave 100 W, and waited for the + 15-minute probe. N>1 already required 22 s in the band, so it was safe + at a 10 s lag but not at 20 s. + +## What changed + +- **The gate counts only time the register was asked to move** + (`freshness.Freshness._oblige`). A read counts the time since the last + read when, at that read, the command is 250 W or more and has moved 250 W + from where it stood at the register's last change. A register change + (or a SAFETY-57 release) restarts the count. A command flapping in and + out of the ask still adds up (frozen register, 0/1500 W every 5 s: trip + at 69 s). The conjunction, `MOVE_W` and `RELEASE_S` are unchanged. +- **The bound is unchanged at 35 s and now means what its derivation + says.** The floor was always "how long a healthy register takes to answer + a command": 6 s plant + 10 s publish + 1 s read = 17 s. The band + `[2 x 17, 0.5 x 75] = [34, 37.5]` holds. Live answers measured: 1.4, 3, + 6 and 11 s after the command. +- **Refusal evidence is the same at every roster size** + (`main.Controller._gave_nothing`, SAFETY-73's count moved into a method). + The reading must stay under 100 W, one way, while asked over 200 W, for 12 + cycles spanning 22 s. At N=1 `compute()` gets this as `gave_nothing` and + needs it on top of its own freeze test. `None` (compute alone) keeps the + old law. +- **The evidence restarts when the reading moves 50 W towards the ask** + (`REFUSED_PROGRESS_W`). A refusing battery reads flat (-3/-4 W live). Live + readings wander by under 25 W at rest. A first-order lag crossing the band + gains 50 W in at most 0.4 tau, so it cannot fill 22 s unless tau > 54 s. + This also covers B, whose reading is 100 W below its power. + +## Bounds and worst-case catch + +| fault | before | after | budget | +|---|---|---|---| +| register frozen, then asked to move | 0-35 s after the ask (often 1 s) | 35 s after the ask (+1 read) | SAFETY-37: 35 s, under the 75/85 s consumer holds | +| register frozen at rest reading ~0 W | stale 1 s after the ask | refused or stale, 0 W within 23-35 s | the same | +| refusing battery, 1 s meter, N=1 | 11-16 s | 22-28 s | SAFETY-73's 22 s span (+ one 10 s publish) | +| refusing battery, 2 s meter | 22-28 s | 22-28 s | unchanged | +| refusing battery, 5 s meter | N=1 35-41 s, N=2 41-55 s | N=1 40-45 s, N=2 50-55 s | 12 cycles = 55-60 s | + +Rig: `test_safety94.py` part 3. A release after SAFETY-57 still exposes +the held number for one 35 s window of asking, so 35 < 75 < 85 still holds. + +## Before -> after + +- Live replay (part 1): 4 trips -> 0. +- Rig, 10 s lag, N=1 and N=2, 1/2/5 s meters, two register shapes, 48 runs + (part 2): before 27 stale trips and 1 refusal, after 0 and 0. +- Reversals, lag 10 and 20 s, 1 s meter (part 2): before N=1 6 stale / 3 + refused, N=2 6 / 1; after 0 / 0. +- 16-day loop capture (`test_freshness.py` part 8): 93 trips -> 59. Trips + answered within 20 s: 21 -> 1. Two of the removed trips were not quick: + a register held 351 s under a command hovering at the 250 W travel edge + (under 35 s of asking before it moved; survivor 2's shape), and a + refusing battery the gate had caught only on a passing ramp (SAFETY-52's + refusal covers it). + +## SAFETY-58 and SAFETY-74 + +- **SAFETY-58 is fixed** by this change: its card asks for the clock to + start with the obligation and for the bound to be re-derived. Both are + done above. `test_freshness.py` survivor 5 is now graded as closed. +- **SAFETY-74 is not fixed.** A battery capped at 300 W with a perfectly + flat reading, 5 s meter, house stepping: 50 trips/h at N=1 and 80 at N=2 + before, 37 and 60 after. With +-1 W dither: 0. A flat reading under a + command that moved is exactly what the gate is for; telling "limited" + from "frozen" needs a second signal (the meter, or SoC). + +## Known gaps + +- B's standby offset (-95 to -118 W at idle) puts a refusing B outside the + 100 W band some of the time, so a refusing B may not latch. Pre-existing, + not touched here. +- A register frozen under a command that stays near the 250 W travel edge + is caught late or not at all (survivor 2). diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index a5b659f..3329d5b 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -590,7 +590,7 @@ accumulator is doing: | `freeze-released` | the freeze had run out of plant-shaped explanations and the command was let move again | | `mode-bound` | the batteries' limits set the command, not the meter: their battery modes, or, with two or more inverters, a battery in its monthly maintenance | | `not-running` | control is stopped **and** shadow mode is off, so the loop did not run | -| `battery refuses` | the battery gave less than 100 W for 12 cycles (a minute at a 5-second meter) while asked for more than 500 W, so the add-on asks 0 W in that direction (see below) | +| `battery refuses` | the battery gave less than 100 W for 12 cycles and at least 22 s (a minute at a 5-second meter) while asked for more than 500 W, so the add-on asks 0 W in that direction (see below) | | `battery probe` | the add-on asks 200 W to test whether a refusing battery gives power again | **When the battery refuses.** At its cut-out level the battery gives nothing, @@ -600,7 +600,9 @@ request, and the `Why` row reads *The battery refuses to give power: 0 W*. Every the battery gives 100 W or more, when the grid needs charging instead, and when control is stopped or started. 200 W is below the 250 W the stale-reading check needs, so a test never trips it. A battery that gives some power, even 100 W, -is not refusing and keeps its request. +is not refusing and keeps its request. Nor is a battery whose reading is +still moving towards the request, such as one turning from charging to +giving (SAFETY-94). **With two or more inverters** the same rule works per inverter. An inverter whose battery gives less than 100 W for 12 cycles, and at least 22 s, while @@ -3457,7 +3459,8 @@ reason code, and its full figures are on the DEBUG line right after it and in …`, and the peak guard's full reason is on the DEBUG line after its ERROR. - ***name*: battery power reading stopped changing** is SAFETY-37/56/57. The ERROR line says when it clears and names `STALE_READING`. The full reason, - with both possible causes, is on the DEBUG line after it. + with both possible causes, is on the DEBUG line after it. The 35 s counts + only while the command asks the reading to move (SAFETY-94). - **Meter: no reading for …** (red): the add-on logs one WARNING when the meter goes quiet, "No meter reading for …", and an INFO line when readings return, at most once every 10 minutes. Past `stale_input_s` the **Why** diff --git a/goodwe_controller/app/control.py b/goodwe_controller/app/control.py index 1f2713a..db9bf82 100644 --- a/goodwe_controller/app/control.py +++ b/goodwe_controller/app/control.py @@ -484,6 +484,7 @@ def compute( refused: int | None = None, probe: bool = False, bound: tuple | None = None, + gave_nothing: bool | None = None, ) -> Decision: """One control cycle. A cycle is one meter update (~5 s on a HomeWizard P1). @@ -497,6 +498,10 @@ def compute( `bound` OPT-11: `(lo, hi)`, what the batteries may place this tick with their modes applied (`allocator.loop_bound()`), or None. See MODE_BOUND below. None is the law as it was. + `gave_nothing` SAFETY-94: the caller's span check - the reading stayed + under REFUSED_BELOW_W, one way, for SETTLE_CYCLES spanning + REFUSED_SPAN_S. A refusal engages only with it. None leaves it + out (compute() alone, as before). """ reason = "tracking" # OPT-11: a bound that leaves out 0 W would FORCE a direction, which no @@ -817,9 +822,14 @@ def compute( # `saturation_w` is a refusal, not a slow plant. The drop to 0 skips the slew # limit because it only removes an ask the battery is not meeting. if refused is not None: + # SAFETY-94: and only when the reading has given nothing for the + # whole span, not just now. `sat_count` also counts the cycles a + # healthy battery spends reversing through its lag (-2000 W towards + # +800 W): at a 1 s meter its 12 cycles end as the reading passes 0. if (refused == 0 and frozen and sat_count >= SETTLE_CYCLES and abs(prev_w) > tuning.saturation_w - and abs(actual_w) < REFUSED_BELOW_W): + and abs(actual_w) < REFUSED_BELOW_W + and gave_nothing is not False): refused = 1 if prev_w > 0 else -1 if refused and refused * i_w < 0: refused = 0 # the loop wants the other way diff --git a/goodwe_controller/app/freshness.py b/goodwe_controller/app/freshness.py index 7281d7a..ba577c8 100644 --- a/goodwe_controller/app/freshness.py +++ b/goodwe_controller/app/freshness.py @@ -87,6 +87,10 @@ when it has not moved THROUGH A CHANGE IT WAS OBLIGED TO FOLLOW: AND |standing command now - standing command when the reading last changed| >= MOVE_W +SAFETY-94: "unchanged for" counts only the reads at which both command terms +held, so a register that rested is not judged before it could answer. +DECISIONS/SAFETY-94-lagging-battery.md. + 0x0518 is the inverter reporting ITSELF and it tracks its own command: r = 0.998 with battery power against r = 0.09 with the real meter (goodwe/NOTES.md §3.3), command and actual agreeing within 4 W once settled (§5.10, 2026-08-22), and the @@ -216,6 +220,8 @@ change - which was the ONLY exit - has a median of 143 s, 17 episodes at or over the live one's 710 s, and a worst case of 21440 s. That distribution is a property of this battery's firmware. It is not a property of this code, and the add-on must not depend on it. +SAFETY-94: many were the gate judging a rested register on its first read +(SAFETY-58). Counted from the ask, the same days give 59 trips. THE RULE, and it is SAFETY-13's shape one module over - "which latches get an exit, not how long the block lasts": @@ -372,7 +378,9 @@ BOUNDS = { "SAFETY-57: the verdict is withdrawn after 17.0 s of the " "standing command below 250 W, because at 0 W the register " "is obliged to nothing and the verdict cannot be tested " - "again from inside the state it creates"), + "again from inside the state it creates. SAFETY-94: the " + "35 s counts only reads at which the command asks the " + "register to move"), "soc": Bound( signal="soc (0x050E, pack SoC, U16, 1 %, 10 s publish)", unchanged_s=float("inf"), @@ -416,11 +424,9 @@ class Freshness: new grading window at that instant. The 1 Hz oscillation the latch exists to prevent is still prevented: the new window is `bound.unchanged_s` wide, so no re-trip can land sooner than 35 s after a release, rather than on the - next read. ⚠️ MEASURED FROM THE RELEASE, NOT FROM THE COMMAND RESUMING - if - the site rests for longer than the window before it asks for anything, the - window is already old and the re-trip lands on the first read at which the - command travels 250 W again. That is survivor 5's shape, it predates this - card, and its cost is one `stale_input_s` hold. + next read. SAFETY-94: the 35 s counts only while the command asks the + register to move, so a site that rests after a release is not re-tripped + on its first ask. ⚠️ TWO CLOCKS, AND THE DISTINCTION IS THE WHOLE OF SAFETY-57'S HONESTY. `changed_mono` / `held` are facts about the REGISTER: when it last carried a @@ -451,6 +457,11 @@ class Freshness: # the change-stamps; `_release()` is where they diverge. self.graded_from = None self.standing_at_grade = None + # SAFETY-94. Seconds of this window during which the register was + # obliged to move (the command at least `move_w` and `move_w` away + # from `standing_at_grade`), and the instant of the last read. + self.obliged_acc = 0.0 + self.last_read = None # The instant the standing command last FELL below `move_w`, or None # while an obligation is standing. The release timer runs off this. self.rested_since = None @@ -499,6 +510,7 @@ class Freshness: # it reads in the order the state machine moves - withdraw, then # re-grade - not because the code depends on it. self._release(standing_w, now_mono) + self._oblige(standing_w, now_mono) self._grade(standing_w, now_mono) def _open_window(self, standing_w, now_mono) -> None: @@ -510,6 +522,35 @@ class Freshness: self.graded_from = float(now_mono) self.standing_at_grade = (None if standing_w is None else float(standing_w)) + # The read that opens a window is never an ask (no travel yet). + self.obliged_acc = 0.0 + + def _obliged(self, standing_w) -> bool: + """Is the register asked to move: the command is at least `move_w` and + has travelled `move_w` since the window opened?""" + if standing_w is None or self.standing_at_grade is None: + return False + standing_w = float(standing_w) + return (abs(standing_w) >= self.bound.move_w + and abs(standing_w - self.standing_at_grade) + >= self.bound.move_w) + + def _oblige(self, standing_w, now_mono) -> None: + """SAFETY-94. Count only the time the register was asked to move. + + A register that held still while nothing asked it to move has not + failed, so time before the command moved is not evidence. The time + since the previous read counts when the obligation stands at this + read; a command flapping in and out of the obligation still adds up. + """ + now_mono = float(now_mono) + if self.last_read is not None and self._obliged(standing_w): + self.obliged_acc += max(0.0, now_mono - self.last_read) + self.last_read = now_mono + + def obliged_s(self) -> float: + """Seconds of the current window the register was obliged to move.""" + return self.obliged_acc def _release(self, standing_w, now_mono) -> None: """SAFETY-57. Withdraw a verdict whose obligation is no longer standing. @@ -543,7 +584,12 @@ class Freshness: if self.stale or self.graded_from is None: return held_s = self.graded_s(now_mono) - if held_s < self.bound.unchanged_s: + # SAFETY-94 (closes SAFETY-58): the bound counts only the time the + # command asked the register to move, not the time since it last + # changed. A register that rested for minutes was otherwise judged on + # the first read after a new command, before it could answer (live, + # 2026-10-03: 1 s after the command). + if self.obliged_s() < self.bound.unchanged_s: return # ⚠️ BOTH None GUARDS ARE FOR A CALLER THAT DOES NOT KNOW ITS OWN # OBLIGATION YET, WHICH IS NOT `_read_unit`. `Inverter.standing_w()` diff --git a/goodwe_controller/app/inverter.py b/goodwe_controller/app/inverter.py index 328c9fa..b387507 100644 --- a/goodwe_controller/app/inverter.py +++ b/goodwe_controller/app/inverter.py @@ -321,13 +321,14 @@ class Inverter: # take, 0 none), when that refusal or its last probe started, the # current probe's start, and the cycles of evidence so far and when # they began. Memory only, like SAFETY-52's: stop and start end it. - # Used at N>1 only. + # The evidence count is also used at N=1 (SAFETY-94); the rest at N>1. self.refused = 0 self.refused_at = None self.probe_from = None self.ref_count = 0 self.ref_since = None self.ref_sign = 0 + self.ref_from = None # -- store --------------------------------------------------------------- def key(self, name: str) -> str: diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 6aeb8fb..39c176a 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -281,6 +281,14 @@ def pack_energy_of(inv): # ordinary load steps. At 2 s and slower this changes nothing. REFUSED_SPAN_S = (_ctl.SETTLE_CYCLES - 1) * _ctl.LOOP_TICK_S +# SAFETY-94: the evidence restarts when the reading has moved this far towards +# the ask since the evidence began. A refusing battery reads flat (-4 W live, +# 2026-09-06); live readings wander by under 25 W at rest (B's standby, -95 to +# -118 W, 2026-10-03). A battery crossing the 100 W band on a first-order lag +# gains 50 W in at most tau x ln(151/101) = 0.4 tau, so the evidence cannot +# span REFUSED_SPAN_S unless the lag is over 54 s. +REFUSED_PROGRESS_W = _ctl.PROBE_ANSWER_W / 2 + # SAFETY-85 review: a battery another owner set is graded against the least it # was told over the same span as above (the plant's measured settle time), so # a healthy battery still settling on a new command is not short. @@ -1360,27 +1368,42 @@ class Controller: self._unit_refusal_end( inv, "the battery takes power the other way") continue - if b is None: - continue # a gap keeps the count: the gate's trip seeds - standing = inv.standing_w() - sign = 1 if standing > 0 else -1 - # One direction throughout: a command that swaps sign every cycle - # (a flapping load) reads ~0 through the plant's own lag, and that - # is a battery following, not refusing (TEST-19 flapping family). - if abs(standing) > _ctl.PROBE_W and abs(b) < _ctl.REFUSED_BELOW_W: - if sign != inv.ref_sign or not inv.ref_count: - inv.ref_count, inv.ref_since = 0, self._now_mono - inv.ref_count = min(inv.ref_count + 1, _ctl.SETTLE_CYCLES) - else: - inv.ref_count = 0 - inv.ref_sign = sign - if inv.ref_count >= _ctl.SETTLE_CYCLES and ( - inv.ref_since is None or self._now_mono is None - or self._now_mono - inv.ref_since >= REFUSED_SPAN_S): + sign = self._gave_nothing(inv) + if sign: self._unit_refusal_start( inv, sign, - f"asked {standing:+.0f} W, the battery gave {b:+.0f} W " - f"for {_ctl.SETTLE_CYCLES} cycles") + f"asked {inv.standing_w():+.0f} W, the battery gave " + f"{b:+.0f} W for {_ctl.SETTLE_CYCLES} cycles") + + def _gave_nothing(self, inv) -> int: + """The refusal evidence, one cycle of it: the sign `inv` has given + under REFUSED_BELOW_W while asked over PROBE_W, one way, for + SETTLE_CYCLES spanning REFUSED_SPAN_S, else 0. SAFETY-73 at N>1; + SAFETY-94 adds it to the N=1 engage in compute().""" + b = inv.batt + if b is None: + return 0 # a gap keeps the count: the gate's trip seeds + standing = inv.standing_w() + sign = 1 if standing > 0 else -1 + # One direction throughout: a command that swaps sign every cycle + # (a flapping load) reads ~0 through the plant's own lag, and that + # is a battery following, not refusing (TEST-19 flapping family). + if abs(standing) > _ctl.PROBE_W and abs(b) < _ctl.REFUSED_BELOW_W: + # SAFETY-94: a reading still moving towards the ask is a battery + # catching up through its lag, not one refusing: start again. + if (sign != inv.ref_sign or not inv.ref_count + or sign * (b - inv.ref_from) >= REFUSED_PROGRESS_W): + inv.ref_count, inv.ref_since, inv.ref_from = ( + 0, self._now_mono, b) + inv.ref_count = min(inv.ref_count + 1, _ctl.SETTLE_CYCLES) + else: + inv.ref_count = 0 + inv.ref_sign = sign + if inv.ref_count >= _ctl.SETTLE_CYCLES and ( + inv.ref_since is None or self._now_mono is None + or self._now_mono - inv.ref_since >= REFUSED_SPAN_S): + return sign + return 0 def _site_refused(self): """Owner decision 4: every unit refuses the same way, so the site law @@ -3162,8 +3185,9 @@ class Controller: inv.batt_fresh.bound.move_w, messages.reason_code(inv.batt_fresh.reason)) _LOG.debug("Evidence for %s: %s.", inv.name, inv.batt_fresh.reason) - _LOG.debug("%s: the limits are unchanged over %.0f s, a command " - "over %.0f W and a move by over %.0f W.", inv.name, + _LOG.debug("%s: the limits are unchanged for %.0f s while asked " + "to move: a command over %.0f W that moved over %.0f W.", + inv.name, inv.batt_fresh.bound.unchanged_s, inv.batt_fresh.bound.move_w, inv.batt_fresh.bound.move_w) @@ -4660,6 +4684,10 @@ class Controller: refused=self.batt_refused if len(self.inverters) == 1 else self._site_refused(), probe=probe, + # SAFETY-94: at N=1 the engage also needs the reading to have + # given nothing for the whole span, as at N>1. + gave_nothing=(bool(self._gave_nothing(self.inverters[0])) + if len(self.inverters) == 1 else None), # OPT-11: None unless battery modes are on. SAFETY-85: or while # maintenance holds a battery at N>=2, so the loop does not # wind past what the others can give and jump when it ends. diff --git a/goodwe_controller/test_clamp.py b/goodwe_controller/test_clamp.py index b3cc1b2..f651a2d 100644 --- a/goodwe_controller/test_clamp.py +++ b/goodwe_controller/test_clamp.py @@ -439,7 +439,7 @@ class _Hass: return True -def wired(grid, cycles=40, batt=0.0, track=True, **over): +def wired(grid, cycles=40, batt=0.0, track=True, tick=None, **over): async def go(): hass = _Hass(grid, batt=batt, track=track) c = Controller(dict( @@ -455,8 +455,11 @@ def wired(grid, cycles=40, batt=0.0, track=True, **over): # hysteresis clears it a few cycles later - so it is captured live # rather than looked for after the run, when it is correctly gone. c.red_snapshot = [] - for _ in range(cycles): - await c.read_inputs() + for i in range(cycles): + # `tick`: simulated seconds per cycle. SAFETY-94: a refusal needs + # its evidence to span REFUSED_SPAN_S, which back-to-back cycles + # on the real clock never do. + await c.read_inputs(now_mono=None if tick is None else i * tick) await c.cycle() if c.clamp_wd.active and not c.red_snapshot: c.red_snapshot = c.checks() @@ -474,7 +477,7 @@ def clamp_notes(hass): # unclamped integrator climbed ~130 W per 4 s to over 10 kW and, pinned at the # 5 kW rail, reported 14 768 W." `track=False` is the inverter switched off: it # reports 0 W however hard it is commanded, so the error never closes. -hass_r, ctl_r = wired(6000.0, cycles=60, track=False) +hass_r, ctl_r = wired(6000.0, cycles=60, track=False, tick=2.0) emitted = [abs(w) for w in hass_r.writes] # What the SAME error would have done to an accumulator with no bound - the diff --git a/goodwe_controller/test_freshness.py b/goodwe_controller/test_freshness.py index 80a8e3c..620da86 100644 --- a/goodwe_controller/test_freshness.py +++ b/goodwe_controller/test_freshness.py @@ -900,34 +900,51 @@ def _loop_series(day, sig): return out +class _Before94(Freshness): + """The gate before SAFETY-94: the clock ran from the register's last + change, not from when the command asked it to move. The history below is + about that gate, so it is replayed on it.""" + + def obliged_s(self): + return self.graded_s(self.last_read) + + +def _replay16(cls): + """Every committed day at 1 Hz. Returns (trips, waits for the next move).""" + waits, trips = [], 0 + for _day in _loop_days: + _b, _s = _loop_series(_day, "batt"), _loop_series(_day, "setpoint") + if not _b or not _s: + continue + _moves = [r[0] for i, r in enumerate(_b) + if i == 0 or r[1] != _b[i - 1][1]] + _f8b, _prev8b = cls(BOUNDS["batt"]), False + _ib = _is = 0 + _t = max(_b[0][0], _s[0][0]) + _end = min(_b[-1][0], _s[-1][0]) + while _t <= _end: + while _ib + 1 < len(_b) and _b[_ib + 1][0] <= _t: + _ib += 1 + while _is + 1 < len(_s) and _s[_is + 1][0] <= _t: + _is += 1 + _f8b.submit(_b[_ib][1], _s[_is][1], _t) + if _f8b.stale and not _prev8b: + trips += 1 + _nxt = next((m for m in _moves if m > _t), None) + if _nxt is not None: + waits.append(_nxt - _t) + _prev8b = _f8b.stale + _t += 1.0 + return trips, sorted(waits) + + _t8b = time.monotonic() -_waits, _trips8b = [], 0 -for _day in _loop_days: - _b, _s = _loop_series(_day, "batt"), _loop_series(_day, "setpoint") - if not _b or not _s: - continue - _moves = [r[0] for i, r in enumerate(_b) if i == 0 or r[1] != _b[i - 1][1]] - _f8b, _prev8b = Freshness(BOUNDS["batt"]), False - _ib = _is = 0 - _t = max(_b[0][0], _s[0][0]) - _end = min(_b[-1][0], _s[-1][0]) - while _t <= _end: - while _ib + 1 < len(_b) and _b[_ib + 1][0] <= _t: - _ib += 1 - while _is + 1 < len(_s) and _s[_is + 1][0] <= _t: - _is += 1 - _f8b.submit(_b[_ib][1], _s[_is][1], _t) - if _f8b.stale and not _prev8b: - _trips8b += 1 - _nxt = next((m for m in _moves if m > _t), None) - if _nxt is not None: - _waits.append(_nxt - _t) - _prev8b = _f8b.stale - _t += 1.0 -_waits.sort() +_trips8b, _waits = _replay16(_Before94) +_trips94, _waits94 = _replay16(Freshness) _p50 = nth(_waits, len(_waits) // 2) print(f" {len(_loop_days)} days replayed at 1 Hz in " - f"{time.monotonic() - _t8b:.1f}s: {_trips8b} trips") + f"{time.monotonic() - _t8b:.1f}s (twice): {_trips8b} trips before " + f"SAFETY-94, {_trips94} after") print(f" wait for the register's next change after a trip: " f"min {nth(_waits, 0):.1f} s median {_p50:.1f} s max {nth(_waits, -1):.1f} s; " f"{sum(1 for w in _waits if w >= 710.0)} at or over the live episode's " @@ -953,6 +970,17 @@ check("⚠️ AND THAT DISTRIBUTION IS A PROPERTY OF THIS BMS, NOT OF THIS CODE "is `stale_input_s + release_s` = 32 s whatever the register does", sum(1 for w in _waits if w < 20.0) >= 20 and float(option_default("stale_input_s")) + BOUNDS["batt"].release_s == 32.0) +print(f" SAFETY-94: trips answered within 20 s: {sum(1 for w in _waits if w < 20.0)}" + f" before, {sum(1 for w in _waits94 if w < 20.0)} after; quickest " + f"answer after a trip {nth(_waits94, 0):.1f} s") +check("⚠️ SAFETY-94 (SAFETY-58): MOST OF THOSE SHORT WAITS WERE FALSE TRIPS, " + "NOT KEEP-ALIVE PULSES. The clock now counts only the time the command " + "asked the register to move. The same sixteen days give 59 trips, not " + "93, and only one of the 59 is answered within 20 s (21 before): the " + "gate no longer judges a register on the first read after a rest", + _trips94 == 59 and len(_waits94) == _trips94 + and sum(1 for w in _waits94 if w < 20.0) == 1 + and sum(1 for w in _waits if w < 20.0) == 21) # ============================================================================== @@ -1017,32 +1045,30 @@ check("NOT A SURVIVOR, recorded because it looks like one: a publisher failure " "each unit's own command travel and each unit's own number", _s4a.stale and _s4b.stale and _s4a.held != _s4b.held) -# S5. SAFETY-57's own, and it is a FALSE POSITIVE that predates this card. -# The unchanged clock keeps running while the site rests, so the FIRST read -# at which a command travels 250 W after a long quiet already satisfies all -# three terms - the register is given no time at all to answer the new -# command. Present on the shipped tree; the live capture in part 10 contains -# one (10:48:14, cleared one second later). +# S5. SAFETY-57's own FALSE POSITIVE, CLOSED BY SAFETY-94 (it was SAFETY-58). +# The unchanged clock used to keep running while the site rested, so the +# FIRST read at which a command travelled 250 W after a long quiet tripped +# the gate. The clock now counts only time the register was asked to move. +# Live 2026-10-03: four such trips, each 1 s after the command. _s5 = Freshness(BOUNDS["batt"]) for t in range(0, 300): # five minutes of a resting site _s5.submit(-3.0, 0.0, float(t)) _s5_quiet = _s5.stale _s5.submit(-3.0, 1500.0, 300.0) # the loop asks for 1500 W -check("⚠️ SURVIVOR 5, FALSE POSITIVE, AND IT IS OLDER THAN SAFETY-57: after a " - "long rest the unchanged clock is already past 35 s, so the very FIRST " - "read carrying a travelled command trips the gate - before the 17.0 s in " - "which a healthy register could have answered it. The gate accuses on " - "read one and the register answers on read eleven", - not _s5_quiet and _s5.stale and _s5.graded_s(300.0) == 300.0) -check("...and its SIZE is one `stale_input_s` hold, which is why it is recorded " - "here rather than fixed here: `cycle()` returns at `inputs missing " - "{n}s` for the whole of it, so nothing is commanded differently, and the " - "register's next publish clears it. Fixing it means measuring the " - "unchanged clock from when the OBLIGATION appeared rather than from the " - "register's last move, which re-derives the 35.0 s bound and is a " - "different card", - (lambda f: (f.submit(-4.0, 1500.0, 311.0), not f.stale)[-1])(_s5) - and float(option_default("stale_input_s")) >= 5.0) +_s5_first = _s5.stale +for t in range(301, 334): + _s5.submit(-3.0, 1500.0, float(t)) +_s5_before = _s5.stale +_s5.submit(-3.0, 1500.0, 334.0) +check("⚠️ SURVIVOR 5 IS CLOSED (SAFETY-94): after five minutes of rest the " + "first read carrying a travelled command is NOT a trip, nor is any read " + "before 35 s of standing command - and a register that still has not " + "moved after 35 s of it IS", + not _s5_quiet and not _s5_first and not _s5_before and _s5.stale + and _s5.graded_s(334.0) == 334.0 and _s5.obliged_s() == 35.0) +check("...and the evidence sentence still gives the register's own unchanged " + "time (334 s), which an operator compares with a history graph", + "unchanged for 334 s" in _s5.reason) # ============================================================================== @@ -1353,20 +1379,23 @@ print(f" after : held number reached a consumer on {sum(_exp57)} reads " check("⚠️ THE EXIT IS NOT FREE AND THE PRICE IS STATED AS A NUMBER. A register " "that is dead for good is re-graded for ever: over 1201 reads the held " - "number reaches a consumer on 630 of them against 35 before, and 17 " + "number reaches a consumer on 631 of them against 36 before, and 17 " "releases are taken. This check exists so that cost cannot be quietly " "forgotten, and so a change that makes it worse has to move a number " "somebody reads", - sum(_expb) == 35 and sum(_exp57) == 630 and _dead.releases == 17 + sum(_expb) == 36 and sum(_exp57) == 631 and _dead.releases == 17 and _deadb.releases == 0) check("⚠️ AND IT IS PAYABLE ONLY BECAUSE ONE RUN IS SHORTER THAN EITHER " - "CONSUMER'S HOLD. Every unbroken run of exposure is exactly one 35.0 s " - "grading window, and 35 < 75 (circulation_hold_s) < 85 " + "CONSUMER'S HOLD. Every unbroken run of exposure after a release is " + "exactly one 35.0 s window of standing command (the first is 36 s: the " + "fixture's command starts one read late), and 36 < 75 " + "(circulation_hold_s) < 85 " "(unit_sign_disagree_s) - so NEITHER detector can reach a verdict inside " "one, and the re-trip that ends each run hands them a None, which CLEARS " "their windows rather than accumulating into them", - set(_runs57) == {BOUNDS["batt"].unchanged_s} - and BOUNDS["batt"].unchanged_s < _circ_hold < _unit_hold) + set(_runs57[1:]) == {BOUNDS["batt"].unchanged_s} + and nth(_runs57, 0) == BOUNDS["batt"].unchanged_s + 1.0 + and max(_runs57) < _circ_hold < _unit_hold) check("...and nothing SAFETY-37 detects is given back by that, which is the " "half that makes the trade a trade. A permanent None clears both windows " "for ever too, so on the shipped-before module the same two detectors are " @@ -1643,8 +1672,10 @@ check("...and a re-trip's reason names the TWO durations separately, each " [f.submit(A_FROZEN_W, 0.0, float(t)) for t in range(40, 80)], [f.submit(A_FROZEN_W, 1300.0, float(t)) for t in range(80, 130)], f.stale and f.releases == 1 - and "unchanged for 92 s" in f.reason - and "checked 2 times over a 35 s window since the last stop was " + # SAFETY-94: the re-trip comes 35 s after the command returns at + # 80 s (114 s), not 35 s after the release at 57 s. + and "unchanged for 114 s" in f.reason + and "checked 2 times over a 57 s window since the last stop was " "withdrawn" in f.reason)[-1])( Freshness(BOUNDS["batt"]))) diff --git a/goodwe_controller/test_modes.py b/goodwe_controller/test_modes.py index ab9b229..40eb5b6 100644 --- a/goodwe_controller/test_modes.py +++ b/goodwe_controller/test_modes.py @@ -153,10 +153,16 @@ check("⭐ at N=1 and N=2, with the option absent and with it false, every " "is the arbiter on 6000 claim lists", {k: v for k, v in _now.items() if k not in _S91} == {k: v for k, v in A5DDDAD.items() if k not in _S91}) +# SAFETY-94: with SAFETY-87's held rule, A (cut out at 1600 s) now trips the +# stale gate at 1640 s, not 1635 s - 35 s after its command moved rather than +# after its reading last changed - so that run is no longer a5dddad's. It was +# a5dddad's (a60003b1...) before SAFETY-94. The shipped n2 is unchanged. +SAFETY94_N2_S87 = \ + "4ddea7ef9dc1efa437af899675db442cd531b084ae4cec35951d7c7bb3d6d8b5" check("...n2 and n2_off are the SAFETY-91 digest (A at its reserve gives " - "last), and SAFETY-87's held rule gives a5dddad's back", + "last), and SAFETY-87's held rule gives SAFETY-94's", _now["n2"] == _now["n2_off"] == SAFETY91_N2 != A5DDDAD["n2"] - and _n2_s87 == A5DDDAD["n2"]) + and _n2_s87 == SAFETY94_N2_S87) check("...and n2_maint is the SAFETY-78 digest: the N=2 drain that a5dddad " "refused on 392 of 540 cycles now runs (test_safety78.py), bounded by " "SAFETY-85", diff --git a/goodwe_controller/test_safety94.py b/goodwe_controller/test_safety94.py new file mode 100644 index 0000000..40baf86 --- /dev/null +++ b/goodwe_controller/test_safety94.py @@ -0,0 +1,341 @@ +"""Runnable check for SAFETY-94: a healthy battery that lags or reverses is +not flagged as frozen or refusing. `python3 test_safety94.py` + +Two false alarms, both pre-existing: +- The stale-reading gate (SAFETY-37) counted its 35 s from the register's last + change. After a rest that clock was already past 35 s, so the first read + after a new command tripped it, before the register could answer (SAFETY-58; + live 2026-10-03, four trips, each 1 s after a command). +- At N=1 the refusal (SAFETY-52) engaged after 12 frozen cycles if the reading + was under 100 W at that moment. At a 1 s meter, a battery reversing out of a + charge through its lag is passing 0 W just then (SAFETY-85 rig, O1). It was + held at 0 W until the next probe, up to 15 min. + +Now the gate counts only the time the command asked the register to move, and +a refusal needs the reading to have stayed under 100 W, not moving towards the +ask, for 12 cycles spanning 22 s, at every roster size. +DECISIONS/SAFETY-94-lagging-battery.md. + +1. The four live trips, replayed through the gate. +2. The REAL Controller on a lagging battery (tools/safety94_rig.py), N=1 and + N=2: rest then a step, maintenance drain start, drain -> charge, a charge + reversing into a discharge. No stale trip and no refusal. +3. A frozen register and a refusing battery are still caught, and how fast. +4. The rules at their edges. + +Mutations (MUTATION.md, purged copies) are listed in the pull request. +""" + +import logging +import os +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, HERE) +sys.path.insert(0, os.path.join(HERE, "tools")) +logging.disable(logging.CRITICAL) + +import safety94_rig as rig # noqa: E402 +import app.inverter as app_inverter # noqa: E402 +import app.main as app_main # noqa: E402 +from app import control # noqa: E402 +from app.freshness import BOUNDS, Freshness # noqa: E402 + +fails = [] + + +def check(name, cond): + print(f" {'ok ' if cond else 'FAIL'} {name}") + if not cond: + fails.append(name) + + +class Before94(Freshness): + """The gate before SAFETY-94: the clock ran from the last change.""" + + def obliged_s(self): + return self.graded_s(self.last_read) + + +def without_fix(fn): + """Run `fn` with the gate's old clock and the old refusal evidence.""" + real_f, real_c = app_inverter.Freshness, app_main.compute + real_p = app_main.REFUSED_PROGRESS_W + app_inverter.Freshness = Before94 + app_main.compute = lambda *a, **k: real_c(*a, **dict(k, gave_nothing=None)) + app_main.REFUSED_PROGRESS_W = float("inf") + try: + return fn() + finally: + app_inverter.Freshness, app_main.compute = real_f, real_c + app_main.REFUSED_PROGRESS_W = real_p + + +# ============================================================================ # +print("1. the four live trips of 2026-10-03, replayed") +# HA history (only changes are recorded), local time: the unit's setpoint +# (what stands on its wire) and its battery power register (0x0518). +LIVE = { + "17:39:16 inverter-b": ( + [("17:38:00", -24), ("17:38:14.7", 0), ("17:38:25.4", 5), + ("17:38:40.5", 16), ("17:38:44.7", 26), ("17:38:55.7", 37), + ("17:39:05.1", 42), ("17:39:09.8", 58), ("17:39:15.6", 268), + ("17:39:25.0", 479), ("17:39:30.7", 689), ("17:39:34.8", 900)], + [("17:38:00", -118), ("17:38:21.6", -99), ("17:39:21.6", 179), + ("17:39:31.6", 399)]), + "17:39:25 inverter-a": ( + [("17:38:00", -16), ("17:38:14.9", 0), ("17:38:25.0", 5), + ("17:38:40.0", 14), ("17:38:44.7", 24), ("17:38:55.2", 33), + ("17:39:04.7", 38), ("17:39:09.3", 52), ("17:39:15.0", 242), + ("17:39:24.6", 431), ("17:39:30.3", 621), ("17:39:34.8", 810)], + [("17:38:00", -3), ("17:38:25.9", 0), ("17:39:25.9", 318), + ("17:39:36.0", 654)]), + "19:00:04 inverter-a": ( + [("18:58:30.0", 290), ("18:58:43.3", 305), ("18:58:48.0", 314), + ("18:58:52.5", 300), ("19:00:03.1", 2500)], + [("18:58:30.0", 292), ("19:00:06.0", 951), ("19:00:16.0", 2360), + ("19:00:26.0", 2376)]), + "21:21:22 inverter-b": ( + [("21:19:30.0", 0), ("21:21:21.9", 2100), ("21:21:26.6", 1700), + ("21:21:31.1", 1300), ("21:21:36.9", 900), ("21:21:41.0", 500), + ("21:21:47.3", 200)], + [("21:19:30.0", -98), ("21:21:22.8", -97), ("21:21:32.8", 1411), + ("21:21:42.8", 798)]), +} + + +def _sec(hms): + h, m, s = hms.split(":") + return int(h) * 3600 + int(m) * 60 + float(s) + + +def replay(cls, setp, reg): + """1 Hz reads, as `run_control()` does. Returns the trip times.""" + f, trips = cls(BOUNDS["batt"]), [] + t, end = _sec(setp[0][0]), _sec(reg[-1][0]) + + def at(series, t): + return [float(v) for ts, v in series if _sec(ts) <= t][-1] + while t <= end: + was = f.stale + f.submit(at(reg, t), at(setp, t), t) + if f.stale and not was: + trips.append(t) + t += 1.0 + return trips + + +for name, (setp, reg) in LIVE.items(): + old, new = replay(Before94, setp, reg), replay(Freshness, setp, reg) + print(f" {name}: before {[f'{int(t // 3600):02d}:{int(t % 3600 // 60):02d}:{t % 60:04.1f}' for t in old]}" + f", now {len(new)} trips") + check(f"{name}: the shipped trip reproduces 1-2 s after the command, and " + f"now there is none (the register answered within 11 s)", + len(old) == 1 and abs(old[0] - _sec(name.split()[0])) < 1.0 + and new == []) + +# ============================================================================ # +print("\n2. the REAL Controller on a lagging battery (10 s lag)") +# Two register shapes: published every 10 s and repeating its value (the +# boards, live), and every 1 s with +-1 W of dither (the SAFETY-85 rig). +SHAPES = ((10.0, False), (1.0, True)) +NAMES = ("rest_step", "drain_start", "drain_charge", "reversal") + + +def sweep(fn=lambda f: f()): + out = {} + for n in (1, 2): + for name in NAMES: + for tick in (1.0, 2.0, 5.0): + for pub, dith in SHAPES: + s, r, _ = fn(lambda: rig.counts(n, name, tick=tick, + publish_s=pub, + dither=dith)) + out[(n, name, tick, pub)] = (s, r) + return out + + +NOW = sweep() +OLD = sweep(without_fix) +for n in (1, 2): + for name in NAMES: + now = [NOW[k] for k in NOW if k[:2] == (n, name)] + old = [OLD[k] for k in OLD if k[:2] == (n, name)] + print(f" N={n} {name:12s} stale/refused now " + f"{sum(a for a, _ in now)}/{sum(b for _, b in now)}, before " + f"{sum(a for a, _ in old)}/{sum(b for _, b in old)}") +check("⭐ no stale trip and no refusal, at N=1 and N=2, at 1, 2 and 5 s meters, " + "on both register shapes: a step after a rest, a drain start, drain -> " + "charge and a charge reversing into a discharge", + all(v == (0, 0) for v in NOW.values()) and len(NOW) == 48) +check("non-vacuous: before, the same runs trip the gate (drain start and " + "drain -> charge at both N, as live) and refuse at N=1 on a 1 s meter", + sum(OLD[(n, nm, t, 10.0)][0] for n in (1, 2) + for nm in ("drain_start", "drain_charge") for t in (1.0, 2.0, 5.0)) + >= 12 and OLD[(1, "drain_charge", 1.0, 1.0)][1] >= 1) + + +def reversals(n, tick, taus=(10.0, 20.0)): + """A charge reversing into a discharge: (stale trips, refusals) summed.""" + s = r = 0 + for tau in taus: + for charge in (-1500.0, -2500.0): + for house in (300.0, 800.0, 2000.0): + for pub, dith in SHAPES: + x = rig.run(n, rig.reversal(300.0, charge, house), 600.0, + tau=tau, tick=tick, publish_s=pub, + dither=dith) + s, r = s + len(x["stale"]), r + len(x["refused"]) + return s, r + + +REV = {(n, t): reversals(n, t) for n in (1, 2) for t in (1.0, 2.0)} +REV_OLD = {(n, 1.0): without_fix(lambda n=n: reversals(n, 1.0)) for n in (1, 2)} +print(f" reversals (lag 10 and 20 s): now {REV}, before {REV_OLD}") +check("⭐ SAFETY-94's case: a battery reversing out of a charge (1500 or 2500 " + "W) into a 300-2000 W house, lag 10 or 20 s, 1 or 2 s meter, is never " + "refused or flagged, at N=1 or N=2", + all(v == (0, 0) for v in REV.values())) +check("non-vacuous: before, the 1 s meter refuses at N=1 (lag 20 s) and at " + "N=2 (B, lag 20 s, its reading 100 W below its power)", + REV_OLD[(1, 1.0)][1] >= 1 and REV_OLD[(2, 1.0)][1] >= 1) + +# ============================================================================ # +print("\n3. a frozen register and a refusing battery are still caught") + + +def obliged_at(rows, t_f): + """First cycle from `t_f` where A's command is 250 W or more and has moved + 250 W from where it stood at `t_f`.""" + c0 = None + for row in rows: + if row[0] >= t_f and c0 is None: + c0 = row[2]["a"] + if c0 is not None and abs(row[2]["a"]) >= 250 \ + and abs(row[2]["a"] - c0) >= 250: + return row[0] + return None + + +CATCH = {} +for n in (1, 2): + for tick in (1.0, 5.0): + # frozen while giving 1000 W, then the house steps + r1 = rig.run(n, lambda t: 3000.0 if t >= 400 else 1000.0, 520.0, + tick=tick, publish_s=1.0, dither=True, + freeze_at=lambda u: 300.0 if u == "a" else None) + ob = obliged_at(r1["rows"], 300.0) + st = [t for t, u in r1["stale"] if u == "inverter-a"] + # frozen at rest (HW-01's shape), the house steps 500 s later + r2 = rig.run(n, lambda t: 2500.0 if t >= 600 else 0.0, 700.0, + tick=tick, publish_s=1.0, dither=True, + freeze_at=lambda u: 100.0 if u == "a" else None) + ob2 = obliged_at(r2["rows"], 100.0) + park = min([t for t, u in r2["stale"] if u == "inverter-a"] + + [t for t, _u in r2["refused"]] or [None]) + # a refusing battery from 300 s + r3 = rig.run(n, lambda t: 3000.0, 420.0, tick=tick, publish_s=10.0, + cut=lambda u, t: u == "a" and t >= 300.0) + ref = [t for t, _u in r3["refused"]] + CATCH[(n, tick)] = (st[0] - ob if st and ob is not None else None, + park - ob2 if park is not None and ob2 is not None + else None, ref[0] - 300.0 if ref else None) +print(" (N, meter): (frozen giving: trip after the command moved, frozen " + "at rest: 0 W after, refusing: refused after the cut), in s") +print(f" {CATCH}") +check("⭐ a register frozen while giving is caught 35 s after the command asks " + "it to move (the SAFETY-37 bound, now counted from the ask)", + all(v[0] == BOUNDS["batt"].unchanged_s for v in CATCH.values())) +check("⭐ a register frozen at rest reads like a refusing battery: the " + "battery is at 0 W, refused or stale, within 36 s of the ask", + all(v[1] is not None and v[1] <= 36.0 for v in CATCH.values())) +check("⭐ a refusing battery is still refused: within 22 s plus one 10 s " + "publish at a 1 s meter, and within 12 cycles plus a publish at 5 s", + all(CATCH[(n, 1.0)][2] is not None and CATCH[(n, 1.0)][2] <= 32.0 + for n in (1, 2)) + and all(CATCH[(n, 5.0)][2] is not None + and CATCH[(n, 5.0)][2] <= 12 * 5.0 + 10.0 for n in (1, 2))) + +# ============================================================================ # +print("\n4. the rules at their edges") +# The gate counts time the register was asked to move, across a flapping +# command too. +f = Freshness(BOUNDS["batt"]) +trip = None +for t in range(0, 200): + f.submit(-3.0, 1500.0 if (t // 5) % 2 else 0.0, float(t)) + if f.stale and trip is None: + trip = t +print(f" command flapping 0 / 1500 W every 5 s, register frozen: trip at " + f"{trip} s") +check("a frozen register under a command that flaps in and out of the ask is " + "still caught (after 35 s of asking, about 70 s here)", + trip is not None and 65 <= trip <= 75) +f = Freshness(BOUNDS["batt"]) +for t in range(0, 30): + f.submit(-3.0, 1500.0, float(t)) +f.submit(400.0, 1500.0, 30.0) # it answers +for t in range(31, 60): + f.submit(400.0, 1500.0, float(t)) +check("a register that answers starts a fresh count: 30 s asked, then it " + "moves, then 29 s more is not stale", + not f.stale and f.obliged_s() < 35.0) +f = Freshness(BOUNDS["batt"]) +for t in range(0, 600): + f.submit(-3.0, 200.0 if t % 60 < 3 else 0.0, float(t)) +check("a command that never asks 250 W is never an ask (SAFETY-52's 200 W " + "probe cannot arm the gate)", + not f.stale and f.obliged_s() == 0.0) + +# compute(): `gave_nothing` gates the N=1 engage; None keeps the old law. +T = control.Tuning(max_w=4000.0, slew_w=400.0) +args = dict(prev_w=1200.0, grid_w=1200.0, actual_w=-20.0, tuning=T, + sat_count=control.SETTLE_CYCLES, i_w=1200.0, refused=0) +check("compute(): a frozen loop at SETTLE_CYCLES reading -20 W engages with " + "gave_nothing None (compute alone, as before) or True, and not with " + "False", + control.compute(**args).refused == 1 + and control.compute(**args, gave_nothing=True).refused == 1 + and control.compute(**args, gave_nothing=False).refused == 0) + +# The evidence restarts on progress towards the ask, not on jitter. +print(f" REFUSED_PROGRESS_W {app_main.REFUSED_PROGRESS_W:.0f} W, span " + f"{app_main.REFUSED_SPAN_S:.0f} s") + + +class _Inv: + def __init__(self): + self.batt, self.ref_count, self.ref_since, self.ref_sign = 0.0, 0, None, 0 + self.ref_from = None + + def standing_w(self): + return 800.0 + + +class _C: + _now_mono = 0.0 + + +def evidence(readings, tick=1.0): + c, inv = _C(), _Inv() + got = [] + for i, b in enumerate(readings): + c._now_mono, inv.batt = i * tick, b + got.append(app_main.Controller._gave_nothing(c, inv)) + return got + + +flat = evidence([-4.0 + (i % 3) for i in range(30)]) +climb = evidence([-95.0 + 4.0 * i for i in range(48)]) +check("a flat reading (+-1 W) under an 800 W ask is evidence after 22 s; one " + "climbing 4 W/s through the band is never", + 1 in flat and flat.index(1) == 22 and not any(climb)) +check("REFUSED_PROGRESS_W is under the band's width and over the live " + "readings' rest wander (25 W)", + 25.0 < app_main.REFUSED_PROGRESS_W < 2 * control.REFUSED_BELOW_W) + +print() +if fails: + print(f"{len(fails)} FAILED: " + ", ".join(fails)) + sys.exit(1) +print("all checks passed") diff --git a/goodwe_controller/tools/safety94_rig.py b/goodwe_controller/tools/safety94_rig.py new file mode 100644 index 0000000..7052591 --- /dev/null +++ b/goodwe_controller/tools/safety94_rig.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +"""SAFETY-94: the REAL Controller on a lagging battery whose reading is late. + + python3 tools/safety94_rig.py # prints every scenario's counts + +Each battery ramps at 800 W/s, then follows a first-order lag (`tau`, 10 s +as on the SAFETY-85 rig). Its reading (register 0x0518) is published every +10 s, at a different phase per unit, and repeats its value between publishes, +as the live boards do. B reads 100 W below what it gives (its standby draw: +-100 W at idle, live 2026-10-03). The meter updates every `tick` s; the add-on +reads every 1 s and cycles on a new meter value, as `run_control()` does. + +A frozen register stops publishing at `freeze_at`; a refusing battery (`cut`) +gives 0 W and reads -4 W. Uses only the Controller API release/1.0 02ed120 +has, so the same file runs on the tree before SAFETY-94. +""" + +import asyncio +import math +import os +import sys +from datetime import datetime, timedelta, timezone + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import app.main as m # noqa: E402 +from app.main import Controller # noqa: E402 + +BASE = datetime(2026, 10, 3, 10, 50, tzinfo=timezone.utc) # drain from 600 s +PUBLISH_S = 10.0 +PHASE = {"a": 6.0, "b": 1.6} # live: A publishes at :x6, B at :x1.6 +OFFSET = {"a": 0.0, "b": -100.0} # B's standby draw, live 2026-10-03 +CUT_W = -4.0 +LIVE = {"gain": 0.6, "slew_w": 400, "target_grid_w": -10, "deadband_w": 15, + "step_w": 10, "heartbeat_s": 10, "stale_input_s": 15, + "auto_start": True, "meter_entity": "sensor.grid", + "meter_source": "off"} +MAINT = {"maintenance_enabled": True, "maintenance_start_hour": 11, + "maintenance_discharge_w": 2500, "maintenance_hold_min": 30} + + +class _Store: + def __init__(self): + from app.store import DEFAULTS + self.data = dict(DEFAULTS) + + def set(self, k, v): + self.data[k] = v + + def get_time(self, k): + v = self.data.get(k) + return datetime.fromisoformat(v) if v else None + + def set_time(self, k, when): + self.data[k] = when.isoformat() if when else None + + +class _Pub: + enabled = False + + def publish(self, *a, **k): + pass + + def close(self): + pass + + +class Plant: + """The batteries, their registers and the meter, in simulated seconds.""" + + def __init__(self, units, load, tau, tick, soc, cut, freeze_at, + publish_s=PUBLISH_S, dither=False): + self.units, self.load, self.tau, self.tick = units, load, tau, tick + self.publish_s, self.dither, self.n_pub = publish_s, dither, 0 + self.soc_fn, self.cut, self.freeze_at = soc, cut, freeze_at + self.cmd = {u: 0.0 for u in units} + self.ramp = {u: 0.0 for u in units} + self.real = {u: 0.0 for u in units} + self.reg = {u: round(OFFSET[u]) for u in units} + self.grid_v = float(load(0.0)) + self.t = 0.0 + + def advance(self, t): + """Integrate to `t` in 0.1 s steps; publish and sample on schedule.""" + while self.t < t - 1e-9: + self.t = round(self.t + 0.1, 6) + for u in self.units: + want = 0.0 if self.cut(u, self.t) else self.cmd[u] + d = want - self.ramp[u] + self.ramp[u] += math.copysign(min(abs(d), 80.0), d) + self.real[u] += ((self.ramp[u] - self.real[u]) + * (1 - math.exp(-0.1 / self.tau))) + frozen = (self.freeze_at is not None + and self.freeze_at(u) is not None + and self.t >= self.freeze_at(u)) + ph = (self.t - PHASE[u] % self.publish_s) / self.publish_s + if not frozen and abs(ph - round(ph)) < 1e-6: + # `dither`: the +-1 W a settled register shows (live). + self.n_pub += 1 + jit = (self.n_pub * 7 % 3 - 1) if self.dither else 0 + self.reg[u] = (CUT_W if self.cut(u, self.t) + else float(round(self.real[u] + OFFSET[u]) + + jit)) + if abs(self.t / self.tick - round(self.t / self.tick)) < 1e-6: + self.grid_v = float(round(self.load(self.t) + - sum(self.real.values()))) + + +class _Hass: + def __init__(self, plant): + self.p = plant + + async def limits(self, e): + return (-5000.0, 5000.0, 1.0) + + async def call(self, *a): + return True + + async def number(self, e, invert=False): + if e == "sensor.grid": + return self.p.grid_v + u = e[-1] + if e.startswith("sensor.soc"): + return self.p.soc_fn(u, self.p.t) + if e.startswith("sensor.batt"): + return self.p.reg[u] + return None + + async def set_number(self, e, v): + self.p.cmd[e[-1]] = float(v) + return True + + +def options(n, extra=None): + o = dict(LIVE) + if n == 1: + o.update({"setpoint_entity": "number.sp_a", + "soc_entity": "sensor.soc_a", "batt_entity": "sensor.batt_a", + "max_w": 4000, "bms_soc_floor": 10}) + else: + o["inverters"] = [ + {"name": f"inverter-{u}", "setpoint_entity": f"number.sp_{u}", + "soc_entity": f"sensor.soc_{u}", "batt_entity": f"sensor.batt_{u}", + "max_w": 2500, "bms_soc_floor": 10} for u in "ab"[:n]] + o.update(extra or {}) + return o + + +def run(n, load, dur, *, tau=10.0, tick=1.0, soc=None, cut=None, + freeze_at=None, extra=None, floor_s=15.0, publish_s=PUBLISH_S, + dither=False): + """Returns a dict: per-unit stale trips [(t, unit)], refusals + [(t, unit or 'site')], rows [(t, grid, {u: cmd}, {u: real}, {u: reg}, + phases)] and the controller.""" + units = tuple("ab"[:n]) + soc = soc or (lambda u, t: 50.0) + cut = cut or (lambda u, t: False) + plant = Plant(units, load, tau, tick, soc, cut, freeze_at, publish_s, + dither) + + async def go(): + c = Controller(options(n, extra), _Hass(plant), _Store(), _Pub(), + stop=asyncio.Event()) + for i in c.inverters: + i.dev_min, i.dev_max = -5000.0, 5000.0 + c.auto = True + real_dt = m.datetime + clock = [0.0] + + class _DT(real_dt): + @classmethod + def now(cls, tz=None): + return BASE + timedelta(seconds=clock[0]) + + m.datetime = _DT + out = {"stale": [], "refused": [], "rows": [], "c": c} + was = {i.name: False for i in c.inverters} + was_ref = {i.name: 0 for i in c.inverters} + was_site = 0 + last_grid, last_cycle = None, None + try: + t = 0.0 + while t < dur: + plant.advance(t) + clock[0] = t + await c.read_inputs(now_mono=t) + for i in c.inverters: + if i.batt_fresh.stale and not was[i.name]: + out["stale"].append((t, i.name)) + was[i.name] = i.batt_fresh.stale + cycled = (c.grid != last_grid or last_cycle is None + or t - last_cycle >= floor_s) + if cycled: + last_grid, last_cycle = c.grid, t + await c.cycle() + # A refusal can start on a read (a gate trip) or in a cycle. + for i in c.inverters: + if i.refused and not was_ref[i.name]: + out["refused"].append((t, i.name)) + was_ref[i.name] = i.refused + if c.batt_refused and not was_site: + out["refused"].append((t, "site")) + was_site = c.batt_refused + if cycled: + out["rows"].append(( + t, plant.grid_v, dict(plant.cmd), + {u: round(v) for u, v in plant.real.items()}, + dict(plant.reg), [mm.phase for mm in c.maints])) + t += 1.0 + finally: + m.datetime = real_dt + return out + return asyncio.run(go()) + + +# --- the scenarios --------------------------------------------------------- +def rest_then(step_at, w): + """A house at 0 W (the batteries rest) until `step_at`, then `w`.""" + return lambda t: w if t >= step_at else 0.0 + + +def reversal(at_s, charge_w, house_w): + """Solar surplus (the batteries charge), then a house load from `at_s`.""" + return lambda t: house_w if t >= at_s else charge_w + + +def drain_soc(floor_at): + """A drains (maintenance) and reaches its floor at `floor_at`; B at 50 %.""" + def soc(u, t): + if u != "a": + return 50.0 + return 50.0 if t < floor_at else 10.0 + return soc + + +SCENARIOS = { + # first command after a long rest (the live 17:39 shape) + "rest_step": dict(load=rest_then(600.0, 2400.0), dur=900.0), + # maintenance drain starts after a rest (the live 19:00 shape) + "drain_start": dict(load=lambda t: 300.0, dur=900.0, + extra=MAINT, soc=drain_soc(1e9)), + # A's drain ends and its charge starts (the live 21:21 shape) + "drain_charge": dict(load=lambda t: 300.0, dur=1200.0, extra=MAINT, + soc=drain_soc(900.0)), + # a charge reverses into a discharge through the lag (SAFETY-94) + "reversal": dict(load=reversal(300.0, -2500.0, 800.0), dur=600.0), +} + + +def counts(n, name, **kw): + sc = dict(SCENARIOS[name], **kw) + r = run(n, **sc) + return len(r["stale"]), len(r["refused"]), r + + +if __name__ == "__main__": + for n in (1, 2): + for name in SCENARIOS: + for tick in (1.0, 2.0, 5.0): + for pub, dith in ((10.0, False), (1.0, True)): + s, f, r = counts(n, name, tick=tick, publish_s=pub, + dither=dith) + print(f"N={n} {name:13s} tick {tick:.0f} s publish " + f"{pub:2.0f} s{' dither' if dith else ' '}: " + f"stale {s} {r['stale'][:2]}, refusals {f} " + f"{r['refused'][:2]}")