|
|
|
@@ -0,0 +1,589 @@
|
|
|
|
|
"""Runnable check for UX-53. `python3 test_ux53.py`
|
|
|
|
|
|
|
|
|
|
Home draws a grid charge only where the add-on will take it.
|
|
|
|
|
|
|
|
|
|
1. Modes on (F-1): inside a cheap run that is under way, the trade gate
|
|
|
|
|
(`TariffPlan.arbitrage()`, as `Controller._mode_roles()` asks it) refuses
|
|
|
|
|
with `not_worth`. The control cycle files that as `Controller.mode_gate`,
|
|
|
|
|
and Home draws no charge, and marks no cheap quarter, from then on. With
|
|
|
|
|
the gate open the charge is drawn as before. The real `_mode_roles()` runs
|
|
|
|
|
at each instant, so the page is graded against the decision it makes.
|
|
|
|
|
2. Modes off (F-2): the verdict's charge comes after its last dear period.
|
|
|
|
|
Home asks the same gate at the charge's own start (`homeview.leg_taken()`):
|
|
|
|
|
a flat remainder is refused there, so no charge is drawn and no quarter is
|
|
|
|
|
marked cheap. A charge the gate allows at its start is drawn as before, and
|
|
|
|
|
so is the sale.
|
|
|
|
|
3. Wording, English and Dutch: "lowest price left", and "a fully fixed
|
|
|
|
|
product" only for a real fixed product.
|
|
|
|
|
4. Day/night and price-off pages are the same as before.
|
|
|
|
|
|
|
|
|
|
Mutations (MUTATION.md, purged copies): listed in the pull request.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
import json
|
|
|
|
|
import logging
|
|
|
|
|
import os
|
|
|
|
|
import sys
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
from app import arbitrage, homeview, modetrade, packmode # noqa: E402
|
|
|
|
|
from app.main import Controller # noqa: E402
|
|
|
|
|
from app.peakguard import PeakDecision # noqa: E402
|
|
|
|
|
from app.price import (PricePoint, PriceProduct, PriceRead, # noqa: E402
|
|
|
|
|
Uncertainty, brussels)
|
|
|
|
|
from app.static_tariff import TariffPlan, static_plan # 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
UTC = timezone.utc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _Store:
|
|
|
|
|
def __init__(self):
|
|
|
|
|
self.data = {}
|
|
|
|
|
|
|
|
|
|
def set(self, k, v):
|
|
|
|
|
self.data[k] = v
|
|
|
|
|
|
|
|
|
|
def get(self, k, d=None):
|
|
|
|
|
return self.data.get(k, d)
|
|
|
|
|
|
|
|
|
|
def set_time(self, k, v):
|
|
|
|
|
self.data[k] = v.isoformat()
|
|
|
|
|
|
|
|
|
|
def get_time(self, k):
|
|
|
|
|
v = self.data.get(k)
|
|
|
|
|
return None if v is None else datetime.fromisoformat(v)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _Pub:
|
|
|
|
|
enabled = False
|
|
|
|
|
|
|
|
|
|
def publish(self, _v):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
def close(self):
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class _Hass:
|
|
|
|
|
session = None
|
|
|
|
|
token = ""
|
|
|
|
|
|
|
|
|
|
async def limits(self, _e):
|
|
|
|
|
return (-5000.0, 5000.0, 1.0)
|
|
|
|
|
|
|
|
|
|
async def number(self, _e, invert=False):
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
async def state(self, _e):
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
async def set_number(self, _e, _v):
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
async def call(self, *_a):
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
async def states(self):
|
|
|
|
|
return []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
BASE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
|
|
|
|
"meter_source": "off", "max_w": 5000, "shadow_mode": "off",
|
|
|
|
|
"maintenance_enabled": False, "arbitrage_enabled": True,
|
|
|
|
|
"cycle_cost_threshold_eur": 0.10,
|
|
|
|
|
"inverters": [
|
|
|
|
|
{"name": "inverter-a", "setpoint_entity": "number.a",
|
|
|
|
|
"soc_entity": "sensor.soc_a", "batt_entity": "sensor.batt_a",
|
|
|
|
|
"max_w": 2500, "capacity_kwh": 2.0},
|
|
|
|
|
{"name": "inverter-b", "setpoint_entity": "number.b",
|
|
|
|
|
"soc_entity": "sensor.soc_b", "batt_entity": "sensor.batt_b",
|
|
|
|
|
"max_w": 2500, "capacity_kwh": 2.0}]}
|
|
|
|
|
|
|
|
|
|
# Tuesday 27 October 2026 in Brussels (UTC+1 since 25 October).
|
|
|
|
|
DAY0 = datetime(2026, 10, 26, 23, 0, tzinfo=UTC)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def at(h, m=0, s=0):
|
|
|
|
|
return DAY0 + timedelta(hours=h, minutes=m, seconds=s)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def quarters(price_of, days=2, product=PriceProduct.DAY_AHEAD):
|
|
|
|
|
"""48 hours of 15-minute prices; `price_of(local_hour_float)` -> EUR/kWh."""
|
|
|
|
|
pts = []
|
|
|
|
|
for i in range(96 * days):
|
|
|
|
|
t = DAY0 + timedelta(minutes=15 * i)
|
|
|
|
|
pts.append(PricePoint(brussels(t), price_of((i % 96) / 4.0), False))
|
|
|
|
|
return TariffPlan(PriceRead.series(tuple(pts), product,
|
|
|
|
|
"generic_dayahead", DAY0), None,
|
|
|
|
|
"provider")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def make(opts, plan, now, *, modes=None, soc=(50.0, 50.0)):
|
|
|
|
|
c = Controller(dict(opts), _Hass(), _Store(), _Pub(), stop=asyncio.Event())
|
|
|
|
|
c.auto = True
|
|
|
|
|
for i, inv in enumerate(c.inverters):
|
|
|
|
|
inv.soc, inv.batt, inv.last_write = soc[i], 0.0, 0.0
|
|
|
|
|
inv.dev_min, inv.dev_max = -5000.0, 5000.0
|
|
|
|
|
c.grid = 500.0
|
|
|
|
|
c.tariff_plan, c.tariff_at = plan, now
|
|
|
|
|
c.peak_guard = PeakDecision(engaged=False, floor_w=None, ceiling_w=3500.0,
|
|
|
|
|
ceiling_from="cap", projected_w=800.0)
|
|
|
|
|
c.arb_headroom_w = 3000.0
|
|
|
|
|
if modes:
|
|
|
|
|
for inv in c.inverters:
|
|
|
|
|
c.store.data[unit_key(inv.name, packmode.MODE_KEY)] = modes
|
|
|
|
|
return c
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build(c, now):
|
|
|
|
|
return homeview.build(c, level="ok", checks=[], now=now)["plan"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def kinds_at(plan_json, label):
|
|
|
|
|
return [b["kind"] for b in plan_json["prices"] if b["label"] == label]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def grid_blocks(plan_json):
|
|
|
|
|
return [r for r in plan_json["runs"] if r["kind"] == "grid"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
print("1. modes on: the gate refuses inside a cheap run (F-1)")
|
|
|
|
|
# All-in prices: 0.10 everywhere, 20:00 0.01, 20:15 0.09, 21:00-22:00 0.22. The
|
|
|
|
|
# cheap run is 20:00-20:30. At 20:00 the spread is 0.21. From 20:15 the quarters
|
|
|
|
|
# ahead are 0.09 to 0.22: 0.13 does not clear the 0.15 this cycle needs (0.10
|
|
|
|
|
# widened by 1.5 for the missing weather forecast).
|
|
|
|
|
def f1_price(h):
|
|
|
|
|
if h == 20.0:
|
|
|
|
|
return 0.01
|
|
|
|
|
if h == 20.25:
|
|
|
|
|
return 0.09
|
|
|
|
|
if 21.0 <= h < 22.0:
|
|
|
|
|
return 0.22
|
|
|
|
|
return 0.10
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
F1_PLAN = quarters(f1_price, days=1)
|
|
|
|
|
c1 = make(dict(BASE, battery_modes_enabled=True), F1_PLAN, at(19, 0, 5),
|
|
|
|
|
modes=packmode.CHEAP_HOURS)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def f1(now):
|
|
|
|
|
"""One real control tick of `_mode_roles()` at `now`, then Home."""
|
|
|
|
|
c1.tariff_at = now
|
|
|
|
|
tick = c1._mode_roles(now, None)
|
|
|
|
|
return tick, build(c1, now)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
t_pre, p_pre = f1(at(19, 0, 5)) # builds the run plan
|
|
|
|
|
t_open, p_open = f1(at(20, 0, 5))
|
|
|
|
|
print(f" 20:00:05 roles {t_open.roles}, action {t_open.action}, "
|
|
|
|
|
f"gate {c1.mode_gate}, runs {c1.mode_runs.cheap}")
|
|
|
|
|
check("gate open (20:00): the control cycle charges, mode_gate is None, and "
|
|
|
|
|
"Home draws the charge on both batteries, 20:00 to 20:30",
|
|
|
|
|
t_open.action == modetrade.CHARGE and c1.mode_gate is None
|
|
|
|
|
and {r["battery"] for r in grid_blocks(p_open)} == {0, 1}
|
|
|
|
|
and all(r["from"] == "20:00" and r["to"] == "20:30"
|
|
|
|
|
for r in grid_blocks(p_open)))
|
|
|
|
|
check("...and marks the run's two quarters cheap on the chart",
|
|
|
|
|
kinds_at(p_open, "20:00") == ["cheap"]
|
|
|
|
|
and kinds_at(p_open, "20:15") == ["cheap"])
|
|
|
|
|
t_mid, p_mid = f1(at(20, 7, 30))
|
|
|
|
|
check("gate still open at 20:07: the charge goes on and is still drawn",
|
|
|
|
|
t_mid.action == modetrade.CHARGE and c1.mode_gate is None
|
|
|
|
|
and len(grid_blocks(p_mid)) == 2)
|
|
|
|
|
c1.inverters[0].soc = c1.inverters[1].soc = 52.0
|
|
|
|
|
t_ref, p_ref = f1(at(20, 17, 52))
|
|
|
|
|
print(f" 20:17:52 roles {t_ref.roles}, action {t_ref.action}, why "
|
|
|
|
|
f"{t_ref.why}, gate {c1.mode_gate}, run {c1.mode_runs.cheap}")
|
|
|
|
|
check("the run is still kept (20:00-20:30) and the gate refuses with "
|
|
|
|
|
"not_worth at 20:17: no charge, why not_worth",
|
|
|
|
|
c1.mode_runs.cheap.holds(at(20, 17, 52)) and c1.mode_gate == "not_worth"
|
|
|
|
|
and t_ref.action is None and t_ref.why == "not_worth"
|
|
|
|
|
and all(r != modetrade.CHARGE for r in t_ref.roles.values()))
|
|
|
|
|
check("Home draws no charge block for the refused part, on any battery",
|
|
|
|
|
grid_blocks(p_ref) == [] and not any(
|
|
|
|
|
r["what"].startswith("Charges") for r in p_ref["runs"]))
|
|
|
|
|
check("...marks no quarter from 20:15 cheap, keeps the 20:00 quarter it "
|
|
|
|
|
"did charge in, and keeps the give run marked dear",
|
|
|
|
|
kinds_at(p_ref, "20:00") == ["cheap"]
|
|
|
|
|
and kinds_at(p_ref, "20:15") == ["mid"]
|
|
|
|
|
and kinds_at(p_ref, "20:30") == ["mid"]
|
|
|
|
|
and kinds_at(p_ref, "21:00") == ["dear"])
|
|
|
|
|
saved = c1.mode_gate
|
|
|
|
|
c1.mode_gate = None # what Home did before UX-53
|
|
|
|
|
p_old = build(c1, at(20, 17, 52))
|
|
|
|
|
c1.mode_gate = saved
|
|
|
|
|
check("the same moment without the gate record draws the charge again (the "
|
|
|
|
|
"defect, shown): so the fixture is the F-1 case",
|
|
|
|
|
len(grid_blocks(p_old)) == 2 and kinds_at(p_old, "20:15") == ["cheap"])
|
|
|
|
|
row = homeview.battery_rows(c1, False, True)[0]
|
|
|
|
|
check("the battery card says what Home now says: it rests (role hold), and "
|
|
|
|
|
"does not say it charges",
|
|
|
|
|
t_ref.roles["inverter-a"] == modetrade.HOLD
|
|
|
|
|
and row["why_key"] in ("saves", "saves_later"))
|
|
|
|
|
t_n, p_n = f1(at(20, 31, 0))
|
|
|
|
|
check("after the run: nothing is drawn, as before",
|
|
|
|
|
grid_blocks(p_n) == [] and c1.mode_gate == "not_worth")
|
|
|
|
|
|
|
|
|
|
# Other refusals of the gate are followed the same way: no charge is drawn
|
|
|
|
|
# while the control cycle's gate says no.
|
|
|
|
|
c1b = make(dict(BASE, battery_modes_enabled=True), F1_PLAN, at(20, 0, 5),
|
|
|
|
|
modes=packmode.CHEAP_HOURS)
|
|
|
|
|
c1b.tariff_at = at(20, 0, 5)
|
|
|
|
|
c1b.peak_guard = PeakDecision(engaged=True, floor_w=1000.0, ceiling_w=3500.0,
|
|
|
|
|
ceiling_from="cap", projected_w=3600.0)
|
|
|
|
|
tb = c1b._mode_roles(at(20, 0, 5), None)
|
|
|
|
|
check("the peak guard holding refuses the charge (gate 'peak'): the cycle "
|
|
|
|
|
"does not charge and Home draws no charge",
|
|
|
|
|
c1b.mode_gate == "peak" and tb.action is None
|
|
|
|
|
and grid_blocks(build(c1b, at(20, 0, 5))) == [])
|
|
|
|
|
c1c = make(dict(BASE, battery_modes_enabled=True), F1_PLAN, at(20, 0, 5),
|
|
|
|
|
modes=packmode.CHEAP_HOURS)
|
|
|
|
|
c1c.peak_guard = None
|
|
|
|
|
tc = c1c._mode_roles(at(20, 0, 5), None)
|
|
|
|
|
check("a peak guard that measures nothing refuses it too (gate "
|
|
|
|
|
"'not_covered'): no charge, none drawn",
|
|
|
|
|
c1c.mode_gate == "not_covered" and tc.action is None
|
|
|
|
|
and grid_blocks(build(c1c, at(20, 0, 5))) == [])
|
|
|
|
|
c1d = make(dict(BASE, battery_modes_enabled=True), F1_PLAN, at(20, 0, 5),
|
|
|
|
|
modes=packmode.CHEAP_HOURS)
|
|
|
|
|
c1d.arb_headroom_w = None
|
|
|
|
|
td = c1d._mode_roles(at(20, 0, 5), None)
|
|
|
|
|
check("a refusal that is not the gate (no measured headroom yet) is not the "
|
|
|
|
|
"gate's: mode_gate stays None and the charge is still drawn",
|
|
|
|
|
c1d.mode_gate is None and td.why == "headroom"
|
|
|
|
|
and len(grid_blocks(build(c1d, at(20, 0, 5)))) == 2)
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
print("\n2. modes off: a charge after the last dear period (F-2)")
|
|
|
|
|
NO_WEATHER = frozenset({Uncertainty.NO_WEATHER})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def decide_at(c, now, soc=50.0):
|
|
|
|
|
"""What the control cycle does at `now` on the plan (modes off)."""
|
|
|
|
|
return arbitrage.decide(
|
|
|
|
|
c.tariff_plan, now, peak_guard_active=False, charge_headroom_w=5000.0,
|
|
|
|
|
soc=soc, min_spread_eur_kwh=arbitrage.threshold_eur_kwh(c.o),
|
|
|
|
|
uncertainty=NO_WEATHER, charge_w=2500.0, discharge_w=2500.0,
|
|
|
|
|
soc_floor_pct=10.0, soc_ceiling_pct=90.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
OFF = dict(BASE)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def f2_price(h):
|
|
|
|
|
return 0.54 if 21.5 <= h < 21.75 else 0.24
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
F2_PLAN = quarters(f2_price, days=1)
|
|
|
|
|
c2 = make(OFF, F2_PLAN, at(21, 36, 0))
|
|
|
|
|
p2 = build(c2, at(21, 36, 0))
|
|
|
|
|
g2 = [r for r in p2["runs"] if r["kind"] == "give"]
|
|
|
|
|
v2, _w = homeview.trade_verdict(c2, F2_PLAN, at(21, 36, 0))
|
|
|
|
|
print(f" 21:36 verdict: cheapest {homeview.hhmm(v2.cheapest.start)}, "
|
|
|
|
|
f"dearest {homeview.hhmm(v2.dearest.start)}")
|
|
|
|
|
check("the fixture: at 21:36 the verdict's cheapest quarter (21:45) comes "
|
|
|
|
|
"after its dearest (21:30, in progress), and the rest of the day is flat",
|
|
|
|
|
homeview.hhmm(v2.cheapest.start) == "21:45"
|
|
|
|
|
and homeview.hhmm(v2.dearest.start) == "21:30")
|
|
|
|
|
check("the add-on sells in 21:30, and at 21:45 it does nothing: idle, the "
|
|
|
|
|
"tariff is flat",
|
|
|
|
|
decide_at(c2, at(21, 30, 5)).action == arbitrage.DISCHARGE
|
|
|
|
|
and decide_at(c2, at(21, 45, 5)).action is None
|
|
|
|
|
and "the tariff is flat" in decide_at(c2, at(21, 45, 5)).reason)
|
|
|
|
|
check("Home draws the sale in progress and NO charge at 21:45",
|
|
|
|
|
len(g2) == 2 and all(r["from"] == "21:30" for r in g2)
|
|
|
|
|
and grid_blocks(p2) == [])
|
|
|
|
|
check("...and marks 21:30 dear and 21:45 not cheap",
|
|
|
|
|
kinds_at(p2, "21:30") == ["dear"] and kinds_at(p2, "21:45") == ["mid"])
|
|
|
|
|
real = homeview.leg_taken
|
|
|
|
|
homeview.leg_taken = lambda *a, **k: True # Home before UX-53
|
|
|
|
|
p2_old = build(c2, at(21, 36, 0))
|
|
|
|
|
homeview.leg_taken = real
|
|
|
|
|
check("without the question to the gate the page draws the 21:45 charge "
|
|
|
|
|
"(the defect, shown): so the fixture is the F-2 case",
|
|
|
|
|
[r["from"] for r in grid_blocks(p2_old)] == ["21:45", "21:45"]
|
|
|
|
|
and kinds_at(p2_old, "21:45") == ["cheap"])
|
|
|
|
|
|
|
|
|
|
# A charge the gate allows at its own start is still drawn, with the sale.
|
|
|
|
|
def q_price(h):
|
|
|
|
|
return {20.0: 0.10, 20.5: 0.12, 21.0: 0.59, 21.5: 0.54}.get(h, 0.24)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Q_PLAN = quarters(q_price, days=1)
|
|
|
|
|
c3 = make(OFF, Q_PLAN, at(20, 20, 0))
|
|
|
|
|
p3 = build(c3, at(20, 20, 0))
|
|
|
|
|
v3, _w = homeview.trade_verdict(c3, Q_PLAN, at(20, 20, 0))
|
|
|
|
|
check("gate allows (20:20, charge 20:30, sale 21:00): both are drawn, and "
|
|
|
|
|
"the add-on does charge at 20:30 and sell at 21:00",
|
|
|
|
|
[r["from"] for r in grid_blocks(p3)] == ["20:30", "20:30"]
|
|
|
|
|
and [r["from"] for r in p3["runs"] if r["kind"] == "give"] == ["21:00"] * 2
|
|
|
|
|
and decide_at(c3, at(20, 30, 5)).action == arbitrage.CHARGE
|
|
|
|
|
and decide_at(c3, at(21, 0, 5)).action == arbitrage.DISCHARGE
|
|
|
|
|
and kinds_at(p3, "20:30") == ["cheap"] and kinds_at(p3, "21:00") == ["dear"])
|
|
|
|
|
check("...and the charge in progress at 20:30 is drawn too",
|
|
|
|
|
len(grid_blocks(build(c3, at(20, 35, 0)))) == 2)
|
|
|
|
|
|
|
|
|
|
# Every probe on a day-ahead day: the charge is drawn exactly where the add-on
|
|
|
|
|
# will take it. "Takes it" is the control cycle's own `decide()` at the start
|
|
|
|
|
# of the charge's period, on the same plan.
|
|
|
|
|
def sweep(plan, opts, lo, hi, step=15):
|
|
|
|
|
rows = []
|
|
|
|
|
for m in range(lo, hi, step):
|
|
|
|
|
now = at(0, 0, 0) + timedelta(minutes=m, seconds=7)
|
|
|
|
|
c = make(opts, plan, now)
|
|
|
|
|
v, _w = homeview.trade_verdict(c, plan, now)
|
|
|
|
|
if v is None:
|
|
|
|
|
rows.append((now, None, None, None))
|
|
|
|
|
continue
|
|
|
|
|
legs = homeview.trade_legs(c, plan, v, now)
|
|
|
|
|
drawn = {k: homeview.hhmm(pt.start) for k, pt in legs}
|
|
|
|
|
rows.append((now, v, drawn, [k for k, _pt in legs]))
|
|
|
|
|
return rows
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def takes(c, plan, kind, pt, now):
|
|
|
|
|
"""True when decide() acts in that leg's first moment (or now, when it
|
|
|
|
|
is already under way)."""
|
|
|
|
|
t = max(now, pt.start + timedelta(seconds=5))
|
|
|
|
|
d = decide_at(c, t)
|
|
|
|
|
return d.action == (arbitrage.CHARGE if kind == "grid"
|
|
|
|
|
else arbitrage.DISCHARGE)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
bad, nlegs, nsuppressed = [], 0, 0
|
|
|
|
|
for now, v, drawn, kinds in sweep(F2_PLAN, OFF, 0, 24 * 60):
|
|
|
|
|
if v is None:
|
|
|
|
|
continue
|
|
|
|
|
c = make(OFF, F2_PLAN, now)
|
|
|
|
|
for kind, pt in (("grid", v.cheapest), ("give", v.dearest)):
|
|
|
|
|
nlegs += 1
|
|
|
|
|
shown = kind in kinds
|
|
|
|
|
if not shown:
|
|
|
|
|
nsuppressed += 1
|
|
|
|
|
if shown != takes(c, F2_PLAN, kind, pt, now):
|
|
|
|
|
bad.append((homeview.hhmm(now), kind, shown))
|
|
|
|
|
print(f" {nlegs} verdict legs over a day, {nsuppressed} not drawn, "
|
|
|
|
|
f"{len(bad)} where Home and decide() disagree {bad[:3]}")
|
|
|
|
|
check("over a whole day of 15-minute probes, a leg is drawn exactly when "
|
|
|
|
|
"decide() acts on it at its start; and some are not drawn",
|
|
|
|
|
not bad and nsuppressed > 0)
|
|
|
|
|
|
|
|
|
|
# The same, on 40 random days of 15-minute prices (rounded to a cent, so there
|
|
|
|
|
# are ties), today only, every quarter probed: Home and decide() agree on every
|
|
|
|
|
# leg, and the page does drop some.
|
|
|
|
|
import random # noqa: E402
|
|
|
|
|
rnd = random.Random(53)
|
|
|
|
|
n_leg = n_drop = 0
|
|
|
|
|
disagree = []
|
|
|
|
|
for day in range(40):
|
|
|
|
|
shape = [round(rnd.choice((0.10, 0.18, 0.24, 0.24, 0.30, 0.45, 0.60))
|
|
|
|
|
+ rnd.randrange(0, 3) * 0.01, 2) for _ in range(96)]
|
|
|
|
|
if day % 2:
|
|
|
|
|
shape = [shape[i // 4 * 4] for i in range(96)] # hour-constant
|
|
|
|
|
plan = quarters(lambda h, sh=shape: sh[int(h * 4)], days=1)
|
|
|
|
|
c = make(OFF, plan, at(0, 0, 0))
|
|
|
|
|
for q in range(96):
|
|
|
|
|
now = at(0, 0, 0) + timedelta(minutes=15 * q, seconds=7)
|
|
|
|
|
v, _w = homeview.trade_verdict(c, plan, now)
|
|
|
|
|
if v is None:
|
|
|
|
|
continue
|
|
|
|
|
kinds = [k for k, _pt in homeview.trade_legs(c, plan, v, now)]
|
|
|
|
|
for kind, pt in (("grid", v.cheapest), ("give", v.dearest)):
|
|
|
|
|
n_leg += 1
|
|
|
|
|
shown = kind in kinds
|
|
|
|
|
n_drop += not shown
|
|
|
|
|
if shown != takes(c, plan, kind, pt, now):
|
|
|
|
|
disagree.append((day, homeview.hhmm(now), kind, shown))
|
|
|
|
|
print(f" 40 random days: {n_leg} verdict legs, {n_drop} not drawn, "
|
|
|
|
|
f"{len(disagree)} disagreements with decide() {disagree[:3]}")
|
|
|
|
|
check("40 random 15-minute days, every quarter: a leg is drawn exactly "
|
|
|
|
|
"when decide() acts on it at its start",
|
|
|
|
|
not disagree and n_leg > 3000 and n_drop > 0)
|
|
|
|
|
|
|
|
|
|
# A charge tomorrow: announced only when the add-on will take it. Today's
|
|
|
|
|
# dear period is 21:00 (0.50, then 0.30). Tomorrow opens with a dear hour
|
|
|
|
|
# (0.45) and the cheapest quarter comes right after it, at 01:00 (0.24).
|
|
|
|
|
def two_days(tomorrow):
|
|
|
|
|
pts = []
|
|
|
|
|
for i in range(96 * 2):
|
|
|
|
|
t = DAY0 + timedelta(minutes=15 * i)
|
|
|
|
|
h = (i % 96) / 4.0
|
|
|
|
|
price = (0.50 if h == 21.0 else 0.30) if i < 96 else tomorrow(h)
|
|
|
|
|
pts.append(PricePoint(brussels(t), price, False))
|
|
|
|
|
return TariffPlan(PriceRead.series(tuple(pts), PriceProduct.DAY_AHEAD,
|
|
|
|
|
"generic_dayahead", DAY0), None,
|
|
|
|
|
"provider")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def tomorrow_lines(plan, now):
|
|
|
|
|
c = make(OFF, plan, now)
|
|
|
|
|
return [x for x in build(c, now)["lines"] if x.startswith("Tomorrow")]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
T_FLAT = two_days(lambda h: 0.45 if h < 1.0 else 0.24)
|
|
|
|
|
T_WITH = two_days(lambda h: 0.45 if h < 1.0 else (0.50 if h == 18.0 else 0.24))
|
|
|
|
|
l_with = tomorrow_lines(T_WITH, at(21, 5, 0))
|
|
|
|
|
l_flat = tomorrow_lines(T_FLAT, at(21, 5, 0))
|
|
|
|
|
print(f" tomorrow lines: with a later dear period {l_with}; without {l_flat}")
|
|
|
|
|
check("tomorrow's charge (01:00) is announced when a dearer period follows "
|
|
|
|
|
"it, and not when the rest of tomorrow is flat",
|
|
|
|
|
len(l_with) == 1 and "01:00" in l_with[0] and l_flat == [])
|
|
|
|
|
check("...and the add-on charges at 01:00 on the first and does not on the "
|
|
|
|
|
"second",
|
|
|
|
|
decide_at(make(OFF, T_WITH, at(21, 5, 0)),
|
|
|
|
|
at(25, 0, 5)).action == arbitrage.CHARGE
|
|
|
|
|
and decide_at(make(OFF, T_FLAT, at(21, 5, 0)),
|
|
|
|
|
at(25, 0, 5)).action is None)
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
print("\n3. wording, English and Dutch")
|
|
|
|
|
c6 = make(OFF, Q_PLAN, at(20, 20, 0))
|
|
|
|
|
en = homeview.build(c6, level="ok", checks=[], now=at(20, 20, 0),
|
|
|
|
|
lang="en")["plan"]
|
|
|
|
|
nl = homeview.build(c6, level="ok", checks=[], now=at(20, 20, 0),
|
|
|
|
|
lang="nl")["plan"]
|
|
|
|
|
gw_en = [r["why"] for r in en["runs"] if r["kind"] == "grid"]
|
|
|
|
|
gw_nl = [r["why"] for r in nl["runs"] if r["kind"] == "grid"]
|
|
|
|
|
dw_en = [r["why"] for r in en["runs"] if r["kind"] == "give"]
|
|
|
|
|
dw_nl = [r["why"] for r in nl["runs"] if r["kind"] == "give"]
|
|
|
|
|
print(f" {gw_en[:1]} {gw_nl[:1]} {dw_en[:1]} {dw_nl[:1]}")
|
|
|
|
|
check("the charge says 'lowest price left', English and Dutch",
|
|
|
|
|
gw_en and set(gw_en) == {"Lowest price left, \u20ac0.12 per kWh."}
|
|
|
|
|
and set(gw_nl) == {"Laagste prijs die nog over is, \u20ac0,12 per kWh."})
|
|
|
|
|
check("...and the sale 'highest price left'",
|
|
|
|
|
set(dw_en) == {"Highest price left, \u20ac0.59 per kWh."}
|
|
|
|
|
and set(dw_nl) == {"Hoogste prijs die nog over is, \u20ac0,59 per kWh."})
|
|
|
|
|
check("...and no 'published' is left in the run words of either language",
|
|
|
|
|
all("ublished" not in str(homeview.uitext.words(l)["home_words"]["run"])
|
|
|
|
|
and "gepubliceerd" not in str(
|
|
|
|
|
homeview.uitext.words(l)["home_words"]["run"])
|
|
|
|
|
for l in ("en", "nl")))
|
|
|
|
|
check("the short words fit the plan-line rule: one sentence of at most 12 "
|
|
|
|
|
"words",
|
|
|
|
|
all(len(x.split()) <= 12 for x in gw_en + gw_nl + dw_en + dw_nl))
|
|
|
|
|
|
|
|
|
|
NOW6 = at(21, 45, 5)
|
|
|
|
|
FLAT_DYN = quarters(lambda h: 0.24, days=1)
|
|
|
|
|
r_dyn = FLAT_DYN.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.10,
|
|
|
|
|
uncertainty=NO_WEATHER, now=NOW6).match(
|
|
|
|
|
allowed=lambda v: None, refused=lambda v: v.reason)
|
|
|
|
|
FIXED = {"periods": [{"name": "all", "from": "00:00", "to": "24:00",
|
|
|
|
|
"days": "all", "eur_per_kwh": 0.30}]}
|
|
|
|
|
FIXED_PLAN = static_plan({"static_profile": json.dumps(FIXED)}, at(0), at(36),
|
|
|
|
|
now=at(0))
|
|
|
|
|
r_fix = FIXED_PLAN.arbitrage(peak_guard_active=False,
|
|
|
|
|
min_spread_eur_kwh=0.10, uncertainty=NO_WEATHER,
|
|
|
|
|
now=NOW6).match(allowed=lambda v: None,
|
|
|
|
|
refused=lambda v: v.reason)
|
|
|
|
|
DN = {"periods": [{"name": "day", "from": "07:00", "to": "22:00",
|
|
|
|
|
"days": "all", "eur_per_kwh": 0.34},
|
|
|
|
|
{"name": "night", "from": "22:00", "to": "07:00",
|
|
|
|
|
"days": "all", "eur_per_kwh": 0.26}]}
|
|
|
|
|
DN_FLAT = static_plan({"static_profile": json.dumps(DN)}, at(23, 0), at(30),
|
|
|
|
|
now=at(23, 0)) # 23:00 to 06:00: night only
|
|
|
|
|
r_dn = DN_FLAT.arbitrage(peak_guard_active=False, min_spread_eur_kwh=0.10,
|
|
|
|
|
uncertainty=NO_WEATHER, now=at(23, 10)).match(
|
|
|
|
|
allowed=lambda v: None, refused=lambda v: v.reason)
|
|
|
|
|
print(f" dynamic: {r_dyn}\n day/night: {r_dn}\n fixed: {r_fix}")
|
|
|
|
|
check("a dynamic site whose remaining quarters are equal is refused as flat, "
|
|
|
|
|
"and is not called a fully fixed product",
|
|
|
|
|
r_dyn and r_dyn.startswith("the tariff is flat - every one of the ")
|
|
|
|
|
and "fully fixed" not in r_dyn and "fixed product" not in r_dyn
|
|
|
|
|
and r_dyn.endswith("arithmetically impossible"))
|
|
|
|
|
check("...a day/night site whose remaining hours are all night: the same",
|
|
|
|
|
r_dn and r_dn.startswith("the tariff is flat")
|
|
|
|
|
and "fixed" not in r_dn)
|
|
|
|
|
check("...a site with a real fixed product is still told so",
|
|
|
|
|
r_fix and r_fix.endswith(
|
|
|
|
|
"§9.2: a fully fixed product is self-consumption only")
|
|
|
|
|
and FIXED_PLAN.product is PriceProduct.FIXED
|
|
|
|
|
and DN_FLAT.product is PriceProduct.STATIC
|
|
|
|
|
and FLAT_DYN.product is PriceProduct.DAY_AHEAD)
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
print("\n4. day/night, hourly and price-off pages")
|
|
|
|
|
def old_vs_new(plan, opts, hours=24):
|
|
|
|
|
"""Verdict legs the page drew before (both, when there is a verdict) and
|
|
|
|
|
the ones it draws now, probed every 30 minutes."""
|
|
|
|
|
same = diff = none = 0
|
|
|
|
|
for m in range(0, hours * 60, 30):
|
|
|
|
|
now = at(0) + timedelta(minutes=m, seconds=7)
|
|
|
|
|
c = make(opts, plan, now)
|
|
|
|
|
v, _w = homeview.trade_verdict(c, plan, now)
|
|
|
|
|
if v is None:
|
|
|
|
|
none += 1
|
|
|
|
|
continue
|
|
|
|
|
if len(homeview.trade_legs(c, plan, v, now)) == 2:
|
|
|
|
|
same += 1
|
|
|
|
|
else:
|
|
|
|
|
diff += 1
|
|
|
|
|
return same, diff, none
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
DN_OPTS = dict(OFF, cycle_cost_threshold_eur=0.02,
|
|
|
|
|
static_profile=json.dumps(DN))
|
|
|
|
|
DN_48 = static_plan(DN_OPTS, at(0), at(48), now=at(0))
|
|
|
|
|
s_dn = old_vs_new(DN_48, DN_OPTS)
|
|
|
|
|
print(f" day/night, 48 h plan, 48 probes: same {s_dn[0]}, changed "
|
|
|
|
|
f"{s_dn[1]}, no verdict {s_dn[2]}")
|
|
|
|
|
check("day/night (a 48-hour plan, as the refresh builds it): at every probe "
|
|
|
|
|
"that has a verdict both legs are drawn, as before; and there are some",
|
|
|
|
|
s_dn[1] == 0 and s_dn[0] >= 20)
|
|
|
|
|
HOURLY = quarters(lambda h: [.24, .22, .21, .20, .21, .22, .24, .27, .26, .22,
|
|
|
|
|
.18, .16, .15, .14, .16, .19, .23, .28, .33, .36,
|
|
|
|
|
.34, .29, .26, .24][int(h)], days=2)
|
|
|
|
|
HR_OPTS = dict(OFF, cycle_cost_threshold_eur=0.05)
|
|
|
|
|
s_hr = old_vs_new(HOURLY, HR_OPTS)
|
|
|
|
|
print(f" hourly shape on a 48 h plan: same {s_hr[0]}, changed {s_hr[1]}, "
|
|
|
|
|
f"no verdict {s_hr[2]}")
|
|
|
|
|
check("an hourly-shaped day-ahead feed with tomorrow known: both legs are "
|
|
|
|
|
"drawn at every probe with a verdict, as before",
|
|
|
|
|
s_hr[1] == 0 and s_hr[0] >= 20)
|
|
|
|
|
s_one = old_vs_new(quarters(lambda h: [.24, .22, .21, .20, .21, .22, .24, .27,
|
|
|
|
|
.26, .22, .18, .16, .15, .14, .16, .19,
|
|
|
|
|
.23, .28, .33, .36, .34, .29, .26,
|
|
|
|
|
.24][int(h)], days=1), HR_OPTS)
|
|
|
|
|
print(f" the same with today only: same {s_one[0]}, changed {s_one[1]}, "
|
|
|
|
|
f"no verdict {s_one[2]}")
|
|
|
|
|
check("...and with today only, the probes that change are the ones where "
|
|
|
|
|
"the cheapest quarter left is after the last dear one (F-2)",
|
|
|
|
|
s_one[1] > 0 and s_one[0] > 0)
|
|
|
|
|
c7 = make(dict(OFF, arbitrage_enabled=False), DN_48, at(12, 0, 0))
|
|
|
|
|
p7 = build(c7, at(12, 0, 0))
|
|
|
|
|
check("price trading off (a day/night site, modes off): no charge or sale "
|
|
|
|
|
"is drawn and no bar is marked, as before",
|
|
|
|
|
[r for r in p7["runs"] if r["kind"] in ("grid", "give")] == []
|
|
|
|
|
and {b["kind"] for b in p7["prices"]} == {"mid"})
|
|
|
|
|
c8 = make(dict(OFF, arbitrage_enabled=False, battery_modes_enabled=True),
|
|
|
|
|
DN_48, at(12, 0, 0), modes=packmode.SOLAR_FIRST)
|
|
|
|
|
c8._mode_roles(at(12, 0, 0), None)
|
|
|
|
|
p8 = build(c8, at(12, 0, 0))
|
|
|
|
|
check("modes on, every battery Solar first (the owner's setting): no grid "
|
|
|
|
|
"charge is drawn, and the gate record changes nothing",
|
|
|
|
|
grid_blocks(p8) == [] and {b["kind"] for b in p8["prices"]} == {"mid"})
|
|
|
|
|
c9 = make(dict(OFF, battery_modes_enabled=True), DN_48, at(12, 0, 0),
|
|
|
|
|
modes=packmode.SOLAR_FIRST)
|
|
|
|
|
check("a controller that never ran a cycle has no gate record, and the "
|
|
|
|
|
"default is 'no refusal' (None)",
|
|
|
|
|
getattr(c9, "mode_gate", "missing") is None)
|
|
|
|
|
|
|
|
|
|
print(f"fails: {len(fails)}")
|
|
|
|
|
sys.exit(1 if fails else 0)
|