UX-54 round 4: modes off reads tomorrow at Home's count, no relabel in the walk
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:
co-authored by
Claude
parent
5ab8622265
commit
4256bd3eda
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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").
|
||||
|
||||
@@ -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 %); "
|
||||
|
||||
Reference in New Issue
Block a user