Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bacafadf0 | ||
|
|
c43873b142 | ||
|
|
b291262ee1 | ||
|
|
c70eeb6916 | ||
|
|
03541a1eee | ||
|
|
85433edac6 | ||
|
|
2667cf0dda | ||
|
|
5b6cb0bf08 | ||
|
|
c66aa7f69a |
@@ -4,6 +4,8 @@
|
||||
|
||||
- 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 within 100 seconds, not 3 minutes ([[SAFETY-129]]).
|
||||
- If one of the tomorrow solar forecast sensors stops updating in the afternoon, tomorrow's forecast now stays on the Forecast tab instead of vanishing until 20:00 ([[OPT-38]]).
|
||||
|
||||
## 0.20.1
|
||||
|
||||
|
||||
@@ -44,3 +44,5 @@ tonight." (`home_words.solar.kept`, EN and NL). The event log says
|
||||
**Not changed.** Control with modes off: it does not use the forecast. The
|
||||
screens still show the line, so it says "kept", not "used for the plan". A
|
||||
forecast that stays fresh. The hold does not look at the weather, only at the clock and the day.
|
||||
|
||||
**OPT-38 (2026-11-07).** A kept TOMORROW reading (at most `KEPT_MAX_AGE_S` old, same `day0`) now stands in at any hour, not only from `BELL_TO_H`: one sensor that stopped at 14:54 had dropped the whole sum (OPT-17) from 17:54 to 20:00. Today readings and the OPT-34 morning rules are unchanged; `test_opt38.py`.
|
||||
|
||||
@@ -31,7 +31,12 @@ 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). Alone it reads 35-90 s on healthy
|
||||
runs, a few times in a row; only the slow-start and battery-fit check
|
||||
below keeps those from a verdict. 10 s late is not found: it cannot be
|
||||
told from a 10 s slow battery, 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 +69,12 @@ 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 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
|
||||
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.
|
||||
- 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,22 @@ 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. Alone it reads 35-90 s on healthy runs, a few times in a row; what
|
||||
# keeps those from a verdict is the slow-start and battery-fit check in
|
||||
# watch(), not the limit. A 10 s lag is not found: it cannot be told from a
|
||||
# slow battery (R2-1), nor does it swing at 5 s.
|
||||
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 +193,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 +235,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 +257,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 +302,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 +322,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 +334,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 +348,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):
|
||||
|
||||
@@ -34,7 +34,9 @@ evening and api.forecast.solar is sometimes out for a poll. A kept reading
|
||||
stands in for its sensor (a) for up to `FRESH_S` when the sensor is missing
|
||||
or unavailable, as a sensor that merely stopped updating is already
|
||||
tolerated, and (b) from the end of the bell (`BELL_TO_H`) until local
|
||||
midnight, if it is at most `KEPT_MAX_AGE_S` old when used.
|
||||
midnight, if it is at most `KEPT_MAX_AGE_S` old when used. A TOMORROW
|
||||
reading needs no evening for (b), at any hour (OPT-38: one Forecast.Solar
|
||||
sensor stopped at 14:54 and tomorrow vanished from 17:54 to 20:00).
|
||||
Never beyond that, and never for another day: a today figure is only today's,
|
||||
a tomorrow figure is tomorrow's until midnight and then, until the bell starts
|
||||
(`BELL_FROM_H`, 06:00 local), today's (OPT-34: at most `KEPT_MORNING_MAX_AGE_S`
|
||||
@@ -330,8 +332,9 @@ class Hold:
|
||||
|
||||
The kept reading must be for `day0`, and either (a) at most `FRESH_S`
|
||||
old, or (b) used at night: from the end of the bell to local midnight,
|
||||
at most `KEPT_MAX_AGE_S` old. `kept` is true when (b) was needed,
|
||||
or `as_today` (OPT-34: a tomorrow figure standing in for today, after
|
||||
at most `KEPT_MAX_AGE_S` old; a "tomorrow" reading at any hour
|
||||
(OPT-38; `read_sum()` flags it kept only from the end of the bell).
|
||||
`kept` is true when (b) was needed, or `as_today` (OPT-34: a tomorrow figure standing in for today, after
|
||||
midnight and until the bell starts, `KEPT_MORNING_MAX_AGE_S`)."""
|
||||
rec = self._r.get((kind, entity, day0))
|
||||
if rec is None:
|
||||
@@ -347,8 +350,8 @@ class Hold:
|
||||
return (day, at, True) if ok else None
|
||||
if age > KEPT_MAX_AGE_S:
|
||||
return None
|
||||
# A tomorrow sensor is asked with day0 = the next midnight, which is
|
||||
# in the future all day, so the evening ends at that day's end.
|
||||
if kind == "tomorrow":
|
||||
return day, at, True # OPT-38: no evening needed
|
||||
evening = _local_hour(now) >= BELL_TO_H and now < _bounds(day0)[1]
|
||||
return (day, at, True) if evening else None
|
||||
|
||||
@@ -556,6 +559,9 @@ def read_sum(today, tomorrow, now: datetime, hold: Hold | None = None):
|
||||
if why0 is not None:
|
||||
return None, why0, gaps
|
||||
tday = _sum(tdays) if tomorrow and len(tdays) == len(tomorrow) else None
|
||||
kept = kept or (tday is not None and tkept)
|
||||
# OPT-38: before the bell ends a kept tomorrow reading is no "kept for
|
||||
# tonight": it must not replace today's line or the log.
|
||||
kept = kept or (tday is not None and tkept
|
||||
and _local_hour(now) >= BELL_TO_H)
|
||||
# The oldest member's report is when the sum was last fully known.
|
||||
return Forecast(_sum(days), tday, min(ats), kept), None, gaps
|
||||
|
||||
@@ -144,8 +144,9 @@ rs(st(5.0, at(4, 9, 5)), st(12.0, at(4, 9, 5)), at(4, 9, 6), h9)
|
||||
for label, tm in (("stale", st(12.0, at(4, 9, 5))), ("unavailable", GONE)):
|
||||
fd = rs(st(3.0, at(4, 13, 55)), tm, at(4, 14, 0), h9)[0]
|
||||
check(f"14:00, today fresh, tomorrow {label} since 09:05 (over 3 h): "
|
||||
"no tomorrow, nothing kept",
|
||||
fd is not None and fd.tomorrow is None and not fd.kept)
|
||||
"the 09:05 figure covers it, kept (OPT-38: at any hour)",
|
||||
fd is not None and fd.tomorrow is not None
|
||||
and fd.tomorrow.kwh == 12.0 and not fd.kept)
|
||||
fn = rs(st(0.1, at(4, 20, 55)), tm, at(4, 21, 0), h9)[0]
|
||||
check(f"21:00, tomorrow {label}: the 09:05 figure covers it, kept",
|
||||
fn is not None and fn.tomorrow is not None
|
||||
|
||||
@@ -186,8 +186,9 @@ check("a fresh figure replaces the kept one at once (04:00: 5.0 today, "
|
||||
and fy.today.kwh == 5.0 and fy.tomorrow.kwh == 9.0)
|
||||
fy2 = rs(st(5.0, at(4, 7, 55)), GONE, at(4, 8, 0), hd)[0]
|
||||
check("... and at 08:00, today fresh and tomorrow's sensor down for over 3 h: "
|
||||
"no tomorrow (the 3rd's figure is not the 5th's)",
|
||||
fy2 is not None and fy2.tomorrow is None and fy2.today.kwh == 5.0)
|
||||
"tomorrow is the 03:50 figure for the 5th (9.0, not flagged kept before 20:00: OPT-38), never "
|
||||
"the 3rd's 12.0", fy2 is not None and fy2.today.kwh == 5.0
|
||||
and fy2.tomorrow is not None and fy2.tomorrow.kwh == 9.0 and not fy2.kept)
|
||||
|
||||
# Restart: the saved hold keeps the figure.
|
||||
for label, now in (("01:00", at(4, 1, 0)), ("04:00", at(4, 4, 0))):
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Runnable check for OPT-38. `python3 test_opt38.py`
|
||||
|
||||
Live 0.20.1, 2026-11-06: one of three Forecast.Solar tomorrow sensors last
|
||||
reported 14:54. From 17:54 (over FRESH_S) to 20:00 (BELL_TO_H) nothing stood in
|
||||
for it, the OPT-17 sum was dropped and the Forecast tab had no tomorrow. A kept
|
||||
tomorrow reading now stands in at any hour (`Hold.stand_in()`). Today members,
|
||||
and the OPT-34 morning rules, are as before.
|
||||
|
||||
Mutations (MUTATION.md, purged copies): see the pull request.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
|
||||
from app import homeview, solarfc # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
L = timezone(timedelta(hours=1)) # Brussels in winter (CET)
|
||||
|
||||
|
||||
def at(day, hh, mm=0):
|
||||
return datetime(2026, 11, day, hh, mm, tzinfo=L).astimezone(timezone.utc)
|
||||
|
||||
|
||||
def st(kwh, when):
|
||||
return {"state": f"{kwh}", "attributes": {"unit_of_measurement": "kWh"},
|
||||
"last_updated": when.isoformat(), "last_reported": when.isoformat()}
|
||||
|
||||
|
||||
TODAY = [("sensor.t", None)]
|
||||
M = ["sensor.m1", "sensor.m2", "sensor.m3"]
|
||||
|
||||
|
||||
def run(hold, readings, now, today_at=None):
|
||||
t = [("sensor.t", st(0.5, today_at or now - timedelta(minutes=5)))]
|
||||
return solarfc.read_sum(
|
||||
t, [(e, st(k, w)) for e, (k, w) in zip(M, readings)], now, hold)
|
||||
|
||||
|
||||
# The live day: m2 last reported 14:54, the others keep reporting.
|
||||
h = solarfc.Hold()
|
||||
run(h, [(4.0, at(6, 14, 0)), (5.0, at(6, 14, 54)), (6.0, at(6, 14, 0))],
|
||||
at(6, 14, 55))
|
||||
fresh = lambda w: [(4.0, w), (5.0, at(6, 14, 54)), (6.0, w)] # noqa: E731
|
||||
f, why, gaps = run(h, fresh(at(6, 18, 20)), at(6, 18, 30))
|
||||
check("18:30, one member last reported 14:54: tomorrow sum is there (not "
|
||||
"flagged kept before 20:00)",
|
||||
f is not None and f.tomorrow is not None and f.tomorrow.kwh == 15.0
|
||||
and not f.kept and gaps == ())
|
||||
f, _w, _g = run(h, fresh(at(6, 17, 40)), at(6, 17, 50))
|
||||
check("17:50 (just under 3 h): fresh, not kept",
|
||||
f is not None and f.tomorrow.kwh == 15.0 and not f.kept)
|
||||
f, _w, _g = run(h, fresh(at(6, 17, 55)), at(6, 18, 0))
|
||||
check("18:00 (stale since 17:54): tomorrow there",
|
||||
f is not None and f.tomorrow is not None and not f.kept)
|
||||
f, _w, _g = run(h, fresh(at(6, 19, 50)), at(6, 19, 55))
|
||||
check("19:55 (before the bell ends): tomorrow there", f.tomorrow is not None)
|
||||
|
||||
# Too old: over KEPT_MAX_AGE_S (12 h) when used.
|
||||
h2 = solarfc.Hold()
|
||||
run(h2, [(4.0, at(6, 5, 0)), (5.0, at(6, 5, 0)), (6.0, at(6, 5, 0))],
|
||||
at(6, 5, 5))
|
||||
w = at(6, 16, 50)
|
||||
f, why, gaps = run(h2, [(4.0, w), (5.0, at(6, 5, 0)), (6.0, w)],
|
||||
at(6, 17, 10))
|
||||
check("member 12 h 10 min old: no tomorrow sum (OPT-17 rule), today stays",
|
||||
f is not None and f.tomorrow is None
|
||||
and [g[0] for g in gaps] == ["tomorrow"])
|
||||
f, _w, _g = run(h2, [(4.0, w), (5.0, at(6, 5, 0)), (6.0, w)],
|
||||
at(6, 16, 50))
|
||||
check("... and 11 h 50 min old is still kept",
|
||||
f is not None and f.tomorrow is not None and not f.kept)
|
||||
|
||||
# Not a partial sum: a member never read has nothing to stand in.
|
||||
f, _w, gaps = run(solarfc.Hold(), [(4.0, at(6, 18, 0)),
|
||||
(5.0, at(6, 14, 54)),
|
||||
(6.0, at(6, 18, 0))], at(6, 18, 30))
|
||||
check("a member with no kept reading: no tomorrow sum, no partial sum",
|
||||
f is not None and f.tomorrow is None and len(gaps) == 1)
|
||||
|
||||
# Today members are as before: a stale today member at 18:30 is not kept.
|
||||
h3 = solarfc.Hold()
|
||||
solarfc.read_sum([("sensor.t", st(1.0, at(6, 14, 54)))],
|
||||
[(M[0], st(9.0, at(6, 14, 54)))], at(6, 14, 55), h3)
|
||||
f, why, _g = solarfc.read_sum([("sensor.t", st(1.0, at(6, 14, 54)))],
|
||||
[(M[0], st(9.0, at(6, 18, 20)))],
|
||||
at(6, 18, 30), h3)
|
||||
check("stale today member at 18:30: not kept, no forecast",
|
||||
f is None and why == solarfc.STALE)
|
||||
f, _w, _g = solarfc.read_sum([("sensor.t", st(1.0, at(6, 14, 54)))],
|
||||
[(M[0], st(9.0, at(6, 20, 0)))],
|
||||
at(6, 20, 30), h3)
|
||||
check("... and at 20:30 it is kept, as before (the bell has ended)",
|
||||
f is not None and f.kept)
|
||||
|
||||
# After midnight: a tomorrow reading from before midnight is never tomorrow's.
|
||||
h4 = solarfc.Hold()
|
||||
run(h4, [(4.0, at(6, 14, 0)), (5.0, at(6, 14, 0)), (6.0, at(6, 14, 0))],
|
||||
at(6, 14, 5))
|
||||
for label, now in (("00:30", at(7, 0, 30)), ("10:00", at(7, 10, 0)),
|
||||
("15:00", at(7, 15, 0))):
|
||||
f, _w, _g = solarfc.read_sum(
|
||||
[("sensor.t", st(2.0, now - timedelta(minutes=5)))],
|
||||
[(e, st(k, at(6, 14, 0))) for e, k in zip(M, (4.0, 5.0, 6.0))],
|
||||
now, h4)
|
||||
check(f"{label} on the 7th: the 6th's tomorrow figure is not the "
|
||||
"7th's tomorrow", f is not None and f.tomorrow is None)
|
||||
|
||||
# Before 20:00 a kept tomorrow reading is not "kept for tonight": today's
|
||||
# line stays the normal one (EN and NL) and the event log (main.py, from
|
||||
# `fc.kept`) has no "kept".
|
||||
h5 = solarfc.Hold()
|
||||
solarfc.read_sum([("t", st(3.0, at(6, 9, 0)))], [("m", st(12.0, at(6, 9, 5)))],
|
||||
at(6, 9, 6), h5)
|
||||
for hh in (14, 18, 19):
|
||||
f, _w, _g = solarfc.read_sum([("t", st(2.5, at(6, hh - 1, 55)))],
|
||||
[("m", st(12.0, at(6, 9, 5)))],
|
||||
at(6, hh, 0), h5)
|
||||
check(f"{hh}:00, today fresh, tomorrow kept from 09:05: tomorrow there, "
|
||||
"not flagged kept", f is not None and f.tomorrow is not None
|
||||
and f.tomorrow.kwh == 12.0 and f.kept is False)
|
||||
f, _w, _g = solarfc.read_sum([("t", st(2.5, at(6, 13, 55)))],
|
||||
[("m", st(12.0, at(6, 9, 5)))], at(6, 14, 0), h5)
|
||||
for lang in ("en", "nl"):
|
||||
with homeview.in_lang(lang):
|
||||
line = homeview.line_solar(f.today.kwh, f.today.kwh * 0.7,
|
||||
homeview.hhmm(f.at), kept=f.kept)
|
||||
kept_line = homeview.line_solar(1, 1, homeview.hhmm(f.at), kept=True)
|
||||
print(f" {lang}: {line!r}")
|
||||
if lang == "en":
|
||||
check("... EN says 'Solar still to come today'",
|
||||
line.startswith("Solar still to come today"))
|
||||
check(f"14:00 Home line ({lang}) is the normal line, not the kept one",
|
||||
line != kept_line and "2,5" in line.replace(".", ",")
|
||||
and "13:55" in line)
|
||||
f, _w, _g = solarfc.read_sum([("t", st(0.2, at(6, 20, 55)))],
|
||||
[("m", st(12.0, at(6, 9, 5)))], at(6, 21, 0), h5)
|
||||
check("21:00 as before: tomorrow kept from 09:05 is flagged kept",
|
||||
f is not None and f.kept and f.tomorrow.kwh == 12.0)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(" -", f)
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -229,9 +229,11 @@ late5 = R.Rig(lag_s=30.0, lag_on=ON, lag_off=ON + 300.0, p1_s=5.0)
|
||||
late5.run(ON + 300.0, lambda rig, c, t: rows5.extend(
|
||||
str(r["text"]) for r in c.checks() if r.get("id") == "late"))
|
||||
print(f" 5 s meter, 30 s late: {sorted(set(rows5))}")
|
||||
check("at a 5 s meter (the batteries' fit) the row names the delay: about "
|
||||
"20-32 s for 30 s late",
|
||||
rows5 and set(rows5) <= _ok_texts and any("about" in x for x in rows5))
|
||||
# SAFETY-129: the 5 s meter's fit finds it first (no figure); the hold then
|
||||
# leaves the batteries' fit nothing to measure, so the row names no delay.
|
||||
check("at a 5 s meter the row says the readings are late, with no figure "
|
||||
"(the meter alone found it)",
|
||||
rows5 and set(rows5) == {str(messages.row_readings_late(None))})
|
||||
check("logged once for the episode, and once when it ends, not per cycle",
|
||||
n_late == 1 and n_back == 1)
|
||||
check("the row goes once a fit shows the readings on time, or 2 minutes "
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
"""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 100 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 at 2, 5 and 10 s, HA
|
||||
restarts and meter pauses at 2 and 5 s, 8-10 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 100 s of the lag starting",
|
||||
first is not None and first - ON <= 100.0)
|
||||
check(f"{n}: the export never goes below -200 W", low >= -200.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))
|
||||
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 + 200.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=800.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)
|
||||
|
||||
# At a 6 s meter an 8 s dead time reads late on the slow fit alone; only the
|
||||
# slow-start and battery-fit check keeps it from a verdict (122 s late
|
||||
# without the check, from 1 084 s).
|
||||
r, secs, first = run_late(house=owner, noise=80.0, units=AB, p1_s=6.0,
|
||||
dead_s=8.0, dur=1500.0)
|
||||
print(f" 8 s dead time, 6 s meter: late {secs} s")
|
||||
check("an 8 s dead time at a 6 s meter is never late (the slow fit needs an "
|
||||
"ask slow to start and no battery fit on time)", 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"): # 12 s dead time: late from about 140 s
|
||||
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=900.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 (2.0, 5.0, 10.0):
|
||||
kw = dict(house=owner, noise=80.0, units=AB, p1_s=p1)
|
||||
r, secs, first = run_late(dur=800.0, **kw)
|
||||
new = r
|
||||
old = R.Rig(**kw)
|
||||
no_gate(lambda: old.run(800.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),), False),
|
||||
("meter pause", ((640.0, 700.0),), True)):
|
||||
for p1 in (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=800.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())
|
||||
def fed(values):
|
||||
"""Answer.watch() with the slow fit reading `values`, one per fit."""
|
||||
a = lateness.Answer()
|
||||
a.meter.slow = lambda: True
|
||||
a.meter.estimate = a.delay.estimate = lambda: None
|
||||
it = iter(values)
|
||||
a.meter_slow.estimate = lambda: next(it, None)
|
||||
for k in range(len(values)):
|
||||
a.watch(2.0 * k, [], 0.0, 0.0)
|
||||
return a
|
||||
|
||||
|
||||
check("one late slow-meter fit is no fit; two in a row are one, and "
|
||||
"one on time or in between is none",
|
||||
fed([30.0]).fits == 0 and fed([30.0, 30.0]).fits == 1
|
||||
and fed([30.0, None, 30.0]).fits == 0 and fed([30.0, 12.0]).fits == 0
|
||||
and fed([3.0, 3.0]).fits == 0 and not fed([30.0, 30.0]).late_seen)
|
||||
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 late limit is above the fast one's and above a "
|
||||
"10 s lag", 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