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:
co-authored by
Claude
parent
c66aa7f69a
commit
5b6cb0bf08
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user