Skip to content

feat: double difference factor for rtk gnss#2502

Merged
dellaert merged 12 commits intoborglab:developfrom
inuex35:dd-rtk-factor
May 1, 2026
Merged

feat: double difference factor for rtk gnss#2502
dellaert merged 12 commits intoborglab:developfrom
inuex35:dd-rtk-factor

Conversation

@inuex35
Copy link
Copy Markdown
Contributor

@inuex35 inuex35 commented Apr 13, 2026

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 arm
  • DDCarrierPhaseFactor – DD carrier-phase, takes a Point3 (ECEF antenna position), with ambiguity variable N
  • DDCarrierPhaseFactorArm – same, but takes a Pose3 in the navigation frame + body-frame lever arm, with ambiguity variable N

Factors without the Arm suffix take an ECEF antenna position directly. The Arm variants 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

inuex35 and others added 5 commits April 13, 2026 23:36
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
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

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 optional ecef_T_nav) and corresponding unit tests.
  • Add CarrierPhaseFactor / CarrierPhaseFactorArm plus DDCarrierPhaseFactor / DDCarrierPhaseFactorArm and unit tests.
  • Introduce GnssCommon.h for shared GNSS constants and geodist() (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).

Comment thread gtsam/navigation/navigation.i Outdated
Comment thread gtsam/navigation/CarrierPhaseFactor.h Outdated
Comment thread gtsam/navigation/CarrierPhaseFactor.h Outdated
Comment thread gtsam/navigation/GnssCommon.h Outdated
Comment thread gtsam/navigation/PseudorangeFactor.h Outdated
Comment thread gtsam/navigation/PseudorangeFactor.h Outdated
Comment thread gtsam/navigation/PseudorangeFactor.h Outdated
Comment thread gtsam/navigation/CarrierPhaseFactor.h Outdated
Comment thread gtsam/navigation/CarrierPhaseFactor.h Outdated
Copy link
Copy Markdown
Contributor

@varunagrawal varunagrawal left a comment

Choose a reason for hiding this comment

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

Some initial comments.

Comment thread gtsam/navigation/CarrierPhaseFactor.h
Comment thread gtsam/navigation/CarrierPhaseFactor.h Outdated
Comment thread gtsam/navigation/CarrierPhaseFactor.h Outdated
Comment thread gtsam/navigation/CarrierPhaseFactor.h Outdated
Comment thread gtsam/navigation/CarrierPhaseFactor.h Outdated
Comment thread gtsam/navigation/CarrierPhaseFactor.h Outdated
Comment thread gtsam/navigation/CarrierPhaseFactor.h Outdated
Comment thread gtsam/navigation/GnssCommon.h Outdated

public:
// Provide access to the Matrix& version of evaluateError:
using Base::evaluateError;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Do you need this? You've implemented evaluateError.

Comment thread gtsam/navigation/PseudorangeFactor.h Outdated
@dellaert
Copy link
Copy Markdown
Member

Thanks for initial review, @varunagrawal . @masoug - would also appreciate your take on the implemented functionality, and how it relates.

@inuex35 please run make check locally and report on result in the PR comment. Make sure to compile with all warnings treated as errors. After you do this and address other comments above I'll run CI.

claude and others added 3 commits April 15, 2026 23:58
- 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
Copy link
Copy Markdown
Contributor

@masoug masoug left a comment

Choose a reason for hiding this comment

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

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.

Comment thread gtsam/navigation/CarrierPhaseFactor.h Outdated
Comment thread gtsam/navigation/CarrierPhaseFactor.h Outdated
Comment thread gtsam/navigation/CarrierPhaseFactor.h
Comment thread gtsam/navigation/GnssCommon.h Outdated
Comment thread gtsam/navigation/GnssCommon.h Outdated
@inuex35
Copy link
Copy Markdown
Contributor Author

inuex35 commented Apr 16, 2026

@masoug you can try this. make sure there is no outlier or multipath detection.
you will achieve integer fix when it's open sky.
https://github.com/inuex35/gnss-gtsam-rtk

claude and others added 2 commits April 16, 2026 23:56
- 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.
@dellaert
Copy link
Copy Markdown
Member

dellaert commented Apr 21, 2026

@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 :-)

@dellaert
Copy link
Copy Markdown
Member

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.
@inuex35
Copy link
Copy Markdown
Contributor Author

inuex35 commented Apr 23, 2026

@dellaert Thank you, maybe I finished the implementation. If you do not have any problem with this, please review again

@dellaert
Copy link
Copy Markdown
Member

This is looking great, thanks! My remaining concern is mostly elegance/maintainability rather than the high-level functionality, in particular:

  • the DD pseudo-range and carrier-phase factors carry almost identical data and compute almost identical range/Jacobian terms, with carrier phase only adding the ambiguity term. Could we factor the shared DD observation/satellite/base-position data and the DD range/Jacobian computation into a small internal helper?
  • same goes for the arm variants: they repeat the same ecef_T_nav composition, antenna-position computation, and lever-arm Jacobian logic. Could this be pulled into a small helper, e.g. something that maps a Pose3 + lever arm + optional ecef_T_nav to antenna position and Jacobian?
  • finally, some similar comments to be made about the tests. Maybe some named namespaces with quantities used in several tests would eliminate copy/paste.

All these things would make the new API feel less copy/pasted and easier to maintain.

@inuex35
Copy link
Copy Markdown
Contributor Author

inuex35 commented Apr 29, 2026

Thanks @dellaert, all three addressed:

  1. Added gnss::DoubleDifferenceData in GnssCommon.h/.cpp — holds the rover/base observations + 4 satellite positions + base position, and exposes observed() and model(rcv, H_rcv) (Sagnac-corrected, with Jacobian). Both DD factors store one of these and just delegate; carrier-phase only adds the λ·(N_ref − N_target) term and the two ambiguity Jacobians.
  2. Added gnss::LeverArm (lever arm + optional ecef_T_nav) with antennaPosition(pose, frame) and antennaPoseJacobian(H_antenna, frame). All four *Arm factors now share this for the Pose3 → antenna mapping and the chain rule through compose + lever arm.
  3. Added gtsam/navigation/tests/gnssTestHelpers.h with the shared satellite/base/observation constants, lever arm, ecef_T_nav (Tokyo ENU origin), and λ_L1. Both test files use it, so each TEST body only specifies what's unique to that case.

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
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.

Awesome, LGTM!

@dellaert dellaert merged commit e0b21bd into borglab:develop May 1, 2026
32 checks passed
@inuex35 inuex35 deleted the dd-rtk-factor branch May 4, 2026 01:55
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