UX-10 review: the line as logged gets the red-alarm shape, clamp lines at 30 words, plain HA 400
checks / unit (pull_request) Successful in 9m3s
infra-pin / fresh (pull_request) Successful in 2s

Part 6 checked the quoted reason alone, so title + reason + tails logged
as 4+ sentences. It now also checks the whole line (at most 3 sentences,
none over 30 words); 25 lines failed and are rewritten (one-sentence
wrappers, two-sentence circulation and peak reasons, STALE_READING's
fixed limits on a DEBUG line). The clamp lines for one inverter are
pinned at 30 words with OUTPUT_CLAMP and the numbers kept. Home
Assistant's bare '400: Bad Request' while it loads becomes one plain
line; still WARNING, throttled, a failed write.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 12:08:38 +02:00
co-authored by Claude Opus 5.5
parent f1a660881c
commit e0b578756f
13 changed files with 231 additions and 98 deletions
+51 -45
View File
@@ -612,8 +612,8 @@ class Controller:
for inv in self.inverters:
if inv.sign_wd.tripped:
_LOG.error("%s stays at 0 W after the restart because its "
"power went the wrong way: %s. Start control once "
"you know why.", inv.name,
"power went the wrong way, so find out why before "
"you start control. %s.", inv.name,
inv.sign_wd.reason or "(reason not recorded)")
# --- SAFETY-42 + SAFETY-34 -------------------------------------------
@@ -635,9 +635,9 @@ class Controller:
for inv in self.inverters:
if inv.delivery_wd.tripped:
_LOG.error(
"%s is still marked as not delivering after the restart: "
"%s. Control keeps running, and only stopping and starting "
"control clears this.",
"%s is still marked as not delivering after the restart "
"while control keeps running, and only stopping and "
"starting control clears this. %s.",
inv.name, inv.delivery_wd.reason or "(reason not recorded)")
# --- SAFETY-30: THE SITE HALF OF THE SIGN CHECK ----------------------
@@ -667,9 +667,9 @@ class Controller:
reason=str(store.data.get("site_sign_reason", "") or ""))
if self.site_sign_wd.tripped:
_LOG.error(
"Every inverter stays at 0 W after the restart because the "
"total power went the wrong way: %s. Start control once you "
"know why.",
"After the restart every inverter stays at 0 W, as the total "
"power went the wrong way, so find out why before starting "
"control. %s.",
self.site_sign_wd.reason or "(reason not recorded)")
# And the per-unit detectors are told which question they are asking,
# here as well as in `cycle()`. `checks()` and the status page read
@@ -720,9 +720,9 @@ class Controller:
self._announced_mono = {}
if self.circ_wd.tripped:
_LOG.error(
"Every inverter stays at 0 W after the restart because the "
"inverters worked against each other: %s. Start control once "
"you know why.",
"After the restart every inverter stays at 0 W, as the "
"inverters worked against each other, so find out why before "
"starting control. %s.",
self.circ_wd.reason or "(reason not recorded)")
# SAFETY-03. The output clamp's alarm - see clamp.py for why the clamp
@@ -1228,8 +1228,8 @@ class Controller:
first.store_set("sign_reason", first.sign_wd.reason)
first.store_set("sign_tripped", True)
_LOG.error(
"%s is set to 0 W again, because an older saved safety stop was "
"found: %s. Start control once you know why.",
"%s is set to 0 W again because an older saved safety stop was "
"found, so find out why before you start control. %s.",
first.name, first.sign_wd.reason)
_LOG.debug(
"SAFETY-02 latch guard: the pre-DEV-02 flat key says the "
@@ -1953,11 +1953,18 @@ class Controller:
# learns to skim it.
# UX-08 2d: plain, with SAFETY-57's escape clause kept as an
# ordered pair (time, then power), and the reason still in `%s`.
_LOG.error("%s: battery power reading stopped changing. Inverters "
"go to 0 W until it changes, or %.0f s after the "
"command drops below %.0f W: %s.",
# UX-10: one sentence, then the reason's two. Its limits are on
# the DEBUG line below, to keep the line within 3 sentences.
_LOG.error("%s: battery power reading stopped changing, set to "
"0 W until it changes, or %.0f s after the command "
"drops below %.0f W. %s.",
inv.name, inv.batt_fresh.bound.release_s,
inv.batt_fresh.bound.move_w, inv.batt_fresh.reason)
_LOG.debug("%s: the limits are unchanged over %.0f s, a command "
"over %.0f W and a move by over %.0f W.", inv.name,
inv.batt_fresh.bound.unchanged_s,
inv.batt_fresh.bound.move_w,
inv.batt_fresh.bound.move_w)
elif inv.batt_fresh.releases > was_releases:
# ⚠️ SAFETY-57. REPORTED, NEVER SILENT. A latch that lets go on a
# timer and says nothing is a latch no operator can audit - and this
@@ -2187,8 +2194,8 @@ class Controller:
self.log_event(
f"{inv.name}: WRITE REFUSED {value:+.0f} W - commanding 0 W",
hint=messages.hint("write_blocked", unit=inv.name))
_LOG.error("Refused to send %+.0f W to %s (%s), so it is set to "
"0 W instead. This is a software fault.",
_LOG.error("Refused to send %+.0f W to %s, so it is set to 0 W "
"instead, which is a software fault. The reason: %s.",
value, inv.name, refusal)
value = 0.0
@@ -2483,8 +2490,8 @@ class Controller:
self.store.set("sign_tripped", True)
self.log_event(f"FAILSAFE {inv.name}: " + inv.sign_wd.reason,
hint=messages.hint("direction", unit=inv.name))
_LOG.error("%s is set to 0 W because its power went the wrong "
"way: %s. Only starting control clears this.",
_LOG.error("%s is set to 0 W until control is started, "
"because its power went the wrong way. %s.",
inv.name, inv.sign_wd.reason)
# ⚠️ THE SITE'S OWN STANDING COMMAND, WHICH IS THE SUM OF THE UNITS'.
# SAFETY-07 and SAFETY-18 both grade a SITE quantity - the quarter-hour
@@ -2531,9 +2538,9 @@ class Controller:
self.store.set("site_sign_tripped", True)
self.log_event("FAILSAFE site: " + self.site_sign_wd.reason,
hint=messages.hint("site_direction"))
_LOG.error("Every inverter is set to 0 W because the total "
"power went the wrong way: %s. Only starting "
"control clears this.", self.site_sign_wd.reason)
_LOG.error("Every inverter is set to 0 W until control is "
"started, because the total power went the wrong "
"way. %s.", self.site_sign_wd.reason)
# --- SAFETY-31, AND IT RUNS BESIDE SAFETY-02 FOR THE SAME REASON -----
# Evaluated EVERY cycle, unconditionally, above the "inputs missing but
@@ -2564,8 +2571,8 @@ class Controller:
self.log_event("CIRCULATING POWER - " + self.circ_wd.reason,
hint=messages.hint("circulating"))
_LOG.error(
"Every inverter is set to 0 W because the inverters worked "
"against each other: %s. Only starting control clears this.",
"Every inverter is set to 0 W until control is started, "
"because the inverters worked against each other. %s.",
self.circ_wd.reason)
# --- SAFETY-42, AND IT RUNS HERE FOR SAFETY-02's AND SAFETY-31's REASON
@@ -2672,9 +2679,9 @@ class Controller:
hint=messages.hint("not_delivering",
unit=inv.name))
_LOG.error(
"%s is not delivering: %s. Nothing was changed and control "
"keeps running, and only stopping and starting control "
"clears this.", inv.name, inv.delivery_wd.reason)
"%s is not delivering while control keeps running, and "
"only stopping and starting control clears this. %s.",
inv.name, inv.delivery_wd.reason)
_LOG.debug(
"SAFETY-42 reports and does not act: commanding a unit "
"that is not listening achieves nothing (HW-01), and the "
@@ -3522,7 +3529,7 @@ class Controller:
# other unit never gets. Loud, every cycle, and it does not change
# the resolution - the arbiter already routed by `actuator`.
_LOG.error("A limit was filed on the wrong inverter, which is a "
"software fault: %s", " / ".join(resolution.scope_errors))
"software fault. %s.", " / ".join(resolution.scope_errors))
if resolution.contradiction:
self.log_event("ARBITER CONTRADICTION - commanding 0 W, see the log",
hint=messages.hint("clash"))
@@ -3814,9 +3821,10 @@ class Controller:
# SAFETY-40: only an over-rating is a bug; the integrator bound is not.
bug = engaged_write or loop_bug
if engaged_write:
reason = write_clamp_note([(inv.name, inv.clamp_last)
for inv in clamped_units])
_named = len(self.inverters) > 1
reason = write_clamp_note([(inv.name if _named else None,
inv.clamp_last)
for inv in clamped_units])
facts = [messages.Clamp(inv.name if _named else None,
inv.clamp_last.requested_w,
inv.clamp_last.applied_w,
@@ -3872,8 +3880,8 @@ class Controller:
messages.loop_bound().data())
return
_LOG.error(
"Power request too high, which is a software fault: %s. Control is "
"still running. Cut back %s so far.",
"Power request too high, a software fault: %s. Cut back %s so "
"far, control runs on.",
self.clamp_wd.reason, messages.times(self.clamp_wd.engagements))
_LOG.debug(
"SAFETY-03: silently clipping this is how a winding integrator "
@@ -3922,9 +3930,8 @@ class Controller:
self.log_event("PEAK_GUARD_SATURATED - " + g.reason,
hint=messages.hint("peak_saturated"))
_LOG.error(
"The battery is asked for all it can give, but this 15-minute "
"period still goes over the peak limit: %s. Switch off large "
"loads.", g.reason)
"Switch off large loads, the battery alone cannot keep this "
"15-minute period under the peak limit. %s.", g.reason)
# A fixed notification_id, and a DIFFERENT one from every other alarm
# here - check_meter_frozen() gives the reasoning. A saturated peak guard
# is not a frozen meter and is not an out-of-range setpoint.
@@ -3980,9 +3987,8 @@ class Controller:
# reason carries the word in the middle of a sentence, not at the front.
self.log_event(g.reason, hint=messages.hint("peak_reserve"))
_LOG.error(
"The peak guard may not draw the battery lower, so this 15-minute "
"period will go over the peak limit: %s. Switch off large loads.",
g.reason)
"Switch off large loads, the peak guard may not draw the battery "
"lower. %s.", g.reason)
# A fixed notification_id, and a DIFFERENT one from every other alarm
# here - check_meter_frozen() gives the reasoning. A battery at its
# energy floor is not a saturated inverter and is not a frozen meter,
@@ -4142,8 +4148,8 @@ class Controller:
hint=messages.hint("meter_frozen"))
_LOG.error(
"The meter shows the same value while the battery is asked for "
"full power: %s. Control still follows it, so check the meter.",
self.freeze_wd.reason)
"full power, and control still follows it, so check the meter. "
"%s.", self.freeze_wd.reason)
_LOG.debug(
"SAFETY-18: nothing else stops this. The meter still answers, so "
"its age is fresh, the firmware watchdog is fed and the arbiter "
@@ -4257,10 +4263,10 @@ class Controller:
and standing is not None and standing.from_feed)
if keep:
_LOG.warning(
"The price update was incomplete (%s), so the plan "
"from %s is kept until it is %s old.",
plan.words, messages.when(brussels(self.tariff_at)),
messages.duration(TARIFF_MAX_AGE_S))
"The price update was incomplete, so the plan from %s "
"is kept until it is %s old. The update held %s.",
messages.when(brussels(self.tariff_at)),
messages.duration(TARIFF_MAX_AGE_S), plan.words)
else:
self.tariff_plan, self.tariff_at = plan, now
_LOG.info("tariff refreshed: %s", plan.words)