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,
|
||||
|
||||
Reference in New Issue
Block a user