SAFETY-85: no false 'not keeping up' while maintenance or the peak guard sets the battery
checks / unit (pull_request) Successful in 25m35s
infra-pin / fresh (pull_request) Successful in 2s

The loop's integrator bound counts only on a cycle the loop decided. A battery
another owner set (maintenance, the peak guard, a safety limit) alarms only
when it gives more than saturation_w less than told for saturation_cycles
cycles. At N>=2 the loop is bounded while maintenance holds a battery, so it
does not wind and control comes back one slew step a cycle. The status row
counts episodes, not cycles. Digests re-pinned where behaviour changed on
purpose; DECISIONS/SAFETY-85-no-false-not-keeping-up.md has the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
This commit is contained in:
glenn schrooyen
2026-10-01 19:05:27 +02:00
co-authored by Claude Opus 5.5
parent 935878193b
commit 4b81387908
12 changed files with 554 additions and 35 deletions
+53 -7
View File
@@ -59,7 +59,8 @@ from .circulation import (
)
from .clamp import (
REASON_CODE as CLAMP_REASON, ClampWatchdog,
clamp_to_rating, is_commandable, loop_clamp_note, write_clamp_note,
clamp_to_rating, delivery_short, is_commandable, loop_clamp_note,
shortfall_note, write_clamp_note,
)
from . import freshness
from . import homeview
@@ -1179,6 +1180,10 @@ class Controller:
self.loop_clamped: tuple[bool, str, bool] = (False, "", False)
# UX-08: the numbers of a loop-side over-rating, for the notification.
self.loop_clamp_facts: tuple = ()
# SAFETY-85: this cycle's loop Decision, and how many cycles in a row
# a battery set by another owner gave less than asked.
self._loop_decision = None
self._short_run = 0
self.stopping = False
# SAFETY-61. time.monotonic() at the end of the last run_control() pass.
self.loop_pass_mono = None
@@ -4547,9 +4552,13 @@ class Controller:
refused=self.batt_refused if len(self.inverters) == 1
else self._site_refused(),
probe=probe,
# OPT-11: None unless battery modes are on, which is the law
# exactly as it was.
bound=self.loop_bound(claims) if self.modes_on else None,
# OPT-11: None unless battery modes are on. SAFETY-85: or while
# maintenance holds a battery at N>=2, so the loop does not
# wind past what the others can give and jump when it ends.
bound=(self.loop_bound(claims) if self.modes_on or (
len(self.inverters) > 1
and any(r.phase != IDLE for r in maint_results))
else None),
)
if decision.frozen and self.sat_count < self.tuning.saturation_cycles:
self.log_event(f"saturation freeze ({self.target:.0f} W vs "
@@ -4558,6 +4567,7 @@ class Controller:
self.sat_count = decision.sat_count
self.i_w = decision.i_w
self.loop_w = decision.target_w
self._loop_decision = decision
if len(self.inverters) == 1:
self._refusal_update(decision, loop_prev, site_batt, probe)
# --- TEL-10 ------------------------------------------------------
@@ -4637,6 +4647,7 @@ class Controller:
# de-assert when control is stopped.
self.loop_clamped = (False, "", False)
self.loop_clamp_facts = ()
self._loop_decision = None
# SAFETY-28. A new resolution is a new tick, and the previous record is
# stale from this line onwards. Incremented BEFORE resolve() so that the
@@ -4856,6 +4867,8 @@ class Controller:
# That is the dropped-link case: the inverter holds its last command
# FOREVER, so "not in the split" must mean zero on the wire, not
# silence. The write is the same clamped, budgeted path as every other.
# SAFETY-85, before the writes: the wire still holds last cycle's.
self._loop_alarm_input(resolution)
writes = []
for inv in self.inverters:
inv.target = float(resolution.shares.get(inv.name, 0.0))
@@ -5069,6 +5082,38 @@ class Controller:
for inv in self.inverters:
inv.target = shadow.WIRE_W
def _loop_alarm_input(self, resolution) -> None:
"""SAFETY-85. The loop's clamp counts only on a cycle the loop decided
(a rating may cut it). A battery another owner set counts when it gave
less than it was told for `saturation_cycles` cycles in a row.
DECISIONS/SAFETY-85-no-false-not-keeping-up.md."""
d = self._loop_decision
if d is None:
self._short_run = 0
return
unmet = self.allocation.unmet_w if self.allocation is not None else 0
cut = (resolution.bound_by or "").split("(")[0]
owns = (resolution.winner == "loop" and not d.held and not unmet
and (resolution.target_w == d.target_w
or cut in ("device", "supervised")))
# Graded against what stands on the wire (0 W in shadow mode).
others = [inv for inv in self.inverters if not owns
or allocator.pin_w(resolution.unit_bounds.get(inv.name))
is not None]
short = [(inv, inv.standing_w(), inv.batt) for inv in others
if inv.batt is not None
and delivery_short(inv.standing_w(), inv.batt,
self.tuning.saturation_w)]
self._short_run = self._short_run + 1 if short else 0
if owns and self.loop_clamped[0]:
return
engaged = self._short_run >= self.tuning.saturation_cycles
named = len(self.inverters) > 1
self.loop_clamped = (engaged, shortfall_note(
[(inv.name if named else None, a, g) for inv, a, g in short])
if engaged else "", False)
self.loop_clamp_facts = ()
async def check_output_clamp(self) -> None:
"""SAFETY-03. Alarm whenever a clamp actually engaged this cycle.
@@ -6291,7 +6336,8 @@ class Controller:
elif _cw.active:
# SAFETY-40: the integrator bound alone is amber, not red.
out.append({"ok": False, "warn": True, "id": "not_keeping_up",
"text": messages.row_not_keeping_up(_cw.bound)})
"text": messages.row_not_keeping_up(
_cw.bound_episodes)})
# No over-rating in THIS episode (`bug` would be set), so any count
# is from an earlier one: keep that software-fault evidence shown.
if _cw.over_rating:
@@ -6303,10 +6349,10 @@ class Controller:
out.append({"ok": False, "warn": True, "id": "power_too_high",
"text": messages.row_before(
messages.T_OUTPUT_CLAMP, _cw.over_rating)})
if _cw.bound:
if _cw.bound_episodes:
out.append({"ok": False, "warn": True, "id": "not_keeping_up",
"text": messages.row_before(
messages.T_LOOP_BOUND, _cw.bound)})
messages.T_LOOP_BOUND, _cw.bound_episodes)})
for inv in self.inverters:
if inv.last_write_ok is False: