feat: double difference factor for rtk gnss#2502
Conversation
Nonlinear DD and SD factors for GNSS RTK positioning: - DDPseudorangeFactor / DDCarrierPhaseFactor - DDPseudorangeFactorArm / DDCarrierPhaseFactorArm - PseudorangeFactor / CarrierPhaseFactor (undifferenced) - PseudorangeSDFactor / CarrierPhaseSDFactor - DifferentialPseudorangeFactor Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move duplicated geodist(), OMGE, and C_LIGHT into a shared gtsam/navigation/GnssCommon.h header. This eliminates identical copies from DDPseudorangeFactor, DDPseudorangeFactorArm, DDCarrierPhaseFactor, and DDCarrierPhaseFactorArm. Also unifies the naming from CLIGHT (anonymous namespace) to gnss::C_LIGHT in both .cpp files. https://claude.ai/code/session_01Wqjya7ZCHeJFJiZ5Sxxgwt
Change all four DD factors (DDPseudorangeFactor, DDPseudorangeFactorArm, DDCarrierPhaseFactor, DDCarrierPhaseFactorArm) from (observed - model) to (model - observed), matching the convention used by the undifferenced PseudorangeFactor and CarrierPhaseFactor. Jacobian signs updated accordingly. https://claude.ai/code/session_01Wqjya7ZCHeJFJiZ5Sxxgwt
A localization test
There was a problem hiding this comment.
Pull request overview
Adds RTK-style double-difference GNSS factors (pseudorange + carrier phase) with optional lever-arm compensation, and introduces a shared GNSS utilities header to consolidate constants/geometry helpers.
Changes:
- Add
DDPseudorangeFactor/DDPseudorangeFactorArm(with optionalecef_T_nav) and corresponding unit tests. - Add
CarrierPhaseFactor/CarrierPhaseFactorArmplusDDCarrierPhaseFactor/DDCarrierPhaseFactorArmand unit tests. - Introduce
GnssCommon.hfor shared GNSS constants andgeodist()(Sagnac-corrected range), and wire constants into existing pseudorange code.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| gtsam/navigation/PseudorangeFactor.h | Adds DD pseudorange factor classes (point + lever-arm) inline. |
| gtsam/navigation/PseudorangeFactor.cpp | Switches to shared C_LIGHT constant; refactors placement of differential pseudorange implementation. |
| gtsam/navigation/GnssCommon.h | New shared GNSS constants + Sagnac-corrected geodist() utility. |
| gtsam/navigation/CarrierPhaseFactor.h | New carrier phase factors (undifferenced + DD, with lever-arm + optional ecef_T_nav). |
| gtsam/navigation/CarrierPhaseFactor.cpp | Implements undifferenced carrier phase factor logic + Jacobians. |
| gtsam/navigation/navigation.i | Exposes new factors to wrappers via SWIG interface. |
| gtsam/navigation/tests/testPseudorangeFactor.cpp | Adds unit tests for DD pseudorange factors (including lever-arm and ecef_T_nav). |
| gtsam/navigation/tests/testCarrierPhaseFactor.cpp | New unit tests for carrier phase factors (undifferenced + DD, lever-arm + ecef_T_nav). |
varunagrawal
left a comment
There was a problem hiding this comment.
Some initial comments.
|
|
||
| public: | ||
| // Provide access to the Matrix& version of evaluateError: | ||
| using Base::evaluateError; |
There was a problem hiding this comment.
Do you need this? You've implemented evaluateError.
|
Thanks for initial review, @varunagrawal . @masoug - would also appreciate your take on the implemented functionality, and how it relates. @inuex35 please run |
- Move heavy DD factor evaluateError(), print(), equals(), and ctor bodies from PseudorangeFactor.h / CarrierPhaseFactor.h into the matching .cpp files so headers stay light and follow the rest of the navigation module. - Refactor gnss::geodist() to take an OptionalJacobian<1, 3> for the range derivative w.r.t. the receiver position. The Jacobian now includes the Sagnac correction term, fixing the inconsistency between the modeled range and the previous Euclidean-only unit-vector Jacobian used by the DD factors. - Guard gnss::geodist() against zero distance (sat == rcv) to avoid NaNs. - DD pseudorange / carrier-phase factors (with and without lever arm) now consume the new Jacobian, so the chain rule through the Sagnac term is exact and consistent across all variants. - Drop the duplicate `#include <gtsam/navigation/CarrierPhaseFactor.h>` in navigation.i and fix the doxygen typo "= lambda * N).." in CarrierPhaseFactor.h. Local make check on the navigation tests passes: testPseudorangeFactor - no test failures testCarrierPhaseFactor - no test failures
- DDPseudorangeFactor -> DoubleDifferencePseudorangeFactor - DDPseudorangeFactorArm -> DoubleDifferencePseudorangeFactorArm - DDCarrierPhaseFactor -> DoubleDifferenceCarrierPhaseFactor - DDCarrierPhaseFactorArm -> DoubleDifferenceCarrierPhaseFactorArm Also expands "DD" in the doxygen class summaries to "Double-difference" so the class purpose is obvious without needing to know the GNSS jargon. navigation.i, the unit tests, and all internal references are updated. Local make check on the navigation tests still passes: testPseudorangeFactor - no test failures testCarrierPhaseFactor - no test failures
Claude/gtsam pr 2502 dc c aw
masoug
left a comment
There was a problem hiding this comment.
This is great! One humble request, if I may: Would you be willing to provide an end-to-end example of these carrier phase factors using data from physical receivers (similar to the other psuedorange factors)? I am especially interested in getting your thoughts on cm-scale positioning by some hybrid estimation between gtsam and a integer optimizer for ambiguity resolution.
|
@masoug you can try this. make sure there is no outlier or multipath detection. |
- Consolidate PseudorangeBase and CarrierPhaseBase into a shared GnssMeasurementBase struct in GnssCommon.h (per @masoug's suggestion). Rename the measurement member from `pseudorange_`/`carrierPhase_` to the signal-agnostic `measurement_`. - Move gnss::geodist() implementation from the GnssCommon.h header into a new GnssCommon.cpp translation unit (per @masoug's suggestion). - Add testGnssCommon.cpp covering the Sagnac-corrected range, unit-vector normalization, the r==0 singularity handling, the analytic Jacobian vs. numerical derivative, and the Sagnac contribution to the Jacobian (per @masoug's request for unit/jacobian tests). - Expand DoubleDifferencePseudorangeFactor and DoubleDifferenceCarrierPhaseFactor docstrings to explain when these DD convenience factors should be used vs. composing undifferenced factors (per @masoug's question about purpose). - Remove the accidental duplicate `#include <gtsam/navigation/PseudorangeFactor.h>` in navigation.i (per Copilot). - Keep `using Base::evaluateError` with a short comment: removing it hides the NoiseModelFactorN convenience overloads that the unit tests rely on, matching the pattern used throughout gtsam/navigation.
…or-8Fpni Address PR borglab#2502 review feedback (round 2)
|
@inuex35 Please resolve all comments above that you have addressed or respond to them if you don't think they are relevant. I will then do a final review - maybe I'll also push some commits onto your branch; I see that you've allowed that - which is good :-) |
|
Still seeing a bunch of comments that you did not resolve or respond to. I’m assuming you’re still looking at them :-). Please re-request a review for me when done. |
Per review: carrier phase is conventionally quoted in cycles, but our API takes meters (= lambda * cycles). Rename constructor parameters to `...Meters` so the unit is unambiguous at the call site, without touching the shared base member `measurement_` (also used by PseudorangeFactor). - `measuredCarrierPhase` -> `measuredCarrierPhaseMeters` (CarrierPhaseFactor, CarrierPhaseFactorArm x2) - `cpRovRef/cpBaseRef/cpRovTarget/cpBaseTarget` -> `...Meters` (DoubleDifferenceCarrierPhaseFactor, DoubleDifferenceCarrierPhaseFactorArm x2) .h / .cpp / navigation.i all updated. No behavior change; both testCarrierPhaseFactor and testPseudorangeFactor pass.
|
@dellaert Thank you, maybe I finished the implementation. If you do not have any problem with this, please review again |
|
This is looking great, thanks! My remaining concern is mostly elegance/maintainability rather than the high-level functionality, in particular:
All these things would make the new API feel less copy/pasted and easier to maintain. |
|
Thanks @dellaert, all three addressed:
|
Address PR review feedback that the DD pseudorange/carrier-phase factors and the *Arm variants are nearly copy-paste of each other. Extract the shared work into two helpers in `gtsam/navigation/GnssCommon`: - `gnss::DoubleDifferenceData` owns the four (rover/base x ref/target) observations, satellite ECEF positions at rover and base time, and the base station ECEF position. Provides `observed()` and `model(rcv, H)` which computes the DD geometric range with Sagnac correction and its 1x3 Jacobian. Used by DoubleDifferencePseudorangeFactor[Arm] and DoubleDifferenceCarrierPhaseFactor[Arm] -- the carrier-phase factor just adds the `+ lam * (ambRef - ambTarget)` term and its scalar Jacobians. - `gnss::LeverArm` owns `bL` + optional `ecef_T_nav`. Provides `antennaPosition(pose, &frame)` which composes the (optional) ecef_T_nav, applies the lever arm, and caches the rotation / compose-Jacobian needed later, plus `antennaPoseJacobian(H_antenna_1x3, frame)` which folds in the lever-arm chain rule and (optional) compose chain rule. Used by PseudorangeFactorArm, DifferentialPseudorangeFactorArm, CarrierPhaseFactorArm, DoubleDifferencePseudorangeFactorArm and DoubleDifferenceCarrierPhaseFactorArm so the `H_ecef = [u*-R*[bL]_x | u*R]; *H_pose = has_nav ? H_ecef * H_compose : H_ecef` block lives in one place. Each *Arm factor now stores a single `gnss::LeverArm arm_` member instead of separate `bL_` / `ecef_T_nav_` fields; the public `leverArm()` / `ecefTnav()` accessors and serialization NVP tags are preserved so the navigation.i bindings and existing serialization streams continue to work. DD factors store a single `gnss::DoubleDifferenceData dd_` member; the per-field NVP tags from the original layout (`prRovRef_`, `satRefRov_`, etc.) are kept for serialization compatibility. testPseudorangeFactor.cpp and testCarrierPhaseFactor.cpp share a new header `gnssTestHelpers.h` that holds the previously copy-pasted `makeEcefTnav()`, the L1 wavelength, the speed of light, the `SinglePointPositioningExample` sample (sat / receiver / clock), the DD shared geometry constants (kBasePos, kSatRefRov, ..., kTruePos), the Tokyo ENU origin, and a `geodistRange()` convenience. Tests now say e.g. `sample::kSatPos`, `dd::kTruePos`, `tokyo::ecefTnav()` instead of duplicating the numeric literals. All 30 navigation tests pass (including testSerializationNavigation). https://claude.ai/code/session_01HR3meboi8CCNsqntZzVxXH
Add DDPseudorangeFactor and DDCarrierPhaseFactor (with and without lever-arm)
Following up on #2447, this PR adds four double-differenced GNSS factors:
DDPseudorangeFactor– DD code pseudorange, takes a Point3 (ECEF antenna position)DDPseudorangeFactorArm– same, but takes a Pose3 in the navigation frame + body-frame lever armDDCarrierPhaseFactor– DD carrier-phase, takes a Point3 (ECEF antenna position), with ambiguity variableNDDCarrierPhaseFactorArm– same, but takes a Pose3 in the navigation frame + body-frame lever arm, with ambiguity variableNFactors without the
Armsuffix take an ECEF antenna position directly. TheArmvariants take a Pose3 in the navigation frame and compute the antenna position via a fixed body-frame lever arm.I'd appreciate feedback on whether this structure fits cleanly into the existing factor hierarchy. @masoug @dellaert
Related: #2447, #2477