UX-10: the last plain-language leftovers: log lines, two table rows, 'Why: Other', red at 0 s
checks / unit (pull_request) Successful in 9m3s
infra-pin / fresh (pull_request) Successful in 2s

- test_messages.py part 6: every dynamic part of a WARNING+ line is classified
  as data or as a built text, and every built text is produced by the real
  code and checked. This is the gap the 2d scan had: it counted `%s` as one
  word, so reasons built by loop_clamp_note() and friends were never seen.
- Quoted reasons rewritten to STYLE.md (red-alarm shape, codes kept):
  OUTPUT_CLAMP, METER_FROZEN, CIRCULATING_POWER, STALE_READING, the peak
  guard (codes now PEAK_GUARD*), the migration report, price feed, price
  history and gate reasons. Plurals via messages.times()/count().
- Top table: Price history "96 prices over 24 hours for 26 Sep", Tariff
  "one every 15 minutes or every hour", "not final yet" dropped.
- Levels and noise: per-cycle shadow line DEBUG, shadow on/off INFO,
  aiohttp access log only at log level debug, settings warnings once per
  start, one plain line per Home Assistant outage (hass.Outage).
- INFO jargon and error texts: no "§8.3", ISO times, "banked", "keys ARE",
  "NOT_OBSERVED", "did NOT land", "AND".
- Display bugs: WHY_WINNER covers every winner and safety reason
  (main.why_key(), part 7 finds them in the code); the meter row is amber
  "waiting for the first reading" inside its limit after a start; the
  missing-reading age is counted when the page asks.
- STYLE.md and DECISIONS/UX-08-short-notifications.md (UX-10 section).

No change to safety behaviour, alarm levels, notification ids, triggers,
cadence or the banner cascade. One row colour changes (meter at start),
justified in DECISIONS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-09-27 10:58:43 +02:00
co-authored by Claude Opus 5.5
parent 125b0702a2
commit 23a3a3f5b5
33 changed files with 1268 additions and 347 deletions
+91 -41
View File
@@ -49,8 +49,8 @@ from .circulation import (
circulating_w, opposing,
)
from .clamp import (
BUG_SIGNAL as CLAMP_BUG_SIGNAL, REASON_CODE as CLAMP_REASON, ClampWatchdog,
clamp_to_rating, is_commandable, loop_clamp_note,
REASON_CODE as CLAMP_REASON, ClampWatchdog,
clamp_to_rating, is_commandable, loop_clamp_note, write_clamp_note,
)
from . import freshness
from . import messages
@@ -230,6 +230,22 @@ def pack_energy_of(inv):
inv.capacity_kwh, inv.max_w)
def why_key(winner: str, claims) -> str:
"""The key of `messages.WHY_WINNER` for a resolution's winner.
"safety" and "failsafe" each stand for several causes, so for those the
winning claim's own reason is the key ("stopped", "inputs missing", "sign
disagreement"). A failsafe with no claim of its own is the arbiter's "no
claim was made", or main.py's refusal to split the power ("unsplit").
"""
if winner in ("safety", "failsafe"):
return next((c.reason for c in claims
if c.source == winner and c.kind == "set"),
"no claim was made" if not any(
c.kind == "set" for c in claims) else "unsplit")
return winner
def tariff_plan_row(plan, tick_words: str, *, age_s, horizon_h: float,
provider: bool, max_age_s: float) -> str:
"""The *Tariff* status row: the 30 s tick's sentence AND the provider plan.
@@ -544,7 +560,7 @@ class Controller:
# re-arm anything. It is still the difference between a pack that is
# recalibrated on schedule and one that silently is not, which is
# §12's opening sentence, so it is said where an operator looks.
self.log_event("maintenance history did NOT migrate cleanly: "
self.log_event("maintenance history did not move over cleanly: "
+ self.maint_migration.explain(),
hint=messages.hint("maint_history"))
# ⚠️ ONE CONFIG, N MACHINES. The `maintenance_*` options describe the
@@ -750,7 +766,7 @@ class Controller:
self.peak = PeakTracker(self.peak_store,
cap_w=float(opts.get("peak_cap_w", 3500)))
self.peak.restore(self.p1.averager)
_LOG.info("capacity-tariff peak store: %s", self.peak.restore_state)
_LOG.info("Month peak store: %s", self.peak.restore_state)
# FCST-04. The §8.3 carry-forward store: the published prices of the
# last few Brussels days, so the hours a day-ahead feed has not printed
@@ -786,7 +802,7 @@ class Controller:
# `.words` read below are unguarded, and a cache that raises here is an
# add-on that does not start.
self.price_history = PriceHistory(price_history_path)
_LOG.info("§8.3 price history: %s", self.price_history.words)
_LOG.info("Price history: %s", self.price_history.words)
# SAFETY-07. The guard TARIFF-01 was built for. It is stateless by
# design (peakguard.py holds no history between calls), so the only
@@ -1071,9 +1087,9 @@ class Controller:
"""
rep = self.store_migration
if rep.ran and not rep.ok:
_LOG.error("The saved safety stops did not copy over cleanly "
"(%s), so they are read again from the older saved "
"state.", rep.explain())
_LOG.error("The saved safety stops did not copy over cleanly, so "
"they are read again from the older saved state: %s.",
rep.explain())
data = getattr(store, "data", {})
legacy_tripped = bool(data.get("sign_tripped", False))
if not legacy_tripped or not self.inverters:
@@ -1678,6 +1694,22 @@ class Controller:
"""
return packshare.weights(self.pack_energy, target_w)
@property
def why_words(self) -> str:
"""The page's "Why" line. While control waits for a missing reading
the age is counted when the page asks (UX-10), not frozen at the age
the last cycle saw."""
if self._why_waiting_since is not None:
return messages.why_waiting(max(0.0, (
datetime.now(timezone.utc)
- self._why_waiting_since).total_seconds()))
return self._why_words
@why_words.setter
def why_words(self, text: str) -> None:
self._why_words = text
self._why_waiting_since = None
@property
def inputs_ok(self) -> bool:
"""The site meter, and EVERY pack's own two readings.
@@ -1923,7 +1955,7 @@ class Controller:
# 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",
"command drops below %.0f W: %s.",
inv.name, inv.batt_fresh.bound.release_s,
inv.batt_fresh.bound.move_w, inv.batt_fresh.reason)
elif inv.batt_fresh.releases > was_releases:
@@ -2078,9 +2110,10 @@ class Controller:
f"refusing to broadcast {float(value):+.0f} W to "
f"{len(targets)} inverters - that is the ARCH-01 fault; "
f"commanding 0 W", hint=messages.hint("all_blocked"))
_LOG.error("Refused to send %+.0f W to all %d inverters at "
"once, so every inverter is set to 0 W. This is a "
"software fault.", float(value), len(targets))
_LOG.error("Refused to send %+.0f W to all %s at once, so "
"every inverter is set to 0 W. This is a software "
"fault.", float(value),
messages.count(len(targets), "inverter"))
_LOG.debug("ARCH-01: a site target handed to each unit is "
"supplied by each of them, so the gain at the meter "
"becomes N times the per-unit gain. Only 0 W may be "
@@ -2203,10 +2236,11 @@ class Controller:
# one writer, and this is that writer emitting the value it emits for
# every other refusal.
if self.shadow and value != shadow.WIRE_W:
_LOG.info("SHADOW MODE: not commanding %+.0f W to %s - holding "
"%+.0f W. The decision was reached and recorded; "
"nothing was sent to the inverter.",
value, inv.name, shadow.WIRE_W)
# UX-10: DEBUG, because it repeats every cycle in shadow mode. The
# INFO line when shadow mode comes on says the same once.
_LOG.debug("Shadow mode: would send %+.0f W to %s, and holds "
"%+.0f W. Nothing was sent to the inverter.",
value, inv.name, shadow.WIRE_W)
value = shadow.WIRE_W
changed = (inv.last_write is None or value != inv.last_write)
@@ -2666,7 +2700,12 @@ class Controller:
# A single missed poll is not a fault. Hold, keep the heartbeat
# going, and give it a few seconds to come back.
self.reason = f"inputs missing {bad_for:.0f}s"
# UX-10: the age is rendered when the page asks, not frozen at
# this cycle's. With the meter reading gone the grid value
# stops changing, so the next cycle is up to cycle_floor_s
# away and the line used to read "0 seconds" all that time.
self.why_words = messages.why_waiting(bad_for)
self._why_waiting_since = now - timedelta(seconds=bad_for)
return
claims = []
@@ -3505,12 +3544,9 @@ class Controller:
self.reason = resolution.explain()
# UX-08: the same answer in plain words, for the page's "Why" line.
# `reason` stays the technical record the log and the checks read.
_why = resolution.winner
if _why in ("safety", "failsafe"):
_why = next((c.reason for c in claims
if c.source == _why and c.kind == "set"),
"no claim was made" if not any(
c.kind == "set" for c in claims) else "unsplit")
# 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)
@@ -3692,7 +3728,9 @@ class Controller:
self.o, control_started=self.auto)
+ f"; inverter held at {shadow.WIRE_W:.0f} W",
hint=messages.hint("shadow_on"))
_LOG.warning(
# UX-10: INFO. It follows a setting or a button press, and
# no person has to act on it.
_LOG.info(
"Shadow mode is on, so every inverter stays at %.0f W and "
"nothing the add-on decides is sent. %s.", shadow.WIRE_W,
shadow.why(self.o, control_started=self.auto))
@@ -3701,7 +3739,7 @@ class Controller:
"SHADOW MODE OFF - the battery is being controlled again, "
f"resuming from {shadow.WIRE_W:.0f} W",
hint=messages.hint("shadow_off"))
_LOG.warning(
_LOG.info(
"Shadow mode is off, so the add-on controls the battery "
"again, starting from %.0f W.", shadow.WIRE_W)
if not engaged:
@@ -3776,8 +3814,8 @@ 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 = (", ".join(f"{inv.name}: {inv.clamp_last.describe()}"
for inv in clamped_units) + ". " + CLAMP_BUG_SIGNAL)
reason = write_clamp_note([(inv.name, inv.clamp_last)
for inv in clamped_units])
_named = len(self.inverters) > 1
facts = [messages.Clamp(inv.name if _named else None,
inv.clamp_last.requested_w,
@@ -3824,16 +3862,19 @@ class Controller:
# SAFETY-40: the integrator bound is anti-windup, not a bug - a
# 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 %d "
"times so far.", self.clamp_wd.reason,
self.clamp_wd.engagements)
_LOG.warning("The battery gives less than asked: %s. Held back %s "
"so far.", self.clamp_wd.reason,
messages.times(self.clamp_wd.engagements))
_LOG.debug("A short hold after a large load or solar step is "
"normal. If it lasts, check that the battery follows "
"its commands.")
await self.hass.call("persistent_notification", "create",
messages.loop_bound().data())
return
_LOG.error(
"Power request too high, which is a software fault: %s Control is "
"still running. Cut back %d times so far.",
self.clamp_wd.reason, self.clamp_wd.engagements)
"Power request too high, which is a software fault: %s. Control is "
"still running. Cut back %s so far.",
self.clamp_wd.reason, messages.times(self.clamp_wd.engagements))
_LOG.debug(
"SAFETY-03: silently clipping this is how a winding integrator "
"reached 14 768 W on this project before. The clamp makes every "
@@ -3878,7 +3919,7 @@ class Controller:
was, self._peak_saturated = self._peak_saturated, saturated
if not saturated or was:
return
self.log_event("PEAK GUARD SATURATED - " + g.reason,
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 "
@@ -3933,7 +3974,7 @@ class Controller:
was, self._peak_floor_limited = self._peak_floor_limited, limited
if not limited or was:
return
# No prefix: `reason` already opens with PEAK GUARD FLOOR-LIMITED, and
# No prefix: `reason` already opens with PEAK_GUARD_FLOOR_LIMITED, and
# a second copy is the kind of stutter that makes a 40-line action log
# read like boilerplate. The saturated path needs its prefix because its
# reason carries the word in the middle of a sentence, not at the front.
@@ -4217,10 +4258,9 @@ class Controller:
if keep:
_LOG.warning(
"The price update was incomplete (%s), so the plan "
"from %s, %.0f s ago, is kept until it is %.0f s old.",
plan.words, self.tariff_at,
(now - self.tariff_at).total_seconds(),
TARIFF_MAX_AGE_S)
"from %s is kept until it is %s old.",
plan.words, messages.when(brussels(self.tariff_at)),
messages.duration(TARIFF_MAX_AGE_S))
else:
self.tariff_plan, self.tariff_at = plan, now
_LOG.info("tariff refreshed: %s", plan.words)
@@ -4231,8 +4271,9 @@ class Controller:
# the same reason and on the same terms.
_LOG.warning(
"The price update failed (%s), so the plan from %s is kept "
"until it is %.0f s old, and trading stops then.",
err, self.tariff_at, TARIFF_MAX_AGE_S)
"until it is %s old, and trading stops then.",
err, messages.when(brussels(self.tariff_at)),
messages.duration(TARIFF_MAX_AGE_S))
with contextlib.suppress(asyncio.TimeoutError):
await asyncio.wait_for(self.stop_event.wait(),
timeout=TARIFF_REFRESH_S)
@@ -4612,7 +4653,16 @@ class Controller:
"text": messages.row_controller_ran(_pass_age)})
if self.p1_enabled:
age = self.p1.published_age_s
if self.p1.stale:
if (self.p1.stale and self.p1.age_s() is None
and age <= self.p1.max_age_s):
# UX-10: no reading yet since the start, still inside the
# limit. Amber, not red: it is not a fault yet, but control
# waits for it. Red at 0 s contradicted the limit the row
# prints. Past the limit it is the red row below, as before.
out.append({"ok": False, "warn": True, "id": "meter",
"text": messages.row_meter_waiting(
self.p1.max_age_s)})
elif self.p1.stale:
# The last error is on the add-on log's WARNING line (p1.py).
out.append({"ok": False, "warn": False, "id": "meter",
"text": messages.row_meter_stale(