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>
This commit is contained in:
@@ -19,12 +19,14 @@ import hashlib
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from datetime import timedelta
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
@@ -53,6 +55,21 @@ def _feed(c, t):
|
||||
s79._feed(c, t)
|
||||
|
||||
|
||||
# OPT-15: the P1 sample of each cycle is built this long after the cycle
|
||||
# reads the grid (the HA source's debounce), so the cycle on a quarter
|
||||
# boundary still sees the old block, as on TEST-23.
|
||||
P1_LAG_S = 0.35
|
||||
|
||||
|
||||
def _feed_lag(c, t):
|
||||
c.tariff_plan, c.tariff_at = PLAN, rig.BASE + timedelta(seconds=t)
|
||||
imp, exp = split_signed(c.hass.grid())
|
||||
c.p1.submit(make_sample(
|
||||
"ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=rig.BASE + timedelta(seconds=t - P1_LAG_S),
|
||||
ingest_mono=time.monotonic()))
|
||||
|
||||
|
||||
def load(t):
|
||||
"""Under the limit, over it, a surplus, a big evening load."""
|
||||
k = int(t // 600) % 5
|
||||
@@ -70,10 +87,13 @@ SCENARIOS = {
|
||||
# The option present and false: an install that saved it off.
|
||||
"n1_off": dict(n=1, extra={"battery_modes_enabled": False}),
|
||||
"n2_off": dict(n=2, extra={"battery_modes_enabled": False}),
|
||||
# OPT-15: every quarter boundary with the old block still open.
|
||||
"n1_lag": dict(n=1, lag=True),
|
||||
"n2_lag": dict(n=2, lag=True),
|
||||
}
|
||||
|
||||
|
||||
def run(n, *, extra=None, dur=DUR):
|
||||
def run(n, *, extra=None, dur=DUR, lag=False):
|
||||
opts = dict(s79.P1, **ARB, **(extra or {}))
|
||||
trade = []
|
||||
|
||||
@@ -85,7 +105,8 @@ def run(n, *, extra=None, dur=DUR):
|
||||
rig.Controller = functools.partial(real, peak_db_path=db)
|
||||
try:
|
||||
rows, c = rig.run(n, extra=opts, dur=dur, load=load, soc=soc,
|
||||
cut=lambda u, t: False, before=_feed,
|
||||
cut=lambda u, t: False,
|
||||
before=_feed_lag if lag else _feed,
|
||||
on_cycle=on_cycle)
|
||||
finally:
|
||||
rig.Controller = real
|
||||
|
||||
@@ -18,7 +18,11 @@ 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), the all-in price (the EPEX file's own stated
|
||||
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.
|
||||
@@ -104,10 +108,28 @@ def qh_points():
|
||||
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
|
||||
@@ -115,8 +137,8 @@ class Plant:
|
||||
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 / 3.0))
|
||||
dp = max(-800.0 * dt, min(800.0 * dt, want - self.p))
|
||||
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
|
||||
@@ -125,17 +147,52 @@ class Plant:
|
||||
|
||||
|
||||
class Hass:
|
||||
def __init__(self, units, house_at, soc0=None, refuse=(), limits=None):
|
||||
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)
|
||||
@@ -145,7 +202,8 @@ class Hass:
|
||||
|
||||
async def number(self, e, invert=False):
|
||||
if e == "sensor.grid":
|
||||
return self.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)
|
||||
@@ -153,11 +211,17 @@ class Hass:
|
||||
# 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
|
||||
return float(round(self.plants[u].p) + j)
|
||||
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):
|
||||
self.plants[e[-1]].cmd = float(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
|
||||
|
||||
|
||||
@@ -239,7 +303,7 @@ def plan_at(now, pts):
|
||||
|
||||
def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2,
|
||||
soc0=None, refuse=(), extra=None, choice=None, patch=None,
|
||||
limits=None):
|
||||
limits=None, lag=None):
|
||||
modes, arb, var_choice = VARIANTS[variant]
|
||||
choice = var_choice if choice is None else choice
|
||||
units = tuple("ab"[:n])
|
||||
@@ -272,7 +336,8 @@ def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2,
|
||||
ticks=0, quarters={}, trade_ticks=0)
|
||||
|
||||
async def go():
|
||||
hass = Hass(units, house_at, soc0, refuse, limits)
|
||||
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,
|
||||
@@ -299,6 +364,8 @@ def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2,
|
||||
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,
|
||||
@@ -306,17 +373,25 @@ def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2,
|
||||
c.tariff_plan, c.tariff_at = plan_at(now, pts), now
|
||||
else:
|
||||
c.tariff_at = now
|
||||
imp, exp = split_signed(hass.grid())
|
||||
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
|
||||
ingest_ts=now,
|
||||
ingest_mono=time.monotonic()))
|
||||
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()
|
||||
for p in hass.plants.values():
|
||||
p.step(TICK, 10.0)
|
||||
hass.advance(tick, 10.0)
|
||||
g = hass.grid()
|
||||
pr = price_at(now)
|
||||
dt_h = TICK / 3600.0
|
||||
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
|
||||
@@ -339,7 +414,7 @@ def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2,
|
||||
stats["ticks"] += 1
|
||||
if check is not None:
|
||||
check(c, hass, now, pr)
|
||||
t += TICK
|
||||
t += tick
|
||||
finally:
|
||||
m.datetime = real_dt
|
||||
return c
|
||||
|
||||
Reference in New Issue
Block a user