Skip to content

navigation: add undifferenced (PPP) pseudorange and carrier-phase factors#2575

Open
inuex35 wants to merge 13 commits into
borglab:developfrom
inuex35:feature/ppp-undifferenced-factors
Open

navigation: add undifferenced (PPP) pseudorange and carrier-phase factors#2575
inuex35 wants to merge 13 commits into
borglab:developfrom
inuex35:feature/ppp-undifferenced-factors

Conversation

@inuex35

@inuex35 inuex35 commented Jun 20, 2026

Copy link
Copy Markdown
Contributor

Adds undifferenced (PPP) GNSS factors. In PPP the receiver clock, tropospheric zenith wet delay and slant ionosphere do not cancel by differencing, so they are carried as state variables:

  • UndifferencedPseudorangeFactor (+Arm) — keys [pos/pose, clock, ZTD, slant-iono]; error = geodist(sat, rcv) + c*(dt_u - dt_s) + m_w*ZTD + mu_f*I - measured.
  • UndifferencedCarrierPhaseFactor (+Arm) — additionally carries the float ambiguity (+ lam*N); the ionosphere enters with the opposite sign (carrier advance).

m_w (tropospheric wet mapping) and mu_f (first-order iono coefficient) are constants per factor; ZTD and slant iono are estimated nodes. The Arm variants take a body-frame lever arm and optional ecef_T_nav, matching the existing factors. Analytical Jacobians, Python bindings, and unit tests with numerical-derivative checks (EXPECT_CORRECT_FACTOR_JACOBIANS) are included.

Geometry is routed through gnss::geodist (Sagnac-corrected), consistent with the double-difference factors. Note: on develop the existing PseudorangeFactor/CarrierPhaseFactor still use a plain Euclidean range; #2574 routes them through gnss::geodist, after which all factors compute geometry consistently.

inuex35 and others added 4 commits June 20, 2026 20:01
First piece of the uncombined PPP factor family (Step 2). Models a single raw
pseudorange (SSR satellite corrections pre-applied) with the receiver clock,
zenith wet tropo and slant ionosphere carried as state variables:

  error = geodist(sat, rcv) + c*(dt_u - dt_s) + m_w*ZTD + mu_f*I_slant - meas

Geometry reuses the Sagnac-corrected gnss::geodist primitive. The wet mapping
function m_w and iono coefficient mu_f are held constant per factor. Keys:
[pos, clock, ztd, slant-iono]. Adds a test validating the residual and all
four analytic Jacobians against numericalDerivative.

Follow-ups: CarrierPhase counterpart (adds ambiguity), Arm (Pose3) variants,
navigation.i bindings, and a cssrlib pppssr residual cross-check.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the Arm (Pose3 + lever arm) and carrier-phase members of the uncombined
PPP factor family, plus Python bindings for all four:

- UncombinedPseudorangeFactorArm        [pose, clock, ztd, iono]
- UncombinedCarrierPhaseFactor          [pos,  clock, ztd, iono, N]
- UncombinedCarrierPhaseFactorArm       [pose, clock, ztd, iono, N]

Carrier phase advances with the ionosphere (-mu_f*I) and carries the integer
ambiguity in cycles with a lambda coefficient (+lambda*N), preserving the
integer structure for ambiguity resolution. Arm variants reuse gnss::LeverArm
(optional ecef_T_nav). All geometry routes through Sagnac-corrected
gnss::geodist.

Tests: residual + numerical-Jacobian checks for every factor and an
ecef_T_nav consistency check for the CP Arm variant. navigation.i exposes the
four factors to Python.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Align naming with the differencing-scheme family already in navigation:
  Undifferenced (PPP)  <->  Differential (SD)  <->  DoubleDifference (DD/RTK)

"Undifferenced" is the textbook counterpart to DoubleDifference and avoids the
"uncombined" term, which implies an ionosphere-free counterpart that does not
exist. Pure rename: UncombinedPseudorangeFactor(Arm) ->
UndifferencedPseudorangeFactor(Arm) and the CarrierPhase equivalents, across
headers, impl, navigation.i bindings and tests. Behaviour unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The undifferenced PPP factors serialized their base object via
BOOST_SERIALIZATION_BASE_OBJECT_NVP(<Class>::Base), whose NVP tag contains "::"
and is rejected by the XML archive on a round-trip. Use
BOOST_SERIALIZATION_BASE_OBJECT_NVP(Base) (the in-class typedef, the dominant
convention) so the tag is XML-valid.

Add obj/XML/binary round-trip tests to testSerializationNavigation for
UndifferencedPseudorangeFactor / UndifferencedCarrierPhaseFactor and their
lever-arm variants.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

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.

Pull request overview

This PR adds new undifferenced (PPP-style) GNSS measurement factors to the gtsam/navigation module, enabling PPP estimation by explicitly including receiver clock bias, tropospheric zenith wet delay, and slant ionosphere (and carrier-phase ambiguity) as state variables. The new factors compute geometry using gnss::geodist (Sagnac-corrected) and include analytic Jacobians, SWIG/Python exposure, and unit tests with numerical Jacobian checks.

Changes:

  • Add UndifferencedPseudorangeFactor (+ Arm) with state keys [pos/pose, clock, ZTD_wet, slant-iono] and Sagnac-corrected geometry via gnss::geodist.
  • Add UndifferencedCarrierPhaseFactor (+ Arm) with state keys [pos/pose, clock, ZTD_wet, slant-iono, ambiguity], including the + lambda * N term and opposite-sign ionosphere term vs code.
  • Add unit tests (model + Jacobians) and SWIG interface entries to expose the new factors to wrappers.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated no comments.

Show a summary per file
File Description
gtsam/navigation/PseudorangeFactor.h Declares new undifferenced PPP pseudorange factors (point and lever-arm variants) with doxygen docs and serialization.
gtsam/navigation/PseudorangeFactor.cpp Implements residuals/Jacobians for the new pseudorange PPP factors using gnss::geodist and lever-arm chaining.
gtsam/navigation/CarrierPhaseFactor.h Declares new undifferenced PPP carrier-phase factors (point and lever-arm variants), including wavelength-scaled ambiguity handling.
gtsam/navigation/CarrierPhaseFactor.cpp Implements residuals/Jacobians for the new carrier-phase PPP factors (including iono sign and ambiguity scaling) using gnss::geodist.
gtsam/navigation/navigation.i Exposes the new factor classes/constructors and evaluateError methods to the wrapper interface.
gtsam/navigation/tests/testPseudorangeFactor.cpp Adds PPP pseudorange unit tests validating the measurement model and Jacobians (including Arm variant).
gtsam/navigation/tests/testCarrierPhaseFactor.cpp Adds PPP carrier-phase unit tests validating the measurement model, Jacobians (including Arm), and ecef_T_nav consistency.

@dellaert

Copy link
Copy Markdown
Member

Would it be possible to add a notebook example before I review?

@inuex35

inuex35 commented Jun 24, 2026

Copy link
Copy Markdown
Contributor Author

Sure — I can add a short open-sky notebook example. A clean open-sky case avoids the multipath/cycle-slip complications, so it should make the factors easy to follow. I'll push it shortly.

inuex35 and others added 8 commits June 27, 2026 20:04
Demonstrates the new undifferenced factors (UndifferencedPseudorangeFactor,
UndifferencedCarrierPhaseFactor) on a real open-sky QZSS CLAS record: a
single receiver (no base station) reaches cm-level accuracy. The cssrlib
GNSS front-end decodes CLAS and produces SSR-corrected undifferenced
residuals; GTSAM estimates the static position together with per-epoch
clock, ZTD (random walk) and slant-iono states plus float ambiguities via
incremental ISAM2, and the integers are resolved with LAMBDA.

Follows the existing GNSS example pattern (SinglePointPositioning,
DifferentialPseudorange): Colab badge, license cell, pip-install of the
front-end, and runtime data download. Executed result: float 2D=0.21 m ->
fixed 2D=0.022 m (22 SD ambiguities).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…fferenced-factors

# Conflicts:
#	gtsam/navigation/tests/testPseudorangeFactor.cpp
#	gtsam/navigation/tests/testSerializationNavigation.cpp
Replace PrecisePointPositioningExample.ipynb with RtkAndPppExample.ipynb,
which now covers both flavours of carrier-phase integer ambiguity
resolution in one story:

- Part 1 - RTK (double difference): base + rover, DoubleDifference
  {Pseudorange,CarrierPhase}Factor, on the dataset bundled with cssrlib
  (no download). float 2D=0.155 m -> fixed 2D=0.007 m (28 SD ambiguities).
- Part 2 - PPP-RTK (undifferenced): single receiver + QZSS CLAS,
  Undifferenced{Pseudorange,CarrierPhase}Factor. float 2D=0.208 m ->
  fixed 2D=0.022 m (22 SD ambiguities).

Both build the float graph with incremental ISAM2 and resolve integers
with LAMBDA. cssrlib provides the GNSS front-end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make RtkAndPppExample.ipynb readable by hiding the cssrlib front-end
(RINEX/CLAS decoding, satellite states, observation extraction) and the
LAMBDA covariance bridge in a helper module, mirroring how gnss_utils.py
backs the pyrtklib examples. The notebook cells now show only the GTSAM
factor graph: load -> add Double-Difference / Undifferenced factors ->
ISAM2 update -> resolve_ar -> result.

cssrlib_gnss.py exposes load_rtk / load_ppp (per-epoch measurement
bundles), dd_observations / undiff_observations (ready-to-splat factor
arguments) and resolve_ar (LAMBDA on the ISAM2 float). Results unchanged:
RTK fixed 2D=0.007 m (28 amb), PPP-RTK fixed 2D=0.022 m (22 amb).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… notebook

Rename cssrlib_gnss.py -> gnss_frontend.py and remove the proper noun from
the notebook narrative, module docstring and section headings, keeping the
front-end library only where unavoidable (the pip-install URL and internal
imports). Env var CSSRLIB_DATA -> GNSS_DATA. Results unchanged
(RTK fixed 2D=0.007 m, PPP-RTK fixed 2D=0.022 m).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Update the pip-install URL (notebook setup cell and gnss_frontend.py
docstring) from the auto-named claude/* branch to the renamed
gtsam-gnss-frontend branch of the front-end fork.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dellaert

Copy link
Copy Markdown
Member

The notebook helps a lot in showing the RTK vs PPP-RTK story.

One documentation clarification: I initially wondered whether the ambiguity state N is optimized as an integer inside GTSAM. From the implementation and notebook, it looks like GTSAM estimates N as a continuous float ambiguity in cycles, and integer fixing is then performed separately via LAMBDA using resolve_ar(...).

Could we make that explicit in the notebook and the carrier-factor docs? For example:

The ambiguity state N is represented as a continuous double in the GTSAM factor graph, in cycles. This gives the float ambiguity estimate. Integer ambiguity fixing is performed separately, e.g. with LAMBDA, using the float estimate and covariance.

That would make the modeling boundary very clear for readers who are newer to carrier-phase GNSS.

I'd also shy away from acronyms like resolve_ar. Prefer verbose and clear.

…e_ar

Address PR review: make explicit that the carrier-phase ambiguity N is
estimated as a continuous float (double, in cycles) inside the GTSAM
factor graph and that integer fixing is a separate LAMBDA step.

- CarrierPhaseFactor.h: add @note to CarrierPhaseFactor and
  UndifferencedCarrierPhaseFactor docstrings stating N is a float and
  integer resolution happens outside the graph.
- RtkAndPppExample.ipynb: spell out the float/integer boundary in the
  RTK and PPP "resolve the integers" cells.
- gnss_frontend.py / notebook: rename resolve_ar ->
  resolve_integer_ambiguities (avoid the AR acronym; verbose and clear).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@inuex35

inuex35 commented Jul 1, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — both done:

  • Documented N as a float ambiguity (cycles), with integer fixing as a separate LAMBDA step, in the notebook and carrier-factor docstrings.
  • Renamed resolve_arresolve_integer_ambiguities.

@dellaert dellaert left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thanks ! LGTM!

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.

3 participants