OPT-15: price-run fixes (quarter edge, charge-end overshoot, refusal probe)
checks / unit (pull_request) Successful in 18m49s
infra-pin / fresh (pull_request) Successful in 1s

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>
This commit is contained in:
2026-09-29 20:22:32 +02:00
co-authored by Claude Opus 5.5
parent 5a4cdc273e
commit fae6681e19
10 changed files with 763 additions and 58 deletions
+65 -18
View File
@@ -233,6 +233,9 @@ def load_options() -> dict:
# OPT-11 P3: how long the last cheap run stays the price stored energy is
# valued at (`priceruns.plan(ref_eur_kwh=)`): the rest of that day.
REF_CHEAP_KEEP = timedelta(hours=12)
# OPT-15: the longest a run edge holds the loop at E waiting for the meter to
# show the step: the plant's measured step time (control.PLANT_SETTLE_S).
EDGE_HOLD_S = _ctl.PLANT_SETTLE_S
def why_raised(resolution, claims) -> bool:
@@ -942,6 +945,9 @@ class Controller:
self._mode_trade_was = False
self._charge_blocked_q = None
self._ref_cheap = None
# OPT-15: a run edge the loop holds at E: (E, step sign, until, the
# last battery reading).
self._edge = None
# OPT-11 P2: (topic, reason) of MQTT commands refused and logged once.
self._mqtt_refused = set()
self._mqtt_refused_more = 0
@@ -2059,10 +2065,15 @@ class Controller:
or maint_results[i].phase == IDLE)
refused = (inv.refused if len(self.inverters) > 1
else self.batt_refused) or 0
# OPT-15: a charge refusal's probe (SAFETY-73, N>1) as it stood
# when last decided; its pin is filed after this, in cycle().
probing = (len(self.inverters) > 1 and refused < 0
and inv.probe_from is not None)
out.append(modetrade.Pack(
inv.name, pm.use, pm.mode, pm.sell and pm.can_sell,
float(inv.max_w), inv.soc, inv.capacity_kwh,
live=inv.name in live, idle=idle, refused=int(refused)))
live=inv.name in live, idle=idle, refused=int(refused),
probing=probing))
return out
def _price_runs(self, now, packs, floor_pct, ceiling_pct):
@@ -2163,8 +2174,12 @@ class Controller:
blocked=self._charge_blocked_q == quarter,
done_charge=done_c, done_sell=done_s)
# A charge the guard or the headroom stopped stays off this period.
if was and self.mode_tick.action != modetrade.CHARGE and \
self.mode_tick.why in ("peak", "headroom"):
# OPT-15: only a MEASURED stop latches; an unmeasured headroom (None)
# stops this tick only, so a gap cannot cost the whole quarter.
if was and self.mode_tick.action != modetrade.CHARGE and (
self.mode_tick.why == "peak"
or (self.mode_tick.why == "headroom"
and self.arb_headroom_w is not None)):
self._charge_blocked_q = quarter
return self.mode_tick
@@ -2233,18 +2248,18 @@ class Controller:
else "trade_off"))
return self.arb.action is not None
def _reseed_run_edge(self, claims) -> None:
def _reseed_run_edge(self, claims, now) -> None:
"""OPT-11 P3: a mode trade just ended, so the loop takes over from a
command no loop chose. Start it at the house's own point E, the
command that brings the grid to its target now (`site battery + grid
- target`, inside the loop bound), instead of at the trade's watts
(the trade began with the same unslewed step). This tick's error still
measures the trade's command (it is exactly E - battery), so the
integrator is seeded that much short and lands on E after its step.
Measured (tools/opt11p3_replay.py, lagged battery, 2.5 kW charge
ending under an 800 W house): no reseed -258 W export then a 2976 W
import, settled in 55 s; this -257 W / +615 W, settled in 20 s.
- target`, inside the loop bound), instead of at the trade's watts.
OPT-15: then HOLD it at E (`_edge_grid()`) until the meter shows the
step or `EDGE_HOLD_S` passes. The readings still describe the trade for
a second or two (meter and battery lag), and stepping on them sent
E 810 -> 1370 -> 1920 W on TEST-23: -1120 W export at a charge end.
Measured: DECISIONS/OPT-11-battery-modes.md.
"""
self._edge = None
batt = self.site_batt_w
if batt is None or self.grid is None:
return
@@ -2253,8 +2268,34 @@ class Controller:
if bound is not None:
want = max(bound[0], min(bound[1], want))
want = float(round(max(-self.site_max_w, min(self.site_max_w, want))))
self.target = want
self.i_w = want - self.tuning.gain * (want - float(batt))
self.target = self.i_w = want
if want != float(batt):
self._edge = (want, 1.0 if want > float(batt) else -1.0,
now + timedelta(seconds=EDGE_HOLD_S), float(batt))
def _edge_grid(self, end, now):
"""OPT-15: the grid reading the loop acts on this tick. While a run
edge is held, the target itself (no error, the command stays at E).
The hold ends when the meter reaches the target in the step's
direction (or passes it), on `end` (a trade started, or another claim
moved the command), or after `EDGE_HOLD_S` - later, up to twice that,
while the battery reading is still moving towards E."""
e = self._edge
if e is None:
return self.grid
held_w, sign, until, last_b = e
tg, db = self.tuning.target_grid_w, self.tuning.deadband_w
b = self.site_batt_w
moving = (b is not None and sign * (float(b) - last_b) >= db
and sign * (held_w - float(b)) >= db
and now < until + timedelta(seconds=EDGE_HOLD_S))
if (end or (now >= until and not moving)
or sign * (float(self.grid) - tg) < db):
self._edge = None
return self.grid
self._edge = (held_w, sign, until, last_b if b is None else float(b))
self.i_w = held_w
return tg
def _mode_fault_log(self, resolution) -> None:
"""OPT-11: say once, per battery, that its mode was dropped as a fault.
@@ -3713,7 +3754,9 @@ class Controller:
# the same answer, for the same reason, that the status page already
# gives for SAFETY-18 on a transport that cannot see `unchanged_s`.
self.peak_guard = None if not self.p1_enabled else peakguard.decide(
self.peak.project(self.p1.averager, now),
# OPT-15: with battery modes on, the quarter `now` is in, even
# before its first sample (DECISIONS/OPT-11-battery-modes.md).
self.peak.project(self.p1.averager, now, roll=self.modes_on),
# ⚠️ THE SITE SUM. The guard sizes a floor from what the battery is
# ALREADY removing from the meter, and the meter sees every
# inverter. One unit's power here would size the floor from a
@@ -4113,11 +4156,15 @@ class Controller:
# entering failsafe re-seeds the integrator to zero and the first
# cycle after release starts from zero instead of dumping the whole
# stale period as power.
if self.loop_w is None or self.target != self.loop_w:
moved = self.loop_w is None or self.target != self.loop_w
if moved:
self.i_w = self.target
# OPT-11 P3: a run edge (a mode trade ended) reseeds the loop.
# OPT-11 P3: a run edge (a mode trade ended) reseeds the loop, and
# OPT-15 holds it there until the meter shows the step.
if trade_was and not mode_trade:
self._reseed_run_edge(claims)
self._reseed_run_edge(claims, now)
moved = False
loop_grid = self._edge_grid(mode_trade or moved, now)
# SAFETY-52: another writer overrode the loop, so a refusal learned
# against the loop's own ask no longer describes the wire. A gap
# (`loop_w` None: blind or stopped) keeps it: the stale-reading stop
@@ -4150,7 +4197,7 @@ class Controller:
loop_prev = self.target
decision = compute(
prev_w=loop_prev,
grid_w=self.grid,
grid_w=loop_grid,
actual_w=site_batt,
tuning=self.tuning,
sat_count=self.sat_count,
+19 -11
View File
@@ -21,17 +21,17 @@ Roles (the page's Why line names them):
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):
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 the 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 a 3.5 kW house);
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);
@@ -39,7 +39,10 @@ Rules that keep this safe (tests: test_modes_p3.py):
* 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.
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
@@ -69,6 +72,7 @@ class Pack:
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)
@@ -88,13 +92,16 @@ def _room_kwh(p: Pack, pct: float) -> float:
return max(0.0, abs(pct - p.soc)) / 100.0 * float(p.capacity_kwh)
def chargers(packs, ceiling_pct, done=()) -> list:
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)."""
`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 and p.name not in done]
and (p.refused >= 0 or (probe and p.probing))
and p.name not in done]
def sellers(packs, floor_pct, done=()) -> list:
@@ -137,7 +144,8 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
in_sell = plan_on and runs.sell is not None and runs.sell.holds(now)
why = None
ch = chargers(packs, ceiling_pct, done_charge) if in_cheap else []
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
+6
View File
@@ -312,6 +312,12 @@ class QuarterAverager:
"""
return self._last_net
@property
def held_t(self) -> float | None:
"""Epoch seconds of the held sample. OPT-15: `PeakTracker.project()`
with `roll=True` credits it into the next block as `add()` will."""
return self._last_t
@property
def offtake_avg_w(self) -> float:
"""Average offtake over the part of the open block seen so far."""
+14 -1
View File
@@ -776,9 +776,14 @@ class PeakTracker:
return r.peak_w / self.cap_w
@staticmethod
def project(averager, now: datetime) -> Projection:
def project(averager, now: datetime, roll: bool = False) -> Projection:
"""End-of-quarter average if the last sample held for the rest of it.
`roll` (OPT-15, battery modes only): when `now` is past the open block
because no sample has come since the boundary, project the quarter
`now` is in, crediting the held sample as `add()` will (`max_hold_s`).
Without it that tick reads "no seconds left" and measures nothing.
⚠️ The two divisors are DIFFERENT and only coincide at the end of the
block. `elapsed_w` divides by the seconds actually covered (what the
meter has shown so far); `projected_w` divides by the full 900 s and
@@ -798,6 +803,14 @@ class PeakTracker:
elapsed_wall = max(0.0, (now - start).total_seconds())
elapsed = averager.elapsed_s
acc = averager.partial_ws
if roll and elapsed_wall >= QUARTER_S:
t = now.timestamp()
block = int(t // QUARTER_S) * QUARTER_S
start = datetime.fromtimestamp(block, timezone.utc)
held_end = float(averager.held_t) + float(averager.max_hold_s)
elapsed = max(0.0, min(t, held_end) - block)
elapsed_wall = t - block
acc = max(averager.held_net_w, 0.0) * elapsed
remaining = max(0.0, QUARTER_S - elapsed_wall)
# ⚠️ NO `getattr` DEFAULT, AND THAT IS THE FIX, NOT A TIDY-UP. This read
# was `getattr(averager, "_last_net", 0.0)`: a private name with a