Source code for state_space_response_viz.playback
"""PlaybackClock: the transport state machine, independent of ROS and UI.
Answers exactly one question — "what simulation time is it?" — as a pure
function of an injected wall-clock reading, so it is unit-testable with
fake clocks and shared conceptually with the JS twin in the setup
assistant. Every mutation (play, pause, seek, speed change) rebases the
(wall_anchor, sim_anchor) pair, so a speed change never makes the cursor
jump and dropped ticks never accumulate error.
"""
STOPPED = 'stopped'
PLAYING = 'playing'
PAUSED = 'paused'
SPEED_MIN = 0.01
SPEED_MAX = 10.0
[docs]
class PlaybackClock:
def __init__(self, duration: float, *, speed: float = 1.0,
loop: bool = False, t0: float = 0.0):
if duration <= 0:
raise ValueError('duration must be positive')
self.t0 = float(t0)
self.duration = float(duration)
self.loop = bool(loop)
self.state = STOPPED
self._speed = self._check_speed(speed)
self._wall_anchor = 0.0
self._sim_anchor = self.t0
@staticmethod
def _check_speed(speed: float) -> float:
speed = float(speed)
if not SPEED_MIN <= speed <= SPEED_MAX:
raise ValueError(
f'speed must be in [{SPEED_MIN}, {SPEED_MAX}], got {speed}')
return speed
@property
def speed(self) -> float:
return self._speed
@property
def t_end(self) -> float:
return self.t0 + self.duration
[docs]
def now(self, wall: float) -> float:
"""Current simulation time; advances state on end-of-trajectory."""
if self.state != PLAYING:
return self._sim_anchor
t = self._sim_anchor + self._speed * (wall - self._wall_anchor)
if t < self.t_end:
return t
if self.loop:
t = self.t0 + (t - self.t0) % self.duration
self._rebase(wall, t)
return t
self.state = STOPPED
self._sim_anchor = self.t_end
return self.t_end
def _rebase(self, wall: float, t_sim: float) -> None:
self._wall_anchor = wall
self._sim_anchor = min(max(t_sim, self.t0), self.t_end)
[docs]
def play(self, wall: float) -> None:
if self.state == PLAYING:
return
# Restarting after reaching the end replays from the start.
start = self.t0 if self._sim_anchor >= self.t_end else self._sim_anchor
self._rebase(wall, start)
self.state = PLAYING
[docs]
def pause(self, wall: float) -> None:
if self.state == PLAYING:
self._rebase(wall, self.now(wall))
if self.state == PLAYING: # now() may have hit the end
self.state = PAUSED
[docs]
def reset(self) -> None:
self.state = STOPPED
self._sim_anchor = self.t0
[docs]
def seek(self, t_sim: float, wall: float) -> None:
"""Jump the cursor; playing stays playing, paused stays paused."""
self._rebase(wall, t_sim)
[docs]
def set_speed(self, speed: float, wall: float) -> None:
if self.state == PLAYING:
self._rebase(wall, self.now(wall))
self._speed = self._check_speed(speed)