Files
goodwe-addon/goodwe_controller/app/freshness.py
T
adminandClaude Opus 5.5 6759d21ea5
checks / unit (pull_request) Successful in 28m10s
infra-pin / fresh (pull_request) Failing after 2s
SAFETY-94: a healthy battery that lags or reverses is not flagged as frozen or refusing
The stale-reading gate counted its 35 s from the register's last change, so
after a rest the first read after a new command tripped it (SAFETY-58; live
2026-10-03, four trips 1 s after the command). It now counts only the time
the command asked the register to move.

At N=1 the refusal engaged after 12 frozen cycles if the reading was under
100 W at that moment, which a battery reversing through its lag passes at a
1 s meter. The refusal now needs the N>1 evidence at every roster size: under
100 W, one way, for 12 cycles spanning 22 s, restarting when the reading
moves 50 W towards the ask.

DECISIONS/SAFETY-94-lagging-battery.md; test_safety94.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 00:15:42 +02:00

740 lines
40 KiB
Python

"""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
SAFETY-94: "unchanged for" counts only the reads at which both command terms
held, so a register that rested is not judged before it could answer.
DECISIONS/SAFETY-94-lagging-battery.md.
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 COST IS BOUNDED FOR ONE CONSUMER AND NOT FOR THE OTHER, and an earlier
version of this note claimed both. TRUE for the per-unit sign check: no such
check can speak about that unit either, since `unit_sign_disagree_w` is 250 W.
FALSE for circulation. "Contributing at most 250 W" bounds the COMMAND - but a
frozen register's REPORTED value is unrelated to its command, and that is
precisely HW-01's shape: commanded ~0 W, register stuck on the last large
number. Measured in review: a unit frozen at -1200 W beside a healthy +900 W
sibling gives `circulating_w` = 1800 W against a 700 W floor, 2.6x over, with
no gate armed. So this gate is scoped to where the SIGN consumer can be misled;
the circulation consumer keeps an unbounded exposure below MOVE_W and that is
a known, stated gap rather than a covered one.
`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.
THE EXIT, AND WHY THE FIRST VERSION OF THIS MODULE DID NOT HAVE ONE (SAFETY-57)
==============================================================================
⚠️ THE VERDICT ABOVE IS ABOUT AN OBLIGATION, AND ITS OWN CONSEQUENCE REMOVES
THE OBLIGATION. Declaring the reading stale makes `Controller.inputs_ok` False,
which commands 0 W (§11.1 rule 1). At 0 W nothing is asking the register to be
anywhere - `|standing| >= MOVE_W` is false, so the conjunction cannot arm - and
the class docstring's clearing rule, "an arrival carrying a DIFFERENT number",
asks the register for evidence that only a command could have produced. A pack
sitting at its BMS floor answers the same integer watt for hours. **The state
had no exit reachable from inside it**, and the operator-facing line said the
opposite: *"It clears itself the moment the register reports a different
value."*
MEASURED ON THE LIVE SITE, 2026-09-21, and committed as
`scenarios/safety57-2026-09-21/` - a passive RS-485 capture, decoded by
`tools/decode_bus_capture.py`:
10:51:24.7 0x0518 reaches -3 W. The pack is at 11 % and the loop has just
reversed from -447 W of charge into discharge; the BMS refuses
and the inverter idles. The register is HEALTHY and TRUTHFUL.
10:51:29.7 the standing command reaches +1060 W. 800 W of travel, and the
register is obliged to follow it.
10:52:00 the conjunction arms. `Inverter.batt` becomes None.
10:52:21.7 the site commands 0 W - on the wire, in the capture.
... 0x0518 does not change value for 850.0 s. The setpoint does not
change value for 790.0 s. Nothing in this add-on can end it.
11:04 a human restarts the add-on.
11:05:31.7 the restarted loop commands -400 W of charge, which the pack
ACCEPTS at 11 %, and the register moves 3 s later.
⚠️ AND IT HAD BEEN ESCAPING BY LUCK. This BMS emits a keep-alive pulse of a few
hundred watts, which twitches 0x0518 and satisfies the clearing rule by
accident. Replaying the committed 16-day loop capture
(`scenarios/loop-2026-09-02_2026-09-17/`) through the shipped conjunction at
1 Hz gives **93 trips**, and the time from each trip to the register's next
change - which was the ONLY exit - has a median of 143 s, 17 episodes at or
over the live one's 710 s, and a worst case of 21440 s. That distribution is a
property of this battery's firmware. It is not a property of this code, and the
add-on must not depend on it.
SAFETY-94: many were the gate judging a rested register on its first read
(SAFETY-58). Counted from the ask, the same days give 59 trips.
THE RULE, and it is SAFETY-13's shape one module over - "which latches get an
exit, not how long the block lasts":
RELEASE <=> the verdict is standing
AND |standing command| < MOVE_W has held continuously for
>= RELEASE_S
On a release the latch clears and a NEW grading window opens at that instant:
the register is re-graded, from scratch, against the commands issued AFTER it.
Nothing about the register's own history is rewritten - `changed_mono`,
`held` and `unchanged_s()` go on reporting that the register has not moved for
850 s, because it has not.
⚠️ `RELEASE_S` IS `BATT_OBLIGED_S`, 17.0 s, AND IT IS THE FLOOR OF ITS BAND
RATHER THAN A PREFERENCE - the mirror of `MOVE_W` taking the ceiling of its
own. The question the bound answers is *has the register had its chance to
answer the command that is now standing?*, and that is exactly the 17.0 s
already derived above: 6.0 s of plant, one 10 s publish, one 1 s read. Below it
the release happens before the cheaper exit - the register moving by itself -
could have occurred. Which half is safe is the opposite of SAFETY-13's, and for
the reason that card states in its §4.2 table: being wrong LONG here is the
failure being fixed (a site parked while it exports), and being wrong SHORT
costs one re-grading window, which is bounded below.
WHAT A RELEASE COSTS, MEASURED AND NOT ABSORBED - AND IT IS NOT SMALL. For the
duration of the new window the consumers get the held number again, which is
the exposure this module was written to close. Driven at the live install's
shape against a register frozen for good (`test_freshness.py` part 10c), over
1200 s:
shipped parked 1166 s, the held number reaches a consumer 35 s 2.9 %
SAFETY-57 parked 571 s, the held number reaches a consumer 630 s 52.5 %
⚠️ **52 % IS THE PRICE OF THE EXIT AND IT IS PAID EVERY CYCLE**, not an
occasional slip. The period is the whole of `stale_input_s` (15 s) + `RELEASE_S`
(17 s) parked, then one `BATT_UNCHANGED_S` (35 s) window of re-grading. It is
bounded by construction, and that bound is the one thing that makes it payable:
the new window is AT MOST BATT_UNCHANGED_S = 35 s, and 35 < 75 < 85 - the
circulation hold and the per-unit sign hold. NEITHER CONSUMER CAN REACH A
VERDICT INSIDE ONE WINDOW, and a re-trip returns them a None, which CLEARS
both windows rather than accumulating into them.
That inequality is the same one part 6 already grades, and it is why the window
is `BATT_UNCHANGED_S` rather than a number chosen to make the table above look
better. Neither detector can be made to say anything by a held number under
this scheme - which is exactly the state the shipped code leaves them in as
well, since a permanent None clears both windows for ever. **So nothing that
SAFETY-37 detects is given back.** What the held number does reach for those
35 s is `site_batt_w`, the anti-windup input of `control.compute()`, which
responds to a large unexplained divergence by FREEZING the output - the
conservative direction, and SAFETY-13's business. And the command itself is
derived from the P1 meter, never from `batt`, so a site regulating through a
re-grading window is regulating on the signal it always regulates on.
**A register frozen under a command that never travels 250 W is still not
detected at all** (survivor 2, unchanged), and a dead publisher whose value
alternates is still invisible (survivor 3).
⚠️ AND THE NARROWING THIS DOES NOT TAKE, named so the next card can. The live
episode's register refused a DISCHARGE into an 11 % pack and then followed a
CHARGE immediately; a refusal in one direction is not evidence about the other,
and a gate that knew the pack's floor could re-grade on the first command that
crosses it instead of on a timer. That needs SoC, which this module deliberately
does not take (see the `soc` row), and the pack floor, which is SAFETY-32's
number. It would shrink the 52 % above; it is not needed to give the state an
exit, and mixing it in here would make the exit depend on a second measurement
being right.
⚠️ WHAT THIS DOES NOT DO, because the temptation is to call it a fix for the
whole episode: it does not tell a BMS refusal apart from a dead register. The
site above was parked for 790 s by a register that was working perfectly, and
after this change it is parked for `stale_input_s + RELEASE_S` instead - about
32 s on the shipped default - and then regrades. Telling the two apart needs
the pack's own floor, which is SAFETY-32's number and not this module's input;
the wording the operator reads about it is SAFETY-56's.
"""
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
# SAFETY-57. How long the obligation must have been ABSENT before a standing
# verdict is withdrawn and the register re-graded. See the module docstring's
# last section for the live episode this exists for and for which half of the
# band is the safe one.
#
# ⚠️ IT IS `BATT_OBLIGED_S` AND NOT A NEW NUMBER, and that identity is the
# derivation rather than a convenience. The question is "has the register had
# its chance to answer the command standing NOW?", which is the same physical
# quantity the floor of `BATT_UNCHANGED_S`'s band is: 6.0 s of plant, one 10 s
# ESPHome publish, one 1 s add-on read. Releasing sooner would throw away the
# cheaper exit - the register moving by itself - before it could have happened.
RELEASE_S = BATT_OBLIGED_S # 17.0
@dataclass(frozen=True)
class Bound:
"""One signal's freshness bound, and whether anything consumes it."""
signal: str
unchanged_s: float
move_w: float
release_s: 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,
release_s=RELEASE_S,
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]. "
"SAFETY-57: the verdict is withdrawn after 17.0 s of the "
"standing command below 250 W, because at 0 W the register "
"is obliged to nothing and the verdict cannot be tested "
"again from inside the state it creates. SAFETY-94: the "
"35 s counts only reads at which the command asks the "
"register to move"),
"soc": Bound(
signal="soc (0x050E, pack SoC, U16, 1 %, 10 s publish)",
unchanged_s=float("inf"),
move_w=float("inf"),
release_s=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 an arrival carrying a
DIFFERENT number clears the latch, 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.
⚠️ AND IT IS NOT THE ONLY THING THAT CLEARS IT ANY MORE (SAFETY-57), BECAUSE
THAT MADE THE STATE INESCAPABLE FROM INSIDE. The command the verdict itself
produces is 0 W, and at 0 W the register is obliged to be nowhere - so
"report a different value" is evidence a parked site cannot ask for. The
live site sat at 0 W for 790 s on 2026-09-21 with a register that was
working correctly, and was freed by a human. `_release()` below withdraws a
verdict whose obligation has been gone for `bound.release_s`, and opens a
new grading window at that instant. The 1 Hz oscillation the latch exists to
prevent is still prevented: the new window is `bound.unchanged_s` wide, so
no re-trip can land sooner than 35 s after a release, rather than on the
next read. SAFETY-94: the 35 s counts only while the command asks the
register to move, so a site that rests after a release is not re-tripped
on its first ask.
⚠️ TWO CLOCKS, AND THE DISTINCTION IS THE WHOLE OF SAFETY-57'S HONESTY.
`changed_mono` / `held` are facts about the REGISTER: when it last carried a
different number. `graded_from` / `standing_at_grade` are the window the
VERDICT is computed over, which a release re-opens. They are the same thing
until the first release and diverge afterwards, and `unchanged_s()` - the
number an operator reads - is always the register's.
"""
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
# SAFETY-57. The window the VERDICT is graded over, as opposed to the
# register's own history above, and the standing command at the instant
# it opened. The conjunction compares against THIS, not against the
# previous cycle's command: what matters is whether the register has
# followed anything since the current question was put to it. A register
# that moves opens a window, so until the first release these two are
# the change-stamps; `_release()` is where they diverge.
self.graded_from = None
self.standing_at_grade = None
# SAFETY-94. Seconds of this window during which the register was
# obliged to move (the command at least `move_w` and `move_w` away
# from `standing_at_grade`), and the instant of the last read.
self.obliged_acc = 0.0
self.last_read = None
# The instant the standing command last FELL below `move_w`, or None
# while an obligation is standing. The release timer runs off this.
self.rested_since = None
self.released_at = None
self.releases = 0
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._open_window(standing_w, now_mono)
# Movement clears the latch, because it is exactly the evidence the
# verdict was about. See the class docstring on the 1 Hz oscillation
# the latch prevents, and `_release()` for the other way out.
self.stale = False
self.reason = ""
self.last = value
# ⚠️ THE ORDER OF THESE TWO IS NOT OBSERVABLE, AND THIS COMMENT SAYS SO
# BECAUSE THE FIRST VERSION OF IT CLAIMED THE OPPOSITE. Swapping them is
# a ZERO-BYTE edit and it SURVIVED the battery; the reason is structural
# rather than a missing fixture, so no fixture could have closed it:
# `_grade()` acts only when `|standing| >= move_w` and `_release()` only
# when `|standing| < move_w`, so at most one of them can do anything on
# any given read, whichever runs first. Release is written first because
# it reads in the order the state machine moves - withdraw, then
# re-grade - not because the code depends on it.
self._release(standing_w, now_mono)
self._oblige(standing_w, now_mono)
self._grade(standing_w, now_mono)
def _open_window(self, standing_w, now_mono) -> None:
"""Start grading the register again, from this instant (SAFETY-57).
Called when the register moves - the window and the register's history
coincide there - and when a verdict is withdrawn, where they do not.
"""
self.graded_from = float(now_mono)
self.standing_at_grade = (None if standing_w is None
else float(standing_w))
# The read that opens a window is never an ask (no travel yet).
self.obliged_acc = 0.0
def _obliged(self, standing_w) -> bool:
"""Is the register asked to move: the command is at least `move_w` and
has travelled `move_w` since the window opened?"""
if standing_w is None or self.standing_at_grade is None:
return False
standing_w = float(standing_w)
return (abs(standing_w) >= self.bound.move_w
and abs(standing_w - self.standing_at_grade)
>= self.bound.move_w)
def _oblige(self, standing_w, now_mono) -> None:
"""SAFETY-94. Count only the time the register was asked to move.
A register that held still while nothing asked it to move has not
failed, so time before the command moved is not evidence. The time
since the previous read counts when the obligation stands at this
read; a command flapping in and out of the obligation still adds up.
"""
now_mono = float(now_mono)
if self.last_read is not None and self._obliged(standing_w):
self.obliged_acc += max(0.0, now_mono - self.last_read)
self.last_read = now_mono
def obliged_s(self) -> float:
"""Seconds of the current window the register was obliged to move."""
return self.obliged_acc
def _release(self, standing_w, now_mono) -> None:
"""SAFETY-57. Withdraw a verdict whose obligation is no longer standing.
⚠️ THE TIMER RUNS ON THE COMMAND, NOT ON THE VERDICT. `rested_since` is
maintained on every arrival, latched or not, so the elapsed rest is the
real one and not "time since we last looked". An obligation reappearing
resets it to None, which is the whole point: a site that goes back to
asking the register for 250 W of movement has put the question back, and
the verdict stands until the question is withdrawn again.
"""
obliged = (standing_w is not None
and abs(float(standing_w)) >= self.bound.move_w)
if obliged:
self.rested_since = None
return
if self.rested_since is None:
self.rested_since = float(now_mono)
if not self.stale:
return
if float(now_mono) - self.rested_since < self.bound.release_s:
return
self.released_at = float(now_mono)
self.releases += 1
self.stale = False
self.reason = ""
self._open_window(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.graded_from is None:
return
held_s = self.graded_s(now_mono)
# SAFETY-94 (closes SAFETY-58): the bound counts only the time the
# command asked the register to move, not the time since it last
# changed. A register that rested for minutes was otherwise judged on
# the first read after a new command, before it could answer (live,
# 2026-10-03: 1 s after the command).
if self.obliged_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_grade 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_grade)
if moved_w < self.bound.move_w:
return
self.stale = True
# ⚠️ THE SENTENCE SAYS "unchanged", SO IT GETS THE REGISTER'S OWN
# NUMBER, NOT THE WINDOW'S. They are the same until the first release
# and different afterwards, and an operator comparing this line against
# a history graph is reading the register.
shown_s = self.unchanged_s(now_mono)
# ⚠️ SAFETY-56. THE OBSERVATION IS A FACT, THE DIAGNOSIS WAS NOT, AND
# THE SENTENCE NOW STOPS WHERE THE EVIDENCE DOES. The conjunction above
# is satisfied by TWO disjoint worlds, and nothing this object is handed
# separates them:
# (1) the register has stopped reporting reality - what the gate was
# built for - or
# (2) reality is not moving, because the SOURCE cannot comply with
# the command. A pack the BMS is refusing at its floor holds
# still under any setpoint, and a register reporting that
# truthfully publishes the same unchanged number a dead one does.
# The live 2026-09-21 episode was (2) - the pack read 11 % for the
# whole 850 s hold, the commands were discharge-only, and the register
# answered the first CHARGE command within one obligation window
# (test_freshness.py 11a measures all three off the committed capture)
# - and this sentence asserted (1), which sent the operator to look at
# healthy hardware.
# Naming both is what the module can honestly say. Naming WHICH would
# need SoC and the pack floor - deliberately not this module's inputs
# (see the `soc` row in BOUNDS and the docstring's last section), and
# TEL-08's and SAFETY-32's numbers rather than this sentence's.
#
# ⚠️ AND IT IS NOT SHARED WITH `main.py`'s NOT READY ROW, WHICH SAYS
# THE SAME AMBIGUITY IN DIFFERENT WORDS ON PURPOSE. This sentence is a
# LOG LINE about a behaviour, quoted verbatim by `note()` onto the
# status page and by `_read_unit()` into the action log; that one is a
# BANNER about a named ENTITY, and the two answer different questions
# ("what did the register do" against "can I trust this entity"). One
# helper serving both would have to drop whichever half the other does
# not need, and the half it dropped would be the operator's next step.
# UX-10: two plain sentences (test_messages.py part 6). SAFETY-56's
# two explanations stay, in this order, and so does the admission
# that this check cannot tell them apart. The reading is treated as
# missing either way, which the log line around this says. The
# limits (`self.bound`) are on main.py's DEBUG line beside it, so the
# logged line stays within 3 sentences.
# ⚠️ SAFETY-57, AND THE THREE DURATIONS ARE THREE DIFFERENT FACTS, each
# in its own slot: the register's unchanged time, the WINDOW this
# verdict actually rests on after a release (`regraded`), and how many
# times this unit has been round the loop. Without the second one the
# sentence reads as though 92 s of unchanged register were what was
# compared against the 35 s limit, and it was not.
regraded = (f", checked {self.releases + 1} times over a {held_s:.0f} s "
f"window since the last stop was withdrawn"
if self.releases else "")
self.reason = (
f"STALE_READING: {self.held:+.0f} W unchanged for {shown_s:.0f} s "
f"while the command moved {moved_w:.0f} W to {standing_w:+.0f} W"
f"{regraded}. The reading may have stopped, or the battery may be "
f"unable to follow the command, and this check cannot tell which")
def unchanged_s(self, now_mono) -> float | None:
"""Seconds since an arrival carried a different number. Status page.
⚠️ THE REGISTER'S CLOCK, NEVER THE VERDICT'S (SAFETY-57). A release
re-opens the grading window; it does not make the register younger, and
an operator reading "unchanged for 12 s" off a register that has held
one value for 850 s would be reading this module's bookkeeping back as a
measurement. `graded_s()` is the other one and is named differently for
that reason.
"""
if self.changed_mono is None:
return None
return max(0.0, float(now_mono) - self.changed_mono)
def graded_s(self, now_mono) -> float | None:
"""Seconds the CURRENT verdict window has been open (SAFETY-57).
Equal to `unchanged_s()` until the first release, and shorter after one.
"""
if self.graded_from is None:
return None
return max(0.0, float(now_mono) - self.graded_from)
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.
⚠️ AND IT CAN GO BACK TO BEING A NUMBER WITHOUT THE REGISTER MOVING
(SAFETY-57). That is the exit, and it is bounded rather than free: the
window a release opens is `bound.unchanged_s` = 35 s, shorter than both
consumer holds (75 s and 85 s), so neither can reach a verdict inside
one - and a re-trip hands them a None, which CLEARS their windows. See
the module docstring's last section.
"""
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 release_note(self, unit_name: str) -> str:
"""SAFETY-57's release, for the log. Empty when nothing was withdrawn.
⚠️ IT NAMES WHAT IS AND IS NOT BEING CLAIMED, because a line reading
"recovered" would be a lie: nothing has been learned about the register.
The verdict was withdrawn because it can no longer be tested, and the
next 35 s are the test.
"""
if self.released_at is None:
return ""
return (f"{unit_name}: STALE_WITHDRAWN after the standing command sat "
f"below {self.bound.move_w:.0f} W for "
f"{self.bound.release_s:.0f} s. NOTHING HAS BEEN LEARNED ABOUT "
f"THE REGISTER - it is still holding {self.held:+.0f} - but a "
f"verdict about a command that is no longer standing cannot be "
f"tested, and the 0 W this one produces is what stopped it "
f"being tested. The register is now re-graded over a fresh "
f"{self.bound.unchanged_s:.0f} s window; if it still does not "
f"follow, this returns")
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()