SAFETY-52: a battery that refuses is asked for 0 W, with a 200 W probe every 15 min #141

Merged
admin merged 3 commits from SAFETY-52-refused-hold into release/1.0 2026-09-27 18:21:22 +02:00
Owner

SAFETY-52 (Trello 6aade38496ed60391d98124a), option D3 from the phase A plan. The owner approved it on 2026-09-27, answering yes to all four questions.

What changes

  • compute() carries a refused direction (refused=, Decision.refused), much like sat_count.
    • Engages when the saturation freeze has held for SETTLE_CYCLES and the battery reads within saturation_w of 0 against a larger ask.
    • While engaged: that direction is held at 0 W, i_w is 0, and the reason is battery refuses.
    • Exits when the loop wants the other direction (charging is accepted at the floor), or when a probe is answered with at least 100 W.
    • refused=None leaves release/1.0's law unchanged, including the i_w=None equivalence path.
  • main.py:
    • Runs a 200 W probe every 15 min, 34 s long, on now_mono. 200 W is below freshness.MOVE_W (250 W), so a probe can never arm the stale-reading gate.
    • Treats a stale-reading trip while the loop is frozen on a ~0 W reading as a refusal. This ends SAFETY-59's ~70 s cycle.
    • Ends the refusal on stop/start and when another writer reseeds the loop. A gap in the readings keeps it.
    • Applies all of this at one inverter only; with more, OPT-09's pack limit already covers it.
    • No SoC anywhere in the loop (SAFETY-25 Option A stands).
  • Words:
    • The Why line reads "The battery refuses to give power: 0 W" and "Testing if the battery gives power again: 200 W". Both are in messages.WHY_WINNER, reached through why_key().
    • Two new hints.
    • INFO lines end with the code BATTERY_REFUSES, followed by a DEBUG evidence line.
    • DOCS.md gains the two Loop reason values and a short "When the battery refuses" paragraph.
  • Record: DECISIONS/SAFETY-52-a-battery-that-refuses.md, including what SAFETY-58/59 must take into account. This lands before them.

Tests

test_refusal.py drives the real Controller (read_inputs + cycle) and the real SAFETY-37/57 gate. The battery reads a flat -4 W at the cut-out, and simulated time comes from now_mono. Each scenario is run on the old law beside the new one.

  • 09-06 shape:
    • Old law: +5000 W for 11.87 h.
    • New law: 0 W from 58 s after the cut, 0 gate trips in 12 h, and the refusal ends on the morning's charge.
  • 09-19 shape:
    • compute() alone: the old law parks at +1940 W on 2000 of 2000 cycles; the new law holds 0 W within SETTLE_CYCLES.
    • With the gate: the old law trips 191 times (the ~70 s cycle); the new law trips once, then holds.
  • Probes: 45 in 11.8 h, 935 s apart, 35 s long, always exactly 200 W.
  • Recovery with no sign change:
    • Old law: -3630 W at the grid.
    • New law: never exports more than the probe. The answered probe resumes control, and the event log says so.
  • Exits: charge, answered probe, stop/start, and another writer.
  • Words: the Why text for both reasons.

Re-baselined on purpose:

  • test_clamp.py: the "inverter switched off" fixture now ends in battery refuses at 0 W, not in a standing freeze.
  • test_delivery.py N=1: stops at 11 cycles, before the hold engages.
  • test_messages.py: now scans the two new reasons.

Mutations. Each was run on a purged copy with python3 -B over test_refusal / control / clamp / telemetry / messages. All nine go red; the counts are failing checks.

# mutation failing checks
R1 engage removed (release/1.0's behaviour) 8 + 1 in test_clamp
R2 exit on the other direction removed 1
R3 exit on an answered probe removed 2
R4 PROBE_W = 300 4
R5 hold keeps the old target 4
R6 seeding from a stale trip removed 1
R7 another writer ignored 1
R8 stop/start ignored 1
R9 probe never opens 4

The full suite (37 files) passes locally in the CI image, with the infra checkout at the d637d0b pin. SAFETY-13 (test_control.py), SAFETY-48 (test_telemetry.py) and SAFETY-57 (test_freshness.py) are unchanged and green.

Replay (16-day capture, tools/replay_refusal.py)

This uses the same model as replay_recorded_loop.py, plus main.py's hold rules and the real gate. The figures are modelled watt-hours, so read the deltas.

plant law gate trips request >1 kW above delivery longest held >=250 W import / export kWh worst 15-min
fitted old 85 113.3 h 16.57 h 112.0 / 35.2 3434 W
fitted new 130 11.4 h 0.21 h 112.3 / 35.4 3456 W
offset 0 W old 1010 102.2 h 16.58 h 112.0 / 35.2 3434 W
offset 0 W new 85 6.1 h 0.33 h 111.9 / 35.1 3434 W

On the fitted plant, the +22 W worst quarter and the +45 trips follow the fit's -22 W offset. That offset slowly charges the modelled pack above its floor while the hold stands, and with the offset at 0 both disappear. That link is inferred from these two rows; I did not trace it further.

Not covered

A battery that gives a steady part of the ask (for example 500 W) is a genuine limit and still holds its request.

The TEST-23 verification follows as a comment on this PR.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p

SAFETY-52 (Trello 6aade38496ed60391d98124a), option D3 from the phase A plan. The owner approved it on 2026-09-27, answering yes to all four questions. ## What changes - **`compute()` carries a refused direction** (`refused=`, `Decision.refused`), much like `sat_count`. - **Engages** when the saturation freeze has held for `SETTLE_CYCLES` and the battery reads within `saturation_w` of 0 against a larger ask. - **While engaged:** that direction is held at **0 W**, `i_w` is 0, and the reason is `battery refuses`. - **Exits** when the loop wants the other direction (charging is accepted at the floor), or when a probe is answered with at least 100 W. - `refused=None` leaves release/1.0's law unchanged, including the `i_w=None` equivalence path. - **main.py:** - Runs a **200 W probe every 15 min, 34 s long**, on `now_mono`. 200 W is below `freshness.MOVE_W` (250 W), so a probe can never arm the stale-reading gate. - Treats a **stale-reading trip while the loop is frozen on a ~0 W reading** as a refusal. This ends SAFETY-59's ~70 s cycle. - Ends the refusal on **stop/start** and when **another writer** reseeds the loop. A gap in the readings keeps it. - Applies all of this at **one inverter only**; with more, OPT-09's pack limit already covers it. - No SoC anywhere in the loop (SAFETY-25 Option A stands). - **Words:** - The Why line reads "The battery refuses to give power: 0 W" and "Testing if the battery gives power again: 200 W". Both are in `messages.WHY_WINNER`, reached through `why_key()`. - Two new hints. - INFO lines end with the code `BATTERY_REFUSES`, followed by a DEBUG evidence line. - `DOCS.md` gains the two `Loop reason` values and a short "When the battery refuses" paragraph. - **Record:** `DECISIONS/SAFETY-52-a-battery-that-refuses.md`, including what SAFETY-58/59 must take into account. This lands before them. ## Tests `test_refusal.py` drives the real `Controller` (`read_inputs` + `cycle`) and the real SAFETY-37/57 gate. The battery reads a flat -4 W at the cut-out, and simulated time comes from `now_mono`. Each scenario is run on the old law beside the new one. - **09-06 shape:** - Old law: +5000 W for 11.87 h. - New law: 0 W from 58 s after the cut, 0 gate trips in 12 h, and the refusal ends on the morning's charge. - **09-19 shape:** - `compute()` alone: the old law parks at +1940 W on 2000 of 2000 cycles; the new law holds 0 W within `SETTLE_CYCLES`. - With the gate: the old law trips 191 times (the ~70 s cycle); the new law trips once, then holds. - **Probes:** 45 in 11.8 h, 935 s apart, 35 s long, always exactly 200 W. - **Recovery with no sign change:** - Old law: -3630 W at the grid. - New law: never exports more than the probe. The answered probe resumes control, and the event log says so. - **Exits:** charge, answered probe, stop/start, and another writer. - **Words:** the Why text for both reasons. **Re-baselined on purpose:** - `test_clamp.py`: the "inverter switched off" fixture now ends in `battery refuses` at 0 W, not in a standing freeze. - `test_delivery.py` N=1: stops at 11 cycles, before the hold engages. - `test_messages.py`: now scans the two new reasons. **Mutations.** Each was run on a purged copy with `python3 -B` over test_refusal / control / clamp / telemetry / messages. All nine go red; the counts are failing checks. | # | mutation | failing checks | |---|---|---| | R1 | engage removed (release/1.0's behaviour) | 8 + 1 in test_clamp | | R2 | exit on the other direction removed | 1 | | R3 | exit on an answered probe removed | 2 | | R4 | `PROBE_W = 300` | 4 | | R5 | hold keeps the old target | 4 | | R6 | seeding from a stale trip removed | 1 | | R7 | another writer ignored | 1 | | R8 | stop/start ignored | 1 | | R9 | probe never opens | 4 | The full suite (37 files) passes locally in the CI image, with the infra checkout at the d637d0b pin. SAFETY-13 (`test_control.py`), SAFETY-48 (`test_telemetry.py`) and SAFETY-57 (`test_freshness.py`) are unchanged and green. ## Replay (16-day capture, `tools/replay_refusal.py`) This uses the same model as `replay_recorded_loop.py`, plus main.py's hold rules and the real gate. The figures are modelled watt-hours, so read the deltas. | plant | law | gate trips | request >1 kW above delivery | longest held >=250 W | import / export kWh | worst 15-min | |---|---|---|---|---|---|---| | fitted | old | 85 | 113.3 h | 16.57 h | 112.0 / 35.2 | 3434 W | | fitted | new | 130 | 11.4 h | 0.21 h | 112.3 / 35.4 | 3456 W | | offset 0 W | old | 1010 | 102.2 h | 16.58 h | 112.0 / 35.2 | 3434 W | | offset 0 W | new | 85 | 6.1 h | 0.33 h | 111.9 / 35.1 | 3434 W | On the fitted plant, the +22 W worst quarter and the +45 trips follow the fit's -22 W offset. That offset slowly charges the modelled pack above its floor while the hold stands, and with the offset at 0 both disappear. That link is inferred from these two rows; I did not trace it further. ## Not covered A battery that gives a steady part of the ask (for example 500 W) is a genuine limit and still holds its request. The TEST-23 verification follows as a comment on this PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
admin added 1 commit 2026-09-27 16:43:58 +02:00
SAFETY-52: a battery that refuses is asked for 0 W, with a 200 W probe every 15 min
infra-pin / fresh (pull_request) Successful in 1s
checks / unit (pull_request) Failing after 9m32s
1f97c7652f
At the BMS cut-out the saturation freeze held the last request for hours
(+5000 W for 11.87 h on 09-06, +1940 W for 3 h 55 m on 09-19) until the grid
error changed sign. compute() now carries a refused direction: after
SETTLE_CYCLES of a ~0 W reading against a larger ask it holds that direction
at 0 W, probes 200 W (below the gate's 250 W) every 15 minutes, and lets go
on charge, an answered probe, stop/start or another writer. A stale-reading
trip on a frozen ~0 W reading seeds the same hold, which ends SAFETY-59's
~70 s cycle. One inverter only; no SoC in the loop (SAFETY-25 Option A).

test_refusal.py drives the real Controller and gate; tools/replay_refusal.py
replays the 16-day capture. test_clamp.py and test_delivery.py re-baselined
where they asserted the old hold. DECISIONS/SAFETY-52 has the record and what
SAFETY-58/59 must take into account.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
admin added 1 commit 2026-09-27 17:04:58 +02:00
test_price: grade the Why row of the same render, not a second read of why_words
checks / unit (pull_request) Successful in 9m20s
infra-pin / fresh (pull_request) Successful in 1s
1d33265aac
Since UX-10 the waiting sentence counts its seconds when it is read, so the
second read could cross a second boundary on a loaded runner ("1 second" in
the row, "2 seconds" in the check). CI run 953 on this PR failed that way;
test_price.py passes locally on the same tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

TEST-23 verification at 1f97c76 (the control code is identical at the head 1d33265, which only fixes a flaky check in test_price.py). Run 2026-09-27, 16:44 to 17:36 CEST.

Setup

  • Paused the soak, stopped both add-ons, and tarred both /data volumes with a sha256 and owner manifest. tariff.db passed integrity_check on both.
  • Built test23-goodwe-addon:1f97c76; its 34 app/*.py hashes match the clone.
  • Ran it on the same volume, IP, port, hostname and env, using options.json.pre-doc1 (one inverter, max_w 2000) with auto_start: true and log_level: debug. up.sh was not run.
  • A host script plays the battery: it follows the power request, except during the emulated cut-out, when it reads exactly -4 W for any request >= -4 W (charging still accepted). The grid reads house minus battery.

Timeline. Power requests are the values written to number.test23_setpoint_w, change points only.

time event power request
16:47:47 house load 600 -> 2600 W ramps 620 -> 2000 W (rail) by 16:47:56
16:48:03 BMS cut-out (battery -4 W) 2000 W, saturation freeze at 16:48:05
16:48:15 INFO The battery refuses to give power, so the add-on asks 0 W and tries 200 W every 15 minutes: BATTERY_REFUSES., DEBUG Evidence: BATTERY_REFUSES: asked +2000 W, the battery gave -4 W for 12 cycles. 0 W
16:48:33 load ends, house 900 W, grid +904 W 0 W (the old law would stand at 2000 W)
17:03:16 to 17:03:50 probe 1, unanswered 200 W for 34 s, then 0 W
17:18:51 to 17:19:24 probe 2, unanswered (15 min 35 s after probe 1) 200 W for 33 s, then 0 W
17:19:35 battery recovers, no sign change (house still 900 W) 0 W, held until the next probe
17:34:25 probe 3 answered. INFO The battery refusal ends, control asks for power again: the probe was answered. 200 -> 420 -> 640 -> 860 -> 910 W by 17:34:29, grid -10 W
  • The page:
    • During the hold, Why reads "The battery refuses to give power: 0 W" and the hint "The battery refuses to give power, the add-on asks 0 W".
    • During a probe, Why reads "Testing if the battery gives power again: 200 W".
    • After recovery, Why reads "Keeping the grid near zero: 910 W" and the hint "The battery refusal ended, control asks for power again".
    • The event log shows BATTERY REFUSES asked +2000 W, … and BATTERY REFUSES ENDED: the probe was answered.
  • No gate cycling: 0 STALE_READING trips over 46 minutes with the register flat at -4 W. There were no ERROR lines; the only WARNINGs were the startup price and config notices, the 30 s meter wait before the host script started, and one OUTPUT_CLAMP during the ramp.

Restore

  • Removed the test container, renamed the original back (:16c796f, hostname a5a20d236eb6), and emptied both volumes, then restored them from the tars (tar sha256 re-checked).
  • Every file's sha256, owner and mode match the manifest, and both tariff.db pass integrity_check.
  • Options and state are back to fa602196…6f5b / aa4848a9…b530 on the main add-on and 8837dbed… / cd6dbdf2… on arb.
  • HA inputs are back to setpoint 0, soc 52.4 and batt 363.4; grid is driven by the soak again. Both add-ons are running and the soak is unpaused.
  • Nothing contacted .158, .221 or .167.
## TEST-23 verification at `1f97c76` (the control code is identical at the head `1d33265`, which only fixes a flaky check in test_price.py). Run 2026-09-27, 16:44 to 17:36 CEST. **Setup** - Paused the soak, stopped both add-ons, and tarred both `/data` volumes with a sha256 and owner manifest. `tariff.db` passed `integrity_check` on both. - Built `test23-goodwe-addon:1f97c76`; its 34 `app/*.py` hashes match the clone. - Ran it on the same volume, IP, port, hostname and env, using `options.json.pre-doc1` (one inverter, `max_w` 2000) with `auto_start: true` and `log_level: debug`. `up.sh` was not run. - A host script plays the battery: it follows the power request, except during the emulated cut-out, when it reads exactly **-4 W for any request >= -4 W** (charging still accepted). The grid reads house minus battery. **Timeline.** Power requests are the values written to `number.test23_setpoint_w`, change points only. | time | event | power request | |---|---|---| | 16:47:47 | house load 600 -> 2600 W | ramps 620 -> 2000 W (rail) by 16:47:56 | | 16:48:03 | **BMS cut-out** (battery -4 W) | 2000 W, saturation freeze at 16:48:05 | | 16:48:15 | INFO `The battery refuses to give power, so the add-on asks 0 W and tries 200 W every 15 minutes: BATTERY_REFUSES.`, DEBUG `Evidence: BATTERY_REFUSES: asked +2000 W, the battery gave -4 W for 12 cycles.` | **0 W** | | 16:48:33 | load ends, house 900 W, grid +904 W | 0 W (the old law would stand at 2000 W) | | 17:03:16 to 17:03:50 | **probe 1**, unanswered | 200 W for 34 s, then 0 W | | 17:18:51 to 17:19:24 | **probe 2**, unanswered (15 min 35 s after probe 1) | 200 W for 33 s, then 0 W | | 17:19:35 | battery recovers, no sign change (house still 900 W) | 0 W, held until the next probe | | 17:34:25 | **probe 3 answered**. INFO `The battery refusal ends, control asks for power again: the probe was answered.` | 200 -> 420 -> 640 -> 860 -> 910 W by 17:34:29, grid -10 W | - **The page:** - During the hold, Why reads "The battery refuses to give power: 0 W" and the hint "The battery refuses to give power, the add-on asks 0 W". - During a probe, Why reads "Testing if the battery gives power again: 200 W". - After recovery, Why reads "Keeping the grid near zero: 910 W" and the hint "The battery refusal ended, control asks for power again". - The event log shows `BATTERY REFUSES asked +2000 W, …` and `BATTERY REFUSES ENDED: the probe was answered`. - **No gate cycling:** 0 `STALE_READING` trips over 46 minutes with the register flat at -4 W. There were no ERROR lines; the only WARNINGs were the startup price and config notices, the 30 s meter wait before the host script started, and one OUTPUT_CLAMP during the ramp. **Restore** - Removed the test container, renamed the original back (`:16c796f`, hostname a5a20d236eb6), and emptied both volumes, then restored them from the tars (tar sha256 re-checked). - Every file's sha256, owner and mode match the manifest, and both `tariff.db` pass `integrity_check`. - Options and state are back to `fa602196…6f5b` / `aa4848a9…b530` on the main add-on and `8837dbed…` / `cd6dbdf2…` on arb. - HA inputs are back to setpoint 0, soc 52.4 and batt 363.4; grid is driven by the soak again. Both add-ons are running and the soak is unpaused. - Nothing contacted .158, .221 or .167.
Author
Owner

Review of 1d33265 (SAFETY-52): two defects to fix, the rest checks out

D1. False engagement on a battery that gives some power (defect). The engage clause treats "near 0" as |actual_w| < saturation_w (500 W), but the probe needs 100 W for an answer. A battery derated to 100-499 W (cold, tapering) engages. Then it answers the probe, control ramps, and it engages again 12 cycles later. Measured with compute() on a 1-tick plant capped at 300 W, house 2000 W, 4 h: old law 1200 Wh delivered, this PR 86 Wh (held at 0 W 92.7 % of the time). The same happens at 150 W and 450 W. The recorded capture has only 0.11 h in that band, so the replay can't see it. A client's battery could.

D2. A healthy battery can be held at 0 W for 15 min (defect). SETTLE_CYCLES is 12 cycles, and meter_poll_s accepts 1 s. At a 1 s meter that is 12 s, but a healthy reading lags up to BATT_OBLIGED_S (17 s). Measured: at a 1 s tick, 6 s dead time and 10 s publish at mid phase, a 0 -> 3000 W step engages at 23 s. It then holds until the next probe, because only a reading taken during a probe ends it, although the reading shows the battery delivering. 2 s and 5 s ticks don't engage. For the same reason, an answer that arrives after the 34 s probe window is missed.

Fix, pushing to this branch: (a) engage, and seed from a stale trip, only below REFUSED_BELOW_W = PROBE_ANSWER_W (100 W); (b) any reading of 100 W or more in the held direction ends the refusal, not only one taken during a probe. After the fix: 300 W derated delivers the same as the old law and is never held. The 1 s case engages once and is released after 2 s.

Traced: 85 -> 130 trips and +22 W (no longer "inferred"). I instrumented the replay.

  • The worst quarter is the same quarter for both laws (1789372800). The old law stands at +2060 W with the pack giving 0 W. The new law asks 0 W, and the fitted plant (g*0 + o) charges 22.3 W from the grid. That is the +22 W exactly. The fit above the floor is batt = cmd - 28 W, so a real inverter asked 0 W may do the same, at about 25 W. That is harmless.
  • Of the 71 new-law trips with no old-law trip within 2 min, 59 come 85-97 s after an answered probe. The chain: modelled SoC 10.00-10.02 % (the offset lifted it above the floor), the probe is answered, control ramps, the pack cuts again mid-ramp, the gate trips, and that re-seeds the hold. The other 12 follow an other-direction exit the same way.
  • On a site this is at most one STALE_READING ERROR line per answered probe, and only on a battery that answers and then cuts again (for example after a voltage rebound). I found no fix that doesn't accuse a healthy battery: catching the re-cut before the gate needs an engage faster than SETTLE_CYCLES. I'm recording it in DECISIONS for SAFETY-58/59 rather than blocking this PR.

Checked, OK:

  • Probe vs gate: the gate needs |standing| >= MOVE_W now (freshness.py _grade), so a 200 W probe cannot arm it. Part 2 pins PROBE_W < MOVE_W, and mutation R4 goes red (4).
  • Probe vs peak guard: the probe only goes in the direction the loop already wants (wants = refused*i_w > 0). A discharge probe needs import, and a charge probe needs export beyond the deadband. A peak lo limit that clips it makes target != loop_w, which ends the hold ("another writer"), so the peak guard wins.
  • Shadow: self.target is re-seeded to WIRE_W every cycle, so |prev_w| > 500 never holds and it never engages. The write gate would block a probe anyway.
  • Restart: the state isn't persisted. After a restart the add-on re-learns within 12 cycles, or on one gate trip, from a pack reading ~0. There is no recovery hazard.
  • Latches (SAFETY-13/48/57): I found none. Export past the deadband exits the hold (i_w < 0), and the deadband holds 0 W at the target. A dead register seeded as a refusal sits behind the gate's own stop, as before.
  • N>1: refused=None skips the block. _refusal_from_stale is gated len == 1, the probe is off while batt_refused == 0, and the stop/start and another-writer ends are no-ops. The law is unchanged. DOCS overstates the N>1 coverage (the pack limit only helps when the cut-out level is configured right), and I'm rewording it as a stated limit.
  • Re-baselines: justified. test_clamp's switched-off inverter reads 0 W, which is a refusal. test_delivery N=1 still carries "the window was never even opened", which keeps it non-vacuous at 11 cycles. I'm fixing its stale "2 s" comment.
  • Mutations re-run on fresh copies, over test_refusal/control/clamp/telemetry/messages: R3 red (4), R4 red (4), R6 red (2 with the new seed test).
## Review of 1d33265 (SAFETY-52): two defects to fix, the rest checks out **D1. False engagement on a battery that gives some power (defect).** The engage clause treats "near 0" as `|actual_w| < saturation_w` (500 W), but the probe needs 100 W for an answer. A battery derated to 100-499 W (cold, tapering) engages. Then it answers the probe, control ramps, and it engages again 12 cycles later. Measured with `compute()` on a 1-tick plant capped at 300 W, house 2000 W, 4 h: old law 1200 Wh delivered, this PR **86 Wh (held at 0 W 92.7 % of the time)**. The same happens at 150 W and 450 W. The recorded capture has only 0.11 h in that band, so the replay can't see it. A client's battery could. **D2. A healthy battery can be held at 0 W for 15 min (defect).** `SETTLE_CYCLES` is 12 *cycles*, and `meter_poll_s` accepts 1 s. At a 1 s meter that is 12 s, but a healthy reading lags up to `BATT_OBLIGED_S` (17 s). Measured: at a 1 s tick, 6 s dead time and 10 s publish at mid phase, a 0 -> 3000 W step engages at 23 s. It then holds until the next probe, because only a reading taken *during a probe* ends it, although the reading shows the battery delivering. 2 s and 5 s ticks don't engage. For the same reason, an answer that arrives after the 34 s probe window is missed. **Fix, pushing to this branch:** (a) engage, and seed from a stale trip, only below `REFUSED_BELOW_W = PROBE_ANSWER_W` (100 W); (b) any reading of 100 W or more in the held direction ends the refusal, not only one taken during a probe. After the fix: 300 W derated delivers the same as the old law and is never held. The 1 s case engages once and is released after 2 s. **Traced: 85 -> 130 trips and +22 W (no longer "inferred").** I instrumented the replay. - The worst quarter is the *same* quarter for both laws (1789372800). The old law stands at +2060 W with the pack giving 0 W. The new law asks 0 W, and the fitted plant (`g*0 + o`) charges 22.3 W from the grid. That is the +22 W exactly. The fit above the floor is `batt = cmd - 28 W`, so a real inverter asked 0 W may do the same, at about 25 W. That is harmless. - Of the 71 new-law trips with no old-law trip within 2 min, **59 come 85-97 s after an answered probe**. The chain: modelled SoC 10.00-10.02 % (the offset lifted it above the floor), the probe is answered, control ramps, the pack cuts again mid-ramp, the gate trips, and that re-seeds the hold. The other 12 follow an other-direction exit the same way. - **On a site** this is at most one `STALE_READING` ERROR line per answered probe, and only on a battery that answers and then cuts again (for example after a voltage rebound). I found no fix that doesn't accuse a healthy battery: catching the re-cut before the gate needs an engage faster than `SETTLE_CYCLES`. I'm recording it in DECISIONS for SAFETY-58/59 rather than blocking this PR. **Checked, OK:** - **Probe vs gate:** the gate needs `|standing| >= MOVE_W` *now* (freshness.py `_grade`), so a 200 W probe cannot arm it. Part 2 pins `PROBE_W < MOVE_W`, and mutation R4 goes red (4). - **Probe vs peak guard:** the probe only goes in the direction the loop already wants (`wants = refused*i_w > 0`). A discharge probe needs import, and a charge probe needs export beyond the deadband. A peak `lo` limit that clips it makes target != loop_w, which ends the hold ("another writer"), so the peak guard wins. - **Shadow:** `self.target` is re-seeded to `WIRE_W` every cycle, so `|prev_w| > 500` never holds and it never engages. The write gate would block a probe anyway. - **Restart:** the state isn't persisted. After a restart the add-on re-learns within 12 cycles, or on one gate trip, from a pack reading ~0. There is no recovery hazard. - **Latches (SAFETY-13/48/57):** I found none. Export past the deadband exits the hold (i_w < 0), and the deadband holds 0 W at the target. A dead register seeded as a refusal sits behind the gate's own stop, as before. - **N>1:** `refused=None` skips the block. `_refusal_from_stale` is gated `len == 1`, the probe is off while `batt_refused == 0`, and the stop/start and another-writer ends are no-ops. The law is unchanged. DOCS overstates the N>1 coverage (the pack limit only helps when the cut-out level is configured right), and I'm rewording it as a stated limit. - **Re-baselines:** justified. test_clamp's switched-off inverter reads 0 W, which is a refusal. test_delivery N=1 still carries "the window was never even opened", which keeps it non-vacuous at 11 cycles. I'm fixing its stale "2 s" comment. - **Mutations re-run** on fresh copies, over test_refusal/control/clamp/telemetry/messages: R3 red (4), R4 red (4), R6 red (2 with the new seed test).
admin added 1 commit 2026-09-27 18:11:44 +02:00
SAFETY-52 review: a refusal is under 100 W, and any delivery ends it
checks / unit (pull_request) Successful in 9m22s
infra-pin / fresh (pull_request) Successful in 1s
3c0f2b9669
- Engage (and seed from a stale stop) below REFUSED_BELOW_W, the probe's
  own 100 W answer, not below saturation_w: a battery derated to 100-499 W
  was held at 0 W ~93 % of the time.
- A reading of 100 W or more in the held direction ends the refusal, probe
  or not: at a 1 s meter SETTLE_CYCLES is 12 s, shorter than a healthy
  reading's lag, and that held a healthy step at 0 W until the next probe.
- test_refusal part 5b and a seed test; DOCS states the N>1 limit; the
  DECISIONS record carries the traced cause of the replay's extra trips.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

Review fixes pushed: 3c0f2b9 (on 1d33265)

  • D1 fixed. control.REFUSED_BELOW_W = PROBE_ANSWER_W (100 W) is the engage threshold in compute() and in _refusal_from_stale(). Part 5b: a battery derated to 300 W is never held, and it delivers exactly what the old law got from it.
  • D2 fixed. The exit is now refused * actual_w >= PROBE_ANSWER_W on any cycle, not only during a probe. _refusal_end says "the battery gives power" outside a probe and keeps "the probe was answered" during one. Part 5b: at a 1 s meter a healthy late reading engages once and is released after 2 s. Before the fix it was held until the next probe.
  • Replay after the fix: fitted plant 133 trips (was 130; the delivery exit now also catches late probe answers, which re-ramp). The other columns are unchanged: 11.4 h / 0.21 h / 112.3 / 35.4 / 3456 W. Offset 0 is unchanged at 85 / 6.1 h / 3434 W.
  • Words: DOCS now defines refusing as "less than 100 W", lists the new exit, and has a ⚠️ one-inverter-only paragraph stating the N>1 limit for client sites. DECISIONS has the traced cause of the extra trips and the +22 W, plus a "Review changes" section. The test_delivery comment is corrected.
  • Mutations (fresh copies, python3 -B, CI image): R3 red (4), R4 red (4), R6 red (2), R10 seed threshold back to saturation_w red (1), R11 and probe back in the exit red (2). The same threshold mutation in compute()'s engage clause survives. That's expected and documented in the test docstring: the delivery exit ends such a refusal in the same cycle, so the two answer one question.
  • Full suite: 37/37 pass locally in goodwe-addon-checks:1 with infra at the d637d0b pin.

Waiting for CI, then merging.

## Review fixes pushed: 3c0f2b9 (on 1d33265) - **D1 fixed.** `control.REFUSED_BELOW_W = PROBE_ANSWER_W` (100 W) is the engage threshold in `compute()` and in `_refusal_from_stale()`. Part 5b: a battery derated to 300 W is never held, and it delivers exactly what the old law got from it. - **D2 fixed.** The exit is now `refused * actual_w >= PROBE_ANSWER_W` on any cycle, not only during a probe. `_refusal_end` says "the battery gives power" outside a probe and keeps "the probe was answered" during one. Part 5b: at a 1 s meter a healthy late reading engages once and is released after 2 s. Before the fix it was held until the next probe. - **Replay after the fix:** fitted plant 133 trips (was 130; the delivery exit now also catches late probe answers, which re-ramp). The other columns are unchanged: 11.4 h / 0.21 h / 112.3 / 35.4 / 3456 W. Offset 0 is unchanged at 85 / 6.1 h / 3434 W. - **Words:** DOCS now defines refusing as "less than 100 W", lists the new exit, and has a ⚠️ one-inverter-only paragraph stating the N>1 limit for client sites. DECISIONS has the traced cause of the extra trips and the +22 W, plus a "Review changes" section. The test_delivery comment is corrected. - **Mutations** (fresh copies, `python3 -B`, CI image): R3 red (4), R4 red (4), R6 red (2), **R10** seed threshold back to `saturation_w` red (1), **R11** `and probe` back in the exit red (2). The same threshold mutation in `compute()`'s engage clause *survives*. That's expected and documented in the test docstring: the delivery exit ends such a refusal in the same cycle, so the two answer one question. - **Full suite:** 37/37 pass locally in `goodwe-addon-checks:1` with infra at the d637d0b pin. Waiting for CI, then merging.
admin merged commit 652097abde into release/1.0 2026-09-27 18:21:22 +02:00
Author
Owner

Merged as 652097a (ls-remote verified) with head pinned at 3c0f2b9. PR CI green: checks/unit and infra-pin/fresh. Post-merge infra-pin / fresh (push) green. No pin bump needed.

Merged as 652097a (ls-remote verified) with head pinned at 3c0f2b9. PR CI green: checks/unit and infra-pin/fresh. Post-merge infra-pin / fresh (push) green. No pin bump needed.
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/goodwe-addon#141