Card SAFETY-73 (6ab942cce43ceb7f1ca7f1d9), phase B, option R. The owner answered yes to all four new decisions; SAFETY-52's four still apply.
What changes (N>1 only; N=1 is bit-for-bit 652097a's path)
A unit that gives < 100 W for 12 cycles in one direction while asked > 200 W, or trips its stale-reading gate on a ~0 W reading, gets a P_SAFETY unit limit refused closing that direction. The allocator hands its share to the others within their own limits (decision 1). It is a hard unit limit that always intersects, so OPT-11's P_MODE layer fits inside it, and it binds every strategy's ask (decision 2).
Probe: every 15 min for 34 s the limit becomes a ±200 W pin, one unit at a time. It stands only while the resolved site target asks ≥ 200 W that way, so a sibling never has to take the opposite direction.
All units refusing the same way pass that sign to compute(): the site holds 0 W with SAFETY-52's law (decision 4).
Exits: ≥ 100 W in the held direction; ≥ 100 W taken the other way for 12 cycles; stop or start. Every exit reseeds the site target and integrator from the watts on the wire (the phase A −1059 W spike).
Above the configured cut-out: INFO plus one WARNING … check its cut-out level: REFUSES_ABOVE_CUT_OUT. with DEBUG evidence (decision 3). SAFETY-42/43 no longer report a refusing unit: its share is 0 W, and an ask ≤ 200 W giving < 100 W is "too small to call".
The ask threshold at N>1 is 200 W, not saturation_w: a 400 W night load split two ways otherwise leaves the empty battery a phantom share that SAFETY-43 names as not delivering. The count needs one direction throughout; without that, TEST-19's flapping family gave 42 false engagements.
Texts: a status row per unit, hints unit_refuses / unit_gives, WHY_WINNER["refused"], and WHY_LIMIT["refused"]. DOCS replaces SAFETY-52's "one inverter only" caveat. The DECISIONS record is DECISIONS/SAFETY-73-refusal-per-inverter.md.
Tests (test_refusal_units.py, real Controller, two units, flat −4 W and ±3 W jitter registers, each against the old behaviour)
case
old
new
a: house 3000 W, A cuts (flat)
B 533 W, grid +2471 W, 46 A gate trips/h stopping the site
B 2000 W (its rating), grid +1004 W, 1 trip
a (jitter)
B 1910 W, grid +1094 W
B 2000 W, grid +1004 W
b: house 1200 W (flat / jitter)
grid +671 / +284 W
grid −16 W, probes every 935 s
c: both cut
asks 180/180 (flat), 1910/1910 W (jitter)
site 0 W, probes alternate, never together
recovery with no sign change
—
worst grid −196/−203 W (c), +0 W (a)
The file also covers: the 400 W and 150 W night loads, the < 200 W site (the pin voids), a peak-guard SET placed around the limit, the WARNING, the status row, shadow mode, and SAFETY-52's whole suite with every SAFETY-73 method made to raise (the N=1 gate).
Mutations U1–U13 (table in the file's docstring) are all red. test_arbiter, test_peakguard and test_delivery are updated for the 12th claim source. The wedged-at-0 W fixture is now refused on its 13th cycle, after its report, because the fixture compresses the 185 s hold to 1 s. The full suite is green locally in goodwe-addon-ci:latest.
TEST-19 no-fault campaign (admin/goodwe delivery_window.py families, graded with the engage clause; grader in goodwe-session/scratch_s73/s73_nofault.py): 0 engagements in 2640 N=2 runs and 2640 N=1 runs. The longest qualifying streak is 5 cycles against 12.
Card SAFETY-73 (6ab942cce43ceb7f1ca7f1d9), phase B, option R. The owner answered yes to all four new decisions; SAFETY-52's four still apply.
**What changes (N>1 only; N=1 is bit-for-bit 652097a's path)**
- A unit that gives < 100 W for 12 cycles in one direction while asked > 200 W, or trips its stale-reading gate on a ~0 W reading, gets a P_SAFETY unit limit `refused` closing that direction. The allocator hands its share to the others within their own limits (decision 1). It is a hard unit limit that always intersects, so OPT-11's P_MODE layer fits inside it, and it binds every strategy's ask (decision 2).
- Probe: every 15 min for 34 s the limit becomes a ±200 W pin, one unit at a time. It stands only while the resolved site target asks ≥ 200 W that way, so a sibling never has to take the opposite direction.
- All units refusing the same way pass that sign to `compute()`: the site holds 0 W with SAFETY-52's law (decision 4).
- Exits: ≥ 100 W in the held direction; ≥ 100 W taken the other way for 12 cycles; stop or start. Every exit reseeds the site target and integrator from the watts on the wire (the phase A −1059 W spike).
- Above the configured cut-out: INFO plus one WARNING `… check its cut-out level: REFUSES_ABOVE_CUT_OUT.` with DEBUG evidence (decision 3). SAFETY-42/43 no longer report a refusing unit: its share is 0 W, and an ask ≤ 200 W giving < 100 W is "too small to call".
- The ask threshold at N>1 is 200 W, not `saturation_w`: a 400 W night load split two ways otherwise leaves the empty battery a phantom share that SAFETY-43 names as not delivering. The count needs one direction throughout; without that, TEST-19's flapping family gave 42 false engagements.
- Texts: a status row per unit, hints `unit_refuses` / `unit_gives`, `WHY_WINNER["refused"]`, and `WHY_LIMIT["refused"]`. DOCS replaces SAFETY-52's "one inverter only" caveat. The DECISIONS record is `DECISIONS/SAFETY-73-refusal-per-inverter.md`.
**Tests (`test_refusal_units.py`, real `Controller`, two units, flat −4 W and ±3 W jitter registers, each against the old behaviour)**
| case | old | new |
|---|---|---|
| a: house 3000 W, A cuts (flat) | B 533 W, grid +2471 W, 46 A gate trips/h stopping the site | B 2000 W (its rating), grid +1004 W, 1 trip |
| a (jitter) | B 1910 W, grid +1094 W | B 2000 W, grid +1004 W |
| b: house 1200 W (flat / jitter) | grid +671 / +284 W | grid −16 W, probes every 935 s |
| c: both cut | asks 180/180 (flat), 1910/1910 W (jitter) | site 0 W, probes alternate, never together |
| recovery with no sign change | — | worst grid −196/−203 W (c), +0 W (a) |
The file also covers: the 400 W and 150 W night loads, the < 200 W site (the pin voids), a peak-guard SET placed around the limit, the WARNING, the status row, shadow mode, and SAFETY-52's whole suite with every SAFETY-73 method made to raise (the N=1 gate).
Mutations U1–U13 (table in the file's docstring) are all red. `test_arbiter`, `test_peakguard` and `test_delivery` are updated for the 12th claim source. The wedged-at-0 W fixture is now refused on its 13th cycle, after its report, because the fixture compresses the 185 s hold to 1 s. The full suite is green locally in `goodwe-addon-ci:latest`.
**TEST-19 no-fault campaign** (admin/goodwe `delivery_window.py` families, graded with the engage clause; grader in `goodwe-session/scratch_s73/s73_nofault.py`): 0 engagements in 2640 N=2 runs and 2640 N=1 runs. The longest qualifying streak is 5 cycles against 12.
TEST-23 results will follow as a comment.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
SAFETY-52's hold applied per unit at N>1 (owner, 2026-09-27, option R).
A unit that gives < 100 W for 12 cycles in one direction while asked
> 200 W, or trips its stale-reading gate on a ~0 W reading, gets a
P_SAFETY `refused` unit limit closing that direction, so the allocator
hands its share to the others within their own limits. Probe: a +-200 W
pin every 15 min, one unit at a time, only while the site asks 200 W that
way. All units refusing: the site law holds 0 W. Exits reseed the site
target from the wire. INFO plus one WARNING (REFUSES_ABOVE_CUT_OUT) above
the configured cut-out; SAFETY-42/43 no longer report a refusing unit.
N=1 unchanged.
test_refusal_units.py: cases a/b/c on flat and jitter registers against
the old behaviour, probes, recovery, exits; mutations U1-U13 all red.
TEST-19 no-fault campaign: 0 engagements in 5280 runs.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
TEST-23 post-PR verification of 016ed5a (2026-09-28, 00:14–01:15 local). Result: PASS.
Setup. Before the test I recorded: test23-addon a5a20d236eb6 on :16c796f (9896c53a…), options fa602196… and state aa4848a9…; arb 8837dbed…/cd6dbdf2…; HA with 45 entities and configuration.yaml md5 c1048b26; 22 retained MQTT topics.
The soak was paused and both add-ons stopped. Both /data volumes were tarred with sha256, owner and mode manifests, and both tariff.db passed integrity_check.
I built :016ed5a; its 34 app/*.py hashes match the clone.
The original container was renamed aside. The test container ran with the same volume, IP, port, hostname and env, and up.sh was not run.
The second unit came from MQTT-discovered entities: number.s73_{a,b}_setpoint, sensor.s73_{a,b}_battery_power, sensor.s73_{a,b}_soc and sensor.s73_grid. HA's MQTT integration was already present, so configuration.yaml was not touched.
A plant container (goodwe-session/scratch_s73/plant.py) played both batteries. Each follows its command, reads a flat -4 W at the cut-out and still accepts charge. Grid = house - A - B, with ±1 W of meter noise.
Options were the volume's two-unit roster pointed at those entities, max_w 2000 each, auto_start: true, log_level: debug.
#
Check
Observed (plant times are UTC)
Result
b
House 1200 W, A cuts out
A was cut at 22:25:56. At 22:26:10 the INFO line read "inverter-a: the battery refuses power, so it is asked 0 W and the other inverters take over: BATTERY_REFUSES." It was followed by the one WARNING "inverter-a refuses power above its battery cut-out level, check its cut-out level: REFUSES_ABOVE_CUT_OUT." with DEBUG evidence (50 % against 10 %). A went to 0 W and B to 1210 W; the grid read -5 W. The page shows the row "inverter-a: the battery refuses to give power, the others take over". No STALE or ERROR line appeared in this run.
PASS
probe
15 minutes later
A was asked 200 W from 22:41:11 to 22:41:45 (35 s, 901 s after the refusal started) and did not answer. B went from 1010 to 1210 W, then A returned to 0 W.
PASS
a
House 3000 W, A still refused
B stayed at 2000 W (its rating) and A at 0 W. The grid read +1005 W, and the unmet 1420 W was logged.
PASS
c
B cuts out as well
B was refused at 22:43:58. Both inverters went to 0 W, and the Why row reads "The battery refuses to give power: 0 W". Probes: A from 22:56:47 to 22:57:20, B from 22:58:59 to 22:59:32. There were 0 seconds with both probing.
PASS
recovery
House 940 W, A's battery back, B still cut
A's probe at 23:12:21 was answered, logged as "…refusal ends…: the probe was answered". A ramped 200 → 950 W with the site target reseeded from the wire. The worst grid reading was -345 W for 1 s, the ordinary overshoot of a 1 s meter ramp; phase A measured -1059 W without the reseed. It then held at -5 W.
PASS
Noted. In a first attempt the plant published a constant grid value. HA does not emit a state change for an unchanged value, so the add-on's P1 ingest marked the meter stale and held the site at 0 W. That was a plant defect, fixed with ±1 W of noise, and not the add-on. That first engagement came from the stale-reading gate (one STALE_READING ERROR, as SAFETY-52 allows). The clean run above engaged on the 12-cycle count before the gate could arm.
Restore.
The plant was stopped, and empty retained configs removed every s73 entity: HA is back at 45 entities with the same per-domain counts and 0 notifications, and the retained topics are identical to before (22).
The test container was removed and the original renamed back: test23-addon a5a20d236eb6 on :16c796f (9896c53a…), 10.77.34.40, unless-stopped.
Both volumes were restored from tar (tar sha256 OK). The per-file sha256, owner and mode match the manifest, and both tariff.db pass integrity_check.
Options/state are fa602196…/aa4848a9… and arb's are 8837dbed…/cd6dbdf2…. Arb was stopped 00:14:16–01:14:35 and is running.
The soak was paused 00:14:11–01:14:35 and is unpaused. HA reads setpoint 0, soc 52.4, batt 363.4, and configuration.yaml md5 c1048b26. There are 0 ERROR lines on either add-on since the restart.
**TEST-23 post-PR verification of 016ed5a (2026-09-28, 00:14–01:15 local).** Result: PASS.
**Setup.** Before the test I recorded: `test23-addon` a5a20d236eb6 on `:16c796f` (9896c53a…), options `fa602196…` and state `aa4848a9…`; arb `8837dbed…`/`cd6dbdf2…`; HA with 45 entities and configuration.yaml md5 c1048b26; 22 retained MQTT topics.
- The soak was paused and both add-ons stopped. Both /data volumes were tarred with sha256, owner and mode manifests, and both tariff.db passed integrity_check.
- I built `:016ed5a`; its 34 `app/*.py` hashes match the clone.
- The original container was renamed aside. The test container ran with the same volume, IP, port, hostname and env, and `up.sh` was not run.
- The second unit came from MQTT-discovered entities: `number.s73_{a,b}_setpoint`, `sensor.s73_{a,b}_battery_power`, `sensor.s73_{a,b}_soc` and `sensor.s73_grid`. HA's MQTT integration was already present, so `configuration.yaml` was not touched.
- A plant container (`goodwe-session/scratch_s73/plant.py`) played both batteries. Each follows its command, reads a flat -4 W at the cut-out and still accepts charge. Grid = house - A - B, with ±1 W of meter noise.
- Options were the volume's two-unit roster pointed at those entities, `max_w` 2000 each, `auto_start: true`, `log_level: debug`.
| # | Check | Observed (plant times are UTC) | Result |
|---|---|---|---|
| b | House 1200 W, A cuts out | A was cut at 22:25:56. At 22:26:10 the INFO line read "inverter-a: the battery refuses power, so it is asked 0 W and the other inverters take over: BATTERY_REFUSES." It was followed by the one WARNING "inverter-a refuses power above its battery cut-out level, check its cut-out level: REFUSES_ABOVE_CUT_OUT." with DEBUG evidence (50 % against 10 %). A went to 0 W and B to 1210 W; the grid read -5 W. The page shows the row "inverter-a: the battery refuses to give power, the others take over". No STALE or ERROR line appeared in this run. | PASS |
| probe | 15 minutes later | A was asked 200 W from 22:41:11 to 22:41:45 (35 s, 901 s after the refusal started) and did not answer. B went from 1010 to 1210 W, then A returned to 0 W. | PASS |
| a | House 3000 W, A still refused | B stayed at 2000 W (its rating) and A at 0 W. The grid read +1005 W, and the unmet 1420 W was logged. | PASS |
| c | B cuts out as well | B was refused at 22:43:58. Both inverters went to 0 W, and the Why row reads "The battery refuses to give power: 0 W". Probes: A from 22:56:47 to 22:57:20, B from 22:58:59 to 22:59:32. There were 0 seconds with both probing. | PASS |
| recovery | House 940 W, A's battery back, B still cut | A's probe at 23:12:21 was answered, logged as "…refusal ends…: the probe was answered". A ramped 200 → 950 W with the site target reseeded from the wire. The worst grid reading was -345 W for 1 s, the ordinary overshoot of a 1 s meter ramp; phase A measured -1059 W without the reseed. It then held at -5 W. | PASS |
**Noted.** In a first attempt the plant published a constant grid value. HA does not emit a state change for an unchanged value, so the add-on's P1 ingest marked the meter stale and held the site at 0 W. That was a plant defect, fixed with ±1 W of noise, and not the add-on. That first engagement came from the stale-reading gate (one `STALE_READING` ERROR, as SAFETY-52 allows). The clean run above engaged on the 12-cycle count before the gate could arm.
**Restore.**
- The plant was stopped, and empty retained configs removed every `s73` entity: HA is back at 45 entities with the same per-domain counts and 0 notifications, and the retained topics are identical to before (22).
- The test container was removed and the original renamed back: `test23-addon` a5a20d236eb6 on `:16c796f` (9896c53a…), 10.77.34.40, unless-stopped.
- Both volumes were restored from tar (tar sha256 OK). The per-file sha256, owner and mode match the manifest, and both tariff.db pass integrity_check.
- Options/state are `fa602196…`/`aa4848a9…` and arb's are `8837dbed…`/`cd6dbdf2…`. Arb was stopped 00:14:16–01:14:35 and is running.
- The soak was paused 00:14:11–01:14:35 and is unpaused. HA reads setpoint 0, soc 52.4, batt 363.4, and configuration.yaml md5 c1048b26. There are 0 ERROR lines on either add-on since the restart.
- The `:016ed5a` image and the backups are deleted.
- Nothing touched .158, .221 or .167.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
I merged release/1.0 (afd6af0, UX-13 P0) into the branch the normal way; the head is now 5a8d832. The full suite is green locally on the merged tree, and checks / unit and infra-pin / fresh both pass on 5a8d832. TEST-23 ran on 016ed5a, before the merge. I did not re-run it: the base change is UX-13's page and options layer, and it touches none of the control, arbiter or inverter paths this PR changes.
I merged release/1.0 (afd6af0, UX-13 P0) into the branch the normal way; the head is now 5a8d832. The full suite is green locally on the merged tree, and `checks / unit` and `infra-pin / fresh` both pass on 5a8d832. TEST-23 ran on 016ed5a, before the merge. I did not re-run it: the base change is UX-13's page and options layer, and it touches none of the control, arbiter or inverter paths this PR changes.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
- REFUSED_SPAN_S: the 12 cycles of evidence must also span (SETTLE_CYCLES-1) *
LOOP_TICK_S = 22 s. At a 1 s meter, lagged healthy readings refused both
units 10-16 times an hour after load steps (site held 0 W). 2 s and slower
ticks are unchanged.
- REFUSES_ABOVE_CUT_OUT only for a refusal to give power: a full battery that
stops taking charge is always above its cut-out level.
- test_delivery: its wedged fixture (0.2 s cycles) no longer reaches a refusal;
the release/1.0 checks are restored and a span check added.
- test_refusal_units: lagged-reading and full-battery plants, two new checks,
U14/U15 in the mutation table. Stale "> 500 W" wording fixed. DOCS/DECISIONS.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review of 5a8d832 (SAFETY-73): two defects fixed on the branch, the rest checks out
I merged release/1.0 (a36dca6, UX-13 P1) into the branch the normal way (ce7ea82, clean) and pushed my fixes on top: 06aa09e. My harness is a real Controller with two units whose readings lag (dead time, first-order ramp, publish interval, offset, noise, derating), in goodwe-addon-ci:latest with --network none.
R1. Healthy units refused at a 1 s meter (defect, fixed). At N>1 the engage clause counts 12 cycles. At meter_poll_s 1 s, that is 11 s, which is shorter than a healthy reading's lag (dead time plus a 10-17 s publish). This is SAFETY-52's D2 residual, but at N=2 both units engage in the same cycle, so the site law then holds 0 W. Measured over 1 h, with both units at 6 s dead time, load steps 300/3000 W every 2 min, old vs this PR:
publish 13 s: 0 vs 10 engagements, import 598 vs 683 Wh, grid peak +3660 vs +4558 W
publish 17 s: 0 vs 16, 652 vs 704 Wh
flapping load 600/2400 W every 10 s, readings 17 s / 3 s: 0 vs 17 engagements
At a 2 s tick: 0 engagements in every case, identical traces.
Fix: REFUSED_SPAN_S = (SETTLE_CYCLES - 1) * LOOP_TICK_S (22 s). The evidence must span that long as well as 12 cycles, so ticks of 2 s and slower are unchanged. After the fix, all six cases above give 0 engagements, with traces identical to the old law.
test_refusal_units: 1 h of steps at 1 s with lagged readings refuses neither unit. The same lag with a real cut-out still refuses, after ≥ 22 s and before the 185 s report. U14 (span removed) goes red.
test_delivery: the wedged fixture runs 14 cycles 0.2 s apart, so it no longer reaches a refusal. Its two SAFETY-42 checks go back to their release/1.0 form. A new check pins "count met, span not met, not refused", and U14 turns it red too.
R2. The cut-out WARNING fired for a full battery (defect, fixed). The warning's condition was soc > bms_soc_floor in either direction. A full battery tapering to 40 W of charge against a 1500 W charge ask is refused (-1), which is correct, but it logged "check its cut-out level". That is always true, and it points the installer at the wrong setting. It is now for sign > 0 only. There is a new check, and U15 goes red. DOCS and DECISIONS say "refuses to give power".
Words (fixed): the test docstring said "> 500 W" and DOCS said "a minute of charging". They now say 200 W and "12 cycles of charging". DECISIONS has a Review changes section.
Checked, OK (measured unless marked):
False engagement at N=2 (5 s and 1 s ticks):
Small share near 200 W, with the reading 28/60/90 W low ±40 W: 0 engagements.
Derated A at 110/150/300/450 W (SAFETY-52 D1): never held. A delivers its cap, and SAFETY-42 still reports it, so a wedged unit that delivers the wrong amount above 100 W is still named.
Slow step reading 6+10 s at 4 phases, with B fast or the same: 0.
D2 per unit: any reading ≥ 100 W in the held direction ends it, in or out of a probe (U9 red). A recovered battery held at 0 W reads 0 W, so the probe finds it, at most 15 min later. That is SAFETY-52's decision.
Probes:
They never overlap. Case c ran 3 h with A 78 / B 78 probe cycles and 0 cycles with both non-zero.
There is no opposite-direction command or power in case b or with the site hovering at 200±60 W (the void works).
PROBE_W < MOVE_W, and there are 0 gate trips during probes: every trip is at the initial cut.
The peak guard's claim is a lo floor. During a probe, B carries T-200, and the loop, closed on the meter, raises T again. In case b the grid during probes averaged +39 W with a max of +166 W. The worst case is B with headroom under a binding floor: 200 W for 34 s, ≤ 7.6 W on the 15-minute average (inferred).
Shadow mode switched on while a unit is refused: 0 non-zero writes.
Take-over: B stops at its rating (case a, 2000 W, unmet logged). The unit limit intersects with pack and supervised. I found no latch with SAFETY-13/42/43/48/57/52: SAFETY-42 goes quiet because the share is 0, and the gate trips once at the cut.
N=1 unchanged: I wrapped the five methods with counters instead of raising. test_refusal.py and test_control_task.py made 0 calls. test_refusal.py is untouched by the PR.
12th claim source: the test_arbiter/test_peakguard updates add refused to the pinned sets and counts. Nothing is loosened.
Mutations re-run by me: U1 red 8, U5 1, U6 1, U9 3, U10 1, U12 1, U14 1 (+1 in test_delivery), U15 1.
The dev's three choices:
200 W at N>1: accepted. F1 (small shares with a low-reading offset) and D1 show no false engagement, because the < 100 W reading does the discriminating. The asymmetry with N=1 is justified: a unit's share is a fraction of S. The N=1 clause also needs compute()'s freeze, which a per-unit share never produces.
One direction throughout: accepted. It is needed on flapping loads, and my flapping case confirms it.
12 cycles of charge to exit: accepted. A recovered battery keeps charging, since its limit closes only the refused direction, so no energy is refused. It gets its discharge share back after 12 cycles of ≥ 100 W charge, or at a probe.
Follow-ups, not blocking:
(a) A voided probe still uses its window: a site asking < 200 W or charging delays the next try by 15 min.
(b) The WARNING repeats on each engage, so a battery that answers a probe and cuts again warns every ~15 min.
(c) WHY_WINNER["refused"] and WHY_LIMIT["refused"] can't be reached at runtime: a unit claim is never the site winner or bound_by. They exist for test_messages' scan.
Full suite green locally on 06aa09e (42/42, CI image, repo mounted). Waiting for CI, then merging.
## Review of 5a8d832 (SAFETY-73): two defects fixed on the branch, the rest checks out
I merged release/1.0 (a36dca6, UX-13 P1) into the branch the normal way (ce7ea82, clean) and pushed my fixes on top: **06aa09e**. My harness is a real `Controller` with two units whose readings lag (dead time, first-order ramp, publish interval, offset, noise, derating), in `goodwe-addon-ci:latest` with `--network none`.
**R1. Healthy units refused at a 1 s meter (defect, fixed).** At N>1 the engage clause counts 12 *cycles*. At `meter_poll_s` 1 s, that is 11 s, which is shorter than a healthy reading's lag (dead time plus a 10-17 s publish). This is SAFETY-52's D2 residual, but at N=2 both units engage in the same cycle, so the site law then holds **0 W**. Measured over 1 h, with both units at 6 s dead time, load steps 300/3000 W every 2 min, old vs this PR:
- publish 13 s: 0 vs 10 engagements, import 598 vs 683 Wh, grid peak +3660 vs +4558 W
- publish 17 s: 0 vs 16, 652 vs 704 Wh
- flapping load 600/2400 W every 10 s, readings 17 s / 3 s: 0 vs 17 engagements
At a 2 s tick: 0 engagements in every case, identical traces.
Fix: `REFUSED_SPAN_S = (SETTLE_CYCLES - 1) * LOOP_TICK_S` (22 s). The evidence must span that long as well as 12 cycles, so ticks of 2 s and slower are unchanged. After the fix, all six cases above give 0 engagements, with traces identical to the old law.
- test_refusal_units: 1 h of steps at 1 s with lagged readings refuses neither unit. The same lag with a real cut-out still refuses, after ≥ 22 s and before the 185 s report. **U14** (span removed) goes red.
- test_delivery: the wedged fixture runs 14 cycles 0.2 s apart, so it no longer reaches a refusal. Its two SAFETY-42 checks go back to their release/1.0 form. A new check pins "count met, span not met, not refused", and U14 turns it red too.
**R2. The cut-out WARNING fired for a full battery (defect, fixed).** The warning's condition was `soc > bms_soc_floor` in either direction. A full battery tapering to 40 W of charge against a 1500 W charge ask is refused (-1), which is correct, but it logged "check its cut-out level". That is always true, and it points the installer at the wrong setting. It is now for `sign > 0` only. There is a new check, and **U15** goes red. DOCS and DECISIONS say "refuses to give power".
**Words (fixed):** the test docstring said "> 500 W" and DOCS said "a minute of charging". They now say 200 W and "12 cycles of charging". DECISIONS has a *Review changes* section.
**Checked, OK (measured unless marked):**
- **False engagement at N=2 (5 s and 1 s ticks):**
- Small share near 200 W, with the reading 28/60/90 W low ±40 W: 0 engagements.
- Derated A at 110/150/300/450 W (SAFETY-52 D1): never held. A delivers its cap, and SAFETY-42 still reports it, so a wedged unit that delivers the wrong amount above 100 W is still named.
- Slow step reading 6+10 s at 4 phases, with B fast or the same: 0.
- **D2 per unit:** any reading ≥ 100 W in the held direction ends it, in or out of a probe (U9 red). A recovered battery held at 0 W reads 0 W, so the probe finds it, at most 15 min later. That is SAFETY-52's decision.
- **Probes:**
- They never overlap. Case c ran 3 h with A 78 / B 78 probe cycles and 0 cycles with both non-zero.
- There is no opposite-direction command or power in case b or with the site hovering at 200±60 W (the void works).
- `PROBE_W` < `MOVE_W`, and there are 0 gate trips during probes: every trip is at the initial cut.
- The peak guard's claim is a `lo` floor. During a probe, B carries T-200, and the loop, closed on the meter, raises T again. In case b the grid during probes averaged +39 W with a max of +166 W. The worst case is B with headroom under a binding floor: 200 W for 34 s, ≤ 7.6 W on the 15-minute average (inferred).
- Shadow mode switched on while a unit is refused: 0 non-zero writes.
- **Take-over:** B stops at its rating (case a, 2000 W, unmet logged). The unit limit intersects with `pack` and `supervised`. I found no latch with SAFETY-13/42/43/48/57/52: SAFETY-42 goes quiet because the share is 0, and the gate trips once at the cut.
- **N=1 unchanged:** I wrapped the five methods with counters instead of raising. `test_refusal.py` and `test_control_task.py` made **0 calls**. `test_refusal.py` is untouched by the PR.
- **12th claim source:** the test_arbiter/test_peakguard updates add `refused` to the pinned sets and counts. Nothing is loosened.
- **Mutations re-run by me:** U1 red 8, U5 1, U6 1, U9 3, U10 1, U12 1, U14 1 (+1 in test_delivery), U15 1.
**The dev's three choices:**
1. **200 W at N>1:** accepted. F1 (small shares with a low-reading offset) and D1 show no false engagement, because the < 100 W reading does the discriminating. The asymmetry with N=1 is justified: a unit's share is a fraction of S. The N=1 clause also needs compute()'s freeze, which a per-unit share never produces.
2. **One direction throughout:** accepted. It is needed on flapping loads, and my flapping case confirms it.
3. **12 cycles of charge to exit:** accepted. A recovered battery keeps charging, since its limit closes only the refused direction, so no energy is refused. It gets its discharge share back after 12 cycles of ≥ 100 W charge, or at a probe.
**Follow-ups, not blocking:**
- (a) A voided probe still uses its window: a site asking < 200 W or charging delays the next try by 15 min.
- (b) The WARNING repeats on each engage, so a battery that answers a probe and cuts again warns every ~15 min.
- (c) `WHY_WINNER["refused"]` and `WHY_LIMIT["refused"]` can't be reached at runtime: a unit claim is never the site winner or `bound_by`. They exist for test_messages' scan.
Full suite green locally on 06aa09e (42/42, CI image, repo mounted). Waiting for CI, then merging.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
CI on 06aa09e: checks / unit success, infra-pin / fresh success. R1 and R2 from comment 1876 are fixed on this head, and the follow-ups (a)-(c) are not blocking. Merging with head_commit_id pinned to 06aa09e.
CI on 06aa09e: `checks / unit` success, `infra-pin / fresh` success. R1 and R2 from comment 1876 are fixed on this head, and the follow-ups (a)-(c) are not blocking. Merging with head_commit_id pinned to 06aa09e.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
admin
merged commit 28a49ccf75 into release/1.02026-09-28 02:27:15 +02:00
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.
Card SAFETY-73 (6ab942cce43ceb7f1ca7f1d9), phase B, option R. The owner answered yes to all four new decisions; SAFETY-52's four still apply.
What changes (N>1 only; N=1 is bit-for-bit 652097a's path)
refusedclosing that direction. The allocator hands its share to the others within their own limits (decision 1). It is a hard unit limit that always intersects, so OPT-11's P_MODE layer fits inside it, and it binds every strategy's ask (decision 2).compute(): the site holds 0 W with SAFETY-52's law (decision 4).… check its cut-out level: REFUSES_ABOVE_CUT_OUT.with DEBUG evidence (decision 3). SAFETY-42/43 no longer report a refusing unit: its share is 0 W, and an ask ≤ 200 W giving < 100 W is "too small to call".saturation_w: a 400 W night load split two ways otherwise leaves the empty battery a phantom share that SAFETY-43 names as not delivering. The count needs one direction throughout; without that, TEST-19's flapping family gave 42 false engagements.unit_refuses/unit_gives,WHY_WINNER["refused"], andWHY_LIMIT["refused"]. DOCS replaces SAFETY-52's "one inverter only" caveat. The DECISIONS record isDECISIONS/SAFETY-73-refusal-per-inverter.md.Tests (
test_refusal_units.py, realController, two units, flat −4 W and ±3 W jitter registers, each against the old behaviour)The file also covers: the 400 W and 150 W night loads, the < 200 W site (the pin voids), a peak-guard SET placed around the limit, the WARNING, the status row, shadow mode, and SAFETY-52's whole suite with every SAFETY-73 method made to raise (the N=1 gate).
Mutations U1–U13 (table in the file's docstring) are all red.
test_arbiter,test_peakguardandtest_deliveryare updated for the 12th claim source. The wedged-at-0 W fixture is now refused on its 13th cycle, after its report, because the fixture compresses the 185 s hold to 1 s. The full suite is green locally ingoodwe-addon-ci:latest.TEST-19 no-fault campaign (admin/goodwe
delivery_window.pyfamilies, graded with the engage clause; grader ingoodwe-session/scratch_s73/s73_nofault.py): 0 engagements in 2640 N=2 runs and 2640 N=1 runs. The longest qualifying streak is 5 cycles against 12.TEST-23 results will follow as a comment.
🤖 Generated with Claude Code
https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
TEST-23 post-PR verification of
016ed5a(2026-09-28, 00:14–01:15 local). Result: PASS.Setup. Before the test I recorded:
test23-addona5a20d236eb6 on:16c796f(9896c53a…), optionsfa602196…and stateaa4848a9…; arb8837dbed…/cd6dbdf2…; HA with 45 entities and configuration.yaml md5 c1048b26; 22 retained MQTT topics.:016ed5a; its 34app/*.pyhashes match the clone.up.shwas not run.number.s73_{a,b}_setpoint,sensor.s73_{a,b}_battery_power,sensor.s73_{a,b}_socandsensor.s73_grid. HA's MQTT integration was already present, soconfiguration.yamlwas not touched.goodwe-session/scratch_s73/plant.py) played both batteries. Each follows its command, reads a flat -4 W at the cut-out and still accepts charge. Grid = house - A - B, with ±1 W of meter noise.max_w2000 each,auto_start: true,log_level: debug.Noted. In a first attempt the plant published a constant grid value. HA does not emit a state change for an unchanged value, so the add-on's P1 ingest marked the meter stale and held the site at 0 W. That was a plant defect, fixed with ±1 W of noise, and not the add-on. That first engagement came from the stale-reading gate (one
STALE_READINGERROR, as SAFETY-52 allows). The clean run above engaged on the 12-cycle count before the gate could arm.Restore.
s73entity: HA is back at 45 entities with the same per-domain counts and 0 notifications, and the retained topics are identical to before (22).test23-addona5a20d236eb6 on:16c796f(9896c53a…), 10.77.34.40, unless-stopped.fa602196…/aa4848a9…and arb's are8837dbed…/cd6dbdf2…. Arb was stopped 00:14:16–01:14:35 and is running.:016ed5aimage and the backups are deleted.🤖 Generated with Claude Code
https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
afd6af0) into SAFETY-73-per-unit-refusalI merged release/1.0 (
afd6af0, UX-13 P0) into the branch the normal way; the head is now5a8d832. The full suite is green locally on the merged tree, andchecks / unitandinfra-pin / freshboth pass on5a8d832. TEST-23 ran on016ed5a, before the merge. I did not re-run it: the base change is UX-13's page and options layer, and it touches none of the control, arbiter or inverter paths this PR changes.🤖 Generated with Claude Code
https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
a36dca6) into SAFETY-73-per-unit-refusal 1a9c7523c9Review of
5a8d832(SAFETY-73): two defects fixed on the branch, the rest checks outI merged release/1.0 (
a36dca6, UX-13 P1) into the branch the normal way (ce7ea82, clean) and pushed my fixes on top:06aa09e. My harness is a realControllerwith two units whose readings lag (dead time, first-order ramp, publish interval, offset, noise, derating), ingoodwe-addon-ci:latestwith--network none.R1. Healthy units refused at a 1 s meter (defect, fixed). At N>1 the engage clause counts 12 cycles. At
meter_poll_s1 s, that is 11 s, which is shorter than a healthy reading's lag (dead time plus a 10-17 s publish). This is SAFETY-52's D2 residual, but at N=2 both units engage in the same cycle, so the site law then holds 0 W. Measured over 1 h, with both units at 6 s dead time, load steps 300/3000 W every 2 min, old vs this PR:At a 2 s tick: 0 engagements in every case, identical traces.
Fix:
REFUSED_SPAN_S = (SETTLE_CYCLES - 1) * LOOP_TICK_S(22 s). The evidence must span that long as well as 12 cycles, so ticks of 2 s and slower are unchanged. After the fix, all six cases above give 0 engagements, with traces identical to the old law.R2. The cut-out WARNING fired for a full battery (defect, fixed). The warning's condition was
soc > bms_soc_floorin either direction. A full battery tapering to 40 W of charge against a 1500 W charge ask is refused (-1), which is correct, but it logged "check its cut-out level". That is always true, and it points the installer at the wrong setting. It is now forsign > 0only. There is a new check, and U15 goes red. DOCS and DECISIONS say "refuses to give power".Words (fixed): the test docstring said "> 500 W" and DOCS said "a minute of charging". They now say 200 W and "12 cycles of charging". DECISIONS has a Review changes section.
Checked, OK (measured unless marked):
PROBE_W<MOVE_W, and there are 0 gate trips during probes: every trip is at the initial cut.lofloor. During a probe, B carries T-200, and the loop, closed on the meter, raises T again. In case b the grid during probes averaged +39 W with a max of +166 W. The worst case is B with headroom under a binding floor: 200 W for 34 s, ≤ 7.6 W on the 15-minute average (inferred).packandsupervised. I found no latch with SAFETY-13/42/43/48/57/52: SAFETY-42 goes quiet because the share is 0, and the gate trips once at the cut.test_refusal.pyandtest_control_task.pymade 0 calls.test_refusal.pyis untouched by the PR.refusedto the pinned sets and counts. Nothing is loosened.The dev's three choices:
Follow-ups, not blocking:
WHY_WINNER["refused"]andWHY_LIMIT["refused"]can't be reached at runtime: a unit claim is never the site winner orbound_by. They exist for test_messages' scan.Full suite green locally on
06aa09e(42/42, CI image, repo mounted). Waiting for CI, then merging.🤖 Generated with Claude Code
https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
CI on
06aa09e:checks / unitsuccess,infra-pin / freshsuccess. R1 and R2 from comment 1876 are fixed on this head, and the follow-ups (a)-(c) are not blocking. Merging with head_commit_id pinned to06aa09e.🤖 Generated with Claude Code
https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p