Merge release/1.0 (e3e4ba6) into opt15-price-run-fixes
infra-pin / fresh (pull_request) Successful in 3s
checks / unit (pull_request) Successful in 39m30s

Conflicts (5 files, 9 hunks) resolved keeping both sides:
- p1.py: held_t (OPT-15) beside held_since (SAFETY-90).
- modetrade.py: chargers() takes targets (P4) and probe (OPT-15); decide()
  clears the chargers, a probing refuser included, while a battery drains
  (SAFETY-78); the docstring lists both rules.
- main.py: the roll goes into the SAFETY-90/91 projection chain
  (`_peak_proj`); the SAFETY-95 drain-tail grid_w keeps the run-edge hold on
  its other branch; the P4 forecast state and the hold state both stay.
- DOCS.md, DECISIONS: both lines kept, OPT-15 item refreshed.

Adaptations the merge needs:
- SAFETY-81 x F1: a rolled projection is not graded by the saturation alarm
  until the quarter's first sample, as the old edge tick was not, so a
  standing overload is still notified once per quarter.
- test_safety81, test_modes_p4: modes-on pins re-pinned (with a note), modes
  off pinned to e3e4ba6; test_price_run_fixes: identity pins on e3e4ba6,
  F1 reproduced at one inverter (SAFETY-91 already holds two), tests for the
  drain and the day plan against the probe.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
glenn schrooyen
2026-10-13 12:15:44 +02:00
co-authored by Claude
105 changed files with 17752 additions and 471 deletions
+66 -25
View File
@@ -7,12 +7,14 @@ 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.
At two inverters SAFETY-91's `next_quarter()` already does that for the
guard; at one inverter only the rolled projection does.
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.
5. Modes off is byte-identical to release/1.0 e3e4ba6, 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
@@ -81,7 +83,7 @@ def house(steps, base=800.0):
def run(start_h, hours, *, lag=None, soc_a=20.0, steps=(), choice=None,
refuse=(), patch=None, check_cb=None):
refuse=(), patch=None, check_cb=None, n=2):
rows = []
def cb(c, h, now, pr):
@@ -99,7 +101,7 @@ def run(start_h, hours, *, lag=None, soc_a=20.0, steps=(), choice=None,
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,
3600 * hours, check=cb, lag=lag, n=n,
soc0={"a": soc_a, "b": 60.0},
choice=CH_SF if choice is None else choice,
refuse=refuse, patch=patch,
@@ -196,8 +198,8 @@ _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)",
check("no stop at any quarter edge crossed while charging (two inverters; "
"see below for the old projection)",
len(_edges1) >= 5 and not _st1)
@@ -206,13 +208,33 @@ def _no_roll(c):
c.peak.project = lambda a, now, roll=False: real(a, now)
ROWS1M, _ = run(1.9, 0.6, lag=LAG, patch=_no_roll)
_CH1 = {"a": (True, packmode.CHEAP_HOURS, False),
"b": (True, packmode.LEFTOVER_SOLAR, False)}
# One inverter: SAFETY-91's `next_quarter()` is not applied there, so this is
# where the old projection stops the charge at every edge.
ROWS1N, C1N = run(1.9, 1.6, lag=LAG, n=1, choice=_CH1)
ROWS1M, _ = run(1.9, 0.6, lag=LAG, n=1, patch=_no_roll, choice=_CH1)
_m1 = stops(ROWS1M)
check("non-vacuous: with the old projection this model stops at the edge "
check("one inverter: no stop at any edge crossed while charging",
len({q(r["now"]) for r in ROWS1N if r["action"] == arbitrage.CHARGE})
>= 5 and not stops(ROWS1N))
check("non-vacuous: with the old projection one inverter stops at every 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")
len(_m1) >= 2 and all("headroom" in r["reason"] and
r["now"].astimezone(L).minute % 15 == 0 and
r["now"].astimezone(L).second == 0
for _, r in _m1))
# Two inverters: since SAFETY-91 the guard grades the next quarter past a
# block's end (`tariff.next_quarter()`), so the old projection no longer
# stops there; the rolled one gives the same quarters.
ROWS1O, C1O = run(1.9, 1.6, lag=LAG, patch=_no_roll, choice=_CH1)
print(f" two inverters, old projection: {len(stops(ROWS1O))} stops; banked "
f"{[round(x) for x in banked(C1O, ROWS1O)]} against "
f"{[round(x) for x in banked(C1, ROWS1)]} rolled")
check("two inverters: SAFETY-91 already holds the edge (no stop with the old "
"projection), and rolling it changes no banked quarter",
not stops(ROWS1O) and banked(C1O, ROWS1O) == banked(C1, ROWS1))
# 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
@@ -509,6 +531,23 @@ check("modetrade: a probing charge refuser is a charger, the others rest; "
and modetrade.energy([_pk], 20.0, 95.0)[1] == 0.0
and _dec([replace(_pk, probing=False), _pb]).action is None)
# On the new base (release/1.0 e3e4ba6): SAFETY-78's drain comes before the
# probe, and P4's day plan sets how far a probing Leftover solar battery goes.
_ls = replace(_pk, mode=packmode.LEFTOVER_SOLAR)
_dr = _dec([_pk, _pb], draining=True)
check("a monthly drain comes before the probe: no grid charge while another "
"battery drains, the probing refuser included (why 'cycle')",
_dr.action is None and _dr.why == "cycle"
and _dr.roles.get(A) != modetrade.CHARGE
and _dec([_pk, _pb], draining=False).action == modetrade.CHARGE)
check("with a day plan a probing Leftover solar battery is a charger up to "
"its planned level and no further; without a level for it, it is not",
_dec([_ls, _pb], targets={A: 60.0}).action == modetrade.CHARGE
and _dec([_ls, _pb], targets={A: 25.0}).action is None
and _dec([_ls, _pb], targets={B: 60.0}).action is None
and _dec([_ls, _pb]).action is None
and modetrade.energy([_ls], 20.0, 95.0, targets={A: 60.0})[1] == 0.0)
# ============================================================================ #
print("\n4. invariants on these runs (and part 1c's house in the model)")
_rows4, _c4 = run(1.9, 0.9, steps=[
@@ -521,10 +560,10 @@ _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")
f"(e3e4ba6: 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",
"battery's lag may use 2 W of the 50 W margin, as on e3e4ba6",
_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 "
@@ -537,30 +576,32 @@ check("selling is off: no sale on any cycle",
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 = {
print("\n5. modes off: byte-identical to release/1.0 e3e4ba6")
# Printed by tools/opt11p3_identity.py on release/1.0 e3e4ba6 (2026-10-13),
# goodwe-addon-checks:1, with this branch's harness (same API). n1 and n2 are
# also 5a4cdc2's; the lag ones moved with SAFETY-90/91 (n2_lag now equals n2:
# the next-quarter grading removed the edge tick's blindness at N=2).
AT_BASE = {
"n1": "71245b227ca40bada4f71f57ea6154475f4b0bd60239c9ad4f80bffb0094514c",
"n2": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722",
"n1_lag": "c27ad6aada1dfed77351472b1944dae31639bbd5cb84f266e6741c97c3e59fc6",
"n2_lag": "fafcae0e36718a640216e346be9f568c28126aae6e1ad2d18414f4ac74f90413",
"n1_lag": "86ef0f5ad8da1498ff7dcf59262a177bef847a0b201ce804e947c8eab5f07190",
"n2_lag": "56cf28010b06765c29e5479abb815d7503d570b39240d82dfc818efa90c93722",
}
_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'} "
for k in AT_BASE}
for _k in AT_BASE:
print(f" {_k} {'same' if _got[_k] == AT_BASE[_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)
"trade sentence equals e3e4ba6's, also when every quarter boundary's "
"cycle still sees the old P1 block", _got == AT_BASE)
_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"]
_moved = ident.digest(ident.run(**ident.SCENARIOS["n1_lag"])[0]) \
!= AT_BASE["n1_lag"]
finally:
tariff.PeakTracker.project = _real_project
check("non-vacuous: the rolled projection leaking into modes off moves the "