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.
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.
## 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
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>
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>
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:
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.
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).
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.02026-10-05 13:23:33 +02:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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
peakguard.gap_w(),peakguard.fill_gaps(),Controller._fill_peak_gaps()): once readings are back, every second of a silence longer thanmeter_max_age_scounts atgap_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 firstmeter_max_age_s, which the averager credits at the last reading, is topped up togap_wtoo. The guard then grades the quarter as usual.8dfe91e): the add-on was down, so the batteries were at 0 W. Those seconds count like a gap, atgap_w(limit, import seen first), and a restored reading's held 30 s is topped up. The page says so until the quarter ends: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".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).Evidence (
test_safety90.py: REAL Controller, 10 s battery lag, P1 telegrams stopped; quarter = what the meter really averages; aim 3450 W)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_sfilled, +1 ms not; unseen split; no div-by-zero at 0 s elapsed).goodwe-addon-checks:1with 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)_fill_peak_gapsnot called)tail > max_hold_s->>=13/13 killed, no tracebacks. Logs:
scratch/SAFETY-90/dev/mutations-final.log.Handed back
meter_max_age_sis 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.🤖 Generated with Claude Code
https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
Review round 1 (SAFETY-90 review)
Pushed two commits to this branch:
8dfe91eand24dbbd1(head is now24dbbd1, checked withgit ls-remote).F1: start-up and restart seconds were counted at the aim (money). Fixed in
8dfe91e9d3fbe1, with the import before the start taken as the house:9d3fbe18dfe91egap_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 atController.__init__, right afterpeak.restore().F2: a second gap could price the first one down. Test added in
24dbbd1gap_w(ceiling, self._gap_w, …)keeps the higher value. But no test caught droppingself._gap_w; my mutant R3 survived.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
3ed3302and8dfe91e. I checked 9 scenarios over 1900 s each: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:
his clamped at 0, so a gap across an edge or a restore from an earlier quarter adds nothing;nowgives missing = 0;Tests: the diff against
3ed3302addstest_safety90.pyand 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)8dfe91e→ killed on24dbbd1seenwith signed battery (charging subtracts)R5, refuted as not a money risk: R5 can only lower
seenwhile 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_wnever goes below the limit, so the count still covers that import.Still open (disclosed, for the rig)
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 on24dbbd1in goodwe-addon-checks:1 with hwsim at 3b4ff84. CIchecks / unit(run 1181) andinfra-pin / freshare green on24dbbd1. 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.