Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 39 additions & 11 deletions mode/timers.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@
class Timer:
"""Timer state."""

interval: Seconds
interval_s: float

max_drift: float
Expand All @@ -43,21 +42,17 @@ def __init__(
clock: ClockArg = perf_counter,
sleep: SleepArg = asyncio.sleep,
) -> None:
self.interval = interval
self.max_drift_correction = max_drift_correction
self.name = name
self.clock: ClockArg = clock
self.sleep: SleepArg = sleep
interval_s = self.interval_s = want_seconds(interval)

# Log when drift exceeds this number
self.max_drift = min(interval_s * MAX_DRIFT_PERCENT, MAX_DRIFT_CEILING)

if interval_s > self.max_drift_correction:
self.min_interval_s = interval_s - self.max_drift_correction
self.max_interval_s = interval_s + self.max_drift_correction
else:
self.min_interval_s = self.max_interval_s = interval_s
# Assigning ``interval`` computes ``interval_s``, the default
# ``max_drift`` threshold, and the drift-correction bounds (see the
# property setter below). Going through the property means all of
# those derived values are recomputed if ``interval`` is reassigned
# while the timer is running.
self.interval = interval

# If the loop calls asyncio.sleep(interval)
# it will always wake up a little bit late, and can eventually
Expand All @@ -82,6 +77,39 @@ def __init__(
self.drifting_late = 0
self.overlaps = 0

@property
def interval(self) -> Seconds:
"""Interval to sleep between each iteration.

Reassigning this while the timer is running retunes it live: the
assignment recomputes ``interval_s``, the default ``max_drift``
threshold, and the drift-correction bounds
(``min_interval_s``/``max_interval_s``), so the next ``tick`` uses
the new cadence.
"""
return self._interval

@interval.setter
def interval(self, interval: Seconds) -> None:
self._interval = interval
self._configure_interval(want_seconds(interval))

def _configure_interval(self, interval_s: float) -> None:
# Derive everything that depends on the interval. Called from the
# ``interval`` setter so a runtime interval change keeps these in
# sync (a stale ``max_interval_s`` would otherwise clamp
# ``adjust_interval`` to the old cadence).
self.interval_s = interval_s

# Log when drift exceeds this number.
self.max_drift = min(interval_s * MAX_DRIFT_PERCENT, MAX_DRIFT_CEILING)

if interval_s > self.max_drift_correction:
self.min_interval_s = interval_s - self.max_drift_correction
self.max_interval_s = interval_s + self.max_drift_correction
else:
self.min_interval_s = self.max_interval_s = interval_s

async def __aiter__(self) -> AsyncIterator[float]:
for _ in count():
sleep_time = self.tick()
Expand Down
63 changes: 63 additions & 0 deletions tests/functional/test_timers.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,69 @@ async def test_Timer_real_run():
i += 1


def test_Timer_interval__setter_recomputes_derived_values():
timer = Timer(1.0, name="test")
assert timer.interval == 1.0
assert timer.interval_s == pytest.approx(1.0)
assert timer.max_drift == pytest.approx(0.30) # min(1.0 * 0.30, 1.2)
assert timer.min_interval_s == pytest.approx(0.9) # 1.0 - 0.1
assert timer.max_interval_s == pytest.approx(1.1) # 1.0 + 0.1

# Reassigning the interval refreshes every interval-derived value so
# the timer is fully retuned, not just its nominal interval.
timer.interval = 10.0
assert timer.interval == 10.0
assert timer.interval_s == pytest.approx(10.0)
assert timer.max_drift == pytest.approx(1.2) # min(10.0 * 0.30, 1.2)
assert timer.min_interval_s == pytest.approx(9.9) # 10.0 - 0.1
assert timer.max_interval_s == pytest.approx(10.1) # 10.0 + 0.1


def test_Timer_interval__small_interval_collapses_bounds():
# An interval at or under the drift-correction window cannot be
# corrected, so the bounds collapse onto the interval itself -- this
# must still hold when the interval is set at runtime.
timer = Timer(1.0, name="test")
timer.interval = 0.05
assert timer.interval_s == pytest.approx(0.05)
assert timer.min_interval_s == pytest.approx(0.05)
assert timer.max_interval_s == pytest.approx(0.05)
assert timer.max_drift == pytest.approx(0.015) # min(0.05 * 0.30, 1.2)


@pytest.mark.asyncio
async def test_Timer_interval__can_be_changed_at_runtime():
# After the interval is changed live, tick()/adjust_interval() must use
# the NEW interval and its recomputed bounds. The induced drift is
# large, so the next sleep is clamped up to the new max_interval_s
# (10.1); with the original 1s interval it would clamp to 1.1 instead,
# so the asserted value only holds if the change took effect.
clock = Mock()
clock.side_effect = [
9.0, # __init__ epoch
10.0,
10.5, # iter 1 (first tick): returns interval_s, slept 0.5s
11.0,
12.0, # iter 2: drift computed against the new interval
]
sleep = AsyncMock()
timer = Timer(1.0, name="test", clock=clock, sleep=sleep)
it = timer.__aiter__()

with patch("mode.timers.logger"):
first = await it.__anext__()
assert first == pytest.approx(1.0) # still the original cadence

timer.interval = 10.0 # retune live, mid-iteration

second = await it.__anext__()
# 0.5s slept vs the new 10.0s interval is a big positive drift, so
# adjust_interval clamps up to the NEW max_interval_s (10.1).
assert second == pytest.approx(10.1)

await it.aclose()


class Interval(NamedTuple):
interval: float
wakeup_time: float
Expand Down
Loading