Multi-Sensor Fusion (C++)

Fuse IMU heading with wheel odometry position for more accurate localization than either sensor alone. Uses a complementary filter approach to blend the strengths of both sensors.

Problem

Wheel odometry gives position (x, y) and heading (theta), but heading drifts because of wheel slip, uneven terrain, and tire wear. The IMU gyroscope gives accurate short-term heading changes but drifts long-term due to bias. You need to combine both sensors so the robot's position estimate is better than either one alone.

How It Works

This fusion node runs a complementary filter on heading while taking position directly from odometry:

  • Position (x, y): comes from wheel odometry. The IMU accelerometer is too noisy for double-integration into position — it drifts by meters within seconds. Wheel odometry is the only practical source for position without GPS or SLAM.
  • Heading (theta): blended between odometry heading and IMU gyro integration. The gyro is trusted for short-term changes (no wheel slip), while odometry heading provides a long-term reference (no gyro drift).

The fusion formula for heading:

theta = alpha * (theta + gyro_z * dt) + (1 - alpha) * odom_theta

Where alpha is the IMU trust factor (0.95 = 95% gyro, 5% odometry correction). High alpha gives smoother heading during turns (gyro-dominated). Low alpha corrects gyro drift faster (odometry-dominated).

Why not a full Kalman filter? A Kalman filter is the theoretically optimal approach, but it requires a noise model (process and measurement covariances), matrix operations, and careful tuning. The complementary filter gives 90% of the benefit with 10% of the complexity. Start here, upgrade to Kalman only if you need covariance estimates or more than two sensors.

When To Use

  • Any ground robot with wheel encoders and an IMU
  • When odometry heading drifts due to wheel slip (carpet, gravel, wet floors)
  • When you need better localization than odometry alone but cannot run SLAM

Prerequisites

Complete Code

#include <horus/horus.hpp>
#include <cmath>
#include <cstdio>
using namespace horus::literals;

class SensorFusion : public horus::Node {
public:
    SensorFusion(double alpha = 0.95)
        : Node("sensor_fusion"), alpha_(alpha)
    {
        // Subscribe to both sensor sources
        imu_sub_   = subscribe<horus::msg::Imu>("imu.data");
        odom_sub_  = subscribe<horus::msg::Odometry>("odom");
        // Publish the fused estimate
        fused_pub_ = advertise<horus::msg::Odometry>("fused.pose");
    }

    void tick() override {
        // ── Always drain both subscribers to avoid stale data ────────
        auto imu  = imu_sub_->recv();
        auto odom = odom_sub_->recv();

        double dt = 1.0 / 100.0;  // matches 100 Hz tick rate

        // ── Predict from odometry (position + heading reference) ────
        if (odom) {
            // Position comes directly from odometry — no IMU correction
            // IMU accelerometer is too noisy for position estimation
            x_ = odom->get()->pose.x;
            y_ = odom->get()->pose.y;

            // Odometry heading provides long-term reference (no gyro drift)
            odom_theta_ = odom->get()->pose.theta;
            has_odom_ = true;
        }

        // ── Correct heading from IMU gyroscope ──────────────────────
        if (imu) {
            double gz = imu->get()->angular_velocity[2];  // yaw rate (rad/s)

            if (has_odom_) {
                // Complementary filter: gyro for short-term, odom for long-term
                // alpha_ high (0.95) = trust gyro more = smooth during wheel slip
                // alpha_ low (0.80) = trust odom more = corrects gyro drift faster
                theta_ = alpha_ * (theta_ + gz * dt) + (1.0 - alpha_) * odom_theta_;
            } else {
                // No odometry yet — integrate gyro only (will drift)
                theta_ += gz * dt;
            }
            has_imu_ = true;
        } else if (has_odom_ && !has_imu_) {
            // No IMU data — fall back to odometry heading
            theta_ = odom_theta_;
        }

        // Normalize theta to [-pi, pi]
        if (theta_ > M_PI)  theta_ -= 2.0 * M_PI;
        if (theta_ < -M_PI) theta_ += 2.0 * M_PI;

        // ── Publish fused estimate ──────────────────────────────────
        horus::msg::Odometry fused{};
        fused.pose.x     = x_;
        fused.pose.y     = y_;
        fused.pose.theta = theta_;
        fused_pub_->send(fused);

        // Log at 2 Hz (every 50th tick at 100 Hz)
        if (++tick_count_ % 50 == 0) {
            double heading_diff = theta_ - odom_theta_;
            char buf[128];
            std::snprintf(buf, sizeof(buf),
                "fused=(%.2f, %.2f, %.1f deg)  odom_drift=%.1f deg",
                x_, y_, theta_ * 180.0 / M_PI,
                heading_diff * 180.0 / M_PI);
            horus::log::info("fusion", buf);

            // Log large heading disagreement as a warning
            if (std::abs(heading_diff) > 0.17) {  // ~10 degrees
                horus::log::warn("fusion",
                    "Large IMU/odom heading disagreement — possible wheel slip");
            }
        }
    }

    void enter_safe_state() override {
        // Publish last known good pose with a flag indicating degraded mode.
        // Downstream nodes (e.g., navigation) should check for stale data
        // and switch to a conservative behavior.
        horus::msg::Odometry last{};
        last.pose.x     = x_;
        last.pose.y     = y_;
        last.pose.theta = theta_;
        fused_pub_->send(last);
        horus::blackbox::record("fusion",
            "Entered safe state — publishing last known pose");
        horus::log::warn("fusion",
            "Safe state active — fused estimate may be stale");
    }

private:
    horus::Subscriber<horus::msg::Imu>*      imu_sub_;
    horus::Subscriber<horus::msg::Odometry>* odom_sub_;
    horus::Publisher<horus::msg::Odometry>*  fused_pub_;

    // Filter parameter
    double alpha_;  // IMU trust factor (0.80-0.99 typical)

    // Fused state (double for running-sum precision)
    double x_ = 0, y_ = 0, theta_ = 0;
    double odom_theta_ = 0;

    // Data availability flags
    bool has_odom_ = false;
    bool has_imu_  = false;

    int tick_count_ = 0;
};

int main() {
    horus::Scheduler sched;
    sched.tick_rate(100_hz).name("fusion_demo");

    // alpha=0.95: 95% gyro trust for heading, 5% odometry correction
    SensorFusion fusion(0.95);
    sched.add(fusion)
        .order(20)           // after odometry (0-10) and IMU (5) nodes
        .budget(2_ms)        // lightweight math
        .on_miss(horus::Miss::Warn)  // warn but don't stop — downstream can tolerate a stale estimate
        .build();

    sched.spin();
}

Common Errors

SymptomCauseFix
Heading oscillates during turnsalpha too low — odometry heading disagrees with gyroIncrease alpha (0.95-0.99) to trust gyro more during turns
Heading drifts when stationaryalpha too high — gyro drift not correctedDecrease alpha toward 0.90 or calibrate gyro bias
Position is completely wrongOdometry node not running or publishing on wrong topicVerify odom node publishes to "odom" topic
Sudden heading jumpsOdometry theta wraps at pi/-pi differently than fused thetaNormalize both angles before blending
No output on startupNeither sensor has published yetCheck has_odom/has_imu flags; fusion waits for first data
Heading matches odometry exactlyIMU not publishing or alpha = 0Verify IMU node runs and alpha is set correctly

Design Decisions

ChoiceRationale
Position from odometry onlyIMU accelerometer double-integration drifts meters in seconds — unusable for position
Complementary filter over Kalman90% of Kalman's benefit with 10% of the complexity; no covariance matrices
alpha = 0.95 defaultTrusts gyro for smooth turns while slowly correcting drift from odometry
Graceful degradationWorks with odometry-only, IMU-only, or both — does not crash on missing data
order(20)Runs after sensor nodes (IMU=5, odom=10) so both inputs are fresh
Miss::Warn policyA stale fused estimate is imperfect but not dangerous — downstream nodes tolerate it
Heading disagreement warningLarge drift between IMU and odometry signals wheel slip or sensor failure
enter_safe_state() publishes last poseDownstream navigation nodes get a final estimate rather than silence during a safety event

Variations

Extended Kalman Filter (EKF) — for optimal fusion with uncertainty estimates:

// State: [x, y, theta], Measurement: [odom_x, odom_y, odom_theta, gyro_z]
// Requires process noise Q and measurement noise R matrices
void predict(double v, double w, double dt) {
    x_ += v * std::cos(theta_) * dt;
    y_ += v * std::sin(theta_) * dt;
    theta_ += w * dt;
    // P = F * P * F^T + Q  (covariance propagation)
}
void correct_odom(double ox, double oy, double otheta) {
    // K = P * H^T * (H * P * H^T + R)^-1  (Kalman gain)
    // x = x + K * (z - H * x)               (state update)
}

GPS fusion — add absolute position correction when available:

if (auto gps = gps_sub_->recv()) {
    // GPS provides absolute x/y but at low rate (1-10 Hz) and with noise (~2m)
    double gps_alpha = 0.05;  // low trust — GPS is noisy
    x_ = (1.0 - gps_alpha) * x_ + gps_alpha * gps->get()->x;
    y_ = (1.0 - gps_alpha) * y_ + gps_alpha * gps->get()->y;
}

Multi-IMU fusion — average multiple IMUs for redundancy and noise reduction:

auto imu1 = imu1_sub_->recv();
auto imu2 = imu2_sub_->recv();
double gz = 0;
int count = 0;
if (imu1) { gz += imu1->get()->angular_velocity[2]; count++; }
if (imu2) { gz += imu2->get()->angular_velocity[2]; count++; }
if (count > 0) gz /= count;  // averaged yaw rate

Key Takeaways

  • Never integrate IMU accelerometer for position — it drifts meters in seconds
  • The complementary filter is the right starting point: simple, tunable, and effective
  • Drain both subscribers every tick, even if only one has new data, to avoid stale buffers
  • Log the heading disagreement between sensors — it reveals wheel slip and sensor failures
  • order(20) ensures sensor nodes run first and fusion gets fresh data every tick
  • Use enter_safe_state() to publish a final known-good pose before the system shuts down