SAFETY-93 review: a charge that ends by a stale link or a refusal hands back smoothly too
A battery still in CHARGE but dropped by the allocator (stale link, or a SAFETY-73 refusal contradicting its charge) left its -2000 W in the loop's start point, and B went 0 -> -1600 W in one cycle. The end is now noted after the unit claims are filed: in CHARGE, live and not contradicted. Tests: part 6 (stale link, refusal) and a spy on the guard suspension. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,13 @@ step a cycle. The ending battery's own step (-2000 -> 0 W) is maintenance's
|
|||||||
pin, as before; it lowers the grid import. The end is kept until the loop
|
pin, as before; it lowers the grid import. The end is kept until the loop
|
||||||
next runs, so a missed poll on that cycle (the input hold) does not lose it.
|
next runs, so a missed poll on that cycle (the input hold) does not lose it.
|
||||||
|
|
||||||
|
Review: a charge also ends, for the site, when the battery is still in
|
||||||
|
CHARGE but can no longer be placed: a stale link or a refusal (SAFETY-73)
|
||||||
|
drops it, and its -2000 W landed on B the same way (B 0 -> -1600 W at a
|
||||||
|
1200 W house). So the end is noted after the unit claims are filed: a
|
||||||
|
battery leaves the charge when it leaves CHARGE, is not live, or its own
|
||||||
|
claims contradict. The guard suspending the charge (SAFETY-84) is no end.
|
||||||
|
|
||||||
Why a start-point reseed, and not a per-battery slew in the allocator: the
|
Why a start-point reseed, and not a per-battery slew in the allocator: the
|
||||||
jump comes from one stale number at one edge, the kind of edge SAFETY-73
|
jump comes from one stale number at one edge, the kind of edge SAFETY-73
|
||||||
(`_unit_refusal_end`) and OPT-11 P3 (`_reseed_run_edge`) already reseed.
|
(`_unit_refusal_end`) and OPT-11 P3 (`_reseed_run_edge`) already reseed.
|
||||||
@@ -39,6 +46,7 @@ The change stays at that edge; nothing else at N>=2 moves.
|
|||||||
| charge times out / A switched Off | B 0 -> -1600 | B 0 -> 400 |
|
| charge times out / A switched Off | B 0 -> -1600 | B 0 -> 400 |
|
||||||
| guard suspended the charge, A gives 1825 | B step 675 W | B step 395 W |
|
| guard suspended the charge, A gives 1825 | B step 675 W | B step 395 W |
|
||||||
| missed poll on the end cycle | B 0 -> -1600 a cycle later | B 0 -> 400 |
|
| missed poll on the end cycle | B 0 -> -1600 a cycle later | B 0 -> 400 |
|
||||||
|
| A dropped mid-charge (stale link, refusal), house 1200 | B 0 -> -1600 | B 0 -> 400 |
|
||||||
| N=1, both charge ends above | | byte-identical |
|
| N=1, both charge ends above | | byte-identical |
|
||||||
|
|
||||||
No pinned digest moved: the 46 digests of `tools/*identity*.py` (N=1 and
|
No pinned digest moved: the 46 digests of `tools/*identity*.py` (N=1 and
|
||||||
|
|||||||
@@ -994,8 +994,8 @@ class Controller:
|
|||||||
self._drain_waits = set()
|
self._drain_waits = set()
|
||||||
# SAFETY-91: last cycle's held-back batteries (`_held_back()`).
|
# SAFETY-91: last cycle's held-back batteries (`_held_back()`).
|
||||||
self._held_was = frozenset()
|
self._held_was = frozenset()
|
||||||
# SAFETY-93: the batteries in their maintenance charge last cycle,
|
# SAFETY-93: the batteries placed in their maintenance charge last
|
||||||
# and those whose charge ended since the loop last ran.
|
# cycle, and those whose charge ended since the loop last ran.
|
||||||
self._charging_was = frozenset()
|
self._charging_was = frozenset()
|
||||||
self._charge_ended = frozenset()
|
self._charge_ended = frozenset()
|
||||||
# Set by amain() once the transport is built, so the status page can show
|
# Set by amain() once the transport is built, so the status page can show
|
||||||
@@ -2372,6 +2372,21 @@ class Controller:
|
|||||||
self.target = want
|
self.target = want
|
||||||
self.i_w = want - self.tuning.gain * (want - float(batt))
|
self.i_w = want - self.tuning.gain * (want - float(batt))
|
||||||
|
|
||||||
|
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."""
|
||||||
|
if len(self.inverters) < 2:
|
||||||
|
return
|
||||||
|
live = set(self.live_units())
|
||||||
|
now = 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)
|
||||||
|
|
||||||
def _reseed_charge_end(self, maint_results) -> None:
|
def _reseed_charge_end(self, maint_results) -> None:
|
||||||
"""SAFETY-93, N>=2: when a battery's maintenance charge ends, the loop
|
"""SAFETY-93, N>=2: when a battery's maintenance charge ends, the loop
|
||||||
starts from what the others stood at plus that battery's new command
|
starts from what the others stood at plus that battery's new command
|
||||||
@@ -3603,11 +3618,6 @@ class Controller:
|
|||||||
self.log_event(msg, hint=messages.hint("maintenance",
|
self.log_event(msg, hint=messages.hint("maintenance",
|
||||||
phase=machine.phase))
|
phase=machine.phase))
|
||||||
maint_results.append(res)
|
maint_results.append(res)
|
||||||
# SAFETY-93: kept until the loop next runs (a missed poll holds).
|
|
||||||
_charging = frozenset(inv.name for inv, r in zip(
|
|
||||||
self.inverters, maint_results) if r.phase == CHARGE)
|
|
||||||
self._charge_ended |= self._charging_was - _charging
|
|
||||||
self._charging_was = _charging
|
|
||||||
# ⚠️ CARRIED TO THE STATUS PAGE, NOT LEFT IN THE 40-LINE ACTION LOG. The
|
# ⚠️ CARRIED TO THE STATUS PAGE, NOT LEFT IN THE 40-LINE ACTION LOG. The
|
||||||
# deferral is edge-logged (it lasts as long as the other pack's cycle),
|
# deferral is edge-logged (it lasts as long as the other pack's cycle),
|
||||||
# so by the time an operator asks "why has pack B not recalibrated" the
|
# so by the time an operator asks "why has pack B not recalibrated" the
|
||||||
@@ -4583,6 +4593,7 @@ class Controller:
|
|||||||
if self.modes_on:
|
if self.modes_on:
|
||||||
mode_trade = self._mode_trade(claims, now)
|
mode_trade = self._mode_trade(claims, now)
|
||||||
self._mode_trade_was = mode_trade
|
self._mode_trade_was = mode_trade
|
||||||
|
self._charge_track(claims, maint_results)
|
||||||
if (self.auto or self.shadow) and self.inputs_ok:
|
if (self.auto or self.shadow) and self.inputs_ok:
|
||||||
# ⚠️ prev is the ARBITER's last output, not the loop's own last
|
# ⚠️ prev is the ARBITER's last output, not the loop's own last
|
||||||
# wish. If something outranked the loop, that is what the hardware
|
# wish. If something outranked the loop, that is what the hardware
|
||||||
|
|||||||
@@ -362,6 +362,59 @@ check("it fires once, at the charge end: not at the HOLD end or on any "
|
|||||||
"other cycle", [x for x in _seen if x] == [[A]]
|
"other cycle", [x for x in _seen if x] == [[A]]
|
||||||
and len(_seen) > 300)
|
and len(_seen) > 300)
|
||||||
|
|
||||||
|
# ============================================================================ #
|
||||||
|
print("\n6. A still in CHARGE but no longer placed: stale link, refusal")
|
||||||
|
|
||||||
|
|
||||||
|
def link_drop(at):
|
||||||
|
"""A's writes rejected from `at` s, so it is dropped as a stale link."""
|
||||||
|
def before(c, t):
|
||||||
|
a = c.inverters[0]
|
||||||
|
if t >= at and a.rejected_since is None:
|
||||||
|
a.rejected_since = rig.BASE + timedelta(seconds=at)
|
||||||
|
return before
|
||||||
|
|
||||||
|
|
||||||
|
def refuses(at):
|
||||||
|
"""A stops charging from `at` s (reads 0 W); SAFETY-73 drops it."""
|
||||||
|
def before(c, t):
|
||||||
|
if not getattr(c.hass, "_s93r", False):
|
||||||
|
real = c.hass.power
|
||||||
|
|
||||||
|
def power(u):
|
||||||
|
p = real(u)
|
||||||
|
return 0.0 if u == "a" and c.hass.t >= at and p < 0 else p
|
||||||
|
c.hass.power, c.hass._s93r = power, True
|
||||||
|
return before
|
||||||
|
|
||||||
|
|
||||||
|
for k, hook in (("stale link", link_drop), ("refusal", refuses)):
|
||||||
|
def fn(hook=hook):
|
||||||
|
return run(2, 1200.0, 9999.0, dur=1500.0, before=hook(1300.0))
|
||||||
|
rows, _c6 = fn()
|
||||||
|
old = without(fn)[0]
|
||||||
|
drop = first((r[0] for r in rows if r[0] >= 1300
|
||||||
|
and "dropped inverter-a" in str(r[7])), "A dropped")
|
||||||
|
print(f" {k}: A dropped at {drop:.0f} s; B "
|
||||||
|
f"{[cmd(r)['b'] for r in rows if drop - 2 <= r[0] <= drop + 4]}, "
|
||||||
|
f"without the fix {[cmd(r)['b'] for r in old if drop - 2 <= r[0] <= drop + 4]}")
|
||||||
|
check(f"⭐ {k}: A is dropped while still in CHARGE, and B never charges "
|
||||||
|
f"from the grid and moves at most one slew step a cycle",
|
||||||
|
phase_at(rows, drop) == "charge"
|
||||||
|
and min(cmd(r)["b"] for r in rows if r[0] >= 1300) >= 0.0
|
||||||
|
and moves(rows, "b", 1296, 1500) <= SLEW)
|
||||||
|
check(f"non-vacuous ({k}): without the fix B jumps to -1600 W",
|
||||||
|
min(cmd(r)["b"] for r in old if r[0] >= 1300) <= -1600.0)
|
||||||
|
|
||||||
|
_seen.clear()
|
||||||
|
Controller._reseed_charge_end = _spy
|
||||||
|
try:
|
||||||
|
run(2, 6000.0, 1530.0, step=1500.0)
|
||||||
|
finally:
|
||||||
|
Controller._reseed_charge_end = no_fix
|
||||||
|
check("the guard suspending the charge is no end: it fires once, at the HOLD",
|
||||||
|
[x for x in _seen if x] == [[A]])
|
||||||
|
|
||||||
print()
|
print()
|
||||||
if fails:
|
if fails:
|
||||||
print(f"{len(fails)} FAILED: " + ", ".join(fails))
|
print(f"{len(fails)} FAILED: " + ", ".join(fails))
|
||||||
|
|||||||
Reference in New Issue
Block a user