MAINT-20 part 2 r2: app/main.py comments, lines 5001-5500 (shutdown .. checks)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-11-08 18:06:01 +01:00
co-authored by Claude
parent 31d13ee8bb
commit b05ad2b273
+102 -46
View File
@@ -5002,41 +5002,60 @@ class Controller:
"soc": self.pack_floor_soc,
"phase": self.maint.phase,
# ⚠️ SAFETY-02's alarm surface, and it is deliberately an existing
# one. (DECISIONS/MAIN-orchestration-archive.md)
# one: "failsafe" is additive to a sensor already on every
# dashboard, and a P_SAFETY stop that did not reach it would be a
# site at 0 W reporting "running".
# (DECISIONS/MAIN-orchestration-archive.md)
"status": ("failsafe" if any(i.sign_wd.tripped
for i in self.inverters)
or (self.site_sign_wd.tripped
and len(self.inverters) > 1)
or self.circ_wd.tripped
# SAFETY-10: "shadow" is its own value and not a flavour
# of "stopped".
# of "stopped". ⚠️ BELOW `failsafe`: a latch that is up
# is up, and shadow does not clear it.
# (DECISIONS/MAIN-orchestration-archive.md)
else "shadow" if self.shadow
else "running" if self.auto else "stopped"),
# SAFETY-31. ⚠️ MEASURED, not the allocation record - see
# `site_circulating_w`. (DECISIONS/MAIN-orchestration-archive.md)
# `site_circulating_w`. `publish()` drops None values, so a missing
# per-unit reading leaves the last figure standing rather than
# publishing a zero that would read as health.
# (DECISIONS/MAIN-orchestration-archive.md)
"circulation": (None if self.site_circulating_w is None
else round(self.site_circulating_w)),
# TEL-10. (DECISIONS/MAIN-orchestration-archive.md)
# TEL-10. The loop's own three, written from the fields `cycle()`
# recorded and not re-derived (the page and the publisher run on
# their own tasks, and `self.target` has moved on).
# (DECISIONS/MAIN-orchestration-archive.md)
"loop_i": round(self.loop_i_w, 1),
"loop_lead": round(self.loop_i_lead, 1),
"loop_reason": self.loop_reason,
}
# TEL-11 ⚠️ THE KEY IS PRESENT WITH THE VALUE `None` WHEN THERE IS NO
# READING, AND ABSENT ENTIRELY WHEN THERE IS NO SOURCE.
# AND THE GATE IS `optional_keys()`, THE SAME EXPRESSION THE
# ANNOUNCEMENT USES.
# TEL-11. ⚠️ THE KEY IS PRESENT WITH `None` WHEN THERE IS NO READING
# AND ABSENT WHEN THERE IS NO SOURCE: two states
# (`mqtt.ABSENT_AS_UNKNOWN` makes the first an `unknown`, a gap in the
# recorder). TEL-12: still ONE number, the SITE total, None unless
# EVERY entity answered. ⚠️ THE GATE IS `optional_keys()`, THE SAME
# EXPRESSION THE ANNOUNCEMENT USES.
# (DECISIONS/MAIN-orchestration-archive.md)
if "pv" in optional_keys(self.o):
values["pv"] = (None if self.pv_w is None
else round(self.pv_w, 1))
# ⚠️ ONLY when P1 ingestion is actually running.
# (DECISIONS/MAIN-orchestration-archive.md)
# ⚠️ ONLY when P1 ingestion is running: the ESP32's stale-input
# watchdog subscribes to sensor.goodwe_p1_sample_age and forces layer-1
# failsafe at max_age_s, so with meter_source off a published age would
# climb unbounded and pin every existing install at 0 W within 30 s.
# Publishing nothing leaves the entity non-existent (firmware
# has_state()). (DECISIONS/MAIN-orchestration-archive.md)
if self.p1_enabled:
# Recomputed here, once a second, on purpose - see P1Ingest.
values["p1_age"] = round(self.p1.published_age_s, 1)
# TEL-07. THE PER-UNIT ROWS, AND THE EMPTY LIST IS THE GATE.
# THERE IS NO `if len(self.inverters) > 1` HERE, DELIBERATELY.
# TEL-07. THE PER-UNIT ROWS, AND THE EMPTY LIST IS THE GATE: ⚠️ no `if
# len(self.inverters) > 1` here, deliberately: `announced_units()`
# answers [] at N=1, so what is announced and what is published come
# from one expression. ⚠️ The site rows do not move: `battery` stays
# the SUM and `soc` the EMPTIEST pack (live dashboards use those ids).
# (DECISIONS/MAIN-orchestration-archive.md)
names = announced_units([inv.name for inv in self.inverters])
for inv, slug in zip(self.inverters, unit_slugs(names)):
@@ -5076,8 +5095,11 @@ class Controller:
for inv in self.inverters))
async def command_site_zero(self, *, last: bool = False) -> None:
"""Every inverter to 0 W, and every inverter's TARGET to 0 W with it.
THE SECOND HALF IS NOT BOOKKEEPING, AND IT IS A HAZARD DEV-02 CREATED.
"""Every inverter to 0 W, and every inverter's TARGET with it. ⚠️ NOT
BOOKKEEPING (DEV-02): the heartbeat re-emits `inv.target`, so zeroing
only `self.target` lets the next heartbeat put each unit's last share
back on the wire. ⚠️ 0 W IS THE ONE VALUE THAT MAY BE WRITTEN SITE-WIDE
(`write_setpoint()` refuses others: ARCH-01).
(DECISIONS/MAIN-orchestration-archive.md)"""
self.target = 0.0
for inv in self.inverters:
@@ -5088,8 +5110,8 @@ class Controller:
def checks(self, when: datetime | None = None) -> list:
"""The status rows. `when` is the reference time for anything billed by
the calendar - today's month when it is None, which is what the live
page means; `status_payload()` calls this with no argument.
IT EXISTS SO THE PEAK ROW CAN BE GRADED (TEST-17).
page means. ⚠️ IT EXISTS SO THE PEAK ROW CAN BE GRADED (TEST-17): a
suite that banks an August quarter went red at the September boundary.
(DECISIONS/MAIN-orchestration-archive.md)"""
o = self.o
out = []
@@ -5143,7 +5165,10 @@ class Controller:
"text": messages.row_meter(
self.p1.net_w, age, self.p1.parse_errors,
unchanged)})
# SAFETY-18. (DECISIONS/MAIN-orchestration-archive.md)
# SAFETY-18. Reported in both states (a detector nobody can see is
# armed is not trusted when it fires). ok=False/warn=False so the
# banner goes red; it changes NOTHING about control (see
# check_meter_frozen()). (DECISIONS/MAIN-orchestration-archive.md)
if self.freeze_wd.active:
# The figures are in `freeze_wd.reason`, on the log and the
# event list (check_meter_frozen()).
@@ -5172,19 +5197,19 @@ class Controller:
_rail = rail_w(self.site_max_w,
dev_min=self.site_dev_bound("dev_min"),
dev_max=self.site_dev_bound("dev_max"))
# And when the device is what set that number, SAY SO - the
# operator configured `max_w` and is owed the reason the guard
# is armed somewhere else. ⚠️ Either bound may be None on its
# own - `hass.limits()` reads `min` and `max` independently and
# returns None for whichever attribute is missing or
# unparseable - so this renders whatever is there rather than
# formatting a None. (DECISIONS/MAIN-orchestration-archive.md)
# And when the device set that number, SAY SO: the operator
# configured `max_w`. ⚠️ Either bound may be None on its own
# (`hass.limits()` reads min and max independently), so render
# what is there. (DECISIONS/MAIN-orchestration-archive.md)
out.append({"ok": True, "warn": False, "id": "freeze_check",
"text": messages.row_freeze_on(
self.freeze_wd.cfg.hold_s, _rail,
own_limit=_rail < rail_w(self.site_max_w))})
# TARIFF-01. (DECISIONS/MAIN-orchestration-archive.md)
# TARIFF-01. The month's billed peak, and a DIFFERENT row when the
# store could not answer: "no peak yet" is normal on the 1st,
# "unreadable" is a broken instrument.
# (DECISIONS/MAIN-orchestration-archive.md)
_pk = self.peak.peak(when)
if _pk.is_unreadable:
out.append({"ok": False, "warn": True, "id": "month_peak",
@@ -5206,9 +5231,10 @@ class Controller:
self.peak.recorded)})
# SAFETY-07. Three rows, not one, because "idle" and "cannot see"
# are opposite states and a page that spelled them the same way
# would show a green line over a guard that is switched off.
# (DECISIONS/MAIN-orchestration-archive.md)
# are opposite states and a page that spelled them the same would
# show a green line over a guard that is switched off. `None` is a
# fourth state on purpose: before the first cycle nothing has been
# concluded. (DECISIONS/MAIN-orchestration-archive.md)
_pg = self.peak_guard
if _pg is None:
out.append({"ok": True, "warn": True, "id": "peak_guard",
@@ -5220,9 +5246,12 @@ class Controller:
elif _pg.floor_limited and (
not ((_pg.floor_w or 0.0) > 0.0 or _pg.within_aim)
or self._peak_floor_limited):
# SAFETY-24. NOT ok, like saturation: in both states the money
# is going to be spent and a human is the only actuator left.
# SAFETY-25.
# SAFETY-24. NOT ok, like saturation (the money is being spent
# and a human is the only actuator left); a separate row
# because the remedy is opposite. ⚠️ SAFETY-25: the scope
# sentence (the floor binds the guard, not the loop) is part of
# the row, which carries its own short `banner`; shortening the
# ROW would weaken AC 3.
# (DECISIONS/MAIN-orchestration-archive.md)
out.append({"ok": False, "warn": False, "id": "peak_guard",
"runs": True,
@@ -5262,7 +5291,10 @@ class Controller:
"text": _pg.explain()})
else:
# ⚠️ OUTSIDE the `p1_enabled` block, and that placement is the
# point. (DECISIONS/MAIN-orchestration-archive.md)
# point: SAFETY-07 is NOT COVERED on the legacy single-entity path
# (no telegrams), and a row that rendered only where the guard runs
# would leave the unprotected install showing nothing.
# (DECISIONS/MAIN-orchestration-archive.md)
out.append({"ok": False, "warn": True, "id": "peak_guard",
"text": messages.row_peak_guard_off()})
@@ -5288,9 +5320,11 @@ class Controller:
out.append({"ok": False, "warn": False, "id": "sensor",
"text": messages.row_sensor_unset(label, unit)})
elif value is None:
# ⚠️ SAFETY-56. THIS ROW ASSERTED A FALSE FACT ABOUT A NAMED
# ENTITY, WHICH IS THE WORST THING A DIAGNOSTIC CAN DO.
# AND IT IS FIXED BY SAYING LESS, NOT BY LOOKING HARDER.
# ⚠️ SAFETY-56. THIS ROW ONCE ASSERTED A FALSE FACT ABOUT A
# NAMED ENTITY: `Freshness.reading()` returns None BY DESIGN
# while the gate withholds a numeric reading. ⚠️ FIXED BY
# SAYING LESS: the gate has its own surface (`stale_note()` in
# `liveness_notes()`).
# (DECISIONS/MAIN-orchestration-archive.md)
out.append({"ok": False, "warn": False, "id": "sensor",
"text": messages.row_sensor_unusable(label, unit)})
@@ -5311,7 +5345,11 @@ class Controller:
"text": messages.row_control(inv.dev_min,
inv.dev_max, unit)})
# DEV-02. (DECISIONS/MAIN-orchestration-archive.md)
# DEV-02. Whether this controller can still put a number on each
# inverter: a dropped link that shows nothing on the page reads as a
# healthy site. Red only for a unit no longer live; the rest amber. The
# same notes, in the same order, as `liveness_notes()`.
# (DECISIONS/MAIN-orchestration-archive.md)
_now = datetime.now(timezone.utc)
for inv in self.inverters:
for note, red in ((inv.link_note(_now), not inv.is_live(_now)[0]),
@@ -5324,7 +5362,11 @@ class Controller:
"text": messages.row_readings_late(
self.answer.shown_s, self._late_zero)})
# SAFETY-03. (DECISIONS/MAIN-orchestration-archive.md)
# SAFETY-03. Red while an over-rating clamp is engaged (amber for the
# integrator bound, SAFETY-40), and the engagement COUNT stays shown
# after it clears. Each count is shown under ITS alarm's name, never
# the two summed (UX-07/UX-08).
# (DECISIONS/MAIN-orchestration-archive.md)
_cw = self.clamp_wd
if _cw.active and _cw.bug:
out.append({"ok": False, "warn": False, "id": "power_too_high",
@@ -5359,10 +5401,18 @@ class Controller:
None if len(self.inverters) == 1
else inv.name)})
# SAFETY-02. (DECISIONS/MAIN-orchestration-archive.md)
# SAFETY-02. Reported in both states. ⚠️ ONE ROW PER INVERTER: a trip
# on B leaves A commanding. Since SAFETY-30 the row says what the unit
# was compared against (`sign_wd.against`, the string the trip sentence
# uses); at N=1 the wording is unchanged.
# (DECISIONS/MAIN-orchestration-archive.md)
for inv in self.inverters:
unit = None if len(self.inverters) == 1 else inv.name
# ⚠️ ADDED ONLY WHEN THERE IS SOMETHING TO DISAMBIGUATE.
# ⚠️ ADDED ONLY WHEN THERE IS SOMETHING TO DISAMBIGUATE: at N=1 the
# two sentences below are the ones that have always been on the
# page, word for word (AC 8); at N>=2 the noun is load-bearing, so
# the row does not send an operator to P1 for a fault visible only
# on one inverter's own power.
# (DECISIONS/MAIN-orchestration-archive.md)
if inv.sign_wd.tripped:
out.append({"ok": False, "warn": False, "id": "direction",
@@ -5373,8 +5423,9 @@ class Controller:
"text": messages.row_direction_check(
held, inv.sign_wd.cfg.hold_s, unit)})
# SAFETY-30: the SITE row ⚠️ ONLY AT N>=2, and the absence at N=1 is
# the honest report rather than a missing row.
# SAFETY-30: the SITE row. ⚠️ ONLY AT N>=2, and the absence at N=1 is
# the honest report: with one inverter the site comparison and the row
# above are the same sentence about the same actuator.
# (DECISIONS/MAIN-orchestration-archive.md)
if len(self.inverters) > 1:
if self.site_sign_wd.tripped:
@@ -5386,8 +5437,10 @@ class Controller:
"text": messages.row_site_direction_check(
_sheld, self.site_sign_wd.cfg.hold_s)})
# SAFETY-31 ⚠️ SITE-LEVEL, WHERE THE ROWS ABOVE ARE PER-INVERTER, and
# the row says so. (DECISIONS/MAIN-orchestration-archive.md)
# SAFETY-31. ⚠️ SITE-LEVEL, WHERE THE ROWS ABOVE ARE PER-INVERTER, and
# the row says so: there is one circulating figure for the site, and
# naming an inverter would invite clearing "the other one".
# (DECISIONS/MAIN-orchestration-archive.md)
_circ = self.site_circulating_w
if self.circ_wd.tripped:
out.append({"ok": False, "warn": False, "id": "circulating",
@@ -5405,9 +5458,12 @@ class Controller:
"text": messages.row_circulating_check(
_circ, held, self.circ_wd.cfg.hold_s)})
# SAFETY-42 ⚠️ PER INVERTER, AND WARN RATHER THAN BAD, WHICH IS THE
# ONE PLACE THE "reports, does not act" DECISION IS VISIBLE TO AN
# OPERATOR. (DECISIONS/MAIN-orchestration-archive.md)
# SAFETY-42. ⚠️ PER INVERTER, AND WARN RATHER THAN BAD, WHICH IS THE
# ONE PLACE THE "reports, does not act" DECISION IS VISIBLE: every
# other latched row means the site is at 0 W, this one means it is
# STILL RUNNING and quietly short by one unit's share; `ok: False` with
# `warn: True` is this list's caution.
# (DECISIONS/MAIN-orchestration-archive.md)
if len(self.inverters) > 1:
for inv in self.inverters:
if inv.delivery_wd.tripped: