Files
goodwe-addon/goodwe_controller/app/freeze.py
T
glenn schrooyenandClaude Opus 5.5 23a3a3f5b5
checks / unit (pull_request) Successful in 9m3s
infra-pin / fresh (pull_request) Successful in 2s
UX-10: the last plain-language leftovers: log lines, two table rows, 'Why: Other', red at 0 s
- 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>
2026-09-27 10:58:43 +02:00

444 lines
24 KiB
Python

"""SAFETY-18 - the frozen-but-fresh meter detector.
WHAT IT CATCHES, in one sentence: a meter that keeps answering 200 OK with a
document that never changes, which every freshness mechanism in this stack reads
as perfectly healthy while the loop winds to the rail and stays there.
THE FAILURE NEEDS NO FAULT ANYWHERE ELSE. A frozen-but-talking meter is
*arriving* on every poll, so:
- `meter_max_age_s` is structurally blind. Every response resets the arrival
stamp (p1.py, `HomeWizardLocalSource`), so the age never climbs. Age catches
a meter that stops TALKING; this one stops CHANGING.
- firmware layer 1 is fed. Its watchdog keys on setpoint staleness, which the
heartbeat satisfies, and on `sensor.goodwe_p1_sample_age`, which a talking
meter satisfies.
- the arbiter contributes only the device rating and +-`max_w` - it bounds the
magnitude, which is the number this fault drives us to, not away from.
- there is no SoC guard on the grid-following loop at all. `soc_floor` belongs
to the maintenance cycle; the BMS is the sole energy backstop.
⚠️ STILL TRUE, AND NOW FILED. SAFETY-24 added `peak_soc_floor` and
OPT-04 added `arbitrage_soc_floor`, so the sentence above is no longer
"there is one SoC floor and it is maintenance's" - but neither of the new
ones binds THIS loop, and `peak_soc_floor`'s name reads as though it does.
SAFETY-25 replayed the exposure (test_peakguard.py part 10) and recorded
the options in DECISIONS/SAFETY-25-loop-soc-floor.md. DECIDED 2026-09-02,
Option A: the BMS is the sole energy backstop for demand-driven discharge
ON PURPOSE. A loop-side reserve is DEFERRED, not rejected - its revisit
trigger is ALLOC-01. So the sentence above is a recorded decision, not an
oversight and no longer an open question.
The loop reads a constant error and integrates it. Time from freeze to the rail,
replaying the real `control.compute()` at 15 s cycles:
meter frozen at to rail, max_w 2000 to rail, max_w 5000
350 W 150 s 360 s
1000 W 60 s 135 s
>= 3000 W 30 s 75 s
⚠️ THE LIVE INSTALL RUNS `max_w: 5000`, which is both the schema maximum and the
device rating, so the right-hand column is the one that describes this house.
------------------------------------------------------------------------------
THIS HAS A EURO FIGURE, AND IT IS THE REASON THE TICKET IS P1
`maintenance_charge_floor()` caps MAINTENANCE charging against `peak_cap_w`. The
grid-following loop is not capped by anything but `max_w`. A meter frozen
reading a large export drives a 5 kW unattended grid IMPORT, and >= 15 minutes
of that sets the month's billed capaciteitstarief peak: specs.md:583 records a
measured case where a single 4998 W quarter moved a monthly peak from 4832 W to
5013 W and billed the whole month at it.
⚠️ THIS CUT DOES NOT CAP THAT. It notifies. See DOCS.md, "Capacity-tariff
exposure of the grid-following loop", and SAFETY-07.
------------------------------------------------------------------------------
WHY IT IS A CONJUNCTION AND NOT AN `unchanged_s` THRESHOLD
`unchanged_s` (p1.py) already measures exactly the right thing: seconds since
the meter served a DIFFERENT measurement, fingerprinted over the power figure
and both energy registers. It is computed, it is on the status page, and TEL-05
deliberately refused to threshold it - correctly:
this controller regulates grid power toward ~0 W, and at a converged -10 W
the export register takes about six minutes to advance by its 1 Wh
resolution while the power figure legitimately repeats.
So a bare `unchanged_s > N` false-trips at the exact operating point we aim for,
and `p1.py` says as much in a note that ends "whoever wires a freeze detector
must handle the low-power case first". This is that handling, and it is not a
smaller number:
unchanged_s >= hold_s AND |commanded_w| is at the rail
The second term is what TEL-05's case cannot satisfy. At the converged point the
command is nowhere near +-`max_w`; on the 40 minutes of live converged behaviour
in `sim/scenarios/p1-age-2026-08-26.jsonl.gz` the meter never once leaves
+-500 W, so the loop never even approaches a 5000 W rail. And the reverse case -
a command genuinely pinned at the rail by a real 8 kW load - cannot satisfy the
FIRST term, because a real railed condition means kilowatts still crossing the
meter, and a kilowatt advances a 1 Wh register every 3.6 s.
The two terms fail for independent reasons. That is the whole design.
⚠️ AND THE SECOND HALF OF THAT WAS MEASURED ON THE RIG, WITH A RESULT WORTH
READING CAREFULLY. Driving the real transport against the rig's own hwsim under
+8000 W of house load - a genuinely railed command, meter perfectly healthy - the
POWER FIGURE DID NOT MOVE AT ALL: `active_power_w` sat at exactly 10130.0 W for
26 consecutive polls. What kept `unchanged_s` pinned at 0.0 s was the kWh
registers advancing, and nothing else.
So the register terms in `measurement_fingerprint()` are not belt-and-braces
here, they are the entire negative case. A freeze detector fingerprinted on the
power figure alone would have raised a false alarm on that rig run - a healthy
meter, a real load, a correctly railed command - which is the exact failure mode
that gets a safety guard switched off. Anyone tempted to simplify the fingerprint
should read that run first.
------------------------------------------------------------------------------
WHERE `hold_s` COMES FROM
Measured on `sim/scenarios/p1-age-2026-08-26.jsonl.gz` - 2399 s (40 min) of the
live house at the converged operating point, mean grid -14.2 W, 1194 polls:
polls that repeated the previous reading 735 of 1193 (61.6 %)
distinct-reading transitions 458
median gap between distinct readings 6.0 s
p95 gap 6.0 s
LARGEST `unchanged_s` any sample observes 8.0 s
longest interval serving no distinct reading 15.4 s
samples outside +-500 W 0
The default 300 s is 37x the largest `unchanged_s` this house has been recorded
producing while converged, and 19.5x the longest interval in which it served no
distinct reading at all. The two figures differ because the capture has an 11.4 s
hole in its OWN polling; the second is quoted because it is the pessimistic one.
300 s is not fitted to either margin - it is picked as "clearly past any
plausible register-tick interval, and clearly inside the 900 s quarter that bills
the capacity tariff". test_freeze.py re-measures both every run, so this
paragraph cannot quietly stop being true.
⚠️ BE PRECISE ABOUT WHAT THAT CAPTURE PROVES. It is POWER-ONLY - the shipped
fingerprint also covers both kWh registers, so a real `unchanged_s` on that data
can only be shorter, never longer, which makes it a conservative measurement of
the converged case. But it does NOT on its own refute a bare `unchanged_s`
threshold, and saying it did would be exactly the overstatement this project
keeps having to walk back: at 8 s worst, a bare 300 s threshold is silent on it
too. The case that refutes the bare threshold is the one below, which no capture
of THIS meter contains because this meter's power figure jitters.
The floor on the other side: at the rail, the smallest residual grid flow that
still ticks a 1 Wh register inside 300 s is 12 W. Any condition that genuinely
pins this loop at +-5000 W has three orders of magnitude more than that crossing
the meter.
⚠️ AND THOSE TWO NUMBERS DO NOT MERELY HAVE MARGIN - THEY CANNOT BOTH BE TRUE.
The worry the conjunction has to answer is "a HEALTHY meter whose register is
slower than `hold_s`". At 300 s that is |grid| < 12 W. The shipped `deadband_w`
is 15 W, so anywhere the register is that slow the control law is already inside
its deadband and the command is parked - the rail term is not merely unlikely to
be true there, it is 0 W. The two terms are mutually exclusive by construction
at the defaults, not just far apart, and the check re-derives that every run.
That relationship is the one to protect. `hold_s` may be lowered only while
3600/hold_s stays under `deadband_w`; at the schema floor of 60 s that is 60 W
against a 15 W deadband, and the mutual exclusion is gone. A 60 s `meter_freeze_s`
is a supported configuration that gives up this argument and keeps only the
margin - do not set one without a capture that says the margin is enough.
⚠️ WHAT `hold_s` DOES NOT BUY IS DETECTION SPEED. The alert lands at
max(hold_s, time-to-rail) after the freeze, so at the shipped default and
`max_w: 5000` that is 300-360 s. The rail is REACHED first, from 75 s. A
notification is not a brake; see the DOCS.md note.
------------------------------------------------------------------------------
THE RAIL TERM, PRECISELY
abs(commanded_w) >= RAIL_FRAC * max_w
`commanded_w` is the EMITTED setpoint - what the arbiter resolved and
`write_setpoint()` put on the wire - not the loop's raw wish, for the same
reason SAFETY-02 uses the emitted value: the wire carries the emitted number.
RAIL_FRAC is 0.95 and is a constant rather than an option. The command is
quantised to `step_w` (max 100), so at the rail the emitted value IS the ceiling
on every schema-legal combination; the 5 % is quantisation slack, not a tuning
knob. Making it configurable would offer a way to widen the one term that
carries the whole no-false-positive argument.
⚠️ SAFETY-20 - AND THE CEILING IS NOT `max_w`. THE 5 % USED TO BE DESCRIBED AS
"slack for a device rating that clips `max_w` slightly in `write_setpoint()`",
AND THAT SENTENCE WAS THE BUG. 5 % of slack does not absorb a device rating; it
inverts the guard. `write_setpoint()` clamps the emitted command to `max_w` AND
THEN to the setpoint device's own `[dev_min, dev_max]` range (clamp.py,
`clamp_to_rating()`), and the arbiter files the same range as a P_SAFETY limit
claim, so the largest magnitude that can ever stand on the wire is
min(max_w, |dev_min|, |dev_max|)
not `max_w`. Measured on the shipped predicate at `max_w: 5000` against a device
of +-4700 - the exact case the ticket is named for:
looks_frozen(unchanged_s=3600, commanded_w=4700, max_w=5000) -> False
The command is pinned at the hardware's rail, the meter has been frozen for an
hour, and term 2 is unsatisfiable BY CONSTRUCTION because 4700 < 4750. Any
device rating at or below `RAIL_FRAC * max_w` disarms the conjunction outright.
The status page then printed `armed (...command is over 4750 W)`, which is the
"disarmed guard that advertises itself as armed" failure validate.py rule 5 and
SAFETY-15/19 all exist to prevent - so `rail_ceiling_w()` below takes the device
range and the rail is measured against the smaller number.
WHY THE SMALLER OF THE TWO DEVICE BOUNDS, AND NOT A SIDE-AWARE PAIR. The rail is
a MAGNITUDE test against what clamp.py calls a magnitude clamp - it binds import
and export identically - so the ceiling here is a magnitude that is available in
BOTH directions. The alternative considered was a per-sign rail:
`min(max_w, dev_max)` for a positive command, `min(max_w, |dev_min|)` for a
negative one. That is arithmetically tighter and it is NOT wrong - on a device
range of -500..+5000 it would grade a -480 W charge command as railed, which the
symmetric ceiling reaches only by dropping the discharge rail to 475 W too. It
was rejected on cost, not on correctness: a per-sign rail is two rails, and two
rails need two coverage verdicts (`dev_min == 0` is a degenerate charge rail
whatever the discharge side can do) on a status row that carries one number and
in a predicate that takes one magnitude. That is a bigger change than this
defect earns, and the ONLY configuration that distinguishes the two - a device
range narrower than `max_w` on one side - is refused at startup by CFG-01 (see
the reachability note below). Between the two, the symmetric ceiling can only
ever LOWER the rail, which widens term 2 rather than disarming it; term 1
(`unchanged_s >= hold_s`) is the term carrying the no-false-positive argument,
and it is untouched. Whoever needs the tighter version should take both status
rows with it.
⚠️ AND WHEN THE CEILING IS ZERO THE RAIL IS NOT "LOW", IT IS GONE. A device that
accepts no command in one direction at all (`dev_max == 0`, or `dev_min == 0`)
gives a ceiling of 0 W, and `abs(commanded_w) >= 0` is TRUE FOR EVERY COMMAND
INCLUDING 0 W. Term 2 would not be disarmed there, it would be degenerately
true, and the detector would collapse into the bare `unchanged_s > N` threshold
TEL-05 refused - false-tripping at the converged operating point we aim for.
That case is reported as NOT COVERED, through the row the unsupported-transport
case already uses, and `looks_frozen()` returns False rather than firing.
⚠️ HOW REACHABLE IS ANY OF THIS - READ THIS BEFORE "SIMPLIFYING" IT AWAY.
`validate_startup_config()` rule 2 (CFG-01) already REFUSES TO START on
`max_w > dev_max` or `-max_w < dev_min`, and `run_control()` re-runs that gate
once ESPHome is up and the real rating is known. So on any install that reaches
the control loop with a rating it could read, `min(max_w, |dev_min|, |dev_max|)`
IS `max_w` and everything above is arithmetically dead. That is not an argument
against it - it is clamp.py's argument for its own second stage, word for word:
the case where this bites is the case where the startup gate did not run or no
longer holds (`hass.limits()` returns (None, None) whenever ESPHome is not up,
and the range checks are then skipped rather than guessed at, so a rating that
arrives late is never re-read), and a guard that quietly assumes the gate ran is
a guard that reports armed in exactly the state it cannot fire in. See AC 4's
decision, recorded in validate.py under WHAT IS DELIBERATELY *NOT* REFUSED.
⚠️ NOT GATED ON `following`, and that is deliberate. A maintenance charge
commands against the meter on purpose and can sit at the rail for hours - but it
does so while pushing kilowatts through the meter, so the FIRST term is what
excludes it, and it excludes it on physics rather than on bookkeeping. Gating on
`following` as well would additionally blind the detector to a maintenance phase
running against a meter that really has frozen, which is the same fault with a
worse actuator behind it.
------------------------------------------------------------------------------
NOTIFY, DO NOT TRIP
First cut is an alarm and nothing else: an HA persistent notification, an ERROR
in the log, an event on the status page, and a failed check row. It does NOT
claim the actuator, does NOT file a P_SAFETY claim and does NOT latch.
That is a deliberate ordering of costs, not timidity. A false trip here parks
the battery - on a house that is running this loop precisely to avoid buying
power at the wrong moment - and the operator's response to a parked battery is
to switch the guard off. A false notification costs one alert. The trip is a
separate decision that has to be earned with observed data from a real install,
and it needs an answer to a question this cut does not have one for: what should
the loop do instead, given that 0 W is itself a grid import of whatever the
house is drawing.
⚠️ TRANSPORT-CONDITIONAL. `unchanged_s` exists ONLY on `homewizard_local`,
because it is the only transport that sees the meter's own document. Under
`ha_dsmr`, `mqtt_p1`, `ha_signed` and `meter_source: off` this detector is
silent and the exposure above is uncovered. The caller passes None and gets
False; there is no half-measure available on those paths, and pretending
otherwise would be worse than the gap.
"""
from dataclasses import dataclass
from datetime import datetime
from .clamp import is_commandable
# See "THE RAIL TERM, PRECISELY" above. A constant, on purpose.
RAIL_FRAC = 0.95
@dataclass(frozen=True)
class FreezeWatchdogConfig:
"""Default, not a constant - config.yaml carries `meter_freeze_s`."""
hold_s: float = 300.0
def rail_ceiling_w(max_w, *, dev_min=None, dev_max=None) -> float:
"""The largest command magnitude that can actually stand on the wire.
SAFETY-20, and the whole point of the ticket: this is NOT `max_w`. The
emitted command is bounded by `max_w` AND by the setpoint device's own
`[dev_min, dev_max]` range - twice, in `clamp_to_rating()` and again as the
arbiter's `device` P_SAFETY limit claim - so the reachable ceiling is the
smallest of the three magnitudes. See "THE RAIL TERM, PRECISELY" for why it
is the smaller of the two device bounds rather than a per-sign pair, and for
why this is arithmetically dead on any install CFG-01 let start.
`dev_min` / `dev_max` are `hass.limits()`'s answer and are None whenever
ESPHome could not be reached. UNKNOWN IS NOT ZERO: a missing bound means
this add-on applies no device clamp at all on that side (clamp.py's second
stage is skipped unless BOTH are known), so an unknown bound must not lower
the ceiling. `is_commandable()` is write_setpoint()'s own predicate, reused
rather than retyped: a NaN rating must not become a 0 W ceiling.
Returns 0.0 when the device can command nothing - see rail_unreachable().
"""
ceiling = abs(float(max_w))
for bound in (dev_min, dev_max):
if is_commandable(bound):
ceiling = min(ceiling, abs(float(bound)))
return max(0.0, ceiling)
def rail_unreachable(max_w, *, dev_min=None, dev_max=None) -> str:
""""" when the rail is a real threshold, else WHY it is not. SAFETY-20 AC2.
The status page renders this as a NOT COVERED row - the same row, for the
same reason, that an unsupported transport already gets. It is a second
reason to use that surface, not a new mechanism: in both states the honest
report is "this guard is not watching", and the one thing this project has
decided repeatedly it will not ship is a page that says `armed` instead.
⚠️ THE UNREACHABLE CASE IS NOT "THE RAIL IS HIGH", IT IS "THE RAIL IS 0 W".
A ceiling of zero makes `abs(commanded_w) >= 0` true for every command
including a parked 0 W one, so term 2 is not disarmed but DEGENERATELY TRUE,
and the conjunction decays into the bare `unchanged_s > N` threshold TEL-05
refused - which false-trips at the converged operating point this controller
aims for. Silence plus a NOT COVERED row is strictly better than that.
"""
if not max_w or not is_commandable(max_w):
return (f"max_w is {max_w!r}, so there is no ceiling to measure a rail "
f"against")
if rail_ceiling_w(max_w, dev_min=dev_min, dev_max=dev_max) > 0.0:
return ""
return (f"the setpoint device reports a range of {dev_min}...{dev_max} W, "
f"which leaves no command magnitude available in both directions, "
f"so the rail term would be true of every command including 0 W")
def rail_w(max_w, *, dev_min=None, dev_max=None) -> float:
"""The magnitude at or above which a command counts as railed.
Measured against the REACHABLE ceiling, not against `max_w` - SAFETY-20.
Callers that do not know the device range pass none and get the old
behaviour, which is correct precisely when there is no device clamp to
account for.
"""
return rail_ceiling_w(max_w, dev_min=dev_min, dev_max=dev_max) * RAIL_FRAC
def looks_frozen(unchanged_s, commanded_w, max_w,
cfg: FreezeWatchdogConfig, *, dev_min=None, dev_max=None) -> bool:
"""The predicate, and the ONLY place the conjunction is written down.
Public for the same reason `watchdog.disagrees()` is: the checks that grade
it must assert on THE SHIPPED EXPRESSION. A safety predicate transcribed
into its own test is one copy plus a future disagreement about what the
check means, and the copy that drifts is always the one doing the grading.
`unchanged_s` seconds since the meter served a different measurement, or
None on a transport that cannot know (see the module note).
`commanded_w` the EMITTED setpoint standing on the wire.
`max_w` the configured output bound.
`dev_min` / the setpoint device's own reported range, or None when it
`dev_max` could not be read. SAFETY-20: the rail is measured against
`min(max_w, |dev_min|, |dev_max|)`, because that - not
`max_w` - is what the wire can carry.
⚠️ THE DEVICE BOUNDS ARE KEYWORD-ONLY AND DEFAULT TO None, WHICH IS THE
CONSERVATIVE DIRECTION AND ALSO THE HONEST ONE. A caller that does not know
the rating gets a rail measured against `max_w` - correct, because when this
add-on has no rating it applies no device clamp either (clamp.py's second
stage needs both bounds), so `max_w` really is the ceiling on that path.
"""
if unchanged_s is None or commanded_w is None or not max_w:
return False
# SAFETY-20. A ceiling of 0 W would make term 2 true of EVERY command; that
# is the degenerate case, not the armed one. Silence, and the status page
# says NOT COVERED - see rail_unreachable().
if rail_unreachable(max_w, dev_min=dev_min, dev_max=dev_max):
return False
return (float(unchanged_s) >= cfg.hold_s
and abs(float(commanded_w))
>= rail_w(max_w, dev_min=dev_min, dev_max=dev_max))
class FreezeWatchdog:
"""One instance per controller, evaluated once per control cycle.
Level-triggered state with an EDGE for the alarm: `evaluate()` returns True
on the cycle the conjunction first becomes true and False on every cycle
after, so the caller raises one notification per episode rather than one
every second. `active` is the level, for the status page.
No latch, deliberately - see "NOTIFY, DO NOT TRIP". A latch exists so a trip
cannot erase its own evidence by working; nothing here acts, so there is
nothing to erase. When the meter starts moving again the alert clears
itself, which is the honest report of the state.
"""
def __init__(self, cfg: FreezeWatchdogConfig | None = None):
self.cfg = cfg or FreezeWatchdogConfig()
self.active = False
self.since: datetime | None = None
self.reason = ""
self.episodes = 0
def evaluate(self, unchanged_s, commanded_w, max_w, now: datetime, *,
dev_min=None, dev_max=None) -> bool:
"""Advance one cycle. Returns True ONLY on the rising edge.
`now` is the caller's clock so this stays testable and replayable - the
same discipline as `SignWatchdog.evaluate()`. `dev_min`/`dev_max` are
the setpoint device's reported range; see looks_frozen().
"""
if not looks_frozen(unchanged_s, commanded_w, max_w, self.cfg,
dev_min=dev_min, dev_max=dev_max):
self.active = False
self.since = None
self.reason = ""
return False
# ⚠️ SAFETY-20. THE REASON QUOTES THE CEILING IT ACTUALLY USED, not
# `max_w`. The old line said "at the 5000 W rail (... command over
# 4750 W)" on an install whose device stopped at 4700 W, i.e. it named
# a threshold the command could not reach - the same untruth the status
# page was telling, printed into the log and the HA notification where
# it would outlive the page.
ceiling = rail_ceiling_w(max_w, dev_min=dev_min, dev_max=dev_max)
# UX-10: plain sentences, every figure kept (test_messages.py part 6).
capped = ("" if ceiling >= abs(float(max_w))
else f", capped from max_w {float(max_w):.0f} W by the "
f"inverter's own rating")
self.reason = (
f"METER_FROZEN: the meter has not changed for "
f"{float(unchanged_s):.0f} s while the command is "
f"{float(commanded_w):+.0f} W, at the {ceiling:.0f} W limit"
f"{capped}. Limits: unchanged over {self.cfg.hold_s:.0f} s, "
f"command over "
f"{rail_w(max_w, dev_min=dev_min, dev_max=dev_max):.0f} W")
if self.active:
return False
self.active = True
self.since = now
self.episodes += 1
return True