SAFETY-90 review: count the start-up seconds like a gap

While the add-on is down the batteries are at 0 W (it sets 0 W when it
stops; the ESP32 watchdog after 30 s), so the import before its first
reading was the house, not the aim. Those seconds now count at
gap_w(limit, the import seen first), and after a restart the restored
reading's held 30 s is topped up the same way. Status row: "counted as
high use". Tests take the pre-start import as the house (batteries at
0 W) and add restarts of 10/60/150 s with a restored averager.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Claude (SAFETY-90 review)
2026-10-05 12:24:33 +02:00
parent 9d3fbe1be9
commit 8dfe91e1d2
5 changed files with 120 additions and 43 deletions
+65 -19
View File
@@ -4,8 +4,8 @@
A gap past `meter_max_age_s` left seconds the quarter average never counted,
so the guard was blind for the rest of the quarter: a HOLD gave 0 W and the
quarter raised the month peak (SAFETY-86 rig case 7b: 3964.8 W against a
3450 W aim). Now the missed seconds count at `peakguard.gap_w()`, and seconds
before the add-on's first reading count at the aim, with a plain line on the
3450 W aim). Now the missed seconds count at `peakguard.gap_w()`, and so do
the seconds before the add-on's first reading, with a plain line on the
page. Drives the REAL Controller on a lagging battery (10 s), with the P1
telegrams stopped for each gap. DECISIONS/SAFETY-90-peak-guard-after-a-gap.md.
@@ -35,6 +35,7 @@ logging.disable(logging.WARNING)
import opt11_identity as rig # noqa: E402
import safety84_identity as s84 # noqa: E402
import app.p1 as p1mod # noqa: E402
import app.tariff as tariff # noqa: E402
from app import homeview, messages, peakguard # noqa: E402
from app.maintenance import HOLD # noqa: E402
from app.p1 import make_sample, split_signed # noqa: E402
@@ -280,32 +281,77 @@ def grab(c, t):
UI["nl"] = homeview.month_peak(c, c.cycle_at)["line"]
finally:
homeview._LANG[0] = "en"
if t == 500.0:
if t == 453.0:
UI["later"] = (c.peak_unmeasured_s,
[r for r in c.checks() if r.get("id") == "peak_guard"])
for n, socs, house in ((1, {"a": 99.0}, 6500.0),
(2, {"a": 99.0, "b": 15.0}, 5500.0)):
rows, c = run(house, socs, 950.0, n=n, start=450.0,
on_cycle=grab if n == 1 else None)
orows, oc = without_fix(lambda: run(house, socs, 950.0, n=n, start=450.0))
seen = sum(max(g, 0.0) for t, g in c.truth if t < 900) / QUARTER_S
old_seen = sum(max(g, 0.0) for t, g in oc.truth if t < 900) / QUARTER_S
land = seen + 450.0 * AIM / QUARTER_S
def restored(t_dead, start=450.0):
"""TARIFF-01 restores the averager at start-up: 0..t_dead measured at
the aim, the held sample at t_dead (the restart's first second)."""
def fake(self, averager, now=None):
bs = int(rig.BASE.timestamp() - start)
averager.restore({"block": bs, "acc": AIM * t_dead,
"pp_acc": [AIM * t_dead], "elapsed": t_dead,
"last_t": bs + t_dead, "last_net": AIM,
"last_pp": [AIM], "phases": 1})
self.restore_state = "resumed"
return self.restore_state
return fake
def startup(house, socs, n, *, t_dead=None, on_cycle=None):
"""The add-on starts 450 s in. While it was down the batteries were at
0 W (the ESP32 watchdog), so the meter saw the house: the quarter is
that, or the restored part at the aim, plus what this run measured."""
real = tariff.PeakTracker.restore
if t_dead is not None:
tariff.PeakTracker.restore = restored(t_dead)
try:
rows, c = run(house, socs, 455.0, n=n, start=450.0,
on_cycle=on_cycle)
finally:
tariff.PeakTracker.restore = real
pre = 450.0 * house if t_dead is None else (
t_dead * AIM + (450.0 - t_dead) * house)
return rows, c, (pre + sum(max(g, 0.0) for t, g in c.truth if t < 900)) \
/ QUARTER_S
for n, socs, house in ((1, {"a": 99.0}, 5000.0),
(2, {"a": 99.0, "b": 15.0}, 4500.0)):
rows, c, land = startup(house, socs, n, on_cycle=grab if n == 1 else None)
orows, _oc, old = without_fix(lambda: startup(house, socs, n))
TABLE.append((f"N={n} start-up mid-quarter", n, round(land, 1),
round(old_seen + 450.0 * AIM / QUARTER_S, 1)))
print(f" N={n}: measured part {seen:.1f} W + 450 s at the aim = "
f"{land:.1f} W (before the fix {old_seen:.1f} W + the same)")
check(f"⭐ N={n}: with the first 450 s at the aim the quarter lands at or "
f"under it; the holding A gives the need from the first minute",
land <= AIM + TOL and mean(rows, "a", 30, 450) >= house - AIM)
round(old, 1)))
check(f"⭐ N={n}: the first 450 s (house {house:.0f} W, batteries at 0 W) "
f"count at the import seen first; the quarter lands at or under "
f"the aim ({land:.1f} W, before the fix {old:.1f} W)",
land <= AIM + TOL and (n == 2 or old > AIM + 100))
if n == 2:
check("...and the held B gives nothing (O2 at start-up)",
all(cmd(r)["b"] == 0.0 for r in rows if 1 <= r[0] < 450))
check("...and the held B gives nothing (O2 at start-up); before the "
"fix it was drawn at 2500 W",
all(cmd(r)["b"] == 0.0 for r in rows if 1 <= r[0] < 450)
and mean(orows, "b", 30, 450) >= 2450)
check("...non-vacuous: before the fix A gave 0 W for the quarter",
mean(orows, "a", 30, 450) == 0)
rows, c, land = startup(6500.0, {"a": 99.0}, 1)
check(f"⭐ N=1, house 6500 W: more than the battery can make up; A gives its "
f"full 5000 W and the alarm says so ({land:.1f} W)",
mean(rows, "a", 30, 450) >= 4900
and any("PEAK_GUARD_SATURATED" in e for e in c.events))
for t_dead in (300.0, 390.0, 440.0):
rows, c, land = startup(6500.0, {"a": 99.0}, 1, t_dead=t_dead)
TABLE.append((f"N=1 restart, down {450 - t_dead:.0f} s", 1,
round(land, 1), round(without_fix(lambda: startup(
6500.0, {"a": 99.0}, 1, t_dead=t_dead))[2], 1)))
check(f"⭐ restart mid-quarter, down {450 - t_dead:.0f} s at house "
f"6500 W (the restored reading's held 30 s topped up too): the "
f"quarter lands at or under the aim ({land:.1f} W)",
land <= AIM + TOL)
rows, c = run(6500.0, {"a": 99.0}, 1350.0, n=1, start=450.0,
gaps=[(1200, 1260)])
a = quarter(c, 1)