Compare commits
3
Commits
SAFETY-129
...
OPT-38
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0bacafadf0 | ||
|
|
c43873b142 | ||
|
|
b291262ee1 |
@@ -5,6 +5,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 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`.
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user