Skip to content

Commit 32a6964

Browse files
committed
feat(mujoco): integrate project multi-agent mujoco sim
from 'a11delavar/bitbots_main' branch 'mujoco-benchmark' created in the bitbots robocup uni project to allow running multiple sets of our software to run on single mujoco sim setup including the required soccer field environment.
1 parent 5801d37 commit 32a6964

133 files changed

Lines changed: 2817 additions & 0 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

pixi.lock

Lines changed: 340 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pixi.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ libnuma = ">=2.0.18,<3"
5858
libopencv = ">=4.11.0,<5"
5959
libprotobuf = ">=6.31.1,<7"
6060
matplotlib = ">=3.10.8,<4"
61+
mujoco = ">=3.6.0,<4"
6162
mypy = ">=1.18.2,<2"
6263
ninja = ">=1.13.2,<2"
6364
numpy = ">=1.26.4,<2"
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import os
2+
from pathlib import Path
3+
4+
import yaml
5+
from ament_index_python.packages import get_package_share_directory
6+
from launch import LaunchDescription
7+
from launch.actions import (
8+
DeclareLaunchArgument,
9+
ExecuteProcess,
10+
LogInfo,
11+
OpaqueFunction,
12+
TimerAction,
13+
)
14+
from launch.substitutions import LaunchConfiguration
15+
from launch_ros.actions import Node
16+
17+
# Teamplayer arguments to expose (name, description)
18+
# Empty string default means "not set" - will use teamplayer's default
19+
# sim is always true for mujoco simulation
20+
TEAMPLAYER_ARGS = [
21+
("audio", "Whether the audio system should be started"),
22+
("behavior", "Whether the behavior control system should be started"),
23+
("behavior_dsd_file", "The behavior dsd file that should be used"),
24+
("game_controller", "Whether the Gamecontroller module should be started"),
25+
("ipm", "Whether the inverse perspective mapping should be started"),
26+
("localization", "Whether the localization system should be started"),
27+
("motion", "Whether the motion control system should be started"),
28+
("path_planning", "Whether the path planning should be started"),
29+
("teamcom", "Whether the team communication system should be started"),
30+
("vision", "Whether the vision system should be started"),
31+
("world_model", "Whether the world model should be started"),
32+
("monitoring", "Whether the system monitor and udp bridge should be started"),
33+
("record", "Whether the ros bag recording should be started"),
34+
("tts", "Whether to speak"),
35+
]
36+
37+
38+
def generate_domain_bridge_config(robot_domain: int, output_dir: Path) -> Path:
39+
"""Generate domain bridge config file for a single robot.
40+
41+
Returns the config file path.
42+
"""
43+
main_domain = int(os.getenv("ROS_DOMAIN_ID", "0"))
44+
output_dir.mkdir(parents=True, exist_ok=True)
45+
46+
namespace = f"robot{robot_domain}"
47+
48+
config = {
49+
"name": f"robot{robot_domain}_bridge",
50+
"from_domain": main_domain,
51+
"to_domain": robot_domain,
52+
"topics": {
53+
# Clock: main → robot domain
54+
"clock": {
55+
"type": "rosgraph_msgs/msg/Clock",
56+
},
57+
},
58+
}
59+
60+
# Sensor topics: main → robot domain (with remap to remove namespace)
61+
sensor_topics = [
62+
("joint_states", "sensor_msgs/msg/JointState"),
63+
("imu/data", "sensor_msgs/msg/Imu"),
64+
("camera/image_proc", "sensor_msgs/msg/Image"),
65+
("camera/camera_info", "sensor_msgs/msg/CameraInfo"),
66+
("foot_pressure_left/filtered", "bitbots_msgs/msg/FootPressure"),
67+
("foot_pressure_right/filtered", "bitbots_msgs/msg/FootPressure"),
68+
("foot_center_of_pressure_left", "geometry_msgs/msg/PointStamped"),
69+
("foot_center_of_pressure_right", "geometry_msgs/msg/PointStamped"),
70+
]
71+
72+
for topic_suffix, msg_type in sensor_topics:
73+
src_topic = f"{namespace}/{topic_suffix}"
74+
config["topics"][src_topic] = {
75+
"type": msg_type,
76+
"remap": topic_suffix,
77+
}
78+
79+
# Command topic: robot domain → main (reversed direction)
80+
# Key is source topic in from_domain, remap is destination in to_domain
81+
config["topics"]["DynamixelController/command"] = {
82+
"type": "bitbots_msgs/msg/JointCommand",
83+
"from_domain": robot_domain,
84+
"to_domain": main_domain,
85+
"remap": f"{namespace}/DynamixelController/command",
86+
}
87+
88+
config_path = output_dir / f"robot{robot_domain}_bridge.yaml"
89+
with open(config_path, "w") as f:
90+
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
91+
92+
return config_path
93+
94+
95+
def generate_world_xml(num_robots: int, package_share: str) -> Path:
96+
"""Generate MuJoCo world XML with the correct number of robots."""
97+
template_path = Path(package_share) / "xml" / "adult_field.xml"
98+
output_path = Path(package_share) / "xml" / "generated_world.xml"
99+
100+
with open(template_path) as f:
101+
template = f.read()
102+
103+
# Replace placeholder with actual robot count
104+
world_xml = template.replace("{{NUM_ROBOTS}}", str(num_robots))
105+
106+
with open(output_path, "w") as f:
107+
f.write(world_xml)
108+
109+
return output_path
110+
111+
112+
def launch_setup(context):
113+
"""Dynamically set up launches based on num_robots."""
114+
num_robots = int(LaunchConfiguration("num_robots").perform(context))
115+
package_share = get_package_share_directory("bitbots_mujoco_sim")
116+
bridge_config_dir = Path(package_share) / "config" / "domain_bridges"
117+
118+
# Get teamplayer argument values - only pass if explicitly set (not empty)
119+
teamplayer_args = ["sim:=true"] # sim is always true for mujoco simulation
120+
for arg_name, _ in TEAMPLAYER_ARGS:
121+
value = LaunchConfiguration(arg_name).perform(context)
122+
if value: # Only pass if not empty string
123+
teamplayer_args.append(f"{arg_name}:={value}")
124+
125+
world_file = generate_world_xml(num_robots, package_share)
126+
127+
actions = []
128+
129+
actions.append(
130+
LogInfo(msg=f"Starting MuJoCo simulation with {num_robots} robot(s)"),
131+
)
132+
actions.append(
133+
Node(
134+
package="bitbots_mujoco_sim",
135+
executable="sim",
136+
name="sim_interface",
137+
output="screen",
138+
emulate_tty=True,
139+
parameters=[{"world_file": str(world_file)}],
140+
),
141+
)
142+
143+
for robot_domain in range(num_robots):
144+
config_file = generate_domain_bridge_config(robot_domain, bridge_config_dir)
145+
actions.append(
146+
LogInfo(msg=f"Starting domain bridge for robot{robot_domain} (domain {robot_domain})"),
147+
)
148+
actions.append(
149+
Node(
150+
package="domain_bridge",
151+
executable="domain_bridge",
152+
name=f"domain_bridge_robot{robot_domain}",
153+
arguments=[str(config_file)],
154+
output="screen",
155+
emulate_tty=True,
156+
),
157+
)
158+
159+
actions.append(
160+
TimerAction(
161+
period=3.0,
162+
actions=[
163+
LogInfo(msg=f"Launching teamplayer stack for robot{robot_domain} in domain {robot_domain}"),
164+
ExecuteProcess(
165+
cmd=[
166+
"ros2",
167+
"launch",
168+
"bitbots_bringup",
169+
"teamplayer.launch",
170+
]
171+
+ teamplayer_args,
172+
output="screen",
173+
additional_env={"ROS_DOMAIN_ID": str(robot_domain)},
174+
),
175+
],
176+
)
177+
)
178+
179+
return actions
180+
181+
182+
def generate_launch_description():
183+
"""Launch MuJoCo simulation with domain bridge for multi-robot support."""
184+
185+
declared_args = [
186+
DeclareLaunchArgument(
187+
"num_robots",
188+
default_value="1",
189+
description="Number of robots in the simulation",
190+
),
191+
]
192+
193+
# Add all teamplayer arguments with empty default (means use teamplayer's default)
194+
for arg_name, description in TEAMPLAYER_ARGS:
195+
declared_args.append(
196+
DeclareLaunchArgument(
197+
arg_name,
198+
default_value="",
199+
description=description,
200+
)
201+
)
202+
203+
return LaunchDescription(
204+
declared_args
205+
+ [
206+
# All setup happens in OpaqueFunction to ensure proper ordering
207+
OpaqueFunction(function=launch_setup),
208+
]
209+
)

0 commit comments

Comments
 (0)