UX-54 round 3: tomorrow's blocks last as long as control acts, give blocks checked at run start
checks / unit (pull_request) Successful in 48m22s
infra-pin / fresh (pull_request) Successful in 1s

- Tomorrow's grid block is drawn to where control stops charging. A cheap run
  is sized for all batteries together; a slower one is still below
  `charge_to()` when it ends and control plans the next cheap slots. The tab
  asks `modetrade.energy()` and `priceruns.plan()` with the packs at the
  curve's level and extends the run, so the curve charges as long.
- Each battery's block ends where it is at its own level (grid) or at the
  floor (give). The curve puts a point where a battery reaches its charge
  level inside a step.
- `homeview.mode_runs(ahead=f, rp=...)` reads each block at the level the
  curve has when it starts, with Home's own filter and MIN_KWH: no give block
  at the floor, no grid block at the planned level. Home never passes either.
- `no_tomorrow` names the hour from `solarfc.BELL_FROM_H`, where the curve
  stops.
- test_ux54.py part 7 compares control (`_mode_roles()` every 15 minutes) with
  the tab with no tolerance, and adds the cloudy / floor case.

No decision, command, timing or option key changes.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
This commit is contained in:
glenn schrooyen
2026-10-27 00:53:58 +01:00
co-authored by Claude
parent 75195f7073
commit 5ab8622265
7 changed files with 258 additions and 89 deletions
@@ -17,13 +17,24 @@ battery's expected level with its blocks. It follows the add-on, as Home does
and draws none when there is no room or energy (`room()` and `plan_need()`
in `homeview.mode_runs()`, the leg sizing in `trade_runs()`). For a day that
has not started that level says nothing, so tomorrow's blocks are built with
`ahead=True` (Home never passes it): a charge is read from the floor, a give
from the ceiling, and a block is drawn wherever the plan and the gate have a
run. A grid block is then dropped where control will not take it: the curve's
level when the run starts is asked of `modetrade.chargers()`, the cycle's own
test. The gate (`mode_gate`, UX-53) is not bypassed. Control, checked every
15 minutes through tomorrow (`test_ux54.py` part 7), gives and charges where
the tab has a block.
`ahead` (Home never passes it): `mode_runs()` reads each block at the level
the curve has when it starts, with its own filter and `MIN_KWH`, so there is
no give block where the battery is at the floor and no grid block where it
is at its planned level. Where the curve has no level yet, a charge is read
from the floor and a give from the ceiling. The blocks and the curve follow
each other and are taken to a fixed point. The gate (`mode_gate`, UX-53) is
not bypassed.
- **A block lasts as long as control acts.** A cheap run is sized for all the
batteries charging together, so a slower one is still below
`modetrade.charge_to()` when it ends, and control plans the next cheap slots
(`Controller._price_runs()`). `dayview._levels()` asks the same functions,
`modetrade.energy()` and `priceruns.plan()`, with the packs at the curve's
level, and draws the grid block to where control stops. Each battery's block
then ends where the curve has it at its own level (the curve puts a point
there), and a give block where it reaches the floor (a step end, within the
0.1 % the tab shows). Control, walked every 15 minutes through tomorrow
(`test_ux54.py` part 7, no tolerance), charges and gives in exactly the
quarters the tab has a block.
- **The sun** is what `solarfc` reads. An hourly split (Solcast
`detailedForecast`, Open-Meteo `wh_period`) is used as given. A daily total
(Forecast.Solar, and any other state-only sensor, the owner's three "today
@@ -60,12 +71,17 @@ is `SolarToday.add()` after the solar read, in a `try`, in memory.
- Prices are planned about a day ahead (`priceruns.HORIZON`), so a block of
tomorrow later than 24 h from now, or one that needs prices not yet
published, appears as the day comes near. The tab says so. Control may also
charge longer than a block when a slower battery is still short at its end.
published, appears as the day comes near. The tab says so.
- Today's blocks are Home's, and are not extended or shortened: control may
plan more once they end, and Home draws it then. The curve stops charging
at its level (`charge_to()`) and giving at the floor.
- Today, a give run in the plan on a battery that is empty now has no block on
Home (no energy to give). The tab equals Home, and says so: "The add-on plans
to give power. No block shows while the battery has no energy to give." The
curve gives in that run if the sun fills the battery first.
- With no forecast for tomorrow the curve stops at the first hour of the sun,
`solarfc.BELL_FROM_H` (06:00): `Forecast.between()` has no answer past it. The
note names that hour from the constant.
- Tomorrow shows price blocks only. A monthly cycle is drawn today, where the
curve then stops for that battery.
- The curve uses the arbitrage floor and ceiling, as the plan does, not the
+19 -2
View File
@@ -109,7 +109,9 @@ def curve(batts, start, end, sun_kwh, use_kwh, *, floor_pct, ceiling_pct,
level = {b.name: float(b.soc) for b in batts}
out = {b.name: [(start, level[b.name])] for b in batts}
alive = {b.name for b in batts}
for a, z in zip(ts, ts[1:]):
i = 0
while i < len(ts) - 1:
a, z = ts[i], ts[i + 1]
if not alive:
break
sun = sun_kwh(a, z)
@@ -128,9 +130,23 @@ def curve(batts, start, end, sun_kwh, use_kwh, *, floor_pct, ceiling_pct,
for s, e, to in b.grid:
if s <= a < e and level[b.name] < to - EPS:
want[b.name] = float(to)
by = {b.name: b for b in batts}
# A battery that reaches its level inside the step gets a point
# there: the step is cut and read again, so the line is exact.
cut = None
for n, to in want.items():
b = by[n]
room = (to - level[n]) / 100.0 * b.capacity_kwh
rate = b.rating_kw * hrs * EFF
if EPS < room < rate * (1.0 - 1e-9):
t_r = a + (z - a) * (room / rate)
if a < t_r < z and (cut is None or t_r < cut):
cut = t_r
if cut is not None:
ts.insert(i + 1, cut)
continue
roles = {b.name: _role(b, a, bool(want), b.name in want)
for b in batts if b.name in alive}
by = {b.name: b for b in batts}
for n, to in want.items(): # charging from the grid
b = by[n]
room = (to - level[n]) / 100.0 * b.capacity_kwh
@@ -163,6 +179,7 @@ def curve(batts, start, end, sun_kwh, use_kwh, *, floor_pct, ceiling_pct,
for b in batts:
if b.name in alive:
out[b.name].append((z, level[b.name]))
i += 1
return out
+112 -24
View File
@@ -19,10 +19,11 @@ empty chart. DECISIONS/UX-54-forecast-tab.md has the record.
import dataclasses
from datetime import datetime, timedelta
from . import arbitrage, dayplan, homeview as hv, modetrade
from . import arbitrage, dayplan, homeview as hv, modetrade, priceruns
from . import solarfc
from .daycurve import Batt, curve
from .packmode import SOLAR_FIRST
from .price import brussels
from .price import Uncertainty, brussels, required_spread, uncertainty_of
BLOCKS = ("grid", "give", "cycle") # the kinds a battery's blocks carry
@@ -83,14 +84,14 @@ def _build(c, status, now):
runs = [plan["runs"], hv.runs_for(c, n0, n1, now, shadow, ahead=True)
if running else []]
# The level curve of every battery that is on and known. It also drops a
# grid block of tomorrow that control will not take: the battery is then
# at or above its planned level when the run starts.
# The level curve of every battery that is on and known. It also makes
# tomorrow's blocks again at the level each has when its run starts, and
# draws a grid block as long as control keeps charging (`_levels()`).
notes_b = {}
levels = {}
if running and missing is None:
levels, runs = _levels(c, est, fc, trust, now, n1, runs, floor, ceil,
notes_b)
notes_b, shadow, n0, n1)
blocks = [[[r for r in rs if r["battery"] == i and r["kind"] in BLOCKS]
for i in range(len(c.inverters))] for rs in runs]
# Prices are planned about a day ahead (`priceruns.HORIZON`), so tomorrow's
@@ -109,13 +110,25 @@ def _build(c, status, now):
"days": days}
def _levels(c, est, fc, trust, now, end, runs, floor, ceil, notes_b):
def _levels(c, est, fc, trust, now, end, runs, floor, ceil, notes_b, shadow,
n0, n1):
"""`({name: [(t, pct), ...]}, runs)`: `daycurve.curve()` for the batteries
that are on, have a level and are not in their monthly cycle, and `runs`
less the grid blocks of tomorrow that control will not take. Those are
asked of `modetrade.chargers()`, the cycle's own test, at the level the
curve has when the block starts (a block is drawn from the floor, see
`homeview.mode_runs(ahead=True)`, so that level is the filter)."""
with tomorrow's blocks made as control will meet them.
Tomorrow's blocks are `homeview.mode_runs()`'s, read at the level the
curve has when each block starts (its `ahead` function), so its own room
and energy filter and `MIN_KWH` decide: no give block where the battery
is at the floor when the run starts, no grid block where it is at its
planned level. The curve in turn follows the blocks, so the two are taken
to a fixed point.
A cheap run is sized for all the batteries charging together, so a slower
one is still below `modetrade.charge_to()` when it ends, and control then
plans the next cheap slots (`Controller._price_runs()`). That is asked of
the same functions, `modetrade.energy()` and `priceruns.plan()`, with the
packs at the curve's level, and the block is drawn to where control
stops charging. Today's blocks are Home's, as they are."""
packs = {p.name: p for p in c._mode_packs()}
targets = c.plan_targets()
rp = getattr(c, "mode_runs", None)
@@ -161,18 +174,86 @@ def _levels(c, est, fc, trust, now, end, runs, floor, ceil, notes_b):
batts = make(rs)
return curve(batts, now, end, sun, use, floor_pct=floor,
ceiling_pct=ceil, trust=trust) if batts else {}
first = run([runs[0], [r for r in runs[1] if r["kind"] != "grid"]])
keep = []
for r in runs[1]:
name = c.inverters[r["battery"]].name
lvl = _level_at(first.get(name, ()), _times(r)[0])
if r["kind"] == "grid" and lvl is not None and not modetrade.chargers(
[dataclasses.replace(packs[name], soc=lvl)], ceil,
targets=targets):
continue
keep.append(r)
runs = [runs[0], keep]
return run(runs), runs
def settle(ext, lv):
"""Tomorrow's blocks read at the levels of `lv`, and the curve they
make, again until the blocks stop changing."""
seen = None
for _ in range(4):
def at(name, _kind, start, lv=lv):
return _level_at(lv.get(name, ()), start)
rt = hv.runs_for(c, n0, n1, now, shadow, ahead=at, rp=ext)
lv = run([runs[0], rt])
sig = [(r["battery"], r["kind"], r["start"], r["end"])
for r in rt]
if sig == seen:
break
seen = sig
return rt, lv
lv0 = run([runs[0], []])
ext = rp
plan = c.fresh_tariff_plan(now)
cheap = None if rp is None else rp.cheap
end_t = None
if plan_on and cheap is not None and rp.charge_useful() \
and n0 <= cheap.start < n1 and plan is not None:
required = required_spread(
arbitrage.threshold_eur_kwh(c.o),
uncertainty_of(plan.read.product) | {Uncertainty.NO_WEATHER})
end_t = cheap.end
for _ in range(24):
ext = dataclasses.replace(rp, cheap=priceruns.Run(
cheap.start, end_t, cheap.mean_eur_kwh))
rt, lv = settle(ext, lv0)
at_end = [dataclasses.replace(packs[n], soc=v)
for n, v in ((n, _level_at(lv.get(n, ()), end_t))
for n in lv) if v is not None]
e = modetrade.energy(at_end, floor, ceil, targets=targets)
if e[0] <= 1e-6:
break
new = priceruns.plan(
plan.read.points, end_t, required_eur_kwh=required,
charge_kwh=e[0], charge_kw=e[1], sell_kwh=0.0, sell_kw=0.0)
nxt = new.cheap
if nxt is None or not new.charge_useful() or nxt.start > end_t \
or nxt.end <= end_t:
break
end_t = nxt.end
ext = dataclasses.replace(rp, cheap=priceruns.Run(
cheap.start, end_t, cheap.mean_eur_kwh))
rt, lv = settle(ext, lv0)
# A run is for all the batteries charging together; each stops when it is
# at its own level (control's `chargers()`), so its block ends there.
names = [inv.name for inv in c.inverters]
rt = [_stops(r, names, lv, packs, targets, floor, ceil, n0) for r in rt]
return lv, [runs[0], rt]
def _stops(r, names, lv, packs, targets, floor, ceil, day0):
"""`r` ended where its battery stops: a grid block where the curve has it
at the level `modetrade.charge_to()` allows (the curve has a point there),
a give block where the curve has it at the floor, within the 0.1 % the
tab shows (a step end), if that is
before the run ends. A run is for all the batteries together; each leaves
it on its own."""
if r["kind"] not in ("grid", "give"):
return r
name = names[r["battery"]]
start, end = _times(r)
if r["kind"] == "grid":
to = modetrade.charge_to(packs[name], ceil, targets)
if to is None:
return r
hit = lambda v: v >= to - 1e-6 # noqa: E731
else:
hit = lambda v: v <= floor + 0.05 # noqa: E731
done = next((t for t, v in lv.get(name, ()) if start < t <= end
and hit(v)), None)
if done is None or done >= end:
return r
return dict(r, end=done.isoformat(), to=hv.hhmm(done),
end_h=round(_hours_between(day0, done), 3))
def _level_at(pts, t):
@@ -246,7 +327,7 @@ def _day(c, k, d0, d1, now, fc, house, trust, levels, blocks, rows, shadow,
if day is not None and not day.periods:
notes.append(hv.day_note("estimate"))
if k == 1 and fc.tomorrow is None:
notes.append(hv.day_note("no_tomorrow"))
notes.append(hv.day_note("no_tomorrow", at=_sun_from()))
if k == 0 and solar is not None and solar.first is not None \
and solar.day == brussels(now).date() \
and _hours_between(d0, solar.first) > 0.5:
@@ -270,6 +351,13 @@ def _day(c, k, d0, d1, now, fc, house, trust, levels, blocks, rows, shadow,
"hours": [h for _s, h in hours], "batteries": batts}
def _sun_from() -> str:
"""When the sun of a day starts for `solarfc` (its bell's first hour):
with no forecast for tomorrow the sun is unknown from then, and so the
curve stops there (`Forecast.between()` answers None past it)."""
return f"{int(solarfc.BELL_FROM_H):02d}:00"
def _tomorrow_line(status) -> str:
"""Home's own line about tomorrow's prices, for the tomorrow tab."""
return (status.get("plan") or {}).get("tomorrow_line") or ""
+20 -11
View File
@@ -395,7 +395,7 @@ def trade_runs(c, plan, verdict, day0, day1, now, shadow, ahead=False):
PRICE_MODES = (LEFTOVER_SOLAR, CHEAP_HOURS)
def mode_runs(c, day0, day1, now, shadow, ahead=False):
def mode_runs(c, day0, day1, now, shadow, ahead=False, rp=None):
"""OPT-11 P3: each battery's price runs today, as the control cycle keeps
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
@@ -405,10 +405,12 @@ def mode_runs(c, day0, day1, now, shadow, ahead=False):
control cycle found). Returns `(runs, no_trade_key)`.
UX-54 `ahead`: the window has not started, so the level now says nothing
about it. The room and energy filter (`room()` and `plan_need()` below)
then reads a charge from the floor and a give from the ceiling, and a
block is drawn wherever the plan and the gate have a run. Home never
passes it."""
rp = getattr(c, "mode_runs", None)
then reads the level a block starts at: `ahead(name, kind, start)`, a
function giving the expected level in % or None, or for None (and for
`ahead=True`) a charge from the floor and a give from the ceiling. The
same filter, the same `MIN_KWH`. `rp`: a run plan to read instead of
`c.mode_runs`. Home never passes either."""
rp = rp if rp is not None else getattr(c, "mode_runs", None)
if getattr(c, "mode_tick", None) is None:
return [], None # no cycle yet: nothing is known
if rp is None:
@@ -424,16 +426,23 @@ def mode_runs(c, day0, day1, now, shadow, ahead=False):
continue
soc, cap = inv.soc, inv.capacity_kwh
def room(kind, hrs):
def start_pct(kind, start):
"""The level a block of `kind` is read at when `ahead`."""
got = ahead(inv.name, kind, start) if callable(ahead) else None
return got if got is not None else (
floor if kind == "grid" else ceiling)
def room(kind, hrs, start):
kwh = inv.max_w / 1000.0 * hrs
lvl = ((floor if kind == "grid" else ceiling) if ahead else soc)
lvl = start_pct(kind, start) if ahead else soc
if cap and lvl is not None:
kwh = min(kwh, max(0.0, (ceiling - lvl) if kind == "grid"
else (lvl - floor)) / 100.0 * cap)
return kwh
blocks = []
# P4: with a day plan, the grid block is the planned shortfall.
need = plan_need(c, inv.name, floor if ahead else None)
need = plan_need(c, inv.name, start_pct("grid", rp.cheap.start)
if ahead and rp.cheap is not None else None)
if gate is None and rp.charge_useful() and (
need > 0.0 if need is not None
else pm.mode in GRID_CHARGE_MODES):
@@ -448,7 +457,7 @@ def mode_runs(c, day0, day1, now, shadow, ahead=False):
if run.end <= now or not (run.start < day1 and run.end > day0):
continue
hrs = (run.end - run.start).total_seconds() / 3600.0
kwh = room(kind, hrs)
kwh = room(kind, hrs, run.start)
if kind == "grid" and need is not None:
kwh = min(kwh, need)
if kwh < MIN_KWH:
@@ -629,13 +638,13 @@ def region_of(opts):
if rid in REGION_PRESETS else None}
def runs_for(c, day0, day1, now, shadow, ahead=False):
def runs_for(c, day0, day1, now, shadow, ahead=False, rp=None):
"""UX-54: the price blocks of the window `[day0, day1)`, by the same
builders `build_plan()` uses for today: the batteries' price runs with
modes on, the verdict's legs with them off. Nothing is decided here.
`ahead`: see `mode_runs()`; for a day that has not started."""
if getattr(c, "modes_on", False):
return mode_runs(c, day0, day1, now, shadow, ahead)[0]
return mode_runs(c, day0, day1, now, shadow, ahead, rp)[0]
fresh = c.fresh_tariff_plan(now)
verdict, _why = trade_verdict(c, fresh, now)
if verdict is None:
+1 -1
View File
@@ -448,7 +448,7 @@
"not_set": "No solar forecast is set up. Without one, no battery level is planned.",
"no_forecast": "The solar forecast is missing or too old. The levels return when it is back.",
"learning": "The add-on is learning your house's use. The levels start after one full day.",
"no_tomorrow": "There is no solar forecast for tomorrow yet. The levels stop at 06:00.",
"no_tomorrow": "There is no solar forecast for tomorrow yet. The levels stop at {at}.",
"estimate": "The forecast gives one total for the day. The add-on spreads it over the hours.",
"trust": "The levels count on {pct} % of the forecast.",
"cycle": "{name} does its monthly cycle. Its level is not shown once the cycle starts.",
+1 -1
View File
@@ -376,7 +376,7 @@
"not_set": "Er is geen zonneprognose ingesteld. Zonder prognose wordt geen laadniveau gepland.",
"no_forecast": "De zonneprognose ontbreekt of is te oud. De niveaus komen terug als ze er weer is.",
"learning": "De add-on leert het verbruik van uw huis. De niveaus starten na één volle dag.",
"no_tomorrow": "Er is nog geen zonneprognose voor morgen. De niveaus stoppen om 06:00.",
"no_tomorrow": "Er is nog geen zonneprognose voor morgen. De niveaus stoppen om {at}.",
"estimate": "De prognose geeft één totaal voor de dag. De add-on verdeelt het over de uren.",
"trust": "De niveaus rekenen op {pct} % van de prognose.",
"cycle": "{name} doet de maandelijkse cyclus. Het niveau wordt niet getoond zodra de cyclus start.",
+80 -41
View File
@@ -340,6 +340,12 @@ check("a grid block charges to its level at the rating (5 kW x 95 %: 47.5 % "
"an hour on 10 kWh, so 80 % in the first hour), and stops there",
lv(oc, "c", 2) == 40.0 and abs(lv(oc, "c", 3) - 80.0) < 1e-6
and lv(oc, "c", 5) == 80.0)
reach = T0 + timedelta(hours=2, minutes=50, seconds=31.579)
check("a battery that reaches its grid level inside a step gets a point "
"there (80 % at 02:50:31, not at the next hour), so the line and the "
"block end are exact",
any(abs((t - reach).total_seconds()) < 1.0
and abs(v - 80.0) < 1e-6 for t, v in oc["c"]))
two = [Batt("a", 10.0, 5.0, 50.0, True, False),
Batt("b", 10.0, 5.0, 50.0, False, True)]
ot = run(two, flat(3.0), flat(1.0))
@@ -474,14 +480,16 @@ check("learning (no house profile yet): a note, the sun is drawn, no house "
say("learning") in notes_of(o) and no_charts(o)
and o["days"][0]["sun"]["forecast"] and not o["days"][0]["sun"]["house"]
and all(h["house_kwh"] is None for h in o["days"][0]["hours"]))
SUN_FROM = f"{int(solarfc.BELL_FROM_H):02d}:00"
o = tab(S.make("notomorrow", at(10, 7)), at(10, 7))
tmr = o["days"][1]
check("no forecast for tomorrow: a note on that day only; the curve stops at "
"sunrise; today is whole",
say("no_tomorrow") in tmr["notes"]
and say("no_tomorrow") not in notes_of(o, 0)
check("no forecast for tomorrow: a note on that day only, naming the hour "
"the curve really stops (`solarfc.BELL_FROM_H`, the first hour of the "
"sun: its last point is there); today is whole",
say("no_tomorrow", at=SUN_FROM) in tmr["notes"]
and say("no_tomorrow", at=SUN_FROM) not in notes_of(o, 0)
and not tmr["sun"]["forecast"]
and tmr["batteries"][0]["levels"][-1][0] <= 6.0 + 1e-6
and abs(tmr["batteries"][0]["levels"][-1][0] - solarfc.BELL_FROM_H) < 1e-6
and o["days"][0]["batteries"][0]["levels"][-1][0] > 23.0)
c = S.make("owner", at(10, 7))
c.inverters[1].soc = None
@@ -721,10 +729,14 @@ Q = timedelta(minutes=15)
def level_at(b, h):
"""The curve's level at hour `h` of the day the list is for (the last
point at or before it; the first one before the curve starts)."""
pts = [p for p in b["levels"] if p[0] <= h + 1e-9]
return pts[-1][1] if pts else b["levels"][0][1]
"""The curve's level at hour `h` of the day the list is for, straight
between its points (the first level before the curve starts, the last
after it ends)."""
pts = b["levels"]
for (h0, v0), (h1, v1) in zip(pts, pts[1:]):
if h0 <= h <= h1:
return v0 if h1 <= h0 else v0 + (v1 - v0) * (h - h0) / (h1 - h0)
return pts[0][1] if h < pts[0][0] else pts[-1][1]
def night_evening(h):
@@ -733,7 +745,7 @@ def night_evening(h):
return 0.08 if 1.0 <= h < 5.0 else 0.34 if 17.0 <= h < 21.0 else 0.20
def sweep(name, now0, soc, *, guard=None, until=48.0):
def sweep(name, now0, soc, *, guard=None, until=48.0, sun=None):
"""Walk the real control cycle (`_mode_roles()`) every 15 minutes from
`now0` to the end of tomorrow, each battery at the level the tab's curve
has at that time, and return the tab and the role seen per battery and
@@ -743,6 +755,8 @@ def sweep(name, now0, soc, *, guard=None, until=48.0):
c.tariff_at = now0
if guard is not None:
c.peak_guard = guard
if sun is not None:
c.solar_fc = S.forecast(now0, sun[0], sun[1])
c._mode_roles(now0, None)
out = tab(c, now0)
d0, _d1 = homeview.local_day(now0)
@@ -758,38 +772,36 @@ def sweep(name, now0, soc, *, guard=None, until=48.0):
else inv.soc
c.tariff_at = t
tick = c._mode_roles(t, None)
floor_, _ceil = arbitrage.soc_bounds(c.o)
for inv in c.inverters:
seen[inv.name][t] = tick.roles.get(inv.name)
role = tick.roles.get(inv.name)
# a give from a battery at the floor has no energy behind it:
# Home's own rule (`mode_runs()`, `MIN_KWH`) draws no block
seen[inv.name][t] = "no energy" if role == modetrade.GIVE \
and inv.soc <= floor_ + 0.05 else role
t += Q
return c, out, seen, d0
def tomorrow_match(out, seen, d0, role, kind, tail=0.0):
"""(a block of `kind` has a quarter where control had `role`, and no
quarter of that role is outside a block) per battery, tomorrow. `tail`:
hours control may go on after a block ends. A charge does: the run is
sized for all the batteries charging together, so a slower one is still
short when it ends and the next plan carries on in the same cheap hours."""
ok = True
def tomorrow_match(out, seen, d0, role, kind):
"""Per battery, tomorrow: control has `role` in exactly the quarters the
tab has a block of `kind` (no quarter outside a block, none of a block
without it), with no tolerance."""
for i, name in enumerate(seen):
blocks = [r for r in out["days"][1]["batteries"][i]["blocks"]
if r["kind"] == kind]
inside = lambda h: any(
r["start_h"] - 1e-6 <= h < r["end_h"] + tail - 1e-6
for r in blocks)
quarters = [(t, (t - d0).total_seconds() / 3600.0 - 24.0)
for t in seen[name] if (t - d0).total_seconds() >= 86400]
acting = [h for t, h in quarters if seen[name][t] == role]
ok &= all(inside(h) for h in acting)
ok &= all(any(r["start_h"] - 1e-6 <= h < r["end_h"] - 1e-6
for h in acting) for r in blocks)
ok &= bool(blocks) == bool(acting)
if not ok:
print(" ", name, role, "acting at", acting[:3], "..",
acting[-2:], "blocks", [(r["start_h"], r["end_h"])
for r in blocks])
return False
return ok
for t, h in quarters:
acting = seen[name][t] == role
inside = any(r["start_h"] - 1e-6 <= h < r["end_h"] - 1e-6
for r in blocks)
if acting != inside:
print(" ", name, role, "quarter", round(h, 2),
"control acts" if acting else "tab has a block",
[(r["start_h"], r["end_h"]) for r in blocks])
return False
return True
t22 = at(22, 0, 5)
@@ -798,30 +810,57 @@ tm1 = o1["days"][1]["batteries"]
print(" 22:00, both batteries at 20 %: tomorrow", [[(r["kind"], r["from"], r["to"]) for r in b["blocks"]] for b in tm1])
check("F1, modes on: at 22:00 with both batteries at 20 % tomorrow's give "
"block (17:00-21:00) and the night's grid block show on both",
all([(r["kind"], r["from"], r["to"]) for r in b["blocks"]]
== [("grid", "01:00", "03:30"), ("give", "17:00", "21:00")]
for b in tm1))
all([(r["kind"], r["from"]) for r in b["blocks"]]
== [("grid", "01:00"), ("give", "17:00")]
and b["blocks"][1]["to"] == "21:00" for b in tm1))
check("F2: ...and control, walked every 15 minutes through tomorrow at the "
"level the curve has, gives (role GIVE) exactly in the give block and "
"charges (role CHARGE) only in the grid block, on both batteries",
tomorrow_match(o1, s1, d01, modetrade.GIVE, "give")
and tomorrow_match(o1, s1, d01, modetrade.CHARGE, "grid", tail=2.6))
and tomorrow_match(o1, s1, d01, modetrade.CHARGE, "grid"))
check("...and today's blocks are still Home's runs there",
tab_blocks(o1) == home_blocks(home(c1, t22), 2))
guard = PeakDecision(engaged=True, floor_w=1000.0, ceiling_w=3500.0,
ceiling_from="cap", projected_w=3600.0)
c2, o2, s2, d02 = sweep("dynamic", t22, (20.0, 20.0), guard=guard)
tm2 = o2["days"][1]["batteries"]
check("F2, the UX-53 gate: while the peak guard holds, control never charges "
"tomorrow, and the tab draws no grid block there (the give block stays)",
check("F2, the UX-53 gate: while the peak guard holds control never "
"charges tomorrow and the tab draws no grid block (both batteries stay "
"at the floor, so no give either, which control cannot give from)",
c2.mode_gate == "peak"
and all([r["kind"] for r in b["blocks"]] == ["give"] for b in tm2)
and tomorrow_match(o2, s2, d02, modetrade.CHARGE, "grid", tail=2.6)
and all(not b["blocks"] for b in tm2)
and tomorrow_match(o2, s2, d02, modetrade.CHARGE, "grid")
and tomorrow_match(o2, s2, d02, modetrade.GIVE, "give"))
c2b, o2b, s2b, d02b = sweep("dynamic", t22, (60.0, 60.0), guard=guard)
check("...with the batteries at 60 % the give block stays and the grid "
"block does not",
all([r["kind"] for r in b["blocks"]] == ["give"]
for b in o2b["days"][1]["batteries"])
and tomorrow_match(o2b, s2b, d02b, modetrade.CHARGE, "grid")
and tomorrow_match(o2b, s2b, d02b, modetrade.GIVE, "give"))
# Low sun, a battery at the floor when the evening give run starts (no grid
# charge, the peak guard holds): Home's rule draws no give block for it, and
# control has no energy to give from. A little sun fills it a little: a small
# block, and control gives from it.
c4, o4, s4, d04 = sweep("dynamic", t22, (20.0, 20.0), guard=guard,
sun=(0.0, 0.0))
check("F1, cloudy day, no grid charge, both batteries at the floor at the "
"evening give run: no block on either, and control has no energy to "
"give from (the sweep sees none)",
all(not b["blocks"] for b in o4["days"][1]["batteries"])
and tomorrow_match(o4, s4, d04, modetrade.GIVE, "give"))
c5, o5, s5, d05 = sweep("dynamic", t22, (20.0, 20.0), guard=guard,
sun=(14.0, 14.0))
gv5 = [[r for r in b["blocks"] if r["kind"] == "give"]
for b in o5["days"][1]["batteries"]]
check("...a bright day fills them: a give block on both, and control gives "
"in exactly that block",
all(len(g) == 1 and g[0]["from"] == "17:00" for g in gv5)
and tomorrow_match(o5, s5, d05, modetrade.GIVE, "give"))
o3 = [sweep("dynamic", t22, soc) for soc in ((55.0, 40.0), (90.0, 70.0))]
check("F2: the same with the batteries part full and nearly full at 22:00",
all(tomorrow_match(x[1], x[2], x[3], modetrade.GIVE, "give")
and tomorrow_match(x[1], x[2], x[3], modetrade.CHARGE, "grid", tail=2.6)
and tomorrow_match(x[1], x[2], x[3], modetrade.CHARGE, "grid")
for x in o3))