From 56d5532b83cf5ea33e619a1ab170af75bf4a55dc Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Sun, 8 Nov 2026 18:06:00 +0100 Subject: [PATCH] MAINT-20 part 2 r2: app/main.py comments, lines 2001-2500 (_reseed_drain_end .. _fill_peak_gaps) Co-Authored-By: Claude --- goodwe_controller/app/main.py | 65 ++++++++++++++++++++++------------- 1 file changed, 41 insertions(+), 24 deletions(-) diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 3a8225c..cf6147a 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -2013,9 +2013,10 @@ class Controller: def _reseed_drain_end(self, maint_results, live) -> None: """SAFETY-95, N>=2: when a battery's maintenance drain ends, its drain - leaves the loop's start point, as a charge does (SAFETY-93): the - start is what the others stood at plus its new command (its pin, - else 0 W). (DECISIONS/MAIN-orchestration-archive.md)""" + leaves the loop's start point, as a charge does (SAFETY-93): the start + is what the others stood at plus its new command (its pin, else 0 W), + never above last cycle's start; else the drain's floor lands on the + others (B 0 -> +1600 W, exporting). DECISIONS/SAFETY-95-drain-end.md""" ended, self._drain_ended = self._drain_ended, frozenset() if len(self.inverters) < 2 or not ended: return @@ -2040,8 +2041,13 @@ class Controller: self.target = self.i_w = min(hi, max(lo, here)) def _drain_grid_w(self, live) -> float: - """SAFETY-95: the grid the loop regulates. - (DECISIONS/MAIN-orchestration-archive.md)""" + """SAFETY-95: the grid the loop regulates. While a battery settles from + its ended drain (`_held_out_w`, at most PLANT_SETTLE_DOWN_S) the + batteries count at the site command, so the loop does not chase the + drain's tail. SAFETY-103: a reading can be up to a register period (10 + s) older than the meter, so the house point is held and moved only as + far as the meter proves. + DECISIONS/SAFETY-103-old-readings-after-a-drain.md""" grid = float(self.grid) self._drain_tail &= set(self._settle) & set(live) if not self._drain_tail: @@ -2086,9 +2092,9 @@ class Controller: def _reseed_drop(self, live) -> None: """SAFETY-96, N>=2: a battery that drops out of the split (stale link) - leaves the loop's start point, so the others take over at one slew - step a cycle instead of taking its share at once. - (DECISIONS/MAIN-orchestration-archive.md)""" + leaves the loop's start point, so the others take over at one slew step + a cycle instead of taking its share at once. + DECISIONS/SAFETY-96-dropped-battery.md""" was, self._live_was = self._live_was, frozenset(live) if len(self.inverters) < 2 or was is None or was == live: return @@ -2110,8 +2116,9 @@ class Controller: def _held_out_w(self, live) -> float: """SAFETY-96, N>=2: what to add to the site sum so that a battery the - loop cannot move right now counts at its command, not at its - reading. (DECISIONS/MAIN-orchestration-archive.md)""" + loop cannot move right now counts at its command, not at its reading. + Its real power still reaches the loop through the meter: neither lost + nor counted twice. DECISIONS/SAFETY-96-dropped-battery.md""" if len(self.inverters) < 2: return 0.0 out = 0.0 @@ -2132,7 +2139,9 @@ class Controller: def _mode_fault_log(self, resolution) -> None: """OPT-11: say once, per battery, that its mode was dropped as a fault. - (DECISIONS/MAIN-orchestration-archive.md)""" + A mode range without 0 W would force a direction, which no mode may do, + so it is never applied (allocator.unit_interval()); main.py never + builds one. (DECISIONS/MAIN-orchestration-archive.md)""" alloc = resolution.allocation now = {u for u, _ in getattr(alloc, "mode_faults", ())} if resolution.mode_fault is not None and self.inverters: @@ -2148,7 +2157,9 @@ class Controller: def _pack_action(self, what, unit, value, now) -> dict: """OPT-11: switch one battery on or off, set its mode, or (P3) turn its - selling on or off. (DECISIONS/MAIN-orchestration-archive.md)""" + selling on or off. Store state, not an option: it applies next cycle + with no restart. Switching a battery off also stops its maintenance + cycle. (DECISIONS/MAIN-orchestration-archive.md)""" if not self.modes_on: return {"error": messages.pack_refused("off")} inv = next((i for i in self.inverters if i.name == unit), None) @@ -2189,7 +2200,8 @@ class Controller: async def _maint_unit_action(self, what, unit, now) -> dict: """MAINT-11: start or abort the maintenance cycle of ONE named battery. - (DECISIONS/MAIN-orchestration-archive.md)""" + The site keeps ONE maintenance slot: a start while any battery holds it + is refused (no queue). (DECISIONS/MAIN-orchestration-archive.md)""" inv = next((i for i in self.inverters if i.name == unit), None) if inv is None: return self._maint_refusal("unit") @@ -2234,8 +2246,10 @@ class Controller: return out async def mqtt_command(self, topic, payload, retained) -> None: - """OPT-11 P2: one command from a battery's HA switch or mode list. - (DECISIONS/MAIN-orchestration-archive.md)""" + """OPT-11 P2: one command from a battery's HA switch or mode list, + through handle_action(). A retained command is never applied (the store + already holds the last choice). Never raises: MQTT must not reach + control. (DECISIONS/MAIN-orchestration-archive.md)""" try: slug, action, value, why = parse_command( topic, payload, retained, packmode.MODES) @@ -2303,8 +2317,9 @@ class Controller: def _peak_on_able(self, decision, maint_results): """SAFETY-87: at N>=2, `decision` placed on the batteries that can - still give: live, not refusing, above their own reserve. - (DECISIONS/MAIN-orchestration-archive.md)""" + still give: live, not refusing, above their own reserve. `able_w` is + their rating plus the drain a held battery gives anyway. + DECISIONS/SAFETY-87-guard-uses-able-batteries.md""" if len(self.inverters) < 2: return decision floor = float(self.o.get( @@ -2332,8 +2347,9 @@ class Controller: def _peak_landing(self, decision): """SAFETY-126: `decision` marked `within_aim` while the guard wants power but the batteries at 0 W would end the quarter within - `ALARM_MISS_W` of the aim (`peakguard.misses_aim()`). - (DECISIONS/MAIN-orchestration-archive.md)""" + `ALARM_MISS_W` of the aim (`peakguard.misses_aim()`). The claim and the + commands are unchanged; only the alarms and the "Why" read it. + DECISIONS/SAFETY-126-landing-error-is-not-an-overrun.md""" batt, prev = self.site_batt_w, self._land_batt_prev self._land_batt_prev = batt if not (decision.engaged and decision.blind is None @@ -2387,8 +2403,9 @@ class Controller: def _held_back(self) -> frozenset: """SAFETY-91, N>=2: the batteries at or under their own peak reserve - while another can still give (live, not refusing). - (DECISIONS/MAIN-orchestration-archive.md)""" + while another can still give (live, not refusing). One held back stays + so until its SoC reads more than 1 point above its reserve. + DECISIONS/SAFETY-91-held-battery-last.md""" if len(self.inverters) < 2: return frozenset() floor = float(self.o.get( @@ -2409,9 +2426,9 @@ class Controller: def _drain_rise(self, claims, maint_results) -> None: """SAFETY-91, N>=2: while a battery is held back (`_held_back()`), a - draining battery's pin becomes a floor at its drain rate, so it - rises towards its rating before the held one gives. - (DECISIONS/MAIN-orchestration-archive.md)""" + draining battery's pin becomes a floor at its drain rate, so it rises + towards its rating before the held one gives. A site stop still decides + alone. DECISIONS/SAFETY-91-held-battery-last.md""" held = self._held_back() if not held or any(c.kind == SET and c.actuator == ACTUATOR_SITE and c.priority > P_PEAK for c in claims):