UX-54 round 2: tomorrow's blocks where control will act, trust as the plan counts it, strings
checks / unit (pull_request) Successful in 48m25s
infra-pin / fresh (pull_request) Successful in 1s

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:
glenn schrooyen
2026-10-26 22:48:48 +01:00
co-authored by Claude
parent b6b2d1b184
commit 75195f7073
7 changed files with 388 additions and 85 deletions
@@ -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
+8 -2
View File
@@ -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:
+91 -43
View File
@@ -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)
+39 -22
View File
@@ -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):
+6 -5
View File
@@ -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.",
+6 -5
View File
@@ -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.",
+214 -2
View File
@@ -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")