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>
70 lines
3.8 KiB
Markdown
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.
|