"""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)