Compare commits

..
27 Commits
Author SHA1 Message Date
admin f3f9483d9c Merge pull request 'MAINT-28: during a real peak the maintenance charge stays paused' (#247) from MAINT-28 into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-11-13 06:23:12 +01:00
glenn schrooyenandClaude e7706150e8 MAINT-28: DECISIONS gives the measured reason for the uncapped give (review M-1); ctl_cycle comment one line + pointer (L-1)
checks / unit (pull_request) Successful in 27m38s
infra-pin / fresh (pull_request) Successful in 2s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-13 05:53:55 +01:00
glenn schrooyenandClaude 7edf8484d1 MAINT-28: test_maint28 reads the replay clock directly (main_files.live parsed every app file per cycle): 269 s -> 28 s, same numbers
checks / unit (pull_request) Successful in 42m47s
infra-pin / fresh (pull_request) Successful in 2s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-13 03:57:31 +01:00
glenn schrooyenandClaude 29a8a05f30 MAINT-28: shorter test_maint28 runs (892 s -> ~280 s), digests re-pinned on 1b5f7c9; hold kept through the two short holds (documented); DECISIONS duplicate removed
checks / unit (pull_request) Successful in 40m7s
infra-pin / fresh (pull_request) Successful in 2s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-13 02:29:27 +01:00
glenn schrooyenandClaude f5bd2a3520 MAINT-28: re-pin test_safety84 n2_charge_banked (the charge no longer restarts in the 5000 W load)
checks / unit (pull_request) Successful in 43m17s
infra-pin / fresh (pull_request) Successful in 2s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-13 00:59:41 +01:00
glenn schrooyenandClaude bc5be4964e MAINT-28: N=1 digest test with a banked peak (so the N=1 gate is tested)
checks / unit (pull_request) Successful in 34m0s
infra-pin / fresh (pull_request) Successful in 2s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-13 00:20:21 +01:00
glenn schrooyenandClaude edf30f345a MAINT-28: keep the single shaping_suspended(.., maint_aim) call site (test_peak_margin pin); shaping_held takes its answer
checks / unit (pull_request) Successful in 32m46s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-12 23:58:19 +01:00
glenn schrooyenandClaude 23359a047d MAINT-28: test_maint28 points at MUTATION.md (test_bytecode)
checks / unit (pull_request) Failing after 34m49s
infra-pin / fresh (pull_request) Successful in 1s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-12 23:29:13 +01:00
glenn schrooyenandClaude 8640bfcbc1 MAINT-28: hold only where MAINT-24 moved the aim (banked peak above the cap); test_safety119 pins stay
checks / unit (pull_request) Failing after 34m27s
infra-pin / fresh (pull_request) Successful in 2s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-12 23:13:57 +01:00
glenn schrooyenandClaude 92f0387415 MAINT-28: reset the hold every cycle, test it; digests vs 1b5f7c9; DECISIONS numbers and mutants
checks / unit (pull_request) Failing after 36m49s
infra-pin / fresh (pull_request) Successful in 2s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-12 22:44:32 +01:00
glenn schrooyenandClaude 4f9aa5223f MAINT-28: the hold is for N=2 and up (SAFETY-87 N=1 pins), N=1 peak digest test
checks / unit (pull_request) Failing after 36m51s
infra-pin / fresh (pull_request) Successful in 2s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-12 22:28:01 +01:00
glenn schrooyenandClaude 8cd10ee6aa MAINT-28: during a real peak the maintenance charge stays paused until the house is clear of the aim
infra-pin / fresh (pull_request) Successful in 2s
checks / unit (pull_request) Failing after 1h10m11s
Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-12 21:51:56 +01:00
admin 1b5f7c9ccb Merge pull request 'MAINT-24 + MAINT-25: a started maintenance charge gets the cheap night; the plan honours the back-off' (#246) from MAINT-24 into release/1.0
infra-pin / fresh (push) Successful in 3s
2026-11-12 16:09:32 +01:00
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
goodwe-agent 0f9c119bcf SAFETY-136 round 2: tests for override and direction, true wording
checks / unit (pull_request) Successful in 26m2s
infra-pin / fresh (pull_request) Successful in 2s
F1 the on-change case is tested as slow, F2 override and direction checks,
F3 the 3.75 s rule in the texts, F4 the quarter figures and the live note.
No behaviour change.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 20:51:06 +01:00
goodwe-agent c50e2279dd SAFETY-136: a gentler loop on a slow meter until the last move shows
checks / unit (pull_request) Successful in 26m18s
infra-pin / fresh (pull_request) Successful in 2s
On a meter whose readings come 4 s or more apart, the loop runs at half its
gain after a move of 150 W or more, until the meter has fallen by half of the
move or 2 cycles have passed. With readings 10 s late on a 5 s meter the export
falls 44 % over oven, kettle and hob, owner shape, steps; 1-2 s meters are
untouched.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-11-09 14:49:26 +01:00
admin 8508ac33f0 Merge pull request 'MAINT-21: split app/main.py Controller into ctl_*.py mixins' (#243) from MAINT-21 into release/1.0
infra-pin / fresh (push) Successful in 2s
2026-11-09 08:06:31 +01:00
28 changed files with 1953 additions and 35 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,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 that reaches the maintenance loop 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). `cycle()` has two returns before that loop, and the flag is kept through them on purpose: "inputs missing" and "readings late" (SAFETY-128) are holds of under `stale_after` seconds that write nothing and decide nothing, so the next full cycle sees the same charge and the same peak. A missed poll must not end a peak pause; a reset there would restart the charge on it (`_maint_shape_suspended` is kept through them the same way).
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). Measured alternative, A capped at 0 W while held and the guard idle (review scratch, same 45 min run): A gives 0.44 kWh in the load instead of 1.66, quarters 5919/5915 W, but A switches give/idle 32 times in 40 min. The uncapped give costs ~1.2 kWh round trip per 45-min peak (a few cents) and ~50 min more maintenance charge, far inside the 36 h timeout, so the steady give is kept.
## 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 15 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 1b5f7c9). The pause for a late or missing forecast.
@@ -71,7 +71,8 @@ The fits run all the time; the pull request has the counts.
**What it costs.** **What it costs.**
- Found 36-68 s after a lag starts at 1-2 s meters; at a 5 s meter 30 s late - Found 36-68 s after a lag starts at 1-2 s meters; at a 5 s meter 30 s late
is found within 100 s after it starts (88 s measured) (the loop first moves 100 W after 60 s, is found within 100 s after it starts (88 s measured) (the loop first moves 100 W after 60 s,
SAFETY-129) with the export above -200 W, and 10 s late is not found. Until SAFETY-129) with the export above -200 W, and 10 s late is not found (SAFETY-136 calms
the loop at a slow meter instead). Until
then control is as before. In a quiet house the "readings late" row stays then control is as before. In a quiet house the "readings late" row stays
about 2 minutes longer after the lag ends (126 s against 32 s on base); no about 2 minutes longer after the lag ends (126 s against 32 s on base); no
harm measured, and it clears as early as on base when the load changes. harm measured, and it clears as early as on base when the load changes.
@@ -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.
@@ -0,0 +1,81 @@
# SAFETY-136 - a slow meter gets a gentler loop until the last move shows
**Status: for review.** Code: `app/settle.py`, a call in `Controller.cycle()`
(`app/ctl_cycle.py`). Tests: `test_safety136.py`. No option, no text change.
**Problem.** On a 5 s meter, Home Assistant readings 10-15 s late are not
found (SAFETY-129): they look like a slow battery (R2-1), and a hold does not
help (a perfect one cut the export of the oven by 7 %, SAFETY-129 review).
With moving loads the loop answers the same stale error at each cycle and
overshoots: an oven, a kettle and hob, the owner's day or a step export 3-10
times what they do with no lag. The fix is a calmer loop, not a detector.
**Decision.** After a move of 150 W or more, a loop on a slow meter runs at half
its gain until the meter has fallen by half of that move, or for 2 cycles.
Slow is judged from the readings that arrive: the usual gap
between them, over the last 60 s and at least 5 readings, is over 3.75 s (the
limit of SAFETY-128's fast meter fit). It counts arrivals, not changes of
value: on the recorded day at 1-2 s the value-based test (`MeterFit.slow()`)
called 24 % of the cycles slow, because the recorded house repeats a value
for 5 s or more, and the commands then differed from base. So there is no
option. At a meter with a usual gap of 3.75 s or less (1-3 s) it is false, the tuning is never touched and the
commands are those of the tree before. A meter that Home Assistant passes on
only when its value changes looks as slow as its changes are far apart, as it
does to SAFETY-128's fits: a 1-2 s meter with a value that holds for 5 s or
more is then treated as slow, and the loop is gentler for it (the cost below).
With no lag the meter shows a move at the next reading, so the wait ends at
once.
**Candidates** (SAFETY-129 sim, real `cycle()`, 5 s meter, sum over oven,
kettle and hob, owner shape over the oven and over the heat pump, step down,
step up, flat; export Wh, lag 0 / 10 s / 15 s; base 64 / 340 / 363):
| Candidate, only at a slow meter | 0 s | 10 s | 15 s | Import Wh, 0 / 10 / 15 s |
|---|---|---|---|---|
| gain 0.4 | 97 | 201 | 299 | 97 / 224 / 301 |
| gain 0.3 | 128 | 185 | 322 | 132 / 232 / 293 |
| step limit 500 W | 118 | 285 | 293 | 110 / 325 / 324 |
| cut fast, raise slowly (0.6 / 0.3) | 55 | 140 | 226 | 140 / 332 / 345 |
| ignore 1 cycle after a move | 93 | 216 | 325 | 90 / 240 / 291 |
| ignore 2 cycles after a move, unless the meter showed half | 69 | 206 | 277 | 62 / 240 / 354 |
| **half gain for 2 cycles after a move, unless the meter showed half** | **67** | **189** | **296** | **62 / 223 / 288** |
A lower gain for good, a step limit and a fixed ignore all make a 5 s meter
with no lag worse (export +30-110 Wh, quarter peak up to 150 W): that is the
common case. Cutting fast and raising slowly moves export to import and peak.
Waiting only while the last move has not shown costs nothing with no lag.
Ignoring the meter (the 2-cycle line) was worse than half gain where the plant
is slow, not the readings late: a 12 s dead time with one inverter went from
250 to 841 Wh import and a quarter peak of 206 to 1 509 W, so the loop keeps
following at half gain.
**What it gives** (5 s meter; the pull request and the report have each
scenario). Export summed over the seven houses: 10 s late 340 -> 189 Wh
(-44 %, import 363 -> 223), 15 s late 363 -> 296 Wh (-19 %, import 281 ->
288), together -31 %. At 15 s the oven and the owner's oven are not better
(92 / 39 Wh against 91 / 37), the kettle and the steps are. No lag: export
64.4 -> 66.5 Wh (kettle +2), import and peak the same.
**What it costs.** The loop follows a real step more slowly: half gain for up
to two cycles after each move of 150 W or more. Of the 136 sim runs (every
SAFETY-129 review scenario and its 15 harm runs), 56 at 4-10 s meters differ
from base. Together their export is 10 363 -> 9 559 Wh (-8 %) and import
8 389 -> 7 835 Wh (-7 %). Only 5 export more than 1 Wh and 14 import more:
export by at most 11.5 Wh (a 10 s battery lag, +10 %), import by at most
17 Wh (a 12 s dead time, one inverter, +7 %), and the quarter average by at
most 35 W, except two import quarters: +73 W (10 s meter, 10 s dead time,
export -59 Wh) and +40 W (`dead10_p6`, -6 to 34 W, export -68 Wh). Slow
batteries with a dead time of 8-10 s export 5-39 % less; a 12 s dead time is
the same. `READINGS_LATE` holds are the same seconds in every run.
**Live, a 1-2 s meter can be judged slow.** Through Home Assistant on change,
a value that holds for 4-10 s makes a 1 s meter slow. In 20 rig scenarios the
commands were those of base. On the recorded day (1 s, N=1, 1 day, repeats
dropped; its values hold about 5 s) it was slow in 33 % of cycles, half gain
in 83 cycles, 15 422 commands in both trees, 4 016 with another value (at
most 190 W apart), cost 0.61 EUR in both (review of SAFETY-136).
**Does not change.** Meters of 1-2 s: the same commands in all 63 runs (34
at 1 s, 29 at 2 s), and the recorded-day digests (see the report). The late
verdict, the hold and the freeze tests. 15 s late is only partly helped, and
30 s late is the hold's (SAFETY-129).
+34 -8
View File
@@ -537,6 +537,10 @@ class CycleMixin:
# maintenance ⚠️ ONE PASS PER PACK, AND `arbitrate()` IS WHY THE LIST # maintenance ⚠️ ONE PASS PER PACK, AND `arbitrate()` IS WHY THE LIST
# STAYS SHORT. (DECISIONS/MAIN-orchestration-archive.md) # STAYS SHORT. (DECISIONS/MAIN-orchestration-archive.md)
# MAINT-28: the hold lives only while the charge-only branch is taken;
# kept through the two short holds above on purpose
# (DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md).
_peak_held, self._maint_peak_hold = self._maint_peak_hold, False
for inv, result in zip(self.inverters, maint_results): for inv, result in zip(self.inverters, maint_results):
if result.owns_setpoint: if result.owns_setpoint:
# PER-INVERTER: §12's recalibration is a PACK procedure, filed # PER-INVERTER: §12's recalibration is a PACK procedure, filed
@@ -559,8 +563,22 @@ class CycleMixin:
# peakguard.shaping_suspended(), NOT HERE. One suspension flag # peakguard.shaping_suspended(), NOT HERE. One suspension flag
# per SITE (`arbitrate()` allows one pack in CHARGE). # per SITE (`arbitrate()` allows one pack in CHARGE).
# (DECISIONS/MAIN-orchestration-archive.md) # (DECISIONS/MAIN-orchestration-archive.md)
if peakguard.shaping_suspended( # MAINT-24: the guard's aim, month peak included (SAFETY-80).
self.peak_guard, self.peak_fc, self.peak_aim_w()): # 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: hold the pause in a real peak.
# DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md
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 # Edge-logged: level-logging here would write one line per
# cycle for as long as the event lasts and flush the 40-line # cycle for as long as the event lasts and flush the 40-line
# action log of the very context that explains it. # action log of the very context that explains it.
@@ -582,7 +600,7 @@ class CycleMixin:
# (DECISIONS/MAIN-orchestration-archive.md) # (DECISIONS/MAIN-orchestration-archive.md)
floor = maintenance_charge_floor( floor = maintenance_charge_floor(
self.maint.cfg.charge_w, self.maint.cfg.charge_w,
self.peak_fc, self.peak_aim_w()) self.peak_fc, maint_aim)
floor = self._charge_headroom(floor, claims) floor = self._charge_headroom(floor, claims)
if floor > 0: if floor > 0:
hi, reason = -floor, "cheap-window charge" hi, reason = -floor, "cheap-window charge"
@@ -734,15 +752,21 @@ class CycleMixin:
# TEL-10. ⚠️ ONE NAME FOR THE COMMAND THE LOOP WAS MEASURED # TEL-10. ⚠️ ONE NAME FOR THE COMMAND THE LOOP WAS MEASURED
# AGAINST. (DECISIONS/MAIN-orchestration-archive.md) # AGAINST. (DECISIONS/MAIN-orchestration-archive.md)
loop_prev = self.target loop_prev = self.target
decision = compute(
prev_w=loop_prev,
# SAFETY-95: a drain's tail is not the loop's to chase. # SAFETY-95: a drain's tail is not the loop's to chase.
# OPT-15: `loop_grid` is the grid, or the target while a run # OPT-15: `loop_grid` is the grid, or the target while a run
# edge is held. # edge is held.
grid_w=(self._drain_grid_w(_live96) if self._drain_tail grid_in = (self._drain_grid_w(_live96) if self._drain_tail
else loop_grid), else loop_grid)
# SAFETY-136: on a slow meter, a gentler loop until the last move
# shows.
slow_meter = self.settle.slow()
if moved or mode_trade:
self.settle.reset()
decision = compute(
prev_w=loop_prev,
grid_w=grid_in,
actual_w=site_batt, actual_w=site_batt,
tuning=self.tuning, tuning=self.settle.tuning(self.tuning, grid_in, slow_meter),
sat_count=self.sat_count, sat_count=self.sat_count,
i_w=self.i_w, i_w=self.i_w,
# SAFETY-52 at one inverter only: with several, OPT-09's pack # SAFETY-52 at one inverter only: with several, OPT-09's pack
@@ -772,6 +796,7 @@ class CycleMixin:
self.log_event(f"saturation freeze ({self.target:.0f} W vs " self.log_event(f"saturation freeze ({self.target:.0f} W vs "
f"{site_batt:.0f} W)", f"{site_batt:.0f} W)",
hint=messages.hint("not_keeping_up")) hint=messages.hint("not_keeping_up"))
self.settle.moved(grid_in, loop_prev, decision.target_w, slow_meter)
self.sat_count = decision.sat_count self.sat_count = decision.sat_count
self.i_w = decision.i_w self.i_w = decision.i_w
self.loop_w = decision.target_w self.loop_w = decision.target_w
@@ -811,6 +836,7 @@ class CycleMixin:
else: else:
# Stopped or blind: no accumulation may survive the outage. # Stopped or blind: no accumulation may survive the outage.
self.i_w = 0.0 self.i_w = 0.0
self.settle.reset()
self.loop_w = None self.loop_w = None
self._live_was = frozenset(_live96) self._live_was = frozenset(_live96)
# TEL-10, and it is the same zero the line above writes rather than # TEL-10, and it is the same zero the line above writes rather than
+1
View File
@@ -197,6 +197,7 @@ class InputsMixin:
# correlates 0.998 with battery power and 0.09 with the real meter # correlates 0.998 with battery power and 0.09 with the real meter
# (a controller would regulate against its own output). # (a controller would regulate against its own output).
self.grid = self.p1.net_w self.grid = self.p1.net_w
self.settle.arrive(now_mono, self.p1.samples) # SAFETY-136
else: else:
self.grid = await self.hass.number(o.get("meter_entity", ""), self.grid = await self.hass.number(o.get("meter_entity", ""),
bool(o.get("meter_invert"))) bool(o.get("meter_invert")))
+46 -2
View File
@@ -2,6 +2,7 @@
Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md.""" Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md."""
import collections
import dataclasses import dataclasses
import logging import logging
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@@ -401,6 +402,18 @@ class TradeMixin:
quarter = int(now.timestamp() // 900) quarter = int(now.timestamp() // 900)
was = self.mode_tick is not None and \ was = self.mode_tick is not None and \
self.mode_tick.action == modetrade.CHARGE 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( self.mode_tick = modetrade.decide(
runs, now, packs, charge_ok=charge_ok, sell_ok=sell_ok, runs, now, packs, charge_ok=charge_ok, sell_ok=sell_ok,
trade_why=trade_why, price_now=price_now, trade_why=trade_why, price_now=price_now,
@@ -408,7 +421,10 @@ class TradeMixin:
ceiling_pct=ceiling_pct, house_w=house, charging_now=was, ceiling_pct=ceiling_pct, house_w=house, charging_now=was,
blocked=self._charge_blocked_q == quarter, blocked=self._charge_blocked_q == quarter,
done_charge=done_c, done_sell=done_s, targets=targets, 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) self._latch_charge(now, runs, targets, packs)
# A charge the guard or the headroom stopped stays off this period. # A charge the guard or the headroom stopped stays off this period.
# OPT-15: only a MEASURED stop latches; the first unmeasured headroom # 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 gap = why == "headroom" and self.arb_headroom_w is None
if why == "peak" or (why == "headroom" and not gap) or ( if why == "peak" or (why == "headroom" and not gap) or (
gap and self._charge_gap_q == quarter): gap and self._charge_gap_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 self._charge_blocked_q = quarter
elif gap: elif gap:
self._charge_gap_q = quarter self._charge_gap_q = quarter
return self.mode_tick 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: def _run_mark(self, kind, run, packs, out, extra=None) -> set:
"""The batteries that left run `run` (full, or at the floor); they """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. stay out until it ends, so a level edge does not re-arm them.
@@ -475,7 +518,8 @@ class TradeMixin:
None, 0.0, "size") None, 0.0, "size")
if size == 0.0 or runs is None: if size == 0.0 or runs is None:
reason = ("battery modes: no price run now" if tick.why 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( self.arb = arbitrage.ArbitrageDecision(
action=None, power_w=None, reason=reason, action=None, power_w=None, reason=reason,
spread_eur_kwh=None, required_eur_kwh=None, 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_why(shadow),
run_cycle_amount(hours))) run_cycle_amount(hours)))
continue 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 if not m.due(now) and (m.next_due(now) is None
or m.next_due(now) >= day1): or m.next_due(now) >= day1):
continue continue
@@ -1529,6 +1534,13 @@ def line_cycle_waits(name) -> str:
return _t("cycle_waits", name=name) 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: def line_no_prices(since, now) -> str:
"""PRICE-01: no price covers now. `since` is an instant or None.""" """PRICE-01: no price covers now. `since` is an instant or None."""
if since is None: if since is None:
@@ -1669,6 +1681,8 @@ def _samples() -> list:
("plan", run_house_why()), ("plan", run_house_why(True)), ("plan", run_house_why()), ("plan", run_house_why(True)),
("plan", run_house_amount()), ("plan", run_house_amount()),
("plan", line_cycle_waits("Battery B")), ("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, ("plan", line_no_prices(None, datetime(2026, 10, 14, 9, 0,
tzinfo=timezone.utc))), tzinfo=timezone.utc))),
("plan", line_no_prices(datetime(2026, 10, 14, 0, 0, ("plan", line_no_prices(datetime(2026, 10, 14, 0, 0,
+6
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.""" ctl_tasks (MAINT-21), the status-row functions in rows."""
import asyncio import asyncio
import collections
import contextlib import contextlib
import dataclasses import dataclasses
import functools import functools
@@ -63,6 +64,7 @@ from .clamp import (
) )
from . import freshness from . import freshness
from . import lateness from . import lateness
from .settle import Settle
from . import dayview, homeview from . import dayview, homeview
from .daycurve import SolarToday from .daycurve import SolarToday
from . import handback from . import handback
@@ -503,6 +505,7 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
self._land_batt_prev = None self._land_batt_prev = None
self._peak_needed = False self._peak_needed = False
self._maint_shape_suspended = 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 # 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, # battery's mode and on/off switch live in the store, not in options,
# so a change applies at once (`pack_use`, `pack_mode`). # so a change applies at once (`pack_use`, `pack_mode`).
@@ -527,6 +530,8 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
self._mode_trade_was = False self._mode_trade_was = False
self._charge_blocked_q = None self._charge_blocked_q = None
self._charge_gap_q = None # OPT-15: an unmeasured stop's quarter 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 self._ref_cheap = None
# OPT-15: a run edge the loop holds at E: (E, step sign, until, the # OPT-15: a run edge the loop holds at E: (E, step sign, until, the
# last battery reading). # last battery reading).
@@ -741,6 +746,7 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
self._arb_action = None self._arb_action = None
self.inputs_bad_since = None self.inputs_bad_since = None
self.answer = lateness.Answer() # SAFETY-128 self.answer = lateness.Answer() # SAFETY-128
self.settle = Settle() # SAFETY-136
self._late_logged = self._late_zero = False self._late_logged = self._late_zero = False
# SAFETY-03: what the loop's OWN bounds did last cycle, `(engaged, # SAFETY-03: what the loop's OWN bounds did last cycle, `(engaged,
# note, over_rating)` (SAFETY-40 splits the last out). Site-level: the # note, over_rating)` (SAFETY-40 splits the last out). Site-level: the
+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 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 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); (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 * 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 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); 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 the probe (none: PROBE_W alone), so a probe never places charge on a
battery in a maintenance phase or on one that rests; battery in a maintenance phase or on one that rests;
* no grid charge while another battery drains for its monthly cycle * 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 from dataclasses import dataclass
@@ -69,6 +75,18 @@ PRICE_MODES = (LEFTOVER_SOLAR, CHEAP_HOURS)
# would stop covering the house for less than they give. # would stop covering the house for less than they give.
START_W = 500.0 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) @dataclass(frozen=True)
class Pack: 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, def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
headroom_w, floor_pct, ceiling_pct, house_w, charging_now=False, headroom_w, floor_pct, ceiling_pct, house_w, charging_now=False,
blocked=False, done_charge=(), done_sell=(), 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. """This tick's ranges, roles and site ask.
`runs` is the kept `priceruns.RunPlan` or None (no fresh prices). `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 it going); `blocked` that the guard or the headroom stopped a charge in
this 15-minute period. `targets` is the day plan (`charge_to()`). this 15-minute period. `targets` is the day plan (`charge_to()`).
`draining` says a battery drains for its monthly cycle, so none may `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 plan_on = runs is not None and runs.on
in_cheap = plan_on and runs.cheap.holds(now) and runs.charge_useful() 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 in_cheap else []
if ch and draining: if ch and draining:
ch, why = [], "cycle" 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 charging = False
if ch: if ch:
need = 0.0 if charging_now else START_W 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 why = trade_why
elif headroom_w is None or not headroom_w > need: elif headroom_w is None or not headroom_w > need:
why = "headroom" why = "headroom"
elif wait and not charging_now:
why = wait
else: else:
charging = True charging = True
se = sellers(packs, floor_pct, done_sell) if in_sell and not charging \ se = sellers(packs, floor_pct, done_sell) if in_sell and not charging \
+18
View File
@@ -28,6 +28,10 @@ FLOOR_AT_SOC_FLOOR = "the battery level is at or below the peak reserve"
SOURCE = "peak" 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 # Mirror of config.yaml's peak options, pinned to the file by
# test_peakguard.py. Not a read: `decide()` takes them as arguments. # test_peakguard.py. Not a read: `decide()` takes them as arguments.
PEAK_OPTION_DEFAULTS = { 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 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): def cap_floor(decision, cap_w):
"""SAFETY-128: the floor no higher than `cap_w` (the command standing while """SAFETY-128: the floor no higher than `cap_w` (the command standing while
the readings have not answered it), or `decision` unchanged when None. the readings have not answered it), or `decision` unchanged when None.
+74
View File
@@ -0,0 +1,74 @@
"""SAFETY-136: on a slow meter, follow the meter gently until the last move shows.
A meter whose usual gap between readings is over 3.75 s (a 4-5 s meter), read
through Home Assistant, can be 10-15 s late. The loop then answers the same error again at each cycle
and overshoots. After a move of MOVE_W or more the loop therefore runs at
WAIT_SHARE of its gain until the meter has fallen SHOWN of the move, or for at
most WAIT_CYCLES cycles. Off for a meter with a usual gap of 3.75 s or less, so those sites run as
before. DECISIONS/SAFETY-136-slow-meter-settle.md.
"""
from collections import deque
from dataclasses import replace
from .lateness import M_FIT_S, M_ROWS
# A meter is slow when its readings come further apart than this (the usual
# gap, over the last 60 s and at least 5 readings): the fast meter fit's own
# limit (SAFETY-128), 3.75 s.
SLOW_GAP_S = M_FIT_S / M_ROWS
SLOW_ROWS = 5
# A move this big is waited for (above the house noise of about 80 W).
MOVE_W = 150.0
# The meter has shown a move once it fell by this share of it.
SHOWN = 0.5
# Meanwhile the loop runs at this share of its gain.
WAIT_SHARE = 0.5
# Wait at most this many cycles (10 s at a 5 s meter).
WAIT_CYCLES = 2
class Settle:
def __init__(self):
self.pend = None # (meter at the move, the move in W, cycles waited)
self._n = None # readings taken in so far
self._at = deque() # when the last ones arrived, over M_FIT_S
def arrive(self, now, n):
"""Each read: `n` is the count of meter readings taken in. A reading
counts when it arrives, not when its value changes, so a steady load on
a 1 s meter is not a slow meter."""
if n != self._n:
self._n = n
self._at.append(now)
while self._at and self._at[0] < now - M_FIT_S:
self._at.popleft()
def slow(self):
"""The usual gap between readings is over SLOW_GAP_S."""
ts = list(self._at)
if len(ts) < SLOW_ROWS:
return False
gaps = sorted(b - a for a, b in zip(ts, ts[1:]))
return gaps[len(gaps) // 2] > SLOW_GAP_S
def reset(self):
self.pend = None
def tuning(self, tuning, grid_w, slow):
"""The tuning for this cycle: `tuning`, or with the gain cut while the
last move has not shown on the meter (`grid_w`)."""
p = self.pend
if p is None:
return tuning
g0, move, waited = p
if not slow or (g0 - grid_w) / move >= SHOWN or waited >= WAIT_CYCLES:
self.pend = None
return tuning
self.pend = (g0, move, waited + 1)
return replace(tuning, gain=tuning.gain * WAIT_SHARE)
def moved(self, grid_w, prev_w, new_w, slow):
"""After a cycle: start waiting if the loop moved by MOVE_W or more."""
if slow and self.pend is None and abs(new_w - prev_w) >= MOVE_W:
self.pend = (float(grid_w), float(new_w - prev_w), 0)
+1
View File
@@ -430,6 +430,7 @@
"give": "Tomorrow: gives power at {at}, {eur} per kWh.", "give": "Tomorrow: gives power at {at}, {eur} per kWh.",
"give_would": "Tomorrow: would give 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.", "cycle_waits": "{name} waits its turn for the monthly cycle.",
"no_prices_yet": "No prices yet, maintenance waits.", "no_prices_yet": "No prices yet, maintenance waits.",
"no_prices_since": "No prices since {at}, 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." "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."}, "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.", "cycle_waits": "{name} wacht zijn beurt af voor de maandelijkse cyclus.",
"no_prices_yet": "Nog geen prijzen, onderhoud wacht.", "no_prices_yet": "Nog geen prijzen, onderhoud wacht.",
"no_prices_since": "Geen prijzen sinds {at}, 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")
+288
View File
@@ -0,0 +1,288 @@
"""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 15 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 opt11p3_replay as rp # noqa: E402
import opt20_scen as sc # noqa: E402
from app import ctl_cycle, 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 = 120.0, 900.0, 120.0 # s (CI time: short runs)
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=600.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:
# the replay's clock; not main_files.live (it parses
# every app file per access: ~0.1 s a cycle).
now = ctl_cycle.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 15 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")
# The idle window opens on a quarter boundary, so the new charge meets a
# fresh quarter whose projection (house 5700 W) is under the aim.
HI_START, HI_END, IDLE_END, B_END = 300.0, 900.0, 1000.0, 1300.0
def _house_b():
rnd = random.Random(29)
out = [(sc.UTC0.timestamp() - 10, 3000.0)]
for t in range(0, int(B_END) + 100, 5):
w = 3000.0 if t < HI_START 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=B_END)
held_before = [r for r in rb if HI_START <= 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"] < B_END]
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": "f9053833891794ead8b3f44db4030ded8c4b43681e25e8a19882105d3593f20d",
"n1_peak": "3e451c536bea8270469fffe578c5a7199431e0e7ba4902413406b0afe2bb0fd1",
"n1": "02ecc3011c559df050ae5321ceb6b24cf79342ae35f1a289fbd1a749c86f1d10",
"drain": "bfcf9a643dfffff21d0a8417426d2088377a8f64afbc544732b1635a77775d8a",
"off": "6f4ec14578d8ed59fc7721485d037f3c1fb086abae7c52f28c9e9155cf2107b0",
}
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]}")
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")
+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, ROWS_W, _, _ = run_day("p3_ch_sell", hours=1.5, house=_sw,
soc0={"a": 60.0, "b": 30.0}) soc0={"a": 60.0, "b": 30.0})
_per_q = {} _per_q = {}
_ev_q = {} # SAFETY-135: per period, the charge's starts and stops
for a_, b_ in zip(ROWS_W, ROWS_W[1:]): 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) q = int(b_["now"].timestamp() // 900)
_per_q[q] = _per_q.get(q, 0) + 1 _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 " print(f" house swinging 2.0/3.9 kW each minute: charge starts per period "
f"{sorted(_per_q.values())}") f"{sorted(_per_q.values())}")
check("a charge the peak guard or the headroom stopped stays off for the " 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)", "rest of that 15-minute period, after at most one restart RESTART_S or "
_per_q and max(_per_q.values()) == 1) "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. # At the floor: B starts the sale just above it.
ROWS_F, _, _ = run_day("p3_ch_sell", start_h=18.75, hours=1.5, 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 # 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 # that hold and its reseed switched off they equal 5a4cdc2's again; the others
# (no charge) never moved. # (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 = { BASE_DIGESTS = {
"n1_ls": "n1_ls":
"13f11c330263c91625acde7628426ff16dae3a9af99aebeef3051dc5b5a884ea", "13f11c330263c91625acde7628426ff16dae3a9af99aebeef3051dc5b5a884ea",
"n1_ch": "n1_ch":
"97893a54f8a8e3f36ad24f100973b3ff35766527e7287a6ae30cc1c8941bac02", "5d529f516307e4da4dca48714ee891332a2eea5e1b1763f0f1e049cfb0819a0a",
"n2_sf_ls": "n2_sf_ls":
"9424e1837b805a6a31293a28b8fbad6ba611ea0bfe3240d636d68d62dc5e9f22", "9424e1837b805a6a31293a28b8fbad6ba611ea0bfe3240d636d68d62dc5e9f22",
"n2_ch_ls": "n2_ch_ls":
@@ -81,7 +86,7 @@ BASE_DIGESTS = {
"n1_ls_fc_down": "n1_ls_fc_down":
"13f11c330263c91625acde7628426ff16dae3a9af99aebeef3051dc5b5a884ea", "13f11c330263c91625acde7628426ff16dae3a9af99aebeef3051dc5b5a884ea",
"n1_ch_fc_down": "n1_ch_fc_down":
"97893a54f8a8e3f36ad24f100973b3ff35766527e7287a6ae30cc1c8941bac02", "5d529f516307e4da4dca48714ee891332a2eea5e1b1763f0f1e049cfb0819a0a",
"n2_sf_ls_fc_down": "n2_sf_ls_fc_down":
"9424e1837b805a6a31293a28b8fbad6ba611ea0bfe3240d636d68d62dc5e9f22", "9424e1837b805a6a31293a28b8fbad6ba611ea0bfe3240d636d68d62dc5e9f22",
"n2_ch_ls_fc_down": "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)", check(f"{k}: pinned digest (5a4cdc2; OPT-15 for the _ch ones)",
got.get(k) == v) got.get(k) == v)
check("every scenario is pinned", set(got) == set(BASE_DIGESTS)) 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")]: for k in [k for k in got if k.endswith("_fc_down")]:
check(f"{k}: a set but unavailable forecast equals no forecast", check(f"{k}: a set but unavailable forecast equals no forecast",
got[k] == got[k[:-len('_fc_down')]]) 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 # 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, # 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...). # 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 = { AT_BASE = {
"sf_ch": "422d6578a7decf1ae3749edd1898ff3cd0541d904c95f636cdd9b1de8c1a07c9", "sf_ch": "422d6578a7decf1ae3749edd1898ff3cd0541d904c95f636cdd9b1de8c1a07c9",
"ch_ch": "2b5879ec95810c39a5f0f82c04b9f8e358ea03ddce4ba187cc2b7e0885dfcbfa", "ch_ch": "2b5879ec95810c39a5f0f82c04b9f8e358ea03ddce4ba187cc2b7e0885dfcbfa",
"ch_ch_maint": "ch_ch_maint":
"e56eddabdb785f81793e7afa525f8e6da237b0b2165860aa8e0f8a6499b275a9", "df86a451a3b2d365ca5d35b772cd1eb8502d164e6b63e6844b5cfb82f15aeb46",
} }
_got = sc.no_probe_digests() _got = sc.no_probe_digests()
for _k in AT_BASE: for _k in AT_BASE:
@@ -260,8 +263,8 @@ for _k in AT_BASE:
f"{_got[_k][:16]}") f"{_got[_k][:16]}")
check("with no refusal every share, grid, trade sentence, role and plant " 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 " "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 " "charging, A in maintenance charge; the last two as SAFETY-125 and "
"them)", _got == AT_BASE) "MAINT-24 moved them)", _got == AT_BASE)
print() print()
if fails: 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" shaping_suspended(..., {arg(_s, 2)})")
print(f" maintenance_charge_floor(..., {arg(_m, 2)})") print(f" maintenance_charge_floor(..., {arg(_m, 2)})")
print(f" PeakTracker(cap_w={kw(_t, 'cap_w')})") 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 " 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 " "forecast checks get the guard's aim, cap - margin and the month "
"real cap", "peak (MAINT-24); the month-peak tracker gets the real cap",
len(_dec) == 1 and "peak_cap_w" in kw(_d, "cap_w") len(_dec) == 1 and "peak_cap_w" in kw(_d, "cap_w")
and kw(_d, "margin_w") == "self.peak_margin_w" and kw(_d, "margin_w") == "self.peak_margin_w"
and len(_shape) == 1 and len(_maint) == 1 and len(_shape) == 1 and len(_maint) == 1
and arg(_s, 2) == "self.peak_aim_w()" and arg(_s, 2) == "maint_aim" and arg(_m, 2) == "maint_aim"
and arg(_m, 2) == "self.peak_aim_w()" and "maint_aim = peakguard.quarter_target_w(" in _src
and len(_tracker) == 1 and len(_tracker) == 1
and kw(_t, "cap_w") == 'float(opts.get("peak_cap_w", 3500))') 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 " 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. 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 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 At two inverters SAFETY-91's `next_quarter()` already does that for the
guard; at one inverter only the rolled projection does. 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 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", "projection), and rolling it changes no banked quarter",
not stops(ROWS1O) and banked(C1O, ROWS1O) == banked(C1, ROWS1)) 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 # 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). # in the next quarter); 2850 W from 02:40 (600 W).
ROWS2, C2 = run(1.9, 0.9, lag=LAG, steps=[ 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 " print(f" guard stop at "
f"{_first['now'].astimezone(L).strftime('%H:%M:%S') if _first else None}" f"{_first['now'].astimezone(L).strftime('%H:%M:%S') if _first else None}"
f" ({_first['reason'][:40] if _first else ''})") f" ({_first['reason'][:40] if _first else ''})")
check("a mid-quarter 3.7 kW load stops the charge for the peak and it stays " # SAFETY-135: the first guard stop in a quarter holds the charge RESTART_S,
"off for the rest of that quarter, the house back at 800 W", # 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 _first is not None and _first["now"].astimezone(L).minute == 20
and ("peak" in _first["reason"] or "headroom" in _first["reason"]) 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:]) _starts = [b for a, b in zip(ROWS1 + ROWS2, (ROWS1 + ROWS2)[1:])
if a["action"] != arbitrage.CHARGE if a["action"] != arbitrage.CHARGE
and b["action"] == arbitrage.CHARGE] and b["action"] == arbitrage.CHARGE]
+13 -1
View File
@@ -38,6 +38,7 @@ logging.disable(logging.WARNING)
import safety128_rig as R # noqa: E402 import safety128_rig as R # noqa: E402
import app.main as app_main # noqa: E402 import app.main as app_main # noqa: E402
from app import lateness # noqa: E402 from app import lateness # noqa: E402
from app import settle # noqa: E402
fails = [] fails = []
@@ -58,6 +59,17 @@ def no_gate(fn):
app_main.Controller._readings_late = real app_main.Controller._readings_late = real
def no_settle(fn):
"""`fn()` without SAFETY-136's gentler loop: the tree this card was written
against (with it the same run exports -645 W, not -1 325 W)."""
real = settle.Settle.moved
settle.Settle.moved = lambda self, *a, **k: None
try:
return fn()
finally:
settle.Settle.moved = real
def run_late(**kw): def run_late(**kw):
"""Run the rig; (rig, seconds the verdict stood, first second it stood).""" """Run the rig; (rig, seconds the verdict stood, first second it stood)."""
dur = kw.pop("dur") dur = kw.pop("dur")
@@ -91,7 +103,7 @@ for units, house in ((AB, 1800.0), (A, 1500.0)):
if len(units) == 2: if len(units) == 2:
old = R.Rig(house=house, lag_s=30.0, lag_on=ON, lag_off=ON + 300.0, old = R.Rig(house=house, lag_s=30.0, lag_on=ON, lag_off=ON + 300.0,
units=units, p1_s=5.0) units=units, p1_s=5.0)
no_gate(lambda: old.run(ON + 300.0)) no_settle(lambda: no_gate(lambda: old.run(ON + 300.0)))
check(f"{n}: without the gate the export goes below -1 kW", check(f"{n}: without the gate the export goes below -1 kW",
grid_min(old.rows, ON, ON + 300.0) < -1000.0) grid_min(old.rows, ON, ON + 300.0) < -1000.0)
+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")
+263
View File
@@ -0,0 +1,263 @@
"""Runnable check for SAFETY-136: a calmer loop on a slow meter.
`python3 test_safety136.py`
With a 5 s meter and Home Assistant readings 10-15 s late the loop answers the
same error again at each cycle and overshoots: with an oven, a kettle and hob,
or a step the export was 3-10 times that of a meter with no lag. After a move
of 150 W or more `app/settle.py` now runs the loop at half its gain (at most
2 cycles) until the meter shows half of it. It is off for a meter under 4 s.
DECISIONS/SAFETY-136-slow-meter-settle.md.
The REAL Controller on TEST-23's plant (tools/safety128_rig.py). "Off" is the
same run with `Settle.moved` doing nothing: the tree before this card.
1. `Settle` alone.
2. 5 s meter, readings 10 s late: oven, kettle and hob, a step down and a step
up export clearly less than with it off.
3. 5 s meter, no lag: the same houses are no worse than a small margin.
4. 1 s and 2 s meters: the commands are those of the run with it off, and the
loop never waits.
5. The slow meter is told by its observed interval, at 4 s and 5 s.
Mutations (MUTATION.md, purged copies) are listed in the pull request.
"""
import logging
import os
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 safety128_rig as R # noqa: E402
from app.control import Tuning # noqa: E402
from named_absence import nth, present # noqa: E402
from app import settle # noqa: E402
from app.lateness import MeterFit # noqa: E402
fails = []
def check(name, cond):
print(f" {'ok ' if cond else 'FAIL'} {name}")
if not cond:
fails.append(name)
def off(fn):
"""`fn()` with the settle wait off: the tree before this card."""
real = settle.Settle.moved
settle.Settle.moved = lambda self, *a, **k: None
try:
return fn()
finally:
settle.Settle.moved = real
def oven(t):
return 1800.0 + (2000.0 if (t >= 300.0 and (t - 300.0) % 105 < 45) else 0.0)
def kettle(t):
w = 600.0
if t % 400 < 120:
w += 2200
if t >= 900 and t % 90 < 30:
w += 1500
return w
def step(t):
return 1800.0 if t < 320.0 else 300.0
def stepup(t):
return 300.0 if t < 320.0 else 2500.0
# name: (house, noise, run length, lag end)
HOUSES = {"oven": (oven, 80.0, 900.0, 900.0),
"kettle": (kettle, 80.0, 1500.0, 1500.0),
"step": (step, 50.0, 700.0, 600.0),
"stepup": (stepup, 50.0, 700.0, 600.0)}
ON = 300.0
def wh(rows, t0, t1, sign):
"""Export (sign -1) or import (sign +1) energy in Wh over [t0, t1)."""
return sum((-min(r[1], 0.0) if sign < 0 else max(r[1], 0.0))
for r in rows if t0 <= r[0] < t1) / 3600.0
def run(name, lag, p1_s=5.0, units=("a", "b"), dur=None):
house, noise, length, lag_off = HOUSES[name]
r = R.Rig(house=house, noise=noise, p1_s=p1_s, units=units, lag_s=lag,
lag_on=ON, lag_off=lag_off)
waits = []
r.run(dur or length + 20.0,
lambda rig, c, t: waits.append(1) if c.settle.pend else None)
return r, len(waits)
# --------------------------------------------------------------------------- #
print("1. Settle alone")
T = Tuning()
s = settle.Settle()
check("no move: the tuning is untouched", s.tuning(T, 500.0, True) is T)
s.moved(500.0, 0.0, 100.0, True)
check("a move under 150 W is not waited for", s.pend is None)
s.moved(500.0, 0.0, 600.0, False)
check("a meter that is not slow is not waited for", s.pend is None)
s.moved(500.0, 0.0, 600.0, True)
check("a move of 600 W is waited for: half the gain",
s.tuning(T, 500.0, True).gain == T.gain * 0.5)
check("the meter fell 200 W of 600 W: still half the gain",
s.tuning(T, 300.0, True).gain == T.gain * 0.5)
check("two cycles waited: the third has the whole gain",
s.tuning(T, 300.0, True) is T and s.pend is None)
s.moved(500.0, 0.0, 600.0, True)
check("the meter went the other way by half the move: the wait stands",
s.tuning(T, 800.0, True).gain == T.gain * 0.5)
s.reset()
s.moved(500.0, 0.0, -600.0, True)
check("a move down, the meter rose half the move: the whole gain",
s.tuning(T, 800.0, True) is T)
s.reset()
s.moved(500.0, 0.0, 600.0, True)
check("the meter fell half the move: the whole gain at once",
s.tuning(T, 200.0, True) is T and s.pend is None)
s.moved(500.0, 0.0, -600.0, True)
check("a move down is waited for too",
s.tuning(T, 500.0, True).gain == T.gain * 0.5)
s.reset()
check("reset ends the wait", s.pend is None)
s.moved(500.0, 0.0, 600.0, True)
check("a meter that turns out not to be slow ends the wait",
s.tuning(T, 500.0, False) is T and s.pend is None)
check("the numbers are pinned",
(settle.MOVE_W, settle.SHOWN, settle.WAIT_SHARE, settle.WAIT_CYCLES)
== (150.0, 0.5, 0.5, 2))
# --------------------------------------------------------------------------- #
print("\n2. 5 s meter, readings 10 s late")
tot_on = tot_off = 0.0
for name, (_h, _n, length, lag_off) in HOUSES.items():
a, waited = run(name, 10.0)
b, _w = off(lambda: run(name, 10.0))
ea, eb = wh(a.rows, ON, lag_off, -1), wh(b.rows, ON, lag_off, -1)
tot_on, tot_off = tot_on + ea, tot_off + eb
print(f" {name}: export {ea:.1f} Wh, off {eb:.1f} Wh, waited {waited} s")
check(f"{name}: waits at some point", waited > 0)
check(f"{name}: exports at most 75 % of the run with it off",
ea <= 0.75 * eb)
print(f" sum: {tot_on:.1f} Wh, off {tot_off:.1f} Wh")
check("the four together export at most 65 % of the run with it off",
tot_on <= 0.65 * tot_off)
# --------------------------------------------------------------------------- #
print("\n2b. Another writer overrides the loop: the wait ends")
halved = []
real_tuning = settle.Settle.tuning
def watch_tuning(self, tuning, grid_w, slow):
r = real_tuning(self, tuning, grid_w, slow)
halved.append(r is not tuning)
return r
settle.Settle.tuning = watch_tuning
try:
mark = []
def bump(rig, c, t):
# right after a cycle that started a wait, another writer moves the
# command, so the next cycle sees `target != loop_w`
if not mark and c.settle.pend and t > 330.0:
mark.append(len(halved))
c.target = c.target + 300.0
r = R.Rig(house=oven, noise=80.0, p1_s=5.0, lag_s=10.0, lag_on=ON,
lag_off=900.0)
r.run(500.0, bump)
finally:
settle.Settle.tuning = real_tuning
nxt = nth(halved, mark[0] if mark else 10 ** 6)
check("the cycle after an override has the whole gain",
bool(mark) and present(nxt) and nxt is False)
# --------------------------------------------------------------------------- #
print("\n3. 5 s meter, no lag")
for name in ("oven", "kettle", "step", "stepup"):
_h, _n, length, lag_off = HOUSES[name]
a, _w = run(name, 0.0)
b, _w = off(lambda: run(name, 0.0))
ea, eb = wh(a.rows, ON, lag_off, -1), wh(b.rows, ON, lag_off, -1)
ia, ib = wh(a.rows, ON, lag_off, 1), wh(b.rows, ON, lag_off, 1)
print(f" {name}: export {ea:.1f} Wh (off {eb:.1f}), import {ia:.1f} Wh "
f"(off {ib:.1f})")
check(f"{name}: export at most 6 Wh over the run with it off",
ea <= eb + 6.0)
check(f"{name}: import at most 1 Wh over the run with it off",
ia <= ib + 1.0)
# --------------------------------------------------------------------------- #
print("\n4. 1 s and 2 s meters")
for p1 in (1.0, 2.0):
for lag in (0.0, 10.0):
for units in (("a", "b"), ("a",)):
a, waited = run("oven", lag, p1_s=p1, units=units, dur=700.0)
b, _w = off(lambda: run("oven", lag, p1_s=p1, units=units,
dur=700.0))
same = ([(r[0], r[1], r[2]) for r in a.rows]
== [(r[0], r[1], r[2]) for r in b.rows])
check(f"{p1:.0f} s meter, {lag:.0f} s late, N={len(units)}: "
f"same commands, never waits", same and waited == 0)
# --------------------------------------------------------------------------- #
print("\n5. The slow meter is told by its observed interval")
s = settle.Settle()
for k in range(30):
s.arrive(float(k), k)
check("a reading each second is not a slow meter", not s.slow())
s = settle.Settle()
for k in range(30):
s.arrive(5.0 * k, k)
check("a reading every 5 s is a slow meter", s.slow())
s = settle.Settle()
for k in range(4):
s.arrive(5.0 * k, k)
check("fewer than 5 readings in a minute tell nothing", not s.slow())
s = settle.Settle()
for k in range(30):
s.arrive(5.0 * k, 0 if k else 1)
check("the same count again is no new reading", not s.slow())
for p1, expect in ((1.0, False), (2.0, False), (3.0, False), (4.0, True),
(5.0, True), (10.0, True)):
r = R.Rig(house=1800.0, p1_s=p1)
seen = []
r.run(120.0, lambda rig, c, t: seen.append(c.settle.slow()))
last = nth(seen, -1)
check(f"{p1:.0f} s meter: slow is {expect}",
present(last) and last is expect)
# A 1 s meter on a house that changes every 5 s repeats its value 4 times in 5.
# Where every reading arrives (the rig, a polled meter) it is not slow; where
# Home Assistant sends a reading only when the value changes (ha_signed) the
# same meter is slow (T3 of the review; DECISIONS).
s, m, h = settle.Settle(), MeterFit(), settle.Settle()
for k in range(60):
s.arrive(float(k), k)
h.arrive(float(k), k // 5)
m.add(float(k), 0.0, 1800.0 + 100.0 * (k // 5))
check("every reading arrives, value changes every 5 s: not slow (the value "
"fit says slow)", not s.slow() and m.slow())
check("the same meter through Home Assistant on change (a reading per "
"change): slow", h.slow())
print()
if fails:
print(f"{len(fails)} FAILED: " + ", ".join(fails))
sys.exit(1)
print("all checks passed")
+5 -1
View File
@@ -481,6 +481,10 @@ print("\n8. the N=1 and N=2 digests")
# quarter's room, as at N=2, so the guard no longer steps in and discharges. # quarter's room, as at N=2, so the guard no longer steps in and discharges.
# The banked quarters go 3461.9 -> 3450.0 W (aim 3450) and 3938.9 -> 3950.0 # The banked quarters go 3461.9 -> 3450.0 W (aim 3450) and 3938.9 -> 3950.0
# W (aim 3950). n1_charge_dear (no cheap window) is unchanged. # W (aim 3950). n1_charge_dear (no cheap window) is unchanged.
# MAINT-28 re-pinned n2_charge_banked: in the 5000 W load (over the 3950 W
# aim) the maintenance charge no longer restarts at -125 W after each guard
# stop (t=1550 s, 64 of 480 rows); the pause is held until the load is gone
# (DECISIONS/MAINT-28-hold-the-pause-in-a-peak.md). The other five are equal.
PINNED = { PINNED = {
"n1_charge": "n1_charge":
"00f0a16b3ac1bd2830e9e0aaac8923fd9d1736c2bc6c543669bdd35b2fb542e9", "00f0a16b3ac1bd2830e9e0aaac8923fd9d1736c2bc6c543669bdd35b2fb542e9",
@@ -493,7 +497,7 @@ PINNED = {
"n2_charge_dear": "n2_charge_dear":
"aa2bbc677a8e2274b25ba1170de1b1635e08a3bd93105e2a38a9bb1575b5b6db", "aa2bbc677a8e2274b25ba1170de1b1635e08a3bd93105e2a38a9bb1575b5b6db",
"n2_charge_banked": "n2_charge_banked":
"a6973faea916e4c88ca1770047f94478346eeb1efa4889b154fad0747f91267f", "5d523562219245c135a4e9aa856826bc2939a3157c6ae0320cafc2cd1a8a6cf1",
} }
_now = s84.digests() _now = s84.digests()
for k in PINNED: for k in PINNED: