SAFETY-97: "gives nothing" is graded against each battery's idle reading
B's power reading shows -95 to -118 W at idle (standby draw), so the |reading| < 100 W refusal band never held a refusing B: never at N=2, and since SAFETY-94's shared rule not at N=1 either (review F1). Each unit now learns its idle reading (app/idle.py): only while control runs, the unit is not refused/stale/tripped and its command has stayed within 50 W for 120 s; first value a 120 s mean, then a 30 min follow; samples beyond 250 W dropped, value clamped to 250 W; saved per unit and restored. The shared refusal evidence, compute()'s N=1 engage and end, the unit refusal ends, the stale-path refusal and SAFETY-42's "parked" test are centred on it. Until learned it is 0 W, the rule as it was. Refusing B caught at N=1 28/28/65 s and N=2 24/24/60 s (1/2/5 s meters), before never; refusing A unchanged. Healthy B with its offset, SAFETY-94's scenarios, 96 runs: 0 stale, 0 refused. Live replay: 0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019DBUfvndgHwaV339Fvd2gr
This commit is contained in:
@@ -1249,7 +1249,8 @@ class Controller:
|
||||
f"gave {batt_w:+.0f} W for "
|
||||
f"{decision.sat_count} cycles")
|
||||
elif self.batt_refused and not now:
|
||||
gave = self.batt_refused * batt_w >= _ctl.PROBE_ANSWER_W
|
||||
gave = (self.batt_refused * (batt_w - self._site_idle_w())
|
||||
>= _ctl.PROBE_ANSWER_W)
|
||||
self._refusal_end("the probe was answered" if gave and probe
|
||||
else "the battery gives power" if gave
|
||||
else "the grid now asks the other direction")
|
||||
@@ -1267,7 +1268,8 @@ class Controller:
|
||||
if ((self.auto or self.shadow) and not inv.refused
|
||||
and inv.ref_count >= t.saturation_cycles
|
||||
and inv.batt_fresh.held is not None
|
||||
and abs(inv.batt_fresh.held) < _ctl.REFUSED_BELOW_W
|
||||
and abs(inv.batt_fresh.held - inv.idle_w)
|
||||
< _ctl.REFUSED_BELOW_W
|
||||
and abs(standing) > _ctl.PROBE_W):
|
||||
self._now_mono = now_mono
|
||||
self._unit_refusal_start(
|
||||
@@ -1279,7 +1281,8 @@ class Controller:
|
||||
and not self.batt_refused
|
||||
and self.sat_count >= t.saturation_cycles
|
||||
and inv.batt_fresh.held is not None
|
||||
and abs(inv.batt_fresh.held) < _ctl.REFUSED_BELOW_W
|
||||
and abs(inv.batt_fresh.held - inv.idle_w)
|
||||
< _ctl.REFUSED_BELOW_W
|
||||
and abs(standing) > t.saturation_w):
|
||||
self._now_mono = now_mono
|
||||
self._refusal_start(1 if standing > 0 else -1,
|
||||
@@ -1352,7 +1355,9 @@ class Controller:
|
||||
for inv in self.inverters:
|
||||
b = inv.batt
|
||||
if inv.refused:
|
||||
if b is not None and inv.refused * b >= _ctl.PROBE_ANSWER_W:
|
||||
# SAFETY-97: given and taken are counted from the idle reading.
|
||||
if (b is not None and inv.refused * (b - inv.idle_w)
|
||||
>= _ctl.PROBE_ANSWER_W):
|
||||
self._unit_refusal_end(
|
||||
inv, "the probe was answered"
|
||||
if inv.probe_from is not None
|
||||
@@ -1363,7 +1368,8 @@ class Controller:
|
||||
# on its own reading, so one cycle of the loop overshooting
|
||||
# through zero does not hand it its share back.
|
||||
inv.ref_count = (inv.ref_count + 1 if b is not None and
|
||||
-inv.refused * b >= _ctl.PROBE_ANSWER_W else 0)
|
||||
-inv.refused * (b - inv.idle_w)
|
||||
>= _ctl.PROBE_ANSWER_W else 0)
|
||||
if inv.ref_count >= _ctl.SETTLE_CYCLES:
|
||||
self._unit_refusal_end(
|
||||
inv, "the battery takes power the other way")
|
||||
@@ -1379,7 +1385,8 @@ class Controller:
|
||||
"""The refusal evidence, one cycle of it: the sign `inv` has given
|
||||
under REFUSED_BELOW_W while asked over PROBE_W, one way, for
|
||||
SETTLE_CYCLES spanning REFUSED_SPAN_S, else 0. SAFETY-73 at N>1;
|
||||
SAFETY-94 adds it to the N=1 engage in compute()."""
|
||||
SAFETY-94 adds it to the N=1 engage in compute(). SAFETY-97: the band
|
||||
is centred on the unit's idle reading."""
|
||||
b = inv.batt
|
||||
if b is None:
|
||||
return 0 # a gap keeps the count: the gate's trip seeds
|
||||
@@ -1388,7 +1395,8 @@ class Controller:
|
||||
# One direction throughout: a command that swaps sign every cycle
|
||||
# (a flapping load) reads ~0 through the plant's own lag, and that
|
||||
# is a battery following, not refusing (TEST-19 flapping family).
|
||||
if abs(standing) > _ctl.PROBE_W and abs(b) < _ctl.REFUSED_BELOW_W:
|
||||
if (abs(standing) > _ctl.PROBE_W
|
||||
and abs(b - inv.idle_w) < _ctl.REFUSED_BELOW_W):
|
||||
# SAFETY-94: a reading still moving towards the ask is a battery
|
||||
# catching up through its lag, not one refusing: start again.
|
||||
if (sign != inv.ref_sign or not inv.ref_count
|
||||
@@ -1405,6 +1413,10 @@ class Controller:
|
||||
return sign
|
||||
return 0
|
||||
|
||||
def _site_idle_w(self) -> float:
|
||||
"""SAFETY-97: the site reading at idle, the sum of the units'."""
|
||||
return float(sum(inv.idle_w for inv in self.inverters))
|
||||
|
||||
def _site_refused(self):
|
||||
"""Owner decision 4: every unit refuses the same way, so the site law
|
||||
holds 0 W (SAFETY-52's, in compute()). Otherwise None, the old law."""
|
||||
@@ -3143,6 +3155,14 @@ class Controller:
|
||||
# `stale_input_s` and then commands 0 W and keeps commanding, which is
|
||||
# §11.1 rule 1 and is already what a missing reading does here.
|
||||
inv.batt = inv.batt_fresh.reading()
|
||||
# SAFETY-97: learn this unit's idle reading, only while control runs
|
||||
# and nothing says the unit is refusing, stale or faulty.
|
||||
if inv.idle.observe(
|
||||
inv.batt, inv.standing_w(), now_mono,
|
||||
bool(self.auto) and inv.batt is not None
|
||||
and not inv.batt_fresh.stale and not inv.refused
|
||||
and not self.batt_refused and not inv.sign_wd.tripped):
|
||||
inv.store_set("idle_w", round(inv.idle.value, 1))
|
||||
if inv.batt_fresh.stale and not was_stale:
|
||||
self.log_event(f"STALE {inv.name}: " + inv.batt_fresh.reason,
|
||||
hint=messages.hint("battery_stuck", unit=inv.name))
|
||||
@@ -3904,7 +3924,8 @@ class Controller:
|
||||
parked=inv.sign_wd.tripped or bool(inv.refused) or (
|
||||
n_units > 1 and inv.batt is not None
|
||||
and abs(float(standing[inv.name])) <= _ctl.PROBE_W
|
||||
and abs(float(inv.batt)) < _ctl.REFUSED_BELOW_W),
|
||||
and abs(float(inv.batt) - inv.idle_w)
|
||||
< _ctl.REFUSED_BELOW_W),
|
||||
# ⚠️ THIS UNIT'S OWN CUT-OUT. Not the shipped 10 %, and not
|
||||
# the site's `peak_soc_floor` either - that one bounds a
|
||||
# money-saving discharge only, and grading a pack against it
|
||||
@@ -4688,6 +4709,8 @@ class Controller:
|
||||
# given nothing for the whole span, as at N>1.
|
||||
gave_nothing=(bool(self._gave_nothing(self.inverters[0]))
|
||||
if len(self.inverters) == 1 else None),
|
||||
# SAFETY-97: "gives nothing" is centred on the idle reading.
|
||||
idle_w=self._site_idle_w(),
|
||||
# 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.
|
||||
|
||||
Reference in New Issue
Block a user