MAINT-28: reset the hold every cycle, test it; digests vs 1b5f7c9; DECISIONS numbers and mutants
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude
parent
4f9aa5223f
commit
92f0387415
@@ -8,14 +8,29 @@
|
||||
## 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 does not move when A switches.
|
||||
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.
|
||||
|
||||
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). A cap on that give would conflict with the guard's `lo` (`lo > hi` resolves to 0 W), so it is not tried here.
|
||||
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.
|
||||
|
||||
## Numbers (sim, real `cycle()`, house 3000 -> 6500 W for 45 min -> 3000 W, +-50 W, aim 5950 W, forecast = quarter average so far plus the rest at the present grid; `scratch/MAINT-28/dev/m28_repro.py`)
|
||||
See the dev report for the table. Live shape (house 3000 W): no pause, A 1475 W, as MAINT-24.
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
@@ -569,7 +571,7 @@ class CycleMixin:
|
||||
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(
|
||||
self._maint_peak_hold and len(self.inverters) > 1,
|
||||
_peak_held and len(self.inverters) > 1,
|
||||
self.peak_guard, self.peak_fc,
|
||||
maint_aim, house)
|
||||
if self._maint_peak_hold:
|
||||
|
||||
@@ -17,7 +17,7 @@ present grid), house +-50 W every 5 s.
|
||||
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 02eac3b, as
|
||||
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.
|
||||
"""
|
||||
@@ -36,7 +36,7 @@ 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, MaintResult # noqa: E402
|
||||
from app.maintenance import DRAIN, IDLE, MaintResult # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
@@ -65,7 +65,7 @@ def stepped_house(base_w, hi_w):
|
||||
return out
|
||||
|
||||
|
||||
def scen(*, a=None, soc=(49.0, 30.0), house=None, house_w=3000.0,
|
||||
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
|
||||
@@ -74,7 +74,10 @@ def scen(*, a=None, soc=(49.0, 30.0), house=None, house_w=3000.0,
|
||||
q = dict(q=None, s=0.0, last=None, start=None)
|
||||
|
||||
def patch(c):
|
||||
if a is not None:
|
||||
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:
|
||||
@@ -119,7 +122,7 @@ def scen(*, a=None, soc=(49.0, 30.0), house=None, house_w=3000.0,
|
||||
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]))
|
||||
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,
|
||||
@@ -149,10 +152,10 @@ 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((r["t"] - end for r in after if r["a"] < -50), None)
|
||||
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} s after it, quarters "
|
||||
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)
|
||||
@@ -163,12 +166,47 @@ 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",
|
||||
back is not None and back <= 10.0)
|
||||
tail = [r["a"] for r in after if r["t"] >= end + 10.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
|
||||
@@ -219,12 +257,12 @@ 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 02eac3b", got["n1"] == AT_BASE["n1"])
|
||||
"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 02eac3b, 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 02eac3b",
|
||||
"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 02eac3b", got["off"] == AT_BASE["off"])
|
||||
check("battery modes off: equal to 1b5f7c9", got["off"] == AT_BASE["off"])
|
||||
|
||||
print()
|
||||
if fails:
|
||||
|
||||
Reference in New Issue
Block a user