Files
adminandClaude Opus 5.5 fae6681e19
checks / unit (pull_request) Successful in 18m49s
infra-pin / fresh (pull_request) Successful in 1s
OPT-15: price-run fixes (quarter edge, charge-end overshoot, refusal probe)
F1: with battery modes on, the peak guard projects the quarter `now` is in
even before its first P1 sample (PeakTracker.project(roll=True)), and only
a measured stop latches the period. The edge tick no longer reads "no
seconds left", so a charge is not stopped and latched at a quarter edge.

F2: after a run edge the loop is held at E until the meter shows the step
(EDGE_HOLD_S, longer while the battery still moves). The replay gained a
lag model (dead time, reading age, P1 debounce, 1 s cycles) that shows
TEST-23's overshoot without the hold.

F3: a charge refuser's SAFETY-73 probe inside a cheap run is a charger
pinned at PROBE_W, the others rest, so the probe actually runs.

Modes off stays byte-identical to 5a4cdc2 (new late-P1 identity scenarios).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:22:32 +02:00

470 lines
18 KiB
Python

#!/usr/bin/env python3
"""OPT-11 P3: replay days of recorded house load and real prices through the
REAL Controller (read_inputs + cycle, the arbiter, the allocator, the peak
guard on a P1 meter), with two modelled batteries, and report energy, cycles
and money, plus the P3 invariants on every tick.
python3 tools/opt11p3_replay.py --days 16 --variant p3_ls
python3 tools/opt11p3_replay.py --captured # 2026-09-09/10
The same file runs on release/1.0 7a00f12 (the P2 baseline) and on this
branch: it uses only the Controller API 7a00f12 has, and sets the batteries'
choices through the store keys P1 defined (`pack_sell` was reserved there).
REAL: the house (grid + battery power recorded 2026-09-02..17 on the live
install, scenarios/loop-2026-09-02_2026-09-17: the house's own net demand,
solar included), the prices (EPEX BE day-ahead, hourly, same weekday one year
earlier (364 days), or the captured quarter-hourly energy-charts bodies for
2026-09-09/10), the decision code.
MODELLED: the batteries (a first-order lag, 3 s, 800 W/s ramp, SoC
integrated, 95 % each way, a reading that jitters by a few watts, and
optionally a device limit or a refusal to charge). By default the readings
are instantaneous and the loop runs every 5 s, which hides what the loop
does on readings a second or two old; `lag=Lag(...)` (OPT-15) adds a command
dead time, a meter and battery reading age, the P1 sample's debounce and a
1 s cycle, as on TEST-23. The all-in price (the EPEX file's own stated
assumption: (wholesale + 0.02 + 0.10) x 1.06), export paid at the wholesale
price, tomorrow's prices known from 13:00 local. The absolute money is
therefore an estimate; the comparison between variants is the point.
"""
import argparse
import asyncio
import gzip
import json
import math
import os
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)
import app.main as m # noqa: E402
from app.main import Controller # noqa: E402
from app.p1 import make_sample, split_signed # noqa: E402
from app.price import PricePoint, PriceProduct, PriceRead, brussels # noqa
from app.static_tariff import TariffPlan # noqa: E402
from app.store import unit_key # noqa: E402
LOOP = os.path.join(HERE, "scenarios", "loop-2026-09-02_2026-09-17")
EPEX = os.path.join(HERE, "scenarios",
"epex-be-dayahead-2025-09-01_2026-09-01.json")
QH = os.path.join(HERE, "scenarios",
"energy-charts-be-price-2026-09-09_2026-09-10-twoday.json")
TICK = 5.0
CAP_KWH = 10.0
RATING = 2500.0
PEAK_CAP = 3500
def all_in(wholesale_kwh):
return round((wholesale_kwh + 0.02 + 0.10) * 1.06, 6)
def load_house(days):
"""[(t_utc_epoch, house_w)] from the recording: grid + battery power."""
out = []
for d in days:
def rd(kind):
f = os.path.join(LOOP, f"{d}-{kind}.jsonl.gz")
rows = []
with gzip.open(f, "rt") as fh:
for line in fh:
r = json.loads(line)
try:
v = float(r["v"])
except ValueError:
continue
rows.append((datetime.fromisoformat(r["t"]).timestamp(),
v))
return rows
g, b = rd("grid"), rd("batt")
j = 0
for t, gw in g:
while j + 1 < len(b) and b[j + 1][0] <= t:
j += 1
bw = b[j][1] if b and b[j][0] <= t else 0.0
out.append((t, gw + bw))
out.sort()
return out
def epex_points(shift_days=364):
d = json.load(open(EPEX))["prices"]
pts = []
for r in d:
s = datetime.fromisoformat(r["start"]) + timedelta(days=shift_days)
pts.append((s.astimezone(timezone.utc), 3600,
float(r["eur_per_kwh"])))
return pts
def qh_points():
d = json.load(open(QH))
return [(datetime.fromtimestamp(t, timezone.utc), 900, p / 1000.0)
for t, p in zip(d["unix_seconds"], d["price"])
if p is not None]
class Lag:
"""OPT-15: how old what the add-on reads is, and how often it acts.
`dead_s` a command takes effect that late; `meter_s` / `batt_s` the age
of the grid and battery readings; `p1_s` a P1 sample is built that long
after the reading (the HA source's debounce), so a cycle just after a
quarter boundary can still see the old block; `tick` the cycle period;
`tau_s` / `ramp_w_s` the battery's own lag (TEST-23's plant: `dead_s` 1,
no lag or ramp)."""
def __init__(self, dead_s=1.0, meter_s=1.0, batt_s=1.0, p1_s=0.35,
tick=1.0, sub=0.25, tau_s=3.0, ramp_w_s=800.0):
self.dead_s, self.meter_s, self.batt_s = dead_s, meter_s, batt_s
self.p1_s, self.tick, self.sub = p1_s, tick, sub
self.tau_s, self.ramp_w_s = tau_s, ramp_w_s
class Plant:
def __init__(self, soc, refuse_charge=False):
self.cmd, self.p, self.soc = 0.0, 0.0, float(soc)
self.refuse_charge = refuse_charge
self.queue = [] # (t, command) not yet in effect
self.tau, self.ramp = 3.0, 800.0
def step(self, dt, floor):
c = self.cmd
if c > 0 and self.soc <= floor:
c = 0.0
if c < 0 and (self.soc >= 100.0 or self.refuse_charge):
c = 0.0
want = self.p + (c - self.p) * (1 - math.exp(-dt / self.tau))
dp = max(-self.ramp * dt, min(self.ramp * dt, want - self.p))
self.p += dp
e = self.p * dt / 3600.0 / 1000.0 # kWh, + = out
eff = 1 / 0.95 if e > 0 else 0.95
self.soc -= e * eff / CAP_KWH * 100.0
self.soc = max(0.0, min(100.0, self.soc))
class Hass:
def __init__(self, units, house_at, soc0=None, refuse=(), limits=None,
lag=None):
self.units = units
self.lim = limits or {}
self.plants = {u: Plant((soc0 or {}).get(u, 50.0), u in refuse)
for u in units}
self.house_at = house_at
self.t = 0.0
self.lag = lag
self.hist = [] # (t, grid, {unit: p}), with `lag`
for p in self.plants.values():
if lag is not None:
p.tau, p.ramp = lag.tau_s, lag.ramp_w_s
def grid(self):
return self.house_at(self.t) - sum(p.p for p in self.plants.values())
def _past(self, age):
"""(grid, {unit: p}) as they were `age` seconds ago."""
want = self.t - age
for t, g, ps in reversed(self.hist):
if t <= want + 1e-9:
return g, ps
return self.hist[0][1:] if self.hist else (self.grid(), {
u: p.p for u, p in self.plants.items()})
def advance(self, dt, floor):
"""Step the plant `dt` seconds (with `lag`: in `sub` steps, commands
applied after `dead_s`, history kept for the lagged readings)."""
lag = self.lag
if lag is None:
for p in self.plants.values():
p.step(dt, floor)
return
end = self.t + dt
while self.t < end - 1e-9:
h = min(lag.sub, end - self.t)
for p in self.plants.values():
while p.queue and p.queue[0][0] <= self.t + 1e-9:
p.cmd = p.queue.pop(0)[1]
p.step(h, floor)
self.t += h
self.hist.append((self.t, self.grid(),
{u: p.p for u, p in self.plants.items()}))
del self.hist[:-200]
async def limits(self, e):
lo, hi = self.lim.get(e[-1], (-5000.0, 5000.0))
return (lo, hi, 1.0)
async def call(self, *a):
return True
async def number(self, e, invert=False):
if e == "sensor.grid":
return self.grid() if self.lag is None else \
self._past(self.lag.meter_s)[0]
u = e[-1]
if e.startswith("sensor.soc"):
return round(self.plants[u].soc, 1)
if e.startswith("sensor.batt"):
# A real reading jitters by a few watts; a dead-flat one is a
# stale register to the add-on (SAFETY-37), which is not modelled.
j = (ord(u) * 31 + int(self.t)) % 7 - 3
p = self.plants[u].p if self.lag is None else \
self._past(self.lag.batt_s)[1][u]
return float(round(p) + j)
return None
async def set_number(self, e, v):
p = self.plants[e[-1]]
if self.lag is None:
p.cmd = float(v)
else:
p.queue.append((self.t + self.lag.dead_s, float(v)))
return True
class _Store:
def __init__(self):
from app.store import DEFAULTS
self.data = dict(DEFAULTS)
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
VARIANTS = {
# (modes on, arbitrage_enabled, {unit: (use, mode, sell)})
"modes_off": (False, False, {}),
"arb_only": (False, True, {}),
"p2_sf_ls": (True, False, {"a": (True, "solar_first", False),
"b": (True, "leftover_solar", False)}),
"p2_arb": (True, True, {"a": (True, "solar_first", False),
"b": (True, "leftover_solar", False)}),
"p3_ls": (True, False, {"a": (True, "solar_first", False),
"b": (True, "leftover_solar", False)}),
"p3_ch_sell": (True, False, {"a": (True, "solar_first", False),
"b": (True, "cheap_hours", True)}),
"p3_both_ch": (True, False, {"a": (True, "cheap_hours", False),
"b": (True, "cheap_hours", True)}),
}
def options(modes, arb, n=2):
o = {"meter_entity": "sensor.grid", "meter_source": "ha_signed",
"p1_net_entity": "sensor.net", "meter_phases": 1,
"peak_cap_w": PEAK_CAP, "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,
"battery_modes_enabled": modes, "arbitrage_enabled": arb,
"cycle_cost_threshold_eur": 0.10,
"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": int(RATING), "bms_soc_floor": 10,
"capacity_kwh": CAP_KWH} for u in "ab"[:n]]}
return o
def plan_at(now, pts):
"""The firm day-ahead points a site holds at `now`: today, and tomorrow
once published (13:00 local)."""
loc = brussels(now)
day0 = loc.replace(hour=0, minute=0, second=0, microsecond=0)
end = day0 + timedelta(days=2 if loc.hour >= 13 else 1)
sel = [PricePoint(start=brussels(s), eur_per_kwh=all_in(p),
provisional=False)
for s, span, p in pts if day0 <= s < end]
if len(sel) < 2:
return None
return TariffPlan(PriceRead.series(sel, PriceProduct.DAY_AHEAD,
"generic_dayahead", now), None,
"provider")
def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2,
soc0=None, refuse=(), extra=None, choice=None, patch=None,
limits=None, lag=None):
modes, arb, var_choice = VARIANTS[variant]
choice = var_choice if choice is None else choice
units = tuple("ab"[:n])
import bisect
ts = [h[0] for h in house]
def house_at(t):
i = bisect.bisect_right(ts, start.timestamp() + t) - 1
return house[max(0, i)][1]
wholesale = {s: (span, p) for s, span, p in pts}
starts = sorted(wholesale)
def price_at(now):
i = bisect.bisect_right(starts, now) - 1
if i < 0:
return None
s = starts[i]
span, p = wholesale[s]
return p if now < s + timedelta(seconds=span) else None
store = _Store()
for u, (use, mode, sell) in choice.items():
store.data[unit_key(f"inverter-{u}", "pack_use")] = use
store.data[unit_key(f"inverter-{u}", "pack_mode")] = mode
store.data[unit_key(f"inverter-{u}", "pack_sell")] = sell
stats = dict(imp_kwh=0.0, exp_kwh=0.0, cost=0.0, exp_rev=0.0,
out_kwh={u: 0.0 for u in "ab"[:n]},
in_kwh={u: 0.0 for u in "ab"[:n]},
ticks=0, quarters={}, trade_ticks=0)
async def go():
hass = Hass(units, house_at, soc0, refuse, limits, lag)
tick = TICK if lag is None else lag.tick
import tempfile
db = os.path.join(tempfile.mkdtemp(prefix="p3r-"), "tariff.db")
c = Controller(dict(options(modes, arb, n), **(extra or {})), hass,
store, _Pub(), stop=asyncio.Event(), peak_db_path=db)
for i in c.inverters:
i.dev_min, i.dev_max = (limits or {}).get(i.name[-1],
(-5000.0, 5000.0))
if patch is not None:
patch(c)
c.auto = True
real_dt = m.datetime
clock = [start]
class _DT(real_dt):
@classmethod
def now(cls, tz=None):
return clock[0] if tz is None else clock[0].astimezone(tz)
m.datetime = _DT
plan_hour = None
try:
t = 0.0
while t < dur:
now = start + timedelta(seconds=t)
hass.t = t
clock[0] = now
if lag is not None and not hass.hist:
hass.advance(0.0, 10.0)
if plan_hour != now.replace(minute=0, second=0,
microsecond=0):
plan_hour = now.replace(minute=0, second=0,
microsecond=0)
c.tariff_plan, c.tariff_at = plan_at(now, pts), now
else:
c.tariff_at = now
if lag is None:
imp, exp = split_signed(hass.grid())
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
ingest_ts=now,
ingest_mono=time.monotonic()))
elif t >= lag.p1_s:
# The sample of the reading one debounce ago.
imp, exp = split_signed(hass._past(
lag.meter_s + lag.p1_s)[0])
c.p1.submit(make_sample(
"ha_signed", imp, exp, phases=1,
ingest_ts=now - timedelta(seconds=lag.p1_s),
ingest_mono=time.monotonic()))
await c.read_inputs(now_mono=t)
await c.cycle()
hass.advance(tick, 10.0)
g = hass.grid()
pr = price_at(now)
dt_h = tick / 3600.0
if g > 0:
stats["imp_kwh"] += g / 1000 * dt_h
stats["cost"] += g / 1000 * dt_h * (all_in(pr) if pr
is not None else 0)
else:
stats["exp_kwh"] += -g / 1000 * dt_h
stats["exp_rev"] += -g / 1000 * dt_h * (pr or 0.0)
for u, p in hass.plants.items():
if p.p > 0:
stats["out_kwh"][u] += p.p / 1000 * dt_h
else:
stats["in_kwh"][u] -= p.p / 1000 * dt_h
q = int(now.timestamp() // 900)
acc = stats["quarters"].setdefault(q, [0.0, 0, False])
acc[0] += max(0.0, g)
acc[1] += 1
if (getattr(c.arb, "action", None) or "").endswith("CHARGE"):
acc[2] = True
stats["trade_ticks"] += 1
stats["ticks"] += 1
if check is not None:
check(c, hass, now, pr)
t += tick
finally:
m.datetime = real_dt
return c
c = asyncio.run(go())
return stats, c
def summary(stats):
qs = stats["quarters"]
worst = max((a[0] / a[1] for a in qs.values()), default=0.0)
worst_trade = max((a[0] / a[1] for a in qs.values() if a[2]),
default=0.0)
cyc = {u: round(v / CAP_KWH, 2) for u, v in stats["out_kwh"].items()}
return {"import_kwh": round(stats["imp_kwh"], 2),
"export_kwh": round(stats["exp_kwh"], 2),
"import_eur": round(stats["cost"], 2),
"export_eur": round(stats["exp_rev"], 2),
"net_eur": round(stats["cost"] - stats["exp_rev"], 2),
"cycles": cyc, "worst_quarter_w": round(worst, 1),
"worst_charge_quarter_w": round(worst_trade, 1),
"charge_ticks": stats["trade_ticks"]}
LOOP_DAYS = [f"2026-09-{d:02d}" for d in range(2, 18)]
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--variant", default="p3_ls")
ap.add_argument("--days", type=int, default=16)
ap.add_argument("--captured", action="store_true")
a = ap.parse_args()
if a.captured:
days = ["2026-09-09", "2026-09-10"]
pts = qh_points()
else:
days = LOOP_DAYS[:a.days]
pts = epex_points()
house = load_house(days)
start = datetime.fromisoformat(days[0] + "T00:00:00+02:00") \
.astimezone(timezone.utc)
dur = 86400.0 * len(days)
t0 = time.time()
stats, _c = run(a.variant, house, pts, start, dur)
out = summary(stats)
out["variant"], out["days"] = a.variant, len(days)
out["wall_s"] = round(time.time() - t0, 1)
print(json.dumps(out))
if __name__ == "__main__":
main()