SAFETY-129: find readings 30 s late within about 90 s at a 5 s meter

The meter's delay fit needed 16 new values in 60 s, so at the owner's 5 s P1
only the batteries' fit ran and 30 s late was found after about 3 minutes
(export to -420 W). A meter that slow is now also fitted over 120 s; it calls
a delay late only over 16 s, on two fits in a row, with the same corroboration
as before, and never ends a verdict, so a slow battery (R2-1) runs as before.
Found about 90 s after the start, export above -200 W. 10 s late at a 5 s
meter is not found (nothing swings; it cannot be told from a 10 s battery).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-11-06 19:38:21 +01:00
co-authored by Claude
parent c66aa7f69a
commit 5b6cb0bf08
4 changed files with 271 additions and 19 deletions
+1
View File
@@ -4,6 +4,7 @@
- Home now draws today's charge from the grid for every hour the add-on will really charge, not only the run under way ([[UX-77]]).
- When Home Assistant passes readings on late, control no longer swings on them: it waits until the battery shows its last power request, and the peak limit no longer asks for full power on them. The page says so, and if the readings stay too late every inverter goes to 0 W ([[SAFETY-128]]).
- With a 5 s meter, readings 30 s late are now noticed after about 90 seconds, not 3 minutes ([[SAFETY-129]]).
## 0.20.1
@@ -31,7 +31,11 @@ inverter limit in its last seconds (OPT-34 rig F-2).
- **Meter** (`MeterFit`, round 1's fit): `meter(t) + request(t - d)` is the
house alone at the right `d`. Quick, but confounded by the house. Needs
16 new values in 60 s, so only a meter of about 3.5 s or faster: at 5 s
it gave false delays. Late over 8 s.
it gave false delays. Late over 8 s. At a slower meter (a 5 s P1) the
same fit runs over 120 s and calls only a delay over 16 s late, on 2 fits
in a row, and never ends a verdict (SAFETY-129): a 10-12 s slow battery
reads up to 15 s there, so 10 s late is not told from it, and it does not
swing at 5 s.
- A meter fit says late only with evidence from the batteries' side too:
a request more than 100 W from the battery reading took 3 s or more to
move a reading towards it (`SLOW_START_S`; on time they start within
@@ -64,9 +68,10 @@ house at 1, 2 and 5 s ticks, so the commands are the same as without the gate.
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, about 3 minutes at a 5 s
meter (battery fit only; the meter fit needs a faster meter). Until then control is as before. At 5 s and 30 s
late the export is cut to about a third, not removed.
- Found 36-68 s after a lag starts at 1-2 s meters; at a 5 s meter 30 s late
is found about 90 s after it starts (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
then control is as before.
- A battery that answers slowly is read as late readings and held: a dead
time of 10 s or more (8 s reads 7-8; 10 s reads 10 at 1-2 s meters, 9 at
5 s), or a 15-20 s lag (review round 2). The GoodWe measured settles in 6 s
+50 -15
View File
@@ -10,7 +10,8 @@ already fixed. The readings show it: they answer each power request late.
house does not move that reading, so a repeating load cannot fool it. It
needs the request to move FIT_MOVE_W; one reading per 10 s makes it slow.
- `MeterFit`: how long after the site request the meter shows it. Quick at a
1-3 s meter, none at a slower one, and a house load that repeats can fool it.
1-3 s meter, slower and for long lags only at a 5 s one (SAFETY-129), and a
house load that repeats can fool it.
The meter counts only when an ask was slow to start and no battery fit said
"on time"; with hysteresis (ON_S / OFF_S) the result is `late_seen`. Then (`observe()`, each cycle):
- a new command more than ANSWER_W from the battery reading is PENDING until
@@ -163,13 +164,20 @@ class Delay:
# The meter's delay: fitted over this much history, from this many new
# values (a meter of about 3.5 s or faster: a slower one gives too few to
# fit without false delays); late over M_LATE_S (the plant's 6 s settle and one 2 s meter tick),
# but only when the batteries' own readings have not just shown them on time.
# values (a meter of about 3.5 s or faster; a slower one see M_SLOW_FIT_S);
# late over M_LATE_S (the plant's 6 s settle and one 2 s meter tick), but only
# when the batteries' own readings have not just shown them on time.
M_FIT_S = 60.0
M_ROWS = 16
M_LATE_S = freshness.PLANT_SETTLE_S + 2.0 # 8.0
M_SHARE, M_BETTER = 0.4, 0.7
# SAFETY-129: a meter slower than that (a 5 s P1) has fewer than M_ROWS values
# in M_FIT_S, so it is fitted over M_SLOW_FIT_S instead, from the same M_ROWS
# values. It calls a delay late only over M_SLOW_LATE_S and on ON_FITS fits in
# a row: the slow-battery runs (10-12 s dead time, R2-1) read up to 15 s at a
# 5 s meter, and a 10 s lag cannot be told from them, nor does it swing.
M_SLOW_FIT_S = 120.0
M_SLOW_LATE_S = 16.0
# A meter fit counts only after an ask that took this long to start moving a
# reading: on time they start within 1-2 s (the recorded house, at 1-5 s).
SLOW_START_S = 3.0
@@ -183,19 +191,30 @@ class MeterFit:
up with an earlier request, so it only counts while the batteries' own
fit is not on time (`Answer.watch`)."""
def __init__(self):
def __init__(self, fit_s=M_FIT_S, late_s=M_LATE_S):
self.fit_s, self.late_s = fit_s, late_s
self.cmds = deque() # (t, request) when it changed
self.grid = deque() # (t, meter) new values only
def slow(self):
"""The meter is too slow for M_ROWS values in `fit_s`: the usual gap
between its new values (median, so a pause does not count) is over
fit_s / M_ROWS."""
ts = [t for t, _g in self.grid]
if len(ts) < 5:
return False
gaps = sorted(b - a for a, b in zip(ts, ts[1:]))
return gaps[len(gaps) // 2] * M_ROWS > self.fit_s
def add(self, now, cmd, grid):
if cmd is not None and (not self.cmds or self.cmds[-1][1] != cmd):
self.cmds.append((now, float(cmd)))
if grid is not None and (not self.grid or self.grid[-1][1] != grid):
self.grid.append((now, float(grid)))
while self.grid and self.grid[0][0] < now - M_FIT_S:
while self.grid and self.grid[0][0] < now - self.fit_s:
self.grid.popleft()
while (len(self.cmds) > 1
and self.cmds[1][0] <= now - M_FIT_S - MAX_DELAY_S):
and self.cmds[1][0] <= now - self.fit_s - MAX_DELAY_S):
self.cmds.popleft()
def estimate(self):
@@ -214,9 +233,9 @@ class MeterFit:
best = min(range(len(pure)), key=lambda d: (pure[d], d))
if g_var <= 0 or pure[best] > (1 - M_SHARE) * g_var:
return None
if best <= M_LATE_S:
if best <= self.late_s:
return float(best)
if pure[best] > M_BETTER * min(pure[:int(M_LATE_S) + 1]):
if pure[best] > M_BETTER * min(pure[:int(self.late_s) + 1]):
return None
return float(best)
@@ -236,6 +255,8 @@ class Answer:
self._was = None # (command, reading, meter) at the last call
self.delay = Delay()
self.meter = MeterFit()
self.meter_slow = MeterFit(M_SLOW_FIT_S, M_SLOW_LATE_S) # SAFETY-129
self._ms_run = 0 # late slow-meter fits in a row
self._batt_fit = None # (when, d): the batteries' last fit
self._b_run = 0 # late battery fits in a row
self._start_age = None # (when, how long the last ask took to start)
@@ -279,14 +300,18 @@ class Answer:
delays. Late on ON_FITS battery fits from ON_S, or on a meter fit
over M_LATE_S when an ask was slow to start (SLOW_START_S) and no
battery fit within FIT_S said on time: a house load that repeats can
fool the meter fit, not these. On time again on a battery fit under
OFF_S, or a meter fit within M_LATE_S unless a battery fit said late.
fool the meter fit, not these. A meter too slow for M_ROWS values in
M_FIT_S (a 5 s P1) is fitted over M_SLOW_FIT_S and counts only over
M_SLOW_LATE_S, on ON_FITS fits in a row. On time again on a battery fit
under OFF_S, or a meter fit within M_LATE_S unless a battery fit said
late.
"""
if self._start is None:
self._start = now
for name, cmd, reading in units:
self.delay.add(now, name, cmd, reading)
self.meter.add(now, standing_w, grid_w)
self.meter_slow.add(now, standing_w, grid_w)
if self._fit_at is not None and now - self._fit_at < FIT_EVERY_S:
return
self._fit_at = now
@@ -295,6 +320,9 @@ class Answer:
and now - self._exempt_at <= FIT_S:
b = None # a battery that stopped following: no late fit
m = self.meter.estimate()
ms = None
if self.meter.slow():
ms = self.meter_slow.estimate()
if b is not None:
self._batt_fit = (now, b)
self.shown_s = b if b >= ON_S else None
@@ -304,7 +332,13 @@ class Answer:
b_late = recent and self._batt_fit[1] >= ON_S
self._b_run = self._b_run + 1 if (b or 0.0) >= ON_S else 0
d = b if b is not None else m
self._ms_run = self._ms_run + 1 if (ms or 0.0) > M_SLOW_LATE_S else 0
# A slow-meter fit only ever adds a late verdict: it counts as a fit
# when it is late on ON_FITS in a row, and never ends one, so a slow
# battery (R2-1) runs exactly as before.
if self._ms_run < ON_FITS:
ms = None
d = b if b is not None else m if m is not None else ms
if d is None:
if self._fit_ok is None or now - self._fit_ok > FIT_GONE_S:
self.late_seen = False # nothing to tell it by
@@ -312,12 +346,13 @@ class Answer:
self.fits += 1
self.delay_s, self._fit_ok = d, now
warm = now - self._start >= MAX_DELAY_S + FIT_S
m_late = (m or 0.0) > M_LATE_S
m_late = ((m or 0.0) > M_LATE_S and warm) or (
self._ms_run >= ON_FITS
and now - self._start >= MAX_DELAY_S + M_SLOW_FIT_S)
slow = (self._start_age is not None
and now - self._start_age[0] <= M_FIT_S
and self._start_age[1] >= SLOW_START_S)
if self._b_run >= ON_FITS or (
m_late and warm and slow and not b_fresh):
if self._b_run >= ON_FITS or (m_late and slow and not b_fresh):
self.late_seen = True
elif (b is not None and b < OFF_S) or (
m is not None and m <= M_LATE_S and not b_late):
+211
View File
@@ -0,0 +1,211 @@
"""Runnable check for SAFETY-129: late Home Assistant readings found at a 5 s meter.
`python3 test_safety129.py`
SAFETY-128 fitted the meter's delay only at a meter of about 3.5 s or faster
(16 new values in 60 s). The owner's P1 reports every 5 s, so there only the
batteries' fit ran: with readings 30 s late the loop swung to -420 W export
and was found after about 3 minutes (-1 325 W before the hold).
`lateness.MeterFit` now also runs at a slow meter over 120 s, and calls a
delay late only over `M_SLOW_LATE_S` on two fits in a row, with the same
corroboration as before (an ask slow to start, no battery fit on time).
DECISIONS/SAFETY-128-late-readings.md.
The REAL Controller on TEST-23's plant (tools/safety128_rig.py).
1. 5 s meter, readings 30 s late (N=2 house 1 800 W, N=1 house 1 500 W): found
within 120 s, and the export never goes below -200 W (red on c66aa7f: found
after 190 s, export to -420 W).
2. 5 s meter, readings 10 s late: no swing, nothing to hold; and the limit that
goes with it: a battery that answers in 10 s (R2-1) is still not read as
late at a 5 s meter, which a lower slow-meter threshold would break.
3. No false holds at a slow meter: the owner's shape, HA restarts and meter
pauses at 1, 2 and 5 s, a 4 s and a 10 s meter, 6-8 s dead times: never
late, so the commands are those of the run without the gate.
4. The slow-meter fit at its 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.WARNING)
import safety128_rig as R # noqa: E402
import app.main as app_main # noqa: E402
from app import lateness # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def no_gate(fn):
"""`fn()` with the answer gate off: the tree before SAFETY-128."""
real = app_main.Controller._readings_late
app_main.Controller._readings_late = lambda self, standing_w: None
try:
return fn()
finally:
app_main.Controller._readings_late = real
def run_late(**kw):
"""Run the rig; (rig, seconds the verdict stood, first second it stood)."""
dur = kw.pop("dur")
seen = []
r = R.Rig(**kw)
r.run(dur, lambda rig, c, t: seen.append(t) if c.answer.late_seen
else None)
return r, len(seen), min(seen) if seen else None
def grid_min(rows, t0, t1):
return min(r[1] for r in rows if t0 <= r[0] < t1)
AB, A = ("a", "b"), ("a",)
ON = 300.0
# --------------------------------------------------------------------------- #
print("1. 5 s meter, readings 30 s late for 300 s")
for units, house in ((AB, 1800.0), (A, 1500.0)):
n = f"N={len(units)}"
r, secs, first = run_late(house=house, lag_s=30.0, lag_on=ON,
lag_off=ON + 300.0, units=units, p1_s=5.0,
dur=ON + 420.0)
low = grid_min(r.rows, ON, ON + 300.0)
print(f" {n}: found {None if first is None else first - ON:.0f} s "
f"after the start, export to {low:.0f} W")
check(f"{n}: found within 120 s of the lag starting",
first is not None and first - ON <= 120.0)
check(f"{n}: the export never goes below -200 W", low >= -200.0)
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))
check(f"{n}: without the gate the export goes below -1 kW",
grid_min(old.rows, ON, ON + 300.0) < -1000.0)
# --------------------------------------------------------------------------- #
print("\n2. 5 s meter, readings 10 s late; a battery that answers in 10 s")
r, secs, first = run_late(house=1800.0, lag_s=10.0, lag_on=ON,
lag_off=ON + 150.0, units=AB, p1_s=5.0,
dur=ON + 270.0)
low = grid_min(r.rows, ON, ON + 150.0)
print(f" 10 s late: export to {low:.0f} W, verdict stood {secs} s")
check("10 s late: no swing, the export never goes below -200 W", low >= -200.0)
def owner(t):
w = 400.0
if 600 <= t < 780:
w += 2200
if 1200 <= t < 2400 and ((t - 1200) % 105) < 45:
w += 2000
return w
for dead in (8.0, 10.0):
r, secs, first = run_late(house=owner, noise=80.0, units=AB, p1_s=5.0,
dead_s=dead, dur=1500.0)
print(f" {dead:.0f} s dead time, 5 s meter: late {secs} s")
check(f"{dead:.0f} s dead time at a 5 s meter is not read as late "
f"(R2-1 does not get worse)", secs == 0)
# A 12 s dead time is already read as late (R2-1, SAFETY-128); the slow-meter
# fit must not make that longer. The same run with the slow fit off is the
# tree before this card.
_slow = lateness.MeterFit.slow
secs = {}
for label in ("with", "without"):
lateness.MeterFit.slow = _slow if label == "with" else (lambda self: False)
try:
secs[label] = run_late(house=owner, noise=80.0, units=A, p1_s=5.0,
dead_s=12.0, dur=3300.0)[1]
finally:
lateness.MeterFit.slow = _slow
print(f" 12 s dead time, N=1, 5 s meter: late {secs['with']} s, with the "
f"slow fit off {secs['without']} s")
check("a 12 s dead time at a 5 s meter is late no longer than before "
"(R2-1 does not get worse)", secs["with"] <= secs["without"])
# --------------------------------------------------------------------------- #
print("\n3. no false holds")
_view = R.Plant.view
def frozen(windows, meter_only):
def view(self, t, lag_s, lag_on, lag_off):
cur = _view(self, t, lag_s, lag_on, lag_off)
for a, b in windows:
if a <= t < b:
for row in reversed(self.hist):
if row[0] <= a + 1e-9:
old = row[1:]
return (old[0], old[1], cur[2], cur[3]) \
if meter_only else old
return cur
return view
for p1 in (1.0, 2.0, 4.0, 5.0, 10.0):
kw = dict(house=owner, noise=80.0, units=AB, p1_s=p1)
r, secs, first = run_late(dur=1500.0, **kw)
new = r
old = R.Rig(**kw)
no_gate(lambda: old.run(1500.0))
same = max(abs(x[2] - y[2]) for x, y in zip(new.rows, old.rows))
print(f" owner shape, {p1:.0f} s meter: late {secs} s, command "
f"difference {same:.0f} W")
check(f"{p1:.0f} s meter, owner shape: never late, same commands",
secs == 0 and same == 0)
for label, windows, meter_only in (
("HA restart", ((650.0, 710.0), (1300.0, 1345.0)), False),
("meter pause", ((640.0, 700.0), (1250.0, 1300.0)), True)):
for p1 in (1.0, 2.0, 5.0):
R.Plant.view = frozen(windows, meter_only)
try:
r, secs, first = run_late(house=owner, noise=80.0, units=AB,
p1_s=p1, dur=1400.0)
finally:
R.Plant.view = _view
check(f"{label} at a {p1:.0f} s meter: never late", secs == 0)
# --------------------------------------------------------------------------- #
print("\n4. the slow-meter fit at its edges")
def meter_with(period, pause_after=None, pause_s=40.0, n=24):
f = lateness.MeterFit()
f.add(0.0, 100.0, 0.0)
for k in range(1, n):
gap = pause_s if pause_after is not None and k > pause_after else 0.0
f.add(period * k + gap, None, float(k))
return f
check("a 5 s and a 10 s meter are slow",
meter_with(5.0).slow() and meter_with(10.0).slow())
check("a 1 s, a 2 s and a 3 s meter are not slow",
not any(meter_with(p, n=60).slow() for p in (1.0, 2.0, 3.0)))
check("a pause in a 2 s meter does not make it slow",
not meter_with(2.0, pause_after=10, n=40).slow())
check("the slow fit asks for a longer window and a later delay than the "
"fast one", lateness.M_SLOW_FIT_S > lateness.M_FIT_S
and lateness.M_SLOW_LATE_S > lateness.M_LATE_S)
check("the slow fit's delay is above what a 10-12 s slow battery reads "
"(R2-1: up to 15 s at a 5 s meter)", lateness.M_SLOW_LATE_S >= 16.0)
print()
if fails:
print(f"{len(fails)} FAILED: " + ", ".join(fails))
sys.exit(1)
print("all checks passed")