@@ -343,6 +343,85 @@ void test_robot_pose_observation()
343343 std::cout << " OK\n " ;
344344}
345345
346+ // --------------------------------------------------------------------------
347+ // Test 7: Mixed ICP + 3-D wheel-odometry source
348+ // --------------------------------------------------------------------------
349+ // LiDAR ICP supplies the ground-truth trajectory via fuse_pose() at 1 Hz.
350+ // Wheel odometry arrives as CObservationRobotPose at 2 Hz and advances the
351+ // state between ICP scans via fuse_odometry_3d_pose().
352+ //
353+ // Key invariants checked:
354+ // (a) The twist derived from two consecutive ICP poses is the true ICP-to-ICP
355+ // velocity and is NOT contaminated by odometry deltas applied to
356+ // state_.last_pose between those scans.
357+ // (b) estimated_navstate() extrapolates correctly from the odom-advanced pose
358+ // while an ICP scan is still pending.
359+ // (c) estimated_navstate() extrapolates correctly from the ICP pose once a
360+ // new scan has been fused.
361+ void test_icp_and_3d_odometry_fusion ()
362+ {
363+ std::cout << " [Test 7] ICP + 3-D odometry fusion... " ;
364+
365+ mola::state_estimation_simple::StateEstimationSimple estimator;
366+ estimator.setMinLoggingLevel (mrpt::system::LVL_DEBUG );
367+ estimator.initialize (get_default_config ());
368+
369+ const auto cov = mrpt::math::CMatrixDouble66::Identity ();
370+ const double v_x = 1.0 ; // m/s — robot moves steadily in +X
371+
372+ // Helper: inject a CObservationRobotPose for the wheel-odometry source.
373+ auto send_wheel_odom = [&](double t, double x)
374+ {
375+ auto obs = mrpt::obs::CObservationRobotPose::Create ();
376+ obs->timestamp = mrpt::Clock::fromDouble (t);
377+ obs->sensorLabel = " wheel_odom" ;
378+ obs->pose = mrpt::poses::CPose3DPDFGaussian (mrpt::poses::CPose3D (x, 0 , 0 ), cov);
379+ estimator.onNewObservation (obs);
380+ };
381+
382+ // --- t=0: both sources initialise at origin ---
383+ estimator.fuse_pose (
384+ mrpt::Clock::fromDouble (0.0 ),
385+ mrpt::poses::CPose3DPDFGaussian (mrpt::poses::CPose3D::Identity (), cov), " map" );
386+ send_wheel_odom (0.0 , 0.0 );
387+
388+ // --- t=0.5: odom fires mid-scan, advancing state_.last_pose to x=0.5 ---
389+ send_wheel_odom (0.5 , 0.5 );
390+
391+ // (b) Mid-scan extrapolation: odom twist (1 m/s) from (0.5,0,0) at t=0.5
392+ // queried at t=0.7 → expected x = 0.5 + 1.0*0.2 = 0.7
393+ {
394+ auto s = estimator.estimated_navstate (mrpt::Clock::fromDouble (0.7 ), " map" );
395+ ASSERT_ (s.has_value ());
396+ ASSERT_NEAR_ (s->pose .mean .x (), 0.7 , 1e-3 );
397+ }
398+
399+ // --- t=1: LiDAR ICP delivers the true vehicle position (1,0,0) ---
400+ estimator.fuse_pose (
401+ mrpt::Clock::fromDouble (1.0 ),
402+ mrpt::poses::CPose3DPDFGaussian (mrpt::poses::CPose3D (v_x, 0 , 0 ), cov), " map" );
403+
404+ // (a) Twist must equal the ICP-to-ICP velocity (1.0 m/s).
405+ // A regression would give 0.5 m/s because the broken code used the
406+ // odom-advanced state_.last_pose=(0.5,0,0) as the base instead of the
407+ // per-source ICP pose=(0,0,0).
408+ {
409+ auto tw = estimator.get_last_twist ();
410+ ASSERT_ (tw.has_value ());
411+ ASSERT_NEAR_ (tw->vx , v_x, 1e-3 );
412+ }
413+
414+ // (c) Post-ICP extrapolation: ICP pose (1,0,0) at t=1, query at t=1.5
415+ // → expected x = 1.0 + 1.0*0.5 = 1.5
416+ {
417+ auto s = estimator.estimated_navstate (mrpt::Clock::fromDouble (1.5 ), " map" );
418+ ASSERT_ (s.has_value ());
419+ ASSERT_NEAR_ (s->pose .mean .x (), 1.5 , 1e-3 );
420+ }
421+
422+ std::cout << " OK\n " ;
423+ }
424+
346425} // namespace
347426
348427int main ([[maybe_unused]] int argc, [[maybe_unused]] char ** argv)
@@ -355,6 +434,7 @@ int main([[maybe_unused]] int argc, [[maybe_unused]] char** argv)
355434 test_planar_motion ();
356435 test_gnss_ignored ();
357436 test_robot_pose_observation ();
437+ test_icp_and_3d_odometry_fusion ();
358438
359439 std::cout << " \n All StateEstimationSimple tests passed!\n " ;
360440 return 0 ;
0 commit comments