From c50e2279dd0c7854cb3030302b679964f84da022 Mon Sep 17 00:00:00 2001 From: goodwe-agent Date: Mon, 9 Nov 2026 14:21:49 +0100 Subject: [PATCH 1/2] SAFETY-136: a gentler loop on a slow meter until the last move shows On a meter whose readings come 4 s or more apart, the loop runs at half its gain after a move of 150 W or more, until the meter has fallen by half of the move or 2 cycles have passed. With readings 10 s late on a 5 s meter the export falls 44 % over oven, kettle and hob, owner shape, steps; 1-2 s meters are untouched. Co-Authored-By: Claude --- .../DECISIONS/SAFETY-128-late-readings.md | 3 +- .../DECISIONS/SAFETY-136-slow-meter-settle.md | 73 ++++++ goodwe_controller/app/ctl_cycle.py | 20 +- goodwe_controller/app/ctl_inputs.py | 1 + goodwe_controller/app/main.py | 2 + goodwe_controller/app/settle.py | 74 ++++++ goodwe_controller/test_safety129.py | 14 +- goodwe_controller/test_safety136.py | 219 ++++++++++++++++++ 8 files changed, 398 insertions(+), 8 deletions(-) create mode 100644 goodwe_controller/DECISIONS/SAFETY-136-slow-meter-settle.md create mode 100644 goodwe_controller/app/settle.py create mode 100644 goodwe_controller/test_safety136.py diff --git a/goodwe_controller/DECISIONS/SAFETY-128-late-readings.md b/goodwe_controller/DECISIONS/SAFETY-128-late-readings.md index 6b9e644..e2751f6 100644 --- a/goodwe_controller/DECISIONS/SAFETY-128-late-readings.md +++ b/goodwe_controller/DECISIONS/SAFETY-128-late-readings.md @@ -71,7 +71,8 @@ The fits run all the time; the pull request has the counts. **What it costs.** - Found 36-68 s after a lag starts at 1-2 s meters; at a 5 s meter 30 s late is found within 100 s after it starts (88 s measured) (the loop first moves 100 W after 60 s, - SAFETY-129) with the export above -200 W, and 10 s late is not found. Until + SAFETY-129) with the export above -200 W, and 10 s late is not found (SAFETY-136 calms + the loop at a slow meter instead). Until then control is as before. In a quiet house the "readings late" row stays about 2 minutes longer after the lag ends (126 s against 32 s on base); no harm measured, and it clears as early as on base when the load changes. diff --git a/goodwe_controller/DECISIONS/SAFETY-136-slow-meter-settle.md b/goodwe_controller/DECISIONS/SAFETY-136-slow-meter-settle.md new file mode 100644 index 0000000..27b1207 --- /dev/null +++ b/goodwe_controller/DECISIONS/SAFETY-136-slow-meter-settle.md @@ -0,0 +1,73 @@ +# SAFETY-136 - a slow meter gets a gentler loop until the last move shows + +**Status: for review.** Code: `app/settle.py`, a call in `Controller.cycle()` +(`app/ctl_cycle.py`). Tests: `test_safety136.py`. No option, no text change. + +**Problem.** On a 5 s meter, Home Assistant readings 10-15 s late are not +found (SAFETY-129): they look like a slow battery (R2-1), and a hold does not +help (a perfect one cut the export of the oven by 7 %, SAFETY-129 review). +With moving loads the loop answers the same stale error at each cycle and +overshoots: an oven, a kettle and hob, the owner's day or a step export 3-10 +times what they do with no lag. The fix is a calmer loop, not a detector. + +**Decision.** After a move of 150 W or more, a loop on a slow meter runs at half +its gain until the meter has fallen by half of that move, or for 2 cycles. +Slow is judged from the readings that arrive: the usual gap +between them, over the last 60 s and at least 5 readings, is over 3.75 s (the +limit of SAFETY-128's fast meter fit). It counts arrivals, not changes of +value: on the recorded day at 1-2 s the value-based test (`MeterFit.slow()`) +called 24 % of the cycles slow, because the recorded house repeats a value +for 5 s or more, and the commands then differed from base. So there is no +option. At a 1-3 s meter it is false, the tuning is never touched and the +commands are those of the tree before. A meter that Home Assistant passes on +only when its value changes looks as slow as its changes are far apart, as it +does to SAFETY-128's fits: a 1-2 s meter with a value that holds for 5 s or +more is then treated as slow, and the loop is gentler for it (the cost below). +With no lag the meter shows a move at the next reading, so the wait ends at +once. + +**Candidates** (SAFETY-129 sim, real `cycle()`, 5 s meter, sum over oven, +kettle and hob, owner shape over the oven and over the heat pump, step down, +step up, flat; export Wh, lag 0 / 10 s / 15 s; base 64 / 340 / 363): + +| Candidate, only at a slow meter | 0 s | 10 s | 15 s | Import Wh, 0 / 10 / 15 s | +|---|---|---|---|---| +| gain 0.4 | 97 | 201 | 299 | 97 / 224 / 301 | +| gain 0.3 | 128 | 185 | 322 | 132 / 232 / 293 | +| step limit 500 W | 118 | 285 | 293 | 110 / 325 / 324 | +| cut fast, raise slowly (0.6 / 0.3) | 55 | 140 | 226 | 140 / 332 / 345 | +| ignore 1 cycle after a move | 93 | 216 | 325 | 90 / 240 / 291 | +| ignore 2 cycles after a move, unless the meter showed half | 69 | 206 | 277 | 62 / 240 / 354 | +| **half gain for 2 cycles after a move, unless the meter showed half** | **67** | **189** | **296** | **62 / 223 / 288** | + +A lower gain for good, a step limit and a fixed ignore all make a 5 s meter +with no lag worse (export +30-110 Wh, quarter peak up to 150 W): that is the +common case. Cutting fast and raising slowly moves export to import and peak. +Waiting only while the last move has not shown costs nothing with no lag. +Ignoring the meter (the 2-cycle line) was worse than half gain where the plant +is slow, not the readings late: a 12 s dead time with one inverter went from +250 to 841 Wh import and a quarter peak of 206 to 1 509 W, so the loop keeps +following at half gain. + +**What it gives** (5 s meter; the pull request and the report have each +scenario). Export summed over the seven houses: 10 s late 340 -> 189 Wh +(-44 %, import 363 -> 223), 15 s late 363 -> 296 Wh (-19 %, import 281 -> +288), together -31 %. At 15 s the oven and the owner's oven are not better +(92 / 39 Wh against 91 / 37), the kettle and the steps are. No lag: export +64.4 -> 66.5 Wh (kettle +2), import and peak the same. + +**What it costs.** The loop follows a real step more slowly: half gain for up +to two cycles after each move of 150 W or more. Of the 136 sim runs (every +SAFETY-129 review scenario and its 15 harm runs), 56 at 4-10 s meters differ +from base. Together their export is 10 363 -> 9 559 Wh (-8 %) and import +8 389 -> 7 835 Wh (-7 %). Only 5 export more than 1 Wh and 14 import more: +export by at most 11.5 Wh (a 10 s battery lag, +10 %), import by at most +17 Wh (a 12 s dead time, one inverter, +7 %), and the quarter average by at +most 35 W, once 73 W (10 s meter, 10 s dead time, export -59 Wh). Slow +batteries with a dead time of 8-10 s export 5-39 % less; a 12 s dead time is +the same. `READINGS_LATE` holds are the same seconds in every run. + +**Does not change.** Meters of 1-2 s: the same commands in all 63 runs (34 +at 1 s, 29 at 2 s), and the recorded-day digests (see the report). The late +verdict, the hold and the freeze tests. 15 s late is only partly helped, and +30 s late is the hold's (SAFETY-129). diff --git a/goodwe_controller/app/ctl_cycle.py b/goodwe_controller/app/ctl_cycle.py index c242179..6f8ac9f 100644 --- a/goodwe_controller/app/ctl_cycle.py +++ b/goodwe_controller/app/ctl_cycle.py @@ -734,15 +734,21 @@ class CycleMixin: # TEL-10. ⚠️ ONE NAME FOR THE COMMAND THE LOOP WAS MEASURED # AGAINST. (DECISIONS/MAIN-orchestration-archive.md) loop_prev = self.target + # SAFETY-95: a drain's tail is not the loop's to chase. + # OPT-15: `loop_grid` is the grid, or the target while a run + # edge is held. + grid_in = (self._drain_grid_w(_live96) if self._drain_tail + else loop_grid) + # SAFETY-136: on a slow meter, a gentler loop until the last move + # shows. + slow_meter = self.settle.slow() + if moved or mode_trade: + self.settle.reset() decision = compute( prev_w=loop_prev, - # SAFETY-95: a drain's tail is not the loop's to chase. - # OPT-15: `loop_grid` is the grid, or the target while a run - # edge is held. - grid_w=(self._drain_grid_w(_live96) if self._drain_tail - else loop_grid), + grid_w=grid_in, actual_w=site_batt, - tuning=self.tuning, + tuning=self.settle.tuning(self.tuning, grid_in, slow_meter), sat_count=self.sat_count, i_w=self.i_w, # SAFETY-52 at one inverter only: with several, OPT-09's pack @@ -772,6 +778,7 @@ class CycleMixin: self.log_event(f"saturation freeze ({self.target:.0f} W vs " f"{site_batt:.0f} W)", hint=messages.hint("not_keeping_up")) + self.settle.moved(grid_in, loop_prev, decision.target_w, slow_meter) self.sat_count = decision.sat_count self.i_w = decision.i_w self.loop_w = decision.target_w @@ -811,6 +818,7 @@ class CycleMixin: else: # Stopped or blind: no accumulation may survive the outage. self.i_w = 0.0 + self.settle.reset() self.loop_w = None self._live_was = frozenset(_live96) # TEL-10, and it is the same zero the line above writes rather than diff --git a/goodwe_controller/app/ctl_inputs.py b/goodwe_controller/app/ctl_inputs.py index c5af7b1..7568dce 100644 --- a/goodwe_controller/app/ctl_inputs.py +++ b/goodwe_controller/app/ctl_inputs.py @@ -197,6 +197,7 @@ class InputsMixin: # correlates 0.998 with battery power and 0.09 with the real meter # (a controller would regulate against its own output). self.grid = self.p1.net_w + self.settle.arrive(now_mono, self.p1.samples) # SAFETY-136 else: self.grid = await self.hass.number(o.get("meter_entity", ""), bool(o.get("meter_invert"))) diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 5c440c4..e15b743 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -63,6 +63,7 @@ from .clamp import ( ) from . import freshness from . import lateness +from .settle import Settle from . import dayview, homeview from .daycurve import SolarToday from . import handback @@ -741,6 +742,7 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin, self._arb_action = None self.inputs_bad_since = None self.answer = lateness.Answer() # SAFETY-128 + self.settle = Settle() # SAFETY-136 self._late_logged = self._late_zero = False # SAFETY-03: what the loop's OWN bounds did last cycle, `(engaged, # note, over_rating)` (SAFETY-40 splits the last out). Site-level: the diff --git a/goodwe_controller/app/settle.py b/goodwe_controller/app/settle.py new file mode 100644 index 0000000..04e202a --- /dev/null +++ b/goodwe_controller/app/settle.py @@ -0,0 +1,74 @@ +"""SAFETY-136: on a slow meter, follow the meter gently until the last move shows. + +A meter whose readings come 4 s or more apart, read through Home Assistant, can +be 10-15 s late. The loop then answers the same error again at each cycle +and overshoots. After a move of MOVE_W or more the loop therefore runs at +WAIT_SHARE of its gain until the meter has fallen SHOWN of the move, or for at +most WAIT_CYCLES cycles. Off for a meter under 4 s, so those sites run as +before. DECISIONS/SAFETY-136-slow-meter-settle.md. +""" + +from collections import deque +from dataclasses import replace + +from .lateness import M_FIT_S, M_ROWS + +# A meter is slow when its readings come further apart than this (the usual +# gap, over the last 60 s and at least 5 readings): the fast meter fit's own +# limit (SAFETY-128), 3.75 s. +SLOW_GAP_S = M_FIT_S / M_ROWS +SLOW_ROWS = 5 +# A move this big is waited for (above the house noise of about 80 W). +MOVE_W = 150.0 +# The meter has shown a move once it fell by this share of it. +SHOWN = 0.5 +# Meanwhile the loop runs at this share of its gain. +WAIT_SHARE = 0.5 +# Wait at most this many cycles (10 s at a 5 s meter). +WAIT_CYCLES = 2 + + +class Settle: + def __init__(self): + self.pend = None # (meter at the move, the move in W, cycles waited) + self._n = None # readings taken in so far + self._at = deque() # when the last ones arrived, over M_FIT_S + + def arrive(self, now, n): + """Each read: `n` is the count of meter readings taken in. A reading + counts when it arrives, not when its value changes, so a steady load on + a 1 s meter is not a slow meter.""" + if n != self._n: + self._n = n + self._at.append(now) + while self._at and self._at[0] < now - M_FIT_S: + self._at.popleft() + + def slow(self): + """The usual gap between readings is over SLOW_GAP_S.""" + ts = list(self._at) + if len(ts) < SLOW_ROWS: + return False + gaps = sorted(b - a for a, b in zip(ts, ts[1:])) + return gaps[len(gaps) // 2] > SLOW_GAP_S + + def reset(self): + self.pend = None + + def tuning(self, tuning, grid_w, slow): + """The tuning for this cycle: `tuning`, or with the gain cut while the + last move has not shown on the meter (`grid_w`).""" + p = self.pend + if p is None: + return tuning + g0, move, waited = p + if not slow or (g0 - grid_w) / move >= SHOWN or waited >= WAIT_CYCLES: + self.pend = None + return tuning + self.pend = (g0, move, waited + 1) + return replace(tuning, gain=tuning.gain * WAIT_SHARE) + + def moved(self, grid_w, prev_w, new_w, slow): + """After a cycle: start waiting if the loop moved by MOVE_W or more.""" + if slow and self.pend is None and abs(new_w - prev_w) >= MOVE_W: + self.pend = (float(grid_w), float(new_w - prev_w), 0) diff --git a/goodwe_controller/test_safety129.py b/goodwe_controller/test_safety129.py index 08c96df..89d904b 100644 --- a/goodwe_controller/test_safety129.py +++ b/goodwe_controller/test_safety129.py @@ -38,6 +38,7 @@ logging.disable(logging.WARNING) import safety128_rig as R # noqa: E402 import app.main as app_main # noqa: E402 from app import lateness # noqa: E402 +from app import settle # noqa: E402 fails = [] @@ -58,6 +59,17 @@ def no_gate(fn): app_main.Controller._readings_late = real +def no_settle(fn): + """`fn()` without SAFETY-136's gentler loop: the tree this card was written + against (with it the same run exports -645 W, not -1 325 W).""" + real = settle.Settle.moved + settle.Settle.moved = lambda self, *a, **k: None + try: + return fn() + finally: + settle.Settle.moved = real + + def run_late(**kw): """Run the rig; (rig, seconds the verdict stood, first second it stood).""" dur = kw.pop("dur") @@ -91,7 +103,7 @@ for units, house in ((AB, 1800.0), (A, 1500.0)): if len(units) == 2: old = R.Rig(house=house, lag_s=30.0, lag_on=ON, lag_off=ON + 300.0, units=units, p1_s=5.0) - no_gate(lambda: old.run(ON + 300.0)) + no_settle(lambda: no_gate(lambda: old.run(ON + 300.0))) check(f"{n}: without the gate the export goes below -1 kW", grid_min(old.rows, ON, ON + 300.0) < -1000.0) diff --git a/goodwe_controller/test_safety136.py b/goodwe_controller/test_safety136.py new file mode 100644 index 0000000..bca5545 --- /dev/null +++ b/goodwe_controller/test_safety136.py @@ -0,0 +1,219 @@ +"""Runnable check for SAFETY-136: a calmer loop on a slow meter. +`python3 test_safety136.py` + +With a 5 s meter and Home Assistant readings 10-15 s late the loop answers the +same error again at each cycle and overshoots: with an oven, a kettle and hob, +or a step the export was 3-10 times that of a meter with no lag. After a move +of 150 W or more `app/settle.py` now runs the loop at half its gain (at most +2 cycles) until the meter shows half of it. It is off for a meter under 4 s. +DECISIONS/SAFETY-136-slow-meter-settle.md. + +The REAL Controller on TEST-23's plant (tools/safety128_rig.py). "Off" is the +same run with `Settle.moved` doing nothing: the tree before this card. + +1. `Settle` alone. +2. 5 s meter, readings 10 s late: oven, kettle and hob, a step down and a step + up export clearly less than with it off. +3. 5 s meter, no lag: the same houses are no worse than a small margin. +4. 1 s and 2 s meters: the commands are those of the run with it off, and the + loop never waits. +5. The slow meter is told by its observed interval, at 4 s and 5 s. + +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.WARNING) + +import safety128_rig as R # noqa: E402 +from app.control import Tuning # noqa: E402 +from named_absence import nth, present # noqa: E402 +from app import settle # noqa: E402 +from app.lateness import MeterFit # noqa: E402 + +fails = [] + + +def check(name, cond): + print(f" {'ok ' if cond else 'FAIL'} {name}") + if not cond: + fails.append(name) + + +def off(fn): + """`fn()` with the settle wait off: the tree before this card.""" + real = settle.Settle.moved + settle.Settle.moved = lambda self, *a, **k: None + try: + return fn() + finally: + settle.Settle.moved = real + + +def oven(t): + return 1800.0 + (2000.0 if (t >= 300.0 and (t - 300.0) % 105 < 45) else 0.0) + + +def kettle(t): + w = 600.0 + if t % 400 < 120: + w += 2200 + if t >= 900 and t % 90 < 30: + w += 1500 + return w + + +def step(t): + return 1800.0 if t < 320.0 else 300.0 + + +def stepup(t): + return 300.0 if t < 320.0 else 2500.0 + + +# name: (house, noise, run length, lag end) +HOUSES = {"oven": (oven, 80.0, 900.0, 900.0), + "kettle": (kettle, 80.0, 1500.0, 1500.0), + "step": (step, 50.0, 700.0, 600.0), + "stepup": (stepup, 50.0, 700.0, 600.0)} +ON = 300.0 + + +def wh(rows, t0, t1, sign): + """Export (sign -1) or import (sign +1) energy in Wh over [t0, t1).""" + return sum((-min(r[1], 0.0) if sign < 0 else max(r[1], 0.0)) + for r in rows if t0 <= r[0] < t1) / 3600.0 + + +def run(name, lag, p1_s=5.0, units=("a", "b"), dur=None): + house, noise, length, lag_off = HOUSES[name] + r = R.Rig(house=house, noise=noise, p1_s=p1_s, units=units, lag_s=lag, + lag_on=ON, lag_off=lag_off) + waits = [] + r.run(dur or length + 20.0, + lambda rig, c, t: waits.append(1) if c.settle.pend else None) + return r, len(waits) + + +# --------------------------------------------------------------------------- # +print("1. Settle alone") +T = Tuning() +s = settle.Settle() +check("no move: the tuning is untouched", s.tuning(T, 500.0, True) is T) +s.moved(500.0, 0.0, 100.0, True) +check("a move under 150 W is not waited for", s.pend is None) +s.moved(500.0, 0.0, 600.0, False) +check("a meter that is not slow is not waited for", s.pend is None) +s.moved(500.0, 0.0, 600.0, True) +check("a move of 600 W is waited for: half the gain", + s.tuning(T, 500.0, True).gain == T.gain * 0.5) +check("the meter fell 200 W of 600 W: still half the gain", + s.tuning(T, 300.0, True).gain == T.gain * 0.5) +check("two cycles waited: the third has the whole gain", + s.tuning(T, 300.0, True) is T and s.pend is None) +s.moved(500.0, 0.0, 600.0, True) +check("the meter fell half the move: the whole gain at once", + s.tuning(T, 200.0, True) is T and s.pend is None) +s.moved(500.0, 0.0, -600.0, True) +check("a move down is waited for too", + s.tuning(T, 500.0, True).gain == T.gain * 0.5) +s.reset() +check("reset ends the wait", s.pend is None) +s.moved(500.0, 0.0, 600.0, True) +check("a meter that turns out not to be slow ends the wait", + s.tuning(T, 500.0, False) is T and s.pend is None) +check("the numbers are pinned", + (settle.MOVE_W, settle.SHOWN, settle.WAIT_SHARE, settle.WAIT_CYCLES) + == (150.0, 0.5, 0.5, 2)) + +# --------------------------------------------------------------------------- # +print("\n2. 5 s meter, readings 10 s late") +tot_on = tot_off = 0.0 +for name, (_h, _n, length, lag_off) in HOUSES.items(): + a, waited = run(name, 10.0) + b, _w = off(lambda: run(name, 10.0)) + ea, eb = wh(a.rows, ON, lag_off, -1), wh(b.rows, ON, lag_off, -1) + tot_on, tot_off = tot_on + ea, tot_off + eb + print(f" {name}: export {ea:.1f} Wh, off {eb:.1f} Wh, waited {waited} s") + check(f"{name}: waits at some point", waited > 0) + check(f"{name}: exports at most 75 % of the run with it off", + ea <= 0.75 * eb) +print(f" sum: {tot_on:.1f} Wh, off {tot_off:.1f} Wh") +check("the four together export at most 65 % of the run with it off", + tot_on <= 0.65 * tot_off) + +# --------------------------------------------------------------------------- # +print("\n3. 5 s meter, no lag") +for name in ("oven", "kettle", "step", "stepup"): + _h, _n, length, lag_off = HOUSES[name] + a, _w = run(name, 0.0) + b, _w = off(lambda: run(name, 0.0)) + ea, eb = wh(a.rows, ON, lag_off, -1), wh(b.rows, ON, lag_off, -1) + ia, ib = wh(a.rows, ON, lag_off, 1), wh(b.rows, ON, lag_off, 1) + print(f" {name}: export {ea:.1f} Wh (off {eb:.1f}), import {ia:.1f} Wh " + f"(off {ib:.1f})") + check(f"{name}: export at most 6 Wh over the run with it off", + ea <= eb + 6.0) + check(f"{name}: import at most 1 Wh over the run with it off", + ia <= ib + 1.0) + +# --------------------------------------------------------------------------- # +print("\n4. 1 s and 2 s meters") +for p1 in (1.0, 2.0): + for lag in (0.0, 10.0): + for units in (("a", "b"), ("a",)): + a, waited = run("oven", lag, p1_s=p1, units=units, dur=700.0) + b, _w = off(lambda: run("oven", lag, p1_s=p1, units=units, + dur=700.0)) + same = ([(r[0], r[1], r[2]) for r in a.rows] + == [(r[0], r[1], r[2]) for r in b.rows]) + check(f"{p1:.0f} s meter, {lag:.0f} s late, N={len(units)}: " + f"same commands, never waits", same and waited == 0) + +# --------------------------------------------------------------------------- # +print("\n5. The slow meter is told by its observed interval") +s = settle.Settle() +for k in range(30): + s.arrive(float(k), k) +check("a reading each second is not a slow meter", not s.slow()) +s = settle.Settle() +for k in range(30): + s.arrive(5.0 * k, k) +check("a reading every 5 s is a slow meter", s.slow()) +s = settle.Settle() +for k in range(4): + s.arrive(5.0 * k, k) +check("fewer than 5 readings in a minute tell nothing", not s.slow()) +s = settle.Settle() +for k in range(30): + s.arrive(5.0 * k, 0 if k else 1) +check("the same count again is no new reading", not s.slow()) +for p1, expect in ((1.0, False), (2.0, False), (3.0, False), (4.0, True), + (5.0, True), (10.0, True)): + r = R.Rig(house=1800.0, p1_s=p1) + seen = [] + r.run(120.0, lambda rig, c, t: seen.append(c.settle.slow())) + last = nth(seen, -1) + check(f"{p1:.0f} s meter: slow is {expect}", + present(last) and last is expect) +# A 1 s meter on a house that changes every 5 s repeats its value 4 times in 5: +# the fit that counts changed values calls that slow, the readings do not (the +# recorded day at 1-2 s showed it). +s, m = settle.Settle(), MeterFit() +for k in range(60): + s.arrive(float(k), k) + m.add(float(k), 0.0, 1800.0 + 100.0 * (k // 5)) +check("a 1 s meter whose value changes every 5 s: not slow, though its value " + "repeats", not s.slow() and m.slow()) + +print() +if fails: + print(f"{len(fails)} FAILED: " + ", ".join(fails)) + sys.exit(1) +print("all checks passed") -- 2.54.0 From 0f9c119bcf215dfe3950cad22bc2280b90e5aa79 Mon Sep 17 00:00:00 2001 From: goodwe-agent Date: Mon, 9 Nov 2026 20:51:06 +0100 Subject: [PATCH 2/2] SAFETY-136 round 2: tests for override and direction, true wording F1 the on-change case is tested as slow, F2 override and direction checks, F3 the 3.75 s rule in the texts, F4 the quarter figures and the live note. No behaviour change. Co-Authored-By: Claude --- .../DECISIONS/SAFETY-136-slow-meter-settle.md | 12 +++- goodwe_controller/app/settle.py | 6 +- goodwe_controller/test_safety136.py | 56 +++++++++++++++++-- 3 files changed, 63 insertions(+), 11 deletions(-) diff --git a/goodwe_controller/DECISIONS/SAFETY-136-slow-meter-settle.md b/goodwe_controller/DECISIONS/SAFETY-136-slow-meter-settle.md index 27b1207..4818202 100644 --- a/goodwe_controller/DECISIONS/SAFETY-136-slow-meter-settle.md +++ b/goodwe_controller/DECISIONS/SAFETY-136-slow-meter-settle.md @@ -18,7 +18,7 @@ limit of SAFETY-128's fast meter fit). It counts arrivals, not changes of value: on the recorded day at 1-2 s the value-based test (`MeterFit.slow()`) called 24 % of the cycles slow, because the recorded house repeats a value for 5 s or more, and the commands then differed from base. So there is no -option. At a 1-3 s meter it is false, the tuning is never touched and the +option. At a meter with a usual gap of 3.75 s or less (1-3 s) it is false, the tuning is never touched and the commands are those of the tree before. A meter that Home Assistant passes on only when its value changes looks as slow as its changes are far apart, as it does to SAFETY-128's fits: a 1-2 s meter with a value that holds for 5 s or @@ -63,10 +63,18 @@ from base. Together their export is 10 363 -> 9 559 Wh (-8 %) and import 8 389 -> 7 835 Wh (-7 %). Only 5 export more than 1 Wh and 14 import more: export by at most 11.5 Wh (a 10 s battery lag, +10 %), import by at most 17 Wh (a 12 s dead time, one inverter, +7 %), and the quarter average by at -most 35 W, once 73 W (10 s meter, 10 s dead time, export -59 Wh). Slow +most 35 W, except two import quarters: +73 W (10 s meter, 10 s dead time, +export -59 Wh) and +40 W (`dead10_p6`, -6 to 34 W, export -68 Wh). Slow batteries with a dead time of 8-10 s export 5-39 % less; a 12 s dead time is the same. `READINGS_LATE` holds are the same seconds in every run. +**Live, a 1-2 s meter can be judged slow.** Through Home Assistant on change, +a value that holds for 4-10 s makes a 1 s meter slow. In 20 rig scenarios the +commands were those of base. On the recorded day (1 s, N=1, 1 day, repeats +dropped; its values hold about 5 s) it was slow in 33 % of cycles, half gain +in 83 cycles, 15 422 commands in both trees, 4 016 with another value (at +most 190 W apart), cost 0.61 EUR in both (review of SAFETY-136). + **Does not change.** Meters of 1-2 s: the same commands in all 63 runs (34 at 1 s, 29 at 2 s), and the recorded-day digests (see the report). The late verdict, the hold and the freeze tests. 15 s late is only partly helped, and diff --git a/goodwe_controller/app/settle.py b/goodwe_controller/app/settle.py index 04e202a..41a7ab4 100644 --- a/goodwe_controller/app/settle.py +++ b/goodwe_controller/app/settle.py @@ -1,10 +1,10 @@ """SAFETY-136: on a slow meter, follow the meter gently until the last move shows. -A meter whose readings come 4 s or more apart, read through Home Assistant, can -be 10-15 s late. The loop then answers the same error again at each cycle +A meter whose usual gap between readings is over 3.75 s (a 4-5 s meter), read +through Home Assistant, can be 10-15 s late. The loop then answers the same error again at each cycle and overshoots. After a move of MOVE_W or more the loop therefore runs at WAIT_SHARE of its gain until the meter has fallen SHOWN of the move, or for at -most WAIT_CYCLES cycles. Off for a meter under 4 s, so those sites run as +most WAIT_CYCLES cycles. Off for a meter with a usual gap of 3.75 s or less, so those sites run as before. DECISIONS/SAFETY-136-slow-meter-settle.md. """ diff --git a/goodwe_controller/test_safety136.py b/goodwe_controller/test_safety136.py index bca5545..5f3a957 100644 --- a/goodwe_controller/test_safety136.py +++ b/goodwe_controller/test_safety136.py @@ -118,6 +118,14 @@ check("the meter fell 200 W of 600 W: still half the gain", check("two cycles waited: the third has the whole gain", s.tuning(T, 300.0, True) is T and s.pend is None) s.moved(500.0, 0.0, 600.0, True) +check("the meter went the other way by half the move: the wait stands", + s.tuning(T, 800.0, True).gain == T.gain * 0.5) +s.reset() +s.moved(500.0, 0.0, -600.0, True) +check("a move down, the meter rose half the move: the whole gain", + s.tuning(T, 800.0, True) is T) +s.reset() +s.moved(500.0, 0.0, 600.0, True) check("the meter fell half the move: the whole gain at once", s.tuning(T, 200.0, True) is T and s.pend is None) s.moved(500.0, 0.0, -600.0, True) @@ -148,6 +156,38 @@ print(f" sum: {tot_on:.1f} Wh, off {tot_off:.1f} Wh") check("the four together export at most 65 % of the run with it off", tot_on <= 0.65 * tot_off) +# --------------------------------------------------------------------------- # +print("\n2b. Another writer overrides the loop: the wait ends") +halved = [] +real_tuning = settle.Settle.tuning + + +def watch_tuning(self, tuning, grid_w, slow): + r = real_tuning(self, tuning, grid_w, slow) + halved.append(r is not tuning) + return r + + +settle.Settle.tuning = watch_tuning +try: + mark = [] + + def bump(rig, c, t): + # right after a cycle that started a wait, another writer moves the + # command, so the next cycle sees `target != loop_w` + if not mark and c.settle.pend and t > 330.0: + mark.append(len(halved)) + c.target = c.target + 300.0 + + r = R.Rig(house=oven, noise=80.0, p1_s=5.0, lag_s=10.0, lag_on=ON, + lag_off=900.0) + r.run(500.0, bump) +finally: + settle.Settle.tuning = real_tuning +nxt = nth(halved, mark[0] if mark else 10 ** 6) +check("the cycle after an override has the whole gain", + bool(mark) and present(nxt) and nxt is False) + # --------------------------------------------------------------------------- # print("\n3. 5 s meter, no lag") for name in ("oven", "kettle", "step", "stepup"): @@ -202,15 +242,19 @@ for p1, expect in ((1.0, False), (2.0, False), (3.0, False), (4.0, True), last = nth(seen, -1) check(f"{p1:.0f} s meter: slow is {expect}", present(last) and last is expect) -# A 1 s meter on a house that changes every 5 s repeats its value 4 times in 5: -# the fit that counts changed values calls that slow, the readings do not (the -# recorded day at 1-2 s showed it). -s, m = settle.Settle(), MeterFit() +# A 1 s meter on a house that changes every 5 s repeats its value 4 times in 5. +# Where every reading arrives (the rig, a polled meter) it is not slow; where +# Home Assistant sends a reading only when the value changes (ha_signed) the +# same meter is slow (T3 of the review; DECISIONS). +s, m, h = settle.Settle(), MeterFit(), settle.Settle() for k in range(60): s.arrive(float(k), k) + h.arrive(float(k), k // 5) m.add(float(k), 0.0, 1800.0 + 100.0 * (k // 5)) -check("a 1 s meter whose value changes every 5 s: not slow, though its value " - "repeats", not s.slow() and m.slow()) +check("every reading arrives, value changes every 5 s: not slow (the value " + "fit says slow)", not s.slow() and m.slow()) +check("the same meter through Home Assistant on change (a reading per " + "change): slow", h.slow()) print() if fails: -- 2.54.0