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>
82 lines
4.9 KiB
Markdown
82 lines
4.9 KiB
Markdown
# 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.
|