SAFETY-52: a battery that refuses is asked for 0 W, with a 200 W probe every 15 min
At the BMS cut-out the saturation freeze held the last request for hours (+5000 W for 11.87 h on 09-06, +1940 W for 3 h 55 m on 09-19) until the grid error changed sign. compute() now carries a refused direction: after SETTLE_CYCLES of a ~0 W reading against a larger ask it holds that direction at 0 W, probes 200 W (below the gate's 250 W) every 15 minutes, and lets go on charge, an answered probe, stop/start or another writer. A stale-reading trip on a frozen ~0 W reading seeds the same hold, which ends SAFETY-59's ~70 s cycle. One inverter only; no SoC in the loop (SAFETY-25 Option A). test_refusal.py drives the real Controller and gate; tools/replay_refusal.py replays the 16-day capture. test_clamp.py and test_delivery.py re-baselined where they asserted the old hold. DECISIONS/SAFETY-52 has the record and what SAFETY-58/59 must take into account. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d1f7e3336e
commit
1f97c7652f
@@ -0,0 +1,84 @@
|
||||
# SAFETY-52 - a battery that refuses is asked for 0 W
|
||||
|
||||
**Decided 2026-09-27 by the owner, on the phase A plan (option D3). All four
|
||||
questions answered yes.**
|
||||
|
||||
## The defect
|
||||
|
||||
At the BMS cut-out the battery gives ~0 W whatever it is asked. The saturation
|
||||
freeze (`control.py`, `frozen`) then holds the request where it stood, and its
|
||||
SAFETY-13 exit cannot fire, because a reading of ~0 against a large ask lies
|
||||
inside the interval a limit can produce. The request stood at +5000 W for
|
||||
11.87 h on 2026-09-06 and at +1940 W for 3 h 55 m on the bus on 2026-09-19. It
|
||||
moved again only when the grid error changed sign. SAFETY-48's lead cap only
|
||||
changes where `i_w` parks (one slew step ahead instead of at the rail). When
|
||||
the request moved after the reading went flat, the stale-reading gate won
|
||||
instead and ran SAFETY-59's ~70 s trip cycle.
|
||||
|
||||
## What was decided
|
||||
|
||||
1. **A refused direction is held at 0 W**, reason `battery refuses`. It engages
|
||||
in `compute()` when the freeze has held `SETTLE_CYCLES` and the reading is
|
||||
within `saturation_w` of 0 against an ask beyond it. The drop to 0 skips
|
||||
the slew limit (it only removes an ask nobody meets). The direction is
|
||||
carried like `sat_count` (`refused=`, `Decision.refused`).
|
||||
2. **A 200 W probe every 15 minutes, 34 s long** (`PROBE_W`, `PROBE_EVERY_S`,
|
||||
`PROBE_S`; twice `BATT_OBLIGED_S`). An answer of at least 100 W ends the
|
||||
refusal. 200 W is below `freshness.MOVE_W` (250 W), so a probe cannot arm
|
||||
the gate. The clock is main.py's `now_mono`; `compute()` stays clockless.
|
||||
3. **A gate trip while the loop is frozen on a ~0 W reading is a refusal** for
|
||||
control (`Controller._refusal_from_stale`). This is what stops the ~70 s
|
||||
cycle. The log keeps SAFETY-56's two-explanation wording.
|
||||
4. **No SoC in the loop.** SAFETY-25 Option A stands. Nothing reads SoC.
|
||||
|
||||
Other exits: the loop wants the other direction (charge is accepted at the
|
||||
floor), control stopped or started, another writer on the wire (the arbiter
|
||||
reseed). A gap in the readings keeps the refusal, because the gate's own stop is
|
||||
what seeds it.
|
||||
|
||||
**One inverter only.** At N>1, OPT-09's `pack` limit already closes an empty
|
||||
battery's discharge end and moves its share. There, a one-unit gate trip must
|
||||
not refuse the site. `compute()` is passed `refused=None` there, and that path
|
||||
is release/1.0's law unchanged (the `i_w=None` equivalence sweep in
|
||||
`test_control.py` still holds).
|
||||
|
||||
## Evidence
|
||||
|
||||
`test_refusal.py` drives the real Controller and gate (the 09-06 and 09-19
|
||||
shapes, recovery with no sign change, the probe cadence, every exit).
|
||||
`tools/replay_refusal.py` replays the 16-day capture, same model as
|
||||
`replay_recorded_loop.py`, and should be read for deltas:
|
||||
|
||||
| plant | law | gate trips | request >1 kW above delivery | longest held | import / export kWh | worst 15-min |
|
||||
|---|---|---|---|---|---|---|
|
||||
| fitted | old | 85 | 113.3 h | 16.57 h | 112.0 / 35.2 | 3434 W |
|
||||
| fitted | new | 130 | 11.4 h | 0.21 h | 112.3 / 35.4 | 3456 W |
|
||||
| offset 0 W | old | 1010 | 102.2 h | 16.58 h | 112.0 / 35.2 | 3434 W |
|
||||
| offset 0 W | new | 85 | 6.1 h | 0.33 h | 111.9 / 35.1 | 3434 W |
|
||||
|
||||
The +22 W worst quarter and the +45 trips on the fitted plant follow the fit's
|
||||
-22 W offset, which slowly charges the modelled pack above its floor while the
|
||||
hold stands. With the offset at 0 both vanish. That is inferred from these two
|
||||
rows and was not traced further. Rejected alternatives and their figures are in
|
||||
the phase A plan: bounding the integrator by demand made the worst quarter
|
||||
+541 W worse or cycled the gate 3368 times, and an SoC envelope needs the
|
||||
configured floor to be right and needs SoC.
|
||||
|
||||
## Not covered
|
||||
|
||||
A battery that gives a steady part of the ask (e.g. a 500 W keep-alive) is a
|
||||
genuine limit (SAFETY-13 case a) and still holds its request.
|
||||
|
||||
## What SAFETY-58 and SAFETY-59 must take into account
|
||||
|
||||
- **SAFETY-58** (the first read after a rest trips at once): a probe never arms
|
||||
the gate (200 < 250 W), but the refusal seeding reads the trip. If 58 delays
|
||||
the first verdict, the seed arrives later. The 09-19 shape then needs the
|
||||
compute-side engagement (12 cycles) to win, which it does only while the
|
||||
request stands still. Keep `test_refusal.py` part 4 green.
|
||||
- **SAFETY-59** (DOCS says a ~70 s pattern proves a failed register): with this
|
||||
change a refusing battery no longer produces the pattern at one inverter. The
|
||||
DOCS text should say so, and that the `battery refuses` reason is the
|
||||
discriminator the add-on now has. At N>1 the old pattern can still occur.
|
||||
- If either changes `MOVE_W`, `PROBE_W` must stay below it.
|
||||
`test_refusal.py` part 2 checks the inequality.
|
||||
Reference in New Issue
Block a user