SAFETY-95: a maintenance drain that ends hands back smoothly (N>=2)
When a battery's drain ended (done, Off, timeout, abort, stale link, refusal), its drain floor stayed in the site target and landed on the others: B 0 -> +1600 W in one cycle at a 1200 W house, exporting; under a solar surplus B gave into the export. Now, on that cycle, the loop starts from what the others stood at plus the battery's new command, raised to the house's own point when the house needs more (never above last cycle's start). While the battery settles, the loop counts the batteries at its start instead of chasing the drain's tail on the meter. Then one slew step a cycle. N=1 is unchanged. test_safety95.py; DECISIONS/SAFETY-95-drain-end.md; one DOCS line. test_modes.py: opt11 n2_maint re-pinned on purpose (its drain ends by a refusal at 1655 s; B now stays at the house's 1420 W instead of 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
b2f55732b6
commit
bdec66e97a
@@ -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-95: the same for a maintenance drain.
|
||||
self._draining_was = frozenset()
|
||||
self._drain_ended = frozenset()
|
||||
# SAFETY-95: batteries settling after their drain ended; meanwhile
|
||||
# the loop's error counts every battery at its command.
|
||||
self._drain_tail = set()
|
||||
# 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}).
|
||||
@@ -2440,18 +2446,23 @@ class Controller:
|
||||
|
||||
def _charge_track(self, claims, maint_results) -> None:
|
||||
"""SAFETY-93, N>=2: a charge ends when its battery leaves CHARGE or can
|
||||
no longer be placed (stale link, refusal). Kept until the loop runs."""
|
||||
no longer be placed (stale link, refusal). Kept until the loop runs.
|
||||
SAFETY-95: a drain likewise; its refusal (SAFETY-73) outranks the pin
|
||||
without a contradiction, so it is read from the battery."""
|
||||
if len(self.inverters) < 2:
|
||||
return
|
||||
live = set(self.live_units())
|
||||
now = set()
|
||||
now = {CHARGE: set(), DRAIN: set()}
|
||||
for inv, res in zip(self.inverters, maint_results):
|
||||
u = self.actuator_of("maintenance", inv)
|
||||
if res.phase == CHARGE and u in live and not pre_resolve(
|
||||
[c for c in claims if c.actuator == u]).contradiction:
|
||||
now.add(inv.name)
|
||||
self._charge_ended |= self._charging_was - now
|
||||
self._charging_was = frozenset(now)
|
||||
if res.phase in now and u in live and not pre_resolve(
|
||||
[c for c in claims if c.actuator == u]).contradiction \
|
||||
and not (res.phase == DRAIN and inv.refused):
|
||||
now[res.phase].add(inv.name)
|
||||
self._charge_ended |= self._charging_was - now[CHARGE]
|
||||
self._charging_was = frozenset(now[CHARGE])
|
||||
self._drain_ended |= self._draining_was - now[DRAIN]
|
||||
self._draining_was = frozenset(now[DRAIN])
|
||||
|
||||
def _reseed_charge_end(self, maint_results) -> None:
|
||||
"""SAFETY-93, N>=2: when a battery's maintenance charge ends, the loop
|
||||
@@ -2476,6 +2487,53 @@ class Controller:
|
||||
self._settle[inv.name] = self._now_mono
|
||||
self.i_w = self.target
|
||||
|
||||
def _reseed_drain_end(self, maint_results, live) -> None:
|
||||
"""SAFETY-95, N>=2: when a battery's maintenance drain ends, its
|
||||
drain leaves the loop's start point, as a charge does (SAFETY-93): the
|
||||
start is what the others stood at plus its new command (its pin, else
|
||||
0 W). Where the house needs more, the start is the house's own point
|
||||
(the live batteries' readings + grid - target), never above last
|
||||
cycle's start. The loop then moves one slew step a cycle. Else the
|
||||
drain's floor stays in the target and lands on the others: B 0 ->
|
||||
+1600 W in one cycle, exporting. DECISIONS/SAFETY-95-drain-end.md.
|
||||
"""
|
||||
ended, self._drain_ended = self._drain_ended, frozenset()
|
||||
if len(self.inverters) < 2 or not ended:
|
||||
return
|
||||
hi = self.target
|
||||
for inv, res in zip(self.inverters, maint_results):
|
||||
if inv.name not in ended:
|
||||
continue
|
||||
now_w = (float(res.setpoint_w or 0.0)
|
||||
if res.owns_setpoint and res.phase != DRAIN
|
||||
else min(float(inv.target), 0.0))
|
||||
if inv.name not in live:
|
||||
now_w = 0.0 # SAFETY-96: out of the split, it is sent 0 W
|
||||
else:
|
||||
self._drain_tail.add(inv.name)
|
||||
self.target += now_w - float(inv.target)
|
||||
self._loop_start[inv.name] = now_w
|
||||
self._settle[inv.name] = self._now_mono
|
||||
lo, hi = sorted((self.target, hi))
|
||||
# A dropped battery's power is the meter's, like the house's.
|
||||
here = float(self.grid) - self.tuning.target_grid_w + sum(
|
||||
float(inv.batt) for inv in self.inverters if inv.name in live)
|
||||
self.target = self.i_w = min(hi, max(lo, here))
|
||||
|
||||
def _drain_grid_w(self, live) -> float:
|
||||
"""SAFETY-95: the grid the loop regulates. While a battery settles
|
||||
from its ended drain (`_held_out_w`: until within the deadband, at
|
||||
most PLANT_SETTLE_DOWN_S), the meter still shows the drain's tail and
|
||||
the others' climb, so the loop would chase them (B down, then up
|
||||
again). Meanwhile the batteries count at the site command: the error
|
||||
is the house's own point minus the loop's start."""
|
||||
grid = float(self.grid)
|
||||
self._drain_tail &= set(self._settle) & set(live)
|
||||
if not self._drain_tail:
|
||||
return grid
|
||||
return grid - float(self.target) + sum(
|
||||
float(inv.batt) for inv in self.inverters if inv.name in live)
|
||||
|
||||
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
|
||||
@@ -2488,8 +2546,8 @@ class Controller:
|
||||
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 self._charge_ended | self._drain_ended:
|
||||
continue # SAFETY-93/95's edge: `_reseed_*_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:
|
||||
@@ -4835,6 +4893,9 @@ class Controller:
|
||||
self._reseed_drop(_live96)
|
||||
# SAFETY-93: a charge that ended leaves the loop's start point.
|
||||
self._reseed_charge_end(maint_results)
|
||||
# SAFETY-95: then a drain that ended, last (it reads the start
|
||||
# the others left).
|
||||
self._reseed_drain_end(maint_results, _live96)
|
||||
probe = self._refusal_probe()
|
||||
if len(self.inverters) > 1:
|
||||
probe = any(_probing.values())
|
||||
@@ -4864,7 +4925,9 @@ class Controller:
|
||||
loop_prev = self.target
|
||||
decision = compute(
|
||||
prev_w=loop_prev,
|
||||
grid_w=self.grid,
|
||||
# SAFETY-95: a drain's tail is not the loop's to chase.
|
||||
grid_w=(self._drain_grid_w(_live96) if self._drain_tail
|
||||
else self.grid),
|
||||
actual_w=site_batt,
|
||||
tuning=self.tuning,
|
||||
sat_count=self.sat_count,
|
||||
|
||||
Reference in New Issue
Block a user