From 8dfe91e1d238b6c604ea9af515732f2de23e462a Mon Sep 17 00:00:00 2001 From: "Claude (SAFETY-90 review)" Date: Mon, 5 Oct 2026 12:24:33 +0200 Subject: [PATCH] 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 --- .../SAFETY-90-peak-guard-after-a-gap.md | 28 ++++--- goodwe_controller/DOCS.md | 5 +- goodwe_controller/app/main.py | 42 +++++++--- goodwe_controller/app/messages.py | 4 +- goodwe_controller/test_safety90.py | 84 ++++++++++++++----- 5 files changed, 120 insertions(+), 43 deletions(-) diff --git a/goodwe_controller/DECISIONS/SAFETY-90-peak-guard-after-a-gap.md b/goodwe_controller/DECISIONS/SAFETY-90-peak-guard-after-a-gap.md index 9274bc2..dfaf73c 100644 --- a/goodwe_controller/DECISIONS/SAFETY-90-peak-guard-after-a-gap.md +++ b/goodwe_controller/DECISIONS/SAFETY-90-peak-guard-after-a-gap.md @@ -22,7 +22,7 @@ resort (SAFETY-91 test, O2). 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 stayed at or under the higher of the loads seen just + 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 @@ -33,14 +33,21 @@ resort (SAFETY-91 test, O2). - **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) nothing is - known, not even the house load, so a conservative count has no basis: the - connection rating would make every restart drain at full power and raise - "Load too high". Those seconds count **at the aim**, so the guard keeps the - rest of the quarter on the aim, and the page says so: the status row "Peak - guard: part of this 15-minute period was not measured, counted as on - target" 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. +- **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 @@ -61,7 +68,8 @@ it, gap starting 300 s in; 10 s battery lag; aim 3450 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 into the quarter, N=1 (first half at the aim) | 4975.0 | 3450.0 | +| 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 diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 0798008..897669a 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -1760,8 +1760,9 @@ 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 as on target, and the page says part of the period was not measured. -DECISIONS/SAFETY-90-peak-guard-after-a-gap.md has the bound. +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. diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 2cc43cb..ee2dc35 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -955,6 +955,12 @@ class Controller: 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 @@ -2706,16 +2712,18 @@ class Controller: 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 at the aim (shown as not - measured). DECISIONS/SAFETY-90-peak-guard-after-a-gap.md.""" + 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)) + 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: @@ -2723,12 +2731,25 @@ class Controller: 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 self._unseen is not None - and self._unseen[0] == proj.block_start else 0.0), - unseen_w=peakguard.quarter_target_w( - cap, self.peak_margin_w, reading)) + 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, @@ -2752,7 +2773,8 @@ class Controller: 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, **kw) + 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: @@ -6458,7 +6480,7 @@ class Controller: 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 at the aim. + # 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: diff --git a/goodwe_controller/app/messages.py b/goodwe_controller/app/messages.py index 0e4621d..6ad9814 100644 --- a/goodwe_controller/app/messages.py +++ b/goodwe_controller/app/messages.py @@ -794,9 +794,9 @@ def row_peak_guard_blind(why: str) -> str: def row_peak_guard_unmeasured() -> str: - """SAFETY-90. Before the first reading; that part counts as on target.""" + """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 on target") + "counted as high use") def row_peak_guard_on(floor_w, limit_w, *, shadow: bool = False) -> str: diff --git a/goodwe_controller/test_safety90.py b/goodwe_controller/test_safety90.py index 350f881..8d5840b 100644 --- a/goodwe_controller/test_safety90.py +++ b/goodwe_controller/test_safety90.py @@ -4,8 +4,8 @@ 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 seconds -before the add-on's first reading count at the aim, with a plain line on the +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. @@ -35,6 +35,7 @@ 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 @@ -280,32 +281,77 @@ def grab(c, t): UI["nl"] = homeview.month_peak(c, c.cycle_at)["line"] finally: homeview._LANG[0] = "en" - if t == 500.0: + if t == 453.0: UI["later"] = (c.peak_unmeasured_s, [r for r in c.checks() if r.get("id") == "peak_guard"]) -for n, socs, house in ((1, {"a": 99.0}, 6500.0), - (2, {"a": 99.0, "b": 15.0}, 5500.0)): - rows, c = run(house, socs, 950.0, n=n, start=450.0, - on_cycle=grab if n == 1 else None) - orows, oc = without_fix(lambda: run(house, socs, 950.0, n=n, start=450.0)) - seen = sum(max(g, 0.0) for t, g in c.truth if t < 900) / QUARTER_S - old_seen = sum(max(g, 0.0) for t, g in oc.truth if t < 900) / QUARTER_S - land = seen + 450.0 * AIM / QUARTER_S +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_seen + 450.0 * AIM / QUARTER_S, 1))) - print(f" N={n}: measured part {seen:.1f} W + 450 s at the aim = " - f"{land:.1f} W (before the fix {old_seen:.1f} W + the same)") - check(f"⭐ N={n}: with the first 450 s at the aim the quarter lands at or " - f"under it; the holding A gives the need from the first minute", - land <= AIM + TOL and mean(rows, "a", 30, 450) >= house - AIM) + 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)", - all(cmd(r)["b"] == 0.0 for r in rows if 1 <= r[0] < 450)) + 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)