Compare commits

...
4 Commits
Author SHA1 Message Date
glenn schrooyenandClaude 92f0387415 MAINT-28: reset the hold every cycle, test it; digests vs 1b5f7c9; DECISIONS numbers and mutants
checks / unit (pull_request) Waiting to run
infra-pin / fresh (pull_request) Waiting to run
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-12 22:44:32 +01:00
glenn schrooyenandClaude 4f9aa5223f MAINT-28: the hold is for N=2 and up (SAFETY-87 N=1 pins), N=1 peak digest test
infra-pin / fresh (pull_request) Waiting to run
checks / unit (pull_request) In progress
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-12 22:28:01 +01:00
glenn schrooyenandClaude 8cd10ee6aa MAINT-28: during a real peak the maintenance charge stays paused until the house is clear of the aim
checks / unit (pull_request) In progress
infra-pin / fresh (pull_request) Successful in 2s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-12 21:51:56 +01:00
admin 1b5f7c9ccb Merge pull request 'MAINT-24 + MAINT-25: a started maintenance charge gets the cheap night; the plan honours the back-off' (#246) from MAINT-24 into release/1.0
infra-pin / fresh (push) Successful in 3s
2026-11-12 16:09:32 +01:00
5 changed files with 339 additions and 2 deletions
@@ -0,0 +1,36 @@
# MAINT-28 - during a real peak the maintenance charge stays paused
**Status: for review.** Tests: `test_maint28.py`. No option added; the peak aim, the month peak and the MAINT-13 numbers are unchanged. MAINT-24 follow-up (rig test finding F-1, `reports/MAINT-24-test-report.md`).
## What happened (rig x10, house 6.5 kW, month aim 5.95 kW, A in maintenance CHARGE)
`peakguard.shaping_suspended()` pauses the charge while the forecast is over the aim or the guard is engaged. MAINT-24 moved the aim from 3450 W to the month aim (SAFETY-80), so the pause no longer lasts the whole peak: the guard gives, its projection drops under 5950 W and it idles ("heading for 5 6xx W, under this month's peak"), the pause ends, the floor charges A 150-460 W, the grid goes to 6.3-6.9 kW, the guard trips again. A flipped charge/give 468 times in 45 min (base 02eac3b 11). The quarters stayed at the aim; the cost was a battery working against itself and a grid that swung 5.4-6.9 kW.
## The change
`peakguard.shaping_held(held, decision, forecast, aim, house)`: the old conjunction, and once it was true the pause stays until the house (`grid + battery power`, the load without our own charge) is `MAINT_RESUME_UNDER_W` = 300 W under the aim. A guard that is engaged or a forecast over the aim still pauses at once. House unread: the forecast stands in; neither read: no pause (SAFETY-63). `ctl_cycle.py` keeps the flag in `_maint_peak_hold`. The hold is for N=2 and up: at one inverter the old rule stays, because SAFETY-87's N=1 digests (`n1_charge_low`, a real peak at N=1) are pinned byte for byte.
Why the house and not the forecast: while A gives, the forecast sits just under the aim by design, so no forecast margin is both safe and reachable. The house is the meter reading plus the batteries' power (`grid + site_batt_w`): the net demand the meter would see with no battery at work. Solar is already in it, and that is right: the aim is a limit on what the meter takes, so a sunny hour that lowers the net demand is real room, and a cloud that raises it brings the hold back at once. It is not the gross load, and it would be wrong to add PV back (that would hold the pause on room the meter really has). It stays put when A switches, except for the age of the battery reading (up to 10 s on the rig); the 300 W line is far larger than that lag moves it.
Why 300 W: the house wanders +-50 W; once the charge resumes it settles at (aim - house) / 2, so 300 W gives at least 150 W, three times the noise, and charging less than that is not worth a restart.
The flag lives only while the charge-only branch is taken: every cycle starts it at False, so a maintenance that ended, a mode change or a missing aim cannot carry a stale True into the next charge (test_maint28.py part 1b). The hold is for N=2 and up: at one inverter the old rule stays, because SAFETY-87's N=1 digests (`n1_charge_low`, a real peak at N=1) are pinned byte for byte.
It only ever keeps the maintenance charge off. No cut-back is delayed or weakened: the guard's claim is filed as before. While the pause is held the loop works as at 02eac3b, so A also gives for the house (quarters then sit under the aim, A's energy goes to the load: 1.67 kWh in the 45 min below, 0.45 kWh on 1b5f7c9). A cap on that give would conflict with the guard's `lo` (`lo > hi` resolves to 0 W), so it is not tried here.
## Numbers (sim, real `cycle()`, A rated 2500 W)
House 3000 -> 6500 W for 45 min -> 3000 W, +-50 W every 5 s, banked month peak 6000 W (aim 5950 W), forecast = quarter average so far plus the rest at the present grid (`scratch/MAINT-28/dev/m28_repro.py`; `test_maint28.py` runs the same shape for 20 min to keep the suite short).
| 45 min load | base 02eac3b | 1b5f7c9 | MAINT-28 |
|---|---|---|---|
| A charge/give reversals | 0 | 52 | 1 (the first trip) |
| A charging seconds in the load (of 2700) | 0 | 367 | 301 (before the first trip) |
| quarter averages (W) | 5959 5950 5950 5438 5502 | 5356 5923 5924 5041 4479 | 5356 4012 4000 4226 4476 |
| A out / in (kWh) | 0.65 / 0 | 0.45 / 0.51 | 1.67 / 0.56 |
| charge back after the load | - | 3 s | 3 s |
Rig (MAINT-24 test report, F-1): 468 reversals in 45 min on 1b5f7c9, 11 on 02eac3b. Live shape (house 3000 W): no pause, A 1475 W, as MAINT-24.
## Tests and mutants
`test_maint28.py`: part 1 the real peak, 1b the stale hold, 2 `shaping_held()` edges, 3 the unchanged cases (digests equal 1b5f7c9). On 1b5f7c9 itself (hold stubbed out) part 1 and 1b fail. Mutants, each red: M1 hold state not kept (`shaping_held(False, ...)`: the 1b5f7c9 behaviour); M2 resume line `-` to `+`; M3 house not passed; M4 the per-cycle reset removed; M5 the N=1 gate removed (digest `n1_peak`).
## Not changed
MAINT-24's rate and its tests, N=1, the drain, modes off (digests of 02eac3b). The pause for a late or missing forecast.
+11 -2
View File
@@ -537,6 +537,8 @@ class CycleMixin:
# maintenance ⚠️ ONE PASS PER PACK, AND `arbitrate()` IS WHY THE LIST
# STAYS SHORT. (DECISIONS/MAIN-orchestration-archive.md)
# MAINT-28: the hold lives only while the charge-only branch is taken.
_peak_held, self._maint_peak_hold = self._maint_peak_hold, False
for inv, result in zip(self.inverters, maint_results):
if result.owns_setpoint:
# PER-INVERTER: §12's recalibration is a PACK procedure, filed
@@ -564,8 +566,15 @@ class CycleMixin:
maint_aim = peakguard.quarter_target_w(
float(self.o.get("peak_cap_w", 3500)),
self.peak_margin_w, _reading)
if peakguard.shaping_suspended(
self.peak_guard, self.peak_fc, maint_aim):
# MAINT-28: stays paused until the house is clear of the aim.
# N=2 and up; one inverter keeps the old rule (SAFETY-87 pins).
house = (None if self.grid is None or self.site_batt_w is None
else float(self.grid) + float(self.site_batt_w))
self._maint_peak_hold = peakguard.shaping_held(
_peak_held and len(self.inverters) > 1,
self.peak_guard, self.peak_fc,
maint_aim, house)
if self._maint_peak_hold:
# Edge-logged: level-logging here would write one line per
# cycle for as long as the event lasts and flush the 40-line
# action log of the very context that explains it.
+1
View File
@@ -505,6 +505,7 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
self._land_batt_prev = None
self._peak_needed = False
self._maint_shape_suspended = False
self._maint_peak_hold = False # MAINT-28
# OPT-11: battery modes, off by default (SAFETY-10 section 1). Each
# battery's mode and on/off switch live in the store, not in options,
# so a change applies at once (`pack_use`, `pack_mode`).
+18
View File
@@ -28,6 +28,10 @@ FLOOR_AT_SOC_FLOOR = "the battery level is at or below the peak reserve"
SOURCE = "peak"
# MAINT-28. Watts the house must sit under the aim before a paused maintenance
# charge resumes. DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md.
MAINT_RESUME_UNDER_W = 300.0
# Mirror of config.yaml's peak options, pinned to the file by
# test_peakguard.py. Not a read: `decide()` takes them as arguments.
PEAK_OPTION_DEFAULTS = {
@@ -446,6 +450,20 @@ def shaping_suspended(decision, peak_forecast_w, cap_w) -> bool:
return decision is not None and decision.engaged
def shaping_held(held, decision, peak_forecast_w, aim_w, house_w) -> bool:
"""MAINT-28. `shaping_suspended()`, kept on until the house is
`MAINT_RESUME_UNDER_W` under `aim_w` (`peak_forecast_w` stands in for an
unread house; neither read: not held, as SAFETY-63). Only ever pauses the
maintenance charge, never a cut-back.
DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md."""
if shaping_suspended(decision, peak_forecast_w, aim_w):
return True
if not held:
return False
level = house_w if house_w is not None else peak_forecast_w
return level is not None and level > float(aim_w) - MAINT_RESUME_UNDER_W
def cap_floor(decision, cap_w):
"""SAFETY-128: the floor no higher than `cap_w` (the command standing while
the readings have not answered it), or `decision` unchanged when None.
+273
View File
@@ -0,0 +1,273 @@
"""Runnable check for MAINT-28. `python3 test_maint28.py`
MAINT-24 follow-up. With A in maintenance CHARGE and a real peak (house over
the month aim), MAINT-24's pause ended every time the guard idled: the
forecast dipped under the aim, the floor charged A, the guard tripped again,
and A flipped charge/give every few seconds (rig x10: 468 flips in 45 min,
base 02eac3b 11). Now the pause is held (`peakguard.shaping_held()`) until the
house is MAINT_RESUME_UNDER_W under the aim.
All on the real Controller through `cycle()` (tools/opt20_scen.py): cheap
window, cap 3500 / margin 50, a month peak of 6000 W banked (aim 5950 W), a
peak forecast like the live sensor (quarter average so far, the rest at the
present grid), house +-50 W every 5 s.
1. A real peak (house 3000 W, then 6500 W for 20 min, then 3000 W): once the
guard has stepped in, A does not charge and does not flip; every quarter is
at or under the aim; A's charge is back within 10 cycles after the load.
2. peakguard.shaping_held(): the pure rule, with its edges.
3. Unchanged: the MAINT-24 live shape (A 1475 W, no pause), N=1, the SAFETY-78
drain and battery modes off (digests of release/1.0 1b5f7c9, as
test_maint24.py).
Mutations: DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md.
"""
import logging
import os
import random
import sys
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 main_files # noqa: E402
import opt11p3_replay as rp # noqa: E402
import opt20_scen as sc # noqa: E402
from app import peakguard, tariff # noqa: E402
from app.maintenance import DRAIN, IDLE, MaintResult # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
CH = sc.MAINT_CHARGE
MONTH_PEAK = 6000.0
AIM = MONTH_PEAK - 50.0
DRAIN_R = MaintResult(DRAIN, True, 2000.0, False, [])
T0 = sc.at(2.0) # a quarter boundary
PRE, LOAD, POST = 300.0, 1200.0, 600.0 # s
def stepped_house(base_w, hi_w):
"""[(t_utc, W)]: base_w, then hi_w from PRE for LOAD s, +-50 W per 5 s."""
rnd = random.Random(28)
out = [(sc.UTC0.timestamp() - 10, base_w)]
for t in range(0, int(PRE + LOAD + POST), 5):
w = hi_w if PRE <= t < PRE + LOAD else base_w
out.append((T0.timestamp() + t, w + rnd.uniform(-50.0, 50.0)))
return out
def scen(*, a=None, tick=None, soc=(49.0, 30.0), house=None, house_w=3000.0,
peak=MONTH_PEAK, fc="grid", n=2, variant="p3_ch_sell", dur=2700.0):
"""`a`: A's forced maintenance result. `fc`: "grid" (follows the grid),
"quarter" (quarter average so far, the rest at the present grid, like the
live sensor) or None (no forecast entity). Returns (rows, stats, c)."""
rows = []
q = dict(q=None, s=0.0, last=None, start=None)
def patch(c):
if tick is not None:
c.maints[0].tick = (
lambda now, soc_, blocked_by=None, gate_open=True: tick(now))
elif a is not None:
c.maints[0].tick = (
lambda now, soc_, blocked_by=None, gate_open=True: a)
if peak is not None:
orig_peak = c.peak.peak
def peak_(now):
r = orig_peak(now)
return tariff.PeakReading(tariff.OBSERVED,
getattr(r, "month", ""), peak)
c.peak.peak = peak_
orig = c.cycle
susp = []
c._m28_susp = susp
async def cyc():
c.cheap = True
if fc and c.grid is not None:
g = float(c.grid)
if fc == "grid":
c.peak_fc = g
else:
now = main_files.live.datetime.now()
qi = int(now.timestamp() // 900)
if q["q"] != qi:
q.update(q=qi, s=0.0, last=now, start=now)
el = (now - q["start"]).total_seconds()
q["s"] += g * (now - q["last"]).total_seconds()
q["last"] = now
c.peak_fc = (q["s"] + g * (900.0 - el)) / 900.0
await orig()
susp.append(bool(c._maint_shape_suspended))
c.cycle = cyc
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 {},
why=c.mode_tick.why if c.mode_tick else None,
p={u: p.p for u, p in h.plants.items()},
t=(now - T0).total_seconds(), a=h.plants["a"].p,
susp=c._m28_susp[-1], hold=bool(getattr(c, "_maint_peak_hold", False))))
src = house if house is not None else [(sc.UTC0.timestamp() - 10, house_w)]
stats, c = rp.run(variant, src, sc.WPTS, T0, dur, check=cb, lag=sc.LAG,
n=n, soc0={"a": soc[0], "b": soc[1]},
choice=sc.CH_CH, refuse=(), patch=patch,
extra={"cycle_cost_threshold_eur": 0.10})
return rows, stats, c
def flips(rs):
"""Reversals of A between charging and giving (beyond 50 W)."""
n, s = 0, 0
for r in rs:
v = 1 if r["a"] > 50 else -1 if r["a"] < -50 else 0
if v and s and v != s:
n += 1
if v:
s = v
return n
# ============================================================================ #
print("1. a real peak: house 3000 W, 6500 W for 20 min, 3000 W")
rows, st, c = scen(a=CH, house=stepped_house(3000.0, 6500.0), fc="quarter",
dur=PRE + LOAD + POST)
end = PRE + LOAD
trip = next((r["t"] for r in rows if r["t"] >= PRE and r["a"] > 50), None)
load = [r for r in rows if trip is not None and trip <= r["t"] < end]
after = [r for r in rows if r["t"] >= end]
back = next((i for i, r in enumerate(after) if r["a"] < -50), None) # cycles
print(f" first give at {trip}, flips in the load {flips(load)}, charge s in"
f" the load {sum(1 for r in load if r['a'] < -50)}, charge back "
f"{back} cycles after it, quarters "
f"{[round(a[0] / a[1]) for a in st['quarters'].values()]}")
check("the guard stepped in once the projection went over the aim",
trip is not None)
check("from the first give to the load's end A does not flip",
flips(load) == 0)
check("...and does not charge at all", not any(r["a"] < -50 for r in load))
check("...the pause is on in every one of those cycles",
all(r["susp"] for r in load))
check("every quarter at or under the month aim",
max(a[0] / a[1] for a in st["quarters"].values()) <= AIM + 1.0)
check("after the load A's charge is back within 10 cycles (1 s each)",
back is not None and back <= 10)
tail = [r["a"] for r in after[10:]]
check("...and stays on: at least 150 W (half the resume margin), no give",
tail and sum(tail) / len(tail) <= -150.0 and max(tail) <= 50.0)
# ============================================================================ #
print("\n1b. the hold does not outlive the charge: a stale True must not "
"pause the next charge")
HI_END, IDLE_END = 900.0, 1000.0
def _house_b():
rnd = random.Random(29)
out = [(sc.UTC0.timestamp() - 10, 3000.0)]
for t in range(0, 1500, 5):
w = 3000.0 if t < PRE else 6500.0 if t < HI_END else 5700.0
out.append((T0.timestamp() + t, w + rnd.uniform(-50.0, 50.0)))
return out
def _tick_b(now):
t = (now - T0).total_seconds()
return MaintResult(IDLE, False, None, False, []) \
if HI_END <= t < IDLE_END else CH
rb, sb, cb_ = scen(tick=_tick_b, house=_house_b(), fc="quarter", dur=1400.0)
held_before = [r for r in rb if PRE <= r["t"] < HI_END and r["hold"]]
idle_rows = [r for r in rb if HI_END <= r["t"] < IDLE_END]
later = [r["a"] for r in rb if IDLE_END + 10.0 <= r["t"] < 1300.0]
print(f" hold on {len(held_before)} cycles before; in the idle window "
f"{sum(r['hold'] for r in idle_rows)} of {len(idle_rows)}; A after "
f"{sum(later) / len(later):+.0f} W (house 5700 W: 125 W)")
check("the hold was on in the peak", len(held_before) > 100)
check("it is off in every cycle the charge-only branch is not taken",
not any(r["hold"] for r in idle_rows))
check("a new maintenance charge at house 5700 W (over the resume line) "
"starts at once: (5950 - 5700) / 2 = 125 W, not paused",
sum(later) / len(later) <= -60.0)
# ============================================================================ #
print("\n2. peakguard.shaping_held()")
held = peakguard.shaping_held
U = peakguard.MAINT_RESUME_UNDER_W
eng = peakguard.PeakDecision(engaged=True, floor_w=100.0, ceiling_w=6000.0,
ceiling_from="x", target_w=AIM)
idle = peakguard.PeakDecision(engaged=False, floor_w=None, ceiling_w=6000.0,
ceiling_from="x", target_w=AIM)
check("the old conjunction still pauses: forecast over the aim; guard engaged",
held(False, idle, AIM + 1, AIM, 3000.0)
and held(False, eng, None, AIM, 3000.0))
check("not held and not suspended: not paused", not held(False, idle, AIM - 1,
AIM, 6500.0))
check("held: stays paused while the house is over the aim minus the margin",
held(True, idle, AIM - 1, AIM, 6500.0)
and held(True, idle, AIM - 1, AIM, AIM - U + 1))
check("held: resumes when the house is the margin under the aim",
not held(True, idle, AIM - 1, AIM, AIM - U)
and not held(True, idle, AIM - 1, AIM, 3000.0))
check("held, house unread: the forecast stands in; neither read: resumes "
"(SAFETY-63)",
held(True, idle, AIM - 1, AIM, None)
and not held(True, idle, AIM - U, AIM, None)
and not held(True, idle, None, AIM, None))
check("a guard that is engaged pauses whatever the house reads",
held(True, eng, AIM - 1, AIM, 3000.0))
# ============================================================================ #
print("\n3. unchanged: the live shape, N=1, the drain, modes off")
rl, sl, cl = scen(a=CH)
body = rl[len(rl) // 6:]
mean_a = sum(r["a"] for r in body) / len(body)
print(f" A {mean_a:+.0f} W, paused {sum(cl._m28_susp)} cycles")
check("MAINT-24 live shape: A charges 1475 W and the pause never comes",
-1500.0 <= mean_a <= -1440.0 and not any(cl._m28_susp))
d1 = sc.digest(scen(a=CH, n=1, fc=None, peak=None)[0])
dd = sc.digest(scen(a=DRAIN_R, soc=(60.0, 30.0), peak=None, fc=None)[0])
dm = sc.digest(scen(a=CH, variant="modes_off", peak=None, fc=None)[0])
d1p = sc.digest(scen(a=CH, n=1, fc=None, peak=None, house_w=5000.0)[0])
AT_BASE = {
"n1_peak": "8d266f39eb880f80fad8d236b2fb5100934cb92efd8ed2082e85eb3b71cd6c45",
"n1": "44816d132984ecd64d5a2510223debe102f29decd1d5127ae2b255b45dbf8bd4",
"drain": "8fd86d0816626ee5ae86bc973cb0121b1b5108e56c3b0fdd4b4be6c1f403f748",
"off": "a007f962793611bce15b11f576a76aa52063adcfde82b2a2dc0b0c075b60e60b",
}
got = {"n1_peak": d1p, "n1": d1, "drain": dd, "off": dm}
for k in AT_BASE:
print(f" {k} {'same' if got[k] == AT_BASE[k] else 'DIFFERS'} "
f"{got[k][:16]}")
check("N=1 with A in maintenance charge and no forecast entity: equal to "
"release/1.0 1b5f7c9", got["n1"] == AT_BASE["n1"])
check("N=1 in a real peak (house 5000 W, a guard that steps in): equal "
"to 1b5f7c9, the hold is for N=2 and up", got["n1_peak"] == AT_BASE["n1_peak"])
check("the SAFETY-78 drain beside a charger: equal to 1b5f7c9",
got["drain"] == AT_BASE["drain"])
check("battery modes off: equal to 1b5f7c9", got["off"] == AT_BASE["off"])
print()
if fails:
print(f"{len(fails)} FAILED:")
for f in fails:
print(f" - {f}")
sys.exit(1)
print("all checks passed")