diff --git a/goodwe_controller/DECISIONS/SAFETY-119-bound-after-hand-back.md b/goodwe_controller/DECISIONS/SAFETY-119-bound-after-hand-back.md index 87399f7..ad06a16 100644 --- a/goodwe_controller/DECISIONS/SAFETY-119-bound-after-hand-back.md +++ b/goodwe_controller/DECISIONS/SAFETY-119-bound-after-hand-back.md @@ -17,34 +17,51 @@ until the plant's settle has passed (`SHORT_CYCLES` cycles and counts as before. An over-rating (`clamped`, a bug) on a cycle the loop decided always counts. No command changes, and no text was added or changed. -**One window per run of hand-backs (round 2).** A run is hand-backs with the -wire away less than a settle (12 cycles and 22 s) between them. It ends when -the loop holds the wire, or the wire is away, a whole settle in a row. Each -hand-back restarts the settle, but a run hides the bound for at most 11 bound -cycles and 22 s in all. Round 1 restarted the window at every hand-back, so -a guard and a charge taking turns hid the bound for good (0 warnings in 2 h -where 28 were raised before). Now a bound that stays warns after at most 11 -of its cycles, however often the wire changes hands. +**A rolling budget (round 3).** The settle above is per hand-back, and +losing the wire restarts it. Across all hand-backs the bound is hidden on at +most 8 cycles in any 15 minutes (`ROLLING_BOUND_CYCLES`, `ROLLING_S`). A cycle +over the budget counts. Round 1 restarted the settle at +every hand-back, so a guard and a charge taking turns hid the bound for good +(0 warnings in 2 h where 28 were raised before). Round 2 gave each run of +hand-backs its own budget; at 10 s and 15 s meters each quarter then started +a fresh one and the loop's bound was never counted. A quarter-edge kick is a +few bound cycles in one quarter, a real bound that stays is many: a budget +per 15 minutes tells them apart. -**Delay.** The first counted bound cycle, from the hand-back (worst case): +**Why 8.** Measured over 2 h (`goodwe-addon-checks:1`), budgets 3 to 10, the +quarter-edge case (house 800 W, cap 2500 W, 1 to 15 s meters) and the case +below. 3, 4, 5 and 6 let the false alarm back (at 2 s, for each). 7 let it +back on 10 of 30 seeds at 2 s. 8: 1 of 30 seeds at 2 s, none at 1, 5, 10 and 15 s. +9 and 10: none, but the case below then misses quarters at 10 s. So 8 is the +largest budget that still warns every quarter at 2, 5 and 10 s, and the +margin is narrow: 7 and 9 each fail one side. -| meter | one hand-back | repeated hand-backs | -|---|---|---| -| 2 s | 22 s | 264 s | -| 5 s (default) | 55 s | 660 s | -| 60 s | 660 s | 7920 s | +**What holds with 8.** Single hand-back, the loop at its rail: the first +counted bound cycle is the 9th, so 8 cycles after the hand-back (measured, +real Controller): 8 s at 1 s, 16 s at 2 s, 40 s at 5 s (default), 80 s at +10 s, 120 s at 15 s, 240 s at 30 s, 480 s at 60 s. Repeated hand-backs +(2 x 2500 W, cheap-window charge, no usable forecast, cap 5000 W, house +5050 and 5100 W, 2 h, 4 loads each): -One hand-back: 11 cycles, never less than 22 s. Repeated: the worst case is -one bound cycle per turn with the wire away 11 cycles between (22 at 1 s), -so 11 turns. It grows with the meter; it stays under a quarter (900 s) up -to a 5 s meter. -Measured: N=2, 5 s meter, cheap-window charge, no usable forecast, house 50 W -over the 5000 W rating: 14 warnings in 2 h (two in each quarter after the first), 0 with -round 1, 28 without any rule. The cap held. +| meter | warnings in 2 h | quarters warned (of 7 after the first) | without the fix | +|---|---|---|---| +| 2 s | 51 to 57 | 7 | 54 to 56 | +| 5 s | 19 to 23 | 7 | 28 | +| 10 s | 7 to 10 | 7 | 14 | +| 15 s | 1 to 3 | 1 to 3 | 14 | +| 30 s | 0 | 0 | 7 and 11 | + +At 15 s it warns in some quarters, not all. At 30 s it does not warn in this +case. For 60 s the rig's cycle is a different regime (the house is not over +the cap there), so nothing is claimed. The cap held at 2 s and 5 s; at 10 s +and 15 s the meter's lag puts the highest quarter 20 to 55 W over 5000 W, +the same with and without the fix. Quarter-edge case, 15 s meter: the highest +quarter is 2327 W to 2680 W, the same with and without the fix. **Kept.** The loop in charge at its rail warns on the same cycle as before. The start and a restart after a stop are not hand-backs. The quarter-edge -kick of `test_safety119.py` part 1 stays hidden (0 warnings in 2 h). +kick of `test_safety119.py` part 1 stays hidden (0 warnings in 2 h at +1, 2, 5, 10 and 15 s meters). **Re-pinned.** test_safety84 (all six) and test_safety87 (`n1_crossing`, `n1_above`, `n1_charge_low`): only OUTPUT_CLAMP log rows differ; commands and diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index c4a2daa..afc6de1 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -299,10 +299,10 @@ REFUSED_PROGRESS_W = _ctl.PROBE_ANSWER_W / 2 # was told over the same span as above (the plant's measured settle time), so # a healthy battery still settling on a new command is not short. SHORT_CYCLES, SHORT_SPAN_S = _ctl.SETTLE_CYCLES, REFUSED_SPAN_S -# SAFETY-119: a run of hand-backs (the wire away less than a settle between -# them) may hide the loop's bound for at most this many cycles and -# SHORT_SPAN_S, in all. -RUN_BOUND_CYCLES = SHORT_CYCLES - 1 +# SAFETY-119: across all hand-backs, the loop's bound is hidden on at most +# this many cycles in any ROLLING_S seconds (a budget measured in +# DECISIONS/SAFETY-119-bound-after-hand-back.md). +ROLLING_BOUND_CYCLES, ROLLING_S = 8, 900.0 def _refused_interval(sign: int) -> tuple: @@ -1276,13 +1276,12 @@ class Controller: self._loop_decision = None self._asked_hist: dict = {} self._short_run = 0 - # SAFETY-119: did another owner set the wire last cycle; the open run - # (monotonic s and count of the loop's cycles in a row, bound cycles - # hidden, monotonic s of the first); the wire's cycles away in a row - # (first s, count). + # SAFETY-119: did another owner set the wire last cycle; the window + # after a hand-back (monotonic s, the loop's cycles in a row); the + # monotonic s of the bound cycles hidden in the last ROLLING_S. self._wire_lost = False self._handback = None - self._away = None + self._hidden = () self.stopping = False # SAFETY-61. time.monotonic() at the end of the last run_control() pass. self.loop_pass_mono = None @@ -5825,7 +5824,6 @@ class Controller: self._short_run = 0 self._asked_hist = {} self._wire_lost, self._handback = False, None - self._away = None return unmet = self.allocation.unmet_w if self.allocation is not None else 0 cut = (resolution.bound_by or "").split("(")[0] @@ -5864,41 +5862,35 @@ class Controller: def _settling(self, owns, now) -> bool: """SAFETY-119: does the loop's bound on this cycle count? Not within - a run of hand-backs from another owner: its integrator is re-seeded - from that owner's command and kicked by a meter that does not show it - yet. A run opens at a hand-back and ends when the loop held the wire, - or the wire was away, a whole settle in a row (SHORT_CYCLES cycles - and SHORT_SPAN_S). Each hand-back restarts the settle, but the run - hides the bound for RUN_BOUND_CYCLES cycles and SHORT_SPAN_S in all, - so a bound that stays is counted after that, however often the wire - changes hands. DECISIONS/SAFETY-119-bound-after-hand-back.md.""" + a settle after another owner handed the wire back: its integrator is + re-seeded from that owner's command and kicked by a meter that does + not show it yet. The settle is SHORT_CYCLES cycles and SHORT_SPAN_S + after the hand-back; losing the wire restarts it. Across all + hand-backs the bound is hidden on at most ROLLING_BOUND_CYCLES cycles + in any ROLLING_S, so a bound that stays is counted however often the + wire changes hands. DECISIONS/SAFETY-119-bound-after-hand-back.md.""" def aged(t0, span): return now is None or t0 is None or now - t0 >= span if not owns: - self._wire_lost = True - t0, k = self._away or (now, 0) - self._away = (t0, k + 1) - if k + 1 >= SHORT_CYCLES and aged(t0, SHORT_SPAN_S): - self._handback = None + self._wire_lost, self._handback = True, None return False - self._away = None if self._wire_lost: - self._wire_lost = False - self._handback = (now, 0) + (self._handback or (0,) * 4)[2:] + self._wire_lost, self._handback = False, (now, 0) if self._handback is None: return False - tc, n, hid, tb = self._handback + tc, n = self._handback n += 1 - self._handback = (tc, n, hid, tb) + self._handback = (tc, n) if aged(tc, SHORT_SPAN_S) and n >= SHORT_CYCLES: self._handback = None return False if self.loop_clamped[0] and not self.loop_clamped[2]: - if hid >= RUN_BOUND_CYCLES and aged(tb, SHORT_SPAN_S): + kept = tuple(t for t in self._hidden if not aged(t, ROLLING_S)) + if len(kept) >= ROLLING_BOUND_CYCLES: + self._hidden = kept return False - tb = tb if hid else now - self._handback = (tc, n, hid + 1, tb) + self._hidden = kept + (now,) return True async def check_output_clamp(self) -> None: diff --git a/goodwe_controller/test_safety119.py b/goodwe_controller/test_safety119.py index 02c958c..652e217 100644 --- a/goodwe_controller/test_safety119.py +++ b/goodwe_controller/test_safety119.py @@ -9,10 +9,10 @@ It crossed its rail for one or two cycles, the loop's ask beat the one-sided limit, so the cycle counted as the loop's own and raised `goodwe_integrator_bound`. The battery gave what it was told. Now the loop's bound does not count until the plant's settle (SHORT_CYCLES cycles and -SHORT_SPAN_S) has passed since the hand-back. Round 2: one such window per -run of hand-backs, hiding at most RUN_BOUND_CYCLES bound cycles and -SHORT_SPAN_S in all, so a bound that stays warns when the guard and the -charge keep taking turns. DECISIONS/SAFETY-119-bound-after-hand-back.md. +SHORT_SPAN_S) has passed since the hand-back. Round 3: across all +hand-backs the bound is hidden on at most ROLLING_BOUND_CYCLES cycles in any +ROLLING_S seconds, so a bound that stays warns when the guard and the charge +keep taking turns. DECISIONS/SAFETY-119-bound-after-hand-back.md. 1. The rig's shape (1 s meter that lags the battery, 1 s battery lag, house 800 W, rating 2500 W, limit 2500 W, margin 50, charge 2000 W, no @@ -23,10 +23,12 @@ charge keep taking turns. DECISIONS/SAFETY-119-bound-after-hand-back.md. hand-back (after the settle). 3. With a usable forecast: the same commands and the same alarms as without the fix. -4. Repeated hand-backs (N=2, 5 s meter, the guard and the charge take turns): - it warns at least once per quarter; a start or a blind cycle is not a +4. Repeated hand-backs (N=2, the guard and the charge take turns), 5, 10 and + 15 s meters: it warns (every quarter at 5 s and 10 s); the quarter-edge + case stays silent at 10 s and 15 s too; a start or a blind cycle is not a hand-back; a slow meter delays by cycles, not seconds. -5. The rule at its boundaries (`Controller._settling`). +5. The rule at its boundaries (`Controller._settling`): the settle, and the + rolling budget. 6. test_safety84's and test_safety87's re-pinned digests: the same commands and claims; only OUTPUT_CLAMP log rows differ. @@ -248,14 +250,14 @@ _first = notes(_hb[1])[:1] print(f" hand-back to a loop at its rail at 600 s: first notification " f"{_first}, without the fix {notes(_hb_old[1])[:1]}; command at the " f"end {_hb[0][-1][1]}") -check("⭐ the loop at its rail after a hand-back still warns, once the " - "settle has passed (12 cycles, 22 s): delay, not loss", - _first and 600.0 + app_main.SHORT_SPAN_S <= _first[0] <= 640.0 +MB = 8 +check(f"⭐ the loop at its rail after a hand-back still warns, once " + f"{MB} of its bound cycles are hidden (1 s meter: {MB} s)", + _first and 600.0 + MB <= _first[0] <= 600.0 + MB + 4.0 and _hb[0][-1][1] == -2500.0 and _hb[1].clamp_wd.bound > 100) check("...without the fix it warned at once (non-vacuous)", - notes(_hb_old[1])[:1] and notes(_hb_old[1])[0] < 600.0 - + app_main.SHORT_SPAN_S) + notes(_hb_old[1])[:1] and notes(_hb_old[1])[0] < 600.0 + MB) # The same charge with no forecast; at 1795 s, under the guard's step-in at # the quarter's end, the house goes to 4000 W, over the 2500 W rating. _up = run(house(1, step=(1795.0, 4000.0)), dur=1900.0) @@ -309,32 +311,79 @@ def round1(self, owns, now): return True -# N=2 (2 x 2500 W), a 5 s meter, a cheap-window charge, no usable forecast, -# the house 50 W over the 5000 W rating from 300 s: the loop never held the -# wire for more than 10 cycles. Round 1: no notification in 2 h. -rig.TICK = 5.0 -_nv = run(house(1, step=(300.0, 5050.0)), n=2) -_nv_old = no_fix(lambda: run(house(1, step=(300.0, 5050.0)), n=2)) -_nv_r1 = patched(Controller, "_settling", round1)( - lambda: run(house(1, step=(300.0, 5050.0)), n=2)) -rig.TICK = 1.0 -_per_q = [sum(1 for t in notes(_nv[1]) if int(t // QUARTER) == q) - for q in range(8)] -print(f" N=2, 5 s meter, house 5050 W: notifications {len(notes(_nv[1]))} " - f"(per quarter {_per_q}); round 1's rule {len(notes(_nv_r1[1]))}; " - f"without the fix {len(notes(_nv_old[1]))}; highest quarter " - f"{max(quarters(_nv[1], 5.0)):.0f} W") -check("⭐ it warns at least once in every quarter after the first warning " - "(the guard and the charge keep taking turns)", - len(notes(_nv[1])) >= 7 and all(_per_q[q] >= 1 for q in range(1, 8))) +# N=2 (2 x 2500 W), a cheap-window charge, no usable forecast, the house 50 W +# over the 5000 W rating from 300 s: the loop never held the wire for more +# than 10 cycles. Round 1: no notification in 2 h; round 2: none at 10 s or +# 15 s meters. +def never(tick, hw=5050.0, fix=True): + rig.TICK = tick + try: + f = lambda: run(house(1, step=(300.0, hw)), n=2) + return f() if fix else no_fix(f) + finally: + rig.TICK = 1.0 + + +def per_quarter(c): + return [sum(1 for t in notes(c) if int(t // QUARTER) == q) + for q in range(8)] + + +_nv = {t: never(t) for t in (5.0, 10.0, 15.0)} +_nv_old = {t: never(t, fix=False) for t in (5.0, 10.0, 15.0)} +_nv_r1 = patched(Controller, "_settling", round1)(lambda: never(5.0)) +for _t in _nv: + print(f" N=2, {_t:.0f} s meter, house 5050 W: notifications " + f"{len(notes(_nv[_t][1]))} (per quarter " + f"{per_quarter(_nv[_t][1])}), without the fix " + f"{len(notes(_nv_old[_t][1]))}, highest quarter " + f"{max(quarters(_nv[_t][1], _t)):.0f} W") +print(f" round 1's rule at 5 s: {len(notes(_nv_r1[1]))}") +check("⭐ 5 s meter: it warns in every quarter after the first", + all(per_quarter(_nv[5.0][1])[q] >= 1 for q in range(1, 8))) +check("⭐ 10 s meter: it warns in every quarter after the first (round 2 " + "gave none)", + all(per_quarter(_nv[10.0][1])[q] >= 1 for q in range(1, 8))) +check("⭐ 15 s meter: it warns (round 2 gave none), in at least one quarter " + "of the seven after the first", + sum(1 for n in per_quarter(_nv[15.0][1])[1:] if n) >= 1) check("non-vacuous: round 1's rule (a window per hand-back) never warned " "here, and the fix still hides some of what was raised without it", - notes(_nv_r1[1]) == [] - and _nv[1].clamp_wd.bound < _nv_old[1].clamp_wd.bound) -check("...the cap held and every command is the same as without the fix", - len(quarters(_nv[1], 5.0)) == 8 - and max(quarters(_nv[1], 5.0)) < 5000.0 - and commands(_nv[0]) == commands(_nv_old[0])) + notes(_nv_r1[1]) == [] and all( + _nv[t][1].clamp_wd.bound < _nv_old[t][1].clamp_wd.bound + for t in _nv)) +check("...every command is the same as without the fix, at 5, 10 and 15 s", + all(commands(_nv[t][0]) == commands(_nv_old[t][0]) for t in _nv)) +check("...and at 5 s the cap held", + len(quarters(_nv[5.0][1], 5.0)) == 8 + and max(quarters(_nv[5.0][1], 5.0)) < 5000.0) + +# The quarter-edge case (part 1) at the slower meters: still no warning. +# The meter's own lag limits the cap there, the same with and without the fix. +_qe = {} +for _t, _seeds in ((10.0, ((1, 1), (2, 1), (3, 2))), + (15.0, ((1, 1), (2, 1), (3, 2))), + (2.0, ((8, 1), (9, 2)))): # a budget of 7 warns here + rig.TICK = _t + for _seed, _dl in _seeds: + _qe[_t, _seed] = (run(house(_seed), delay=_dl), + no_fix(lambda: run(house(_seed), delay=_dl))) + rig.TICK = 1.0 + print(f" {_t:.0f} s meter, quarter edge: notifications " + f"{[len(notes(r[0][1])) for k, r in _qe.items() if k[0] == _t]} " + f"(without the fix " + f"{[len(notes(r[1][1])) for k, r in _qe.items() if k[0] == _t]})") +check("⭐ the quarter-edge case at 10 s and 15 s meters, and at 2 s on two " + "more seeds: no notification", + all(notes(r[0][1]) == [] and r[0][1].clamp_wd.bound == 0 + for r in _qe.values())) +check("...every command the same as without the fix; without it the false " + "alarm is there (non-vacuous)", + all(commands(r[0][0]) == commands(r[1][0]) for r in _qe.values()) + and all(len(notes(r[1][1])) >= 1 for r in _qe.values())) +check("the budget is the one the sweep chose (8). Changing it means " + "re-running the sweep in DECISIONS/SAFETY-119-bound-after-hand-back.md", + app_main.ROLLING_BOUND_CYCLES == 8 and app_main.ROLLING_S == 900.0) # A start, or a blind cycle, is not a hand-back: the loop at its rail after # 20 s of not running warns at once (R2: a restart counted as a hand-back). @@ -348,8 +397,8 @@ check("⭐ a start after a blind spell is not a hand-back: it warns at the " notes(_bl[1])[:1] and 20.0 <= notes(_bl[1])[0] <= 26.0 and notes(_bl[1]) == notes(_bl_old[1])) -# The settle is SHORT_CYCLES cycles, not only SHORT_SPAN_S (R4): at a 5 s -# meter the loop at its rail after a hand-back warns 11 cycles (55 s) later. +# The budget is cycles, not seconds (R4): at a 5 s meter the loop at its +# rail after a hand-back warns 8 cycles (40 s) later, not 8 s. rig.TICK = 5.0 _sl = run(house(1, step=(600.0, -6000.0)), dur=900.0, extra={"peak_cap_w": 20000}) @@ -357,9 +406,9 @@ rig.TICK = 1.0 _sl1 = [t for t in notes(_sl[1]) if t >= 600.0][:1] print(f" 5 s meter, hand-back to a loop at its rail at 600 s: first " f"notification {_sl1}") -check("⭐ at a 5 s meter the settle is 11 cycles: the first warning comes " - "55 s after the hand-back, not 22 s", - _sl1 and 600.0 + 11 * 5.0 <= _sl1[0] <= 600.0 + 11 * 5.0 + 10.0) +check(f"⭐ at a 5 s meter the budget is cycles, not seconds: the first " + f"warning comes {MB} cycles ({MB * 5} s) after the hand-back", + _sl1 and 600.0 + MB * 5.0 <= _sl1[0] <= 600.0 + MB * 5.0 + 10.0) # ============================================================================ # print("\n5. the rule at its boundaries") @@ -369,7 +418,7 @@ class _C: _settling = Controller._settling def __init__(self): - self._wire_lost, self._handback, self._away = False, None, None + self._wire_lost, self._handback, self._hidden = False, None, () self.loop_clamped = (False, "", False) @@ -383,6 +432,7 @@ def seq(c, steps): N, SPAN = app_main.SHORT_CYCLES, app_main.SHORT_SPAN_S +M, ROLL = 8, 900.0 # the chosen budget, written out on purpose _c = _C() check("from the start, owned every cycle: never settling (no hand-back)", seq(_c, [(True, float(i)) for i in range(30)]) == [False] * 30) @@ -393,37 +443,36 @@ check(f"after another owner: settling for {N - 1} owned cycles, then not " _out == [False] + [True] * (N - 1) + [False] * 3) _c = _C() _out = seq(_c, [(False, 0.0)] + [(True, 0.5 * i) for i in range(1, 60)]) -_end = _out.index(False, 1) +_end = _out.index(False, 1) if False in _out[1:] else -1 check(f"at a 0.5 s meter the span decides: settling until {SPAN:.0f} s", _end == 1 + int(SPAN / 0.5) and all(_out[1:_end])) _c = _C() _out = seq(_c, [(False, 0.0), (True, 2.0), (True, 4.0), (False, 6.0), (True, 8.0), (True, 10.0)]) -check("losing the wire again restarts the settle (but not the run)", +check("losing the wire again restarts the settle", _out == [False, True, True, False, True, True] - and _c._handback == (8.0, 2, 0, 0)) + and _c._handback == (8.0, 2)) _c = _C() check("no monotonic clock: the cycle count alone decides", seq(_c, [(False, None)] + [(True, None)] * (N + 1)) == [False] + [True] * (N - 1) + [False] * 2) -# One run of hand-backs hides RUN_BOUND_CYCLES bound cycles and SHORT_SPAN_S, -# however often the wire changes hands. T = the loop's bound that cycle. -M = app_main.RUN_BOUND_CYCLES +# The rolling budget: the loop's bound is hidden on at most M cycles in any +# ROLL seconds, across all hand-backs. T = the loop's bound that cycle. +assert M < N - 1, "the budget is meant to run out inside one settle" -def turns(c, tick, owned, lost, periods, first=0.0): - """A hand-back, then `periods` x (`owned` bound cycles, `lost` cycles - away). Returns (time, hidden) of each owned bound cycle.""" +def turns(c, tick, lost, periods, first=0.0, bound=True): + """A hand-back, then `periods` x (1 owned cycle with the loop's bound, + `lost` cycles away). Returns [(time, hidden)] of the owned cycles.""" t, out = first, [] for _ in range(lost): c._settling(False, t) t += tick for _ in range(periods): - for _ in range(owned): - c.loop_clamped = (True, "", False) - out.append((t, c._settling(True, t))) - t += tick + c.loop_clamped = (bound, "", False) + out.append((t, c._settling(True, t))) + t += tick for _ in range(lost): c._settling(False, t) t += tick @@ -431,69 +480,68 @@ def turns(c, tick, owned, lost, periods, first=0.0): _c = _C() -_o, _ = turns(_c, 5.0, 1, 3, 20) -_hid = [h for _t, h in _o] -check(f"at 5 s, one bound cycle per turn: {M} are hidden, the {M + 1}th and " - "every later one counts", - _hid == [True] * M + [False] * (20 - M)) +_o = seq(_c, [(False, 0.0)] + [(True, 5.0 * i, True) for i in range(1, N)]) +check(f"one hand-back at 5 s, the bound on every cycle: {M} are hidden, " + f"then it counts for the rest of the settle ({N - 1} cycles)", + _o == [False] + [True] * M + [False] * (N - 1 - M)) _c = _C() -_o, _ = turns(_c, 1.0, 2, 1, 12) -_first, _o0 = first(t for t, h in _o if not h), nth(_o, 0)[0] -check(f"at 1 s the span holds too: not counted before {SPAN:.0f} s after " - f"the first hidden one (counted at {_first:.0f} s)", - present(_first, _o0) and _o0 + SPAN <= _first <= _o0 + SPAN + 3.0 - and sum(h for _t, h in _o) > M) +_o, _ = turns(_c, 5.0, 3, 60) +_hid = [t for t, h in _o if h] +check(f"across hand-backs (one bound cycle per turn, 5 s): the first {M} " + "are hidden, then every one counts", + [h for _t, h in _o[:M + 5]] == [True] * M + [False] * 5) +check(f"...never more than {M} hidden in any {ROLL:.0f} s, and they come " + f"back as the oldest ages out (exactly {M} in the first {ROLL:.0f} s " + "after the first)", + all(sum(1 for x in _hid if t <= x < t + ROLL) <= M for t in _hid) + and sum(1 for x in _hid if x < _hid[0] + ROLL) == M + and any(x >= _hid[0] + ROLL for x in _hid)) _c = _C() -_t = 0.0 -for _ in range(20): # an over-rating: always counts - for _ in range(3): - _c._settling(False, _t) - _t += 5.0 - _c.loop_clamped = (True, "", True) - _c._settling(True, _t) - _t += 5.0 -for _ in range(3): - _c._settling(False, _t) - _t += 5.0 +_c._hidden = (0.0,) * M +_c._wire_lost, _c._handback = False, (ROLL - 30.0, 0) _c.loop_clamped = (True, "", False) -check("an over-rating does not use up what the run may hide", - _c._settling(True, _t) is True) +_a = _c._settling(True, ROLL - 1.0) +_c.loop_clamped = (True, "", False) +_b = _c._settling(True, ROLL) +check(f"the budget refills {ROLL:.0f} s after the oldest hidden cycle: " + f"counted at {ROLL - 1:.0f} s, hidden again at {ROLL:.0f} s", + _a is False and _b is True) _c = _C() -_o1, _t = turns(_c, 2.0, 1, 3, 20) +_o1, _t = turns(_c, 2.0, 3, 20) for _ in range(N): # the wire away a whole settle _c._settling(False, _t) _t += 2.0 -_o2 = [] -for _ in range(20): - _c.loop_clamped = (True, "", False) - _o2.append(_c._settling(True, _t)) - _c._settling(False, _t + 2.0) - _t += 4.0 -check("a settle with the wire away ends the run: the next hand-back hides " - f"{M} again", - [h for _t2, h in _o1] == [True] * M + [False] * (20 - M) - and _o2 == [True] * M + [False] * (20 - M)) +_o2, _ = turns(_c, 2.0, 3, 5, first=_t) +check("the budget is not per hand-back or per run: after a whole settle " + "with the wire away, a new hand-back within the same " + f"{ROLL:.0f} s hides nothing more", + [h for _t2, h in _o1[:M]] == [True] * M + and not any(h for _t2, h in _o2)) _c = _C() -_o1, _t = turns(_c, 2.0, 1, 3, 20) -for _ in range(N - 1 - 3): # 3 already: N - 1 away - _c._settling(False, _t) - _t += 2.0 -_c.loop_clamped = (True, "", False) -check("...but not a shorter time away: the same run, nothing more hidden", - _c._settling(True, _t) is False) -_c = _C() -_o1, _t = turns(_c, 2.0, 1, 3, 20) +_o1, _t = turns(_c, 2.0, 3, 20) _ = [_c._settling(True, _t + 2.0 * i) for i in range(N + 1)] _c._settling(False, _t + 40.0) _c.loop_clamped = (True, "", False) -check("the loop holding the wire a whole settle ends the run too: the next " - "hand-back hides again", - _c._settling(True, _t + 42.0) is True) +check("the loop holding the wire a whole settle ends the window: the next " + "hand-back hides nothing more while the budget is spent", + _c._settling(True, _t + 42.0) is False) +_c = _C() +_c._wire_lost = True +_t = 0.0 +for _ in range(M + 4): # an over-rating: always counts + _c.loop_clamped = (True, "", True) + _c._settling(True, _t) + _c._wire_lost = True + _t += 5.0 +_c._settling(False, _t) +_c.loop_clamped = (True, "", False) +check("an over-rating does not use up the budget", + _c._settling(True, _t + 5.0) is True and len(_c._hidden) == 1) # An over-rating (a command over `max_w`) is a bug, never settled away. _ctl = _hb[1] _ctl.loop_clamped = (True, "x", True) -_ctl._wire_lost, _ctl._handback = True, None +_ctl._wire_lost, _ctl._handback, _ctl._hidden = True, None, () class _Res: