SAFETY-20: measure the freeze rail against the reachable ceiling, not max_w
checks / unit (pull_request) Successful in 3m37s

SAFETY-18's conjunction needs `abs(commanded_w) >= 0.95 * max_w`, but the
emitted command is clipped to the setpoint device's own [dev_min, dev_max]
range as well as to max_w - in clamp_to_rating()'s second stage and again as
the arbiter's `device` P_SAFETY limit claim. Measured on the shipped predicate
at the ticket's numbers, max_w 5000 on a +-4700 W inverter:

    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 0.95 * max_w disarms the conjunction outright, and
the status page then printed `armed (... command is over 4750 W)` - a guard
that cannot fire, advertising that it can, which is the failure SAFETY-15,
SAFETY-19 and validate.py rule 5 all exist to prevent.

The rail is now measured against min(max_w, |dev_min|, |dev_max|), through a
new `rail_ceiling_w()` threaded into looks_frozen(), FreezeWatchdog.evaluate()
and both call sites in main.py. An unread rating leaves the old behaviour
exactly, and correctly: hass.limits() returns None whenever ESPHome is not up,
and on that path no device clamp is applied either, so max_w really is the
ceiling. A rating larger than max_w cannot raise the rail - this is a min(),
not a swap.

THE DELIBERATE CHOICE, since SAFETY-03 has landed. The arithmetic was kept
rather than substituting Decision.i_clamped / Decision.clamped. i_clamped is
the integrator bound (control.py:208, `bounded != i_w`), not "the command is
at the device rating", and `clamped` is the max_w output bound, unreachable on
a default install - neither is the device rail. More decisively, both answer
"did the LOOP ask for more than a bound", while the rail term asks "is the
command standing at the ceiling", and those coincide only when the loop is the
claimer that won. A maintenance SET or a peak-guard floor reaches the ceiling
through arbiter LIMIT claims with no clamp engaging at all, so a boolean rail
would go False exactly during a maintenance phase running against a frozen
meter - which freeze.py's own docstring names as the same fault with a worse
actuator behind it. The predicate is also public with two call sites that pass
DIFFERENT command values (self.target, and `standing` = last_write); a
per-cycle boolean cannot be passed per call site, so substituting it would
have silently decided the pre-clamp-versus-wire question that PM recorded as
still open. AC 1 governs, and it is satisfied literally.

The smaller of the two device bounds, not a per-sign pair. A per-sign rail is
tighter and is not wrong, but it is two rails needing two coverage verdicts on
a status row that carries one number, and the only configuration that
distinguishes them is refused at startup anyway. The symmetric ceiling can only
lower the rail, which widens term 2 rather than disarming it; term 1 carries
the whole no-false-positive argument and is untouched.

A ceiling of 0 W is NOT COVERED, not "armed at 0 W". A device that can command
nothing in one direction would make `abs(command) >= 0` true of every command
including a parked one - term 2 degenerately TRUE, collapsing the conjunction
into the bare unchanged_s > N threshold TEL-05 refused for false-tripping at
this controller's own operating point. rail_unreachable() returns the reason,
looks_frozen() stays silent, and the page uses the NOT COVERED row the
unsupported-transport case already has - a second reason to use that surface,
not a new mechanism.

AC 4, decided rather than assumed: NO NEW validate.py RULE. The configuration
is max_w above the device rating, which rule 2 (CFG-01) already refuses on the
identical predicate, re-run by run_control() once the real rating is known:

    validate_startup_config({... max_w: 5000}, -4700, 4700)
      -> ['max_w is 5000 W but number.sp accepts at most 4700 W - THE DEVICE
          WINS ...']

A rail-specific rule would fire on a strict subset of that (0.95 * max_w >
dev_max implies max_w > dev_max) and could never be the rule that stopped a
start. The residue is the state where hass.limits() answered (None, None) -
ESPHome not up at boot, range checks skipped rather than guessed at, rating
never re-read - and there is nothing to refuse there by construction, which is
why the answer is a runtime row. Recorded in validate.py's "deliberately not
refused" list and pinned by four checks in test_validate.py, so the decision's
premise goes red if rule 2 is ever softened back to a warning.

HOW REACHABLE THIS IS, stated rather than glossed. Because rule 2 refuses the
configuration whenever the rating is known, min(max_w, dev_max) IS max_w on
every install that reaches the control loop with a rating it could read, and
this change is arithmetically dead there. That is clamp.py's argument for its
own second stage word for word: the case where it bites is the case where the
startup gate did not run or no longer holds, and a guard that quietly assumes
the gate ran is a guard that reports armed in exactly the state it cannot fire
in.

Verified by breaking it, per this project's rule. Reverting rail_w() to bare
max_w fails nine checks; deleting the NOT COVERED handling fails five. The one
that mattered: shipping the corrected predicate while NOT passing the rating
from check_meter_frozen() left the entire suite green, because nothing graded
the caller - so test_freeze.py part 5b now drives the real cycle() on a +-4700 W
device and asserts on the notification that leaves the process, and the
unwiring mutation fails three checks there. The peak-guard call site's wiring
is still ungraded (behind p1_enabled, needs a fed averager for `frozen` to have
any observable consequence) and that gap is named in the file rather than
implied.

Full suite: 15/15 in goodwe-addon-ci.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-01 02:14:07 +02:00
co-authored by Claude Opus 5
parent 358fbb0172
commit 83911ed637
8 changed files with 582 additions and 25 deletions
+67
View File
@@ -2,6 +2,73 @@
## Unreleased
**A setpoint device smaller than `max_w` silently disarmed the frozen-meter
detector, and the status page reported it as armed (SAFETY-20).** SAFETY-18's
conjunction needs `abs(commanded_w) >= 0.95 * max_w`, but the emitted command is
clipped to the setpoint device's own `[dev_min, dev_max]` range as well as to
`max_w` — in `clamp_to_rating()` and again as the arbiter's `device` P_SAFETY
limit claim. Measured on the shipped predicate at the ticket's numbers,
`max_w: 5000` on a ±4700 W inverter:
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
rating at or below `0.95 * max_w` disarms the conjunction outright. The page
then printed `armed (… command is over 4750 W)` — a guard that cannot fire,
advertising that it can, which is the failure SAFETY-15, SAFETY-19 and
validate.py rule 5 all exist to prevent.
**The rail is now measured against `min(max_w, |dev_min|, |dev_max|)`** —
`freeze.rail_ceiling_w()`, threaded through `looks_frozen()`,
`FreezeWatchdog.evaluate()` and both call sites in `main.py`. An unread rating
(`hass.limits()` returning `None` when ESPHome is not up) leaves the old
behaviour exactly, and correctly: on that path no device clamp is applied
either, so `max_w` really is the ceiling. A rating *larger* than `max_w` cannot
raise the rail — this is a `min()`, not a swap. The status row prints the rail
the predicate actually uses and names the device when the device is what capped
it; the log line and the HA notification do the same, since they outlive the
page.
**The smaller of the two device bounds, not a per-sign pair.** A per-sign rail
is tighter and is not wrong, but it is two rails needing two coverage verdicts
on a status row that carries one number, and the only configuration that
distinguishes them is refused at startup anyway. The symmetric ceiling can only
*lower* the rail, which widens term 2 rather than disarming it; term 1 carries
the no-false-positive argument and is untouched.
**A ceiling of 0 W is NOT COVERED, not "armed at 0 W".** A device that can
command nothing in one direction would make `abs(command) >= 0` true of every
command including a parked one — term 2 degenerately *true*, collapsing the
conjunction into the bare `unchanged_s > N` threshold TEL-05 refused for
false-tripping at this controller's own operating point. `rail_unreachable()`
returns the reason, `looks_frozen()` stays silent, and the page uses the NOT
COVERED row the unsupported-transport case already has — a second reason to use
that surface, not a new mechanism.
**AC4, decided rather than assumed: no new `validate.py` rule.** The
configuration is `max_w` above the device rating, which rule 2 (CFG-01) already
refuses on the identical predicate, re-run by `run_control()` once the real
rating is known. A rail-specific rule would fire on a strict subset of what rule
2 refuses (`0.95 * max_w > dev_max` implies `max_w > dev_max`) and could never
be the rule that stopped a start. The residue is the state where `hass.limits()`
answered `(None, None)` and the range checks were skipped — there is nothing to
refuse there, by construction, which is why the answer is a runtime row. Written
into validate.py's "deliberately not refused" list, and *pinned* by four checks
in `test_validate.py` so the decision's premise goes red if rule 2 is ever
softened back to a warning.
**And it was verified by breaking it.** Reverting `rail_w()` to bare `max_w`
fails nine checks; deleting the NOT COVERED handling fails five; and — the one
that mattered — shipping the corrected predicate while *not* passing the rating
from `check_meter_frozen()` originally left the whole suite green, because
nothing graded the caller. `test_freeze.py` part 5b now drives the real
`cycle()` on a ±4700 W device and asserts on the notification that leaves the
process; the unwiring mutation fails three checks there. The peak-guard call
site's wiring is still ungraded — it sits behind `p1_enabled` and needs a fed
averager to have any observable consequence — and that gap is named in the file
rather than implied.
**`release/1.0` went red at midnight on a commit nobody had touched (TEST-17).**
`Controller.checks()` read the capacity-tariff peak with `self.peak.peak()` - no
argument - so `tariff.py` resolved the month from `datetime.now()`, while
+19 -1
View File
@@ -640,7 +640,18 @@ The add-on alarms when **both** of these are true at once:
- the meter's own document (power figure *and* both kWh registers) has not
changed for `meter_freeze_s` — 300 s by default; **and**
- the command standing on the wire is at the rail, within 5 % of `max_w`.
- the command standing on the wire is at the rail, within 5 % of the ceiling
the command can actually reach.
**That ceiling is `min(max_w, the setpoint device's own rating)`, not `max_w`.**
The emitted command is clipped to the device's reported range as well as to
`max_w`, so on a 4700 W inverter configured with `max_w: 5000` a rail measured
at 4750 W is one the command can never stand at — the guard could not fire, and
the status page said `armed` anyway. It now measures the rail against the
smaller number and the page names it, saying *"below max_w because …"* when the
device is what capped it. (On an install that started, the two are equal: a
`max_w` above the device rating is refused at startup. This matters when the
rating could not be read at boot — see below.)
Neither half is usable alone. A bare "unchanged for N seconds" rule false-trips
at the exact operating point this controller aims for — at a converged −10 W a
@@ -666,6 +677,13 @@ that reads the meter's own document; on `ha_dsmr`, `mqtt_p1`, `ha_signed` and
`off` there is no signal to threshold and the status page says **NOT COVERED**
rather than pretending the guard is armed.
The same row appears for a second reason: a setpoint device that reports a range
from which no command magnitude is available in both directions leaves no rail
to measure at all, and a 0 W rail would be satisfied by *every* command. That is
reported as **NOT COVERED** too, and the detector stays silent rather than
degenerating into the bare "unchanged for N seconds" rule this design exists to
avoid.
| option | default | meaning |
|---|---|---|
| `meter_freeze_s` | 300 | Seconds of an unchanged measurement, *while the command is at the rail*, before the alarm |
+12 -2
View File
@@ -46,8 +46,18 @@ the validation did not run or no longer holds - `hass.limits()` returns
(None, None) whenever ESPHome is not up yet, and the range checks are skipped
rather than guessed at - and that is a louder fault than the first stage, not a
quieter one. It therefore alarms too, through the same channel, with the bound
named. This is NOT SAFETY-20; nothing here changes what `freeze.py` measures its
rail against.
named.
⚠️ SAFETY-20 UPDATED THE SECOND HALF OF THAT SENTENCE. It used to read "this is
NOT SAFETY-20; nothing here changes what `freeze.py` measures its rail against",
and that was true of this module and false of the system: this stage is exactly
what clips the emitted command below `RAIL_FRAC * max_w`, which is how a device
rating disarmed the freeze detector while the page said `armed`. `freeze.py` now
takes `dev_min`/`dev_max` and measures its rail against
`min(max_w, |dev_min|, |dev_max|)`. THE CLAMP ITSELF IS UNCHANGED - it still
clamps to `max_w` first and to the device range second, for the reasons above,
and it still does not `min()` the two. What changed is only that the detector
downstream now knows this stage exists.
------------------------------------------------------------------------------
WHERE THE WIND ACTUALLY SHOWS UP - READ THIS BEFORE "SIMPLIFYING" THE CALLER
+170 -15
View File
@@ -151,12 +151,77 @@ THE RAIL TERM, PRECISELY
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) and clamped to `max_w` (min 100), so at the rail
the emitted value IS `max_w` on every schema-legal combination; the 5 % is slack
for a device rating that clips `max_w` slightly in `write_setpoint()`, not a
tuning knob. Making it configurable would offer a way to widen the one term that
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
@@ -192,6 +257,8 @@ 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
@@ -203,13 +270,72 @@ class FreezeWatchdogConfig:
hold_s: float = 300.0
def rail_w(max_w) -> float:
"""The magnitude at or above which a command counts as railed."""
return abs(float(max_w)) * RAIL_FRAC
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) -> bool:
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
@@ -220,12 +346,28 @@ def looks_frozen(unchanged_s, commanded_w, max_w,
`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 the rail is measured against.
`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))
and abs(float(commanded_w))
>= rail_w(max_w, dev_min=dev_min, dev_max=dev_max))
class FreezeWatchdog:
@@ -249,24 +391,37 @@ class FreezeWatchdog:
self.reason = ""
self.episodes = 0
def evaluate(self, unchanged_s, commanded_w, max_w, now: datetime) -> bool:
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()`.
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):
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)
capped = ("" if ceiling >= abs(float(max_w))
else f", capped from max_w {float(max_w):.0f} W by the "
f"setpoint device's own rating")
self.reason = (
f"METER_FROZEN: the meter has answered {float(unchanged_s):.0f} s "
f"without its measurement changing while the command stands at "
f"{float(commanded_w):+.0f} W, at the {float(max_w):.0f} W rail "
f"{float(commanded_w):+.0f} W, at the {ceiling:.0f} W rail{capped} "
f"(limits: unchanged over {self.cfg.hold_s:.0f} s, command over "
f"{rail_w(max_w):.0f} W)")
f"{rail_w(max_w, dev_min=dev_min, dev_max=dev_max):.0f} W)")
if self.active:
return False
self.active = True
+46 -4
View File
@@ -42,7 +42,7 @@ from .clamp import (
is_commandable,
)
from .freeze import (
FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_w,
FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_unreachable, rail_w,
)
from .hass import HomeAssistant
from .maintenance import IDLE, MaintConfig, Maintenance
@@ -557,8 +557,14 @@ class Controller:
"peak_soc_floor",
peakguard.PEAK_OPTION_DEFAULTS["peak_soc_floor"])),
max_hold_s=self.p1.averager.max_hold_s,
# ⚠️ SAFETY-20. The device range goes in TOO, and this call site is
# the one that needed it most: `standing` is `last_write`, i.e. the
# value AFTER `clamp_to_rating()` applied `[dev_min, dev_max]`, so
# grading it against a rail derived from bare `max_w` compares a
# device-clipped number to a ceiling the device cannot reach.
frozen=looks_frozen(getattr(self.p1_source, "unchanged_s", None),
standing, max_w, self.freeze_wd.cfg),
standing, max_w, self.freeze_wd.cfg,
dev_min=self.dev_min, dev_max=self.dev_max),
)
peak_claim = (peakguard.claim(self.peak_guard)
if self.peak_guard is not None else None)
@@ -1035,7 +1041,9 @@ class Controller:
unchanged = getattr(self.p1_source, "unchanged_s", None)
fired = self.freeze_wd.evaluate(unchanged, self.target,
float(self.o.get("max_w", 2000)),
datetime.now(timezone.utc))
datetime.now(timezone.utc),
dev_min=self.dev_min,
dev_max=self.dev_max)
if not fired:
return
self.log_event("METER FROZEN? " + self.freeze_wd.reason)
@@ -1332,12 +1340,46 @@ class Controller:
f"meter_source {o.get('meter_source')} - only "
f"homewizard_local sees whether the meter's own "
f"document changes"})
elif rail_unreachable(o.get("max_w", 2000),
dev_min=self.dev_min, dev_max=self.dev_max):
# SAFETY-20, and THE SECOND REASON TO USE THIS ROW RATHER THAN A
# NEW MECHANISM. The transport case above and this one are the
# same statement - "the conjunction cannot be evaluated here" -
# and an operator does not need to learn two vocabularies for
# it. What differs is only which term is missing: there,
# `unchanged_s`; here, a rail the command could ever stand at.
out.append({"ok": False, "warn": True,
"text": f"freeze detector: NOT COVERED - "
+ rail_unreachable(
o.get("max_w", 2000),
dev_min=self.dev_min,
dev_max=self.dev_max)})
else:
# ⚠️ SAFETY-20. THE RAIL PRINTED HERE IS THE RAIL THE PREDICATE
# USES, device range included. This row used to interpolate
# `rail_w(max_w)` while `looks_frozen()` graded a command the
# device had already clipped below it, so on any install whose
# inverter is smaller than its configured ceiling the page named
# a number the command could never reach and called it `armed`.
# Both the row and the predicate now read the same three inputs.
_rail = rail_w(o.get("max_w", 2000),
dev_min=self.dev_min, dev_max=self.dev_max)
# And when the device is what set that number, SAY SO - the
# operator configured `max_w` and is owed the reason the guard
# is armed somewhere else.
# ⚠️ Either bound may be None on its own - `hass.limits()`
# reads `min` and `max` independently and returns None for
# whichever attribute is missing or unparseable - so this
# renders whatever is there rather than formatting a None.
_cap = ("" if _rail >= rail_w(o.get("max_w", 2000))
else f" - below max_w because "
f"{o.get('setpoint_entity') or 'the setpoint device'}"
f" accepts only {self.dev_min}...{self.dev_max} W")
out.append({"ok": True, "warn": False,
"text": f"freeze detector: armed (alerts after "
f"{self.freeze_wd.cfg.hold_s:.0f} s unchanged "
f"while the command is over "
f"{rail_w(self.o.get('max_w', 2000)):.0f} W)"})
f"{_rail:.0f} W{_cap})"})
# TARIFF-01. The month's billed peak, and - the load-bearing half -
# a DIFFERENT row when the store could not answer. "no peak yet" is
+23
View File
@@ -170,6 +170,29 @@ WHAT IS DELIBERATELY *NOT* REFUSED, because it is untidy rather than unsafe:
- `maintenance_discharge_w` above `max_w`. Clamped, not contradicted, and the
clamp names itself in the arbiter's explain() - see rule 6. Warned about,
not refused.
- A device rating that makes SAFETY-18's freeze rail unreachable. SAFETY-20's
AC 4, decided here rather than left implicit: **NO NEW RULE, and rule 2 is
the reason.** The configuration the ticket is about is `max_w` above the
setpoint device's rating, which is rule 2 EXACTLY - already refused, on the
identical predicate, since CFG-01, and re-run by `run_control()` once
ESPHome is up and the real rating is known. A second rule phrased as
"`RAIL_FRAC * max_w` exceeds the device rating" would fire on a strict
subset of the configurations rule 2 already refuses (`0.95 * max_w > dev_max`
implies `max_w > dev_max`), so it could never be the rule that stopped a
start, and its whole visible effect would be a second entry in a refusal
list that already names the problem.
⚠️ AND IT WOULD NOT COVER THE CASE THAT REMAINS, WHICH IS WHY THE STATUS ROW
IS NOT A CONSOLATION PRIZE. The residue after rule 2 is the state where
`hass.limits()` answered `(None, None)` - ESPHome not up when the add-on
booted - so the range checks were SKIPPED rather than guessed at, and the
rating is never re-read afterwards. There is nothing to refuse at startup in
that state, by construction: the number a refusal would need does not exist
yet. That is a runtime coverage question, and the runtime surface -
`freeze.rail_unreachable()` and the NOT COVERED row it feeds - is where it
is answered. Compare rule 5, which CAN be settled at startup because both
`sign_disagree_w` and `max_w` are config and are both known at boot.
- `peak_soc_floor` below `maintenance_soc_floor`, or above it. They are
DIFFERENT QUANTITIES (SAFETY-24: a drain target versus a spend limit) and
every ordering of the two is a legitimate site configuration. Refusing on
+211 -3
View File
@@ -79,7 +79,8 @@ import aiohttp
from app.control import Tuning, compute
from app.freeze import (
RAIL_FRAC, FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_w,
RAIL_FRAC, FreezeWatchdog, FreezeWatchdogConfig, looks_frozen,
rail_ceiling_w, rail_unreachable, rail_w,
)
from app.main import Controller
from app.p1 import HomeWizardLocalSource, P1Ingest
@@ -155,6 +156,87 @@ check("no transport signal (unchanged_s None) -> silent, never assumed frozen",
check("no command yet -> silent", not looks_frozen(3600, None, 5000, cfg))
check("rail_w tracks RAIL_FRAC", rail_w(5000) == 5000 * RAIL_FRAC)
# --------------------------------------------------------------------------- #
# 1c. SAFETY-20 - the rail is measured against what the WIRE can carry
# --------------------------------------------------------------------------- #
# ⚠️ EVERY CHECK IN THIS SECTION WAS RED BEFORE THE FIX AND IS THE FIX'S ONLY
# EVIDENCE. The shipped rail was `RAIL_FRAC * max_w` and the emitted command is
# clipped to the SETPOINT DEVICE's range as well (clamp.py's second stage, and
# the arbiter's `device` P_SAFETY claim), so any rating at or below
# `0.95 * max_w` made term 2 unsatisfiable by construction while the status page
# went on printing `armed (... over 4750 W)`.
#
# THIS IS ALSO THE CHECK THAT HAS TO GO RED IF ANYONE REVERTS THE RAIL TO BARE
# `max_w` (AC3). Verified by doing it - reinstating
# `return abs(float(max_w)) * RAIL_FRAC` in `rail_w()` fails the six checks
# below plus three in part 6, and nothing else in the suite.
print("SAFETY-20: the rail against the device rating")
# The ticket's own numbers: a 5 kW ceiling on a 4.7 kW inverter.
DEV = dict(dev_min=-4700.0, dev_max=4700.0)
check("the rail is the DEVICE's rail, not max_w's, when the device is smaller",
rail_w(LIVE_MAX_W, **DEV) == 4700.0 * RAIL_FRAC
and rail_w(LIVE_MAX_W, **DEV) != LIVE_MAX_W * RAIL_FRAC)
check("a command pinned at the device rating with a meter frozen for an hour "
"FIRES - this is the ticket, and it returned False before the fix",
looks_frozen(3600, 4700, LIVE_MAX_W, cfg, **DEV))
check("...and the identical call without the rating is still False, so the "
"check above is measuring the rating and not something else",
not looks_frozen(3600, 4700, LIVE_MAX_W, cfg))
check("the charge rail moves with it too - the runaway can be either sign",
looks_frozen(3600, -4700, LIVE_MAX_W, cfg, **DEV))
check("just under the DEVICE rail is still silent, so the fix widened term 2 "
"by exactly the rating and not into a bare unchanged_s threshold",
not looks_frozen(3600, 4700 * RAIL_FRAC - 1, LIVE_MAX_W, cfg, **DEV))
check("a device BIGGER than max_w cannot raise the rail - max_w is still a "
"ceiling the operator set and this is a min(), not a swap",
rail_w(LIVE_MAX_W, dev_min=-9000.0, dev_max=9000.0)
== LIVE_MAX_W * RAIL_FRAC)
check("an unread rating (ESPHome not up) leaves the old behaviour exactly - "
"no device clamp is applied on that path either, so max_w IS the ceiling",
rail_w(LIVE_MAX_W, dev_min=None, dev_max=None) == LIVE_MAX_W * RAIL_FRAC)
check("one bound alone still lowers it - hass.limits() reads min and max "
"independently and either can come back None on its own",
rail_w(LIVE_MAX_W, dev_max=4000.0) == 4000.0 * RAIL_FRAC)
check("a non-finite rating is IGNORED, never read as a 0 W ceiling",
rail_w(LIVE_MAX_W, dev_min=float("nan"), dev_max=float("inf"))
== LIVE_MAX_W * RAIL_FRAC)
check("the ceiling helper says what it measured",
rail_ceiling_w(LIVE_MAX_W, **DEV) == 4700.0)
# The degenerate end. A ceiling of 0 W does not disarm term 2, it makes it TRUE
# OF EVERY COMMAND - including a parked 0 W one - which decays the conjunction
# into the bare `unchanged_s > N` threshold TEL-05 refused for false-tripping at
# this controller's own operating point.
check("a device that can command nothing makes the rail UNREACHABLE, with a "
"reason for the status page",
bool(rail_unreachable(LIVE_MAX_W, dev_min=0.0, dev_max=0.0)))
check("...and a reachable rail says so with an empty string, or the row above "
"would be permanent",
rail_unreachable(LIVE_MAX_W, **DEV) == ""
and rail_unreachable(LIVE_MAX_W) == "")
check("...and the predicate is SILENT there rather than degenerately true - "
"0 W would otherwise satisfy `abs(command) >= 0`",
not looks_frozen(3600, 0.0, LIVE_MAX_W, cfg, dev_min=0.0, dev_max=0.0)
and not looks_frozen(3600, 5000, LIVE_MAX_W, cfg, dev_min=0.0,
dev_max=0.0))
# The alarm text is the third surface that carried the untrue number - it
# outlives the page, in the log and in the HA notification.
_wd20 = FreezeWatchdog(cfg)
_wd20.evaluate(3600, 4700, LIVE_MAX_W, T0, **DEV)
print(f" at max_w {LIVE_MAX_W:.0f} on a +-4700 W device: "
f"{_wd20.reason}")
check("the alarm reason quotes the rail it actually used and NOT the max_w one "
"the command could never have reached",
f"command over {4700 * RAIL_FRAC:.0f} W" in _wd20.reason
and f"{LIVE_MAX_W * RAIL_FRAC:.0f} W" not in _wd20.reason)
check("...and it says the device is what capped it, so the operator is not "
"left wondering why the guard is armed somewhere other than max_w",
"rating" in _wd20.reason and "max_w" in _wd20.reason)
print("the edge")
wd = FreezeWatchdog(cfg)
edges = [wd.evaluate(u, c, 5000, T0 + timedelta(seconds=i))
@@ -649,19 +731,24 @@ def notes_by_text_of(hass):
if "frozen" in str(d.get("title", "")).lower()]
def wired(unchanged_s, grid, cycles=60, **over):
def wired(unchanged_s, grid, cycles=60, dev=(-5000.0, 5000.0), **over):
"""Returns (hass, controller, trace).
`trace` is one row per cycle - (command after the cycle, the watchdog's
level, notifications raised so far) - which is what AC1 needs: the
QUESTION IS NOT WHETHER THE ALARM HAPPENS BUT ON WHICH CYCLE.
⚠️ `dev` IS THE SETPOINT DEVICE'S OWN REPORTED RANGE, and it is a parameter
rather than a constant because SAFETY-20 is entirely about installs where it
is NOT the default +-5000. It defaults to the live install's rating, which
equals its `max_w` - which is exactly why that install never saw the defect.
"""
async def go():
hass = _Hass(grid)
c = Controller(_opts(**over), hass, _Store(), _Pub(),
stop=asyncio.Event())
c.auto = True
c.dev_min, c.dev_max = -5000.0, 5000.0
c.dev_min, c.dev_max = dev
c.p1_source = _Source(unchanged_s)
trace = []
for _ in range(cycles):
@@ -994,6 +1081,127 @@ check("...and it quotes the hold and the rail it is armed at, derived from the "
check("...as an ok row, since a covered armed guard is not a warning",
bool(_rows_c) and _rows_c[0]["ok"] and not _rows_c[0]["warn"])
# --------------------------------------------------------------------------- #
# 5b. SAFETY-20 - is the device range actually WIRED into the guard? (AC1)
# --------------------------------------------------------------------------- #
# ⚠️ THIS SECTION EXISTS BECAUSE THE FIX SURVIVED ITS OWN MUTATION TEST WITHOUT
# IT. Deleting `dev_min=`/`dev_max=` from `check_meter_frozen()`'s call - i.e.
# shipping the corrected predicate and then not telling it the rating - left
# every check in 1c and 6b green, because those grade `freeze.py` and the status
# string and neither of them goes through `cycle()`. A guard that is right in the
# module and unwired in the caller is the defect with an extra step.
#
# So this drives the REAL `Controller.cycle()` on a controller configured the way
# the ticket describes - `max_w: 5000` on a +-4700 W inverter - and asserts on
# the notification that leaves the process.
#
# ⚠️ WHAT IT STILL DOES NOT COVER, stated rather than implied: the OTHER call
# site, `looks_frozen(...)` inside the SAFETY-07 peak-guard block, is behind
# `if self.p1_enabled` and needs a fed averager to have any observable
# consequence (`blindness()` returns BLIND_NO_BLOCK before it ever reads
# `frozen`). That gate is uncovered here for the same reason freeze_rows() says
# it is uncovered there, and widening the fixture belongs to whoever covers it.
print("SAFETY-20: the device range through the REAL cycle()")
_h20, _c20, _t20 = wired(3600.0, 4000.0, dev=(-4700.0, 4700.0))
_n20 = notes_of(_h20)
print(f" max_w 5000 on a +-4700 W device, meter frozen an hour: the command "
f"stood at {_c20.target:+.0f} W, {len(_n20)} freeze notification(s), "
f"watchdog active={_c20.freeze_wd.active}")
check("PRECONDITION: the device rating - not max_w - is what stopped the "
"command, so this really is the configuration the ticket is about",
abs(_c20.target) == 4700.0)
check("...and that command is BELOW the max_w rail, which is why the shipped "
"guard could never fire on this install",
abs(_c20.target) < rail_w(LIVE_MAX_W))
check("...and at or above the device rail, which is why it must",
abs(_c20.target) >= rail_w(LIVE_MAX_W, dev_min=-4700.0, dev_max=4700.0))
check("THE ALARM FIRES through the real cycle() - the rating reaches the "
"predicate from the controller, not just from a unit call",
len(_n20) == 1 and _c20.freeze_wd.active)
check("...and the notification the operator gets quotes the capped rail, "
"not the max_w one",
bool(_n20)
and f"{rail_w(LIVE_MAX_W, dev_min=-4700.0, dev_max=4700.0):.0f} W"
in str(_n20[0].get("message", "")))
# p1_enabled is what a homewizard_local install has - set by hand here for the
# same reason part 4 sets it, see freeze_rows().
_c20.p1_enabled = True
check("...and the red status row is up too, so the page and the notification "
"agree",
any("METER MAY BE FROZEN" in r["text"] for r in _c20.checks()))
# --------------------------------------------------------------------------- #
# 6b. SAFETY-20 - what the page says when the DEVICE is the ceiling (AC2)
# --------------------------------------------------------------------------- #
# ⚠️ THIS IS THE ACCEPTANCE CRITERION READ OFF THE RENDERED PAGE, NOT OFF THE
# SOURCE. The card is explicit that reading the code does not settle it, so
# every assertion below goes through the real `Controller.checks()` on a
# controller that was CONFIGURED with a device smaller than its `max_w` and
# then run - three real cycles, the real arbiter, the real write path.
print("what the page says when the device rating is the smaller ceiling")
# max_w 5000 on a 4700 W inverter - the ticket's own install. Covered transport,
# so the only question is what the ARMED row says.
_ctl_s, _rows_s = freeze_rows(2.0, -14.2, dev=(-4700.0, 4700.0))
# And the degenerate end: a device that can command nothing at all.
_ctl_z, _rows_z = freeze_rows(2.0, -14.2, dev=(0.0, 0.0))
print(f" max_w 5000 on a +-4700 W device: {_rows_s[0]['text'] if _rows_s else '(no row)'}")
print(f" a device with no commandable range: {_rows_z[0]['text'] if _rows_z else '(no row)'}")
check("a device smaller than max_w still gets exactly one freeze-detector row",
len(_rows_s) == 1)
check("...and the page does NOT print the max_w rail the command can never "
"reach - THE DEFECT, in one assertion",
bool(_rows_s)
and f"{rail_w(LIVE_MAX_W):.0f} W" not in _rows_s[0]["text"])
check("...it prints the rail the predicate actually uses",
bool(_rows_s)
and f"{rail_w(LIVE_MAX_W, dev_min=-4700.0, dev_max=4700.0):.0f} W"
in _rows_s[0]["text"])
check("...and it names the device range as the reason, since the operator "
"configured max_w and is owed the reason the guard is armed elsewhere",
bool(_rows_s) and "4700" in _rows_s[0]["text"]
and "max_w" in _rows_s[0]["text"])
# ⚠️ THE NUMBER IS TAKEN OFF THE ROW AND FED BACK INTO THE SHIPPED PREDICATE.
# Asserting the page against `rail_w(...)` alone proves the two agree, which a
# revert of BOTH to bare max_w also satisfies. What a revert cannot satisfy is
# this: the rail the page advertises has to be a magnitude the wire can carry,
# and the wire stops at the device rating.
_m = re.search(r"command is over (\d+(?:\.\d+)?) W", _rows_s[0]["text"]) if _rows_s else None
_page_rail = float(_m.group(1)) if _m else None
print(f" the page advertises a rail of {_page_rail} W against a device that "
f"stops at {_ctl_s.dev_max:.0f} W")
check("...the row states a rail in watts at all, so the parse below means "
"something", _page_rail is not None)
check("...and THE RAIL THE PAGE ADVERTISES IS ONE THE WIRE CAN ACTUALLY CARRY. "
"This is the whole ticket: 4750 W on a device that stops at 4700 W is a "
"guard that cannot fire, advertising that it can",
_page_rail is not None and _page_rail <= abs(_ctl_s.dev_max))
check("...and a command at exactly that advertised rail, with the meter frozen, "
"really does trip the shipped predicate the row is describing",
_page_rail is not None
and looks_frozen(3600, _page_rail, LIVE_MAX_W, cfg,
dev_min=_ctl_s.dev_min, dev_max=_ctl_s.dev_max))
check("...as an ok row - a guard armed at a lower rail is still armed, not a "
"warning", bool(_rows_s) and _rows_s[0]["ok"] and not _rows_s[0]["warn"])
check("a device with no commandable range gets exactly one row", len(_rows_z) == 1)
check("...and it says NOT COVERED - the rail is not merely low there, it is "
"0 W, which would be TRUE of every command including a parked one",
bool(_rows_z) and "NOT COVERED" in _rows_z[0]["text"])
check("...and it NEVER says armed",
bool(_rows_z) and "armed" not in _rows_z[0]["text"])
check("...flagged as a warning rather than a silent ok, like the uncovered "
"transport row it shares",
bool(_rows_z) and not _rows_z[0]["ok"] and _rows_z[0]["warn"])
check("...and the predicate agrees with the page rather than firing behind it",
not looks_frozen(3600, 0.0, LIVE_MAX_W, cfg,
dev_min=_ctl_z.dev_min, dev_max=_ctl_z.dev_max))
# AC3's other half, through the built object rather than through a literal: the
# opts dict does NOT carry meter_freeze_s, so this is the number an install that
# leaves the key at its config.yaml default actually lands on.
+34
View File
@@ -76,6 +76,40 @@ check("an unreachable device skips the range check rather than assuming",
check("a non-numeric max_w is refused",
why(validate_startup_config(base(max_w="lots"), -5000, 5000), "not a number"))
# --------------------------------------------------------------------------- #
# 1b. SAFETY-20's AC4 decision, PINNED HERE RATHER THAN ONLY WRITTEN DOWN
# --------------------------------------------------------------------------- #
# AC4 asked whether a device rating that disarms SAFETY-18's freeze rail
# additionally warrants a refusal of its own, the way `sign_disagree_w >= max_w`
# is refused. THE ANSWER IS NO, AND THE REASON IS RULE 2 ABOVE: the configuration
# is `max_w` above the device rating, which rule 2 already refuses on the
# identical predicate. A rail-specific rule (`0.95 * max_w > dev_max`) would fire
# on a strict SUBSET of what rule 2 refuses, so it could never be the rule that
# stopped a start.
#
# ⚠️ THAT DECISION HAS A PREMISE, AND THIS IS THE PREMISE, EXECUTED. If rule 2 is
# ever softened to a warning - which is exactly what main.py used to do before
# CFG-01 - the reasoning recorded in validate.py's "deliberately not refused"
# list silently stops being true, and SAFETY-18 is left with only its runtime
# NOT COVERED row. Then somebody has to come back and re-decide AC4.
from app.freeze import RAIL_FRAC # noqa: E402 - imported here, beside its use
_DEV = 4700.0 # the ticket's inverter
_CEIL = 5000.0 # the ticket's configured ceiling
check("SAFETY-20's premise: a rating below max_w disarms the freeze rail, so "
f"{RAIL_FRAC:g} * max_w is unreachable on the ticket's install",
_DEV < RAIL_FRAC * _CEIL)
check("...and rule 2 REFUSES that whole configuration at startup, which is why "
"SAFETY-20 added no rule of its own (AC4)",
len(validate_startup_config(base(max_w=_CEIL), -_DEV, _DEV)) == 1)
check("...on the rating side specifically, not incidentally through some other "
"rule", why(validate_startup_config(base(max_w=_CEIL), -_DEV, _DEV),
"THE DEVICE WINS"))
check("...and the case the refusal CANNOT reach - the rating unread because "
"ESPHome was not up - still starts, which is precisely why SAFETY-20's "
"answer had to be a runtime status row and not a startup gate",
validate_startup_config(base(max_w=_CEIL), None, None) == [])
# --------------------------------------------------------------------------- #
print("2. an actuator to write to")
# A controller with no actuator: write_setpoint() returns on the blank entity,