- TariffPlan.arbitrage(now=...) is required (keyword-only, no default); so are homeview.trade_verdict/refusal_key's now. Existing test calls pass one. - test_arbitrage.py: no module-level decide wrapper. Fixtures that probed a one-day plan after its extremes now hold two days (as a site does); the flap day gets a unique cheapest and dearest hour; the replay breaks its ties without direction. The flap bound is <= 4 and the replay check is len(_trades) < 365 again. 228 checks pass on the old and the new code. - test_price02.py part 6: ties (equal quarters, a real 15-minute day, alternating hourly prices) and the required now. - Identity pins: the masked digest is only for the older-tree comparison; this tree's own digests are pinned with nothing hidden. - DECISIONS: the tie analysis with numbers. Co-Authored-By: Claude <noreply@anthropic.com>
158 lines
5.7 KiB
Python
158 lines
5.7 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 re
|
|
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 _trade_words(text):
|
|
"""PRICE-02: the trade sentence as release/1.0 before it printed it. The
|
|
plan now drops price periods that have ended, so a sale's "above the
|
|
cheapest" figure is a different number once the first cheap period is
|
|
over, and the sentences say "price period" where they said "hour". Both
|
|
maps are applied to both sides, so only those two things are hidden.
|
|
|
|
ONLY FOR COMPARING WITH A TREE OLDER THAN PRICE-02. A pin of this tree
|
|
itself uses `digest()`, with nothing hidden."""
|
|
text = text.replace("this hour is neither the buy hour nor the sell hour",
|
|
"now is not the cheapest or the dearest price period")
|
|
text = re.sub(r"[0-9]+\.[0-9]+ above the cheapest",
|
|
"N above the cheapest", text)
|
|
return text.replace("firm hour at", "firm price period at")
|
|
|
|
|
|
def trade_digest(rows) -> str:
|
|
"""`digest()` with the trade sentence passed through `_trade_words()`:
|
|
every command, integrator and allocation, and the action, watts and the
|
|
rest of the sentence, byte for byte."""
|
|
return digest([r[:-1] + (None if r[-1] is None else
|
|
(r[-1][0], r[-1][1], _trade_words(r[-1][2])),)
|
|
for r in rows])
|
|
|
|
|
|
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}")
|