Skip to content
Merged
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
22 changes: 22 additions & 0 deletions mote_bringup/config/record.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Rosbag recording streams — consumed by record_launch.py.
# Each stream writes to ~/.mote/bags/<name>/<timestamp>/ with its own segment
# length and rolling disk cap (enforced by bag_pruner).

streams:
lite:
topics:
- /tf
- /tf_static
- /scan_filtered
split: 600 # seconds per bag segment
max_gb: 2.0 # rolling disk cap

perception:
topics:
- /tf
- /tf_static
- /scan_filtered
- /image_raw/compressed
- /camera_info
split: 60
max_gb: 10.0
76 changes: 76 additions & 0 deletions mote_bringup/launch/record_launch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
"""Record rosbags for perception development, split by purpose with disk caps.

Streams are defined in config/record.yaml. Each enabled stream is recorded in
parallel under ~/.mote/bags/<name>/<timestamp> with its own segment length and
rolling disk cap (bag_pruner deletes the oldest segments once a stream exceeds
its cap so continuous recording never fills the disk).

The compressed image stream is recorded rather than raw; republish it to
/image_raw on playback with image_transport.
"""

import os
from datetime import datetime

import yaml
from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import ExecuteProcess


def _load_streams():
config_path = os.path.join(
get_package_share_directory("mote_bringup"), "config", "record.yaml"
)
with open(config_path) as f:
return yaml.safe_load(f)["streams"]


def _bags_dir(kind):
base = os.path.join(os.path.expanduser("~/.mote/bags"), kind)
os.makedirs(base, exist_ok=True)
return base


def _record(kind, topics, split):
output = os.path.join(_bags_dir(kind), datetime.now().strftime("%Y%m%d_%H%M%S"))
return ExecuteProcess(
cmd=[
"ros2",
"bag",
"record",
"--max-bag-duration",
str(split),
"-o",
output,
*topics,
],
output="screen",
)


def _pruner(kind, max_gb, interval):
return ExecuteProcess(
cmd=[
"ros2",
"run",
"mote_bringup",
"bag_pruner",
"--dir",
_bags_dir(kind),
"--max-gb",
str(max_gb),
"--interval",
str(interval),
],
output="screen",
)


def generate_launch_description():
actions = []
for name, stream in _load_streams().items():
split = stream["split"]
actions.append(_record(name, stream["topics"], split))
actions.append(_pruner(name, stream["max_gb"], split))
return LaunchDescription(actions)
30 changes: 17 additions & 13 deletions mote_bringup/launch/rviz_launch.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,21 @@


def generate_launch_description():
rviz_config = PathJoinSubstitution([
FindPackageShare("mote_bringup"),
"config",
"mote.rviz",
])
rviz_config = PathJoinSubstitution(
[
FindPackageShare("mote_bringup"),
"config",
"mote.rviz",
]
)

return LaunchDescription([
Node(
package="rviz2",
executable="rviz2",
arguments=["-d", rviz_config],
output="screen",
)
])
return LaunchDescription(
[
Node(
package="rviz2",
executable="rviz2",
arguments=["-d", rviz_config],
output="screen",
)
]
)
97 changes: 97 additions & 0 deletions mote_bringup/mote_bringup/bag_pruner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""Enforce a rolling disk budget on a rosbag2 output tree.

Periodically sums the size of every .mcap segment under a directory and deletes
the oldest segments once the total exceeds a cap, so a robot that records
continuously never fills its disk. The newest segment (the one currently being
written) is never deleted. Run directories left empty by pruning are removed
along with their now-stale metadata.

Each .mcap segment is self-contained, so pruning older segments out of a still
active run does not affect playback of the segments that remain.
"""

import argparse
import os
import shutil
import time


EMPTY_RUN_MIN_AGE_SECONDS = 120.0


def _segments(root):
files = []
for dirpath, _dirnames, filenames in os.walk(root):
for name in filenames:
if not name.endswith(".mcap"):
continue
path = os.path.join(dirpath, name)
try:
st = os.stat(path)
except FileNotFoundError:
continue
files.append((path, st.st_mtime, st.st_size))
files.sort(key=lambda f: f[1]) # oldest first
return files


def _remove_empty_runs(root):
now = time.time()
for entry in os.scandir(root):
if not entry.is_dir():
continue
try:
age = now - entry.stat().st_mtime
except FileNotFoundError:
continue
if age < EMPTY_RUN_MIN_AGE_SECONDS:
continue
has_mcap = any(
name.endswith(".mcap")
for _d, _sub, names in os.walk(entry.path)
for name in names
)
if not has_mcap:
shutil.rmtree(entry.path, ignore_errors=True)
print(f"bag_pruner: removed empty run {entry.path}", flush=True)


def prune(root, cap_bytes):
segs = _segments(root)
total = sum(s[2] for s in segs)
# Keep the newest segment regardless of the cap — it is still being written.
for path, _mtime, size in segs[:-1]:
if total <= cap_bytes:
break
try:
os.remove(path)
print(f"bag_pruner: removed {path} ({size / 1e6:.0f} MB)", flush=True)
except FileNotFoundError:
pass
total -= size
_remove_empty_runs(root)


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--dir", required=True, help="rosbag2 output tree to prune")
parser.add_argument(
"--max-gb", type=float, required=True, help="disk cap for the tree, in GB"
)
parser.add_argument(
"--interval", type=float, default=30.0, help="seconds between sweeps"
)
args = parser.parse_args()

cap_bytes = int(args.max_gb * 1e9)
os.makedirs(args.dir, exist_ok=True)
try:
while True:
prune(args.dir, cap_bytes)
time.sleep(args.interval)
except KeyboardInterrupt:
pass


if __name__ == "__main__":
main()
1 change: 1 addition & 0 deletions mote_bringup/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
entry_points={
"console_scripts": [
"odom_tf_relay = mote_bringup.odom_tf_relay:main",
"bag_pruner = mote_bringup.bag_pruner:main",
],
},
)
2 changes: 1 addition & 1 deletion mote_bringup/systemd/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@ for unit in "$SRC_DIR"/*.service; do
done

sudo systemctl daemon-reload
sudo systemctl enable mote-bringup mote-slam mote-nav
sudo systemctl enable mote-bringup mote-slam mote-nav mote-record
16 changes: 16 additions & 0 deletions mote_bringup/systemd/mote-record.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
[Unit]
Description=Mote Data Recording
After=mote-bringup.service
Wants=mote-bringup.service

[Service]
Type=simple
User=@USER@
WorkingDirectory=@HOME@/Mote
ExecStart=@HOME@/.pixi/bin/pixi run record
Restart=on-failure
RestartSec=5
Environment=HOME=@HOME@

[Install]
WantedBy=multi-user.target
Loading
Loading