"""UX-54: controllers in a known situation, for test_ux54.py and the browser proof (`scratch/UX-54/dev/demo54.py`). Real `Controller`, readings set by hand, no Home Assistant. `make(name, now)` returns one; the names are in `SCENARIOS`. owner modes on, both batteries Solar first, a day/night tariff, a forecast from three state-only sensors (Forecast.Solar's shape), a house profile (the owner's site 2026-10-26) dynamic modes on, A Leftover solar and B Cheap hours, 15-minute prices with a cheap night and a dear evening, a dark day (a grid charge is due) sunny as dynamic, a sunny day (the sun fills A, no grid charge) periods as dynamic with a Solcast-shaped forecast (an hourly split) off modes off, arbitrage on, the same prices learning modes on, a forecast, and no house profile yet nofc modes on, no forecast sensor set stale modes on, a sensor set that gives no forecast now notomorrow as owner, today's forecast only cycle as dynamic, battery A waiting for its monthly cycle """ import asyncio import json import math import os import sys from datetime import datetime, timedelta, timezone HERE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, HERE) from app import dayplan, packmode, solarfc # noqa: E402 from app.main import Controller # noqa: E402 from app.peakguard import PeakDecision # noqa: E402 from app.price import PricePoint, PriceProduct, PriceRead, brussels # noqa from app.static_tariff import TariffPlan, static_plan # noqa: E402 from app.store import unit_key # noqa: E402 UTC = timezone.utc SCENARIOS = ("owner", "dynamic", "sunny", "periods", "off", "learning", "nofc", "stale", "notomorrow", "cycle") INV = [{"name": "inverter-a", "setpoint_entity": "number.a", "soc_entity": "sensor.soc_a", "batt_entity": "sensor.batt_a", "max_w": 5000, "capacity_kwh": 15.36}, {"name": "inverter-b", "setpoint_entity": "number.b", "soc_entity": "sensor.soc_b", "batt_entity": "sensor.batt_b", "max_w": 4600, "capacity_kwh": 20.48}] BASE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid", "meter_source": "off", "max_w": 9600, "shadow_mode": "off", "maintenance_enabled": False, "arbitrage_enabled": True, "cycle_cost_threshold_eur": 0.10, "pv_entity": "sensor.pv", "inverters": INV} FC1 = "sensor.energy_production_today_remaining" FCT1 = "sensor.energy_production_tomorrow" DN = {"periods": [{"name": "day", "from": "07:00", "to": "22:00", "days": "all", "eur_per_kwh": 0.2801}, {"name": "night", "from": "22:00", "to": "07:00", "days": "all", "eur_per_kwh": 0.2333}]} class Store: fresh = False 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 [] def day0_of(now): return solarfc._bounds(now)[0] def quarters(now, price_of, days=2): """`days` days of 15-minute prices from local midnight; `price_of(h)`.""" d0 = day0_of(now) pts = [] for i in range(96 * days): t = d0 + timedelta(minutes=15 * i) pts.append(PricePoint(brussels(t), price_of((i % 96) / 4.0), False)) return TariffPlan(PriceRead.series(tuple(pts), PriceProduct.DAY_AHEAD, "generic_dayahead", now), None, "provider") def dyn_price(h): if 1.0 <= h < 5.0: return 0.08 if 11.0 <= h < 15.0: return 0.14 if 17.0 <= h < 21.0: return 0.34 return 0.20 def profile(now, *, day=600.0, evening=1800.0, night=350.0): """A house profile with three full days: the net use per local hour.""" shape = {h: (night if h < 6 or h >= 22 else 900.0 if h < 9 else day if h < 17 else evening) for h in range(24)} d = brussels(now).date() return dayplan.HouseProfile( {(d - timedelta(days=k)).isoformat(): {str(h): w for h, w in shape.items()} for k in (1, 2, 3)}) def sensor(entity, kwh, now, *, periods=None, unit="kWh"): """A forecast sensor's state, as Home Assistant gives it.""" attrs = {"unit_of_measurement": unit} if periods: attrs["wh_period"] = periods return {"entity_id": entity, "state": f"{kwh:.3f}", "attributes": attrs, "last_updated": now.isoformat(), "last_reported": now.isoformat()} def bell_periods(now, day_kwh, *, tomorrow=False): """An hourly split (Open-Meteo's `wh_period`) of a bell, for `now`'s day.""" d0 = day0_of(now) + (timedelta(days=1) if tomorrow else timedelta(0)) out = {} for h in range(24): a = solarfc._bell(h + 1.0) - solarfc._bell(float(h)) out[(d0 + timedelta(hours=h)).isoformat()] = round( a * day_kwh * 1000.0, 1) return out def left_today(now, day_kwh): """What the integration says is left today at `now` (state only).""" d0 = day0_of(now) h = (now - d0).total_seconds() / 3600.0 return day_kwh * (1.0 - solarfc._bell(h)) def forecast(now, today_kwh, tomorrow_kwh, *, split=False, members=1, tomorrow=True): """A `solarfc.Forecast` through `read_sum()`, as the controller reads it. `today_kwh` is the whole day's sun; the sensors say what is left.""" left = left_today(now, today_kwh) ts = [(f"{FC1}_{i}", sensor( f"{FC1}_{i}", left / members, now, periods=(bell_periods(now, today_kwh / members) if split else None))) for i in range(members)] tm = [(f"{FCT1}_{i}", sensor( f"{FCT1}_{i}", tomorrow_kwh / members, now, periods=(bell_periods(now, tomorrow_kwh / members, tomorrow=True) if split else None))) for i in range(members)] if tomorrow else [] fc, why, _gaps = solarfc.read_sum(ts, tm, now) return fc def measure(c, now, peak_w): """The solar this site made today until `now`, one reading a minute.""" d0 = day0_of(now) t = d0 while t <= now: h = (t - d0).total_seconds() / 3600.0 c.solar_today.add(t, peak_w * math.sin(math.pi * (h - 6.0) / 14.0) if 6.0 <= h < 20.0 else 0.0) t += timedelta(minutes=1) def make(name, now, *, soc=(55.0, 40.0), modes=("solar_first", "solar_first")): """The controller of scenario `name` at `now` (UTC), its cycle run once.""" assert name in SCENARIOS, name opts = dict(BASE) plan = quarters(now, dyn_price) sun_today, sun_tomorrow = 18.0, 16.0 members, split, tomorrow = 1, False, True if name == "owner": opts.update(battery_modes_enabled=True, pv_forecast_entities=[f"{FC1}_{i}" for i in range(3)], pv_forecast_tomorrow_entities=[f"{FCT1}_{i}" for i in range(3)], static_profile=json.dumps(DN), arbitrage_enabled=False) plan = static_plan(opts, day0_of(now), day0_of(now) + timedelta( hours=48), now=now) members = 3 elif name in ("dynamic", "sunny", "periods", "learning", "stale", "cycle"): opts.update(battery_modes_enabled=True, pv_forecast_entity=FC1, pv_forecast_tomorrow_entity=FCT1) modes = ("leftover_solar", "cheap_hours") if name == "dynamic": sun_today, sun_tomorrow = 3.0, 3.0 if name == "periods": split = True sun_today, sun_tomorrow = 6.0, 6.0 if name == "cycle": opts.update(maintenance_enabled=True) elif name == "off": opts.update(battery_modes_enabled=False, arbitrage_enabled=True) elif name == "nofc": opts.update(battery_modes_enabled=True) modes = ("leftover_solar", "cheap_hours") elif name == "notomorrow": opts.update(battery_modes_enabled=True, pv_forecast_entity=FC1, static_profile=json.dumps(DN), arbitrage_enabled=False) plan = static_plan(opts, day0_of(now), day0_of(now) + timedelta( hours=48), now=now) tomorrow = False if name == "sunny": sun_today, sun_tomorrow = 40.0, 40.0 if name not in ("nofc", "off", "stale") and \ opts.get("battery_modes_enabled"): # TARIFF-24: with a forecast the gap needed is the cycle cost, not # 1.5 x it. These scenarios were written for the old gate (0.15 at # the shipped 0.10), so they keep it: the cost is 0.15. opts["cycle_cost_threshold_eur"] = 0.15 c = Controller(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 if opts.get("battery_modes_enabled"): c.store.data[unit_key(inv.name, packmode.MODE_KEY)] = modes[i] c.grid, c.pv_w = 300.0, 0.0 c.tariff_plan, c.tariff_at = plan, now c.peak_guard = PeakDecision(engaged=False, floor_w=None, ceiling_w=7000.0, ceiling_from="cap", projected_w=800.0) c.arb_headroom_w = 6000.0 if name not in ("nofc", "off", "stale") and \ opts.get("battery_modes_enabled"): c.solar_fc = forecast(now, sun_today, sun_tomorrow, split=split, members=members, tomorrow=tomorrow) c.solar_fc_why = None if name != "learning": c.house_profile = profile(now) if name in ("owner", "dynamic", "sunny", "periods", "learning", "notomorrow", "cycle"): measure(c, now, 7000.0 if name != "dynamic" else 2500.0) if opts.get("battery_modes_enabled"): c.tariff_at = now c._mode_roles(now, None) return c