Files
goodwe-addon/goodwe_controller/app/settle.py
T
goodwe-agent c50e2279dd
checks / unit (pull_request) Successful in 26m18s
infra-pin / fresh (pull_request) Successful in 2s
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 <noreply@anthropic.com>
2026-11-09 14:49:26 +01:00

75 lines
2.9 KiB
Python

"""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)