Compare commits
16
Commits
SAFETY-135
...
MAINT-28
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc5be4964e | ||
|
|
edf30f345a | ||
|
|
23359a047d | ||
|
|
8640bfcbc1 | ||
|
|
92f0387415 | ||
|
|
4f9aa5223f | ||
|
|
8cd10ee6aa | ||
|
|
1b5f7c9ccb | ||
|
|
33cfc733d3 | ||
|
|
de0790e62d | ||
|
|
b80cfa969f | ||
|
|
6004bfad9a | ||
|
|
b44a648cab | ||
|
|
0f5e513f1d | ||
|
|
54a738dd87 | ||
|
|
02eac3b84e |
@@ -0,0 +1,72 @@
|
||||
# MAINT-24 - a started maintenance charge gets the cheap night
|
||||
|
||||
**Status: for review.** Tests: `test_maint24.py` (`test_opt20.py`'s `ch_ch_maint` digest moved with it). No option added; the MAINT-13
|
||||
timeout numbers are unchanged. Folds in SAFETY-80. SAFETY-111 stays on its card (section 3).
|
||||
|
||||
**What happened (live 0.21.0, A, 2026-11-08..11).** A sat in its maintenance
|
||||
CHARGE phase for more than 75 h and stalled at 49-50 %. At night B's battery-mode
|
||||
grid charge took the cheap run: A is not idle, so `modetrade.chargers()` never
|
||||
lists it, B is the only charger, and the allocator hands the whole negative
|
||||
target to B. A's own shaping then paused or shrank (the check compared the peak
|
||||
forecast with `cap - margin` = 3450 W, while B's trade and the guard aim at this
|
||||
month's peak, about 4.9 kW), and A gave power back on guard stops against a peak
|
||||
that B's own charge made. The timeout (MAINT-13) counted that starved time as
|
||||
charging time and ended the cycle. Report: `reports/MAINT-24-investigation-report.md`.
|
||||
|
||||
## 1. The maintenance charge goes first in the cheap run
|
||||
|
||||
`modetrade.decide(maint_charging=)`, set in `_mode_roles()` when any pack is in
|
||||
its maintenance CHARGE phase. It is the mirror of `draining` (SAFETY-78): no
|
||||
grid-charge trade for the other packs, why `cycle`, the same Tick. The other
|
||||
packs keep their solar roles, so they may still take spare solar. A refusal probe
|
||||
(SAFETY-73, PROBE_W alone, OPT-20) still goes on: it places no charge on the
|
||||
pack in maintenance, and `test_opt20.py` g7 pins it. The drain still blocks it. B's night
|
||||
charge waits for the nights A needs. At N=1 the only pack is the one in
|
||||
maintenance, so nothing changes.
|
||||
|
||||
## 2. The shaping aims where the guard aims (SAFETY-80)
|
||||
|
||||
The pause (`shaping_suspended()`) and the floor (`maintenance_charge_floor()`)
|
||||
get `peakguard.quarter_target_w(cap, margin, month reading)` instead of
|
||||
`peak_cap_w - peak_margin_w`. With no banked peak above the cap that is the same
|
||||
number. SAFETY-63 (no forecast: no pause, configured rate) and SAFETY-125 (late
|
||||
reading: `charge_room_w`) are untouched. `peak_aim_w()` stays for the day plan.
|
||||
|
||||
## 3. SAFETY-111 is not folded in
|
||||
|
||||
`peak_fc` holds the pack's own charge, so the floor `aim - peak_fc` settles at
|
||||
(aim - house) / 2, here 1475 W instead of 2000 W. Taking the own charge off the
|
||||
forecast was tried: it lifts A to 2000 W, but it changes the N=1 charge that
|
||||
`test_safety119.py` pins (a static 800 W forecast then no longer shapes the
|
||||
charge), so it is a change of its own. 1475 W fills a 10 kWh pack inside the
|
||||
night (sim below). It stays on its card.
|
||||
|
||||
## 4. A gives nothing while no real peak exists
|
||||
|
||||
With 1 and 2 no grid charge of B makes a peak against the lower aim, and the pause
|
||||
comes only when the forecast is over the month aim, so A is not made the only
|
||||
battery that gives while B charges. A guard stop on a real peak
|
||||
still lands on A (SAFETY-07 / MAINT-02, unchanged).
|
||||
|
||||
## Numbers (sim, house 3000 W, cap 3500, margin 50, month peak 6000 W)
|
||||
|
||||
| | base 02eac3b | MAINT-24 |
|
||||
|---|---|---|
|
||||
| A charge | 0 W (B charges 2500 W) | 1475 W = (5950 - 3000) / 2 |
|
||||
| B grid trade | yes, quarters 5500 W | none, why `cycle` |
|
||||
| quarter peak | 5500 W | 4475 W (aim 5950 W) |
|
||||
| A at house 3800 W | 0 W | 1075 W, quarters 4876 W |
|
||||
|
||||
Whole night (sim, `tools`-style replay, N=2, cheap 21:00-06:00, A from 10 %, B
|
||||
from 30 %, 9 h): base A takes 1.41 kWh (10 -> 23 %), B takes 6.84 kWh by grid
|
||||
trade; MAINT-24 A takes 9.47 kWh and is full (100 %) inside the night, B takes
|
||||
no grid charge. Worst 15-minute average 5500 W on base, 4475 W now, A never
|
||||
gives.
|
||||
|
||||
## Not changed
|
||||
|
||||
The MAINT-13 timeout, the peak aim itself, the month peak, `P_MAINT_SHAPE` and
|
||||
`P_ARBITRAGE`. The Home card for B does not say "the other battery is cycling"
|
||||
for a CHARGE phase (`other_cycle` reads "emptying"); that is a wording card.
|
||||
|
||||
MAINT-25 (the Home plan and the back-off) has its own note: `MAINT-25-plan-backoff.md`.
|
||||
@@ -0,0 +1,10 @@
|
||||
# MAINT-25 - the Home plan honours the 7-day back-off
|
||||
|
||||
**Status: for review.** Added to the MAINT-24 PR by the owner (2026-11-12).
|
||||
Tests: `test_maint25.py`. After a failed cycle `wants_start()` waits 7 days
|
||||
(MAINT-13, `backoff_until()`), but `homeview.maintenance_runs()` only asked
|
||||
`due()`, so the plan drew a cycle at the start hour every night of the wait.
|
||||
During the back-off it now draws no run for that battery and shows one line
|
||||
instead: "Battery A: the monthly cycle waits until 19 November." (Dutch: "...
|
||||
wacht tot 19 november."). A cycle completed after the failure ends it, as in
|
||||
`backoff_until()`. The other battery is planned as before.
|
||||
@@ -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(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 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, 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).
|
||||
|
||||
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 (`held` always 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`: N=1, banked peak, house 6500 W); M6 the aim gate removed (digest `n2_nobank`).
|
||||
|
||||
## 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
|
||||
@@ -559,8 +561,24 @@ class CycleMixin:
|
||||
# peakguard.shaping_suspended(), NOT HERE. One suspension flag
|
||||
# per SITE (`arbitrate()` allows one pack in CHARGE).
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
if peakguard.shaping_suspended(
|
||||
self.peak_guard, self.peak_fc, self.peak_aim_w()):
|
||||
# MAINT-24: the guard's aim, month peak included (SAFETY-80).
|
||||
# DECISIONS/MAINT-24-charge-first.md
|
||||
maint_aim = peakguard.quarter_target_w(
|
||||
float(self.o.get("peak_cap_w", 3500)),
|
||||
self.peak_margin_w, _reading)
|
||||
# MAINT-28: stays paused until the house is clear of the aim.
|
||||
# Only where MAINT-24 moved the aim (a banked peak above the
|
||||
# cap) and at N=2 and up: elsewhere the old rule, byte for byte
|
||||
# (SAFETY-87 / SAFETY-119 pins).
|
||||
house = (None if self.grid is None or self.site_batt_w is None
|
||||
else float(self.grid) + float(self.site_batt_w))
|
||||
_susp = peakguard.shaping_suspended(
|
||||
self.peak_guard, self.peak_fc, maint_aim)
|
||||
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)
|
||||
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.
|
||||
@@ -582,7 +600,7 @@ class CycleMixin:
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
floor = maintenance_charge_floor(
|
||||
self.maint.cfg.charge_w,
|
||||
self.peak_fc, self.peak_aim_w())
|
||||
self.peak_fc, maint_aim)
|
||||
floor = self._charge_headroom(floor, claims)
|
||||
if floor > 0:
|
||||
hi, reason = -floor, "cheap-window charge"
|
||||
|
||||
@@ -422,7 +422,9 @@ class TradeMixin:
|
||||
blocked=self._charge_blocked_q == quarter,
|
||||
done_charge=done_c, done_sell=done_s, targets=targets,
|
||||
draining=any(r.phase == DRAIN for r in maint_results or ()),
|
||||
wait=wait)
|
||||
wait=wait,
|
||||
maint_charging=any(r.phase == CHARGE
|
||||
for r in maint_results or ()))
|
||||
self._latch_charge(now, runs, targets, packs)
|
||||
# A charge the guard or the headroom stopped stays off this period.
|
||||
# OPT-15: only a MEASURED stop latches; the first unmeasured headroom
|
||||
|
||||
@@ -630,6 +630,11 @@ def maintenance_runs(c, day0, day1, now, shadow):
|
||||
run_cycle_why(shadow),
|
||||
run_cycle_amount(hours)))
|
||||
continue
|
||||
until = m.backoff_until(now)
|
||||
if until is not None:
|
||||
# MAINT-25: a failed cycle waits (MAINT-13); no run is planned.
|
||||
lines.append(line_cycle_backoff(inv.name, until))
|
||||
continue
|
||||
if not m.due(now) and (m.next_due(now) is None
|
||||
or m.next_due(now) >= day1):
|
||||
continue
|
||||
@@ -1529,6 +1534,13 @@ def line_cycle_waits(name) -> str:
|
||||
return _t("cycle_waits", name=name)
|
||||
|
||||
|
||||
def line_cycle_backoff(name, until) -> str:
|
||||
"""MAINT-25: the failed cycle waits until `until` (MAINT-13)."""
|
||||
local = brussels(until)
|
||||
return _t("cycle_backoff", name=name, day=local.day,
|
||||
month=month_name(local))
|
||||
|
||||
|
||||
def line_no_prices(since, now) -> str:
|
||||
"""PRICE-01: no price covers now. `since` is an instant or None."""
|
||||
if since is None:
|
||||
@@ -1669,6 +1681,8 @@ def _samples() -> list:
|
||||
("plan", run_house_why()), ("plan", run_house_why(True)),
|
||||
("plan", run_house_amount()),
|
||||
("plan", line_cycle_waits("Battery B")),
|
||||
("plan", line_cycle_backoff("Battery B", datetime(
|
||||
2026, 11, 19, 12, 0, tzinfo=timezone.utc))),
|
||||
("plan", line_no_prices(None, datetime(2026, 10, 14, 9, 0,
|
||||
tzinfo=timezone.utc))),
|
||||
("plan", line_no_prices(datetime(2026, 10, 14, 0, 0,
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -56,7 +56,9 @@ Rules that keep this safe (tests: test_modes_p3.py, test_price_run_fixes.py):
|
||||
the probe (none: PROBE_W alone), so a probe never places charge on a
|
||||
battery in a maintenance phase or on one that rests;
|
||||
* no grid charge while another battery drains for its monthly cycle
|
||||
(`draining`, SAFETY-78): it would charge against the drain.
|
||||
(`draining`, SAFETY-78): it would charge against the drain; nor while
|
||||
one is in its maintenance CHARGE (`maint_charging`, MAINT-24): the cheap
|
||||
night's room is that pack's.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
@@ -180,7 +182,8 @@ def energy(packs, floor_pct, ceiling_pct, done_charge=(), done_sell=(),
|
||||
def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
|
||||
headroom_w, floor_pct, ceiling_pct, house_w, charging_now=False,
|
||||
blocked=False, done_charge=(), done_sell=(),
|
||||
targets=None, draining=False, wait=None) -> Tick:
|
||||
targets=None, draining=False, wait=None,
|
||||
maint_charging=False) -> Tick:
|
||||
"""This tick's ranges, roles and site ask.
|
||||
|
||||
`runs` is the kept `priceruns.RunPlan` or None (no fresh prices).
|
||||
@@ -194,7 +197,10 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
|
||||
this 15-minute period. `targets` is the day plan (`charge_to()`).
|
||||
`draining` says a battery drains for its monthly cycle, so none may
|
||||
charge from the grid (why "cycle"). `wait` ("wait" or "settle", the
|
||||
why) says no charge may start yet (SAFETY-135).
|
||||
why) says no charge may start yet (SAFETY-135). `maint_charging` says a
|
||||
battery is in its maintenance CHARGE phase: no grid charge for the others
|
||||
either, same why, but a refusal probe goes on (MAINT-24,
|
||||
DECISIONS/MAINT-24-charge-first.md).
|
||||
"""
|
||||
plan_on = runs is not None and runs.on
|
||||
in_cheap = plan_on and runs.cheap.holds(now) and runs.charge_useful()
|
||||
@@ -206,6 +212,10 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
|
||||
if in_cheap else []
|
||||
if ch and draining:
|
||||
ch, why = [], "cycle"
|
||||
elif ch and maint_charging:
|
||||
# MAINT-24: a refusal probe (PROBE_W alone, OPT-20) may go on.
|
||||
ch = [p for p in ch if p.refused < 0]
|
||||
why = None if ch else "cycle"
|
||||
charging = False
|
||||
if ch:
|
||||
need = 0.0 if charging_now else START_W
|
||||
|
||||
@@ -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(suspended, held, peak_forecast_w, aim_w, house_w) -> 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.
|
||||
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
|
||||
|
||||
|
||||
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.
|
||||
|
||||
@@ -430,6 +430,7 @@
|
||||
"give": "Tomorrow: gives power at {at}, {eur} per kWh.",
|
||||
"give_would": "Tomorrow: would give power at {at}, {eur} per kWh."
|
||||
},
|
||||
"cycle_backoff": "{name}: the monthly cycle waits until {day} {month}.",
|
||||
"cycle_waits": "{name} waits its turn for the monthly cycle.",
|
||||
"no_prices_yet": "No prices yet, maintenance waits.",
|
||||
"no_prices_since": "No prices since {at}, maintenance waits.",
|
||||
|
||||
@@ -365,6 +365,7 @@
|
||||
"no_level": "Nu geen handel op prijs: een laadniveau is onbekend."
|
||||
},
|
||||
"tomorrow_trade": {"grid": "Morgen: laadt om {at}, {eur} per kWh.", "grid_would": "Morgen: zou laden om {at}, {eur} per kWh.", "give": "Morgen: levert stroom om {at}, {eur} per kWh.", "give_would": "Morgen: zou stroom leveren om {at}, {eur} per kWh."},
|
||||
"cycle_backoff": "{name}: de maandelijkse cyclus wacht tot {day} {month}.",
|
||||
"cycle_waits": "{name} wacht zijn beurt af voor de maandelijkse cyclus.",
|
||||
"no_prices_yet": "Nog geen prijzen, onderhoud wacht.",
|
||||
"no_prices_since": "Geen prijzen sinds {at}, onderhoud wacht.",
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Runnable check for MAINT-24. `python3 test_maint24.py`
|
||||
|
||||
A started maintenance charge at N=2 must get the cheap night. On the live site
|
||||
(2026-11-08..11) battery B's grid charge (battery modes) took the night, and
|
||||
A's own charge was switched off or sized at 200-450 W: its pause and its floor
|
||||
compared the peak forecast with `cap - margin`, not with this month's peak
|
||||
that the guard and B's trade aim at (SAFETY-80), and its floor counted its own
|
||||
charge in the forecast (SAFETY-111).
|
||||
|
||||
All on the real Controller through `cycle()` (the lagged N=2 replay, tools/
|
||||
opt20_scen.py): cheap window, house 3000 W, cap 3500 / margin 50, a month peak
|
||||
of 6000 W banked (aim 5950 W), a peak forecast that follows the grid.
|
||||
|
||||
1. A in maintenance CHARGE, B a Cheap hours charger: A charges at the room under
|
||||
the month aim (half of it: the forecast holds A's own charge, SAFETY-111
|
||||
stays on its card), B files no grid trade, B's why is "cycle", every quarter is at or
|
||||
under the month aim.
|
||||
2. Forecast above 3450 W but under the month aim: shaping is not paused and A
|
||||
gives nothing. A real peak (house over the month aim) still pauses it.
|
||||
3. The mirror: B in maintenance CHARGE, A the charger.
|
||||
4. modetrade.decide(): `maint_charging` files no charge, why "cycle"; solar
|
||||
roles stay.
|
||||
5. Unchanged: N=1 with no forecast entity, the SAFETY-78 drain, battery modes
|
||||
off (digests printed on release/1.0 02eac3b).
|
||||
Mutations: DECISIONS/MAINT-24-charge-first.md.
|
||||
Read MUTATION.md before you report a survivor.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
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 modetrade, packmode, tariff # noqa: E402
|
||||
from app.maintenance import DRAIN, MaintResult # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
A, B = sc.A, sc.B
|
||||
MONTH_PEAK = 6000.0
|
||||
AIM = MONTH_PEAK - 50.0 # the guard's aim this month
|
||||
DRAIN_R = MaintResult(DRAIN, True, 2000.0, False, [])
|
||||
|
||||
|
||||
def scen(*, a=None, b=None, soc=(49.0, 30.0), house_w=3000.0, peak=MONTH_PEAK,
|
||||
fc=True, n=2, variant="p3_ch_sell", choice=None, hours=0.75):
|
||||
"""`a` / `b`: that pack's forced maintenance result (or None). `peak`: the
|
||||
banked month peak or None. `fc`: a peak forecast entity that follows the
|
||||
grid. Returns (rows, stats, controller)."""
|
||||
rows = []
|
||||
extra = {"cycle_cost_threshold_eur": 0.10}
|
||||
|
||||
def patch(c):
|
||||
for i, res in enumerate((a, b)):
|
||||
if res is not None and i < len(c.maints):
|
||||
c.maints[i].tick = (
|
||||
lambda now, soc_, blocked_by=None, gate_open=True,
|
||||
res=res: res)
|
||||
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._m24_susp = susp
|
||||
|
||||
async def cyc():
|
||||
c.cheap = True
|
||||
if fc and c.grid is not None:
|
||||
c.peak_fc = float(c.grid)
|
||||
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()}))
|
||||
|
||||
stats, c = rp.run(variant, [(sc.UTC0.timestamp() - 10, house_w)], sc.WPTS,
|
||||
sc.at(2.0), 3600 * hours, check=cb, lag=sc.LAG, n=n,
|
||||
soc0={"a": soc[0], "b": soc[1]},
|
||||
choice=choice or sc.CH_CH, refuse=(), patch=patch,
|
||||
extra=extra)
|
||||
return rows, stats, c
|
||||
|
||||
|
||||
def mean(rows, u):
|
||||
r = rows[len(rows) // 6:]
|
||||
return sum(x["p"][u] for x in r) / len(r)
|
||||
|
||||
|
||||
def worst_quarter(stats):
|
||||
return max(a[0] / a[1] for a in stats["quarters"].values())
|
||||
|
||||
|
||||
CH = sc.MAINT_CHARGE
|
||||
|
||||
# ============================================================================ #
|
||||
print("1. A in maintenance CHARGE, B a charger in the cheap window")
|
||||
rows, st, c = scen(a=CH)
|
||||
ma, mb = mean(rows, "a"), mean(rows, "b")
|
||||
last = rows[-1] if rows else {}
|
||||
print(f" A {ma:+.0f} W, B {mb:+.0f} W, worst quarter {worst_quarter(st):.0f}"
|
||||
f" W, why {last.get('why')}, roles {last.get('roles')}")
|
||||
check("A charges at (5950 - 3000) / 2 = 1475 W, not 0 or a few hundred W",
|
||||
-1500.0 <= ma <= -1440.0)
|
||||
check("B files no grid trade: no trade action in the run, B near 0 W",
|
||||
all("CHARGE" not in str(r["action"]) for r in rows) and mb > -50.0)
|
||||
check("roles: B does not charge, and the why is 'cycle'",
|
||||
all(r["roles"].get(B) != "charge" for r in rows)
|
||||
and last.get("why") == "cycle")
|
||||
check("every quarter at or under the month aim",
|
||||
worst_quarter(st) <= AIM + 1.0)
|
||||
check("A never gives back while there is no real peak",
|
||||
max(r["p"]["a"] for r in rows) <= 0.0)
|
||||
check("the shaping was never paused", not any(c._m24_susp))
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n2. forecast between 3450 W and the month aim; a real peak")
|
||||
rows2, st2, c2 = scen(a=CH, house_w=3800.0)
|
||||
ma2 = mean(rows2, "a")
|
||||
print(f" house 3800: A {ma2:+.0f} W, worst quarter "
|
||||
f"{worst_quarter(st2):.0f} W")
|
||||
check("forecast 4900 W (over 3450, under 5950): no pause, A charges "
|
||||
"(5950 - 3800) / 2 = 1075 W and gives nothing",
|
||||
not any(c2._m24_susp) and -1100.0 <= ma2 <= -1040.0
|
||||
and max(r["p"]["a"] for r in rows2) <= 0.0
|
||||
and worst_quarter(st2) <= AIM + 1.0)
|
||||
rows3, st3, c3 = scen(a=CH, house_w=6500.0)
|
||||
print(f" house 6500: paused {sum(c3._m24_susp)}/{len(c3._m24_susp)} "
|
||||
f"cycles, A max {max(r['p']['a'] for r in rows3):+.0f} W")
|
||||
check("a real peak (house over the month aim) still pauses the shaping "
|
||||
"(SAFETY-07)", sum(c3._m24_susp) > len(c3._m24_susp) // 2)
|
||||
rows4, st4, c4 = scen(a=CH, house_w=3000.0, peak=None)
|
||||
print(f" no banked peak: A {mean(rows4, 'a'):+.0f} W, worst quarter "
|
||||
f"{worst_quarter(st4):.0f} W")
|
||||
check("no banked peak: the aim is cap - margin (3450 W) as before, so A "
|
||||
"charges (3450 - 3000) / 2 = 225 W, B does not trade, the quarter "
|
||||
"stays under 3450 W",
|
||||
-240.0 <= mean(rows4, "a") <= -210.0 and not any(c4._m24_susp)
|
||||
and all("CHARGE" not in str(r["action"]) for r in rows4)
|
||||
and worst_quarter(st4) <= 3450.0 + 1.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n3. the mirror: B in maintenance CHARGE, A the charger")
|
||||
rowsm, stm, cm = scen(b=CH, soc=(30.0, 49.0))
|
||||
mam, mbm = mean(rowsm, "a"), mean(rowsm, "b")
|
||||
lastm = rowsm[-1] if rowsm else {}
|
||||
print(f" A {mam:+.0f} W, B {mbm:+.0f} W, worst quarter "
|
||||
f"{worst_quarter(stm):.0f} W, why {lastm.get('why')}")
|
||||
check("B charges at 1475 W, A files no grid trade, why 'cycle'",
|
||||
-1500.0 <= mbm <= -1440.0 and mam > -50.0
|
||||
and all("CHARGE" not in str(r["action"]) for r in rowsm)
|
||||
and lastm.get("why") == "cycle"
|
||||
and all(r["roles"].get(A) != "charge" for r in rowsm))
|
||||
check("...every quarter at or under the month aim",
|
||||
worst_quarter(stm) <= AIM + 1.0)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n4. modetrade.decide(maint_charging=)")
|
||||
_runs = SimpleNamespace(on=True, cheap=SimpleNamespace(holds=lambda n: True),
|
||||
charge_useful=lambda: True, give_at=lambda n: None,
|
||||
sell=None)
|
||||
|
||||
|
||||
def _dec(packs, **kw):
|
||||
args = dict(charge_ok=True, sell_ok=True, trade_why=None, price_now=0.1,
|
||||
headroom_w=3000.0, floor_pct=20.0, ceiling_pct=95.0,
|
||||
house_w=800.0)
|
||||
args.update(kw)
|
||||
return modetrade.decide(_runs, sc.UTC0, packs, **args)
|
||||
|
||||
|
||||
_pa = modetrade.Pack(A, True, packmode.CHEAP_HOURS, False, 2500.0, 50.0, 10.0,
|
||||
idle=False)
|
||||
_pb = modetrade.Pack(B, True, packmode.CHEAP_HOURS, False, 2500.0, 30.0, 10.0)
|
||||
_off = _dec([_pa, _pb])
|
||||
_on = _dec([_pa, _pb], maint_charging=True)
|
||||
_dr = _dec([_pa, _pb], draining=True)
|
||||
check("without it B is the charger (as before)",
|
||||
_off.action == modetrade.CHARGE and _off.roles[B] == modetrade.CHARGE)
|
||||
check("with it: no charge, why 'cycle', B keeps a hold role, no ask",
|
||||
_on.action is None and _on.why == "cycle" and _on.ask_w == 0.0
|
||||
and _on.roles[B] == modetrade.HOLD)
|
||||
check("same answer as the SAFETY-78 drain", _on == _dr)
|
||||
_pr = modetrade.Pack(B, True, packmode.CHEAP_HOURS, False, 2500.0, 30.0, 10.0,
|
||||
refused=-1, probing=True)
|
||||
_tp = _dec([_pa, _pr], maint_charging=True)
|
||||
check("a refusal probe beside the maintenance charge goes on: PROBE_W alone "
|
||||
"(OPT-20); beside a drain it does not",
|
||||
_tp.action == modetrade.CHARGE and _tp.ask_w == -modetrade.PROBE_W
|
||||
and _dec([_pa, _pr], draining=True).action is None)
|
||||
check("a probe does not carry a normal charger along",
|
||||
_dec([_pa, _pr, modetrade.Pack("inverter-c", True, packmode.CHEAP_HOURS,
|
||||
False, 2500.0, 30.0, 10.0)],
|
||||
maint_charging=True).ask_w == -modetrade.PROBE_W)
|
||||
check("it files nothing outside a cheap run (why None)",
|
||||
_dec([_pa, _pb], maint_charging=True,
|
||||
).why == "cycle" and modetrade.decide(
|
||||
SimpleNamespace(on=False), sc.UTC0, [_pa, _pb], charge_ok=True,
|
||||
sell_ok=True, trade_why=None, price_now=0.1, headroom_w=3000.0,
|
||||
floor_pct=20.0, ceiling_pct=95.0, house_w=800.0,
|
||||
maint_charging=True).why is None)
|
||||
|
||||
# ============================================================================ #
|
||||
print("\n5. unchanged: N=1 (no forecast entity), the drain, modes off")
|
||||
d1 = sc.digest(scen(a=CH, n=1, fc=False, peak=None, soc=(49.0, 30.0))[0])
|
||||
dd = sc.digest(scen(a=DRAIN_R, soc=(60.0, 30.0), peak=None, fc=False)[0])
|
||||
dm = sc.digest(scen(a=CH, variant="modes_off", peak=None, fc=False)[0])
|
||||
AT_BASE = {
|
||||
"n1": "44816d132984ecd64d5a2510223debe102f29decd1d5127ae2b255b45dbf8bd4",
|
||||
"drain": "8fd86d0816626ee5ae86bc973cb0121b1b5108e56c3b0fdd4b4be6c1f403f748",
|
||||
"off": "a007f962793611bce15b11f576a76aa52063adcfde82b2a2dc0b0c075b60e60b",
|
||||
}
|
||||
got = {"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: shares, "
|
||||
"grid, trade, roles and plant powers equal release/1.0 02eac3b",
|
||||
got["n1"] == AT_BASE["n1"])
|
||||
check("the SAFETY-78 drain beside a charger: equal to 02eac3b",
|
||||
got["drain"] == AT_BASE["drain"])
|
||||
check("battery modes off: equal to 02eac3b", 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")
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Runnable check for MAINT-25. `python3 test_maint25.py`
|
||||
|
||||
After a failed monthly cycle the add-on waits 7 days (MAINT-13, `backoff_until`).
|
||||
The Home plan ignored that and drew a cycle at the start hour every night of
|
||||
the wait. Through the plan builder (`status()` as in test_home.py), N=1 and N=2:
|
||||
during the back-off no cycle run is drawn and a short line says when it waits
|
||||
until, in English and Dutch; after 7 days the run is drawn again; a cycle
|
||||
completed after the failure ends the back-off.
|
||||
Read MUTATION.md before you report a survivor.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
|
||||
from app import homeview # noqa: E402
|
||||
from app.main import Controller # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
|
||||
def check(name, cond):
|
||||
print(f" {'ok ' if cond else 'FAIL'} {name}")
|
||||
if not cond:
|
||||
fails.append(name)
|
||||
|
||||
|
||||
class _Store:
|
||||
def __init__(self):
|
||||
self.data = {}
|
||||
|
||||
def set(self, k, v):
|
||||
self.data[k] = v
|
||||
|
||||
def get(self, k, d=None):
|
||||
return self.data.get(k, d)
|
||||
|
||||
def set_time(self, k, v):
|
||||
self.data[k] = v.isoformat()
|
||||
|
||||
def get_time(self, k):
|
||||
v = self.data.get(k)
|
||||
return None if v is None else datetime.fromisoformat(v)
|
||||
|
||||
|
||||
class _Pub:
|
||||
enabled = False
|
||||
|
||||
def publish(self, _v):
|
||||
pass
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class _Hass:
|
||||
session = None
|
||||
token = ""
|
||||
|
||||
async def limits(self, _e):
|
||||
return (-5000.0, 5000.0, 1.0)
|
||||
|
||||
async def number(self, _e, invert=False):
|
||||
return None
|
||||
|
||||
async def state(self, _e):
|
||||
return None
|
||||
|
||||
async def set_number(self, _e, _v):
|
||||
return True
|
||||
|
||||
async def call(self, *_a):
|
||||
return True
|
||||
|
||||
async def states(self):
|
||||
return []
|
||||
|
||||
|
||||
NOW = datetime(2026, 9, 27, 9, 40, tzinfo=timezone.utc)
|
||||
ONE = {"setpoint_entity": "number.sp", "meter_entity": "sensor.grid",
|
||||
"soc_entity": "sensor.soc", "batt_entity": "sensor.batt",
|
||||
"meter_source": "off", "max_w": 5000, "shadow_mode": "off",
|
||||
"maintenance_enabled": True, "maintenance_start_hour": 12}
|
||||
TWO = dict(ONE, inverters=[
|
||||
{"name": "Battery A", "setpoint_entity": "number.a", "soc_entity":
|
||||
"sensor.soc_a", "batt_entity": "sensor.batt_a", "max_w": 2500,
|
||||
"capacity_kwh": 9.6},
|
||||
{"name": "Battery B", "setpoint_entity": "number.b", "soc_entity":
|
||||
"sensor.soc_b", "batt_entity": "sensor.batt_b", "max_w": 2500,
|
||||
"capacity_kwh": 5.4}])
|
||||
|
||||
|
||||
def make(opts, failed=None, done=None):
|
||||
c = Controller(dict(opts), _Hass(), _Store(), _Pub(), stop=asyncio.Event())
|
||||
c.auto = True
|
||||
for inv in c.inverters:
|
||||
inv.soc, inv.batt, inv.last_write = 50.0, 0.0, 0.0
|
||||
inv.dev_min, inv.dev_max = -5000.0, 5000.0
|
||||
c.grid = 10.0
|
||||
m = c.maints[0]
|
||||
if failed is not None:
|
||||
m.store.set_time("last_failed", failed)
|
||||
if done is not None:
|
||||
m.store.set_time("last_completed", done)
|
||||
return c
|
||||
|
||||
|
||||
def plan(c, lang=None):
|
||||
s = c.status_payload()
|
||||
s.update(homeview.build(c, level=s["level"], checks=s["checks"], now=NOW,
|
||||
**({} if lang is None else {"lang": lang})))
|
||||
p = s["plan"]
|
||||
return [r for r in p["runs"] if r["kind"] == "cycle"], p["lines"]
|
||||
|
||||
|
||||
D = timedelta(days=1)
|
||||
print("1. a cycle failed 1 day ago (N=2: Battery A failed, B never ran)")
|
||||
runs, lines = plan(make(TWO, failed=NOW - D))
|
||||
print(" ", [r["from"] for r in runs], lines)
|
||||
check("the run tonight is B's, not A's (A waits out the back-off)",
|
||||
len(runs) == 1 and not any("Battery A waits its turn" in x for x in lines))
|
||||
check("a plain line says Battery A waits until 3 October",
|
||||
any("Battery A: the monthly cycle waits until 3 October." == x
|
||||
for x in lines))
|
||||
|
||||
print("\n2. N=1")
|
||||
runs1, lines1 = plan(make(ONE, failed=NOW - D))
|
||||
check("no cycle run is drawn, one line says when it waits until",
|
||||
runs1 == [] and any("waits until 3 October" in x for x in lines1))
|
||||
runs1b, lines1b = plan(make(ONE))
|
||||
check("without a failure the run is drawn (non-vacuous)",
|
||||
len(runs1b) == 1 and not any("waits until" in x for x in lines1b))
|
||||
|
||||
print("\n3. after the 7 days, and after a cycle that completed")
|
||||
runs2, lines2 = plan(make(ONE, failed=NOW - timedelta(days=7, hours=1)))
|
||||
check("7 days and an hour after the failure the run is drawn again, no line",
|
||||
len(runs2) == 1 and not any("waits until" in x for x in lines2))
|
||||
runs3, lines3 = plan(make(ONE, failed=NOW - D, done=NOW - D / 2))
|
||||
check("a cycle completed after the failure ends the back-off (the run is "
|
||||
"not drawn only if it is no longer due)",
|
||||
not any("waits until" in x for x in lines3))
|
||||
runs3b, lines3b = plan(make(ONE, failed=NOW - 2 * D, done=NOW - 3 * D))
|
||||
check("a completion BEFORE the failure does not end it",
|
||||
runs3b == [] and any("waits until" in x for x in lines3b))
|
||||
|
||||
print("\n4. Dutch")
|
||||
_, lnl = plan(make(ONE, failed=NOW - D), lang="nl")
|
||||
print(" ", lnl)
|
||||
check("the Dutch line says it plainly",
|
||||
any("de maandelijkse cyclus wacht tot 3 oktober" in x for x in lnl))
|
||||
|
||||
print()
|
||||
if fails:
|
||||
print(f"{len(fails)} FAILED:")
|
||||
for f in fails:
|
||||
print(f" - {f}")
|
||||
sys.exit(1)
|
||||
print("all checks passed")
|
||||
@@ -0,0 +1,285 @@
|
||||
"""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.
|
||||
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 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()")
|
||||
|
||||
|
||||
def held(was, decision, fc, aim, house):
|
||||
return peakguard.shaping_held(
|
||||
peakguard.shaping_suspended(decision, fc, aim), was, fc, aim, house)
|
||||
|
||||
|
||||
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])
|
||||
d2n = sc.digest(scen(a=CH, house_w=5000.0, peak=None, fc="grid")[0])
|
||||
d1p = sc.digest(scen(a=CH, n=1, fc=None, house_w=6500.0)[0])
|
||||
AT_BASE = {
|
||||
"n2_nobank": "c164c9d4f221f7d685a0aa904a7878d4b287cf295d2e22281132140ba4a047db",
|
||||
"n1_peak": "ba8eddb78265ecc0db1d3bc0e06f48478e69047f7eb124dc523ae19ef930b631",
|
||||
"n1": "44816d132984ecd64d5a2510223debe102f29decd1d5127ae2b255b45dbf8bd4",
|
||||
"drain": "8fd86d0816626ee5ae86bc973cb0121b1b5108e56c3b0fdd4b4be6c1f403f748",
|
||||
"off": "a007f962793611bce15b11f576a76aa52063adcfde82b2a2dc0b0c075b60e60b",
|
||||
}
|
||||
got = {"n2_nobank": d2n, "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 above a banked month peak (house 6500 W, the guard steps in): equal "
|
||||
"to 1b5f7c9, the hold is for N=2 and up", got["n1_peak"] == AT_BASE["n1_peak"])
|
||||
check("N=2 in a real peak with no banked peak (house 5000 W over the aim "
|
||||
"3450 W): equal to 1b5f7c9, the hold is for a month aim above the cap",
|
||||
got["n2_nobank"] == AT_BASE["n2_nobank"])
|
||||
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")
|
||||
@@ -248,11 +248,14 @@ print("\n4. battery modes on, no probe: byte-identical to release/1.0 bf9019e")
|
||||
# now counts those seconds, so ch_ch's quarters land on 3450.0 W where they
|
||||
# landed 1.3-1.4 W over it (bf9019e: 15fc25dd...), and ch_ch_maint's first,
|
||||
# partial quarter charges a little less at its end (bf9019e: e46e3c66...).
|
||||
# MAINT-24 moved ch_ch_maint again: B no longer grid-charges beside A's
|
||||
# maintenance charge, and A's charge is no longer paused (release/1.0 02eac3b:
|
||||
# e56eddab...).
|
||||
AT_BASE = {
|
||||
"sf_ch": "422d6578a7decf1ae3749edd1898ff3cd0541d904c95f636cdd9b1de8c1a07c9",
|
||||
"ch_ch": "2b5879ec95810c39a5f0f82c04b9f8e358ea03ddce4ba187cc2b7e0885dfcbfa",
|
||||
"ch_ch_maint":
|
||||
"e56eddabdb785f81793e7afa525f8e6da237b0b2165860aa8e0f8a6499b275a9",
|
||||
"df86a451a3b2d365ca5d35b772cd1eb8502d164e6b63e6844b5cfb82f15aeb46",
|
||||
}
|
||||
_got = sc.no_probe_digests()
|
||||
for _k in AT_BASE:
|
||||
@@ -260,8 +263,8 @@ for _k in AT_BASE:
|
||||
f"{_got[_k][:16]}")
|
||||
check("with no refusal every share, grid, trade sentence, role and plant "
|
||||
"power of a scripted cheap run equals bf9019e's (A Solar first, A "
|
||||
"charging, A in maintenance charge; the last two as SAFETY-125 moved "
|
||||
"them)", _got == AT_BASE)
|
||||
"charging, A in maintenance charge; the last two as SAFETY-125 and "
|
||||
"MAINT-24 moved them)", _got == AT_BASE)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
|
||||
@@ -245,14 +245,16 @@ print(f" decide(cap_w={kw(_d, 'cap_w')}, margin_w={kw(_d, 'margin_w')})")
|
||||
print(f" shaping_suspended(..., {arg(_s, 2)})")
|
||||
print(f" maintenance_charge_floor(..., {arg(_m, 2)})")
|
||||
print(f" PeakTracker(cap_w={kw(_t, 'cap_w')})")
|
||||
# MAINT-24: the maintenance forecast checks get the guard's own aim, cap -
|
||||
# margin with this month's peak in (`maint_aim`), not the plain cap - margin.
|
||||
check("test_call_sites: the guard gets the real cap AND the margin; the "
|
||||
"forecast checks get cap - margin; the month-peak tracker gets the "
|
||||
"real cap",
|
||||
"forecast checks get the guard's aim, cap - margin and the month "
|
||||
"peak (MAINT-24); the month-peak tracker gets the real cap",
|
||||
len(_dec) == 1 and "peak_cap_w" in kw(_d, "cap_w")
|
||||
and kw(_d, "margin_w") == "self.peak_margin_w"
|
||||
and len(_shape) == 1 and len(_maint) == 1
|
||||
and arg(_s, 2) == "self.peak_aim_w()"
|
||||
and arg(_m, 2) == "self.peak_aim_w()"
|
||||
and arg(_s, 2) == "maint_aim" and arg(_m, 2) == "maint_aim"
|
||||
and "maint_aim = peakguard.quarter_target_w(" in _src
|
||||
and len(_tracker) == 1
|
||||
and kw(_t, "cap_w") == 'float(opts.get("peak_cap_w", 3500))')
|
||||
check("test_aim_w: `peak_aim_w()` is cap - margin, and the forecast sizing "
|
||||
|
||||
Reference in New Issue
Block a user