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:
"""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):