Add PseudorangeFactorArm and DifferentialPseudorangeFactorArm#2447
Add PseudorangeFactorArm and DifferentialPseudorangeFactorArm#2447dellaert merged 10 commits intoborglab:developfrom
Conversation
…nuex35/gtsam into feature/PseudorangeFactorLever
There was a problem hiding this comment.
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
PseudorangeFactorArmandDifferentialPseudorangeFactorArm(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. |
| /** default constructor - only use for serialization */ | ||
| PseudorangeFactorArm() : bL_(0, 0, 0) {} | ||
|
|
||
| virtual ~PseudorangeFactorArm() = default; |
There was a problem hiding this comment.
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.
gtsam/navigation/PseudorangeFactor.h
Outdated
| typedef DifferentialPseudorangeFactorArm This; | ||
|
|
||
| /** default constructor - only use for serialization */ | ||
| DifferentialPseudorangeFactorArm() : bL_(0, 0, 0) {} |
There was a problem hiding this comment.
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.
| DifferentialPseudorangeFactorArm() : bL_(0, 0, 0) {} | |
| DifferentialPseudorangeFactorArm() | |
| : PseudorangeBase{0.0, Point3(0, 0, 0), 0.0}, bL_(0, 0, 0) {} |
| // Clock bias derivative should always be speed-of-light in vacuum: | ||
| EXPECT_DOUBLES_EQUAL(Hbias.norm(), 299792458.0, 1e-9); | ||
| } |
There was a problem hiding this comment.
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).
| EXPECT(!Hcorrection.array().isNaN().any()); | ||
| EXPECT_DOUBLES_EQUAL(Hbias.norm(), 299792458.0, 1e-9); | ||
| EXPECT_DOUBLES_EQUAL(Hcorrection(0, 0), -1.0, 1e-9); |
There was a problem hiding this comment.
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).
| if (range < std::numeric_limits<double>::epsilon()) { | ||
| *H_nTb = Matrix16::Zero(); | ||
| } else { |
There was a problem hiding this comment.
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.
|
@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
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. |
|
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. |
|
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. |
|
@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. |
|
Minor nitpick: the naming convention usually puts |
Even better, |
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? |
|
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. |
Address PR borglab#2447 review feedback from Copilot
|
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:
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. |
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
|
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. |
dellaert
left a comment
There was a problem hiding this comment.
Thanks, my comments have been addressed. I also agree with the naming.
|
Could you merge this when you get a chance? I'm planning to work on lever arm compensation and ENU/NED coordinate support next. |
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.