SAFETY-128: control does not act on late Home Assistant readings
Readings that Home Assistant delivers late but still changing passed every staleness gate, so the loop corrected again each cycle an error its last command had already fixed (10 s late: the give run swung and exported to -1.9 kW), and the peak guard pushed to the inverter limit on the same late figures (OPT-34 F-2). app/lateness.py fits how long after a power request the meter shows it. Over 8 s the readings are late: a request the battery reading has not answered holds the loop (it may not lead the reading by more than 100 W) and caps the peak guard at what stands; unanswered for 23 s it holds, then 0 W after stale_input_s. An amber row and one log line per episode say so. With readings on time nothing changes (digests equal). DECISIONS/SAFETY-128-late-readings.md; test_safety128.py. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
This commit is contained in:
co-authored by
Claude
parent
77fe31b161
commit
09e1fbb898
@@ -64,6 +64,7 @@ from .clamp import (
|
||||
shortfall_note, write_clamp_note,
|
||||
)
|
||||
from . import freshness
|
||||
from . import lateness
|
||||
from . import dayview, homeview
|
||||
from .daycurve import SolarToday
|
||||
from . import messages
|
||||
@@ -1281,6 +1282,8 @@ class Controller:
|
||||
self.cycle_at = None
|
||||
self._arb_action = None
|
||||
self.inputs_bad_since = None
|
||||
self.answer = lateness.Answer() # SAFETY-128
|
||||
self._late_logged = self._late_zero = False
|
||||
# SAFETY-03. What the loop's OWN bounds did last cycle - a
|
||||
# (engaged, note) pair so the alarm can say WHICH bound bit without
|
||||
# re-deriving it from a string. ⚠️ THIS ONE STAYS SITE-LEVEL, and it is
|
||||
@@ -3116,6 +3119,36 @@ class Controller:
|
||||
return (decision if self._peak_needed
|
||||
else dataclasses.replace(decision, within_aim=True))
|
||||
|
||||
def _readings_late(self, standing_w):
|
||||
"""SAFETY-128: update the answer gate; seconds the late verdict has
|
||||
stood, or None. Readings found late are logged once per episode.
|
||||
DECISIONS/SAFETY-128-late-readings.md."""
|
||||
a = self.answer
|
||||
if self._now_mono is None:
|
||||
return None
|
||||
a.observe(self._now_mono, standing_w, self.site_batt_w,
|
||||
grid_w=self.grid, exempt=bool(self.batt_refused) or any(
|
||||
i.refused or (
|
||||
i.batt is not None
|
||||
and abs(i.standing_w()) >= lateness.ANSWER_W
|
||||
and abs(i.batt - i.idle_w) < lateness.ANSWER_W)
|
||||
for i in self.inverters),
|
||||
idle_w=self._site_idle_w())
|
||||
if a.late_seen != self._late_logged:
|
||||
self._late_logged = a.late_seen
|
||||
if a.late_seen:
|
||||
self.log_event(f"readings late ({a.delay_s:.0f} s) - holding",
|
||||
hint=messages.hint("readings_late"))
|
||||
_LOG.warning("Home Assistant readings arrive late, so control "
|
||||
"holds the battery: READINGS_LATE.")
|
||||
_LOG.debug("READINGS_LATE: the meter shows a power request "
|
||||
"%.0f s after it is sent.", a.delay_s)
|
||||
else:
|
||||
self.log_event("readings on time", hint=messages.hint(
|
||||
"readings_back"))
|
||||
_LOG.info("Home Assistant readings are on time again.")
|
||||
return a.late_s(self._now_mono)
|
||||
|
||||
def _peak_reserve(self) -> frozenset:
|
||||
"""SAFETY-87: the batteries that give nothing for the peak this
|
||||
cycle: under their reserve while the others carry a peak floor."""
|
||||
@@ -4647,6 +4680,8 @@ class Controller:
|
||||
"away a partly delivering unit's output.")
|
||||
|
||||
stale_after = float(self.o.get("stale_input_s", 15))
|
||||
late_s = self._readings_late(site_standing) # SAFETY-128
|
||||
self._late_zero = late_s is not None and late_s >= stale_after
|
||||
# ⚠️ `any`, NOT the sole unit's. The hold exists so a single missed poll
|
||||
# does not become a fault; a LATCHED inverter must still reach the claim
|
||||
# list below, because its `failsafe` SET is what parks it. One tripped
|
||||
@@ -4672,6 +4707,13 @@ class Controller:
|
||||
self.why_words = messages.why_waiting(bad_for)
|
||||
self._why_waiting_since = now - timedelta(seconds=bad_for)
|
||||
return
|
||||
if late_s is not None and late_s < stale_after and self.inputs_ok \
|
||||
and (self.auto or self.shadow) \
|
||||
and not any(i.sign_wd.tripped for i in self.inverters) \
|
||||
and not (n_units > 1 and self.site_sign_wd.tripped):
|
||||
self.reason = f"readings late {late_s:.0f}s" # SAFETY-128: hold
|
||||
self.why_words = messages.why_late()
|
||||
return
|
||||
|
||||
claims = []
|
||||
|
||||
@@ -4850,6 +4892,9 @@ class Controller:
|
||||
hint=messages.hint("inputs_missing"))
|
||||
claims.append(Claim.set("safety", P_SAFETY, 0.0, "inputs missing",
|
||||
actuator=self.actuator_of("safety")))
|
||||
elif late_s is not None:
|
||||
claims.append(Claim.set("safety", P_SAFETY, 0.0, "readings late",
|
||||
actuator=self.actuator_of("safety")))
|
||||
|
||||
# --- SAFETY-07: the quarter-peak projection guard --------------------
|
||||
# ONE `lo` limit at P_PEAK, and peakguard.py carries the whole argument.
|
||||
@@ -4973,6 +5018,8 @@ class Controller:
|
||||
self.peak_guard = self._peak_on_able(self.peak_guard,
|
||||
maint_results)
|
||||
self.peak_guard = self._peak_landing(self.peak_guard)
|
||||
self.peak_guard = peakguard.cap_floor( # SAFETY-128
|
||||
self.peak_guard, self.answer.peak_cap(site_standing))
|
||||
# SITE, and peakguard.claim() says so on the object it builds. The
|
||||
# quantity is the 15-minute average OFFTAKE AT THE GRID CONNECTION,
|
||||
# which is what the capacity tariff bills; there is one quarter-hour
|
||||
@@ -5437,6 +5484,7 @@ class Controller:
|
||||
if len(self.inverters) == 1 else None),
|
||||
# SAFETY-97: "gives nothing" is centred on the idle reading.
|
||||
idle_w=self._site_idle_w(),
|
||||
hold=self.answer.hold, hold_w=self.answer.hold_w, # SAFETY-128
|
||||
# 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.
|
||||
@@ -7333,6 +7381,10 @@ class Controller:
|
||||
(inv.refusal_note(), False)):
|
||||
if note:
|
||||
out.append({"ok": False, "warn": not red, "text": note})
|
||||
if self.answer.late_seen: # SAFETY-128
|
||||
out.append({"ok": False, "warn": True, "id": "late",
|
||||
"text": messages.row_readings_late(
|
||||
self.answer.delay_s, self._late_zero)})
|
||||
|
||||
# SAFETY-03. Red while an over-rating clamp is engaged (amber for the
|
||||
# integrator bound, SAFETY-40), and the engagement COUNT is shown even
|
||||
|
||||
Reference in New Issue
Block a user