Merge release/1.0 (0.15.0 + SAFETY-90) into maint-10-local-start-hour

Conflicts: main.py's maintenance import (base added CHARGE, HOLD; branch added
site_zone) - both kept. DOCS maintenance table: base's longer drain-rate row
kept, start hour row at 19 / local.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-10-05 21:51:04 +02:00
co-authored by Claude Opus 5.5
70 changed files with 9513 additions and 294 deletions
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""OPT-11 P4: with no usable solar forecast, is every command byte-identical
to release/1.0 5a4cdc2 (P3, owner option A)?
python3 tools/opt11p4_identity.py # one digest per scenario
tools/opt11p3_identity.py's harness (the REAL Controller on a P1 meter, a
cheap slot, a dear slot, a house over the limit, `arbitrage_enabled`), now
with battery modes ON and each battery's mode set in the store, so P3's run
code runs on every cycle. Each row adds the trade and the roles. Scenarios:
* `ls`, `ch`, `ls_ls`, `ch_ls`: no `pv_forecast_entity` (the day plan never
runs); N=1 and N=2.
* `*_fc_down`: `pv_forecast_entity` set but the sensor reads `unavailable`,
with a ready house profile in the store (the plan falls back); 5a4cdc2
ignores both keys, so the digest must match the scenario without them.
Uses only the API 5a4cdc2 has; test_modes_p4.py part 1 holds this tree to the
digests printed on 5a4cdc2.
"""
import functools
import os
import sys
import tempfile
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import opt11_identity as rig # noqa: E402
import opt11p3_identity as p3 # noqa: E402
import safety79_identity as s79 # noqa: E402
from app.store import unit_key # noqa: E402
FC = {"pv_forecast_entity": "sensor.energy_production_today_remaining",
"pv_forecast_tomorrow_entity": "sensor.energy_production_tomorrow"}
async def _unavailable(self, e):
return {"state": "unavailable", "attributes": {},
"last_updated": "2026-09-20T11:00:00+00:00"}
rig._Hass.state = _unavailable
SCENARIOS = {
"n1_ls": dict(n=1, modes={"inverter-a": "leftover_solar"}),
"n1_ch": dict(n=1, modes={"inverter-a": "cheap_hours"}),
"n2_sf_ls": dict(n=2, modes={"inverter-a": "solar_first",
"inverter-b": "leftover_solar"}),
"n2_ch_ls": dict(n=2, modes={"inverter-a": "cheap_hours",
"inverter-b": "leftover_solar"}),
"n2_ls_ls": dict(n=2, modes={"inverter-a": "leftover_solar",
"inverter-b": "leftover_solar"}),
"n2_sf_ch": dict(n=2, modes={"inverter-a": "solar_first",
"inverter-b": "cheap_hours"}),
}
for _k, _v in list(SCENARIOS.items()):
SCENARIOS[_k + "_fc_down"] = dict(_v, extra=FC)
def run(n, *, modes, extra=None, dur=p3.DUR):
opts = dict(s79.P1, **p3.ARB, battery_modes_enabled=True,
**(extra or {}))
if n == 2:
# A capacity on each battery, so a day plan could size a level;
# small, so the cheap run is the one cheap slot.
opts["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, "capacity_kwh": 1.0}
for u in "ab"]
store = rig._Store()
# At N=1 the one battery is named after the site; the key is per name.
for name, mode in modes.items():
store.data[unit_key(name, "pack_mode")] = mode
if extra:
# A ready house profile (a day, 600 W, 3 kW 17:00-22:00), so only
# the missing forecast stops a plan here. 5a4cdc2 ignores the key.
store.data["house_profile"] = {"2026-09-19": {
str(h): (3000.0 if 17 <= h < 22 else 600.0) for h in range(24)}}
trade = []
def on_cycle(c, t):
a, tick = c.arb, getattr(c, "mode_tick", None)
trade.append((None if a is None else (a.action, a.power_w, a.reason),
None if tick is None else
tuple(sorted(tick.roles.items()))))
db = os.path.join(tempfile.mkdtemp(prefix="p4id-"), "tariff.db")
real = rig.Controller
rig.Controller = functools.partial(real, peak_db_path=db)
try:
rows, c = rig.run(n, extra=opts, dur=dur, load=p3.load, soc=p3.soc,
cut=lambda u, t: False, before=p3._feed,
on_cycle=on_cycle, store=store)
finally:
rig.Controller = real
return [r + (x,) for r, x in zip(rows, trade)], c
def digests() -> dict:
return {k: p3.digest(run(**v)[0]) for k, v in SCENARIOS.items()}
if __name__ == "__main__":
if "--summary" in sys.argv:
for k, v in SCENARIOS.items():
rows = run(**v)[0]
acts = sorted({str(r[-1][0][0]) if r[-1][0] else "None"
for r in rows})
roles = sorted({x for r in rows if r[-1][1]
for x in (y for _n, y in r[-1][1])})
print(f"{k}: {len(rows)} cycles, trade {acts}, roles {roles}")
for k, v in digests().items():
print(f"{k} {v}")
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env python3
"""OPT-11 P4: does the solar forecast pay? Replays recorded days through the
REAL Controller with tools/opt11p3_replay.py's engine (house, batteries,
prices, peak guard), plus a solar forecast sensor in Home Assistant.
python3 tools/opt11p4_replay.py --variant p4_ls --fc recorded
python3 tools/opt11p4_replay.py --variant p3_ls --prices real2026
Runs unchanged on release/1.0 7a00f12 (P2) and 5a4cdc2 (P3): an older tree
ignores `pv_forecast_entity` and never reads the sensor.
HOW THE FORECAST IS MADE (the site's own solar was not recorded for these
days; the 2026-09-24 PV recording is only in the live Home Assistant):
* The site's solar, hour by hour, is rebuilt from the recording: the house's
net use (grid + battery) is house - solar, so solar = max(0, H - net),
where H is the median net use of the night hours (22:00-06:00) over all
days (`--fc perfect` feeds exactly this).
* `recorded`: that solar times the day's error of the real Belgian day-ahead
solar forecast (Elia, via energy-charts): forecast / actual, per day.
* `national`: the Belgian day-ahead forecast itself, scaled to the site
(one factor: the site's total over the national total, all days). It
carries local weather differences too, so it is the harder test.
* `zero`: a forecast of no sun at all (is the gain the forecast's, or only
Leftover solar buying the evening's shortfall?).
* `high`: `national` x 1.5. `missing`: the sensor is configured but reads
`unavailable`. The sensor has Open-Meteo's shape (state = kWh left today,
`wh_period` = Wh per hour); `--shape state` drops `wh_period`
(Forecast.Solar's shape).
MONEY: as opt11p3_replay.py (all-in import (wholesale + 0.12) x 1.06, export
at wholesale), plus `stored_eur`: the change in usable energy (above the
10 % cut-out) from start to end, x 0.95, at the median all-in import price
of the run. `net_after_eur` = net - stored_eur.
"""
import argparse
import bisect
import json
import os
import statistics
import sys
import time
from datetime import datetime, timedelta, timezone
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 opt11p3_replay as rp # noqa: E402
EC = os.path.join(HERE, "scenarios",
"energy-charts-be-solar-price-2026-09-01_2026-09-17.json")
FC_ENTITY = "sensor.energy_production_today_remaining"
FC_TOMORROW = "sensor.energy_production_tomorrow"
rp.VARIANTS.update({
"p4_ls": (True, False, {"a": (True, "solar_first", False),
"b": (True, "leftover_solar", False)}),
"p4_ch": (True, False, {"a": (True, "solar_first", False),
"b": (True, "cheap_hours", False)}),
})
LOCAL = timezone(timedelta(hours=2)) # CEST for every replayed day
def ec():
return json.load(open(EC))
def real2026_points():
"""The real day-ahead prices of the replayed days (15-minute)."""
d = ec()["price"]
return [(datetime.fromtimestamp(t, timezone.utc), 900, p / 1000.0)
for t, p in zip(d["unix_seconds"], d["eur_mwh"]) if p is not None]
def site_solar(house):
"""{hour start (UTC epoch): site solar kWh}, and H (W)."""
by_h = {}
for t, w in house:
by_h.setdefault(int(t // 3600) * 3600, []).append(w)
net = {h: statistics.fmean(v) for h, v in by_h.items() if len(v) > 20}
night = [w for h, w in net.items()
if datetime.fromtimestamp(h, LOCAL).hour in
(22, 23, 0, 1, 2, 3, 4, 5)]
base = statistics.median(night)
return {h: max(0.0, base - w) / 1000.0 for h, w in net.items()}, base
def national():
s = ec()["solar"]
act = dict(zip(s["unix_seconds"], s["actual_mw"]))
fc = dict(zip(s["dayahead_unix_seconds"], s["dayahead_mw"]))
return act, fc
def forecast_table(kind, house, days):
"""{hour start epoch: forecast kWh}, and a report of its accuracy."""
pv, base = site_solar(house)
act, fc = national()
day_of = {h: datetime.fromtimestamp(h, LOCAL).date() for h in pv}
if kind in ("perfect", "missing"):
out = dict(pv)
elif kind == "zero":
out = {h: 0.0 for h in pv}
elif kind == "recorded":
ratio = {}
for d in set(day_of.values()):
hs = [h for h in pv if day_of[h] == d]
a = sum(act.get(h) or 0.0 for h in hs)
f = sum(fc.get(h) or 0.0 for h in hs)
ratio[d] = f / a if a > 0 else 1.0
out = {h: pv[h] * ratio[day_of[h]] for h in pv}
else:
k = sum(pv.values()) / max(1e-9, sum(act.get(h) or 0.0 for h in pv))
out = {h: (fc.get(h) or 0.0) * k * (1.5 if kind == "high" else 1.0)
for h in pv}
rep = []
for d in sorted(set(day_of.values())):
a = sum(pv[h] for h in pv if day_of[h] == d)
f = sum(out[h] for h in out if day_of[h] == d)
rep.append((d.isoformat(), round(a, 1), round(f, 1)))
return out, base, rep
def install_sensor(table, kind, shape):
"""Give opt11p3_replay's Hass a `state()` for the forecast sensors."""
hours = sorted(table)
t0 = rp_start[0]
def day_bounds(now):
loc = now.astimezone(LOCAL)
d0 = loc.replace(hour=0, minute=0, second=0, microsecond=0)
return d0, d0 + timedelta(days=1)
def sensor(now, tomorrow):
d0, d1 = day_bounds(now)
if tomorrow:
d0, d1 = d1, d1 + timedelta(days=1)
a, b = d0.timestamp(), d1.timestamp()
i, j = bisect.bisect_left(hours, a), bisect.bisect_left(hours, b)
rows = [(h, table[h]) for h in hours[i:j]]
if not rows:
return None
n = now.timestamp()
left = 0.0
for h, kwh in rows:
if tomorrow or h >= n:
left += kwh
elif h + 3600 > n:
left += kwh * (h + 3600 - n) / 3600.0
attrs = {"unit_of_measurement": "kWh"}
if shape == "periods":
attrs["wh_period"] = {
datetime.fromtimestamp(h, timezone.utc).isoformat():
round(kwh * 1000.0, 1) for h, kwh in rows}
# The forecast refreshes every hour.
rep = datetime.fromtimestamp(int(n // 3600) * 3600, timezone.utc)
return {"state": f"{left:.3f}", "attributes": attrs,
"last_updated": rep.isoformat(),
"last_reported": rep.isoformat()}
async def state(self, e):
now = t0 + timedelta(seconds=self.t)
if kind == "missing":
return {"state": "unavailable", "attributes": {},
"last_updated": now.isoformat()}
if e == FC_ENTITY:
return sensor(now, False)
if e == FC_TOMORROW:
return sensor(now, True)
return None
rp.Hass.state = state
rp_start = [None]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--variant", default="p4_ls")
ap.add_argument("--fc", default="recorded",
choices=("none", "recorded", "national", "perfect",
"high", "missing", "zero"))
ap.add_argument("--prices", default="epex2025",
choices=("epex2025", "real2026"))
ap.add_argument("--shape", default="periods",
choices=("periods", "state"))
ap.add_argument("--first", type=int, default=2)
ap.add_argument("--days", type=int, default=16)
ap.add_argument("--trust", type=int, default=None)
ap.add_argument("--report", action="store_true",
help="print the forecast's accuracy and exit")
a = ap.parse_args()
days = [f"2026-09-{d:02d}" for d in range(a.first, a.first + a.days)]
house = rp.load_house(days)
pts = rp.epex_points() if a.prices == "epex2025" else real2026_points()
start = datetime.fromisoformat(days[0] + "T00:00:00+02:00") \
.astimezone(timezone.utc)
rp_start[0] = start
extra = {}
if a.fc != "none":
table, base, rep = forecast_table(a.fc, house, days)
if a.report:
print(json.dumps({"fc": a.fc, "night_base_w": round(base),
"days": rep}))
return
install_sensor(table, a.fc, a.shape)
extra = {"pv_forecast_entity": FC_ENTITY,
"pv_forecast_tomorrow_entity": FC_TOMORROW}
if a.trust is not None:
extra["solar_trust_pct"] = a.trust
socs = {}
def check(c, hass, now, pr):
if "start" not in socs:
socs["start"] = {u: p.soc for u, p in hass.plants.items()}
socs["end"] = {u: p.soc for u, p in hass.plants.items()}
dp = getattr(c, "day_plan", None)
if dp is not None:
socs.setdefault("verdicts", {})[
now.astimezone(LOCAL).date().isoformat()] = dp.verdict
t0 = time.time()
stats, _c = rp.run(a.variant, house, pts, start, 86400.0 * len(days),
extra=extra, check=check)
out = rp.summary(stats)
prices = sorted(rp.all_in(p) for _s, _n, p in pts
if start <= _s < start + timedelta(days=len(days)))
med = prices[len(prices) // 2]
def usable(soc):
return sum(max(0.0, v - 10.0) / 100.0 * rp.CAP_KWH
for v in soc.values())
d_kwh = usable(socs["end"]) - usable(socs["start"])
out["stored_change_kwh"] = round(d_kwh, 2)
out["stored_eur"] = round(d_kwh * 0.95 * med, 2)
out["net_after_eur"] = round(out["net_eur"] - out["stored_eur"], 2)
out["end_soc"] = {u: round(v, 1) for u, v in socs["end"].items()}
out.update(variant=a.variant, fc=a.fc, prices=a.prices, shape=a.shape,
first=days[0], days=len(days),
verdicts=socs.get("verdicts", {}),
wall_s=round(time.time() - t0, 1))
print(json.dumps(out))
if __name__ == "__main__":
main()
+78 -2
View File
@@ -13,11 +13,18 @@ no watt still moves the digest. The `_modes` scenarios run battery modes on,
Cheap hours only, over opt11p3_identity's prices. Uses only the API release/1.0 5a4cdc2 has, so the same file runs on
both trees; test_safety78.py part 5 holds this tree to the digests printed
there.
SAFETY-83 adds the owner's settings (limit 3500, drain 2500, margin 50) with a
house that goes over drain + aim and back, and N=2 runs of both. At N=1 the
peak floor now outranks the drain where it asks for more, so the N=1 digests
moved; `--dump FILE` on the base tree and `--diff FILE` here list every
changed cycle and whether the peak guard wanted more than the drain on it.
"""
import functools
import hashlib
import os
import pickle
import sys
import tempfile
@@ -47,14 +54,25 @@ def store_cheap(n):
return s
def load_owner(t):
"""Below the 2500 W drain, above it, over drain + aim (5950 W), back."""
return (1200.0, 3000.0, 6500.0, 3000.0, -1500.0)[min(int(t // 480), 4)]
OWNER = {"peak_cap_w": 3500, "peak_margin_w": 50,
"maintenance_discharge_w": 2500}
SCENARIOS = {
"n1_drain": dict(n=1),
"n1_drain_modes": dict(n=1, modes=True),
"n1_drain_owner": dict(n=1, load=load_owner, extra=OWNER),
"n2_drain": dict(n=2),
"n2_drain_owner": dict(n=2, load=load_owner, extra=OWNER),
}
def run(n, *, modes=False, dur=DUR):
extra = dict(s79.P1, **DRAIN)
def run(n, *, modes=False, dur=DUR, load=load, extra=None):
extra = dict(s79.P1, **DRAIN, **(extra or {}))
if modes:
extra.update(p3.ARB, battery_modes_enabled=True)
seen, extra_rows = [], []
@@ -99,7 +117,65 @@ def digests() -> dict:
return {k: digest(run(**v)[0]) for k, v in SCENARIOS.items()}
FIELDS = ("t", "target", "commands", "loop reason", "integrator",
"saturation", "why", "allocation")
XFIELDS = ("claims", "guard", "roles", "trade", "phase", "log length",
"newest log line")
def wanted_more(row) -> bool:
"""Did the peak guard ask for more than the drain rate on this cycle?"""
claims, guard = row[-1][0] or (), row[-1][1]
rate = [c[3] for c in claims if c[0] == "maintenance" and c[2] == "set"]
return (bool(rate) and guard is not None and bool(guard[0])
and guard[1] is not None and guard[1] > max(rate))
def diff(base: dict, now: dict) -> dict:
"""Per scenario: (cycles, changed, changed where the guard wanted more on
either tree, changed cycles whose commands differ without that)."""
out = {}
for k, rows in now.items():
old = base.get(k)
if old is None:
continue
ch = [i for i, (a, b) in enumerate(zip(old, rows)) if a != b]
ch += list(range(min(len(old), len(rows)), max(len(old), len(rows))))
more = [i for i in ch if i < min(len(old), len(rows))
and (wanted_more(old[i]) or wanted_more(rows[i]))]
cmd = [i for i in ch if i not in more
and (i >= min(len(old), len(rows))
or old[i][1:3] != rows[i][1:3])]
fields = set()
for i in set(ch) - set(more) - set(cmd):
fields |= {FIELDS[j] for j in range(8) if old[i][j] != rows[i][j]}
fields |= {XFIELDS[j] for j in range(7)
if old[i][8][j] != rows[i][8][j]}
out[k] = (len(rows), ch, more, cmd, sorted(fields))
return out
def all_rows() -> dict:
return {k: run(**v)[0] for k, v in SCENARIOS.items()}
if __name__ == "__main__":
if "--dump" in sys.argv:
with open(sys.argv[sys.argv.index("--dump") + 1], "wb") as f:
pickle.dump(all_rows(), f)
sys.exit(0)
if "--diff" in sys.argv:
with open(sys.argv[sys.argv.index("--diff") + 1], "rb") as f:
base = pickle.load(f)
for k, (n, ch, more, cmd, fields) in diff(base, all_rows()).items():
rest = sorted(set(ch) - set(more))
print(f"{k}: {n} cycles, {len(ch)} changed, {len(more)} with the "
f"guard above the drain, {len(rest)} others "
f"({len(cmd)} with a different target or command"
+ (f"; the rest differ only in {', '.join(fields)}"
if fields else "") + ")"
+ (f", first changed t={ch[0] * rig.TICK:.0f} s" if ch else ""))
sys.exit(0)
if "--summary" in sys.argv:
for k, v in SCENARIOS.items():
rows = run(**v)[0]
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""SAFETY-84: at one inverter, is a maintenance charge byte-identical to before?
python3 tools/safety84_identity.py # prints one digest per scenario
safety78_identity's harness (the REAL Controller on a P1 meter, every claim
hashed) with the battery in its maintenance CHARGE phase from the first cycle,
on the owner's settings (limit 3500, margin 50, charge 2000 W). The house steps
below the charge rate, at it, under the aim, over it and into a solar surplus,
in a cheap window, outside one, and under a month peak banked at 4000 W. Uses
only the API release/1.0 9d5b7d1 has, so the same file runs on both trees;
test_safety84.py holds this tree to the digests printed there (N=1) and to the
new N=2 ones.
"""
import functools
import hashlib
import os
import sys
import tempfile
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import opt11_identity as rig # noqa: E402
import safety79_identity as s79 # noqa: E402
import app.main as m # noqa: E402
from app import tariff # noqa: E402
DUR = 2400.0
OPT = {"peak_cap_w": 3500, "peak_margin_w": 50, "maintenance_enabled": True,
"maintenance_start_hour": 11, "maintenance_charge_w": 2000,
"price_now_entity": "sensor.pnow", "price_avg_entity": "sensor.pavg"}
def load(t):
"""Below the 2000 W charge, at it, under the aim, over it, a surplus."""
return (800.0, 2000.0, 3000.0, 5000.0, -1500.0)[min(int(t // 480), 4)]
def feed(cheap):
"""s79's P1 feed plus a price now under (cheap) or over the average."""
now = 0.05 if cheap else 0.30
def before(c, t):
if not getattr(c.hass, "_s84", False):
read = c.hass.number
async def number(e, invert=False):
if e == "sensor.pnow":
return now
if e == "sensor.pavg":
return 0.20
return await read(e, invert)
c.hass.number, c.hass._s84 = number, True
s79._feed(c, t)
return before
SCENARIOS = {
"n1_charge": dict(n=1),
"n1_charge_dear": dict(n=1, cheap=False),
"n1_charge_banked": dict(n=1, month_peak=4000.0),
"n2_charge": dict(n=2),
"n2_charge_dear": dict(n=2, cheap=False),
"n2_charge_banked": dict(n=2, month_peak=4000.0),
}
def run(n, *, cheap=True, month_peak=None, dur=DUR, load=load, soc=None,
before=None, cut=None, extra=None, store=None):
"""Rows (opt11_identity's fields plus every claim, the guard and the
phases) and the controller. `month_peak` banks that peak this month."""
seen, extra_rows = [], []
real_resolve = m.resolve
def spy(claims, **kw):
seen.append(tuple((c.source, c.priority, c.kind, c.value, c.lo, c.hi,
c.reason, c.actuator) for c in claims))
return real_resolve(claims, **kw)
def on_cycle(c, t):
g = c.peak_guard
extra_rows.append((
seen[-1] if seen else None,
None if g is None else (g.engaged, g.floor_w, g.reason),
c._maint_shape_suspended, [mm.phase for mm in c.maints],
len(c.events), c.events[0] if c.events else None))
db = os.path.join(tempfile.mkdtemp(prefix="s84-"), "tariff.db")
if month_peak is not None:
tariff.PeakStore(db)._db.execute(
"INSERT INTO monthly_peak VALUES (?, ?, ?, NULL)",
(tariff.month_key(rig.BASE), month_peak,
"2026-09-02T10:00:00+00:00")).connection.commit()
real = rig.Controller
rig.Controller = functools.partial(real, peak_db_path=db)
m.resolve = spy
try:
rows, c = rig.run(n, extra=dict(s79.P1, **OPT, **(extra or {})),
dur=dur, load=load, soc=soc or s79.soc,
cut=cut or (lambda u, t: False),
before=before or feed(cheap), on_cycle=on_cycle,
store=store or s79.store_charging(n))
finally:
rig.Controller = real
m.resolve = real_resolve
return [r + (x,) for r, x in zip(rows, extra_rows)], c
def digest(rows) -> str:
return hashlib.sha256(repr(rows).encode()).hexdigest()
def digests() -> dict:
return {k: digest(run(**v)[0]) for k, v in SCENARIOS.items()}
if __name__ == "__main__":
for k, v in digests().items():
print(f"{k} {v}")
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""SAFETY-87: at one inverter, is the peak guard byte-identical to before?
python3 tools/safety87_identity.py # prints one digest per scenario
safety84_identity's harness (the REAL Controller on a P1 meter, every claim
and the guard's decision hashed) with the battery under, crossing and above
the peak reserve, the house over the aim, in a maintenance charge and HOLD,
and under a month peak banked at 4000 W. Uses only the API release/1.0
b8ddfd7 has, so the same file runs on both trees; test_safety87.py holds this
tree to the digests printed there.
"""
import os
import sys
from datetime import timedelta
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import opt11_identity as rig # noqa: E402
import safety84_identity as s84 # noqa: E402
DUR = 1800.0
def crossing(u, t):
"""30 % falling 1 % a minute to 12 %: crosses the 20 % reserve."""
return max(12.0, 30.0 - t / 60.0)
def store(phase=None):
"""In `phase` from the first cycle, or idle with a cycle done yesterday."""
s = rig._Store()
if phase is None:
s.data["last_completed"] = (rig.BASE - timedelta(days=1)).isoformat()
else:
s.data["phase"], s.data["phase_started"] = phase, rig.BASE.isoformat()
return s
SCENARIOS = {
"n1_under": dict(soc=lambda u, t: 15.0, load=lambda t: 5500.0),
"n1_crossing": dict(soc=crossing, load=lambda t: 5000.0),
"n1_above": dict(soc=lambda u, t: 60.0, load=lambda t: 5500.0),
"n1_under_banked": dict(soc=lambda u, t: 15.0, load=lambda t: 6000.0,
month_peak=4000.0),
"n1_charge_low": dict(soc=lambda u, t: 11.0, load=lambda t: 5000.0,
store="charge"),
"n1_hold": dict(soc=lambda u, t: 99.0, load=lambda t: 5500.0,
store="hold", cheap=False,
extra={"maintenance_hold_min": 600}),
}
def digests() -> dict:
out = {}
for k, v in SCENARIOS.items():
v = dict(v)
st = store(v.pop("store", None))
out[k] = s84.digest(s84.run(1, dur=DUR, store=st, **v)[0])
return out
if __name__ == "__main__":
for k, v in digests().items():
print(f"{k} {v}")
+271
View File
@@ -0,0 +1,271 @@
#!/usr/bin/env python3
"""SAFETY-94: the REAL Controller on a lagging battery whose reading is late.
python3 tools/safety94_rig.py # prints every scenario's counts
Each battery ramps at 800 W/s, then follows a first-order lag (`tau`, 10 s
as on the SAFETY-85 rig). Its reading (register 0x0518) is published every
10 s, at a different phase per unit, and repeats its value between publishes,
as the live boards do. B reads 100 W below what it gives (its standby draw:
-100 W at idle, live 2026-10-03). The meter updates every `tick` s; the add-on
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 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
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import app.main as m # noqa: E402
from app.main import Controller # noqa: E402
BASE = datetime(2026, 10, 3, 10, 50, tzinfo=timezone.utc) # drain from 600 s
PUBLISH_S = 10.0
PHASE = {"a": 6.0, "b": 1.6} # live: A publishes at :x6, B at :x1.6
OFFSET = {"a": 0.0, "b": -100.0} # B's standby draw, live 2026-10-03
CUT_W = -4.0
LIVE = {"gain": 0.6, "slew_w": 400, "target_grid_w": -10, "deadband_w": 15,
"step_w": 10, "heartbeat_s": 10, "stale_input_s": 15,
"auto_start": True, "meter_entity": "sensor.grid",
"meter_source": "off"}
MAINT = {"maintenance_enabled": True, "maintenance_start_hour": 11,
"maintenance_discharge_w": 2500, "maintenance_hold_min": 30}
class _Store:
def __init__(self, data=None):
from app.store import DEFAULTS
self.data = dict(DEFAULTS, **(data or {}))
def set(self, k, v):
self.data[k] = v
def get_time(self, k):
v = self.data.get(k)
return datetime.fromisoformat(v) if v else None
def set_time(self, k, when):
self.data[k] = when.isoformat() if when else None
class _Pub:
enabled = False
def publish(self, *a, **k):
pass
def close(self):
pass
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, 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}
self.ramp = {u: 0.0 for u in units}
self.real = {u: 0.0 for u in units}
self.reg = {u: round(OFFSET[u]) for u in units}
self.grid_v = float(load(0.0))
self.t = 0.0
def advance(self, t):
"""Integrate to `t` in 0.1 s steps; publish and sample on schedule."""
while self.t < t - 1e-9:
self.t = round(self.t + 0.1, 6)
for u in self.units:
want = 0.0 if self.cut(u, self.t) else self.cmd[u]
d = want - self.ramp[u]
self.ramp[u] += math.copysign(min(abs(d), 80.0), d)
self.real[u] += ((self.ramp[u] - self.real[u])
* (1 - math.exp(-0.1 / self.tau)))
frozen = (self.freeze_at is not None
and self.freeze_at(u) is not None
and self.t >= self.freeze_at(u))
ph = (self.t - PHASE[u] % self.publish_s) / self.publish_s
if not frozen and abs(ph - round(ph)) < 1e-6:
# `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] = (
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())))
class _Hass:
def __init__(self, plant):
self.p = plant
async def limits(self, e):
return (-5000.0, 5000.0, 1.0)
async def call(self, *a):
return True
async def number(self, e, invert=False):
if e == "sensor.grid":
return self.p.grid_v
u = e[-1]
if e.startswith("sensor.soc"):
return self.p.soc_fn(u, self.p.t)
if e.startswith("sensor.batt"):
return self.p.reg[u]
return None
async def set_number(self, e, v):
self.p.cmd[e[-1]] = float(v)
return True
def options(n, extra=None):
o = dict(LIVE)
if n == 1:
o.update({"setpoint_entity": "number.sp_a",
"soc_entity": "sensor.soc_a", "batt_entity": "sensor.batt_a",
"max_w": 4000, "bms_soc_floor": 10})
else:
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 "ab"[:n]]
o.update(extra or {})
return o
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, 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. `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, noise, seed)
async def go():
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
c.auto = True
real_dt = m.datetime
clock = [0.0]
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return BASE + timedelta(seconds=clock[0])
m.datetime = _DT
out = {"stale": [], "refused": [], "rows": [], "c": c}
was = {i.name: False for i in c.inverters}
was_ref = {i.name: 0 for i in c.inverters}
was_site = 0
last_grid, last_cycle = None, None
try:
t = 0.0
while t < dur:
plant.advance(t)
clock[0] = t
await c.read_inputs(now_mono=t)
for i in c.inverters:
if i.batt_fresh.stale and not was[i.name]:
out["stale"].append((t, i.name))
was[i.name] = i.batt_fresh.stale
cycled = (c.grid != last_grid or last_cycle is None
or t - last_cycle >= floor_s)
if cycled:
last_grid, last_cycle = c.grid, t
await c.cycle()
# A refusal can start on a read (a gate trip) or in a cycle.
for i in c.inverters:
if i.refused and not was_ref[i.name]:
out["refused"].append((t, i.name))
was_ref[i.name] = i.refused
if c.batt_refused and not was_site:
out["refused"].append((t, "site"))
was_site = c.batt_refused
if cycled:
out["rows"].append((
t, plant.grid_v, dict(plant.cmd),
{u: round(v) for u, v in plant.real.items()},
dict(plant.reg), [mm.phase for mm in c.maints]))
t += 1.0
finally:
m.datetime = real_dt
return out
return asyncio.run(go())
# --- the scenarios ---------------------------------------------------------
def rest_then(step_at, w):
"""A house at 0 W (the batteries rest) until `step_at`, then `w`."""
return lambda t: w if t >= step_at else 0.0
def reversal(at_s, charge_w, house_w):
"""Solar surplus (the batteries charge), then a house load from `at_s`."""
return lambda t: house_w if t >= at_s else charge_w
def drain_soc(floor_at):
"""A drains (maintenance) and reaches its floor at `floor_at`; B at 50 %."""
def soc(u, t):
if u != "a":
return 50.0
return 50.0 if t < floor_at else 10.0
return soc
SCENARIOS = {
# first command after a long rest (the live 17:39 shape)
"rest_step": dict(load=rest_then(600.0, 2400.0), dur=900.0),
# maintenance drain starts after a rest (the live 19:00 shape)
"drain_start": dict(load=lambda t: 300.0, dur=900.0,
extra=MAINT, soc=drain_soc(1e9)),
# A's drain ends and its charge starts (the live 21:21 shape)
"drain_charge": dict(load=lambda t: 300.0, dur=1200.0, extra=MAINT,
soc=drain_soc(900.0)),
# a charge reverses into a discharge through the lag (SAFETY-94)
"reversal": dict(load=reversal(300.0, -2500.0, 800.0), dur=600.0),
}
def counts(n, name, **kw):
sc = dict(SCENARIOS[name], **kw)
r = run(n, **sc)
return len(r["stale"]), len(r["refused"]), r
if __name__ == "__main__":
for n in (1, 2):
for name in SCENARIOS:
for tick in (1.0, 2.0, 5.0):
for pub, dith in ((10.0, False), (1.0, True)):
s, f, r = counts(n, name, tick=tick, publish_s=pub,
dither=dith)
print(f"N={n} {name:13s} tick {tick:.0f} s publish "
f"{pub:2.0f} s{' dither' if dith else ' '}: "
f"stale {s} {r['stale'][:2]}, refusals {f} "
f"{r['refused'][:2]}")