Compare commits

...
11 Commits
Author SHA1 Message Date
glenn schrooyenandClaude 33cfc733d3 MAINT-25: clean test_maint25.py, split its DECISIONS note
checks / unit (pull_request) Successful in 26m39s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-12 11:08:30 +01:00
glenn schrooyenandClaude de0790e62d MAINT-25: the Home plan shows no cycle during the 7-day back-off
checks / unit (pull_request) Successful in 27m21s
infra-pin / fresh (pull_request) Successful in 1s
homeview.maintenance_runs() ignored backoff_until(); it drew a cycle every
night of the wait. Now no run and a plain line (EN, NL). test_maint25.py.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-12 09:57:56 +01:00
glenn schrooyenandClaude b80cfa969f MAINT-24: test_peak_margin pins the maintenance checks to the guard's aim
checks / unit (pull_request) Successful in 29m32s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-12 08:22:13 +01:00
glenn schrooyenandClaude 6004bfad9a MAINT-24: read the last row without a bare index (test_named_absence)
checks / unit (pull_request) Successful in 26m37s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-12 08:15:01 +01:00
glenn schrooyenandClaude b44a648cab MAINT-24: test_maint24.py points at MUTATION.md
checks / unit (pull_request) Successful in 28m32s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-12 07:51:28 +01:00
glenn schrooyenandClaude 0f5e513f1d MAINT-24: keep the refusal probe beside a maintenance charge
checks / unit (pull_request) Failing after 45m44s
infra-pin / fresh (pull_request) Successful in 2s
The mode trade still lets a SAFETY-73 probe (PROBE_W alone) go on while a pack
is in its maintenance CHARGE phase; test_opt20.py g7 pins it. ch_ch_maint
digest moved (B no longer grid-charges beside A's charge).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-12 07:33:04 +01:00
glenn schrooyenandClaude 54a738dd87 MAINT-24: a started maintenance charge gets the cheap night
checks / unit (pull_request) Failing after 41m45s
infra-pin / fresh (pull_request) Successful in 2s
While a pack is in its maintenance CHARGE phase the mode trade files no grid
charge for the other packs (why "cycle", mirror of SAFETY-78). The maintenance
shaping check and charge floor aim at peakguard.quarter_target_w, month peak
included (SAFETY-80). Tests through cycle(): test_maint24.py.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-12 07:07:44 +01:00
admin 02eac3b84e Merge pull request #245: SAFETY-135 one guard stop does not cost the night charge the whole period
infra-pin / fresh (push) Successful in 2s
2026-11-11 20:55:30 +01:00
glenn schrooyenandClaude Opus 5.5 4066624cce SAFETY-135 round 2: the first guard stop in a period may restart once; tests and docs
checks / unit (pull_request) Successful in 29m42s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC
2026-11-11 19:49:18 +01:00
glenn schrooyenandClaude 66d8777b39 SAFETY-135: a young 15-minute period does not cost the night charge the whole period
checks / unit (pull_request) Successful in 41m54s
infra-pin / fresh (pull_request) Successful in 2s
With battery modes on, a grid charge the peak guard (or a measured headroom)
stops in the first 60 s of a period no longer latches off for the period: it
may start again once the period is 60 s old, if the guard is clear and the
headroom is over START_W. A stop after 60 s latches as before. In a young
period no charge starts while the batteries' give is still falling (over
START_W within 15 s), since the meter may not show that drop yet. The guard
itself is unchanged, so a real over-aim load is cut on the same cycle.

DECISIONS/SAFETY-135-young-period.md, test_safety135.py.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-10 17:43:28 +01:00
admin a8ecf171d6 Merge pull request #244: SAFETY-136 a gentler loop on a slow meter until the last move shows
infra-pin / fresh (push) Successful in 2s
2026-11-09 22:35:21 +01:00
18 changed files with 1141 additions and 25 deletions
@@ -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,116 @@
# SAFETY-135 - one guard stop does not cost the night charge the whole period
**Status: for review, round 2.** Code: `Controller._mode_roles()` and
`Controller._give_falling()` (`app/ctl_trade.py`), `modetrade.decide(wait=)`,
`RESTART_S`, `YOUNG_S`, `GAVE_S`, `WAIT_WORDS` (`app/modetrade.py`). Tests:
`test_safety135.py`. Battery modes only. No option, no change to the peak
guard.
**Problem.** A grid charge the peak guard stops stays off to the period's end
(the OPT-11 latch). Three cases seen on TEST-23:
- *A spike at a period's start* (UX-77 I-1, x10): a 1-3 s spike, or a charge
still at full power from the end of the last period, makes the guard project
4.3-6.6 kW and cut the charge. The cut is right, but one 2 s spike cost 15
minutes of charge, about 0.16 kWh per trip on the rig.
- *A start at the tariff edge* (sim, 5 s meter): a give ends at 01:00 and the
cheap run starts. The battery reading drops at once, the meter a few
seconds later. In between, grid + batteries reads the house too low, the
room too big: the charge starts and the guard cuts it within seconds.
- *An oven cycling under a cheap-hours charge* (SAFETY-136 rig run C/s5b, x1):
the charge started at 01:00:32 in an oven pause and the guard cut it at
01:01:31 when the oven came back on (5.3 kW projected). The cut is right,
but the charge stayed off to 01:15.
**Decision.**
1. *The first guard stop in a period does not latch.* A charge the peak guard
stops (or a measured headroom stops in the period's first `YOUNG_S` = 60 s)
may start again `RESTART_S` = 60 s after that stop,
under the normal start rule (guard clear, headroom over `START_W`), but not
in the period's last 60 s: there the room is the deficit times 900/s left,
and a restart overshot by 15 W in the owner-shape night (N=2, 01:00). A
second stop latches to the period's end, so there is at most one restart
per period and the OPT-11 flip guard keeps its job. A measured headroom
stop later in the period latches at once, and an unmeasured headroom
(OPT-15) on its second stop, as before. Why only guard stops later on: all
three rig cases are guard stops, while a headroom that falls to 0 W mid
period is the slow loss of room the OPT-11 latch was made for (the
2026-09-09 flips), so freeing it buys no case and risks that flip.
Three existing checks pinned "one start per period" and move with this:
`test_modes_p3.py` (a house swinging 2.0/3.9 kW each minute: now at most
two starts per period), `test_price_run_fixes.py` 1c (a 1-minute 3.7 kW
load: one restart after 60 s), and the `n1_ch` digest in
`test_modes_p4.py` (only the waiting reason word moved, "(peak)" for
"(headroom)"; with this card off it is OPT-15's pin again).
2. *No start while a give is still falling.* In a period's first `YOUNG_S`,
while the batteries give over `START_W` less than they did in the
last `GAVE_S` = 15 s, no charge starts: the meter may not show that drop
yet. A smaller drop does not hold the start.
The guard is not touched (claim, floor, projection), so a real over-aim load
is cut on the same cycle as before, and a running charge is never held. While
a start waits, the status row says "grid charge paused, it tries again in a
minute" or "grid charge waits for the meter to catch up".
**Options weighed.**
- *Round 1: only a stop in the first 60 s does not latch.* Missed the rig
oven case, cut at 91 s (review M-1).
- *Re-evaluation each cycle with no latch*: brings back the 13 flips in 4
minutes the latch was made for (2026-09-09 replay).
- *Restart at once after the first stop*: two flips in 20 s with spikes at
0/20 s (`test_safety135.py` part 5 fails on it).
- *No start in a young period within 60 s of a give or a guard action*:
0.018 kWh (N=1) and 0.003 kWh (N=2) less than base on the owner night.
- *Cutting the young room by the recent give*: double-counts once the meter
catches up and flapped on a 1 s meter where base did not.
- *A hysteresis band*: the edge error is the readings' age, not a small room.
**Numbers** (`scratch/SAFETY-135/dev/s135_sim.py`, real `cycle()`, TEST-23-like
lag, 1 s cycle, two 2.5 kW batteries unless said, cap 3 500 W, aim 3 450 W;
the sim's grid average per period):
| Scenario | Base: paused s, kWh, flaps, top period | Head |
|---|---|---|
| (a) 4 s spike at 01:30 and 01:45, house 2.8 kW | 1 197 s, 0.330 kWh, 1, 3 458 W | 139 s, 0.537 kWh, 0, 3 458 W |
| (b) 01:00 edge after a give, 5 s meter, house 3.0 kW | 882 s, 0.006 kWh, 1, 3 009 W | 21 s, 0.119 kWh, 0, 3 464 W |
| (b) the same, house 2.4 kW | 0 s, 0.266 kWh, 0, 3 461 W | 11 s, 0.266 kWh, 0, 3 461 W |
| Rig s5b (5 kW + 4.6 kW, oven), 1 s / 5 s meter | 838 / 886 s, 0.054 / 0.035 kWh, 2 224 / 2 123 W | 793 / 793 s, 0.075 / 0.084 kWh, 2 299 / 2 286 W |
| (c) +4 kW for 5 min from 01:30:20 | cut 01:30:22, 0.387 kWh | cut 01:30:22, 0.492 kWh |
| Owner-shape night, N=1 / N=2 | 5.510 / 6.048 kWh, top 3 454 / 3 453 W | 5.510 / 6.169 kWh, 3 454 / 3 453 W |
| Owner-shape day, N=1 / N=2 | top 331 / 145 W | the same |
In s5b the restart at 01:02:32 is cut again by the oven at 01:03:17 and then
latches: one restart, as designed. A +4 kW load from second 30, 57, 59, 60 or
61 (N=1 and N=2) is cut on the same cycle as base, the charge restarts only
after the load ends, and the period lands at 3 450.0 W (base 3 037-3 350 W).
The add-on's own period averages in `test_safety135.py` are at or under the
aim + 2 W in every part.
**Sensitivity** (one constant moved, the others as shipped; head):
| Constant | Spike (a): paused s, kWh | Edge (b) 5 s meter 3.0 kW: paused s, flaps | Rig s5b 1 s: kWh, period W | Owner night N=2: kWh, top W |
|---|---|---|---|---|
| `RESTART_S` 30 | 79, 0.541 | 21, 0 | 0.082, 2 321 | 6.206, **3 459** |
| `RESTART_S` 60 (shipped) | 139, 0.537 | 21, 0 | 0.075, 2 299 | 6.169, 3 453 |
| `RESTART_S` 90 | 199, 0.532 | 21, 0 | 0.061, 2 250 | 6.169, 3 453 |
| `RESTART_S` 120 | 259, 0.528 | 21, 0 | 0.085, 2 338 | 6.169, 3 453 |
| `GAVE_S` 5 | - | 75, **1** | 0.075, 2 299 | 6.170, 3 453 |
| `GAVE_S` 10 | - | 15, 0 | 0.075, 2 299 | 6.169, 3 453 |
| `GAVE_S` 15 (shipped) | - | 21, 0 | 0.075, 2 299 | 6.169, 3 453 |
| `GAVE_S` 30 | - | 35, 0 | 0.073, 2 290 | 6.168, 3 453 |
| `YOUNG_S` 30 / 90 | - | 21, 0 | 0.075, 2 299 | 6.169, 3 453 |
`RESTART_S` 30 restarts the owner night in a period's last 30 s and lands
9 W over base's top period; 60 is the shortest value without that. In s5b
the value only moves where the restart meets the oven cycle. `GAVE_S` 5 s is
shorter than a 5 s meter's age and flaps again; 10-30 s only move the wait.
`YOUNG_S` does not move these runs: the give falls in the first seconds.
On the synthetic night where the house alone is 950 W over the aim (N=1,
heat pump 4.4 kW), head's top period is 4 403 W against base's 4 401 W: a
charge restarted in the period before runs into the heat pump's start (the
carry-over below). Base charges 1.142 kWh there, head 1.560 kWh.
**Not changed.** With battery modes off, `arbitrage.decide()` has no period
latch and sizes the charge to the headroom each cycle (SAFETY-45). A charge at
full power in a period's last seconds still runs into the next period (the
charge side of SAFETY-124); with rule 1 that costs 60 s, not the period.
+7 -2
View File
@@ -559,8 +559,13 @@ class CycleMixin:
# peakguard.shaping_suspended(), NOT HERE. One suspension flag
# per SITE (`arbitrate()` allows one pack in CHARGE).
# (DECISIONS/MAIN-orchestration-archive.md)
# 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)
if peakguard.shaping_suspended(
self.peak_guard, self.peak_fc, self.peak_aim_w()):
self.peak_guard, self.peak_fc, maint_aim):
# 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 +587,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"
+47 -3
View File
@@ -2,6 +2,7 @@
Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md."""
import collections
import dataclasses
import logging
from datetime import datetime, timedelta, timezone
@@ -401,6 +402,18 @@ class TradeMixin:
quarter = int(now.timestamp() // 900)
was = self.mode_tick is not None and \
self.mode_tick.action == modetrade.CHARGE
# SAFETY-135: the first guard stop in a period (or measured headroom
# stop in a young one) does not latch; the charge may start again
# RESTART_S after it, but not in the period's last RESTART_S. No
# start in a young period while a give falls.
# DECISIONS/SAFETY-135-young-period.md
st = getattr(self, "_charge_stop", None)
paused = (st is not None and st[0] == quarter
and (now.timestamp() - st[1] < modetrade.RESTART_S
or (quarter + 1) * 900 - now.timestamp()
< modetrade.RESTART_S))
wait = ("wait" if paused else
"settle" if self._give_falling(now) else None)
self.mode_tick = modetrade.decide(
runs, now, packs, charge_ok=charge_ok, sell_ok=sell_ok,
trade_why=trade_why, price_now=price_now,
@@ -408,7 +421,10 @@ class TradeMixin:
ceiling_pct=ceiling_pct, house_w=house, charging_now=was,
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 ()))
draining=any(r.phase == DRAIN for r in maint_results or ()),
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
@@ -419,11 +435,38 @@ class TradeMixin:
gap = why == "headroom" and self.arb_headroom_w is None
if why == "peak" or (why == "headroom" and not gap) or (
gap and self._charge_gap_q == quarter):
self._charge_blocked_q = quarter
young = now.timestamp() - quarter * 900 < modetrade.YOUNG_S
if (why == "peak" or (young and not gap)) and (
st is None or st[0] != quarter):
self._charge_stop = (quarter, now.timestamp())
else:
self._charge_blocked_q = quarter
elif gap:
self._charge_gap_q = quarter
return self.mode_tick
def _give_falling(self, now) -> bool:
"""SAFETY-135: True in a young period while the batteries give over
START_W less than they did in the last GAVE_S s: the meter may not
show that yet, so a charge room read now is too big.
DECISIONS/SAFETY-135-young-period.md"""
t = now.timestamp()
gave = getattr(self, "_gave", None)
if gave is None:
gave = collections.deque()
self._gave = gave
if gave and t < gave[-1][0]:
gave.clear() # the clock stepped back
if self.site_batt_w is not None:
gave.append((t, max(0.0, float(self.site_batt_w))))
while gave and t - gave[0][0] >= modetrade.GAVE_S:
gave.popleft()
if self.site_batt_w is None or not gave \
or t % 900 >= modetrade.YOUNG_S:
return False
drop = max(w for _t, w in gave) - max(0.0, float(self.site_batt_w))
return drop > modetrade.START_W
def _run_mark(self, kind, run, packs, out, extra=None) -> set:
"""The batteries that left run `run` (full, or at the floor); they
stay out until it ends, so a level edge does not re-arm them.
@@ -475,7 +518,8 @@ class TradeMixin:
None, 0.0, "size")
if size == 0.0 or runs is None:
reason = ("battery modes: no price run now" if tick.why is None
else f"battery modes: no trade ({tick.why})")
else modetrade.WAIT_WORDS.get(tick.why)
or f"battery modes: no trade ({tick.why})")
self.arb = arbitrage.ArbitrageDecision(
action=None, power_w=None, reason=reason,
spread_eur_kwh=None, required_eur_kwh=None,
+14
View File
@@ -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,
+3
View File
@@ -22,6 +22,7 @@ ctl_inputs, ctl_writes, ctl_trade, ctl_cycle, ctl_alarms, ctl_status and
ctl_tasks (MAINT-21), the status-row functions in rows."""
import asyncio
import collections
import contextlib
import dataclasses
import functools
@@ -528,6 +529,8 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
self._mode_trade_was = False
self._charge_blocked_q = None
self._charge_gap_q = None # OPT-15: an unmeasured stop's quarter
self._charge_stop = None # SAFETY-135: (quarter, t) first stop
self._gave = collections.deque() # SAFETY-135: (t, W given), GAVE_S
self._ref_cheap = None
# OPT-15: a run edge the loop holds at E: (E, step sign, until, the
# last battery reading).
+32 -3
View File
@@ -36,6 +36,10 @@ Rules that keep this safe (tests: test_modes_p3.py, test_price_run_fixes.py):
makes the other batteries stop giving power, so a few watts of charge
flipping on and off would swing the house between them and the grid
(measured on the 2026-09-09 replay: 13 flips in 4 minutes at 3.5 kW);
SAFETY-135: the first such stop in a period holds the charge RESTART_S
only, a second one to the period's end (at most one restart), and in a
period's first YOUNG_S s no charge starts while the batteries' give is
still falling (`wait`);
* selling needs selling on for that battery, the sell run, a price >= 0
now, and the battery above `arbitrage_soc_floor`; it stops there. The
peak guard never stops a sell (section 9.1: discharge is never capped);
@@ -52,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
@@ -69,6 +75,18 @@ PRICE_MODES = (LEFTOVER_SOLAR, CHEAP_HOURS)
# would stop covering the house for less than they give.
START_W = 500.0
# SAFETY-135: how long the first stop in a period holds the charge, how long
# a period is young, and how far back a give may not be on the meter yet.
# DECISIONS/SAFETY-135-young-period.md
RESTART_S = 60.0
YOUNG_S = 60.0
GAVE_S = 15.0
# SAFETY-135: the plain status words for `wait` (UX-08).
WAIT_WORDS = {
"wait": "battery modes: grid charge paused, it tries again in a minute",
"settle": "battery modes: grid charge waits for the meter to catch up",
}
@dataclass(frozen=True)
class Pack:
@@ -164,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) -> 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).
@@ -177,7 +196,11 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
it going); `blocked` that the guard or the headroom stopped a charge in
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").
charge from the grid (why "cycle"). `wait` ("wait" or "settle", the
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()
@@ -189,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
@@ -198,6 +225,8 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
why = trade_why
elif headroom_w is None or not headroom_w > need:
why = "headroom"
elif wait and not charging_now:
why = wait
else:
charging = True
se = sellers(packs, floor_pct, done_sell) if in_sell and not charging \
+1
View File
@@ -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.",
+1
View File
@@ -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.",
+257
View File
@@ -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")
+162
View File
@@ -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")
+24 -3
View File
@@ -472,15 +472,36 @@ _sw = [(UTC0.timestamp() + 60 * k, 2000.0 if k % 2 else 3900.0)
ROWS_W, _, _ = run_day("p3_ch_sell", hours=1.5, house=_sw,
soc0={"a": 60.0, "b": 30.0})
_per_q = {}
_ev_q = {} # SAFETY-135: per period, the charge's starts and stops
for a_, b_ in zip(ROWS_W, ROWS_W[1:]):
if a_["action"] != arbitrage.CHARGE and b_["action"] == arbitrage.CHARGE:
on_a, on_b = (a_["action"] == arbitrage.CHARGE,
b_["action"] == arbitrage.CHARGE)
if on_a != on_b:
q = int(b_["now"].timestamp() // 900)
_ev_q.setdefault(q, []).append((on_b, b_["now"].timestamp()))
if not on_a and on_b:
q = int(b_["now"].timestamp() // 900)
_per_q[q] = _per_q.get(q, 0) + 1
def _one_restart(ev):
"""At most one start after a stop in the period, RESTART_S or more after
it; a second stop is the last change of the period."""
re = [(t0, t1) for (s0, t0), (s1, t1) in zip(ev, ev[1:])
if not s0 and s1]
stops, late = 0, False
for on, _t in ev:
stops += 0 if on else 1
late = late or (on and stops >= 2)
return (len(re) <= 1 and not late
and all(t1 - t0 >= modetrade.RESTART_S - 1 for t0, t1 in re))
print(f" house swinging 2.0/3.9 kW each minute: charge starts per period "
f"{sorted(_per_q.values())}")
check("a charge the peak guard or the headroom stopped stays off for the "
"rest of that 15-minute period (at most one start per period)",
_per_q and max(_per_q.values()) == 1)
"rest of that 15-minute period, after at most one restart RESTART_S or "
"more after the first stop; a second stop latches (SAFETY-135)",
_per_q and max(_per_q.values()) <= 2
and all(_one_restart(ev) for ev in _ev_q.values()))
# At the floor: B starts the sale just above it.
ROWS_F, _, _ = run_day("p3_ch_sell", start_h=18.75, hours=1.5,
+39 -2
View File
@@ -65,11 +65,16 @@ print("1. no usable forecast: byte-identical to 5a4cdc2 (P3 option A)")
# OPT-15 head: a charge end now holds the loop at the house's level (F2). With
# that hold and its reseed switched off they equal 5a4cdc2's again; the others
# (no charge) never moved.
# SAFETY-135: n1_ch re-pinned. A first guard stop no longer latches, so while
# the charge waits its reason reads "(peak)" where the latch said
# "(headroom)"; no command moves. With SAFETY-135 off it is OPT-15's pin
# again (checked below).
N1_CH_OPT15 = "97893a54f8a8e3f36ad24f100973b3ff35766527e7287a6ae30cc1c8941bac02"
BASE_DIGESTS = {
"n1_ls":
"13f11c330263c91625acde7628426ff16dae3a9af99aebeef3051dc5b5a884ea",
"n1_ch":
"97893a54f8a8e3f36ad24f100973b3ff35766527e7287a6ae30cc1c8941bac02",
"5d529f516307e4da4dca48714ee891332a2eea5e1b1763f0f1e049cfb0819a0a",
"n2_sf_ls":
"9424e1837b805a6a31293a28b8fbad6ba611ea0bfe3240d636d68d62dc5e9f22",
"n2_ch_ls":
@@ -81,7 +86,7 @@ BASE_DIGESTS = {
"n1_ls_fc_down":
"13f11c330263c91625acde7628426ff16dae3a9af99aebeef3051dc5b5a884ea",
"n1_ch_fc_down":
"97893a54f8a8e3f36ad24f100973b3ff35766527e7287a6ae30cc1c8941bac02",
"5d529f516307e4da4dca48714ee891332a2eea5e1b1763f0f1e049cfb0819a0a",
"n2_sf_ls_fc_down":
"9424e1837b805a6a31293a28b8fbad6ba611ea0bfe3240d636d68d62dc5e9f22",
"n2_ch_ls_fc_down":
@@ -98,6 +103,38 @@ for k, v in BASE_DIGESTS.items():
check(f"{k}: pinned digest (5a4cdc2; OPT-15 for the _ch ones)",
got.get(k) == v)
check("every scenario is pinned", set(got) == set(BASE_DIGESTS))
class _Every:
"""SAFETY-135 off: a stop period equal to every period, so every guard
stop latches as before."""
def __eq__(self, other):
return True
def __ne__(self, other):
return False
def _s135_off_digest(scn):
from app import main as _m
real = _m.Controller.__init__
def init(self, *a, **k):
real(self, *a, **k)
self._charge_stop = (_Every(), float("-inf"))
young, restart = modetrade.YOUNG_S, modetrade.RESTART_S
_m.Controller.__init__ = init
modetrade.YOUNG_S = modetrade.RESTART_S = 0.0
try:
return ident.p3.digest(ident.run(**ident.SCENARIOS[scn])[0])
finally:
_m.Controller.__init__ = real
modetrade.YOUNG_S, modetrade.RESTART_S = young, restart
check("n1_ch with SAFETY-135 off is OPT-15's pin: only the reason word moved",
_s135_off_digest("n1_ch") == N1_CH_OPT15)
for k in [k for k in got if k.endswith("_fc_down")]:
check(f"{k}: a set but unavailable forecast equals no forecast",
got[k] == got[k[:-len('_fc_down')]])
+6 -3
View File
@@ -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:
+6 -4
View File
@@ -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 "
+20 -5
View File
@@ -6,7 +6,8 @@ with the lagged model (`Lag`: command dead time, meter and battery reading
age, the P1 debounce, 1 s cycles), which reproduces all three on 5a4cdc2.
1. F1: a quarter edge while charging does not stop the charge (the guard
projects the quarter `now` is in), and a real guard stop still latches.
projects the quarter `now` is in), and a real guard stop still holds it
(SAFETY-135: one restart a minute later, then a second stop latches).
At two inverters SAFETY-91's `next_quarter()` already does that for the
guard; at one inverter only the rolled projection does.
2. F2: the export overshoot at a charge end stays within 0.15 x rating in
@@ -252,7 +253,8 @@ check("two inverters: SAFETY-91 already holds the edge (no stop with the old "
"projection), and rolling it changes no banked quarter",
not stops(ROWS1O) and banked(C1O, ROWS1O) == banked(C1, ROWS1))
# 1c. A real guard stop still latches, and the 500 W minimum stays.
# 1c. A real guard stop holds the charge (SAFETY-135: one restart after
# RESTART_S, a second stop latches), and the 500 W minimum stays.
# House: 800 W; 3700 W at 02:20-02:21; 3000 W from 02:26 (headroom 450 W
# in the next quarter); 2850 W from 02:40 (600 W).
ROWS2, C2 = run(1.9, 0.9, lag=LAG, steps=[
@@ -266,11 +268,24 @@ _after2 = [r for r in ROWS2 if _first and r["now"] > _first["now"]
print(f" guard stop at "
f"{_first['now'].astimezone(L).strftime('%H:%M:%S') if _first else None}"
f" ({_first['reason'][:40] if _first else ''})")
check("a mid-quarter 3.7 kW load stops the charge for the peak and it stays "
"off for the rest of that quarter, the house back at 800 W",
# SAFETY-135: the first guard stop in a quarter holds the charge RESTART_S,
# then it may start once more; a second stop latches to the quarter's end.
_hold2 = [r for r in _after2
if (r["now"] - _first["now"]).total_seconds() < modetrade.RESTART_S]
_on2 = [b for a, b in zip(_after2, _after2[1:])
if a["action"] != arbitrage.CHARGE and b["action"] == arbitrage.CHARGE]
_off2 = [b for a, b in zip(_after2, _after2[1:])
if a["action"] == arbitrage.CHARGE and b["action"] != arbitrage.CHARGE]
print(f" after it: {len(_on2)} restart(s), {len(_off2)} more stop(s)")
check("a mid-quarter 3.7 kW load stops the charge for the peak; it stays off "
"for RESTART_S, then starts again at most once that quarter, and a "
"second stop holds it off to the quarter's end (SAFETY-135)",
_first is not None and _first["now"].astimezone(L).minute == 20
and ("peak" in _first["reason"] or "headroom" in _first["reason"])
and _after2 and all(r["action"] is None for r in _after2))
and _hold2 and all(r["action"] is None for r in _hold2)
and len(_on2) <= 1 and len(_off2) <= 1
and not any(r["action"] == arbitrage.CHARGE for r in _after2
if _off2 and r["now"] >= _off2[0]["now"]))
_starts = [b for a, b in zip(ROWS1 + ROWS2, (ROWS1 + ROWS2)[1:])
if a["action"] != arbitrage.CHARGE
and b["action"] == arbitrage.CHARGE]
+324
View File
@@ -0,0 +1,324 @@
"""Runnable check for SAFETY-135: a young 15-minute period does not cost the
night charge the whole period. `python3 test_safety135.py`
The REAL Controller (tools/opt11p3_replay.py, read_inputs + cycle, battery
modes, the peak guard on a P1 meter) with TEST-23-like lag: 1 s cycles,
readings 1 s old, a 5 s old meter in part 2. "Off" is the same run with
every measured stop latching and no wait for a falling give: the tree before
this card.
DECISIONS/SAFETY-135-young-period.md.
1. A peak-bound night charge; a period starts with a 4 s kettle spike. The
guard cuts at once, and the charge resumes in that period (off: it stays
off to the period's end). The period stays at or under the aim.
2. A cheap run starts at 01:00 right after a give run, with a 5 s meter: no
start-then-cut within 60 s (off: one), and no period over the aim.
3. A real over-aim load: the guard and the charge stop on the same cycle as
off, and the charge stays off while the load is on.
4. The SAFETY-136 rig run C/s5b (house 1 800 W, an oven 2 kW 45 s in every
105 s, A Leftover solar 5 kW, B Cheap hours 4.6 kW): the charge starts at
about 01:00:32 and the guard cuts it about 60 s later on the oven. It
starts again 60 s after that cut (off: never in that period).
5. Repeated spikes in one period: at most one restart, and none sooner than
RESTART_S after the first stop.
Mutations (MUTATION.md, purged copies) are listed in the pull request.
"""
import logging
import os
import sys
from datetime import datetime, timedelta, timezone
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
from app import arbitrage, modetrade, packmode # noqa: E402
from named_absence import nth, present # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
L = timezone(timedelta(hours=1))
D0 = datetime(2026, 11, 3, tzinfo=L)
UTC0 = D0.astimezone(timezone.utc)
# Local quarters: dear 00:00-01:00 (a give run), cheap 01:00-07:00, 0.30 else.
PRICES = [0.40 if q < 4 else 0.10 if q < 28 else 0.30 for q in range(96)] * 2
WPTS = [(UTC0 + timedelta(minutes=15 * i), 900, p)
for i, p in enumerate(PRICES)]
AIM = rp.PEAK_CAP - 50
CH = {"a": (True, packmode.CHEAP_HOURS, False),
"b": (True, packmode.CHEAP_HOURS, False)}
T1 = D0 + timedelta(hours=1) # 01:00 local, the cheap edge
RESTART = getattr(modetrade, "RESTART_S", 60.0) # 60.0 on older trees
class _Every:
"""A period that equals every period: `_charge_stop` set to it makes
every measured stop latch, as before this card."""
def __eq__(self, other):
return True
def __ne__(self, other):
return False
def _off(c):
# With RESTART_S 0 (see `off()`) nothing waits; every guard stop latches.
c._charge_stop = (_Every(), float("-inf"))
def off(fn):
"""`fn()` with SAFETY-135 off: every measured stop latches and no start
waits for a falling give, as on the tree before this card."""
names = [n for n in ("YOUNG_S", "RESTART_S") if hasattr(modetrade, n)]
real = {n: getattr(modetrade, n) for n in names}
OFF[0] = True
for n in names: # RESTART_S 0: no wait at a period's end
setattr(modetrade, n, 0.0)
try:
return fn()
finally:
OFF[0] = False
for n in names:
setattr(modetrade, n, real[n])
OFF = [False]
def run(house_w, start, secs, meter_s=1.0, *, choice=None, soc0=None,
limits=None, rating=None):
"""Rows per cycle and the Controller, for `house_w(seconds since 01:00)`."""
t0 = start.timestamp()
house = [(t0 + k, house_w(t0 + k - T1.timestamp()))
for k in range(int(secs) + 5)]
rows = []
def cb(c, h, now, pr):
g = c.peak_guard
rows.append(dict(now=now, s=now.timestamp() - T1.timestamp(),
grid=h.grid(),
charge=getattr(c.arb, "action", None)
== arbitrage.CHARGE,
engaged=g is not None and g.engaged,
reason=getattr(c.arb, "reason", None) or ""))
was = rp.RATING
if rating is not None:
rp.RATING = rating
try:
_st, c = rp.run("p3_ch_sell", house, WPTS,
start.astimezone(timezone.utc), secs, check=cb,
soc0=soc0 or {"a": 30.0, "b": 30.0},
choice=choice or CH, limits=limits,
n=len(choice or CH),
patch=_off if OFF[0] else None,
extra={"slew_w": 0, "cycle_cost_threshold_eur": 0.03},
lag=rp.Lag(meter_s=meter_s))
finally:
rp.RATING = was
return rows, c
def banked(c, frm):
"""The add-on's own P1 period averages from `frm` (seconds after 01:00)."""
return [b.offtake_avg_w for b in c.p1.blocks
if b.start.timestamp() - T1.timestamp() >= frm]
def flaps(rows, frm, to):
"""Charge starts in [frm, to) s that stopped again within 60 s."""
out = []
for i in range(1, len(rows)):
r = rows[i]
if frm <= r["s"] < to and r["charge"] and not rows[i - 1]["charge"]:
if any(not x["charge"] for x in rows[i:]
if x["s"] - r["s"] <= 60):
out.append(r["s"])
return out
# ============================================================================ #
print("1. a 4 s spike at the start of a peak-bound night charge's period")
def spike(s):
return 2800.0 + (2500.0 if s >= 1800 and (s - 1800) % 900 < 4 else 0.0)
R1, C1 = run(spike, T1 + timedelta(minutes=20), 1560)
R1o, C1o = off(lambda: run(spike, T1 + timedelta(minutes=20), 1560))
def period(rows, frm):
return [r for r in rows if frm <= r["s"] < frm + 900]
for tag, rows, c in (("on", R1, C1), ("off", R1o, C1o)):
p = period(rows, 1800)
cut = next((r["s"] - 1800 for r in p if r["engaged"]), None)
on = sum(r["charge"] for r in p)
print(f" {tag}: guard cut at +{cut} s, charging {on} of {len(p)} s, "
f"period {banked(c, 1800)[:1]}")
_p = period(R1, 1800)
_cut = next((i for i, r in enumerate(_p) if r["engaged"]), None)
check("the spike trips the guard in the period's first seconds and the "
"charge stops there",
_cut is not None and _p[_cut]["s"] - 1800 <= 5
and not _p[_cut]["charge"])
_back = [r["s"] - 1800 for r in _p[(_cut or 0):] if r["charge"]]
check("the charge resumes in the same period, within 90 s of its start",
_back and _back[0] <= 90)
_po = period(R1o, 1800)
check("off, the same spike keeps the charge off to the period's end (the "
"defect this card fixes)",
any(r["engaged"] for r in _po[:5])
and not any(r["charge"] for r in _po[10:]))
check("every period with the charge is at or under the aim (lag allows 2 W)",
banked(C1, 0) and max(banked(C1, 0)) <= AIM + 2.0)
# ============================================================================ #
print("\n2. a cheap run at 01:00 right after a give run, 5 s meter")
for house in (2400.0, 3000.0):
def flat(s, w=house):
return w
R2, C2 = run(flat, T1 - timedelta(minutes=5), 1260, meter_s=5.0)
R2o, C2o = off(lambda: run(flat, T1 - timedelta(minutes=5), 1260,
meter_s=5.0))
f2, f2o = flaps(R2, 0, 900), flaps(R2o, 0, 900)
p2, p2o = banked(C2, 0)[:1], banked(C2o, 0)[:1]
on2 = sum(r["charge"] for r in period(R2, 0))
on2o = sum(r["charge"] for r in period(R2o, 0))
print(f" house {house:.0f} W: on flaps {f2} charging {on2} s period "
f"{p2}; off flaps {f2o} charging {on2o} s period {p2o}")
check(f"house {house:.0f} W: no charge start is cut within 60 s in the "
f"01:00 period", not f2)
check(f"house {house:.0f} W: the 01:00 period is at or under the aim "
f"(lag allows 2 W)", p2 and p2[0] <= AIM + 2.0)
check(f"house {house:.0f} W: the charge still runs in the 01:00 period",
on2 >= 800)
if house == 3000.0:
check("off, the 3 000 W house starts on the young period and is cut "
"within 60 s (the defect this card fixes)", bool(f2o))
# ============================================================================ #
print("\n3. a real over-aim load still cuts on the same cycle")
def load(s):
return 2800.0 + (4000.0 if 1820 <= s < 2120 else 0.0)
R3, C3 = run(load, T1 + timedelta(minutes=20), 1500)
R3o, C3o = off(lambda: run(load, T1 + timedelta(minutes=20), 1500))
def first(rows, key, val, frm):
return next((r["s"] for r in rows if r["s"] >= frm and r[key] == val),
None)
_g, _go = first(R3, "engaged", True, 1800), first(R3o, "engaged", True, 1800)
_s, _so = first(R3, "charge", False, 1800), first(R3o, "charge", False, 1800)
print(f" guard cut at {_g} s (off {_go} s), charge stop at {_s} s "
f"(off {_so} s)")
check("the guard cuts on the same cycle as off, and the charge stops on it",
_g is not None and _g == _go and _s == _so == _g)
check("the charge stays off while the load is on",
not any(r["charge"] for r in R3 if _g <= r["s"] < 2120))
check("every period is at or under the aim (lag allows 2 W)",
banked(C3, 0) and max(banked(C3, 0)) <= AIM + 2.0)
# ============================================================================ #
print("\n4. the SAFETY-136 rig run C/s5b: start at 01:00:32, oven cut a minute "
"later")
# The rig's house (s5b.ctl.json), in seconds from 01:00: 1 800 W, an oven of
# 2 000 W for 45 s in every 105 s from 00:30 to 01:05:45, 3 000 W 00:56-00:58.
def s5b(s):
w = 1800.0
if -1800 <= s < 345 and (s + 1800) % 105 < 45:
w += 2000.0
if -240 <= s < -120:
w += 3000.0
return w
S5B = dict(choice={"a": (True, packmode.LEFTOVER_SOLAR, False),
"b": (True, packmode.CHEAP_HOURS, False)},
soc0={"a": 53.6, "b": 27.4}, limits={"b": (-4600.0, 4600.0)},
rating=5000.0)
R4, C4 = run(s5b, T1 - timedelta(minutes=5), 1260, **S5B)
R4o, C4o = off(lambda: run(s5b, T1 - timedelta(minutes=5), 1260, **S5B))
def edges(rows, frm):
"""(start s, stop s) lists of the charge in the period from `frm`."""
p = period(rows, frm)
on = [round(b["s"] - frm) for a, b in zip(p, p[1:])
if b["charge"] and not a["charge"]]
of = [round(b["s"] - frm) for a, b in zip(p, p[1:])
if a["charge"] and not b["charge"]]
return on, of
_on4, _of4 = edges(R4, 0)
_on4o, _of4o = edges(R4o, 0)
print(f" on: starts {_on4}, stops {_of4}, period {banked(C4, 0)[:1]}; "
f"off: starts {_on4o}, stops {_of4o}, period {banked(C4o, 0)[:1]}")
check("off, as on the rig: the charge starts about 30 s in, the guard cuts "
"it about a minute later, and it stays off to the period's end",
len(_on4o) == 1 and 25 <= _on4o[0] <= 40 and len(_of4o) == 1
and 80 <= _of4o[0] <= 100)
check("the same start and the same cut as off (the cut is not delayed)",
_on4[:1] == _on4o[:1] and _of4[:1] == _of4o[:1])
_cut4 = nth(_of4, 0, "the guard's cut in s5b")
check("the charge starts again in that period, 60 s or more after the cut, "
"once only",
present(_cut4) and len(_on4) == 2 and _on4[1] - _cut4 >= RESTART - 1)
check("the period is at or under the aim (lag allows 2 W)",
banked(C4, 0) and banked(C4, 0)[0] <= AIM + 2.0)
# ============================================================================ #
print("\n5. repeated spikes in one period: one restart, not too soon")
_SP = (0, 20, 40, 62, 80, 300, 500)
def spikes(s):
return 2800.0 + (2500.0 if any(0 <= s - 1800 - k < 4 for k in _SP)
else 0.0)
for n_ch in (CH, {"a": CH["a"]}):
R5, C5 = run(spikes, T1 + timedelta(minutes=20), 1560, choice=n_ch,
soc0={k: 30.0 for k in n_ch})
_on5, _of5 = edges(R5, 1800)
print(f" N={len(n_ch)}: starts {_on5}, stops {_of5}, "
f"period {banked(C5, 1800)[:1]}")
check(f"N={len(n_ch)}: at most one restart in the period, none sooner "
f"than RESTART_S after the first stop, and the last stop latches",
_of5 and len(_on5) <= 1 and len(_of5) == len(_on5) + 1
and all(t - _of5[0] >= RESTART - 1 for t in _on5))
check(f"N={len(n_ch)}: the period is at or under the aim (lag allows "
f"2 W)", banked(C5, 1800) and banked(C5, 1800)[0] <= AIM + 2.0)
_w = [r["reason"] for r in period(R5, 1800) if not r["charge"]]
check(f"N={len(n_ch)}: the wait says it in plain words (UX-08)",
any("tries again in a minute" in x for x in _w)
and not any("(young)" in x or "(wait)" in x for x in _w))
print()
if fails:
print(f"FAILED: {len(fails)}")
sys.exit(1)
print("all SAFETY-135 checks passed")