From 4066624cce4469d389970f027c36f2345faa3f0b Mon Sep 17 00:00:00 2001 From: glenn schrooyen Date: Wed, 11 Nov 2026 19:49:18 +0100 Subject: [PATCH] SAFETY-135 round 2: the first guard stop in a period may restart once; tests and docs Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Bdqr7PiaHcBnzPituu2yTC --- .../DECISIONS/SAFETY-135-young-period.md | 144 +++++++++++------ goodwe_controller/app/ctl_trade.py | 37 +++-- goodwe_controller/app/main.py | 2 +- goodwe_controller/app/modetrade.py | 27 ++-- goodwe_controller/test_modes_p3.py | 27 +++- goodwe_controller/test_modes_p4.py | 41 ++++- goodwe_controller/test_price_run_fixes.py | 25 ++- goodwe_controller/test_safety135.py | 148 ++++++++++++++++-- 8 files changed, 356 insertions(+), 95 deletions(-) diff --git a/goodwe_controller/DECISIONS/SAFETY-135-young-period.md b/goodwe_controller/DECISIONS/SAFETY-135-young-period.md index d085db1..4f8d6aa 100644 --- a/goodwe_controller/DECISIONS/SAFETY-135-young-period.md +++ b/goodwe_controller/DECISIONS/SAFETY-135-young-period.md @@ -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 -`Controller._give_falling()` (`app/ctl_trade.py`), `modetrade.decide(young=)`, -`YOUNG_S`, `GAVE_S` (`app/modetrade.py`). Tests: `test_safety135.py`. Battery -modes only. No option, no text change, no change to the peak guard. +**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.** In the first seconds of a period the guard's projection is almost -only the last reading (`held_w`). Two cases seen on TEST-23: +**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 night charge. The cut is right. But a cut charge - stays off to the period's end (the OPT-11 latch), so one 2 s spike cost 15 + 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* (SAFETY-136 rig F-4, x1): a give ends at 01:00 - and the cheap run starts. The battery reading drops at once, the meter - reading a few seconds later. In between, grid + batteries reads the house - too low, so the charge room is too big. The charge starts and the guard cuts - it 4-61 s later, and the latch then holds it off for the period. +- *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.** Two small rules, both only in a young period (its first -`YOUNG_S` = 60 s): -1. *A stop does not latch.* A charge the guard or a measured headroom stops in - a young period may not start again until the period is 60 s old; then the - normal start rule (guard clear, over `START_W` of headroom) decides. A stop - after 60 s latches to the period's end as before, so there is at most one - restart per period and the OPT-11 flip guard keeps its job. -2. *No start while a give is still falling.* 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. 15 s covers a 5 s meter twice plus - Home Assistant's own delay. A give that falls by `START_W` or less does not - hold the start: the error it leaves is under the smallest charge worth - starting. +**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 itself is not touched: its claim, floor and projection are the same -code, so a real over-aim load is cut on the same cycle as before. A running -charge is never held by rule 2. +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.** -- *A minimum data window before the latch* (no latch in the first N s): this - is rule 1. Alone it lets the edge start flap once more. -- *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. +- *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). -- *A hysteresis band on the start*: does not help at the edge. The error there - is the readings' age, not a small room. +- *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, cap 3 500 W, aim 3 450 W; grid -average over each period as the sim saw it): +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 | 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) 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.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 | -The add-on's own period averages in `test_safety135.py`: 3 450.0 W in the -spike period (base 2 807.9 W, the charge lost), 3 450.0 W in the 01:00 period -(base 2 996.5 W at 3.0 kW). Over the aim by a few watts in the sim's grid -average is the landing error base has too (SAFETY-126). +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). Nothing -here touches it. +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. diff --git a/goodwe_controller/app/ctl_trade.py b/goodwe_controller/app/ctl_trade.py index 3dcb2e3..ea3c078 100644 --- a/goodwe_controller/app/ctl_trade.py +++ b/goodwe_controller/app/ctl_trade.py @@ -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,10 +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: a stop in a young period holds only until it is not - # young (no latch), and no charge starts there while a give falls. - young = now.timestamp() - quarter * 900 < modetrade.YOUNG_S - falling = self._give_falling(now) + # 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, @@ -413,7 +422,7 @@ class TradeMixin: 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 ()), - young=falling or (young and self._charge_young_q == quarter)) + wait=wait) 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 @@ -424,8 +433,10 @@ 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): - if young and not gap: - self._charge_young_q = quarter # SAFETY-135 + 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: @@ -438,10 +449,15 @@ class TradeMixin: show that yet, so a charge room read now is too big. DECISIONS/SAFETY-135-young-period.md""" 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: 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() if self.site_batt_w is None or not gave \ or t % 900 >= modetrade.YOUNG_S: @@ -500,7 +516,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, diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 4536ae5..9023c48 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -529,7 +529,7 @@ 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_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._ref_cheap = None # OPT-15: a run edge the loop holds at E: (E, step sign, until, the diff --git a/goodwe_controller/app/modetrade.py b/goodwe_controller/app/modetrade.py index e50ed8e..55184bd 100644 --- a/goodwe_controller/app/modetrade.py +++ b/goodwe_controller/app/modetrade.py @@ -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 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: a stop in a period's first YOUNG_S s holds only until then, - not to the period's end, and no charge starts then while the batteries' - give is still falling (`young`); + 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); @@ -72,11 +73,17 @@ PRICE_MODES = (LEFTOVER_SOLAR, CHEAP_HOURS) # would stop covering the house for less than they give. START_W = 500.0 -# SAFETY-135: seconds a 15-minute period is young, and how far back a -# battery's give counts as maybe not yet on the meter. +# 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) @@ -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, headroom_w, floor_pct, ceiling_pct, house_w, charging_now=False, 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. `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 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"). `young` says no charge may start - yet in this young period (SAFETY-135, why "young"). + 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 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 elif headroom_w is None or not headroom_w > need: why = "headroom" - elif young and not charging_now: - why = "young" + elif wait and not charging_now: + why = wait else: charging = True se = sellers(packs, floor_pct, done_sell) if in_sell and not charging \ diff --git a/goodwe_controller/test_modes_p3.py b/goodwe_controller/test_modes_p3.py index ecb13cd..668e9c8 100644 --- a/goodwe_controller/test_modes_p3.py +++ b/goodwe_controller/test_modes_p3.py @@ -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, diff --git a/goodwe_controller/test_modes_p4.py b/goodwe_controller/test_modes_p4.py index fb16d90..ed0c389 100644 --- a/goodwe_controller/test_modes_p4.py +++ b/goodwe_controller/test_modes_p4.py @@ -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')]]) diff --git a/goodwe_controller/test_price_run_fixes.py b/goodwe_controller/test_price_run_fixes.py index a623f7f..af2d99e 100644 --- a/goodwe_controller/test_price_run_fixes.py +++ b/goodwe_controller/test_price_run_fixes.py @@ -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] diff --git a/goodwe_controller/test_safety135.py b/goodwe_controller/test_safety135.py index d8bcfe5..342f691 100644 --- a/goodwe_controller/test_safety135.py +++ b/goodwe_controller/test_safety135.py @@ -4,7 +4,8 @@ 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 -`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. 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. 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. """ @@ -30,6 +37,7 @@ 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 = [] @@ -51,21 +59,46 @@ 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: no period is young.""" - real = getattr(modetrade, "YOUNG_S", None) - if real is None: - return fn() - modetrade.YOUNG_S = 0.0 + """`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: - 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)`.""" t0 = start.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(), charge=getattr(c.arb, "action", None) == arbitrage.CHARGE, - engaged=g is not None and g.engaged)) - _st, c = rp.run("p3_ch_sell", house, WPTS, start.astimezone(timezone.utc), - secs, check=cb, soc0={"a": 30.0, "b": 30.0}, choice=CH, - extra={"slew_w": 0, "cycle_cost_threshold_eur": 0.03}, - lag=rp.Lag(meter_s=meter_s)) + 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 @@ -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)", 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)}")