Migrating from robot_localization to FusionCore¶
If you're coming from robot_localization and want to switch, this guide walks you through it step by step: no prior knowledge of FusionCore required.
Three files, one command¶
Everything you need is already in this repo. Here's the big picture before we dive in:
| File | What it does |
|---|---|
fusioncore_ros/config/fusioncore.yaml (or your hardware config) |
Tells FusionCore about your robot: IMU noise, GPS quality, sensor offsets |
fusioncore_ros/config/nav2_params.yaml |
A ready-to-use Nav2 config pre-wired to FusionCore's output (if you use Nav2) |
fusioncore_ros/launch/fusioncore_nav2.launch.py |
One launch file that starts both FusionCore and Nav2 together |
Once you have your robot config set up, the entire stack starts with:
That's it. FusionCore starts, configures itself, activates, and Nav2 comes up 8 seconds later once localization is publishing. No separate navsat_transform_node, no feedback loop, no topic wiring.
Don't use Nav2? Use fusioncore.launch.py instead:
What's changing (and what isn't)¶
With robot_localization, you wired two nodes together:
/imu/data ──────────────────────────────────────────────────┐
/odom/wheels ──────→ ekf_node → /odometry/filtered │
/odometry/filtered ─→ navsat_transform_node → GPS in ENU ───┘
/fix ──────────────→ navsat_transform_node
Two nodes, two config files, feedback loop between them, lots of parameter tuning.
With FusionCore, it's one node:
/imu/data ────┐
/odom/wheels ─┤→ fusioncore_node → /fusion/odom
/gnss/fix ────┘ → TF: odom → base_link
GPS fusion happens inside: no navsat_transform_node, no feedback loop. The output topic changes from /odometry/filtered to /fusion/odom.
What stays the same:
- Your robot still publishes /imu/data, /odom/wheels, /gnss/fix (you may need a remap: see Step 4)
- Nav2 still works the same way, it just reads from /fusion/odom instead of /odometry/filtered
- The odom → base_link TF is still published the same way
Step 1: Remove robot_localization from your launch¶
In your launch file, remove or comment out:
- The ekf_node launch
- The navsat_transform_node launch
- Any parameter files you were loading for those nodes
You don't need to replace them with anything complex. Either use the provided launch file (recommended):
Or if you have your own launch file, add FusionCore as a lifecycle node:
from launch_ros.actions import LifecycleNode
fusioncore_node = LifecycleNode(
package="fusioncore_ros",
executable="fusioncore_node",
name="fusioncore",
namespace="",
output="screen",
parameters=["/path/to/fusioncore.yaml"],
)
FusionCore is a lifecycle node, so you need to configure and activate it after launch. The provided fusioncore_nav2.launch.py does this automatically (2-second delay, then configure → activate). If you're writing your own launch file, see the launch file source at fusioncore_ros/launch/fusioncore_nav2.launch.py for how to wire the lifecycle transitions.
Step 2: Create your robot config¶
FusionCore needs one YAML file that describes your hardware. You can start from a ready-made config for your platform:
config/clearpath_husky.yaml: Husky A200 + Microstrain GX5-25 + u-blox F9Pconfig/bno085_custom.yaml: BNO085 + standard GPS (DIY robots)config/duatic_mecanum.yaml: Duatic mecanum + BNO085, no GPS
Or start from scratch with this minimal template:
fusioncore:
ros__parameters:
base_frame: base_link # must match your robot's base TF frame
odom_frame: odom
publish_rate: 100.0
publish.force_2d: true # set true for ground robots
imu.has_magnetometer: false
imu.gyro_noise: 0.005 # rad/s: check your IMU datasheet
imu.accel_noise: 0.1 # m/s²
imu.remove_gravitational_acceleration: false # see gravity note below
encoder.vel_noise: 0.05 # m/s
encoder.yaw_noise: 0.02 # rad/s
gnss.base_noise_xy: 2.5 # m: standard GPS. Use 0.5 for RTK float, 0.015 for RTK fixed
gnss.base_noise_z: 5.0
gnss.max_hdop: 4.0
gnss.min_satellites: 4
gnss.min_fix_type: 1 # 1=GPS, 2=DGPS/RTK_FIXED, 4=RTK_FIXED only
# WARNING: sensor_msgs/NavSatFix has no RTK_FLOAT status.
# Setting min_fix_type: 3 will silently starve the filter. Use 2 or 4.
outlier_rejection: true
outlier_threshold_gnss: 16.27
outlier_threshold_imu: 15.09
outlier_threshold_enc: 11.34
outlier_threshold_hdg: 10.83
adaptive.imu: true
adaptive.encoder: true
adaptive.gnss: true
adaptive.window: 50
adaptive.alpha: 0.01
ukf.q_position: 0.01
ukf.q_orientation: 1.0e-9
ukf.q_velocity: 0.1
ukf.q_angular_vel: 0.1
ukf.q_acceleration: 1.0
ukf.q_gyro_bias: 1.0e-5
ukf.q_accel_bias: 1.0e-5
input.gnss_crs: "EPSG:4326"
output.crs: "EPSG:4978"
output.convert_to_enu_at_reference: true
reference.use_first_fix: true # map origin = first GPS fix
GPS quality presets: if you want to override GPS noise thresholds for your environment without editing the robot config, you can layer an environment preset on top:
ros2 launch fusioncore_ros fusioncore_nav2.launch.py \
fusioncore_config:=your_robot.yaml \
env_config:=$(ros2 pkg prefix fusioncore_ros)/share/fusioncore_ros/config/env_urban.yaml
Available presets: env_open.yaml, env_urban.yaml, env_canopy.yaml.
Gravity removal: one thing to get right¶
There's one parameter where the naming is confusing:
# robot_localization:
imu0_remove_gravitational_acceleration: true
# means: "remove gravity before I fuse it"
# FusionCore:
imu.remove_gravitational_acceleration: true
# means: "my driver already removed gravity, add it back"
The names look the same and they mean opposite things, so the value must be inverted when you migrate.
IMU reads ~9.8 m/s² in z at rest (gravity present, the common case)
robot_localization: imu0_remove_gravitational_acceleration: true
FusionCore: imu.remove_gravitational_acceleration: false
IMU reads ~0.0 m/s² in z at rest (driver already removed it)
robot_localization: imu0_remove_gravitational_acceleration: false
FusionCore: imu.remove_gravitational_acceleration: true
robot_localization's flag means "the data has gravity in it, please take it out". FusionCore's means "the driver already took it out, please put it back", because the filter's measurement model expects specific force. Copying the value across unchanged makes FusionCore add a second 9.8 m/s² to data that already carries it, and a constant acceleration error double-integrates into position.
The reliable check is the sensor, not the old config. Run
ros2 topic echo /imu/data --field linear_acceleration.z --once with the robot at
rest. ~9.8 means false, ~0.0 means true. tools/rl_to_fusioncore.py performs
this inversion for you and says so in its output.
Step 3: Nav2 setup (if you use Nav2)¶
This is the most common pain point when migrating: all the places in Nav2 that referenced /odometry/filtered need to point to /fusion/odom.
The easy path: use the bundled nav2_params.yaml at fusioncore_ros/config/nav2_params.yaml. It comes pre-wired to /fusion/odom and is configured for outdoor GPS navigation (differential drive, Regulated Pure Pursuit, no AMCL). The fusioncore_nav2.launch.py launch file uses it by default.
# This already uses the bundled nav2_params.yaml: nothing else needed
ros2 launch fusioncore_ros fusioncore_nav2.launch.py \
fusioncore_config:=your_robot.yaml
Customizing: if you have your own nav2_params.yaml, pass it as an argument:
ros2 launch fusioncore_ros fusioncore_nav2.launch.py \
fusioncore_config:=your_robot.yaml \
nav2_params:=/path/to/your_nav2_params.yaml
Updating your existing Nav2 config manually: if you want to keep your own nav2_params.yaml, find and replace every instance of /odometry/filtered with /fusion/odom. The three places that need it:
bt_navigator:
ros__parameters:
odom_topic: /fusion/odom # was /odometry/filtered
velocity_smoother:
ros__parameters:
odom_topic: /fusion/odom # was /odometry/filtered
# if you had AMCL and are doing GPS-only outdoor navigation, remove it
GPS-only outdoor navigation (no map): remove AMCL. It has nothing to localize against without a static map, and the Nav2 global_frame must be set to odom everywhere: see Nav2 Integration.
Indoor navigation with a map: keep AMCL. FusionCore publishes odom → base_link. AMCL publishes map → odom. These are different TF edges and are fully compatible: there is no conflict.
Step 4: Remap your topics (if your robot uses non-default names)¶
FusionCore subscribes to these topics by default:
| Input | Parameter | Default topic | Your topic | Change needed? |
|---|---|---|---|---|
| IMU | imu.topic |
/imu/data |
probably the same | usually no |
| Wheel odometry | encoder.topic |
/odom/wheels |
often different | likely yes |
| GPS fix | gnss.fix_topic |
/gnss/fix |
often /fix |
often yes |
The wheel odometry default is /odom/wheels, not the conventional /odom. That is
deliberate: FusionCore publishes its own fused odometry, so defaulting to /odom would
invite a feedback loop with its own output. Expect to set this one.
If your topics differ, set them in your config YAML:
Clearpath Husky example (odom at /husky_velocity_controller/odom, GPS at /fix):
fusioncore:
ros__parameters:
encoder.topic: "/husky_velocity_controller/odom"
gnss.fix_topic: "/fix"
ROS 2 remaps also still work if you prefer them, and apply on top of whatever the parameter resolves to:
ros2 launch fusioncore_ros fusioncore_nav2.launch.py \
fusioncore_config:=your_robot.yaml \
--ros-args \
-r /odom/wheels:=/husky_velocity_controller/odom \
-r /gnss/fix:=/fix
Either way, check the startup log to confirm what it actually subscribed to. Pointing at a topic that does not exist fails silently: the filter still runs and publishes, it just never fuses that sensor.
[INFO] [fusioncore]: Encoder topic: /husky_velocity_controller/odom
[INFO] [fusioncore]: GNSS topic: /fix (sensor_msgs/NavSatFix)
FusionCore publishes to /fusion/odom. Any downstream node that was reading /odometry/filtered needs to be updated to read /fusion/odom: the bundled nav2_params.yaml already handles this for Nav2.
Full parameter mapping reference¶
EKF parameters¶
| robot_localization (ekf_node) | FusionCore | Notes |
|---|---|---|
frequency |
publish_rate |
|
sensor_timeout |
automatic | FC detects stale sensors per-sensor automatically |
two_d_mode: true |
publish.force_2d: true |
|
transform_time_offset |
automatic | FC handles timing internally |
transform_timeout |
automatic | |
predict_to_current_time |
always on | FC always predicts to current time |
smooth_lagged_data |
automatic | FC replays buffered IMU for delayed GPS |
history_length |
automatic | FC uses a 1-second IMU ring buffer |
imu0 (topic name) |
remap /imu/data |
FC subscribes to /imu/data by default |
odom0 (topic name) |
encoder.topic |
defaults to /odom/wheels; set it or remap |
imu0_config |
not needed | FC fuses all available IMU axes automatically |
odom0_config |
not needed | FC fuses linear velocity and yaw rate from odometry |
imu0_differential |
not needed | FC handles this internally |
imu0_relative |
not needed | |
imu0_queue_size |
not needed | |
imu0_remove_gravitational_acceleration |
imu.remove_gravitational_acceleration |
Logic is inverted: see Step 2 above |
odomN_pose_rejection_threshold, odomN_twist_rejection_threshold |
outlier_threshold_gnss/imu/enc/hdg |
Both use Mahalanobis distance. RL exposes raw scalar thresholds with no guidance on chi-squared calibration per DOF: you set them manually. FC defaults are chi-squared critical values pre-calibrated to each sensor's measurement DOF. |
process_noise_covariance |
ukf.q_position, ukf.q_velocity, etc. |
FC exposes named scalars instead of a 15×15 matrix |
initial_estimate_covariance |
not configurable | FC initializes automatically from first sensor readings |
print_diagnostics |
always on | FC publishes to /diagnostics at 1 Hz |
navsat_transform parameters¶
| robot_localization (navsat_transform_node) | FusionCore | Notes |
|---|---|---|
frequency |
publish_rate |
|
delay |
not needed | FC handles timing automatically |
magnetic_declination_radians |
not needed | FC uses ECEF (true north), not magnetic north |
yaw_offset |
not needed | |
zero_altitude |
publish.force_2d: true |
|
broadcast_utm_transform |
not applicable | FC fuses in ECEF, outputs local ENU |
publish_filtered_gps |
not available | not currently supported |
use_odometry_yaw |
automatic | FC initializes heading from motion automatically |
wait_for_datum |
reference.use_first_fix: true |
true = anchor to first fix (default) |
datum |
reference.x, reference.y, reference.z |
set in your output CRS coordinates |
What FusionCore handles automatically¶
These required manual config in robot_localization:
| Task | robot_localization | FusionCore |
|---|---|---|
| IMU frame → base_link transform | specify imu0_config axes manually |
looks up TF tree automatically |
| GPS antenna offset (lever arm) | ignored | gnss.lever_arm_x/y/z applied per-fix |
| GPS noise estimation | sensor-reported covariance, used as-is | adapts from 50-sample innovation window |
| Zero-velocity updates (ZUPT) | not built-in | auto when encoder + UKF angular rate below threshold |
| IMU bias estimation | not built-in | gyro + accel bias states in the 23D state vector |
| GPS fix quality pre-filter | not built-in | gnss.max_hdop, gnss.min_satellites, gnss.min_fix_type |
| Measurement rejection | Mahalanobis threshold (per sensor, scalar) | Chi-squared gate (per sensor, calibrated to sensor DOF) |
| Delayed GPS fusion | smooth_lagged_data + history_length |
IMU ring buffer replay, always on |
What robot_localization has that FusionCore doesn't¶
Know these before you migrate:
| Feature | robot_localization | FusionCore |
|---|---|---|
| Multiple independent odometry sources | yes (odom0, odom1, ...) | primary wheel odom + one secondary via encoder2.topic |
| Multiple IMUs | yes (imu0, imu1, ...) | primary /imu/data + one secondary via imu2.topic |
| Configurable state vector | yes (per-sensor config booleans) | fixed 23D state (position, orientation, velocity, biases, encoder WZ bias) |
| Arbitrary sensor plugins | yes (extensible) | IMU, wheel odometry, GPS only |
| Published filtered GPS | publish_filtered_gps: true |
not currently supported |
| navsat datum from ROS service | /datum service |
not currently supported |
Services¶
FusionCore advertises four services: /fromLL, ~/reset, ~/save_checkpoint and ~/load_checkpoint. The comparison against robot_localization:
| Service | robot_localization | FusionCore |
|---|---|---|
/fromLL (lat/lon to map frame) |
robot_localization/srv/FromLL |
supported, same type, since 0.3.5 |
/fromLLArray (batch conversion) |
yes | not supported |
/toLL (map frame back to lat/lon) |
yes | not supported |
/set_datum |
yes | not supported, use reference.x/y/z at configure time |
/set_pose (jump the filter to a given pose) |
yes | not supported, ~/reset re-initialises instead |
/toggle_filter_processing |
yes | not supported |
| Save and restore filter state | no | ~/save_checkpoint, ~/load_checkpoint |
/fromLL is the one Nav2's GPS waypoint follower needs, and it is deliberately advertised with robot_localization's own type so followGpsWaypoints binds to it. See Nav2 integration for why that matters and for a known issue with the bundled Nav2 launch.
Questions? Open a GitHub Discussion.