SAFETY-37: a stale reading is None, and arrival cannot be what says so
checks / unit (pull_request) Successful in 5m34s
checks / unit (pull_request) Successful in 5m34s
The architect pass made SAFETY-37, TEL-06 and TEL-08 one systemic problem - "a stale reading holds its last value and reads healthy" - and this builds the single policy. Both consumers the card names are gated by one change, and neither consumer needed a line: `SignWatchdog.evaluate` already CLEARS its window on a None measurement and `circulating_w` already returns None for a roster with a missing term. Neither could ever receive one. The gate is the piece that was missing BETWEEN the shipped read and the shipped checks. ⚠️ ONE CRITERION IN THE CARD COULD NOT BE IMPLEMENTED AS WRITTEN, AND SAYING SO IS THE MAIN CONTENT. The card says "only arrival separates them". At this layer arrival cannot: `read_inputs()` polls Home Assistant's state cache about once a second and HA answers 200 with a number whether or not the publisher behind the entity is alive, so both members of the pair arrive identically for ever. test_freshness.py part 1a BUILDS the arrival-age gate the card asks for and grades it failing - the age is 0.0 s at every instant of both runs, a threshold that can never fire, which is TEL-06's own defect one layer up. The half of the rule that survives is honoured absolutely: nothing here reads a Home Assistant timestamp, and the check asserts the module contains no `last_updated`. So the criterion became: a reading is stale when it has not moved through a change it was OBLIGED to follow. STALE <=> unchanged >= 35 s AND |standing| >= 250 W AND |standing - standing when the reading last changed| >= 250 W Latched until a different value arrives. `Inverter.batt` becomes None - never a held number - which is a missing input, which §11.1 rule 1 answers with 0 W. WHY A CONJUNCTION AND NOT A THRESHOLD, MEASURED 2026-09-06. Replaying the committed 30-day capture of this house's own 1 W power entity (485,841 samples out of HA's recorder, where a row exists only on a CHANGE, so the gap between rows IS the unchanged duration every consumer saw): the longest legitimate unchanged run is 263.2 s, held at -899.0 W. A false-positive-free threshold would have to exceed that, and 263 s is 3.5x too slow for a consumer that trips at 75 s. There is no admissible threshold. Every one of those long runs is under an UNMOVING load, which is what the travel term keys on. THE BOUND, derived at both ends and graded against the shipped config.yaml. Floor 17.0 s = 6.0 plant settle (NOTES §5.9, measured 2026-08-22) + 10.0 ESPHome publish (firmware/goodwe-master.yaml:181) + 1.0 read period (run_control's asyncio.sleep(1)). Ceiling 75.0 s = min(circulation_hold_s 75, unit_sign_disagree_s 85). Band [34.0, 37.5]; 35 is a CHOICE inside a DERIVED band and the check says so - the first draft claimed the band held one integer and went red on its own first run. MOVE_W is the LARGEST admissible value, unit_sign_disagree_w, because below it the consumer cannot speak at all. SoC is STATED, NOT WIRED, and BOUNDS carries it with wired=False and the reason. Its obligation term is energy through the pack, not command travel - a rested pack holds a percent for hours under a 0 W command - and K needs a usable capacity nobody has measured. That is TEL-08's; the record says so. ⚠️ BEHAVIOUR CHANGES AT THE SHIPPED ROSTER OF ONE, and it is named rather than hidden: a FROZEN register now stops the site where it used to read as a healthy measurement for ever. That is HW-01, which was a one-inverter site. A healthy install is unchanged, asserted over sixty reads of a dithering register under a reversing command. A false verdict fails toward 0 W behind the existing stale_input_s hold and clears itself on the register's next move. ⚠️ BOTH UNITS IN EVERY TWO-INVERTER FIXTURE ARE INVERTER A with different numbers passed in; inverter B does not physically exist (~2026-09-12). MUTATION: 16 mutations against a purged, :ro, python3 -B tree, fingerprinted before and after every revert. One survivor, and it is the finding - dropping the magnitude term left every check green because no fixture separated "the command travelled 250 W" from "the command ended up above 250 W". Closed, and recorded as MUTATION.md habit 17. A second process defect is habit 18: a mutation run that timed out between the edit and the revert left the module mutated on disk and nothing announced it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
This commit is contained in:
co-authored by
Claude Opus 5
parent
a4290d39ac
commit
6864109a01
@@ -45,6 +45,7 @@ from .circulation import (
|
||||
from .clamp import (
|
||||
REASON_CODE as CLAMP_REASON, ClampWatchdog, clamp_to_rating, is_commandable,
|
||||
)
|
||||
from . import freshness
|
||||
from .freeze import (
|
||||
FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_unreachable, rail_w,
|
||||
)
|
||||
@@ -1109,8 +1110,18 @@ class Controller:
|
||||
for inv in self.inverters)
|
||||
|
||||
# -- io ------------------------------------------------------------------
|
||||
async def read_inputs(self) -> None:
|
||||
async def read_inputs(self, now_mono: float | None = None) -> None:
|
||||
"""Every input this cycle needs, and SAFETY-37's freshness verdict.
|
||||
|
||||
`now_mono` the monotonic clock the per-unit freshness records are
|
||||
stamped on. Defaulted rather than read inside `_read_unit()`
|
||||
so that ONE instant covers every unit in a gather - two
|
||||
units stamped from two clock reads would carry a freshness
|
||||
skew nobody could reproduce - and so that a check can drive
|
||||
real seconds through the gate without sleeping through them.
|
||||
"""
|
||||
o = self.o
|
||||
now_mono = freshness.monotonic() if now_mono is None else float(now_mono)
|
||||
if self.p1_enabled:
|
||||
# ⚠️ P1 is the only authoritative measurement of what the utility
|
||||
# sees (§5.1). When it is stale this is None, which falls into the
|
||||
@@ -1130,7 +1141,8 @@ class Controller:
|
||||
# names for the WRITE path, one function up. Within a unit the order is
|
||||
# unchanged (SoC, then battery power), so at the shipped roster of one
|
||||
# this is the same two calls in the same order.
|
||||
await asyncio.gather(*(self._read_unit(inv) for inv in self.inverters))
|
||||
await asyncio.gather(*(self._read_unit(inv, now_mono)
|
||||
for inv in self.inverters))
|
||||
if o.get("peak_forecast_entity"):
|
||||
raw = await self.hass.number(o["peak_forecast_entity"])
|
||||
# Accept kW or W - nobody's quarter-hour forecast is 50 W.
|
||||
@@ -1176,10 +1188,41 @@ class Controller:
|
||||
elif self.inputs_bad_since is None:
|
||||
self.inputs_bad_since = datetime.now(timezone.utc)
|
||||
|
||||
async def _read_unit(self, inv) -> None:
|
||||
"""One inverter's own SoC and battery power, from ITS OWN entities."""
|
||||
async def _read_unit(self, inv, now_mono: float) -> None:
|
||||
"""One inverter's own SoC and battery power, from ITS OWN entities.
|
||||
|
||||
⚠️ SAFETY-37: THE POWER READING GOES THROUGH THE FRESHNESS GATE AND THE
|
||||
SoC READING DOES NOT, AND THE ASYMMETRY IS DELIBERATE. `batt` feeds the
|
||||
two consumers this card is about - SAFETY-30's per-unit sign check and
|
||||
SAFETY-31's circulating-power sensor - and its obligation term is
|
||||
command travel, which this method already has. SoC's obligation term is
|
||||
ENERGY through the pack and the constant it needs has never been
|
||||
measured on this site; wiring it here with a guessed constant would be a
|
||||
gate that fires on a rested pack, which is TEL-08's whole hazard.
|
||||
`freshness.BOUNDS["soc"]` states the bound and what TEL-08 owes.
|
||||
|
||||
⚠️ THE STANDING COMMAND IS ASKED OF THE UNIT, NOT RECOMPUTED. `cycle()`
|
||||
grades the sign watchdog against `inv.standing_w()` and this gate arms
|
||||
off the same method, because the gate exists to protect that check and a
|
||||
second spelling of "what is standing" would let the two disagree about
|
||||
the number they are both reasoning about.
|
||||
"""
|
||||
inv.soc = await self.hass.number(inv.soc_entity)
|
||||
inv.batt = await self.hass.number(inv.batt_entity, inv.batt_invert)
|
||||
raw = await self.hass.number(inv.batt_entity, inv.batt_invert)
|
||||
was_stale = inv.batt_fresh.stale
|
||||
inv.batt_fresh.submit(raw, inv.standing_w(), now_mono)
|
||||
# ⚠️ `inv.batt` IS THE GATED VALUE. A stale register is a MISSING input,
|
||||
# not a measurement - `inputs_ok` sees the None, the site holds for
|
||||
# `stale_input_s` and then commands 0 W and keeps commanding, which is
|
||||
# §11.1 rule 1 and is already what a missing reading does here.
|
||||
inv.batt = inv.batt_fresh.reading()
|
||||
if inv.batt_fresh.stale and not was_stale:
|
||||
self.log_event(f"STALE {inv.name}: " + inv.batt_fresh.reason)
|
||||
_LOG.error("SAFETY-37: %s battery-power register is not following "
|
||||
"its own command - %s. Treating it as MISSING, which "
|
||||
"commands 0 W. It clears itself the moment the register "
|
||||
"reports a different value.",
|
||||
inv.name, inv.batt_fresh.reason)
|
||||
|
||||
def write_refusal(self, value: float, inv=None) -> str | None:
|
||||
"""Why `value` may NOT be written to `inv` right now, or None if it may.
|
||||
@@ -1469,9 +1512,20 @@ class Controller:
|
||||
return tuple(live)
|
||||
|
||||
def liveness_notes(self) -> list:
|
||||
"""Why each unit is or is not commandable, for the status page."""
|
||||
"""Why each unit is or is not commandable, for the status page.
|
||||
|
||||
⚠️ SAFETY-37's STALE-READING LINE RIDES HERE TOO, AND IT IS A DIFFERENT
|
||||
FAULT WITH A DIFFERENT REMEDY. A unit that is not commandable is dropped
|
||||
and its share is carried by the survivors; a unit whose power register
|
||||
has stopped following its command stops the SITE. They share this list
|
||||
because they share one place on the status page - the operator asking
|
||||
"why is nothing happening" needs both - and nothing downstream reads it.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
return [n for n in (inv.link_note(now) for inv in self.inverters) if n]
|
||||
return [n for n in
|
||||
[x for inv in self.inverters
|
||||
for x in (inv.link_note(now), inv.stale_note())]
|
||||
if n]
|
||||
|
||||
async def cycle(self) -> None:
|
||||
"""Gather claims, resolve precedence, write the winner.
|
||||
@@ -1560,7 +1614,10 @@ class Controller:
|
||||
standing = {}
|
||||
for inv in self.inverters:
|
||||
was_tripped = inv.sign_wd.tripped
|
||||
stood = inv.last_write if inv.last_write is not None else inv.target
|
||||
# ⚠️ `Inverter.standing_w()`, NOT THE EXPRESSION IT REPLACES.
|
||||
# SAFETY-37's freshness gate arms off the same method, and this used
|
||||
# to be the only spelling of it. See that method's docstring.
|
||||
stood = inv.standing_w()
|
||||
standing[inv.name] = stood
|
||||
against = self.grid if n_units <= 1 else inv.batt
|
||||
if inv.sign_wd.evaluate(stood, against, self.following, now) \
|
||||
|
||||
Reference in New Issue
Block a user