UX-54 round 4: modes off reads tomorrow at Home's count, no relabel in the walk
checks / unit (pull_request) Successful in 48m51s
infra-pin / fresh (pull_request) Successful in 2s

Modes off has no curve (no forecast is read, no house profile kept), so
dayview._levels() never ran there and tomorrow's legs were drawn from the
bound. homeview.trade_runs(ahead=f) now reads each leg at f(name, kind,
start), as mode_runs() does: no give while the emptiest battery is at the
floor, each share sized from that level. dayview._counted() gives a give
the level by Home's own count (the level now, plus what the blocks before
it put in, less what they take out); a charge is read from the floor,
because a full battery covers the house before the cheap hour.

The battery line says a give is planned when one is and the battery has
no give block, also with modes off.

test_ux54.py: the modes-on walk no longer relabels a GIVE role at the
floor; it asks control's own discharge envelope (packshare.envelope())
with each pack's cut-out at the floor. The peak-guard 20 % case asserts
the curve is at the floor at the 17:00 give start. A modes-off walk on a
cloudy day (arbitrage.decide() with the cycle's inputs, the packs moved by
what control does and by the house), cases A-D: control charges and gives
in exactly the quarters the tab has a block. trade_runs(ahead=f) unit
check. _stops() ends a give block where the level the tab shows is at the
floor (LEVEL_DP).

No decision, command, timing or option key changes.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
This commit is contained in:
glenn schrooyen
2026-10-27 03:06:12 +01:00
co-authored by Claude
parent 5ab8622265
commit 4256bd3eda
4 changed files with 326 additions and 42 deletions
@@ -24,6 +24,16 @@ battery's expected level with its blocks. It follows the add-on, as Home does
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.
- **Modes off has no curve** (no forecast is read, no house profile kept), so
`trade_runs(ahead=f)` reads each leg at Home's own count
(`dayview._counted()`): the level now, plus what the blocks before it put in,
less what they take out. No sun is read and the house only takes, so no give
block where the emptiest battery is at the floor by that count, and control
does not give there. A charge is read from the floor: a full battery covers
the house before the cheap hour, and control charges it back then. Walked
every 15 minutes on a cloudy day with the house (`test_ux54.py` part 7,
cases A-C), control charges and gives in exactly the quarters the tab has a
block.
- **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
@@ -33,8 +43,10 @@ battery's expected level with its blocks. It follows the add-on, as Home does
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.
(`test_ux54.py` part 7), charges and gives in exactly the quarters the tab
has a block. A GIVE role counts only where control's own discharge envelope
(`packshare.envelope()`) is open; the walk puts each pack's cut-out at the
floor.
- **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
@@ -85,8 +97,16 @@ is `SolarToday.add()` after the solar read, in a `try`, in memory.
- 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
battery's own cut-out level. The house profile is a median per hour, net of
the solar the house used.
battery's own cut-out level. Control stops a pack at its cut-out, so with a
cut-out under the floor a battery in a give run covers the house below the
floor; Home draws no block there either. The house profile is a median per
hour, net of the solar the house used.
- Modes off: the count knows no sun and no house. A battery at the floor that
the sun fills before a give gets no block until then (the tab says "The
add-on plans to give power. No block shows while the battery has no energy
to give."), and a small charge may be used by the house before the give.
A leg is the verdict's as it stands now, one price period, as on Home; with
several equal prices in a row control takes the next one as each passes.
- Measured solar starts when the add-on starts; the tab says from when.
## Found, not changed
+81 -20
View File
@@ -26,6 +26,7 @@ from .packmode import SOLAR_FIRST
from .price import Uncertainty, brussels, required_spread, uncertainty_of
BLOCKS = ("grid", "give", "cycle") # the kinds a battery's blocks carry
LEVEL_DP = 1 # the tab shows a level to 0.1 %
def _hours_between(a, b) -> float:
@@ -79,10 +80,15 @@ def _build(c, status, now):
# The blocks of each day: today's from Home, tomorrow's by its builders.
# 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 []]
# builders read each block at the level it starts at (`ahead`), with
# their own room and energy filter. With modes on that level is the
# curve's (`_levels()`); with modes off there is no curve, and it is
# Home's own count (`_counted()`).
runs = [plan["runs"], []]
if running and not modes_on:
runs[1] = _off_tomorrow(c, runs[0], now, shadow, n0, n1)
elif running:
runs[1] = hv.runs_for(c, n0, n1, now, shadow, ahead=True)
# 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
@@ -230,13 +236,55 @@ def _levels(c, est, fc, trust, now, end, runs, floor, ceil, notes_b, shadow,
return lv, [runs[0], rt]
def _off_tomorrow(c, today, now, shadow, n0, n1):
"""Modes off: tomorrow's blocks by `homeview.trade_runs()`, each read at
the level `_counted()` gives when it starts. A charge may come before a
give, so this repeats until the blocks stop changing."""
sig, rt = None, []
for _ in range(4):
rt = hv.runs_for(c, n0, n1, now, shadow,
ahead=_counted(c, [today, rt]))
new = [(r["battery"], r["kind"], r["start"], r["end"]) for r in rt]
if new == sig:
break
sig = new
return rt
def _counted(c, rs):
"""Modes off reads no forecast and keeps no house profile, so there is no
curve. A give is read at Home's own count (`trade_runs()`): the level now,
plus what the blocks in `rs` that start before it put in, less what they
take out. The house can only take from that and no sun is read, so a
battery at the floor by the count is at the floor then. A charge is read
from the floor (None): the house uses some of a full battery before the
cheap hour, and control charges it back then."""
_floor, ceil = arbitrage.soc_bounds(c.o)
def at(name, kind, start):
i = next(k for k, inv in enumerate(c.inverters) if inv.name == name)
inv = c.inverters[i]
if kind == "grid" or inv.soc is None or not inv.capacity_kwh:
return None
v = float(inv.soc)
mine = sorted((r for day in rs for r in day if r["battery"] == i
and r["kind"] in ("grid", "give")
and r.get("kwh") is not None
and _times(r)[0] < start),
key=lambda r: _times(r)[0])
for r in mine:
d = float(r["kwh"]) / float(inv.capacity_kwh) * 100.0
v = min(max(v, ceil), v + d) if r["kind"] == "grid" else v - d
return v
return at
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."""
"""`r` ended where its battery stops, if that is before the run ends: a
grid block where the curve has it at the level `modetrade.charge_to()`
allows (the curve has a point there), a give block at the first point
where the level the tab shows (`LEVEL_DP`) is at the floor. 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"]]
@@ -247,7 +295,7 @@ def _stops(r, names, lv, packs, targets, floor, ceil, day0):
return r
hit = lambda v: v >= to - 1e-6 # noqa: E731
else:
hit = lambda v: v <= floor + 0.05 # noqa: E731
hit = lambda v: round(v, LEVEL_DP) <= floor # 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:
@@ -296,7 +344,7 @@ def _day(c, k, d0, d1, now, fc, house, trust, levels, blocks, rows, shadow,
batts.append({
"name": inv.name, "capacity_kwh": inv.capacity_kwh,
"off": row.get("use") is False,
"levels": [[round(_hours_between(d0, t), 3), round(v, 1)]
"levels": [[round(_hours_between(d0, t), 3), round(v, LEVEL_DP)]
for t, v in pts],
"blocks": [{x: r[x] for x in ("kind", "from", "to", "start_h",
"end_h", "what", "why", "amount")}
@@ -305,7 +353,8 @@ def _day(c, k, d0, d1, now, fc, house, trust, levels, blocks, rows, shadow,
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))
hr["levels"].append(None if s not in at
else round(at[s], LEVEL_DP))
has_levels = any(b["levels"] for b in batts)
fcs = [h["forecast_kwh"] for _s, h in hours]
notes = []
@@ -364,7 +413,16 @@ def _tomorrow_line(status) -> str:
def _give_planned(c, d0, d1, now) -> bool:
"""Does the run plan have a give run in `[d0, d1)` not yet over?"""
"""Does the add-on plan a give in `[d0, d1)` not yet over? Modes on: a
give run of the run plan. Modes off: a give leg of the verdict that the
gate takes (`homeview.trade_legs()`), at a price that is not negative."""
if not getattr(c, "modes_on", False):
plan = c.fresh_tariff_plan(now)
verdict, _why = hv.trade_verdict(c, plan, now)
return verdict is not None and any(
k == "give" and pt.eur_per_kwh >= 0 and pt.start < d1
and hv._point_end(plan, pt) > max(now, d0)
for k, pt in hv.trade_legs(c, plan, verdict, now))
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)
@@ -372,16 +430,19 @@ def _give_planned(c, d0, d1, now) -> bool:
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."""
draws no give block for a battery with no energy to give, so a give that
the add-on plans with no block gets its own sentence."""
if not running:
return hv.why_words(key)
if row.get("use") is False:
return hv.why_words("off")
modes_on = getattr(c, "modes_on", False)
gives = not modes_on or row.get("mode_choice") in hv.PRICE_MODES
if gives and not any(r["kind"] == "give" for r in mine) \
and _give_planned(c, d0, d1, now):
return hv.day_note("give_later")
if mine:
return ""
if getattr(c, "modes_on", False) and row.get("mode_choice") in \
hv.PRICE_MODES:
return hv.day_note("give_later" if _give_planned(c, d0, d1, now)
else "no_blocks")
if modes_on and gives:
return hv.day_note("no_blocks")
return hv.run_house_what(shadow) + " " + hv.run_house_why(shadow)
+16 -9
View File
@@ -342,13 +342,21 @@ def trade_runs(c, plan, verdict, day0, day1, now, shadow, ahead=False):
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.
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."""
about it. The same rules then read each battery at the level a leg starts
at: `ahead(name, kind, start)`, a level in % or None, as in `mode_runs()`;
for None (and for `ahead=True`) a charge is read from the floor and a give
from the ceiling (the most it could be). The leg itself is still the
gate's (`trade_legs()`). Home never passes it."""
floor, ceiling = arbitrage.soc_bounds(c.o)
if not ahead and any(inv.soc is None for inv in c.inverters):
return [], [], "no_level"
level = [] if ahead else [float(inv.soc) for inv in c.inverters]
def start_pct(kind, start):
got = [ahead(inv.name, kind, start) if callable(ahead) else None
for inv in c.inverters]
return [(floor if kind == "grid" else ceiling) if v is None
else float(v) for v in got]
runs, later = [], []
for kind, pt in trade_legs(c, plan, verdict, now):
end = _point_end(plan, pt)
@@ -357,8 +365,9 @@ def trade_runs(c, plan, verdict, day0, day1, now, shadow, ahead=False):
if not (day0 <= pt.start < day1):
later.append((kind, pt))
continue
if kind == "give" and (pt.eur_per_kwh < 0
or (not ahead and min(level) <= floor)):
if ahead:
level = start_pct(kind, pt.start)
if kind == "give" and (pt.eur_per_kwh < 0 or min(level) <= floor):
continue
hrs = (end - pt.start).total_seconds() / 3600.0
for i, inv in enumerate(c.inverters):
@@ -367,10 +376,8 @@ def trade_runs(c, plan, verdict, day0, day1, now, shadow, ahead=False):
kwh = inv.max_w / 1000.0 * hrs
cap = inv.capacity_kwh
if cap:
now_pct = (floor if kind == "grid" else ceiling) if ahead \
else level[i]
room = (ceiling - now_pct if kind == "grid"
else now_pct - floor)
room = (ceiling - level[i] if kind == "grid"
else level[i] - 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").
+205 -9
View File
@@ -23,6 +23,7 @@ Mutations (MUTATION.md, purged copies): listed in the pull request.
"""
import asyncio
import dataclasses
import json
import logging
import os
@@ -39,7 +40,9 @@ logging.disable(logging.WARNING)
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 import packmode, packshare, peakguard, solarfc, uitext # noqa: E402
from app.main import pack_energy_of # noqa: E402
from app.price import Uncertainty # noqa: E402
from app.daycurve import Batt, SolarToday, curve # noqa: E402
from app.peakguard import PeakDecision # noqa: E402
@@ -745,12 +748,31 @@ 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 cut_out_at_floor(c):
"""Each pack cuts out at the add-on's floor. Home, the plan and the curve
stop giving there; control stops a pack at its own cut-out (the `pack`
claim, `packshare.envelope()`). With a lower cut-out a battery in a give
run covers the house below the floor, which none of them draws
(DECISIONS, Limits)."""
floor_, _ceil = arbitrage.soc_bounds(c.o)
for inv in c.inverters:
inv.bms_soc_floor = floor_
def may_give(inv):
"""Control's own answer: is this pack asked to discharge at all?"""
return packshare.envelope(pack_energy_of(inv)).hi > 0.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
quarter. The tab is built once, at `now0`."""
quarter. A GIVE role on a pack that control's discharge envelope closes
is seen as "closed": the pack is not asked to give. The tab is built
once, at `now0`."""
c = S.make(name, now0, soc=soc)
cut_out_at_floor(c)
c.tariff_plan = S.quarters(now0, night_evening, days=3)
c.tariff_at = now0
if guard is not None:
@@ -772,17 +794,20 @@ def sweep(name, now0, soc, *, guard=None, until=48.0, sun=None):
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:
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
seen[inv.name][t] = "closed" if role == modetrade.GIVE \
and not may_give(inv) else role
t += Q
return c, out, seen, d0
def role_at(seen, name, d0, h):
"""The role seen in the quarter that starts at hour `h` from `d0`."""
return next((r for t, r in seen[name].items()
if 0 <= (t - d0).total_seconds() / 3600.0 - h < 0.25), None)
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
@@ -824,11 +849,20 @@ 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"]
fl2 = arbitrage.soc_bounds(c2.o)[0]
print(" peak guard, 20 %: level at 17:00", [level_at(b, 17.0) for b in tm2],
"floor", fl2, "give runs", [(homeview.hhmm(g.start), homeview.hhmm(g.end))
for g in c2.mode_runs.give])
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)",
"charges tomorrow and the tab draws no grid block. The give run starts "
"at 17:00 with the curve at the floor on both (the 3 kWh of sun is "
"less than the house uses), so no give block, and control's GIVE role "
"there has its discharge closed",
c2.mode_gate == "peak"
and any(homeview.hhmm(g.start) == "17:00" for g in c2.mode_runs.give)
and all(level_at(b, 17.0) == fl2 for b in tm2)
and all(not b["blocks"] for b in tm2)
and all(role_at(s2, n, d02, 41.0) == "closed" for n in s2)
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)
@@ -902,6 +936,168 @@ for soc in ((20.0, 20.0), (60.0, 60.0), (95.0, 95.0)):
"%H:%M") == "07:00" and v7.dearest.start <= t07
and v6 is not None and v6.dearest.start > t06)
# Modes off: no forecast is read and no house profile kept, so there is no
# curve, and tomorrow's blocks are Home's own count (`dayview._counted()`).
# Control is walked every 15 minutes from 22:00 to the end of tomorrow on a
# cloudy day (no sun), the packs moved by what control does:
# `arbitrage.decide()` with the inputs `Controller.cycle()` gives it (the
# emptiest pack, `trade_power_w()`, the guard's headroom), and between trades
# each pack above its cut-out covers its share of the house. The tab is
# built once, at 22:00. Each price extreme is one quarter, so a leg is one
# quarter: per battery, control charges and gives in exactly the quarters
# the tab has a block of that kind, in both directions.
HOUSE_W = 400.0
def one_quarter(cheap, dear):
"""Prices with one cheap and one dear quarter a day (hours), 0.20 else."""
return lambda h: 0.12 if h == cheap else 0.45 if h == dear else 0.20
def off_sweep(price_of, now0, soc):
c = S.make("off", now0, soc=soc)
cut_out_at_floor(c)
c.tariff_plan = S.quarters(now0, price_of, days=3)
c.tariff_at = now0
# the guard has measured the quarter: 6 kW of charging headroom
c.peak_guard = dataclasses.replace(c.peak_guard, wanted_w=-6000.0)
out = tab(c, now0)
floor_, ceil_ = arbitrage.soc_bounds(c.o)
d0, _d1 = homeview.local_day(now0)
seen = {inv.name: {} for inv in c.inverters}
t = now0
while t < d0 + timedelta(hours=48):
c.tariff_at = t
charge_w, discharge_w = c.trade_power_w(floor_, ceil_)
d = arbitrage.decide(
c.fresh_tariff_plan(t), t,
peak_guard_active=arbitrage.guard_state(c.peak_guard),
charge_headroom_w=peakguard.charge_headroom_w(c.peak_guard),
soc=c.pack_floor_soc,
min_spread_eur_kwh=arbitrage.threshold_eur_kwh(c.o),
uncertainty=frozenset({Uncertainty.NO_WEATHER}),
charge_w=charge_w, discharge_w=discharge_w,
soc_floor_pct=floor_, soc_ceiling_pct=ceil_)
able = [inv for inv in c.inverters if may_give(inv)]
for inv in c.inverters:
cap, act = float(inv.capacity_kwh), None
if d.action == arbitrage.CHARGE and inv.soc < ceil_:
act = "grid"
kw = abs(d.power_w) * inv.max_w / charge_w / 1000.0
inv.soc = min(ceil_, inv.soc + kw * 0.25 * dayplan.EFF
/ cap * 100.0)
elif d.action == arbitrage.DISCHARGE and inv.soc > floor_ \
and may_give(inv):
act = "give"
inv.soc = max(floor_, inv.soc - inv.max_w / 1000.0 * 0.25
/ dayplan.EFF / cap * 100.0)
elif d.action is None and inv in able:
inv.soc = max(floor_, inv.soc - HOUSE_W / len(able) / 1000.0
* 0.25 / dayplan.EFF / cap * 100.0)
seen[inv.name][t] = act
t += Q
return c, out, seen, d0
def acts(seen, d0, kind):
"""`{name: [hour, ...]}` tomorrow where control does `kind`."""
return {name: [round((t - d0).total_seconds() / 3600.0 - 24.0, 2)
for t, a in row.items() if a == kind
and (t - d0).total_seconds() >= 86400]
for name, row in seen.items()}
def off_match(out, seen, d0):
"""Per battery, tomorrow: control does `kind` in exactly the quarters
the tab has a block of that kind."""
for kind in ("grid", "give"):
got = acts(seen, d0, kind)
for i, name in enumerate(seen):
hs = [round(r["start_h"] + 0.25 * k, 2)
for r in out["days"][1]["batteries"][i]["blocks"]
if r["kind"] == kind
for k in range(round((r["end_h"] - r["start_h"]) / 0.25))]
if hs != got[name]:
print(" ", name, kind, "tab", hs, "control", got[name])
return False
return True
def kinds(out):
return [[(r["kind"], r["from"], r["to"]) for r in b["blocks"]]
for b in out["days"][1]["batteries"]]
# A: at the floor, a cheap quarter at 05:00, a dear one at 07:00.
ca, oa, sa, da = off_sweep(one_quarter(5.0, 7.0), t22, (20.0, 20.0))
print(" modes off A, 20 %, cheap 05:00, dear 07:00, no sun: tab", kinds(oa),
"control", acts(sa, da, "grid"), acts(sa, da, "give"))
check("F2, modes off: both batteries at the floor at 22:00, a charge at "
"05:00 before the 07:00 give: both blocks on both, and control charges "
"and gives in exactly those quarters",
kinds(oa) == [[("grid", "05:00", "05:15"), ("give", "07:00", "07:15")]]
* 2 and off_match(oa, sa, da))
# B: the cloudy-day give run. Nothing charges before the give.
cb, ob, sb, db = off_sweep(one_quarter(13.0, 7.0), t22, (20.0, 20.0))
print(" modes off B, 20 %, dear 07:00, cheap 13:00, no sun: tab", kinds(ob),
"control", acts(sb, db, "grid"), acts(sb, db, "give"))
v_b, _w = homeview.trade_verdict(cb, cb.fresh_tariff_plan(t22), t22)
check("F2, modes off, cloudy day: both batteries at the floor at 22:00 and "
"nothing charges before the verdict's 07:00 give: no give block on "
"either and control never gives; the 13:00 charge block shows and "
"control charges in exactly that quarter",
v_b is not None and homeview.hhmm(v_b.dearest.start) == "07:00"
and kinds(ob) == [[("grid", "13:00", "13:15")]] * 2
and all(not v for v in acts(sb, db, "give").values())
and off_match(ob, sb, db))
check("...and each battery says the add-on plans to give, not that it only "
"follows the house",
all(b["line"] == say("give_later")
for b in ob["days"][1]["batteries"]))
# D: the same day with B at 60 %: the site gives as one, and not while its
# emptiest pack is at the floor (`arbitrage.decide()` reads the emptiest).
cd_, od, sd, dd = off_sweep(one_quarter(13.0, 7.0), t22, (20.0, 60.0))
print(" modes off D, 20 % and 60 %, dear 07:00, cheap 13:00: tab", kinds(od),
"control", acts(sd, dd, "grid"), acts(sd, dd, "give"))
check("F2, modes off: one battery at the floor and one at 60 %: no give block "
"on either (the site does not give while its emptiest pack is at the "
"floor) and control gives on neither; the charge blocks match",
all(not [r for r in b if r[0] == "give"] for b in kinds(od))
and all(not v for v in acts(sd, dd, "give").values())
and off_match(od, sd, dd))
# C: full at 22:00 and a cheap quarter at 01:00: the house uses some first,
# so control charges then, and the tab has that block.
cc, oc, sc, dc = off_sweep(one_quarter(1.0, 17.0), t22, (95.0, 95.0))
print(" modes off C, 95 %, cheap 01:00, dear 17:00, no sun: tab", kinds(oc),
"control", acts(sc, dc, "grid"), acts(sc, dc, "give"))
check("F2, modes off: both batteries at the ceiling at 22:00, a cheap "
"quarter at 01:00 and a dear one at 17:00: the house uses some first, "
"so control charges at 01:00 and gives at 17:00, in exactly the "
"quarters the tab has a block",
kinds(oc) == [[("grid", "01:00", "01:15"), ("give", "17:00", "17:15")]]
* 2 and off_match(oc, sc, dc))
# `trade_runs(ahead=f)` reads each leg at `f(name, kind, start)`.
n0_, n1_ = homeview.local_day(homeview.local_day(t22)[1] + timedelta(hours=2))
fl_b, ce_b = arbitrage.soc_bounds(cb.o)
cb.tariff_at = t22
def legs_at(lv):
return sorted((r["battery"], r["kind"], r["from"]) for r in
homeview.runs_for(cb, n0_, n1_, t22, False,
ahead=lambda _n, k, _s: lv.get(k)))
check("trade_runs(ahead=f): a give leg is read at the level f gives when it "
"starts (none at the floor), a charge too (none at the ceiling); f "
"None reads a charge from the floor and a give from the ceiling",
legs_at({"give": fl_b, "grid": None}) == [(0, "grid", "13:00"),
(1, "grid", "13:00")]
and legs_at({"give": 60.0, "grid": ce_b}) == [(0, "give", "07:00"),
(1, "give", "07:00")]
and legs_at({}) == sorted([(0, "give", "07:00"), (1, "give", "07:00"),
(0, "grid", "13:00"), (1, "grid", "13:00")]))
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 %); "