Merge pull request 'SAFETY-90: the peak guard goes on after a P1 meter gap' (#175) from safety-90-peak-guard-gap into release/1.0
infra-pin / fresh (push) Successful in 2s

SAFETY-90: the peak guard goes on after a P1 meter gap (#175)
This commit was merged in pull request #175.
This commit is contained in:
2026-10-05 13:23:32 +02:00
10 changed files with 747 additions and 7 deletions
@@ -0,0 +1,81 @@
# SAFETY-90 - the peak guard goes on after a P1 meter gap
**Status: DEV 2026-10-05, for review.** Tests: `test_safety90.py`.
**The defect.** A meter gap longer than `meter_max_age_s` leaves seconds the
quarter average does not count (`p1.QuarterAverager`, on purpose: a guess must
not reach the bill record). `peakguard.blindness()` then called the projection
blind for the rest of the quarter, so the floor was 0 W. During a HOLD the
battery gave nothing and the quarter went over: SAFETY-86 rig case 7b, N=2,
3964.8 W against a 3450 W aim, the month peak raised; N=1, 3701 W (review).
The page still read "Keeping the grid near zero". At start-up mid-quarter the
same rule left the start-up quarter blind, and a held battery was drawn as last
resort (SAFETY-91 test, O2).
**The fix** (`peakguard.fill_gaps()`, `peakguard.gap_w()`,
`Controller._fill_peak_gaps()`).
- Once readings are back, every second of a silence longer than
`meter_max_age_s` counts at `gap_w = max(the limit, the import just before
the gap, the import just after it)`, where "import" is the grid plus the
batteries' discharge, i.e. with the batteries at 0 W. The gap's first
`meter_max_age_s`, which the averager credits at the last reading, is topped
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 (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
only inside the gap is the one case it can miss.
- **Cost of the bound.** It is conservative: with the house at 6500 W, a 30 s
to 5 min gap lands the quarter 100-180 W under the aim (table below), paid in
battery energy.
- **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) 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
the watts it was counted at.
- No option. The threshold is `meter_max_age_s`, the limit and the aim are
`peak_cap_w` and `peak_margin_w`.
**Before -> after** (`test_safety90.py`, quarter as the meter really averages
it, gap starting 300 s in; 10 s battery lag; aim 3450 W):
| case | before | after |
|---|---|---|
| N=1 HOLD 6500 W, gap 30 s / 60 s | 3456.6 / 5329.2 | 3346.2 / 3272.2 |
| N=1 HOLD 5500 W, gap 5 min | 4717.9 | 3330.6 |
| N=2 HOLD, B able, 6500 W, gap 30 s / 60 s | 3456.6 / 3865.6 | 3346.3 / 3272.7 |
| N=2 HOLD, B able, 5500 W, gap 5 min | 3745.4 | 3288.9 |
| N=2 HOLD, B held, 5500 W, gap 60 s | 3254.9, B drawn 2500 W | 3335.4, B 0 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 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
less than `meter_max_age_s` is not a gap, and the seconds since it are not
counted in the projection (on any quarter, not only after a gap). Near a
quarter's end the 900/remaining gain turns that into a large negative excess
and the floor drops for a cycle or more. Counting those seconds at the held
reading would fix it, but it changes every quarter's last seconds on a
healthy meter; it is handed back as its own card.
+11 -4
View File
@@ -1752,10 +1752,17 @@ ceiling at the cap, so a broken instrument makes the guard stricter, never loose
**When it cannot see, it forbids importing.** Three states are not evidence and
are checked before the projection is graded: no open quarter (a fresh averager
projects a confident 0 W, which is under every ceiling there is), a meter gap
longer than `meter_max_age_s`, and SAFETY-18's frozen-meter conjunction. In all
three the claim becomes a floor of **0 W**. That is the backstop for the frozen
meter: the alarm arrives at `meter_freeze_s` (300 s) of a 900 s billing quarter
and commands nothing, and this stops the import.
longer than `meter_max_age_s` while it lasts, and SAFETY-18's frozen-meter
conjunction. In all three the claim becomes a floor of **0 W**. That is the
backstop for the frozen meter: the alarm arrives at `meter_freeze_s` (300 s) of
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 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.
+8 -2
View File
@@ -904,7 +904,9 @@ def month_peak(c, now):
if pg is not None:
out["period_w"] = pg.projected_w
ceiling = pg.ceiling_w or limit_w
out["line"] = line_period(out["period_w"], ceiling, c.shadow)
out["line"] = line_period(out["period_w"], ceiling, c.shadow,
unmeasured=getattr(c, "peak_unmeasured_s", 0.0)
> 0.0)
return out
@@ -1294,8 +1296,11 @@ def line_peak_not_covered() -> str:
return _t("peak_not_covered")
def line_period(period_w, limit_w, shadow) -> str:
def line_period(period_w, limit_w, shadow, unmeasured=False) -> str:
step = _w(shadow, "step_in", kw=_kw(limit_w))
if unmeasured:
# SAFETY-90: the add-on started during this period.
return _t("period_unmeasured") + " " + step
if period_w is None:
return step
return _t("period", kw=_kw(period_w)) + " " + step
@@ -1369,6 +1374,7 @@ def _samples() -> list:
("plan", line_peak_limit(3500, sh)),
("plan", line_period(1240, 3500, sh)),
("plan", line_period(None, 3500, sh)),
("plan", line_period(1240, 3500, sh, unmeasured=True)),
("plan", control_text(True, sh)),
("plan", control_text(False, sh))]
out += [("plan", run_grid_why(0.1412)), ("plan", run_grid_amount(1.23)),
+94 -1
View File
@@ -947,6 +947,22 @@ class Controller:
# SAFETY-81: what the alarm is graded on, beside the guard's decision.
self._peak_proj = None
self._peak_batt_prev = None
# SAFETY-90: what a meter gap is counted at (`_fill_peak_gaps()`).
self._gap_seen_w = None
self._gap_w = None
self._gap_block = None
self._gap_filled_s = 0.0
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
self._peak_high_since = None
self._peak_floor_limited = False
@@ -2693,6 +2709,74 @@ class Controller:
lo=p, reason=c.reason,
actuator=u)
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 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),
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:
self._gap_block, self._gap_w = proj.block_start, None
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 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,
self._gap_seen_w, seen)
if self._gap_pre is not None:
# The averager credited the gap's first max_hold_s at the
# reading before it; top that part up to gap_w as well.
s0, g0 = self._gap_pre
h = (min(s0 + timedelta(seconds=avg.max_hold_s), since)
- max(s0, proj.block_start)).total_seconds()
self._gap_held_ws += max(0.0, h) * max(
0.0, self._gap_w - max(0.0, g0))
self.log_event(f"PEAK_GUARD_GAP: the meter missed {gap_s:.0f} s "
f"of this 15-minute period, counted at "
f"{self._gap_w:.0f} W",
hint=messages.hint("peak_gap"))
self._gap_filled_s = max(self._gap_filled_s, gap_s)
if seen is not None:
self._gap_seen_w = seen
if self.grid is not None:
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 + (self._unseen_held_ws if unseen_on
else 0.0), **kw)
return out
def _peak_over_drain(self, claims, maint_results) -> None:
"""SAFETY-83: at N=1 a peak floor above the drain rate outranks it.
@@ -4218,6 +4302,10 @@ class Controller:
# gives for SAFETY-18 on a transport that cannot see `unchanged_s`.
self._peak_proj = (None if not self.p1_enabled
else self.peak.project(self.p1.averager, now))
_reading = self.peak.peak(now) if self.p1_enabled else None
# SAFETY-90: seconds a meter gap left uncounted are counted, so the
# guard goes on after the readings return.
self._peak_proj = self._fill_peak_gaps(self._peak_proj, now, _reading)
# SAFETY-91: at N>=2 the quarter's edge is graded on the next quarter,
# so a HOLD's ceiling does not lapse for a cycle onto a held battery.
if self._peak_proj is not None and len(self.inverters) > 1:
@@ -4232,7 +4320,7 @@ class Controller:
batt_w=self.site_batt_w,
cap_w=float(self.o.get("peak_cap_w", 3500)),
margin_w=self.peak_margin_w,
reading=self.peak.peak(now),
reading=_reading,
# ⚠️ SUMMED FROM THE PER-UNIT CONJUNCTION, WHICH IS AC 4. Each
# unit contributes `min(its max_w, |its own device bounds|)`, so
# inverter B's rating bounds inverter B. Conjoining the SUMS instead
@@ -6390,6 +6478,11 @@ class Controller:
out.append({"ok": False, "warn": True, "id": "peak_guard",
"text": messages.row_peak_guard_blind(
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 as high use.
out.append({"ok": False, "warn": True, "id": "peak_guard",
"text": messages.row_peak_guard_unmeasured()})
elif _pg.engaged:
out.append({"ok": False, "warn": True, "id": "peak_guard",
"text": messages.row_peak_guard_on(
+8
View File
@@ -793,6 +793,12 @@ def row_peak_guard_blind(why: str) -> str:
f"meanwhile")
def row_peak_guard_unmeasured() -> str:
"""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 high use")
def row_peak_guard_on(floor_w, limit_w, *, shadow: bool = False) -> str:
if shadow:
return (f"Peak guard: would ask for {watts(floor_w)} to stay under "
@@ -912,6 +918,7 @@ HINTS = {
"peak_on": "Peak guard started",
"peak_off": "Peak guard idle",
"peak_blind": "Peak guard cannot read the meter well",
"peak_gap": "Meter is back, the peak guard counts the missed time as high use",
"peak_saturated": T_PEAK_SATURATED,
"peak_reserve": T_PEAK_RESERVE,
"meter_frozen": T_METER_FROZEN,
@@ -1097,6 +1104,7 @@ def status_samples() -> list:
("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, True)),
("row_peak_guard_idle", GREEN, row_peak_guard_idle(1300, 2500, False)),
("row_peak_guard_quiet", GREEN, row_peak_guard_quiet()),
("row_peak_guard_unmeasured", AMBER, row_peak_guard_unmeasured()),
*(("row_peak_guard_blind", AMBER, row_peak_guard_blind(k))
for k in BLIND_WORDS),
("row_peak_guard_on", AMBER, row_peak_guard_on(1200, 2500)),
+7
View File
@@ -312,6 +312,13 @@ class QuarterAverager:
"""
return self._last_net
@property
def held_since(self) -> datetime | None:
"""SAFETY-90: when the held sample arrived (its `ingest_ts`), or None."""
if self._last_t is None:
return None
return datetime.fromtimestamp(self._last_t, timezone.utc)
@property
def offtake_avg_w(self) -> float:
"""Average offtake over the part of the open block seen so far."""
+53
View File
@@ -183,6 +183,8 @@ guard passes its own tests while being switched off:
record). Those seconds make the projection read LOW, which is the
under-guarding direction. The threshold is `max_hold_s` itself - the point
p1.py already stops trusting a held sample - not a new number.
SAFETY-90: once readings are back, main.py counts those seconds
(`fill_gaps()`), so this holds only while the gap lasts.
* FROZEN. SAFETY-18's conjunction, evaluated through `freeze.looks_frozen()`
rather than re-written here. A frozen meter is the worst case of all: the
@@ -599,6 +601,57 @@ def blindness(projection, max_hold_s, frozen: bool) -> str | None:
return None
# SAFETY-90. Seconds of uncounted time below which nothing is filled: float
# noise between the averager's sums and the wall clock, never a real gap.
GAP_TOL_S = 1e-3
def gap_w(ceiling_w, *seen_w) -> float:
"""SAFETY-90: the watts a meter gap is counted at. The highest of the
limit and `seen_w`, the import with the batteries at 0 W (grid + their
discharge) just before and just after the gap; None entries are skipped.
DECISIONS/SAFETY-90-peak-guard-after-a-gap.md."""
return max([float(ceiling_w)] + [float(w) for w in seen_w if w is not None])
def fill_gaps(projection, *, tail_s, max_hold_s, unseen_s, gap_w, unseen_w,
held_ws=0.0):
"""SAFETY-90: `projection` with the seconds the averager did not count
(a meter gap past `max_hold_s`) counted at `gap_w`, and those from before
this run's first reading (`unseen_s`) at `unseen_w`. `held_ws` is added
too: what the gap's first `max_hold_s`, credited at the reading before
it, lacks to reach `gap_w`. Returns
`(projection, gap seconds filled, unseen seconds filled)`.
`tail_s` is the age of the held reading: up to `max_hold_s` of it is left
as before (the averager credits it once the next reading lands). Past
that the gap is still open, nothing is filled and `blindness()` answers
as before. DECISIONS/SAFETY-90-peak-guard-after-a-gap.md."""
if projection.block_start is None or max_hold_s is None:
return projection, 0.0, 0.0
wall = float(projection.elapsed_s)
tail = min(max(0.0, float(tail_s)), wall)
if tail > float(max_hold_s):
return projection, 0.0, 0.0
covered = wall - uncovered_s(projection)
missing = wall - covered - tail
if missing <= GAP_TOL_S:
missing = 0.0
if held_ws <= 0.0:
return projection, 0.0, 0.0
unseen = min(missing, max(0.0, float(unseen_s)))
gap = missing - unseen
add_ws = gap * float(gap_w) + unseen * float(unseen_w) + float(held_ws)
done = covered + missing
return (replace(
projection,
projected_w=float(projection.projected_w) + add_ws / QUARTER_S,
elapsed_w=((float(projection.elapsed_w) * covered + add_ws) / done
if done > 0.0 else float(projection.elapsed_w)),
covered_frac=(done / wall if wall > 0.0
else float(projection.covered_frac))), gap, unseen)
def discharge_permitted(soc, soc_floor) -> str | None:
"""SAFETY-24. May the guard size a forced discharge, or why may it not?
+1
View File
@@ -410,6 +410,7 @@
"step_in": "The batteries step in above {kw}.",
"step_in_would": "The batteries would step in above {kw}.",
"period": "This 15-minute period is heading for {kw}.",
"period_unmeasured": "Part of this 15-minute period was not measured.",
"prices": {
"now": "Price now {eur} per kWh, all in.",
"today": "Today: lowest {lo} at {lo_at}, highest {hi} at {hi_at}.",
+1
View File
@@ -347,6 +347,7 @@
"step_in": "De batterijen springen bij boven {kw}.",
"step_in_would": "De batterijen zouden bijspringen boven {kw}.",
"period": "Dit kwartier gaat richting {kw}.",
"period_unmeasured": "Een deel van dit kwartier is niet gemeten.",
"prices": {"now": "Prijs nu {eur} per kWh, alles inbegrepen.", "today": "Vandaag: laagste {lo} om {lo_at}, hoogste {hi} om {hi_at}.", "none": "Geen prijzen voor vandaag."},
"tomorrow": {"known": "De prijzen van morgen zijn binnen.", "unknown": "De prijzen van morgen komen meestal na 13:00."},
"region": {"preset": "Heffingen en btw: {region}, cijfers van {year}.", "own": "Heffingen en btw: uw eigen bedrag."},
+483
View File
@@ -0,0 +1,483 @@
"""Runnable check for SAFETY-90: the peak guard goes on after a P1 meter gap.
`python3 test_safety90.py`
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 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.
1. N=1 HOLD: gaps of 30 s, 60 s and 5 min mid-quarter land at or under the aim.
2. N=2 HOLD, B able: the same.
3. N=2 HOLD, B held at its reserve: the same, and B gives nothing (O2).
4. A gap across a quarter edge; the house rising during a gap.
5. Start-up mid-quarter: the guard runs, the page says what it assumed.
6. While the gap lasts the guard is blind as before; no gap, no change.
7. fill_gaps() and gap_w() at their boundaries.
Mutations (MUTATION.md, purged copies) are listed in the pull request.
"""
import logging
import math
import os
import sys
import types
from datetime import timedelta
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 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
from app.store import unit_key # noqa: E402
from app.tariff import QUARTER_S, Projection # noqa: E402
fails = []
A, B = "inverter-a", "inverter-b"
AIM = 3450.0 # peak_cap_w 3500 - peak_margin_w 50
TOL = 5.0 # W over the aim a quarter may land (SAFETY-79)
CUT = 10.0
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def store(phase_of, n):
s = rig._Store()
for u in (A, B)[:n]:
key = (lambda k: k) if n == 1 else (lambda k, u=u: unit_key(u, k))
if u in phase_of:
s.data[key("phase")] = phase_of[u]
s.data[key("phase_started")] = rig.BASE.isoformat()
else:
s.data[key("last_completed")] = rig.BASE.isoformat()
return s
def run(house, socs, dur, *, n, gaps=(), start=0.0, lag=10.0, on_cycle=None):
"""Rows and controller. A holds full; `socs` are the starting levels
(B's follows its power). `gaps`: (t0, t1), no telegram for t0 <= t < t1.
`start`: the add-on starts this many s into the run (mid-quarter). The
P1 clock is simulated, so a gap really goes stale. `c.truth` is the grid
each second, which the quarter really averages."""
clock = [0.0]
soc, last, truth = dict(socs), [start], []
def before(c, t0):
if on_cycle is not None:
on_cycle(c, t0) # the state the last cycle left
t = t0 + start
clock[0] = t
g = c.hass.grid()
truth.append((t, g))
for u in soc:
soc[u] = max(CUT, soc[u] - c.hass.power(u) * (t - last[0])
/ 3600.0 / 153.6)
last[0] = t
if any(a <= t < b for a, b in gaps):
return
imp, exp = split_signed(g)
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
ingest_ts=rig.BASE + timedelta(seconds=t0),
ingest_mono=1e6 + t))
def power(self, u):
st = self.__dict__.setdefault("_plant", {})
a, tgt, tl = st.get(u, (0.0, 0.0, self.t))
for _ in range(int(round((self.t - tl) / 0.1))):
d = self.cmd[u] - tgt
tgt += math.copysign(min(abs(d), 80.0), d)
a += (tgt - a) * (1 - math.exp(-0.1 / lag))
st[u] = (a, tgt, self.t)
return 0.0 if self.soc(u, self.t) <= CUT and a > 0 else float(round(a))
real = (rig._Hass.power, rig.TICK, rig.BASE, p1mod.time)
rig._Hass.power, rig.TICK = power, 1.0
rig.BASE = real[2] + timedelta(seconds=start)
p1mod.time = types.SimpleNamespace(monotonic=lambda: 1e6 + clock[0])
try:
rows, c = s84.run(
n, load=house if callable(house) else (lambda t: house),
cheap=False, store=store({A: HOLD}, n), before=before, dur=dur,
soc=lambda u, t: soc[u],
extra={"maintenance_hold_min": 600})
finally:
rig._Hass.power, rig.TICK, rig.BASE, p1mod.time = real
c.truth = truth
return rows, c
def without_fix(fn):
"""release/1.0 3ed3302: nothing is filled, so a gap blinds the guard."""
real = peakguard.fill_gaps
peakguard.fill_gaps = lambda p, **kw: (p, 0.0, 0.0)
try:
return fn()
finally:
peakguard.fill_gaps = real
def quarter(c, q):
"""What quarter `q` (0 = the run's first) really averages, in W."""
return sum(max(g, 0.0) for t, g in c.truth if int(t // QUARTER_S) == q) \
/ QUARTER_S
def cmd(r):
return dict(r[2])
def mean(rows, u, lo, hi):
rs = [cmd(r)[u] for r in rows if lo <= r[0] < hi]
return round(sum(rs) / len(rs))
TABLE = []
def case(tag, n, house, socs, gaps, q=0, dur=1800.0):
"""One gap case, after and before the fix; prints and records it."""
new = run(house, socs, dur, n=n, gaps=gaps)
old = without_fix(lambda: run(house, socs, dur, n=n, gaps=gaps))
a, b = quarter(new[1], q), quarter(old[1], q)
print(f" {tag}: quarter {a:.1f} W (before the fix {b:.1f} W), aim "
f"{AIM:.0f}")
TABLE.append((tag, n, round(a, 1), round(b, 1)))
return new, old, a, b
# ============================================================================ #
print("1. N=1 HOLD, house 6500 W (5500 W for 5 min, so it can be held)")
for tag, gap, house in (("30 s", (300, 330), 6500.0),
("60 s", (300, 360), 6500.0),
("5 min", (300, 600), 5500.0)):
(rows, c), _old, a, b = case(f"N=1 gap {tag}", 1, house, {"a": 99.0},
[gap])
check(f"⭐ N=1, {tag} gap mid-quarter: the quarter lands at or under the "
f"aim ({a:.1f} W)", a <= AIM + TOL)
if tag != "30 s":
check(f"...non-vacuous: before the fix it went over ({b:.1f} W)",
b > AIM + 100)
check("...and once readings are back the battery gives at least the "
"house over the aim",
mean(rows, "a", gap[1] + 15, 900) >= house - AIM)
check("...the action log says what the gap was counted at",
any("PEAK_GUARD_GAP" in e for e in c.events))
# ============================================================================ #
print("\n2. N=2 HOLD (A holding), B able at 60 %, house 6500 W")
for tag, gap, house in (("30 s", (300, 330), 6500.0),
("60 s", (300, 360), 6500.0),
("5 min", (300, 600), 5500.0)):
(rows, c), _old, a, b = case(f"N=2 gap {tag}", 2, house,
{"a": 99.0, "b": 60.0}, [gap])
check(f"⭐ N=2, {tag} gap: the quarter lands at or under the aim "
f"({a:.1f} W)", a <= AIM + TOL)
if tag != "30 s":
check(f"...non-vacuous: before the fix it went over ({b:.1f} W)",
b > AIM + 100)
check("...B gives first: at its full 2500 W once readings are back",
mean(rows, "b", gap[1] + 30, 900) >= 2450)
# ============================================================================ #
print("\n3. N=2 HOLD, B held at 15 % (under its 20 % reserve), house 5500 W "
"(4500 W for 5 min: A alone gives 2500 W)")
for tag, gap, house in (("30 s", (300, 330), 5500.0),
("60 s", (300, 360), 5500.0),
("5 min", (300, 600), 4500.0)):
(rows, c), (orows, _oc), a, b = case(
f"N=2 B held, gap {tag}", 2, house, {"a": 99.0, "b": 15.0}, [gap])
check(f"⭐ N=2 B held, {tag} gap: the quarter lands at or under the aim "
f"({a:.1f} W)", a <= AIM + TOL)
check("⭐ O2: the held B gives nothing once readings are back; the "
"holding A gives the need",
all(cmd(r)["b"] == 0.0 for r in rows if gap[1] <= r[0] < 900)
and mean(rows, "a", gap[1] + 15, 900) >= house - AIM)
if tag != "30 s":
check("...non-vacuous: before the fix the blind guard left A at 0 W "
"and the held B was drawn at 2500 W as last resort",
mean(orows, "a", gap[1] + 30, 900) == 0
and mean(orows, "b", gap[1] + 30, 900) >= 2450)
rows, c = run(5500.0, {"a": 99.0, "b": 15.0}, 1000.0, n=2,
gaps=[(300, 360), (899, 910)])
edge = [(r[0], cmd(r)["a"], cmd(r)["b"]) for r in rows if 900 <= r[0] < 915]
print(f" a 60 s gap, then the edge's telegram 11 s late: {edge[:4]}; the "
f"second before the edge (O1, not this card): "
f"{[(r[0], cmd(r)['a'], cmd(r)['b']) for r in rows if r[0] == 899]}")
check("⭐ ...a quarter that had a gap still hands over at its edge (SAFETY-91): "
"with the edge's telegram late the held B gives nothing and A keeps "
"giving", all(b == 0.0 and a > 1500.0 for _t, a, b in edge))
# ============================================================================ #
print("\n4. a gap across a quarter edge, and a house that rises in a gap")
for n, socs in ((1, {"a": 99.0}), (2, {"a": 99.0, "b": 60.0})):
(rows, c), _o, a, b = case(f"N={n} gap across the edge", n, 6500.0, socs,
[(870, 930)], q=1)
check(f"⭐ N={n}: the quarter the readings come back in lands at or "
f"under the aim ({a:.1f} W; before the fix {b:.1f} W)",
a <= AIM + TOL and b > AIM + 100)
check("...the quarter that ended in the gap could not be steered after "
"it, and stays under the limit itself",
quarter(c, 0) <= 3500.0)
def rising(t):
return 3000.0 if t < 320 else 6500.0
for n, socs in ((1, {"a": 99.0}), (2, {"a": 99.0, "b": 15.0})):
house = rising if n == 1 else (lambda t: 3000.0 if t < 320 else 5500.0)
(rows, c), _o, a, b = case(f"N={n} house rises in a 60 s gap", n, house,
socs, [(300, 360)])
check(f"⭐ N={n}: the house goes from 3000 W to over the limit during "
f"the gap; the import seen after it is counted and the quarter "
f"lands at or under the aim ({a:.1f} W)", a <= AIM + TOL)
for n, socs in ((1, {"a": 99.0}), (2, {"a": 99.0, "b": 15.0})):
big = 6500.0 if n == 1 else 5500.0
(rows, c), _o, a, b = case(
f"N={n} house falls in a 5 min gap", n,
lambda t, big=big: big if t < 560 else 2000.0, socs, [(300, 600)])
check(f"⭐ N={n}: the house falls to 2000 W just before readings return; "
f"the import before the gap (batteries at 0 W) is counted and the "
f"quarter lands at or under the aim ({a:.1f} W; before {b:.1f} W, "
f"at N=2 by drawing the held B)",
a <= AIM + TOL and (n == 2 or b > AIM + 100))
(rows, c), _o, a, b = case(
"N=1 5 min gap at 3000 W, then 60 s gap at 6500 W next quarter", 1,
lambda t: 3000.0 if t < 900 else 6500.0, {"a": 99.0},
[(300, 600), (1200, 1260)], q=1)
check(f"⭐ ...each quarter's gap is counted on its own: a long gap at a low "
f"load does not hide a shorter one in the next quarter ({a:.1f} W)",
a <= AIM + TOL and sum("PEAK_GUARD_GAP" in e for e in c.events) == 2)
def dip(t):
return 3000.0 if 450 <= t < 700 else 6500.0
(rows, c), _o, a, b = case("N=1 two gaps, the second at a lower load", 1,
dip, {"a": 99.0}, [(300, 360), (500, 560)])
at = [float(e.split("counted at ")[1].split(" W")[0]) for e in c.events
if "PEAK_GUARD_GAP" in e]
check(f"⭐ ...two gaps in one quarter: the second, at a lower load, does not "
f"price the first one down (counted at {at}; {a:.1f} W)",
a <= AIM + TOL and len(at) == 2 and min(at) >= 6400.0)
# ============================================================================ #
print("\n5. start-up mid-quarter (the add-on starts 450 s into the quarter)")
UI = {}
def grab(c, t):
if t == 60.0:
UI["unmeasured"] = c.peak_unmeasured_s
UI["rows"] = [r for r in c.checks() if r.get("id") == "peak_guard"]
UI["en"] = homeview.month_peak(c, c.cycle_at)["line"]
homeview._LANG[0] = "nl"
try:
UI["nl"] = homeview.month_peak(c, c.cycle_at)["line"]
finally:
homeview._LANG[0] = "en"
if t == 453.0:
UI["later"] = (c.peak_unmeasured_s,
[r for r in c.checks() if r.get("id") == "peak_guard"])
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, 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); 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)
TABLE.append(("N=1 start-up, then 60 s gap next quarter", 1, round(a, 1),
round(quarter(without_fix(lambda: run(
6500.0, {"a": 99.0}, 1350.0, n=1, start=450.0,
gaps=[(1200, 1260)]))[1], 1), 1)))
check(f"...the start-up assumption stays in its own quarter: a 60 s gap in "
f"the next one is counted as a gap and lands at or under the aim "
f"({a:.1f} W)", a <= AIM + TOL)
print(f" page: {UI.get('rows')}\n EN {UI.get('en')!r}\n"
f" NL {UI.get('nl')!r}")
check("⭐ the page says plainly that part of the period was not measured, "
"in English and Dutch",
UI.get("unmeasured", 0) > 400
and UI["rows"][0]["text"] == messages.row_peak_guard_unmeasured()
and UI["en"].startswith("Part of this 15-minute period was not "
"measured.")
and UI["nl"].startswith("Een deel van dit kwartier is niet gemeten."))
check("...and stops saying it in the next quarter",
UI["later"][0] == 0.0 and UI["later"][1][0]["text"]
!= messages.row_peak_guard_unmeasured())
# ============================================================================ #
print("\n6. while the gap lasts, and with no gap at all")
LIVE = {}
def live(c, t):
if t == 350.0:
LIVE["g"] = c.peak_guard
run(6500.0, {"a": 99.0}, 400.0, n=1, gaps=[(300, 360)], on_cycle=live)
check("⭐ 50 s into a gap the guard is blind as before (floor 0 W, no "
"charging): nothing is filled while the gap is open",
LIVE["g"].blind is not None and LIVE["g"].floor_w == 0.0)
for n, socs, house in ((1, {"a": 99.0}, 6500.0),
(2, {"a": 99.0, "b": 60.0}, 6500.0),
(2, {"a": 99.0, "b": 15.0}, 5500.0)):
rows, c = run(house, socs, 1000.0, n=n)
orows, _ = without_fix(lambda: run(house, socs, 1000.0, n=n))
check(f"⭐ N={n}, meter never drops: every row is identical with and "
f"without the fix, and nothing is filled",
rows == orows and not any("PEAK_GUARD_GAP" in e for e in c.events))
# ============================================================================ #
print("\n7. fill_gaps() and gap_w() at their boundaries")
T0 = rig.BASE
def proj(wall, covered, held=1000.0, acc_w=1000.0):
return Projection(projected_w=(acc_w * covered + held * (QUARTER_S - wall))
/ QUARTER_S, elapsed_w=acc_w, elapsed_s=wall,
covered_frac=covered / wall, complete=False,
block_start=T0, held_w=held)
def fill(p, tail, unseen=0.0, gw=4000.0, uw=3450.0):
return peakguard.fill_gaps(p, tail_s=tail, max_hold_s=30.0,
unseen_s=unseen, gap_w=gw, unseen_w=uw)
p = proj(400.0, 300.0) # 100 s uncounted
f, g, u = fill(p, 10.0)
check("90 s missed (100 s uncounted less a 10 s tail): counted at gap_w, "
"the tail left as before, no longer blind",
g == 90.0 and u == 0.0
and abs(f.projected_w - (p.projected_w + 90.0 * 4000.0 / QUARTER_S))
< 1e-9 and abs(peakguard.uncovered_s(f) - 10.0) < 1e-9
and peakguard.blindness(f, 30.0, False) is None
and peakguard.blindness(p, 30.0, False) is not None)
check("tail exactly max_hold_s: filled; one past it: the gap is open, "
"nothing filled",
fill(p, 30.0)[1] == 70.0 and fill(p, 30.001) == (p, 0.0, 0.0))
f, g, u = fill(p, 10.0, unseen=60.0)
check("unseen seconds come first and count at the aim, the rest at gap_w",
u == 60.0 and g == 30.0
and abs(f.projected_w - (p.projected_w + (60 * 3450.0 + 30 * 4000.0)
/ QUARTER_S)) < 1e-9)
check("nothing missed (or under 1 ms): the projection object is returned "
"unchanged",
fill(proj(400.0, 400.0), 0.0)[0] is not None
and fill(proj(400.0, 400.0), 0.0) == (proj(400.0, 400.0), 0.0, 0.0)
and fill(proj(400.0, 399.9995), 0.0)[1] == 0.0)
f, g, u = fill(proj(400.0, 400.0), 0.0)
f2 = peakguard.fill_gaps(proj(400.0, 400.0), tail_s=0.0, max_hold_s=30.0,
unseen_s=0.0, gap_w=4000.0, unseen_w=3450.0,
held_ws=9000.0)
check("held_ws (the gap's first 30 s topped up) is added even when nothing "
"is missing any more",
g == 0.0 and abs(f2[0].projected_w - f.projected_w - 10.0) < 1e-9)
z = peakguard.fill_gaps(Projection(0, 0, 0, 0, False, T0, 0), tail_s=0.0,
max_hold_s=30.0, unseen_s=0.0, gap_w=4000.0,
unseen_w=3450.0, held_ws=9000.0)
check("the first instant of a block (0 s elapsed) with held_ws: no division "
"by zero", abs(z[0].projected_w - 10.0) < 1e-9)
check("no open block: unchanged (blindness says so)",
fill(Projection(0, 0, 0, 0, False, None, 0), 0.0)[1:] == (0.0, 0.0))
check("gap_w(): the highest of the limit and the imports seen, None skipped",
peakguard.gap_w(3500, None, 3000.0) == 3500.0
and peakguard.gap_w(3500, 6500.0, None) == 6500.0
and peakguard.gap_w(3500, 3000.0, 4500.0) == 4500.0
and peakguard.gap_w(3500) == 3500.0)
# ============================================================================ #
print("\n case N quarter W before W aim")
for tag, n, a, b in TABLE:
print(f" {tag:<34}{n} {a:>9.1f} {b:>9.1f} {AIM:.0f}")
print()
if fails:
print(f"{len(fails)} FAILED")
sys.exit(1)
print("all SAFETY-90 checks passed")