UX-54 round 2: tomorrow's blocks where control will act, trust as the plan counts it, strings
F1: tomorrow's blocks no longer depend on the battery's level now. Home sizes a block from the level now (`room()`, `plan_need()` in mode_runs, the leg sizing in trade_runs) and draws none when there is no room or energy. For a day that has not started the builders take `ahead=True` (Home never passes it): a charge is read from the floor, a give from the ceiling. A grid block is then kept only where `modetrade.chargers()` would charge at the curve's level when the run starts. The UX-53 gate is unchanged. Today still equals Home; a give run in the plan with no block (empty battery) has its own sentence instead of "No charge or give blocks planned". F2: test_ux54.py part 7 walks the real `_mode_roles()` every 15 minutes through tomorrow and compares the roles with the tab's blocks (modes on, the peak gate, part-full batteries), and the control cycle's verdict at 07:00 (modes off). F3: the curve counts spare solar as trust x max(0, sun - house), stored at EFF, as `dayplan.plan()` does (10 kWh sun, 2 kWh house, 70 %: 5.32 kWh). Strings: cycle, no_tomorrow, ahead, no_level, measured; new give_later. 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
b6b2d1b184
commit
75195f7073
@@ -13,6 +13,17 @@ battery's expected level with its blocks. It follows the add-on, as Home does
|
||||
(grid, give, monthly cycle). Tomorrow's come from the same builders
|
||||
(`homeview.runs_for()`). The words are the plan lines'. A block is on the tab
|
||||
exactly where Home has one, so the UX-53 gate is followed too.
|
||||
- **Tomorrow is read ahead.** Home sizes a block from the battery's level now
|
||||
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.
|
||||
- **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
|
||||
@@ -25,8 +36,11 @@ battery's expected level with its blocks. It follows the add-on, as Home does
|
||||
gives (follow the house, hold, give, charge from the grid, rest), spare solar
|
||||
goes to Solar first batteries first, a shortfall is met by those that may
|
||||
give, both at `dayplan.EFF`, inside the plan's floor and ceiling, from the
|
||||
level now. It counts on `solar_trust_pct` of the sun, as the plan does, and
|
||||
says so. Grid charging goes to `modetrade.charge_to()`. It is an estimate.
|
||||
level now. Spare solar is `trust x max(0, sun - house)` and what is stored
|
||||
loses `EFF`, the plan's own `trust x EFF x max(0, sun - house)` (10 kWh of
|
||||
sun, 2 kWh of house, 70 %: 5.32 kWh stored). A shortfall is not scaled by
|
||||
trust. The tab says it counts on `solar_trust_pct`. Grid charging goes to
|
||||
`modetrade.charge_to()`. It is an estimate.
|
||||
- **Measured solar** is kept per local hour in memory (`SolarToday`), from the
|
||||
reading the control cycle already takes; nothing is read back.
|
||||
- **On request.** `GET forecast` builds the tab (`Controller.forecast_payload()`
|
||||
@@ -44,10 +58,14 @@ is `SolarToday.add()` after the solar read, in a `try`, in memory.
|
||||
|
||||
## Limits
|
||||
|
||||
- Prices are planned about a day ahead (`priceruns.HORIZON`), so tomorrow's
|
||||
blocks appear as the day comes near. The tab says so.
|
||||
- A give run drawn on a battery that is empty now has no block on Home (no
|
||||
energy to give), but the curve gives in it if the sun fills it first.
|
||||
- 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.
|
||||
- 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.
|
||||
- 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
|
||||
|
||||
@@ -88,11 +88,15 @@ def _spread(amount, caps, weights):
|
||||
return got, max(0.0, left)
|
||||
|
||||
|
||||
def curve(batts, start, end, sun_kwh, use_kwh, *, floor_pct, ceiling_pct):
|
||||
def curve(batts, start, end, sun_kwh, use_kwh, *, floor_pct, ceiling_pct,
|
||||
trust=1.0):
|
||||
"""`{name: [(t, pct), ...]}` from `start` (each battery's level now) to
|
||||
`end`, a point at every hour edge and every block edge. A battery's list
|
||||
stops where `sun_kwh(a, b)` is None (the sun is not known) or its
|
||||
`stop_at` comes. `sun_kwh` and `use_kwh` give kWh for the span `[a, b)`."""
|
||||
`stop_at` comes. `sun_kwh` and `use_kwh` give kWh for the span `[a, b)`.
|
||||
Spare solar is `trust x max(0, sun - use)`, and what is stored loses
|
||||
`EFF`: `dayplan.plan()`'s own `trust x EFF x max(0, sun - house)`. A
|
||||
shortfall is the house's use less the whole sun."""
|
||||
floor, ceil = float(floor_pct), float(ceiling_pct)
|
||||
cuts = {end}
|
||||
for a, _b, _h in _hour_slices(start, end):
|
||||
@@ -115,6 +119,8 @@ def curve(batts, start, end, sun_kwh, use_kwh, *, floor_pct, ceiling_pct):
|
||||
if b.name in alive and b.stop_at is not None and a >= b.stop_at:
|
||||
alive.discard(b.name)
|
||||
net = sun - use_kwh(a, z)
|
||||
if net > 0.0:
|
||||
net *= trust
|
||||
hrs = (z - a).total_seconds() / 3600.0
|
||||
want = {} # name -> grid target %
|
||||
for b in batts:
|
||||
|
||||
@@ -16,6 +16,7 @@ A part that is not known yet is a plain note (`home_words.day`), never an
|
||||
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
|
||||
@@ -76,17 +77,22 @@ def _build(c, status, now):
|
||||
else "learning" if est is None else None)
|
||||
|
||||
# The blocks of each day: today's from Home, tomorrow's by its builders.
|
||||
runs = [plan["runs"], hv.runs_for(c, n0, n1, now, shadow) if running
|
||||
else []]
|
||||
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]
|
||||
# Tomorrow has not started, so the level now says nothing about it: the
|
||||
# builders read it with `ahead` and draw a block wherever the plan and the
|
||||
# gate have a run (`homeview.mode_runs()` has the room and energy filter).
|
||||
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.
|
||||
# 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.
|
||||
notes_b = {}
|
||||
levels = {}
|
||||
if running and missing is None:
|
||||
levels = _levels(c, est, fc, trust, now, n1, runs, floor, ceil,
|
||||
notes_b)
|
||||
levels, runs = _levels(c, est, fc, trust, now, n1, runs, floor, ceil,
|
||||
notes_b)
|
||||
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
|
||||
# blocks may be missing now and appear later.
|
||||
priced = bool(plan.get("has_prices")) and (
|
||||
@@ -104,49 +110,80 @@ def _build(c, status, now):
|
||||
|
||||
|
||||
def _levels(c, est, fc, trust, now, end, runs, floor, ceil, notes_b):
|
||||
"""`{name: [(t, pct), ...]}` from `daycurve.curve()` for the batteries
|
||||
that are on, have a level and are not in their monthly cycle."""
|
||||
"""`({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)."""
|
||||
packs = {p.name: p for p in c._mode_packs()}
|
||||
targets = c.plan_targets()
|
||||
rp = getattr(c, "mode_runs", None)
|
||||
plan_on = rp is not None and rp.on
|
||||
house_w, base_w = est
|
||||
batts = []
|
||||
for i, inv in enumerate(c.inverters):
|
||||
pm = c.pack_mode(inv)
|
||||
if not pm.use:
|
||||
continue
|
||||
if inv.soc is None or not inv.capacity_kwh:
|
||||
notes_b[inv.name] = hv.day_note("no_level", name=inv.name)
|
||||
continue
|
||||
mine = [r for rs in runs for r in rs
|
||||
if r["battery"] == i and r["kind"] in BLOCKS]
|
||||
cyc = [_times(r)[0] for r in mine if r["kind"] == "cycle"]
|
||||
if cyc and min(cyc) <= now:
|
||||
notes_b[inv.name] = hv.day_note("cycle", name=inv.name)
|
||||
continue
|
||||
if cyc:
|
||||
notes_b[inv.name] = hv.day_note("cycle", name=inv.name)
|
||||
to = modetrade.charge_to(packs[inv.name], ceil, targets)
|
||||
grid = tuple((*_times(r), to) for r in mine
|
||||
if r["kind"] == "grid" and to is not None)
|
||||
priced = plan_on and pm.mode != SOLAR_FIRST
|
||||
give = tuple((g.start, g.end) for g in rp.give) if priced else ()
|
||||
batts.append(Batt(inv.name, float(inv.capacity_kwh),
|
||||
inv.max_w / 1000.0, float(inv.soc),
|
||||
pm.mode == SOLAR_FIRST, priced, grid, give,
|
||||
min(cyc) if cyc else None))
|
||||
|
||||
def make(rs):
|
||||
batts = []
|
||||
for i, inv in enumerate(c.inverters):
|
||||
pm = c.pack_mode(inv)
|
||||
if not pm.use:
|
||||
continue
|
||||
if inv.soc is None or not inv.capacity_kwh:
|
||||
notes_b[inv.name] = hv.day_note("no_level", name=inv.name)
|
||||
continue
|
||||
mine = [r for day in rs for r in day
|
||||
if r["battery"] == i and r["kind"] in BLOCKS]
|
||||
cyc = [_times(r)[0] for r in mine if r["kind"] == "cycle"]
|
||||
if cyc and min(cyc) <= now:
|
||||
notes_b[inv.name] = hv.day_note("cycle", name=inv.name)
|
||||
continue
|
||||
if cyc:
|
||||
notes_b[inv.name] = hv.day_note("cycle", name=inv.name)
|
||||
to = modetrade.charge_to(packs[inv.name], ceil, targets)
|
||||
grid = tuple((*_times(r), to) for r in mine
|
||||
if r["kind"] == "grid" and to is not None)
|
||||
priced = plan_on and pm.mode != SOLAR_FIRST
|
||||
give = tuple((g.start, g.end) for g in rp.give) if priced else ()
|
||||
batts.append(Batt(inv.name, float(inv.capacity_kwh),
|
||||
inv.max_w / 1000.0, float(inv.soc),
|
||||
pm.mode == SOLAR_FIRST, priced, grid, give,
|
||||
min(cyc) if cyc else None))
|
||||
return batts
|
||||
|
||||
def sun(a, z):
|
||||
kwh = sun_kwh(fc, now, a, z)
|
||||
return None if kwh is None else kwh * trust
|
||||
return sun_kwh(fc, now, a, z)
|
||||
|
||||
def use(a, z):
|
||||
return sum(max(house_w(h), base_w) * _hours_between(s, e) / 1000.0
|
||||
for s, e, h in dayplan._hour_slices(a, z))
|
||||
if not batts:
|
||||
return {}
|
||||
return curve(batts, now, end, sun, use, floor_pct=floor, ceiling_pct=ceil)
|
||||
|
||||
def run(rs):
|
||||
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 _level_at(pts, t):
|
||||
"""The curve's level at `t` (between two points, straight), or None when
|
||||
the curve does not reach `t`."""
|
||||
for (t0, v0), (t1, v1) in zip(pts, pts[1:]):
|
||||
if t0 <= t <= t1:
|
||||
span = (t1 - t0).total_seconds()
|
||||
return v0 if span <= 0 else v0 + (v1 - v0) * (
|
||||
(t - t0).total_seconds() / span)
|
||||
return None
|
||||
|
||||
|
||||
def _day(c, k, d0, d1, now, fc, house, trust, levels, blocks, rows, shadow,
|
||||
@@ -183,7 +220,8 @@ def _day(c, k, d0, d1, now, fc, house, trust, levels, blocks, rows, shadow,
|
||||
"blocks": [{x: r[x] for x in ("kind", "from", "to", "start_h",
|
||||
"end_h", "what", "why", "amount")}
|
||||
for r in mine],
|
||||
"line": _line(c, inv, row, mine, running, key, shadow)})
|
||||
"line": _line(c, inv, row, mine, running, key, shadow, d0, d1,
|
||||
now)})
|
||||
at = {t: v for t, v in pts}
|
||||
for s, hr in hours:
|
||||
hr["levels"].append(None if s not in at else round(at[s], 1))
|
||||
@@ -237,8 +275,17 @@ def _tomorrow_line(status) -> str:
|
||||
return (status.get("plan") or {}).get("tomorrow_line") or ""
|
||||
|
||||
|
||||
def _line(c, inv, row, mine, running, key, shadow) -> str:
|
||||
"""What to say for a battery with no block: the plan's own words."""
|
||||
def _give_planned(c, d0, d1, now) -> bool:
|
||||
"""Does the run plan have a give run in `[d0, d1)` not yet over?"""
|
||||
rp = getattr(c, "mode_runs", None)
|
||||
return rp is not None and rp.on and any(
|
||||
g.end > max(now, d0) and g.start < d1 for g in rp.give)
|
||||
|
||||
|
||||
def _line(c, inv, row, mine, running, key, shadow, d0, d1, now) -> str:
|
||||
"""What to say for a battery with no block: the plan's own words. Home
|
||||
draws no give block for a battery with no energy to give now, so a give run
|
||||
in the plan with no block gets its own sentence."""
|
||||
if not running:
|
||||
return hv.why_words(key)
|
||||
if row.get("use") is False:
|
||||
@@ -247,5 +294,6 @@ def _line(c, inv, row, mine, running, key, shadow) -> str:
|
||||
return ""
|
||||
if getattr(c, "modes_on", False) and row.get("mode_choice") in \
|
||||
hv.PRICE_MODES:
|
||||
return hv.day_note("no_blocks")
|
||||
return hv.day_note("give_later" if _give_planned(c, d0, d1, now)
|
||||
else "no_blocks")
|
||||
return hv.run_house_what(shadow) + " " + hv.run_house_why(shadow)
|
||||
|
||||
@@ -333,18 +333,22 @@ def trade_legs(c, plan, verdict, now):
|
||||
MIN_KWH = 0.05
|
||||
|
||||
|
||||
def trade_runs(c, plan, verdict, day0, day1, now, shadow):
|
||||
def trade_runs(c, plan, verdict, day0, day1, now, shadow, ahead=False):
|
||||
"""A charge block and a give block on each battery, where the verdict put
|
||||
them. The site trades as one; each battery's share is capped by its own
|
||||
rating and by the room its level leaves (arbitrage's own floor/ceiling).
|
||||
Only what `arbitrage.decide()` would still do is drawn: no block on a
|
||||
battery with no room, no sale at a negative price, and no sale while the
|
||||
emptiest battery is at the floor (the cycle's `soc` is the emptiest).
|
||||
With a level unknown the cycle does not trade, so nothing is drawn."""
|
||||
With a level unknown the cycle does not trade, so nothing is drawn.
|
||||
UX-54 `ahead`: the window has not started, so the level now says nothing
|
||||
about it. A charge is sized from the floor, a give from the ceiling (the
|
||||
most it could be), and the levels are not asked; the leg itself is still
|
||||
the gate's (`trade_legs()`). Home never passes it."""
|
||||
floor, ceiling = arbitrage.soc_bounds(c.o)
|
||||
if any(inv.soc is None for inv in c.inverters):
|
||||
if not ahead and any(inv.soc is None for inv in c.inverters):
|
||||
return [], [], "no_level"
|
||||
level = [float(inv.soc) for inv in c.inverters]
|
||||
level = [] if ahead else [float(inv.soc) for inv in c.inverters]
|
||||
runs, later = [], []
|
||||
for kind, pt in trade_legs(c, plan, verdict, now):
|
||||
end = _point_end(plan, pt)
|
||||
@@ -353,7 +357,8 @@ def trade_runs(c, plan, verdict, day0, day1, now, shadow):
|
||||
if not (day0 <= pt.start < day1):
|
||||
later.append((kind, pt))
|
||||
continue
|
||||
if kind == "give" and (pt.eur_per_kwh < 0 or min(level) <= floor):
|
||||
if kind == "give" and (pt.eur_per_kwh < 0
|
||||
or (not ahead and min(level) <= floor)):
|
||||
continue
|
||||
hrs = (end - pt.start).total_seconds() / 3600.0
|
||||
for i, inv in enumerate(c.inverters):
|
||||
@@ -362,12 +367,15 @@ def trade_runs(c, plan, verdict, day0, day1, now, shadow):
|
||||
kwh = inv.max_w / 1000.0 * hrs
|
||||
cap = inv.capacity_kwh
|
||||
if cap:
|
||||
room = (ceiling - level[i] if kind == "grid"
|
||||
else level[i] - floor)
|
||||
now_pct = (floor if kind == "grid" else ceiling) if ahead \
|
||||
else level[i]
|
||||
room = (ceiling - now_pct if kind == "grid"
|
||||
else now_pct - floor)
|
||||
kwh = min(kwh, max(0.0, room) / 100.0 * cap)
|
||||
# A later give may use what this charge put in: an upper
|
||||
# bound, like every amount here ("up to").
|
||||
level[i] += (kwh if kind == "grid" else -kwh) / cap * 100.0
|
||||
if not ahead:
|
||||
level[i] += (kwh if kind == "grid" else -kwh) / cap * 100.0
|
||||
if kwh < MIN_KWH:
|
||||
continue
|
||||
eur = kwh * pt.eur_per_kwh
|
||||
@@ -387,14 +395,19 @@ def trade_runs(c, plan, verdict, day0, day1, now, shadow):
|
||||
PRICE_MODES = (LEFTOVER_SOLAR, CHEAP_HOURS)
|
||||
|
||||
|
||||
def mode_runs(c, day0, day1, now, shadow):
|
||||
def mode_runs(c, day0, day1, now, shadow, ahead=False):
|
||||
"""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
|
||||
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. 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)`."""
|
||||
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)
|
||||
if getattr(c, "mode_tick", None) is None:
|
||||
return [], None # no cycle yet: nothing is known
|
||||
@@ -413,13 +426,14 @@ def mode_runs(c, day0, day1, now, shadow):
|
||||
|
||||
def room(kind, hrs):
|
||||
kwh = inv.max_w / 1000.0 * hrs
|
||||
if cap and soc is not None:
|
||||
kwh = min(kwh, max(0.0, (ceiling - soc) if kind == "grid"
|
||||
else (soc - floor)) / 100.0 * cap)
|
||||
lvl = ((floor if kind == "grid" else ceiling) 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)
|
||||
need = plan_need(c, inv.name, floor if ahead 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):
|
||||
@@ -461,16 +475,18 @@ def mode_runs(c, day0, day1, now, shadow):
|
||||
return out, None
|
||||
|
||||
|
||||
def plan_need(c, name):
|
||||
def plan_need(c, name, soc=None):
|
||||
"""P4: kWh this battery buys from the grid by the day plan, or None when
|
||||
there is no plan for it (then P3 option A applies)."""
|
||||
there is no plan for it (then P3 option A applies). `soc`: UX-54, a level
|
||||
to read it from instead of the one now (`mode_runs(ahead=True)`)."""
|
||||
targets = c.plan_targets() if hasattr(c, "plan_targets") else None
|
||||
if not targets or name not in targets:
|
||||
return None
|
||||
inv = next((i for i in c.inverters if i.name == name), None)
|
||||
if inv is None or inv.soc is None or not inv.capacity_kwh:
|
||||
at = None if inv is None else (inv.soc if soc is None else soc)
|
||||
if inv is None or at is None or not inv.capacity_kwh:
|
||||
return None
|
||||
return max(0.0, targets[name] - float(inv.soc)) / 100.0 * float(
|
||||
return max(0.0, targets[name] - float(at)) / 100.0 * float(
|
||||
inv.capacity_kwh)
|
||||
|
||||
|
||||
@@ -613,17 +629,18 @@ def region_of(opts):
|
||||
if rid in REGION_PRESETS else None}
|
||||
|
||||
|
||||
def runs_for(c, day0, day1, now, shadow):
|
||||
def runs_for(c, day0, day1, now, shadow, ahead=False):
|
||||
"""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."""
|
||||
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)[0]
|
||||
return mode_runs(c, day0, day1, now, shadow, ahead)[0]
|
||||
fresh = c.fresh_tariff_plan(now)
|
||||
verdict, _why = trade_verdict(c, fresh, now)
|
||||
if verdict is None:
|
||||
return []
|
||||
return trade_runs(c, fresh, verdict, day0, day1, now, shadow)[0]
|
||||
return trade_runs(c, fresh, verdict, day0, day1, now, shadow, ahead)[0]
|
||||
|
||||
|
||||
def build_plan(c, now, shadow, running, held=False, settled=True):
|
||||
|
||||
@@ -76,7 +76,7 @@
|
||||
"sun": "Solar and house",
|
||||
"unit": "kWh per hour",
|
||||
"forecast": "Solar forecast",
|
||||
"measured": "Solar measured",
|
||||
"measured": "Measured solar",
|
||||
"house": "House use",
|
||||
"levels": "Battery level",
|
||||
"table": "Hour by hour",
|
||||
@@ -448,15 +448,16 @@
|
||||
"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 sunrise.",
|
||||
"no_tomorrow": "There is no solar forecast for tomorrow yet. The levels stop at 06:00.",
|
||||
"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 from the start.",
|
||||
"no_level": "{name} has no level reading yet. Its levels are not shown.",
|
||||
"cycle": "{name} does its monthly cycle. Its level is not shown once the cycle starts.",
|
||||
"no_level": "{name} has no level reading yet. Its level is not shown.",
|
||||
"measured_from": "Measured solar is counted from {at}, when the add-on started.",
|
||||
"tomorrow_total": "Solar expected tomorrow: {kwh}, we count on {count}.",
|
||||
"no_blocks": "No charge or give blocks planned.",
|
||||
"ahead": "The add-on plans prices about a day ahead. Blocks for later hours appear as they come near."
|
||||
"give_later": "The add-on plans to give power. No block shows while the battery has no energy to give.",
|
||||
"ahead": "The add-on plans about a day ahead. Blocks for later hours appear as they come near."
|
||||
},
|
||||
"region": {
|
||||
"preset": "Levies and VAT: {region}, {year} figures.",
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
"sun": "Zon en huis",
|
||||
"unit": "kWh per uur",
|
||||
"forecast": "Zonneprognose",
|
||||
"measured": "Zon gemeten",
|
||||
"measured": "Gemeten zon",
|
||||
"house": "Huisverbruik",
|
||||
"levels": "Laadniveau",
|
||||
"table": "Uur per uur",
|
||||
@@ -376,15 +376,16 @@
|
||||
"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 bij zonsopgang.",
|
||||
"no_tomorrow": "Er is nog geen zonneprognose voor morgen. De niveaus stoppen om 06:00.",
|
||||
"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 vanaf de start.",
|
||||
"no_level": "{name} geeft nog geen laadniveau door. De niveaus worden niet getoond.",
|
||||
"cycle": "{name} doet de maandelijkse cyclus. Het niveau wordt niet getoond zodra de cyclus start.",
|
||||
"no_level": "{name} geeft nog geen laadniveau door. Het niveau wordt niet getoond.",
|
||||
"measured_from": "Gemeten zon telt vanaf {at}, toen de add-on startte.",
|
||||
"tomorrow_total": "Verwachte zon morgen: {kwh}, we rekenen op {count}.",
|
||||
"no_blocks": "Geen laad- of leverblokken gepland.",
|
||||
"ahead": "De add-on plant prijzen ongeveer een dag vooruit. Blokken voor latere uren verschijnen als ze naderen."
|
||||
"give_later": "De add-on plant stroom te leveren. Er verschijnt geen blok zolang de batterij geen stroom heeft.",
|
||||
"ahead": "De add-on plant ongeveer een dag vooruit. Blokken voor latere uren verschijnen als ze naderen."
|
||||
},
|
||||
"region": {"preset": "Heffingen en btw: {region}, cijfers van {year}.", "own": "Heffingen en btw: uw eigen bedrag."},
|
||||
"peak_not_covered": "Geen piekgrens: deze meter meet geen kwartieren.",
|
||||
|
||||
@@ -41,6 +41,7 @@ import ux54_scenarios as S # noqa: E402
|
||||
from app import arbitrage, dayplan, dayview, homeview, modetrade # noqa: E402
|
||||
from app import packmode, solarfc, uitext # noqa: E402
|
||||
from app.daycurve import Batt, SolarToday, curve # noqa: E402
|
||||
from app.peakguard import PeakDecision # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
@@ -193,9 +194,10 @@ check("Home's own price blocks for today are `homeview.runs_for()` of "
|
||||
== [block_key(r) for r in home(c, now)["plan"]["runs"]
|
||||
if r["kind"] in ("grid", "give")])
|
||||
tmr = tab(c, now)["days"][1]["batteries"]
|
||||
check("tomorrow's blocks are the same builder over tomorrow's window",
|
||||
check("tomorrow's blocks are the same builder over tomorrow's window, read "
|
||||
"`ahead` (the level now does not filter a day that has not started)",
|
||||
[[block_key(r) for r in b["blocks"]] for b in tmr]
|
||||
== [[block_key(r) for r in homeview.runs_for(c, n0, n1, now, False)
|
||||
== [[block_key(r) for r in homeview.runs_for(c, n0, n1, now, False, ahead=True)
|
||||
if r["battery"] == i] for i in range(2)])
|
||||
|
||||
# Modes on: Solar first draws no price block; Leftover solar and Cheap hours do.
|
||||
@@ -712,6 +714,216 @@ check("it is readable on a phone: columns flex to the width, the chart has "
|
||||
and not re.search(r"\.fc[a-z]*\{[^}]*width:\s*\d{3,}px", CSS)
|
||||
and "min-height:44px" in CSS)
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
print("\n7. round 2: a block exists where the add-on will act (F1, F2, F3)")
|
||||
# --------------------------------------------------------------------------- #
|
||||
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]
|
||||
|
||||
|
||||
def night_evening(h):
|
||||
"""A cheap night and a dear evening, flat between: one cheap run, one give
|
||||
run, nothing the plan of tomorrow evening would add before then."""
|
||||
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):
|
||||
"""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
|
||||
quarter. The tab is built once, at `now0`."""
|
||||
c = S.make(name, now0, soc=soc)
|
||||
c.tariff_plan = S.quarters(now0, night_evening, days=3)
|
||||
c.tariff_at = now0
|
||||
if guard is not None:
|
||||
c.peak_guard = guard
|
||||
c._mode_roles(now0, None)
|
||||
out = tab(c, now0)
|
||||
d0, _d1 = homeview.local_day(now0)
|
||||
seen = {inv.name: {} for inv in c.inverters}
|
||||
t = now0
|
||||
end = d0 + timedelta(hours=until)
|
||||
while t < end:
|
||||
h = (t - d0).total_seconds() / 3600.0
|
||||
for i, inv in enumerate(c.inverters):
|
||||
day = out["days"][0 if h < 24 else 1]
|
||||
b = day["batteries"][i]
|
||||
inv.soc = level_at(b, h if h < 24 else h - 24) if b["levels"] \
|
||||
else inv.soc
|
||||
c.tariff_at = t
|
||||
tick = c._mode_roles(t, None)
|
||||
for inv in c.inverters:
|
||||
seen[inv.name][t] = tick.roles.get(inv.name)
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
t22 = at(22, 0, 5)
|
||||
c1, o1, s1, d01 = sweep("dynamic", t22, (20.0, 20.0))
|
||||
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))
|
||||
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))
|
||||
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)",
|
||||
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 tomorrow_match(o2, s2, d02, 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)
|
||||
for x in o3))
|
||||
|
||||
|
||||
def off_prices(h):
|
||||
if 23.0 <= h < 24.0:
|
||||
return 0.05
|
||||
if 7.0 <= h < 9.0:
|
||||
return 0.40
|
||||
return 0.20
|
||||
|
||||
|
||||
def off_case(soc):
|
||||
now = at(20, 0, 5)
|
||||
c = S.make("off", now, soc=soc)
|
||||
c.tariff_plan = S.quarters(now, off_prices)
|
||||
c.tariff_at = now
|
||||
return c, now, tab(c, now), home(c, now)
|
||||
|
||||
|
||||
for soc in ((20.0, 20.0), (60.0, 60.0), (95.0, 95.0)):
|
||||
c, now, out, st = off_case(soc)
|
||||
tz = S.brussels(now).tzinfo
|
||||
v, _why = homeview.trade_verdict(c, c.fresh_tariff_plan(now), now)
|
||||
t07 = at(24 + 7, 0, 5)
|
||||
c.tariff_at = t07
|
||||
v7, _w = homeview.trade_verdict(c, c.fresh_tariff_plan(t07), t07)
|
||||
t06 = at(24 + 6, 0, 5)
|
||||
c.tariff_at = t06
|
||||
v6, _w6 = homeview.trade_verdict(c, c.fresh_tariff_plan(t06), t06)
|
||||
check(f"F1, modes off, a charge today at 23:00 and a give tomorrow at "
|
||||
f"07:00, batteries at {soc[0]:.0f} %: Home says 'Tomorrow: gives "
|
||||
"power at 07:00', the tab draws that give on both batteries, and "
|
||||
"the control cycle's own verdict gives at 07:00 and not at 06:00",
|
||||
any("07:00" in x for x in st["plan"]["lines"])
|
||||
and [[(r["kind"], r["from"]) for r in b["blocks"]]
|
||||
for b in out["days"][1]["batteries"]]
|
||||
== [[("give", "07:00")]] * 2
|
||||
and v7 is not None and v7.dearest.start.astimezone(tz).strftime(
|
||||
"%H:%M") == "07:00" and v7.dearest.start <= t07
|
||||
and v6 is not None and v6.dearest.start > t06)
|
||||
|
||||
tm90 = o3[1][1]["days"][1]["batteries"]
|
||||
check("F2: a battery already at its planned level when the run starts has no "
|
||||
"grid block tomorrow (A at 90 %), the one below it has (B at 70 %); "
|
||||
"control charged neither A nor B outside that",
|
||||
[r["kind"] for r in tm90[0]["blocks"]] == ["give"]
|
||||
and [r["kind"] for r in tm90[1]["blocks"]] == ["grid", "give"])
|
||||
# Today: the give run is planned and Home draws no block for an empty battery.
|
||||
c_s = S.make("sunny", at(10, 7), soc=(20.0, 20.0))
|
||||
st_s = home(c_s, at(10, 7))
|
||||
o_s = tab(c_s, at(10, 7))
|
||||
check("today: with a give run in the plan and both batteries at the floor "
|
||||
"Home draws no give block, the tab equals Home, and it says the "
|
||||
"add-on plans to give, never 'No charge or give blocks planned'",
|
||||
not [r for r in st_s["plan"]["runs"] if r["kind"] == "give"]
|
||||
and c_s.mode_runs.give
|
||||
and tab_blocks(o_s) == home_blocks(st_s, 2)
|
||||
and all(b["line"] == say("give_later")
|
||||
for b in o_s["days"][0]["batteries"]))
|
||||
bad_no_blocks = []
|
||||
for name, hh in SCN + [("sunny", 10.1), ("dynamic", 22.0)]:
|
||||
for soc in ((20.0, 20.0), (55.0, 40.0), (95.0, 95.0)):
|
||||
c = S.make(name, at(hh), soc=soc)
|
||||
if not c.modes_on:
|
||||
continue
|
||||
o = tab(c, at(hh))
|
||||
for k in (0, 1):
|
||||
for b in o["days"][k]["batteries"]:
|
||||
if b["line"] == say("no_blocks"):
|
||||
d0_, d1_ = homeview.local_day(at(hh))
|
||||
lo, hi = (d0_, d1_) if k == 0 else homeview.local_day(
|
||||
d1_ + timedelta(hours=2))
|
||||
if c.mode_runs is not None and c.mode_runs.on and any(
|
||||
g.end > max(at(hh), lo) and g.start < hi
|
||||
for g in c.mode_runs.give):
|
||||
bad_no_blocks.append((name, hh, soc, k))
|
||||
check("'No charge or give blocks planned.' is never said where the run plan "
|
||||
f"has a give run in that day ({bad_no_blocks})", not bad_no_blocks)
|
||||
|
||||
# F3: trust as the plan counts it.
|
||||
cap_big = 100.0
|
||||
f3 = curve([Batt("a", cap_big, 50.0, 50.0, True, False)], T0,
|
||||
T0 + timedelta(hours=1), flat(10.0), flat(2.0), floor_pct=20.0,
|
||||
ceiling_pct=95.0, trust=0.7)
|
||||
stored = (lv(f3, "a", 1) - 50.0) / 100.0 * cap_big
|
||||
plan_sun = 0.7 * dayplan.EFF * max(0.0, 10.0 - 2.0)
|
||||
print(f" 10 kWh sun, 2 kWh house, 70 %: curve stores {stored:.3f} kWh, plan {plan_sun:.3f}")
|
||||
check("F3: 10 kWh of sun, 2 kWh of house and 70 % trust: the curve stores "
|
||||
"5.32 kWh, as the plan counts trust x EFF x (sun - house), not 4.75",
|
||||
abs(stored - 5.32) < 1e-6 and abs(plan_sun - 5.32) < 1e-9)
|
||||
f3b = curve([Batt("a", cap_big, 50.0, 50.0, True, False)], T0,
|
||||
T0 + timedelta(hours=1), flat(1.0), flat(2.0), floor_pct=20.0,
|
||||
ceiling_pct=95.0, trust=0.7)
|
||||
check("...a shortfall is not scaled by trust (the house needs all of it)",
|
||||
abs((50.0 - lv(f3b, "a", 1)) / 100.0 * cap_big - 1.0 / dayplan.EFF)
|
||||
< 1e-6)
|
||||
c_t = S.make("owner", at(10, 7))
|
||||
c_t.solar_trust_pct = 70
|
||||
hi_ = tab(c_t, at(10, 7))["days"][0]["batteries"][0]["levels"]
|
||||
check("on a controller the curve is built with the plan's trust "
|
||||
"(`solar_trust_pct`)", hi_ and max(v for _h, v in hi_) > hi_[0][1])
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED")
|
||||
|
||||
Reference in New Issue
Block a user