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
@@ -26,14 +26,15 @@ standing import, new -16 W. Both cut out: old keeps asking two empty batteries
|
|||||||
|
|
||||||
1. **A refusing unit is closed in that direction** by its own P_SAFETY unit
|
1. **A refusing unit is closed in that direction** by its own P_SAFETY unit
|
||||||
claim, source `refused`. It engages when the unit gives under
|
claim, source `refused`. It engages when the unit gives under
|
||||||
`REFUSED_BELOW_W` (100 W) for `SETTLE_CYCLES` (12) cycles in one direction
|
`REFUSED_BELOW_W` (100 W) for `SETTLE_CYCLES` (12) cycles, spanning at least
|
||||||
while asked more than `PROBE_W` (200 W), or when its stale-reading gate
|
`REFUSED_SPAN_S` (22 s), in one direction while asked more than `PROBE_W`
|
||||||
|
(200 W), or when its stale-reading gate
|
||||||
trips on a held reading under 100 W. The allocator then hands its share to
|
trips on a held reading under 100 W. The allocator then hands its share to
|
||||||
the others within their own limits (owner decision 1). It is a hard unit
|
the others within their own limits (owner decision 1). It is a hard unit
|
||||||
limit that always intersects, so OPT-11's P_MODE layer fits inside it.
|
limit that always intersects, so OPT-11's P_MODE layer fits inside it.
|
||||||
2. **It binds every strategy's ask on that unit** (decision 2): peak guard,
|
2. **It binds every strategy's ask on that unit** (decision 2): peak guard,
|
||||||
arbitrage and maintenance are placed around it like the loop.
|
arbitrage and maintenance are placed around it like the loop.
|
||||||
3. **A refusal above the configured cut-out** logs INFO plus one WARNING ending
|
3. **A refusal to give power above the configured cut-out** logs INFO plus one WARNING ending
|
||||||
in `REFUSES_ABOVE_CUT_OUT` (decision 3). SAFETY-42/43 no longer report such a
|
in `REFUSES_ABOVE_CUT_OUT` (decision 3). SAFETY-42/43 no longer report such a
|
||||||
unit: its share is 0 W, and `account()` treats it as parked.
|
unit: its share is 0 W, and `account()` treats it as parked.
|
||||||
4. **All units refusing the same way** pass that sign to `compute()`, so the
|
4. **All units refusing the same way** pass that sign to `compute()`, so the
|
||||||
@@ -79,6 +80,20 @@ SAFETY-73 method made to raise.
|
|||||||
target: worst grid +0 W with the reseed. Phase A measured -1059 W without the
|
target: worst grid +0 W with the reseed. Phase A measured -1059 W without the
|
||||||
reseed and the site hold.
|
reseed and the site hold.
|
||||||
|
|
||||||
|
## Review changes
|
||||||
|
|
||||||
|
- **The evidence must span 22 s, not only 12 cycles.** At a 1 s meter the 12
|
||||||
|
cycles were 11 s, shorter than a healthy reading's lag (dead time plus a
|
||||||
|
10-17 s publish). The reviewer's harness (real `Controller`, both units with
|
||||||
|
6 s dead time and a 13-17 s publish, load steps 300/3000 W every 2 min) then
|
||||||
|
refused both healthy units 10-16 times an hour, and each time the site held
|
||||||
|
0 W: +50-85 Wh import an hour and a +4.6 kW grid peak against +3.7 kW without
|
||||||
|
the refusal. At a 2 s tick there were none. `REFUSED_SPAN_S` is
|
||||||
|
`(SETTLE_CYCLES - 1) * LOOP_TICK_S`, so 2 s and slower ticks are unchanged.
|
||||||
|
- **The cut-out WARNING is for a refusal to give power only.** A full battery
|
||||||
|
that stops taking charge is always above its cut-out level, and the WARNING
|
||||||
|
told the installer to check a setting that has nothing to do with it.
|
||||||
|
|
||||||
## Not covered
|
## Not covered
|
||||||
|
|
||||||
- A unit that gives a steady part of its ask (100 W or more) is a limit, as at
|
- A unit that gives a steady part of its ask (100 W or more) is a limit, as at
|
||||||
|
|||||||
@@ -551,15 +551,15 @@ needs, so a test never trips it. A battery that gives some power, even 100 W,
|
|||||||
is not refusing and keeps its request.
|
is not refusing and keeps its request.
|
||||||
|
|
||||||
**With two or more inverters** the same rule works per inverter. An inverter
|
**With two or more inverters** the same rule works per inverter. An inverter
|
||||||
whose battery gives less than 100 W for 12 cycles while asked for more than
|
whose battery gives less than 100 W for 12 cycles, and at least 22 s, while
|
||||||
200 W is asked 0 W, and the other inverters take over its share, each up to
|
asked for more than 200 W is asked 0 W, and the other inverters take over its share, each up to
|
||||||
its own limits. The status page names it: *inverter-a: the battery refuses to
|
its own limits. The status page names it: *inverter-a: the battery refuses to
|
||||||
give power, the others take over*. It is tested at 200 W every 15 minutes, one
|
give power, the others take over*. It is tested at 200 W every 15 minutes, one
|
||||||
inverter at a time, and only while the site asks at least 200 W that way.
|
inverter at a time, and only while the site asks at least 200 W that way.
|
||||||
It still accepts charge, and a minute of charging ends the refusal. When every
|
It still accepts charge, and 12 cycles of charging end the refusal. When every
|
||||||
inverter refuses the same way, the whole site asks 0 W as with one inverter.
|
inverter refuses the same way, the whole site asks 0 W as with one inverter.
|
||||||
If the battery refuses while its level reads above its “Battery cut-out level”,
|
If the battery refuses to give power while its level reads above its “Battery
|
||||||
the log adds one warning ending in `REFUSES_ABOVE_CUT_OUT`: check that setting.
|
cut-out level”, the log adds one warning ending in `REFUSES_ABOVE_CUT_OUT`: check that setting.
|
||||||
A battery that refuses is not reported as *not delivering*.
|
A battery that refuses is not reported as *not delivering*.
|
||||||
|
|
||||||
*For installers:* SAFETY-52 and SAFETY-73. The event log reads
|
*For installers:* SAFETY-52 and SAFETY-73. The event log reads
|
||||||
|
|||||||
@@ -319,12 +319,14 @@ class Inverter:
|
|||||||
|
|
||||||
# SAFETY-73. The direction this unit's battery refuses (+1 give, -1
|
# SAFETY-73. The direction this unit's battery refuses (+1 give, -1
|
||||||
# take, 0 none), when that refusal or its last probe started, the
|
# take, 0 none), when that refusal or its last probe started, the
|
||||||
# current probe's start, and the cycles of evidence so far. Memory
|
# current probe's start, and the cycles of evidence so far and when
|
||||||
# only, like SAFETY-52's: stop and start end it. Used at N>1 only.
|
# they began. Memory only, like SAFETY-52's: stop and start end it.
|
||||||
|
# Used at N>1 only.
|
||||||
self.refused = 0
|
self.refused = 0
|
||||||
self.refused_at = None
|
self.refused_at = None
|
||||||
self.probe_from = None
|
self.probe_from = None
|
||||||
self.ref_count = 0
|
self.ref_count = 0
|
||||||
|
self.ref_since = None
|
||||||
self.ref_sign = 0
|
self.ref_sign = 0
|
||||||
|
|
||||||
# -- store ---------------------------------------------------------------
|
# -- store ---------------------------------------------------------------
|
||||||
|
|||||||
@@ -236,6 +236,15 @@ def pack_energy_of(inv):
|
|||||||
inv.capacity_kwh, inv.max_w)
|
inv.capacity_kwh, inv.max_w)
|
||||||
|
|
||||||
|
|
||||||
|
# SAFETY-73 review: the evidence must also span this long, not only
|
||||||
|
# SETTLE_CYCLES cycles. SETTLE_CYCLES was derived at the 2 s meter tick (22 s
|
||||||
|
# from its first to its twelfth cycle). A 1 s meter makes 12 cycles only 11 s,
|
||||||
|
# shorter than a healthy battery reading's lag (BATT_OBLIGED_S, 17 s, plus the
|
||||||
|
# plant's dead time), and both inverters of a site were then refused after
|
||||||
|
# ordinary load steps. At 2 s and slower this changes nothing.
|
||||||
|
REFUSED_SPAN_S = (_ctl.SETTLE_CYCLES - 1) * _ctl.LOOP_TICK_S
|
||||||
|
|
||||||
|
|
||||||
def _refused_interval(sign: int) -> tuple:
|
def _refused_interval(sign: int) -> tuple:
|
||||||
"""SAFETY-73: the (lo, hi) a refusing unit may be asked: 0 W that way."""
|
"""SAFETY-73: the (lo, hi) a refusing unit may be asked: 0 W that way."""
|
||||||
return (-math.inf, 0.0) if sign > 0 else (0.0, math.inf)
|
return (-math.inf, 0.0) if sign > 0 else (0.0, math.inf)
|
||||||
@@ -1171,7 +1180,9 @@ class Controller:
|
|||||||
_LOG.info("%s: the battery refuses power, so it is asked 0 W and the "
|
_LOG.info("%s: the battery refuses power, so it is asked 0 W and the "
|
||||||
"other inverters take over: BATTERY_REFUSES.", inv.name)
|
"other inverters take over: BATTERY_REFUSES.", inv.name)
|
||||||
_LOG.debug("Evidence for %s: BATTERY_REFUSES: %s.", inv.name, evidence)
|
_LOG.debug("Evidence for %s: BATTERY_REFUSES: %s.", inv.name, evidence)
|
||||||
if (inv.soc is not None and inv.bms_soc_floor is not None
|
# Only a refusal to GIVE: a full battery that stops taking charge is
|
||||||
|
# above its cut-out level by definition and says nothing about it.
|
||||||
|
if (sign > 0 and inv.soc is not None and inv.bms_soc_floor is not None
|
||||||
and float(inv.soc) > float(inv.bms_soc_floor)):
|
and float(inv.soc) > float(inv.bms_soc_floor)):
|
||||||
# Owner decision 3: a refusal above the configured cut-out level
|
# Owner decision 3: a refusal above the configured cut-out level
|
||||||
# may mean that level is set wrong. One line, no safety stop.
|
# may mean that level is set wrong. One line, no safety stop.
|
||||||
@@ -1243,12 +1254,15 @@ class Controller:
|
|||||||
# (a flapping load) reads ~0 through the plant's own lag, and that
|
# (a flapping load) reads ~0 through the plant's own lag, and that
|
||||||
# is a battery following, not refusing (TEST-19 flapping family).
|
# 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) < _ctl.REFUSED_BELOW_W:
|
||||||
inv.ref_count = (min(inv.ref_count + 1, _ctl.SETTLE_CYCLES)
|
if sign != inv.ref_sign or not inv.ref_count:
|
||||||
if sign == inv.ref_sign else 1)
|
inv.ref_count, inv.ref_since = 0, self._now_mono
|
||||||
|
inv.ref_count = min(inv.ref_count + 1, _ctl.SETTLE_CYCLES)
|
||||||
else:
|
else:
|
||||||
inv.ref_count = 0
|
inv.ref_count = 0
|
||||||
inv.ref_sign = sign
|
inv.ref_sign = sign
|
||||||
if inv.ref_count >= _ctl.SETTLE_CYCLES:
|
if inv.ref_count >= _ctl.SETTLE_CYCLES and (
|
||||||
|
inv.ref_since is None or self._now_mono is None
|
||||||
|
or self._now_mono - inv.ref_since >= REFUSED_SPAN_S):
|
||||||
self._unit_refusal_start(
|
self._unit_refusal_start(
|
||||||
inv, sign,
|
inv, sign,
|
||||||
f"asked {standing:+.0f} W, the battery gave {b:+.0f} W "
|
f"asked {standing:+.0f} W, the battery gave {b:+.0f} W "
|
||||||
|
|||||||
@@ -592,21 +592,16 @@ check("...and the SHARES now differ, which is OPT-09 and not a weakening of the
|
|||||||
"cards meet here from opposite sides: OPT-09 stops the ASK, this file "
|
"cards meet here from opposite sides: OPT-09 stops the ASK, this file "
|
||||||
"grades the ALARM, and neither can do the other's half",
|
"grades the ALARM, and neither can do the other's half",
|
||||||
_pw != _pf
|
_pw != _pf
|
||||||
and _sf[-1]["shares"][1] == 0.0
|
and _sf[-1]["shares"][1] == 0.0 and _sw[-1]["shares"][1] == 2000.0)
|
||||||
and max(r["shares"][1] for r in _sw) == 2000.0)
|
# SAFETY-73. A unit that gives < 100 W while asked > 200 W is refused after
|
||||||
# SAFETY-73. A unit that gives < 100 W for 12 cycles while asked > 500 W is a
|
# SETTLE_CYCLES cycles that also span REFUSED_SPAN_S (22 s). This fixture's 14
|
||||||
# battery that refuses (owner, 2026-09-27): it is closed, its share moves, and
|
# cycles are 0.2 s apart, so the wedged B meets the count and not the span, and
|
||||||
# SAFETY-42 is left with units that deliver something but not enough. This
|
# SAFETY-42's report stands alone here. test_refusal_units.py grades the
|
||||||
# fixture compresses `delivery_hold_s` to 1 s at 0.2 s cycles, so the report
|
# refusal. On a site the 185 s hold puts the refusal first.
|
||||||
# still comes first here. On a site 185 s against 60 s puts the refusal first.
|
check("SAFETY-73: the wedged arm's B is not refused in this fixture: 12 cycles "
|
||||||
_ref_at = next((i for i, r in enumerate(_sw) if r["shares"][1] == 0.0), None)
|
"of evidence in under 3 s are not the 22 s a refusal needs",
|
||||||
check("SAFETY-73: the wedged arm's B, giving 0 W against 2000 W, is refused "
|
_cw.inverters[1].refused == 0
|
||||||
"after 12 cycles of that evidence and asked 0 W from then, the report "
|
and _cw.inverters[1].ref_count >= control.SETTLE_CYCLES)
|
||||||
"having come first only because this fixture's hold is 1 s",
|
|
||||||
_ref_at is not None and _ref_at >= control.SETTLE_CYCLES
|
|
||||||
and all(r["shares"][1] > 0 for r in _sw[:_ref_at])
|
|
||||||
and all(r["shares"][1] == 0.0 for r in _sw[_ref_at:])
|
|
||||||
and _cw.inverters[1].refused == 1)
|
|
||||||
check("...and the state of charge is still what tells them apart",
|
check("...and the state of charge is still what tells them apart",
|
||||||
[r["soc"] for r in _sw] != [r["soc"] for r in _sf])
|
[r["soc"] for r in _sw] != [r["soc"] for r in _sf])
|
||||||
print(f" both arms, last cycle: grid {_sw[-1]['grid']:.0f} W, shares "
|
print(f" both arms, last cycle: grid {_sw[-1]['grid']:.0f} W, shares "
|
||||||
@@ -654,10 +649,10 @@ check("...and `account()` STILL turns this pack away if the ask does arrive - "
|
|||||||
account(live=True, parked=False, soc=_cold_b.soc,
|
account(live=True, parked=False, soc=_cold_b.soc,
|
||||||
bms_floor=_cold_b.bms_soc_floor) is not None)
|
bms_floor=_cold_b.bms_soc_floor) is not None)
|
||||||
check("...and the WEDGED arm's B differs from it in exactly that one place - "
|
check("...and the WEDGED arm's B differs from it in exactly that one place - "
|
||||||
"which is why it was asked for its share until the refusal, and named",
|
"which is why it is still asked for its share, and still named",
|
||||||
account(live=True, parked=False, soc=_cw.inverters[1].soc,
|
account(live=True, parked=False, soc=_cw.inverters[1].soc,
|
||||||
bms_floor=_cw.inverters[1].bms_soc_floor) is None
|
bms_floor=_cw.inverters[1].bms_soc_floor) is None
|
||||||
and _sw[0]["shares"][1] > 0 and _sw[-1]["named"] == ("inverter-b",))
|
and float(_cw.inverters[1].standing_w()) > 0)
|
||||||
|
|
||||||
# ⚠️ WHICH FLOOR, AND THIS IS A WIRING CHECK THAT THE PURE ONES ABOVE CANNOT
|
# ⚠️ WHICH FLOOR, AND THIS IS A WIRING CHECK THAT THE PURE ONES ABOVE CANNOT
|
||||||
# MAKE. `peakguard.pack_floor_pct()` conjoins a pack's own cut-out with the
|
# MAKE. `peakguard.pack_floor_pct()` conjoins a pack's own cut-out with the
|
||||||
@@ -930,13 +925,9 @@ check("...and EVERY WATT IS THE SAME. Grid, site target, both shares and both "
|
|||||||
"reported or not - which is what 'it does not act' means, stated as a "
|
"reported or not - which is what 'it does not act' means, stated as a "
|
||||||
"measurement instead of as an intention",
|
"measurement instead of as an intention",
|
||||||
_power_domain(_sw) == _power_domain(_sn))
|
_power_domain(_sw) == _power_domain(_sn))
|
||||||
_trip_at = next((i for i, r in enumerate(_sw) if r["named"]), None)
|
check("...the wedged unit is still being commanded its share after the trip, "
|
||||||
check("...the wedged unit is still commanded its share after the trip, not "
|
"not parked: this is NOT SAFETY-02's remedy and must not become it",
|
||||||
"parked by this guard: this is NOT SAFETY-02's remedy. What later takes "
|
abs(float(_cw.inverters[1].target)) > 0)
|
||||||
"the share is SAFETY-73's refusal, which runs the same in the arm where "
|
|
||||||
"this guard never trips (the line above)",
|
|
||||||
_trip_at is not None and _ref_at is not None and _trip_at < _ref_at
|
|
||||||
and all(r["shares"][1] > 0 for r in _sw[_trip_at:_ref_at]))
|
|
||||||
check("...and nothing named `delivery` reaches the arbiter - the resolution's "
|
check("...and nothing named `delivery` reaches the arbiter - the resolution's "
|
||||||
"own sentence is unchanged by the trip",
|
"own sentence is unchanged by the trip",
|
||||||
"deliver" not in _cw.reason.lower() and _cw.reason == _cn.reason)
|
"deliver" not in _cw.reason.lower() and _cw.reason == _cn.reason)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Runnable check for SAFETY-73. `python3 test_refusal_units.py`
|
"""Runnable check for SAFETY-73. `python3 test_refusal_units.py`
|
||||||
|
|
||||||
SAFETY-52 per inverter. With two or more inverters, a battery that refuses
|
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,
|
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
|
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
|
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
|
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
|
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
|
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
|
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
|
`refused`); its OLD arms reproduce that tree's N>1 behaviour instead, and
|
||||||
@@ -61,6 +63,7 @@ def check(name, cond):
|
|||||||
|
|
||||||
TICK = 5.0
|
TICK = 5.0
|
||||||
CUT_W = -4.0
|
CUT_W = -4.0
|
||||||
|
FULL_W = 40.0
|
||||||
H = 3600.0
|
H = 3600.0
|
||||||
BASE = datetime(2026, 9, 6, 19, 30, tzinfo=timezone.utc)
|
BASE = datetime(2026, 9, 6, 19, 30, tzinfo=timezone.utc)
|
||||||
UNITS = ("a", "b")
|
UNITS = ("a", "b")
|
||||||
@@ -89,23 +92,39 @@ class _Pub:
|
|||||||
|
|
||||||
|
|
||||||
class _Hass:
|
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.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.cmd = {u: 0.0 for u in UNITS}
|
||||||
|
self.hist = {u: [(-1e18, 0.0)] for u in UNITS}
|
||||||
self.t = 0.0
|
self.t = 0.0
|
||||||
|
|
||||||
def batt(self, u):
|
def power(self, u, t=None):
|
||||||
c = self.cmd[u]
|
t = self.t if t is None else t
|
||||||
if self.cut[u](self.t) and c >= CUT_W:
|
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:
|
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
|
return CUT_W
|
||||||
|
if u in self.full and self.full[u](t) and c < -FULL_W:
|
||||||
|
return -FULL_W
|
||||||
return float(round(c))
|
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):
|
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):
|
async def limits(self, e):
|
||||||
return (-2000.0, 2000.0, 1.0)
|
return (-2000.0, 2000.0, 1.0)
|
||||||
@@ -126,6 +145,7 @@ class _Hass:
|
|||||||
|
|
||||||
async def set_number(self, e, v):
|
async def set_number(self, e, v):
|
||||||
self.cmd[e[-1]] = float(v)
|
self.cmd[e[-1]] = float(v)
|
||||||
|
self.hist[e[-1]].append((self.t, float(v)))
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -136,10 +156,12 @@ def _off(c):
|
|||||||
|
|
||||||
|
|
||||||
def run(load, cut, dur, *, soc=None, jitter=False, old=False, events=None,
|
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."""
|
"""Real cycles every TICK of simulated time. Rows are dicts per cycle."""
|
||||||
async def go():
|
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}",
|
inv = [{"name": f"inverter-{u}", "setpoint_entity": f"number.sp_{u}",
|
||||||
"soc_entity": f"sensor.soc_{u}",
|
"soc_entity": f"sensor.soc_{u}",
|
||||||
"batt_entity": f"sensor.batt_{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,
|
ra=c.inverters[0].refused, rb=c.inverters[1].refused,
|
||||||
da=c.inverters[0].delivery_wd.tripped,
|
da=c.inverters[0].delivery_wd.tripped,
|
||||||
db=c.inverters[1].delivery_wd.tripped))
|
db=c.inverters[1].delivery_wd.tripped))
|
||||||
t += TICK
|
t += dt
|
||||||
finally:
|
finally:
|
||||||
m.datetime = real_dt
|
m.datetime = real_dt
|
||||||
return rows, trips, c
|
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
|
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))
|
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")
|
print("6. one inverter is untouched")
|
||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user