Compare commits

..
Author SHA1 Message Date
glenn schrooyenandClaude d2820e8b9b SAFETY-123: the day plan's hour loop steps in UTC and always ends
checks / unit (pull_request) Successful in 48m40s
infra-pin / fresh (pull_request) Successful in 2s
dayplan._hour_slices() added an hour to the local wall clock and read it
back. Without tzdata (tariff._EUTimezone) 02:00 on the spring change reads
as 00:00 UTC again, so the loop never advanced and the list grew until the
process died. Each step now adds the time left in the local hour to the UTC
instant. Ordinary days give the old slices; the spring day gives 23 slices
and the autumn day 25. No option, no text change.

test_safety123.py: every quarter of 27-29 March and 30 Oct-1 Nov 2027 (and
the US change days) for the slices, dayplan.plan() and
Controller._mode_roles(), each call under a hard time limit.

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-10-27 08:58:54 +01:00
admin a1d07030f8 Merge pull request 'UX-53: Home draws a grid charge only where the add-on will take it' (#209) from UX-53 into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-10-26 13:59:22 +01:00
glenn schrooyenandClaude Opus 5.5 5dfe9720c5 UX-53 review: DECISIONS text, day/night weekend case and peak guard limit
checks / unit (pull_request) Successful in 48m2s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:10:49 +01:00
glenn schrooyenandClaude 242cf434f4 UX-53: Home draws a grid charge only where the add-on will take it
checks / unit (pull_request) Successful in 48m7s
infra-pin / fresh (pull_request) Successful in 2s
Modes on: Controller._mode_roles() files the trade gate it just asked as
mode_gate; while it refuses, Home draws no grid charge and marks no cheap
quarter. Modes off: a charge after the last dear period is asked of the same
gate at its own start (homeview.leg_taken) and dropped when refused there.
Words: "Lowest/Highest price left" (EN, NL); a flat remainder on a dynamic or
day/night site is no longer called a fully fixed product.

No change to control decisions, commands or options.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-26 11:15:29 +01:00
admin 514256923b Merge pull request 'Release 0.19.0' (#207) from release-0.18.3 into release/1.0
infra-pin / fresh (push) Successful in 1s
checks / unit (pull_request) Successful in 48m38s
infra-pin / fresh (pull_request) Successful in 1s
2026-10-26 03:10:27 +01:00
11 changed files with 1249 additions and 28 deletions
@@ -0,0 +1,35 @@
# SAFETY-123 - the day plan's hour loop steps in UTC
**Status: for review.** Tests: `test_safety123.py`. No option, no text change.
`dayplan._hour_slices()` stepped to the next local hour by adding an hour to
the local wall clock and converting back. Where the zone has no tzdata
(`tariff._EUTimezone`: the CI image, a dev host, an image that loses tzdata)
the missing 02:00 of the spring change reads as summer time, which is
00:00 UTC again. At 28 March 2027 00:00 UTC `t` did not move and the list
grew until the process died. Control and the web page share one event loop,
so the whole add-on froze. `dayplan.plan()` calls it on every plan, before it
looks at a battery's mode, so a site with all batteries on Solar first reaches
it as well, if the prices give a plan.
The shipped image has tzdata (`amd64-base:3.20`), where this zone gives the
right answer, so a running add-on is not known to be hit. The fix is for any
host without it. (AUDIT-01 F4, card SAFETY-120, is the same defect.)
**Decision.** Each step adds the time left in the local hour to the UTC
instant. It is at least a microsecond, so `t` always advances and the loop
ends, in any zone, with or without tzdata. A day of 23 hours gives 23 slices
and a day of 25 hours gives 25, each at most an hour, none across a local
hour. On ordinary days the slices are the old ones.
**One change on the autumn day.** The old function (with tzdata) made one
two-hour slice for the doubled hour 02:00-03:00; it now makes two of an hour,
both labelled 2. `plan()` sums per slice, so only a forecast with sun in that
hour could tell the difference. There is none.
**Other loops.** The other code that walks local hours or days steps in UTC
or on the calendar date (`static_tariff`, `price_forecast`, `price_history`,
`price`, `maintenance`, `homeview.local_day`, `solarfc._bounds`), or has a
fixed bound. None can stall. One known inexactness stays, outside this fix:
`solarfc` spreads a state-only forecast over hours since local midnight, so on
the two change days its sun curve is an hour off the clock.
@@ -0,0 +1,40 @@
# UX-53 - Home draws a grid charge only where the add-on will take it
**Status: DONE 2026-10-26 (owner go 2026-10-25, from the PRICE-02 rig test F-1 and F-2).**
## Decision
Home asks the add-on's own gate, and does not decide for itself.
- **Modes on.** `Controller._mode_roles()` files the gate it just asked
(`TariffPlan.arbitrage()` with the measured peak guard) as `mode_gate`: None
when it allowed a charge, else its reason (`peak`, `not_worth`,
`not_covered`). While it is set, `homeview.mode_runs()` draws no grid charge
and `price_windows()` marks no cheap quarter. Control never reads it. A
refusal that is not the gate (no measured headroom, a battery cycling) is
not filed, so those pages are as before.
- **Modes off.** A charge the verdict puts after its last dear period is
refused when its time comes: the quarters left are all equal. `leg_taken()`
asks `trade_verdict()` (the control cycle's own call) at the leg's start and
keeps the leg only if the gate allows it there. A leg in progress is the
verdict's own answer now. The sale, "Tomorrow:" lines and chart marks use
the same answer.
- **Words.** "Lowest/Highest published price" becomes "Lowest/Highest price
left" (the verdict reads only the periods not yet over). The flat-tariff
refusal calls a site "a fully fixed product" only when its product is fixed.
## What does not change
No control decision, command or option. Day/night and hourly sites with a
second day of prices keep both legs at every half hour of a day
(`test_ux53.py` part 4), except where every price left is equal: with an
all-night weekend, Friday's 22:00 charge is no longer drawn, as the add-on
does not take it. Price-off sites draw nothing, as before.
## Limits
The leg's gate is asked on today's plan, as the control cycle will find it at
that time. A plan that gets new prices in between is drawn again at the next
refresh. While the peak guard holds, a modes-on page shows no grid charge at
all, also not the rest of a run the add-on charges in once the guard clears,
as the modes-off page already did.
+6 -4
View File
@@ -157,13 +157,15 @@ class Plan:
def _hour_slices(t0, t1):
"""Local-hour slices of [t0, t1): (start, end, local hour)."""
"""Local-hour slices of [t0, t1): (start, end, local hour).
Steps in UTC, so `t` always advances (SAFETY-123, DECISIONS)."""
out, t = [], t0
while t < t1:
lt = _local(t)
nxt = (lt.replace(minute=0, second=0, microsecond=0)
+ timedelta(hours=1)).astimezone(timezone.utc)
e = min(nxt, t1)
left = timedelta(minutes=60 - lt.minute) - timedelta(
seconds=lt.second, microseconds=lt.microsecond)
e = min(t.astimezone(timezone.utc) + left, t1)
out.append((t, e, lt.hour))
t = e
return out
+40 -17
View File
@@ -212,25 +212,26 @@ def _price_mode_in_use(c):
return False
def price_windows(c, plan, verdict):
def price_windows(c, plan, verdict, now=None):
"""`[(kind, start, end)]`: where the add-on itself buys ("cheap") and
gives ("dear"). With battery modes on that is the kept `priceruns.RunPlan`
(`c.mode_runs`): the cheap run when charging in it pays, and every give
run. With them off it is the arbitrage verdict's two points, the ones
`trade_runs()` draws. Nothing is classified here."""
(`c.mode_runs`): the cheap run when charging in it pays and the trade gate
allows it (UX-53), and every give run. With them off it is the arbitrage
verdict's legs the add-on will take, the ones `trade_runs()` draws
(`trade_legs()`, given `now`). Nothing is classified here."""
if getattr(c, "modes_on", False):
rp = getattr(c, "mode_runs", None)
if rp is None or not rp.on or not _price_mode_in_use(c):
return []
out = [("cheap", rp.cheap.start, rp.cheap.end)] \
if rp.charge_useful() else []
if rp.charge_useful() and getattr(c, "mode_gate", None) is None \
else []
return out + [("dear", g.start, g.end) for g in rp.give]
if verdict is None:
return []
return [("cheap", verdict.cheapest.start,
_point_end(plan, verdict.cheapest)),
("dear", verdict.dearest.start,
_point_end(plan, verdict.dearest))]
return [("cheap" if kind == "grid" else "dear", pt.start,
_point_end(plan, pt))
for kind, pt in trade_legs(c, plan, verdict, now)]
def day_range(points):
@@ -306,6 +307,27 @@ def _point_end(plan, point):
return point.start + timedelta(hours=1)
def leg_taken(c, plan, pt, now):
"""UX-53: will the add-on act on this point of the verdict? One in
progress is the verdict's own answer now. One still ahead is asked of the
same gate (`trade_verdict()`, the call the control cycle makes) at the
point's own start. A charge that comes after the last dear period meets a
flat remainder there and is refused, so it is not announced. When the gate
allows it, the point is still the cheapest (dearest) one: the points
before it are gone and it was the first extreme of the points from now."""
if now is None or pt.start <= now:
return True
return trade_verdict(c, plan, pt.start)[0] is not None
def trade_legs(c, plan, verdict, now):
"""`[(kind, point)]` by start: the verdict's charge ("grid") and give
("give") legs that the add-on will take (`leg_taken()`)."""
legs = sorted((("grid", verdict.cheapest), ("give", verdict.dearest)),
key=lambda kp: kp[1].start)
return [(k, pt) for k, pt in legs if leg_taken(c, plan, pt, now)]
# Under this many kWh a block is not drawn: the cycle would not move it.
MIN_KWH = 0.05
@@ -322,10 +344,8 @@ def trade_runs(c, plan, verdict, day0, day1, now, shadow):
if any(inv.soc is None for inv in c.inverters):
return [], [], "no_level"
level = [float(inv.soc) for inv in c.inverters]
legs = sorted((("grid", verdict.cheapest), ("give", verdict.dearest)),
key=lambda kp: kp[1].start)
runs, later = [], []
for kind, pt in legs:
for kind, pt in trade_legs(c, plan, verdict, now):
end = _point_end(plan, pt)
if end <= now:
continue
@@ -371,8 +391,9 @@ def mode_runs(c, day0, day1, now, shadow):
them (`c.mode_runs`): the cheap run on the batteries that may charge
from the grid, the give runs on Leftover solar and Cheap hours, and the
sell run on those with selling on. Amounts are upper bounds ("up to"),
from the battery's own room, rating and the run's length. Returns
`(runs, no_trade_key)`."""
from the battery's own room, rating and the run's length. UX-53: no grid
charge is drawn while the trade gate refuses one (`c.mode_gate`, what the
control cycle found). Returns `(runs, no_trade_key)`."""
rp = getattr(c, "mode_runs", None)
if getattr(c, "mode_tick", None) is None:
return [], None # no cycle yet: nothing is known
@@ -381,6 +402,7 @@ def mode_runs(c, day0, day1, now, shadow):
if not rp.on:
return [], "not_worth"
floor, ceiling = arbitrage.soc_bounds(c.o)
gate = getattr(c, "mode_gate", None)
out = []
for i, inv in enumerate(c.inverters):
pm = c.pack_mode(inv)
@@ -397,8 +419,9 @@ def mode_runs(c, day0, day1, now, shadow):
blocks = []
# P4: with a day plan, the grid block is the planned shortfall.
need = plan_need(c, inv.name)
if rp.charge_useful() and (need > 0.0 if need is not None
else pm.mode in GRID_CHARGE_MODES):
if gate is None and rp.charge_useful() and (
need > 0.0 if need is not None
else pm.mode in GRID_CHARGE_MODES):
blocks.append(("grid", rp.cheap))
sell = rp.sell if pm.sell and pm.can_sell else None
for g in rp.give:
@@ -608,7 +631,7 @@ def build_plan(c, now, shadow, running, held=False, settled=True):
runs, lines = [], []
# The control cycle trades on the fresh plan only, so this does too.
verdict, trade_why = trade_verdict(c, fresh, now)
bars = price_bars(seen, day0, day1, price_windows(c, fresh, verdict),
bars = price_bars(seen, day0, day1, price_windows(c, fresh, verdict, now),
now, days.kinds)
modes_on = bool(getattr(c, "modes_on", False))
if running:
+5
View File
@@ -1001,6 +1001,10 @@ class Controller:
self.mode_runs = None
self._runs_key = None
self.mode_tick = None
# UX-53: why the trade gate refused a grid charge this tick (`peak`,
# `not_worth`, `not_covered`), or None when it allowed one. Home reads
# it; nothing in control does.
self.mode_gate = None
self._run_done = {}
self._mode_trade_was = False
self._charge_blocked_q = None
@@ -2380,6 +2384,7 @@ class Controller:
charge_ok = sell_ok and allowed(guard)
trade_why = (None if charge_ok else "peak" if sell_ok
else "not_worth")
self.mode_gate = None if charge_ok else trade_why
price_now = None
if plan is not None:
try:
+6 -2
View File
@@ -1103,8 +1103,12 @@ class TariffPlan:
f"the tariff is flat - every one of the {len(firm)} firm "
f"points, spanning {_whole_hours(firm)} hours, costs "
f"{firm[0].eur_per_kwh:.4f} EUR/kWh, so buying low and "
f"selling high is arithmetically impossible. §9.2: a fully "
f"fixed product is self-consumption only")
f"selling high is arithmetically impossible"
# UX-53: only a real fixed product is "fully fixed". A
# dynamic or day/night site whose remaining periods happen to
# be equal trades again when they differ.
+ (". §9.2: a fully fixed product is self-consumption only"
if self.product is PriceProduct.FIXED else ""))
# REFUSAL 5, NEW IN FCST-03 (AC3). One widening path, driven by the set
# of active uncertainty sources: the product's own (§9.2 row 2, derived
+2 -2
View File
@@ -367,11 +367,11 @@
"run": {
"grid_what": "Charges up to {kwh} from the grid.",
"grid_what_would": "Would charge up to {kwh} from the grid.",
"grid_why": "Lowest published price, {eur} per kWh.",
"grid_why": "Lowest price left, {eur} per kWh.",
"grid_amount": "Costs up to {eur}.",
"give_what": "Gives up to {kwh}, to the house first.",
"give_what_would": "Would give up to {kwh}, to the house first.",
"give_why": "Highest published price, {eur} per kWh.",
"give_why": "Highest price left, {eur} per kWh.",
"sell_what": "Sells up to {kwh}, to the house first.",
"sell_what_would": "Would sell up to {kwh}, to the house first.",
"cheap_why": "Cheapest hours, about {eur} per kWh.",
+2 -2
View File
@@ -307,11 +307,11 @@
"run": {
"grid_what": "Laadt tot {kwh} van het net.",
"grid_what_would": "Zou tot {kwh} van het net laden.",
"grid_why": "Laagste gepubliceerde prijs, {eur} per kWh.",
"grid_why": "Laagste prijs die nog over is, {eur} per kWh.",
"grid_amount": "Kost tot {eur}.",
"give_what": "Levert tot {kwh}, eerst aan het huis.",
"give_what_would": "Zou tot {kwh} leveren, eerst aan het huis.",
"give_why": "Hoogste gepubliceerde prijs, {eur} per kWh.",
"give_why": "Hoogste prijs die nog over is, {eur} per kWh.",
"sell_what": "Verkoopt tot {kwh}, eerst aan het huis.",
"sell_what_would": "Zou tot {kwh} verkopen, eerst aan het huis.",
"cheap_why": "Goedkoopste uren, ongeveer {eur} per kWh.",
+1 -1
View File
@@ -523,7 +523,7 @@ _lo = min(_q[:96], key=lambda p: p.eur_per_kwh)
check("quarter-hour prices: the charge block, its price and the price line's "
"lowest are the same 15-minute period",
_grid and _grid[0]["from"] == homeview.hhmm(_lo.start)
and _grid[0]["why"] == f"Lowest published price, €{_lo.eur_per_kwh:.2f} "
and _grid[0]["why"] == f"Lowest price left, €{_lo.eur_per_kwh:.2f} "
"per kWh."
and f"Today: lowest €{_lo.eur_per_kwh:.2f} at "
f"{homeview.hhmm(_lo.start)}," in s_q["plan"]["price_line"]
+523
View File
@@ -0,0 +1,523 @@
"""SAFETY-123: the day plan's local-hour loop ends on the clock-change days.
`python3 test_safety123.py`
On a host with no tzdata (`tariff._EUTimezone`) `dayplan._hour_slices()` never
returned on the spring clock change (28 March 2027, 00:00 UTC): the control
tick froze. Graded here, each call under a hard time limit (SIGALRM):
1. `_hour_slices()` over 27-29 March and 30 Oct - 1 Nov 2027, and the US
change days, at every quarter, in the no-tzdata zone, a US-rule zone built
the same way, and (where the host has tzdata) Europe/Brussels,
America/New_York and zones with half-hour changes: it ends, covers the
span, never crosses a local hour, labels the local hour, and gives 23
slices on the spring day and 25 on the autumn day.
2. Normal days: the same slices as release/1.0 a1d0703 (a copy of its
function is kept below as the reference).
3. `dayplan.plan()` at every quarter of those days with each pack mode, and
its answer on normal days equal to the old function's.
4. `Controller._mode_roles()` at every quarter of those days with modes on,
for Solar first, Leftover solar and Cheap hours, and one full control
cycle through the forecast read at the hang's instants.
See DECISIONS/SAFETY-123-hour-slices-step-in-utc.md.
Mutating what this file grades? Read MUTATION.md first (stale bytecode).
"""
import asyncio
import logging
import os
import resource
import signal
import sys
from datetime import date, datetime, timedelta, timezone, tzinfo
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 opt11_identity as rig # noqa: E402
import app.main as M # noqa: E402
from app import dayplan, modetrade, packmode, priceruns, solarfc # noqa: E402
from app.price import PricePoint, PriceProduct, PriceRead, brussels # noqa
from app.static_tariff import TariffPlan # noqa: E402
from app.store import unit_key # noqa: E402
from app.tariff import BILLING_TZ, _EUTimezone # noqa: E402
UTC = timezone.utc
fails = []
LIMIT_S = 3.0 # one call; the old loop never returned
try: # a loop that appends without end must not take the host
resource.setrlimit(resource.RLIMIT_AS, (3 << 29, resource.getrlimit(
resource.RLIMIT_AS)[1]))
except (ValueError, OSError):
pass
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
class Slow(Exception):
pass
def _alarm(*_a):
raise Slow()
def limited(fn, *a, **k):
"""`fn(*a, **k)` or `Slow` when it takes more than LIMIT_S seconds."""
signal.signal(signal.SIGALRM, _alarm)
signal.setitimer(signal.ITIMER_REAL, LIMIT_S)
try:
return fn(*a, **k)
except MemoryError:
raise Slow() from None
finally:
signal.setitimer(signal.ITIMER_REAL, 0)
# --------------------------------------------------------------------------- #
# The reference: release/1.0 a1d0703's function, verbatim, for normal days
# --------------------------------------------------------------------------- #
def old_slices(t0, t1):
out, t = [], t0
while t < t1:
lt = dayplan._local(t)
nxt = (lt.replace(minute=0, second=0, microsecond=0)
+ timedelta(hours=1)).astimezone(timezone.utc)
e = min(nxt, t1)
out.append((t, e, lt.hour))
t = e
return out
# --------------------------------------------------------------------------- #
# Zones
# --------------------------------------------------------------------------- #
def _nth_sunday(year, month, n):
d = date(year, month, 1)
d += timedelta(days=(6 - d.weekday()) % 7 + 7 * (n - 1))
return datetime(d.year, d.month, d.day)
class USZone(tzinfo):
"""America/New_York by the US rule (DST from the second Sunday of March
02:00 to the first Sunday of November 02:00, local), built like
tariff._EUTimezone: the offset is read from the local wall clock, so the
missing 02:00 reads as DST. Its change days are not the EU's."""
def _dst_local(self, naive):
y = naive.year
return (_nth_sunday(y, 3, 2).replace(hour=2) <= naive
< _nth_sunday(y, 11, 1).replace(hour=2))
def _dst_utc(self, naive_utc):
y = naive_utc.year
return (_nth_sunday(y, 3, 2).replace(hour=7) <= naive_utc
< _nth_sunday(y, 11, 1).replace(hour=6))
def fromutc(self, dt):
return dt + timedelta(
hours=-4 if self._dst_utc(dt.replace(tzinfo=None)) else -5)
def utcoffset(self, dt):
if dt is None:
return timedelta(hours=-5)
return timedelta(hours=-4 if self._dst_local(
dt.replace(tzinfo=None)) else -5)
def dst(self, dt):
return timedelta(hours=1) if dt is not None and self._dst_local(
dt.replace(tzinfo=None)) else timedelta(0)
def _q(y, mo, d, h=0):
return datetime(y, mo, d, h, tzinfo=UTC)
# (name, tzinfo, whole-hour offsets?, windows [from, to) at every quarter)
BRU_SPRING = (_q(2027, 3, 27), _q(2027, 3, 30))
BRU_AUTUMN = (_q(2027, 10, 30), _q(2027, 11, 2))
US_SPRING = (_q(2027, 3, 13), _q(2027, 3, 16))
US_AUTUMN = (_q(2027, 11, 6), _q(2027, 11, 9))
COMMON = [BRU_SPRING, BRU_AUTUMN, US_SPRING, US_AUTUMN]
ZONES = [("no-tzdata EU rule", _EUTimezone(), True, COMMON),
("US rule", USZone(), True, COMMON)]
try:
from zoneinfo import ZoneInfo
for _n, _w, _extra in (
("Europe/Brussels", True, []),
("America/New_York", True, []),
# DST of half an hour, and clocks that jump at midnight
("Australia/Lord_Howe", False,
[(_q(2027, 4, 3), _q(2027, 4, 6)),
(_q(2027, 10, 2), _q(2027, 10, 5))]),
("America/Santiago", True,
[(_q(2027, 4, 2), _q(2027, 4, 5)),
(_q(2027, 9, 4), _q(2027, 9, 7))]),
("Asia/Kathmandu", False, [])):
try:
ZONES.append((_n, ZoneInfo(_n), _w, COMMON + _extra))
except Exception: # noqa: BLE001
print(f" no tzdata for {_n} here: not graded")
except ImportError:
print(" no zoneinfo here")
print(" zones graded:", ", ".join(z[0] for z in ZONES))
class In:
"""`dayplan.BILLING_TZ` is `tz` inside the with-block."""
def __init__(self, tz):
self.tz = tz
def __enter__(self):
self.was = dayplan.BILLING_TZ
dayplan.BILLING_TZ = self.tz
def __exit__(self, *_a):
dayplan.BILLING_TZ = self.was
def quarters(a, b):
t = a
while t < b:
yield t
t += timedelta(minutes=15)
# ============================================================================ #
print("1. _hour_slices(): ends, covers, local hours, 23 and 25 hours")
HOUR = timedelta(hours=1)
for zname, tz, whole, windows in ZONES:
bad, slow, n = [], None, 0
with In(tz):
for a, b in windows:
for t0 in quarters(a, b):
for span in (HOUR, 7 * HOUR, 30 * HOUR):
t1 = t0 + span
n += 1
try:
sl = limited(dayplan._hour_slices, t0, t1)
except Slow:
slow = (t0, t1)
break
ok = bool(sl) and sl[0][0] == t0 and sl[-1][1] == t1
ok = ok and all(x[1] == y[0] for x, y in zip(sl, sl[1:]))
for s, e, h in sl:
ok = ok and timedelta(0) < e - s <= HOUR
lt = s.astimezone(tz)
ok = ok and h == lt.hour
last = (e - timedelta(microseconds=1)).astimezone(tz)
# no local hour boundary inside the slice
ok = ok and (lt.year, lt.month, lt.day, lt.hour) == (
last.year, last.month, last.day, last.hour)
# it ends on a local hour, the span's end, or where
# the clock itself jumps (half-hour changes)
el = e.astimezone(tz)
ok = ok and (e == t1 or el.minute == 0
or el.utcoffset() != lt.utcoffset())
if whole:
# whole-hour zones: local hours are UTC hours
ok = ok and (e.minute == 0 or e == t1) \
and (s.minute == 0 or s == t0)
if not ok:
bad.append((t0, t1))
if slow:
break
if slow:
break
check(f"{zname}: every quarter x 1, 7, 30 h ({n} spans) returns within "
f"{LIMIT_S:.0f} s" + ("" if not slow else f" - HUNG at {slow[0]}"),
slow is None)
check(f"{zname}: ...contiguous from t0 to t1, each slice at most an "
f"hour, labelled with its local hour, none across a local hour",
not bad)
# The change days: 23 and 25 local hours, from local midnight to midnight.
# (spring day, autumn day, the local hour that happens twice in autumn)
BRU_DAYS = ((2027, 3, 28), (2027, 10, 31), 2)
US_DAYS = ((2027, 3, 14), (2027, 11, 7), 1)
DAYS = {"no-tzdata EU rule": BRU_DAYS, "Europe/Brussels": BRU_DAYS,
"US rule": US_DAYS, "America/New_York": US_DAYS}
def local_day(tz, y, m, d):
d0 = datetime(y, m, d, tzinfo=tz).astimezone(UTC)
n = date(y, m, d) + timedelta(days=1)
return d0, datetime(n.year, n.month, n.day, tzinfo=tz).astimezone(UTC)
for zname, tz, _w, _win in ZONES:
if zname not in DAYS:
continue
spring, autumn, twice = DAYS[zname]
with In(tz):
for what, (y, m, d), want, want_hours in (
("spring", spring, 23, [h for h in range(24) if h != 2]),
("autumn", autumn, 25,
[h for h in range(24) for _ in range(2 if h == twice else 1)])):
d0, d1 = local_day(tz, y, m, d)
try:
sl = limited(dayplan._hour_slices, d0, d1)
except Slow:
sl = None
check(f"{zname}, {what} day {y}-{m:02d}-{d:02d}: {want} one-hour "
f"slices from local midnight to midnight",
sl is not None and len(sl) == want
and all(e - s == HOUR for s, e, _h in sl)
and d1 - d0 == want * HOUR and sl[0][0] == d0
and sl[-1][1] == d1)
check(f"{zname}, {what} day: the local hours are "
f"{'0-23 without 2' if what == 'spring' else f'0-23 with {twice} twice'}",
sl is not None and [h for _s, _e, h in sl] == want_hours)
# ============================================================================ #
print("2. normal days: the same slices as release/1.0 a1d0703")
NORMAL = [(_q(2027, 1, 9), _q(2027, 1, 12)), (_q(2027, 6, 18), _q(2027, 6, 21)),
(_q(2027, 8, 30), _q(2027, 9, 2)), (_q(2027, 12, 18), _q(2027, 12, 21))]
for zname, tz, _w, _win in ZONES:
diff, n = [], 0
with In(tz):
for a, b in NORMAL:
for t0 in quarters(a, b):
for off in (timedelta(0), timedelta(seconds=7, microseconds=5),
timedelta(minutes=14, seconds=59, microseconds=999999),
timedelta(minutes=37, seconds=1)):
for span in (timedelta(minutes=1), timedelta(minutes=50),
3 * HOUR, 26 * HOUR):
s0 = t0 + off
n += 1
if dayplan._hour_slices(s0, s0 + span) != old_slices(
s0, s0 + span):
diff.append(s0)
check(f"{zname}: {n} spans on ordinary days equal the old slices "
f"(including starts off the quarter)", n > 1000 and not diff)
with In(ZONES[0][1]):
s0 = _q(2027, 1, 10, 9)
eq = dayplan._hour_slices(s0, s0) == [] == old_slices(s0, s0) \
and dayplan._hour_slices(s0, s0 - HOUR) == []
check("an empty or backwards span gives no slices, as before", eq)
plus2 = timezone(timedelta(hours=2))
t0 = datetime(2027, 6, 1, 10, 20, tzinfo=plus2)
check("a start given in another zone still gives the old slices",
dayplan._hour_slices(t0, t0 + 3 * HOUR) ==
old_slices(t0, t0 + 3 * HOUR))
# ============================================================================ #
print("3. dayplan.plan() at every quarter of the change days")
def st(kwh, at):
return {"state": str(kwh), "attributes": {"unit_of_measurement": "kWh"},
"last_updated": at.isoformat(), "last_reported": at.isoformat()}
def cheap_dear(h):
return 0.08 if 1.0 <= h < 5.0 else 0.34 if 17.0 <= h < 21.0 else 0.20
def price_plan(now, f=cheap_dear, days=4):
"""15-minute firm prices from a day before `now` for `days` days."""
start = (now - timedelta(days=1)).replace(minute=0, second=0,
microsecond=0)
pts = []
for i in range(days * 96 + 96):
b = brussels(start + timedelta(minutes=15 * i))
pts.append(PricePoint(start=b, eur_per_kwh=f(b.hour + b.minute / 60),
provisional=False))
return TariffPlan(PriceRead.series(pts, PriceProduct.DAY_AHEAD,
"generic_dayahead", now), None,
"provider")
_PLANS = {}
def plan_for(start):
"""One price plan covering `start` - 1 day .. + 5 days, kept: building it
for every quarter is most of the run time."""
if start not in _PLANS:
_PLANS[start] = price_plan(start, days=6)
return _PLANS[start]
HOUSE = dayplan.HouseProfile({"2027-03-20": {
str(h): (3000.0 if 17 <= h < 22 else 600.0) for h in range(24)}})
MODES = {"Solar first": (packmode.SOLAR_FIRST, packmode.SOLAR_FIRST),
"Leftover solar": (packmode.SOLAR_FIRST, packmode.LEFTOVER_SOLAR),
"Cheap hours": (packmode.CHEAP_HOURS, packmode.CHEAP_HOURS)}
def packs_of(modes, soc=(55.0, 40.0)):
return [modetrade.Pack(f"inverter-{u}", True, mode, False, 2500.0, s, 10.0)
for u, mode, s in zip("ab", modes, soc)]
_INPUTS = {}
def plan_at(now, modes, start=None):
"""plan() on the price runs and forecast of `now` (made once per quarter:
the runs do not depend on the pack modes)."""
if now not in _INPUTS:
pts = plan_for(start or now.replace(hour=0, minute=0)).read.points
_INPUTS[now] = (
priceruns.plan(pts, now, required_eur_kwh=0.1, charge_kwh=8.0,
charge_kw=5.0, sell_kwh=0.0, sell_kw=0.0),
solarfc.read(st(2.0, now), st(8.0, now), now)[0])
runs, fc = _INPUTS[now]
return dayplan.plan(fc, HOUSE.estimator(), runs, now, packs_of(modes),
trust_pct=70, floor_pct=20, ceiling_pct=95)
spy = []
_slices = dayplan._hour_slices
dayplan._hour_slices = lambda a, b: (spy.append((a, b)), _slices(a, b))[1]
try:
for mname, modes in MODES.items():
for wname, (a, b) in (("27-29 March", BRU_SPRING),
("30 Oct-1 Nov", BRU_AUTUMN)):
made, slow, n = 0, None, 0
spy.clear()
for now in quarters(a, b):
n += 1
try:
p = limited(plan_at, now, modes, a)
except Slow:
slow = now
break
made += p is not None
check(f"plan(), {mname}, {wname}: {n} quarters return within "
f"{LIMIT_S:.0f} s" + ("" if not slow else
f" - HUNG at {slow}")
+ f"; a plan is made at {made} of them", slow is None
and made > 20)
if wname == "27-29 March" and not slow:
cross = [s for s in spy if s[0] <= _q(2027, 3, 28) < s[1]]
check(f"plan(), {mname}: {len(cross)} spans ran across the "
"change (28 March 00:00 UTC): the test reaches the "
"loop", len(cross) > 20)
finally:
dayplan._hour_slices = _slices
# The answer on normal days is the old function's.
for mname, modes in MODES.items():
same, n = True, 0
for now in quarters(_q(2027, 1, 11), _q(2027, 1, 13)):
new = plan_at(now, modes)
dayplan._hour_slices = old_slices
try:
was = plan_at(now, modes)
finally:
dayplan._hour_slices = _slices
n += 1
same = same and new == was
check(f"plan(), {mname}: {n} quarters on ordinary days equal the old "
"answer", same)
# ============================================================================ #
print("4. Controller._mode_roles() at every quarter, modes on")
def controller(modes, now):
extra = {"battery_modes_enabled": True, "arbitrage_enabled": True,
"cycle_cost_threshold_eur": 0.05,
"pv_forecast_entities": ["sensor.t1", "sensor.t2", "sensor.t3"],
"pv_forecast_tomorrow_entities": ["sensor.m1", "sensor.m2",
"sensor.m3"]}
opts = rig._options(2, extra)
for i in opts["inverters"]:
i["capacity_kwh"] = 10.0
store = rig._Store()
for u, mode in zip("ab", modes):
store.data[unit_key(f"inverter-{u}", "pack_mode")] = mode
store.data["house_profile"] = {"2027-03-20": {
str(h): (3000.0 if 17 <= h < 22 else 600.0) for h in range(24)}}
class Hass(rig._Hass):
async def state(self, e):
if e.startswith(("sensor.t", "sensor.m")):
return st(2.0 if e.startswith("sensor.t") else 8.0, self.at)
return await super().state(e)
hass = Hass(("a", "b"), lambda u, t: 55.0 if u == "a" else 40.0,
lambda u, t: False)
hass.at = now
c = M.Controller(opts, hass, store, rig._Pub(), stop=asyncio.Event())
for i in c.inverters:
i.dev_min, i.dev_max = -5000.0, 5000.0
i.soc = 55.0 if i.name.endswith("a") else 40.0
c.auto = True
return c, hass
for mname, modes in MODES.items():
for wname, (a, b) in (("27-29 March", BRU_SPRING),
("30 Oct-1 Nov", BRU_AUTUMN)):
c, hass = controller(modes, a)
slow, n, made, roles = None, 0, 0, set()
spy.clear()
dayplan._hour_slices = lambda x, y: (spy.append((x, y)),
_slices(x, y))[1]
try:
for now in quarters(a, b):
n += 1
c.tariff_plan, c.tariff_at = plan_for(a), now
c.solar_fc, _w, _g = solarfc.read_sum(
[("sensor.t1", st(2.0, now))],
[("sensor.m1", st(8.0, now))], now)
try:
t = limited(c._mode_roles, now, None)
except Slow:
slow = now
break
made += c.day_plan is not None
roles |= set(t.roles.values())
finally:
dayplan._hour_slices = _slices
check(f"_mode_roles(), {mname}, {wname}: {n} quarters return within "
f"{LIMIT_S:.0f} s" + ("" if not slow else f" - HUNG at {slow}"),
slow is None)
check(f"...a day plan is made at {made} of them (the loop is "
f"reached), roles seen {sorted(roles)}", slow is None
and made > 20 and len(spy) > 20)
# One whole control cycle through the real forecast read (three sensors a
# day) at the instants the old loop hung: the page and control share the loop.
for iso in ("2027-03-27T21:00:05+00:00", "2027-03-27T23:45:05+00:00",
"2027-03-28T00:00:05+00:00"):
now = datetime.fromisoformat(iso)
c, hass = controller(MODES["Leftover solar"], now)
real_dt = M.datetime
class _DT(real_dt):
@classmethod
def now(cls, tz=None, _n=now):
return _n if tz is None else _n.astimezone(tz)
M.datetime = _DT
try:
async def one():
await c.read_inputs(now_mono=0.0)
c.tariff_plan, c.tariff_at = price_plan(now), now
await c.cycle()
try:
limited(lambda: asyncio.run(one()))
ok = c.solar_fc is not None and c.day_plan is not None
except Slow:
ok = False
finally:
M.datetime = real_dt
check(f"a whole control cycle at {iso[:16]}Z (three forecast sensors a "
"day) returns with a day plan", ok)
print()
if fails:
print(f"{len(fails)} FAILED:")
for f in fails:
print(" -", f)
sys.exit(1)
print("all ok")
+589
View File
@@ -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)