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
@@ -22,7 +22,7 @@ resort (SAFETY-91 test, O2).
up to `gap_w` too. The guard then grades the quarter as usual, so it is not
blind and gives what keeps the quarter at the aim.
- **The bound.** The count is at or above the true import as long as, during
the gap, the house load stayed at or under the higher of the loads seen just
the gap, the house load (less solar) stayed at or under the higher of the loads seen just
before and just after it, or under the limit, and the batteries did not
start charging (during a gap they hold their last command, then go to 0 W).
It never counts the gap below the limit. A load that rises above all three
@@ -33,14 +33,21 @@ resort (SAFETY-91 test, O2).
- **While the gap lasts** nothing changes: the reading is too old to steer by,
the guard is blind (floor 0 W) and the "inputs missing" stop sets 0 W, as
before.
- **Before the first reading** (start-up or restart mid-quarter) nothing is
known, not even the house load, so a conservative count has no basis: the
connection rating would make every restart drain at full power and raise
"Load too high". Those seconds count **at the aim**, so the guard keeps the
rest of the quarter on the aim, and the page says so: the status row "Peak
guard: part of this 15-minute period was not measured, counted as on
target" and the home line "Part of this 15-minute period was not measured." /
"Een deel van dit kwartier is niet gemeten." until the quarter ends.
- **Before the first reading** (start-up or restart mid-quarter) the
add-on was down, so the batteries were at 0 W (it sets 0 W when it stops,
the ESP32 watchdog after 30 s). Those seconds count like a gap, at
`gap_w(the limit, the import seen first)`; after a restart the restored
reading's held `meter_max_age_s` is topped up the same way. The bound is
the gap's, with only the "after" side known. A load the batteries cannot
make up then raises "Load too high", which is true: that quarter is going
over. The page says so: the status row "Peak guard: part of this
15-minute period was not measured, counted as high use" and the home line
"Part of this 15-minute period was not measured." / "Een deel van dit
kwartier is niet gemeten." until the quarter ends.
*Review:* the first version counted them at the aim, with "nothing is
known" as the reason. The first reading does show the load, and with the
batteries at 0 W the import was that load, not the aim: a 60 s restart
during a 6500 W HOLD landed 3653 W against 3450 W.
- The bill record (`quarter_blocks`) is unchanged: it still banks only what
was measured. The estimate steers the guard and is never banked.
- The action log gets one `PEAK_GUARD_GAP` row per gap, with the seconds and
@@ -61,7 +68,8 @@ it, gap starting 300 s in; 10 s battery lag; aim 3450 W):
| gap across the edge, next quarter, N=1 / N=2 | 4100.0 / 3621.2 | 3446.3 / 3382.2 |
| house 3000 -> 6500 W inside a 60 s gap, N=1 | 5255.6 | 3368.3 |
| house 6500 -> 2000 W late in a 5 min gap, N=1 | 3629.2 | 3145.1 |
| start-up 450 s into the quarter, N=1 (first half at the aim) | 4975.0 | 3450.0 |
| start-up 450 s in, N=1, house 5000 W (batteries at 0 W before it) | 5000.0 | 3450.0 |
| restart, down 60 s / 150 s, N=1, house 6500 W | 4039.1 / 5483.3 | 3450.0 / 3450.0 |
| meter never drops, N=1 and N=2 | | rows identical |
**Not changed.** The late-reading edge (SAFETY-91 test, O1): a reading late by
+3 -2
View File
@@ -1760,8 +1760,9 @@ a 900 s billing quarter and commands nothing, and this stops the import.
**After a gap it goes on (SAFETY-90).** The seconds the meter missed count at
the higher of the peak limit and the import seen just before and just after
the gap, with the batteries at 0 W. Seconds before the add-on's first reading
count as on target, and the page says part of the period was not measured.
DECISIONS/SAFETY-90-peak-guard-after-a-gap.md has the bound.
count the same way, at the import seen first, and the page says part of the
period was not measured. DECISIONS/SAFETY-90-peak-guard-after-a-gap.md has the
bound.
**It trips, where the freeze detector only notifies,** and the captures are why.
On the healthy reference day (2026-08-23) it engages on **zero** of 95 quarters.
+32 -10
View File
@@ -955,6 +955,12 @@ class Controller:
self._gap_held_ws = 0.0
self._gap_pre = None
self._unseen = None
# A held sample TARIFF-01 restored from before this restart: the
# averager credits it max_hold_s into the restart, at 0 W batteries.
_held = self.p1.averager.held_since
self._run_pre = (None if _held is None
else (_held, self.p1.averager.held_net_w))
self._unseen_held_ws = 0.0
self.peak_gap_s = 0.0
self.peak_unmeasured_s = 0.0
self._peak_alarm_block = None
@@ -2706,16 +2712,18 @@ class Controller:
def _fill_peak_gaps(self, proj, now, reading):
"""SAFETY-90: `proj` with a meter gap's uncounted seconds counted at
`peakguard.gap_w()` of the imports seen just before and just after it,
and those before this run's first reading at the aim (shown as not
measured). DECISIONS/SAFETY-90-peak-guard-after-a-gap.md."""
and those before this run's first reading likewise at the import seen
first (shown as not measured).
DECISIONS/SAFETY-90-peak-guard-after-a-gap.md."""
self.peak_gap_s = self.peak_unmeasured_s = 0.0
avg = self.p1.averager
since = avg.held_since
if proj is None or proj.block_start is None or since is None:
return proj
if self._unseen is None and self.p1.last is not None:
self._unseen = (proj.block_start, max(0.0, (
since - proj.block_start).total_seconds() - avg.elapsed_s))
self._unseen = [proj.block_start, max(0.0, (
since - proj.block_start).total_seconds() - avg.elapsed_s),
since, None]
seen = (None if self.grid is None or self.site_batt_w is None
else float(self.grid) + max(0.0, float(self.site_batt_w)))
if proj.block_start != self._gap_block:
@@ -2723,12 +2731,25 @@ class Controller:
self._gap_filled_s = self._gap_held_ws = 0.0
cap = float(self.o.get("peak_cap_w", 3500))
ceiling = peakguard.quarter_ceiling_w(cap, reading)[0]
unseen_on = (self._unseen is not None
and self._unseen[0] == proj.block_start)
if unseen_on and self._unseen[3] is None and seen is not None:
# While the add-on was down the batteries were at 0 W, so the
# seconds before its first reading count like a gap, at the
# import seen first; a restored reading's held part is topped up.
self._unseen[3] = peakguard.gap_w(ceiling, seen)
if self._run_pre is not None:
s0, g0 = self._run_pre
h = (min(s0 + timedelta(seconds=avg.max_hold_s),
self._unseen[2])
- max(s0, proj.block_start)).total_seconds()
self._unseen_held_ws = max(0.0, h) * max(
0.0, self._unseen[3] - max(0.0, g0))
kw = dict(tail_s=(now - since).total_seconds(),
max_hold_s=avg.max_hold_s,
unseen_s=(self._unseen[1] if self._unseen is not None
and self._unseen[0] == proj.block_start else 0.0),
unseen_w=peakguard.quarter_target_w(
cap, self.peak_margin_w, reading))
unseen_s=self._unseen[1] if unseen_on else 0.0,
unseen_w=(self._unseen[3] if unseen_on
and self._unseen[3] is not None else ceiling))
_p, gap_s, _u = peakguard.fill_gaps(proj, gap_w=0.0, **kw)
if gap_s > self._gap_filled_s + peakguard.GAP_TOL_S:
self._gap_w = peakguard.gap_w(ceiling, self._gap_w,
@@ -2752,7 +2773,8 @@ class Controller:
self._gap_pre = (since, float(self.grid))
out, self.peak_gap_s, self.peak_unmeasured_s = peakguard.fill_gaps(
proj, gap_w=self._gap_w if self._gap_w is not None else ceiling,
held_ws=self._gap_held_ws, **kw)
held_ws=self._gap_held_ws + (self._unseen_held_ws if unseen_on
else 0.0), **kw)
return out
def _peak_over_drain(self, claims, maint_results) -> None:
@@ -6458,7 +6480,7 @@ class Controller:
peakguard.blind_kind(_pg.blind))})
elif self.peak_unmeasured_s > 0.0:
# SAFETY-90: the start of this quarter came before the first
# reading, and the guard counts it at the aim.
# reading, and the guard counts it as high use.
out.append({"ok": False, "warn": True, "id": "peak_guard",
"text": messages.row_peak_guard_unmeasured()})
elif _pg.engaged:
+2 -2
View File
@@ -794,9 +794,9 @@ def row_peak_guard_blind(why: str) -> str:
def row_peak_guard_unmeasured() -> str:
"""SAFETY-90. Before the first reading; that part counts as on target."""
"""SAFETY-90. Before the first reading; that part counts as high use."""
return ("Peak guard: part of this 15-minute period was not measured, "
"counted as on target")
"counted as high use")
def row_peak_guard_on(floor_w, limit_w, *, shadow: bool = False) -> str:
+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)