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
@@ -50,7 +50,7 @@ P1 is its section 6 P1, and the pull request lists what was measured.
needs its own run to clear the spread (without: a night charge was spent needs its own run to clear the spread (without: a night charge was spent
on flat morning prices, then charging went on at them); on flat morning prices, then charging went on at them);
- a run edge reseeds the loop at the house's level (a 3 kW import spike - a run edge reseeds the loop at the house's level (a 3 kW import spike
removed; about 0.1 x rating still overshoots as export). removed) and, since OPT-15, holds it there until the meter shows the step.
- Replay (tools/opt11p3_replay.py, 16 recorded days, EPEX hourly prices a - Replay (tools/opt11p3_replay.py, 16 recorded days, EPEX hourly prices a
year earlier, modelled batteries), net EUR, lower is better: P2 19.33, P3 year earlier, modelled batteries), net EUR, lower is better: P2 19.33, P3
Solar first + Leftover solar 19.57, with a sale 19.60, the old one-period Solar first + Leftover solar 19.57, with a sale 19.60, the old one-period
@@ -75,3 +75,33 @@ P1 is its section 6 P1, and the pull request lists what was measured.
P2's 19.33; Solar first + Cheap hours only 19.57. P2's 19.33; Solar first + Cheap hours only 19.57.
- Not done in P3: the forecast (P4), and a headroom too short for the run - Not done in P3: the forecast (P4), and a headroom too short for the run
moving the rest to the next cheap periods. moving the rest to the next cheap periods.
- OPT-15 (TEST-23 findings on 5a4cdc2, `test_price_run_fixes.py`):
- F1, a charge stopped at a quarter edge and latched for the quarter. The
P1 averager opens a quarter only when a sample arrives, and the HA source
builds one 0.35 s after the reading, so the cycle on the boundary still
saw the old block: "no seconds left", headroom None, "no trade
(headroom)", latched for the new quarter. With modes on the guard now
projects the quarter `now` is in (`PeakTracker.project(roll=True)`,
crediting the held sample as the averager will), and only a measured
stop latches. Modes off still reads the old block (identity).
- F2, -1120 W export at a charge end against -257 W modelled. The replay
acted every 5 s on readings of that instant, and by then the lagged
battery had nearly arrived, so it never acted on a reading that still
showed the trade. The add-on cycles on each meter update (1 s on the rig)
on readings 1-2 s old: the two cycles after the reseed saw the trade's
+3300 W and slewed E up 555 W each. The loop now holds E until the meter
reaches the target (or `EDGE_HOLD_S`, 18 s; to 36 s while the battery
still moves). The replay gained `Lag` (dead time, reading age, the P1
debounce, 1 s cycles). Charge end, 800 W house: -10 to -51 W over eight
lag variants; without the hold -1370 W (meter 2 s).
- F3, a charge refusal in a cheap run was never probed: the SAFETY-73 pin
stands only while the site asks that way, and nothing asked to charge.
Probing was chosen over changing the card: the probe is 200 W for 34 s
each 15 min under the same peak gates, and it wins back the run. In its
probe window the refusing battery is a charger, pinned at 200 W by its
own claim, and the others rest. At one inverter SAFETY-52 already ends a
charge refusal as soon as the house asks for power.
- Not fixed, same on 5a4cdc2: with lagged readings a headroom-sized quarter
banks up to 1.4 W over the aim (48.6 W under the cap), and a battery
reading older than the meter can make a charge start look like a house
over the aim, a real guard stop.
+3 -3
View File
@@ -537,9 +537,9 @@ With battery modes on, these choices replace the *Charging and selling on price*
**What always comes first.** **What always comes first.**
- The peak limit, the safety stops and the monthly cycle come before a battery's mode. - The peak limit, the safety stops and the monthly cycle come before a battery's mode.
- A grid charge is sized so the 15-minute period stays under the peak limit less its margin (`peak_margin_w`). It starts only at 500 W or more. Once the peak limit stops it, it stays off for the rest of that 15-minute period. - A grid charge is sized so the 15-minute period stays under the peak limit less its margin (`peak_margin_w`). It starts only at 500 W or more. Once the peak limit stops it, it stays off for the rest of that 15-minute period. The start of a new period does not stop it.
- While one battery charges from the grid, the others rest, and the house uses the grid. - While one battery charges from the grid, the others rest, and the house uses the grid.
- A battery that refuses to charge or give power takes no part in a run in that direction. - A battery that refuses to charge or give power takes no part in a run in that direction. In a cheap run it is still tested every 15 minutes: it charges at 200 W for about half a minute while the others rest.
**In Home Assistant.** With MQTT set up, each battery also gets a *Mode* list and a *Use* switch, for example `select.goodwe_inverter_a_mode` and `switch.goodwe_inverter_a_use`. They do the same as the Batteries screen. Turn battery modes off and restart, and they are removed. **In Home Assistant.** With MQTT set up, each battery also gets a *Mode* list and a *Use* switch, for example `select.goodwe_inverter_a_mode` and `switch.goodwe_inverter_a_use`. They do the same as the Batteries screen. Turn battery modes off and restart, and they are removed.
@@ -547,7 +547,7 @@ With battery modes on, these choices replace the *Charging and selling on price*
*For installers:* a mode is a limit at priority 35, kept apart from the hard limits. It gives way only as far as a limit above it (the peak guard) needs. A mode range always holds 0 W, so a mode can forbid a direction and never force one. With one inverter the mode also bounds the loop. The record is `DECISIONS/OPT-11-battery-modes.md`. *For installers:* a mode is a limit at priority 35, kept apart from the hard limits. It gives way only as far as a limit above it (the peak guard) needs. A mode range always holds 0 W, so a mode can forbid a direction and never force one. With one inverter the mode also bounds the loop. The record is `DECISIONS/OPT-11-battery-modes.md`.
*For installers:* when a charge or sale ends, the loop restarts at the house's own level rather than at the trade's watts. On the replay model that removes a ~3 kW import spike after a 2.5 kW charge and halves the settling time; about 0.1 × the battery's power still overshoots as export. The *Mode* list gains *Cheap hours only* in Home Assistant, at the end. *For installers:* when a charge or sale ends, the loop restarts at the house's own level rather than at the trade's watts, and holds there until the meter shows the change (at most 18 s, up to 36 s while the battery is still moving). This removes the ~3 kW import spike after a 2.5 kW charge and the export overshoot that readings a second or two old caused (-1120 W on the test rig); with the hold it stays within 0.15 × the battery's power. The *Mode* list gains *Cheap hours only* in Home Assistant, at the end.
### Reading the grid-following loop: `Loop integrator`, `Loop integrator lead`, `Loop reason` ### Reading the grid-following loop: `Loop integrator`, `Loop integrator lead`, `Loop reason`
+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 # 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. # valued at (`priceruns.plan(ref_eur_kwh=)`): the rest of that day.
REF_CHEAP_KEEP = timedelta(hours=12) 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: def why_raised(resolution, claims) -> bool:
@@ -942,6 +945,9 @@ class Controller:
self._mode_trade_was = False self._mode_trade_was = False
self._charge_blocked_q = None self._charge_blocked_q = None
self._ref_cheap = 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. # OPT-11 P2: (topic, reason) of MQTT commands refused and logged once.
self._mqtt_refused = set() self._mqtt_refused = set()
self._mqtt_refused_more = 0 self._mqtt_refused_more = 0
@@ -2059,10 +2065,15 @@ class Controller:
or maint_results[i].phase == IDLE) or maint_results[i].phase == IDLE)
refused = (inv.refused if len(self.inverters) > 1 refused = (inv.refused if len(self.inverters) > 1
else self.batt_refused) or 0 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( out.append(modetrade.Pack(
inv.name, pm.use, pm.mode, pm.sell and pm.can_sell, inv.name, pm.use, pm.mode, pm.sell and pm.can_sell,
float(inv.max_w), inv.soc, inv.capacity_kwh, 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 return out
def _price_runs(self, now, packs, floor_pct, ceiling_pct): def _price_runs(self, now, packs, floor_pct, ceiling_pct):
@@ -2163,8 +2174,12 @@ class Controller:
blocked=self._charge_blocked_q == quarter, blocked=self._charge_blocked_q == quarter,
done_charge=done_c, done_sell=done_s) done_charge=done_c, done_sell=done_s)
# A charge the guard or the headroom stopped stays off this period. # A charge the guard or the headroom stopped stays off this period.
if was and self.mode_tick.action != modetrade.CHARGE and \ # OPT-15: only a MEASURED stop latches; an unmeasured headroom (None)
self.mode_tick.why in ("peak", "headroom"): # 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 self._charge_blocked_q = quarter
return self.mode_tick return self.mode_tick
@@ -2233,18 +2248,18 @@ class Controller:
else "trade_off")) else "trade_off"))
return self.arb.action is not None 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 """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 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 command that brings the grid to its target now (`site battery + grid
- target`, inside the loop bound), instead of at the trade's watts - 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 OPT-15: then HOLD it at E (`_edge_grid()`) until the meter shows the
measures the trade's command (it is exactly E - battery), so the step or `EDGE_HOLD_S` passes. The readings still describe the trade for
integrator is seeded that much short and lands on E after its step. a second or two (meter and battery lag), and stepping on them sent
Measured (tools/opt11p3_replay.py, lagged battery, 2.5 kW charge E 810 -> 1370 -> 1920 W on TEST-23: -1120 W export at a charge end.
ending under an 800 W house): no reseed -258 W export then a 2976 W Measured: DECISIONS/OPT-11-battery-modes.md.
import, settled in 55 s; this -257 W / +615 W, settled in 20 s.
""" """
self._edge = None
batt = self.site_batt_w batt = self.site_batt_w
if batt is None or self.grid is None: if batt is None or self.grid is None:
return return
@@ -2253,8 +2268,34 @@ class Controller:
if bound is not None: if bound is not None:
want = max(bound[0], min(bound[1], want)) want = max(bound[0], min(bound[1], want))
want = float(round(max(-self.site_max_w, min(self.site_max_w, want)))) want = float(round(max(-self.site_max_w, min(self.site_max_w, want))))
self.target = want self.target = self.i_w = want
self.i_w = want - self.tuning.gain * (want - float(batt)) 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: def _mode_fault_log(self, resolution) -> None:
"""OPT-11: say once, per battery, that its mode was dropped as a fault. """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 # the same answer, for the same reason, that the status page already
# gives for SAFETY-18 on a transport that cannot see `unchanged_s`. # 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_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 # ⚠️ THE SITE SUM. The guard sizes a floor from what the battery is
# ALREADY removing from the meter, and the meter sees every # ALREADY removing from the meter, and the meter sees every
# inverter. One unit's power here would size the floor from a # 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 # entering failsafe re-seeds the integrator to zero and the first
# cycle after release starts from zero instead of dumping the whole # cycle after release starts from zero instead of dumping the whole
# stale period as power. # 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 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: 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 # SAFETY-52: another writer overrode the loop, so a refusal learned
# against the loop's own ask no longer describes the wire. A gap # 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 # (`loop_w` None: blind or stopped) keeps it: the stale-reading stop
@@ -4150,7 +4197,7 @@ class Controller:
loop_prev = self.target loop_prev = self.target
decision = compute( decision = compute(
prev_w=loop_prev, prev_w=loop_prev,
grid_w=self.grid, grid_w=loop_grid,
actual_w=site_batt, actual_w=site_batt,
tuning=self.tuning, tuning=self.tuning,
sat_count=self.sat_count, 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 grid the others cannot give power either; the allocator never
splits a target both ways). 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); * 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); * 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 * 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 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 `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 more, and once the guard or a measured headroom stops it, it stays
the rest of that 15-minute period (`blocked`): a charge makes the other stopped for the rest of that 15-minute period (`blocked`): a charge
batteries stop giving power, so a few watts of charge flipping on and makes the other batteries stop giving power, so a few watts of charge
off would swing the house between them and the grid (measured on the flipping on and off would swing the house between them and the grid
2026-09-09 replay: 13 flips in 4 minutes at a 3.5 kW house); (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 * 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 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); 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 * 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; 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 * 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 from dataclasses import dataclass
@@ -69,6 +72,7 @@ class Pack:
live: bool = True live: bool = True
idle: bool = True # not in a maintenance phase idle: bool = True # not in a maintenance phase
refused: int = 0 # SAFETY-52/73: +1 refuses to give, -1 to charge 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) @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) 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 """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 return [p for p in packs
if p.use and p.idle and p.live and p.mode in GRID_CHARGE_MODES 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.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: 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) in_sell = plan_on and runs.sell is not None and runs.sell.holds(now)
why = None 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 charging = False
if ch: if ch:
need = 0.0 if charging_now else START_W need = 0.0 if charging_now else START_W
+6
View File
@@ -312,6 +312,12 @@ class QuarterAverager:
""" """
return self._last_net 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 @property
def offtake_avg_w(self) -> float: def offtake_avg_w(self) -> float:
"""Average offtake over the part of the open block seen so far.""" """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 return r.peak_w / self.cap_w
@staticmethod @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. """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 ⚠️ The two divisors are DIFFERENT and only coincide at the end of the
block. `elapsed_w` divides by the seconds actually covered (what 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 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_wall = max(0.0, (now - start).total_seconds())
elapsed = averager.elapsed_s elapsed = averager.elapsed_s
acc = averager.partial_ws 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) remaining = max(0.0, QUARTER_S - elapsed_wall)
# ⚠️ NO `getattr` DEFAULT, AND THAT IS THE FIX, NOT A TIDY-UP. This read # ⚠️ 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 # was `getattr(averager, "_last_net", 0.0)`: a private name with a
+11 -5
View File
@@ -74,7 +74,7 @@ AT_7A00F12 = {
"n1_off": "71245b227ca40bada4f71f57ea6154475f4b0bd60239c9ad4f80bffb0094514c", "n1_off": "71245b227ca40bada4f71f57ea6154475f4b0bd60239c9ad4f80bffb0094514c",
"n2_off": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722", "n2_off": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722",
} }
_rows = {k: ident.run(**v)[0] for k, v in ident.SCENARIOS.items()} _rows = {k: ident.run(**ident.SCENARIOS[k])[0] for k in AT_7A00F12}
_got = {k: ident.digest(v) for k, v in _rows.items()} _got = {k: ident.digest(v) for k, v in _rows.items()}
for k in AT_7A00F12: for k in AT_7A00F12:
print(f" {k} {'same' if _got[k] == AT_7A00F12[k] else 'DIFFERS'} " print(f" {k} {'same' if _got[k] == AT_7A00F12[k] else 'DIFFERS'} "
@@ -482,10 +482,16 @@ _ref = [r for r in ROWS_R if present(_first)
print(f" B refuses to charge; after 3 min: trades " print(f" B refuses to charge; after 3 min: trades "
f"{sorted({str(r['action']) for r in _ref})}, B roles " f"{sorted({str(r['action']) for r in _ref})}, B roles "
f"{sorted({r['roles'].get(B) for r in _ref})}") f"{sorted({r['roles'].get(B) for r in _ref})}")
# OPT-15: B's SAFETY-73 probe (every 15 min, 34 s) is the one grid trade
# left: PROBE_W on B alone, A resting.
_prb = [r for r in _ref if r["action"] is not None]
check("B refusing to charge: no grid trade is filed after the refusal is " check("B refusing to charge: no grid trade is filed after the refusal is "
"learned, B's role is no longer 'charge', and A covers the house", "learned but B's probe (PROBE_W on B, A resting), B charges only then, "
_ref and all(r["action"] is None for r in _ref) "and A covers the house",
and all(r["roles"].get(B) != modetrade.CHARGE for r in _ref) _ref and all(r["shares"].get(B) == -200 and r["shares"].get(A, 0) == 0
for r in _prb) and len(_prb) <= 0.1 * len(_ref)
and all(r["roles"].get(B) != modetrade.CHARGE
for r in _ref if r["action"] is None)
and sum(r["shares"].get(A, 0) > 0 for r in _ref) > len(_ref) / 2) and sum(r["shares"].get(A, 0) > 0 for r in _ref) > len(_ref) / 2)
# ============================================================================ # # ============================================================================ #
@@ -516,7 +522,7 @@ print("\n7. a run edge reseeds the loop")
def _no_reseed(c): def _no_reseed(c):
c._reseed_run_edge = lambda claims: None c._reseed_run_edge = lambda claims, now: None
def edge(rows, kind): def edge(rows, kind):
+499
View File
@@ -0,0 +1,499 @@
"""Runnable check for OPT-15, the P3 price-run fixes. `python3 test_price_run_fixes.py`
The OPT-11 P3 post-merge test on TEST-23 (5a4cdc2) found three defects. This
file drives the pure modules and the REAL Controller (tools/opt11p3_replay.py)
with the lagged model (`Lag`: command dead time, meter and battery reading
age, the P1 debounce, 1 s cycles), which reproduces all three on 5a4cdc2.
1. F1: a quarter edge while charging does not stop the charge (the guard
projects the quarter `now` is in), and a real guard stop still latches.
2. F2: the export overshoot at a charge end stays within 0.15 x rating in
the model and in the lagged variants (TEST-23 measured -1120 W).
3. F3: a charge refusal inside a cheap run is probed every 15 minutes.
4. The P3 and SAFETY-79 invariants on the lagged runs.
5. Modes off is byte-identical to 5a4cdc2, also with a late P1 sample on
every quarter boundary, and the harness sees a change there.
DECISIONS/OPT-11-battery-modes.md has the record. Mutations (MUTATION.md,
purged copies, graded over this file and test_modes_p3.py) are listed in the
pull request.
"""
import logging
import os
import sys
from dataclasses import replace
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
sys.path.insert(0, os.path.join(HERE, "tools"))
logging.disable(logging.WARNING)
import opt11p3_identity as ident # noqa: E402
import opt11p3_replay as rp # noqa: E402
from app import arbitrage, modetrade, packmode, peakguard # noqa: E402
from app import tariff # noqa: E402
from app import control as ctl # noqa: E402
from app import main # noqa: E402
from app.p1 import QuarterAverager, make_sample # noqa: E402
from app.tariff import PeakTracker # noqa: E402
from named_absence import nth, present # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
L = timezone(timedelta(hours=2))
A, B = "inverter-a", "inverter-b"
D0 = datetime(2026, 9, 9, 0, 0, tzinfo=L)
UTC0 = D0.astimezone(timezone.utc)
# Cheap 02:00-04:00 local, dear 19:00-20:00, flat otherwise (two days).
PRICES = [0.30] * 192
for _i in range(8, 16):
PRICES[_i] = 0.10
for _i in range(76, 80):
PRICES[_i] = 0.40
WPTS = [(UTC0 + timedelta(minutes=15 * i), 900, p)
for i, p in enumerate(PRICES)]
RATING = rp.RATING
BOUND = 0.15 * RATING
AIM = rp.PEAK_CAP - 50
CH_SF = {"a": (True, packmode.CHEAP_HOURS, False),
"b": (True, packmode.SOLAR_FIRST, False)}
def at(h):
return UTC0 + timedelta(hours=h)
def house(steps, base=800.0):
"""[(t, W)] for the replay: `base`, then each (local hour, W) from then."""
out = [(UTC0.timestamp() - 10, base)]
out += [(at(h).timestamp(), float(w)) for h, w in steps]
return out
def run(start_h, hours, *, lag=None, soc_a=20.0, steps=(), choice=None,
refuse=(), patch=None, check_cb=None):
rows = []
def cb(c, h, now, pr):
sh = dict(c.allocation.shares) if c.allocation is not None and \
c.allocation.shares else {}
rows.append(dict(
now=now, grid=h.grid(), shares=sh,
action=getattr(c.arb, "action", None),
reason=getattr(c.arb, "reason", None) or "",
roles=dict(c.mode_tick.roles) if c.mode_tick else {},
refused=c.inverters[0].refused,
probing=c.inverters[0].probe_from is not None,
headroom=c.arb_headroom_w,
p={u: p.p for u, p in h.plants.items()}))
if check_cb:
check_cb(c, h, now, pr)
st, c = rp.run("p3_ch_sell", house(steps), WPTS, at(start_h),
3600 * hours, check=cb, lag=lag,
soc0={"a": soc_a, "b": 60.0},
choice=CH_SF if choice is None else choice,
refuse=refuse, patch=patch,
extra={"cycle_cost_threshold_eur": 0.10})
return rows, c
def stops(rows):
"""[(row index, row)] where a grid charge stopped."""
return [(i, rows[i]) for i in range(1, len(rows))
if rows[i - 1]["action"] == arbitrage.CHARGE
and rows[i]["action"] != arbitrage.CHARGE]
def q(when):
return int(when.timestamp() // 900)
def banked(c, rows):
"""The add-on's own P1 blocks for the quarters that had a grid charge."""
chq = {q(r["now"]) for r in rows if r["action"] == arbitrage.CHARGE}
return [b.offtake_avg_w for b in c.p1.blocks if q(b.start) in chq]
def mixed(rows):
return [r for r in rows if any(v > 0 for v in r["shares"].values())
and any(v < 0 for v in r["shares"].values())]
# Close to TEST-23's plant: 1 s cycles, readings 1 s old, a battery that
# follows the previous second's command.
LAG = rp.Lag(tau_s=0.05, ramp_w_s=1e9)
# ============================================================================ #
print("1. F1: a quarter edge while charging")
# 1a. The projection on the edge tick, before the new block's first sample.
_avg = QuarterAverager(phases=1, max_hold_s=30.0)
_edge = datetime(2026, 9, 9, 12, 0, tzinfo=timezone.utc)
for _s in range(-900, 1, 1):
_t = _edge + timedelta(seconds=_s - 0.35)
_avg.add(make_sample("ha_signed", 3300.0, 0.0, phases=1, ingest_ts=_t,
ingest_mono=0.0))
_now = _edge + timedelta(seconds=0.6)
_old = PeakTracker.project(_avg, _now)
_new = PeakTracker.project(_avg, _now, roll=True)
def _decide(pr):
return peakguard.decide(pr, batt_w=-2500.0, cap_w=3500.0, margin_w=50.0,
reading=None, achievable_w=5000.0, soc=50.0,
soc_floor=20.0, max_hold_s=30.0)
print(f" edge tick: old block {_old.explain()}; rolled {_new.explain()}")
check("on the edge tick the open block is still the old one: the guard reads "
"'no seconds left' and the headroom is unmeasured (the F1 cause)",
_old.elapsed_s >= 900 and peakguard.charge_headroom_w(
_decide(_old)) is None)
_later = QuarterAverager(phases=1, max_hold_s=30.0)
for _s in range(-900, 1, 1):
_later.add(make_sample("ha_signed", 3300.0, 0.0, phases=1,
ingest_ts=_edge + timedelta(seconds=_s - 0.35),
ingest_mono=0.0))
_later.add(make_sample("ha_signed", 3300.0, 0.0, phases=1,
ingest_ts=_edge + timedelta(seconds=0.6),
ingest_mono=0.0))
_real = PeakTracker.project(_later, _now)
_hr = peakguard.charge_headroom_w(_decide(_new))
check("rolled, it is the new quarter, measured, and equal to what the "
"averager projects once the new block's first sample is in; the "
"headroom is the house and the aim (3450 - 800 W)",
_new.block_start == _edge == _real.block_start and _new.complete
and abs(_new.projected_w - _real.projected_w) < 0.01
and abs(_new.elapsed_s - 0.6) < 1e-6
and _hr is not None and abs(_hr - 2650.0) < 1.0)
_gap = QuarterAverager(phases=1, max_hold_s=30.0)
_gap.add(make_sample("ha_signed", 3300.0, 0.0, phases=1,
ingest_ts=_edge - timedelta(seconds=100), ingest_mono=0))
_gp = PeakTracker.project(_gap, _edge + timedelta(seconds=40), roll=True)
check("...and a sample older than the hold is not credited: past the hold "
"the rolled quarter is blind, as the averager would make it",
_gp.elapsed_s == 40.0 and _gp.covered_frac == 0.0
and peakguard.blindness(_gp, 30.0, False) is not None)
check("without `roll` nothing changes (the modes-off path)",
PeakTracker.project(_avg, _now, roll=False) == _old)
# 1b. The lagged replay: 02:00-03:30 local, a Cheap hours battery charging
# under an 800 W house through five quarter edges.
ROWS1, C1 = run(1.9, 1.6, lag=LAG, choice={
"a": (True, packmode.CHEAP_HOURS, False),
"b": (True, packmode.LEFTOVER_SOLAR, False)})
_ch1 = [r for r in ROWS1 if r["action"] == arbitrage.CHARGE]
_edges1 = sorted({q(r["now"]) for r in _ch1})
_st1 = stops(ROWS1)
print(f" {len(_ch1)} charge cycles over {len(_edges1)} quarters; stops "
f"{[(r['now'].astimezone(L).strftime('%H:%M:%S'), r['reason'][:40]) for _, r in _st1]}")
check("no stop at any quarter edge crossed while charging (5a4cdc2 in this "
"model: a stop at each, 'no trade (headroom)', latched for the quarter)",
len(_edges1) >= 5 and not _st1)
def _no_roll(c):
real = c.peak.project
c.peak.project = lambda a, now, roll=False: real(a, now)
ROWS1M, _ = run(1.9, 0.6, lag=LAG, patch=_no_roll)
_m1 = stops(ROWS1M)
check("non-vacuous: with the old projection this model stops at the edge "
"for want of a measured headroom (5a4cdc2 then latched the quarter; "
"the latch fix alone keeps it to that tick)",
_m1 and "headroom" in _m1[0][1]["reason"]
and _m1[0][1]["now"].astimezone(L).strftime("%M:%S") == "15:00")
# 1c. A real guard stop still latches, and the 500 W minimum stays.
# House: 800 W; 3700 W at 02:20-02:21; 3000 W from 02:26 (headroom 450 W
# in the next quarter); 2850 W from 02:40 (600 W).
ROWS2, C2 = run(1.9, 0.9, lag=LAG, steps=[
(2 + 20 / 60, 3700), (2 + 21 / 60, 800), (2 + 26 / 60, 3000),
(2 + 40 / 60, 2850)])
_st2 = stops(ROWS2)
_first = _st2[0][1] if _st2 else None
_q2 = q(_first["now"]) if _first else None
_after2 = [r for r in ROWS2 if _first and r["now"] > _first["now"]
and q(r["now"]) == _q2]
print(f" guard stop at "
f"{_first['now'].astimezone(L).strftime('%H:%M:%S') if _first else None}"
f" ({_first['reason'][:40] if _first else ''})")
check("a mid-quarter 3.7 kW load stops the charge for the peak and it stays "
"off for the rest of that quarter, the house back at 800 W",
_first is not None and _first["now"].astimezone(L).minute == 20
and ("peak" in _first["reason"] or "headroom" in _first["reason"])
and _after2 and all(r["action"] is None for r in _after2))
_starts = [b for a, b in zip(ROWS1 + ROWS2, (ROWS1 + ROWS2)[1:])
if a["action"] != arbitrage.CHARGE
and b["action"] == arbitrage.CHARGE]
_low = [r for r in ROWS2 if r["headroom"] is not None
and 0 < r["headroom"] <= modetrade.START_W and r["action"] is None]
print(f" {len(_starts)} charge starts, lowest headroom at a start "
f"{min(r['headroom'] for r in _starts):.0f} W; {len(_low)} cycles "
f"with 0-500 W of headroom and no charge")
check("the 500 W minimum stays: every charge starts with more than 500 W of "
"headroom, and a house at 3 kW (450 W) holds it off",
_starts and _low and all(r["headroom"] > modetrade.START_W
for r in _starts))
# 1d. An unmeasured headroom stops one tick and does not latch.
_c1d = run(1.9, 0.2, soc_a=20.0)[1]
_n1d = UTC0 + timedelta(hours=2, minutes=5)
_ok1d = False
if _c1d.mode_tick is not None and _c1d.mode_tick.action == modetrade.CHARGE:
_c1d.arb_headroom_w = None
_t1 = _c1d._mode_roles(_n1d, None)
_c1d.arb_headroom_w = 3000.0
_t2 = _c1d._mode_roles(_n1d + timedelta(seconds=1), None)
_c1d.arb_headroom_w = 0.0
_t3 = _c1d._mode_roles(_n1d + timedelta(seconds=2), None)
_c1d.arb_headroom_w = 3000.0
_t4 = _c1d._mode_roles(_n1d + timedelta(seconds=3), None)
_ok1d = (_t1.action is None and _t2.action == modetrade.CHARGE
and _t3.action is None and _t4.action is None
and _c1d._charge_blocked_q == q(_n1d))
check("an unmeasured headroom (None) stops the charge that tick only; a "
"measured 0 W stops it for the rest of the quarter", _ok1d)
# ============================================================================ #
print("\n2. F2: the export overshoot at a charge end")
# A fills to its 95 % ceiling at about 04:03 under an 800 W house, and
# Solar first B takes the house over.
VARIANTS = {
"model (5 s, instant readings)": None,
"model, lagged (1 s cycles)": rp.Lag(),
"TEST-23 plant, meter 1 s": rp.Lag(tau_s=0.05, ramp_w_s=1e9),
"TEST-23 plant, meter 2 s": rp.Lag(tau_s=0.05, ramp_w_s=1e9,
meter_s=2.0),
"meter 2 s, battery reading fresh": rp.Lag(tau_s=0.05, ramp_w_s=1e9,
meter_s=2.0, batt_s=0.0),
"2 s cycles, meter 2 s": rp.Lag(tick=2.0, meter_s=2.0),
"5 s meter": rp.Lag(tick=5.0, meter_s=5.0, batt_s=2.0),
"slow battery (8 s, 2 s dead), 5 s meter": rp.Lag(
tau_s=8.0, dead_s=2.0, tick=5.0, meter_s=5.0, batt_s=2.0),
}
def charge_end(rows, lag):
"""(min grid, max grid, pre-edge grid, settle s) after the first charge
end, over 120 s; settle: from then on within 40 W of the target."""
st = stops(rows)
if not st:
return None
i = st[0][0]
tick = rp.TICK if lag is None else lag.tick
w = [r["grid"] for r in rows[i:i + int(120 / tick)]]
settle = next((j * tick for j in range(len(w))
if all(abs(x + 10) < 40 for x in w[j:])), None)
return min(w), max(w), rows[i - 1]["grid"], settle
F2 = {}
for _k, _lag in VARIANTS.items():
F2[_k] = charge_end(run(3.9, 0.5, lag=_lag, soc_a=86.0)[0], _lag)
print(f" {_k}: {F2[_k] and tuple(round(x) if x is not None else x for x in F2[_k])}")
check(f"every variant: the charge ends, the export overshoot stays within "
f"0.15 x rating ({BOUND:.0f} W), no import above the charge's own, and "
f"the grid settles within 45 s (90 s for the slow battery)",
all(v is not None and v[0] >= -BOUND and v[1] <= v[2] + 50
and v[3] is not None and v[3] <= (90 if k.startswith("slow")
else 45)
for k, v in F2.items()))
def _no_hold(c):
c._edge_grid = lambda end, now: c.grid
_pre = charge_end(run(3.9, 0.5, lag=VARIANTS["TEST-23 plant, meter 2 s"],
soc_a=86.0, patch=_no_hold)[0],
VARIANTS["TEST-23 plant, meter 2 s"])
print(f" without the hold (5a4cdc2's reseed), meter 2 s: "
f"{_pre and tuple(round(x) if x is not None else x for x in _pre)}")
check("non-vacuous: without the hold the lagged model shows TEST-23's "
"overshoot (over 0.4 x rating), so it cannot hide again",
_pre is not None and _pre[0] < -0.4 * RATING)
# The hold must not hide a real load change at the edge.
_down = run(3.9, 0.5, lag=LAG, soc_a=86.0, steps=[])[0]
_i_end = stops(_down)[0][0] if stops(_down) else None
_edge_t = _down[_i_end]["now"] if _i_end else None
def _edge_step(w):
"""The same charge end with the house stepping to `w` at the edge:
(seconds until the loop moves B off E, lowest grid, highest grid after
the first 3 s)."""
if _edge_t is None:
return None
h = (_edge_t - UTC0).total_seconds() / 3600
rows = run(3.9, 0.5, lag=LAG, soc_a=86.0, steps=[(h, w)])[0]
after = [r for r in rows if r["now"] >= _edge_t]
first = nth(after, 0, "the edge cycle")
if not present(first):
return None
e = first["shares"].get(B)
moved = next(((r["now"] - _edge_t).total_seconds() for r in after
if r["shares"].get(B) != e), None)
return moved, min(r["grid"] for r in after[:60]), max(
r["grid"] for r in after[3:60]), [r["grid"] for r in after[:60]]
_fall, _rise = _edge_step(200.0), _edge_step(1800.0)
print(f" house 800 -> 200 W at the edge: loop moves after {_fall[0]} s, "
f"lowest {_fall[1]:.0f} W; 800 -> 1800 W: after {_rise[0]} s, highest "
f"{_rise[2]:.0f} W")
check("the hold does not hide a load change: a house that falls at the edge "
"ends it within 5 s, with no export beyond the fall and the bound; one "
"that rises is followed by EDGE_HOLD_S, with no import beyond the rise",
_fall and _rise and _fall[0] is not None and _fall[0] <= 5
and _fall[1] >= -600 - BOUND
and _rise[0] is not None and _rise[0] <= main.EDGE_HOLD_S + 2
and _rise[2] <= 1000 + 50)
_peak = _edge_step(4500.0)
_g10 = nth(_peak[3], 10, "10 s after the edge") if _peak else None
print(f" 800 -> 4500 W at the edge: grid 10 s later {_g10} W")
check("...and when the peak floor takes the command during the hold, the "
"hold ends: 10 s after the edge B gives its rating (grid 4500 - 2500 W), "
"not the aim the floor holds", present(_g10) and _g10 <= 2100)
# ============================================================================ #
print("\n3. F3: a charge refusal inside a cheap run is probed")
# A refuses to charge from its first charge; the plant accepts again at
# 02:40. The cheap run lasts to 04:45.
def _accept(c, h, now, pr):
if now >= at(2 + 40 / 60):
h.plants["a"].refuse_charge = False
ROWS3, C3 = run(1.9, 1.2, soc_a=30.0, refuse=("a",), check_cb=_accept)
_ref3 = [r for r in ROWS3 if r["refused"]]
_pr3 = [r for r in ROWS3 if r["probing"] and r["action"] == arbitrage.CHARGE]
_probe_q = sorted({r["now"].astimezone(L).strftime("%H:%M") for r in _pr3})
_end3 = next((r for r in ROWS3 if _ref3 and r["now"] > _ref3[-1]["now"]),
None)
print(f" refused {len(_ref3)} cycles; charge probes at {_probe_q}; "
f"after: {_end3 and _end3['now'].astimezone(L).strftime('%H:%M:%S')}"
f" {_end3 and _end3['action']}")
check("while A refuses, it is probed every 15 minutes: at PROBE_W exactly, "
"with B resting (no share on B, one sign)",
len(_probe_q) >= 2 and all(
r["shares"].get(A) == -ctl.PROBE_W and r["shares"].get(B, 0) == 0
for r in _pr3))
check("...outside a probe the refusal wins: no grid trade and A never "
"charges",
all(r["action"] is None and r["shares"].get(A, 0) >= 0
for r in _ref3 if not r["probing"]))
check("...and the probe after the plant accepts again ends the refusal and "
"the charge resumes in the same run",
_end3 is not None and _end3["now"] < at(3)
and any(r["action"] == arbitrage.CHARGE and r["shares"].get(A, 0)
< -1000 for r in ROWS3 if r["now"] > _end3["now"]))
_pk = modetrade.Pack(A, True, packmode.CHEAP_HOURS, False, 2500.0, 30.0,
10.0, refused=-1, probing=True)
_runs = SimpleNamespace(on=True, cheap=SimpleNamespace(holds=lambda n: True),
charge_useful=lambda: True, give_at=lambda n: None,
sell=None)
def _dec(packs, **kw):
args = dict(charge_ok=True, sell_ok=True, trade_why=None,
price_now=0.1, headroom_w=3000.0, floor_pct=20.0,
ceiling_pct=95.0, house_w=800.0)
args.update(kw)
return modetrade.decide(_runs, UTC0, packs, **args)
_pb = modetrade.Pack(B, True, packmode.SOLAR_FIRST, False, 2500.0, 60.0,
10.0)
check("modetrade: a probing charge refuser is a charger, the others rest; "
"not while in maintenance, not on Leftover solar or Solar first, not "
"latched, not under the 500 W minimum, not with the guard engaged, "
"and never in energy() (the plan's sizing)",
_dec([_pk, _pb]).action == modetrade.CHARGE
and _dec([_pk, _pb]).roles == {A: modetrade.CHARGE, B: modetrade.REST}
and _dec([replace(_pk, idle=False), _pb]).action is None
and all(_dec([replace(_pk, mode=m), _pb]).action is None
for m in (packmode.LEFTOVER_SOLAR, packmode.SOLAR_FIRST))
and _dec([_pk, _pb], blocked=True).action is None
and _dec([_pk, _pb], headroom_w=450.0).action is None
and _dec([_pk, _pb], charge_ok=False, trade_why="peak").action is None
and modetrade.energy([_pk], 20.0, 95.0)[1] == 0.0
and _dec([replace(_pk, probing=False), _pb]).action is None)
# ============================================================================ #
print("\n4. invariants on these runs (and part 1c's house in the model)")
_rows4, _c4 = run(1.9, 0.9, steps=[
(2 + 20 / 60, 3700), (2 + 21 / 60, 800), (2 + 26 / 60, 3000),
(2 + 40 / 60, 2850)])
_all = ROWS1 + ROWS2 + ROWS3 + _rows4
check("no opposite-direction split on any cycle", not mixed(_all))
_ceil = peakguard.quarter_ceiling_w(rp.PEAK_CAP, None)[0]
_bk0 = banked(_c4, _rows4)
_bk = banked(C1, ROWS1) + banked(C2, ROWS2)
print(f" banked quarters with a grid charge: model {len(_bk0)}, highest "
f"{max(_bk0):.2f} W; lagged {len(_bk)}, highest {max(_bk):.2f} W "
f"(5a4cdc2: the same 3451.37 W); ceiling - margin {_ceil - 50:.0f} W")
check("every banked 15-minute period with a grid charge is at most "
"quarter_ceiling_w() - margin (SAFETY-79) in the model; lagged, the "
"battery's lag may use 2 W of the 50 W margin, as on 5a4cdc2",
_bk0 and max(_bk0) <= _ceil - 50 + 0.5
and _bk and max(_bk) <= _ceil - 50 + 2.0)
check("Solar first and Leftover solar never charge from the grid: no charge "
"role and no charge share on B while the site charges",
all(r["roles"].get(B) != modetrade.CHARGE for r in _all)
and all(r["shares"].get(B, 0) >= 0 for r in _all
if r["action"] == arbitrage.CHARGE))
check("selling is off: no sale on any cycle",
packmode.SELLING_ENABLED is False
and all(r["action"] != arbitrage.DISCHARGE for r in _all))
# ============================================================================ #
print("\n5. modes off: byte-identical to 5a4cdc2")
# Printed by tools/opt11p3_identity.py on release/1.0 5a4cdc2 (2026-09-29),
# goodwe-addon-checks:1, with this branch's harness (same API).
AT_5A4CDC2 = {
"n1": "71245b227ca40bada4f71f57ea6154475f4b0bd60239c9ad4f80bffb0094514c",
"n2": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722",
"n1_lag": "c27ad6aada1dfed77351472b1944dae31639bbd5cb84f266e6741c97c3e59fc6",
"n2_lag": "fafcae0e36718a640216e346be9f568c28126aae6e1ad2d18414f4ac74f90413",
}
_got = {k: ident.digest(ident.run(**ident.SCENARIOS[k])[0])
for k in AT_5A4CDC2}
for _k in AT_5A4CDC2:
print(f" {_k} {'same' if _got[_k] == AT_5A4CDC2[_k] else 'DIFFERS'} "
f"{_got[_k][:16]}")
check("with battery modes off every command, integrator, allocation and "
"trade sentence equals 5a4cdc2's, also when every quarter boundary's "
"cycle still sees the old P1 block", _got == AT_5A4CDC2)
_real_project = tariff.PeakTracker.project
tariff.PeakTracker.project = staticmethod(
lambda a, now, roll=False: _real_project(a, now, roll=True))
try:
_moved = ident.digest(ident.run(**ident.SCENARIOS["n2_lag"])[0]) \
!= AT_5A4CDC2["n2_lag"]
finally:
tariff.PeakTracker.project = _real_project
check("non-vacuous: the rolled projection leaking into modes off moves the "
"late-sample scenario", _moved)
print()
if fails:
print(f"{len(fails)} FAILED:")
for f in fails:
print(f" - {f}")
sys.exit(1)
print("all checks passed")
+23 -2
View File
@@ -19,12 +19,14 @@ import hashlib
import os import os
import sys import sys
import tempfile import tempfile
import time
from datetime import timedelta from datetime import timedelta
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import opt11_identity as rig # noqa: E402 import opt11_identity as rig # noqa: E402
import safety79_identity as s79 # noqa: E402 import safety79_identity as s79 # noqa: E402
from app.p1 import make_sample, split_signed # noqa: E402
from app.price import PricePoint, PriceProduct, PriceRead, brussels # noqa from app.price import PricePoint, PriceProduct, PriceRead, brussels # noqa
from app.static_tariff import TariffPlan # noqa: E402 from app.static_tariff import TariffPlan # noqa: E402
@@ -53,6 +55,21 @@ def _feed(c, t):
s79._feed(c, t) s79._feed(c, t)
# OPT-15: the P1 sample of each cycle is built this long after the cycle
# reads the grid (the HA source's debounce), so the cycle on a quarter
# boundary still sees the old block, as on TEST-23.
P1_LAG_S = 0.35
def _feed_lag(c, t):
c.tariff_plan, c.tariff_at = PLAN, rig.BASE + timedelta(seconds=t)
imp, exp = split_signed(c.hass.grid())
c.p1.submit(make_sample(
"ha_signed", imp, exp, phases=1,
ingest_ts=rig.BASE + timedelta(seconds=t - P1_LAG_S),
ingest_mono=time.monotonic()))
def load(t): def load(t):
"""Under the limit, over it, a surplus, a big evening load.""" """Under the limit, over it, a surplus, a big evening load."""
k = int(t // 600) % 5 k = int(t // 600) % 5
@@ -70,10 +87,13 @@ SCENARIOS = {
# The option present and false: an install that saved it off. # The option present and false: an install that saved it off.
"n1_off": dict(n=1, extra={"battery_modes_enabled": False}), "n1_off": dict(n=1, extra={"battery_modes_enabled": False}),
"n2_off": dict(n=2, extra={"battery_modes_enabled": False}), "n2_off": dict(n=2, extra={"battery_modes_enabled": False}),
# OPT-15: every quarter boundary with the old block still open.
"n1_lag": dict(n=1, lag=True),
"n2_lag": dict(n=2, lag=True),
} }
def run(n, *, extra=None, dur=DUR): def run(n, *, extra=None, dur=DUR, lag=False):
opts = dict(s79.P1, **ARB, **(extra or {})) opts = dict(s79.P1, **ARB, **(extra or {}))
trade = [] trade = []
@@ -85,7 +105,8 @@ def run(n, *, extra=None, dur=DUR):
rig.Controller = functools.partial(real, peak_db_path=db) rig.Controller = functools.partial(real, peak_db_path=db)
try: try:
rows, c = rig.run(n, extra=opts, dur=dur, load=load, soc=soc, rows, c = rig.run(n, extra=opts, dur=dur, load=load, soc=soc,
cut=lambda u, t: False, before=_feed, cut=lambda u, t: False,
before=_feed_lag if lag else _feed,
on_cycle=on_cycle) on_cycle=on_cycle)
finally: finally:
rig.Controller = real rig.Controller = real
+92 -17
View File
@@ -18,7 +18,11 @@ earlier (364 days), or the captured quarter-hourly energy-charts bodies for
2026-09-09/10), the decision code. 2026-09-09/10), the decision code.
MODELLED: the batteries (a first-order lag, 3 s, 800 W/s ramp, SoC MODELLED: the batteries (a first-order lag, 3 s, 800 W/s ramp, SoC
integrated, 95 % each way, a reading that jitters by a few watts, and integrated, 95 % each way, a reading that jitters by a few watts, and
optionally a device limit or a refusal to charge), the all-in price (the EPEX file's own stated optionally a device limit or a refusal to charge). By default the readings
are instantaneous and the loop runs every 5 s, which hides what the loop
does on readings a second or two old; `lag=Lag(...)` (OPT-15) adds a command
dead time, a meter and battery reading age, the P1 sample's debounce and a
1 s cycle, as on TEST-23. The all-in price (the EPEX file's own stated
assumption: (wholesale + 0.02 + 0.10) x 1.06), export paid at the wholesale assumption: (wholesale + 0.02 + 0.10) x 1.06), export paid at the wholesale
price, tomorrow's prices known from 13:00 local. The absolute money is price, tomorrow's prices known from 13:00 local. The absolute money is
therefore an estimate; the comparison between variants is the point. therefore an estimate; the comparison between variants is the point.
@@ -104,10 +108,28 @@ def qh_points():
if p is not None] if p is not None]
class Lag:
"""OPT-15: how old what the add-on reads is, and how often it acts.
`dead_s` a command takes effect that late; `meter_s` / `batt_s` the age
of the grid and battery readings; `p1_s` a P1 sample is built that long
after the reading (the HA source's debounce), so a cycle just after a
quarter boundary can still see the old block; `tick` the cycle period;
`tau_s` / `ramp_w_s` the battery's own lag (TEST-23's plant: `dead_s` 1,
no lag or ramp)."""
def __init__(self, dead_s=1.0, meter_s=1.0, batt_s=1.0, p1_s=0.35,
tick=1.0, sub=0.25, tau_s=3.0, ramp_w_s=800.0):
self.dead_s, self.meter_s, self.batt_s = dead_s, meter_s, batt_s
self.p1_s, self.tick, self.sub = p1_s, tick, sub
self.tau_s, self.ramp_w_s = tau_s, ramp_w_s
class Plant: class Plant:
def __init__(self, soc, refuse_charge=False): def __init__(self, soc, refuse_charge=False):
self.cmd, self.p, self.soc = 0.0, 0.0, float(soc) self.cmd, self.p, self.soc = 0.0, 0.0, float(soc)
self.refuse_charge = refuse_charge self.refuse_charge = refuse_charge
self.queue = [] # (t, command) not yet in effect
self.tau, self.ramp = 3.0, 800.0
def step(self, dt, floor): def step(self, dt, floor):
c = self.cmd c = self.cmd
@@ -115,8 +137,8 @@ class Plant:
c = 0.0 c = 0.0
if c < 0 and (self.soc >= 100.0 or self.refuse_charge): if c < 0 and (self.soc >= 100.0 or self.refuse_charge):
c = 0.0 c = 0.0
want = self.p + (c - self.p) * (1 - math.exp(-dt / 3.0)) want = self.p + (c - self.p) * (1 - math.exp(-dt / self.tau))
dp = max(-800.0 * dt, min(800.0 * dt, want - self.p)) dp = max(-self.ramp * dt, min(self.ramp * dt, want - self.p))
self.p += dp self.p += dp
e = self.p * dt / 3600.0 / 1000.0 # kWh, + = out e = self.p * dt / 3600.0 / 1000.0 # kWh, + = out
eff = 1 / 0.95 if e > 0 else 0.95 eff = 1 / 0.95 if e > 0 else 0.95
@@ -125,17 +147,52 @@ class Plant:
class Hass: class Hass:
def __init__(self, units, house_at, soc0=None, refuse=(), limits=None): def __init__(self, units, house_at, soc0=None, refuse=(), limits=None,
lag=None):
self.units = units self.units = units
self.lim = limits or {} self.lim = limits or {}
self.plants = {u: Plant((soc0 or {}).get(u, 50.0), u in refuse) self.plants = {u: Plant((soc0 or {}).get(u, 50.0), u in refuse)
for u in units} for u in units}
self.house_at = house_at self.house_at = house_at
self.t = 0.0 self.t = 0.0
self.lag = lag
self.hist = [] # (t, grid, {unit: p}), with `lag`
for p in self.plants.values():
if lag is not None:
p.tau, p.ramp = lag.tau_s, lag.ramp_w_s
def grid(self): def grid(self):
return self.house_at(self.t) - sum(p.p for p in self.plants.values()) return self.house_at(self.t) - sum(p.p for p in self.plants.values())
def _past(self, age):
"""(grid, {unit: p}) as they were `age` seconds ago."""
want = self.t - age
for t, g, ps in reversed(self.hist):
if t <= want + 1e-9:
return g, ps
return self.hist[0][1:] if self.hist else (self.grid(), {
u: p.p for u, p in self.plants.items()})
def advance(self, dt, floor):
"""Step the plant `dt` seconds (with `lag`: in `sub` steps, commands
applied after `dead_s`, history kept for the lagged readings)."""
lag = self.lag
if lag is None:
for p in self.plants.values():
p.step(dt, floor)
return
end = self.t + dt
while self.t < end - 1e-9:
h = min(lag.sub, end - self.t)
for p in self.plants.values():
while p.queue and p.queue[0][0] <= self.t + 1e-9:
p.cmd = p.queue.pop(0)[1]
p.step(h, floor)
self.t += h
self.hist.append((self.t, self.grid(),
{u: p.p for u, p in self.plants.items()}))
del self.hist[:-200]
async def limits(self, e): async def limits(self, e):
lo, hi = self.lim.get(e[-1], (-5000.0, 5000.0)) lo, hi = self.lim.get(e[-1], (-5000.0, 5000.0))
return (lo, hi, 1.0) return (lo, hi, 1.0)
@@ -145,7 +202,8 @@ class Hass:
async def number(self, e, invert=False): async def number(self, e, invert=False):
if e == "sensor.grid": if e == "sensor.grid":
return self.grid() return self.grid() if self.lag is None else \
self._past(self.lag.meter_s)[0]
u = e[-1] u = e[-1]
if e.startswith("sensor.soc"): if e.startswith("sensor.soc"):
return round(self.plants[u].soc, 1) return round(self.plants[u].soc, 1)
@@ -153,11 +211,17 @@ class Hass:
# A real reading jitters by a few watts; a dead-flat one is a # A real reading jitters by a few watts; a dead-flat one is a
# stale register to the add-on (SAFETY-37), which is not modelled. # stale register to the add-on (SAFETY-37), which is not modelled.
j = (ord(u) * 31 + int(self.t)) % 7 - 3 j = (ord(u) * 31 + int(self.t)) % 7 - 3
return float(round(self.plants[u].p) + j) p = self.plants[u].p if self.lag is None else \
self._past(self.lag.batt_s)[1][u]
return float(round(p) + j)
return None return None
async def set_number(self, e, v): async def set_number(self, e, v):
self.plants[e[-1]].cmd = float(v) p = self.plants[e[-1]]
if self.lag is None:
p.cmd = float(v)
else:
p.queue.append((self.t + self.lag.dead_s, float(v)))
return True return True
@@ -239,7 +303,7 @@ def plan_at(now, pts):
def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2, def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2,
soc0=None, refuse=(), extra=None, choice=None, patch=None, soc0=None, refuse=(), extra=None, choice=None, patch=None,
limits=None): limits=None, lag=None):
modes, arb, var_choice = VARIANTS[variant] modes, arb, var_choice = VARIANTS[variant]
choice = var_choice if choice is None else choice choice = var_choice if choice is None else choice
units = tuple("ab"[:n]) units = tuple("ab"[:n])
@@ -272,7 +336,8 @@ def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2,
ticks=0, quarters={}, trade_ticks=0) ticks=0, quarters={}, trade_ticks=0)
async def go(): async def go():
hass = Hass(units, house_at, soc0, refuse, limits) hass = Hass(units, house_at, soc0, refuse, limits, lag)
tick = TICK if lag is None else lag.tick
import tempfile import tempfile
db = os.path.join(tempfile.mkdtemp(prefix="p3r-"), "tariff.db") db = os.path.join(tempfile.mkdtemp(prefix="p3r-"), "tariff.db")
c = Controller(dict(options(modes, arb, n), **(extra or {})), hass, c = Controller(dict(options(modes, arb, n), **(extra or {})), hass,
@@ -299,6 +364,8 @@ def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2,
now = start + timedelta(seconds=t) now = start + timedelta(seconds=t)
hass.t = t hass.t = t
clock[0] = now clock[0] = now
if lag is not None and not hass.hist:
hass.advance(0.0, 10.0)
if plan_hour != now.replace(minute=0, second=0, if plan_hour != now.replace(minute=0, second=0,
microsecond=0): microsecond=0):
plan_hour = now.replace(minute=0, second=0, plan_hour = now.replace(minute=0, second=0,
@@ -306,17 +373,25 @@ def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2,
c.tariff_plan, c.tariff_at = plan_at(now, pts), now c.tariff_plan, c.tariff_at = plan_at(now, pts), now
else: else:
c.tariff_at = now c.tariff_at = now
imp, exp = split_signed(hass.grid()) if lag is None:
c.p1.submit(make_sample("ha_signed", imp, exp, phases=1, imp, exp = split_signed(hass.grid())
ingest_ts=now, c.p1.submit(make_sample("ha_signed", imp, exp, phases=1,
ingest_mono=time.monotonic())) ingest_ts=now,
ingest_mono=time.monotonic()))
elif t >= lag.p1_s:
# The sample of the reading one debounce ago.
imp, exp = split_signed(hass._past(
lag.meter_s + lag.p1_s)[0])
c.p1.submit(make_sample(
"ha_signed", imp, exp, phases=1,
ingest_ts=now - timedelta(seconds=lag.p1_s),
ingest_mono=time.monotonic()))
await c.read_inputs(now_mono=t) await c.read_inputs(now_mono=t)
await c.cycle() await c.cycle()
for p in hass.plants.values(): hass.advance(tick, 10.0)
p.step(TICK, 10.0)
g = hass.grid() g = hass.grid()
pr = price_at(now) pr = price_at(now)
dt_h = TICK / 3600.0 dt_h = tick / 3600.0
if g > 0: if g > 0:
stats["imp_kwh"] += g / 1000 * dt_h stats["imp_kwh"] += g / 1000 * dt_h
stats["cost"] += g / 1000 * dt_h * (all_in(pr) if pr stats["cost"] += g / 1000 * dt_h * (all_in(pr) if pr
@@ -339,7 +414,7 @@ def run(variant, house, pts, start, dur, *, check=None, stride=1, n=2,
stats["ticks"] += 1 stats["ticks"] += 1
if check is not None: if check is not None:
check(c, hass, now, pr) check(c, hass, now, pr)
t += TICK t += tick
finally: finally:
m.datetime = real_dt m.datetime = real_dt
return c return c