MAINT-20 part 2 r2: app/main.py comments, lines 2001-2500 (_reseed_drain_end .. _fill_peak_gaps)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-11-08 18:06:00 +01:00
co-authored by Claude
parent 9ec0a84129
commit 56d5532b83
+41 -24
View File
@@ -2013,9 +2013,10 @@ class Controller:
def _reseed_drain_end(self, maint_results, live) -> None: def _reseed_drain_end(self, maint_results, live) -> None:
"""SAFETY-95, N>=2: when a battery's maintenance drain ends, its drain """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 leaves the loop's start point, as a charge does (SAFETY-93): the start
start is what the others stood at plus its new command (its pin, is what the others stood at plus its new command (its pin, else 0 W),
else 0 W). (DECISIONS/MAIN-orchestration-archive.md)""" 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() ended, self._drain_ended = self._drain_ended, frozenset()
if len(self.inverters) < 2 or not ended: if len(self.inverters) < 2 or not ended:
return return
@@ -2040,8 +2041,13 @@ class Controller:
self.target = self.i_w = min(hi, max(lo, here)) self.target = self.i_w = min(hi, max(lo, here))
def _drain_grid_w(self, live) -> float: def _drain_grid_w(self, live) -> float:
"""SAFETY-95: the grid the loop regulates. """SAFETY-95: the grid the loop regulates. While a battery settles from
(DECISIONS/MAIN-orchestration-archive.md)""" 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) grid = float(self.grid)
self._drain_tail &= set(self._settle) & set(live) self._drain_tail &= set(self._settle) & set(live)
if not self._drain_tail: if not self._drain_tail:
@@ -2086,9 +2092,9 @@ class Controller:
def _reseed_drop(self, live) -> None: def _reseed_drop(self, live) -> None:
"""SAFETY-96, N>=2: a battery that drops out of the split (stale link) """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 leaves the loop's start point, so the others take over at one slew step
step a cycle instead of taking its share at once. a cycle instead of taking its share at once.
(DECISIONS/MAIN-orchestration-archive.md)""" DECISIONS/SAFETY-96-dropped-battery.md"""
was, self._live_was = self._live_was, frozenset(live) was, self._live_was = self._live_was, frozenset(live)
if len(self.inverters) < 2 or was is None or was == live: if len(self.inverters) < 2 or was is None or was == live:
return return
@@ -2110,8 +2116,9 @@ class Controller:
def _held_out_w(self, live) -> float: def _held_out_w(self, live) -> float:
"""SAFETY-96, N>=2: what to add to the site sum so that a battery the """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 loop cannot move right now counts at its command, not at its reading.
reading. (DECISIONS/MAIN-orchestration-archive.md)""" 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: if len(self.inverters) < 2:
return 0.0 return 0.0
out = 0.0 out = 0.0
@@ -2132,7 +2139,9 @@ class Controller:
def _mode_fault_log(self, resolution) -> None: def _mode_fault_log(self, resolution) -> None:
"""OPT-11: say once, per battery, that its mode was dropped as a fault. """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 alloc = resolution.allocation
now = {u for u, _ in getattr(alloc, "mode_faults", ())} now = {u for u, _ in getattr(alloc, "mode_faults", ())}
if resolution.mode_fault is not None and self.inverters: 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: def _pack_action(self, what, unit, value, now) -> dict:
"""OPT-11: switch one battery on or off, set its mode, or (P3) turn its """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: if not self.modes_on:
return {"error": messages.pack_refused("off")} return {"error": messages.pack_refused("off")}
inv = next((i for i in self.inverters if i.name == unit), None) 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: async def _maint_unit_action(self, what, unit, now) -> dict:
"""MAINT-11: start or abort the maintenance cycle of ONE named battery. """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) inv = next((i for i in self.inverters if i.name == unit), None)
if inv is None: if inv is None:
return self._maint_refusal("unit") return self._maint_refusal("unit")
@@ -2234,8 +2246,10 @@ class Controller:
return out return out
async def mqtt_command(self, topic, payload, retained) -> None: async def mqtt_command(self, topic, payload, retained) -> None:
"""OPT-11 P2: one command from a battery's HA switch or mode list. """OPT-11 P2: one command from a battery's HA switch or mode list,
(DECISIONS/MAIN-orchestration-archive.md)""" 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: try:
slug, action, value, why = parse_command( slug, action, value, why = parse_command(
topic, payload, retained, packmode.MODES) topic, payload, retained, packmode.MODES)
@@ -2303,8 +2317,9 @@ class Controller:
def _peak_on_able(self, decision, maint_results): def _peak_on_able(self, decision, maint_results):
"""SAFETY-87: at N>=2, `decision` placed on the batteries that can """SAFETY-87: at N>=2, `decision` placed on the batteries that can
still give: live, not refusing, above their own reserve. still give: live, not refusing, above their own reserve. `able_w` is
(DECISIONS/MAIN-orchestration-archive.md)""" their rating plus the drain a held battery gives anyway.
DECISIONS/SAFETY-87-guard-uses-able-batteries.md"""
if len(self.inverters) < 2: if len(self.inverters) < 2:
return decision return decision
floor = float(self.o.get( floor = float(self.o.get(
@@ -2332,8 +2347,9 @@ class Controller:
def _peak_landing(self, decision): def _peak_landing(self, decision):
"""SAFETY-126: `decision` marked `within_aim` while the guard wants """SAFETY-126: `decision` marked `within_aim` while the guard wants
power but the batteries at 0 W would end the quarter within power but the batteries at 0 W would end the quarter within
`ALARM_MISS_W` of the aim (`peakguard.misses_aim()`). `ALARM_MISS_W` of the aim (`peakguard.misses_aim()`). The claim and the
(DECISIONS/MAIN-orchestration-archive.md)""" 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 batt, prev = self.site_batt_w, self._land_batt_prev
self._land_batt_prev = batt self._land_batt_prev = batt
if not (decision.engaged and decision.blind is None if not (decision.engaged and decision.blind is None
@@ -2387,8 +2403,9 @@ class Controller:
def _held_back(self) -> frozenset: def _held_back(self) -> frozenset:
"""SAFETY-91, N>=2: the batteries at or under their own peak reserve """SAFETY-91, N>=2: the batteries at or under their own peak reserve
while another can still give (live, not refusing). while another can still give (live, not refusing). One held back stays
(DECISIONS/MAIN-orchestration-archive.md)""" so until its SoC reads more than 1 point above its reserve.
DECISIONS/SAFETY-91-held-battery-last.md"""
if len(self.inverters) < 2: if len(self.inverters) < 2:
return frozenset() return frozenset()
floor = float(self.o.get( floor = float(self.o.get(
@@ -2409,9 +2426,9 @@ class Controller:
def _drain_rise(self, claims, maint_results) -> None: def _drain_rise(self, claims, maint_results) -> None:
"""SAFETY-91, N>=2: while a battery is held back (`_held_back()`), a """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 draining battery's pin becomes a floor at its drain rate, so it rises
rises towards its rating before the held one gives. towards its rating before the held one gives. A site stop still decides
(DECISIONS/MAIN-orchestration-archive.md)""" alone. DECISIONS/SAFETY-91-held-battery-last.md"""
held = self._held_back() held = self._held_back()
if not held or any(c.kind == SET and c.actuator == ACTUATOR_SITE if not held or any(c.kind == SET and c.actuator == ACTUATOR_SITE
and c.priority > P_PEAK for c in claims): and c.priority > P_PEAK for c in claims):