Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02eac3b84e | ||
|
|
4066624cce | ||
|
|
66d8777b39 | ||
|
|
a8ecf171d6 |
@@ -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.
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md."""
|
Moved unchanged from app/main.py (MAINT-21). DECISIONS/MAIN-orchestration.md."""
|
||||||
|
|
||||||
|
import collections
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
@@ -401,6 +402,18 @@ class TradeMixin:
|
|||||||
quarter = int(now.timestamp() // 900)
|
quarter = int(now.timestamp() // 900)
|
||||||
was = self.mode_tick is not None and \
|
was = self.mode_tick is not None and \
|
||||||
self.mode_tick.action == modetrade.CHARGE
|
self.mode_tick.action == modetrade.CHARGE
|
||||||
|
# SAFETY-135: the first guard stop in a period (or measured headroom
|
||||||
|
# stop in a young one) does not latch; the charge may start again
|
||||||
|
# RESTART_S after it, but not in the period's last RESTART_S. No
|
||||||
|
# start in a young period while a give falls.
|
||||||
|
# DECISIONS/SAFETY-135-young-period.md
|
||||||
|
st = getattr(self, "_charge_stop", None)
|
||||||
|
paused = (st is not None and st[0] == quarter
|
||||||
|
and (now.timestamp() - st[1] < modetrade.RESTART_S
|
||||||
|
or (quarter + 1) * 900 - now.timestamp()
|
||||||
|
< modetrade.RESTART_S))
|
||||||
|
wait = ("wait" if paused else
|
||||||
|
"settle" if self._give_falling(now) else None)
|
||||||
self.mode_tick = modetrade.decide(
|
self.mode_tick = modetrade.decide(
|
||||||
runs, now, packs, charge_ok=charge_ok, sell_ok=sell_ok,
|
runs, now, packs, charge_ok=charge_ok, sell_ok=sell_ok,
|
||||||
trade_why=trade_why, price_now=price_now,
|
trade_why=trade_why, price_now=price_now,
|
||||||
@@ -408,7 +421,8 @@ class TradeMixin:
|
|||||||
ceiling_pct=ceiling_pct, house_w=house, charging_now=was,
|
ceiling_pct=ceiling_pct, house_w=house, charging_now=was,
|
||||||
blocked=self._charge_blocked_q == quarter,
|
blocked=self._charge_blocked_q == quarter,
|
||||||
done_charge=done_c, done_sell=done_s, targets=targets,
|
done_charge=done_c, done_sell=done_s, targets=targets,
|
||||||
draining=any(r.phase == DRAIN for r in maint_results or ()))
|
draining=any(r.phase == DRAIN for r in maint_results or ()),
|
||||||
|
wait=wait)
|
||||||
self._latch_charge(now, runs, targets, packs)
|
self._latch_charge(now, runs, targets, packs)
|
||||||
# A charge the guard or the headroom stopped stays off this period.
|
# A charge the guard or the headroom stopped stays off this period.
|
||||||
# OPT-15: only a MEASURED stop latches; the first unmeasured headroom
|
# OPT-15: only a MEASURED stop latches; the first unmeasured headroom
|
||||||
@@ -419,11 +433,38 @@ class TradeMixin:
|
|||||||
gap = why == "headroom" and self.arb_headroom_w is None
|
gap = why == "headroom" and self.arb_headroom_w is None
|
||||||
if why == "peak" or (why == "headroom" and not gap) or (
|
if why == "peak" or (why == "headroom" and not gap) or (
|
||||||
gap and self._charge_gap_q == quarter):
|
gap and self._charge_gap_q == quarter):
|
||||||
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:
|
elif gap:
|
||||||
self._charge_gap_q = quarter
|
self._charge_gap_q = quarter
|
||||||
return self.mode_tick
|
return self.mode_tick
|
||||||
|
|
||||||
|
def _give_falling(self, now) -> bool:
|
||||||
|
"""SAFETY-135: True in a young period while the batteries give over
|
||||||
|
START_W less than they did in the last GAVE_S s: the meter may not
|
||||||
|
show that yet, so a charge room read now is too big.
|
||||||
|
DECISIONS/SAFETY-135-young-period.md"""
|
||||||
|
t = now.timestamp()
|
||||||
|
gave = getattr(self, "_gave", None)
|
||||||
|
if gave is None:
|
||||||
|
gave = collections.deque()
|
||||||
|
self._gave = gave
|
||||||
|
if gave and t < gave[-1][0]:
|
||||||
|
gave.clear() # the clock stepped back
|
||||||
|
if self.site_batt_w is not None:
|
||||||
|
gave.append((t, max(0.0, float(self.site_batt_w))))
|
||||||
|
while gave and t - gave[0][0] >= modetrade.GAVE_S:
|
||||||
|
gave.popleft()
|
||||||
|
if self.site_batt_w is None or not gave \
|
||||||
|
or t % 900 >= modetrade.YOUNG_S:
|
||||||
|
return False
|
||||||
|
drop = max(w for _t, w in gave) - max(0.0, float(self.site_batt_w))
|
||||||
|
return drop > modetrade.START_W
|
||||||
|
|
||||||
def _run_mark(self, kind, run, packs, out, extra=None) -> set:
|
def _run_mark(self, kind, run, packs, out, extra=None) -> set:
|
||||||
"""The batteries that left run `run` (full, or at the floor); they
|
"""The batteries that left run `run` (full, or at the floor); they
|
||||||
stay out until it ends, so a level edge does not re-arm them.
|
stay out until it ends, so a level edge does not re-arm them.
|
||||||
@@ -475,7 +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,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ ctl_inputs, ctl_writes, ctl_trade, ctl_cycle, ctl_alarms, ctl_status and
|
|||||||
ctl_tasks (MAINT-21), the status-row functions in rows."""
|
ctl_tasks (MAINT-21), the status-row functions in rows."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import collections
|
||||||
import contextlib
|
import contextlib
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import functools
|
import functools
|
||||||
@@ -528,6 +529,8 @@ class Controller(InputsMixin, WritesMixin, TradeMixin, CycleMixin, AlarmsMixin,
|
|||||||
self._mode_trade_was = False
|
self._mode_trade_was = False
|
||||||
self._charge_blocked_q = None
|
self._charge_blocked_q = None
|
||||||
self._charge_gap_q = None # OPT-15: an unmeasured stop's quarter
|
self._charge_gap_q = None # OPT-15: an unmeasured stop's quarter
|
||||||
|
self._charge_stop = None # SAFETY-135: (quarter, t) first stop
|
||||||
|
self._gave = collections.deque() # SAFETY-135: (t, W given), GAVE_S
|
||||||
self._ref_cheap = None
|
self._ref_cheap = None
|
||||||
# OPT-15: a run edge the loop holds at E: (E, step sign, until, the
|
# OPT-15: a run edge the loop holds at E: (E, step sign, until, the
|
||||||
# last battery reading).
|
# last battery reading).
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ Rules that keep this safe (tests: test_modes_p3.py, test_price_run_fixes.py):
|
|||||||
makes the other batteries stop giving power, so a few watts of charge
|
makes the other batteries stop giving power, so a few watts of charge
|
||||||
flipping on and off would swing the house between them and the grid
|
flipping on and off would swing the house between them and the grid
|
||||||
(measured on the 2026-09-09 replay: 13 flips in 4 minutes at 3.5 kW);
|
(measured on the 2026-09-09 replay: 13 flips in 4 minutes at 3.5 kW);
|
||||||
|
SAFETY-135: the first such stop in a period holds the charge RESTART_S
|
||||||
|
only, a second one to the period's end (at most one restart), and in a
|
||||||
|
period's first YOUNG_S s no charge starts while the batteries' give is
|
||||||
|
still falling (`wait`);
|
||||||
* selling needs selling on for that battery, the sell run, a price >= 0
|
* selling needs selling on for that battery, the sell run, a price >= 0
|
||||||
now, and the battery above `arbitrage_soc_floor`; it stops there. The
|
now, and the battery above `arbitrage_soc_floor`; it stops there. The
|
||||||
peak guard never stops a sell (section 9.1: discharge is never capped);
|
peak guard never stops a sell (section 9.1: discharge is never capped);
|
||||||
@@ -69,6 +73,18 @@ PRICE_MODES = (LEFTOVER_SOLAR, CHEAP_HOURS)
|
|||||||
# would stop covering the house for less than they give.
|
# would stop covering the house for less than they give.
|
||||||
START_W = 500.0
|
START_W = 500.0
|
||||||
|
|
||||||
|
# SAFETY-135: how long the first stop in a period holds the charge, how long
|
||||||
|
# a period is young, and how far back a give may not be on the meter yet.
|
||||||
|
# DECISIONS/SAFETY-135-young-period.md
|
||||||
|
RESTART_S = 60.0
|
||||||
|
YOUNG_S = 60.0
|
||||||
|
GAVE_S = 15.0
|
||||||
|
# SAFETY-135: the plain status words for `wait` (UX-08).
|
||||||
|
WAIT_WORDS = {
|
||||||
|
"wait": "battery modes: grid charge paused, it tries again in a minute",
|
||||||
|
"settle": "battery modes: grid charge waits for the meter to catch up",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Pack:
|
class Pack:
|
||||||
@@ -164,7 +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) -> 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).
|
||||||
@@ -177,7 +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").
|
charge from the grid (why "cycle"). `wait` ("wait" or "settle", the
|
||||||
|
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()
|
||||||
@@ -198,6 +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 wait and not charging_now:
|
||||||
|
why = wait
|
||||||
else:
|
else:
|
||||||
charging = True
|
charging = True
|
||||||
se = sellers(packs, floor_pct, done_sell) if in_sell and not charging \
|
se = sellers(packs, floor_pct, done_sell) if in_sell and not charging \
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -65,11 +65,16 @@ print("1. no usable forecast: byte-identical to 5a4cdc2 (P3 option A)")
|
|||||||
# OPT-15 head: a charge end now holds the loop at the house's level (F2). With
|
# OPT-15 head: a charge end now holds the loop at the house's level (F2). With
|
||||||
# that hold and its reseed switched off they equal 5a4cdc2's again; the others
|
# that hold and its reseed switched off they equal 5a4cdc2's again; the others
|
||||||
# (no charge) never moved.
|
# (no charge) never moved.
|
||||||
|
# SAFETY-135: n1_ch re-pinned. A first guard stop no longer latches, so while
|
||||||
|
# the charge waits its reason reads "(peak)" where the latch said
|
||||||
|
# "(headroom)"; no command moves. With SAFETY-135 off it is OPT-15's pin
|
||||||
|
# again (checked below).
|
||||||
|
N1_CH_OPT15 = "97893a54f8a8e3f36ad24f100973b3ff35766527e7287a6ae30cc1c8941bac02"
|
||||||
BASE_DIGESTS = {
|
BASE_DIGESTS = {
|
||||||
"n1_ls":
|
"n1_ls":
|
||||||
"13f11c330263c91625acde7628426ff16dae3a9af99aebeef3051dc5b5a884ea",
|
"13f11c330263c91625acde7628426ff16dae3a9af99aebeef3051dc5b5a884ea",
|
||||||
"n1_ch":
|
"n1_ch":
|
||||||
"97893a54f8a8e3f36ad24f100973b3ff35766527e7287a6ae30cc1c8941bac02",
|
"5d529f516307e4da4dca48714ee891332a2eea5e1b1763f0f1e049cfb0819a0a",
|
||||||
"n2_sf_ls":
|
"n2_sf_ls":
|
||||||
"9424e1837b805a6a31293a28b8fbad6ba611ea0bfe3240d636d68d62dc5e9f22",
|
"9424e1837b805a6a31293a28b8fbad6ba611ea0bfe3240d636d68d62dc5e9f22",
|
||||||
"n2_ch_ls":
|
"n2_ch_ls":
|
||||||
@@ -81,7 +86,7 @@ BASE_DIGESTS = {
|
|||||||
"n1_ls_fc_down":
|
"n1_ls_fc_down":
|
||||||
"13f11c330263c91625acde7628426ff16dae3a9af99aebeef3051dc5b5a884ea",
|
"13f11c330263c91625acde7628426ff16dae3a9af99aebeef3051dc5b5a884ea",
|
||||||
"n1_ch_fc_down":
|
"n1_ch_fc_down":
|
||||||
"97893a54f8a8e3f36ad24f100973b3ff35766527e7287a6ae30cc1c8941bac02",
|
"5d529f516307e4da4dca48714ee891332a2eea5e1b1763f0f1e049cfb0819a0a",
|
||||||
"n2_sf_ls_fc_down":
|
"n2_sf_ls_fc_down":
|
||||||
"9424e1837b805a6a31293a28b8fbad6ba611ea0bfe3240d636d68d62dc5e9f22",
|
"9424e1837b805a6a31293a28b8fbad6ba611ea0bfe3240d636d68d62dc5e9f22",
|
||||||
"n2_ch_ls_fc_down":
|
"n2_ch_ls_fc_down":
|
||||||
@@ -98,6 +103,38 @@ for k, v in BASE_DIGESTS.items():
|
|||||||
check(f"{k}: pinned digest (5a4cdc2; OPT-15 for the _ch ones)",
|
check(f"{k}: pinned digest (5a4cdc2; OPT-15 for the _ch ones)",
|
||||||
got.get(k) == v)
|
got.get(k) == v)
|
||||||
check("every scenario is pinned", set(got) == set(BASE_DIGESTS))
|
check("every scenario is pinned", set(got) == set(BASE_DIGESTS))
|
||||||
|
|
||||||
|
|
||||||
|
class _Every:
|
||||||
|
"""SAFETY-135 off: a stop period equal to every period, so every guard
|
||||||
|
stop latches as before."""
|
||||||
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return True
|
||||||
|
|
||||||
|
def __ne__(self, other):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _s135_off_digest(scn):
|
||||||
|
from app import main as _m
|
||||||
|
real = _m.Controller.__init__
|
||||||
|
|
||||||
|
def init(self, *a, **k):
|
||||||
|
real(self, *a, **k)
|
||||||
|
self._charge_stop = (_Every(), float("-inf"))
|
||||||
|
young, restart = modetrade.YOUNG_S, modetrade.RESTART_S
|
||||||
|
_m.Controller.__init__ = init
|
||||||
|
modetrade.YOUNG_S = modetrade.RESTART_S = 0.0
|
||||||
|
try:
|
||||||
|
return ident.p3.digest(ident.run(**ident.SCENARIOS[scn])[0])
|
||||||
|
finally:
|
||||||
|
_m.Controller.__init__ = real
|
||||||
|
modetrade.YOUNG_S, modetrade.RESTART_S = young, restart
|
||||||
|
|
||||||
|
|
||||||
|
check("n1_ch with SAFETY-135 off is OPT-15's pin: only the reason word moved",
|
||||||
|
_s135_off_digest("n1_ch") == N1_CH_OPT15)
|
||||||
for k in [k for k in got if k.endswith("_fc_down")]:
|
for k in [k for k in got if k.endswith("_fc_down")]:
|
||||||
check(f"{k}: a set but unavailable forecast equals no forecast",
|
check(f"{k}: a set but unavailable forecast equals no forecast",
|
||||||
got[k] == got[k[:-len('_fc_down')]])
|
got[k] == got[k[:-len('_fc_down')]])
|
||||||
|
|||||||
@@ -6,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]
|
||||||
|
|||||||
@@ -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")
|
||||||
Reference in New Issue
Block a user