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
@@ -0,0 +1,376 @@
|
||||
"""SAFETY-37 - a reading that holds its last value and reads healthy.
|
||||
|
||||
THE DEFECT, in one sentence: `hass.number()` returns a held value as a value,
|
||||
and nothing anywhere in this add-on distinguishes "the inverter reports 40 %"
|
||||
from "the entity still says 40 % because nothing has updated it since Tuesday".
|
||||
|
||||
The architect pass of 2026-09-06 put three cards on one mechanism. This module
|
||||
is that mechanism; the other two are its consumers.
|
||||
|
||||
LAYER 1 `goodwe/master/goodwe-master-stage4-failsafe.yaml:690` - the
|
||||
(TEL-06) ingest-age veto tests the VALUE of `sensor.goodwe_p1_sample_age`.
|
||||
A dead publisher leaves the entity frozen at a healthy number and
|
||||
the veto can never fire. NOT FIXED HERE - it is firmware, it is on
|
||||
the other side of a flash, and nothing in this module can reach
|
||||
it. What it owes is written at the bottom of this docstring.
|
||||
THIS ADD-ON `app/hass.py:69-82`, consumed by SAFETY-30's per-unit sign check
|
||||
(SAFETY-37) and SAFETY-31's circulating-power sensor. Both read `Inverter.batt`
|
||||
(register 0x0518) and NEITHER had a freshness gate of any kind.
|
||||
THE EVIDENCE HW-01, 2026-09-02: SoC held 40 % for 6 h 25 m while the pack
|
||||
drained to its floor. Every detector read healthy throughout.
|
||||
|
||||
⚠️ AT N>=2 EVERY ONE OF THOSE READINGS DOUBLES, which is why this card is the
|
||||
one that had to build it: the per-unit sign check and the circulation sensor
|
||||
only exist at N>=2, and both are fed straight from `Inverter.batt`.
|
||||
|
||||
|
||||
WHAT CANNOT BE THE SOURCE OF TRUTH, AND IT IS NOT A DETAIL
|
||||
==========================================================
|
||||
Home Assistant's `last_updated` DOES NOT MOVE when a value is rewritten
|
||||
unchanged. Neither does `last_changed`, and this project measured that
|
||||
`last_reported` does not advance on a repeat on either serialiser either - see
|
||||
`p1.HomeWizardLocalSource`'s docstring, which records the ENV-01 rig measurement
|
||||
over 70 s of a frozen meter and an independent one against the live house.
|
||||
|
||||
So a value-side timestamp out of Home Assistant is a "time since the number
|
||||
changed" sensor wearing an arrival sensor's name. Thresholding it is how a
|
||||
detector ends up unable to fire, which is TEL-06's defect exactly. That trap
|
||||
produced two false diagnoses on 2026-09-05, both by a reader who already knew
|
||||
about it, which is why it is written here in full rather than referenced.
|
||||
|
||||
⚠️ AND THE CONVERSE TRAP IS THE ONE THIS MODULE HAD TO SOLVE. The add-on polls
|
||||
Home Assistant's state cache roughly once a second (`run_control()`'s
|
||||
`await asyncio.sleep(1)` bounds the loop body and `read_inputs()` runs every
|
||||
pass). While the entity exists, HA answers 200 with a number whether or not the
|
||||
publisher behind it is alive. So AN ARRIVAL AT THE ADD-ON IS NOT AN ARRIVAL FROM
|
||||
THE INVERTER, and an arrival age alone cannot see HW-01 at all.
|
||||
|
||||
What IS arrival-stamped here, and what makes this module honest, is the clock
|
||||
the verdict is measured on: `changed_mono` is the MONOTONIC INSTANT OF THE
|
||||
ARRIVAL THAT LAST CARRIED A DIFFERENT NUMBER. Every term below is a fact about
|
||||
arrivals this process observed, on its own clock. Nothing is read out of a Home
|
||||
Assistant timestamp, and nothing would change if Home Assistant stopped
|
||||
publishing timestamps altogether.
|
||||
|
||||
|
||||
THE VERDICT, AND WHY IT IS A CONJUNCTION
|
||||
========================================
|
||||
A bare "unchanged for N seconds" threshold is not available, and that is
|
||||
MEASURED rather than feared. Over the committed 30-day capture of this house's
|
||||
own 1 W-resolution power entity - `scenarios/p1-net-30d-2026-08/`, 485,841
|
||||
samples straight out of Home Assistant's recorder, where a row exists only when
|
||||
the state string CHANGED, so the gap between two rows IS the unchanged duration
|
||||
every consumer saw:
|
||||
|
||||
longest unchanged run, 30 days 263.2 s (held -899.0 W, 2026-08-11 17:04)
|
||||
runs >= 35 s 10 of 485,840 (0.0021 %)
|
||||
runs >= 17 s 386 of 485,840 (0.0794 %)
|
||||
|
||||
Measured on 2026-09-06 by replaying that capture. A real power figure at 1 W
|
||||
resolution held ONE value for four and a half minutes with nothing whatever
|
||||
wrong. ⚠️ That is the METER's register, not 0x0518, and it is named as a
|
||||
surrogate rather than passed off as the thing: no 30-day capture of 0x0518
|
||||
exists, because that register is not in the recorder export. What it does
|
||||
establish - and this is all it is used for - is that the LEGITIMATE unchanged
|
||||
duration of a power entity in this house is minutes, so an unchanged threshold
|
||||
alone must be minutes, and minutes is three times too slow to protect consumers
|
||||
that trip at 75 s and 85 s. The conjunction is not a refinement; without it
|
||||
there is no admissible threshold at all.
|
||||
|
||||
⚠️ AND EVERY ONE OF THOSE LONG RUNS HAPPENED UNDER AN UNMOVING LOAD. That is
|
||||
the way out, and it is the same shape `freeze.looks_frozen()` already uses for
|
||||
the meter (unchanged AND the command pinned at the rail). A reading is stale
|
||||
when it has not moved THROUGH A CHANGE IT WAS OBLIGED TO FOLLOW:
|
||||
|
||||
STALE <=> unchanged for >= UNCHANGED_S
|
||||
AND |standing command now| >= MOVE_W
|
||||
AND |standing command now - standing command when the reading
|
||||
last changed| >= MOVE_W
|
||||
|
||||
0x0518 is the inverter reporting ITSELF and it tracks its own command: r = 0.998
|
||||
with battery power against r = 0.09 with the real meter (goodwe/NOTES.md §3.3),
|
||||
command and actual agreeing within 4 W once settled (§5.10, 2026-08-22), and the
|
||||
register dithering +/-1 W while holding (§5.11, same day). A unit told to move
|
||||
250 W and still answering the same integer watt cannot be tracking.
|
||||
|
||||
|
||||
THE BOUNDS - ONE PER SIGNAL, EACH WITH ITS DERIVATION
|
||||
=====================================================
|
||||
⚠️ ONE CONSTANT FOR BOTH SIGNALS WOULD BE USELESS ON SoC OR TRIGGER-HAPPY ON
|
||||
POWER, which is the third clause of the policy. They are separated here, and
|
||||
only one of them is WIRED - see `BOUNDS` below and the note on SoC.
|
||||
|
||||
`batt` / 0x0518, S16, 1 W, published every 10 s
|
||||
----------------------------------------------
|
||||
LOWER BOUND, 17.0 s, the fastest a healthy register can be OBLIGED to have
|
||||
answered. Three terms, each read out of this repository on 2026-09-06:
|
||||
|
||||
6.0 s the plant. goodwe/NOTES.md §5.9, measured 2026-08-22 with the poll
|
||||
temporarily at 1 s and a 0 -> +500 W step: 26 W at +1.43 s, 469 W
|
||||
(94 %) at +3.34 s, settled 507 W at +6.05 s.
|
||||
10.0 s the publisher. `firmware/goodwe-master.yaml:181`,
|
||||
`update_interval: 10s` on the `modbus_controller` that carries both
|
||||
0x0518 and 0x050E. A command that moves right after a poll is not
|
||||
visible until the next one.
|
||||
1.0 s this add-on's own read period - `run_control()`'s
|
||||
`await asyncio.sleep(1)`, which bounds the loop body that calls
|
||||
`read_inputs()`.
|
||||
|
||||
UPPER BOUND, 75.0 s, the tighter of the two consumers this card must protect,
|
||||
both read from `config.yaml` on 2026-09-06:
|
||||
|
||||
75 s `circulation_hold_s` (config.yaml:512) - SAFETY-31 trips here.
|
||||
85 s `unit_sign_disagree_s` (config.yaml:451) - SAFETY-30's per-unit sign
|
||||
check trips here.
|
||||
|
||||
A gate slower than either is a gate that hands its consumer the frozen number
|
||||
and then explains afterwards. ⚠️ AND THAT INEQUALITY IS GRADED, NOT ASSERTED -
|
||||
`test_freshness.py` part 6 reads BOTH ends out of the shipped `config.yaml`
|
||||
and goes red if an operator's default moves under this bound.
|
||||
|
||||
35.0 s IS THE CHOSEN VALUE, and the admissible band is narrow enough that it
|
||||
is nearly forced: at least 2x the 17.0 s floor (34.0) and at most half the
|
||||
75.0 s consumer window (37.5) leaves [34.0, 37.5]. 35 is the integer in it.
|
||||
⚠️ DELIBERATELY NOT 30 AND NOT 60. 30 is `validate.FIRMWARE_INGEST_VETO_S` and
|
||||
would read as a borrowed constant nobody re-derived; 60 is round, and a round
|
||||
number here would be a guess wearing a measurement's clothes.
|
||||
|
||||
WHAT IT COSTS, stated rather than absorbed: a frozen 0x0518 on a unit that is
|
||||
never asked to move 250 W is not detected by this module at all. That unit is
|
||||
also one no per-unit sign check can speak about (`unit_sign_disagree_w` is
|
||||
250 W) and one contributing at most 250 W to a circulating loop whose floor is
|
||||
700 W. The gate is scoped to where the consumers can be misled, on purpose.
|
||||
|
||||
`soc` / 0x050E, U16, 1 %, published every 10 s - STATED, NOT WIRED
|
||||
-----------------------------------------------------------------
|
||||
⚠️ THIS ROW IS THE POLICY'S ANSWER FOR TEL-08 AND IT IS NOT CONNECTED TO
|
||||
ANYTHING. It is here because the third clause of the policy is "state each
|
||||
bound and its derivation", and because a reader who finds only `batt` wired
|
||||
would reasonably conclude SoC was forgotten. It was not; it needs a
|
||||
corroborator this card cannot derive.
|
||||
|
||||
SoC CANNOT USE THE CONJUNCTION ABOVE. A pack resting at its floor holds one
|
||||
percent for hours with nothing wrong - the card's own example - and the
|
||||
standing command says nothing about it, because a command of 0 W is exactly
|
||||
what a rested pack is given. The physically obliged movement for SoC is
|
||||
ENERGY, not command travel: one percent of pack A is about 153.6 Wh (15.36 kWh
|
||||
nominal, goodwe/NOTES.md §1.1b's table - ⚠️ NOMINAL and FROM DATASHEET, not a
|
||||
measured usable capacity), so at pack A's 5000 W ceiling one percent takes at
|
||||
least 110 s and at a realistic 1000 W about 553 s. The bound TEL-08 needs is
|
||||
therefore "SoC unchanged while the integral of |battery power| since its last
|
||||
change exceeded K x 153.6 Wh", and K cannot be chosen from anything written
|
||||
down today: nobody has measured this pack's usable capacity, its BMS reporting
|
||||
granularity, or how far its reported percent lags true state of charge.
|
||||
|
||||
So TEL-08 owes: that capacity measurement (or a defensible bound on it), the
|
||||
integrator, and K. It does NOT owe a second freshness mechanism - `Freshness`
|
||||
below takes its obligation term from the caller precisely so that an energy
|
||||
integral can be handed in where command travel is handed in here.
|
||||
"""
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
# --- the three terms of the lower bound, each read out of this repository ----
|
||||
# See the module docstring for where each one is measured. They are separate
|
||||
# names rather than a literal 17.0 so that a reader who changes the firmware's
|
||||
# `update_interval` can find this file by grepping for the number they moved.
|
||||
PLANT_SETTLE_S = 6.0 # goodwe/NOTES.md §5.9, measured 2026-08-22
|
||||
PUBLISH_PERIOD_S = 10.0 # firmware/goodwe-master.yaml:181
|
||||
READ_PERIOD_S = 1.0 # main.run_control()'s `await asyncio.sleep(1)`
|
||||
BATT_OBLIGED_S = PLANT_SETTLE_S + PUBLISH_PERIOD_S + READ_PERIOD_S # 17.0
|
||||
|
||||
# The unchanged bound for register 0x0518. See the derivation above; the band is
|
||||
# [2 * BATT_OBLIGED_S, 0.5 * circulation_hold_s] = [34.0, 37.5].
|
||||
BATT_UNCHANGED_S = 35.0
|
||||
|
||||
# How far the standing command must have travelled for the register to be
|
||||
# OBLIGED to have followed it.
|
||||
#
|
||||
# ⚠️ IT IS THE LARGEST ADMISSIBLE VALUE, NOT A SENSITIVITY PREFERENCE, and that
|
||||
# is what makes it a derivation. The gate has to arm everywhere a stale reading
|
||||
# could mislead a consumer; the earliest either consumer speaks is
|
||||
# `unit_sign_disagree_w`, 250 W (config.yaml:450), because below that
|
||||
# `watchdog.disagrees()` is False whatever the measurement says. Taking the
|
||||
# ceiling of the admissible range is what minimises spurious arming. A smaller
|
||||
# number would arm the gate in states where nothing downstream can be misled,
|
||||
# and every such arming is a spurious site stop bought for nothing.
|
||||
#
|
||||
# ⚠️ NOT A CONFIG KEY. CFG-03 tracks the places a default has to be kept in
|
||||
# step and this does not earn another one - it is derived from a default
|
||||
# `config.yaml` already carries, and `test_freshness.py` part 6 READS that file
|
||||
# and goes red if the two drift.
|
||||
MOVE_W = 250.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Bound:
|
||||
"""One signal's freshness bound, and whether anything consumes it."""
|
||||
|
||||
signal: str
|
||||
unchanged_s: float
|
||||
move_w: float
|
||||
wired: bool
|
||||
derivation: str
|
||||
|
||||
|
||||
# ⚠️ THE POLICY AS DATA, AND THE `wired` COLUMN IS LOAD-BEARING. A reader must be
|
||||
# able to tell, without reading main.py, which of these bounds is actually
|
||||
# gating a consumer today. `test_freshness.py` part 7 greps the shipped app/ for
|
||||
# each row and refuses a row that claims to be wired and is not - and a row that
|
||||
# claims not to be and is.
|
||||
BOUNDS = {
|
||||
"batt": Bound(
|
||||
signal="batt (0x0518, AC power, S16, 1 W, 10 s publish)",
|
||||
unchanged_s=BATT_UNCHANGED_S,
|
||||
move_w=MOVE_W,
|
||||
wired=True,
|
||||
derivation="settle 6.0 + publish 10.0 + read 1.0 = 17.0 s floor; "
|
||||
"circulation_hold_s 75 s ceiling; band [34.0, 37.5]"),
|
||||
"soc": Bound(
|
||||
signal="soc (0x050E, pack SoC, U16, 1 %, 10 s publish)",
|
||||
unchanged_s=float("inf"),
|
||||
move_w=float("inf"),
|
||||
wired=False,
|
||||
derivation="TEL-08. Command travel is the WRONG obligation term for "
|
||||
"SoC - a rested pack holds a percent for hours under a 0 W "
|
||||
"command. The term is energy: >= K x 153.6 Wh through the "
|
||||
"pack since the reading last changed. K needs a measured "
|
||||
"usable capacity nobody has taken."),
|
||||
}
|
||||
|
||||
|
||||
class Freshness:
|
||||
"""One signal's arrival record, and the stale verdict it supports.
|
||||
|
||||
⚠️ ARRIVAL-DRIVEN THROUGHOUT. Every clock reading this object ever makes is
|
||||
handed to `submit()` by the caller at the moment a value arrived; nothing
|
||||
here reads a clock of its own and nothing here reads a Home Assistant
|
||||
timestamp. That is what makes the verdict replayable - `test_freshness.py`
|
||||
drives real seconds through it without sleeping - and it is the difference
|
||||
between this and the value-stamped veto TEL-06 is blocked on.
|
||||
|
||||
⚠️ THE VERDICT LATCHES, AND WITHOUT THAT IT OSCILLATES AT ABOUT 1 Hz.
|
||||
Declaring the reading stale makes `Controller.inputs_ok` False, which
|
||||
commands 0 W (§11.1 rule 1), which drops the standing command below
|
||||
`MOVE_W`, which un-arms the conjunction, which would return the held number
|
||||
to the consumers - and round again every read. So the ONLY thing that clears
|
||||
the latch is an arrival carrying a DIFFERENT number, which is exactly the
|
||||
evidence the verdict was about. A register that is working clears it on its
|
||||
next 10 s publish; a register that is not, does not.
|
||||
"""
|
||||
|
||||
def __init__(self, bound: Bound):
|
||||
self.bound = bound
|
||||
# The last number that ARRIVED, or None if the most recent read did not
|
||||
# produce one. This is what `reading()` hands back when the gate is open,
|
||||
# so a read failure is still an immediate None exactly as it is today.
|
||||
self.last = None
|
||||
# The number the reading is currently HOLDING, and the monotonic instant
|
||||
# of the arrival that last carried a different one. `held` survives a
|
||||
# failed read; the unchanged clock is about the value, not about our
|
||||
# ability to fetch it.
|
||||
self.held = None
|
||||
self.changed_mono = None
|
||||
# The standing command at that same instant. The conjunction compares
|
||||
# against THIS, not against the previous cycle's command: what matters
|
||||
# is whether the register has followed anything since it last moved.
|
||||
self.standing_at_change = None
|
||||
self.stale = False
|
||||
self.reason = ""
|
||||
self.arrivals = 0
|
||||
|
||||
def submit(self, value, standing_w, now_mono) -> None:
|
||||
"""One READ of this signal. `value` None means nothing arrived.
|
||||
|
||||
`value` the number `hass.number()` produced, or None.
|
||||
`standing_w` what this controller currently has standing on this unit's
|
||||
wire, signed, + = discharge. None when nothing is known.
|
||||
`now_mono` the caller's monotonic clock, in seconds.
|
||||
|
||||
⚠️ A FAILED READ IS NOT AN ARRIVAL AND IS NOT AN AGE EITHER. It clears
|
||||
`last` immediately - which is byte-for-byte what shipping code does
|
||||
today, a missing reading being a missing reading - and it leaves the
|
||||
unchanged clock running, because the value has not changed and our
|
||||
inability to fetch it is not evidence that it has.
|
||||
"""
|
||||
if value is None:
|
||||
self.last = None
|
||||
return
|
||||
value = float(value)
|
||||
self.arrivals += 1
|
||||
if self.changed_mono is None or value != self.held:
|
||||
self.held = value
|
||||
self.changed_mono = float(now_mono)
|
||||
self.standing_at_change = (None if standing_w is None
|
||||
else float(standing_w))
|
||||
# Movement is the ONLY thing that clears the latch. See the class
|
||||
# docstring on the 1 Hz oscillation this prevents.
|
||||
self.stale = False
|
||||
self.reason = ""
|
||||
self.last = value
|
||||
self._grade(standing_w, now_mono)
|
||||
|
||||
def _grade(self, standing_w, now_mono) -> None:
|
||||
"""The conjunction. Sets the latch; never clears it."""
|
||||
if self.stale or self.changed_mono is None:
|
||||
return
|
||||
held_s = self.unchanged_s(now_mono)
|
||||
if held_s < self.bound.unchanged_s:
|
||||
return
|
||||
# ⚠️ BOTH None GUARDS ARE FOR A CALLER THAT DOES NOT KNOW ITS OWN
|
||||
# OBLIGATION YET, WHICH IS NOT `_read_unit`. `Inverter.standing_w()`
|
||||
# always answers a float, so neither branch is reachable from the wired
|
||||
# consumer today. They are here for TEL-08, whose obligation term is an
|
||||
# energy integral that has nothing to say until it has a window - and
|
||||
# "nothing to say" must withhold the verdict, not manufacture one.
|
||||
if standing_w is None or self.standing_at_change is None:
|
||||
return
|
||||
standing_w = float(standing_w)
|
||||
if abs(standing_w) < self.bound.move_w:
|
||||
return
|
||||
moved_w = abs(standing_w - self.standing_at_change)
|
||||
if moved_w < self.bound.move_w:
|
||||
return
|
||||
self.stale = True
|
||||
self.reason = (
|
||||
f"STALE_READING: {self.held:+.0f} unchanged for {held_s:.0f} s "
|
||||
f"while the standing command moved {moved_w:.0f} W to "
|
||||
f"{standing_w:+.0f} W - the reading is not following, so it is "
|
||||
f"reported as MISSING rather than as a measurement (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:
|
||||
"""Seconds since an arrival carried a different number. Status page."""
|
||||
if self.changed_mono is None:
|
||||
return None
|
||||
return max(0.0, float(now_mono) - self.changed_mono)
|
||||
|
||||
def reading(self):
|
||||
"""What the consumers get: the value, or None when it cannot be trusted.
|
||||
|
||||
⚠️ `None`, NEVER A HELD NUMBER, and never a zero. Both consumers already
|
||||
do the right thing with None and neither could ever receive one:
|
||||
`watchdog.SignWatchdog.evaluate` CLEARS its window on a None measurement
|
||||
rather than accumulating it, and `circulation.circulating_w` returns None
|
||||
for a roster with a missing term, which clears the circulation window
|
||||
too. This module is the thing that was missing between them.
|
||||
"""
|
||||
return None if self.stale else self.last
|
||||
|
||||
def note(self, unit_name: str) -> str:
|
||||
"""One line for the status page. Empty when there is nothing to say."""
|
||||
return f"{unit_name}: {self.reason}" if self.stale else ""
|
||||
|
||||
|
||||
def monotonic() -> float:
|
||||
"""The clock this module's callers use, in one place so tests can find it.
|
||||
|
||||
⚠️ `time.monotonic()`, not the wall clock, for `p1.P1Sample.age_s`'s reason:
|
||||
an NTP step on a Raspberry Pi that has just found the network is routinely
|
||||
minutes, and a freshness verdict computed across one would either fake a
|
||||
stale reading or, worse, hide a real one.
|
||||
"""
|
||||
return time.monotonic()
|
||||
@@ -122,6 +122,9 @@ import math
|
||||
from datetime import datetime
|
||||
|
||||
from .clamp import ClampResult
|
||||
# SAFETY-37. The freshness policy, and the ONE place its bounds are written.
|
||||
# freshness.py imports nothing from this package, so the direction is one-way.
|
||||
from .freshness import BOUNDS, Freshness
|
||||
# CFG-03's ONE in-code mirror of config.yaml's SAFETY-24 defaults. Imported
|
||||
# rather than retyped: the flat-keys path below needs `bms_soc_floor`'s
|
||||
# shipped default and a literal here would be the fourth defaults site that
|
||||
@@ -213,7 +216,22 @@ class Inverter:
|
||||
|
||||
# --- this unit's own measurements ------------------------------------
|
||||
self.soc = None
|
||||
# ⚠️ SAFETY-37: `batt` IS NOW THE GATED READING, NOT THE RAW ONE. It is
|
||||
# None whenever `batt_fresh` says the register is not following its own
|
||||
# command - see freshness.py - and a None here is a MISSING input, which
|
||||
# §11.1 rule 1 answers with 0 W. The raw number that arrived is on
|
||||
# `batt_fresh.last`; read that only for diagnostics, never for control.
|
||||
self.batt = None
|
||||
# SAFETY-37. One arrival record per SIGNAL per UNIT. Per unit for
|
||||
# DEV-02's reason - a freshness verdict about B is not evidence about A,
|
||||
# and at N>=2 a shared record would be the flat-scalar defect rebuilt
|
||||
# inside the gate that exists to protect the per-unit checks.
|
||||
#
|
||||
# ⚠️ ONLY `batt` IS WIRED. `BOUNDS["soc"]` exists and is deliberately not
|
||||
# constructed here: SoC's obligation term is energy through the pack, not
|
||||
# command travel, and the constant it needs has not been measured. That
|
||||
# is TEL-08's, and freshness.py's docstring says exactly what it owes.
|
||||
self.batt_fresh = Freshness(BOUNDS["batt"])
|
||||
|
||||
# --- what this unit was last told, and what it last accepted ---------
|
||||
# `target` is THIS unit's share of the site target from the last
|
||||
@@ -266,6 +284,24 @@ class Inverter:
|
||||
self.store.set(self.key(name), value)
|
||||
|
||||
# -- the numbers other modules ask this object for -----------------------
|
||||
def standing_w(self) -> float:
|
||||
"""What this controller has standing on THIS unit's wire right now.
|
||||
|
||||
⚠️ ONE DEFINITION, TWO CALLERS, and it was a transcription before
|
||||
SAFETY-37. `cycle()` computes this for the sign watchdog and
|
||||
`_read_unit()` needs the identical number for the freshness conjunction;
|
||||
two spellings of "what is standing" would be two answers to the question
|
||||
the gate and the check it protects are both asking, and the copy that
|
||||
drifts is always the one further from the wire.
|
||||
|
||||
`last_write` is the value after the device clamp and confirmed accepted;
|
||||
`target` covers the case where nothing has been written yet and,
|
||||
deliberately, the case where the last write was REJECTED - the inverter
|
||||
is then still latched on `last_write`, which is what `last_write` holds.
|
||||
"""
|
||||
return float(self.last_write if self.last_write is not None
|
||||
else self.target)
|
||||
|
||||
def rating_w(self) -> float:
|
||||
"""The largest magnitude this unit can be commanded, in W.
|
||||
|
||||
@@ -319,6 +355,18 @@ class Inverter:
|
||||
f"(limit {float(stale_s):.0f} s)")
|
||||
return True, ""
|
||||
|
||||
def stale_note(self) -> str:
|
||||
"""SAFETY-37's one line for the status page. Empty when nothing to say.
|
||||
|
||||
Separate from `link_note()` because they are about different halves of
|
||||
the device and want opposite remedies: liveness is about the WRITE path
|
||||
and its answer is to drop this unit and let the survivors carry it,
|
||||
while a stale MEASUREMENT is about the read path and its answer is to
|
||||
stop the site (`inputs_ok`). Folding one into the other is how a
|
||||
measurement fault ends up handing a sibling extra power.
|
||||
"""
|
||||
return self.batt_fresh.note(self.name)
|
||||
|
||||
def link_note(self, now: datetime) -> str:
|
||||
"""One line for the status page. Empty when there is nothing to say."""
|
||||
if not self.has_writer():
|
||||
|
||||
@@ -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