Files
goodwe-addon/goodwe_controller/app/modetrade.py
T
adminandClaude Opus 5.5 fae6681e19
checks / unit (pull_request) Successful in 18m49s
infra-pin / fresh (pull_request) Successful in 1s
OPT-15: price-run fixes (quarter edge, charge-end overshoot, refusal probe)
F1: with battery modes on, the peak guard projects the quarter `now` is in
even before its first P1 sample (PeakTracker.project(roll=True)), and only
a measured stop latches the period. The edge tick no longer reads "no
seconds left", so a charge is not stopped and latched at a quarter edge.

F2: after a run edge the loop is held at E until the meter shows the step
(EDGE_HOLD_S, longer while the battery still moves). The replay gained a
lag model (dead time, reading age, P1 debounce, 1 s cycles) that shows
TEST-23's overshoot without the hold.

F3: a charge refuser's SAFETY-73 probe inside a cheap run is a charger
pinned at PROBE_W, the others rest, so the probe actually runs.

Modes off stays byte-identical to 5a4cdc2 (new late-P1 identity scenarios).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 20:22:32 +02:00

240 lines
11 KiB
Python

"""OPT-11 P3: each battery's role this tick, from its mode and the price runs.
Pure: no I/O, no clock. main.py hands in the run plan (`priceruns`), each
battery's state and the peak guard's answers, files each battery's range as
its `mode` claim (P_MODE), and files the site trade as the `arbitrage` claim
(P_ARBITRAGE), sized inside the modes (`size()`), only with battery modes on.
With them off, `arbitrage.decide()` runs exactly as before.
Roles (the page's Why line names them):
off Off: [0, 0].
house follows the house: charges from spare solar, gives power at any
hour (Solar first always; the other modes when there is no plan).
hold Leftover solar / Cheap hours outside a give run: may charge from
spare solar, gives nothing, so its energy is kept for the run.
give the same inside a give run: covers the house.
sell inside the sell run, with selling on: gives its rating, to the
house first and the rest to the grid.
charge inside the cheap run: charges from the grid.
rest another battery charges from the grid, so this one may not charge
(the owner's 2026-09-27 answer 3: while one battery charges from the
grid the others cannot give power either; the allocator never
splits a target both ways).
Rules that keep this safe (tests: test_modes_p3.py, test_price_run_fixes.py):
* every range holds 0 W (a mode may forbid a direction, never force one);
* a Solar first battery never charges from the grid (no forecast yet, P4);
* grid charging needs a measured peak guard that is clear and a measured
headroom (SAFETY-45); the trade is sized to that headroom, whose aim is
`peak_cap_w - peak_margin_w` (SAFETY-79). It starts only at START_W or
more, and once the guard or a measured headroom stops it, it stays
stopped for the rest of that 15-minute period (`blocked`): a charge
makes the other batteries stop giving power, so a few watts of charge
flipping on and off would swing the house between them and the grid
(measured on the 2026-09-09 replay: 13 flips in 4 minutes at 3.5 kW);
* selling needs selling on for that battery, the sell run, a price >= 0
now, and the battery above `arbitrage_soc_floor`; it stops there. The
peak guard never stops a sell (section 9.1: discharge is never capped);
it must only have measured the quarter;
* a battery out of a run (full, or at the floor) stays out until the run
ends, so the ceiling and the floor do not re-arm within one run;
* a battery that refuses a direction (SAFETY-52/73) takes no part in a
run in that direction: the refusal wins over the run. The one exception
is its SAFETY-73 probe inside a cheap run (OPT-15): it is a charger then,
pinned at PROBE_W by its own claim, so the others rest (one sign) and the
card's "tries again every 15 minutes" holds in the run too.
"""
from dataclasses import dataclass
from .packmode import (CHEAP_HOURS, GRID_CHARGE_MODES, LEFTOVER_SOLAR,
SOLAR_FIRST)
OFF, HOUSE, HOLD, GIVE, SELL, CHARGE, REST = (
"off", "house", "hold", "give", "sell", "charge", "rest")
PRICE_MODES = (LEFTOVER_SOLAR, CHEAP_HOURS)
# The smallest grid charge worth starting, in W. Under it the other batteries
# would stop covering the house for less than they give.
START_W = 500.0
@dataclass(frozen=True)
class Pack:
"""One battery as this module needs it."""
name: str
use: bool
mode: str
sell: bool
rating_w: float
soc: float | None
capacity_kwh: float | None
live: bool = True
idle: bool = True # not in a maintenance phase
refused: int = 0 # SAFETY-52/73: +1 refuses to give, -1 to charge
probing: bool = False # OPT-15: its SAFETY-73 probe window is open
@dataclass(frozen=True)
class Tick:
ranges: dict # name -> (lo, hi), only for idle batteries
roles: dict # name -> role
action: str | None # "charge" | "sell" | None
ask_w: float # signed site ask before sizing (0 if none)
why: str | None # why no trade when a run is on, else None
def _room_kwh(p: Pack, pct: float) -> float:
"""kWh to `pct` (charging) or above it (selling); a battery with no
capacity set counts one hour at its rating, so a run is still sized."""
if p.capacity_kwh is None or p.soc is None:
return abs(p.rating_w) / 1000.0
return max(0.0, abs(pct - p.soc)) / 100.0 * float(p.capacity_kwh)
def chargers(packs, ceiling_pct, done=(), probe=False) -> list:
"""Batteries that may charge from the grid in a cheap run: the modes in
`packmode.GRID_CHARGE_MODES` (Cheap hours only until P4's forecast).
With `probe`, also one refusing to charge while its SAFETY-73 probe is
open: its own P_SAFETY pin keeps it at PROBE_W (OPT-15)."""
return [p for p in packs
if p.use and p.idle and p.live and p.mode in GRID_CHARGE_MODES
and p.soc is not None and p.soc < ceiling_pct
and (p.refused >= 0 or (probe and p.probing))
and p.name not in done]
def sellers(packs, floor_pct, done=()) -> list:
"""Batteries that may sell in the sell run. Solar first never sells."""
return [p for p in packs
if p.use and p.idle and p.live and p.sell
and p.mode in PRICE_MODES
and p.soc is not None and p.soc > floor_pct
and p.refused <= 0 and p.name not in done]
def energy(packs, floor_pct, ceiling_pct, done_charge=(), done_sell=()):
"""`(charge_kwh, charge_kw, sell_kwh, sell_kw)` for `priceruns.plan()`."""
ch = chargers(packs, ceiling_pct, done_charge)
se = sellers(packs, floor_pct, done_sell)
return (sum(_room_kwh(p, ceiling_pct) for p in ch),
sum(abs(p.rating_w) for p in ch) / 1000.0,
sum(_room_kwh(p, floor_pct) for p in se),
sum(abs(p.rating_w) for p in se) / 1000.0)
def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
headroom_w, floor_pct, ceiling_pct, house_w, charging_now=False,
blocked=False, done_charge=(), done_sell=()) -> Tick:
"""This tick's ranges, roles and site ask.
`runs` is the kept `priceruns.RunPlan` or None (no fresh prices).
`charge_ok` is True only when `TariffPlan.arbitrage()` allowed a trade
this tick with the MEASURED peak guard clear; `sell_ok` when it allowed
one with the guard measured (either state); else `trade_why` says why.
`headroom_w` is `peakguard.charge_headroom_w()` (None: unmeasured).
`house_w` is the measured house (grid + batteries), or None.
`charging_now` says last tick charged (then any headroom above 0 keeps
it going); `blocked` that the guard or the headroom stopped a charge in
this 15-minute period.
"""
plan_on = runs is not None and runs.on
in_cheap = plan_on and runs.cheap.holds(now) and runs.charge_useful()
in_give = plan_on and runs.give_at(now) is not None
in_sell = plan_on and runs.sell is not None and runs.sell.holds(now)
why = None
ch = chargers(packs, ceiling_pct, done_charge, probe=True) \
if in_cheap else []
charging = False
if ch:
need = 0.0 if charging_now else START_W
if blocked:
why = "headroom"
elif not charge_ok:
why = trade_why
elif headroom_w is None or not headroom_w > need:
why = "headroom"
else:
charging = True
se = sellers(packs, floor_pct, done_sell) if in_sell and not charging \
else []
selling = False
if se:
if not sell_ok:
why = why or trade_why
elif price_now is None or price_now < 0.0 or house_w is None:
why = why or "negative"
else:
selling = True
ranges, roles = {}, {}
ch_names = {p.name for p in ch} if charging else set()
se_names = {p.name for p in se} if selling else set()
for p in packs:
r = abs(float(p.rating_w))
if not p.use:
role, lo, hi = OFF, 0.0, 0.0
elif p.name in ch_names:
role, lo, hi = CHARGE, -r, 0.0
elif charging:
# Another battery charges from the grid: this one may not charge
# (it would be grid charging too), and cannot give (one sign).
role, lo, hi = REST, 0.0, (r if p.mode == SOLAR_FIRST
or not plan_on or in_give else 0.0)
elif p.name in se_names:
role, lo, hi = SELL, -r, r
elif p.mode == SOLAR_FIRST or not plan_on:
role, lo, hi = HOUSE, -r, r
elif in_give:
role, lo, hi = GIVE, -r, r
else:
role, lo, hi = HOLD, -r, 0.0
roles[p.name] = role
if p.idle:
ranges[p.name] = (lo, hi)
if charging:
ask = -sum(abs(p.rating_w) for p in ch)
return Tick(ranges, roles, CHARGE, ask, None)
if selling:
ask = sum(abs(p.rating_w) for p in se) + max(0.0, float(house_w))
return Tick(ranges, roles, SELL, ask, None)
return Tick(ranges, roles, None, 0.0,
why if (in_cheap or in_sell) else None)
def size(tick: Tick, bound, headroom_w) -> float:
"""The site trade in W, inside what the batteries may place with their
modes (`bound` = main's loop bound) and, for a charge, the billed
quarter's headroom. 0.0 means no trade (a 0 W trade is a park order and
is never filed)."""
if tick.action is None or bound is None:
return 0.0
lo, hi = bound
if tick.action == CHARGE:
cap = min(-float(tick.ask_w), -float(lo),
float(headroom_w) if headroom_w is not None else 0.0)
return -max(0.0, cap)
return max(0.0, min(float(tick.ask_w), float(hi)))
def weights(roles: dict, base, target_w: float):
"""Who carries the site target first (design 3.5). Selling: the sellers.
Giving power: batteries in a give run before Solar first. Charging from
the grid: only the chargers. Otherwise `base` unchanged. Comfort only;
the ranges above are the permission."""
names = sorted(roles)
first = None
if target_w > 0 and any(r == SELL for r in roles.values()):
first = {n for n in names if roles[n] == SELL}
elif target_w > 0 and any(r == GIVE for r in roles.values()):
first = {n for n in names if roles[n] in (GIVE, SELL)}
elif target_w < 0 and any(r == CHARGE for r in roles.values()):
first = {n for n in names if roles[n] == CHARGE}
if not first:
return base
w = {n: (1.0 if base is None else float(base.get(n, 1.0))) for n in names}
out = {n: (w[n] if n in first else 0.0) for n in names}
return out if any(v > 0.0 for v in out.values()) else base