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.
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
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>
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>
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.
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_Wnow (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).
- 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>
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), R11and 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.02026-09-27 18:21:22 +02:00
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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 likesat_count.SETTLE_CYCLESand the battery reads withinsaturation_wof 0 against a larger ask.i_wis 0, and the reason isbattery refuses.refused=Noneleaves release/1.0's law unchanged, including thei_w=Noneequivalence path.now_mono. 200 W is belowfreshness.MOVE_W(250 W), so a probe can never arm the stale-reading gate.messages.WHY_WINNER, reached throughwhy_key().BATTERY_REFUSES, followed by a DEBUG evidence line.DOCS.mdgains the twoLoop reasonvalues and a short "When the battery refuses" paragraph.DECISIONS/SAFETY-52-a-battery-that-refuses.md, including what SAFETY-58/59 must take into account. This lands before them.Tests
test_refusal.pydrives the realController(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 fromnow_mono. Each scenario is run on the old law beside the new one.compute()alone: the old law parks at +1940 W on 2000 of 2000 cycles; the new law holds 0 W withinSETTLE_CYCLES.Re-baselined on purpose:
test_clamp.py: the "inverter switched off" fixture now ends inbattery refusesat 0 W, not in a standing freeze.test_delivery.pyN=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 -Bover test_refusal / control / clamp / telemetry / messages. All nine go red; the counts are failing checks.PROBE_W = 300The 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.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
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>TEST-23 verification at
1f97c76(the control code is identical at the head1d33265, which only fixes a flaky check in test_price.py). Run 2026-09-27, 16:44 to 17:36 CEST.Setup
/datavolumes with a sha256 and owner manifest.tariff.dbpassedintegrity_checkon both.test23-goodwe-addon:1f97c76; its 34app/*.pyhashes match the clone.options.json.pre-doc1(one inverter,max_w2000) withauto_start: trueandlog_level: debug.up.shwas not run.Timeline. Power requests are the values written to
number.test23_setpoint_w, change points only.The battery refuses to give power, so the add-on asks 0 W and tries 200 W every 15 minutes: BATTERY_REFUSES., DEBUGEvidence: BATTERY_REFUSES: asked +2000 W, the battery gave -4 W for 12 cycles.The battery refusal ends, control asks for power again: the probe was answered.BATTERY REFUSES asked +2000 W, …andBATTERY REFUSES ENDED: the probe was answered.STALE_READINGtrips 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
:16c796f, hostname a5a20d236eb6), and emptied both volumes, then restored them from the tars (tar sha256 re-checked).tariff.dbpassintegrity_check.fa602196…6f5b/aa4848a9…b530on the main add-on and8837dbed…/cd6dbdf2…on arb.Review of
1d33265(SAFETY-52): two defects to fix, the rest checks outD1. 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 withcompute()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_CYCLESis 12 cycles, andmeter_poll_saccepts 1 s. At a 1 s meter that is 12 s, but a healthy reading lags up toBATT_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.
g*0 + o) charges 22.3 W from the grid. That is the +22 W exactly. The fit above the floor isbatt = cmd - 28 W, so a real inverter asked 0 W may do the same, at about 25 W. That is harmless.STALE_READINGERROR 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 thanSETTLE_CYCLES. I'm recording it in DECISIONS for SAFETY-58/59 rather than blocking this PR.Checked, OK:
|standing| >= MOVE_Wnow (freshness.py_grade), so a 200 W probe cannot arm it. Part 2 pinsPROBE_W < MOVE_W, and mutation R4 goes red (4).wants = refused*i_w > 0). A discharge probe needs import, and a charge probe needs export beyond the deadband. A peaklolimit that clips it makes target != loop_w, which ends the hold ("another writer"), so the peak guard wins.self.targetis re-seeded toWIRE_Wevery cycle, so|prev_w| > 500never holds and it never engages. The write gate would block a probe anyway.refused=Noneskips the block._refusal_from_staleis gatedlen == 1, the probe is off whilebatt_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.Review fixes pushed:
3c0f2b9(on1d33265)control.REFUSED_BELOW_W = PROBE_ANSWER_W(100 W) is the engage threshold incompute()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.refused * actual_w >= PROBE_ANSWER_Won any cycle, not only during a probe._refusal_endsays "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.python3 -B, CI image): R3 red (4), R4 red (4), R6 red (2), R10 seed threshold back tosaturation_wred (1), R11and probeback in the exit red (2). The same threshold mutation incompute()'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.goodwe-addon-checks:1with infra at the d637d0b pin.Waiting for CI, then merging.
Merged as
652097a(ls-remote verified) with head pinned at3c0f2b9. PR CI green: checks/unit and infra-pin/fresh. Post-merge infra-pin / fresh (push) green. No pin bump needed.