Skip to content

fix(state_estimation_simple): correct twist and initial-guess corruption when wheel odometry is active#29

Merged
jlblancoc merged 3 commits into
developfrom
fix/odometry-fuse-pose-twist-corruption
May 6, 2026
Merged

fix(state_estimation_simple): correct twist and initial-guess corruption when wheel odometry is active#29
jlblancoc merged 3 commits into
developfrom
fix/odometry-fuse-pose-twist-corruption

Conversation

@jlblancoc

@jlblancoc jlblancoc commented May 6, 2026

Copy link
Copy Markdown
Member

Three inter-related bugs caused the LiDAR adaptive threshold sigma to grow toward maximum_sigma whenever wheel odometry was fused:

  1. Wrong twist from fuse_pose() when odometry is active. fuse_pose() computed incrPose = new_ICP - last_pose, but last_pose had been modified by fuse_odometry() / fuse_odometry_3d_pose() between scans. The result was the odometry residual / dt instead of the true robot velocity, corrupting de-skewing and the rot_error term in the adaptive threshold. Fix: per-source SourceState in State tracks each source's own last absolute pose and timestamp. fuse_pose() now computes incrPose = new_ICP - src.last_pose, which is always the true ICP-to-ICP motion regardless of intervening odom updates.

  2. CObservationRobotPose (3D odom path) overwrote last_pose_obs_tim. When BridgeROS2 forwards wheel odometry as CObservationRobotPose (odometry_as_robot_pose_observation=true), onNewObservation() routed it to fuse_pose(), which updated last_pose_obs_tim to the odom timestamp. If the next LiDAR ICP fuse_pose() arrived with a slightly earlier timestamp, dt < 0 and the call was silently rejected. last_pose was then the absolute wheel odometry pose (in the odom frame), producing ~90-degree initial-guess errors for ICP (confirmed by debug traces showing motionModelError with yaw ~ -1.56 rad and pitch ~ 0.51 rad on a stationary ground robot). Fix: route CObservationRobotPose matching do_process_odometry_labels_re to a new fuse_odometry_3d_pose() method that applies an incremental delta to last_pose (keeping it in the SLAM frame) and never touches last_pose_obs_tim.

  3. fuse_odometry() ignored the velocity carried in CObservationOdometry. BridgeROS2 always populates hasVelocities / velocityLocal from the ROS /odom twist. Those values were discarded, leaving last_twist stale. Fix: when hasVelocities is true, update last_twist from velocityLocal. IMU angular rates (fuse_imu) still override wx/wy/wz when available.

Summary by CodeRabbit

  • Improvements
    • Enhanced odometry and 3D pose fusion with improved per-source tracking for more accurate state estimation.
    • Better handling of incremental pose updates from 3D odometry observations.
    • Refined twist derivation from velocity measurements during pose fusion.

…ion when wheel odometry is active

Three inter-related bugs caused the LiDAR adaptive threshold sigma to grow
toward maximum_sigma whenever wheel odometry was fused:

1. Wrong twist from fuse_pose() when odometry is active.
   fuse_pose() computed incrPose = new_ICP - last_pose, but last_pose had
   been modified by fuse_odometry() / fuse_odometry_3d_pose() between scans.
   The result was the odometry residual / dt instead of the true robot velocity,
   corrupting de-skewing and the rot_error term in the adaptive threshold.
   Fix: per-source SourceState in State tracks each source's own last absolute
   pose and timestamp. fuse_pose() now computes incrPose = new_ICP - src.last_pose,
   which is always the true ICP-to-ICP motion regardless of intervening odom updates.

2. CObservationRobotPose (3D odom path) overwrote last_pose_obs_tim.
   When BridgeROS2 forwards wheel odometry as CObservationRobotPose
   (odometry_as_robot_pose_observation=true), onNewObservation() routed it
   to fuse_pose(), which updated last_pose_obs_tim to the odom timestamp.
   If the next LiDAR ICP fuse_pose() arrived with a slightly earlier timestamp,
   dt < 0 and the call was silently rejected. last_pose was then the absolute
   wheel odometry pose (in the odom frame), producing ~90-degree initial-guess
   errors for ICP (confirmed by debug traces showing motionModelError with
   yaw ~ -1.56 rad and pitch ~ 0.51 rad on a stationary ground robot).
   Fix: route CObservationRobotPose matching do_process_odometry_labels_re to a
   new fuse_odometry_3d_pose() method that applies an incremental delta to
   last_pose (keeping it in the SLAM frame) and never touches last_pose_obs_tim.

3. fuse_odometry() ignored the velocity carried in CObservationOdometry.
   BridgeROS2 always populates hasVelocities / velocityLocal from the ROS
   /odom twist. Those values were discarded, leaving last_twist stale.
   Fix: when hasVelocities is true, update last_twist from velocityLocal.
   IMU angular rates (fuse_imu) still override wx/wy/wz when available.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented May 6, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@jlblancoc has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 28 minutes and 41 seconds before requesting another review.

To continue reviewing without waiting, purchase usage credits in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: f2fb12bd-5d4f-43e2-8b02-e920db7f070e

📥 Commits

Reviewing files that changed from the base of the PR and between 6d29bae and c6516a1.

📒 Files selected for processing (2)
  • mola_state_estimation_simple/src/StateEstimationSimple.cpp
  • mola_state_estimation_simple/tests/test-state-estimation-simple.cpp
📝 Walkthrough

Walkthrough

The PR refactors state estimation from a single global pose approach to a per-source, delta-based fusion strategy. It introduces per-source state tracking structures, per-source label-processing caches, a new 3D odometry fusion method, and updates twist and covariance calculation to leverage per-source timing and pose deltas.

Changes

Per-Source Delta-Based Pose and Odometry Fusion

Layer / File(s) Summary
Data Structures
mola_state_estimation_simple/include/mola_state_estimation_simple/StateEstimationSimple.h
New nested SourceState struct holds per-source last_pose and last_obs_tim. Added per_source map to State struct for multi-source tracking. Added RegexCache members (do_process_imu_labels_re, do_process_odometry_labels_re, do_process_gnss_labels_re) for per-source label processing.
Core Fusion Logic
mola_state_estimation_simple/src/StateEstimationSimple.cpp (lines 75–186)
fuse_odometry now advances last_pose using 2D odometry delta and derives twist from wheel velocities. New fuse_odometry_3d_pose method handles per-source 3D odometry via CObservationRobotPose, using src.last_pose for delta computation and src.last_obs_tim for dt, with safety checks and per-source twist derivation. fuse_pose updated to compute per-source delta-based twist and covariance using src.last_pose and src.last_obs_tim.
Integration & Routing
mola_state_estimation_simple/src/StateEstimationSimple.cpp (lines 256–343)
fuse_pose signature adjusted to remove [[maybe_unused]] annotation. Robot-pose observations routed to fuse_odometry_3d_pose instead of fuse_pose for consistency with per-source delta methodology. Per-source state and twist covariance finalized after fusion updates.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • MOLAorg/mola_state_estimation#28: Prevents IMU updates from overwriting existing linear twist/covariance, complementing the per-source twist derivation introduced here.
  • MOLAorg/mola_state_estimation#26: Modifies RegexCache implementation (mutex, move-assignment, return type), affecting the per-source caching introduced in this PR.
  • MOLAorg/mola_state_estimation#25: Adds initial CObservationRobotPose support via fuse_pose; this PR refactors robot-pose handling to use per-source delta-based fuse_odometry_3d_pose.

Poem

🐰 Hop along, pose by source we now track,
Deltas cascade with each odometry pack,
Per-source histories bloom in the state,
Twist derives swift—fusion feels great!
Per-source, per-time, per-hop we rotate! 🌱

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 14.29% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title precisely describes the main fix: correcting twist and initial-guess corruption caused by wheel odometry fusion. This aligns with the three inter-related bugs addressed in the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/odometry-fuse-pose-twist-corruption

Tip

💬 Introducing Slack Agent: The best way for teams to turn conversations into code.

Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.

  • Generate code and open pull requests
  • Plan features and break down work
  • Investigate incidents and troubleshoot customer tickets together
  • Automate recurring tasks and respond to alerts with triggers
  • Summarize progress and report instantly

Built for teams:

  • Shared memory across your entire org—no repeating context
  • Per-thread sandboxes to safely plan and execute work
  • Governance built-in—scoped access, auditability, and budget controls

One agent for your entire SDLC. Right inside Slack.

👉 Get started


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
mola_state_estimation_simple/include/mola_state_estimation_simple/StateEstimationSimple.h (1)

68-70: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Stale \note comment: frame_id is no longer ignored.

fuse_pose() now uses frame_id as a per-source map key (state_.per_source[frame_id]), so the note "ignores the passed 'frame_id'" is no longer accurate and should be updated to describe the new per-source keying behavior.

📝 Suggested update
- * \note This implementation of mola::NavStateFilter ignores the passed
- *       "frame_id" and GNSS observations.
+ * \note This implementation of mola::NavStateFilter uses "frame_id" as a
+ *       per-source key for twist estimation (see fuse_pose()) but does not
+ *       perform any coordinate-frame transformation based on it.
+ *       GNSS observations are ignored.

Additionally, the coding guideline for this path requires StateEstimationSimple to "ignore frame_id in sensor measurements". Since frame_id is now actively used for per-source discrimination in fuse_pose(), please confirm with the team whether the guideline should be relaxed or whether a sensor-label-based key (as used in fuse_odometry_3d_pose) would be a more guideline-compliant alternative for the per-source map.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@mola_state_estimation_simple/include/mola_state_estimation_simple/StateEstimationSimple.h`
around lines 68 - 70, The class-level note is stale: update the comment in
StateEstimationSimple.h to remove "ignores the passed 'frame_id'" and instead
document that fuse_pose() uses the measurement's frame_id as a per-source key
(state_.per_source[frame_id]) for source discrimination; then either (a) change
fuse_pose() to use a sensor-label-based key (same approach as
fuse_odometry_3d_pose) to comply with the guideline to ignore frame_id, or (b)
get team approval and update the comment to state the component treats frame_id
as the per-source identifier—make the change around the StateEstimationSimple
declaration and the fuse_pose()/state_.per_source usage sites.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@mola_state_estimation_simple/src/StateEstimationSimple.cpp`:
- Line 274: The code now selects per-source state with
state_.per_source[frame_id], which violates the guideline that
StateEstimationSimple must remain frame-unaware; change fuse_pose() (and its
callers) to use the sensor label instead of frame_id: add a sensorLabel
parameter to fuse_pose() and propagate it from onNewObservation (replace uses of
frame_id when indexing per_source with sensorLabel), keep the previous
[[maybe_unused]] annotation on frame_id if it must be retained, and update any
references to state_.per_source[frame_id] to state_.per_source[sensorLabel]; if
using frame_id here is intentional, update the class note/guideline to document
the frame-keyed behavior.
- Line 261: The for loop that calls ASSERT_GE_ on pose.cov(i, i) is written as a
single-line body and must use braced statements per the coding guidelines;
update the loop in StateEstimationSimple.cpp (the for (int i = 0; i < 6; i++)
ASSERT_GE_(pose.cov(i, i), .0); line) to use a block body { ... } surrounding
the ASSERT_GE_ call so the loop body is explicitly braced.
- Around line 87-104: The current call to state_.last_twist.emplace() overwrites
any IMU-populated angular rates (tw.wx/wy/wz); change the logic to preserve
existing angular-rate components if state_.last_twist already has a value:
before emplacing or assigning the new twist from odom.velocityLocal, read and
store existing wx/wy/wz from state_.last_twist (if present) and restore them
into the new tw, or construct tw from the existing twist and only overwrite
linear components (vx, vy, vz) and omega as appropriate; ensure
state_.last_twist_cov handling remains unchanged and keep the
MRPT_LOG_DEBUG_STREAM call.

---

Outside diff comments:
In
`@mola_state_estimation_simple/include/mola_state_estimation_simple/StateEstimationSimple.h`:
- Around line 68-70: The class-level note is stale: update the comment in
StateEstimationSimple.h to remove "ignores the passed 'frame_id'" and instead
document that fuse_pose() uses the measurement's frame_id as a per-source key
(state_.per_source[frame_id]) for source discrimination; then either (a) change
fuse_pose() to use a sensor-label-based key (same approach as
fuse_odometry_3d_pose) to comply with the guideline to ignore frame_id, or (b)
get team approval and update the comment to state the component treats frame_id
as the per-source identifier—make the change around the StateEstimationSimple
declaration and the fuse_pose()/state_.per_source usage sites.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 879778e7-3ac8-416e-95ac-bc7126c72edd

📥 Commits

Reviewing files that changed from the base of the PR and between 19ae7c2 and 6d29bae.

📒 Files selected for processing (2)
  • mola_state_estimation_simple/include/mola_state_estimation_simple/StateEstimationSimple.h
  • mola_state_estimation_simple/src/StateEstimationSimple.cpp

Comment thread mola_state_estimation_simple/src/StateEstimationSimple.cpp
}
// and the sum of all strictly >0
// Numerical sanity: variances >= 0 (== 0 allowed for some components only)
for (int i = 0; i < 6; i++) ASSERT_GE_(pose.cov(i, i), .0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Missing braces on for loop — coding guideline violation.

🔧 Proposed fix
-    for (int i = 0; i < 6; i++) ASSERT_GE_(pose.cov(i, i), .0);
+    for (int i = 0; i < 6; i++) { ASSERT_GE_(pose.cov(i, i), .0); }

As per coding guidelines: "Use braced statements instead of one-liners (e.g., prefer { stmt; } over single-line if/for/while bodies)".

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
for (int i = 0; i < 6; i++) ASSERT_GE_(pose.cov(i, i), .0);
for (int i = 0; i < 6; i++) { ASSERT_GE_(pose.cov(i, i), .0); }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mola_state_estimation_simple/src/StateEstimationSimple.cpp` at line 261, The
for loop that calls ASSERT_GE_ on pose.cov(i, i) is written as a single-line
body and must use braced statements per the coding guidelines; update the loop
in StateEstimationSimple.cpp (the for (int i = 0; i < 6; i++)
ASSERT_GE_(pose.cov(i, i), .0); line) to use a block body { ... } surrounding
the ASSERT_GE_ call so the loop body is explicitly braced.

Comment thread mola_state_estimation_simple/src/StateEstimationSimple.cpp
@jlblancoc jlblancoc enabled auto-merge May 6, 2026 11:51
@jlblancoc jlblancoc merged commit 68899cf into develop May 6, 2026
6 checks passed
@jlblancoc jlblancoc deleted the fix/odometry-fuse-pose-twist-corruption branch May 6, 2026 11:57
@codecov

codecov Bot commented May 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.78378% with 18 lines in your changes missing coverage. Please review.
✅ Project coverage is 74.13%. Comparing base (19ae7c2) to head (c6516a1).
⚠️ Report is 4 commits behind head on develop.

Files with missing lines Patch % Lines
...te_estimation_simple/src/StateEstimationSimple.cpp 76.62% 18 Missing ⚠️
Additional details and impacted files
@@             Coverage Diff             @@
##           develop      #29      +/-   ##
===========================================
+ Coverage    73.90%   74.13%   +0.22%     
===========================================
  Files           41       41              
  Lines         2905     2996      +91     
  Branches       251      256       +5     
===========================================
+ Hits          2147     2221      +74     
- Misses         758      775      +17     
Flag Coverage Δ
colcon 74.13% <83.78%> (+0.22%) ⬆️
gcc 74.13% <83.78%> (+0.22%) ⬆️
rolling 74.13% <83.78%> (+0.22%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant