- test_messages.py part 6: every dynamic part of a WARNING+ line is classified as data or as a built text, and every built text is produced by the real code and checked. This is the gap the 2d scan had: it counted `%s` as one word, so reasons built by loop_clamp_note() and friends were never seen. - Quoted reasons rewritten to STYLE.md (red-alarm shape, codes kept): OUTPUT_CLAMP, METER_FROZEN, CIRCULATING_POWER, STALE_READING, the peak guard (codes now PEAK_GUARD*), the migration report, price feed, price history and gate reasons. Plurals via messages.times()/count(). - Top table: Price history "96 prices over 24 hours for 26 Sep", Tariff "one every 15 minutes or every hour", "not final yet" dropped. - Levels and noise: per-cycle shadow line DEBUG, shadow on/off INFO, aiohttp access log only at log level debug, settings warnings once per start, one plain line per Home Assistant outage (hass.Outage). - INFO jargon and error texts: no "§8.3", ISO times, "banked", "keys ARE", "NOT_OBSERVED", "did NOT land", "AND". - Display bugs: WHY_WINNER covers every winner and safety reason (main.why_key(), part 7 finds them in the code); the meter row is amber "waiting for the first reading" inside its limit after a start; the missing-reading age is counted when the page asks. - STYLE.md and DECISIONS/UX-08-short-notifications.md (UX-10 section). No change to safety behaviour, alarm levels, notification ids, triggers, cadence or the banner cascade. One row colour changes (meter at start), justified in DECISIONS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
696 lines
38 KiB
Python
696 lines
38 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
|
|
|
|
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.
|
|
|
|
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"),
|
|
"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. ⚠️ MEASURED FROM THE RELEASE, NOT FROM THE COMMAND RESUMING - if
|
|
the site rests for longer than the window before it asks for anything, the
|
|
window is already old and the re-trip lands on the first read at which the
|
|
command travels 250 W again. That is survivor 5's shape, it predates this
|
|
card, and its cost is one `stale_input_s` hold.
|
|
|
|
⚠️ 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
|
|
# 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._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))
|
|
|
|
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)
|
|
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_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: three 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.
|
|
# ⚠️ 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. "
|
|
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:
|
|
"""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()
|