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
@@ -265,6 +265,30 @@ _SAT_COUNT_CAP = SETTLE_CYCLES + 1 # 13
|
||||
# `slew_limit_w()` and therefore with the rail.
|
||||
SLEW_LEAD_STEPS = 1
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SAFETY-52. A BATTERY THAT REFUSES IS ASKED FOR 0 W, NOT HELD AT ITS LAST ASK.
|
||||
#
|
||||
# At the BMS cut-out the pack delivers ~0 W whatever it is asked, so the
|
||||
# saturation freeze below holds the command where it stood (the recorded
|
||||
# 09-06 case: +5000 W for 11.87 h) until the grid error changes sign. The
|
||||
# freeze's own exit cannot help: a reading of ~0 against a large ask is inside
|
||||
# the interval a limit can produce. So a refused DIRECTION is held at 0 W,
|
||||
# carried between cycles like `sat_count`, and left again when the loop wants
|
||||
# the other direction, when a small probe is answered, or when the caller
|
||||
# resets it (stop, start, another writer). The record is DECISIONS/SAFETY-52.
|
||||
#
|
||||
# PROBE_W is below freshness.MOVE_W (250 W) on purpose: a probe that small can
|
||||
# never arm the stale-reading gate, so it cannot start the gate's ~70 s cycle.
|
||||
# test_refusal.py pins the inequality. The cadence and length are the owner's
|
||||
# 2026-09-27 answer (every 15 minutes) and freshness.BATT_OBLIGED_S twice over
|
||||
# (17 s is how long a healthy reading needs to show a command).
|
||||
REFUSED = "battery refuses"
|
||||
PROBING = "battery probe"
|
||||
PROBE_W = 200.0
|
||||
PROBE_ANSWER_W = PROBE_W / 2
|
||||
PROBE_EVERY_S = 900.0
|
||||
PROBE_S = 34.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Tuning:
|
||||
@@ -346,6 +370,9 @@ class Decision:
|
||||
i_bound_w: float = 0.0
|
||||
requested_w: float = 0.0
|
||||
clamped_w: float = 0.0
|
||||
# SAFETY-52. The refused direction to carry back in (+1 discharge, -1
|
||||
# charge, 0 none), or None when the caller does not carry it.
|
||||
refused: int | None = None
|
||||
|
||||
|
||||
def slew_limit_w(tuning: Tuning) -> float:
|
||||
@@ -442,6 +469,8 @@ def compute(
|
||||
tuning: Tuning,
|
||||
sat_count: int = 0,
|
||||
i_w: float | None = None,
|
||||
refused: int | None = None,
|
||||
probe: bool = False,
|
||||
) -> Decision:
|
||||
"""One control cycle. A cycle is one meter update (~5 s on a HomeWizard P1).
|
||||
|
||||
@@ -449,6 +478,9 @@ def compute(
|
||||
`grid_w` net grid power, + = importing
|
||||
`actual_w` what the inverter reports it is doing, + = discharging
|
||||
`i_w` the carried integrator, or None to seed it from `prev_w`
|
||||
`refused` SAFETY-52's carried refused direction, or None to leave it out
|
||||
(the `i_w=None` equivalence path does)
|
||||
`probe` the caller's clock says a refused direction may try PROBE_W now
|
||||
"""
|
||||
reason = "tracking"
|
||||
|
||||
@@ -739,6 +771,26 @@ def compute(
|
||||
target = min(target, prev_w) if prev_w > 0 else max(target, prev_w)
|
||||
reason = "saturated-freeze"
|
||||
|
||||
# --- SAFETY-52: a refused direction is held at 0 W ----------------------
|
||||
# Engages only where the freeze has held for SETTLE_CYCLES and the unit
|
||||
# delivers ~nothing: a reading within `saturation_w` of 0 against an ask
|
||||
# beyond it is a refusal, not a slow plant. The drop to 0 skips the slew
|
||||
# limit because it only removes an ask the battery is not meeting.
|
||||
if refused is not None:
|
||||
if (refused == 0 and frozen and sat_count >= SETTLE_CYCLES
|
||||
and abs(prev_w) > tuning.saturation_w
|
||||
and abs(actual_w) < tuning.saturation_w):
|
||||
refused = 1 if prev_w > 0 else -1
|
||||
if refused and refused * i_w < 0:
|
||||
refused = 0 # the loop wants the other way
|
||||
elif refused and probe and refused * actual_w >= PROBE_ANSWER_W:
|
||||
refused = 0 # the probe was answered
|
||||
elif refused:
|
||||
wants = refused * i_w > 0
|
||||
i_w = 0.0
|
||||
target = refused * PROBE_W if probe and wants else 0.0
|
||||
reason = PROBING if probe and wants else REFUSED
|
||||
|
||||
# --- quantise ----------------------------------------------------------
|
||||
# 10 W. The register is 1 W and the inverter reports at 1 W, but its
|
||||
# response lands on a coarser ladder (~17.6 W measured at ~900 W, consistent
|
||||
@@ -749,7 +801,7 @@ def compute(
|
||||
|
||||
return Decision(float(target), sat_count, frozen, reason, float(i_w),
|
||||
i_clamped, clamped, float(i_bound_w), float(want),
|
||||
float(clamped_w))
|
||||
float(clamped_w), refused)
|
||||
|
||||
|
||||
def maintenance_charge_floor(
|
||||
|
||||
Reference in New Issue
Block a user