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>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user