Tools for optimizing and executing the CONCERT welding trajectory.
The workflow is split into two parts:
- Offline: run
weld_opt.py, optionally replay withreplayer.py, and inspect the RViz visualization. - Online: launch simulation, start XBot GUI, publish the current gap pose, compensate gravity, home to the optimized start, drive the base to the optimized pose, and run the controller.
Start the Docker container before running the offline or online pipeline:
xhost +local:docker
cd /home/user/concert_ws/src/acea_concert/docker
docker compose up -d --build
docker compose exec dev bashOpen another terminal in the same running container when needed:
cd /home/user/concert_ws/src/acea_concert/docker
docker compose exec dev bashStop the container when finished:
cd /home/user/concert_ws/src/acea_concert/docker
docker compose downCommand pipeline:
cd /home/user/concert_ws/src/acea_concert
python3 src/weld_opt.py
python3 src/plan_homing_from_mat.py
python3 src/replayer.py
rviz2 -d rviz/rviz_config.rvizRun the optimizer from the package root:
cd /home/user/concert_ws/src/acea_concert
python3 src/weld_opt.pysrc/weld_opt.py solves the Horizon optimization problem and saves:
mat_files/weld_concert.mat
The MAT file contains the optimized joint trajectory, the pipe/gap geometry, and the weld trajectory expressed in the gap frame. The online controller expects this file to exist before starting the simulation.
Optionally plan a collision-aware homing trajectory and save it back into the MAT file as q_homing:
cd /home/user/concert_ws/src/acea_concert
python3 src/plan_homing_from_mat.pyOptionally replay the optimized trajectory:
cd /home/user/concert_ws/src/acea_concert
python3 src/replayer.pyUse RViz to inspect the robot, pipe, and optimized weld trajectory:
rviz2 -d rviz/rviz_config.rvizStart the standalone container with its default isolated Docker network:
docker compose -f docker/compose.yaml up -dTo communicate with ROS 2 outside the container, set the required host IP and use the ROS launcher:
ROS2_IP=<host-IP-on-the-robot-network> ROS_DOMAIN_ID=0 docker/up-rosThe external ROS 2 machine must use the same ROS_DOMAIN_ID and
RMW_IMPLEMENTATION=rmw_cyclonedds_cpp.
Shut down either mode without setting ROS2_IP:
# Standalone
docker compose -f docker/compose.yaml down
# External ROS networking
docker compose \
-f docker/compose.yaml \
-f docker/compose.ros.yaml \
downStart each component in a separate terminal.
Run in this order:
weld_sim.launch.py- XBot GUI
- gap pose GUI / publisher
gravity_comp_node.pyhome_to_weld_start.pydrive_base_to_weld_pose.pycontroller.py
Open a terminal and connect to the container:
docker exec -it [container-id] bash
Open one Terminator window with a named pane for each component:
cd /home/user/concert_ws/src/acea_concert
scripts/concert_terminalEach pane has the ROS/XBot/workspace environment loaded and shows its
ready-to-run command. Run them in the order listed above. Click a pane to focus
it; Ctrl+Shift+X expands the focused pane and restores the tiled layout.
For a normal terminal, source the same environment manually:
cd /home/user/concert_ws/src/acea_concert
source scripts/concert_env.bashcd /home/user/concert_ws
ros2 launch acea_concert weld_sim.launch.pyThis launches the robot simulation and spawns the two pipe halves using the geometry stored in mat_files/weld_concert.mat.
To use a different optimization result, pass mat_file. To start with the gap at the optimized pose relative to the robot, add optimized_robot_pose:=true:
ros2 launch acea_concert weld_sim.launch.py mat_file:=mat_files/weld_concert.mat optimized_robot_pose:=trueThe original weld_sim.launch.py remains standalone and uses the canonical
robot generator. It does not depend on the perception files and does not add
the wrist camera.
Use the perception wrapper when testing the V16 detector:
cd /home/user/concert_ws
ros2 launch acea_concert weld_sim_perception.launch.py \
mat_file:=/home/user/concert_ws/src/acea_concert/mat_files/weld_concert.mat \
optimized_robot_pose:=trueThe wrapper calls weld_sim.launch.py and adds only the perception setup:
- wrist RGB-D camera
camera_Fand its ROS bridges; - painted pipe with a physical dark inner wall visible through the gap;
- robot TF publication;
- XBot2 startup after the Gazebo clock is active.
These are already the wrapper defaults: optimized_robot_pose:=true,
pipe_visual_preset:=painted_orange,
junction_visual_mode:=inner_wall, xbot2:=true, rviz:=false, and the
unused front-camera bridges disabled. The two arguments in the command above
are kept explicit so the geometry source and optimized placement are obvious.
The wrapper also starts XBot2 only after the Gazebo clock is active and switches
ros_ctrl to Running as soon as XBot2 is ready.
The original launcher keeps gui:=true as its default. In a headless container
or an SSH session without working X11 forwarding, add gui:=false; otherwise
Qt will fail before the Gazebo server can stay up.
optimized_robot_pose places the pipe relative to the optimized welding pose;
it does not move the arm joints. Before checking the camera image or the
detector, run the project's homing sequence. A sky/background-only camera_F
image before homing is expected and is not a detector failure.
camera_F is mounted from the tool body frame at the mounting interface
(end_effector_E, or end_effector_F on the prismatic robot). This is
TOOL_BASE in the supplied holder CAD transform; ee_E/ee_F is a
separately rotated task frame at the torch tip.
The tool geometry is unchanged. The CAD pose is converted to the RealSense
xacro mount frame:
xyz = [0.287780, 0.017500, 0.231314] m
rpy = [0, -20 deg, 180 deg]
Inspect the resulting optical transform while the simulation is running:
ros2 run tf2_ros tf2_echo \
end_effector_E camera_F_depth_optical_frameRelative to end_effector_E, the expected optical-centre translation is
[0.292055, 0, 0.243060] m. The expected rotation matrix is:
0.000000 -0.342020 -0.939693
1.000000 0.000000 0.000000
0.000000 -0.939693 0.342020
The CAD sheet prints this on negative X. It is applied turned 180 deg about Z
because TOOL_BASE faces the other way round to the arm flange: the robot's
own URDF, recorded in acea_real_1, carries the tool camera on positive X.
For a visual check in RViz:
- set
Fixed Frametoend_effector_E(end_effector_Ffor the prismatic robot); - add a
TFdisplay and enableend_effector_E,camera_F_linkandcamera_F_depth_optical_frame; - add
RobotModelto inspect the camera body on the welding holder; - add an
Imagedisplay on/camera_F/color/image_rawafter homing.
Relative to the red/green/blue axes of end_effector_E, the optical origin
must be [292.055, 0, 243.060] mm, placing the camera above the holder as in
the CAD model. For the prismatic robot, replace end_effector_E with
end_effector_F in the tf2_echo command.
After homing, launch perception in another sourced terminal:
cd /home/user/concert_ws/src/acea_concert
source scripts/concert_env.bash
ros2 launch acea_concert detection_v16_dev.launch.py \
use_sim_time:=true \
camera_preset:=sim \
sim_camera_name:=camera_F \
mat_file:=/home/user/concert_ws/src/acea_concert/mat_files/weld_concert.mat \
pipe_radius_m:=autoCheck the detector and controller-facing pose:
ros2 topic echo /acea/pipe_junction/status --once --field data
ros2 topic echo /gap/pose_robot --onceDo not run gap_pose_publisher.py at the same time as the detector: the former
publishes Gazebo ground truth on /gap/pose_robot, while the detector must be
the only publisher on that topic during perception tests.
Start the XBot GUI after the simulation is running.
cd /home/user/concert_ws/src/acea_concert
python3 src/gap_pose_publisher.pyOptional test faults can be added to the published /gap/pose_robot:
python3 src/gap_pose_publisher.py \
--gap-pos-noise-std 0.01 \
--gap-rot-noise-std 0.02 \
--gap-disconnect-every 10 \
--gap-disconnect-duration 2This publishes the current gap pose with respect to base_link:
/gap/pose_robot
This node is ground truth for simulation. In the real setup, it should be replaced by perception.
cd /home/user/concert_ws/src/acea_concert
python3 src/gravity_comp_node.pycd /home/user/concert_ws/src/acea_concert
python3 src/home_to_weld_start.pycd /home/user/concert_ws/src/acea_concert
python3 src/drive_base_to_weld_pose.pycd /home/user/concert_ws/src/acea_concert
python3 src/controller.pyTo smooth noisy camera gap poses, enable the controller-side low-pass filter:
python3 src/controller.py --gap-filter-tau 0.1With scripts/concert_env.bash sourced, the same command is:
concert_controller --gap-filter-tau 0.10 disables the filter. A small value like 0.05-0.15 seconds is a good starting range.
To replay the optimized trajectory without /gap/pose_robot, run open-loop:
concert_controller --open-loopFor slow welding with noisy camera poses, use a history window and reject big camera jumps:
concert_controller \
--gap-filter-history-size 50 \
--gap-filter-tau 0.3 \
--gap-filter-max-position-jump 0.02 \
--gap-filter-max-angle-jump 10The controller uses:
optimized posture trajectory from the MAT file
desired weld position in the gap frame
desired weld orientation in the gap frame
measured /gap/pose_robot from ground truth/perception
/gap/pose_robot is the only online gap input required by the controller. It is a PoseStamped in base_link: the position gives base_p_gap, and the quaternion gives base_R_gap.
At runtime it relocates the optimized weld trajectory onto the current measured gap:
position: base_p_ee_des = base_p_gap + base_R_gap * gap_p_ee_des
orientation: base_R_ee_des = base_R_gap * gap_R_ee_des
The controller does not blindly replay the optimized joint trajectory. The joint trajectory is used as a nominal posture, while the welding target is rebuilt online from the measured gap pose.
flowchart TD
subgraph offline["offline optimization"]
mat["weld_concert.mat"]
qdes["q_des(t)<br/>optimal posture"]
weldgap["gap_p_ee_des(t)<br/>gap_R_ee_des(t)<br/>weld target in gap frame"]
mat --> qdes
mat --> weldgap
end
subgraph online["online measurements"]
perception["/gap/pose_robot<br/>ground truth now<br/>perception later"]
gappose["base_p_gap<br/>base_R_gap<br/>derived from pose"]
feedback["robot feedback<br/>q_meas<br/>base_p_ee_meas<br/>base_R_ee_meas"]
perception --> gappose
end
subgraph controller["controller.py"]
relocate["relocate weld target<br/>base_p_ee_des = base_p_gap + base_R_gap * gap_p_ee_des<br/>base_R_ee_des = base_R_gap * gap_R_ee_des"]
project["project error in gap frame<br/>normal direction<br/>tangent direction"]
pd["PD correction<br/>normal velocity<br/>tangent velocity"]
ik["CartesIO IK<br/>EE pose task<br/>postural task"]
end
output["joint position command"]
diag["diagnostics<br/>PlotJuggler"]
qdes --> ik
weldgap --> relocate
gappose --> relocate
gappose --> project
feedback --> project
relocate --> project
project --> pd
pd --> ik
relocate --> ik
ik --> output
output --> feedback
project --> diag
pd --> diag
From the MAT file:
q_des(t) optimized joint posture trajectory
gap_p_ee_des(t) desired weld position expressed in the gap frame
gap_R_ee_des(t) desired tool orientation expressed in the gap frame
From ground truth now, and from perception later:
/gap/pose_robot PoseStamped in base_link
base_p_gap gap origin from /gap/pose_robot.position
base_R_gap gap orientation from /gap/pose_robot.orientation
From robot feedback:
q_meas measured robot joint state
base_p_ee_meas measured EE position from forward kinematics
base_R_ee_meas measured EE orientation from forward kinematics
At each controller tick:
- Read
q_des(t)and send it to the CartesIO postural task. - Read the desired weld position and orientation in the gap frame.
- Read the current measured
/gap/pose_robotinbase_link. - Derive
base_p_gapandbase_R_gap, then convert the desired weld target from gap frame tobase_link. - Compare the measured EE position with the desired weld target along:
- the gap normal direction, across the pipes
- the gap tangent direction, along the weld
- Compute PD correction velocities along those two directions.
- Command the EE pose task and postural task through CartesIO.
- Publish diagnostics for PlotJuggler.
The key idea is that if the robot or gap moves, the weld target moves with the measured gap:
base_p_ee_des = base_p_gap + base_R_gap * gap_p_ee_des
base_R_ee_des = base_R_gap * gap_R_ee_des
src/weld_opt.py: offline trajectory optimization and MAT file generation.src/plan_homing_from_mat.py: adds a collision-awareq_homingtrajectory to a MAT file.src/replayer.py: replay and RViz visualization of the optimized trajectory.launch/weld_sim.launch.py: simulation launch file; acceptsmat_fileandoptimized_start.src/gap_pose_publisher.py: simulation ground-truth gap pose publisher.src/gravity_comp_node.py: gravity compensation node.src/home_to_weld_start.py: moves weld joints to trajectory node 0.src/drive_base_to_weld_pose.py: drives the mobile base to the optimized relative gap pose.src/controller.py: online welding controller.src/acea_concert/control/: controller logic, ROS interface, filtering, trajectories, and diagnostics.src/acea_concert/optimization/: weld optimization, collision checking, and result helpers.src/acea_concert/homing/: homing optimization helpers.
config/weld.yaml: Horizon optimization task configuration.config/cartesio_stack.yaml: CartesIO stack used by the online controller.src/modular/: robot model generation.mat_files/: generated optimization results.
- ROS 2 Jazzy
- Horizon
- CasADi
- XBot2
- CartesIO
- NumPy, SciPy, Matplotlib
The src/detection/ module replaces the Gazebo ground-truth
gap_pose_publisher.py with a real camera-based pipe-junction detector. It
publishes the same interface: /gap/pose_robot (geometry_msgs/PoseStamped,
frame base_link, y = pipe axis, x = radial, z = x cross y), so
drive_base_to_weld_pose.py and the controller consume it unchanged.
Validated results (2026-07-19, idle host):
- real-camera cloth/support bag: 1463/1463 required frames valid, zero stale/hidden/unsafe poses;
- full live simulation cycle (Gazebo + XBot2 + homing + 65 s trajectory): 354/354 frames accepted, position error median 1.15 mm / p95 6.4 mm, orientation p95 0.30 deg, zero axis flips;
- output rate ~4.4 Hz in live simulation, ~7 Hz on real camera frames (single-thread CPU-bound; the controller was validated at these rates). The detector never publishes a predicted or held pose: silent frames are fail-closed by contract.
Terminal A (simulation, or skip on the real robot):
source /home/user/env/bin/activate
source /opt/ros/jazzy/setup.bash
source install/setup.bash
source setup.bash
source /opt/xbot/setup.sh
ros2 launch acea_concert weld_sim_perception.launch.py \
mat_file:=/home/user/concert_ws/src/acea_concert/mat_files/weld_concert.mat \
optimized_robot_pose:=trueTerminal B (detector + pose bridge; use camera_preset:=real on the robot —
the simulation preset and its recovery paths are NOT loaded on real):
ros2 launch acea_concert detection_v16_dev.launch.py \
use_sim_time:=true camera_preset:=sim sim_camera_name:=camera_F \
mat_file:=/home/user/concert_ws/src/acea_concert/mat_files/weld_concert.mat \
pipe_radius_m:=autoThen run homing / base drive / controller exactly as in section 2; the
controller reads /gap/pose_robot from the detector instead of the ground
truth publisher. Do not run gap_pose_publisher.py at the same time on the
same topic (remap it to /acea/ground_truth/gap_pose_robot if you want the
comparison).