Skip to content

Add PseudorangeFactorArm and DifferentialPseudorangeFactorArm#2447

Merged
dellaert merged 10 commits intoborglab:developfrom
inuex35:feature/PseudorangeFactorLever
Mar 19, 2026
Merged

Add PseudorangeFactorArm and DifferentialPseudorangeFactorArm#2447
dellaert merged 10 commits intoborglab:developfrom
inuex35:feature/PseudorangeFactorLever

Conversation

@inuex35
Copy link
Copy Markdown
Contributor

@inuex35 inuex35 commented Mar 2, 2026

Description:
This PR introduces new factors to the navigation module that support lever arm compensation for GNSS sensors.

Previously, PseudorangeFactor required the antenna's absolute ECEF position. The new PseudorangeFactorArm factors accept a Pose3 (body pose in the navigation frame) and a static lever arm vector in the body frame. This allows the factor to dynamically compute the antenna position based on the body's rotation.

@inuex35 inuex35 changed the title Feature/pseudorange factor lever Add PseudorangeFactorArm and DifferentialPseudorangeFactorArm Mar 2, 2026
@dellaert dellaert requested a review from Copilot March 4, 2026 04:24
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR adds lever-arm–aware GNSS pseudorange factors to the navigation module, enabling pseudorange residuals to be expressed on a body Pose3 while computing the antenna position via a fixed body-frame lever arm.

Changes:

  • Added PseudorangeFactorArm and DifferentialPseudorangeFactorArm (Pose3 + lever arm) implementations.
  • Exposed the new factors to the language wrappers via navigation.i.
  • Added unit tests validating construction, Jacobians, lever-arm behavior, printing, and equality.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.

File Description
gtsam/navigation/PseudorangeFactor.h Declares the new lever-arm pseudorange factor classes, API docs, traits, and serialization.
gtsam/navigation/PseudorangeFactor.cpp Implements error evaluation/Jacobians, print/equals, and constructors for the new factors.
gtsam/navigation/navigation.i Adds SWIG interface declarations for the new factor types.
gtsam/navigation/tests/testPseudorangeFactor.cpp Adds test coverage for the new lever-arm pseudorange factors.

Comment on lines +263 to +266
/** default constructor - only use for serialization */
PseudorangeFactorArm() : bL_(0, 0, 0) {}

virtual ~PseudorangeFactorArm() = default;
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

PseudorangeFactorArm() only initializes bL_; the inherited PseudorangeBase members (pseudorange_, satPos_, satClkBias_) remain uninitialized. Since print()/equals() access these members (and the tests construct a default factor and call print()/equals()), this is undefined behavior. Initialize the PseudorangeBase subobject in the default constructor (or add default member initializers in PseudorangeBase) so default-constructed factors are safe for these operations.

Copilot uses AI. Check for mistakes.
typedef DifferentialPseudorangeFactorArm This;

/** default constructor - only use for serialization */
DifferentialPseudorangeFactorArm() : bL_(0, 0, 0) {}
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

DifferentialPseudorangeFactorArm() only initializes bL_; the inherited PseudorangeBase members (pseudorange_, satPos_, satClkBias_) remain uninitialized. Because print()/equals() read these fields (and the tests default-construct this factor and call those methods), this is undefined behavior. Initialize the PseudorangeBase subobject (or provide default member initializers) in the default constructor.

Suggested change
DifferentialPseudorangeFactorArm() : bL_(0, 0, 0) {}
DifferentialPseudorangeFactorArm()
: PseudorangeBase{0.0, Point3(0, 0, 0), 0.0}, bL_(0, 0, 0) {}

Copilot uses AI. Check for mistakes.
Comment on lines +204 to +206
// Clock bias derivative should always be speed-of-light in vacuum:
EXPECT_DOUBLES_EQUAL(Hbias.norm(), 299792458.0, 1e-9);
}
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

These tests compare Hbias.norm() to the speed of light with an extremely tight absolute tolerance (1e-9) on a ~3e8 value. The Frobenius norm uses a sqrt and can introduce tiny rounding differences; this risks flaky failures on some platforms/build flags. Prefer asserting directly on Hbias(0,0) (or use a looser tolerance / relative comparison).

Copilot uses AI. Check for mistakes.
Comment on lines +345 to +347
EXPECT(!Hcorrection.array().isNaN().any());
EXPECT_DOUBLES_EQUAL(Hbias.norm(), 299792458.0, 1e-9);
EXPECT_DOUBLES_EQUAL(Hcorrection(0, 0), -1.0, 1e-9);
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

These tests compare Hbias.norm() to the speed of light with an extremely tight absolute tolerance (1e-9) on a ~3e8 value. Since the norm involves a sqrt, small floating-point rounding differences can make this flaky. Prefer checking Hbias(0,0) directly (or relax the tolerance / use relative error).

Copilot uses AI. Check for mistakes.
Comment on lines +186 to +188
if (range < std::numeric_limits<double>::epsilon()) {
*H_nTb = Matrix16::Zero();
} else {
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

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

std::numeric_limits<double>::epsilon() is used here in the new code path, but this translation unit does not include <limits> directly. It likely compiles today via transitive includes, but it’s brittle; add an explicit #include <limits> in this .cpp (or the relevant header) to make the dependency explicit.

Copilot uses AI. Check for mistakes.
@dellaert
Copy link
Copy Markdown
Member

dellaert commented Mar 7, 2026

@masoug Could you take a look on whether this fits in the pseudo-range factor hierarchy cleanly?

- Initialize PseudorangeBase in default constructors of PseudorangeFactorArm
  and DifferentialPseudorangeFactorArm to avoid undefined behavior
- Replace Hbias.norm() with Hbias(0, 0) in tests to avoid floating-point
  issues with sqrt on large values like the speed of light
- Add explicit #include <limits> in PseudorangeFactor.cpp for
  std::numeric_limits usage

https://claude.ai/code/session_01WwBj5et3656PootjzHLNEJ
@masoug
Copy link
Copy Markdown
Contributor

masoug commented Mar 7, 2026

@masoug Could you take a look on whether this fits in the pseudo-range factor hierarchy cleanly?

I think this is fine for the time being. Eventually we'll need to re-work the PseudorangeFactor API as more features and capability get added (e.g. different frequencies, yes/no lever arms, carrier vs code measurements, non-US GNSS satellites, etc...) in the future. So if API stability is a critical priority, we'll want to do that refactor here and now. Otherwise, I'm happy with the proposed changes in this PR, and we'll cross the PseudorangeFactor API bridge when we get there.

@inuex35
Copy link
Copy Markdown
Contributor Author

inuex35 commented Mar 7, 2026

Hi — I was working on RTK support in GTSAM but ended up stalling out. I'd love to see native RTK processing land in GTSAM eventually.
I've been experimenting in this repo: https://github.com/inuex35/pyins
Happy to help with implementation if there's interest!

@inuex35
Copy link
Copy Markdown
Contributor Author

inuex35 commented Mar 8, 2026

Hi, I realized the current pseudorange factor is computed in ECEF, but when combining it with IMU or other sensors, it would be more convenient to use NED or ENU due to the complexity of IMU preintegration.

@dellaert
Copy link
Copy Markdown
Member

dellaert commented Mar 8, 2026

@masoug please discuss :-) I think ECEF might be more native to GPS, and if you want NED or ENU you have the additional burden to choose that - which we might not want to have as a prerequisite to using these factors.

@varunagrawal
Copy link
Copy Markdown
Contributor

Minor nitpick: the naming convention usually puts Factor at the end, so the factor should be called PseudorangeArmFactor.

@dellaert
Copy link
Copy Markdown
Member

dellaert commented Mar 9, 2026

Minor nitpick: the naming convention usually puts Factor at the end, so the factor should be called PseudorangeArmFactor.

Even better, PseudoRangeArmFactor

@masoug
Copy link
Copy Markdown
Contributor

masoug commented Mar 10, 2026

@masoug please discuss :-) I think ECEF might be more native to GPS, and if you want NED or ENU you have the additional burden to choose that - which we might not want to have as a prerequisite to using these factors.

Sorry for the late reply, had a bunch of work/life stuff, not to mention Claude Code is quite the rabbit hole 🐰

I concur with @dellaert; GNSS itself is unaware of attitude because its primary focus is point positioning. Therefore, concepts like ENU/NED aren't involved in pseudorange/carrier-phase equations. Instead, users are responsible for choosing the frame convention for their particular application.

The original intention I had in my head was for users to explicitly model SE(3) body-frame and R^3 antenna-frame variables as two distinct nodes linked by a stiff pose-point-factor lever-arm constraint. This decouples GNSS point-positioning from application-specific attitude estimation, allowing flexibility for arbitrary frame conventions encoded in that pose-point factor. But I can see the convenience that comes from native lever-arm support in the pseudorange factors, so maybe the pose3 pseudorange lever-arm factor should have a "ENU" or "NED" option to specify a body-frame convention for applying the offset?

@dellaert
Copy link
Copy Markdown
Member

Well, ENU and NED are navigation frame conventions, whereas the lever-arm is a body-frame quantity, right? Let me go check the code. @inuex35 please resolve the copilot comments that you have addressed and also please do the requested name change, then we can run CI.

@dellaert
Copy link
Copy Markdown
Member

This is awesome, but before we merge, I think the frame naming should be clarified.

Using nTb / nRb suggests a generic “navigation frame”, but in both factors the satellite position is explicitly ECEF, so the pose/translation side is effectively expected to be ECEF as well. In GTSAM, n often reads like a local navigation frame such as NED or ENU, which makes this easy to misread.

Would it make sense to rename these to something more explicit, for example eTb / eRb, or even ecef_T_body / ecef_R_body? That would make it much more obvious that:

  • the body pose supplied to this factor is expected in ECEF,
  • the satellite position is in ECEF, and
  • any conversion to or from a local navigation frame remains the caller’s responsibility.

I think that expectation should also be stated directly in the class and constructor documentation, since otherwise a reader could reasonably assume this accepts an arbitrary local navigation frame.

claude and others added 2 commits March 12, 2026 00:40
Address PR review feedback from dellaert: rename frame variables to
make explicit that the body pose and satellite position are in ECEF,
not a generic navigation frame. Also update documentation to clarify
that local frame (NED/ENU) conversion is the caller's responsibility.

https://claude.ai/code/session_01GqWXxUkLXHV89MnocPt1ws
…Sv2B

Rename frame variables from nTb/nRb to ecef_T_body/ecef_R_body
@inuex35
Copy link
Copy Markdown
Contributor Author

inuex35 commented Mar 12, 2026

I have updated the implementation to work in ECEF coordinates for now. However, if future sensor fusion with cameras and LiDAR is planned, I believe local-frame Jacobians will be necessary.
I also aligned the factor names with the existing convention, where GpsFactor is named GpsFactorArm.

Copy link
Copy Markdown
Member

@dellaert dellaert left a comment

Choose a reason for hiding this comment

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

Thanks, my comments have been addressed. I also agree with the naming.

@inuex35
Copy link
Copy Markdown
Contributor Author

inuex35 commented Mar 19, 2026

Could you merge this when you get a chance? I'm planning to work on lever arm compensation and ENU/NED coordinate support next.

@dellaert dellaert merged commit 75b4219 into borglab:develop Mar 19, 2026
32 checks passed
@inuex35 inuex35 deleted the feature/PseudorangeFactorLever branch March 20, 2026 10:58
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.

6 participants