MAINT-20 part 2 r2: app/main.py comments, lines 1501-2000 (pack_floor_soc .. _reseed_charge_end)

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 6492d179d6
commit 9ec0a84129
+42 -19
View File
@@ -1609,8 +1609,10 @@ class Controller:
def weather_held(self, now) -> bool:
"""TARIFF-24: no forecast now, but a run planned with one is under way:
it keeps its gap to its end. The wider gap applies from the next
run. (DECISIONS/MAIN-orchestration-archive.md)"""
it keeps its gap to its end (OPT-34: a night charge that keeps its
level too); the wider gap applies from the next run. Pure; Home asks
the same (`homeview.trade_verdict()`).
(DECISIONS/MAIN-orchestration-archive.md)"""
return self.solar_fc is None and (
(self._wx_free_to is not None and now < self._wx_free_to)
or (self._charge_latch is not None
@@ -1618,8 +1620,9 @@ class Controller:
def _price_runs(self, now, packs, floor_pct, ceiling_pct):
"""The kept run plan, rebuilt at most once per price slot (15 min) or
when the prices or a battery's choice change; a run under way is
kept to its end (`priceruns.keep_started()`).
when the prices or a battery's choice change; a run under way is kept
to its end (`priceruns.keep_started()`). None with no fresh firm
prices: then no battery holds or trades.
(DECISIONS/MAIN-orchestration-archive.md)"""
plan = self.fresh_tariff_plan(now)
if plan is None or not plan.has_prices:
@@ -1672,9 +1675,9 @@ class Controller:
required, ref, done_c, e):
"""OPT-11 P4: the day plan (`dayplan.plan()`) on the runs just made,
its levels held by the hysteresis (`dayplan.hold()`), then the runs
made again with each battery's shortfall as the charge, so the cheap
run is only as long as the shortfall needs.
(DECISIONS/MAIN-orchestration-archive.md)"""
made again with each battery's shortfall as the charge. With no plan
(no or stale forecast, no house profile yet) the levels are dropped: P3
option A. (DECISIONS/MAIN-orchestration-archive.md)"""
dp = dayplan.plan(
self.solar_fc,
None if self.house_profile is None
@@ -1710,8 +1713,13 @@ class Controller:
return self._plan_targets
def _latch_charge(self, now, runs, targets, packs=None) -> None:
"""OPT-34, once per cycle after `modetrade.decide()`.
(DECISIONS/MAIN-orchestration-archive.md)"""
"""OPT-34, once per cycle after `modetrade.decide()`: a started night
grid charge keeps its planned level when the forecast goes mid-charge
(`plan_targets()` gives the latched levels, `weather_held()` the narrow
price gap). The latch lasts through every cheap run plus one price slot
(`CHARGE_LATCH_GRACE_S`), is dropped when `now` passes its end or the
battery leaves its mode, and a charge that never started has none.
DECISIONS/OPT-34-the-night-charge-keeps-its-level.md"""
old = lt = self._charge_latch
tick = self.mode_tick
run = None if runs is None else runs.cheap
@@ -1752,7 +1760,10 @@ class Controller:
"born": lt[2].isoformat()})
def _mode_roles(self, now, maint_results):
"""OPT-11 P3: each battery's role and range this tick.
"""OPT-11 P3: each battery's role and range this tick. Grid charging
and selling need a fresh plan that `TariffPlan.arbitrage()` allows with
the MEASURED peak guard (None refuses: nothing measures the quarter),
exactly the gate `arbitrage.decide()` has.
(DECISIONS/MAIN-orchestration-archive.md)"""
floor_pct, ceiling_pct = arbitrage.soc_bounds(self.o)
packs = self._mode_packs(maint_results)
@@ -1823,6 +1834,8 @@ class Controller:
draining=any(r.phase == DRAIN for r in maint_results or ()))
self._latch_charge(now, runs, targets, packs)
# A charge the guard or the headroom stopped stays off this period.
# OPT-15: only a MEASURED stop latches; the first unmeasured headroom
# in a quarter stops one tick, a second latches.
# (DECISIONS/MAIN-orchestration-archive.md)
if was and self.mode_tick.action != modetrade.CHARGE:
why = self.mode_tick.why
@@ -1851,15 +1864,19 @@ class Controller:
def _mode_trade(self, claims, now) -> bool:
"""OPT-11 P3: file the site trade the roles ask for, sized inside the
modes and the hard limits (the loop bound) and, for a charge, the
billed quarter's headroom (SAFETY-45, aim cap - margin, SAFETY-79).
modes and the hard limits and, for a charge, the billed quarter's
headroom (SAFETY-45, aim cap - margin, SAFETY-79), so no battery that
its mode or a refusal holds is asked for watts it may not give.
(DECISIONS/MAIN-orchestration-archive.md)"""
tick = self.mode_tick
if tick is None:
return False
if tick.action == modetrade.CHARGE:
# OPT-20: the ask carries PROBE_W for exactly the probes whose pin
# is filed this tick. (DECISIONS/MAIN-orchestration-archive.md)
# is filed this tick (`decide()` ran before the pin, so they can
# differ by one cycle; left alone the allocator moves the pin's
# watts to another battery).
# (DECISIONS/MAIN-orchestration-archive.md)
pins = _ctl.PROBE_W * sum(
1 for c in claims if c.source == "refused"
and c.kind == "set" and c.value < 0)
@@ -1912,7 +1929,10 @@ class Controller:
def _reseed_run_edge(self, claims, now) -> None:
"""OPT-11 P3: a mode trade just ended, so the loop takes over from a
command no loop chose. (DECISIONS/MAIN-orchestration-archive.md)"""
command no loop chose: it starts at the house's own point E, not at the
trade's watts, and OPT-15 HOLDS it at E (`_edge_grid()`) until the
meter shows the step or `EDGE_HOLD_S` passes.
DECISIONS/OPT-11-battery-modes.md"""
self._edge = None
batt = self.site_batt_w
if batt is None or self.grid is None:
@@ -1929,8 +1949,9 @@ class Controller:
def _edge_grid(self, end, now):
"""OPT-15: the grid reading the loop acts on this tick. While a run
edge is held, the target itself (no error, the command stays at E).
(DECISIONS/MAIN-orchestration-archive.md)"""
edge is held, the target itself (no error, the command stays at E); the
hold ends when the meter reaches the target, on `end`, or after
`EDGE_HOLD_S`. (DECISIONS/MAIN-orchestration-archive.md)"""
e = self._edge
if e is None:
return self.grid
@@ -1951,7 +1972,8 @@ class Controller:
def _charge_track(self, claims, maint_results) -> None:
"""SAFETY-93, N>=2: a charge ends when its battery leaves CHARGE or can
no longer be placed (stale link, refusal). Kept until the loop runs.
(DECISIONS/MAIN-orchestration-archive.md)"""
SAFETY-95: a drain likewise; its refusal (SAFETY-73) is read from the
battery (+1 = will not give). (DECISIONS/MAIN-orchestration-archive.md)"""
if len(self.inverters) < 2:
return
live = set(self.live_units())
@@ -1970,8 +1992,9 @@ class Controller:
def _reseed_charge_end(self, maint_results) -> None:
"""SAFETY-93, N>=2: when a battery's maintenance charge ends, the loop
starts from what the others stood at plus that battery's new command
(its HOLD pin, or its last discharge if it is free; a charge stops).
(DECISIONS/MAIN-orchestration-archive.md)"""
(its HOLD pin, or its last discharge if it is free; a charge stops);
else the charge's watts land on the others (B 0 -> -1600 W in one
cycle). DECISIONS/SAFETY-93-charge-end.md"""
ended, self._charge_ended = self._charge_ended, frozenset()
if len(self.inverters) < 2 or not ended:
return