UX-13 P1 review: Home draws only what the cycle would do
- Trade blocks: none on a battery with no room, no sale at a negative price or while the emptiest battery is at arbitrage's floor, nothing when a level is unknown. A give after a charge may use what the charge put in. - The refusal line names the real reason: prices not published vs gap. - The plan's peak line uses the guard's ceiling (this month's peak once above the cap), as the peak card does. - House below zero: 0 W inside the idle band, no figure beyond it. - Price bars are "Low price" / "High price": the add-on trades in one hour. - Control toggle: one request in flight at a time. - test_home: the new cases; the house-sign mutation now goes red. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -85,9 +85,16 @@ def flow(w, *, shadow=False):
|
||||
def now_block(*, grid_w, pv_w, batt_w, has_solar, shadow):
|
||||
"""The four tiles. `house_w` needs all three readings, so without a solar
|
||||
reading it is None rather than a number that leaves the sun out."""
|
||||
house = None
|
||||
house, disagree = None, False
|
||||
if has_solar and None not in (grid_w, pv_w, batt_w):
|
||||
house = float(grid_w) + float(pv_w) + float(batt_w)
|
||||
# The three are read at slightly different moments, so the sum can dip
|
||||
# under zero. Inside the idle band that is 0 W; beyond it the readings
|
||||
# disagree and no house figure is shown.
|
||||
if house < -IDLE_W:
|
||||
house, disagree = None, True
|
||||
elif house < 0.0:
|
||||
house = 0.0
|
||||
return {
|
||||
"solar_w": None if pv_w is None else float(pv_w),
|
||||
"house_w": house,
|
||||
@@ -96,7 +103,7 @@ def now_block(*, grid_w, pv_w, batt_w, has_solar, shadow):
|
||||
"has_solar": bool(has_solar),
|
||||
"lines": {
|
||||
"solar": tile_solar(pv_w, has_solar),
|
||||
"house": tile_house(house, has_solar),
|
||||
"house": tile_house(house, has_solar, disagree),
|
||||
"grid": tile_grid(grid_w),
|
||||
"batt": tile_batt(batt_w, shadow),
|
||||
},
|
||||
@@ -156,7 +163,9 @@ class DayPrices:
|
||||
|
||||
def hour_bars(points, day0, day1):
|
||||
"""One bar per local hour: the mean of its prices, or None when unknown.
|
||||
`kind` is cheap / dear / mid by thirds of today's range (display only)."""
|
||||
`kind` is cheap / dear / mid by thirds of today's range (display only).
|
||||
The page calls these "low price" and "high price", not cheap hours: the
|
||||
add-on trades in the one cheapest and one dearest hour, not in a third."""
|
||||
n = int(round(_hours(day1, day0)))
|
||||
bars = []
|
||||
for k in range(n):
|
||||
@@ -221,7 +230,19 @@ def trade_verdict(c, plan):
|
||||
uncertainty=frozenset({Uncertainty.NO_WEATHER}))
|
||||
return verdict.match(
|
||||
allowed=lambda v: (v, "allowed"),
|
||||
refused=lambda _v: (None, "peak" if guard else "not_worth"))
|
||||
refused=lambda _v: (None, refusal_key(plan, guard)))
|
||||
|
||||
|
||||
def refusal_key(plan, guard):
|
||||
"""Which of `TariffPlan.arbitrage()`'s refusals fired, in its own order:
|
||||
the peak guard, no prices, under two published points, then the gap."""
|
||||
if guard:
|
||||
return "peak"
|
||||
if not plan.has_prices:
|
||||
return "no_prices"
|
||||
if sum(1 for p in _points(plan) if not p.provisional) < 2:
|
||||
return "not_firm"
|
||||
return "not_worth"
|
||||
|
||||
|
||||
def _point_end(plan, point):
|
||||
@@ -231,26 +252,47 @@ def _point_end(plan, point):
|
||||
return point.start + timedelta(hours=1)
|
||||
|
||||
|
||||
# Under this many kWh a block is not drawn: the cycle would not move it.
|
||||
MIN_KWH = 0.05
|
||||
|
||||
|
||||
def trade_runs(c, plan, verdict, day0, day1, now, shadow):
|
||||
"""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)."""
|
||||
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."""
|
||||
floor, ceiling = arbitrage.soc_bounds(c.o)
|
||||
if any(inv.soc is None for inv in c.inverters):
|
||||
return [], [], "no_level"
|
||||
level = [float(inv.soc) for inv in c.inverters]
|
||||
legs = sorted((("grid", verdict.cheapest), ("give", verdict.dearest)),
|
||||
key=lambda kp: kp[1].start)
|
||||
runs, later = [], []
|
||||
for kind, pt in (("grid", verdict.cheapest), ("give", verdict.dearest)):
|
||||
for kind, pt in legs:
|
||||
end = _point_end(plan, pt)
|
||||
if end <= now:
|
||||
continue
|
||||
if not (day0 <= pt.start < day1):
|
||||
later.append((kind, pt))
|
||||
continue
|
||||
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):
|
||||
kwh = inv.max_w / 1000.0 * hrs
|
||||
if inv.capacity_kwh and inv.soc is not None:
|
||||
room = (ceiling - inv.soc if kind == "grid"
|
||||
else inv.soc - floor)
|
||||
kwh = min(kwh, max(0.0, room) / 100.0 * inv.capacity_kwh)
|
||||
cap = inv.capacity_kwh
|
||||
if cap:
|
||||
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").
|
||||
level[i] += (kwh if kind == "grid" else -kwh) / cap * 100.0
|
||||
if kwh < MIN_KWH:
|
||||
continue
|
||||
eur = kwh * pt.eur_per_kwh
|
||||
if kind == "grid":
|
||||
what, why, amount = (run_grid_what(kwh, shadow),
|
||||
@@ -262,7 +304,7 @@ def trade_runs(c, plan, verdict, day0, day1, now, shadow):
|
||||
run_give_amount(eur))
|
||||
runs.append(_run(i, kind, pt.start, end, day0, day1, what, why,
|
||||
amount, kwh=kwh, eur=eur, price=pt.eur_per_kwh))
|
||||
return runs, later
|
||||
return runs, later, None
|
||||
|
||||
|
||||
def maintenance_runs(c, day0, day1, now, shadow):
|
||||
@@ -387,9 +429,11 @@ def build_plan(c, now, shadow, running, held=False):
|
||||
if running:
|
||||
runs += house_runs(len(c.inverters), day0, day1, now, shadow)
|
||||
if verdict is not None:
|
||||
trade, later = trade_runs(c, fresh, verdict, day0, day1, now,
|
||||
shadow)
|
||||
trade, later, no_trade = trade_runs(c, fresh, verdict, day0, day1,
|
||||
now, shadow)
|
||||
runs += trade
|
||||
if no_trade:
|
||||
lines.append(line_no_trade(no_trade))
|
||||
for kind, pt in later:
|
||||
lines.append(line_trade_tomorrow(kind, hhmm(pt.start),
|
||||
pt.eur_per_kwh, shadow))
|
||||
@@ -400,7 +444,10 @@ def build_plan(c, now, shadow, running, held=False):
|
||||
lines += waits
|
||||
runs += peak_run(c, day0, day1, now, shadow, limit_w)
|
||||
if c.p1_enabled:
|
||||
lines.append(line_peak_limit(limit_w, shadow))
|
||||
# The guard's ceiling: the cap, or this month's peak once above it.
|
||||
pg = c.peak_guard
|
||||
lines.append(line_peak_limit(
|
||||
(pg.ceiling_w if pg is not None else None) or limit_w, shadow))
|
||||
lines = [x for x in lines if x]
|
||||
|
||||
kinds = {r["kind"] for r in runs}
|
||||
@@ -663,9 +710,11 @@ def tile_solar(pv_w, has_solar) -> str:
|
||||
return "Producing" if pv_w > IDLE_W else "Not producing"
|
||||
|
||||
|
||||
def tile_house(house_w, has_solar) -> str:
|
||||
def tile_house(house_w, has_solar, disagree=False) -> str:
|
||||
if not has_solar:
|
||||
return "Needs a solar reading"
|
||||
if disagree:
|
||||
return "Readings do not add up"
|
||||
return "No reading" if house_w is None else "Using"
|
||||
|
||||
|
||||
@@ -805,7 +854,9 @@ def line_no_trade(key) -> str:
|
||||
"peak.",
|
||||
"no_prices": "No trading on price yet: no prices have arrived.",
|
||||
"peak": "No trading on price now: the peak limit comes first.",
|
||||
"not_firm": "No trading on price yet: the prices are not published.",
|
||||
"not_worth": "No trading on price today. The price gap is too small.",
|
||||
"no_level": "No trading on price now: a battery level is unknown.",
|
||||
}[key]
|
||||
|
||||
|
||||
@@ -884,6 +935,7 @@ def text_samples() -> list:
|
||||
((None, False), (None, True), (3900, True), (0, True))]
|
||||
out += [("title", tile_house(v, s)) for v, s in
|
||||
((None, False), (None, True), (1100, True))]
|
||||
out.append(("title", tile_house(None, True, True)))
|
||||
out += [("title", tile_grid(v)) for v in (None, 1200, -800, 5)]
|
||||
out += [("title", tile_batt(v, sh)) for v in (None, 2500, -2500, 0)]
|
||||
for phase in (M_IDLE, M_DRAIN, M_CHARGE, M_HOLD):
|
||||
@@ -928,5 +980,6 @@ def text_samples() -> list:
|
||||
("plan", line_region("own", None)),
|
||||
("plan", line_peak_not_covered())]
|
||||
out += [("plan", line_no_trade(k)) for k in
|
||||
("not_covered", "no_prices", "peak", "not_worth")]
|
||||
("not_covered", "no_prices", "peak", "not_firm", "not_worth",
|
||||
"no_level")]
|
||||
return out
|
||||
|
||||
Reference in New Issue
Block a user