SAFETY-73 review: a refusal needs 22 s of evidence, and the cut-out warning is for giving only
- REFUSED_SPAN_S: the 12 cycles of evidence must also span (SETTLE_CYCLES-1) * LOOP_TICK_S = 22 s. At a 1 s meter, lagged healthy readings refused both units 10-16 times an hour after load steps (site held 0 W). 2 s and slower ticks are unchanged. - REFUSES_ABOVE_CUT_OUT only for a refusal to give power: a full battery that stops taking charge is always above its cut-out level. - test_delivery: its wedged fixture (0.2 s cycles) no longer reaches a refusal; the release/1.0 checks are restored and a span check added. - test_refusal_units: lagged-reading and full-battery plants, two new checks, U14/U15 in the mutation table. Stale "> 500 W" wording fixed. DOCS/DECISIONS. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1a9c7523c9
commit
06aa09e28c
@@ -1,7 +1,7 @@
|
||||
"""Runnable check for SAFETY-73. `python3 test_refusal_units.py`
|
||||
|
||||
SAFETY-52 per inverter. With two or more inverters, a battery that refuses
|
||||
(gives < 100 W for 12 cycles while asked > 500 W, or trips the stale-reading
|
||||
(gives < 100 W for 12 cycles and 22 s while asked > 200 W, or trips the stale-reading
|
||||
gate on a ~0 W reading) is closed in that direction by its own P_SAFETY limit,
|
||||
so the allocator hands its share to the others. It is probed at 200 W every
|
||||
15 minutes, one inverter at a time. When every inverter refuses the same way
|
||||
@@ -33,6 +33,8 @@ over this file / test_delivery / test_refusal. Every one is red here:
|
||||
U11 main.py: the above-cut-out WARNING condition inverted 1/0/0
|
||||
U12 main.py: SAFETY-42's account no longer sees a small ask 1/0/0
|
||||
U13 main.py: a unit engages above `saturation_w`, not PROBE_W 1/0/0
|
||||
U14 main.py: the evidence need not span REFUSED_SPAN_S (review) 1/0/0
|
||||
U15 main.py: the WARNING also for a refusal to take (review) 1/0/0
|
||||
|
||||
On 652097a itself this file stops at the first sample (`Inverter` has no
|
||||
`refused`); its OLD arms reproduce that tree's N>1 behaviour instead, and
|
||||
@@ -61,6 +63,7 @@ def check(name, cond):
|
||||
|
||||
TICK = 5.0
|
||||
CUT_W = -4.0
|
||||
FULL_W = 40.0
|
||||
H = 3600.0
|
||||
BASE = datetime(2026, 9, 6, 19, 30, tzinfo=timezone.utc)
|
||||
UNITS = ("a", "b")
|
||||
@@ -89,23 +92,39 @@ class _Pub:
|
||||
|
||||
|
||||
class _Hass:
|
||||
"""A house, one meter, and two batteries that follow unless cut out."""
|
||||
"""A house, one meter, and two batteries that follow unless cut out.
|
||||
|
||||
def __init__(self, load, cut, soc, jitter):
|
||||
`lag` (review): (dead s, publish s). Each battery reading then shows the
|
||||
power of `dead` s before its last publish; the power itself and the meter
|
||||
follow at once. `full[u](t)`: that battery takes at most FULL_W of charge.
|
||||
"""
|
||||
|
||||
def __init__(self, load, cut, soc, jitter, lag=None, full=None):
|
||||
self.load, self.cut, self.soc, self.jitter = load, cut, soc, jitter
|
||||
self.lag, self.full = lag, full or {}
|
||||
self.cmd = {u: 0.0 for u in UNITS}
|
||||
self.hist = {u: [(-1e18, 0.0)] for u in UNITS}
|
||||
self.t = 0.0
|
||||
|
||||
def batt(self, u):
|
||||
c = self.cmd[u]
|
||||
if self.cut[u](self.t) and c >= CUT_W:
|
||||
def power(self, u, t=None):
|
||||
t = self.t if t is None else t
|
||||
c = next((v for tt, v in reversed(self.hist[u]) if tt <= t), 0.0)
|
||||
if self.cut[u](t) and c >= CUT_W:
|
||||
if self.jitter:
|
||||
return CUT_W + ((int(self.t / TICK) % 3) - 1) * 3.0
|
||||
return CUT_W + ((int(t / TICK) % 3) - 1) * 3.0
|
||||
return CUT_W
|
||||
if u in self.full and self.full[u](t) and c < -FULL_W:
|
||||
return -FULL_W
|
||||
return float(round(c))
|
||||
|
||||
def batt(self, u):
|
||||
if self.lag is None:
|
||||
return self.power(u)
|
||||
dead, pub = self.lag
|
||||
return self.power(u, (self.t // pub) * pub - dead)
|
||||
|
||||
def grid(self):
|
||||
return self.load(self.t) - sum(self.batt(u) for u in UNITS)
|
||||
return self.load(self.t) - sum(self.power(u) for u in UNITS)
|
||||
|
||||
async def limits(self, e):
|
||||
return (-2000.0, 2000.0, 1.0)
|
||||
@@ -126,6 +145,7 @@ class _Hass:
|
||||
|
||||
async def set_number(self, e, v):
|
||||
self.cmd[e[-1]] = float(v)
|
||||
self.hist[e[-1]].append((self.t, float(v)))
|
||||
return True
|
||||
|
||||
|
||||
@@ -136,10 +156,12 @@ def _off(c):
|
||||
|
||||
|
||||
def run(load, cut, dur, *, soc=None, jitter=False, old=False, events=None,
|
||||
extra=None):
|
||||
extra=None, tick=None, lag=None, full=None):
|
||||
"""Real cycles every TICK of simulated time. Rows are dicts per cycle."""
|
||||
async def go():
|
||||
hass = _Hass(load, cut, soc or {"a": 50.0, "b": 50.0}, jitter)
|
||||
hass = _Hass(load, cut, soc or {"a": 50.0, "b": 50.0}, jitter, lag,
|
||||
full)
|
||||
dt = tick or TICK
|
||||
inv = [{"name": f"inverter-{u}", "setpoint_entity": f"number.sp_{u}",
|
||||
"soc_entity": f"sensor.soc_{u}",
|
||||
"batt_entity": f"sensor.batt_{u}",
|
||||
@@ -187,7 +209,7 @@ def run(load, cut, dur, *, soc=None, jitter=False, old=False, events=None,
|
||||
ra=c.inverters[0].refused, rb=c.inverters[1].refused,
|
||||
da=c.inverters[0].delivery_wd.tripped,
|
||||
db=c.inverters[1].delivery_wd.tripped))
|
||||
t += TICK
|
||||
t += dt
|
||||
finally:
|
||||
m.datetime = real_dt
|
||||
return rows, trips, c
|
||||
@@ -448,6 +470,41 @@ check("shadow mode: no inverter is refused and both stay at 0 W",
|
||||
c.inverters[0].refused == 0 and c.inverters[1].refused == 0
|
||||
and all(r["a"] == 0.0 and r["b"] == 0.0 for r in rows))
|
||||
|
||||
# Review. A 1 s meter makes 12 cycles 11 s, shorter than a healthy battery
|
||||
# reading's lag (6 s dead time plus a 13 s publish). Ordinary load steps then
|
||||
# refused both inverters and dropped the site to 0 W (16 times an hour). The
|
||||
# evidence must also span REFUSED_SPAN_S.
|
||||
_steps = (lambda t: 300.0 if int(t // 120) % 2 == 0 else 3000.0)
|
||||
rows, _, c = run(_steps, {"a": never, "b": never}, H, tick=1.0,
|
||||
lag=(6.0, 13.0))
|
||||
check("a 1 s meter and slow battery readings: load steps for an hour refuse "
|
||||
"neither healthy inverter",
|
||||
not any(r["ra"] or r["rb"] for r in rows))
|
||||
rows, _, c = run(lambda t: 3000.0, {"a": cut_at(120), "b": never}, 600,
|
||||
tick=1.0, lag=(6.0, 13.0))
|
||||
_eng = next((r["t"] for r in rows if r["ra"]), None)
|
||||
check("...and the same slow readings still refuse a battery that is cut "
|
||||
"out, after the span and well inside the 185 s report",
|
||||
_eng is not None and 120 + m.REFUSED_SPAN_S <= _eng < 185
|
||||
and not any(r["da"] for r in rows))
|
||||
|
||||
# Review. A full battery that stops taking charge is refused (charge), and
|
||||
# it is always above its cut-out level: that says nothing about the setting,
|
||||
# so it gets the INFO line and no REFUSES_ABOVE_CUT_OUT warning.
|
||||
_g2 = _Grab()
|
||||
_lg.setLevel(logging.INFO)
|
||||
_lg.addHandler(_g2)
|
||||
rows, _, c = run(lambda t: -3000.0, {"a": never, "b": never}, 600,
|
||||
soc={"a": 99.0, "b": 60.0}, full={"a": lambda t: t >= 120})
|
||||
_lg.removeHandler(_g2)
|
||||
_lg.setLevel(_lv)
|
||||
check("a full battery that stops taking charge is refused that way only, "
|
||||
"with the INFO line and no warning about its cut-out level",
|
||||
c.inverters[0].refused == -1
|
||||
and any(lv == logging.INFO and "BATTERY_REFUSES" in x
|
||||
for lv, x in _g2.lines)
|
||||
and not any("REFUSES_ABOVE_CUT_OUT" in x for _lv2, x in _g2.lines))
|
||||
|
||||
# ==============================================================================
|
||||
print("6. one inverter is untouched")
|
||||
# ==============================================================================
|
||||
|
||||
Reference in New Issue
Block a user