SAFETY-94: a healthy battery that lags or reverses is not flagged as frozen or refusing #171

Merged
admin merged 7 commits from safety-94-no-false-stale-or-refusal into release/1.0 2026-10-04 17:27:21 +02:00
Owner

dd5eb6fdc8 open
SAFETY-94. Also fixes SAFETY-58. SAFETY-74 stays open (see below). Record: goodwe_controller/DECISIONS/SAFETY-94-lagging-battery.md. Tests: test_safety94.py, with the rig in tools/safety94_rig.py.

Root cause

1. The stale-reading gate (SAFETY-37) started its 35 s clock at the register's last change. After a rest, that clock was already past 35 s. So the first read after a new command tripped the gate, before a healthy register could answer. The gate assumed the register would keep changing; in fact it holds one value while nothing asks it to move.

  • Live on 2026-10-03 (0.14.0, N=2), four trips, each 1 s after the command:
    • 17:39:16 B and 17:39:25 A: the load ramp after a rest.
    • 19:00:04 A: the drain started.
    • 21:21:22 B: drain → charge.
  • Each register answered within 1.4–11 s.
  • test_safety94.py part 1 replays the HA history and reproduces all four to the second.

2. The N=1 refusal (SAFETY-52) checked the reading at one moment only. compute() engaged after 12 frozen cycles if the reading was under 100 W at that moment.

  • The frozen cycles also count a battery reversing through its lag.
  • At a 1 s meter, cycle 12 lands just as the battery reversing out of a charge passes 0 W (SAFETY-85 O1).
  • The hold then asked for 0 W, so the battery settled at 0 W and never gave the 100 W that would end the hold. It stayed parked until the 15-minute probe.
  • N>1 already needed 22 s in the band. That is safe at a 10 s lag but not at 20 s, and not for B, whose reading is 100 W below its power.

Design

  • The gate counts only the time the command asked the register to move (Freshness._oblige).
    • A read adds the time since the previous read when, at that read, the command is ≥ 250 W and has moved ≥ 250 W from where it stood when the register last changed.
    • A register change, or a SAFETY-57 release, resets the count.
    • A command that flaps in and out of the ask still adds up: a frozen register under a 0/1500 W flap trips at 69 s.
    • The conjunction, MOVE_W, RELEASE_S and the log/UI text are unchanged. Only a DEBUG line's wording changed.
  • The refusal evidence is the same at every roster size (Controller._gave_nothing, which is SAFETY-73's count moved into a method).
    • The battery must give < 100 W, one way, while asked > 200 W, for 12 cycles that span ≥ 22 s.
    • At N=1, compute(gave_nothing=...) requires this on top of its own freeze test. None (compute alone) keeps the old law.
  • The evidence restarts when the reading moves 50 W towards the ask (REFUSED_PROGRESS_W).
    • A refusing battery reads flat (−3/−4 W live), and live rest wander is under 25 W.
    • A first-order lag crossing the 100 W band gains 50 W within 0.4 τ, so it cannot fill 22 s unless τ > 54 s.

New bounds and worst-case time to catch a real fault

The bound is still 35 s. It now measures what its derivation always assumed: the time from the ask to the answer.

  • Floor: 6 s plant + 10 s publish + 1 s read = 17 s.
  • Band: [34, 37.5].
  • Live answers took 1.4, 3, 6 and 11 s.
fault before after budget
register frozen, then asked to move 0–35 s after the ask (often 1 s) 35 s after the ask SAFETY-37: 35 s; consumer holds 75/85 s
register frozen at rest (~0 W) stale 1 s after the ask 0 W (refused or stale) in 23–35 s the same
refusing battery, 1 s meter N=1 11–16 s, N=2 22–28 s 22–28 s SAFETY-73: 22 s span + a 10 s publish
refusing battery, 2 s meter 22–28 s 22–28 s unchanged
refusing battery, 5 s meter N=1 35–41 s, N=2 41–55 s N=1 40–45 s, N=2 50–55 s 12 cycles = 55–60 s

After a SAFETY-57 release, the held number is exposed for one 35 s window of asking. That is shorter than either consumer hold (35 < 75 < 85), so the argument still holds (test_freshness.py 10c).

Before → after

  • Live replay: 4 trips → 0.
  • Rig (10 s lag, N=1 and N=2, 1/2/5 s meters, two register shapes: 10 s publish with repeats, and 1 s with ±1 W): rest→step, drain start, drain→charge and charge reversal, 48 runs.
    • Before: 27 stale trips and 1 refusal.
    • After: 0 and 0.
  • Reversals (1500/2500 W of charge into a 300–2000 W house, lag 10 and 20 s, 1 s meter):
    • Before: N=1 had 6 stale trips and 3 refusals; N=2 had 6 and 1.
    • After: 0, also at 2 s.
  • 16-day loop capture (test_freshness.py part 8): 93 trips → 59. Trips answered within 20 s: 21 → 1.
    • Two removed trips were not quick ones:
      • a register held 351 s under a command hovering at the 250 W travel edge (survivor 2's shape);
      • a refusal the gate had only caught on a passing ramp, which SAFETY-52's refusal path covers.
  • Still caught: a frozen register (35 s after the ask), a register frozen at rest, and a refusing battery, at N=1 and N=2 (part 3).

Re-pinned, on purpose:

  • test_freshness.py:
    • part 8 keeps the pre-94 history, replayed on a _Before94 gate, and adds the new figures;
    • survivor 5 is now graded as closed;
    • 10c exposure is 631/36 reads, was 630/35;
    • the 10h re-trip comes at 114 s, was 92 s.
  • test_modes.py: the SAFETY-87 counterfactual digest. A, cut out, now trips at 1640 s, was 1635 s. The shipped n2, n2_off and n2_maint digests are unchanged.
  • test_clamp.py: the runaway fixture now runs on simulated 2 s ticks, because the 22 s span cannot elapse on back-to-back real-clock cycles.

All 61 test files pass locally in the CI image.

Mutations

Each mutation ran on a fresh copy with no .pyc (0 left), mounted :ro, under python3 -B. Each was graded by test_safety94, test_freshness, test_refusal, test_refusal_units, test_clamp, test_control and test_modes.

# mutation result
M0 none (control) all green
M1 gate clock from the last change again (graded_s) red: safety94 8, freshness 6, modes 1
M2 the ask never accumulates red: safety94 4, freshness 54, modes 1
M3 a register change does not reset the count red: safety94 3, freshness 7
M4 the window-opening read also resets last_read survived: equivalent. The opening read can never be an ask, because travel is 0 by construction, so the simpler form ships
M5 the ask drops the travel term red: safety94 4, freshness 3, modes 1
M6 compute() ignores gave_nothing red: safety94 3
M7 N=1 passes gave_nothing=None red: safety94 2
M8 the 50 W progress restart removed red: safety94 2
M9 REFUSED_PROGRESS_W = 0 red: safety94 3, refusal 10, refusal_units 34, clamp 1, modes 2
M10 REFUSED_PROGRESS_W = 400 (+0 B) red: safety94 3
M11 the 22 s span ignored red: safety94 2, refusal_units 1
M12 _oblige after _grade (+0 B) red: safety94 1, freshness 13, modes 1

Other cards

  • SAFETY-58: fixed. Its card asks for the clock to start with the obligation, and for the 35 s bound to be re-derived. Both are done here.
  • SAFETY-74: not fixed. The test: a battery capped at 300 W with a perfectly flat reading, a 5 s meter and a stepping house.
    • Trips per hour: before 50 (N=1) and 80 (N=2); after 37 and 60. With ±1 W of dither there are none.
    • A flat reading under a command that has moved is exactly the case the gate exists for. Telling "limited" from "frozen" needs a second signal, such as the meter or SoC.
  • Open (pre-existing, not changed): B's standby offset (−95 to −118 W at idle) puts a refusing B outside the 100 W band part of the time, so a refusing B may not latch.

No version or CHANGELOG change.

SAFETY-97 folded in (review F1)

B's power reading shows about −100 W at idle (standby draw), so the |reading| < 100 W band never held a refusing B: never at N=2, and since this PR's shared rule not at N=1 either. Record: goodwe_controller/DECISIONS/SAFETY-97-idle-offset.md. Tests: test_safety97.py.

Design

  • Each unit learns its idle reading (app/idle.py): only while control runs, the unit is not refused, stale or sign-tripped, and its command has stayed within 50 W for 120 s. First value: a 120 s mean. Then it follows drift slowly (30 min).
  • Bounds: a sample beyond 250 W is dropped. The band is centred on the learned value clamped to ±75 W (owner, 2026-10-04: the band always keeps 0 W ±25 W; beyond about ±170 W of idle a battery behaves as in 0.14.0). It is saved per unit and restored at start, clamped to 250 W. Until it is learned it is 0 W, which is the old rule.
  • Centred on it (|reading − idle| < 100 W): the shared evidence, compute()'s N=1 engage and end, the unit refusal ends, the stale-path refusal and SAFETY-42's "parked". SAFETY-37, SAFETY-57 and SAFETY-30 are unchanged; the reasons are in DECISIONS.
  • No new option. The HA integration's DC battery-power entity was weighed and not taken; the reasons are in DECISIONS.

Refused, seconds after the cut (rig: 400 s rest, then asked, cut at 600 s, ±4 W)

refusing 1 s meter 2 s 5 s before
A, N=1 28 28 65 same
A, N=2 28 28 110* same
B, N=1 28 28 65 never
B, N=2 24 24 60 never

* The rig's meter goes flat, so the loop cycles on the 15 s floor. The PR without SAFETY-97 gives the same.

  • A B that is refusing at start, with its idle reading restored: 24 s. With nothing saved: never (see open points).
  • A healthy B with its offset, in SAFETY-94's four scenarios (96 runs), plus reversals: 0 stale, 0 refused.
  • Live replay: 0 trips.

Mutations (fresh copies, 0 .pyc, :ro, python3 -B; graded by safety97, safety94, refusal, refusal_units, control and delivery)

# mutation result
S0 control green
S1 band not centred (_gave_nothing) red: safety97 5
S2 N=1 engage not centred (offset at N=2 only) red: safety97 2
S3 offset unbounded red: safety97 3
S4 learned while refused red: safety97 1
S5 learned under any command red: safety97 4
S6 no quiet time red: safety97 2
S7 unit refusal end not centred red: safety97 1
S8 not restored after restart red: safety97 1
S9 compute() end not centred red: safety97 1
S10 stale-path refusal (N>1) not centred survived → red after a new check (5 s meter)
S11 fast follow (τ 1 s) red: safety97 1
S12 idle_w not passed to compute() red: safety97 1

Open points

  • Not mutation-tested: the SAFETY-42 "parked" centring, and the N=1 stale-path centring (the same expression as the N>1 one that S10 tests).
  • A battery that is never asked about 0 W for 2 minutes never learns its idle reading. Such a site keeps the old rule until it rests once.

🤖 Generated with Claude Code

https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr

dd5eb6fdc8840a2f7e35050017702f9d19afb339 open SAFETY-94. Also fixes SAFETY-58. SAFETY-74 stays open (see below). Record: `goodwe_controller/DECISIONS/SAFETY-94-lagging-battery.md`. Tests: `test_safety94.py`, with the rig in `tools/safety94_rig.py`. ## Root cause **1. The stale-reading gate (SAFETY-37) started its 35 s clock at the register's last change.** After a rest, that clock was already past 35 s. So the first read after a new command tripped the gate, before a healthy register could answer. The gate assumed the register would keep changing; in fact it holds one value while nothing asks it to move. - Live on 2026-10-03 (0.14.0, N=2), four trips, each 1 s after the command: - 17:39:16 B and 17:39:25 A: the load ramp after a rest. - 19:00:04 A: the drain started. - 21:21:22 B: drain → charge. - Each register answered within 1.4–11 s. - `test_safety94.py` part 1 replays the HA history and reproduces all four to the second. **2. The N=1 refusal (SAFETY-52) checked the reading at one moment only.** `compute()` engaged after 12 frozen cycles if the reading was under 100 W *at that moment*. - The frozen cycles also count a battery reversing through its lag. - At a 1 s meter, cycle 12 lands just as the battery reversing out of a charge passes 0 W (SAFETY-85 O1). - The hold then asked for 0 W, so the battery settled at 0 W and never gave the 100 W that would end the hold. It stayed parked until the 15-minute probe. - N>1 already needed 22 s in the band. That is safe at a 10 s lag but not at 20 s, and not for B, whose reading is 100 W below its power. ## Design - **The gate counts only the time the command asked the register to move** (`Freshness._oblige`). - A read adds the time since the previous read when, at that read, the command is ≥ 250 W and has moved ≥ 250 W from where it stood when the register last changed. - A register change, or a SAFETY-57 release, resets the count. - A command that flaps in and out of the ask still adds up: a frozen register under a 0/1500 W flap trips at 69 s. - The conjunction, `MOVE_W`, `RELEASE_S` and the log/UI text are unchanged. Only a DEBUG line's wording changed. - **The refusal evidence is the same at every roster size** (`Controller._gave_nothing`, which is SAFETY-73's count moved into a method). - The battery must give < 100 W, one way, while asked > 200 W, for 12 cycles that span ≥ 22 s. - At N=1, `compute(gave_nothing=...)` requires this on top of its own freeze test. `None` (compute alone) keeps the old law. - **The evidence restarts when the reading moves 50 W towards the ask** (`REFUSED_PROGRESS_W`). - A refusing battery reads flat (−3/−4 W live), and live rest wander is under 25 W. - A first-order lag crossing the 100 W band gains 50 W within 0.4 τ, so it cannot fill 22 s unless τ > 54 s. ## New bounds and worst-case time to catch a real fault The bound is still **35 s**. It now measures what its derivation always assumed: the time from the ask to the answer. - Floor: 6 s plant + 10 s publish + 1 s read = 17 s. - Band: [34, 37.5]. - Live answers took 1.4, 3, 6 and 11 s. | fault | before | after | budget | |---|---|---|---| | register frozen, then asked to move | 0–35 s after the ask (often 1 s) | **35 s** after the ask | SAFETY-37: 35 s; consumer holds 75/85 s | | register frozen at rest (~0 W) | stale 1 s after the ask | 0 W (refused or stale) in **23–35 s** | the same | | refusing battery, 1 s meter | N=1 11–16 s, N=2 22–28 s | **22–28 s** | SAFETY-73: 22 s span + a 10 s publish | | refusing battery, 2 s meter | 22–28 s | 22–28 s | unchanged | | refusing battery, 5 s meter | N=1 35–41 s, N=2 41–55 s | N=1 40–45 s, N=2 50–55 s | 12 cycles = 55–60 s | After a SAFETY-57 release, the held number is exposed for one 35 s window of asking. That is shorter than either consumer hold (35 < 75 < 85), so the argument still holds (`test_freshness.py` 10c). ## Before → after - **Live replay:** 4 trips → **0**. - **Rig** (10 s lag, N=1 and N=2, 1/2/5 s meters, two register shapes: 10 s publish with repeats, and 1 s with ±1 W): rest→step, drain start, drain→charge and charge reversal, 48 runs. - Before: **27 stale trips and 1 refusal**. - After: **0 and 0**. - **Reversals** (1500/2500 W of charge into a 300–2000 W house, lag 10 and 20 s, 1 s meter): - Before: N=1 had 6 stale trips and 3 refusals; N=2 had 6 and 1. - After: **0**, also at 2 s. - **16-day loop capture** (`test_freshness.py` part 8): 93 trips → **59**. Trips answered within 20 s: 21 → 1. - Two removed trips were not quick ones: - a register held 351 s under a command hovering at the 250 W travel edge (survivor 2's shape); - a refusal the gate had only caught on a passing ramp, which SAFETY-52's refusal path covers. - **Still caught:** a frozen register (35 s after the ask), a register frozen at rest, and a refusing battery, at N=1 and N=2 (part 3). Re-pinned, on purpose: - `test_freshness.py`: - part 8 keeps the pre-94 history, replayed on a `_Before94` gate, and adds the new figures; - survivor 5 is now graded as closed; - 10c exposure is 631/36 reads, was 630/35; - the 10h re-trip comes at 114 s, was 92 s. - `test_modes.py`: the SAFETY-87 counterfactual digest. A, cut out, now trips at 1640 s, was 1635 s. The shipped n2, n2_off and n2_maint digests are unchanged. - `test_clamp.py`: the runaway fixture now runs on simulated 2 s ticks, because the 22 s span cannot elapse on back-to-back real-clock cycles. All 61 test files pass locally in the CI image. ## Mutations Each mutation ran on a fresh copy with no `.pyc` (0 left), mounted `:ro`, under `python3 -B`. Each was graded by test_safety94, test_freshness, test_refusal, test_refusal_units, test_clamp, test_control and test_modes. | # | mutation | result | |---|---|---| | M0 | none (control) | all green | | M1 | gate clock from the last change again (`graded_s`) | red: safety94 8, freshness 6, modes 1 | | M2 | the ask never accumulates | red: safety94 4, freshness 54, modes 1 | | M3 | a register change does not reset the count | red: safety94 3, freshness 7 | | M4 | the window-opening read also resets `last_read` | **survived: equivalent**. The opening read can never be an ask, because travel is 0 by construction, so the simpler form ships | | M5 | the ask drops the travel term | red: safety94 4, freshness 3, modes 1 | | M6 | `compute()` ignores `gave_nothing` | red: safety94 3 | | M7 | N=1 passes `gave_nothing=None` | red: safety94 2 | | M8 | the 50 W progress restart removed | red: safety94 2 | | M9 | `REFUSED_PROGRESS_W = 0` | red: safety94 3, refusal 10, refusal_units 34, clamp 1, modes 2 | | M10 | `REFUSED_PROGRESS_W = 400` (+0 B) | red: safety94 3 | | M11 | the 22 s span ignored | red: safety94 2, refusal_units 1 | | M12 | `_oblige` after `_grade` (+0 B) | red: safety94 1, freshness 13, modes 1 | ## Other cards - **SAFETY-58: fixed.** Its card asks for the clock to start with the obligation, and for the 35 s bound to be re-derived. Both are done here. - **SAFETY-74: not fixed.** The test: a battery capped at 300 W with a perfectly flat reading, a 5 s meter and a stepping house. - Trips per hour: before 50 (N=1) and 80 (N=2); after 37 and 60. With ±1 W of dither there are none. - A flat reading under a command that has moved is exactly the case the gate exists for. Telling "limited" from "frozen" needs a second signal, such as the meter or SoC. - **Open (pre-existing, not changed):** B's standby offset (−95 to −118 W at idle) puts a refusing B outside the 100 W band part of the time, so a refusing B may not latch. No version or CHANGELOG change. ## SAFETY-97 folded in (review F1) B's power reading shows about −100 W at idle (standby draw), so the `|reading| < 100 W` band never held a refusing B: never at N=2, and since this PR's shared rule not at N=1 either. Record: `goodwe_controller/DECISIONS/SAFETY-97-idle-offset.md`. Tests: `test_safety97.py`. **Design** - Each unit learns its idle reading (`app/idle.py`): only while control runs, the unit is not refused, stale or sign-tripped, and its command has stayed within 50 W for 120 s. First value: a 120 s mean. Then it follows drift slowly (30 min). - Bounds: a sample beyond 250 W is dropped. The band is centred on the learned value clamped to ±75 W (owner, 2026-10-04: the band always keeps 0 W ±25 W; beyond about ±170 W of idle a battery behaves as in 0.14.0). It is saved per unit and restored at start, clamped to 250 W. Until it is learned it is 0 W, which is the old rule. - Centred on it (`|reading − idle| < 100 W`): the shared evidence, `compute()`'s N=1 engage and end, the unit refusal ends, the stale-path refusal and SAFETY-42's "parked". SAFETY-37, SAFETY-57 and SAFETY-30 are unchanged; the reasons are in DECISIONS. - No new option. The HA integration's DC battery-power entity was weighed and not taken; the reasons are in DECISIONS. **Refused, seconds after the cut** (rig: 400 s rest, then asked, cut at 600 s, ±4 W) | refusing | 1 s meter | 2 s | 5 s | before | |---|---|---|---|---| | A, N=1 | 28 | 28 | 65 | same | | A, N=2 | 28 | 28 | 110* | same | | B, N=1 | 28 | 28 | 65 | never | | B, N=2 | 24 | 24 | 60 | never | \* The rig's meter goes flat, so the loop cycles on the 15 s floor. The PR without SAFETY-97 gives the same. - A B that is refusing at start, with its idle reading restored: 24 s. With nothing saved: never (see open points). - A healthy B with its offset, in SAFETY-94's four scenarios (96 runs), plus reversals: 0 stale, 0 refused. - Live replay: 0 trips. **Mutations** (fresh copies, 0 `.pyc`, `:ro`, `python3 -B`; graded by safety97, safety94, refusal, refusal_units, control and delivery) | # | mutation | result | |---|---|---| | S0 | control | green | | S1 | band not centred (`_gave_nothing`) | red: safety97 5 | | S2 | N=1 engage not centred (offset at N=2 only) | red: safety97 2 | | S3 | offset unbounded | red: safety97 3 | | S4 | learned while refused | red: safety97 1 | | S5 | learned under any command | red: safety97 4 | | S6 | no quiet time | red: safety97 2 | | S7 | unit refusal end not centred | red: safety97 1 | | S8 | not restored after restart | red: safety97 1 | | S9 | `compute()` end not centred | red: safety97 1 | | S10 | stale-path refusal (N>1) not centred | survived → red after a new check (5 s meter) | | S11 | fast follow (τ 1 s) | red: safety97 1 | | S12 | `idle_w` not passed to `compute()` | red: safety97 1 | **Open points** - Not mutation-tested: the SAFETY-42 "parked" centring, and the N=1 stale-path centring (the same expression as the N>1 one that S10 tests). - A battery that is never asked about 0 W for 2 minutes never learns its idle reading. Such a site keeps the old rule until it rests once. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
admin added 1 commit 2026-10-04 00:15:49 +02:00
SAFETY-94: a healthy battery that lags or reverses is not flagged as frozen or refusing
checks / unit (pull_request) Successful in 28m10s
infra-pin / fresh (pull_request) Failing after 2s
6759d21ea5
The stale-reading gate counted its 35 s from the register's last change, so
after a rest the first read after a new command tripped it (SAFETY-58; live
2026-10-03, four trips 1 s after the command). It now counts only the time
the command asked the register to move.

At N=1 the refusal engaged after 12 frozen cycles if the reading was under
100 W at that moment, which a battery reversing through its lag passes at a
1 s meter. The refusal now needs the N>1 evidence at every roster size: under
100 W, one way, for 12 cycles spanning 22 s, restarting when the reading
moves 50 W towards the ask.

DECISIONS/SAFETY-94-lagging-battery.md; test_safety94.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
admin added 1 commit 2026-10-04 01:18:00 +02:00
SAFETY-94 review: tests for three surviving mutants; record two gaps
checks / unit (pull_request) Successful in 28m7s
infra-pin / fresh (pull_request) Failing after 2s
dd5eb6fdc8
- test_safety94 part 4: time under 250 W is not counted; a command that
  swaps direction every cycle is never refusal evidence; a reading moving
  away from the ask does not restart the evidence. These kill review
  mutants R1, R3 and R6, which survived the PR's tests.
- DECISIONS: at N=1 a battery with B's standby offset no longer latches
  a refusal (SAFETY-97 now covers N=1); a part-time ask is caught after
  35/d s of wall time.

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

Review, round 1 (head dd5eb6f)

Pushed dd5eb6f: three tests and two lines in DECISIONS. Everything else in the PR checks out. One finding blocks the merge (F1). It needs a decision, not more code.

F1 (blocking): at N=1, a refusing battery with B's −100 W standby offset never latches

  • Before, compute() checked the reading at one instant. A reading wandering −95…−118 W (B's live idle values) was caught as soon as one cycle landed under 100 W.
  • Now N=1 uses the shared rule, which needs 12 cycles in the band in a row. That is SAFETY-73's N=2 gap (SAFETY-97), now also at N=1.
  • Review probe (scratch/SAFETY-94/review/probes/refusal_probe.py, rig battery cut at 300 s, reading drawn from B's live idle values):
N=1, meter before after
1 s latched in 17–78 s never (1200 s)
2 s 28–78 s never
5 s 65–105 s never
  • At N=2 nothing changes: it never latched before either.
  • A flat battery (−4 W) is unaffected: 28 s at 1–2 s meters, 45 s at 5 s.
  • I tried a narrow N=1-only fix: hold the count, rather than reset it, for readings just outside the band. It latches, but in 46–660 s. It is a half-measure because the charge direction stays broken. The real fix is an offset-aware band, which is SAFETY-97's design and also changes N=2.
  • Recorded under Known gaps in DECISIONS. Options for the manager:
    • (a) merge, and extend SAFETY-97 to N=1 before any N=1 client with an offset battery;
    • (b) hold this PR until SAFETY-97 lands;
    • (c) fold SAFETY-97 in here.

F2 (fixed, docs): a command that asks only part of the time is caught after 35/d s

  • The gate counts only reads at which the command asks. A command that asks a fraction d of the time is therefore caught after 35/d s of wall time. The PR's own 0/1500 W flap case gives 69 s.
  • SAFETY-30's per-unit check restarts when its command stops opposing the held reading, so 35 < 85 still holds there.
  • SAFETY-31's circulation check (75 s) does not look at the command. Its margin holds only while d ≥ 0.47.
  • Before, a frozen register was caught ≤ 35 s after it froze whatever the command did. So this case is slower, and it is now written down in DECISIONS.

F3 (fixed): three mutants survived the PR's tests

# mutation before my tests after
R1 the count also adds up while the command is under 250 W red only on the 16-day count pin red: new check, time under 250 W adds nothing
R3 one-direction check dropped from the evidence restart survived red: a command that swaps direction every cycle is never evidence
R6 progress counted either way (abs) instead of towards the ask survived red: a reading moving 80 W away does not restart the evidence

All three new checks are in test_safety94.py part 4.

Checks with no finding

  • Frozen at rest (table row "23–35 s"). At rest nothing is asked, so the 35 s starts only when the command first asks (≥ 250 W and moved ≥ 250 W).
    • A register frozen near 0 W reads like a refusal. It is refused at 22–28 s (1 s meter), or goes stale at 35 s.
    • Worst exposure for a frozen register under a big continuous ask: stale at 35 s (+1 read). Then inputs_ok fails, the site holds 15 s (stale_input_s) and commands 0 W at about 51 s.
    • Before, the worst case was the same 51 s, but a register that froze long before the ask was caught in about 16 s.
    • During those seconds the loop still regulates on the P1 meter. Only the per-unit consumers see the held number: SAFETY-30 85 s, SAFETY-31 75 s, SAFETY-42 185 s.
    • The heartbeat and the board watchdog only cover the add-on stopping. They do not bound this case.
  • Refusal restart on 50 W of progress. It cannot be gamed indefinitely. ref_from moves only up, to the reading that restarted it, and the reading must stay under 100 W. So an uninterrupted stretch allows at most 3 restarts.
    • Trickle (+60 W for one publish every 60 s): no delay.
    • Sawtooth: +10 s.
    • Noise on the register, 2000 seeds each:
      • ±25 W: no delay (22 s / 55 s);
      • ±50 W: p99 42 s at a 1 s meter, 105 s at 5 s;
      • ±75 W: worst 62 s / 135 s.
    • Every seed latched. Live register noise is ±1–4 W.
  • Re-pins (test_freshness.py part 8, S5, 10c and 10h; the test_modes.py S87 counterfactual; the test_clamp.py 2 s ticks): each changed only for its stated reason. The shipped n2, n2_off and n2_maint digests are untouched.
  • Mutants M1, M3, M5, M8, M11 and M12 re-run on purged copies: all red, as reported. M12's test_safety94 red is a TypeError in the test's Before94 helper, but test_freshness has 3 genuine failures. Also red: R2 (no count reset at a SAFETY-57 release), R4 (span 22 → 2 s), R5 (N=2 back on the old inline rule), R7, R8 and R9.
  • Wording and docs are fine. No version or CHANGELOG change.
## Review, round 1 (head dd5eb6f) Pushed `dd5eb6f`: three tests and two lines in DECISIONS. Everything else in the PR checks out. **One finding blocks the merge (F1). It needs a decision, not more code.** ### F1 (blocking): at N=1, a refusing battery with B's −100 W standby offset never latches - Before, `compute()` checked the reading at one instant. A reading wandering −95…−118 W (B's live idle values) was caught as soon as one cycle landed under 100 W. - Now N=1 uses the shared rule, which needs 12 cycles in the band in a row. That is SAFETY-73's N=2 gap (SAFETY-97), now also at N=1. - Review probe (`scratch/SAFETY-94/review/probes/refusal_probe.py`, rig battery cut at 300 s, reading drawn from B's live idle values): | N=1, meter | before | after | |---|---|---| | 1 s | latched in 17–78 s | **never (1200 s)** | | 2 s | 28–78 s | **never** | | 5 s | 65–105 s | **never** | - At N=2 nothing changes: it never latched before either. - A flat battery (−4 W) is unaffected: 28 s at 1–2 s meters, 45 s at 5 s. - I tried a narrow N=1-only fix: hold the count, rather than reset it, for readings just outside the band. It latches, but in 46–660 s. It is a half-measure because the charge direction stays broken. The real fix is an offset-aware band, which is SAFETY-97's design and also changes N=2. - Recorded under Known gaps in DECISIONS. Options for the manager: - (a) merge, and extend SAFETY-97 to N=1 before any N=1 client with an offset battery; - (b) hold this PR until SAFETY-97 lands; - (c) fold SAFETY-97 in here. ### F2 (fixed, docs): a command that asks only part of the time is caught after 35/d s - The gate counts only reads at which the command asks. A command that asks a fraction d of the time is therefore caught after 35/d s of wall time. The PR's own 0/1500 W flap case gives 69 s. - SAFETY-30's per-unit check restarts when its command stops opposing the held reading, so 35 < 85 still holds there. - SAFETY-31's circulation check (75 s) does not look at the command. Its margin holds only while d ≥ 0.47. - Before, a frozen register was caught ≤ 35 s after it froze whatever the command did. So this case is slower, and it is now written down in DECISIONS. ### F3 (fixed): three mutants survived the PR's tests | # | mutation | before my tests | after | |---|---|---|---| | R1 | the count also adds up while the command is under 250 W | red only on the 16-day count pin | red: new check, time under 250 W adds nothing | | R3 | one-direction check dropped from the evidence restart | **survived** | red: a command that swaps direction every cycle is never evidence | | R6 | progress counted either way (`abs`) instead of towards the ask | **survived** | red: a reading moving 80 W away does not restart the evidence | All three new checks are in `test_safety94.py` part 4. ### Checks with no finding - **Frozen at rest (table row "23–35 s").** At rest nothing is asked, so the 35 s starts only when the command first asks (≥ 250 W and moved ≥ 250 W). - A register frozen near 0 W reads like a refusal. It is refused at 22–28 s (1 s meter), or goes stale at 35 s. - Worst exposure for a frozen register under a big continuous ask: stale at 35 s (+1 read). Then `inputs_ok` fails, the site holds 15 s (`stale_input_s`) and commands 0 W at about 51 s. - Before, the worst case was the same 51 s, but a register that froze long before the ask was caught in about 16 s. - During those seconds the loop still regulates on the P1 meter. Only the per-unit consumers see the held number: SAFETY-30 85 s, SAFETY-31 75 s, SAFETY-42 185 s. - The heartbeat and the board watchdog only cover the add-on stopping. They do not bound this case. - **Refusal restart on 50 W of progress.** It cannot be gamed indefinitely. `ref_from` moves only up, to the reading that restarted it, and the reading must stay under 100 W. So an uninterrupted stretch allows at most 3 restarts. - Trickle (+60 W for one publish every 60 s): no delay. - Sawtooth: +10 s. - Noise on the register, 2000 seeds each: - ±25 W: no delay (22 s / 55 s); - ±50 W: p99 42 s at a 1 s meter, 105 s at 5 s; - ±75 W: worst 62 s / 135 s. - Every seed latched. Live register noise is ±1–4 W. - **Re-pins** (`test_freshness.py` part 8, S5, 10c and 10h; the `test_modes.py` S87 counterfactual; the `test_clamp.py` 2 s ticks): each changed only for its stated reason. The shipped n2, n2_off and n2_maint digests are untouched. - **Mutants M1, M3, M5, M8, M11 and M12** re-run on purged copies: all red, as reported. M12's `test_safety94` red is a TypeError in the test's `Before94` helper, but `test_freshness` has 3 genuine failures. Also red: R2 (no count reset at a SAFETY-57 release), R4 (span 22 → 2 s), R5 (N=2 back on the old inline rule), R7, R8 and R9. - Wording and docs are fine. No version or CHANGELOG change.
admin added 1 commit 2026-10-04 12:21:34 +02:00
SAFETY-97: "gives nothing" is graded against each battery's idle reading
checks / unit (pull_request) Failing after 29m7s
infra-pin / fresh (pull_request) Failing after 2s
6918e154c8
B's power reading shows -95 to -118 W at idle (standby draw), so the
|reading| < 100 W refusal band never held a refusing B: never at N=2,
and since SAFETY-94's shared rule not at N=1 either (review F1).

Each unit now learns its idle reading (app/idle.py): only while control
runs, the unit is not refused/stale/tripped and its command has stayed
within 50 W for 120 s; first value a 120 s mean, then a 30 min follow;
samples beyond 250 W dropped, value clamped to 250 W; saved per unit and
restored. The shared refusal evidence, compute()'s N=1 engage and end,
the unit refusal ends, the stale-path refusal and SAFETY-42's "parked"
test are centred on it. Until learned it is 0 W, the rule as it was.

Refusing B caught at N=1 28/28/65 s and N=2 24/24/60 s (1/2/5 s
meters), before never; refusing A unchanged. Healthy B with its offset,
SAFETY-94's scenarios, 96 runs: 0 stale, 0 refused. Live replay: 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
admin added 1 commit 2026-10-04 12:51:54 +02:00
SAFETY-97: point test_safety97 at MUTATION.md; record the DC-entity option
checks / unit (pull_request) Successful in 29m9s
infra-pin / fresh (pull_request) Failing after 1s
80bfc5721e
test_bytecode requires every suite to name MUTATION.md (CI run 1158).
DECISIONS: why the integration's DC battery power entity is not the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
admin added 1 commit 2026-10-04 13:07:09 +02:00
SAFETY-97: test the centred stale-path refusal (mutant S10 survived)
checks / unit (pull_request) Successful in 29m7s
infra-pin / fresh (pull_request) Failing after 1s
3db2b6392f
At a 5 s meter a register frozen at rest on B trips the stale gate before
the 12-cycle evidence; its refusal must be centred on B's idle reading,
or the site stops and re-trips instead of refusing B.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
admin added 1 commit 2026-10-04 14:29:30 +02:00
SAFETY-97 review: tests for four surviving mutants
checks / unit (pull_request) Successful in 28m57s
infra-pin / fresh (pull_request) Failing after 2s
88ebf3a5a6
The N=1 stale-path refusal and SAFETY-42's parked test are centred on the
idle reading; learning stops while control is stopped or the sign watchdog
has latched. Each had no check (review mutants X7, X8, X13, X14).

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

Review round 1 (SAFETY-94 + SAFETY-97), head 88ebf3a

Not mergeable yet: R1 needs an owner decision. Everything else is fixed or refuted below.

R1: a wrong idle reading hides every refusal on that battery. Blocking, owner decision.

  • A refusing battery is caught only while |reading − idle| < 100 W.
  • So an idle value that is off by about 100 W or more, whether learned or restored, means a refusal on that battery is never caught.
  • The stale gate does not catch it either, because the refusing reading jitters.
  • This holds at N=1 and at N=2.
  • With the ±250 W clamp, a battery reading anything from 150 to 350 W can count as "gives nothing", and a battery reading 0 W does not.

Rig: battery cut at 600 s, ±4 W noise, 1 s and 5 s meters. The idle reading was taught wrong by E during the first 400 s rest, or E was restored wrong.

unit E caught (PR head) E missed (never, in 300 s)
A (true 0 W), N=1 and N=2 −90 … +90 ≤ −110 and +110 … +250
B (true −100 W), N=1 and N=2 −90 … +90 ≤ −110 and ≥ +110
  • How it can go wrong: learning only needs the command within 50 W for 120 s. That happens when:
    • the command is held at ~0 W while the reading shows something else, for example a unit at its floor that the pack limit holds at 0 W while the inverter tops it up from the grid at under 250 W;
    • a register freezes at 100–250 W while the site rests;
    • a different inverter is put behind the same name, so the restored value belongs to the old one.
  • Recovery happens only through quiet time, with τ = 30 min. An error of 250 W is back under 100 W after about 30 min of quiet. An error of 350 W takes about 40 min.
  • At N=1, 0.14.0 always caught an A-type battery, so this is a new regression path.

Options

  • (a) Keep the ±250 W clamp. Covers batteries whose idle reading is up to about ±350 W. Any mis-learn of 100 W or more hides refusals.
  • (b) Clamp the value used to ±75 W, and keep accepting samples up to 250 W. Recommended. The band then always keeps 0 W ± 25 W.
    • Rig, same sweep: A is caught at every E, both learned and restored. B is caught for E from −150 to +90.
    • B learned 110 W or more too high is still missed. That is 0.14.0's N=2 state.
    • It also means a wrong idle can never falsely refuse a 0 W-offset battery: an ask over 200 W minus 75 W is still more than 100 W.
    • Cost: a battery whose idle reading is beyond about ±170 W is never caught, as in 0.14.0.
    • On B (−95…−118 W) the band stays 60 W or more inside.
    • test_safety94 passes, and so do refusal, refusal_units, delivery and control. In test_safety97, only the checks that pin −100 W or 250 W change.
  • (c) "Old band OR centred band". Rejected. A correct B would then need to give 200 W to end its refusal, so a 200 W probe reading 82–105 W would not answer.

R2: four mutants survived. Fixed in 88ebf3a.

  • X7: the N=1 stale-path refusal uncentred.
  • X8: SAFETY-42 "parked" uncentred.
  • X13: learning while control is stopped.
  • X14: learning while the sign watchdog has latched.
  • Each one is now red in test_safety97.py with a non-vacuous check.

R3: equivalent or wording-only survivors. Refuted, no test.

  • X3: the learned-value clamp raised to 1000 W. Samples over 250 W are already dropped, so a mean or a drift cannot pass 250 W. Only the restore clamp matters, and X5 (restore unclamped) is red.
  • X10: learning while stale. A stale reading is None, and inv.batt is not None already blocks it.
  • X9: the N=1 end in _refusal_update uncentred. It changes only which end reason is logged; compute() decides the end, and S9 is red.

Checked and fine

  • Full suite on 3db2b63: 62/62 pass.
  • 10-03 live replay with full HA history, 1 Hz: base 4 trips, PR 0.
  • I found no HA history for 2026-10-04 (08:45, 08:48, 09:00, 09:45), so those four are not verified here. They should be checked on the rig or the live log after deploy.
  • The refusal-evidence and freshness mutants M1, M3, M8, R3, R6, R7 and S1, S2, S4, S6, S7, S9, S10, S12 are red. So are my X1, X2, X4, X4c, X5, X6, X11, X12, X15 and X16.
  • Store: corrupt, NaN, inf, string and huge saved values give 0 W or are clamped. A renamed unit starts at 0 W, the old rule.
  • Swapping A for B behind one name: B's −100 W on a 0 W unit is caught only at the band's edge (96 W). A's −3 W on B is missed for about 5 min of quiet.
  • Unchanged checks (SAFETY-37, -57, -30, and SAFETY-42 shortfall): the reasons match the code.
  • Docs are plain. No version or CHANGELOG change, and no new user-facing strings.
  • F2 (35/d s) stands as recorded.
## Review round 1 (SAFETY-94 + SAFETY-97), head 88ebf3a **Not mergeable yet: R1 needs an owner decision.** Everything else is fixed or refuted below. ### R1: a wrong idle reading hides every refusal on that battery. Blocking, owner decision. - A refusing battery is caught only while `|reading − idle| < 100 W`. - So an idle value that is off by about 100 W or more, whether learned or restored, means a refusal on that battery is **never** caught. - The stale gate does not catch it either, because the refusing reading jitters. - This holds at N=1 and at N=2. - With the ±250 W clamp, a battery reading anything from 150 to 350 W can count as "gives nothing", and a battery reading 0 W does not. Rig: battery cut at 600 s, ±4 W noise, 1 s and 5 s meters. The idle reading was taught wrong by E during the first 400 s rest, or E was restored wrong. | unit | E caught (PR head) | E missed (never, in 300 s) | |---|---|---| | A (true 0 W), N=1 and N=2 | −90 … +90 | ≤ −110 and +110 … +250 | | B (true −100 W), N=1 and N=2 | −90 … +90 | ≤ −110 and ≥ +110 | - How it can go wrong: learning only needs the command within 50 W for 120 s. That happens when: - the command is held at ~0 W while the reading shows something else, for example a unit at its floor that the pack limit holds at 0 W while the inverter tops it up from the grid at under 250 W; - a register freezes at 100–250 W while the site rests; - a different inverter is put behind the same name, so the restored value belongs to the old one. - Recovery happens only through quiet time, with τ = 30 min. An error of 250 W is back under 100 W after about 30 min of quiet. An error of 350 W takes about 40 min. - At N=1, 0.14.0 always caught an A-type battery, so this is a new regression path. **Options** - **(a) Keep the ±250 W clamp.** Covers batteries whose idle reading is up to about ±350 W. Any mis-learn of 100 W or more hides refusals. - **(b) Clamp the value used to ±75 W, and keep accepting samples up to 250 W.** Recommended. The band then always keeps 0 W ± 25 W. - Rig, same sweep: A is caught at every E, both learned and restored. B is caught for E from −150 to +90. - B learned 110 W or more too high is still missed. That is 0.14.0's N=2 state. - It also means a wrong idle can never falsely refuse a 0 W-offset battery: an ask over 200 W minus 75 W is still more than 100 W. - Cost: a battery whose idle reading is beyond about ±170 W is never caught, as in 0.14.0. - On B (−95…−118 W) the band stays 60 W or more inside. - test_safety94 passes, and so do refusal, refusal_units, delivery and control. In test_safety97, only the checks that pin −100 W or 250 W change. - **(c) "Old band OR centred band".** Rejected. A correct B would then need to *give* 200 W to end its refusal, so a 200 W probe reading 82–105 W would not answer. ### R2: four mutants survived. Fixed in 88ebf3a. - X7: the N=1 stale-path refusal uncentred. - X8: SAFETY-42 "parked" uncentred. - X13: learning while control is stopped. - X14: learning while the sign watchdog has latched. - Each one is now red in `test_safety97.py` with a non-vacuous check. ### R3: equivalent or wording-only survivors. Refuted, no test. - X3: the learned-value clamp raised to 1000 W. Samples over 250 W are already dropped, so a mean or a drift cannot pass 250 W. Only the restore clamp matters, and X5 (restore unclamped) is red. - X10: learning while stale. A stale reading is `None`, and `inv.batt is not None` already blocks it. - X9: the N=1 end in `_refusal_update` uncentred. It changes only which end reason is logged; `compute()` decides the end, and S9 is red. ### Checked and fine - Full suite on 3db2b63: 62/62 pass. - 10-03 live replay with full HA history, 1 Hz: base 4 trips, PR 0. - I found no HA history for 2026-10-04 (08:45, 08:48, 09:00, 09:45), so those four are not verified here. They should be checked on the rig or the live log after deploy. - The refusal-evidence and freshness mutants M1, M3, M8, R3, R6, R7 and S1, S2, S4, S6, S7, S9, S10, S12 are red. So are my X1, X2, X4, X4c, X5, X6, X11, X12, X15 and X16. - Store: corrupt, NaN, inf, string and huge saved values give 0 W or are clamped. A renamed unit starts at 0 W, the old rule. - Swapping A for B behind one name: B's −100 W on a 0 W unit is caught only at the band's edge (96 W). A's −3 W on B is missed for about 5 min of quiet. - Unchanged checks (SAFETY-37, -57, -30, and SAFETY-42 shortfall): the reasons match the code. - Docs are plain. No version or CHANGELOG change, and no new user-facing strings. - F2 (35/d s) stands as recorded.
admin added 1 commit 2026-10-04 16:57:19 +02:00
SAFETY-97: centre the band on the idle reading clamped to 75 W
checks / unit (pull_request) Successful in 29m26s
infra-pin / fresh (pull_request) Failing after 2s
7d06aa674d
Owner decision 2026-10-04 (review R1). A wrong idle reading more than
about 100 W off hid every refusal on that battery. Clamped under the band,
the band keeps 0 W +-25 W, so a battery reading about 0 W while refusing is
caught whatever was learned or restored. Samples are still taken to 250 W.
Beyond about +-170 W of idle a battery behaves as in 0.14.0.

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

Review round 2, head 7d06aa6

R1: fixed, per the owner's decision of 2026-10-04 (option b)

The band is now centred on the idle reading clamped to ±75 W (IDLE_MAX_W). Samples are still accepted up to 250 W (IDLE_SAMPLE_W), and the saved value is restored clamped to 250 W. The owner's decision, the trade-off, and the note that a battery idling beyond about ±170 W behaves as in 0.14.0 are recorded in DECISIONS.

New checks in test_safety97.py part 3b, 1 s meter, N=1 and N=2:

  • A reads about 0 W and refuses, with its idle reading off by −250/−150/+150/+250 W (taught wrong), or restored at −250/+200/+250 W: refused at 28 s in every case. That includes the +200 W wrong idle_w.
  • B with its idle reading taught −150/−50/+90 W wrong: refused at 28 s (N=1) and 24 s (N=2).

Mutants on the new code:

id mutation result
Y1 clamp back to 250 W red (the ⭐ wrong-idle check, plus the pins)
Y2 clamp applied to the samples instead of the value used red (B's catch, plus three more)
Y3 value used unclamped red
Y5 restore ignores the clamp red
S1, S10, X4, X7, X8, X13, X14 as in round 1 red
M0 no change (control) green

X5 and X11 no longer apply as written. Y5 replaces X5. Dropping the sample filter is now covered by Y2 and by the over-250 W sample check.

R4: the 2026-10-04 live trips, replayed at 1 Hz

I replayed 08:30–10:00 local from the HA history through the gate.

  • Base 02ed120: 4 trips, at A 08:45:05, B 08:48:06, B 09:00:06 and A 09:45:05. Each is within 1 s of a live ERROR line.
  • PR head: 0 trips.
  • The 10-03 history also still replays at 4 trips on the base and 0 on the head.

Other results

  • Full suite on 7d06aa6: 62/62 pass.
  • No version, CHANGELOG or user-facing string change.

Nothing is left open from my side. I will merge once checks / unit is green on 7d06aa6.

## Review round 2, head 7d06aa6 ### R1: fixed, per the owner's decision of 2026-10-04 (option b) The band is now centred on the idle reading clamped to **±75 W** (`IDLE_MAX_W`). Samples are still accepted up to 250 W (`IDLE_SAMPLE_W`), and the saved value is restored clamped to 250 W. The owner's decision, the trade-off, and the note that a battery idling beyond about ±170 W behaves as in 0.14.0 are recorded in DECISIONS. New checks in `test_safety97.py` part 3b, 1 s meter, N=1 and N=2: - A reads about 0 W and refuses, with its idle reading off by −250/−150/+150/+250 W (taught wrong), or restored at −250/+200/+250 W: refused at 28 s in every case. That includes the +200 W wrong `idle_w`. - B with its idle reading taught −150/−50/+90 W wrong: refused at 28 s (N=1) and 24 s (N=2). Mutants on the new code: | id | mutation | result | |---|---|---| | Y1 | clamp back to 250 W | red (the ⭐ wrong-idle check, plus the pins) | | Y2 | clamp applied to the samples instead of the value used | red (B's catch, plus three more) | | Y3 | value used unclamped | red | | Y5 | restore ignores the clamp | red | | S1, S10, X4, X7, X8, X13, X14 | as in round 1 | red | | M0 | no change (control) | green | X5 and X11 no longer apply as written. Y5 replaces X5. Dropping the sample filter is now covered by Y2 and by the over-250 W sample check. ### R4: the 2026-10-04 live trips, replayed at 1 Hz I replayed 08:30–10:00 local from the HA history through the gate. - Base 02ed120: **4 trips**, at A 08:45:05, B 08:48:06, B 09:00:06 and A 09:45:05. Each is within 1 s of a live ERROR line. - PR head: **0 trips**. - The 10-03 history also still replays at 4 trips on the base and 0 on the head. ### Other results - Full suite on 7d06aa6: 62/62 pass. - No version, CHANGELOG or user-facing string change. Nothing is left open from my side. I will merge once `checks / unit` is green on 7d06aa6.
admin merged commit cf705a9058 into release/1.0 2026-10-04 17:27:21 +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#171