MAINT-20 part 2: main.py: keep whole sentences, no truncated warnings; refresh archive
checks / unit (pull_request) Failing after 24m55s
infra-pin / fresh (pull_request) Successful in 1s

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-11-01 03:33:54 +01:00
co-authored by Claude
parent 6889667fcd
commit 3d31b679eb
2 changed files with 35 additions and 66 deletions
@@ -327,20 +327,6 @@ money-saving discharge only, and grading a pack against it
explains a wedged unit away in a band an ordinary install
has ten points of.
### L4619 REASON FIRST, FLAG SECOND, and each `set` writes the file -
REASON FIRST, FLAG SECOND, and each `set` writes the file -
the same atomicity ordering the three trips above use: a crash
between the two must not persist a latch with no explanation
for the operator who has to decide about clearing it.
⚠️ AND THERE IS NO FLAT MIRROR OF THIS PAIR, WHERE SAFETY-02
HAS ONE. store.py's rule 2 keeps the flat keys "so a rollback
still finds its latch", and the version being rolled back TO
drives exactly ONE actuator - a roster at which this check is
inert by construction. A flat mirror would hand a downgraded
add-on a latch taken on evidence about a roster it cannot have.
### L4650 ⚠️ `any`, NOT the sole unit's. The hold exists so a single missed poll
⚠️ `any`, NOT the sole unit's. The hold exists so a single missed poll
+35 -52
View File
@@ -946,8 +946,7 @@ class Controller:
self._handback = None
self._hidden = ()
self.stopping = False
# SAFETY-61. time.monotonic() at the end of the last run_control()
# pass.
# SAFETY-61. time.monotonic() at the end of the last run_control() pass.
self.loop_pass_mono = None
# helpers; SAFETY-52: a battery that refuses is asked for 0 W
@@ -3374,15 +3373,16 @@ class Controller:
if inv.delivery_wd.evaluate(_delinquent.get(inv.name), now) \
and not was_short:
# REASON FIRST, FLAG SECOND, and each `set` writes the file -
# the same atomicity ordering the three trips above use: a
# crash between the two must not persist a latch with no
# explanation for the operator who has to decide about clearing
# it.
# AND THERE IS NO FLAT MIRROR OF THIS PAIR, WHERE SAFETY-02 HAS
# ONE. store.py's rule 2 keeps the flat keys "so a rollback
# the same atomicity ordering the three trips above use: a crash
# between the two must not persist a latch with no explanation
# for the operator who has to decide about clearing it.
#
# ⚠️ AND THERE IS NO FLAT MIRROR OF THIS PAIR, WHERE SAFETY-02
# HAS ONE. store.py's rule 2 keeps the flat keys "so a rollback
# still finds its latch", and the version being rolled back TO
# drives exactly ONE...
# (DECISIONS/MAIN-orchestration-archive.md)
# drives exactly ONE actuator - a roster at which this check is
# inert by construction. A flat mirror would hand a downgraded
# add-on a latch taken on evidence about a roster it cannot have.
inv.store_set("delivery_reason", inv.delivery_wd.reason)
inv.store_set("delivery_tripped", True)
self._delivery_announced.discard(inv.name)
@@ -3609,8 +3609,7 @@ class Controller:
charge_w=charge_w, discharge_w=discharge_w,
soc_floor_pct=floor_pct, soc_ceiling_pct=ceiling_pct)
# SITE, and arbitrage.claim() says so on the object it builds. It
# buys and sells at the grid against a day-ahead price: the price
# is
# buys and sells at the grid against a day-ahead price: the price is
# a property of the connection and the energy crosses the meter.
arb_claim = arbitrage.claim(self.arb)
if arb_claim is not None:
@@ -3654,8 +3653,7 @@ class Controller:
if peakguard.shaping_suspended(
self.peak_guard, self.peak_fc, self.peak_aim_w()):
# Edge-logged: level-logging here would write one line per
# cycle for as long as the event lasts and flush the
# 40-line
# cycle for as long as the event lasts and flush the 40-line
# action log of the very context that explains it.
if not self._maint_shape_suspended:
self._maint_shape_suspended = True
@@ -3677,10 +3675,8 @@ class Controller:
floor = self._charge_headroom(floor, claims)
if floor > 0:
hi, reason = -floor, "cheap-window charge"
# PER-INVERTER, like the SET above and for the same reason
# -
# the shaping is about the pack being charged, which is
# `inv`.
# PER-INVERTER, like the SET above and for the same reason -
# the shaping is about the pack being charged, which is `inv`.
claims.append(Claim.limit("maintenance", P_MAINT_SHAPE, hi=hi,
reason=reason,
actuator=self.actuator_of("maintenance",
@@ -3747,8 +3743,7 @@ class Controller:
reason="maintenance drain",
actuator=self.actuator_of("drain")))
self._peak_over_drain(claims, maint_results)
# --- SAFETY-86: while a HOLD gives, the site stays at the peak floor
# --
# --- SAFETY-86: while a HOLD gives, the site stays at the peak floor --
# So the loop never asks for watts the hold keeps back.
hold_hi = self._peak_over_hold(claims, maint_results)
if hold_hi is not None:
@@ -3781,7 +3776,7 @@ class Controller:
# The integrator has to track the same reality, and it is no longer
# prev_w, so it needs saying out loud: if the arbiter did not give
# the loop what it asked for last cycle, the loop's accumulated
# error...
# error belongs to a command that never happened.
# (DECISIONS/MAIN-orchestration-archive.md)
moved = self.loop_w is None or self.target != self.loop_w
if moved:
@@ -3806,8 +3801,7 @@ class Controller:
# SAFETY-95: then a drain that ended, last (it reads the start
# the others left).
self._reseed_drain_end(maint_results, _live96)
# No probe while it is judged afresh (the loop does not hold the
# wire).
# No probe while it is judged afresh (the loop does not hold the wire).
probe = False if rejudged else self._refusal_probe()
if len(self.inverters) > 1:
probe = any(_probing.values())
@@ -3914,8 +3908,7 @@ class Controller:
self._loop_decision = None
# SAFETY-28. A new resolution is a new tick, and the previous record is
# stale from this line onwards. Incremented BEFORE resolve() so that
# the
# stale from this line onwards. Incremented BEFORE resolve() so that the
# record resolve() builds is stamped with the tick it is for.
self.alloc_tick += 1
# ⚠️ `weights=` IS OPT-09, AND ITS ABSENCE WAS THE CARD.
@@ -4059,8 +4052,7 @@ class Controller:
# (DECISIONS/MAIN-orchestration-archive.md)
await self.check_meter_frozen()
# --- PRICE-01: no price for now, said once and taken down once
# --------
# --- PRICE-01: no price for now, said once and taken down once --------
# Not a safety check, so it is one of the cheap ones: an attribute test
# and, on a change only, one Home Assistant call.
await self.check_price_gap()
@@ -4079,10 +4071,8 @@ class Controller:
# has touched. (DECISIONS/MAIN-orchestration-archive.md)
await self.check_not_delivering()
# --- SAFETY-07's alarm, and it is NOT the engagement
# ------------------
# Its boundary is unconstrained: it grades `self.peak_guard`, which
# this
# --- SAFETY-07's alarm, and it is NOT the engagement ------------------
# Its boundary is unconstrained: it grades `self.peak_guard`, which this
# method assigned above and nothing since has touched.
await self.check_peak_saturated()
@@ -4312,9 +4302,6 @@ class Controller:
async def check_peak_saturated(self) -> None:
"""SAFETY-07. Alarm ONLY when the guard has run out of inverter.
AN ENGAGEMENT IS NOT AN ALARM, AND THE DIFFERENCE IS §10.3's DOCTRINE
READ CAREFULLY. "Treat an out-of-range computed setpoint as a bug
signal - clamp it, then alarm" is SAFETY-03's rule and it is...
(DECISIONS/MAIN-orchestration-archive.md)"""
g = self.peak_guard
batt, prev = self.site_batt_w, self._peak_batt_prev
@@ -4360,8 +4347,7 @@ class Controller:
self.log_event("PEAK_GUARD_SATURATED - " + g.reason,
hint=messages.hint("peak_saturated"))
# A fixed notification_id, and a DIFFERENT one from every other alarm
# here - check_meter_frozen() gives the reasoning. A saturated peak
# guard
# here - check_meter_frozen() gives the reasoning. A saturated peak guard
# is not a frozen meter and is not an out-of-range setpoint.
await self.hass.call("persistent_notification", "create",
messages.peak_saturated(
@@ -4518,8 +4504,7 @@ class Controller:
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
# per-unit conjunction, so a small inverter B lowers the site rail
# rather
# per-unit conjunction, so a small inverter B lowers the site rail rather
# than being graded against inverter A's.
fired = self.freeze_wd.evaluate(unchanged, self.target,
self.site_max_w,
@@ -4646,8 +4631,7 @@ class Controller:
while not self.stopping:
await self.read_inputs()
now_mono = asyncio.get_running_loop().time()
# A cycle is one meter update, exactly as on the reference install
# -
# A cycle is one meter update, exactly as on the reference install -
# or the floor elapsing, whichever comes first.
overdue = last_cycle is None or (now_mono - last_cycle) >= floor
if self.grid != last_grid or overdue:
@@ -4657,7 +4641,9 @@ class Controller:
# 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...
# 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)
await self.cycle()
# ⚠️ The heartbeat is not an optimisation. The ESP32 treats silence
@@ -4675,7 +4661,9 @@ class Controller:
"""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...
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)"""
due = [inv for inv in self.inverters
if loop_now - inv.last_beat >= heartbeat_s]
@@ -4743,8 +4731,7 @@ class Controller:
values[unit_state_key(slug, "soc")] = inv.soc
values[unit_state_key(slug, "sign_latch")] = (
"tripped" if inv.sign_wd.tripped else "clear")
# OPT-11 P2: each battery's mode and switch; nothing while modes are
# off.
# OPT-11 P2: each battery's mode and switch; nothing while modes are off.
values.update(self.mode_values())
self.mqtt.publish(values)
@@ -4830,8 +4817,7 @@ class Controller:
out.append({"ok": False, "warn": False, "id": "meter_frozen",
"text": messages.row_meter_frozen()})
elif getattr(self.p1_source, "unchanged_s", None) is None:
# Not "armed". Every transport but homewizard_local is
# invisible
# Not "armed". Every transport but homewizard_local is invisible
# to this check, and saying "armed" there would be a lie the
# status page tells on behalf of a guard that cannot run.
out.append({"ok": False, "warn": True, "id": "freeze_check",
@@ -5110,8 +5096,7 @@ class Controller:
"text": messages.row_estop_fitted()})
if not o.get("peak_forecast_entity"):
# ok=False + warn=True renders as a caution, not a pass. A check
# that
# ok=False + warn=True renders as a caution, not a pass. A check that
# is both is a check nobody reads.
out.append({"ok": False, "warn": True, "id": "peak_forecast",
"text": messages.row_no_peak_forecast()})
@@ -5458,8 +5443,7 @@ class Controller:
phase=_m.phase))
return {"phase": self.maint.phase}
if what == "maint_abort":
# Aborts the pack that is actually cycling - `_maint_button()`
# again
# Aborts the pack that is actually cycling - `_maint_button()` again
# - and then zeroes the SITE, which is what it always did.
_m = self._maint_button(now)
for msg in _m.abort(now, "operator"):
@@ -5710,8 +5694,7 @@ async def amain() -> None:
if tariff_refresh_wanted(opts):
tasks.append(asyncio.create_task(controller.run_tariff_refresh()))
# P1 ingestion runs as its own long-lived task. ⚠️ It must not be
# driven
# P1 ingestion runs as its own long-lived task. ⚠️ It must not be driven
# off the control loop: telegrams arrive every ~5 s and the loop would
# decimate them, so the 15-minute average - the capacity-tariff billing
# unit - would be computed from a fraction of the data.