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
+25 -16
View File
@@ -142,6 +142,8 @@ import math
from dataclasses import dataclass
from datetime import datetime
from . import messages
# The reason code, distinct from any saturation-freeze code by construction:
# nothing else in this add-on uses it. See "DISTINCT FROM SATURATION".
REASON_CODE = "OUTPUT_CLAMP"
@@ -234,31 +236,38 @@ CLEAR_CYCLES = 3
SUSTAINED_CYCLES = 3
BUG_SIGNAL = ("This is a BUG SIGNAL, not a limit being used: something "
"upstream computed a setpoint the actuator cannot deliver.")
# UX-10: the reasons below are quoted by a WARNING or ERROR line, so they
# follow STYLE.md's rule for a quoted reason (test_messages.py part 6): no
# final full stop, the line adds it. The line around them says "software
# fault" for an over-rating, so the reason does not.
def loop_clamp_note(d) -> str:
"""The loop-side alarm text for one `control.Decision`, or "".
SAFETY-40: the integrator bound is anti-windup doing its job and says so;
only a command over the rating is a BUG SIGNAL. Every number is the
only a command over the rating is a software fault. Every number is the
decision's own, i.e. from the tick that clamped.
"""
if d.clamped:
return (f"{REASON_CODE}: the loop computed {d.requested_w:+.0f} W, over "
f"the {abs(d.clamped_w):.0f} W rating, and it was clamped to "
f"{d.clamped_w:+.0f} W; the loop's command this cycle was "
f"{d.target_w:+.0f} W. {BUG_SIGNAL}")
return (f"{REASON_CODE}: the add-on computed {d.requested_w:+.0f} W, "
f"over the {abs(d.clamped_w):.0f} W rating, and cut it back to "
f"{d.clamped_w:+.0f} W. The command this cycle was "
f"{d.target_w:+.0f} W")
if d.i_clamped:
return (f"{REASON_CODE}: the loop's integrator reached its "
f"{d.i_bound_w:+.0f} W anti-windup bound; the loop's command "
f"this cycle was {d.target_w:+.0f} W. Brief "
f"engagements after a large load or solar step are normal. If it "
f"persists, check that the battery is following its commands.")
return (f"{REASON_CODE}: the correction the add-on builds up reached "
f"its {d.i_bound_w:+.0f} W limit. The command this cycle was "
f"{d.target_w:+.0f} W")
return ""
def write_clamp_note(named) -> str:
"""The write-side alarm text: `named` is `[(inverter name, ClampResult)]`
for every inverter whose write was cut back this cycle."""
return ". ".join(f"{name}: {r.describe()}" for name, r in named)
@dataclass(frozen=True)
class ClampResult:
"""What the guard did, with the RAW value preserved.
@@ -275,10 +284,10 @@ class ClampResult:
bound: str = "" # "" | "max_w" | "device" - which stage bit
def describe(self) -> str:
return (f"{REASON_CODE}: the computed setpoint {self.requested_w:+.0f} W "
f"was clamped to {self.applied_w:+.0f} W by the {self.bound} "
f"bound (rating {self.limit_w:.0f} W) - something upstream is "
f"winding")
return (f"{REASON_CODE}: asked {self.requested_w:+.0f} W and sent "
f"{self.applied_w:+.0f} W, cut back by "
f"{messages.LIMIT_WORDS.get(self.bound, self.bound)}, with a "
f"{self.limit_w:.0f} W rating")
def is_commandable(value) -> bool: