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.
|
- "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.
|
- 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.
|
- "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.
|
every standard configuration.
|
||||||
|
|
||||||
**The integrator bound is not a bug, and it is reported as what it is.** It
|
**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
|
reads *"the correction the add-on builds up reached its +5000 W limit,
|
||||||
command this cycle was +2000 W"*, titled *"Battery is not keeping up"*, logged
|
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
|
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
|
(`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
|
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.
|
block the failsafe is worse than the hole it closes.
|
||||||
"""
|
"""
|
||||||
if tick is None or self.tick is None or self.tick != tick:
|
if tick is None or self.tick is None or self.tick != tick:
|
||||||
return (f"the allocation record on hand was made on tick "
|
return (f"the power split on hand is from tick {self.tick!r}, "
|
||||||
f"{self.tick!r} and this is tick {tick!r} - a record whose "
|
f"not this tick {tick!r}, and an old split cannot allow a "
|
||||||
f"envelopes have moved cannot authorise a write")
|
f"write")
|
||||||
if not self.ok:
|
if not self.ok:
|
||||||
return f"no power split was possible this tick ({self.explain()})"
|
return f"no power split was possible this tick ({self.explain()})"
|
||||||
if unit not in self.shares:
|
if unit not in self.shares:
|
||||||
|
|||||||
@@ -328,9 +328,10 @@ class CirculationWatchdog:
|
|||||||
# UX-10: plain sentences, every figure kept (test_messages.py part 6).
|
# UX-10: plain sentences, every figure kept (test_messages.py part 6).
|
||||||
self.reason = (
|
self.reason = (
|
||||||
f"CIRCULATING_POWER: {circ_w:.0f} W measured going round between "
|
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 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 the limits of {self.cfg.min_w:.0f} W and "
|
||||||
f"over {self.cfg.min_w:.0f} W, held over {self.cfg.hold_s:.0f} s")
|
f"{self.cfg.hold_s:.0f} s. The meter cannot see it, because it "
|
||||||
|
f"nets to zero there")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def held_s(self, now: datetime) -> float:
|
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
|
# 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
|
# 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
|
# 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:
|
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.
|
decision's own, i.e. from the tick that clamped.
|
||||||
"""
|
"""
|
||||||
if d.clamped:
|
if d.clamped:
|
||||||
return (f"{REASON_CODE}: the add-on computed {d.requested_w:+.0f} W, "
|
return (f"{REASON_CODE}: computed {d.requested_w:+.0f} W, cut to the "
|
||||||
f"over the {abs(d.clamped_w):.0f} W rating, and cut it back to "
|
f"{d.clamped_w:+.0f} W rating, command now {d.target_w:+.0f} W")
|
||||||
f"{d.clamped_w:+.0f} W. The command this cycle was "
|
|
||||||
f"{d.target_w:+.0f} W")
|
|
||||||
if d.i_clamped:
|
if d.i_clamped:
|
||||||
return (f"{REASON_CODE}: the correction the add-on builds up reached "
|
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")
|
f"{d.target_w:+.0f} W")
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
def write_clamp_note(named) -> str:
|
def write_clamp_note(named) -> str:
|
||||||
"""The write-side alarm text: `named` is `[(inverter name, ClampResult)]`
|
"""The write-side alarm text: `named` is `[(inverter name or None,
|
||||||
for every inverter whose write was cut back this cycle."""
|
ClampResult)]` for every inverter whose write was cut back this cycle.
|
||||||
return ". ".join(f"{name}: {r.describe()}" for name, r in named)
|
|
||||||
|
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)
|
@dataclass(frozen=True)
|
||||||
@@ -284,10 +292,12 @@ class ClampResult:
|
|||||||
bound: str = "" # "" | "max_w" | "device" - which stage bit
|
bound: str = "" # "" | "max_w" | "device" - which stage bit
|
||||||
|
|
||||||
def describe(self) -> str:
|
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 "
|
return (f"{REASON_CODE}: asked {self.requested_w:+.0f} W and sent "
|
||||||
f"{self.applied_w:+.0f} W, cut back by "
|
f"{self.applied_w:+.0f} W, "
|
||||||
f"{messages.LIMIT_WORDS.get(self.bound, self.bound)}, with a "
|
f"{messages.LIMIT_WORDS.get(self.bound, self.bound)}")
|
||||||
f"{self.limit_w:.0f} W rating")
|
|
||||||
|
|
||||||
|
|
||||||
def is_commandable(value) -> bool:
|
def is_commandable(value) -> bool:
|
||||||
|
|||||||
@@ -594,10 +594,12 @@ class Freshness:
|
|||||||
# ("what did the register do" against "can I trust this entity"). One
|
# ("what did the register do" against "can I trust this entity"). One
|
||||||
# helper serving both would have to drop whichever half the other does
|
# 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.
|
# 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
|
# two explanations stay, in this order, and so does the admission
|
||||||
# that this check cannot tell them apart. The reading is treated as
|
# 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
|
# ⚠️ SAFETY-57, AND THE THREE DURATIONS ARE THREE DIFFERENT FACTS, each
|
||||||
# in its own slot: the register's unchanged time, the WINDOW this
|
# in its own slot: the register's unchanged time, the WINDOW this
|
||||||
# verdict actually rests on after a release (`regraded`), and how many
|
# 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"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"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"{regraded}. The reading may have stopped, or the battery may be "
|
||||||
f"unable to follow the command, and this check cannot tell which. "
|
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")
|
|
||||||
|
|
||||||
def unchanged_s(self, now_mono) -> float | None:
|
def unchanged_s(self, now_mono) -> float | None:
|
||||||
"""Seconds since an arrival carried a different number. Status page.
|
"""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:
|
# value is rejected outright, not clamped. Always log it:
|
||||||
# silently dropped commands are how a controller ends up
|
# silently dropped commands are how a controller ends up
|
||||||
# believing something the hardware never did.
|
# 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}",
|
self._moan(f"svc:{domain}.{service}",
|
||||||
"service %s.%s rejected (%s): %s",
|
"Home Assistant refused %s.%s for %s (error %s, "
|
||||||
domain, service, resp.status, body[:200])
|
"%s). The entity may still be loading, or the "
|
||||||
|
"value is out of range.", domain, service,
|
||||||
|
entity, resp.status, said)
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
except aiohttp.ClientConnectionError as err:
|
except aiohttp.ClientConnectionError as err:
|
||||||
|
|||||||
@@ -612,8 +612,8 @@ class Controller:
|
|||||||
for inv in self.inverters:
|
for inv in self.inverters:
|
||||||
if inv.sign_wd.tripped:
|
if inv.sign_wd.tripped:
|
||||||
_LOG.error("%s stays at 0 W after the restart because its "
|
_LOG.error("%s stays at 0 W after the restart because its "
|
||||||
"power went the wrong way: %s. Start control once "
|
"power went the wrong way, so find out why before "
|
||||||
"you know why.", inv.name,
|
"you start control. %s.", inv.name,
|
||||||
inv.sign_wd.reason or "(reason not recorded)")
|
inv.sign_wd.reason or "(reason not recorded)")
|
||||||
|
|
||||||
# --- SAFETY-42 + SAFETY-34 -------------------------------------------
|
# --- SAFETY-42 + SAFETY-34 -------------------------------------------
|
||||||
@@ -635,9 +635,9 @@ class Controller:
|
|||||||
for inv in self.inverters:
|
for inv in self.inverters:
|
||||||
if inv.delivery_wd.tripped:
|
if inv.delivery_wd.tripped:
|
||||||
_LOG.error(
|
_LOG.error(
|
||||||
"%s is still marked as not delivering after the restart: "
|
"%s is still marked as not delivering after the restart "
|
||||||
"%s. Control keeps running, and only stopping and starting "
|
"while control keeps running, and only stopping and "
|
||||||
"control clears this.",
|
"starting control clears this. %s.",
|
||||||
inv.name, inv.delivery_wd.reason or "(reason not recorded)")
|
inv.name, inv.delivery_wd.reason or "(reason not recorded)")
|
||||||
|
|
||||||
# --- SAFETY-30: THE SITE HALF OF THE SIGN CHECK ----------------------
|
# --- SAFETY-30: THE SITE HALF OF THE SIGN CHECK ----------------------
|
||||||
@@ -667,9 +667,9 @@ class Controller:
|
|||||||
reason=str(store.data.get("site_sign_reason", "") or ""))
|
reason=str(store.data.get("site_sign_reason", "") or ""))
|
||||||
if self.site_sign_wd.tripped:
|
if self.site_sign_wd.tripped:
|
||||||
_LOG.error(
|
_LOG.error(
|
||||||
"Every inverter stays at 0 W after the restart because the "
|
"After the restart every inverter stays at 0 W, as the total "
|
||||||
"total power went the wrong way: %s. Start control once you "
|
"power went the wrong way, so find out why before starting "
|
||||||
"know why.",
|
"control. %s.",
|
||||||
self.site_sign_wd.reason or "(reason not recorded)")
|
self.site_sign_wd.reason or "(reason not recorded)")
|
||||||
# And the per-unit detectors are told which question they are asking,
|
# And the per-unit detectors are told which question they are asking,
|
||||||
# here as well as in `cycle()`. `checks()` and the status page read
|
# here as well as in `cycle()`. `checks()` and the status page read
|
||||||
@@ -720,9 +720,9 @@ class Controller:
|
|||||||
self._announced_mono = {}
|
self._announced_mono = {}
|
||||||
if self.circ_wd.tripped:
|
if self.circ_wd.tripped:
|
||||||
_LOG.error(
|
_LOG.error(
|
||||||
"Every inverter stays at 0 W after the restart because the "
|
"After the restart every inverter stays at 0 W, as the "
|
||||||
"inverters worked against each other: %s. Start control once "
|
"inverters worked against each other, so find out why before "
|
||||||
"you know why.",
|
"starting control. %s.",
|
||||||
self.circ_wd.reason or "(reason not recorded)")
|
self.circ_wd.reason or "(reason not recorded)")
|
||||||
|
|
||||||
# SAFETY-03. The output clamp's alarm - see clamp.py for why the clamp
|
# 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_reason", first.sign_wd.reason)
|
||||||
first.store_set("sign_tripped", True)
|
first.store_set("sign_tripped", True)
|
||||||
_LOG.error(
|
_LOG.error(
|
||||||
"%s is set to 0 W again, because an older saved safety stop was "
|
"%s is set to 0 W again because an older saved safety stop was "
|
||||||
"found: %s. Start control once you know why.",
|
"found, so find out why before you start control. %s.",
|
||||||
first.name, first.sign_wd.reason)
|
first.name, first.sign_wd.reason)
|
||||||
_LOG.debug(
|
_LOG.debug(
|
||||||
"SAFETY-02 latch guard: the pre-DEV-02 flat key says the "
|
"SAFETY-02 latch guard: the pre-DEV-02 flat key says the "
|
||||||
@@ -1953,11 +1953,18 @@ class Controller:
|
|||||||
# learns to skim it.
|
# learns to skim it.
|
||||||
# UX-08 2d: plain, with SAFETY-57's escape clause kept as an
|
# UX-08 2d: plain, with SAFETY-57's escape clause kept as an
|
||||||
# ordered pair (time, then power), and the reason still in `%s`.
|
# ordered pair (time, then power), and the reason still in `%s`.
|
||||||
_LOG.error("%s: battery power reading stopped changing. Inverters "
|
# UX-10: one sentence, then the reason's two. Its limits are on
|
||||||
"go to 0 W until it changes, or %.0f s after the "
|
# the DEBUG line below, to keep the line within 3 sentences.
|
||||||
"command drops below %.0f W: %s.",
|
_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.name, inv.batt_fresh.bound.release_s,
|
||||||
inv.batt_fresh.bound.move_w, inv.batt_fresh.reason)
|
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:
|
elif inv.batt_fresh.releases > was_releases:
|
||||||
# ⚠️ SAFETY-57. REPORTED, NEVER SILENT. A latch that lets go on a
|
# ⚠️ 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
|
# timer and says nothing is a latch no operator can audit - and this
|
||||||
@@ -2187,8 +2194,8 @@ class Controller:
|
|||||||
self.log_event(
|
self.log_event(
|
||||||
f"{inv.name}: WRITE REFUSED {value:+.0f} W - commanding 0 W",
|
f"{inv.name}: WRITE REFUSED {value:+.0f} W - commanding 0 W",
|
||||||
hint=messages.hint("write_blocked", unit=inv.name))
|
hint=messages.hint("write_blocked", unit=inv.name))
|
||||||
_LOG.error("Refused to send %+.0f W to %s (%s), so it is set to "
|
_LOG.error("Refused to send %+.0f W to %s, so it is set to 0 W "
|
||||||
"0 W instead. This is a software fault.",
|
"instead, which is a software fault. The reason: %s.",
|
||||||
value, inv.name, refusal)
|
value, inv.name, refusal)
|
||||||
value = 0.0
|
value = 0.0
|
||||||
|
|
||||||
@@ -2483,8 +2490,8 @@ class Controller:
|
|||||||
self.store.set("sign_tripped", True)
|
self.store.set("sign_tripped", True)
|
||||||
self.log_event(f"FAILSAFE {inv.name}: " + inv.sign_wd.reason,
|
self.log_event(f"FAILSAFE {inv.name}: " + inv.sign_wd.reason,
|
||||||
hint=messages.hint("direction", unit=inv.name))
|
hint=messages.hint("direction", unit=inv.name))
|
||||||
_LOG.error("%s is set to 0 W because its power went the wrong "
|
_LOG.error("%s is set to 0 W until control is started, "
|
||||||
"way: %s. Only starting control clears this.",
|
"because its power went the wrong way. %s.",
|
||||||
inv.name, inv.sign_wd.reason)
|
inv.name, inv.sign_wd.reason)
|
||||||
# ⚠️ THE SITE'S OWN STANDING COMMAND, WHICH IS THE SUM OF THE UNITS'.
|
# ⚠️ 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
|
# 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.store.set("site_sign_tripped", True)
|
||||||
self.log_event("FAILSAFE site: " + self.site_sign_wd.reason,
|
self.log_event("FAILSAFE site: " + self.site_sign_wd.reason,
|
||||||
hint=messages.hint("site_direction"))
|
hint=messages.hint("site_direction"))
|
||||||
_LOG.error("Every inverter is set to 0 W because the total "
|
_LOG.error("Every inverter is set to 0 W until control is "
|
||||||
"power went the wrong way: %s. Only starting "
|
"started, because the total power went the wrong "
|
||||||
"control clears this.", self.site_sign_wd.reason)
|
"way. %s.", self.site_sign_wd.reason)
|
||||||
|
|
||||||
# --- SAFETY-31, AND IT RUNS BESIDE SAFETY-02 FOR THE SAME REASON -----
|
# --- SAFETY-31, AND IT RUNS BESIDE SAFETY-02 FOR THE SAME REASON -----
|
||||||
# Evaluated EVERY cycle, unconditionally, above the "inputs missing but
|
# Evaluated EVERY cycle, unconditionally, above the "inputs missing but
|
||||||
@@ -2564,8 +2571,8 @@ class Controller:
|
|||||||
self.log_event("CIRCULATING POWER - " + self.circ_wd.reason,
|
self.log_event("CIRCULATING POWER - " + self.circ_wd.reason,
|
||||||
hint=messages.hint("circulating"))
|
hint=messages.hint("circulating"))
|
||||||
_LOG.error(
|
_LOG.error(
|
||||||
"Every inverter is set to 0 W because the inverters worked "
|
"Every inverter is set to 0 W until control is started, "
|
||||||
"against each other: %s. Only starting control clears this.",
|
"because the inverters worked against each other. %s.",
|
||||||
self.circ_wd.reason)
|
self.circ_wd.reason)
|
||||||
|
|
||||||
# --- SAFETY-42, AND IT RUNS HERE FOR SAFETY-02's AND SAFETY-31's 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",
|
hint=messages.hint("not_delivering",
|
||||||
unit=inv.name))
|
unit=inv.name))
|
||||||
_LOG.error(
|
_LOG.error(
|
||||||
"%s is not delivering: %s. Nothing was changed and control "
|
"%s is not delivering while control keeps running, and "
|
||||||
"keeps running, and only stopping and starting control "
|
"only stopping and starting control clears this. %s.",
|
||||||
"clears this.", inv.name, inv.delivery_wd.reason)
|
inv.name, inv.delivery_wd.reason)
|
||||||
_LOG.debug(
|
_LOG.debug(
|
||||||
"SAFETY-42 reports and does not act: commanding a unit "
|
"SAFETY-42 reports and does not act: commanding a unit "
|
||||||
"that is not listening achieves nothing (HW-01), and the "
|
"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
|
# other unit never gets. Loud, every cycle, and it does not change
|
||||||
# the resolution - the arbiter already routed by `actuator`.
|
# the resolution - the arbiter already routed by `actuator`.
|
||||||
_LOG.error("A limit was filed on the wrong inverter, which is a "
|
_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:
|
if resolution.contradiction:
|
||||||
self.log_event("ARBITER CONTRADICTION - commanding 0 W, see the log",
|
self.log_event("ARBITER CONTRADICTION - commanding 0 W, see the log",
|
||||||
hint=messages.hint("clash"))
|
hint=messages.hint("clash"))
|
||||||
@@ -3814,9 +3821,10 @@ class Controller:
|
|||||||
# SAFETY-40: only an over-rating is a bug; the integrator bound is not.
|
# SAFETY-40: only an over-rating is a bug; the integrator bound is not.
|
||||||
bug = engaged_write or loop_bug
|
bug = engaged_write or loop_bug
|
||||||
if engaged_write:
|
if engaged_write:
|
||||||
reason = write_clamp_note([(inv.name, inv.clamp_last)
|
|
||||||
for inv in clamped_units])
|
|
||||||
_named = len(self.inverters) > 1
|
_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,
|
facts = [messages.Clamp(inv.name if _named else None,
|
||||||
inv.clamp_last.requested_w,
|
inv.clamp_last.requested_w,
|
||||||
inv.clamp_last.applied_w,
|
inv.clamp_last.applied_w,
|
||||||
@@ -3872,8 +3880,8 @@ class Controller:
|
|||||||
messages.loop_bound().data())
|
messages.loop_bound().data())
|
||||||
return
|
return
|
||||||
_LOG.error(
|
_LOG.error(
|
||||||
"Power request too high, which is a software fault: %s. Control is "
|
"Power request too high, a software fault: %s. Cut back %s so "
|
||||||
"still running. Cut back %s so far.",
|
"far, control runs on.",
|
||||||
self.clamp_wd.reason, messages.times(self.clamp_wd.engagements))
|
self.clamp_wd.reason, messages.times(self.clamp_wd.engagements))
|
||||||
_LOG.debug(
|
_LOG.debug(
|
||||||
"SAFETY-03: silently clipping this is how a winding integrator "
|
"SAFETY-03: silently clipping this is how a winding integrator "
|
||||||
@@ -3922,9 +3930,8 @@ class Controller:
|
|||||||
self.log_event("PEAK_GUARD_SATURATED - " + g.reason,
|
self.log_event("PEAK_GUARD_SATURATED - " + g.reason,
|
||||||
hint=messages.hint("peak_saturated"))
|
hint=messages.hint("peak_saturated"))
|
||||||
_LOG.error(
|
_LOG.error(
|
||||||
"The battery is asked for all it can give, but this 15-minute "
|
"Switch off large loads, the battery alone cannot keep this "
|
||||||
"period still goes over the peak limit: %s. Switch off large "
|
"15-minute period under the peak limit. %s.", g.reason)
|
||||||
"loads.", g.reason)
|
|
||||||
# A fixed notification_id, and a DIFFERENT one from every other alarm
|
# A fixed notification_id, and a DIFFERENT one from every other alarm
|
||||||
# here - check_meter_frozen() gives the reasoning. A saturated peak guard
|
# here - check_meter_frozen() gives the reasoning. A saturated peak guard
|
||||||
# is not a frozen meter and is not an out-of-range setpoint.
|
# 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.
|
# reason carries the word in the middle of a sentence, not at the front.
|
||||||
self.log_event(g.reason, hint=messages.hint("peak_reserve"))
|
self.log_event(g.reason, hint=messages.hint("peak_reserve"))
|
||||||
_LOG.error(
|
_LOG.error(
|
||||||
"The peak guard may not draw the battery lower, so this 15-minute "
|
"Switch off large loads, the peak guard may not draw the battery "
|
||||||
"period will go over the peak limit: %s. Switch off large loads.",
|
"lower. %s.", g.reason)
|
||||||
g.reason)
|
|
||||||
# A fixed notification_id, and a DIFFERENT one from every other alarm
|
# A fixed notification_id, and a DIFFERENT one from every other alarm
|
||||||
# here - check_meter_frozen() gives the reasoning. A battery at its
|
# here - check_meter_frozen() gives the reasoning. A battery at its
|
||||||
# energy floor is not a saturated inverter and is not a frozen meter,
|
# energy floor is not a saturated inverter and is not a frozen meter,
|
||||||
@@ -4142,8 +4148,8 @@ class Controller:
|
|||||||
hint=messages.hint("meter_frozen"))
|
hint=messages.hint("meter_frozen"))
|
||||||
_LOG.error(
|
_LOG.error(
|
||||||
"The meter shows the same value while the battery is asked for "
|
"The meter shows the same value while the battery is asked for "
|
||||||
"full power: %s. Control still follows it, so check the meter.",
|
"full power, and control still follows it, so check the meter. "
|
||||||
self.freeze_wd.reason)
|
"%s.", self.freeze_wd.reason)
|
||||||
_LOG.debug(
|
_LOG.debug(
|
||||||
"SAFETY-18: nothing else stops this. The meter still answers, so "
|
"SAFETY-18: nothing else stops this. The meter still answers, so "
|
||||||
"its age is fresh, the firmware watchdog is fed and the arbiter "
|
"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)
|
and standing is not None and standing.from_feed)
|
||||||
if keep:
|
if keep:
|
||||||
_LOG.warning(
|
_LOG.warning(
|
||||||
"The price update was incomplete (%s), so the plan "
|
"The price update was incomplete, so the plan from %s "
|
||||||
"from %s is kept until it is %s old.",
|
"is kept until it is %s old. The update held %s.",
|
||||||
plan.words, messages.when(brussels(self.tariff_at)),
|
messages.when(brussels(self.tariff_at)),
|
||||||
messages.duration(TARIFF_MAX_AGE_S))
|
messages.duration(TARIFF_MAX_AGE_S), plan.words)
|
||||||
else:
|
else:
|
||||||
self.tariff_plan, self.tariff_at = plan, now
|
self.tariff_plan, self.tariff_at = plan, now
|
||||||
_LOG.info("tariff refreshed: %s", plan.words)
|
_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,
|
# 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
|
# 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.
|
# 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 "
|
FLOOR_SOC_UNREADABLE = ("the battery level was not read this cycle, and "
|
||||||
"unread level is not a full battery")
|
"unread is not full")
|
||||||
# ⚠️ SAFETY-25. "the peak reserve", not "the SoC floor", and the name is
|
# ⚠️ 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,
|
# 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
|
# 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 else f", at {float(soc):.0f} %")
|
||||||
+ ("" if soc is None or soc_floor is None
|
+ ("" if soc is None or soc_floor is None
|
||||||
else f" against {float(soc_floor):.0f} %")
|
else f" against {float(soc_floor):.0f} %")
|
||||||
+ ". So there is no forced discharge and no charging, floor "
|
+ ", so no forced discharge and no charging, and this period "
|
||||||
"0 W, and this period will go over the limit"))
|
"will go over the limit"))
|
||||||
|
|
||||||
floor = min(wanted, ceil_top)
|
floor = min(wanted, ceil_top)
|
||||||
saturated = floor < wanted
|
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"{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"come off the meter. The battery measured {float(batt_w):+.0f} W "
|
||||||
f"and is asked for {floor:+.0f} W"
|
f"and is asked for {floor:+.0f} W"
|
||||||
+ (f". {wanted:+.0f} W was needed, but the inverters give at most "
|
# Saturated means floor == ceil_top: the ask IS the most the
|
||||||
f"{ceil_top:.0f} W" if saturated else "")))
|
# 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:
|
def charge_headroom_w(decision) -> float | None:
|
||||||
|
|||||||
@@ -591,9 +591,9 @@ def _walk(series, hours, action) -> Gate:
|
|||||||
return Gate.block(
|
return Gate.block(
|
||||||
f"{action.name} needs prices for {action.commits_h:g} hours "
|
f"{action.name} needs prices for {action.commits_h:g} hours "
|
||||||
f"from {messages.when(brussels(hours[0]))}, and the price for "
|
f"from {messages.when(brussels(hours[0]))}, and the price for "
|
||||||
f"{messages.when(brussels(u))} is only estimated, from "
|
f"{messages.when(brussels(u))} is only estimated. It can start "
|
||||||
f"yesterday or from the fixed tariff. It can start once the "
|
f"once the day-ahead prices are published, around 14:00 to "
|
||||||
f"day-ahead prices are published, around 14:00 to 16:00")
|
f"16:00")
|
||||||
pts.append(p)
|
pts.append(p)
|
||||||
return Gate.permit(action, pts)
|
return Gate.permit(action, pts)
|
||||||
|
|
||||||
|
|||||||
@@ -1358,12 +1358,12 @@ def _degrade(opts, read, since, until, stamp) -> TariffPlan:
|
|||||||
"no tariff at all.", e)
|
"no tariff at all.", e)
|
||||||
profile = None
|
profile = None
|
||||||
if profile is None:
|
if profile is None:
|
||||||
_LOG.warning("The price feed failed: %s. No fixed tariff is set to "
|
_LOG.warning("The price feed failed and no fixed tariff is set, so "
|
||||||
"fall back to, so trading is off. Peak limiting and home "
|
"trading is off, while peak limiting and home use go on. "
|
||||||
"use go on.", read.reason)
|
"%s.", read.reason)
|
||||||
return TariffPlan(read, None, "none")
|
return TariffPlan(read, None, "none")
|
||||||
_LOG.warning("The price feed failed: %s. The fixed tariff is used "
|
_LOG.warning("The price feed failed, so the fixed tariff is used "
|
||||||
"instead, marked as estimated, and trading is off.",
|
"instead, marked as estimated, and trading is off. %s.",
|
||||||
read.reason)
|
read.reason)
|
||||||
points = profile.series(since, until, provisional=True)
|
points = profile.series(since, until, provisional=True)
|
||||||
return TariffPlan(
|
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
|
wind and all(f"{d.i_w:+.0f} W" not in r and "asked for" not in r
|
||||||
for d, t, r in wind))
|
for d, t, r in wind))
|
||||||
check("the command quoted is that tick's Decision, not a later read",
|
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))
|
for d, t, r in wind))
|
||||||
check("at the bound nothing is described as 'above' or 'over' the rating",
|
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
|
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 "
|
check("PRECONDITION: the output clamp engaged on a tick where the slew limit "
|
||||||
"rewrote the command AND the integrator differs from the request",
|
"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))
|
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",
|
check("the over-rating says it was cut to the rating",
|
||||||
over and all(" rating, and cut it back to " in r for d, t, r in over))
|
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",
|
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))
|
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",
|
check("defect 3: 'cut 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
|
over and all(f"cut to the {_f(d, 'clamped_w'):+.0f} W rating" in r
|
||||||
for d, t, r in over))
|
for d, t, r in over))
|
||||||
check("...and its ERROR says it is a software fault and that control keeps "
|
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 "
|
"running (UX-08 2d moved the 14 768 W rationale to a DEBUG line beside "
|
||||||
"it, which is still in main.py)",
|
"it, which is still in main.py)",
|
||||||
any(x.levelno == logging.ERROR and "software fault" in x.getMessage()
|
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
|
and "reached 14 768 W on this project" in
|
||||||
open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "app",
|
open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "app",
|
||||||
"main.py"), encoding="utf-8").read())
|
"main.py"), encoding="utf-8").read())
|
||||||
|
|||||||
@@ -862,7 +862,7 @@ DATA = {
|
|||||||
# hass.py: the _moan() wrapper and what its callers pass
|
# 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: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: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
|
||||||
"main.py:'cancelled' if task.cancelled() else repr(exc) if exc else 'returned'",
|
"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",
|
"main.py:EXIT_CONTROL_DIED", "main.py:first.name", "main.py:OPTIONS_PATH",
|
||||||
@@ -975,7 +975,9 @@ def _clamp_texts():
|
|||||||
i_w=4500.0)
|
i_w=4500.0)
|
||||||
write = [(n, _clamp.clamp_to_rating(w, 5000.0, -2500.0, 2500.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))]
|
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),
|
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)]
|
_clamp.write_clamp_note(write[:1]), _clamp.write_clamp_note(write)]
|
||||||
|
|
||||||
|
|
||||||
@@ -1098,6 +1100,20 @@ def reason_problems(text):
|
|||||||
return out + line_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):
|
def line_problems(text):
|
||||||
"""What no WARNING+ line may carry, whatever its length."""
|
"""What no WARNING+ line may carry, whatever its length."""
|
||||||
out = []
|
out = []
|
||||||
@@ -1145,7 +1161,7 @@ def scan_log_lines(files, text=None, data=None):
|
|||||||
lambda m: next(slots, m.group(0)), template)
|
lambda m: next(slots, m.group(0)), template)
|
||||||
line = PLACEHOLDER.sub("X", line).replace("\0", sample)
|
line = PLACEHOLDER.sub("X", line).replace("\0", sample)
|
||||||
p += [f"as logged: {x}" for x in line_problems(line)
|
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:
|
if p:
|
||||||
bad.append((key, sample, p))
|
bad.append((key, sample, p))
|
||||||
unknown = sorted(seen - set(data) - set(text))
|
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})",
|
f"'1 times' (use messages.times() and friends; found: {_counts})",
|
||||||
not _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.
|
# Mutations: the scan catches what the 2d scan missed.
|
||||||
_OLD_CLAMP = ("OUTPUT_CLAMP: the loop computed +2148 W, over the 2000 W rating, "
|
_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 "
|
"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.",
|
"Cut back to +2000 W.. Only this.",
|
||||||
"index 0, 1, .... Those are left out.",
|
"index 0, 1, .... Those are left out.",
|
||||||
"moved to one part: . Problems")))
|
"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",
|
check("...while '1 s' and '1 time' are fine",
|
||||||
not line_problems("Held for 1 s, cut back 1 time so far."))
|
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",
|
# 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))
|
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:
|
class _WS:
|
||||||
def __init__(self, kind):
|
def __init__(self, kind):
|
||||||
self.kind = kind
|
self.kind = kind
|
||||||
|
|||||||
Reference in New Issue
Block a user