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
+3 -3
View File
@@ -246,9 +246,9 @@ class Allocation:
block the failsafe is worse than the hole it closes.
"""
if tick is None or self.tick is None or self.tick != tick:
return (f"the allocation record on hand was made on tick "
f"{self.tick!r} and this is tick {tick!r} - a record whose "
f"envelopes have moved cannot authorise a write")
return (f"the power split on hand is from tick {self.tick!r}, "
f"not this tick {tick!r}, and an old split cannot allow a "
f"write")
if not self.ok:
return f"no power split was possible this tick ({self.explain()})"
if unit not in self.shares:
+4 -3
View File
@@ -328,9 +328,10 @@ class CirculationWatchdog:
# UX-10: plain sentences, every figure kept (test_messages.py part 6).
self.reason = (
f"CIRCULATING_POWER: {circ_w:.0f} W measured going round between "
f"the inverters for {held:.0f} s, peaking at {self.peak_w:.0f} W. "
f"The meter cannot see it, because it nets to zero there. Limits: "
f"over {self.cfg.min_w:.0f} W, held over {self.cfg.hold_s:.0f} s")
f"the inverters for {held:.0f} s, peaking at {self.peak_w:.0f} W, "
f"over the limits of {self.cfg.min_w:.0f} W and "
f"{self.cfg.hold_s:.0f} s. The meter cannot see it, because it "
f"nets to zero there")
return True
def held_s(self, now: datetime) -> float:
+23 -13
View File
@@ -239,8 +239,8 @@ SUSTAINED_CYCLES = 3
# 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.
# fault" for an over-rating, so the reason does not. Each is one sentence, so
# the logged line stays within 3 sentences and about 30 words.
def loop_clamp_note(d) -> str:
@@ -251,21 +251,29 @@ def loop_clamp_note(d) -> str:
decision's own, i.e. from the tick that clamped.
"""
if d.clamped:
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")
return (f"{REASON_CODE}: computed {d.requested_w:+.0f} W, cut to the "
f"{d.clamped_w:+.0f} W rating, command now {d.target_w:+.0f} W")
if d.i_clamped:
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"its {d.i_bound_w:+.0f} W limit, command now "
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)
"""The write-side alarm text: `named` is `[(inverter name or None,
ClampResult)]` for every inverter whose write was cut back this cycle.
One inverter reads as `describe()`. Several read as one sentence of
asked/sent pairs, so the line stays one sentence however many inverters
there are. Which limit cut each one back is on its own action-log row
(`describe()`) and in the notification."""
if len(named) == 1:
name, r = named[0]
return f"{name}: {r.describe()}" if name else r.describe()
return f"{REASON_CODE}: " + ", ".join(
f"{name} asked {r.requested_w:+.0f} W and sent {r.applied_w:+.0f} W"
for name, r in named)
@dataclass(frozen=True)
@@ -284,10 +292,12 @@ class ClampResult:
bound: str = "" # "" | "max_w" | "device" - which stage bit
def describe(self) -> str:
# The sent value IS the limit that bound, so the rating is not
# repeated: on "max_w" it equals the rating, and on "device" the
# rating was not what cut it back.
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")
f"{self.applied_w:+.0f} W, "
f"{messages.LIMIT_WORDS.get(self.bound, self.bound)}")
def is_commandable(value) -> bool:
+5 -7
View File
@@ -594,10 +594,12 @@ class Freshness:
# ("what did the register do" against "can I trust this entity"). One
# helper serving both would have to drop whichever half the other does
# not need, and the half it dropped would be the operator's next step.
# UX-10: three plain sentences (test_messages.py part 6). SAFETY-56's
# UX-10: two plain sentences (test_messages.py part 6). SAFETY-56's
# two explanations stay, in this order, and so does the admission
# that this check cannot tell them apart. The reading is treated as
# missing either way, which the log line around this says.
# missing either way, which the log line around this says. The
# limits (`self.bound`) are on main.py's DEBUG line beside it, so the
# logged line stays within 3 sentences.
# ⚠️ SAFETY-57, AND THE THREE DURATIONS ARE THREE DIFFERENT FACTS, each
# in its own slot: the register's unchanged time, the WINDOW this
# verdict actually rests on after a release (`regraded`), and how many
@@ -611,11 +613,7 @@ class Freshness:
f"STALE_READING: {self.held:+.0f} W unchanged for {shown_s:.0f} s "
f"while the command moved {moved_w:.0f} W to {standing_w:+.0f} W"
f"{regraded}. The reading may have stopped, or the battery may be "
f"unable to follow the command, and this check cannot tell which. "
f"Limits: "
f"unchanged over {self.bound.unchanged_s:.0f} s, command over "
f"{self.bound.move_w:.0f} W and moved by over "
f"{self.bound.move_w:.0f} W")
f"unable to follow the command, and this check cannot tell which")
def unchanged_s(self, now_mono) -> float | None:
"""Seconds since an arrival carried a different number. Status page.
+13 -2
View File
@@ -164,9 +164,20 @@ class HomeAssistant:
# value is rejected outright, not clamped. Always log it:
# silently dropped commands are how a controller ends up
# believing something the hardware never did.
# UX-10: plain words. While Home Assistant loads its
# entities it answers 400 with only "400: Bad Request",
# which says nothing, so only a body with more is quoted.
said = body.strip()[:200]
if said.lower() in (f"{resp.status}: {resp.reason}".lower(),
""):
said = "no details"
entity = (data.get("entity_id", "no entity")
if isinstance(data, dict) else "no entity")
self._moan(f"svc:{domain}.{service}",
"service %s.%s rejected (%s): %s",
domain, service, resp.status, body[:200])
"Home Assistant refused %s.%s for %s (error %s, "
"%s). The entity may still be loading, or the "
"value is out of range.", domain, service,
entity, resp.status, said)
return False
return True
except aiohttp.ClientConnectionError as err:
+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)
+8 -6
View File
@@ -414,8 +414,8 @@ CEILING_MONTH_PEAK = "this month's peak"
# SAFETY-24. Why the forced discharge may not be sized. Reasons, not booleans,
# for BLIND_*'s reason: the status page, the action log and the operator
# notification all have to say WHICH, and "floor-limited" alone is unactionable.
FLOOR_SOC_UNREADABLE = ("the battery level was not read this cycle, and an "
"unread level is not a full battery")
FLOOR_SOC_UNREADABLE = ("the battery level was not read this cycle, and "
"unread is not full")
# ⚠️ SAFETY-25. "the peak reserve", not "the SoC floor", and the name is
# load-bearing: this string is what the action log and the add-on log print,
# and `peak_soc_floor` (“Peak reserve”) bounds THIS GUARD ONLY. Widening it to
@@ -818,8 +818,8 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
+ ("" if soc is None else f", at {float(soc):.0f} %")
+ ("" if soc is None or soc_floor is None
else f" against {float(soc_floor):.0f} %")
+ ". So there is no forced discharge and no charging, floor "
"0 W, and this period will go over the limit"))
+ ", so no forced discharge and no charging, and this period "
"will go over the limit"))
floor = min(wanted, ceil_top)
saturated = floor < wanted
@@ -833,8 +833,10 @@ def decide(projection, *, batt_w, cap_w, reading, achievable_w, soc, soc_floor,
f"{QUARTER_S - projection.elapsed_s:.0f} s left, so {ex:.0f} W must "
f"come off the meter. The battery measured {float(batt_w):+.0f} W "
f"and is asked for {floor:+.0f} W"
+ (f". {wanted:+.0f} W was needed, but the inverters give at most "
f"{ceil_top:.0f} W" if saturated else "")))
# Saturated means floor == ceil_top: the ask IS the most the
# inverters give, so one clause says both.
+ (f", the most the inverters give, but {wanted:+.0f} W was needed"
if saturated else "")))
def charge_headroom_w(decision) -> float | None:
+3 -3
View File
@@ -591,9 +591,9 @@ def _walk(series, hours, action) -> Gate:
return Gate.block(
f"{action.name} needs prices for {action.commits_h:g} hours "
f"from {messages.when(brussels(hours[0]))}, and the price for "
f"{messages.when(brussels(u))} is only estimated, from "
f"yesterday or from the fixed tariff. It can start once the "
f"day-ahead prices are published, around 14:00 to 16:00")
f"{messages.when(brussels(u))} is only estimated. It can start "
f"once the day-ahead prices are published, around 14:00 to "
f"16:00")
pts.append(p)
return Gate.permit(action, pts)
+5 -5
View File
@@ -1358,12 +1358,12 @@ def _degrade(opts, read, since, until, stamp) -> TariffPlan:
"no tariff at all.", e)
profile = None
if profile is None:
_LOG.warning("The price feed failed: %s. No fixed tariff is set to "
"fall back to, so trading is off. Peak limiting and home "
"use go on.", read.reason)
_LOG.warning("The price feed failed and no fixed tariff is set, so "
"trading is off, while peak limiting and home use go on. "
"%s.", read.reason)
return TariffPlan(read, None, "none")
_LOG.warning("The price feed failed: %s. The fixed tariff is used "
"instead, marked as estimated, and trading is off.",
_LOG.warning("The price feed failed, so the fixed tariff is used "
"instead, marked as estimated, and trading is off. %s.",
read.reason)
points = profile.series(since, until, provisional=True)
return TariffPlan(