Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b80cfa969f | ||
|
|
6004bfad9a | ||
|
|
b44a648cab | ||
|
|
0f5e513f1d | ||
|
|
54a738dd87 | ||
|
|
02eac3b84e | ||
|
|
4066624cce | ||
|
|
66d8777b39 | ||
|
|
a8ecf171d6 | ||
|
|
0f9c119bcf | ||
|
|
c50e2279dd | ||
|
|
8508ac33f0 |
@@ -0,0 +1,70 @@
|
||||
# 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.
|
||||
@@ -71,7 +71,8 @@ The fits run all the time; the pull request has the counts.
|
||||
**What it costs.**
|
||||
- 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,
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -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).
|
||||
@@ -559,8 +559,13 @@ class CycleMixin:
|
||||
# peakguard.shaping_suspended(), NOT HERE. One suspension flag
|
||||
# per SITE (`arbitrate()` allows one pack in CHARGE).
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
# MAINT-24: the guard's aim, month peak included (SAFETY-80).
|
||||
# DECISIONS/MAINT-24-charge-first.md
|
||||
maint_aim = peakguard.quarter_target_w(
|
||||
float(self.o.get("peak_cap_w", 3500)),
|
||||
self.peak_margin_w, _reading)
|
||||
if peakguard.shaping_suspended(
|
||||
self.peak_guard, self.peak_fc, self.peak_aim_w()):
|
||||
self.peak_guard, self.peak_fc, maint_aim):
|
||||
# Edge-logged: level-logging here would write one line per
|
||||
# cycle for as long as the event lasts and flush the 40-line
|
||||
# action log of the very context that explains it.
|
||||
@@ -582,7 +587,7 @@ class CycleMixin:
|
||||
# (DECISIONS/MAIN-orchestration-archive.md)
|
||||
floor = maintenance_charge_floor(
|
||||
self.maint.cfg.charge_w,
|
||||
self.peak_fc, self.peak_aim_w())
|
||||
self.peak_fc, maint_aim)
|
||||
floor = self._charge_headroom(floor, claims)
|
||||
if floor > 0:
|
||||
hi, reason = -floor, "cheap-window charge"
|
||||
@@ -734,15 +739,21 @@ class CycleMixin:
|
||||
# TEL-10. ⚠️ ONE NAME FOR THE COMMAND THE LOOP WAS MEASURED
|
||||
# AGAINST. (DECISIONS/MAIN-orchestration-archive.md)
|
||||
loop_prev = self.target
|
||||
# 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
|
||||
# edge is held.
|
||||
grid_in = (self._drain_grid_w(_live96) if self._drain_tail
|
||||
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,
|
||||
# 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
|
||||
# edge is held.
|
||||
grid_w=(self._drain_grid_w(_live96) if self._drain_tail
|
||||
else loop_grid),
|
||||
grid_w=grid_in,
|
||||
actual_w=site_batt,
|
||||
tuning=self.tuning,
|
||||
tuning=self.settle.tuning(self.tuning, grid_in, slow_meter),
|
||||
sat_count=self.sat_count,
|
||||
i_w=self.i_w,
|
||||
# SAFETY-52 at one inverter only: with several, OPT-09's pack
|
||||
@@ -772,6 +783,7 @@ class CycleMixin:
|
||||
self.log_event(f"saturation freeze ({self.target:.0f} W vs "
|
||||
f"{site_batt:.0f} W)",
|
||||
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.i_w = decision.i_w
|
||||
self.loop_w = decision.target_w
|
||||
@@ -811,6 +823,7 @@ class CycleMixin:
|
||||
else:
|
||||
# Stopped or blind: no accumulation may survive the outage.
|
||||
self.i_w = 0.0
|
||||
self.settle.reset()
|
||||
self.loop_w = None
|
||||
self._live_was = frozenset(_live96)
|
||||
# TEL-10, and it is the same zero the line above writes rather than
|
||||
|
||||
@@ -197,6 +197,7 @@ class InputsMixin:
|
||||
# correlates 0.998 with battery power and 0.09 with the real meter
|
||||
# (a controller would regulate against its own output).
|
||||
self.grid = self.p1.net_w
|
||||
self.settle.arrive(now_mono, self.p1.samples) # SAFETY-136
|
||||
else:
|
||||
self.grid = await self.hass.number(o.get("meter_entity", ""),
|
||||
bool(o.get("meter_invert")))
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md."""
|
||||
|
||||
import collections
|
||||
import dataclasses
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -401,6 +402,18 @@ class TradeMixin:
|
||||
quarter = int(now.timestamp() // 900)
|
||||
was = self.mode_tick is not None and \
|
||||
self.mode_tick.action == modetrade.CHARGE
|
||||
# SAFETY-135: the first guard stop in a period (or measured headroom
|
||||
# stop in a young one) does not latch; the charge may start again
|
||||
# RESTART_S after it, but not in the period's last RESTART_S. No
|
||||
# start in a young period while a give falls.
|
||||
# DECISIONS/SAFETY-135-young-period.md
|
||||
st = getattr(self, "_charge_stop", None)
|
||||
paused = (st is not None and st[0] == quarter
|
||||
and (now.timestamp() - st[1] < modetrade.RESTART_S
|
||||
or (quarter + 1) * 900 - now.timestamp()
|
||||
< modetrade.RESTART_S))
|
||||
wait = ("wait" if paused else
|
||||
"settle" if self._give_falling(now) else None)
|
||||
self.mode_tick = modetrade.decide(
|
||||
runs, now, packs, charge_ok=charge_ok, sell_ok=sell_ok,
|
||||
trade_why=trade_why, price_now=price_now,
|
||||
@@ -408,7 +421,10 @@ class TradeMixin:
|
||||
ceiling_pct=ceiling_pct, house_w=house, charging_now=was,
|
||||
blocked=self._charge_blocked_q == quarter,
|
||||
done_charge=done_c, done_sell=done_s, targets=targets,
|
||||
draining=any(r.phase == DRAIN for r in maint_results or ()))
|
||||
draining=any(r.phase == DRAIN for r in maint_results or ()),
|
||||
wait=wait,
|
||||
maint_charging=any(r.phase == CHARGE
|
||||
for r in maint_results or ()))
|
||||
self._latch_charge(now, runs, targets, packs)
|
||||
# A charge the guard or the headroom stopped stays off this period.
|
||||
# OPT-15: only a MEASURED stop latches; the first unmeasured headroom
|
||||
@@ -419,11 +435,38 @@ class TradeMixin:
|
||||
gap = why == "headroom" and self.arb_headroom_w is None
|
||||
if why == "peak" or (why == "headroom" and not gap) or (
|
||||
gap and self._charge_gap_q == quarter):
|
||||
self._charge_blocked_q = quarter
|
||||
young = now.timestamp() - quarter * 900 < modetrade.YOUNG_S
|
||||
if (why == "peak" or (young and not gap)) and (
|
||||
st is None or st[0] != quarter):
|
||||
self._charge_stop = (quarter, now.timestamp())
|
||||
else:
|
||||
self._charge_blocked_q = quarter
|
||||
elif gap:
|
||||
self._charge_gap_q = quarter
|
||||
return self.mode_tick
|
||||
|
||||
def _give_falling(self, now) -> bool:
|
||||
"""SAFETY-135: True in a young period while the batteries give over
|
||||
START_W less than they did in the last GAVE_S s: the meter may not
|
||||
show that yet, so a charge room read now is too big.
|
||||
DECISIONS/SAFETY-135-young-period.md"""
|
||||
t = now.timestamp()
|
||||
gave = getattr(self, "_gave", None)
|
||||
if gave is None:
|
||||
gave = collections.deque()
|
||||
self._gave = gave
|
||||
if gave and t < gave[-1][0]:
|
||||
gave.clear() # the clock stepped back
|
||||
if self.site_batt_w is not None:
|
||||
gave.append((t, max(0.0, float(self.site_batt_w))))
|
||||
while gave and t - gave[0][0] >= modetrade.GAVE_S:
|
||||
gave.popleft()
|
||||
if self.site_batt_w is None or not gave \
|
||||
or t % 900 >= modetrade.YOUNG_S:
|
||||
return False
|
||||
drop = max(w for _t, w in gave) - max(0.0, float(self.site_batt_w))
|
||||
return drop > modetrade.START_W
|
||||
|
||||
def _run_mark(self, kind, run, packs, out, extra=None) -> set:
|
||||
"""The batteries that left run `run` (full, or at the floor); they
|
||||
stay out until it ends, so a level edge does not re-arm them.
|
||||
@@ -475,7 +518,8 @@ class TradeMixin:
|
||||
None, 0.0, "size")
|
||||
if size == 0.0 or runs is None:
|
||||
reason = ("battery modes: no price run now" if tick.why is None
|
||||
else f"battery modes: no trade ({tick.why})")
|
||||
else modetrade.WAIT_WORDS.get(tick.why)
|
||||
or f"battery modes: no trade ({tick.why})")
|
||||
self.arb = arbitrage.ArbitrageDecision(
|
||||
action=None, power_w=None, reason=reason,
|
||||
spread_eur_kwh=None, required_eur_kwh=None,
|
||||
|
||||
@@ -22,6 +22,7 @@ ctl_inputs, ctl_writes, ctl_trade, ctl_cycle, ctl_alarms, ctl_status and
|
||||
ctl_tasks (MAINT-21), the status-row functions in rows."""
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
import contextlib
|
||||
import dataclasses
|
||||
import functools
|
||||
@@ -63,6 +64,7 @@ from .clamp import (
|
||||
)
|
||||
from . import freshness
|
||||
from . import lateness
|
||||
from .settle import Settle
|
||||
from . import dayview, homeview
|
||||
from .daycurve import SolarToday
|
||||
from . import handback
|
||||
@@ -527,6 +529,8 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
|
||||
self._mode_trade_was = False
|
||||
self._charge_blocked_q = None
|
||||
self._charge_gap_q = None # OPT-15: an unmeasured stop's quarter
|
||||
self._charge_stop = None # SAFETY-135: (quarter, t) first stop
|
||||
self._gave = collections.deque() # SAFETY-135: (t, W given), GAVE_S
|
||||
self._ref_cheap = None
|
||||
# OPT-15: a run edge the loop holds at E: (E, step sign, until, the
|
||||
# last battery reading).
|
||||
@@ -741,6 +745,7 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
|
||||
self._arb_action = None
|
||||
self.inputs_bad_since = None
|
||||
self.answer = lateness.Answer() # SAFETY-128
|
||||
self.settle = Settle() # SAFETY-136
|
||||
self._late_logged = self._late_zero = False
|
||||
# SAFETY-03: what the loop's OWN bounds did last cycle, `(engaged,
|
||||
# note, over_rating)` (SAFETY-40 splits the last out). Site-level: the
|
||||
|
||||
@@ -36,6 +36,10 @@ Rules that keep this safe (tests: test_modes_p3.py, test_price_run_fixes.py):
|
||||
makes the other batteries stop giving power, so a few watts of charge
|
||||
flipping on and off would swing the house between them and the grid
|
||||
(measured on the 2026-09-09 replay: 13 flips in 4 minutes at 3.5 kW);
|
||||
SAFETY-135: the first such stop in a period holds the charge RESTART_S
|
||||
only, a second one to the period's end (at most one restart), and in a
|
||||
period's first YOUNG_S s no charge starts while the batteries' give is
|
||||
still falling (`wait`);
|
||||
* selling needs selling on for that battery, the sell run, a price >= 0
|
||||
now, and the battery above `arbitrage_soc_floor`; it stops there. The
|
||||
peak guard never stops a sell (section 9.1: discharge is never capped);
|
||||
@@ -52,7 +56,9 @@ Rules that keep this safe (tests: test_modes_p3.py, test_price_run_fixes.py):
|
||||
the probe (none: PROBE_W alone), so a probe never places charge on a
|
||||
battery in a maintenance phase or on one that rests;
|
||||
* no grid charge while another battery drains for its monthly cycle
|
||||
(`draining`, SAFETY-78): it would charge against the drain.
|
||||
(`draining`, SAFETY-78): it would charge against the drain; nor while
|
||||
one is in its maintenance CHARGE (`maint_charging`, MAINT-24): the cheap
|
||||
night's room is that pack's.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
@@ -69,6 +75,18 @@ PRICE_MODES = (LEFTOVER_SOLAR, CHEAP_HOURS)
|
||||
# would stop covering the house for less than they give.
|
||||
START_W = 500.0
|
||||
|
||||
# SAFETY-135: how long the first stop in a period holds the charge, how long
|
||||
# a period is young, and how far back a give may not be on the meter yet.
|
||||
# DECISIONS/SAFETY-135-young-period.md
|
||||
RESTART_S = 60.0
|
||||
YOUNG_S = 60.0
|
||||
GAVE_S = 15.0
|
||||
# SAFETY-135: the plain status words for `wait` (UX-08).
|
||||
WAIT_WORDS = {
|
||||
"wait": "battery modes: grid charge paused, it tries again in a minute",
|
||||
"settle": "battery modes: grid charge waits for the meter to catch up",
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Pack:
|
||||
@@ -164,7 +182,8 @@ def energy(packs, floor_pct, ceiling_pct, done_charge=(), done_sell=(),
|
||||
def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
|
||||
headroom_w, floor_pct, ceiling_pct, house_w, charging_now=False,
|
||||
blocked=False, done_charge=(), done_sell=(),
|
||||
targets=None, draining=False) -> Tick:
|
||||
targets=None, draining=False, wait=None,
|
||||
maint_charging=False) -> Tick:
|
||||
"""This tick's ranges, roles and site ask.
|
||||
|
||||
`runs` is the kept `priceruns.RunPlan` or None (no fresh prices).
|
||||
@@ -177,7 +196,11 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
|
||||
it going); `blocked` that the guard or the headroom stopped a charge in
|
||||
this 15-minute period. `targets` is the day plan (`charge_to()`).
|
||||
`draining` says a battery drains for its monthly cycle, so none may
|
||||
charge from the grid (why "cycle").
|
||||
charge from the grid (why "cycle"). `wait` ("wait" or "settle", the
|
||||
why) says no charge may start yet (SAFETY-135). `maint_charging` says a
|
||||
battery is in its maintenance CHARGE phase: no grid charge for the others
|
||||
either, same why, but a refusal probe goes on (MAINT-24,
|
||||
DECISIONS/MAINT-24-charge-first.md).
|
||||
"""
|
||||
plan_on = runs is not None and runs.on
|
||||
in_cheap = plan_on and runs.cheap.holds(now) and runs.charge_useful()
|
||||
@@ -189,6 +212,10 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
|
||||
if in_cheap else []
|
||||
if ch and draining:
|
||||
ch, why = [], "cycle"
|
||||
elif ch and maint_charging:
|
||||
# MAINT-24: a refusal probe (PROBE_W alone, OPT-20) may go on.
|
||||
ch = [p for p in ch if p.refused < 0]
|
||||
why = None if ch else "cycle"
|
||||
charging = False
|
||||
if ch:
|
||||
need = 0.0 if charging_now else START_W
|
||||
@@ -198,6 +225,8 @@ def decide(runs, now, packs, *, charge_ok, sell_ok, trade_why, price_now,
|
||||
why = trade_why
|
||||
elif headroom_w is None or not headroom_w > need:
|
||||
why = "headroom"
|
||||
elif wait and not charging_now:
|
||||
why = wait
|
||||
else:
|
||||
charging = True
|
||||
se = sellers(packs, floor_pct, done_sell) if in_sell and not charging \
|
||||
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -472,15 +472,36 @@ _sw = [(UTC0.timestamp() + 60 * k, 2000.0 if k % 2 else 3900.0)
|
||||
ROWS_W, _, _ = run_day("p3_ch_sell", hours=1.5, house=_sw,
|
||||
soc0={"a": 60.0, "b": 30.0})
|
||||
_per_q = {}
|
||||
_ev_q = {} # SAFETY-135: per period, the charge's starts and stops
|
||||
for a_, b_ in zip(ROWS_W, ROWS_W[1:]):
|
||||
if a_["action"] != arbitrage.CHARGE and b_["action"] == arbitrage.CHARGE:
|
||||
on_a, on_b = (a_["action"] == arbitrage.CHARGE,
|
||||
b_["action"] == arbitrage.CHARGE)
|
||||
if on_a != on_b:
|
||||
q = int(b_["now"].timestamp() // 900)
|
||||
_ev_q.setdefault(q, []).append((on_b, b_["now"].timestamp()))
|
||||
if not on_a and on_b:
|
||||
q = int(b_["now"].timestamp() // 900)
|
||||
_per_q[q] = _per_q.get(q, 0) + 1
|
||||
|
||||
|
||||
def _one_restart(ev):
|
||||
"""At most one start after a stop in the period, RESTART_S or more after
|
||||
it; a second stop is the last change of the period."""
|
||||
re = [(t0, t1) for (s0, t0), (s1, t1) in zip(ev, ev[1:])
|
||||
if not s0 and s1]
|
||||
stops, late = 0, False
|
||||
for on, _t in ev:
|
||||
stops += 0 if on else 1
|
||||
late = late or (on and stops >= 2)
|
||||
return (len(re) <= 1 and not late
|
||||
and all(t1 - t0 >= modetrade.RESTART_S - 1 for t0, t1 in re))
|
||||
print(f" house swinging 2.0/3.9 kW each minute: charge starts per period "
|
||||
f"{sorted(_per_q.values())}")
|
||||
check("a charge the peak guard or the headroom stopped stays off for the "
|
||||
"rest of that 15-minute period (at most one start per period)",
|
||||
_per_q and max(_per_q.values()) == 1)
|
||||
"rest of that 15-minute period, after at most one restart RESTART_S or "
|
||||
"more after the first stop; a second stop latches (SAFETY-135)",
|
||||
_per_q and max(_per_q.values()) <= 2
|
||||
and all(_one_restart(ev) for ev in _ev_q.values()))
|
||||
|
||||
# At the floor: B starts the sale just above it.
|
||||
ROWS_F, _, _ = run_day("p3_ch_sell", start_h=18.75, hours=1.5,
|
||||
|
||||
@@ -65,11 +65,16 @@ print("1. no usable forecast: byte-identical to 5a4cdc2 (P3 option A)")
|
||||
# OPT-15 head: a charge end now holds the loop at the house's level (F2). With
|
||||
# that hold and its reseed switched off they equal 5a4cdc2's again; the others
|
||||
# (no charge) never moved.
|
||||
# SAFETY-135: n1_ch re-pinned. A first guard stop no longer latches, so while
|
||||
# the charge waits its reason reads "(peak)" where the latch said
|
||||
# "(headroom)"; no command moves. With SAFETY-135 off it is OPT-15's pin
|
||||
# again (checked below).
|
||||
N1_CH_OPT15 = "97893a54f8a8e3f36ad24f100973b3ff35766527e7287a6ae30cc1c8941bac02"
|
||||
BASE_DIGESTS = {
|
||||
"n1_ls":
|
||||
"13f11c330263c91625acde7628426ff16dae3a9af99aebeef3051dc5b5a884ea",
|
||||
"n1_ch":
|
||||
"97893a54f8a8e3f36ad24f100973b3ff35766527e7287a6ae30cc1c8941bac02",
|
||||
"5d529f516307e4da4dca48714ee891332a2eea5e1b1763f0f1e049cfb0819a0a",
|
||||
"n2_sf_ls":
|
||||
"9424e1837b805a6a31293a28b8fbad6ba611ea0bfe3240d636d68d62dc5e9f22",
|
||||
"n2_ch_ls":
|
||||
@@ -81,7 +86,7 @@ BASE_DIGESTS = {
|
||||
"n1_ls_fc_down":
|
||||
"13f11c330263c91625acde7628426ff16dae3a9af99aebeef3051dc5b5a884ea",
|
||||
"n1_ch_fc_down":
|
||||
"97893a54f8a8e3f36ad24f100973b3ff35766527e7287a6ae30cc1c8941bac02",
|
||||
"5d529f516307e4da4dca48714ee891332a2eea5e1b1763f0f1e049cfb0819a0a",
|
||||
"n2_sf_ls_fc_down":
|
||||
"9424e1837b805a6a31293a28b8fbad6ba611ea0bfe3240d636d68d62dc5e9f22",
|
||||
"n2_ch_ls_fc_down":
|
||||
@@ -98,6 +103,38 @@ for k, v in BASE_DIGESTS.items():
|
||||
check(f"{k}: pinned digest (5a4cdc2; OPT-15 for the _ch ones)",
|
||||
got.get(k) == v)
|
||||
check("every scenario is pinned", set(got) == set(BASE_DIGESTS))
|
||||
|
||||
|
||||
class _Every:
|
||||
"""SAFETY-135 off: a stop period equal to every period, so every guard
|
||||
stop latches as before."""
|
||||
|
||||
def __eq__(self, other):
|
||||
return True
|
||||
|
||||
def __ne__(self, other):
|
||||
return False
|
||||
|
||||
|
||||
def _s135_off_digest(scn):
|
||||
from app import main as _m
|
||||
real = _m.Controller.__init__
|
||||
|
||||
def init(self, *a, **k):
|
||||
real(self, *a, **k)
|
||||
self._charge_stop = (_Every(), float("-inf"))
|
||||
young, restart = modetrade.YOUNG_S, modetrade.RESTART_S
|
||||
_m.Controller.__init__ = init
|
||||
modetrade.YOUNG_S = modetrade.RESTART_S = 0.0
|
||||
try:
|
||||
return ident.p3.digest(ident.run(**ident.SCENARIOS[scn])[0])
|
||||
finally:
|
||||
_m.Controller.__init__ = real
|
||||
modetrade.YOUNG_S, modetrade.RESTART_S = young, restart
|
||||
|
||||
|
||||
check("n1_ch with SAFETY-135 off is OPT-15's pin: only the reason word moved",
|
||||
_s135_off_digest("n1_ch") == N1_CH_OPT15)
|
||||
for k in [k for k in got if k.endswith("_fc_down")]:
|
||||
check(f"{k}: a set but unavailable forecast equals no forecast",
|
||||
got[k] == got[k[:-len('_fc_down')]])
|
||||
|
||||
@@ -248,11 +248,14 @@ print("\n4. battery modes on, no probe: byte-identical to release/1.0 bf9019e")
|
||||
# now counts those seconds, so ch_ch's quarters land on 3450.0 W where they
|
||||
# landed 1.3-1.4 W over it (bf9019e: 15fc25dd...), and ch_ch_maint's first,
|
||||
# partial quarter charges a little less at its end (bf9019e: e46e3c66...).
|
||||
# MAINT-24 moved ch_ch_maint again: B no longer grid-charges beside A's
|
||||
# maintenance charge, and A's charge is no longer paused (release/1.0 02eac3b:
|
||||
# e56eddab...).
|
||||
AT_BASE = {
|
||||
"sf_ch": "422d6578a7decf1ae3749edd1898ff3cd0541d904c95f636cdd9b1de8c1a07c9",
|
||||
"ch_ch": "2b5879ec95810c39a5f0f82c04b9f8e358ea03ddce4ba187cc2b7e0885dfcbfa",
|
||||
"ch_ch_maint":
|
||||
"e56eddabdb785f81793e7afa525f8e6da237b0b2165860aa8e0f8a6499b275a9",
|
||||
"df86a451a3b2d365ca5d35b772cd1eb8502d164e6b63e6844b5cfb82f15aeb46",
|
||||
}
|
||||
_got = sc.no_probe_digests()
|
||||
for _k in AT_BASE:
|
||||
@@ -260,8 +263,8 @@ for _k in AT_BASE:
|
||||
f"{_got[_k][:16]}")
|
||||
check("with no refusal every share, grid, trade sentence, role and plant "
|
||||
"power of a scripted cheap run equals bf9019e's (A Solar first, A "
|
||||
"charging, A in maintenance charge; the last two as SAFETY-125 moved "
|
||||
"them)", _got == AT_BASE)
|
||||
"charging, A in maintenance charge; the last two as SAFETY-125 and "
|
||||
"MAINT-24 moved them)", _got == AT_BASE)
|
||||
|
||||
print()
|
||||
if fails:
|
||||
|
||||
@@ -245,14 +245,16 @@ print(f" decide(cap_w={kw(_d, 'cap_w')}, margin_w={kw(_d, 'margin_w')})")
|
||||
print(f" shaping_suspended(..., {arg(_s, 2)})")
|
||||
print(f" maintenance_charge_floor(..., {arg(_m, 2)})")
|
||||
print(f" PeakTracker(cap_w={kw(_t, 'cap_w')})")
|
||||
# MAINT-24: the maintenance forecast checks get the guard's own aim, cap -
|
||||
# margin with this month's peak in (`maint_aim`), not the plain cap - margin.
|
||||
check("test_call_sites: the guard gets the real cap AND the margin; the "
|
||||
"forecast checks get cap - margin; the month-peak tracker gets the "
|
||||
"real cap",
|
||||
"forecast checks get the guard's aim, cap - margin and the month "
|
||||
"peak (MAINT-24); the month-peak tracker gets the real cap",
|
||||
len(_dec) == 1 and "peak_cap_w" in kw(_d, "cap_w")
|
||||
and kw(_d, "margin_w") == "self.peak_margin_w"
|
||||
and len(_shape) == 1 and len(_maint) == 1
|
||||
and arg(_s, 2) == "self.peak_aim_w()"
|
||||
and arg(_m, 2) == "self.peak_aim_w()"
|
||||
and arg(_s, 2) == "maint_aim" and arg(_m, 2) == "maint_aim"
|
||||
and "maint_aim = peakguard.quarter_target_w(" in _src
|
||||
and len(_tracker) == 1
|
||||
and kw(_t, "cap_w") == 'float(opts.get("peak_cap_w", 3500))')
|
||||
check("test_aim_w: `peak_aim_w()` is cap - margin, and the forecast sizing "
|
||||
|
||||
@@ -6,7 +6,8 @@ with the lagged model (`Lag`: command dead time, meter and battery reading
|
||||
age, the P1 debounce, 1 s cycles), which reproduces all three on 5a4cdc2.
|
||||
|
||||
1. F1: a quarter edge while charging does not stop the charge (the guard
|
||||
projects the quarter `now` is in), and a real guard stop still latches.
|
||||
projects the quarter `now` is in), and a real guard stop still holds it
|
||||
(SAFETY-135: one restart a minute later, then a second stop latches).
|
||||
At two inverters SAFETY-91's `next_quarter()` already does that for the
|
||||
guard; at one inverter only the rolled projection does.
|
||||
2. F2: the export overshoot at a charge end stays within 0.15 x rating in
|
||||
@@ -252,7 +253,8 @@ check("two inverters: SAFETY-91 already holds the edge (no stop with the old "
|
||||
"projection), and rolling it changes no banked quarter",
|
||||
not stops(ROWS1O) and banked(C1O, ROWS1O) == banked(C1, ROWS1))
|
||||
|
||||
# 1c. A real guard stop still latches, and the 500 W minimum stays.
|
||||
# 1c. A real guard stop holds the charge (SAFETY-135: one restart after
|
||||
# RESTART_S, a second stop latches), and the 500 W minimum stays.
|
||||
# House: 800 W; 3700 W at 02:20-02:21; 3000 W from 02:26 (headroom 450 W
|
||||
# in the next quarter); 2850 W from 02:40 (600 W).
|
||||
ROWS2, C2 = run(1.9, 0.9, lag=LAG, steps=[
|
||||
@@ -266,11 +268,24 @@ _after2 = [r for r in ROWS2 if _first and r["now"] > _first["now"]
|
||||
print(f" guard stop at "
|
||||
f"{_first['now'].astimezone(L).strftime('%H:%M:%S') if _first else None}"
|
||||
f" ({_first['reason'][:40] if _first else ''})")
|
||||
check("a mid-quarter 3.7 kW load stops the charge for the peak and it stays "
|
||||
"off for the rest of that quarter, the house back at 800 W",
|
||||
# SAFETY-135: the first guard stop in a quarter holds the charge RESTART_S,
|
||||
# then it may start once more; a second stop latches to the quarter's end.
|
||||
_hold2 = [r for r in _after2
|
||||
if (r["now"] - _first["now"]).total_seconds() < modetrade.RESTART_S]
|
||||
_on2 = [b for a, b in zip(_after2, _after2[1:])
|
||||
if a["action"] != arbitrage.CHARGE and b["action"] == arbitrage.CHARGE]
|
||||
_off2 = [b for a, b in zip(_after2, _after2[1:])
|
||||
if a["action"] == arbitrage.CHARGE and b["action"] != arbitrage.CHARGE]
|
||||
print(f" after it: {len(_on2)} restart(s), {len(_off2)} more stop(s)")
|
||||
check("a mid-quarter 3.7 kW load stops the charge for the peak; it stays off "
|
||||
"for RESTART_S, then starts again at most once that quarter, and a "
|
||||
"second stop holds it off to the quarter's end (SAFETY-135)",
|
||||
_first is not None and _first["now"].astimezone(L).minute == 20
|
||||
and ("peak" in _first["reason"] or "headroom" in _first["reason"])
|
||||
and _after2 and all(r["action"] is None for r in _after2))
|
||||
and _hold2 and all(r["action"] is None for r in _hold2)
|
||||
and len(_on2) <= 1 and len(_off2) <= 1
|
||||
and not any(r["action"] == arbitrage.CHARGE for r in _after2
|
||||
if _off2 and r["now"] >= _off2[0]["now"]))
|
||||
_starts = [b for a, b in zip(ROWS1 + ROWS2, (ROWS1 + ROWS2)[1:])
|
||||
if a["action"] != arbitrage.CHARGE
|
||||
and b["action"] == arbitrage.CHARGE]
|
||||
|
||||
@@ -38,6 +38,7 @@ logging.disable(logging.WARNING)
|
||||
import safety128_rig as R # noqa: E402
|
||||
import app.main as app_main # noqa: E402
|
||||
from app import lateness # noqa: E402
|
||||
from app import settle # noqa: E402
|
||||
|
||||
fails = []
|
||||
|
||||
@@ -58,6 +59,17 @@ def no_gate(fn):
|
||||
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):
|
||||
"""Run the rig; (rig, seconds the verdict stood, first second it stood)."""
|
||||
dur = kw.pop("dur")
|
||||
@@ -91,7 +103,7 @@ for units, house in ((AB, 1800.0), (A, 1500.0)):
|
||||
if len(units) == 2:
|
||||
old = R.Rig(house=house, lag_s=30.0, lag_on=ON, lag_off=ON + 300.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",
|
||||
grid_min(old.rows, ON, ON + 300.0) < -1000.0)
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user