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>
75 lines
4.2 KiB
Markdown
75 lines
4.2 KiB
Markdown
# 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.
|