375 lines
16 KiB
Python
375 lines
16 KiB
Python
"""Runnable check for TARIFF-24. `python3 test_tariff24.py`
|
|
|
|
Battery modes on, a day/night `static_profile`, no price feed and the site trade
|
|
switch off used to start no tariff task: `Controller.tariff_plan` stayed None,
|
|
`mode_runs` stayed None, Leftover solar and Cheap hours never charged from the
|
|
grid, and the Batteries screen said "No prices" while Home priced the day from
|
|
the profile itself. DECISIONS/TARIFF-24-modes-need-the-tariff-task.md.
|
|
|
|
1. The startup gate: `tariff_refresh_wanted()` by configuration, and the guard
|
|
`amain()` really puts on the task (read from its source, not retyped).
|
|
2. End to end, on the real Controller and the real `run_tariff_refresh()` task,
|
|
started only if `amain()`'s own guard says so: night period, one battery
|
|
Leftover solar and one Cheap hours.
|
|
3. The same with a gap below the cycle cost.
|
|
4. Home and the Batteries screen agree.
|
|
5. UX-55: with every battery Solar first Home no longer says "no prices have
|
|
arrived" on a static profile, and still does with no profile and no feed.
|
|
6. Nothing else moved: modes off starts no task; the gate rows in part 1 keep
|
|
the arbitrage and feed cases as before.
|
|
|
|
Mutations (MUTATION.md, purged copies, graded over this file): the gate
|
|
reverted to `provider or arbitrage` fails 18 checks in parts 1 to 5; `True`
|
|
fails the modes-off rows; `bool(opts.get(...))` fails the exact-true row;
|
|
dropping the arbitrage term fails the arbitrage rows.
|
|
"""
|
|
|
|
import ast
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, HERE)
|
|
sys.path.insert(0, os.path.join(HERE, "tools"))
|
|
logging.disable(logging.WARNING)
|
|
|
|
import opt11p3_replay as rp # noqa: E402
|
|
from named_absence import first, nth, present # noqa: E402
|
|
import app.main as m # noqa: E402
|
|
from app import homeview, modetrade, packmode # noqa: E402
|
|
from app import price as _price, static_tariff as _st # noqa: E402
|
|
from app.main import Controller, tariff_refresh_wanted # noqa: E402
|
|
from app.p1 import make_sample, split_signed # noqa: E402
|
|
from app.store import unit_key # noqa: E402
|
|
|
|
fails = []
|
|
|
|
|
|
def check(name, cond):
|
|
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
|
if not cond:
|
|
fails.append(name)
|
|
|
|
|
|
A, B = "inverter-a", "inverter-b"
|
|
|
|
# A Belgian-style day/night tariff, example values: dear 07:00-22:00, cheap
|
|
# 22:00-07:00, every day. The gap is 0.08 EUR/kWh.
|
|
PROFILE = ('{"periods": ['
|
|
'{"name": "night", "days": "all", "from": "22:00", "to": "07:00",'
|
|
' "eur_per_kwh": 0.22},'
|
|
'{"name": "day", "days": "all", "from": "07:00", "to": "22:00",'
|
|
' "eur_per_kwh": 0.30}]}')
|
|
# A Friday in winter time (+01:00), 23:00 local: inside the night period.
|
|
L = timezone(timedelta(hours=1))
|
|
NIGHT = datetime(2026, 10, 30, 23, 0, tzinfo=L).astimezone(timezone.utc)
|
|
|
|
|
|
def opts(*, modes=True, arb=False, profile=PROFILE, provider=None,
|
|
cost=0.05):
|
|
o = dict(rp.options(modes, arb, 2))
|
|
o["cycle_cost_threshold_eur"] = cost
|
|
if profile is not None:
|
|
o["static_profile"] = profile
|
|
if provider is not None:
|
|
o["price_provider"] = provider
|
|
return o
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# The guard amain() puts on the tariff task, read from amain()'s source.
|
|
# --------------------------------------------------------------------------- #
|
|
def _guards_of(node, guards=()):
|
|
"""[(guards, coroutine name)] for every create_task(...) under `node`."""
|
|
out = []
|
|
for child in ast.iter_child_nodes(node):
|
|
if isinstance(child, ast.If):
|
|
test = ast.unparse(child.test)
|
|
for stmt in child.body:
|
|
out += _guards_of(stmt, guards + (test,))
|
|
for stmt in child.orelse:
|
|
out += _guards_of(stmt, guards + (f"not ({test})",))
|
|
continue
|
|
if isinstance(child, ast.Call) and \
|
|
ast.unparse(child.func).endswith("create_task") and child.args:
|
|
arg = child.args[0]
|
|
name = (arg.func.attr if isinstance(arg, ast.Call)
|
|
and isinstance(arg.func, ast.Attribute)
|
|
else ast.unparse(arg))
|
|
out.append((guards, name))
|
|
continue
|
|
out += _guards_of(child, guards)
|
|
return out
|
|
|
|
|
|
_TREE = ast.parse(open(os.path.join(HERE, "app", "main.py"),
|
|
encoding="utf-8").read())
|
|
_AMAIN = first((n for n in ast.walk(_TREE)
|
|
if isinstance(n, ast.AsyncFunctionDef) and n.name == "amain"),
|
|
"amain() in main.py")
|
|
_TASK_GUARDS = [g for g, n in _guards_of(_AMAIN) if n == "run_tariff_refresh"]
|
|
|
|
|
|
def task_started(o) -> bool:
|
|
"""Would amain() start the tariff refresh task for options `o`?"""
|
|
if len(_TASK_GUARDS) != 1:
|
|
return False
|
|
return all(bool(eval(g, vars(m), {"opts": o})) for g in _TASK_GUARDS[0])
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("1. the startup gate")
|
|
check("amain() starts the tariff refresh from exactly one guard, and that "
|
|
"guard can be decided from the options alone",
|
|
len(_TASK_GUARDS) == 1 and len(_TASK_GUARDS[0]) >= 1
|
|
and all(isinstance(eval(g, vars(m), {"opts": opts()}), bool)
|
|
for g in _TASK_GUARDS[0]))
|
|
GATE = [
|
|
# label, options, task wanted
|
|
("modes on + static day/night only", opts(), True),
|
|
("modes on + a fixed price only",
|
|
opts(profile='{"periods": [{"name": "all", "days": "all", "from": '
|
|
'"00:00", "to": "24:00", "eur_per_kwh": 0.27}]}'), True),
|
|
("modes on, nothing else", opts(profile=None), True),
|
|
("modes off + arbitrage off + no feed (static profile)",
|
|
opts(modes=False), False),
|
|
("modes off + arbitrage off + no feed, no profile",
|
|
opts(modes=False, profile=None), False),
|
|
("modes off + arbitrage on", opts(modes=False, arb=True), True),
|
|
("modes off + a feed", opts(modes=False, provider="generic_dayahead"),
|
|
True),
|
|
("modes on + a feed", opts(provider="generic_dayahead"), True),
|
|
("modes on + arbitrage on", opts(arb=True), True),
|
|
]
|
|
for label, o, want in GATE:
|
|
got = tariff_refresh_wanted(o)
|
|
print(f" wanted={got!s:5} started={task_started(o)!s:5} {label}")
|
|
check(f"{label}: tariff_refresh_wanted() is {want}", got is want)
|
|
check(f"{label}: amain() starts the task exactly as that says",
|
|
task_started(o) is want)
|
|
check("battery_modes_enabled has to be exactly true (packmode.enabled()): "
|
|
"'true' as text or 1 does not start the task",
|
|
not tariff_refresh_wanted(dict(opts(), battery_modes_enabled="true"))
|
|
and not tariff_refresh_wanted(dict(opts(), battery_modes_enabled=1)))
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# The real Controller over a few minutes of a night: the task starts only if
|
|
# amain()'s own guard says so; then read_inputs() + cycle() as the control
|
|
# loop runs them.
|
|
# --------------------------------------------------------------------------- #
|
|
CHOICE = {"a": (True, "leftover_solar", False),
|
|
"b": (True, "cheap_hours", False)}
|
|
|
|
|
|
FETCHES = []
|
|
|
|
|
|
async def _no_fetch(*a, **k):
|
|
FETCHES.append(a)
|
|
raise AssertionError("the tariff task reached for a price feed")
|
|
|
|
|
|
def drive(o, *, start=NIGHT, minutes=20, soc0=None, choice=CHOICE,
|
|
house_w=300.0, tick=5.0):
|
|
"""-> (controller, rows, task_was_started)."""
|
|
store = rp._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
|
|
rows = []
|
|
|
|
async def go():
|
|
hass = rp.Hass(("a", "b"), lambda t: house_w,
|
|
soc0 or {"a": 40.0, "b": 40.0})
|
|
tmp = tempfile.mkdtemp(prefix="t24-")
|
|
c = Controller(dict(o), hass, store, rp._Pub(),
|
|
stop=asyncio.Event(),
|
|
peak_db_path=os.path.join(tmp, "tariff.db"),
|
|
price_history_path=os.path.join(tmp, "ph.json"))
|
|
for i in c.inverters:
|
|
i.dev_min, i.dev_max = -5000.0, 5000.0
|
|
c.auto = True
|
|
real_dt, real_hv = m.datetime, homeview.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 = homeview.datetime = _DT
|
|
_real_load = _price.load_prices, _st.load_prices
|
|
_price.load_prices = _st.load_prices = _no_fetch
|
|
started = task_started(o)
|
|
task = (asyncio.create_task(c.run_tariff_refresh())
|
|
if started else None)
|
|
try:
|
|
t = 0.0
|
|
while t < minutes * 60:
|
|
now = start + timedelta(seconds=t)
|
|
hass.t = t
|
|
clock[0] = now
|
|
if t == 0.0 and started:
|
|
for _ in range(500): # let the task do its first pass
|
|
if c.tariff_plan is not None:
|
|
break
|
|
await asyncio.sleep(0)
|
|
imp, exp = split_signed(hass.grid())
|
|
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
|
|
ingest_ts=now,
|
|
ingest_mono=time.monotonic()))
|
|
await c.read_inputs(now_mono=t)
|
|
await c.cycle()
|
|
hass.advance(tick, 10.0)
|
|
rows.append(dict(
|
|
now=now, runs=c.mode_runs, tick=c.mode_tick,
|
|
plan=c.fresh_tariff_plan(now),
|
|
action=getattr(c.arb, "action", None),
|
|
p={u: p.p for u, p in hass.plants.items()},
|
|
soc={u: p.soc for u, p in hass.plants.items()}))
|
|
t += tick
|
|
# What the two screens say at the end, while the clock is still
|
|
# the simulated one.
|
|
c.home_plan = homeview.build_plan(c, clock[0], False, True)
|
|
c.battery_cards = homeview.battery_rows(c, False, True)
|
|
finally:
|
|
if task is not None:
|
|
task.cancel()
|
|
try:
|
|
await task
|
|
except asyncio.CancelledError:
|
|
pass
|
|
m.datetime, homeview.datetime = real_dt, real_hv
|
|
_price.load_prices, _st.load_prices = _real_load
|
|
return c, started
|
|
|
|
c, started = asyncio.run(go())
|
|
return c, rows, started
|
|
|
|
|
|
def charging_b(rows):
|
|
"""Cycles in which the Cheap hours battery was charging (grid: no sun and
|
|
a 300 W house in this scenario)."""
|
|
return [r for r in rows if r["p"]["b"] < -200.0]
|
|
|
|
|
|
def why_of(c, name):
|
|
card = first((b for b in c.battery_cards if b["name"] == name),
|
|
f"a card for {name}")
|
|
return card["why_key"]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("\n2. end to end, night, modes on, static day/night, no feed, no arbitrage")
|
|
c1, r1, started1 = drive(opts())
|
|
_last = nth(r1, -1, "the last cycle")
|
|
_first1 = nth(r1, 0, "the first cycle")
|
|
print(f" tariff task started by amain's guard: {started1}")
|
|
print(f" tariff_plan: {None if c1.tariff_plan is None else c1.tariff_plan.words}")
|
|
print(f" mode_runs: {_last['runs']}")
|
|
print(f" mode_tick reason / roles: "
|
|
f"{None if _last['tick'] is None else (_last['tick'].why, dict(_last['tick'].roles))}")
|
|
print(f" Batteries screen why_key: A {why_of(c1, A)}, B {why_of(c1, B)}")
|
|
print(f" cycles with B charging: {len(charging_b(r1))} of {len(r1)}, "
|
|
f"B soc {_first1['soc']['b']:.1f} -> {_last['soc']['b']:.1f}")
|
|
check("the tariff refresh task is started", started1)
|
|
check("...and it adds no network call: with price_provider off the feed "
|
|
"loader is never reached (it is replaced by one that fails)",
|
|
not FETCHES and not _price.provider_configured(opts()))
|
|
check("a fresh tariff plan with prices is held", present(_last)
|
|
and _last["plan"] is not None and _last["plan"].has_prices)
|
|
check("the price runs exist (mode_runs is not None) and carry a cheap run "
|
|
"that holds in the night period",
|
|
present(_last) and _last["runs"] is not None and _last["runs"].on
|
|
and _last["runs"].cheap is not None
|
|
and _last["runs"].cheap.holds(_last["now"]))
|
|
check("the Cheap hours battery charges from the grid in the night period",
|
|
len(charging_b(r1)) > len(r1) // 2
|
|
and present(_last, _first1)
|
|
and _last["soc"]["b"] > _first1["soc"]["b"] + 0.3)
|
|
check("...and the Leftover solar battery does not (no sun, no forecast)",
|
|
not any(r["p"]["a"] < -200.0 for r in r1))
|
|
check("...the reason is not 'no prices': neither battery reads no_prices",
|
|
why_of(c1, A) != "no_prices" and why_of(c1, B) != "no_prices")
|
|
check("...only the Cheap hours battery got the grid charge role",
|
|
present(_last) and _last["tick"] is not None
|
|
and _last["tick"].roles.get(B) == modetrade.CHARGE
|
|
and _last["tick"].roles.get(A) != modetrade.CHARGE)
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("\n3. the same with a gap below the cycle cost")
|
|
c3, r3, started3 = drive(opts(cost=0.20))
|
|
_l3 = nth(r3, -1, "the last cycle")
|
|
print(f" started {started3}; mode_runs {_l3['runs']}; "
|
|
f"why A {why_of(c3, A)}, B {why_of(c3, B)}")
|
|
check("the task is started and the plan is there, so mode_runs is not None",
|
|
started3 and present(_l3) and _l3["plan"] is not None
|
|
and _l3["runs"] is not None)
|
|
check("the reason is the gap: no_spread ('Prices differ too little'), "
|
|
"never no_prices, on both batteries",
|
|
why_of(c3, A) == "no_spread" and why_of(c3, B) == "no_spread"
|
|
and present(_l3) and _l3["runs"] is not None
|
|
and not _l3["runs"].on)
|
|
check("...and no battery charges from the grid",
|
|
not charging_b(r3) and not any(r["p"]["a"] < -200.0 for r in r3))
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("\n4. Home and the Batteries screen agree")
|
|
hp = c1.home_plan
|
|
_bars = [b for b in hp["prices"] if b["eur_kwh"] is not None]
|
|
_words = {b["why_key"]: b["why"] for b in c1.battery_cards}
|
|
print(f" Home: has_prices {hp['has_prices']}, {len(_bars)} priced bars, "
|
|
f"verdict {hp['verdict_key']}, lines {hp['lines']}")
|
|
print(f" Batteries: {[(b['name'], b['why_key'], b['why']) for b in c1.battery_cards]}")
|
|
check("Home prices the day, and the Batteries screen does not say it has no "
|
|
"prices", hp["has_prices"] and _bars
|
|
and all(b["why_key"] != "no_prices" for b in c1.battery_cards))
|
|
check("Home's plan has the grid charge block for the Cheap hours battery "
|
|
"(the 'trade' verdict) and no 'no prices have arrived' line",
|
|
hp["verdict_key"] == "trade"
|
|
and any(r["kind"] == "grid" for r in hp["runs"])
|
|
and not any("no prices have arrived" in ln for ln in hp["lines"]))
|
|
hp3 = c3.home_plan
|
|
check("with the gap too small both say so: Home 'price gap is too small', "
|
|
"the batteries 'differ too little'; neither says no prices",
|
|
any("price gap is too small" in ln for ln in hp3["lines"])
|
|
and all(b["why_key"] == "no_spread" for b in c3.battery_cards))
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("\n5. UX-55: every battery Solar first, static day/night, no feed")
|
|
SF = {"a": (True, "solar_first", False), "b": (True, "solar_first", False)}
|
|
c5, r5, s5 = drive(opts(), choice=SF, minutes=5)
|
|
print(f" Home lines: {c5.home_plan['lines']}")
|
|
check("Home no longer says 'no prices have arrived' with every battery Solar "
|
|
"first on a static profile",
|
|
s5 and not any("no prices have arrived" in ln
|
|
for ln in c5.home_plan["lines"]))
|
|
c5b, r5b, s5b = drive(opts(profile=None), choice=SF, minutes=5)
|
|
print(f" no profile, no feed: started {s5b}, Home lines "
|
|
f"{c5b.home_plan['lines']}")
|
|
check("...and with no profile and no feed there are truly no prices, and "
|
|
"Home still says so",
|
|
any("no prices have arrived" in ln for ln in c5b.home_plan["lines"]))
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
print("\n6. nothing else moved")
|
|
c6, r6, s6 = drive(opts(modes=False), choice={}, minutes=5)
|
|
check("modes off, arbitrage off, no feed: no task, no plan, no price runs "
|
|
"(as before)", not s6 and c6.tariff_plan is None
|
|
and all(r["runs"] is None for r in r6))
|
|
|
|
print()
|
|
if fails:
|
|
print(f"{len(fails)} FAILED:")
|
|
for f in fails:
|
|
print(f" - {f}")
|
|
sys.exit(1)
|
|
print("all checks passed")
|