SAFETY-85: no false 'not keeping up' while maintenance or the peak guard sets the battery
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:
co-authored by
Claude Opus 5.5
parent
935878193b
commit
4b81387908
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user