Files
adminandClaude Opus 5.5 138e45b53f
checks / unit (pull_request) Failing after 35m9s
infra-pin / fresh (pull_request) Successful in 2s
SAFETY-103: no swing on old battery readings after a drain ends
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>
2026-10-08 02:25:55 +02:00

196 lines
7.4 KiB
Python

"""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