SAFETY-97: "gives nothing" is graded against each battery's idle reading
checks / unit (pull_request) Failing after 29m7s
infra-pin / fresh (pull_request) Failing after 2s

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:
2026-10-04 12:21:24 +02:00
co-authored by Claude Opus 5.5
parent dd5eb6fdc8
commit 6918e154c8
10 changed files with 559 additions and 27 deletions
@@ -63,6 +63,7 @@ the held number for one 35 s window of asking, so 35 < 75 < 85 still holds.
## Before -> after
- Live replay (part 1): 4 trips -> 0.
- Refusing B (its -100 W idle reading): see SAFETY-97.
- Rig, 10 s lag, N=1 and N=2, 1/2/5 s meters, two register shapes, 48 runs
(part 2): before 27 stale trips and 1 refusal, after 0 and 0.
- Reversals, lag 10 and 20 s, 1 s meter (part 2): before N=1 6 stale / 3
@@ -87,11 +88,10 @@ the held number for one 35 s window of asking, so 35 < 75 < 85 still holds.
## Known gaps
- B's standby offset (-95 to -118 W at idle) puts a refusing B outside the
100 W band some of the time, so a refusing B may not latch (SAFETY-97).
At N=2 this is unchanged. **At N=1 it is new:** the old one-instant check
caught such a battery in 17-105 s; the shared rule needs 12 cycles in the
band in a row and, on the review probe, never latched in 1200 s.
- ~~B's standby offset (-95 to -118 W at idle) puts a refusing B outside the
100 W band, so a refusing B never latched (review F1: at N=1 new, at N=2
as before).~~ **Fixed by SAFETY-97 in the same PR:** the band is centred
on each unit's learned idle reading. `SAFETY-97-idle-offset.md`.
- A command that asks only part of the time (a fraction d) is caught after
35 s of asking, which is 35/d s of wall time (0/1500 W every 5 s: 69 s).
SAFETY-30's per-unit check restarts when its command stops opposing the
@@ -0,0 +1,84 @@
# SAFETY-97 - "gives nothing" is graded against each battery's idle reading
**Status: implemented 2026-10-04, folded into PR #171 (SAFETY-94), for
review.** Tests: `test_safety97.py` (rig: `tools/safety94_rig.py`). Closes
SAFETY-94 review finding F1.
## The defect
B's power reading (board B's `inverter_ac_power`, 0x0518) shows -95 to -118 W
at idle: its standby draw (the GoodWe app shows 0 W). A's shows -2 to -4 W.
Every "the battery gives nothing" test was `|reading| < 100 W`, so a refusing
B sat on the band's edge and never latched:
- N=2: never caught, before or after SAFETY-94.
- N=1: the old one-instant check caught it in 17-105 s; SAFETY-94's shared
rule (12 cycles in the band in a row) never did (review probe, 1200 s).
## Design
- **Offset source: learned per unit** (`app/idle.py`, `Inverter.idle`). No
new option: a required one is a setup step every client gets wrong, and an
optional one is a second source to reconcile. An override can be added
later if a site needs one.
- **When it learns:** control runs; the unit is not refused (unit or site),
stale or sign-tripped; its command has been within 50 W (`IDLE_ASK_W`) for
120 s (`IDLE_QUIET_S`, 6 lags of 20 s). A refusing battery is asked more
than 50 W, and while refused it does not learn, so its refusal cannot be
learned as idle. A capped battery under a big ask is not sampled either.
- **How:** the first value is the mean of 120 s of samples; then it follows
drift with a 30 min time constant. A sample beyond 250 W (`IDLE_MAX_W`)
is dropped (a register held at a giving value); the value is clamped to it.
- **Restart:** saved per unit (`unit/<name>/idle_w`, on first learn and each
5 W move) and restored, clamped. Without it a battery refusing from the
start is never quiet, so never learned, so never caught.
- **Until learned the idle reading is 0 W**: the rule as it was.
## Where it applies
Every test of "gives nothing / gives / takes" now uses `reading - idle`:
| check | change |
|---|---|
| shared refusal evidence `_gave_nothing` (N=1 and N>=2) | centred |
| N=1 engage and end in `compute()` (`idle_w`, the site sum) | centred |
| N=1 end in `_refusal_update` | centred |
| N>1 unit end (probe answered, takes the other way) | centred; uncentred, B's idle -100 W read as "takes 100 W" and ended a charge refusal at once |
| stale-path refusal (`_refusal_from_stale`, N=1 and N>1) | centred |
| SAFETY-42 "parked" (small ask, gives nothing) | centred |
| SAFETY-37 stale gate | unchanged: it grades register changes against command travel, not a level |
| SAFETY-57 release | unchanged: it reads the command only |
| SAFETY-30 per-unit sign | unchanged: opposition needs both over 250 W; B's 100 W idle cannot make or hide one alone |
| SAFETY-42 shortfall | unchanged: B reads ~100 W short at every power, which errs towards reporting (report only) |
## Bounds
- Band: `|reading - idle| < 100 W`, `|idle| <= 250 W`.
- A correct idle reading: a refusing B behaves as a refusing A.
- A wrong one (worst, 250 W off): a refusing battery reading outside the band
is not refused by this rule; a frozen register is still caught by the stale
gate at 35 s of asking. Learning only at a ~0 W command for 2 min keeps a
wrong value unlikely. Once learned, a 10 min transient moves it by 28 %;
the first value (a 2 min mean) is the exposed one.
- An idle reading beyond 250 W is never learned: such a unit keeps 0 W.
## Catch times (rig, refusing from 200 s after a 400 s rest, +-4 W)
| refusing | meter 1 s | 2 s | 5 s |
|---|---|---|---|
| A, N=1 | 28 s (before 28) | 28 (28) | 65 (65) |
| A, N=2 | 28 (28) | 28 (28) | 110 (110) |
| B, N=1 | 28 (never) | 28 (never) | 65 (never) |
| B, N=2 | 24 (never) | 24 (never) | 60 (never) |
A at N=2 on a 5 s meter is 110 s here because the rig's grid goes flat once
A is cut, so the loop cycles on the 15 s floor; the same run on the PR before
SAFETY-97 gives 110 s. B restored after a restart, refusing from the start:
24 s; with nothing saved: never.
## Before -> after
- Healthy B with its offset, SAFETY-94's four scenarios, N=1 and N=2, 1/2/5 s
meters, both register shapes, idle learned or restored (96 runs): 0 stale,
0 refused. Reversals, lag 10/20 s, idle restored: 0.
- Live replay (SAFETY-94 part 1): 0 trips (the gate is untouched).
+3 -1
View File
@@ -602,7 +602,9 @@ control is stopped or started. 200 W is below the 250 W the stale-reading check
needs, so a test never trips it. A battery that gives some power, even 100 W,
is not refusing and keeps its request. Nor is a battery whose reading is
still moving towards the request, such as one turning from charging to
giving (SAFETY-94).
giving (SAFETY-94). "Gives" is counted from what the battery's reading shows
at rest, which the add-on learns after two quiet minutes and keeps: some
inverters show about -100 W at rest (SAFETY-97).
**With two or more inverters** the same rule works per inverter. An inverter
whose battery gives less than 100 W for 12 cycles, and at least 22 s, while
+6 -2
View File
@@ -485,6 +485,7 @@ def compute(
probe: bool = False,
bound: tuple | None = None,
gave_nothing: bool | None = None,
idle_w: float = 0.0,
) -> Decision:
"""One control cycle. A cycle is one meter update (~5 s on a HomeWizard P1).
@@ -502,6 +503,9 @@ def compute(
under REFUSED_BELOW_W, one way, for SETTLE_CYCLES spanning
REFUSED_SPAN_S. A refusal engages only with it. None leaves it
out (compute() alone, as before).
`idle_w` SAFETY-97: what `actual_w` reads while the batteries give
nothing (app/idle.py). The refusal's band and its end are
centred on it. 0 is the law as it was.
"""
reason = "tracking"
# OPT-11: a bound that leaves out 0 W would FORCE a direction, which no
@@ -828,12 +832,12 @@ def compute(
# +800 W): at a 1 s meter its 12 cycles end as the reading passes 0.
if (refused == 0 and frozen and sat_count >= SETTLE_CYCLES
and abs(prev_w) > tuning.saturation_w
and abs(actual_w) < REFUSED_BELOW_W
and abs(actual_w - idle_w) < REFUSED_BELOW_W
and gave_nothing is not False):
refused = 1 if prev_w > 0 else -1
if refused and refused * i_w < 0:
refused = 0 # the loop wants the other way
elif refused and refused * actual_w >= PROBE_ANSWER_W:
elif refused and refused * (actual_w - idle_w) >= PROBE_ANSWER_W:
refused = 0 # it delivers: a probe answered,
# or a slow reading arriving
elif refused:
+83
View File
@@ -0,0 +1,83 @@
"""SAFETY-97: what a battery's power reading shows while it gives nothing.
B's reading (0x0518) shows -95 to -118 W at idle (standby draw, live
2026-10-03); A's shows -2 to -4 W. "The battery gives nothing" is graded
against this idle reading, not against 0 W, or a refusing B never latches.
DECISIONS/SAFETY-97-idle-offset.md.
Learned per unit, never configured:
- only while control runs, the unit is not refused, stale or tripped, and
its command has been within IDLE_ASK_W for IDLE_QUIET_S (a refusing battery
is asked more than that, so it cannot teach its refusal as idle);
- a reading beyond IDLE_MAX_W is not a sample, and the value is clamped to it;
- first value: the mean of IDLE_FIRST_S of samples; then a slow follow
(IDLE_TAU_S);
- persisted per unit and restored, clamped, at start.
Until learned the idle reading is 0 W, the rule as it was.
"""
import math
IDLE_MAX_W = 250.0 # |idle| bound: a band centred further out hides too much
IDLE_ASK_W = 50.0 # a command this small counts as "asked ~0"
IDLE_QUIET_S = 120.0 # 6 lags of 20 s: a 2500 W step has decayed to 6 W
IDLE_FIRST_S = 120.0 # seconds of samples averaged for the first value
IDLE_TAU_S = 1800.0 # then follow drift this slowly
IDLE_SAVE_W = 5.0 # persist when the value moved this far
IDLE_GAP_S = 10.0 # a longer gap between reads counts as this
def _num(v):
try:
v = float(v)
except (TypeError, ValueError):
return None
return v if math.isfinite(v) else None
class IdleReading:
"""One unit's idle reading. `w` is what "gives nothing" is centred on."""
def __init__(self, saved=None):
v = _num(saved)
self.value = (None if v is None
else max(-IDLE_MAX_W, min(IDLE_MAX_W, v)))
self.saved = self.value
self.quiet_since = None
self.last = None
self.acc_w = 0.0
self.acc_s = 0.0
@property
def w(self) -> float:
return 0.0 if self.value is None else self.value
def observe(self, reading, standing_w, now_mono, may_learn) -> bool:
"""One read. Returns True when the value should be persisted."""
now = float(now_mono)
dt = (0.0 if self.last is None
else max(0.0, min(IDLE_GAP_S, now - self.last)))
self.last = now
standing = _num(standing_w)
if not may_learn or standing is None or abs(standing) > IDLE_ASK_W:
self.quiet_since = None
return False
if self.quiet_since is None:
self.quiet_since = now
b = _num(reading)
if (now - self.quiet_since < IDLE_QUIET_S or b is None
or abs(b) > IDLE_MAX_W or dt <= 0.0):
return False
if self.value is None:
self.acc_w += b * dt
self.acc_s += dt
if self.acc_s < IDLE_FIRST_S:
return False
self.value = self.acc_w / self.acc_s
else:
self.value += (b - self.value) * min(1.0, dt / IDLE_TAU_S)
self.value = max(-IDLE_MAX_W, min(IDLE_MAX_W, self.value))
if self.saved is None or abs(self.value - self.saved) >= IDLE_SAVE_W:
self.saved = self.value
return True
return False
+8
View File
@@ -127,6 +127,7 @@ from . import messages
# SAFETY-37. The freshness policy, and the ONE place its bounds are written.
# freshness.py imports nothing from this package, so the direction is one-way.
from .freshness import BOUNDS, Freshness
from .idle import IdleReading
# CFG-03's ONE in-code mirror of config.yaml's SAFETY-24 defaults. Imported
# rather than retyped: the flat-keys path below needs `bms_soc_floor`'s
# shipped default and a literal here would be the fourth defaults site that
@@ -329,6 +330,13 @@ class Inverter:
self.ref_since = None
self.ref_sign = 0
self.ref_from = None
# SAFETY-97: what this unit's reading shows at idle; "gives nothing"
# is centred on it. Learned, persisted, restored (app/idle.py).
self.idle = IdleReading(data.get(self.key("idle_w")))
@property
def idle_w(self) -> float:
return self.idle.w
# -- store ---------------------------------------------------------------
def key(self, name: str) -> str:
+31 -8
View File
@@ -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.
+1
View File
@@ -319,6 +319,7 @@ class _Inv:
def __init__(self):
self.batt, self.ref_count, self.ref_since, self.ref_sign = 0.0, 0, None, 0
self.ref_from = None
self.idle_w = 0.0 # SAFETY-97: A, ~0 W at idle
self.ask = 800.0
def standing_w(self):
+322
View File
@@ -0,0 +1,322 @@
"""Runnable check for SAFETY-97: "the battery gives nothing" is graded against
each battery's own idle reading. `python3 test_safety97.py`
B's power reading shows -95 to -118 W at idle (standby draw; live
2026-10-03). The refusal evidence (SAFETY-52/73, shared since SAFETY-94)
needed the reading under 100 W in magnitude, so a refusing B never latched,
at N=2 ever, and at N=1 since SAFETY-94 (review F1).
Now each unit learns its idle reading (app/idle.py) and the band, the
refusal's end and the stale-path refusal are centred on it.
DECISIONS/SAFETY-97-idle-offset.md.
1. A refusing B is caught at N=1 and N=2, 1/2/5 s meters; A unchanged.
2. A healthy B with its offset is never refused or flagged (SAFETY-94's
scenarios), learned or restored.
3. Learning: not while refusing or asked, bounded, restored, follows drift.
4. The rules at their edges.
Mutations (purged copies) are listed in the pull request.
"""
import logging
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.CRITICAL)
import safety94_rig as rig # noqa: E402
import app.idle as idle # noqa: E402
import app.main as app_main # noqa: E402
from app import control # noqa: E402
from app.store import unit_key # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def without_fix(fn):
"""Run `fn` with every idle reading at 0 W (the rule before SAFETY-97)."""
real = idle.IdleReading.w
idle.IdleReading.w = property(lambda self: 0.0)
try:
return fn()
finally:
idle.IdleReading.w = real
def with_offset(n, fn):
"""At N=1 the rig's only unit is "a": give it B's offset."""
saved = dict(rig.OFFSET)
if n == 1:
rig.OFFSET["a"] = rig.OFFSET["b"]
try:
return fn()
finally:
rig.OFFSET.clear()
rig.OFFSET.update(saved)
REST, CUT = 400.0, 600.0 # rest (it learns), then ask, then cut
REST_LOAD = (lambda t: 0.0 if t < REST else 3000.0)
def caught(n, unit, tick, *, offset=True, dur=None, store=None, load=None,
cut_at=CUT, **kw):
"""Seconds from the cut to the refusal, or None, and the run."""
cu = "a" if n == 1 else unit
def go():
return rig.run(n, load or REST_LOAD, dur or cut_at + 300.0,
tick=tick, noise=4.0, store=store,
cut=lambda u, t: u == cu and t >= cut_at, **kw)
r = with_offset(n, go) if offset and unit == "b" else go()
ref = [t - cut_at for t, _u in r["refused"]]
return (ref[0] if ref else None), r
# ============================================================================ #
print("1. a refusing battery, B with its -100 W idle reading (+-4 W noise)")
TIMES, OLD = {}, {}
for unit in ("a", "b"):
for n in (1, 2):
for tick in (1.0, 2.0, 5.0):
TIMES[(unit, n, tick)] = caught(n, unit, tick)[0]
OLD[(unit, n, tick)] = without_fix(
lambda: caught(n, unit, tick)[0])
for k in TIMES:
print(f" refusing {k[0].upper()} N={k[1]} meter {k[2]:.0f} s: refused "
f"{TIMES[k]} s after the cut (before {OLD[k]})")
check("⭐ a refusing B is caught at N=1 and N=2: within 22 s plus one 10 s "
"publish at 1 and 2 s meters, 12 cycles plus a publish at 5 s",
all(TIMES[("b", n, t)] is not None
and TIMES[("b", n, t)] <= (32.0 if t < 5 else 12 * 5.0 + 10.0)
for n in (1, 2) for t in (1.0, 2.0, 5.0)))
check("non-vacuous: before, a refusing B is never caught (300 s), at N=1 "
"or N=2",
all(OLD[("b", n, t)] is None for n in (1, 2) for t in (1.0, 2.0, 5.0)))
check("⭐ a refusing A is caught exactly as before",
all(TIMES[("a", n, t)] == OLD[("a", n, t)] is not None
for n in (1, 2) for t in (1.0, 2.0, 5.0)))
# B refusing for 800 s: one refusal, held, not ended by its own idle reading.
_t, r = caught(2, "b", 1.0, dur=CUT + 800.0)
starts = [x for x in r["refused"] if x[1] == "inverter-b"]
check("a refusing B stays refused until its probe: its idle reading is not "
"read as taking power the other way (one refusal in 800 s)",
len(starts) == 1 and r["c"].inverters[1].refused == 1)
# A register frozen at rest on B: refused or stale within 36 s of the ask.
r = rig.run(2, lambda t: 2500.0 if t >= 600 else 0.0, 700.0, tick=1.0,
publish_s=1.0, dither=True,
freeze_at=lambda u: 300.0 if u == "b" else None)
park = min([t for t, u in r["stale"] if u == "inverter-b"]
+ [t for t, u in r["refused"] if u == "inverter-b"] or [None])
print(f" B's register frozen at rest from 300 s, asked at 600 s: 0 W at "
f"{park}")
check("a register frozen at rest on B is caught as on A (within 36 s of "
"the ask)", park is not None and park - 600.0 <= 36.0)
# ============================================================================ #
print("\n2. a healthy B with its offset is never refused or flagged")
SHAPES = ((10.0, False), (1.0, True))
NAMES = ("rest_step", "drain_start", "drain_charge", "reversal")
SAVED = {"b": {unit_key("inverter-b", "idle_w"): -100.0},
"a": {unit_key("inverter-a", "idle_w"): -100.0}}
bad = []
runs = 0
for n in (1, 2):
for name in NAMES:
for tick in (1.0, 2.0, 5.0):
for pub, dith in SHAPES:
for store in (None, SAVED["a" if n == 1 else "b"]):
s, f, _r = with_offset(n, lambda: rig.counts(
n, name, tick=tick, publish_s=pub, dither=dith,
store=store))
runs += 1
if s or f:
bad.append((n, name, tick, pub, bool(store), s, f))
print(f" {runs} runs, stale or refused: {bad}")
check("⭐ SAFETY-94's scenarios with B's offset (N=1 and N=2, 1/2/5 s "
"meters, both register shapes, idle learned or restored): no stale "
"trip and no refusal", not bad and runs == 96)
rev = []
for n in (1, 2):
for tau in (10.0, 20.0):
for charge in (-1500.0, -2500.0):
for house in (300.0, 800.0, 2000.0):
x = with_offset(n, lambda: rig.run(
n, rig.reversal(300.0, charge, house), 600.0, tau=tau,
tick=1.0, store=SAVED["a" if n == 1 else "b"]))
if x["stale"] or x["refused"]:
rev.append((n, tau, charge, house))
check("⭐ a charge reversing into a discharge (lag 10 and 20 s, 1 s meter) "
"with B's idle reading restored is never refused or flagged",
not rev)
# ============================================================================ #
print("\n3. learning the idle reading")
_t, r = caught(2, "b", 1.0)
c = r["c"]
check("learned during the rest: A about 0 W, B about -100 W, and saved",
abs(c.inverters[0].idle_w) < 5 and abs(c.inverters[1].idle_w + 100) < 5
and abs(c.store.data.get(unit_key("inverter-b", "idle_w"), 0) + 100)
< 5)
# A refusing B from the start, no rest, nothing saved: nothing to learn from.
_t, r = caught(2, "b", 1.0, load=lambda t: 3000.0, cut_at=60.0, dur=1200.0)
check("a battery refusing from the start teaches nothing: asked the whole "
"time, then held at 0 W by its refusal (none learned, none saved)",
r["c"].inverters[1].idle.value is None
and unit_key("inverter-b", "idle_w") not in r["c"].store.data)
# ... which is why the value is saved: restored, the same battery is caught.
t_saved, _r = caught(2, "b", 1.0, load=lambda t: 3000.0, cut_at=60.0,
store=SAVED["b"])
t_none, _r = caught(2, "b", 1.0, load=lambda t: 3000.0, cut_at=60.0)
print(f" refusing from the start: saved idle {t_saved} s, none {t_none}")
check("⭐ after a restart the saved idle reading is used: a B refusing from "
"the start is caught in 32 s; with nothing saved it is not",
t_saved is not None and t_saved <= 32.0 and t_none is None)
# Not learned while refused: a refusing B reading -160 W (in the band) does
# not pull its idle reading during the 0 W its refusal holds.
class _P(rig.Plant):
def advance(self, t):
super().advance(t)
for u in self.units:
if self.cut(u, self.t):
self.reg[u] = -160.0
real_plant = rig.Plant
rig.Plant = _P
try:
_t, r = caught(2, "b", 1.0, dur=CUT + 800.0)
finally:
rig.Plant = real_plant
b = r["c"].inverters[1]
print(f" refusing B reading -160 W: refused at {_t} s, idle {b.idle_w:.1f}")
check("⭐ not learned while refused: 800 s held at 0 W reading -160 W leaves "
"B's idle reading at -100 W",
_t is not None and b.refused and abs(b.idle_w + 100.0) < 1.0)
# A battery capped at 150 W under a big ask is not idle.
x = idle.IdleReading()
for t in range(0, 3600):
x.observe(150.0, 2000.0, float(t), True)
check("a reading under a command over 50 W is never a sample (a capped "
"battery is not idle)", x.value is None and x.w == 0.0)
x = idle.IdleReading()
for t in range(0, 239):
x.observe(-100.0, 10.0, float(t), True)
first = x.value
x.observe(-100.0, 10.0, 239.0, True)
x.observe(-100.0, 10.0, 240.0, True)
check("the first value needs 120 s asked ~0 and then 120 s of samples",
first is None and abs(x.value + 100.0) < 1e-6)
y = idle.IdleReading()
for t in range(0, 400):
y.observe(-100.0, 60.0 if t % 100 == 0 else 0.0, float(t), True)
check("a command over 50 W restarts the quiet time (a 60 W blip every "
"100 s: never learned)", y.value is None)
z = idle.IdleReading()
for t in range(0, 400):
z.observe(-100.0, 0.0, float(t), False)
check("nothing is learned while learning is not allowed (refused, stale, "
"tripped or stopped)", z.value is None)
for t in range(240, 300):
x.observe(-130.0, 0.0, float(t), True)
after_min = x.value
for t in range(300, 240 + 3600):
x.observe(-130.0, 0.0, float(t), True)
print(f" drift -100 -> -130 W: {after_min:.1f} after 60 s, {x.value:.1f} "
f"after an hour")
check("drift is followed slowly: under 2 W in the first minute, most of it "
"in an hour", -102.0 < after_min < -100.0 and -130 < x.value < -124)
o = idle.IdleReading()
for t in range(0, 600):
o.observe(-300.0, 0.0, float(t), True)
p = idle.IdleReading()
for t in range(0, 600):
p.observe(2000.0 if t < 300 else -240.0, 0.0, float(t), True)
check("a reading beyond 250 W is not a sample (-300 W: never learned; a "
"register held at 2000 W then -240 W: -240 W)",
o.value is None and p.value is not None and abs(p.value + 240.0) < 1e-6)
check("a saved value is clamped to 250 W, and garbage is ignored",
idle.IdleReading(900).w == idle.IDLE_MAX_W
and idle.IdleReading(-900.0).w == -idle.IDLE_MAX_W
and idle.IdleReading("x").value is None
and idle.IdleReading(float("nan")).value is None)
# ============================================================================ #
print("\n4. the rules at their edges")
class _Inv:
def __init__(self, idle_w):
self.batt, self.ref_count, self.ref_since, self.ref_sign = 0.0, 0, None, 0
self.ref_from, self.idle_w = None, idle_w
def standing_w(self):
return 800.0
class _C:
_now_mono = 0.0
def evidence(readings, idle_w):
c, inv = _C(), _Inv(idle_w)
got = []
for i, b in enumerate(readings):
c._now_mono, inv.batt = float(i), b
got.append(app_main.Controller._gave_nothing(c, inv))
return got
flat_b = evidence([-104.0 + (i % 3) for i in range(30)], -100.0)
flat_b0 = evidence([-104.0 + (i % 3) for i in range(30)], 0.0)
gives = evidence([0.0] * 30, -100.0)
takes = evidence([-200.0] * 30, -100.0)
check("the band is centred on the idle reading: B flat at -104 W is evidence "
"at 22 s with idle -100 W and never with idle 0 W; reading 0 W (giving "
"100 W) or -200 W (taking 100 W) is not",
1 in flat_b and flat_b.index(1) == 22 and not any(flat_b0)
and not any(gives) and not any(takes))
T = control.Tuning(max_w=4000.0, slew_w=400.0)
args = dict(prev_w=1200.0, grid_w=1200.0, tuning=T,
sat_count=control.SETTLE_CYCLES, i_w=1200.0, refused=0,
gave_nothing=True)
check("compute(): the N=1 engage is centred on idle_w (-104 W refuses with "
"idle -100, not with 0; -4 W refuses with idle 0, not with -150)",
control.compute(actual_w=-104.0, idle_w=-100.0, **args).refused == 1
and control.compute(actual_w=-104.0, **args).refused == 0
and control.compute(actual_w=-4.0, **args).refused == 1
and control.compute(actual_w=-4.0, idle_w=-150.0, **args).refused == 0)
args.update(refused=-1, i_w=-1200.0, prev_w=0.0, grid_w=-1200.0)
check("compute(): a charge refusal ends when the battery takes 100 W more "
"than its idle reading, not at its idle reading",
control.compute(actual_w=-110.0, idle_w=-100.0, **args).refused == -1
and control.compute(actual_w=-205.0, idle_w=-100.0, **args).refused
== 0
and control.compute(actual_w=-110.0, **args).refused == 0)
check("the idle bound is under the band's reach of a real delivery: 250 W",
idle.IDLE_MAX_W == 250.0 and idle.IDLE_ASK_W < control.PROBE_W)
print()
if fails:
print(f"{len(fails)} FAILED: " + ", ".join(fails))
sys.exit(1)
print("all checks passed")
+16 -11
View File
@@ -11,13 +11,15 @@ as the live boards do. B reads 100 W below what it gives (its standby draw:
reads every 1 s and cycles on a new meter value, as `run_control()` does.
A frozen register stops publishing at `freeze_at`; a refusing battery (`cut`)
gives 0 W and reads -4 W. Uses only the Controller API release/1.0 02ed120
gives 0 W and reads -4 W plus its offset (B: -104 W), with `noise` W of
seeded jitter (SAFETY-97). Uses only the Controller API release/1.0 02ed120
has, so the same file runs on the tree before SAFETY-94.
"""
import asyncio
import math
import os
import random
import sys
from datetime import datetime, timedelta, timezone
@@ -40,9 +42,9 @@ MAINT = {"maintenance_enabled": True, "maintenance_start_hour": 11,
class _Store:
def __init__(self):
def __init__(self, data=None):
from app.store import DEFAULTS
self.data = dict(DEFAULTS)
self.data = dict(DEFAULTS, **(data or {}))
def set(self, k, v):
self.data[k] = v
@@ -69,8 +71,9 @@ class Plant:
"""The batteries, their registers and the meter, in simulated seconds."""
def __init__(self, units, load, tau, tick, soc, cut, freeze_at,
publish_s=PUBLISH_S, dither=False):
publish_s=PUBLISH_S, dither=False, noise=0.0, seed=0):
self.units, self.load, self.tau, self.tick = units, load, tau, tick
self.noise, self.rng = noise, random.Random(seed)
self.publish_s, self.dither, self.n_pub = publish_s, dither, 0
self.soc_fn, self.cut, self.freeze_at = soc, cut, freeze_at
self.cmd = {u: 0.0 for u in units}
@@ -98,9 +101,11 @@ class Plant:
# `dither`: the +-1 W a settled register shows (live).
self.n_pub += 1
jit = (self.n_pub * 7 % 3 - 1) if self.dither else 0
self.reg[u] = (CUT_W if self.cut(u, self.t)
else float(round(self.real[u] + OFFSET[u])
+ jit))
self.reg[u] = (
float(round(CUT_W + OFFSET[u] + self.rng.uniform(
-self.noise, self.noise)))
if self.cut(u, self.t)
else float(round(self.real[u] + OFFSET[u]) + jit))
if abs(self.t / self.tick - round(self.t / self.tick)) < 1e-6:
self.grid_v = float(round(self.load(self.t)
- sum(self.real.values())))
@@ -148,18 +153,18 @@ def options(n, extra=None):
def run(n, load, dur, *, tau=10.0, tick=1.0, soc=None, cut=None,
freeze_at=None, extra=None, floor_s=15.0, publish_s=PUBLISH_S,
dither=False):
dither=False, noise=0.0, seed=0, store=None):
"""Returns a dict: per-unit stale trips [(t, unit)], refusals
[(t, unit or 'site')], rows [(t, grid, {u: cmd}, {u: real}, {u: reg},
phases)] and the controller."""
phases)] and the controller. `store`: saved state to start from."""
units = tuple("ab"[:n])
soc = soc or (lambda u, t: 50.0)
cut = cut or (lambda u, t: False)
plant = Plant(units, load, tau, tick, soc, cut, freeze_at, publish_s,
dither)
dither, noise, seed)
async def go():
c = Controller(options(n, extra), _Hass(plant), _Store(), _Pub(),
c = Controller(options(n, extra), _Hass(plant), _Store(store), _Pub(),
stop=asyncio.Event())
for i in c.inverters:
i.dev_min, i.dev_max = -5000.0, 5000.0