Files
goodwe-addon/goodwe_controller/app/lateness.py
T
glenn schrooyenandClaude 09e1fbb898
checks / unit (pull_request) Successful in 19m36s
infra-pin / fresh (pull_request) Successful in 1s
SAFETY-128: control does not act on late Home Assistant readings
Readings that Home Assistant delivers late but still changing passed
every staleness gate, so the loop corrected again each cycle an error
its last command had already fixed (10 s late: the give run swung and
exported to -1.9 kW), and the peak guard pushed to the inverter limit
on the same late figures (OPT-34 F-2).

app/lateness.py fits how long after a power request the meter shows
it. Over 8 s the readings are late: a request the battery reading has
not answered holds the loop (it may not lead the reading by more than
100 W) and caps the peak guard at what stands; unanswered for 23 s it
holds, then 0 W after stale_input_s. An amber row and one log line per
episode say so. With readings on time nothing changes (digests equal).

DECISIONS/SAFETY-128-late-readings.md; test_safety128.py.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-11-01 23:45:03 +01:00

219 lines
9.9 KiB
Python

"""SAFETY-128: a reading that does not answer the last command is not current.
When Home Assistant delivers readings late but still changing, every gate that
judges arrival (SAFETY-01) or an unchanged value (SAFETY-18, SAFETY-37) passes
them, and the loop corrects again, each cycle, an error its last command has
already fixed. The readings themselves show it: after a command the meter moves
the other way and the battery reading follows (r = 0.998, freshness.py), and
late readings do both late.
`Answer` watches the site command on the wire, the meter and the site battery
reading (less the batteries' idle reading, SAFETY-97):
- `Delay` fits how long after a command the meter shows it. Over DELAY_S the
readings run late (`late_seen`); within it they do not. A battery that does
not follow gives no fit, so no evidence either way;
- a new command more than ANSWER_W from the battery reading is PENDING until
that reading comes within ANSWER_W of it, or half way. The first move of
either reading towards it (ANSWER_W) is its START;
- while `late_seen` and pending, the loop may not lead the battery reading by
more than ANSWER_W that way (`hold`, `hold_w`; not while `exempt`: a
battery that gives nothing under its ask or refuses is SAFETY-52/94's case)
and the peak guard may not push past what stands (`peak_cap`). Pending for LATE_S with no start is LATE: control
holds, then 0 W after `stale_input_s`, as for a missing input.
With readings on time nothing here acts. DECISIONS/SAFETY-128-late-readings.md.
"""
import bisect
import math
from collections import deque
from . import freshness
from .control import LOOP_TICK_S, PROBE_ANSWER_W
# Within this of the command the reading has answered it (a probe's answer).
ANSWER_W = PROBE_ANSWER_W # 100
# A fresh meter shows a command within the plant's settle (SAFETY-75's 6 s dead
# time) and one meter tick; later than that the readings run late.
DELAY_S = freshness.PLANT_SETTLE_S + LOOP_TICK_S # 8.0
# The slowest healthy battery answer: SAFETY-75's 6 s dead time, the 6 s plant
# settle, one 10 s publish and one 1 s read.
LATE_S = (2 * freshness.PLANT_SETTLE_S + freshness.PUBLISH_PERIOD_S
+ freshness.READ_PERIOD_S) # 23.0
# The meter's delay is fitted over this much history, up to this delay.
FIT_S = 40.0
MAX_DELAY_S = 90
FIT_EVERY_S = 4.0
# A slow battery answers at once but takes this long (SAFETY-85/94 rigs).
SLOW_TAUS_S = (10.0, 20.0)
# With no fit for this long the evidence is dropped (a steady site shows none).
FIT_GONE_S = 120.0
# A fit counts only if the commands explain this share of the meter's swing.
FIT_SHARE = 0.5
# ...and a late delay only if it fits this much better than any fresh one.
FIT_BETTER = 0.7
class Delay:
"""How long after a command the meter shows it, fitted from history.
The meter is the house less the batteries, and the batteries follow the
commands, so `grid(t) + command(t - d)` is the house alone at the right
`d`: the `d` that leaves it flattest is the delay. Only new meter values
count, so a silent meter (SAFETY-99) is not a late one. None while the
commands have not moved enough to tell, or explain too little of the meter
(a battery that does not follow); not late if a slow battery that answers
at once (SLOW_TAUS_S) fits as well."""
def __init__(self):
self.cmds = deque() # (t, command) when it changed
self.grid = deque() # (t, meter)
def add(self, now, cmd, grid):
if not self.cmds or self.cmds[-1][1] != cmd:
self.cmds.append((now, cmd))
if grid is not None and (not self.grid or self.grid[-1][1] != grid):
self.grid.append((now, grid)) # new meter values only
while self.grid and self.grid[0][0] < now - FIT_S:
self.grid.popleft()
while (len(self.cmds) > 1
and self.cmds[1][0] <= now - FIT_S - MAX_DELAY_S):
self.cmds.popleft()
def estimate(self):
if len(self.grid) < 10 or len(self.cmds) < 2:
return None
ts = [t for t, _c in self.cmds]
t0 = self.cmds[0][0]
rows = [(t, g) for t, g in self.grid if t >= t0 + MAX_DELAY_S]
if len(rows) < 10:
return None
cs = [c for _t, c in self.cmds]
if max(cs) - min(cs) < ANSWER_W:
return None
g_mean = sum(g for _t, g in rows) / len(rows)
g_var = sum((g - g_mean) ** 2 for _t, g in rows) / len(rows)
def var(f, d):
e = [g + f(t - d) for t, g in rows]
m = sum(e) / len(e)
return sum((x - m) ** 2 for x in e) / len(e)
step = lambda t: cs[bisect.bisect_right(ts, t) - 1] # noqa: E731
pure = [var(step, d) for d in range(0, MAX_DELAY_S + 1)]
best = min(range(len(pure)), key=lambda d: (pure[d], d))
if g_var <= 0 or pure[best] > (1 - FIT_SHARE) * g_var:
return None
if best <= DELAY_S:
return float(best)
# Late only if it fits clearly better than any fresh delay, and better
# than a slow battery answering at once (SAFETY-85/94's 10-20 s lag).
if pure[best] > FIT_BETTER * min(pure[:int(DELAY_S) + 1]):
return None
first, last = int(t0), int(rows[-1][0])
for tau in SLOW_TAUS_S:
k, f, slow = 1 - math.exp(-1.0 / tau), None, []
for sec in range(first, last + 1): # the battery, each second
f = step(sec) if f is None else f + (step(sec) - f) * k
slow.append(f)
if var(lambda t: slow[min(int(t), last) - first], 0) <= pure[best]:
return None
return float(best)
class Answer:
"""The answer gate. `observe()` once per cycle, before the loop."""
def __init__(self):
self.ask = None # the pending command, W (site, + = give)
self.from_w = None # the battery reading when it was asked
self.from_grid = None # the meter when it was asked
self.since = None # when it was asked
self.started = False # a reading has moved towards it
self.late_seen = False # the last fitted delay was over DELAY_S
self.late_at = None # when the late verdict started
self.exempt = False # a battery that cannot follow explains it
self._was = None # (command, reading, meter) at the last call
self.delay = Delay()
self.delay_s = None # the meter's last fitted delay, s
self._fit_at = self._fit_ok = self._exempt_at = None
@property
def active(self) -> bool:
return self.ask is not None and self.late_seen
@property
def hold(self) -> int:
"""+1 / -1: the loop may not push further that way. 0: no hold."""
if not self.active or self.exempt:
return 0
return 1 if self.ask > self.from_w else -1
@property
def hold_w(self):
"""How far the loop may go while held: ANSWER_W past the reading."""
if not self.hold:
return None
return self._was[1] + self.hold * ANSWER_W
def peak_cap(self, standing_w):
"""The most the peak guard may ask, or None."""
return max(float(standing_w), 0.0) if self.active else None
def late_s(self, now):
"""Seconds the late verdict has stood, or None."""
return None if self.late_at is None else max(0.0, now - self.late_at)
def observe(self, now, standing_w, batt_w, *, grid_w=None, exempt=False,
idle_w=0.0):
"""`standing_w` the site command on the wire, `batt_w` the site
battery reading (None: unknown, nothing changes), `grid_w` the meter
(+ = import), `idle_w` what the batteries read while giving nothing.
`exempt`: a battery gives nothing under its ask, or refuses."""
if batt_w is None or standing_w is None:
return
cmd, r = float(standing_w), float(batt_w) - float(idle_w)
g = None if grid_w is None else float(grid_w)
self.exempt = bool(exempt)
if self.exempt:
self._exempt_at = now
was, self._was = self._was, (cmd, r, g)
self.delay.add(now, cmd, g)
if self._fit_at is None or now - self._fit_at >= FIT_EVERY_S:
self._fit_at = now
d = self.delay.estimate()
if self._exempt_at is not None and now - self._exempt_at < FIT_S:
d = None # a battery that does not follow spoils it
if d is not None:
self.delay_s, self.late_seen, self._fit_ok = d, d > DELAY_S, now
elif self._fit_ok is None or now - self._fit_ok > FIT_GONE_S:
self.late_seen = False # nothing to tell it by
if self.ask is None:
if was is None or cmd == was[0] or abs(cmd - was[1]) < ANSWER_W:
return
self.ask, self.from_w, self.from_grid = cmd, was[1], was[2]
self.since, self.started = now, False
self.ask = cmd
age = now - self.since
gap = self.ask - self.from_w
way = 1.0 if gap > 0 else -1.0
batt_to = (r - self.from_w) * way
grid_to = (-1e9 if g is None or self.from_grid is None
else (self.from_grid - g) * way)
if not self.started and max(batt_to, grid_to) >= ANSWER_W:
self.started, self.late_at = True, None
if (self.ask - r) * way < ANSWER_W or batt_to >= abs(gap) / 2:
self._clear() # answered
elif abs(gap) < ANSWER_W:
self._clear() # withdrawn
elif exempt:
self.late_at = None
elif (self.late_at is None and self.late_seen and not self.started
and age >= LATE_S):
self.late_at = now
def _clear(self):
self.ask = self.from_w = self.from_grid = self.since = None
self.started, self.late_at = False, None