Merge pull request 'SAFETY-20: measure the freeze rail against the reachable ceiling, not max_w' (#28) from SAFETY-20 into release/1.0
This commit was merged in pull request #28.
This commit is contained in:
@@ -2,6 +2,72 @@
|
||||
|
||||
## 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.
|
||||
**A mutation run could grade code that was not there, and the fix is a purge
|
||||
rather than a flag (TEST-15).** FCST-03's developer reverted `self.provisional`
|
||||
to `here.provisional`, re-ran, and reported a survivor; disassembly showed the
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -86,7 +86,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
|
||||
@@ -162,6 +163,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))
|
||||
@@ -656,19 +738,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):
|
||||
@@ -1001,6 +1088,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.
|
||||
|
||||
@@ -83,6 +83,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,
|
||||
|
||||
Reference in New Issue
Block a user