From 83911ed63785b6b22217921bc2d6c9107e206891 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 1 Sep 2026 02:14:07 +0200 Subject: [PATCH] SAFETY-20: measure the freeze rail against the reachable ceiling, not max_w 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) --- goodwe_controller/CHANGELOG.md | 67 +++++++++ goodwe_controller/DOCS.md | 20 ++- goodwe_controller/app/clamp.py | 14 +- goodwe_controller/app/freeze.py | 185 +++++++++++++++++++++++-- goodwe_controller/app/main.py | 50 ++++++- goodwe_controller/app/validate.py | 23 ++++ goodwe_controller/test_freeze.py | 214 ++++++++++++++++++++++++++++- goodwe_controller/test_validate.py | 34 +++++ 8 files changed, 582 insertions(+), 25 deletions(-) diff --git a/goodwe_controller/CHANGELOG.md b/goodwe_controller/CHANGELOG.md index ba6e6ff..b1d37a9 100644 --- a/goodwe_controller/CHANGELOG.md +++ b/goodwe_controller/CHANGELOG.md @@ -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 diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 459bc29..14f87da 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -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 | diff --git a/goodwe_controller/app/clamp.py b/goodwe_controller/app/clamp.py index 7b9acba..e7a9a87 100644 --- a/goodwe_controller/app/clamp.py +++ b/goodwe_controller/app/clamp.py @@ -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 diff --git a/goodwe_controller/app/freeze.py b/goodwe_controller/app/freeze.py index 1a11544..16a6f69 100644 --- a/goodwe_controller/app/freeze.py +++ b/goodwe_controller/app/freeze.py @@ -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 diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 509893b..b416e7a 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -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 diff --git a/goodwe_controller/app/validate.py b/goodwe_controller/app/validate.py index 1dbe6f7..14343dd 100644 --- a/goodwe_controller/app/validate.py +++ b/goodwe_controller/app/validate.py @@ -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 diff --git a/goodwe_controller/test_freeze.py b/goodwe_controller/test_freeze.py index d9f6a9f..470ea15 100644 --- a/goodwe_controller/test_freeze.py +++ b/goodwe_controller/test_freeze.py @@ -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. diff --git a/goodwe_controller/test_validate.py b/goodwe_controller/test_validate.py index 4645058..95e3002 100644 --- a/goodwe_controller/test_validate.py +++ b/goodwe_controller/test_validate.py @@ -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, -- 2.54.0