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:
co-authored by
Claude
parent
47e99a37bc
commit
31d13ee8bb
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user