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
This commit is contained in:
glenn schrooyen
2026-11-11 19:49:18 +01:00
co-authored by Claude Opus 5.5
parent 66d8777b39
commit 4066624cce
8 changed files with 356 additions and 95 deletions
@@ -1,74 +1,116 @@
# SAFETY-135 - a young 15-minute period does not cost the night charge the whole period # SAFETY-135 - one guard stop does not cost the night charge the whole period
**Status: for review.** Code: `Controller._mode_roles()` and **Status: for review, round 2.** Code: `Controller._mode_roles()` and
`Controller._give_falling()` (`app/ctl_trade.py`), `modetrade.decide(young=)`, `Controller._give_falling()` (`app/ctl_trade.py`), `modetrade.decide(wait=)`,
`YOUNG_S`, `GAVE_S` (`app/modetrade.py`). Tests: `test_safety135.py`. Battery `RESTART_S`, `YOUNG_S`, `GAVE_S`, `WAIT_WORDS` (`app/modetrade.py`). Tests:
modes only. No option, no text change, no change to the peak guard. `test_safety135.py`. Battery modes only. No option, no change to the peak
guard.
**Problem.** In the first seconds of a period the guard's projection is almost **Problem.** A grid charge the peak guard stops stays off to the period's end
only the last reading (`held_w`). Two cases seen on TEST-23: (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 - *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 still at full power from the end of the last period, makes the guard project
4.3-6.6 kW and cut the night charge. The cut is right. But a cut charge 4.3-6.6 kW and cut the charge. The cut is right, but one 2 s spike cost 15
stays off to the period's end (the OPT-11 latch), so one 2 s spike cost 15
minutes of charge, about 0.16 kWh per trip on the rig. minutes of charge, about 0.16 kWh per trip on the rig.
- *A start at the tariff edge* (SAFETY-136 rig F-4, x1): a give ends at 01:00 - *A start at the tariff edge* (sim, 5 s meter): a give ends at 01:00 and the
and the cheap run starts. The battery reading drops at once, the meter cheap run starts. The battery reading drops at once, the meter a few
reading a few seconds later. In between, grid + batteries reads the house seconds later. In between, grid + batteries reads the house too low, the
too low, so the charge room is too big. The charge starts and the guard cuts room too big: the charge starts and the guard cuts it within seconds.
it 4-61 s later, and the latch then holds it off for the period. - *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.** Two small rules, both only in a young period (its first **Decision.**
`YOUNG_S` = 60 s): 1. *The first guard stop in a period does not latch.* A charge the peak guard
1. *A stop does not latch.* A charge the guard or a measured headroom stops in stops (or a measured headroom stops in the period's first `YOUNG_S` = 60 s)
a young period may not start again until the period is 60 s old; then the may start again `RESTART_S` = 60 s after that stop,
normal start rule (guard clear, over `START_W` of headroom) decides. A stop under the normal start rule (guard clear, headroom over `START_W`), but not
after 60 s latches to the period's end as before, so there is at most one in the period's last 60 s: there the room is the deficit times 900/s left,
restart per period and the OPT-11 flip guard keeps its job. and a restart overshot by 15 W in the owner-shape night (N=2, 01:00). A
2. *No start while a give is still falling.* While the batteries give over second stop latches to the period's end, so there is at most one restart
`START_W` less than they did in the last `GAVE_S` = 15 s, no charge starts: per period and the OPT-11 flip guard keeps its job. A measured headroom
the meter may not show that drop yet. 15 s covers a 5 s meter twice plus stop later in the period latches at once, and an unmeasured headroom
Home Assistant's own delay. A give that falls by `START_W` or less does not (OPT-15) on its second stop, as before. Why only guard stops later on: all
hold the start: the error it leaves is under the smallest charge worth three rig cases are guard stops, while a headroom that falls to 0 W mid
starting. 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 itself is not touched: its claim, floor and projection are the same The guard is not touched (claim, floor, projection), so a real over-aim load
code, so a real over-aim load is cut on the same cycle as before. A running is cut on the same cycle as before, and a running charge is never held. While
charge is never held by rule 2. 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.** **Options weighed.**
- *A minimum data window before the latch* (no latch in the first N s): this - *Round 1: only a stop in the first 60 s does not latch.* Missed the rig
is rule 1. Alone it lets the edge start flap once more. oven case, cut at 91 s (review M-1).
- *No start in a young period within 60 s of a give or a guard action*: also
stops the edge flap, but delays every run start that follows a give. In the owner-shape night sim it
charged 0.018 kWh (N=1) and 0.003 kWh (N=2) less than base. Rule 2 holds the
start only while the give is still falling, and charges the same as base.
- *Cutting the young charge room by the recent give* (instead of holding the
start): it double-counts once the meter catches up, cut a running charge 3 s
after its start in the 1 s meter edge run, and so flapped where base did not.
- *Re-evaluation each cycle with no latch*: brings back the 13 flips in 4 - *Re-evaluation each cycle with no latch*: brings back the 13 flips in 4
minutes the latch was made for (2026-09-09 replay). minutes the latch was made for (2026-09-09 replay).
- *A hysteresis band on the start*: does not help at the edge. The error there - *Restart at once after the first stop*: two flips in 20 s with spikes at
is the readings' age, not a small room. 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 **Numbers** (`scratch/SAFETY-135/dev/s135_sim.py`, real `cycle()`, TEST-23-like
lag, 1 s cycle, two 2.5 kW batteries, cap 3 500 W, aim 3 450 W; grid lag, 1 s cycle, two 2.5 kW batteries unless said, cap 3 500 W, aim 3 450 W;
average over each period as the sim saw it): the sim's grid average per period):
| Scenario | Base: paused s, kWh, flaps, top period | Head | | 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 | 135 s, 0.537 kWh, 0, 3 458 W | | (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) 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 | | (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 | | (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.048 kWh, 3 454 / 3 453 W | | 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 | | Owner-shape day, N=1 / N=2 | top 331 / 145 W | the same |
The add-on's own period averages in `test_safety135.py`: 3 450.0 W in the In s5b the restart at 01:02:32 is cut again by the oven at 01:03:17 and then
spike period (base 2 807.9 W, the charge lost), 3 450.0 W in the 01:00 period latches: one restart, as designed. A +4 kW load from second 30, 57, 59, 60 or
(base 2 996.5 W at 3.0 kW). Over the aim by a few watts in the sim's grid 61 (N=1 and N=2) is cut on the same cycle as base, the charge restarts only
average is the landing error base has too (SAFETY-126). 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 **Not changed.** With battery modes off, `arbitrage.decide()` has no period
latch and sizes the charge to the headroom each cycle (SAFETY-45). Nothing latch and sizes the charge to the headroom each cycle (SAFETY-45). A charge at
here touches it. 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.
+27 -10
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,10 +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: a stop in a young period holds only until it is not # SAFETY-135: the first guard stop in a period (or measured headroom
# young (no latch), and no charge starts there while a give falls. # stop in a young one) does not latch; the charge may start again
young = now.timestamp() - quarter * 900 < modetrade.YOUNG_S # RESTART_S after it, but not in the period's last RESTART_S. No
falling = self._give_falling(now) # 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,
@@ -413,7 +422,7 @@ class TradeMixin:
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 ()),
young=falling or (young and self._charge_young_q == quarter)) wait=wait)
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
@@ -424,8 +433,10 @@ 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):
if young and not gap: young = now.timestamp() - quarter * 900 < modetrade.YOUNG_S
self._charge_young_q = quarter # SAFETY-135 if (why == "peak" or (young and not gap)) and (
st is None or st[0] != quarter):
self._charge_stop = (quarter, now.timestamp())
else: else:
self._charge_blocked_q = quarter self._charge_blocked_q = quarter
elif gap: elif gap:
@@ -438,10 +449,15 @@ class TradeMixin:
show that yet, so a charge room read now is too big. show that yet, so a charge room read now is too big.
DECISIONS/SAFETY-135-young-period.md""" DECISIONS/SAFETY-135-young-period.md"""
t = now.timestamp() t = now.timestamp()
gave = self._gave 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: if self.site_batt_w is not None:
gave.append((t, max(0.0, float(self.site_batt_w)))) gave.append((t, max(0.0, float(self.site_batt_w))))
while gave and not 0.0 <= t - gave[0][0] < modetrade.GAVE_S: while gave and t - gave[0][0] >= modetrade.GAVE_S:
gave.popleft() gave.popleft()
if self.site_batt_w is None or not gave \ if self.site_batt_w is None or not gave \
or t % 900 >= modetrade.YOUNG_S: or t % 900 >= modetrade.YOUNG_S:
@@ -500,7 +516,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,
+1 -1
View File
@@ -529,7 +529,7 @@ 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_young_q = None # SAFETY-135: a young 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._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
+17 -10
View File
@@ -36,9 +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: a stop in a period's first YOUNG_S s holds only until then, SAFETY-135: the first such stop in a period holds the charge RESTART_S
not to the period's end, and no charge starts then while the batteries' only, a second one to the period's end (at most one restart), and in a
give is still falling (`young`); 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);
@@ -72,11 +73,17 @@ 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: seconds a 15-minute period is young, and how far back a # SAFETY-135: how long the first stop in a period holds the charge, how long
# battery's give counts as maybe not yet on the meter. # a period is young, and how far back a give may not be on the meter yet.
# DECISIONS/SAFETY-135-young-period.md # DECISIONS/SAFETY-135-young-period.md
RESTART_S = 60.0
YOUNG_S = 60.0 YOUNG_S = 60.0
GAVE_S = 15.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)
@@ -173,7 +180,7 @@ 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, young=False) -> Tick: targets=None, draining=False, wait=None) -> 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).
@@ -186,8 +193,8 @@ 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"). `young` says no charge may start charge from the grid (why "cycle"). `wait` ("wait" or "settle", the
yet in this young period (SAFETY-135, why "young"). why) says no charge may start yet (SAFETY-135).
""" """
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()
@@ -208,8 +215,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 young and not charging_now: elif wait and not charging_now:
why = "young" 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 \
+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')]])
+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]
+133 -11
View File
@@ -4,7 +4,8 @@ night charge the whole period. `python3 test_safety135.py`
The REAL Controller (tools/opt11p3_replay.py, read_inputs + cycle, battery 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, 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 readings 1 s old, a 5 s old meter in part 2. "Off" is the same run with
`modetrade.YOUNG_S = 0`: the tree before this card. every measured stop latching and no wait for a falling give: the tree before
this card.
DECISIONS/SAFETY-135-young-period.md. DECISIONS/SAFETY-135-young-period.md.
1. A peak-bound night charge; a period starts with a 4 s kettle spike. The 1. A peak-bound night charge; a period starts with a 4 s kettle spike. The
@@ -14,6 +15,12 @@ DECISIONS/SAFETY-135-young-period.md.
start-then-cut within 60 s (off: one), and no period over the aim. 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 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. 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. Mutations (MUTATION.md, purged copies) are listed in the pull request.
""" """
@@ -30,6 +37,7 @@ logging.disable(logging.WARNING)
import opt11p3_replay as rp # noqa: E402 import opt11p3_replay as rp # noqa: E402
from app import arbitrage, modetrade, packmode # noqa: E402 from app import arbitrage, modetrade, packmode # noqa: E402
from named_absence import nth, present # noqa: E402
fails = [] fails = []
@@ -51,21 +59,46 @@ AIM = rp.PEAK_CAP - 50
CH = {"a": (True, packmode.CHEAP_HOURS, False), CH = {"a": (True, packmode.CHEAP_HOURS, False),
"b": (True, packmode.CHEAP_HOURS, False)} "b": (True, packmode.CHEAP_HOURS, False)}
T1 = D0 + timedelta(hours=1) # 01:00 local, the cheap edge 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): def off(fn):
"""`fn()` with SAFETY-135 off: no period is young.""" """`fn()` with SAFETY-135 off: every measured stop latches and no start
real = getattr(modetrade, "YOUNG_S", None) waits for a falling give, as on the tree before this card."""
if real is None: names = [n for n in ("YOUNG_S", "RESTART_S") if hasattr(modetrade, n)]
return fn() real = {n: getattr(modetrade, n) for n in names}
modetrade.YOUNG_S = 0.0 OFF[0] = True
for n in names: # RESTART_S 0: no wait at a period's end
setattr(modetrade, n, 0.0)
try: try:
return fn() return fn()
finally: finally:
modetrade.YOUNG_S = real OFF[0] = False
for n in names:
setattr(modetrade, n, real[n])
def run(house_w, start, secs, meter_s=1.0): 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)`.""" """Rows per cycle and the Controller, for `house_w(seconds since 01:00)`."""
t0 = start.timestamp() t0 = start.timestamp()
house = [(t0 + k, house_w(t0 + k - T1.timestamp())) house = [(t0 + k, house_w(t0 + k - T1.timestamp()))
@@ -78,11 +111,22 @@ def run(house_w, start, secs, meter_s=1.0):
grid=h.grid(), grid=h.grid(),
charge=getattr(c.arb, "action", None) charge=getattr(c.arb, "action", None)
== arbitrage.CHARGE, == arbitrage.CHARGE,
engaged=g is not None and g.engaged)) engaged=g is not None and g.engaged,
_st, c = rp.run("p3_ch_sell", house, WPTS, start.astimezone(timezone.utc), reason=getattr(c.arb, "reason", None) or ""))
secs, check=cb, soc0={"a": 30.0, "b": 30.0}, choice=CH, 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}, extra={"slew_w": 0, "cycle_cost_threshold_eur": 0.03},
lag=rp.Lag(meter_s=meter_s)) lag=rp.Lag(meter_s=meter_s))
finally:
rp.RATING = was
return rows, c return rows, c
@@ -195,6 +239,84 @@ check("the charge stays off while the load is on",
check("every period is at or under the aim (lag allows 2 W)", check("every period is at or under the aim (lag allows 2 W)",
banked(C3, 0) and max(banked(C3, 0)) <= AIM + 2.0) 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() print()
if fails: if fails:
print(f"FAILED: {len(fails)}") print(f"FAILED: {len(fails)}")