Files
goodwe-addon/goodwe_controller/DECISIONS/SAFETY-95-drain-end.md
T
glenn schrooyenandClaude Opus 5.5 4707d37019
checks / unit (pull_request) Successful in 32m34s
infra-pin / fresh (pull_request) Successful in 1s
SAFETY-95 review: test the stale-link and contradiction legs; refusal +1 only
F1: test_safety95 checks that a dropped draining battery is this rule's
(stale link, N=3, 4000 W: grid import 676 W vs base 786 W); kills R2.
F2: _charge_track tested directly with a contradicting drain (SAFETY-91's
pin as a floor against a closed pack); kills R3. Today a drain contradicts
only through a refusal; the clause stays, as a contradiction sends 0 W.
F3: a drain ends on a refusal only when the battery will not give
(refused > 0, as _held_back()); one that will not take still drains.
Tested both signs; kills R8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 16:41:16 +02:00

70 lines
3.8 KiB
Markdown

# SAFETY-95 - a maintenance drain that ends hands back smoothly
**Status: implemented 2026-10-06, for review.** Tests: `test_safety95.py`.
**The defect** (SAFETY-93 review, O1). At N=2, when A's drain ended (A
switched Off, say) with the house at 1200 W, B went 0 -> +1600 W in one
cycle and exported. It is the mirror of SAFETY-93 on the drain end.
**Cause.** During a drain the site target carries A's drain floor
(SAFETY-78, `drain`, 2000 W) above what the house needs. At the end A's
command drops (pin, Off, abort, stale link, refusal), but the loop slews
from last cycle's target (2000 -> 1600) and only B is left to place it.
Under a solar surplus B gave 1600 W into the export.
**The rule** (`main.py`, `_reseed_drain_end()`, `_drain_grid_w()`, N>=2
only). On the cycle a drain ends:
1. The start point is what the others stood at plus A's new command (its
pin, else 0 W; a dropped battery is sent 0 W), as SAFETY-93 does.
2. If the house needs more, the start is raised to the house's own point
(live batteries' readings + grid - target, as `_reseed_run_edge()`),
but never above last cycle's start. So the others cover the house at
once and never take over the drain's export.
3. While A settles (SAFETY-96's window: within the deadband, at most
`PLANT_SETTLE_DOWN_S`), the loop's error is the house's point minus its
start, not the meter. The meter still shows A's tail and the others'
climb; chasing it sent B down and then up again.
4. Then the loop moves one slew step a cycle.
A drain ends when its battery leaves DRAIN, is not live, its claims
contradict, or it will not give (SAFETY-73's limit outranks the pin
without a contradiction, so the refusal is read from the battery; +1 only,
as `_held_back()`). Today a contradiction on a drain only arises through
a refusal; the clause stays because a contradiction sends A 0 W, which is
an end (tested directly). The end is kept
until the loop runs (a missed poll). A dropped draining battery is this
rule's, not `_reseed_drop()`'s. Order in one cycle: drop/rejoin
(SAFETY-96), charge end (SAFETY-93), drain end (this) last; each moves the
start by its own battery, and the drain end's raise reads the start the
others left.
**Why not SAFETY-93's rule alone** (step 1, then slew). Measured on the
same plant: at 1200 W the grid imports 849 W and B goes to -465 W
(charging) while the loop chases A's tail; at N=3 and 4000 W it imports
784 W where base imports 42 W, as the others give up A's share of the house.
The house point keeps the grid at its aim; the cap at last cycle's start
keeps it from exporting.
**Before -> after** (`test_safety95.py`, 2500 W batteries, 10 s lag, 2 s
meter; base = the same tree with the change taken out):
| case | before | after |
|---|---|---|
| drain done / Off / timeout, N=2, 1200 W | B 0 -> 1600, +390 W commanded export, grid then +559 W | B 0 -> 1210, grid within 40 W of its aim |
| the same, N=3, 1200 W | B, C 0 -> 800, grid +695 W | 0 -> 605, grid +39 W |
| the same, solar surplus | B gives 1600 W into the surplus | B never gives; site moves 400 W a cycle |
| refusal, N=3, 1200 W | B, C step 1020 W, +2000 W commanded export | step 20 W, +70 W |
| N=3, 4000 W | others take A's share at once | the same (grid +1 W, base +42 W) |
| abort (site zeroed by the operator) | climbs from 0, grid +669 W | climbs from 0, grid +16 W |
| stale link (board watchdog 25 s) | | as base, but N=3 4000 W: grid +676 W (base +786) |
| N=1, every drain end | | byte-identical |
`tools/opt11_identity.py` n2_maint moved on purpose (A's drain refused at
1655 s; B stays at the house's 1420 W instead of 2500 W). Re-pinned in
`test_modes.py`. The other 45 digests are byte-identical.
**Not changed.** The peak guard's floor still binds (arbiter, after the
loop). Held batteries still give last; the idle clamp and the freeze test
are as they were. A charge end (SAFETY-93) and a trade end are unchanged.