CI-02 probe F: the ORIGINAL gate, unchanged, on the same unmergeable PR
checks / unit (pull_request) Successful in 3m38s

This commit is contained in:
glenn schrooyen
2026-09-01 03:38:00 +02:00
parent 4c64aa2106
commit 6ff223d44b
25 changed files with 1269 additions and 217 deletions
+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