UX-10 review: the line as logged gets the red-alarm shape, clamp lines at 30 words, plain HA 400
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:
@@ -75,4 +75,6 @@ The allowlist is empty and its cap is 0 (43 → 0). Every WARNING and ERROR line
|
||||
- "Why: Other" during a latched per-inverter stop: `main.py` turns a safety winner into its claim's reason, and "sign disagreement" (and "unsplit", main.py's own refusal to split) had no entry in `WHY_WINNER`. The mapping is now `main.why_key()`, and part 7 finds every `Claim.set()` in the code and checks that every key it can produce has words.
|
||||
- Red "Meter: no reading for 0 seconds" after a restart: until the first reading arrives, `P1Ingest.stale` is true, and the age is the time since start. The row now reads amber "waiting for the first reading" while that age is inside the limit. **Colour change, justified:** red at 0 s contradicted the limit the same row prints, and once readings exist the row is red only past that limit. Amber, not green, because control waits for the reading. Past the limit it is red as before. `stale` itself, which gates control, is unchanged.
|
||||
- "A reading is missing for 0 seconds" stuck at 0: with the meter reading gone, the grid value stops changing, so the loop runs a cycle only every `cycle_floor_s`, and the line was computed once per cycle. The age is now counted when the page asks. The hold and its 15 s escalation are unchanged.
|
||||
7. **Witness** (admin/goodwe): the resolved push says "after no answers from … to …", as the resolved card does. The YAML with every title and message stripped is identical.
|
||||
7. **Review: the line as logged gets the shape too.** Part 6 first checked the quoted reason alone, so a title, a 2-sentence reason and two tails still logged as 4 sentences ("Power request too high …: OUTPUT_CLAMP: … +5000 W. The command this cycle was +1000 W. Control is still running. Cut back 1 time so far."). Part 6 now also checks the whole line: at most 3 sentences, none over 30 words. The wrappers became one sentence each, the circulation and peak guard reasons two, and `STALE_READING`'s fixed limits moved from the reason to a DEBUG line beside the ERROR (its release clause stays, pinned by test_freshness). The clamp lines for one inverter are pinned at 30 words at most: `OUTPUT_CLAMP: computed +9000 W, cut to the +5000 W rating, command now +2000 W`. Several clamped inverters read as one sentence of asked/sent pairs; which limit bound each is on its own action-log row and in the notification. Totals above 30 words remain on the direction, delivery, frozen-meter and stale lines (up to 74 words). Shortening them is a follow-up: the direction and delivery reasons are also persisted in state.json, and the direction one is pinned byte-for-byte in test_watchdog.
|
||||
8. **Home Assistant 400 while it loads** now reads "Home Assistant refused number.set_value for number.x (error 400, no details). The entity may still be loading, or the value is out of range." A body with more than "400: Bad Request" is still quoted. Still WARNING, still once a minute, still a failed write.
|
||||
9. **Witness** (admin/goodwe): the resolved push says "after no answers from … to …", as the resolved card does. The YAML with every title and message stripped is identical.
|
||||
|
||||
@@ -1848,8 +1848,8 @@ Both are watched. A guard that watched only the emitted value would be silent on
|
||||
every standard configuration.
|
||||
|
||||
**The integrator bound is not a bug, and it is reported as what it is.** It
|
||||
reads *"the correction the add-on builds up reached its +5000 W limit. The
|
||||
command this cycle was +2000 W"*, titled *"Battery is not keeping up"*, logged
|
||||
reads *"the correction the add-on builds up reached its +5000 W limit,
|
||||
command now +2000 W"*, titled *"Battery is not keeping up"*, logged
|
||||
as a `WARNING`, with an amber (not red) status row and its own notification
|
||||
(`goodwe_integrator_bound`), so it never replaces a software-fault notice still
|
||||
on screen. Brief engagements after a large load or solar step are normal. If it
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -2039,7 +2039,7 @@ check("defect 2: the integrator accumulator is not printed as a request",
|
||||
wind and all(f"{d.i_w:+.0f} W" not in r and "asked for" not in r
|
||||
for d, t, r in wind))
|
||||
check("the command quoted is that tick's Decision, not a later read",
|
||||
wind and all(f"command this cycle was {d.target_w:+.0f} W" in r
|
||||
wind and all(f"command now {d.target_w:+.0f} W" in r
|
||||
for d, t, r in wind))
|
||||
check("at the bound nothing is described as 'above' or 'over' the rating",
|
||||
wind and all(" above " not in r and " over " not in r
|
||||
@@ -2072,18 +2072,18 @@ for d, t, r in over[:2]:
|
||||
check("PRECONDITION: the output clamp engaged on a tick where the slew limit "
|
||||
"rewrote the command AND the integrator differs from the request",
|
||||
any(t != _f(d, 'clamped_w') and d.i_w != _f(d, 'requested_w') for d, t, r in over))
|
||||
check("the over-rating says it went over the rating",
|
||||
over and all(" rating, and cut it back to " in r for d, t, r in over))
|
||||
check("the over-rating says it was cut to the rating",
|
||||
over and all(" W rating, command now " in r for d, t, r in over))
|
||||
check("defect 2: it quotes the requested COMMAND of that tick, not i_w",
|
||||
over and all(f"computed {_f(d, 'requested_w'):+.0f} W" in r for d, t, r in over))
|
||||
check("defect 3: 'cut it back to' is the clamp's own result, not self.target",
|
||||
over and all(f"cut it back to {_f(d, 'clamped_w'):+.0f} W" in r
|
||||
check("defect 3: 'cut to' is the clamp's own result, not self.target",
|
||||
over and all(f"cut to the {_f(d, 'clamped_w'):+.0f} W rating" in r
|
||||
for d, t, r in over))
|
||||
check("...and its ERROR says it is a software fault and that control keeps "
|
||||
"running (UX-08 2d moved the 14 768 W rationale to a DEBUG line beside "
|
||||
"it, which is still in main.py)",
|
||||
any(x.levelno == logging.ERROR and "software fault" in x.getMessage()
|
||||
and "Control is still running" in x.getMessage() for x in log_o)
|
||||
and "control runs on" in x.getMessage() for x in log_o)
|
||||
and "reached 14 768 W on this project" in
|
||||
open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "app",
|
||||
"main.py"), encoding="utf-8").read())
|
||||
|
||||
@@ -862,7 +862,7 @@ DATA = {
|
||||
# hass.py: the _moan() wrapper and what its callers pass
|
||||
"hass.py:msg", "hass.py:*args", "hass.py:entity_id", "hass.py:err",
|
||||
"hass.py:obj", "hass.py:raw", "hass.py:domain", "hass.py:service",
|
||||
"hass.py:resp.status", "hass.py:body[:200]", "hass.py:what",
|
||||
"hass.py:resp.status", "hass.py:said", "hass.py:entity", "hass.py:what",
|
||||
# main.py
|
||||
"main.py:'cancelled' if task.cancelled() else repr(exc) if exc else 'returned'",
|
||||
"main.py:EXIT_CONTROL_DIED", "main.py:first.name", "main.py:OPTIONS_PATH",
|
||||
@@ -975,7 +975,9 @@ def _clamp_texts():
|
||||
i_w=4500.0)
|
||||
write = [(n, _clamp.clamp_to_rating(w, 5000.0, -2500.0, 2500.0))
|
||||
for n, w in (("inverter-a", 3000.0), ("inverter-b", -7412.0))]
|
||||
# One inverter is not named (main.py passes None), several are.
|
||||
return [_clamp.loop_clamp_note(over), _clamp.loop_clamp_note(at),
|
||||
_clamp.write_clamp_note([(None, write[0][1])]),
|
||||
_clamp.write_clamp_note(write[:1]), _clamp.write_clamp_note(write)]
|
||||
|
||||
|
||||
@@ -1098,6 +1100,20 @@ def reason_problems(text):
|
||||
return out + line_problems(text)
|
||||
|
||||
|
||||
def logged_shape(line):
|
||||
"""UX-10 review: the line as logged, wrapper and reason together, has the
|
||||
red-alarm shape too. The reason alone passing is not enough: the 2d-style
|
||||
"title: reason. tail. tail." line read as 4 sentences with a 3-sentence
|
||||
reason inside it."""
|
||||
out = []
|
||||
if sentences(line) > 3:
|
||||
out.append(f"over 3 sentences ({sentences(line)})")
|
||||
long = [x for x in re.split(r"(?<=[.!?])\s+", line) if len(words(x)) > 30]
|
||||
if long:
|
||||
out.append(f"a sentence over 30 words ({len(words(long[0]))})")
|
||||
return out
|
||||
|
||||
|
||||
def line_problems(text):
|
||||
"""What no WARNING+ line may carry, whatever its length."""
|
||||
out = []
|
||||
@@ -1145,7 +1161,7 @@ def scan_log_lines(files, text=None, data=None):
|
||||
lambda m: next(slots, m.group(0)), template)
|
||||
line = PLACEHOLDER.sub("X", line).replace("\0", sample)
|
||||
p += [f"as logged: {x}" for x in line_problems(line)
|
||||
if x not in p]
|
||||
+ logged_shape(line) if x not in p]
|
||||
if p:
|
||||
bad.append((key, sample, p))
|
||||
unknown = sorted(seen - set(data) - set(text))
|
||||
@@ -1174,6 +1190,36 @@ check(f"no WARNING+ line puts a plural noun after a bare count, which reads "
|
||||
f"'1 times' (use messages.times() and friends; found: {_counts})",
|
||||
not _counts)
|
||||
|
||||
# UX-10 review: the two clamp lines the owner quoted, as logged for one
|
||||
# inverter, fit the red shape the tight way: at most 3 sentences and 30 words
|
||||
# in all, code and numbers kept. The real literals from main.py, filled with
|
||||
# the real notes.
|
||||
def _clamp_line(opening):
|
||||
for n in ast.walk(dict(_FILES)["main.py"]):
|
||||
if isinstance(n, ast.Call) and LOG_CALL.search(ast.unparse(n.func)) \
|
||||
and n.args and (render(n.args[0]) or "").startswith(opening):
|
||||
return n.args[0].value
|
||||
return None
|
||||
|
||||
|
||||
_over, _at, _w1, _w1n, _w2 = _clamp_texts()
|
||||
_clamp_lines = {
|
||||
"error, loop": (_clamp_line("Power request too high"), _over),
|
||||
"error, write": (_clamp_line("Power request too high"), _w1),
|
||||
"warning, loop": (_clamp_line("The battery gives less than asked"), _at),
|
||||
}
|
||||
_clamp_shown = {k: (f % (r, messages.times(1)) if f else None)
|
||||
for k, (f, r) in _clamp_lines.items()}
|
||||
for k, v in _clamp_shown.items():
|
||||
print(f" {k}: {v!r} ({len(words(v or ''))} words)")
|
||||
check("the clamp lines for one inverter are at most 3 sentences and 30 words, "
|
||||
"and keep OUTPUT_CLAMP and the numbers",
|
||||
all(v and sentences(v) <= 3 and len(words(v)) <= 30
|
||||
and "OUTPUT_CLAMP" in v and "1 time so far" in v
|
||||
for v in _clamp_shown.values())
|
||||
and all(x in _clamp_shown["error, loop"]
|
||||
for x in ("+9000 W", "+5000 W", "+2000 W", "software fault")))
|
||||
|
||||
# Mutations: the scan catches what the 2d scan missed.
|
||||
_OLD_CLAMP = ("OUTPUT_CLAMP: the loop computed +2148 W, over the 2000 W rating, "
|
||||
"and it was clamped to +2000 W; the loop's command this cycle was "
|
||||
@@ -1197,6 +1243,13 @@ check("...and '1 times', '(s)', a semicolon and a doubled full stop in a "
|
||||
"Cut back to +2000 W.. Only this.",
|
||||
"index 0, 1, .... Those are left out.",
|
||||
"moved to one part: . Problems")))
|
||||
check("...and so is the 2d-style line, a 2-sentence reason inside a title and "
|
||||
"two tails, which reads as 4 sentences although the reason passes",
|
||||
logged_shape("Power request too high: OUTPUT_CLAMP: cut to +5000 W. The "
|
||||
"command was +1000 W. Control is still running. Cut back "
|
||||
"1 time so far.")
|
||||
and not reason_problems("OUTPUT_CLAMP: cut to +5000 W. The command was "
|
||||
"+1000 W"))
|
||||
check("...while '1 s' and '1 time' are fine",
|
||||
not line_problems("Held for 1 s, cut back 1 time so far."))
|
||||
# INFO lines are not held to the length rule, but the 2d test found "§8.3",
|
||||
@@ -1317,6 +1370,56 @@ check("...and the library's own text is kept, at DEBUG",
|
||||
and not any("8:1000" in m or "Bad Gateway" in m for m in _warn + _info))
|
||||
|
||||
|
||||
class _Resp400:
|
||||
def __init__(self, body):
|
||||
self.status, self.reason, self._body = 400, "Bad Request", body
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
async def text(self):
|
||||
return self._body
|
||||
|
||||
|
||||
class _Session400:
|
||||
def __init__(self, body):
|
||||
self.body = body
|
||||
|
||||
def post(self, *a, **k):
|
||||
return _Resp400(self.body)
|
||||
|
||||
|
||||
def _refused(body):
|
||||
ha = _hass.HomeAssistant(_Session400(body), token="t")
|
||||
ok = []
|
||||
recs = _captured("goodwe.hass", lambda: ok.append(asyncio.run(
|
||||
ha.call("number", "set_value", {"entity_id": "number.sp", "value": 1}))))
|
||||
return (ok[0] if ok else None), [r.getMessage() for r in recs
|
||||
if r.levelno == logging.WARNING]
|
||||
|
||||
|
||||
def _one(res):
|
||||
"""(write result, the one WARNING line) or a named absence that fails."""
|
||||
ok, lines = res
|
||||
return ok, (lines[0] if len(lines) == 1 else f"<{len(lines)} lines>")
|
||||
|
||||
|
||||
_bare_ok, _bare = _one(_refused("400: Bad Request"))
|
||||
_full_ok, _full = _one(_refused(
|
||||
'{"message": "Value 6000 for number.sp is outside valid range"}'))
|
||||
check(f"a write Home Assistant refuses while it loads is one plain WARNING, "
|
||||
f"still a failed write, without the empty '400: Bad Request' ({_bare!r})",
|
||||
_bare_ok is False
|
||||
and _bare.startswith("Home Assistant refused number.set_value for "
|
||||
"number.sp (error 400, no details).")
|
||||
and "Bad Request" not in _bare)
|
||||
check(f"...while a body that says more is still quoted ({_full!r})",
|
||||
_full_ok is False and "outside valid range" in _full)
|
||||
|
||||
|
||||
class _WS:
|
||||
def __init__(self, kind):
|
||||
self.kind = kind
|
||||
|
||||
Reference in New Issue
Block a user