UX-54 round 3: tomorrow's blocks last as long as control acts, give blocks checked at run start
- 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:
co-authored by
Claude
parent
75195f7073
commit
5ab8622265
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user