From edf30f345a8519f300e8c0d5b26dd4748fdbe95d Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Thu, 12 Nov 2026 23:58:19 +0100 Subject: [PATCH] MAINT-28: keep the single shaping_suspended(.., maint_aim) call site (test_peak_margin pin); shaping_held takes its answer Co-Authored-By: Claude --- .../DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md | 2 +- goodwe_controller/app/ctl_cycle.py | 7 ++++--- goodwe_controller/app/peakguard.py | 12 ++++++------ goodwe_controller/test_maint28.py | 8 +++++++- 4 files changed, 18 insertions(+), 11 deletions(-) diff --git a/goodwe_controller/DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md b/goodwe_controller/DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md index 386b1aa..fba51c6 100644 --- a/goodwe_controller/DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md +++ b/goodwe_controller/DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md @@ -6,7 +6,7 @@ `peakguard.shaping_suspended()` pauses the charge while the forecast is over the aim or the guard is engaged. MAINT-24 moved the aim from 3450 W to the month aim (SAFETY-80), so the pause no longer lasts the whole peak: the guard gives, its projection drops under 5950 W and it idles ("heading for 5 6xx W, under this month's peak"), the pause ends, the floor charges A 150-460 W, the grid goes to 6.3-6.9 kW, the guard trips again. A flipped charge/give 468 times in 45 min (base 02eac3b 11). The quarters stayed at the aim; the cost was a battery working against itself and a grid that swung 5.4-6.9 kW. ## The change -`peakguard.shaping_held(held, decision, forecast, aim, house)`: the old conjunction, and once it was true the pause stays until the house (`grid + battery power`, the load without our own charge) is `MAINT_RESUME_UNDER_W` = 300 W under the aim. A guard that is engaged or a forecast over the aim still pauses at once. House unread: the forecast stands in; neither read: no pause (SAFETY-63). `ctl_cycle.py` keeps the flag in `_maint_peak_hold`. The hold is for N=2 and up, and only where MAINT-24 moved the aim (a banked month peak above `peak_cap_w`, so the aim is above cap minus margin). Elsewhere the old rule stays, byte for byte: SAFETY-87's N=1 digests (`n1_charge_low`) and SAFETY-119's N=2 charge at 5050 W over a 5000 W cap with no banked peak are pinned, and 1b5f7c9 behaves as 02eac3b there (a chase that was not a MAINT-24 regression). +`peakguard.shaping_held(suspended, held, forecast, aim, house)`: `suspended` is the old `shaping_suspended()`, and once it was true the pause stays until the house (`grid + battery power`, the load without our own charge) is `MAINT_RESUME_UNDER_W` = 300 W under the aim. A guard that is engaged or a forecast over the aim still pauses at once. House unread: the forecast stands in; neither read: no pause (SAFETY-63). `ctl_cycle.py` keeps the flag in `_maint_peak_hold`. The hold is for N=2 and up, and only where MAINT-24 moved the aim (a banked month peak above `peak_cap_w`, so the aim is above cap minus margin). Elsewhere the old rule stays, byte for byte: SAFETY-87's N=1 digests (`n1_charge_low`) and SAFETY-119's N=2 charge at 5050 W over a 5000 W cap with no banked peak are pinned, and 1b5f7c9 behaves as 02eac3b there (a chase that was not a MAINT-24 regression). Why the house and not the forecast: while A gives, the forecast sits just under the aim by design, so no forecast margin is both safe and reachable. The house is the meter reading plus the batteries' power (`grid + site_batt_w`): the net demand the meter would see with no battery at work. Solar is already in it, and that is right: the aim is a limit on what the meter takes, so a sunny hour that lowers the net demand is real room, and a cloud that raises it brings the hold back at once. It is not the gross load, and it would be wrong to add PV back (that would hold the pause on room the meter really has). It stays put when A switches, except for the age of the battery reading (up to 10 s on the rig); the 300 W line is far larger than that lag moves it. diff --git a/goodwe_controller/app/ctl_cycle.py b/goodwe_controller/app/ctl_cycle.py index 006c919..423d248 100644 --- a/goodwe_controller/app/ctl_cycle.py +++ b/goodwe_controller/app/ctl_cycle.py @@ -572,11 +572,12 @@ class CycleMixin: # (SAFETY-87 / SAFETY-119 pins). house = (None if self.grid is None or self.site_batt_w is None else float(self.grid) + float(self.site_batt_w)) + _susp = peakguard.shaping_suspended( + self.peak_guard, self.peak_fc, maint_aim) self._maint_peak_hold = peakguard.shaping_held( - _peak_held and len(self.inverters) > 1 + _susp, _peak_held and len(self.inverters) > 1 and maint_aim > self.peak_aim_w(), - self.peak_guard, self.peak_fc, - maint_aim, house) + self.peak_fc, maint_aim, house) if self._maint_peak_hold: # Edge-logged: level-logging here would write one line per # cycle for as long as the event lasts and flush the 40-line diff --git a/goodwe_controller/app/peakguard.py b/goodwe_controller/app/peakguard.py index cd31f86..6f052aa 100644 --- a/goodwe_controller/app/peakguard.py +++ b/goodwe_controller/app/peakguard.py @@ -450,13 +450,13 @@ def shaping_suspended(decision, peak_forecast_w, cap_w) -> bool: return decision is not None and decision.engaged -def shaping_held(held, decision, peak_forecast_w, aim_w, house_w) -> bool: - """MAINT-28. `shaping_suspended()`, kept on until the house is - `MAINT_RESUME_UNDER_W` under `aim_w` (`peak_forecast_w` stands in for an - unread house; neither read: not held, as SAFETY-63). Only ever pauses the - maintenance charge, never a cut-back. +def shaping_held(suspended, held, peak_forecast_w, aim_w, house_w) -> bool: + """MAINT-28. `suspended` (`shaping_suspended()`'s answer), kept on while + `held` until the house is `MAINT_RESUME_UNDER_W` under `aim_w` + (`peak_forecast_w` stands in for an unread house; neither read: not held, + as SAFETY-63). Only ever pauses the maintenance charge, never a cut-back. DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md.""" - if shaping_suspended(decision, peak_forecast_w, aim_w): + if suspended: return True if not held: return False diff --git a/goodwe_controller/test_maint28.py b/goodwe_controller/test_maint28.py index 284e851..f4a8c4f 100644 --- a/goodwe_controller/test_maint28.py +++ b/goodwe_controller/test_maint28.py @@ -210,7 +210,13 @@ check("a new maintenance charge at house 5700 W (over the resume line) " # ============================================================================ # print("\n2. peakguard.shaping_held()") -held = peakguard.shaping_held + + +def held(was, decision, fc, aim, house): + return peakguard.shaping_held( + peakguard.shaping_suspended(decision, fc, aim), was, fc, aim, house) + + U = peakguard.MAINT_RESUME_UNDER_W eng = peakguard.PeakDecision(engaged=True, floor_w=100.0, ceiling_w=6000.0, ceiling_from="x", target_w=AIM)