Files
goodwe-addon/goodwe_controller/DECISIONS/SAFETY-91-held-battery-last.md
adminandClaude Opus 5.5 4cd604afcb
checks / unit (pull_request) Successful in 26m26s
infra-pin / fresh (pull_request) Successful in 1s
SAFETY-91 review: a held-back battery is released 1 point above its reserve
A SoC reading flipping across the reserve swapped ~1000 W between the
batteries on each flip (grid +-500 W with unequal lags). Also grades
next_quarter()'s staleness boundary (29.5 / 30.5 s).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 14:54:20 +02:00

4.2 KiB

SAFETY-91 - a held battery is drawn only after the others are full

Status: DECIDED 2026-10-01 (owner: held battery last, SAFETY-25 option A kept). Follows SAFETY-87. Tests: test_safety91.py.

The defect (SAFETY-87 rig test, F1 and F2). At N>=2 a battery at or under its own peak reserve was held back (weight 0) only on cycles where the peak guard asked for power.

  • F1: B draining at 1000 W, A held at 15 %, house 5500. Once the grid was near zero the loop set the target, A had its normal weight and B's drain pin bound, so the loop drew A at 2500 W for the whole quarter (15.0 -> 10.7 %) while B stayed at 1000 W. In the next quarter A reached its cut-out, and B stayed pinned at the peak floor (~2422 W), not its 2500 W.
  • F2: in a HOLD (A holding, B held), at a quarter's edge the guard saw a block with no seconds left and asked for nothing for one cycle. The HOLD's ceiling lapsed, A went back to 0 W and the loop's target went to B: 2500 W for 1 s.

The fix (main.py, N>=2 only).

  • _held_back(): the batteries at or under their own reserve while another can still give (live, not refusing), graded as in _peak_on_able(). They weigh 0 in every split where the site gives, not only for the peak. With nobody able (all held, or the rest refusing or dropped) nobody is held back and the split is as before. Review: once held back, a battery stays so until its SoC reads more than 1 point above its reserve. A reading flipping 20/21 % otherwise swapped ~1000 W between the batteries on each flip (grid +-500 W with A 10 s and B 3 s lag). The guard's own permission (SAFETY-87) is unchanged.
  • _drain_rise(): while a battery is held back, a draining battery's pin becomes a floor at its drain rate (lo), so it rises towards its rating before the held one gives. Not for a held battery's own drain, not under a site stop, not for a battery that is not live.
  • tariff.next_quarter(): past a block's end, before the next block's first telegram, the guard grades the next quarter, projected from the held reading (the averager credits it there). Not when that reading is too old to be credited (max_hold_s).

What stays. SAFETY-25 option A: once every able or draining battery is at full power, the loop still draws a held battery (allocator._fill()'s last-resort pass). With house 5500 and 2 x 2500 W, A still gives 2500 W after B is full. Nothing is placed on a battery at its cut-out: OPT-09's pack limit closes it (hi = 0), and the drain rise lets B take the rest.

Before -> after (test_safety91.py, 2 x 2500 W, lagging battery 10 s):

case before after
B drain 1000, A held, house 4000 A 2500, B 1000 A 1500, B 2500
same, house 5500 A 2500, B 1000 A 2500, B 2500 (option A)
cycles A asked while B not full 612 of 900 0
A at its cut-out, house 5500: B 1572 W mean 2500 W, A 0 W
HOLD, late telegram at the edge A 0, B 2470 for 1 cycle A 2050, B 0
idle, A held, house 2000 A 1005, B 1005 A 0, B 2020
N=1, every scenario byte-identical

Why N>=2 only. At N=1 there is no other battery to give first. The edge roll would also fix the N=1 one-cycle dip at a quarter's edge (SAFETY-86 rig, "A dropped to 0 W for 1 s"), but this ticket keeps N=1 byte-identical; extending it is a one-line change for a later ticket.

Re-pinned. test_modes.py n2 and n2_off: from t=1600 battery A is at its 20 % reserve, so B carries the house and A gives last (100 of 540 cycles differ). With SAFETY-87's held rule both are a5dddad's again, and the test grades that. Two fixtures changed with it: test_safety87.py part 10's "without the raise" run also turns off _drain_rise() (else B rises anyway), and test_peakguard.py 10g and test_watchdog.py's two-inverter rig give their second battery a cut-out, as rule 8 requires (an unknown cut-out is held, so A alone would carry the cycle).

Not changed. The peak guard's own rules (SAFETY-87). A drain with nobody held stays a pin (SAFETY-78). An Off battery beside a held one (SAFETY-87 Open). The first cycle after start, before any quarter is measured, can still put the loop's first step on a held battery in a HOLD. SAFETY-88, -89, -90, -92, -93, -94.