UX-11: safety log lines give one plain sentence and the code, the figures go to DEBUG
checks / unit (pull_request) Successful in 9m3s
infra-pin / fresh (pull_request) Successful in 2s

- Every WARNING/ERROR line that reported a safety reason (direction, delivery,
  stuck meter, stale reading, peak guard, circulation, over-rating) now ends in
  the reason's code (messages.reason_code()). The reason itself, figures and
  limits included, is on a DEBUG line right after it and in the action log.
- The reasons and state.json are unchanged, so an old state file loads and
  shows the same way: every saved reason opens with its code.
- test_messages part 6 checks every WARNING+ line at 25 words as logged; nine
  non-safety families are listed with a reason and capped. The byte pin in
  test_watchdog now pins the meaning, in order.
- The meter logs one WARNING per quiet episode, and INFO when back, so the red
  row's "the add-on log shows why" holds. The Why line past the hold names
  the missing reading. Action log: "25 engagements", "Shadow mode off: ...".

No trigger, latch, threshold, log level, notification id, cadence, row colour
or banner order changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
This commit is contained in:
glenn schrooyen
2026-09-27 13:34:30 +02:00
co-authored by Claude Opus 5.5
parent c4032ebc21
commit 29837dfaa7
10 changed files with 425 additions and 84 deletions
+96 -34
View File
@@ -611,9 +611,13 @@ class Controller:
self._grade_store_migration(store)
for inv in self.inverters:
if inv.sign_wd.tripped:
# UX-11: the plain sentence and the code. The saved reason,
# with its figures, is on the DEBUG line beside it.
_LOG.error("%s stays at 0 W after the restart because its "
"power went the wrong way, so find out why before "
"you start control. %s.", inv.name,
"you start control: %s.", inv.name,
messages.reason_code(inv.sign_wd.reason))
_LOG.debug("Evidence for %s: %s.", inv.name,
inv.sign_wd.reason or "(reason not recorded)")
# --- SAFETY-42 + SAFETY-34 -------------------------------------------
@@ -637,8 +641,10 @@ class Controller:
_LOG.error(
"%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)")
"starting control clears this: %s.",
inv.name, messages.reason_code(inv.delivery_wd.reason))
_LOG.debug("Evidence for %s: %s.", inv.name,
inv.delivery_wd.reason or "(reason not recorded)")
# --- SAFETY-30: THE SITE HALF OF THE SIGN CHECK ----------------------
# ⚠️ ONE PER SITE, BESIDE THE ONE PER INVERTER, AND ARMED ONLY AT N>=2.
@@ -669,8 +675,9 @@ class Controller:
_LOG.error(
"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)")
"control: %s.", messages.reason_code(self.site_sign_wd.reason))
_LOG.debug("Evidence for the site: %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
# `inv.sign_wd.cfg` and can be called before the first cycle has run, so
@@ -722,8 +729,10 @@ class Controller:
_LOG.error(
"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)")
"starting control: %s.",
messages.reason_code(self.circ_wd.reason))
_LOG.debug("Evidence for the site: %s.",
self.circ_wd.reason or "(reason not recorded)")
# SAFETY-03. The output clamp's alarm - see clamp.py for why the clamp
# itself is the cheap half.
@@ -929,6 +938,8 @@ class Controller:
self.following = False
self.reason = "starting"
self.why_words = "Starting"
# UX-11: has this meter episode's WARNING been logged? See read_inputs.
self._meter_quiet_logged = False
# ALLOC-01. The allocation record from the last resolution, or None
# before the first cycle. ⚠️ IT IS KEPT SO THAT SOMETHING CAN GATE ON
# IT: SAFETY-28 puts the write path behind this record, and DEV-02 reads
@@ -1229,8 +1240,9 @@ class Controller:
first.store_set("sign_tripped", True)
_LOG.error(
"%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)
"found, so find out why before you start control: %s.",
first.name, messages.reason_code(first.sign_wd.reason))
_LOG.debug("Evidence for %s: %s.", first.name, first.sign_wd.reason)
_LOG.debug(
"SAFETY-02 latch guard: the pre-DEV-02 flat key says the "
"sign-disagreement failsafe is latched and no inverter came up "
@@ -1726,6 +1738,18 @@ class Controller:
return all(inv.soc is not None and inv.batt is not None
for inv in self.inverters)
def missing_reading(self) -> str:
"""UX-11: the first reading `inputs_ok` finds missing, in words."""
if self.grid is None or not self.inverters:
return "meter"
for inv in self.inverters:
of = "" if len(self.inverters) == 1 else f" from {inv.name}"
if inv.batt is None:
return f"battery power{of}"
if inv.soc is None:
return f"battery level{of}"
return "input"
# -- io ------------------------------------------------------------------
@property
def pv_roster(self) -> tuple:
@@ -1885,6 +1909,21 @@ class Controller:
horizon_h=float(price_option(o, "price_horizon_h")),
provider=provider_configured(o), max_age_s=TARIFF_MAX_AGE_S)
# UX-11: the red meter row says "the add-on log shows why", so a meter
# that goes quiet gets one WARNING per episode, not one per cycle. It
# is a log line only: no notification, and nothing it decides.
if self.p1_enabled:
if (self.p1.stale and not self._meter_quiet_logged
and self.p1.published_age_s > self.p1.max_age_s):
self._meter_quiet_logged = True
_LOG.warning("No meter reading for %s, over the limit of %s, "
"so control waits and then sets the battery to "
"0 W.", messages.seconds(self.p1.published_age_s),
messages.seconds(self.p1.max_age_s))
elif not self.p1.stale and self._meter_quiet_logged:
self._meter_quiet_logged = False
_LOG.info("Meter readings are back.")
if self.inputs_ok:
if self.inputs_bad_since is not None:
self.log_event("inputs recovered",
@@ -1953,13 +1992,16 @@ 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`.
# UX-10: one sentence, then the reason's two. Its limits are on
# the DEBUG line below, to keep the line within 3 sentences.
# UX-11: one sentence and the code. The reason, with SAFETY-56's
# two explanations and its figures, and the limits are on the
# DEBUG lines below and in the action log.
_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.",
"drops below %.0f W: %s.",
inv.name, inv.batt_fresh.bound.release_s,
inv.batt_fresh.bound.move_w, inv.batt_fresh.reason)
inv.batt_fresh.bound.move_w,
messages.reason_code(inv.batt_fresh.reason))
_LOG.debug("Evidence for %s: %s.", inv.name, 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,
@@ -2491,8 +2533,10 @@ class Controller:
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 until control is started, "
"because its power went the wrong way. %s.",
inv.name, inv.sign_wd.reason)
"because its power went the wrong way: %s.",
inv.name, messages.reason_code(inv.sign_wd.reason))
_LOG.debug("Evidence for %s: %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
# average at the grid connection, and a meter that has stopped moving
@@ -2540,7 +2584,10 @@ class Controller:
hint=messages.hint("site_direction"))
_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)
"way: %s.",
messages.reason_code(self.site_sign_wd.reason))
_LOG.debug("Evidence for the site: %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
@@ -2572,8 +2619,9 @@ class Controller:
hint=messages.hint("circulating"))
_LOG.error(
"Every inverter is set to 0 W until control is started, "
"because the inverters worked against each other. %s.",
self.circ_wd.reason)
"because the inverters worked against each other: %s.",
messages.reason_code(self.circ_wd.reason))
_LOG.debug("Evidence for the site: %s.", self.circ_wd.reason)
# --- SAFETY-42, AND IT RUNS HERE FOR SAFETY-02's AND SAFETY-31's REASON
# Evaluated EVERY cycle, unconditionally, ABOVE the "inputs missing but
@@ -2680,8 +2728,10 @@ class Controller:
unit=inv.name))
_LOG.error(
"%s is not delivering while control keeps running, and "
"only stopping and starting control clears this. %s.",
inv.name, inv.delivery_wd.reason)
"only stopping and starting control clears this: %s.",
inv.name, messages.reason_code(inv.delivery_wd.reason))
_LOG.debug("Evidence for %s: %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 "
@@ -3554,9 +3604,14 @@ class Controller:
# UX-10: `why_key()` maps a safety winner to its claim's reason, and
# every key it can return has words in messages.WHY_WINNER.
_why = why_key(resolution.winner, claims)
self.why_words = messages.why(_why, resolution.target_w,
resolution.bound_by,
contradiction=resolution.contradiction)
if _why == "inputs missing" and not resolution.contradiction:
# UX-11: past the hold, say which reading is missing.
self.why_words = messages.why_missing(self.missing_reading(),
stale_after)
else:
self.why_words = messages.why(
_why, resolution.target_w, resolution.bound_by,
contradiction=resolution.contradiction)
# --- DEV-02: EACH INVERTER IS TOLD ITS OWN SHARE ---------------------
# ⚠️ ITS OWN SHARE, NEVER THE SITE TARGET. `resolution.shares` is the
# allocator's answer and `sum(shares) + unmet_w == round(target_w)` is
@@ -3743,8 +3798,8 @@ class Controller:
shadow.why(self.o, control_started=self.auto))
elif not first:
self.log_event(
"SHADOW MODE OFF - the battery is being controlled again, "
f"resuming from {shadow.WIRE_W:.0f} W",
"Shadow mode off: the add-on controls the battery again, "
f"starting from {shadow.WIRE_W:.0f} W",
hint=messages.hint("shadow_off"))
_LOG.info(
"Shadow mode is off, so the add-on controls the battery "
@@ -3856,8 +3911,9 @@ class Controller:
# write-path engagements and 2 loop-side ones.
if engaged and not engaged_write:
tag = f"{CLAMP_REASON} episode {self.clamp_wd.episodes}"
row = (f"{tag}: {self.clamp_wd.engagements} engagement(s) - "
f"{reason}")
row = (f"{tag}: "
f"{messages.count(self.clamp_wd.engagements, 'engagement')}"
f" - {reason}")
if self.events and tag in self.events[0]:
stamp = self.events[0].split(" ", 1)[0]
self.events[0] = f"{stamp} {row}"
@@ -3871,8 +3927,9 @@ class Controller:
# WARNING and a plainly titled notice, same cadence. Its OWN id, so
# it can never replace a BUG SIGNAL notice still on screen.
_LOG.warning("The battery gives less than asked: %s. Held back %s "
"so far.", self.clamp_wd.reason,
"so far.", messages.reason_code(self.clamp_wd.reason),
messages.times(self.clamp_wd.engagements))
_LOG.debug("Evidence: %s.", self.clamp_wd.reason)
_LOG.debug("A short hold after a large load or solar step is "
"normal. If it lasts, check that the battery follows "
"its commands.")
@@ -3881,8 +3938,9 @@ class Controller:
return
_LOG.error(
"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))
"far, control runs on.", messages.reason_code(self.clamp_wd.reason),
messages.times(self.clamp_wd.engagements))
_LOG.debug("Evidence: %s.", self.clamp_wd.reason)
_LOG.debug(
"SAFETY-03: silently clipping this is how a winding integrator "
"reached 14 768 W on this project before. The clamp makes every "
@@ -3931,7 +3989,9 @@ class Controller:
hint=messages.hint("peak_saturated"))
_LOG.error(
"Switch off large loads, the battery alone cannot keep this "
"15-minute period under the peak limit. %s.", g.reason)
"15-minute period under the peak limit: %s.",
messages.reason_code(g.reason))
_LOG.debug("Evidence: %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.
@@ -3988,7 +4048,8 @@ class Controller:
self.log_event(g.reason, hint=messages.hint("peak_reserve"))
_LOG.error(
"Switch off large loads, the peak guard may not draw the battery "
"lower. %s.", g.reason)
"lower: %s.", messages.reason_code(g.reason))
_LOG.debug("Evidence: %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,
@@ -4148,8 +4209,9 @@ class Controller:
hint=messages.hint("meter_frozen"))
_LOG.error(
"The meter shows the same value while the battery is asked for "
"full power, and control still follows it, so check the meter. "
"%s.", self.freeze_wd.reason)
"full power, and control still follows it, so check the meter: "
"%s.", messages.reason_code(self.freeze_wd.reason))
_LOG.debug("Evidence: %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 "