SAFETY-90: the peak guard goes on after a P1 meter gap #175

Merged
admin merged 3 commits from safety-90-peak-guard-gap into release/1.0 2026-10-05 13:23:33 +02:00
Owner

Problem

After a P1 meter gap longer than meter_max_age_s, the averager leaves the missed seconds uncounted (on purpose, so no guess reaches the bill record). peakguard.blindness() then called the projection blind for the rest of the quarter: floor 0 W, a HOLD gave nothing, and the quarter went over. SAFETY-86 rig case 7b: N=2, 3964.8 W against a 3450 W aim, month peak raised; N=1 3701 W (review). The page still said "Keeping the grid near zero". The start-up quarter was blind for the same reason, and a held battery was drawn as last resort (SAFETY-91 test O2).

Fix

  • After a gap (peakguard.gap_w(), peakguard.fill_gaps(), Controller._fill_peak_gaps()): once readings are back, every second of a silence longer than meter_max_age_s counts at gap_w = max(limit, import just before the gap, import just after it), where import = grid + battery discharge (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.
  • 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 before and just after it, or the limit, and the batteries started no new charging (during a gap they hold their last command, then go to 0 W). It never counts a gap below the limit. Missed case: a load that rises above all three only inside the gap. Cost: conservative, 100-180 W under the aim at 6500 W house, paid in battery energy.
  • While the gap lasts: unchanged (blind, floor 0 W, "inputs missing" stop).
  • Before the first reading (start-up / restart mid-quarter; changed in review, 8dfe91e): the add-on was down, so the batteries were at 0 W. Those seconds count like a gap, at gap_w(limit, import seen first), and a restored reading's held 30 s is topped up. The page says so until the quarter ends:
    • status row (EN): "Peak guard: part of this 15-minute period was not measured, counted as high use"
    • home line: EN "Part of this 15-minute period was not measured." / NL "Een deel van dit kwartier is niet gemeten."
    • action-log row PEAK_GUARD_GAP: the meter missed N s of this 15-minute period, counted at X W, hint "Meter is back, the peak guard counts the missed time as high use".
  • Bill record (quarter_blocks) unchanged. No new option. CHANGELOG: not touched (the repo writes it at the release cut; 0.15.0's known issue for SAFETY-90 should be dropped then).
  • DECISIONS/SAFETY-90-peak-guard-after-a-gap.md, one paragraph in DOCS.md's peak-guard section.

Evidence (test_safety90.py: REAL Controller, 10 s battery lag, P1 telegrams stopped; quarter = what the meter really averages; aim 3450 W)

case N after (W) before (W) pass
HOLD 6500 W, gap 30 s 1 3346.2 3456.6 yes
HOLD 6500 W, gap 60 s 1 3272.2 5329.2 yes
HOLD 5500 W, gap 5 min 1 3330.6 4717.9 yes
HOLD, B able, 6500 W, gap 30 s 2 3346.3 3456.6 yes
HOLD, B able, 6500 W, gap 60 s 2 3272.7 3865.6 yes
HOLD, B able, 5500 W, gap 5 min 2 3288.9 3745.4 yes
HOLD, B held 15 %, 5500 W, gap 30 s 2 3381.2 3456.5 yes
HOLD, B held, 5500 W, gap 60 s 2 3335.4 (B 0 W) 3254.9 (B drawn 2500 W) yes
HOLD, B held, 4500 W, gap 5 min 2 3395.9 (B 0 W) 3309.7 (B drawn) yes
gap across the edge (next quarter) 1 3446.3 4100.0 yes
gap across the edge (next quarter) 2 3382.2 3621.2 yes
house 3000 -> 6500 W inside a 60 s gap 1 3368.3 5255.6 yes
house 3000 -> 5500 W inside a 60 s gap, B held 2 3238.1 2193.5 (B drawn) yes
house 6500 -> 2000 W late in a 5 min gap 1 3145.1 3629.2 yes
house 5500 -> 2000 W late in a 5 min gap, B held 2 3256.0 2750.2 (B drawn) yes
5 min gap at 3000 W, then 60 s gap at 6500 W next quarter 1 3272.2 5329.2 yes
start-up 450 s in, house 5000 W (batteries at 0 W before) 1 3450.0, line shown 5000.0 (A 0 W) yes
start-up 450 s in, B held, house 4500 W 2 3450.0, B 0 W, line shown 3287.0 (A 0 W, B drawn) yes
restart, down 10 / 60 / 150 s, house 6500 W 1 3450.0 each 3483.9 / 4039.1 / 5483.3 yes
start-up, then 60 s gap next quarter 1 3275.1 5329.2 yes
meter never drops, N=1 / N=2 / N=2 B held rows byte-identical with and without the fix yes

Also: 50 s into a gap the guard is still blind (floor 0 W); a quarter that had a gap still hands over at a late edge (SAFETY-91 roll) with B 0 W; fill_gaps()/gap_w() boundaries (tail = max_hold_s filled, +1 ms not; unseen split; no div-by-zero at 0 s elapsed).

  • O2: shown above: the held B gives 0 W on every cycle after readings return, and at start-up; A (holding) gives the need.
  • Full suite in goodwe-addon-checks:1 with hwsim at the pin: 63/63 pass. No digest re-pinned. Infra pin 3b4ff84 = admin/goodwe release/1.0.

Mutations (fresh copy each, tools/mutation_run.sh, purged caches)

# mutation result
M1 gap fill removed (_fill_peak_gaps not called) killed, 25 checks red
M2 gap counted at 0 W, no top-up killed, 19 red
M3 start-up assumption applied in every quarter (edge off-by-one) killed, 1 red (start-up then gap)
M4a status row "not measured" skipped killed, 1 red (page check)
M4b home line "not measured" skipped killed, 1 red (page check)
M5 import seen without the batteries' discharge killed, 1 red (house falls in gap)
M6 start-up seconds at 0 W instead of the aim killed, 3 red
M7 import after the gap ignored killed, 3 red
M8 gap's first 30 s not topped up killed, 1 red
M9 tail > max_hold_s -> >= killed, 1 red (boundary)
M10 fill after the SAFETY-91 edge roll (order) killed, 1 red (late edge after a gap)
M11 gap_w without the limit floor killed, 1 red
M12 gap state not reset at the quarter edge killed, 1 red (two gaps, two quarters)

13/13 killed, no tracebacks. Logs: scratch/SAFETY-90/dev/mutations-final.log.

Handed back

  • O1 (late-reading edge): not this mechanism, left for its own card. A reading late by less than meter_max_age_s is not a gap; the seconds since it are not counted in the projection at all, on every quarter. Near a quarter's end the 900/remaining gain turns that into a large negative excess and the floor drops (sim: with the edge's telegram missing, the cycle 1 s before the edge has A 0 W / held B 2500 W, before and after this PR). Counting those seconds at the held reading would fix it, but changes the last seconds of every quarter on a healthy meter (requirement: no change when the meter never drops), so it should be its own card with its own rig test.
  • The add-on's own bank of a gap quarter still reads low (measured part only), as before; the utility's meter counts the gap. Rig test should compare against the plant's true grid.

🤖 Generated with Claude Code

https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr

## Problem After a P1 meter gap longer than `meter_max_age_s`, the averager leaves the missed seconds uncounted (on purpose, so no guess reaches the bill record). `peakguard.blindness()` then called the projection blind **for the rest of the quarter**: floor 0 W, a HOLD gave nothing, and the quarter went over. SAFETY-86 rig case 7b: N=2, 3964.8 W against a 3450 W aim, month peak raised; N=1 3701 W (review). The page still said "Keeping the grid near zero". The start-up quarter was blind for the same reason, and a held battery was drawn as last resort (SAFETY-91 test O2). ## Fix - **After a gap** (`peakguard.gap_w()`, `peakguard.fill_gaps()`, `Controller._fill_peak_gaps()`): once readings are back, every second of a silence longer than `meter_max_age_s` counts at **`gap_w = max(limit, import just before the gap, import just after it)`**, where import = grid + battery discharge (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. - **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 before and just after it, or the limit, and the batteries started no new charging (during a gap they hold their last command, then go to 0 W). It never counts a gap below the limit. Missed case: a load that rises above all three only inside the gap. Cost: conservative, 100-180 W under the aim at 6500 W house, paid in battery energy. - **While the gap lasts:** unchanged (blind, floor 0 W, "inputs missing" stop). - **Before the first reading** (start-up / restart mid-quarter; changed in review, 8dfe91e): the add-on was down, so the batteries were at 0 W. Those seconds count like a gap, at `gap_w(limit, import seen first)`, and a restored reading's held 30 s is topped up. The page says so until the quarter ends: - status row (EN): "Peak guard: part of this 15-minute period was not measured, counted as high use" - home line: EN "Part of this 15-minute period was not measured." / NL "Een deel van dit kwartier is niet gemeten." - action-log row `PEAK_GUARD_GAP: the meter missed N s of this 15-minute period, counted at X W`, hint "Meter is back, the peak guard counts the missed time as high use". - Bill record (`quarter_blocks`) unchanged. **No new option.** CHANGELOG: not touched (the repo writes it at the release cut; 0.15.0's known issue for SAFETY-90 should be dropped then). - DECISIONS/SAFETY-90-peak-guard-after-a-gap.md, one paragraph in DOCS.md's peak-guard section. ## Evidence (`test_safety90.py`: REAL Controller, 10 s battery lag, P1 telegrams stopped; quarter = what the meter really averages; aim 3450 W) | case | N | after (W) | before (W) | pass | |---|---|---|---|---| | HOLD 6500 W, gap 30 s | 1 | 3346.2 | 3456.6 | yes | | HOLD 6500 W, gap 60 s | 1 | 3272.2 | 5329.2 | yes | | HOLD 5500 W, gap 5 min | 1 | 3330.6 | 4717.9 | yes | | HOLD, B able, 6500 W, gap 30 s | 2 | 3346.3 | 3456.6 | yes | | HOLD, B able, 6500 W, gap 60 s | 2 | 3272.7 | 3865.6 | yes | | HOLD, B able, 5500 W, gap 5 min | 2 | 3288.9 | 3745.4 | yes | | HOLD, B held 15 %, 5500 W, gap 30 s | 2 | 3381.2 | 3456.5 | yes | | HOLD, B held, 5500 W, gap 60 s | 2 | 3335.4 (B 0 W) | 3254.9 (B drawn 2500 W) | yes | | HOLD, B held, 4500 W, gap 5 min | 2 | 3395.9 (B 0 W) | 3309.7 (B drawn) | yes | | gap across the edge (next quarter) | 1 | 3446.3 | 4100.0 | yes | | gap across the edge (next quarter) | 2 | 3382.2 | 3621.2 | yes | | house 3000 -> 6500 W inside a 60 s gap | 1 | 3368.3 | 5255.6 | yes | | house 3000 -> 5500 W inside a 60 s gap, B held | 2 | 3238.1 | 2193.5 (B drawn) | yes | | house 6500 -> 2000 W late in a 5 min gap | 1 | 3145.1 | 3629.2 | yes | | house 5500 -> 2000 W late in a 5 min gap, B held | 2 | 3256.0 | 2750.2 (B drawn) | yes | | 5 min gap at 3000 W, then 60 s gap at 6500 W next quarter | 1 | 3272.2 | 5329.2 | yes | | start-up 450 s in, house 5000 W (batteries at 0 W before) | 1 | 3450.0, line shown | 5000.0 (A 0 W) | yes | | start-up 450 s in, B held, house 4500 W | 2 | 3450.0, B 0 W, line shown | 3287.0 (A 0 W, B drawn) | yes | | restart, down 10 / 60 / 150 s, house 6500 W | 1 | 3450.0 each | 3483.9 / 4039.1 / 5483.3 | yes | | start-up, then 60 s gap next quarter | 1 | 3275.1 | 5329.2 | yes | | meter never drops, N=1 / N=2 / N=2 B held | | rows byte-identical with and without the fix | | yes | Also: 50 s into a gap the guard is still blind (floor 0 W); a quarter that had a gap still hands over at a late edge (SAFETY-91 roll) with B 0 W; `fill_gaps()`/`gap_w()` boundaries (tail = `max_hold_s` filled, +1 ms not; unseen split; no div-by-zero at 0 s elapsed). - **O2:** shown above: the held B gives 0 W on every cycle after readings return, and at start-up; A (holding) gives the need. - Full suite in `goodwe-addon-checks:1` with hwsim at the pin: 63/63 pass. No digest re-pinned. Infra pin 3b4ff84 = admin/goodwe release/1.0. ## Mutations (fresh copy each, `tools/mutation_run.sh`, purged caches) | # | mutation | result | |---|---|---| | M1 | gap fill removed (`_fill_peak_gaps` not called) | killed, 25 checks red | | M2 | gap counted at 0 W, no top-up | killed, 19 red | | M3 | start-up assumption applied in every quarter (edge off-by-one) | killed, 1 red (start-up then gap) | | M4a | status row "not measured" skipped | killed, 1 red (page check) | | M4b | home line "not measured" skipped | killed, 1 red (page check) | | M5 | import seen without the batteries' discharge | killed, 1 red (house falls in gap) | | M6 | start-up seconds at 0 W instead of the aim | killed, 3 red | | M7 | import after the gap ignored | killed, 3 red | | M8 | gap's first 30 s not topped up | killed, 1 red | | M9 | `tail > max_hold_s` -> `>=` | killed, 1 red (boundary) | | M10 | fill after the SAFETY-91 edge roll (order) | killed, 1 red (late edge after a gap) | | M11 | gap_w without the limit floor | killed, 1 red | | M12 | gap state not reset at the quarter edge | killed, 1 red (two gaps, two quarters) | 13/13 killed, no tracebacks. Logs: `scratch/SAFETY-90/dev/mutations-final.log`. ## Handed back - **O1 (late-reading edge): not this mechanism, left for its own card.** A reading late by less than `meter_max_age_s` is not a gap; the seconds since it are not counted in the projection at all, on every quarter. Near a quarter's end the 900/remaining gain turns that into a large negative excess and the floor drops (sim: with the edge's telegram missing, the cycle 1 s before the edge has A 0 W / held B 2500 W, before and after this PR). Counting those seconds at the held reading would fix it, but changes the last seconds of every quarter on a healthy meter (requirement: no change when the meter never drops), so it should be its own card with its own rig test. - The add-on's own bank of a gap quarter still reads low (measured part only), as before; the utility's meter counts the gap. Rig test should compare against the plant's true grid. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
admin added 1 commit 2026-10-05 12:07:59 +02:00
SAFETY-90: the peak guard goes on after a P1 meter gap
checks / unit (pull_request) Successful in 31m10s
infra-pin / fresh (pull_request) Successful in 1s
9d3fbe1be9
A meter gap past meter_max_age_s left seconds the quarter average never
counted, so the guard was blind for the rest of the quarter (rig case 7b:
3964.8 W against a 3450 W aim). Once readings are back, the missed seconds
now count at the higher of the limit and the import seen just before and
just after the gap with the batteries at 0 W (peakguard.gap_w/fill_gaps);
the gap's first meter_max_age_s is topped up to that too. Seconds before
the add-on's first reading count at the aim, and the page says part of the
period was not measured (EN/NL). The bill record is unchanged; no option.

DECISIONS/SAFETY-90-peak-guard-after-a-gap.md, test_safety90.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
admin added 2 commits 2026-10-05 12:51:44 +02:00
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>
SAFETY-90 review: test that a second gap does not price the first one down
checks / unit (pull_request) Successful in 31m12s
infra-pin / fresh (pull_request) Successful in 1s
24dbbd18ad
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

Review round 1 (SAFETY-90 review)

Pushed two commits to this branch: 8dfe91e and 24dbbd1 (head is now 24dbbd1, checked with git ls-remote).

F1: start-up and restart seconds were counted at the aim (money). Fixed in 8dfe91e

  • The reason given was "nothing is known, not even the house load". That is not true. The first reading shows the load. And while the add-on is down the batteries are at 0 W: it sets 0 W when it stops, and the ESP32 watchdog does after 30 s (DOCS "meter death → 0 W"). So the meter saw the house, not the aim.
  • I probed this on 9d3fbe1, with the import before the start taken as the house:
case true quarter on 9d3fbe1 on 8dfe91e
N=1, start 450 s in, house 5000 W 4225 W 3450 W
N=1, start 450 s in, house 6500 W 4975 W 4075 W (A at its full 5000 W, "Load too high" fires, which is true)
N=2, B held, start 450 s in, house 4500 W 3975 W 3450 W
N=1 restart, down 60 s (averager restored), house 6500 W 3653 W 3450 W
N=1 restart, down 150 s 3958 W 3450 W
  • The fix: seconds before this run's first reading count like a gap, at gap_w(limit, the import seen first). After a TARIFF-01 restore, the restored reading's held 30 s is topped up the same way. That reading is captured at Controller.__init__, right after peak.restore().
  • Status row: "…not measured, counted as high use". It said "on target", which is no longer what happens.
  • Tests: section 5 now takes the import before the start as the house (it used to assume the aim). It also adds restarts of 10, 60 and 150 s with a restored averager. DECISIONS and DOCS are updated.

F2: a second gap could price the first one down. Test added in 24dbbd1

  • The code was right: gap_w(ceiling, self._gap_w, …) keeps the higher value. But no test caught dropping self._gap_w; my mutant R3 survived.
  • New case: house 6500 → 3000 → 6500 W, with gaps at 300–360 s and 500–560 s. Both gaps must count at ≥ 6400 W. R3 is now killed.

F3: the bound's wording

"House load" is changed to "house load (less solar)" in DECISIONS. A drop in solar inside the gap is the same missed case as a load that rises only inside it.

Checked, no change needed

  • Healthy meter: the rows are byte-identical between base 3ed3302 and 8dfe91e. I checked 9 scenarios over 1900 s each:

    • N=1 HOLD; N=2 with B able; N=2 with B held;
    • a load wave from 2000 to 7000 W at N=1 and N=2;
    • telegram silences of 25 s and 29 s, every 45 or 60 s.

    The pinned identity digests (test_safety78/84/87, modes) still pass. The only path that runs without a gap is the start-up quarter. That change is intended, and the rig digests start on a quarter boundary, so they never reach it.

  • Edge arithmetic:

    • wall = 0 and done = 0 are guarded;
    • h is clamped at 0, so a gap across an edge or a restore from an earlier quarter adds nothing;
    • the tail is clamped to [0, wall];
    • an ingest timestamp ahead of now gives missing = 0;
    • remaining = 0 at the edge only adds watt-seconds, divided by 900;
    • a clock step forward reads as a gap, which is the conservative side;
    • a clock step back gives wall = 0, and the projection is unchanged.
  • Tests: the diff against 3ed3302 adds test_safety90.py and touches no other test. No test was removed (git diff --name-status: A/M only, no D).

  • O2: at N=2 the held B gives 0 W after a gap and at start-up. This is tested, including the new start-up cases. O1 → SAFETY-99: the reasoning holds. Counting the tail at the held reading would change every healthy quarter's last seconds. SAFETY-99 should cover the uncounted tail before the edge and also the 17:30 re-grade after the edge (SAFETY-91 O1).

  • UX-08: the texts are short and plain. Status rows are EN-only by convention: all 56 row_* live in messages.py, none in ui_text. The home line is EN/NL, and the NL is fine.

  • No new option: config.yaml is untouched. DOCS has one paragraph plus a pointer.

Mutations (mine; fresh copy each, tools/mutation_run.sh)

# mutant result
R1 start-up seconds back at the aim killed
R2 no top-up of the restored held 30 s killed
R3 second gap forgets the first's gap_w survived on 8dfe91e → killed on 24dbbd1
R4 tail filled as gap (tail = 0) killed (5 checks, including blind-while-open and identity)
R5 seen with signed battery (charging subtracts) survived: see below
R6 import before the gap never kept killed
R7 filled seconds not counted as covered (stays blind) killed
R8 start-up seconds never priced (limit only) killed

R5, refuted as not a money risk: R5 can only lower seen while the battery charges from the grid. While it charges, the peak guard caps the charge, so the grid stays at or under the aim. gap_w never goes below the limit, so the count still covers that import.

Still open (disclosed, for the rig)

  • A load that rises above all three only inside the gap is missed.
  • In the first 30 s of a gap the loop still steers on the held reading (it did before this PR too).
## Review round 1 (SAFETY-90 review) Pushed two commits to this branch: 8dfe91e and 24dbbd1 (head is now 24dbbd1, checked with `git ls-remote`). ### F1: start-up and restart seconds were counted at the aim (money). **Fixed in 8dfe91e** - The reason given was "nothing is known, not even the house load". That is not true. The first reading shows the load. And while the add-on is down the batteries are at 0 W: it sets 0 W when it stops, and the ESP32 watchdog does after 30 s (DOCS "meter death → 0 W"). So the meter saw the house, not the aim. - I probed this on 9d3fbe1, with the import before the start taken as the house: | case | true quarter on 9d3fbe1 | on 8dfe91e | |---|---|---| | N=1, start 450 s in, house 5000 W | 4225 W | 3450 W | | N=1, start 450 s in, house 6500 W | 4975 W | 4075 W (A at its full 5000 W, "Load too high" fires, which is true) | | N=2, B held, start 450 s in, house 4500 W | 3975 W | 3450 W | | N=1 restart, down 60 s (averager restored), house 6500 W | 3653 W | 3450 W | | N=1 restart, down 150 s | 3958 W | 3450 W | - The fix: seconds before this run's first reading count like a gap, at `gap_w(limit, the import seen first)`. After a TARIFF-01 restore, the restored reading's held 30 s is topped up the same way. That reading is captured at `Controller.__init__`, right after `peak.restore()`. - Status row: "…not measured, counted as high use". It said "on target", which is no longer what happens. - Tests: section 5 now takes the import before the start as the house (it used to assume the aim). It also adds restarts of 10, 60 and 150 s with a restored averager. DECISIONS and DOCS are updated. ### F2: a second gap could price the first one down. **Test added in 24dbbd1** - The code was right: `gap_w(ceiling, self._gap_w, …)` keeps the higher value. But no test caught dropping `self._gap_w`; my mutant R3 survived. - New case: house 6500 → 3000 → 6500 W, with gaps at 300–360 s and 500–560 s. Both gaps must count at ≥ 6400 W. R3 is now killed. ### F3: the bound's wording "House load" is changed to "house load (less solar)" in DECISIONS. A drop in solar inside the gap is the same missed case as a load that rises only inside it. ### Checked, no change needed - **Healthy meter:** the rows are byte-identical between base 3ed3302 and 8dfe91e. I checked 9 scenarios over 1900 s each: - N=1 HOLD; N=2 with B able; N=2 with B held; - a load wave from 2000 to 7000 W at N=1 and N=2; - telegram silences of 25 s and 29 s, every 45 or 60 s. The pinned identity digests (test_safety78/84/87, modes) still pass. The only path that runs without a gap is the start-up quarter. That change is intended, and the rig digests start on a quarter boundary, so they never reach it. - **Edge arithmetic:** - wall = 0 and done = 0 are guarded; - `h` is clamped at 0, so a gap across an edge or a restore from an earlier quarter adds nothing; - the tail is clamped to [0, wall]; - an ingest timestamp ahead of `now` gives missing = 0; - remaining = 0 at the edge only adds watt-seconds, divided by 900; - a clock step forward reads as a gap, which is the conservative side; - a clock step back gives wall = 0, and the projection is unchanged. - **Tests:** the diff against 3ed3302 adds `test_safety90.py` and touches no other test. No test was removed (`git diff --name-status`: A/M only, no D). - **O2:** at N=2 the held B gives 0 W after a gap and at start-up. This is tested, including the new start-up cases. **O1 → SAFETY-99:** the reasoning holds. Counting the tail at the held reading would change every healthy quarter's last seconds. SAFETY-99 should cover the uncounted tail before the edge and also the 17:30 re-grade after the edge (SAFETY-91 O1). - **UX-08:** the texts are short and plain. Status rows are EN-only by convention: all 56 `row_*` live in messages.py, none in ui_text. The home line is EN/NL, and the NL is fine. - **No new option:** config.yaml is untouched. DOCS has one paragraph plus a pointer. ### Mutations (mine; fresh copy each, `tools/mutation_run.sh`) | # | mutant | result | |---|---|---| | R1 | start-up seconds back at the aim | killed | | R2 | no top-up of the restored held 30 s | killed | | R3 | second gap forgets the first's gap_w | survived on 8dfe91e → killed on 24dbbd1 | | R4 | tail filled as gap (tail = 0) | killed (5 checks, including blind-while-open and identity) | | R5 | `seen` with signed battery (charging subtracts) | survived: see below | | R6 | import before the gap never kept | killed | | R7 | filled seconds not counted as covered (stays blind) | killed | | R8 | start-up seconds never priced (limit only) | killed | R5, refuted as not a money risk: R5 can only lower `seen` while the battery charges from the grid. While it charges, the peak guard caps the charge, so the grid stays at or under the aim. `gap_w` never goes below the limit, so the count still covers that import. ### Still open (disclosed, for the rig) - A load that rises above all three only inside the gap is missed. - In the first 30 s of a gap the loop still steers on the held reading (it did before this PR too).
Author
Owner

Review closed. F1 fixed (8dfe91e), F2 test added (24dbbd1), F3 wording fixed. R5 refuted (see above). Rows on a healthy meter are byte-identical to the base. Local suite 63/63 on 24dbbd1 in goodwe-addon-checks:1 with hwsim at 3b4ff84. CI checks / unit (run 1181) and infra-pin / fresh are green on 24dbbd1. Merging into release/1.0 with head_commit_id pinned. Before release, the rig test on test23 must use a real meter dropout and a restart mid-HOLD, at N=1 and N=2, and grade against the plant's true grid.

Review closed. F1 fixed (8dfe91e), F2 test added (24dbbd1), F3 wording fixed. R5 refuted (see above). Rows on a healthy meter are byte-identical to the base. Local suite 63/63 on 24dbbd1 in goodwe-addon-checks:1 with hwsim at 3b4ff84. CI `checks / unit` (run 1181) and `infra-pin / fresh` are green on 24dbbd1. Merging into release/1.0 with head_commit_id pinned. Before release, the rig test on test23 must use a real meter dropout and a restart mid-HOLD, at N=1 and N=2, and grade against the plant's true grid.
admin merged commit 7ef73474dc into release/1.0 2026-10-05 13:23:33 +02:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/goodwe-addon#175