Files
goodwe-addon/goodwe_controller/tools/opt11p3_identity.py
T
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

132 lines
4.5 KiB
Python

#!/usr/bin/env python3
"""OPT-11 P3: with battery modes off, is every command byte-identical to
release/1.0 7a00f12, on the paths P3 changed?
python3 tools/opt11p3_identity.py # prints one digest per scenario
tools/safety79_identity.py's harness (the REAL Controller on a P1 meter, so
the peak guard runs) plus a day-ahead price series and `arbitrage_enabled`,
so the site trade P3 now skips with modes on (`arbitrage.decide()`, its
claim, the sizing to the guard's headroom) runs on every cycle, with a buy
period, a sell period and periods in between, and a house that goes over the
limit. Each row hashes safety79's fields plus the trade's action, watts and
sentence. Uses only the API 7a00f12 has, so the same file runs on both trees;
test_modes_p3.py part 1 holds this tree to the digests printed on 7a00f12.
"""
import functools
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
DUR = 5400.0
def prices():
"""15-minute firm prices: the cheapest period 15-30 min into the run,
the dearest 75-90 min in, and ones in between."""
pts = []
for i in range(-8, 40):
s = rig.BASE + timedelta(minutes=15 * i)
p = 0.08 if i == 1 else 0.52 if i == 5 else 0.20 + 0.003 * (i % 7)
pts.append(PricePoint(start=brussels(s), eur_per_kwh=p,
provisional=False))
return TariffPlan(PriceRead.series(pts, PriceProduct.DAY_AHEAD,
"generic_dayahead", rig.BASE), None,
"provider")
PLAN = prices()
def _feed(c, t):
c.tariff_plan, c.tariff_at = PLAN, rig.BASE + timedelta(seconds=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
return (900.0, 3200.0, -2200.0, 1500.0, 4200.0)[k]
def soc(u, t):
return 55.0 if u == "a" else 45.0
ARB = {"arbitrage_enabled": True, "cycle_cost_threshold_eur": 0.05}
SCENARIOS = {
"n1": dict(n=1),
"n2": dict(n=2),
# 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, lag=False):
opts = dict(s79.P1, **ARB, **(extra or {}))
trade = []
def on_cycle(c, t):
a = c.arb
trade.append(None if a is None else (a.action, a.power_w, a.reason))
db = os.path.join(tempfile.mkdtemp(prefix="p3id-"), "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=load, soc=soc,
cut=lambda u, t: False,
before=_feed_lag if lag else _feed,
on_cycle=on_cycle)
finally:
rig.Controller = real
return [r + (x,) for r, x in zip(rows, trade)], 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__":
if "--summary" in sys.argv:
for k, v in SCENARIOS.items():
rows = run(**v)[0]
acts = sorted({str(r[-1][0]) if r[-1] else "None" for r in rows})
print(f"{k}: {len(rows)} cycles, trade actions {acts}")
for k, v in digests().items():
print(f"{k} {v}")