With master merged in (TARIFF-24), a forecast replaces the 1.5x widening, so the shipped cost 0.10 is now the whole gap with a forecast. test_ux54.py's modes-on scenarios were written for 0.15 and 21 of its checks failed on release/1.0 + master (they pass on release/1.0 alone). They now set the cost to 0.15 where they have a forecast: the same gate as before. Scenarios with no forecast (nofc, stale) and modes off keep 0.10, widened as before. Co-Authored-By: Claude <noreply@anthropic.com>
282 lines
10 KiB
Python
282 lines
10 KiB
Python
"""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
|