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
+26
View File
@@ -260,6 +260,24 @@ def loop_clamp_note(d) -> str:
return ""
def shortfall_note(named) -> str:
"""SAFETY-85: the alarm text while another owner sets the batteries.
`named` is `[(inverter name or None, asked W, given W)]`, one per battery
that gave less than asked."""
return f"{REASON_CODE}: " + ", ".join(
f"{name or 'the battery'} gives {given:+.0f} W of the {asked:+.0f} W "
f"asked" for name, asked, given in named)
def delivery_short(asked_w, given_w, margin_w) -> bool:
"""SAFETY-85: did a battery give more than `margin_w` less than asked, in
the asked direction? Giving more than asked, or 0 W asked, is not short."""
asked, given = float(asked_w), float(given_w)
if asked == 0.0:
return False
return (asked - given) * (1.0 if asked > 0 else -1.0) > abs(float(margin_w))
def write_clamp_note(named) -> str:
"""The write-side alarm text: `named` is `[(inverter name or None,
ClampResult)]` for every inverter whose write was cut back this cycle.
@@ -382,6 +400,10 @@ class ClampWatchdog:
self.over_rating = 0 # bug=True: "Power request too high"
self.bound = 0 # bug=False: "Battery is not keeping up"
self.episodes = 0 # rising edges
# SAFETY-85: episodes with a bug=False engagement. The status row
# counts these; counting cycles read 861 on one rig run.
self.bound_episodes = 0
self._bound_counted = False
self.since: datetime | None = None
self.last_alarm: datetime | None = None
self.reason = ""
@@ -436,6 +458,10 @@ class ClampWatchdog:
self.spoken = False
self.bug = False
self.bug_spoken = False
self._bound_counted = False
if not bug and not self._bound_counted:
self._bound_counted = True
self.bound_episodes += 1
if bug or not self.bug:
self.reason = reason
self.facts = tuple(facts)
+7 -1
View File
@@ -382,6 +382,9 @@ class Decision:
# 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
# SAFETY-85. The `bound` held the integrator or the command this cycle,
# so the batteries' limits, not the loop, decided it.
held: bool = False
def slew_limit_w(tuning: Tuning) -> float:
@@ -614,11 +617,13 @@ def compute(
# OPT-11: the integrator does not wind past what the modes let the
# batteries place. Only a side tighter than the rail is left in `bound`
# (above), so this never takes SAFETY-03's `i_clamped` away from the rail.
held_by_bound = False
if bound is not None:
held = max(float(bound[0]), min(float(bound[1]), i_w))
if held != i_w:
i_w = held
reason = MODE_BOUND
held_by_bound = True
# ⚠️ Applied EVERY cycle, frozen or not: the freeze is conditional, this
# bound is not. It is what makes the worst-case unwind time finite and
@@ -839,6 +844,7 @@ def compute(
if inside != target:
target = inside
reason = MODE_BOUND
held_by_bound = True
# SAFETY-48's lead cap above may pull the integrator back toward the
# slewed command; what is CARRIED stays inside the bound too.
i_w = max(float(bound[0]), min(float(bound[1]), i_w))
@@ -860,7 +866,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), refused)
float(clamped_w), refused, held_by_bound)
def maintenance_charge_floor(
+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: