MAINT-20 part 2 r2: app/main.py comments, lines 4501-5000 (check_peak_saturated .. publish)

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 47e99a37bc
commit 31d13ee8bb
+92 -54
View File
@@ -4529,10 +4529,11 @@ class Controller:
fired = self.clamp_wd.note(engaged, reason, datetime.now(timezone.utc),
bug=bug, facts=facts)
# THE ACTION LOG, AND IT IS COALESCED PER EPISODE Every engagement is
# recorded, but consecutive ones are recorded by UPDATING one row's
# count rather than inserting a row each.
# THAT IS NOT COSMETIC, AND IT WAS FOUND THE HARD WAY.
# THE ACTION LOG IS COALESCED PER EPISODE: consecutive engagements
# update one row's count. ⚠️ NOT COSMETIC: the log keeps 40 rows and a
# winding loop clamps every cycle, so a row each flushed SAFETY-18's
# "METER FROZEN?" row. Here as well as in write_setpoint() because on a
# default install the actuator-side clamp is unreachable.
# (DECISIONS/MAIN-orchestration-archive.md)
if engaged and not engaged_write:
tag = f"{CLAMP_REASON} episode {self.clamp_wd.episodes}"
@@ -4580,7 +4581,12 @@ class Controller:
shadow=self.shadow).data())
async def check_peak_saturated(self) -> None:
"""SAFETY-07. Alarm ONLY when the guard has run out of inverter.
"""SAFETY-07. Alarm ONLY when the guard has run out of inverter. ⚠️ An
engagement is not an alarm: a peak floor above the rating is a house
load bigger than the inverter, not a defect (unlike SAFETY-03's winding
integrator). SAFETY-81: `g.saturated` must agree with
`peakguard.load_too_high()` for `ALARM_HOLD_S`. Edge-triggered: one
notification per episode, at most once per quarter.
(DECISIONS/MAIN-orchestration-archive.md)"""
g = self.peak_guard
batt, prev = self.site_batt_w, self._peak_batt_prev
@@ -4633,9 +4639,12 @@ class Controller:
g.ceiling_w, shadow=self.shadow).data())
async def check_peak_floor_limited(self) -> None:
"""SAFETY-24. Alarm when the EMS SoC floor stops the guard shaving.
FAILING TO SHAVE MUST NOT BE SILENT, AND THAT IS THE WHOLE POINT.
(DECISIONS/MAIN-orchestration-archive.md)"""
"""SAFETY-24. Alarm when the EMS SoC floor stops the guard shaving. ⚠️
FAILING TO SHAVE MUST NOT BE SILENT (the capacity tariff bills the
month's worst quarter). Not check_peak_saturated(): that is shed-load,
this is nothing discharged; never both. ⚠️ SAFETY-25: the message says
the floor binds this guard only; the loop has no SoC floor and goes on
discharging. Edge-triggered. (DECISIONS/MAIN-orchestration-archive.md)"""
g = self.peak_guard
limited = bool(g is not None and g.floor_limited)
if limited and (g.floor_w or 0.0) > 0.0:
@@ -4691,8 +4700,11 @@ class Controller:
>= peakguard.ALARM_HOLD_S)
async def check_circulating(self) -> None:
"""SAFETY-31 + SAFETY-34. The alarm, and it must survive a restart.
THE LATCH IS NOT THE ALARM.
"""SAFETY-31 + SAFETY-34. The alarm, and it must survive a restart. ⚠️
THE LATCH IS NOT THE ALARM: the notification is in memory, so
`_circ_announced` (per-PROCESS, not persisted) makes a restart re-raise
it, and REANNOUNCE_S re-asserts it after a Core-only restart. ⚠️ ONE
CODE PATH for a fresh trip and a restart into one.
(DECISIONS/MAIN-orchestration-archive.md)"""
if not self.circ_wd.tripped:
return
@@ -4721,8 +4733,12 @@ class Controller:
return t is None or _mono() - t >= REANNOUNCE_S
async def check_not_delivering(self) -> None:
"""SAFETY-42 + SAFETY-34. The alarm, and it must survive a restart.
THE LATCH IS NOT THE ALARM, and this guard has nothing BUT the alarm.
"""SAFETY-42 + SAFETY-34. The alarm, and it must survive a restart. ⚠️
THE LATCH IS NOT THE ALARM: the notification is in memory, so
`_delivery_announced` (a per-PROCESS set of unit names, not persisted)
makes a restart re-raise it, and REANNOUNCE_S re-asserts it. ⚠️ ONE
CODE PATH for a fresh trip and a restart into one. ⚠️ ONE ID PER UNIT
(`unit_slugs()`): two wedged inverters give two cards.
(DECISIONS/MAIN-orchestration-archive.md)"""
names = self.units
slugs = unit_slugs(names)
@@ -4754,7 +4770,9 @@ class Controller:
messages.prices_back().data())
async def check_fixed_note(self) -> None:
"""PRICE-01 G1. (DECISIONS/MAIN-orchestration-archive.md)"""
"""PRICE-01 G1. First cycle only: one INFO line when `static_profile`
sits beside `price_service` (the plan's last fallback, not a fault, so
no notification). (DECISIONS/MAIN-orchestration-archive.md)"""
if self._fixed_note_said:
return
self._fixed_note_said = True
@@ -4776,10 +4794,10 @@ class Controller:
async def check_meter_frozen(self) -> None:
"""SAFETY-18. Alarm on a meter that answers forever without changing.
getattr, not an attribute access: `unchanged_s` exists only on
`homewizard_local`, and `p1_source` is None until amain() builds the
transport.
(DECISIONS/MAIN-orchestration-archive.md)"""
Notification only: no claim, no latch, no 0 W (freeze.py carries the
reasoning). getattr, not an attribute access: `unchanged_s` exists only
on `homewizard_local`, and `p1_source` is None until amain() builds the
transport. (DECISIONS/MAIN-orchestration-archive.md)"""
unchanged = getattr(self.p1_source, "unchanged_s", None)
# SITE, all three inputs: one meter, the SITE target the arbiter
# resolved, and the rail the SITE could stand at - which is the summed
@@ -4814,8 +4832,7 @@ class Controller:
# -- OPT-04: the background tariff refresh -------------------------------
def fresh_tariff_plan(self, now):
"""The stored plan if it is still current, else None. NEVER a stale
one.
THIS IS THE ONE PLACE THE AGE IS JUDGED, and it is public for
one. THIS IS THE ONE PLACE THE AGE IS JUDGED, and it is public for
`loop_is_following()`'s reason: a check has to be able to assert on the
shipped expression rather than on a transcription that drifts.
(DECISIONS/MAIN-orchestration-archive.md)"""
@@ -4827,9 +4844,12 @@ class Controller:
async def run_tariff_refresh(self) -> None:
"""Resolve the tariff off the control path, forever. NEVER on the tick.
IT CATCHES EVERYTHING AND KEEPS GOING, for MqttPublisher's reason: a
supplier's outage, a certificate expiry, a malformed feed - all of it
is pricing, and the battery does not care.
⚠️ IT CATCHES EVERYTHING AND KEEPS GOING: raising out of here would
freeze the plan, looking current for ever (`fresh_tariff_plan()`'s age
bound is the backstop). ⚠️ A FAILURE DOES NOT CLEAR THE PLAN
(TARIFF-12): a degraded plan replaces a standing observation only once
it has aged out; a §11.4 fallback never replaces a fresh provider plan.
⚠️ `tariff_at` DOES NOT MOVE ON A KEPT PLAN.
(DECISIONS/MAIN-orchestration-archive.md)"""
horizon = float(price_option(self.o, "price_horizon_h"))
while not self.stopping:
@@ -4855,7 +4875,9 @@ class Controller:
self.tariff_plan, self.tariff_at = plan, now
_LOG.info("tariff refreshed: %s", plan.words)
except Exception as err: # noqa: BLE001
# ⚠️ NOT THE OUTAGE PATH - see the docstring.
# ⚠️ NOT THE OUTAGE PATH - see the docstring: `load_prices()`
# returns on every failure a site can have, so what reaches
# here is unexpected, and the plan is kept on the same terms.
# (DECISIONS/MAIN-orchestration-archive.md)
_LOG.warning(
"The price update failed (%s), so the plan from %s is kept "
@@ -4867,19 +4889,25 @@ class Controller:
timeout=TARIFF_REFRESH_S)
async def run_control(self) -> None:
# ⚠️ EACH INVERTER'S RATING FROM ITS OWN ENTITY, WHICH IS AC 4.
# ⚠️ EACH INVERTER'S RATING FROM ITS OWN ENTITY, WHICH IS AC 4 (one
# read of one `setpoint_entity` left a second inverter's clamp, freeze
# rail and `achievable_w` on the first one's numbers). Concurrently: a
# slow ESPHome on one unit must not delay the start on the others.
# (DECISIONS/MAIN-orchestration-archive.md)
async def _limits(inv):
if inv.driver.configured():
inv.dev_min, inv.dev_max = await self._driver(inv).limits()
await asyncio.gather(*(_limits(inv) for inv in self.inverters))
entity = self.o.get("setpoint_entity", "")
# ⚠️ THIS USED TO WARN AND CARRY ON, and that was the bug CFG-01 exists
# to remove: a max_w above the device's rating meant write_setpoint()
# silently clamped and the configured ceiling was never the real one.
# ⚠️ AND THE `if entity:` THAT USED TO WRAP THE WHOLE BLOCK WAS A
# READER THAT FAILED PERMISSIVE - CFG-04's own hazard, in this file.
# (DECISIONS/MAIN-orchestration-archive.md)
# ⚠️ THIS USED TO WARN AND CARRY ON, the bug CFG-01 exists to remove: a
# max_w above the device's rating was silently clamped. amain() ran
# this gate already; it runs AGAIN because ESPHome is often not up at
# boot (limits None) and this is the first moment the real rating is
# known; a conflict refuses through the normal shutdown (0 W). ⚠️ THE
# `if entity:` THAT WRAPPED THIS BLOCK FAILED PERMISSIVE (CFG-04's
# hazard): a per-inverter config leaves the flat `setpoint_entity`
# blank, so the re-check was skipped; the per-unit rule now runs
# unconditionally. (DECISIONS/MAIN-orchestration-archive.md)
problems = []
if entity:
_first = self.inverters[0]
@@ -4899,8 +4927,14 @@ class Controller:
last_grid = object()
heartbeat = float(self.o.get("heartbeat_s", 10))
# ⚠️ SAFETY-12. The floor is a DEADLINE ON THE GAP BETWEEN CYCLES, not
# a second scheduler running alongside the arrival trigger.
# THE 15 s DEFAULT HAS A CEILING IT DOES NOT OWN.
# a second scheduler: every cycle resets it, so on a healthy meter it
# never elapses and the per-cycle gain is unchanged. It exists because
# a meter that repeats a value would run NO cycles, and the `inputs
# missing -> 0 W` claim lives inside cycle(). NOT a freeze detector. ⚠️
# THE 15 s DEFAULT HAS A CEILING IT DOES NOT OWN: it sits under
# validate.FIRMWARE_INGEST_VETO_S (30 s) and `meter_max_age_s` and
# moves with the firmware's veto; the schema minimum of 5 s is the
# reference meter's interval and deserves a second look.
# (DECISIONS/MAIN-orchestration-archive.md)
floor = float(self.o.get("cycle_floor_s", 15))
last_cycle = None
@@ -4913,19 +4947,20 @@ class Controller:
if self.grid != last_grid or overdue:
last_grid = self.grid
last_cycle = now_mono
# ⚠️ The FULL cycle, not a degraded one.
# THAT GUARANTEE IS TRANSPORT-CONDITIONAL, and saying so
# matters more than the guarantee: with `meter_source: off`,
# read_inputs() takes self.grid straight from hass.number()
# with NO age gate at all, so a Home Assistant entity that has
# gone quiet reads as a current value on that path with or
# without this change.
# (DECISIONS/MAIN-orchestration-archive.md)
# ⚠️ The FULL cycle, not a degraded one: in P1 mode past
# `meter_max_age_s` (30 s) p1.net_w is None ("inputs missing").
# ⚠️ TRANSPORT-CONDITIONAL: with `meter_source: off`,
# read_inputs() has NO age gate, so a quiet entity reads as
# current; the floor does not make it fresh. The integrator
# advances per CYCLE, so bounding the gap bounds the
# accumulation. (DECISIONS/MAIN-orchestration-archive.md)
await self.cycle()
# ⚠️ The heartbeat is not an optimisation. The ESP32 treats silence
# longer than ~30 s as "the controller is gone" and zeroes the
# inverter. Refreshing the SAME value is what proves we are alive.
# ONE DEADLINE PER INVERTER, AND THE REFRESHES GO OUT TOGETHER.
# ⚠️ The heartbeat is not an optimisation: the ESP32 zeroes the
# inverter after ~30 s of silence, and refreshing the SAME value
# proves we are alive. ⚠️ ONE DEADLINE PER INVERTER, refreshed
# concurrently, each bounded by WRITE_BUDGET_S (budget plus the
# largest `heartbeat_s` stays under layer 1's veto: graded); each
# unit refreshes its own `inv.target`, never the site target.
# (DECISIONS/MAIN-orchestration-archive.md)
await self.send_heartbeats(asyncio.get_running_loop().time(),
heartbeat)
@@ -4935,27 +4970,30 @@ class Controller:
async def send_heartbeats(self, loop_now: float, heartbeat_s: float) -> None:
"""Refresh every inverter whose own deadline has come. AC 6 lives here.
IT IS A METHOD RATHER THAN FIVE LINES INSIDE `run_control()` FOR ONE
REASON: the acceptance criterion is "a hung write to A cannot consume
B's layer-1 budget - verified by injecting the hang", and a check
cannot inject a hang into the middle of a `while not self.stopping`
loop that also sleeps, reads inputs and cycles.
(DECISIONS/MAIN-orchestration-archive.md)"""
⚠️ A METHOD RATHER THAN FIVE LINES INSIDE `run_control()` so a check
can inject a hang: "a hung write to A cannot consume B's layer-1
budget"; inline, the mutation to a serial `for` loop passed the whole
suite. (DECISIONS/MAIN-orchestration-archive.md)"""
due = [inv for inv in self.inverters
if loop_now - inv.last_beat >= heartbeat_s]
if not due:
return
# ⚠️ THE DEADLINE MOVES BEFORE THE WRITE, NOT AFTER IT.
# (DECISIONS/MAIN-orchestration-archive.md)
# ⚠️ THE DEADLINE MOVES BEFORE THE WRITE, NOT AFTER IT: after, a
# hanging write drifts into being re-sent every tick and a raising one
# never advances. (DECISIONS/MAIN-orchestration-archive.md)
for inv in due:
inv.last_beat = loop_now
# ⚠️ CONCURRENTLY. (DECISIONS/MAIN-orchestration-archive.md)
# ⚠️ CONCURRENTLY: in series, every inverter behind a hung one waits
# out its whole budget, pushing a healthy sibling towards layer 1's ~30
# s veto. (DECISIONS/MAIN-orchestration-archive.md)
await asyncio.gather(*(
self.write_setpoint(inv.target, force=True, unit=inv)
for inv in due))
def publish(self) -> None:
# ⚠️ SITE TOTALS, AND THE KEYS DO NOT MOVE.
# ⚠️ SITE TOTALS, AND THE KEYS DO NOT MOVE: `battery` stays the SUM and
# `soc` the EMPTIEST pack (their `unique_id`s are on live dashboards);
# per-inverter entities are TEL-07's.
# (DECISIONS/MAIN-orchestration-archive.md)
values = {
"setpoint": self.target,