diff --git a/goodwe_controller/DECISIONS/UX-54-forecast-tab.md b/goodwe_controller/DECISIONS/UX-54-forecast-tab.md index b7a4efd..12acd7b 100644 --- a/goodwe_controller/DECISIONS/UX-54-forecast-tab.md +++ b/goodwe_controller/DECISIONS/UX-54-forecast-tab.md @@ -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 diff --git a/goodwe_controller/app/dayview.py b/goodwe_controller/app/dayview.py index 4dd9741..4319d7b 100644 --- a/goodwe_controller/app/dayview.py +++ b/goodwe_controller/app/dayview.py @@ -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) diff --git a/goodwe_controller/app/homeview.py b/goodwe_controller/app/homeview.py index db710f8..eea7b97 100644 --- a/goodwe_controller/app/homeview.py +++ b/goodwe_controller/app/homeview.py @@ -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"). diff --git a/goodwe_controller/test_ux54.py b/goodwe_controller/test_ux54.py index f1ad774..a897159 100644 --- a/goodwe_controller/test_ux54.py +++ b/goodwe_controller/test_ux54.py @@ -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 %); "