SAFETY-73: a battery that refuses, per inverter, with two or more #143

Merged
admin merged 4 commits from SAFETY-73-per-unit-refusal into release/1.0 2026-09-28 02:27:15 +02:00
Owner

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.ai/code/session_01LyR88rXETvwhTgx6yHE39p

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
admin added 1 commit 2026-09-28 00:13:48 +02:00
SAFETY-73: a battery that refuses, per inverter, with two or more
checks / unit (pull_request) Successful in 10m1s
infra-pin / fresh (pull_request) Successful in 1s
016ed5a5a3
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>
Author
Owner

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.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-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
admin added 1 commit 2026-09-28 01:27:34 +02:00
Merge release/1.0 (afd6af0) into SAFETY-73-per-unit-refusal
checks / unit (pull_request) Successful in 9m58s
infra-pin / fresh (pull_request) Successful in 1s
5a8d832eba
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

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.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. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
admin added 2 commits 2026-09-28 02:16:26 +02:00
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SAFETY-73 review: a refusal needs 22 s of evidence, and the cut-out warning is for giving only
infra-pin / fresh (pull_request) Successful in 1s
checks / unit (pull_request) Successful in 10m11s
06aa09e28c
- 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>
Author
Owner

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.ai/code/session_01LyR88rXETvwhTgx6yHE39p

## 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
Author
Owner

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.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. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01LyR88rXETvwhTgx6yHE39p
admin merged commit 28a49ccf75 into release/1.0 2026-09-28 02:27:15 +02:00
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#143