Files
goodwe-addon/goodwe_controller/DECISIONS/SAFETY-90-peak-guard-after-a-gap.md
Claude (SAFETY-90 review) 8dfe91e1d2 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>
2026-10-05 12:24:33 +02:00

4.9 KiB

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.