SAFETY-96: a battery the loop cannot move does not freeze it
At N>=2 a battery out of the split (stale link), with refused writes, or settling after a step the loop did not make (a charge end, a rejoin) now counts in the freeze test at its command, not its reading. Its real power still reaches the loop through the meter. At a drop its share leaves the loop's start point (the others take over one slew step a cycle); at a rejoin it enters at its reading. No refusal is learned while a battery's writes are refused. N=1 and the 46 identity digests are byte-identical. test_safety78 part 2: the dropped-drain case now rests at B 1510 W (grid at its -10 W aim) instead of 1500 W; the drop no longer sends B to 2500 W. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1bf6b7f9a7
commit
88b9cd470a
@@ -1025,6 +1025,12 @@ class Controller:
|
||||
# cycle, and those whose charge ended since the loop last ran.
|
||||
self._charging_was = frozenset()
|
||||
self._charge_ended = frozenset()
|
||||
# SAFETY-96: last cycle's commandable batteries, this cycle's start
|
||||
# point per battery where a reseed moved it, and batteries settling
|
||||
# after a step the loop did not make ({name: since, monotonic}).
|
||||
self._live_was = None
|
||||
self._loop_start = {}
|
||||
self._settle = {}
|
||||
# Set by amain() once the transport is built, so the status page can show
|
||||
# what only the transport knows (homewizard_local's unchanged_s). Stays
|
||||
# None when P1 is off, or under a transport that has no such counter.
|
||||
@@ -1393,6 +1399,11 @@ class Controller:
|
||||
self._unit_refusal_end(
|
||||
inv, "the battery takes power the other way")
|
||||
continue
|
||||
if inv.rejected_since is not None:
|
||||
# SAFETY-96: its writes are refused, so its board may have
|
||||
# gone to 0 W on its own; that is no refusal by the battery.
|
||||
inv.ref_count = 0
|
||||
continue
|
||||
sign = self._gave_nothing(inv)
|
||||
if sign:
|
||||
self._unit_refusal_start(
|
||||
@@ -2456,9 +2467,69 @@ class Controller:
|
||||
continue
|
||||
now_w = (float(res.setpoint_w or 0.0) if res.owns_setpoint
|
||||
else max(float(inv.target), 0.0))
|
||||
if not inv.is_live(datetime.now(timezone.utc))[0]:
|
||||
now_w = 0.0 # SAFETY-96: out of the split, it is sent 0 W
|
||||
self.target += now_w - float(inv.target)
|
||||
# SAFETY-96: it settles from its charge to that command.
|
||||
self._loop_start[inv.name] = now_w
|
||||
self._settle[inv.name] = self._now_mono
|
||||
self.i_w = self.target
|
||||
|
||||
def _reseed_drop(self, live) -> None:
|
||||
"""SAFETY-96, N>=2: a battery that drops out of the split (stale
|
||||
link) leaves the loop's start point, so the others take over at one
|
||||
slew step a cycle instead of taking its share at once. One that comes
|
||||
back enters it at its reading, and every battery settles while the
|
||||
split is made again (`_held_out_w`).
|
||||
DECISIONS/SAFETY-96-dropped-battery.md.
|
||||
"""
|
||||
was, self._live_was = self._live_was, frozenset(live)
|
||||
if len(self.inverters) < 2 or was is None or was == live:
|
||||
return
|
||||
for inv in self.inverters:
|
||||
if inv.name in self._charge_ended:
|
||||
continue # SAFETY-93's edge: `_reseed_charge_end()`
|
||||
if inv.name in was and inv.name not in live:
|
||||
now_w = 0.0
|
||||
elif inv.name in live and inv.name not in was:
|
||||
now_w = float(inv.batt)
|
||||
# The split is made again, so every battery's command steps.
|
||||
self._settle.update(
|
||||
{u: self._now_mono for u in live})
|
||||
else:
|
||||
continue
|
||||
self.target += now_w - float(inv.target)
|
||||
self._loop_start[inv.name] = now_w
|
||||
self.i_w = self.target
|
||||
|
||||
def _held_out_w(self, live) -> float:
|
||||
"""SAFETY-96, N>=2: what to add to the site sum so that a battery
|
||||
the loop cannot move right now counts at its command, not at its
|
||||
reading. That is a battery out of the split, one whose writes are
|
||||
refused, or one settling after a step the loop did not make (a charge
|
||||
end, a rejoin) until its reading is within the deadband of its
|
||||
command or PLANT_SETTLE_DOWN_S has passed. Its real power still
|
||||
reaches the loop through the meter, so it is neither lost nor counted
|
||||
twice; only the saturation freeze stops seeing it as the loop's.
|
||||
"""
|
||||
if len(self.inverters) < 2:
|
||||
return 0.0
|
||||
out = 0.0
|
||||
for inv in self.inverters:
|
||||
cmd = self._loop_start.get(inv.name, float(inv.target))
|
||||
since = self._settle.get(inv.name)
|
||||
age = (None if since is None or self._now_mono is None
|
||||
else self._now_mono - since)
|
||||
if since is not None and (
|
||||
age is None or age >= _ctl.PLANT_SETTLE_DOWN_S
|
||||
or (age > 0 and abs(float(inv.batt) - cmd)
|
||||
< self.tuning.deadband_w)):
|
||||
del self._settle[inv.name]
|
||||
if (inv.name not in live or inv.rejected_since is not None
|
||||
or inv.name in self._settle):
|
||||
out += cmd - float(inv.batt)
|
||||
return out
|
||||
|
||||
def _mode_fault_log(self, resolution) -> None:
|
||||
"""OPT-11: say once, per battery, that its mode was dropped as a fault.
|
||||
|
||||
@@ -4730,6 +4801,8 @@ class Controller:
|
||||
mode_trade = self._mode_trade(claims, now)
|
||||
self._mode_trade_was = mode_trade
|
||||
self._charge_track(claims, maint_results)
|
||||
_live96 = set(self.live_units())
|
||||
self._loop_start = {}
|
||||
if (self.auto or self.shadow) and self.inputs_ok:
|
||||
# ⚠️ prev is the ARBITER's last output, not the loop's own last
|
||||
# wish. If something outranked the loop, that is what the hardware
|
||||
@@ -4756,6 +4829,9 @@ class Controller:
|
||||
# is what seeds it, and dropping it there restarts the ~70 s cycle.
|
||||
if self.loop_w is not None and self.target != self.loop_w:
|
||||
self._refusal_end("another writer")
|
||||
# SAFETY-96: a battery that dropped out of the split leaves the
|
||||
# loop's start point (SAFETY-93's own if its charge ended too).
|
||||
self._reseed_drop(_live96)
|
||||
# SAFETY-93: a charge that ended leaves the loop's start point.
|
||||
self._reseed_charge_end(maint_results)
|
||||
probe = self._refusal_probe()
|
||||
@@ -4775,6 +4851,9 @@ class Controller:
|
||||
# level: the two halves of §6.2's identity break in opposite
|
||||
# directions. See `site_batt_w` and test_alloc.py part 8.
|
||||
site_batt = self.site_batt_w
|
||||
# SAFETY-96: a battery the loop cannot move counts at its command,
|
||||
# so its own power does not freeze the loop (the meter has it).
|
||||
site_batt += self._held_out_w(_live96)
|
||||
# TEL-10. ⚠️ ONE NAME FOR THE COMMAND THE LOOP WAS MEASURED
|
||||
# AGAINST. `self.target` is reassigned further down this method, so
|
||||
# a `decision.i_w - self.target` written at publish time would
|
||||
@@ -4883,6 +4962,7 @@ class Controller:
|
||||
# Stopped or blind: no accumulation may survive the outage.
|
||||
self.i_w = 0.0
|
||||
self.loop_w = None
|
||||
self._live_was = frozenset(_live96)
|
||||
# TEL-10, and it is the same zero the line above writes rather than
|
||||
# a second opinion about it. ⚠️ `not-running` rather than a stale
|
||||
# `tracking`: a reason row that keeps repeating the last word the
|
||||
|
||||
Reference in New Issue
Block a user