SAFETY-119: no "not keeping up" when the loop takes the wire back

When the peak guard or a maintenance charge hands the wire back, the loop's
integrator is re-seeded from that owner's command and kicked by a meter that
does not show it yet. It crossed its rail for a cycle or two and raised
goodwe_integrator_bound while the battery gave what it was told.

The loop's integrator bound is not counted until the plant's settle
(SHORT_CYCLES, SHORT_SPAN_S) has passed since the hand-back. An over-rating
always counts; a bound that lasts warns after the settle. No command
changes and no text changes. test_safety119.py; DECISIONS entry.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-10-19 14:14:08 +02:00
co-authored by Claude
parent 75f0804f9d
commit c4e15ec4cd
3 changed files with 404 additions and 1 deletions
+30 -1
View File
@@ -1272,6 +1272,10 @@ class Controller:
self._loop_decision = None
self._asked_hist: dict = {}
self._short_run = 0
# SAFETY-119: did another owner set the wire last cycle, and since
# when (monotonic s, owned cycles) has the loop had it back.
self._wire_lost = False
self._handback = None
self.stopping = False
# SAFETY-61. time.monotonic() at the end of the last run_control() pass.
self.loop_pass_mono = None
@@ -5813,6 +5817,7 @@ class Controller:
if d is None:
self._short_run = 0
self._asked_hist = {}
self._wire_lost, self._handback = False, None
return
unmet = self.allocation.unmet_w if self.allocation is not None else 0
cut = (resolution.bound_by or "").split("(")[0]
@@ -5838,7 +5843,9 @@ class Controller:
and delivery_short(asked[inv.name], inv.batt,
self.tuning.saturation_w)]
self._short_run = self._short_run + 1 if short else 0
if owns and self.loop_clamped[0]:
settling = self._settling(owns, now)
if owns and self.loop_clamped[0] and not (
settling and not self.loop_clamped[2]):
return
engaged = self._short_run >= self.tuning.saturation_cycles
named = len(self.inverters) > 1
@@ -5847,6 +5854,28 @@ class Controller:
if engaged else "", False)
self.loop_clamp_facts = ()
def _settling(self, owns, now) -> bool:
"""SAFETY-119: is the loop within the plant's settle (SHORT_CYCLES
cycles and SHORT_SPAN_S) of taking the wire back from another owner?
Its integrator is re-seeded from that owner's command and kicked by a
meter that does not show it yet, so its bound there is not counted.
DECISIONS/SAFETY-119-bound-after-hand-back.md."""
if not owns:
self._wire_lost, self._handback = True, None
return False
if self._wire_lost:
self._wire_lost, self._handback = False, (now, 0)
if self._handback is None:
return False
t0, n = self._handback
n += 1
self._handback = (t0, n)
if n >= SHORT_CYCLES and (now is None or t0 is None
or now - t0 >= SHORT_SPAN_S):
self._handback = None
return False
return True
async def check_output_clamp(self) -> None:
"""SAFETY-03. Alarm whenever a clamp actually engaged this cycle.