From 6918e154c812debca13ef3b81c3b5ded944c53ed Mon Sep 17 00:00:00 2001 From: SAFETY-97 dev Date: Sun, 4 Oct 2026 12:21:24 +0200 Subject: [PATCH] SAFETY-97: "gives nothing" is graded against each battery's idle reading B's power reading shows -95 to -118 W at idle (standby draw), so the |reading| < 100 W refusal band never held a refusing B: never at N=2, and since SAFETY-94's shared rule not at N=1 either (review F1). Each unit now learns its idle reading (app/idle.py): only while control runs, the unit is not refused/stale/tripped and its command has stayed within 50 W for 120 s; first value a 120 s mean, then a 30 min follow; samples beyond 250 W dropped, value clamped to 250 W; saved per unit and restored. The shared refusal evidence, compute()'s N=1 engage and end, the unit refusal ends, the stale-path refusal and SAFETY-42's "parked" test are centred on it. Until learned it is 0 W, the rule as it was. Refusing B caught at N=1 28/28/65 s and N=2 24/24/60 s (1/2/5 s meters), before never; refusing A unchanged. Healthy B with its offset, SAFETY-94's scenarios, 96 runs: 0 stale, 0 refused. Live replay: 0. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr --- .../DECISIONS/SAFETY-94-lagging-battery.md | 10 +- .../DECISIONS/SAFETY-97-idle-offset.md | 84 +++++ goodwe_controller/DOCS.md | 4 +- goodwe_controller/app/control.py | 8 +- goodwe_controller/app/idle.py | 83 +++++ goodwe_controller/app/inverter.py | 8 + goodwe_controller/app/main.py | 39 ++- goodwe_controller/test_safety94.py | 1 + goodwe_controller/test_safety97.py | 322 ++++++++++++++++++ goodwe_controller/tools/safety94_rig.py | 27 +- 10 files changed, 559 insertions(+), 27 deletions(-) create mode 100644 goodwe_controller/DECISIONS/SAFETY-97-idle-offset.md create mode 100644 goodwe_controller/app/idle.py create mode 100644 goodwe_controller/test_safety97.py diff --git a/goodwe_controller/DECISIONS/SAFETY-94-lagging-battery.md b/goodwe_controller/DECISIONS/SAFETY-94-lagging-battery.md index 1059e22..49a043d 100644 --- a/goodwe_controller/DECISIONS/SAFETY-94-lagging-battery.md +++ b/goodwe_controller/DECISIONS/SAFETY-94-lagging-battery.md @@ -63,6 +63,7 @@ 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. +- Refusing B (its -100 W idle reading): see SAFETY-97. - 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 @@ -87,11 +88,10 @@ the held number for one 35 s window of asking, so 35 < 75 < 85 still holds. ## 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 (SAFETY-97). - At N=2 this is unchanged. **At N=1 it is new:** the old one-instant check - caught such a battery in 17-105 s; the shared rule needs 12 cycles in the - band in a row and, on the review probe, never latched in 1200 s. +- ~~B's standby offset (-95 to -118 W at idle) puts a refusing B outside the + 100 W band, so a refusing B never latched (review F1: at N=1 new, at N=2 + as before).~~ **Fixed by SAFETY-97 in the same PR:** the band is centred + on each unit's learned idle reading. `SAFETY-97-idle-offset.md`. - A command that asks only part of the time (a fraction d) is caught after 35 s of asking, which is 35/d s of wall time (0/1500 W every 5 s: 69 s). SAFETY-30's per-unit check restarts when its command stops opposing the diff --git a/goodwe_controller/DECISIONS/SAFETY-97-idle-offset.md b/goodwe_controller/DECISIONS/SAFETY-97-idle-offset.md new file mode 100644 index 0000000..2502beb --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-97-idle-offset.md @@ -0,0 +1,84 @@ +# SAFETY-97 - "gives nothing" is graded against each battery's idle reading + +**Status: implemented 2026-10-04, folded into PR #171 (SAFETY-94), for +review.** Tests: `test_safety97.py` (rig: `tools/safety94_rig.py`). Closes +SAFETY-94 review finding F1. + +## The defect + +B's power reading (board B's `inverter_ac_power`, 0x0518) shows -95 to -118 W +at idle: its standby draw (the GoodWe app shows 0 W). A's shows -2 to -4 W. +Every "the battery gives nothing" test was `|reading| < 100 W`, so a refusing +B sat on the band's edge and never latched: + +- N=2: never caught, before or after SAFETY-94. +- N=1: the old one-instant check caught it in 17-105 s; SAFETY-94's shared + rule (12 cycles in the band in a row) never did (review probe, 1200 s). + +## Design + +- **Offset source: learned per unit** (`app/idle.py`, `Inverter.idle`). No + new option: a required one is a setup step every client gets wrong, and an + optional one is a second source to reconcile. An override can be added + later if a site needs one. +- **When it learns:** control runs; the unit is not refused (unit or site), + stale or sign-tripped; its command has been within 50 W (`IDLE_ASK_W`) for + 120 s (`IDLE_QUIET_S`, 6 lags of 20 s). A refusing battery is asked more + than 50 W, and while refused it does not learn, so its refusal cannot be + learned as idle. A capped battery under a big ask is not sampled either. +- **How:** the first value is the mean of 120 s of samples; then it follows + drift with a 30 min time constant. A sample beyond 250 W (`IDLE_MAX_W`) + is dropped (a register held at a giving value); the value is clamped to it. +- **Restart:** saved per unit (`unit//idle_w`, on first learn and each + 5 W move) and restored, clamped. Without it a battery refusing from the + start is never quiet, so never learned, so never caught. +- **Until learned the idle reading is 0 W**: the rule as it was. + +## Where it applies + +Every test of "gives nothing / gives / takes" now uses `reading - idle`: + +| check | change | +|---|---| +| shared refusal evidence `_gave_nothing` (N=1 and N>=2) | centred | +| N=1 engage and end in `compute()` (`idle_w`, the site sum) | centred | +| N=1 end in `_refusal_update` | centred | +| N>1 unit end (probe answered, takes the other way) | centred; uncentred, B's idle -100 W read as "takes 100 W" and ended a charge refusal at once | +| stale-path refusal (`_refusal_from_stale`, N=1 and N>1) | centred | +| SAFETY-42 "parked" (small ask, gives nothing) | centred | +| SAFETY-37 stale gate | unchanged: it grades register changes against command travel, not a level | +| SAFETY-57 release | unchanged: it reads the command only | +| SAFETY-30 per-unit sign | unchanged: opposition needs both over 250 W; B's 100 W idle cannot make or hide one alone | +| SAFETY-42 shortfall | unchanged: B reads ~100 W short at every power, which errs towards reporting (report only) | + +## Bounds + +- Band: `|reading - idle| < 100 W`, `|idle| <= 250 W`. +- A correct idle reading: a refusing B behaves as a refusing A. +- A wrong one (worst, 250 W off): a refusing battery reading outside the band + is not refused by this rule; a frozen register is still caught by the stale + gate at 35 s of asking. Learning only at a ~0 W command for 2 min keeps a + wrong value unlikely. Once learned, a 10 min transient moves it by 28 %; + the first value (a 2 min mean) is the exposed one. +- An idle reading beyond 250 W is never learned: such a unit keeps 0 W. + +## Catch times (rig, refusing from 200 s after a 400 s rest, +-4 W) + +| refusing | meter 1 s | 2 s | 5 s | +|---|---|---|---| +| A, N=1 | 28 s (before 28) | 28 (28) | 65 (65) | +| A, N=2 | 28 (28) | 28 (28) | 110 (110) | +| B, N=1 | 28 (never) | 28 (never) | 65 (never) | +| B, N=2 | 24 (never) | 24 (never) | 60 (never) | + +A at N=2 on a 5 s meter is 110 s here because the rig's grid goes flat once +A is cut, so the loop cycles on the 15 s floor; the same run on the PR before +SAFETY-97 gives 110 s. B restored after a restart, refusing from the start: +24 s; with nothing saved: never. + +## Before -> after + +- Healthy B with its offset, SAFETY-94's four scenarios, N=1 and N=2, 1/2/5 s + meters, both register shapes, idle learned or restored (96 runs): 0 stale, + 0 refused. Reversals, lag 10/20 s, idle restored: 0. +- Live replay (SAFETY-94 part 1): 0 trips (the gate is untouched). diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 3329d5b..7ea88f6 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -602,7 +602,9 @@ 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. Nor is a battery whose reading is still moving towards the request, such as one turning from charging to -giving (SAFETY-94). +giving (SAFETY-94). "Gives" is counted from what the battery's reading shows +at rest, which the add-on learns after two quiet minutes and keeps: some +inverters show about -100 W at rest (SAFETY-97). **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 diff --git a/goodwe_controller/app/control.py b/goodwe_controller/app/control.py index db9bf82..3484292 100644 --- a/goodwe_controller/app/control.py +++ b/goodwe_controller/app/control.py @@ -485,6 +485,7 @@ def compute( probe: bool = False, bound: tuple | None = None, gave_nothing: bool | None = None, + idle_w: float = 0.0, ) -> Decision: """One control cycle. A cycle is one meter update (~5 s on a HomeWizard P1). @@ -502,6 +503,9 @@ def compute( 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). + `idle_w` SAFETY-97: what `actual_w` reads while the batteries give + nothing (app/idle.py). The refusal's band and its end are + centred on it. 0 is the law as it was. """ reason = "tracking" # OPT-11: a bound that leaves out 0 W would FORCE a direction, which no @@ -828,12 +832,12 @@ def compute( # +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 - idle_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 - elif refused and refused * actual_w >= PROBE_ANSWER_W: + elif refused and refused * (actual_w - idle_w) >= PROBE_ANSWER_W: refused = 0 # it delivers: a probe answered, # or a slow reading arriving elif refused: diff --git a/goodwe_controller/app/idle.py b/goodwe_controller/app/idle.py new file mode 100644 index 0000000..a0b682c --- /dev/null +++ b/goodwe_controller/app/idle.py @@ -0,0 +1,83 @@ +"""SAFETY-97: what a battery's power reading shows while it gives nothing. + +B's reading (0x0518) shows -95 to -118 W at idle (standby draw, live +2026-10-03); A's shows -2 to -4 W. "The battery gives nothing" is graded +against this idle reading, not against 0 W, or a refusing B never latches. +DECISIONS/SAFETY-97-idle-offset.md. + +Learned per unit, never configured: +- only while control runs, the unit is not refused, stale or tripped, and + its command has been within IDLE_ASK_W for IDLE_QUIET_S (a refusing battery + is asked more than that, so it cannot teach its refusal as idle); +- a reading beyond IDLE_MAX_W is not a sample, and the value is clamped to it; +- first value: the mean of IDLE_FIRST_S of samples; then a slow follow + (IDLE_TAU_S); +- persisted per unit and restored, clamped, at start. +Until learned the idle reading is 0 W, the rule as it was. +""" + +import math + +IDLE_MAX_W = 250.0 # |idle| bound: a band centred further out hides too much +IDLE_ASK_W = 50.0 # a command this small counts as "asked ~0" +IDLE_QUIET_S = 120.0 # 6 lags of 20 s: a 2500 W step has decayed to 6 W +IDLE_FIRST_S = 120.0 # seconds of samples averaged for the first value +IDLE_TAU_S = 1800.0 # then follow drift this slowly +IDLE_SAVE_W = 5.0 # persist when the value moved this far +IDLE_GAP_S = 10.0 # a longer gap between reads counts as this + + +def _num(v): + try: + v = float(v) + except (TypeError, ValueError): + return None + return v if math.isfinite(v) else None + + +class IdleReading: + """One unit's idle reading. `w` is what "gives nothing" is centred on.""" + + def __init__(self, saved=None): + v = _num(saved) + self.value = (None if v is None + else max(-IDLE_MAX_W, min(IDLE_MAX_W, v))) + self.saved = self.value + self.quiet_since = None + self.last = None + self.acc_w = 0.0 + self.acc_s = 0.0 + + @property + def w(self) -> float: + return 0.0 if self.value is None else self.value + + def observe(self, reading, standing_w, now_mono, may_learn) -> bool: + """One read. Returns True when the value should be persisted.""" + now = float(now_mono) + dt = (0.0 if self.last is None + else max(0.0, min(IDLE_GAP_S, now - self.last))) + self.last = now + standing = _num(standing_w) + if not may_learn or standing is None or abs(standing) > IDLE_ASK_W: + self.quiet_since = None + return False + if self.quiet_since is None: + self.quiet_since = now + b = _num(reading) + if (now - self.quiet_since < IDLE_QUIET_S or b is None + or abs(b) > IDLE_MAX_W or dt <= 0.0): + return False + if self.value is None: + self.acc_w += b * dt + self.acc_s += dt + if self.acc_s < IDLE_FIRST_S: + return False + self.value = self.acc_w / self.acc_s + else: + self.value += (b - self.value) * min(1.0, dt / IDLE_TAU_S) + self.value = max(-IDLE_MAX_W, min(IDLE_MAX_W, self.value)) + if self.saved is None or abs(self.value - self.saved) >= IDLE_SAVE_W: + self.saved = self.value + return True + return False diff --git a/goodwe_controller/app/inverter.py b/goodwe_controller/app/inverter.py index b387507..1816811 100644 --- a/goodwe_controller/app/inverter.py +++ b/goodwe_controller/app/inverter.py @@ -127,6 +127,7 @@ from . import messages # SAFETY-37. The freshness policy, and the ONE place its bounds are written. # freshness.py imports nothing from this package, so the direction is one-way. from .freshness import BOUNDS, Freshness +from .idle import IdleReading # CFG-03's ONE in-code mirror of config.yaml's SAFETY-24 defaults. Imported # rather than retyped: the flat-keys path below needs `bms_soc_floor`'s # shipped default and a literal here would be the fourth defaults site that @@ -329,6 +330,13 @@ class Inverter: self.ref_since = None self.ref_sign = 0 self.ref_from = None + # SAFETY-97: what this unit's reading shows at idle; "gives nothing" + # is centred on it. Learned, persisted, restored (app/idle.py). + self.idle = IdleReading(data.get(self.key("idle_w"))) + + @property + def idle_w(self) -> float: + return self.idle.w # -- store --------------------------------------------------------------- def key(self, name: str) -> str: diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 39c176a..b2e7ebf 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -1249,7 +1249,8 @@ class Controller: f"gave {batt_w:+.0f} W for " f"{decision.sat_count} cycles") elif self.batt_refused and not now: - gave = self.batt_refused * batt_w >= _ctl.PROBE_ANSWER_W + gave = (self.batt_refused * (batt_w - self._site_idle_w()) + >= _ctl.PROBE_ANSWER_W) self._refusal_end("the probe was answered" if gave and probe else "the battery gives power" if gave else "the grid now asks the other direction") @@ -1267,7 +1268,8 @@ class Controller: if ((self.auto or self.shadow) and not inv.refused and inv.ref_count >= t.saturation_cycles and inv.batt_fresh.held is not None - and abs(inv.batt_fresh.held) < _ctl.REFUSED_BELOW_W + and abs(inv.batt_fresh.held - inv.idle_w) + < _ctl.REFUSED_BELOW_W and abs(standing) > _ctl.PROBE_W): self._now_mono = now_mono self._unit_refusal_start( @@ -1279,7 +1281,8 @@ class Controller: and not self.batt_refused and self.sat_count >= t.saturation_cycles and inv.batt_fresh.held is not None - and abs(inv.batt_fresh.held) < _ctl.REFUSED_BELOW_W + and abs(inv.batt_fresh.held - inv.idle_w) + < _ctl.REFUSED_BELOW_W and abs(standing) > t.saturation_w): self._now_mono = now_mono self._refusal_start(1 if standing > 0 else -1, @@ -1352,7 +1355,9 @@ class Controller: for inv in self.inverters: b = inv.batt if inv.refused: - if b is not None and inv.refused * b >= _ctl.PROBE_ANSWER_W: + # SAFETY-97: given and taken are counted from the idle reading. + if (b is not None and inv.refused * (b - inv.idle_w) + >= _ctl.PROBE_ANSWER_W): self._unit_refusal_end( inv, "the probe was answered" if inv.probe_from is not None @@ -1363,7 +1368,8 @@ class Controller: # on its own reading, so one cycle of the loop overshooting # through zero does not hand it its share back. inv.ref_count = (inv.ref_count + 1 if b is not None and - -inv.refused * b >= _ctl.PROBE_ANSWER_W else 0) + -inv.refused * (b - inv.idle_w) + >= _ctl.PROBE_ANSWER_W else 0) if inv.ref_count >= _ctl.SETTLE_CYCLES: self._unit_refusal_end( inv, "the battery takes power the other way") @@ -1379,7 +1385,8 @@ class Controller: """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().""" + SAFETY-94 adds it to the N=1 engage in compute(). SAFETY-97: the band + is centred on the unit's idle reading.""" b = inv.batt if b is None: return 0 # a gap keeps the count: the gate's trip seeds @@ -1388,7 +1395,8 @@ class Controller: # 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 (abs(standing) > _ctl.PROBE_W + and abs(b - inv.idle_w) < _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 @@ -1405,6 +1413,10 @@ class Controller: return sign return 0 + def _site_idle_w(self) -> float: + """SAFETY-97: the site reading at idle, the sum of the units'.""" + return float(sum(inv.idle_w for inv in self.inverters)) + def _site_refused(self): """Owner decision 4: every unit refuses the same way, so the site law holds 0 W (SAFETY-52's, in compute()). Otherwise None, the old law.""" @@ -3143,6 +3155,14 @@ class Controller: # `stale_input_s` and then commands 0 W and keeps commanding, which is # §11.1 rule 1 and is already what a missing reading does here. inv.batt = inv.batt_fresh.reading() + # SAFETY-97: learn this unit's idle reading, only while control runs + # and nothing says the unit is refusing, stale or faulty. + if inv.idle.observe( + inv.batt, inv.standing_w(), now_mono, + bool(self.auto) and inv.batt is not None + and not inv.batt_fresh.stale and not inv.refused + and not self.batt_refused and not inv.sign_wd.tripped): + inv.store_set("idle_w", round(inv.idle.value, 1)) if inv.batt_fresh.stale and not was_stale: self.log_event(f"STALE {inv.name}: " + inv.batt_fresh.reason, hint=messages.hint("battery_stuck", unit=inv.name)) @@ -3904,7 +3924,8 @@ class Controller: parked=inv.sign_wd.tripped or bool(inv.refused) or ( n_units > 1 and inv.batt is not None and abs(float(standing[inv.name])) <= _ctl.PROBE_W - and abs(float(inv.batt)) < _ctl.REFUSED_BELOW_W), + and abs(float(inv.batt) - inv.idle_w) + < _ctl.REFUSED_BELOW_W), # ⚠️ THIS UNIT'S OWN CUT-OUT. Not the shipped 10 %, and not # the site's `peak_soc_floor` either - that one bounds a # money-saving discharge only, and grading a pack against it @@ -4688,6 +4709,8 @@ class Controller: # 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), + # SAFETY-97: "gives nothing" is centred on the idle reading. + idle_w=self._site_idle_w(), # 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_safety94.py b/goodwe_controller/test_safety94.py index b7a2e9e..c7dfc51 100644 --- a/goodwe_controller/test_safety94.py +++ b/goodwe_controller/test_safety94.py @@ -319,6 +319,7 @@ 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 + self.idle_w = 0.0 # SAFETY-97: A, ~0 W at idle self.ask = 800.0 def standing_w(self): diff --git a/goodwe_controller/test_safety97.py b/goodwe_controller/test_safety97.py new file mode 100644 index 0000000..630a459 --- /dev/null +++ b/goodwe_controller/test_safety97.py @@ -0,0 +1,322 @@ +"""Runnable check for SAFETY-97: "the battery gives nothing" is graded against +each battery's own idle reading. `python3 test_safety97.py` + +B's power reading shows -95 to -118 W at idle (standby draw; live +2026-10-03). The refusal evidence (SAFETY-52/73, shared since SAFETY-94) +needed the reading under 100 W in magnitude, so a refusing B never latched, +at N=2 ever, and at N=1 since SAFETY-94 (review F1). + +Now each unit learns its idle reading (app/idle.py) and the band, the +refusal's end and the stale-path refusal are centred on it. +DECISIONS/SAFETY-97-idle-offset.md. + +1. A refusing B is caught at N=1 and N=2, 1/2/5 s meters; A unchanged. +2. A healthy B with its offset is never refused or flagged (SAFETY-94's + scenarios), learned or restored. +3. Learning: not while refusing or asked, bounded, restored, follows drift. +4. The rules at their edges. + +Mutations (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.idle as idle # noqa: E402 +import app.main as app_main # noqa: E402 +from app import control # noqa: E402 +from app.store import unit_key # noqa: E402 + +fails = [] + + +def check(name, cond): + print(f" {'ok ' if cond else 'FAIL'} {name}") + if not cond: + fails.append(name) + + +def without_fix(fn): + """Run `fn` with every idle reading at 0 W (the rule before SAFETY-97).""" + real = idle.IdleReading.w + idle.IdleReading.w = property(lambda self: 0.0) + try: + return fn() + finally: + idle.IdleReading.w = real + + +def with_offset(n, fn): + """At N=1 the rig's only unit is "a": give it B's offset.""" + saved = dict(rig.OFFSET) + if n == 1: + rig.OFFSET["a"] = rig.OFFSET["b"] + try: + return fn() + finally: + rig.OFFSET.clear() + rig.OFFSET.update(saved) + + +REST, CUT = 400.0, 600.0 # rest (it learns), then ask, then cut +REST_LOAD = (lambda t: 0.0 if t < REST else 3000.0) + + +def caught(n, unit, tick, *, offset=True, dur=None, store=None, load=None, + cut_at=CUT, **kw): + """Seconds from the cut to the refusal, or None, and the run.""" + cu = "a" if n == 1 else unit + + def go(): + return rig.run(n, load or REST_LOAD, dur or cut_at + 300.0, + tick=tick, noise=4.0, store=store, + cut=lambda u, t: u == cu and t >= cut_at, **kw) + r = with_offset(n, go) if offset and unit == "b" else go() + ref = [t - cut_at for t, _u in r["refused"]] + return (ref[0] if ref else None), r + + +# ============================================================================ # +print("1. a refusing battery, B with its -100 W idle reading (+-4 W noise)") +TIMES, OLD = {}, {} +for unit in ("a", "b"): + for n in (1, 2): + for tick in (1.0, 2.0, 5.0): + TIMES[(unit, n, tick)] = caught(n, unit, tick)[0] + OLD[(unit, n, tick)] = without_fix( + lambda: caught(n, unit, tick)[0]) +for k in TIMES: + print(f" refusing {k[0].upper()} N={k[1]} meter {k[2]:.0f} s: refused " + f"{TIMES[k]} s after the cut (before {OLD[k]})") +check("⭐ a refusing B is caught at N=1 and N=2: within 22 s plus one 10 s " + "publish at 1 and 2 s meters, 12 cycles plus a publish at 5 s", + all(TIMES[("b", n, t)] is not None + and TIMES[("b", n, t)] <= (32.0 if t < 5 else 12 * 5.0 + 10.0) + for n in (1, 2) for t in (1.0, 2.0, 5.0))) +check("non-vacuous: before, a refusing B is never caught (300 s), at N=1 " + "or N=2", + all(OLD[("b", n, t)] is None for n in (1, 2) for t in (1.0, 2.0, 5.0))) +check("⭐ a refusing A is caught exactly as before", + all(TIMES[("a", n, t)] == OLD[("a", n, t)] is not None + for n in (1, 2) for t in (1.0, 2.0, 5.0))) + +# B refusing for 800 s: one refusal, held, not ended by its own idle reading. +_t, r = caught(2, "b", 1.0, dur=CUT + 800.0) +starts = [x for x in r["refused"] if x[1] == "inverter-b"] +check("a refusing B stays refused until its probe: its idle reading is not " + "read as taking power the other way (one refusal in 800 s)", + len(starts) == 1 and r["c"].inverters[1].refused == 1) + +# A register frozen at rest on B: refused or stale within 36 s of the ask. +r = rig.run(2, lambda t: 2500.0 if t >= 600 else 0.0, 700.0, tick=1.0, + publish_s=1.0, dither=True, + freeze_at=lambda u: 300.0 if u == "b" else None) +park = min([t for t, u in r["stale"] if u == "inverter-b"] + + [t for t, u in r["refused"] if u == "inverter-b"] or [None]) +print(f" B's register frozen at rest from 300 s, asked at 600 s: 0 W at " + f"{park}") +check("a register frozen at rest on B is caught as on A (within 36 s of " + "the ask)", park is not None and park - 600.0 <= 36.0) + +# ============================================================================ # +print("\n2. a healthy B with its offset is never refused or flagged") +SHAPES = ((10.0, False), (1.0, True)) +NAMES = ("rest_step", "drain_start", "drain_charge", "reversal") +SAVED = {"b": {unit_key("inverter-b", "idle_w"): -100.0}, + "a": {unit_key("inverter-a", "idle_w"): -100.0}} +bad = [] +runs = 0 +for n in (1, 2): + for name in NAMES: + for tick in (1.0, 2.0, 5.0): + for pub, dith in SHAPES: + for store in (None, SAVED["a" if n == 1 else "b"]): + s, f, _r = with_offset(n, lambda: rig.counts( + n, name, tick=tick, publish_s=pub, dither=dith, + store=store)) + runs += 1 + if s or f: + bad.append((n, name, tick, pub, bool(store), s, f)) +print(f" {runs} runs, stale or refused: {bad}") +check("⭐ SAFETY-94's scenarios with B's offset (N=1 and N=2, 1/2/5 s " + "meters, both register shapes, idle learned or restored): no stale " + "trip and no refusal", not bad and runs == 96) +rev = [] +for n in (1, 2): + for tau in (10.0, 20.0): + for charge in (-1500.0, -2500.0): + for house in (300.0, 800.0, 2000.0): + x = with_offset(n, lambda: rig.run( + n, rig.reversal(300.0, charge, house), 600.0, tau=tau, + tick=1.0, store=SAVED["a" if n == 1 else "b"])) + if x["stale"] or x["refused"]: + rev.append((n, tau, charge, house)) +check("⭐ a charge reversing into a discharge (lag 10 and 20 s, 1 s meter) " + "with B's idle reading restored is never refused or flagged", + not rev) + +# ============================================================================ # +print("\n3. learning the idle reading") +_t, r = caught(2, "b", 1.0) +c = r["c"] +check("learned during the rest: A about 0 W, B about -100 W, and saved", + abs(c.inverters[0].idle_w) < 5 and abs(c.inverters[1].idle_w + 100) < 5 + and abs(c.store.data.get(unit_key("inverter-b", "idle_w"), 0) + 100) + < 5) + +# A refusing B from the start, no rest, nothing saved: nothing to learn from. +_t, r = caught(2, "b", 1.0, load=lambda t: 3000.0, cut_at=60.0, dur=1200.0) +check("a battery refusing from the start teaches nothing: asked the whole " + "time, then held at 0 W by its refusal (none learned, none saved)", + r["c"].inverters[1].idle.value is None + and unit_key("inverter-b", "idle_w") not in r["c"].store.data) +# ... which is why the value is saved: restored, the same battery is caught. +t_saved, _r = caught(2, "b", 1.0, load=lambda t: 3000.0, cut_at=60.0, + store=SAVED["b"]) +t_none, _r = caught(2, "b", 1.0, load=lambda t: 3000.0, cut_at=60.0) +print(f" refusing from the start: saved idle {t_saved} s, none {t_none}") +check("⭐ after a restart the saved idle reading is used: a B refusing from " + "the start is caught in 32 s; with nothing saved it is not", + t_saved is not None and t_saved <= 32.0 and t_none is None) + +# Not learned while refused: a refusing B reading -160 W (in the band) does +# not pull its idle reading during the 0 W its refusal holds. +class _P(rig.Plant): + def advance(self, t): + super().advance(t) + for u in self.units: + if self.cut(u, self.t): + self.reg[u] = -160.0 + + +real_plant = rig.Plant +rig.Plant = _P +try: + _t, r = caught(2, "b", 1.0, dur=CUT + 800.0) +finally: + rig.Plant = real_plant +b = r["c"].inverters[1] +print(f" refusing B reading -160 W: refused at {_t} s, idle {b.idle_w:.1f}") +check("⭐ not learned while refused: 800 s held at 0 W reading -160 W leaves " + "B's idle reading at -100 W", + _t is not None and b.refused and abs(b.idle_w + 100.0) < 1.0) + +# A battery capped at 150 W under a big ask is not idle. +x = idle.IdleReading() +for t in range(0, 3600): + x.observe(150.0, 2000.0, float(t), True) +check("a reading under a command over 50 W is never a sample (a capped " + "battery is not idle)", x.value is None and x.w == 0.0) + +x = idle.IdleReading() +for t in range(0, 239): + x.observe(-100.0, 10.0, float(t), True) +first = x.value +x.observe(-100.0, 10.0, 239.0, True) +x.observe(-100.0, 10.0, 240.0, True) +check("the first value needs 120 s asked ~0 and then 120 s of samples", + first is None and abs(x.value + 100.0) < 1e-6) +y = idle.IdleReading() +for t in range(0, 400): + y.observe(-100.0, 60.0 if t % 100 == 0 else 0.0, float(t), True) +check("a command over 50 W restarts the quiet time (a 60 W blip every " + "100 s: never learned)", y.value is None) +z = idle.IdleReading() +for t in range(0, 400): + z.observe(-100.0, 0.0, float(t), False) +check("nothing is learned while learning is not allowed (refused, stale, " + "tripped or stopped)", z.value is None) + +for t in range(240, 300): + x.observe(-130.0, 0.0, float(t), True) +after_min = x.value +for t in range(300, 240 + 3600): + x.observe(-130.0, 0.0, float(t), True) +print(f" drift -100 -> -130 W: {after_min:.1f} after 60 s, {x.value:.1f} " + f"after an hour") +check("drift is followed slowly: under 2 W in the first minute, most of it " + "in an hour", -102.0 < after_min < -100.0 and -130 < x.value < -124) + +o = idle.IdleReading() +for t in range(0, 600): + o.observe(-300.0, 0.0, float(t), True) +p = idle.IdleReading() +for t in range(0, 600): + p.observe(2000.0 if t < 300 else -240.0, 0.0, float(t), True) +check("a reading beyond 250 W is not a sample (-300 W: never learned; a " + "register held at 2000 W then -240 W: -240 W)", + o.value is None and p.value is not None and abs(p.value + 240.0) < 1e-6) +check("a saved value is clamped to 250 W, and garbage is ignored", + idle.IdleReading(900).w == idle.IDLE_MAX_W + and idle.IdleReading(-900.0).w == -idle.IDLE_MAX_W + and idle.IdleReading("x").value is None + and idle.IdleReading(float("nan")).value is None) + +# ============================================================================ # +print("\n4. the rules at their edges") + + +class _Inv: + def __init__(self, idle_w): + self.batt, self.ref_count, self.ref_since, self.ref_sign = 0.0, 0, None, 0 + self.ref_from, self.idle_w = None, idle_w + + def standing_w(self): + return 800.0 + + +class _C: + _now_mono = 0.0 + + +def evidence(readings, idle_w): + c, inv = _C(), _Inv(idle_w) + got = [] + for i, b in enumerate(readings): + c._now_mono, inv.batt = float(i), b + got.append(app_main.Controller._gave_nothing(c, inv)) + return got + + +flat_b = evidence([-104.0 + (i % 3) for i in range(30)], -100.0) +flat_b0 = evidence([-104.0 + (i % 3) for i in range(30)], 0.0) +gives = evidence([0.0] * 30, -100.0) +takes = evidence([-200.0] * 30, -100.0) +check("the band is centred on the idle reading: B flat at -104 W is evidence " + "at 22 s with idle -100 W and never with idle 0 W; reading 0 W (giving " + "100 W) or -200 W (taking 100 W) is not", + 1 in flat_b and flat_b.index(1) == 22 and not any(flat_b0) + and not any(gives) and not any(takes)) + +T = control.Tuning(max_w=4000.0, slew_w=400.0) +args = dict(prev_w=1200.0, grid_w=1200.0, tuning=T, + sat_count=control.SETTLE_CYCLES, i_w=1200.0, refused=0, + gave_nothing=True) +check("compute(): the N=1 engage is centred on idle_w (-104 W refuses with " + "idle -100, not with 0; -4 W refuses with idle 0, not with -150)", + control.compute(actual_w=-104.0, idle_w=-100.0, **args).refused == 1 + and control.compute(actual_w=-104.0, **args).refused == 0 + and control.compute(actual_w=-4.0, **args).refused == 1 + and control.compute(actual_w=-4.0, idle_w=-150.0, **args).refused == 0) +args.update(refused=-1, i_w=-1200.0, prev_w=0.0, grid_w=-1200.0) +check("compute(): a charge refusal ends when the battery takes 100 W more " + "than its idle reading, not at its idle reading", + control.compute(actual_w=-110.0, idle_w=-100.0, **args).refused == -1 + and control.compute(actual_w=-205.0, idle_w=-100.0, **args).refused + == 0 + and control.compute(actual_w=-110.0, **args).refused == 0) +check("the idle bound is under the band's reach of a real delivery: 250 W", + idle.IDLE_MAX_W == 250.0 and idle.IDLE_ASK_W < control.PROBE_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 index 7052591..f832c5e 100644 --- a/goodwe_controller/tools/safety94_rig.py +++ b/goodwe_controller/tools/safety94_rig.py @@ -11,13 +11,15 @@ as the live boards do. B reads 100 W below what it gives (its standby draw: 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 +gives 0 W and reads -4 W plus its offset (B: -104 W), with `noise` W of +seeded jitter (SAFETY-97). 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 random import sys from datetime import datetime, timedelta, timezone @@ -40,9 +42,9 @@ MAINT = {"maintenance_enabled": True, "maintenance_start_hour": 11, class _Store: - def __init__(self): + def __init__(self, data=None): from app.store import DEFAULTS - self.data = dict(DEFAULTS) + self.data = dict(DEFAULTS, **(data or {})) def set(self, k, v): self.data[k] = v @@ -69,8 +71,9 @@ 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): + publish_s=PUBLISH_S, dither=False, noise=0.0, seed=0): self.units, self.load, self.tau, self.tick = units, load, tau, tick + self.noise, self.rng = noise, random.Random(seed) 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} @@ -98,9 +101,11 @@ class Plant: # `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)) + self.reg[u] = ( + float(round(CUT_W + OFFSET[u] + self.rng.uniform( + -self.noise, self.noise))) + 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()))) @@ -148,18 +153,18 @@ def options(n, extra=None): 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): + dither=False, noise=0.0, seed=0, store=None): """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.""" + phases)] and the controller. `store`: saved state to start from.""" 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) + dither, noise, seed) async def go(): - c = Controller(options(n, extra), _Hass(plant), _Store(), _Pub(), + c = Controller(options(n, extra), _Hass(plant), _Store(store), _Pub(), stop=asyncio.Event()) for i in c.inverters: i.dev_min, i.dev_max = -5000.0, 5000.0