SAFETY-119: no "not keeping up" when the loop takes the wire back #203

Merged
admin merged 11 commits from SAFETY-119 into release/1.0 2026-10-21 03:32:39 +02:00
3 changed files with 213 additions and 156 deletions
Showing only changes of commit 103db2fcda - Show all commits
@@ -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 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. 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 **A rolling budget (round 3).** The settle above is per hand-back, and
wire away less than a settle (12 cycles and 22 s) between them. It ends when losing the wire restarts it. Across all hand-backs the bound is hidden on at
the loop holds the wire, or the wire is away, a whole settle in a row. Each most 8 cycles in any 15 minutes (`ROLLING_BOUND_CYCLES`, `ROLLING_S`). A cycle
hand-back restarts the settle, but a run hides the bound for at most 11 bound over the budget counts. Round 1 restarted the settle at
cycles and 22 s in all. Round 1 restarted the window at every hand-back, so every hand-back, so a guard and a charge taking turns hid the bound for good
a guard and a charge taking turns hid the bound for good (0 warnings in 2 h (0 warnings in 2 h where 28 were raised before). Round 2 gave each run of
where 28 were raised before). Now a bound that stays warns after at most 11 hand-backs its own budget; at 10 s and 15 s meters each quarter then started
of its cycles, however often the wire changes hands. 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 | **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,
| 2 s | 22 s | 264 s | real Controller): 8 s at 1 s, 16 s at 2 s, 40 s at 5 s (default), 80 s at
| 5 s (default) | 55 s | 660 s | 10 s, 120 s at 15 s, 240 s at 30 s, 480 s at 60 s. Repeated hand-backs
| 60 s | 660 s | 7920 s | (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 | meter | warnings in 2 h | quarters warned (of 7 after the first) | without the fix |
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 | 2 s | 51 to 57 | 7 | 54 to 56 |
to a 5 s meter. | 5 s | 19 to 23 | 7 | 28 |
Measured: N=2, 5 s meter, cheap-window charge, no usable forecast, house 50 W | 10 s | 7 to 10 | 7 | 14 |
over the 5000 W rating: 14 warnings in 2 h (two in each quarter after the first), 0 with | 15 s | 1 to 3 | 1 to 3 | 14 |
round 1, 28 without any rule. The cap held. | 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. **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 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`, **Re-pinned.** test_safety84 (all six) and test_safety87 (`n1_crossing`,
`n1_above`, `n1_charge_low`): only OUTPUT_CLAMP log rows differ; commands and `n1_above`, `n1_charge_low`): only OUTPUT_CLAMP log rows differ; commands and
+23 -31
View File
@@ -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 # 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. # a healthy battery still settling on a new command is not short.
SHORT_CYCLES, SHORT_SPAN_S = _ctl.SETTLE_CYCLES, REFUSED_SPAN_S 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 # SAFETY-119: across all hand-backs, the loop's bound is hidden on at most
# them) may hide the loop's bound for at most this many cycles and # this many cycles in any ROLLING_S seconds (a budget measured in
# SHORT_SPAN_S, in all. # DECISIONS/SAFETY-119-bound-after-hand-back.md).
RUN_BOUND_CYCLES = SHORT_CYCLES - 1 ROLLING_BOUND_CYCLES, ROLLING_S = 8, 900.0
def _refused_interval(sign: int) -> tuple: def _refused_interval(sign: int) -> tuple:
@@ -1276,13 +1276,12 @@ class Controller:
self._loop_decision = None self._loop_decision = None
self._asked_hist: dict = {} self._asked_hist: dict = {}
self._short_run = 0 self._short_run = 0
# SAFETY-119: did another owner set the wire last cycle; the open run # SAFETY-119: did another owner set the wire last cycle; the window
# (monotonic s and count of the loop's cycles in a row, bound cycles # after a hand-back (monotonic s, the loop's cycles in a row); the
# hidden, monotonic s of the first); the wire's cycles away in a row # monotonic s of the bound cycles hidden in the last ROLLING_S.
# (first s, count).
self._wire_lost = False self._wire_lost = False
self._handback = None self._handback = None
self._away = None self._hidden = ()
self.stopping = False self.stopping = False
# SAFETY-61. time.monotonic() at the end of the last run_control() pass. # SAFETY-61. time.monotonic() at the end of the last run_control() pass.
self.loop_pass_mono = None self.loop_pass_mono = None
@@ -5825,7 +5824,6 @@ class Controller:
self._short_run = 0 self._short_run = 0
self._asked_hist = {} self._asked_hist = {}
self._wire_lost, self._handback = False, None self._wire_lost, self._handback = False, None
self._away = None
return return
unmet = self.allocation.unmet_w if self.allocation is not None else 0 unmet = self.allocation.unmet_w if self.allocation is not None else 0
cut = (resolution.bound_by or "").split("(")[0] cut = (resolution.bound_by or "").split("(")[0]
@@ -5864,41 +5862,35 @@ class Controller:
def _settling(self, owns, now) -> bool: def _settling(self, owns, now) -> bool:
"""SAFETY-119: does the loop's bound on this cycle count? Not within """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 a settle after another owner handed the wire back: its integrator is
from that owner's command and kicked by a meter that does not show it re-seeded from that owner's command and kicked by a meter that does
yet. A run opens at a hand-back and ends when the loop held the wire, not show it yet. The settle is SHORT_CYCLES cycles and SHORT_SPAN_S
or the wire was away, a whole settle in a row (SHORT_CYCLES cycles after the hand-back; losing the wire restarts it. Across all
and SHORT_SPAN_S). Each hand-back restarts the settle, but the run hand-backs the bound is hidden on at most ROLLING_BOUND_CYCLES cycles
hides the bound for RUN_BOUND_CYCLES cycles and SHORT_SPAN_S in all, in any ROLLING_S, so a bound that stays is counted however often the
so a bound that stays is counted after that, however often the wire wire changes hands. DECISIONS/SAFETY-119-bound-after-hand-back.md."""
changes hands. DECISIONS/SAFETY-119-bound-after-hand-back.md."""
def aged(t0, span): def aged(t0, span):
return now is None or t0 is None or now - t0 >= span return now is None or t0 is None or now - t0 >= span
if not owns: if not owns:
self._wire_lost = True self._wire_lost, self._handback = True, None
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
return False return False
self._away = None
if self._wire_lost: if self._wire_lost:
self._wire_lost = False self._wire_lost, self._handback = False, (now, 0)
self._handback = (now, 0) + (self._handback or (0,) * 4)[2:]
if self._handback is None: if self._handback is None:
return False return False
tc, n, hid, tb = self._handback tc, n = self._handback
n += 1 n += 1
self._handback = (tc, n, hid, tb) self._handback = (tc, n)
if aged(tc, SHORT_SPAN_S) and n >= SHORT_CYCLES: if aged(tc, SHORT_SPAN_S) and n >= SHORT_CYCLES:
self._handback = None self._handback = None
return False return False
if self.loop_clamped[0] and not self.loop_clamped[2]: 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 return False
tb = tb if hid else now self._hidden = kept + (now,)
self._handback = (tc, n, hid + 1, tb)
return True return True
async def check_output_clamp(self) -> None: async def check_output_clamp(self) -> None:
+151 -103
View File
@@ -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 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 `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 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 SHORT_SPAN_S) has passed since the hand-back. Round 3: across all
run of hand-backs, hiding at most RUN_BOUND_CYCLES bound cycles and hand-backs the bound is hidden on at most ROLLING_BOUND_CYCLES cycles in any
SHORT_SPAN_S in all, so a bound that stays warns when the guard and the ROLLING_S seconds, so a bound that stays warns when the guard and the charge
charge keep taking turns. DECISIONS/SAFETY-119-bound-after-hand-back.md. 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 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 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). hand-back (after the settle).
3. With a usable forecast: the same commands and the same alarms as without 3. With a usable forecast: the same commands and the same alarms as without
the fix. the fix.
4. Repeated hand-backs (N=2, 5 s meter, the guard and the charge take turns): 4. Repeated hand-backs (N=2, the guard and the charge take turns), 5, 10 and
it warns at least once per quarter; a start or a blind cycle is not a 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. 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 6. test_safety84's and test_safety87's re-pinned digests: the same commands
and claims; only OUTPUT_CLAMP log rows differ. 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 " 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"{_first}, without the fix {notes(_hb_old[1])[:1]}; command at the "
f"end {_hb[0][-1][1]}") f"end {_hb[0][-1][1]}")
check("⭐ the loop at its rail after a hand-back still warns, once the " MB = 8
"settle has passed (12 cycles, 22 s): delay, not loss", check(f"⭐ the loop at its rail after a hand-back still warns, once "
_first and 600.0 + app_main.SHORT_SPAN_S <= _first[0] <= 640.0 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[0][-1][1] == -2500.0
and _hb[1].clamp_wd.bound > 100) and _hb[1].clamp_wd.bound > 100)
check("...without the fix it warned at once (non-vacuous)", check("...without the fix it warned at once (non-vacuous)",
notes(_hb_old[1])[:1] and notes(_hb_old[1])[0] < 600.0 notes(_hb_old[1])[:1] and notes(_hb_old[1])[0] < 600.0 + MB)
+ app_main.SHORT_SPAN_S)
# The same charge with no forecast; at 1795 s, under the guard's step-in at # 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. # 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) _up = run(house(1, step=(1795.0, 4000.0)), dur=1900.0)
@@ -309,32 +311,79 @@ def round1(self, owns, now):
return True return True
# N=2 (2 x 2500 W), a 5 s meter, a cheap-window charge, no usable forecast, # N=2 (2 x 2500 W), a cheap-window charge, no usable forecast, the house 50 W
# the house 50 W over the 5000 W rating from 300 s: the loop never held the # over the 5000 W rating from 300 s: the loop never held the wire for more
# wire for more than 10 cycles. Round 1: no notification in 2 h. # than 10 cycles. Round 1: no notification in 2 h; round 2: none at 10 s or
rig.TICK = 5.0 # 15 s meters.
_nv = run(house(1, step=(300.0, 5050.0)), n=2) def never(tick, hw=5050.0, fix=True):
_nv_old = no_fix(lambda: run(house(1, step=(300.0, 5050.0)), n=2)) rig.TICK = tick
_nv_r1 = patched(Controller, "_settling", round1)( try:
lambda: run(house(1, step=(300.0, 5050.0)), n=2)) f = lambda: run(house(1, step=(300.0, hw)), n=2)
rig.TICK = 1.0 return f() if fix else no_fix(f)
_per_q = [sum(1 for t in notes(_nv[1]) if int(t // QUARTER) == q) finally:
for q in range(8)] rig.TICK = 1.0
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 " def per_quarter(c):
f"{max(quarters(_nv[1], 5.0)):.0f} W") return [sum(1 for t in notes(c) if int(t // QUARTER) == q)
check("⭐ it warns at least once in every quarter after the first warning " for q in range(8)]
"(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)))
_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 " 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", "here, and the fix still hides some of what was raised without it",
notes(_nv_r1[1]) == [] notes(_nv_r1[1]) == [] and all(
and _nv[1].clamp_wd.bound < _nv_old[1].clamp_wd.bound) _nv[t][1].clamp_wd.bound < _nv_old[t][1].clamp_wd.bound
check("...the cap held and every command is the same as without the fix", for t in _nv))
len(quarters(_nv[1], 5.0)) == 8 check("...every command is the same as without the fix, at 5, 10 and 15 s",
and max(quarters(_nv[1], 5.0)) < 5000.0 all(commands(_nv[t][0]) == commands(_nv_old[t][0]) for t in _nv))
and commands(_nv[0]) == commands(_nv_old[0])) 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 # 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). # 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 notes(_bl[1])[:1] and 20.0 <= notes(_bl[1])[0] <= 26.0
and notes(_bl[1]) == notes(_bl_old[1])) and notes(_bl[1]) == notes(_bl_old[1]))
# The settle is SHORT_CYCLES cycles, not only SHORT_SPAN_S (R4): at a 5 s # The budget is cycles, not seconds (R4): at a 5 s meter the loop at its
# meter the loop at its rail after a hand-back warns 11 cycles (55 s) later. # rail after a hand-back warns 8 cycles (40 s) later, not 8 s.
rig.TICK = 5.0 rig.TICK = 5.0
_sl = run(house(1, step=(600.0, -6000.0)), dur=900.0, _sl = run(house(1, step=(600.0, -6000.0)), dur=900.0,
extra={"peak_cap_w": 20000}) 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] _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 " print(f" 5 s meter, hand-back to a loop at its rail at 600 s: first "
f"notification {_sl1}") f"notification {_sl1}")
check("⭐ at a 5 s meter the settle is 11 cycles: the first warning comes " check(f"⭐ at a 5 s meter the budget is cycles, not seconds: the first "
"55 s after the hand-back, not 22 s", f"warning comes {MB} cycles ({MB * 5} s) after the hand-back",
_sl1 and 600.0 + 11 * 5.0 <= _sl1[0] <= 600.0 + 11 * 5.0 + 10.0) _sl1 and 600.0 + MB * 5.0 <= _sl1[0] <= 600.0 + MB * 5.0 + 10.0)
# ============================================================================ # # ============================================================================ #
print("\n5. the rule at its boundaries") print("\n5. the rule at its boundaries")
@@ -369,7 +418,7 @@ class _C:
_settling = Controller._settling _settling = Controller._settling
def __init__(self): 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) self.loop_clamped = (False, "", False)
@@ -383,6 +432,7 @@ def seq(c, steps):
N, SPAN = app_main.SHORT_CYCLES, app_main.SHORT_SPAN_S 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() _c = _C()
check("from the start, owned every cycle: never settling (no hand-back)", check("from the start, owned every cycle: never settling (no hand-back)",
seq(_c, [(True, float(i)) for i in range(30)]) == [False] * 30) 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) _out == [False] + [True] * (N - 1) + [False] * 3)
_c = _C() _c = _C()
_out = seq(_c, [(False, 0.0)] + [(True, 0.5 * i) for i in range(1, 60)]) _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", 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])) _end == 1 + int(SPAN / 0.5) and all(_out[1:_end]))
_c = _C() _c = _C()
_out = seq(_c, [(False, 0.0), (True, 2.0), (True, 4.0), (False, 6.0), _out = seq(_c, [(False, 0.0), (True, 2.0), (True, 4.0), (False, 6.0),
(True, 8.0), (True, 10.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] _out == [False, True, True, False, True, True]
and _c._handback == (8.0, 2, 0, 0)) and _c._handback == (8.0, 2))
_c = _C() _c = _C()
check("no monotonic clock: the cycle count alone decides", check("no monotonic clock: the cycle count alone decides",
seq(_c, [(False, None)] + [(True, None)] * (N + 1)) seq(_c, [(False, None)] + [(True, None)] * (N + 1))
== [False] + [True] * (N - 1) + [False] * 2) == [False] + [True] * (N - 1) + [False] * 2)
# One run of hand-backs hides RUN_BOUND_CYCLES bound cycles and SHORT_SPAN_S, # The rolling budget: the loop's bound is hidden on at most M cycles in any
# however often the wire changes hands. T = the loop's bound that cycle. # ROLL seconds, across all hand-backs. T = the loop's bound that cycle.
M = app_main.RUN_BOUND_CYCLES assert M < N - 1, "the budget is meant to run out inside one settle"
def turns(c, tick, owned, lost, periods, first=0.0): def turns(c, tick, lost, periods, first=0.0, bound=True):
"""A hand-back, then `periods` x (`owned` bound cycles, `lost` cycles """A hand-back, then `periods` x (1 owned cycle with the loop's bound,
away). Returns (time, hidden) of each owned bound cycle.""" `lost` cycles away). Returns [(time, hidden)] of the owned cycles."""
t, out = first, [] t, out = first, []
for _ in range(lost): for _ in range(lost):
c._settling(False, t) c._settling(False, t)
t += tick t += tick
for _ in range(periods): for _ in range(periods):
for _ in range(owned): c.loop_clamped = (bound, "", False)
c.loop_clamped = (True, "", False) out.append((t, c._settling(True, t)))
out.append((t, c._settling(True, t))) t += tick
t += tick
for _ in range(lost): for _ in range(lost):
c._settling(False, t) c._settling(False, t)
t += tick t += tick
@@ -431,69 +480,68 @@ def turns(c, tick, owned, lost, periods, first=0.0):
_c = _C() _c = _C()
_o, _ = turns(_c, 5.0, 1, 3, 20) _o = seq(_c, [(False, 0.0)] + [(True, 5.0 * i, True) for i in range(1, N)])
_hid = [h for _t, h in _o] check(f"one hand-back at 5 s, the bound on every cycle: {M} are hidden, "
check(f"at 5 s, one bound cycle per turn: {M} are hidden, the {M + 1}th and " f"then it counts for the rest of the settle ({N - 1} cycles)",
"every later one counts", _o == [False] + [True] * M + [False] * (N - 1 - M))
_hid == [True] * M + [False] * (20 - M))
_c = _C() _c = _C()
_o, _ = turns(_c, 1.0, 2, 1, 12) _o, _ = turns(_c, 5.0, 3, 60)
_first, _o0 = first(t for t, h in _o if not h), nth(_o, 0)[0] _hid = [t for t, h in _o if h]
check(f"at 1 s the span holds too: not counted before {SPAN:.0f} s after " check(f"across hand-backs (one bound cycle per turn, 5 s): the first {M} "
f"the first hidden one (counted at {_first:.0f} s)", "are hidden, then every one counts",
present(_first, _o0) and _o0 + SPAN <= _first <= _o0 + SPAN + 3.0 [h for _t, h in _o[:M + 5]] == [True] * M + [False] * 5)
and sum(h for _t, h in _o) > M) 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() _c = _C()
_t = 0.0 _c._hidden = (0.0,) * M
for _ in range(20): # an over-rating: always counts _c._wire_lost, _c._handback = False, (ROLL - 30.0, 0)
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.loop_clamped = (True, "", False) _c.loop_clamped = (True, "", False)
check("an over-rating does not use up what the run may hide", _a = _c._settling(True, ROLL - 1.0)
_c._settling(True, _t) is True) _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() _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 for _ in range(N): # the wire away a whole settle
_c._settling(False, _t) _c._settling(False, _t)
_t += 2.0 _t += 2.0
_o2 = [] _o2, _ = turns(_c, 2.0, 3, 5, first=_t)
for _ in range(20): check("the budget is not per hand-back or per run: after a whole settle "
_c.loop_clamped = (True, "", False) "with the wire away, a new hand-back within the same "
_o2.append(_c._settling(True, _t)) f"{ROLL:.0f} s hides nothing more",
_c._settling(False, _t + 2.0) [h for _t2, h in _o1[:M]] == [True] * M
_t += 4.0 and not any(h for _t2, h in _o2))
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))
_c = _C() _c = _C()
_o1, _t = turns(_c, 2.0, 1, 3, 20) _o1, _t = turns(_c, 2.0, 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)
_ = [_c._settling(True, _t + 2.0 * i) for i in range(N + 1)] _ = [_c._settling(True, _t + 2.0 * i) for i in range(N + 1)]
_c._settling(False, _t + 40.0) _c._settling(False, _t + 40.0)
_c.loop_clamped = (True, "", False) _c.loop_clamped = (True, "", False)
check("the loop holding the wire a whole settle ends the run too: the next " check("the loop holding the wire a whole settle ends the window: the next "
"hand-back hides again", "hand-back hides nothing more while the budget is spent",
_c._settling(True, _t + 42.0) is True) _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. # An over-rating (a command over `max_w`) is a bug, never settled away.
_ctl = _hb[1] _ctl = _hb[1]
_ctl.loop_clamped = (True, "x", True) _ctl.loop_clamped = (True, "x", True)
_ctl._wire_lost, _ctl._handback = True, None _ctl._wire_lost, _ctl._handback, _ctl._hidden = True, None, ()
class _Res: class _Res: