checks / unit (pull_request) Successful in 7m58s
SAFETY-37's freshness gate reports a battery-power register as MISSING when it
stops following its own command, and a missing input is "command 0 W and keep
commanding" (§11.1 rule 1). The gate's only exit was "an arrival carrying a
DIFFERENT number" - and at 0 W nothing is asking the register to be anywhere.
The state had no exit reachable from inside it, and the log line promised the
opposite in the same sentence that announced the 0 W.
REPRODUCED FROM THE WIRE, NOT MODELLED. scenarios/safety57-2026-09-21/ is 3840
verbatim frames off the RS-485 pair, read from the passive sniffer, decoded by
tools/decode_bus_capture.py (0 unparsed, 0 bad CRC, 0 unknown shapes; the
mode->sign convention is graded off the capture, not trusted). The box was
running 0.10.0, so the 0 W in the trace IS the add-on's own claim:
10:51:24.7 0x0518 reaches -3 W. Pack at 11 %, the BMS refuses the discharge,
the inverter idles. THE REGISTER IS HEALTHY AND TRUTHFUL.
10:51:29.7 the command reaches +1060 W - 800 W of travel
10:52:00 the conjunction arms. Inverter.batt becomes None
10:52:21.7 the site commands 0 W, on the wire
... 0x0518 does not change value for 850.0 s. The setpoint does not
change value for 790.0 s. The house exports 2.4 kW throughout
~11:04 a human restarts the add-on. That is what ends it
AND IT HAD BEEN ESCAPING BY LUCK. Replaying the committed 16-day loop capture
through the shipped conjunction finds 93 trips whose wait for the BMS
keep-alive pulse that cleared them has a median of 143.4 s, a worst case of
21440.1 s, and 17 of 93 at or beyond this episode's 710 s. That distribution
belongs to the battery's firmware, not to this code.
THE CARD'S OPTION 2 ALREADY HELD AND IS NOT THE BUG. _grade() has required
|standing| >= MOVE_W since SAFETY-37. The trip was at +1060 W and the stuck
state is at 0 W; they are different moments, and an entry-side rule cannot
reach a state entered legitimately and then persisted. Nothing in the
conjunction is weakened here.
THE RULE, SAFETY-13's SHAPE ONE MODULE OVER - which latches get an exit, not
how long the block lasts:
the verdict is withdrawn when the obligation that produced it has been gone
for RELEASE_S, and the register is then re-graded from scratch.
RELEASE_S is BATT_OBLIGED_S, 17.0 s - the obligation window already derived in
the file (6.0 plant + 10.0 publish + 1.0 read), taken at the FLOOR of its band
for the mirror of MOVE_W's reason. Which half is safe is the opposite of
SAFETY-13's: being wrong long is the failure being fixed, being wrong short
costs one bounded re-grading window. Below 17 s the release pre-empts the
cheaper exit - the register answering by itself - and that is graded
behaviourally, not asserted.
The whole parked interval is now stale_input_s + RELEASE_S = 32 s. On the live
episode: telemetry back at 10:52:39 instead of 11:05:35, with 0x0518 still
answering the same -3 W. RED BEFORE (release_s=inf, a graded counterfactual and
not a second implementation), GREEN AFTER.
WHAT IT COSTS, AND IT IS NOT SMALL. A register dead for good is re-graded for
ever: over 1201 reads the held number reaches a consumer on 630 (52.5 %)
against 35 (2.9 %) before. Payable only because one window is at most 35 s and
the two consumers hold for 75 s and 85 s, so neither can reach a verdict inside
one and the re-trip clears both windows. No detection SAFETY-37 had is given
back - a permanent None clears the same windows for ever too - and the command
is derived from the P1 meter, never from batt.
Two clocks, deliberately: changed_mono/unchanged_s() stay facts about the
REGISTER, graded_from/graded_s() are the VERDICT's window. Collapsing them
either lies to the operator or defeats the re-grading.
main.py's stale line had its ESCAPE CLAUSE corrected - it promised a clearance
the state could not produce - and gained a STALE WITHDRAWN edge that says in
its own words that nothing has been learned about the register. The DIAGNOSIS
half is SAFETY-56's and is deliberately untouched, with a named check asserting
it is still there so that rewrite has to be deliberate.
Battery: 22 mutations, 21 red, 1 argued equivalent, 0 unexplained survivors.
Two findings kept in DECISIONS: _release()'s move_w test swapped for
unchanged_s SURVIVED at +5 B because every fixture rested at exactly 0 W (habit
11, closed by a 0/100/300 W fixture); and the code shipped a habit-22 comment
claiming the _release/_grade order was load-bearing, which the battery
falsified - the two act on mutually exclusive predicates, so the order is not
observable. MUTATION.md gains habit 23 for a mutation that ran, fingerprinted
clean, and was a no-op on the fixture because `0.0 or x` is x.
Suite: 30/30 in the CI image, checks.yml's exact command, purged tree, :ro, one
file at a time.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
665 lines
36 KiB
Python
665 lines
36 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)
|
|
self.reason = (
|
|
f"STALE_READING: {self.held:+.0f} unchanged for {shown_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)")
|
|
# ⚠️ SAFETY-57, AND THE THREE DURATIONS ARE THREE DIFFERENT FACTS, each
|
|
# in its own slot: the register's unchanged time is in the sentence
|
|
# above, the WINDOW this verdict actually rests on is here, and so is
|
|
# 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.
|
|
if self.releases:
|
|
self.reason += (
|
|
f" [re-graded: the {held_s:.0f} s window this verdict rests on "
|
|
f"opened when the previous one was withdrawn, not when the "
|
|
f"register last moved - this unit has now been graded "
|
|
f"{self.releases + 1} times]")
|
|
|
|
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()
|