"""Runnable check for MAINT-28 fix-forward. `python3 test_maint28b.py` The rig failed MAINT-28 (TEST-23, f3f9483): the hold of the maintenance pause ended at almost every guard step (F-1), and while it stuck, the pack in maintenance CHARGE gave its full rating for the house below its peak reserve (F-2). test_maint28.py missed both because its battery reading has no lag. Here the battery register is published every 10 s and held (`Lag.batt_period_s`), the meter reading is 2 s old, as on the rig; the rating is the rig's A (5 kW). 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, house +-50 W every 5 s, 3000 W then 6500 W for 13 min (over a quarter boundary), then 3000 W. 1. A real peak, A in CHARGE above its peak reserve: A flips charge/give a few times (base 1b5f7c9: dozens), no release of the pause on an estimate that mixes a new grid with an old battery reading, quarters at or under the aim, A's charge back after the load. 2. F-2: A starts just over its reserve: while the pause holds it gives for the house only down to the reserve, never below. 3. The mirror: B in CHARGE, under its reserve at the load: it gives nothing for the house, the other pack carries what the guard claims. 4. Unchanged: the MAINT-24 live shape (A 1475 W, B no grid trade), with the same lag on. 5. `peakguard.shaping_held()` with `settled`, and `Controller._batts_settled()`. Mutations: DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md. Read MUTATION.md before you report a survivor. """ 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 opt11p3_replay as rp # noqa: E402 import opt20_scen as sc # noqa: E402 from app import ctl_cycle, ctl_trade, freshness, peakguard, tariff # noqa: E402 fails = [] def check(name, cond): print(f" {'ok ' if cond else 'FAIL'} {name}") if not cond: fails.append(name) rp.RATING, rp.CAP_KWH = 5000.0, 15.36 # the rig's A LAG = rp.Lag(dead_s=1.0, meter_s=2.0, batt_s=1.0, p1_s=0.35, tick=1.0, tau_s=2.0, ramp_w_s=1e9, batt_period_s=10.0) CH = sc.MAINT_CHARGE MONTH_PEAK = 6000.0 AIM = MONTH_PEAK - 50.0 LINE = AIM - getattr(peakguard, "MAINT_RESUME_UNDER_W", 300.0) T0 = sc.at(2.0) # a quarter boundary PRE, LOAD, POST = 120.0, 780.0, 120.0 # s (CI time: short runs) RESERVE = 20.0 # peak_soc_floor default def stepped_house(base_w, hi_w): 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(*, unit="a", soc=49.0, other=30.0, house=None, house_w=3000.0, peak=MONTH_PEAK, dur=PRE + LOAD + POST, fc="quarter", n=2, choice=None): """`unit` is in maintenance CHARGE (forced result), at `soc`, the other at `other`. Rows: the plants' power and SoC, the pause flag, the add-on's house estimate and the real house.""" rows = [] q = dict(q=None, s=0.0, last=None, start=None) idx = 0 if unit == "a" else 1 o = "b" if unit == "a" else "a" def patch(c): c.maints[idx].tick = ( lambda now, soc_, blocked_by=None, gate_open=True: CH) 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 async def cyc(): c.cheap = True if fc and c.grid is not None: g = float(c.grid) now = ctl_cycle.datetime.now() # the replay's clock 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() c.cycle = cyc def cb(c, h, now, pr): est = (None if c.grid is None or c.site_batt_w is None else float(c.grid) + float(c.site_batt_w)) rows.append(dict( t=(now - T0).total_seconds(), grid=h.grid(), u=h.plants[unit].p, o=h.plants[o].p, soc=h.plants[unit].soc, soc_o=h.plants[o].soc, hold=bool(getattr(c, "_maint_peak_hold", False)), est=est, house=h.grid() + sum(p.p for p in h.plants.values()))) src = house if house is not None else [(sc.UTC0.timestamp() - 10, house_w)] ch = dict(sc.CH_CH if choice is None else choice) stats, c = rp.run("p3_ch_sell", src, sc.WPTS, T0, dur, check=cb, lag=LAG, n=n, soc0={unit: soc, o: other}, choice=ch, refuse=(), patch=patch, extra={"cycle_cost_threshold_eur": 0.10}) return rows, stats, c def flips(rs, k="u"): n, s = 0, 0 for r in rs: v = 1 if r[k] > 50 else -1 if r[k] < -50 else 0 if v and s and v != s: n += 1 if v: s = v return n def releases(rows): """Cycles in the load where the pause ended: (row, estimate under the release line while the real house was over it).""" out = [] for p, r in zip(rows, rows[1:]): if PRE <= r["t"] < PRE + LOAD and p["hold"] and not r["hold"]: out.append((r, r["est"] is not None and r["est"] < LINE and r["house"] >= LINE)) return out def quarters_ok(st): # SAFETY-126: a landing 0-7 W over the aim is not an overrun. return (max(a[0] / a[1] for a in st["quarters"].values()) <= AIM + peakguard.ALARM_MISS_W) def show(tag, rows, st): load = [r for r in rows if PRE <= r["t"] < PRE + LOAD] rel = releases(rows) print(f" {tag}: flips {flips(load)}, charge s " f"{sum(1 for r in load if r['u'] < -50)}, releases {len(rel)} " f"(stale {sum(1 for _r, s in rel if s)}), lowest SoC " f"{min(r['soc'] for r in rows):.1f} %, give kWh " f"{sum(r['u'] for r in load if r['u'] > 0) / 3.6e6:.2f}, quarters " f"{[round(a[0] / a[1], 1) for a in st['quarters'].values()]}") # ============================================================================ # print("1. a real peak, A in CHARGE at 49 % (above its reserve), the battery " "reading lagging") rows, st, c = scen(house=stepped_house(3000.0, 6500.0)) show("A 49 %", rows, st) load = [r for r in rows if PRE <= r["t"] < PRE + LOAD] rel = releases(rows) after = [r for r in rows if r["t"] >= PRE + LOAD] back = next((i for i, r in enumerate(after) if r["u"] < -50), None) print(f" A's charge back {back} s after the load") check("A flips charge/give 3 times or fewer in the 13 min (1b5f7c9: 60, " "f3f9483: 11)", flips(load) <= 3) check("the pause never ends on an estimate under the release line while the " "real house is over it", not any(s for _r, s in rel)) check("every quarter at or under the month aim", quarters_ok(st)) check("after the load A's charge is back within 20 cycles (1 s each: a 10 s " "register period plus its settle time, 16 s)", back is not None and back <= 20) # ============================================================================ # print("\n2. F-2: A starts just over its peak reserve (21 %), the pause holds") rows, st, c = scen(soc=RESERVE + 1.0, house=stepped_house(3000.0, 6500.0)) show("A 21 %", rows, st) check("A gave for the house while above the reserve (as before)", any(r["u"] > 500 for r in rows if r["soc"] > RESERVE + 0.5)) check("A never goes under its peak reserve in the peak (rig: to 10 %)", min(r["soc"] for r in rows) >= RESERVE - 0.5) check("every quarter at or under the month aim", quarters_ok(st)) # ============================================================================ # print("\n3. mirror: B in CHARGE at 18 % (under its reserve), A on Cheap hours") rows, st, c = scen(unit="b", soc=18.2, other=30.0, house=stepped_house(3000.0, 6500.0)) show("B 18 %", rows, st) check("B gives nothing for the house: its SoC never goes under 18 %", min(r["soc"] for r in rows) >= 18.0) check("every quarter at or under the month aim", quarters_ok(st)) # ============================================================================ # print("\n4. unchanged: the MAINT-24 live shape with the lagging reading") rl, sl, cl = scen(house_w=3000.0, dur=600.0) body = rl[len(rl) // 6:] mean_a = sum(r["u"] for r in body) / len(body) mean_b = sum(r["o"] for r in body) / len(body) print(f" A {mean_a:+.0f} W, B {mean_b:+.0f} W, pause " f"{sum(r['hold'] for r in rl)} cycles") check("A charges about 1475 W (-1500..-1440) and the pause never comes", -1500.0 <= mean_a <= -1440.0 and not any(r["hold"] for r in rl)) check("B does not trade with the grid (Cheap hours, nothing to charge it)", abs(mean_b) < 100.0) # ============================================================================ # print("\n5. peakguard.shaping_held() with `settled`; Controller._batts_settled()") check("the rule exists (MAINT_STEP_W, _batts_settled)", hasattr(ctl_trade, "MAINT_STEP_W") and hasattr(ctl_trade.TradeMixin, "_batts_settled")) if not hasattr(ctl_trade, "MAINT_STEP_W"): print(f"\n{len(fails)} FAILED:") for f in fails: print(f" - {f}") sys.exit(1) U = peakguard.MAINT_RESUME_UNDER_W idle = peakguard.PeakDecision(engaged=False, floor_w=None, ceiling_w=6000.0, ceiling_from="x", target_w=AIM) def held(was, fc, house, settled): return peakguard.shaping_held( peakguard.shaping_suspended(idle, fc, AIM), was, fc, AIM, house, settled) check("held, house under the line but not settled: stays paused", held(True, AIM - 1, AIM - U - 100.0, False)) check("held, house under the line and settled: resumes", not held(True, AIM - 1, AIM - U - 100.0, True)) check("not held: settled makes no difference", not held(False, AIM - 1, 6500.0, False)) check("neither the house nor the forecast read: no pause, settled or not " "(SAFETY-63)", not held(True, None, None, False)) class _Fresh: def __init__(self, changed): self.changed_mono = changed class _Inv: def __init__(self, name, cmd, changed): self.name, self.cmd = name, cmd self.batt_fresh = _Fresh(changed) def standing_w(self): return self.cmd class _Ctl: _batts_settled = getattr(ctl_trade.TradeMixin, "_batts_settled", None) def __init__(self, invs): self.inverters, self._m28_step, self._now_mono = invs, {}, 0.0 S = freshness.PLANT_SETTLE_S a = _Inv("a", -1500.0, 0.0) k = _Ctl([a, _Inv("b", 0.0, 0.0)]) check("no step seen yet: settled", k._batts_settled()) a.cmd = 500.0 k._now_mono = 100.0 check("a step up of the command: not settled", not k._batts_settled()) k._now_mono = 100.0 + S - 0.5 a.batt_fresh.changed_mono = 100.0 + S - 1.0 check("...a reading that arrived before step + settle does not count", not k._batts_settled()) a.batt_fresh.changed_mono = 100.0 + S k._now_mono = 100.0 + S check("...one that arrived at step + settle does", k._batts_settled()) a.cmd = 3000.0 k._now_mono = 200.0 check("a second step up; the old reading is not current", not k._batts_settled()) k._now_mono = 200.0 + freshness.BATT_OBLIGED_S check("...and a reading that never changes is waited for no longer than " "BATT_OBLIGED_S", k._batts_settled()) a.cmd = -1500.0 k._now_mono = 400.0 check("a step down (less give: the estimate errs high) is no step", k._batts_settled()) a2 = _Inv("a", -1500.0, 0.0) k2 = _Ctl([a2]) k2._batts_settled() a2.cmd, k2._now_mono = -1500.0 + ctl_trade.MAINT_STEP_W - 1.0, 1.0 check("a move under ctl_trade.MAINT_STEP_W is no step", k2._batts_settled()) a2.cmd = -1500.0 + ctl_trade.MAINT_STEP_W check("...one of ctl_trade.MAINT_STEP_W is", not k2._batts_settled()) print() if fails: print(f"{len(fails)} FAILED:") for f in fails: print(f" - {f}") sys.exit(1) print("all checks passed")