Compare commits

...
5 Commits
Author SHA1 Message Date
glenn schrooyenandClaude 07d23d2eed MAINT-28 fix-forward round 4: the paused pack's cap is its own part of the guard's floor, the largest of the period, per unit
checks / unit (pull_request) Successful in 27m27s
infra-pin / fresh (pull_request) Successful in 2s
Owner 2026-11-13 ("ok max", relayed by the manager): while the pause holds,
the pack in maintenance CHARGE gives at most the largest floor the guard
claimed in the quarter. The cap is now that pack's own part of the floor
(the floor less what the other units are placed at, split without the cap),
the largest in the 15-minute period, kept per unit (`_m28_hi`). 0 W while
the guard holds it at its reserve. test_maint28b: SoC check stated against
the owner-accepted give (0.70 point, measured 0.64); both-packs-able tests
(B on Cheap hours, B on Solar first); per-unit state and the one-slot
arbitrate. test_maint28 unchanged (5925 W). Pins: test_safety84
n2_charge_banked, test_packshare's one-resolve AST pin (a second call with
the same weights).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-13 23:42:23 +01:00
glenn schrooyenandClaude fb4a449591 MAINT-28 fix-forward round 3: the cap is the guard's largest claim of the period, 0 W for a pack the guard does not place on
checks / unit (pull_request) Successful in 28m17s
infra-pin / fresh (pull_request) Successful in 1s
test_maint28 quarter bound back to aim + 1 W (now 5925 W; the guard alone landed
5960.1 W): the pack's limit is the largest floor the guard claimed in the 15-minute
period. A pack in peak_guard.held (the guard places its claim on the others) gets
0 W. test_safety84 n2_charge_banked re-pinned again, its banked quarters asserted
under the aim. New test: guard claims on the other pack only.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-13 21:21:14 +01:00
glenn schrooyenandClaude 776b84ca20 MAINT-28 fix-forward round 2: while the pause holds, the maintenance pack gives only what the peak guard claims
checks / unit (pull_request) Successful in 27m52s
infra-pin / fresh (pull_request) Successful in 1s
Owner go 2026-11-13. The pack in maintenance CHARGE gets a unit limit with hi
= the guard's own floor while the pause holds, at any SoC (replaces the
under-reserve limit). Sim 45 min: A give 2.39 -> 0.02 kWh, SoC 49.0 %, charge
back 4 s. test_safety84 n2_charge_banked re-pinned (192 of 480 rows),
test_maint28 quarter bound to the month peak. Tests: test_maint28b.py.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-13 19:49:19 +01:00
glenn schrooyenandClaude 34b81e8aeb MAINT-28 fix-forward: the peak pause ends only on a settled house estimate, and a pack under its peak reserve gives nothing for the house while it holds
checks / unit (pull_request) Successful in 28m10s
infra-pin / fresh (pull_request) Successful in 1s
Rig FAIL (TEST-23, f3f9483). F-1: the hold ended at each guard step because
grid + site_batt_w added a new meter reading to a battery reading up to 10 s
old. It now ends only when no battery reading may pre-date a step up of its
command (Controller._batts_settled()). F-2: while the pause holds, a pack in
maintenance CHARGE at or under its peak reserve is limited to 0 W, so it gives
only what the guard claims for the peak. Sim: Lag.batt_period_s (a held 10 s
register). Tests: test_maint28b.py. DECISIONS updated.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-13 16:56:18 +01:00
admin f3f9483d9c Merge pull request 'MAINT-28: during a real peak the maintenance charge stays paused' (#247) from MAINT-28 into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-11-13 06:23:12 +01:00
9 changed files with 579 additions and 17 deletions
@@ -1,6 +1,6 @@
# 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`).
**Status: fix-forward after the rig FAIL (TEST-23, f3f9483).** Tests: `test_maint28.py`, `test_maint28b.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.
@@ -8,7 +8,7 @@
## The change
`peakguard.shaping_held(suspended, held, forecast, aim, house)`: `suspended` is the old `shaping_suspended()`, 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, and only where MAINT-24 moved the aim (a banked month peak above `peak_cap_w`, so the aim is above cap minus margin). Elsewhere the old rule stays, byte for byte: SAFETY-87's N=1 digests (`n1_charge_low`) and SAFETY-119's N=2 charge at 5050 W over a 5000 W cap with no banked peak are pinned, and 1b5f7c9 behaves as 02eac3b there (a chase that was not a MAINT-24 regression).
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 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 does not stay put when a battery steps: see "Fix-forward" below.
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.
@@ -34,3 +34,20 @@ Rig (MAINT-24 test report, F-1): 468 reversals in 45 min on 1b5f7c9, 11 on 02eac
## Not changed
MAINT-24's rate and its tests, N=1, the drain, modes off (digests of 1b5f7c9). The pause for a late or missing forecast.
## Fix-forward (rig FAIL F-1, F-2; `reports/MAINT-28-test-report.md`)
The sim had no battery-reading lag, and the first version said the 300 W line is far larger than the lag moves the estimate. That was wrong. The rig publishes a battery's power every 10 s; the meter is newer. After each guard step charge -> give (+500-1000 W) the next meter reading shows the give and the battery reading still shows the charge, so `grid + site_batt_w` drops by the step, under the 5650 W line: 69/69, 132/133, 215/215, 51/51 releases at a median 5430 W with the house at 6450 W or more. Test: `test_maint28b.py`, sim `Lag.batt_period_s`.
**F-1: the pause ends only on a settled estimate.** `Controller._batts_settled()` is False from a step UP of a unit's command (`MAINT_STEP_W` = 100 W more give or less charge, the SAFETY-128 pending size) until its reading changed `PLANT_SETTLE_S` (6 s) after the step, or `BATT_OBLIGED_S` (17 s) have passed. `peakguard.shaping_held(..., settled)` then keeps the pause. A step down makes the estimate err high, which only holds the pause, so it waits for nothing. Measured in the 45 min sim, pause on: settled, the estimate was at most 146 W under the real house (0 cycles over 300 W); not settled, up to 1933 W under (725 of 1611 cycles over 300 W). Sim, 45 min, house 6500 W: 83 of 83 releases on a stale estimate (f3f9483) -> 0; flips 148 -> 1 (1b5f7c9 210). Charge is back 4 s after the load (12 s in test_maint28b), not 0: the first settled estimate. No cut-back is touched.
**F-2 and the cap.** Owner 2026-11-13 ("1" = cap): while the pause holds, the pack in maintenance CHARGE gives only what the guard claims for the peak, at any SoC; other packs unchanged. Owner 2026-11-13 ("ok max", relayed by the manager): accepted "while the hold is on, the pack in maintenance CHARGE gives at most the largest floor the peak guard claimed in the current quarter" (about 0.37 kWh per 45-min peak in the sim; quarters at or under the aim).
The rule as built (`Controller._m28_share()`): while the pause holds (N>=2, month aim above the cap) the pack gets a unit limit `hi` = the largest part of the guard's floor placed on it in this 15-minute period. Its part in a cycle is the floor less what the other units are placed at in this cycle's split without that limit, so a part the guard's split puts on the other pack is not this pack's. A pack the guard holds at its reserve (`peak_guard.held`) gets 0 W and its part starts again from 0 W. The largest part is kept per unit (`_m28_hi`, keyed by the unit) and dropped in a new period or when the pause ends; only one pack can be in maintenance CHARGE anyway (`maintenance.arbitrate()`, one slot per site).
Why the largest of the period and not the present part: the guard alone lands the quarter 9.5-10 W over its aim in the sim (SAFETY-126: its own projection ends on 5950.0 W but the meter reading it counts lags the true grid), and with the present part only, test_maint28 part 1 ends at 5960.1 W; the largest of the period lets the loop keep A up and it ends at 5925 W. Why not `hi` = 0 W: with the other pack under its reserve the guard needs this one (test_maint28b, house 12 kW, B at 18 %: A gives 1.07 kWh for the guard).
Tests (test_maint28b, 13-min load, A 5 kW): A at 49 %: SoC drop 0.64 point (the owner's 0.37 kWh per 45 min is 0.70 point here; the check is <= 0.70). Both packs able, B on Cheap hours: B is placed nothing, A's limit is the period's largest floor (3087 W) and A gives 3081 W. B on Solar first (gives for the house): A's limit 3294 W against a period's largest floor of 6284 W, under it by 500 W or more in 564 of 569 paused cycles; A still gives 3294 W, so the quarter ends 3843 W, far under the aim: that is the cost of the largest-of-period rule when the other pack gives for the house.
Sim, rig shape, 45 min, house 6500 W: A in CHARGE at 49 %: A gives 0.17 kWh (f3f9483 0.44), SoC 50.5 -> 49.0 %, charge/give flips 1, give/idle switches 3, quarters 5358 1663 1899 2572 4476 W, charge back 4 s after the load; B (Cheap hours, not in maintenance) gives 3.04 kWh for the house (f3f9483 0.32). B at 18.2 % in CHARGE: f3f9483 gave 1.89 kWh to 10.0 %; now 0.00 kWh, 18.2 %, quarters 5948 and 5952 W (the guard alone, on A). test_safety84 n2_charge_banked: banked quarters 3349 and 3217 W against the aim 3950 W.
Mutants, each red: the freshness check dropped (always settled); the cap dropped; the other units' placement dropped (part = the whole floor); one cap for the site instead of per unit; the 0 W for a held pack dropped (all in `test_maint28b.py`); the period's largest dropped (present part only: `test_maint28.py` part 1 quarter 5960 W).
+24 -6
View File
@@ -29,7 +29,7 @@ from . import shadow
from .price import Uncertainty
from .tariff import next_quarter
from .watchdog import loop_is_following
from .ctl_trade import pack_energy_of
from .ctl_trade import M28_REASON, pack_energy_of
from .rows import why_key, why_raised
_LOG = logging.getLogger("goodwe")
@@ -574,10 +574,11 @@ class CycleMixin:
else float(self.grid) + float(self.site_batt_w))
_susp = peakguard.shaping_suspended(
self.peak_guard, self.peak_fc, maint_aim)
_m28 = (len(self.inverters) > 1
and maint_aim > self.peak_aim_w())
self._maint_peak_hold = peakguard.shaping_held(
_susp, _peak_held and len(self.inverters) > 1
and maint_aim > self.peak_aim_w(),
self.peak_fc, maint_aim, house)
_susp, _peak_held and _m28, self.peak_fc, maint_aim,
house, self._batts_settled())
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
@@ -588,6 +589,18 @@ class CycleMixin:
"suspended so the peak claim is not "
"contradicted",
hint=messages.hint("maint_paused"))
# MAINT-28: while held this pack gives at most the most of
# the guard's floor placed on it this period
# (`_m28_share()`).
# DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md
shape = ((0.0 if self.peak_guard is not None
and inv.name in self.peak_guard.held
else max([c.lo for c in claims
if c.source == peakguard.SOURCE]
+ [self._m28_was(self.actuator_of(
"maintenance", inv), now)],
default=0.0), M28_REASON)
if _m28 else None)
else:
self._maint_shape_suspended = False
hi = 0.0
@@ -604,10 +617,12 @@ class CycleMixin:
floor = self._charge_headroom(floor, claims)
if floor > 0:
hi, reason = -floor, "cheap-window charge"
shape = (hi, reason)
if shape is not None:
# PER-INVERTER, like the SET above and for the same reason -
# the shaping is about the pack being charged, which is `inv`.
claims.append(Claim.limit("maintenance", P_MAINT_SHAPE, hi=hi,
reason=reason,
claims.append(Claim.limit("maintenance", P_MAINT_SHAPE,
hi=shape[0], reason=shape[1],
actuator=self.actuator_of("maintenance",
inv)))
@@ -855,6 +870,9 @@ class CycleMixin:
self.loop_clamp_facts = ()
self._loop_decision = None
# MAINT-28: a paused pack gives at most its largest part of the floor
# in this period.
self._m28_share(claims, now)
# SAFETY-28. A new resolution is a new tick, and the previous record is
# stale from this line onwards. Incremented BEFORE resolve() so that the
# record resolve() builds is stamped with the tick it is for.
+68 -1
View File
@@ -10,7 +10,7 @@ from datetime import datetime, timedelta, timezone
from . import arbitrage
from .arbiter import (
ACTUATOR_SITE, Claim, P_MAINTENANCE, P_MAINT_SHAPE, P_PEAK, SET,
pre_resolve,
pre_resolve, resolve,
)
from . import allocator
from . import dayplan
@@ -19,6 +19,7 @@ from . import packmode
from . import priceruns
from . import solarfc
from . import control as _ctl
from . import freshness
from . import messages
from .maintenance import CHARGE, DRAIN, HOLD, IDLE, MaintConfig, POWER_MOVING
from .mqtt import MODE_LABELS, USE_OFF, USE_ON, unit_slugs, unit_state_key
@@ -49,6 +50,13 @@ EDGE_HOLD_S = _ctl.PLANT_SETTLE_S
HOUSE_PROFILE_KEY = "house_profile"
# MAINT-28: a step of a battery's command, in W, after which its reading is not
# current until a changed one arrives (`Controller._batts_settled()`).
MAINT_STEP_W = 100.0
# MAINT-28: the reason on a paused pack's cap (`Controller._m28_share()`).
M28_REASON = "peak hold"
# OPT-34: the level of a night grid charge that has started, so it does not
# drop when the forecast goes (`Controller._latch_charge()`).
CHARGE_LATCH_KEY = "night_charge_latch"
@@ -711,6 +719,65 @@ class TradeMixin:
lo = hi = float(inv.batt)
self._read_span[inv.name] = (min(lo, cmd), max(hi, cmd), since)
def _batts_settled(self) -> bool:
"""MAINT-28: False while a battery reading may pre-date a step UP of
its command, so `grid + site_batt_w` is a new grid and an old battery.
DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md"""
now, ok = self._now_mono, True
if now is None:
return True
for inv in self.inverters:
cmd = float(inv.standing_w())
seen = self._m28_step.get(inv.name)
if seen is None:
seen = self._m28_step[inv.name] = (cmd, None)
elif abs(cmd - seen[0]) >= MAINT_STEP_W:
seen = self._m28_step[inv.name] = (
cmd, now if cmd > seen[0] else seen[1])
at = seen[1]
if at is None or now - at >= freshness.BATT_OBLIGED_S:
continue
got = inv.batt_fresh.changed_mono
if got is None or got < at + freshness.PLANT_SETTLE_S:
ok = False
return ok
def _m28_share(self, claims, now) -> None:
"""MAINT-28: each paused pack's cap (0 W if the guard holds it at its
reserve) set to the largest part of the guard's floor left to it in
this 15-minute period: the floor less what the other units are placed
at in this cycle's split without the cap. Kept per unit in `_m28_hi`,
dropped while the unit is not paused or is held.
DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md"""
floor = max((c.lo for c in claims if c.source == peakguard.SOURCE),
default=0.0)
g = self.peak_guard
held = {self.actuator_of("maintenance", inv) for inv in self.inverters
if g is not None and inv.name in g.held}
q, seen = int(now.timestamp() // 900), set()
for i, c in enumerate(claims):
if c.source != "maintenance" or c.reason != M28_REASON \
or c.actuator == ACTUATOR_SITE or c.actuator in held:
continue
seen.add(c.actuator)
shares = resolve(claims[:i] + claims[i + 1:], units=self.units,
live=self.live_units(), weights=self.pack_weights,
reserve=self._held_back()).shares
part = max(0.0, floor - sum(float(w) for u, w in shares.items()
if u != c.actuator))
hi = max(part, self._m28_was(c.actuator, now))
self._m28_hi[c.actuator] = (q, hi)
claims[i] = dataclasses.replace(c, hi=hi)
# A unit not paused (or held at its reserve) starts again from 0 W.
for u in set(self._m28_hi) - seen:
del self._m28_hi[u]
def _m28_was(self, u, now) -> float:
"""MAINT-28: unit `u`'s cap so far in this 15-minute period, 0 W in
a new one (`_m28_share()`)."""
q, hi = self._m28_hi.get(u, (None, 0.0))
return hi if q == int(now.timestamp() // 900) else 0.0
def _reseed_drop(self, live) -> None:
"""SAFETY-96, N>=2: a battery that drops out of the split (stale link)
leaves the loop's start point, so the others take over at one slew step
+2
View File
@@ -506,6 +506,8 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
self._peak_needed = False
self._maint_shape_suspended = False
self._maint_peak_hold = False # MAINT-28
self._m28_step = {} # MAINT-28: unit -> (command, step at)
self._m28_hi = {} # MAINT-28: unit -> (period, cap)
# 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`).
+8 -3
View File
@@ -450,18 +450,23 @@ def shaping_suspended(decision, peak_forecast_w, cap_w) -> bool:
return decision is not None and decision.engaged
def shaping_held(suspended, held, peak_forecast_w, aim_w, house_w) -> bool:
def shaping_held(suspended, held, peak_forecast_w, aim_w, house_w,
settled=True) -> bool:
"""MAINT-28. `suspended` (`shaping_suspended()`'s answer), kept on while
`held` 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.
as SAFETY-63) and `settled` (no battery reading older than its last
command step, so `house_w` is not a new grid plus an old battery). Only
ever pauses the maintenance charge, never a cut-back.
DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md."""
if suspended:
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
if level is None:
return False
return level > float(aim_w) - MAINT_RESUME_UNDER_W or not settled
def cap_floor(decision, cap_w):
+429
View File
@@ -0,0 +1,429 @@
"""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 and the cap (owner 2026-11-13): while the pause holds, A gives at most
the largest part of the guard's floor placed on it in the 15-min period,
at any SoC (never under its reserve; SoC drop within the owner-accepted
give); with both packs able, never over the period's largest floor, and
when B is placed part of it A's limit is its own part, not the whole floor;
one unit's limit is its own (two packs in CHARGE never share it); with the
other pack under its reserve, A still gives for the guard.
3. The mirror: B in CHARGE, under its reserve at the load: it gives nothing
for the house.
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
from datetime import timedelta
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, maintenance, # noqa: E402
packmode, peakguard, tariff)
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))
g = c.peak_guard
floor = (float(g.floor_w) if g is not None and g.engaged
and g.floor_w is not None else 0.0)
sh = (dict(c.allocation.shares) if c.allocation is not None
and c.allocation.shares else {})
au = c.actuator_of("maintenance", c.inverters[idx])
ao = c.actuator_of("maintenance", c.inverters[1 - idx])
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,
floor=floor, share_u=float(sh.get(au, 0.0)),
hi_u=next((float(pl.hi) for pl in (c.allocation.plans
if c.allocation else ())
if pl.unit == au), None),
share_o=float(sh.get(ao, 0.0)),
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. the cap: while the pause holds, A gives at most the largest part "
"of the guard's floor placed on it this period")
rows, st, c = scen(soc=RESERVE + 1.0, house=stepped_house(3000.0, 6500.0))
show("A 21 %", rows, st)
check("A gives only the guard's claim, under 0.15 kWh (f3f9483 0.94)",
sum(r["u"] for r in rows if r["u"] > 0) / 3.6e6 < 0.15)
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))
rows, st, c = scen(house=stepped_house(3000.0, 6500.0))
soc_start = max(r["soc"] for r in rows if r["t"] <= PRE + 5.0)
drop = soc_start - min(r["soc"] for r in rows if r["t"] >= PRE)
print(f" A at 49 %: SoC drop {drop:.2f} point")
# Owner 2026-11-13 accepted about 0.37 kWh per 45-min peak with the cap
# (DECISIONS): 0.37 * 13 / 45 = 0.107 kWh in this load, 0.70 point of
# 15.36 kWh. Measured 0.64.
check("A at 49 %: its SoC drops no more than the owner-accepted give allows "
"(0.70 point in the 13 min; f3f9483 6.1)", drop <= 0.70)
rows, st, c = scen(soc=49.0, other=18.2, house=stepped_house(3000.0, 12000.0))
show("A 49 %, B 18 % under its reserve, house 12000 W", rows, st)
load = [r for r in rows if PRE <= r["t"] < PRE + LOAD]
check("the guard's own claim stands: with B under its reserve A gives for "
"the peak (over 0.2 kWh; the cap yields to the guard)",
sum(r["u"] for r in load if r["u"] > 0) / 3.6e6 > 0.2)
g = [("give" if r["u"] > 50 else "idle") for r in load]
sw = sum(1 for x, y in zip(g, g[1:]) if x != y)
print(f" give/idle switches of A in the load: {sw}")
check("...give/idle switches of A at most 10 in the 13 min", sw <= 10)
def period_max(rows, f):
"""Per row: the largest f(row) over the paused rows of its 15-minute
period so far (0 W outside the pause), as the owner rule reads."""
out, q, m = [], None, 0.0
for r in rows:
qi = int((T0.timestamp() + r["t"]) // 900)
if qi != q or not r["hold"]:
q, m = qi, 0.0
if r["hold"]:
m = max(m, f(r))
out.append(m)
return out
for tag, ch, hi_w in (("B on Cheap hours", None, 9000.0),
("B on Solar first (it gives for the house too)",
{"a": sc.CH_CH["a"],
"b": (True, packmode.SOLAR_FIRST, False)},
12000.0)):
rows, st, c = scen(soc=49.0, other=40.0, choice=ch,
house=stepped_house(3000.0, hi_w))
show(f"A 49 %, B 40 %, both able, {tag}, house {hi_w:.0f} W", rows, st)
mf = period_max(rows, lambda r: r["floor"])
held_rows = [(r, m) for r, m in zip(rows, mf)
if r["hold"] and r["floor"] > 0.0 and r["hi_u"] is not None]
n = max(1, len(held_rows))
over = sum(1 for r, m in held_rows if r["share_u"] > m + 2.0)
# the whole floor, as far as A's rating lets it be A's limit
under = sum(1 for r, m in held_rows
if r["hi_u"] < min(m, rp.RATING) - 500.0)
print(f" {len(held_rows)} paused cycles with a guard floor (mean "
f"{sum(r['floor'] for r, _m in held_rows) / n:.0f} W, period's "
f"largest {sum(m for _r, m in held_rows) / n:.0f} W): A's limit "
f"{sum(r['hi_u'] for r, _m in held_rows) / n:.0f} W, A "
f"{sum(r['share_u'] for r, _m in held_rows) / n:.0f} W, B "
f"{sum(r['share_o'] for r, _m in held_rows) / n:.0f} W; A over the "
f"period's largest floor in {over}; A's limit 500 W or more under it"
f" (or A's rating) in {under}")
check(f"{tag}: the pause holds with a guard floor for over 5 min",
len(held_rows) >= 300)
check("...A never gives more than the largest guard floor of the period "
"(owner 2026-11-13)", over == 0)
if ch is not None:
check("...B is placed part of the floor, so A's limit is its own part,"
" 500 W or more under the period's largest floor (or A's "
"rating) in 9 of 10 cycles (not the whole floor)",
under >= 0.9 * len(held_rows))
check("...every quarter at or under the month aim", quarters_ok(st))
rows, st, c = scen(soc=18.2, other=30.0, house=stepped_house(3000.0, 12000.0))
show("A 18 % under its reserve, B 30 % able, house 12000 W", rows, st)
check("the guard places its claim on the other pack: A under its reserve "
"gives under 0.05 kWh and its SoC stays at 18 %",
sum(r["u"] for r in rows if r["u"] > 0) / 3.6e6 < 0.05
and min(r["soc"] for r in rows) >= 18.0)
# ============================================================================ #
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())
# Per unit: the cap so far lives under each unit's own key, and only one pack
# can be in maintenance CHARGE at all (maintenance.arbitrate(): one slot).
au, ab = (cl.actuator_of("maintenance", i) for i in cl.inverters)
t = T0 + timedelta(seconds=60)
cl._m28_hi = {au: (int(t.timestamp() // 900), 3000.0)}
check("the period's cap is kept per unit: A's 3000 W is not B's",
cl._m28_was(au, t) == 3000.0 and cl._m28_was(ab, t) == 0.0)
check("...and it starts again from 0 W in the next 15-minute period",
cl._m28_was(au, t + timedelta(seconds=900)) == 0.0)
class _M:
def __init__(self, name):
self.name, self.phase = name, maintenance.CHARGE
check("maintenance.arbitrate(): with two packs in CHARGE only the first keeps "
"the slot", maintenance.arbitrate([_M("a"), _M("b")], t)[1] != ""
and maintenance.arbitrate([_M("a"), _M("b")], t)[0] == "")
print()
if fails:
print(f"{len(fails)} FAILED:")
for f in fails:
print(f" - {f}")
sys.exit(1)
print("all checks passed")
+4 -1
View File
@@ -1148,7 +1148,10 @@ check("⚠️ MAIN.PY ACTUALLY PASSES `weights=`, AND IT PASSES THE METHOD RATHE
"could do - and no fixture anywhere could see that, because every check "
"calls `resolve()` for itself. A method, not a dict, because the "
"weighting is direction-dependent and the direction is stage 2's output",
len(_resolve_calls) == 1 and _w_arg == ["self.pack_weights"])
# MAINT-28 added a second call (`_m28_share()`'s split without the
# paused pack's cap); it must pass the same method, as the cycle's does.
len(_resolve_calls) >= 1
and _w_arg == ["self.pack_weights"] * len(_resolve_calls))
# ⚠️ AC 3 SAYS `unmet_w` MUST BE **VISIBLE**, AND `Allocation.explain()` IS ONLY
# HALF OF THAT. The other half is main.py putting it in the action log, which no
+13 -2
View File
@@ -484,7 +484,12 @@ print("\n8. the N=1 and N=2 digests")
# MAINT-28 re-pinned n2_charge_banked: in the 5000 W load (over the 3950 W
# aim) the maintenance charge no longer restarts at -125 W after each guard
# stop (t=1550 s, 64 of 480 rows); the pause is held until the load is gone
# (DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md). The other five are equal.
# (DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md). MAINT-28 fix-forward
# re-pinned it again: while the pause holds A gives at most the largest part
# of the guard's floor placed on it this period, so rows 288-479 (t = 1440 s
# on, 192 of 480) differ: a "peak hold" limit claim on A and A's share. Its
# banked quarters stay under the aim 3950 W (3349 and 3217 W; f3f9483 3349 and
# 2527 W), graded below. The other five are equal.
PINNED = {
"n1_charge":
"00f0a16b3ac1bd2830e9e0aaac8923fd9d1736c2bc6c543669bdd35b2fb542e9",
@@ -497,8 +502,14 @@ PINNED = {
"n2_charge_dear":
"aa2bbc677a8e2274b25ba1170de1b1635e08a3bd93105e2a38a9bb1575b5b6db",
"n2_charge_banked":
"5d523562219245c135a4e9aa856826bc2939a3157c6ae0320cafc2cd1a8a6cf1",
"2da0c3bc9f0ff3857402146efcd6119180a7d4d1cc1297f1c15ec0b8c0104f42",
}
_nb_rows, _nb = s84.run(**s84.SCENARIOS["n2_charge_banked"])
print(f" n2_charge_banked banked quarters {[round(v, 1) for v in banked(_nb)]}"
f" W (aim 3950)")
check("...and in the re-pinned n2_charge_banked every banked quarter is at or "
"under the aim 3950 W",
len(banked(_nb)) >= 2 and all(v <= 3950.0 + 1.0 for v in banked(_nb)))
_now = s84.digests()
for k in PINNED:
print(f" {k} {'same' if _now[k] == PINNED[k] else 'DIFFERS'} "
+12 -2
View File
@@ -116,10 +116,15 @@ class Lag:
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)."""
no lag or ramp). `batt_period_s` (MAINT-28): the battery register is
published every that many seconds and held between (the rig: 10 s), so its
reading is `batt_s` plus up to a period older than the meter's; None keeps
the constant age."""
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):
tick=1.0, sub=0.25, tau_s=3.0, ramp_w_s=800.0,
batt_period_s=None):
self.batt_period_s = batt_period_s
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
@@ -211,6 +216,11 @@ class Hass:
if e.startswith("sensor.batt"):
# 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.
if self.lag is not None and self.lag.batt_period_s:
per = self.lag.batt_period_s
ts = math.floor((self.t - self.lag.batt_s) / per) * per
j = (ord(u) * 31 + int(ts)) % 7 - 3
return float(round(self._past(self.t - ts)[1][u]) + j)
j = (ord(u) * 31 + int(self.t)) % 7 - 3
p = self.plants[u].p if self.lag is None else \
self._past(self.lag.batt_s)[1][u]