storage_replay.md
July 3, 2026 ยท View on GitHub
Record sensor streams to disk and replay them with original timing. Useful for testing, debugging, and creating reproducible datasets.
Quick Start
Recording
from dimos.utils.testing.replay import TimedSensorStorage
# Create storage (directory in data folder)
storage = TimedSensorStorage("my_recording")
# Save frames from a stream
camera_stream.subscribe(storage.save_one)
# Or save manually
storage.save(frame1, frame2, frame3)
Replaying
from dimos.utils.testing.replay import TimedSensorReplay
# Load recording
replay = TimedSensorReplay("my_recording")
# Iterate at original speed
for frame in replay.iterate_realtime():
process(frame)
# Or as an Observable stream
replay.stream(speed=1.0).subscribe(process)
TimedSensorStorage
Stores sensor data with timestamps as pickle files. Each frame is saved as 000.pickle, 001.pickle, etc.
from dimos.utils.testing.replay import TimedSensorStorage
storage = TimedSensorStorage("lidar_capture")
# Save individual frames
storage.save_one(lidar_msg) # Returns frame count
# Save multiple frames
storage.save(frame1, frame2, frame3)
# Subscribe to a stream
lidar_stream.subscribe(storage.save_one)
# Or pipe through (emits frame count)
lidar_stream.pipe(
ops.flat_map(storage.save_stream)
).subscribe()
Storage location: Files are saved to the data directory under the given name. The directory must not already contain pickle files (prevents accidental overwrites).
What gets stored: By default, if a frame has a .raw_msg attribute, that's pickled instead of the full object. You can customize with the autocast parameter:
# Custom serialization
storage = TimedSensorStorage(
"custom_capture",
autocast=lambda frame: frame.to_dict()
)
TimedSensorReplay
Replays stored sensor data with timestamp-aware iteration and seeking.
Basic Iteration
from dimos.utils.testing.replay import TimedSensorReplay
replay = TimedSensorReplay("lidar_capture")
# Iterate all frames (ignores timing)
for frame in replay.iterate():
process(frame)
# Iterate with timestamps
for ts, frame in replay.iterate_ts():
print(f"Frame at {ts}: {frame}")
# Iterate with relative timestamps (from start)
for relative_ts, frame in replay.iterate_duration():
print(f"At {relative_ts:.2f}s: {frame}")
Realtime Playback
# Play at original speed (blocks between frames)
for frame in replay.iterate_realtime():
process(frame)
# Play at 2x speed
for frame in replay.iterate_realtime(speed=2.0):
process(frame)
# Play at half speed
for frame in replay.iterate_realtime(speed=0.5):
process(frame)
Seeking and Slicing
# Start 10 seconds into the recording
for ts, frame in replay.iterate_ts(seek=10.0):
process(frame)
# Play only 5 seconds starting at 10s
for ts, frame in replay.iterate_ts(seek=10.0, duration=5.0):
process(frame)
# Loop forever
for frame in replay.iterate(loop=True):
process(frame)
Finding Specific Frames
# Find frame closest to absolute timestamp
frame = replay.find_closest(1704067200.0)
# Find frame closest to relative time (30s from start)
frame = replay.find_closest_seek(30.0)
# With tolerance (returns None if no match within 0.1s)
frame = replay.find_closest(timestamp, tolerance=0.1)
Observable Stream
The .stream() method returns an Observable that emits frames with original timing:
# Stream at original speed
replay.stream(speed=1.0).subscribe(process)
# Stream at 2x with seeking
replay.stream(
speed=2.0,
seek=10.0, # Start 10s in
duration=30.0, # Play for 30s
loop=True # Loop forever
).subscribe(process)
Usage: Stub Connections for Testing
A common pattern is creating replay-based connection stubs for testing without hardware. From robot/unitree/go2/connection.py:
This is a bit primitive. We'd like to write a higher-order API for recording full module I/O for any module, but this is a work in progress at the moment.
class ReplayConnection(UnitreeWebRTCConnection):
dir_name = "go2_sf_office"
def __init__(self, **kwargs) -> None:
get_data(self.dir_name)
self.replay_config = {
"loop": kwargs.get("loop"),
"seek": kwargs.get("seek"),
"duration": kwargs.get("duration"),
}
def lidar_stream(self):
lidar_store = TimedSensorReplay(f"{self.dir_name}/lidar")
return lidar_store.stream(**self.replay_config)
def video_stream(self):
video_store = TimedSensorReplay(f"{self.dir_name}/video")
return video_store.stream(**self.replay_config)
This allows running the full perception pipeline against recorded data:
# Use replay connection instead of real hardware
connection = ReplayConnection(loop=True, seek=5.0)
robot = GO2Connection(connection=connection)
Data Format
Each pickle file contains a tuple (timestamp, data):
- timestamp: Unix timestamp (float) when the frame was captured
- data: The sensor data (or result of
autocastif provided)
Files are numbered sequentially: 000.pickle, 001.pickle, etc.
Recordings are stored in the data/ directory. See Data Loading for how data storage works, including Git LFS handling for large datasets.
API Reference
TimedSensorStorage
| Method | Description |
|---|---|
save_one(frame) | Save a single frame, returns frame count |
save(*frames) | Save multiple frames |
save_stream(observable) | Pipe an observable through storage |
consume_stream(observable) | Subscribe and save without returning |
TimedSensorReplay
| Method | Description |
|---|---|
iterate(loop=False) | Iterate frames (no timing) |
iterate_ts(seek, duration, loop) | Iterate with absolute timestamps |
iterate_duration(...) | Iterate with relative timestamps |
iterate_realtime(speed, ...) | Iterate with blocking to match timing |
stream(speed, seek, duration, loop) | Observable with original timing |
find_closest(timestamp, tolerance) | Find frame by absolute timestamp |
find_closest_seek(relative_seconds, tolerance) | Find frame by relative time |
first() | Get first frame |
first_timestamp() | Get first timestamp |
load(name) | Load specific frame by name/index |