SAFETY-103: no swing on old battery readings after a drain ends
checks / unit (pull_request) Failing after 35m9s
infra-pin / fresh (pull_request) Successful in 2s

While a battery settles from its ended drain (SAFETY-95), the house point
was grid + every battery's reading. A register can be 10 s older than the
meter, so B swung 860 -> 1650 -> 870 W on A's old reading (rig check 5).

Now the house point is held and moved only as far as the meter proves:
each battery is between its last changed reading and its commands since
(a reading that changed this cycle, or a refused side, as it reads).
N=1 and 1 s registers are byte-identical to release/1.0.

DECISIONS/SAFETY-103-old-readings-after-a-drain.md, test_safety103.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 02:25:55 +02:00
co-authored by Claude Opus 5.5
parent 451729c9b5
commit 138e45b53f
5 changed files with 727 additions and 4 deletions
@@ -0,0 +1,79 @@
# SAFETY-103 - no swing on old battery readings after a drain ends
**Status: implemented 2026-10-08, for review.** Tests: `test_safety103.py`.
**The defect** (SAFETY-95 rig test, check 5, F1). While a battery settles
from its ended drain, SAFETY-95 steers on the house's point: grid + every
battery's reading - aim. The meter is 1 s old; a battery register can be
10 s old (the SAFETY-90 register model). At E+13 A's reading said 1238 W
(6.8 s old) while A was at 616 W, so the house looked ~620 W bigger: B went
860 -> 1650 -> 870 W. B's own old reading does the same the other way.
**The rule** (`main.py`, `_track_reads()`, `_drain_grid_w()`, N>=2, only
while a drain's tail settles):
1. Each cycle, per battery: the range its power can be in. That is its last
changed reading, widened by every command on its wire since (a lagging
battery moves from one toward the others, never beyond). A new reading
starts the range again.
2. A reading that changed this cycle is taken as it reads. So is the side a
battery refuses (SAFETY-73): it is not heading that way.
3. The house point is held (first cycle: the loop's start from SAFETY-95).
It moves only as far as the meter proves: to the nearest value of grid +
the batteries' ranges - aim.
So the loop no longer moves on a reading that has not caught up. A real
house step still moves it, as soon as the meter shows more than the
batteries can explain.
**Why not the tester's rule** ("a battery whose reading is older than its
last command counts at its command"). B's command changes almost every
cycle, so B is almost always at its command. That is the plain meter, which
chases A's tail: B swings 1420 W. A lag model needs the plant's time
constant, which is not known for a client's inverter. Measured on the plant
below (N=2, 1200 W, A Off; swing = a move of the others later undone):
| rule | 10 s lag | 5 s lag | 20 s lag | surplus, 5 s lag |
|---|---|---|---|---|
| release/1.0 (readings) | 760 | 840 | 150 | 800 |
| tester's: at the command | 1420 | 1020 | 1600 | 800 |
| lag model, tau 10 s | 70 | 140 | 80 | 580 |
| lag model, tau 5 s, plant 20 s | | | 600 | |
| hold all window | 0 | 0 | 0 | 0 |
| **this rule** | **0** | **0** | **0** | **130** |
Holding all window ignores a house step: a 2 kW kettle at E+5 s imported
14.4 Wh over the next 30 s (release 11.5, this rule 11.3); 2 kW more sun
exported 16.9 Wh (release 8.5, this rule 8.9).
**Before -> after** (`test_safety103.py`, `tools/safety103_plant.py`: 800
W/s ramp, 10 s lag, meter 1 s, registers 10 s at the rig's phases, B idles
at -100 W; summed swing of the others in the 23 s after the end):
| case | release/1.0 | after |
|---|---|---|
| rig check 5: N=2, 1200 W, A Off | B 1140 -> 850 -> 1630 -> 870, swing 760 W | B holds 1140 W |
| N=3, 1200 W / 4000 W, A Off | 680 / 630 W | 0 / 0 W |
| solar surplus, N=2 / N=3 | 890 / 740 W | 130 / 130 W |
| lag 5 or 20 s, registers 5 or 10 s, N=2 and N=3 | | at most 10 W |
| registers as fresh as the meter (1 s) | | rows byte-identical |
| refusal end | | rows byte-identical |
| N=1, every end | | rows byte-identical (also against the release/1.0 tree) |
**Cost.** In the window, a house change smaller than what the batteries
could still be doing is not followed until a reading catches up (at most a
register period) or the window ends (23 s). Under a surplus the others
still swing up to 130 W, a third of a slew step.
**Not covered.**
- Outside the drain tail the loop steers on the meter; readings only reach
the saturation freeze (SAFETY-96), unchanged.
- A lost link or a refusal ends the tail at once (A is out of the split, or
reads ~0 W). The swing after them is the loop's own on a lagging plant,
the same with 1 s registers and on release/1.0 (up to 1.9 kW at N=3 under
a surplus): not this card.
- A battery that overshoots its command, or a reading offset beyond the
range, can put the true power outside the range; the meter then moves the
point, as before.
- The end cycle's start (SAFETY-95 step 2) still uses the readings; the
batteries were steady during the drain, so they are current there.
+2 -1
View File
@@ -1704,7 +1704,8 @@ When a battery's link drops, the others take over one slew step a cycle,
and control does not stall on the dropped battery's power (SAFETY-96).
When a drain ends (done, Off, timeout, abort, lost link, refused), the
other batteries cover the house at once but do not export its drain; then
they move one slew step a cycle (SAFETY-95).
they move one slew step a cycle (SAFETY-95). While that battery settles,
an old battery reading does not move them (SAFETY-103).
#### Capacity-tariff exposure of the grid-following loop
+54 -3
View File
@@ -1031,6 +1031,11 @@ class Controller:
# SAFETY-95: batteries settling after their drain ended; meanwhile
# the loop's error counts every battery at its command.
self._drain_tail = set()
# SAFETY-103: per battery, the range its power can be in since its
# reading last changed (that reading and every command since), and
# the house point held while a drain's tail settles.
self._read_span = {}
self._drain_house = None
# SAFETY-96: last cycle's commandable batteries, this cycle's start
# point per battery where a reseed moved it, and batteries settling
# after a step the loop did not make ({name: since, monotonic}).
@@ -2527,13 +2532,56 @@ class Controller:
most PLANT_SETTLE_DOWN_S), the meter still shows the drain's tail and
the others' climb, so the loop would chase them (B down, then up
again). Meanwhile the batteries count at the site command: the error
is the house's own point minus the loop's start."""
is the house's own point minus the loop's start.
SAFETY-103: a reading can be up to a register period (10 s) older
than the meter, so the house point is held, and moved only as far as
the meter proves: to the nearest value the batteries can be at (each
between its last changed reading and its commands since; a reading
that changed this cycle, or the side a battery refuses, is taken as
it reads). DECISIONS/SAFETY-103-old-readings-after-a-drain.md.
"""
grid = float(self.grid)
self._drain_tail &= set(self._settle) & set(live)
if not self._drain_tail:
self._drain_house = None
return grid
return grid - float(self.target) + sum(
float(inv.batt) for inv in self.inverters if inv.name in live)
aim = self.tuning.target_grid_w
lo = hi = grid - aim
for inv in self.inverters:
if inv.name not in live:
continue
r = float(inv.batt)
span = self._read_span.get(inv.name)
if (span is None or span[2] != inv.batt_fresh.changed_mono
or span[2] == self._now_mono):
span = (r, r)
# A battery that refuses one way (SAFETY-73) is not heading
# that way, whatever it was asked: that side is its reading.
if (inv.refused or 0) > 0:
span = (min(span[0], r), r)
elif (inv.refused or 0) < 0:
span = (r, max(span[1], r))
lo, hi = lo + span[0], hi + span[1]
held = (float(self.target) if self._drain_house is None
else self._drain_house)
self._drain_house = min(hi, max(lo, held))
return self._drain_house - float(self.target) + aim
def _track_reads(self) -> None:
"""SAFETY-103, N>=2: per battery, the range its power can be in now:
its last changed reading and every command on its wire since (a
lagging battery moves from one toward the others, never beyond)."""
if len(self.inverters) < 2:
return
for inv in self.inverters:
if inv.batt is None:
continue
cmd, since = inv.standing_w(), inv.batt_fresh.changed_mono
lo, hi, was = self._read_span.get(inv.name, (0.0, 0.0, None))
if was is None or since != was:
lo = hi = float(inv.batt)
self._read_span[inv.name] = (min(lo, cmd), max(hi, cmd), since)
def _reseed_drop(self, live) -> None:
"""SAFETY-96, N>=2: a battery that drops out of the split (stale
@@ -4914,6 +4962,9 @@ class Controller:
# level: the two halves of §6.2's identity break in opposite
# directions. See `site_batt_w` and test_alloc.py part 8.
site_batt = self.site_batt_w
# SAFETY-103: what each battery can be doing (its reading may be
# older than the meter).
self._track_reads()
# SAFETY-96: a battery the loop cannot move counts at its command,
# so its own power does not freeze the loop (the meter has it).
site_batt += self._held_out_w(_live96)
+397
View File
@@ -0,0 +1,397 @@
"""Runnable check for SAFETY-103: no swing on old battery readings after a
drain ends. `python3 test_safety103.py`
While a battery settles from its ended drain (SAFETY-95), the loop works
from the grid plus each battery's reading. A reading can be one register
period (10 s) older than the meter: on the rig (SAFETY-95 test, check 5) A's
reading still said 1238 W while A was at 616 W, and B went 860 -> 1650 ->
870 W. Now the house point is held, and moved only as far as the meter
proves (each battery between its last changed reading and its commands
since). Drives the REAL Controller on tools/safety103_plant.py: a lagging
battery, the meter every 1 s, registers every 10 s at the rig's phases, B
idling at -100 W. DECISIONS/SAFETY-103-old-readings-after-a-drain.md.
1. The rig case and every drain end at N=2 and N=3, 10 s registers: no
swing beyond one slew step; release/1.0 swings (non-vacuous).
2. SAFETY-95/96's gains stay: no jump at the end, no give into a surplus,
one slew step a cycle, refusal and stale link still learned.
3. A house step inside the window is still followed.
4. With readings as fresh as the meter, every row is release/1.0's; N=1 is
untouched.
5. The rule's parts, one by one.
Mutations (MUTATION.md, purged copies) are listed in the pull request.
"""
import hashlib
import logging
import math
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.WARNING)
import safety103_plant as p # noqa: E402
from app.main import Controller # noqa: E402
from app.maintenance import DRAIN # noqa: E402
fails = []
SLEW = 400.0 # the rig's slew_w
AIM = -10.0 # the rig's target_grid_w
T = p.T
A = p.A
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
fixed = Controller._drain_grid_w
track = getattr(Controller, "_track_reads", None)
def _release(self, live):
"""release/1.0's `_drain_grid_w` (SAFETY-95 as merged): every battery at
its reading."""
grid = float(self.grid)
self._drain_tail &= set(self._settle) & set(live)
if not self._drain_tail:
return grid
return grid - float(self.target) + sum(
float(inv.batt) for inv in self.inverters if inv.name in live)
def without(fn):
"""Run `fn` with SAFETY-103 taken out (release/1.0's rule)."""
Controller._drain_grid_w = _release
try:
return fn()
finally:
Controller._drain_grid_w = fixed
def soc_done(u, t):
return 5.0 if (u == "a" and t >= T) else 50.0
# A's drain ends at T: switched Off (the rig case), completed on SoC, its
# battery refusing (gives nothing from T; SAFETY-73 learns it later), or its
# link going stale (writes refused from T, its board to 0 W 25 s later).
ENDS = {
"Off": dict(),
"complete": dict(before=None, soc=soc_done),
"refusal": dict(before=None, dead=("a", T), dur=T + 150.0),
"stale link": dict(before=None, reject={"a": T}, dead=("a", T + 25.0),
dur=T + 150.0),
}
def end_of(rows):
"""The first cycle A's drain is over (its command left 2000 W, or its
phase left DRAIN); None if never."""
return next((r["t"] for r in rows if r["t"] >= T and (
r["cmd"][0] < 1990.0 or r["phase"][0] != DRAIN)), None)
def measure(rows, n, house):
"""From the end on: the largest swing of the others' summed command in
the 23 s window (a move later undone; the slew step is the site's); their
largest command on the end cycle; the site target's largest step after
the end cycle; the others' largest command after the end (for a
surplus); the grid's range in the window."""
te = end_of(rows)
if te is None:
return dict(end=math.nan, swing=math.nan, first=math.nan,
step=math.nan, give=math.nan, gmin=math.nan,
gmax=math.nan, rows=rows)
win = [r for r in rows if te <= r["t"] <= te + 23.0]
sw = 0.0
w = [sum(r["cmd"][1:]) for r in win][1:]
for j in range(len(w)):
sw = max(sw, min(w[j] - min(w[:j + 1]), w[j] - min(w[j:])),
min(max(w[:j + 1]) - w[j], max(w[j:]) - w[j]))
seg = [r["target"] for r in rows if r["t"] >= te]
return dict(
end=te, swing=sw,
first=max(win[0]["cmd"][1:]),
step=max(abs(b - a) for a, b in zip(seg, seg[1:])),
give=max(max(r["cmd"][1:]) for r in rows if r["t"] >= te),
gmin=min(r["grid"] for r in win), gmax=max(r["grid"] for r in win),
rows=rows)
def run(kind, n, house, **k):
return p.run(n, house, **dict(ENDS[kind], **k))[0]
TABLE = []
# A settles from its drain: the tail this rule is about. A refusing battery
# already reads ~0 W, and a dropped one is out of the split (the meter has
# it), so their tails last at most a cycle.
SETTLE = ("Off", "complete")
def case(kind, n, house, **k):
now = measure(run(kind, n, house, **k), n, house)
old = measure(without(lambda: run(kind, n, house, **k)), n, house)
fresh = measure(run(kind, n, house, reg_s=1.0, **k), n, house)
TABLE.append((kind, n, house, now, old, fresh))
return now, old, fresh
# ============================================================================ #
print("1. no swing on an old reading: every drain end, N=2 and N=3, 10 s "
"registers")
for kind in ENDS:
for n in (2, 3):
for house in (1200.0, 4000.0, -1500.0):
now, old, fresh = case(kind, n, house)
own = kind in SETTLE
check(f"{'⭐' if kind == 'Off' else ' '} {kind}, N={n}, house "
f"{house:.0f} W: old readings add at most one slew step "
f"to the others' swing in the 23 s after the end"
f"{', and it is at most one slew step' if own else ''} "
f"({now['swing']:.0f} W; 1 s registers "
f"{fresh['swing']:.0f}; release/1.0 {old['swing']:.0f})",
now["swing"] - fresh["swing"] <= SLEW
and (not own or now["swing"] <= SLEW))
_by = {(k, n, h): (a, b) for k, n, h, a, b, f in TABLE}
_now, _old = _by[("Off", 2, 1200.0)]
_b = [r["cmd"][1] for r in _now["rows"] if 0 <= r["t"] - _now["end"] <= 23]
_bo = [r["cmd"][1] for r in _old["rows"] if 0 <= r["t"] - _old["end"] <= 23]
print(f" rig check 5 (N=2, 1200 W, A Off): B {_b}\n"
f" release/1.0: B {_bo}")
check("⭐ the rig case (SAFETY-95 check 5): release/1.0 swings B by more "
"than a slew step on A's old reading; now B holds within 50 W",
_old["swing"] > SLEW and max(_b) - min(_b) <= 50.0)
for n in (2, 3):
check(f"non-vacuous (N={n}): on release/1.0 old readings add more than "
f"a slew step in at least one of these cases",
any(b["swing"] - f["swing"] > SLEW
for k, nn, h, a, b, f in TABLE if nn == n))
_s5 = []
for lag in (5.0, 20.0):
for reg in (5.0, 10.0):
for n in (2, 3):
_s5.append(measure(run("Off", n, 1200.0, lag=lag, reg_s=reg), n,
1200.0)["swing"])
check(f"no plant constant is assumed: battery lag 5 s and 20 s, registers "
f"every 5 s and 10 s, N=2 and N=3: largest swing {max(_s5):.0f} W",
max(_s5) <= SLEW)
_ph = [measure(run("Off", 2, 1200.0, phase={"a": a, "b": b}), 2, 1200.0)
["swing"] for a, b in ((0.0, 0.5), (0.9, 0.1), (0.3, 0.3))]
check(f"other register phases: largest swing {max(_ph):.0f} W",
max(_ph) <= SLEW)
# ============================================================================ #
print("\n2. SAFETY-95/96's gains stay")
for kind in ENDS:
for n in (2, 3):
now, old = _by[(kind, n, 1200.0)]
check(f"{kind}, N={n}, 1200 W: no jump on the end cycle (others "
f"<= the house, {now['first']:.0f} W) and the site moves at "
f"most one slew step a cycle after ({now['step']:.0f} W)",
now["first"] <= 1200.0 - AIM + 50.0 and now["step"] <= SLEW)
now, old = _by[(kind, n, -1500.0)]
check(f"{kind}, N={n}, solar surplus: no battery gives into it "
f"(largest command after the end {now['give']:.0f} W"
+ (f"; a lost link as release/1.0, {old['give']:.0f} W)"
if kind == "stale link" else ")"),
now["give"] <= (max(50.0, old["give"]) if kind == "stale link"
else 50.0))
_now, _old = _by[("Off", 2, 1200.0)]
check(f"the grid in the window stays inside release/1.0's range "
f"([{_now['gmin']:.0f}, {_now['gmax']:.0f}] W, release/1.0 "
f"[{_old['gmin']:.0f}, {_old['gmax']:.0f}])",
_now["gmin"] >= _old["gmin"] - 50.0 and _now["gmax"] <= _old["gmax"])
_ref = {}
for name, f in (("now", lambda: p.run(2, 1200.0, **ENDS["refusal"])),
("old", lambda: without(lambda: p.run(
2, 1200.0, **ENDS["refusal"])))):
rows, c = f()
_ref[name] = (c.inverters[0].refused, c.inverters[0].refused_at)
check(f"a refusal is still learned, at the same instant as release/1.0 "
f"({_ref['now']})", _ref["now"] == _ref["old"]
and _ref["now"][0] == 1)
_st = {}
for name, f in (("now", lambda: p.run(2, 1200.0, **ENDS["stale link"])),
("old", lambda: without(lambda: p.run(
2, 1200.0, **ENDS["stale link"])))):
rows, c = f()
_st[name] = sorted(c.live_units())
check(f"a stale link is still dropped, as in release/1.0 "
f"(live: {_st['now']})", _st["now"] == _st["old"]
and len(_st["now"]) == 1)
# ============================================================================ #
print("\n3. a house step inside the window is still followed")
def step(sign):
return lambda t: 1200.0 + (sign * 2000.0 if t >= T + 5.0 else 0.0)
for sign, what in ((1, "kettle +2000 W"), (-1, "solar -2000 W")):
for n in (2, 3):
rows = p.run(n, step(sign))[0]
rows_o = without(lambda: p.run(n, step(sign)))[0]
te = end_of(rows)
def wh(rs):
return sum(abs(r["grid"] - AIM) for r in rs
if te + 5.0 <= r["t"] <= te + 35.0) / 3600.0
sw = measure(rows, n, 1200.0)["swing"]
check(f"{what} at E+5 s, N={n}: the grid's distance from its aim "
f"over the next 30 s is within 1.5 Wh of release/1.0's "
f"({wh(rows):.1f} Wh, release/1.0 {wh(rows_o):.1f}); swing "
f"{sw:.0f} W", wh(rows) <= wh(rows_o) + 1.5 and sw <= SLEW)
# ============================================================================ #
print("\n4. fresh readings and N=1: release/1.0's rows")
def digest(rows):
return hashlib.sha256(repr(rows).encode()).hexdigest()
_fresh = [(n, h) for n in (2, 3) for h in (1200.0, 4000.0, -1500.0)]
check("⭐ registers as fresh as the meter (1 s), N=2 and N=3, three houses: "
"every row byte-identical with release/1.0 (a reading that changed "
"this cycle is taken as it is)",
[digest(p.run(n, h, reg_s=1.0)[0]) for n, h in _fresh]
== [digest(without(lambda: p.run(n, h, reg_s=1.0))[0])
for n, h in _fresh])
_rf = [(n, h) for n in (2, 3) for h in (1200.0, -1500.0)]
check("⭐ a refusal end, 10 s registers, N=2 and N=3, 1200 W and a surplus: "
"every row byte-identical with release/1.0 (A reads ~0 W and will not "
"give, so its side is its reading)",
[digest(run("refusal", n, h)) for n, h in _rf]
== [digest(without(lambda: run("refusal", n, h))) for n, h in _rf])
_n1 = [dict(), dict(before=None, soc=soc_done)]
check("⭐ N=1, A switched Off and A's drain completing, 10 s registers: "
"every row byte-identical with release/1.0",
[digest(p.run(1, 1200.0, **k)[0]) for k in _n1]
== [digest(without(lambda: p.run(1, 1200.0, **k))[0]) for k in _n1])
# ============================================================================ #
print("\n5. the rule's parts")
_c = p.run(2, 1200.0, dur=4.0)[1]
AB = {"inverter-a", "inverter-b"}
ia, ib = _c.inverters
def at(now, batts, cmds, changed):
"""Set the clock, each battery's reading, its standing command and the
instant its reading last changed; then track."""
_c._now_mono = now
for inv, b, cmd, ch in zip(_c.inverters, batts, cmds, changed):
inv.batt, inv.last_write = b, cmd
inv.batt_fresh.changed_mono = ch
_c._track_reads()
_c._read_span = {}
at(10.0, (2000.0, -100.0), (2000.0, 0.0), (10.0, 10.0))
check("a reading that changed is the start of its range: (R, R) joined "
"with the command on its wire",
_c._read_span[A] == (2000.0, 2000.0, 10.0)
and _c._read_span["inverter-b"] == (-100.0, 0.0, 10.0))
at(11.0, (2000.0, -100.0), (0.0, 1140.0), (10.0, 10.0))
at(12.0, (2000.0, -100.0), (0.0, 800.0), (10.0, 10.0))
check("every command since the reading widens the range (a lagging "
"battery moves toward each in turn): A [0, 2000], B [-100, 1140]",
_c._read_span[A][:2] == (0.0, 2000.0)
and _c._read_span["inverter-b"][:2] == (-100.0, 1140.0))
at(13.0, (1241.0, -100.0), (0.0, 800.0), (13.0, 10.0))
check("a new reading starts the range again: A [0, 1241]",
_c._read_span[A][:2] == (0.0, 1241.0))
_c._read_span = {}
_c.inverters = _c.inverters[:1]
at(14.0, (2000.0,), (2000.0,), (14.0,))
_c.inverters = [ia, ib]
check("N=1: nothing is tracked", _c._read_span == {})
def tail(now, grid, target, held=None):
"""`_drain_grid_w` with A settling: the error it hands the loop (grid
minus aim) and the house point it holds."""
_c._now_mono, _c.grid, _c.target = now, grid, target
_c._drain_tail, _c._settle = {A}, {A: 0.0}
_c._drain_house = held
g = _c._drain_grid_w(AB)
return round(g - AIM, 6), _c._drain_house
# A: reading 1241 W from 13 s, range [0, 1241]; B: reading -100 W from 10 s,
# range [-100, 1140]. Grid -330 W: the house is in
# [-330 + 10 + 0 - 100, -330 + 10 + 1241 + 1140] = [-420, 2061].
_c._read_span = {A: (0.0, 1241.0, 13.0), "inverter-b": (-100.0, 1140.0, 10.0)}
ia.batt, ib.batt = 1241.0, -100.0
ia.batt_fresh.changed_mono, ib.batt_fresh.changed_mono = 13.0, 10.0
check("⭐ held: the first window cycle holds the loop's start (1140 W) while "
"the meter allows it (error 0; release/1.0's would be -330 + 1241 - "
"100 + 10 - 1140 = -319 W)", tail(14.0, -330.0, 1140.0) == (0.0, 1140.0)
and round(_release(_c, AB) - AIM) == -319)
check("⭐ held, then kept: a later cycle keeps the held point, not the "
"loop's target", tail(15.0, -330.0, 1000.0, held=1140.0)
== (140.0, 1140.0))
check("⭐ moved as far as the meter proves: a grid of +2000 W puts the "
"house at least at 2000 + 10 + 0 - 100 = 1910 W",
tail(15.0, 2000.0, 1140.0, held=1140.0) == (770.0, 1910.0))
check("...and down: a grid of -2500 W puts it at most at -2500 + 10 + 1241 "
"+ 1140 = -109 W", tail(15.0, -2500.0, 1140.0, held=1140.0)
== (-1249.0, -109.0))
ia.batt_fresh.changed_mono = 15.0
check("⭐ a reading that changed this cycle is taken as it is: A at 1241 W, "
"not [0, 1241], so a grid of +2000 W puts the house at least at "
"2000 + 10 + 1241 - 100 = 3151 W", tail(15.0, 2000.0, 1140.0,
held=1140.0) == (2011.0, 3151.0))
ia.batt_fresh.changed_mono = 13.0
# A refuses to give (SAFETY-73): it reads 8 W, though it was asked 2000 W
# since that reading. Grid -1500 W: the house is at most -1500 + 10 + 8 +
# 1140 = -342 W, not -1500 + 10 + 2000 + 1140 = 1650 W.
_c._read_span[A] = (8.0, 2000.0, 13.0)
ia.batt = 8.0
ia.refused = 1
_ref1 = tail(15.0, -1500.0, 1140.0, held=1140.0)
ia.refused = -1
_c._read_span[A] = (-2000.0, 8.0, 13.0)
_ref2 = tail(15.0, 1000.0, -500.0, held=-500.0)
ia.refused = 0
check("⭐ a battery that refuses one way is taken at its reading on that "
"side: will not give, asked 2000 W, reads 8 W -> the house at most "
"-342 W; will not take, asked -2000 W -> at least 1000 + 10 + 8 - 100 "
"= 918 W", _ref1 == (-1482.0, -342.0) and _ref2 == (1418.0, 918.0))
_c._read_span[A], ia.batt = (0.0, 1241.0, 10.0), 1241.0
check("a range from an older reading than the one in hand (a cycle the "
"loop did not run) is not used: A at its reading, 1241 W",
tail(15.0, 2000.0, 1140.0, held=1140.0) == (2011.0, 3151.0))
_c._read_span[A] = (0.0, 1241.0, 13.0)
_c._drain_tail, _c._settle, _c._drain_house = {A}, {}, 1140.0
_c.grid = -330.0
check("⭐ the tail's end lets the hold go: the meter again, and the next "
"tail starts from the loop's start",
_c._drain_grid_w(AB) == -330.0 and _c._drain_house is None)
# ============================================================================ #
print("\n drain end | N | house W | swing W now/release/1 s registers | "
"end-cycle others W now/release | site step W now/release | grid in "
"window W now | release")
for k, n, h, a, b, f in TABLE:
print(f" {k} | {n} | {h:.0f} | {a['swing']:.0f}/{b['swing']:.0f}/"
f"{f['swing']:.0f} | "
f"{a['first']:.0f}/{b['first']:.0f} | {a['step']:.0f}/"
f"{b['step']:.0f} | [{a['gmin']:.0f}, {a['gmax']:.0f}] | "
f"[{b['gmin']:.0f}, {b['gmax']:.0f}]")
print()
if fails:
print(f"{len(fails)} FAILED: " + ", ".join(fails))
sys.exit(1)
print("all SAFETY-103 checks passed")
+195
View File
@@ -0,0 +1,195 @@
"""SAFETY-103's plant: test_safety95's lagging battery with the SAFETY-90
register model, driven through the REAL Controller in simulated time.
Each battery ramps at 800 W/s, then follows a first-order lag (`lag` s). Its
power register publishes every `reg_s` s at its own phase (the SAFETY-90/96
rig: A at 0.6, B at 0.16 of the period), and reads `off` W from the truth
(B idles at -100 W). The meter reads every cycle (`tick` s). A drain on A
runs from the first cycle (the others finished theirs); `before(c, t)` ends
it. `run()` has no side effects on import, so tests and probes share it.
"""
import asyncio
import math
import os
import random
import sys
import tempfile
from datetime import timedelta
HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
import opt11_identity as rig # noqa: E402
from app import main as app_main, packmode # noqa: E402
from app.main import Controller # noqa: E402
from app.store import unit_key # noqa: E402
A = "inverter-a"
T = 300.0 # A's drain ends here
PHASE = {"a": 0.6, "b": 0.16, "c": 0.38}
OFF = {"b": -100.0} # the rig's B: idle reading -100 W
M = {"maintenance_enabled": True, "maintenance_start_hour": 11,
"battery_modes_enabled": True}
def options(n, extra=None):
o = rig._options(n, dict(M, **(extra or {})))
if n > 1:
o["inverters"] = [
{"name": f"inverter-{u}", "setpoint_entity": f"number.sp_{u}",
"soc_entity": f"sensor.soc_{u}", "batt_entity": f"sensor.batt_{u}",
"max_w": 2500, "bms_soc_floor": 10} for u in "abc"[:n]]
return o
def store(n):
"""A due for its monthly cycle (it drains from the first cycle)."""
s = rig._Store()
for u in "bc"[:n - 1]:
s.data[unit_key(f"inverter-{u}", "last_completed")] = \
rig.BASE.isoformat()
return s
def off(c, t):
"""A switched Off at T (the SAFETY-95 check 5 end)."""
if t >= T:
c.store.data[unit_key(A, packmode.USE_KEY)] = False
def run(n, house, *, before=off, dur=T + 90.0, tick=1.0, reg_s=10.0,
lag=10.0, noise=50.0, seed=103, phase=None, offs=None, extra=None,
st=None, soc=None, dead=None, reject=None):
"""Rows of one run. Each row is a dict: t, cmd (the add-on's commands),
target, grid (true), true (each battery's power), read (each register),
age (s since each register published), phase (maintenance phases).
`dead` (u, from s): u gives nothing from then on (board at 0 W).
`reject` {u: from s}: writes to u are refused from then on."""
units = tuple("abc"[:n])
phase = PHASE if phase is None else phase
offs = OFF if offs is None else offs
rnd = random.Random(seed)
soc = soc or (lambda u, t: 50.0)
class Hass(rig._Hass):
def __init__(self, *a):
super().__init__(*a)
self.tgt = {u: 0.0 for u in units}
self.act = {u: 0.0 for u in units}
self.reg = {u: 0.0 for u in units}
self.pub_t = {u: 0.0 for u in units}
self.slot = {u: None for u in units}
self.tl = 0.0
self.hn = 0.0
def advance(self, t):
for k in range(int(round((t - self.tl) / 0.1))):
now = self.tl + (k + 1) * 0.1
for u in units:
c = self.cmd[u]
if dead is not None and u == dead[0] and now >= dead[1]:
c = 0.0
d = c - self.tgt[u]
self.tgt[u] += math.copysign(min(abs(d), 80.0), d)
self.act[u] += (self.tgt[u] - self.act[u]) * (
1 - math.exp(-0.1 / lag))
s = math.floor((now - phase.get(u, 0.0) * reg_s) / reg_s)
if s != self.slot[u]:
self.slot[u] = s
self.reg[u] = float(round(self.act[u] + offs.get(u, 0.0)))
self.pub_t[u] = now
self.tl = t
self.hn = rnd.uniform(-noise, noise) if noise else 0.0
def power(self, u):
return self.reg[u]
def grid(self):
h = house(self.t) if callable(house) else house
return float(round(h + self.hn - sum(self.act.values())))
async def set_number(self, e, v):
u = e[-1]
if reject and u in reject and self.t >= reject[u]:
return False
self.cmd[u] = float(v)
return True
opts = options(n, extra)
db = os.path.join(tempfile.mkdtemp(prefix="s103-"), "tariff.db")
async def go():
hass = Hass(units, soc, lambda u, t: False, lambda t: 0.0)
c = Controller(opts, hass, st or store(n), rig._Pub(),
stop=asyncio.Event(), peak_db_path=db)
for i in c.inverters:
i.dev_min, i.dev_max = -5000.0, 5000.0
c.auto = True
real_dt, clock = app_main.datetime, [0.0]
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return rig.BASE + timedelta(seconds=clock[0])
app_main.datetime = _DT
rows = []
try:
t = 0.0
while t < dur:
hass.advance(t)
hass.t = clock[0] = t
for inv in c.inverters:
u = inv.name[-1]
if reject and u in reject and t >= reject[u] \
and inv.rejected_since is None:
inv.rejected_since = rig.BASE + timedelta(seconds=t)
if before is not None:
r = before(c, t)
if asyncio.iscoroutine(r):
await r
await c.read_inputs(now_mono=t)
await c.cycle()
rows.append(dict(
t=t, cmd=tuple(i.target for i in c.inverters),
target=c.target, grid=hass.grid(),
true=tuple(round(hass.act[u]) for u in units),
read=tuple(hass.reg[u] for u in units),
age=tuple(round(t - hass.pub_t[u], 1) for u in units),
phase=tuple(m.phase for m in c.maints),
reason=c.loop_reason))
t += tick
finally:
app_main.datetime = real_dt
return rows, c
return asyncio.run(go())
def end_at(rows):
"""The first cycle A's command left its drain (None if it never did)."""
return next((r["t"] for r in rows if r["t"] >= T
and r["cmd"][0] < 1990.0), None)
def window(rows, span=23.0):
"""The rows from the end cycle to `span` s after it (the settle window,
PLANT_SETTLE_DOWN_S)."""
te = end_at(rows)
return [r for r in rows if te is not None and te <= r["t"] <= te + span]
def swing(rows, i=1, span=23.0):
"""Battery i's command in the settle window after the end cycle, and
its largest swing there: a move that is later undone (up then down, or
down then up), the smaller of its two legs. A steady climb scores 0."""
w = [r["cmd"][i] for r in window(rows, span)][1:]
best = 0.0
for j in range(len(w)):
lo_b = min(w[:j + 1])
hi_b = max(w[:j + 1])
lo_a = min(w[j:])
hi_a = max(w[j:])
best = max(best, min(w[j] - lo_b, w[j] - lo_a),
min(hi_b - w[j], hi_a - w[j]))
return best, w