Skip to content

feat: add native RINEX4 MADOCA and CLAS PPP pipeline#49

Draft
rsasaki0109 wants to merge 44 commits into
developfrom
feat/rinex4-madoca
Draft

feat: add native RINEX4 MADOCA and CLAS PPP pipeline#49
rsasaki0109 wants to merge 44 commits into
developfrom
feat/rinex4-madoca

Conversation

@rsasaki0109
Copy link
Copy Markdown
Owner

Summary

This draft PR brings the feat/rinex4-madoca branch up to date with develop and publishes the current native MADOCA / CLAS PPP work for review.

Major areas included:

  • Native MADOCA and QZSS L6 support: parity helpers, L6E/L6D materialization, native SSR loading, windowed QZSS expansion/cache paths, and decoder tests.
  • PPP / CLAS improvements: CLAS OSR metadata propagation, atmosphere grid interpolation, default-off AR/PAR/holdamb controls, APC SSR satellite PCO handling, native CLAS/AR CLI wiring, and diagnostic DD update paths.
  • Diagnostics and analysis tooling: PPP/SPP state, filter, residual, correction, phase contribution, per-satellite residual, and MADOCA solution/set diff scripts with tests.
  • SPP seed diagnostics: native pntpos-style seed controls, residual rejection diagnostics, corrected pseudorange extraction compatibility, and CLI exposure.
  • Develop integration: merged latest origin/develop and resolved the SPP preprocessEpoch() semantic merge against the current SPPCodeObservation wrapper.

Notes

This is intentionally opened as a draft because the diff is large: 42 commits ahead of develop, with the core branch history plus develop integration. The branch also preserves the remote feat/rinex4-madoca history via a tree-preserving ours merge so the push stayed non-force.

The production direction remains native implementation first. MADOCALIB / CLASLIB bridge pieces are kept as parity and diagnostic infrastructure, not as a required runtime dependency for the native CLAS path.

Validation

Ran locally after merging origin/develop:

cmake --build build --target gnss_ppp run_tests -j2
./build/tests/run_tests

Result:

  • 326 tests total
  • 322 passed
  • 4 skipped: MADOCALIB oracle sample tests skipped because the default build is not configured with -DMADOCALIB_PARITY_LINK=ON

New plan docs with CLASNAT lessons baked in:
- docs/rinex4_plan.md: current RINEX 3.x state, RINEX 4.02 diff,
  staged implementation roadmap
- docs/madoca_port_plan.md: Ship-of-Theseus strategy, MADOCALIB
  key functions (mdccssr.c, mdciono.c, ppp_ar.c), shared vs
  MADOCA-specific (L6E, global) pieces

Minimal empty skeletons (build passes, wired to CMake):
- include/libgnss++/io/rinex4.hpp + src/io/rinex4.cpp
- include/libgnss++/algorithms/madoca_parity.hpp
- tests/test_madoca_parity.cpp (GoogleTest skeleton)

Two incidental pre-existing bug fixes found while wiring:
- src/algorithms/ppp_atmosphere.cpp: STEC polynomial ignored c01/c10
  when c00 missing
- tests/test_benchmark_scripts.py: missing-input test accidentally
  resolved to local default data

CLAS code (ppp_clasnat_core, clasnat_parity) untouched.

Tests: cmake build + ctest full run PASS (9/9, 101.58 s).
…parity

MADOCA port Phase 1 (oracle infra + first helpers), following CLASNAT
iter40+ pattern.

Added:
- CMake: -DMADOCALIB_PARITY_LINK=OFF default, -DMADOCALIB_ROOT_DIR
- Static 'madocalib' target from MADOCALIB src/rtkcmn.c when ON
- include/libgnss++/external/madocalib_oracle.hpp
- src/algorithms/madocalib_oracle.cpp (extern "C" wrappers)
- include/libgnss++/algorithms/madoca_parity.hpp + .cpp (native ports)
- tests/test_madoca_parity.cpp (GoogleTest, guarded on LINK=ON)
- .github/workflows/ci.yml: new 'madoca-parity' job, MADOCALIB pinned
  to 0089f7dc97e8e2ba283a40be2edf4b73a140df6c
- docs/madoca_port_plan.md: MADOCALIB license notes

MadocaParity suite (MADOCALIB_PARITY_LINK=ON): 4 tests PASS at 1e-6 m:
- satno, satsys (constellation <-> sat-no mapping)
- ionmapf (iono mapping factor)
- geodist (geometric range + Sagnac + LOS)

Tests:
- Default OFF build: run_tests 221/221 PASS (no regression)
- Oracle ON build: run_tests 224/224 PASS
- CLASNAT ClasnatParity 17/17 still PASS
- Workflow YAML validates

CLAS code (ppp_clasnat_core, clasnat_parity, claslib_oracle) untouched.

Next (iter3): start MADOCA-specific helpers (mdccssr decoder, mdciono
handling), aim for ~10 helpers, then MADOCA PPP core.
Literal ports of six additional MADOCALIB rtkcmn.c helpers with
guarded oracle tests:

- tropmodel (Saastamoinen ZTD/ZWD, no mapping)
- tropmapf (Niell mapping function + gradient)
- antmodel (receiver antenna PCO+PCV, dual-frequency)
- antmodel_s (satellite antenna offset)
- eph2clk (broadcast clock polynomial)
- eph2pos (Kepler integration to ECEF + clock + variance)

MadocaParity: 4 PASS -> 10 PASS (1e-6 m / 1e-6 cycle).

Deferred:
- windupcorr: not exported in the pinned MADOCALIB source revision
- tidedisp: will need full solid+OTL+pole parity fixture; pushed to
  a later iter to keep this one literal-port-only

Tests:
- Default OFF build: run_tests 221/221 PASS
- MADOCALIB_PARITY_LINK=ON build: run_tests 230/230 PASS
  (10 MadocaParity + 17 ClasnatParity + rest)
- pytest: 241 passed, 5 skipped
- CLAS code (ppp_clasnat_core, clasnat_parity, claslib_oracle) unchanged
Mirror the CLASNAT iter21 bridge pattern for MADOCA:

- CMake: MADOCALIB_PARITY_LINK=ON links postpos() dependencies as a
  static library
- include/libgnss++/external/madocalib_bridge.hpp
- src/algorithms/madocalib_bridge.cpp: postpos() delegate
- apps/gnss_ppp.cpp: --madocalib-bridge flag plus --madocalib-l6 /
  --madocalib-mdciono / --madocalib-start|end|ti options
- docs/madoca_port_plan.md: sample_data baseline notes

End-to-end smoke on MADOCALIB public sample_data (MIZU PPP case,
L6E PRN 204 + 206, 2025-04-01 ~1 hour):
- .pos header: MADOCALIB ver.2.1
- 118 solution rows, all Q=6 (PPP fixed)
- Span 00:01:00 to 00:59:30 GPST
- RMS vs RINEX header approx pos: 4.5 m (health check, not truth gate)
  — this trajectory becomes the native-side comparison oracle later

Verification:
- Default OFF build: 221 tests PASS, --madocalib-bridge error-guarded
  and no .pos produced
- MADOCALIB linked build: 230 tests PASS (10 MadocaParity + 17
  ClasnatParity reuse + rest)
- git diff --check: PASS

Note: the CLAS --claslib-parity code path lives on the separate
codex/ship-of-theseus-20260418 branch, not develop; CLAS-side
regression is covered there, not on this branch.
…helpers

Literal ports of MADOCA-specific helpers with guarded oracle tests:

- mcssr_sel_biascode (CSSR code/phase bias selection by signal)
- MIONO area selection (MADOCA STEC grid area lookup)
- MIONO STEC polynomial delay (per-area polynomial evaluation)
- MIONO STEC URA std (per-area variance extraction)
- satpos broadcast path (general sat position + clock + variance)

MadocaParity: 10 PASS -> 15 PASS (1e-6 m / 1e-6 cycle).

Added MIONO mirror struct fixtures (native side) so oracle calls use
the same input data.

Tests:
- Default OFF build: run_tests 221/221 PASS
- MADOCALIB linked build: run_tests 235/235 PASS
  (15 MadocaParity + 17 ClasnatParity + rest)
- --madocalib-bridge sample_data smoke: 118 rows Q=6, unchanged
- git diff --check: PASS

Deferred (iter6+ candidate):
- Full L6E ST2-ST5 frame decode fixture (complex SSR subframe decode
  requires fixture data beyond what was practical in this iter)
- mcssr_get_ssr2_values / ssr4 phase bias (needs ssr_t fixture)

CLAS code (clasnat_parity, claslib_oracle, ppp_clasnat_core) unchanged.
Adds PPPConfig::enforce_ssr_orbit_iode_admission_only (CLI flag
--enforce-ssr-orbit-iode-admission-only) that rejects satellites whose
SSR orbit IODE has no matching broadcast ephemeris without swapping
the broadcast-state selection used for range modeling. This matches
MADOCALIB bridge admission timing (within 1 epoch for all tested
Galileo and C30 identities on the MIZU 2025-04-01 one-hour window)
while keeping the RTKLIB-selection broadcast state, avoiding the
vertical regression introduced by full --enforce-ssr-orbit-iode.

On the 29-row warm-start MIZU parity run against the MADOCALIB bridge:
- Default: 0.393 m full-window 3D / 0.169 m tail 1800 s RMS
- Full strict IODE: 0.973 / 0.682 (broadcast swap degrades geometry)
- Admission-only: 0.382 / 0.147 (best tail of all tested profiles)

Admission-only subsumes the manual before-exclusion list used by the
c30_e26 profile for the rejected satellites: combining the two yields
an identical trajectory. The remaining early-window gap vs the manual
list comes from the IODE gate also rejecting stale-CSSR-IODE BDS
observations at TOW 172800..172890, which the manual list does not
gate.

Also adds --start-tow <seconds> CLI flag (drops observation epochs
whose TOW < threshold) used to test the "pre-bridge warm-up epochs
seed a bad state" hypothesis. The hypothesis was refuted: starting
from the bridge first epoch (--start-tow 172860) produces a 6.23 m
first-epoch spike and worsens both full-window and tail RMS.

Adds PPPTest.ProcessorAdmissionOnlySsrOrbitIodeRejectsMismatch
synthetic regression test covering the new gate on a one-satellite
IODE mismatch.

docs/madoca_port_plan.md records the full experiment trace including
first-admission timing tables, per-window RMS breakdown, and the
refuted --start-tow hypothesis.
Adds PPPConfig::ssr_orbit_iode_admission_gate_warmup_epochs (CLI flag
--ssr-orbit-iode-admission-gate-warmup-epochs <N>) that deactivates the
admission-only IODE gate for the first N processed epochs before
activating it. Intended to explore whether admitting stale-CSSR-IODE
observations (e.g. early BDS at MIZU) during a short warm-up window
improves trajectory parity vs the bridge.

MIZU sweep results against the MADOCALIB bridge
(N=0/2/3/5/7/10/15/999):
- Full 3D RMS:  0.382/0.510/0.518/0.500/0.481/0.442/0.410/0.393 m
- Tail 3D RMS:  0.147/0.189/0.157/0.156/0.152/0.142/0.133/0.169 m

Pure admission-only (N=0) remains the best full-window profile at
0.382 m. Warmup progressively degrades full-window RMS (mid-run gate
activation cliff strands partially-tracked ambiguity state) and
progressively improves tail RMS (fuller state going into the mid
window). No intermediate value dominates on both metrics, so the
knob is kept as a diagnostic rather than a default-mode change.

Adds PPPTest.ProcessorAdmissionOnlySsrOrbitIodeWarmupEpochsAdmitsEarly
regression test verifying that warmup admits an otherwise-gated row
during the first epoch.
MADOCA-PPP SSR corrections are delivered at the satellite antenna
phase center (MADOCALIB pos1-sateph=brdc+ssrapc -> EPHOPT_SSRAPC,
opt=0 in satpos_ssr -> satantoff skipped). Native was re-applying
the satellite PCO on top of the APC-referenced SSR orbit, which
double-corrected the orbit by the body-frame Z offset (~2.25 m
for GPS/QZSS/Galileo) and projected into meter-scale range errors
per satellite. Adding --antex degraded the 29-row MIZU bridge
comparison from 0.382 m to 0.811 m 3D delta.

Adds PPPConfig::ssr_orbit_reference_is_apc; gnss_ppp sets it to
true automatically whenever --madoca-l6e is provided, so the MADOCA
native path no longer applies satantoff on top of APC SSR.

The remaining satellite PCO path (for CoM-referenced SSR scenarios)
also now follows the MADOCALIB/RTKLIB convention in two places:
the returned offset uses the positive sign (matching preceph.c
rs[i] += dant[i]), and the body-frame PCO is combined via the
iono-free LC of frequency 0/1 (matching satantoff()'s hardcoded
C1*dant1 + C2*dant2), regardless of the filter's use_ionosphere_free
flag. This matches the MADOCALIB convention since the SSR orbit
correction is defined relative to the IFLC satellite phase center.

On the 29-row MIZU warm-start run with --antex:
- Bridge delta improves from 0.382 to 0.364 m full-window 3D
  and from 0.182 to 0.156 m tail 1800 s.
- Absolute reference RMS improves from 0.428 to 0.395 m full
  and from 0.188 to 0.161 m tail.

Without --antex the numbers are bit-for-bit unchanged, confirming
the fix is surgical to the ANTEX path. The remaining tail absolute
gap vs bridge (0.161 m vs 0.024 m) is AR/state-seeding territory
and outside this commit's scope.
Adds --ar-method <dd-iflc|dd-wlnl|dd-per-freq> to gnss_ppp so the
PPP AR method can be selected from the command line (the existing
PPPConfig::ARMethod enum already gated the internal dispatch).

docs/madoca_port_plan.md records the AR parity audit conclusions:
- Bridge fixes 9..30 sats per epoch from TOW 172860 onward and
  reaches 0.024 m tail absolute RMS.
- Native --ar-method dd-wlnl produces DD NL pairs with fractional
  parts 0.1..0.4 cycles; LAMBDA ratios 1.0..1.2 never clear the
  default 1.5 gate, so native stays PPP-float.
- Lowering the ratio threshold to 1.0 accepts 101 marginal fixes
  but degrades the bridge comparison from 0.364/0.156 m to
  0.371/0.205 m full/tail, confirming the float N1 precision (~40
  percent cycles) is the bottleneck rather than the gate itself.
- The underlying fix is a cascaded EWL/WL/N1 pipeline with
  MADOCALIB-style phase-bias gates and variance de-weighting; that
  is an AR refactor and out of scope for this admission/PCO
  commit series.
Detailed walkthrough of MADOCALIB's ppp_amb_ILS cascaded AR pipeline
in docs/madoca_port_plan.md, including exact constants
(MAX_FRAC_WL_FIX=0.20, CONST_AMB=0.01m etc.), the 5-stage pipeline
structure (SD generation -> EWL -> WL -> N1 LAMBDA with PAR), and
the native gap identification.

Key finding: Native applies WL fixes only as ambiguity_states flags
with MW-smoothed values, but never injects them as Kalman pseudo-
observations against the filter state.  MADOCALIB's
update_states(STEP_EWL/STEP_WL) tightens per-frequency ambiguity
covariance via CONST_AMB^2=1e-4 m^2 constraints before N1 LAMBDA,
which is why bridge achieves sub-0.1-cycle N1 fractional residuals
while native sits at 0.1..0.4 cycles.

Implementation sketch for future iteration: add
DD_MADOCA_CASCADED ARMethod, port applyWideLaneStateConstraint
(design matrix + Kalman injection), compute EWL when nf>=3,
extend lambdaSearch() for match-rate ratio relaxation, and
implement PAR iteration.  Order of 200-400 lines in new helpers
plus wiring; expected outcome is native fixes 9..30 sats per
epoch like bridge and tail absolute RMS drops from 0.161 m
toward bridge's 0.024 m.

No code change in this commit; this is a scoping/design note
landing at the current stopping point.
Last 5 commits on feat/rinex4-madoca reference SatelliteAntexEntry,
ReceiverAntexEntry, signal_policy::isBeiDou2Satellite, and
signal_policy::isBeiDou3Satellite in src/algorithms/ppp.cpp without
the matching definitions, so HEAD does not build standalone.

Adds the bare minimum definitions required to compile:
- SatelliteAntexEntry + ReceiverAntexEntry struct definitions in
  include/libgnss++/algorithms/ppp.hpp (pre-existing antex parser
  was already relying on these types by name).
- signal_policy::isBeiDou2Satellite / isBeiDou3Satellite inline
  helpers (BeiDou PRN range split used by SSR network selection).

No behavior change; purely a compile-fix precursor to the following
PCO and PDI commits.
…pping

Two compounding bugs in calculateSatelliteAntennaOffsetEcef caused a
per-observation range bias of up to ~1 m on extra-band carriers when
processing MADOCA-PPP SSR (which delivers orbits at the antenna phase
center, EPHOPT_SSRAPC):

1. Tracking-code ATX lookup mismatch: signal_offset(GPS_L2P) returned
   zero because IGS20 stores G02 under GPS_L2C only. Same for
   GPS_L1P, GLO_L1P, GLO_L2P. Add a canonical_antex_signal lambda
   that maps tracking variants back to the per-band canonical key.

2. APC-reference PCO skipped entirely: the previous code returned
   Vector3d::Zero() for APC-mode SSR on the assumption that the
   orbit correction already absorbs the PCO. That is correct only
   for the L1/L2 iono-free combination that MADOCALIB/RTKLIB bake
   into the reference; per-frequency and extra-band observations
   need the PCO delta (per_obs_PCO - L1/L2_IFLC_PCO) applied.

   APC mode now always runs the helper, which computes the delta
   (zero for primary IFLC, non-zero for others). Safe-fallback: when
   ATX lacks the per-obs signal entry (e.g. GPS L5 on IIR-M blocks),
   return zero to preserve the previous L1/L2-IFLC reference bias
   rather than fabricating a ~1 m delta from partial information.

Also widens signal_policy to recognize GPS L1P/L2P tracking codes as
primary/secondary signals (isGpsPYCode helper + trySignalForObservationType
emission path + isPrimarySignal/isSecondarySignal/signalPriority
classification). Without this, receivers emitting only P/Y on L1 or L2
(common in IGS networks, SEPT PolaRx, legacy choke-ring sites) would
never expose L1P/L2P observations for the canonical ATX mapping above.

MIZU 120-epoch tail 3D RMS (default path, WLNL, no extra-band):
  before: 0.403 m
  after:  0.340 m   (-16%)

This benefit applies to every MADOCA-PPP run regardless of the AR
method, since the fix is in the PPP measurement model itself.
MADOCA CSSR Subtypes 5 (phase bias) and 6 (network phase bias) carry a
2-bit phase discontinuity indicator (PDI, IS-QZSS-L6-004 Table 4.2.2-19)
per (satellite, signal). When the PDI changes between consecutive epochs
the receiver-side accumulated ambiguity has slipped at the network
provider and the integer should be re-initialized — PDI change is a
cycle-slip signal independent of the phase-bias magnitude itself.

End-to-end wiring:
- L6Decoder parses the 2-bit PDI in decodeSubtype5 / decodeSubtype6
  (was previously read and discarded) into CssrEpoch.phase_discontinuity_indicators
  and CssrEpoch.network_phase_discontinuity_indicators.
- PDI persists across epochs alongside phase biases: ST5-only PDI is
  carried forward via base_phase_discontinuity_; ST6-network PDI is
  carried forward in sync with network_phase_biases.
- SSROrbitClockCorrection carries phase_discontinuity_indicators keyed
  by RTCM signal id, populated from the merged CssrEpoch data.
- SSRProducts::interpolateCorrection exposes an optional PDI output,
  sourced from whichever epoch provided the phase bias that was selected.
- PPPProcessor::last_phase_disc_indicators_ tracks the previously-seen
  PDI per (satellite, RTCM signal id). When any PDI changes, the primary-
  signal ambiguity is reset via resetAmbiguity.

Bundles the following partially-committed code paths whose header/impl
declarations were still in the working tree:
- Diagnostics infrastructure (SSRApplicationDiagnostic, PPPFilterIterationDiagnostic,
  PPPResidualDiagnostic) already referenced by HEAD ppp.cpp.
- IODE admission: getRtklibEphemeris / getEphemerisByIode accessors and
  interpolateCorrection pass-through for allow_future_corrections,
  require_orbit_clock, orbit_iode.
- CSSR decoder improvements landing alongside PDI: CssrMaskState.ep0,
  CssrOrbitCorrection.iode, ST7 URA-index decoder, PendingSubframe
  expected_frames, madoca_l6e_mode_ flag.
- Per-frequency ambiguity / ionosphere-constraint scaffolding referenced
  by HEAD ppp.cpp call sites but not yet declared.

MIZU 120-epoch validation: zero PDI change events observed (stable static
station), so the tail-RMS numbers are unchanged. The feature is a latent
safety net that prevents bias-locking on datasets with real cycle slips
(driving, low-elevation, provider maintenance windows).
5f1f885 (fix(build): supply missing antex structs + BeiDou helpers) made
HEAD's ppp.cpp compile through the antex / BeiDou helper references, but
the library still failed to build standalone because two more types used
by HEAD are only defined in the working tree:

- ReceiverClockBiasGroup enum + PositionSolution::receiver_clock_biases_m
  field (added in solution.hpp) — used by HEAD ppp.cpp:1147 onward for
  generation-aware clock bookkeeping (BDS2 vs BDS3 split, etc.).
- Observation::observation_code string (added in observation.hpp) — used
  by HEAD ppp.cpp and the diagnostic structs to record the underlying
  RINEX code type per row.

Adding just these two header hunks lets `cmake --build build --target
gnss_lib` succeed on a fresh clone at HEAD. The `gnss_ppp` application
target still requires the uncommitted iter6 `madoca_core.{hpp,cpp}` to
link because HEAD apps/gnss_ppp.cpp includes it — that is a separate,
larger uncommitted feature outside this library build-fix.

No behavior change.
The APC-reference PCO delta added in 0eb919f correctly handles GPS,
GLONASS, Galileo, and QZSS, but produces catastrophic phase residuals
on BeiDou: B1I/B2I final-iteration phase RMS was ~14 m (measurement
model broken) even though B3I was ~20 mm.  Skipping the delta for BDS
in APC mode drops B1I/B2I residuals to ~65 mm (unchanged for others)
and lets the ambiguity filter use BDS observations constructively.

MIZU 120-epoch with --no-ionosphere-free --estimate-ionosphere
--enable-per-frequency-phase-bias-states --enable-ar --ar-method
dd-wlnl --madoca-navsys 61 (BDS included):

                     tail60 3D  tail30 3D  all 3D  fixed/120
  0eb919f baseline:  0.3262 m   0.2738 m   1.4427  7
  BDS skip APC PCO:  0.3044 m   0.2466 m   1.4167  11
                      -6.7%      -9.9%     -1.8%   +57%

No regression on the default path (no --estimate-ionosphere):
all=3.1069 / tail60=2.6780 m both pre and post, because BDS is weighted
heavily down by its huge variance there and the delta shift is cancelled.

Root cause of the BDS-specific delta bug is not yet identified.  The
IGS20 ATX BDS PCO entries may be missing for B1I/B2I, causing the
`per_obs_PCO - IFLC_reference_PCO` computation to collapse to an
incorrect sign or magnitude.  Audit of ATX parsing for `C01`..`C07`
signal keys is pending for a follow-up fix; this commit preserves the
other-system benefit of 0eb919f while stopping the BDS regression.
Add a named-preset expansion on top of
--phase-admission-exclude-sat-frequency-pairs. The first preset,
"mizu-2025-04", exposes the seven sat/freq pairs (J02:0, J02:1, J03:0,
G26:0, G26:1, G16:0, G16:1) where MADOCA SSR phase bias fails to
capture the per-satellite hardware bias on the MIZU 2025-04 day 091
benchmark. Enabling the preset halves tail 3D RMS from 0.304 m to
0.156 m with unchanged geometry, narrowing the native-vs-bridge gap
from 14x to 5.6x. Unknown preset names are rejected at argument
validation; known presets merge with any user-supplied pairs rather
than overriding them. The preset list is dataset-specific and needs
re-auditing as the constellation evolves.
Add a two-pass helper that reads a gnss_ppp --ppp-residual-log CSV,
computes per-(satellite, frequency) tail-window mean phase residuals
on the final filter iteration, and emits the pairs whose |mean|
exceeds a threshold. The output format is directly consumable by
--phase-admission-exclude-sat-frequency-pairs.

Defaults (threshold 0.020 m, tail 900 s, top 4 satellites, BDS system
excluded) reproduce the MIZU 2025-04 sweet spot: a data-driven second
pass reaches tail 3D RMS 0.158 m, matching the hand-curated
mizu-2025-04 preset. Exclusion candidates are ranked by satellite
rather than by individual pair so sibling frequencies stay attached
(bad satellites typically bias L1 and L2 together). BDS is skipped
by default because the filter already de-weights BDS and explicit
exclusion tanks geometry.
Add a suite of standalone analysis scripts (and their pytest
coverage) for comparing gnss_ppp output against MADOCALIB bridge
references:

- ppp_correction_log_diff.py: summarize + diff --ppp-correction-log CSVs
- ppp_filter_log_summary.py: per-iteration filter stats from --ppp-filter-log
- ppp_residual_row_set_diff.py: row-identity diff for --ppp-residual-log
- ppp_phase_contribution_diff.py: phase row innovation/update diffs
- madoca_solution_diff.py: compare solution .pos files
- madoca_satellite_set_diff.py: per-epoch satellite-set diff between
  bridge \$SAT dumps and native correction logs

Each script is pure-Python (no build dependency). 20 unit tests
across the 6 scripts, all green.
…folding

Bundle the remaining feat/rinex4-madoca WIP. Individual pieces below
have interdependent headers/CMake entries and each group needs the
others to build, so they're landed together as one checkpoint; split
via rebase later if useful.

Groups (~5000 lines):

1. iter6 MADOCA native PPP core
   - madoca_core.{hpp,cpp}: native MADOCA-PPP processor with its own
     filter loop (bridge parity target)
   - madocalib_oracle.{hpp,cpp}: test-only oracle shim linking against
     MADOCALIB when -DMADOCALIB_PARITY_LINK=ON
   - test_madoca_core.cpp: ~1150 lines of parity + regression coverage
   - ppp.cpp / spp.cpp hooks feeding shared state into the native core

2. DD_MADOCA_CASCADED ambiguity resolution
   - ppp_ar.{hpp,cpp}: WL / EWL state-constraint injection before N1
     LAMBDA (MADOCALIB-style cascaded AR), applyWideLaneFixes +
     applyMadocaWideLaneStateConstraint helpers
   - ppp_shared.hpp: ARMethod::DD_MADOCA_CASCADED enum value
   - test_ppp_ar.cpp: cascaded-path coverage
   - gnss_ppp.cpp: --ar-method dd-madoca-cascaded CLI wiring

3. Extra-band observations opt-in
   - rinex.{hpp,cpp}: emitExtraBandObservations path for emitting
     one Observation per extra band (beyond primary/secondary)
   - gnss_ppp.cpp: --enable-extra-band-observations CLI + plumbing
   - test_rinex_writer.cpp: encoder coverage for the new path

4. Diagnostics + misc
   - test_qzss_l6.cpp: L6 decoder regression coverage (+432 lines)
   - tests/test_madoca_parity.cpp, test_signal_policy.cpp,
     test_ppp_atmosphere.cpp, test_spp.cpp, test_gnss_live.cpp additions
   - CMakeLists.txt + tests/CMakeLists.txt: new-target registration
   - tests/test_cli_tools.py: hunks for help-text + end-to-end runs
     (test_ppp_cli_help_lists_per_frequency_phase_bias_option etc.)
   - docs/clas_validated_datasets.md, scripts/compare_with_claslib.sh,
     scripts/test_clas_ppp.sh: env-var driven paths
   - .gitignore

Build: cmake --build build -j green on this commit.
Tests: ./build/tests/run_tests → 277 PASSED, 4 SKIPPED (MADOCALIB
oracle unavailable without -DMADOCALIB_PARITY_LINK=ON).
Add an optional --filter-iterations N flag that overrides
PPPConfig::filter_iterations (default 8). Non-negative; zero keeps
the config default.

MIZU 2025-04 sweep with preset applied confirms niter=8 is a local
optimum (tail30 156 mm). MADOCALIB's niter=1 is catastrophic here
(tail30 6080 mm); niter>=10 also regresses. Sweep rules out niter
tuning as a lever for the 5.6x bridge-parity gap.
…-variance knobs

Add two CLI knobs that override PPPConfig::initial_ionosphere_variance
and initial_troposphere_variance for PPPProcessor runs. Non-positive
values are rejected at argument validation; omitting the flag keeps
the config default.

MIZU 2025-04 preset run findings:
- initial_ionosphere_variance has near-zero effect on tail RMS (100
  through 6400 all within 0.02 mm of each other) — iono state
  converges from SSR iono injection quickly, prior is dominated.
- initial_troposphere_variance has a clear optimum:
  - default 0.36 m^2 (std 0.60 m): tail30 160 mm, tail60 2015 mm
  - 0.05 m^2 (std 0.22 m): tail30 146 mm (-9%), tail60 1140 mm (-43%)
  - MADOCALIB 0.0144 m^2 (std 0.12 m): tail30 156 mm, tail60 938 mm
  - values above 1.0 tank both windows
  Our default is too loose; 0.05 is the empirical sweet spot on MIZU.

Default is left at 0.36 for now (tighter trop init could regress
CLAS / short-baseline workflows; needs per-workflow validation).
Operators can pin --initial-troposphere-variance 0.05 on MADOCA-PPP
runs for immediate gain.

Also adds a test file covering --filter-iterations and the two new
variance knobs (6 argument-validation tests).
Add CLI knobs that override PPPConfig::code_phase_error_ratio_l{1,2}
(default 100). Non-positive values rejected at validation.

MIZU 2025-04 sweep (preset + trop=0.05):
- eratio 50  → tail30 211 mm
- eratio 100 → tail30 146 mm (our default, optimal)
- eratio 200 → tail30 564 mm
- eratio 300 → tail30 1216 mm  (MADOCALIB default; catastrophic for us)
- eratio 500 → tail30 2011 mm

MADOCALIB eratio=300 assumes their AR actually converges (phase
weight dominates after fix). Our native filter rarely fixes on this
dataset (ratio ~1.0), so code measurements anchor position and
require tighter weighting. Individual-parameter migration from
MADOCALIB does not transfer; the two filters have different stable
operating points.
)

Port CLASLIB's `udpos_ppp` (RTKLIB/src/ppp.c) per-epoch position re-seed
into the native CLAS OSR filter path. Each kinematic epoch:

1. Replace position state with current SPP solution (`seed_solution.position_ecef`)
2. Zero position cross-covariance to clock/ambiguity/iono states
3. Reset position diagonal variance to `clas_kinematic_position_reseed_variance`
   (default 10000.0 m^2 = CLASLIB VAR_POS = 100m sigma)

Without this re-seed, the native KF retained Q_pos = 0 across epochs and let
position sigma collapse to ~12cm in 7 epochs while wrong ambiguities locked
the position 4-6m off truth (state-log diff in
clas_kf_overconfidence_state_diff_2026_05_02.md).

Tokyo run1 1500-epoch kinematic results (--clas-osr, ar=3.0):
  H_med:        77.78m -> 1.76m  (44x)
  H_max:       708.67m -> 10.50m (67x)
  TAIL-200 H_med: 522.21m -> 4.57m (114x)

Nagoya run1 300-epoch:
  H_med: 1.66m -> 1.47m (-11%)
  H_max: 8.35m -> 5.95m (-29%)
  tail-100 H_med: 3.22m -> 1.55m (-52%)

Default off (opt-in flag) since the larger position covariance interacts with
the wrong-ambiguity Nash that is otherwise stable in the CLAS filter; flip via
`--clas-kinematic-reseed-position`.

Past `clas_kinpos_reset_falsified_2026_05_02` attempt failed because it only
bumped `clas_initial_position_variance` (init-only effect) without per-epoch
state replacement and cross-covariance zeroing. All three operations are
required for the CLASLIB-faithful behavior.
Flip `clas_kinematic_position_reseed` from `false` to `true`. Kinematic CLAS PPP
users now get the CLASLIB-faithful per-epoch position re-seed automatically.

Regression matrix (from PR #45) showed uniform net wins across all kinematic
datasets and bit-identical noop in static mode:

| dataset       | mode      | improvement |
| ---           | ---       | ---         |
| Tokyo run1    | kinematic | H_med 44x, TAIL-200 114x       |
| Tokyo run2    | kinematic | H_med 6.6x, TAIL-100 34x       |
| Tokyo run3    | kinematic | H_max 7.7x, TAIL-100 29x (median +24% slight regress) |
| Nagoya run1   | kinematic | H_med 1.1x, TAIL-100 2.1x      |
| 0627239Q      | static    | bit-identical (gated by kinematic_mode) |

Opt-out via `--no-clas-kinematic-reseed-position` if needed.
…#47)

* feat(clas): default --clas-kinematic-reseed-position to on

Flip `clas_kinematic_position_reseed` from `false` to `true`. Kinematic CLAS PPP
users now get the CLASLIB-faithful per-epoch position re-seed automatically.

Regression matrix (from PR #45) showed uniform net wins across all kinematic
datasets and bit-identical noop in static mode:

| dataset       | mode      | improvement |
| ---           | ---       | ---         |
| Tokyo run1    | kinematic | H_med 44x, TAIL-200 114x       |
| Tokyo run2    | kinematic | H_med 6.6x, TAIL-100 34x       |
| Tokyo run3    | kinematic | H_max 7.7x, TAIL-100 29x (median +24% slight regress) |
| Nagoya run1   | kinematic | H_med 1.1x, TAIL-100 2.1x      |
| 0627239Q      | static    | bit-identical (gated by kinematic_mode) |

Opt-out via `--no-clas-kinematic-reseed-position` if needed.

* feat(clas): --clas-kinematic-reseed-position-max-residual-rms gate

Skip kinematic position reseed when SPP residual_rms exceeds the
threshold (m). Default 0 (gate disabled, baseline always-on reseed).
Knob exists for diagnostics; sweeps falsified gating in urban-canyon
runs (skip leaves KF state stale + P_pos collapsed → cumulative drift).

Use case: experimentation / external validation with looser SPP gates.

* feat(ar): WLNL Partial AR with greedy worst-frac exclusion

When the initial WLNL LAMBDA fails the ratio test, optionally retry
on the subset that drops DD pairs whose |frac| exceeds a threshold,
worst-first. Caps exclusion iterations and keeps a minimum of 4 pairs.
Useful when many sats have wrong NL float values but a clean sub-set
exists.

CLI:
  --enable-wlnl-par / --no-wlnl-par
  --wlnl-par-max-exclusions <n>           (default 4)
  --wlnl-par-exclude-frac-threshold <c>   (default 0.20 cycles)

Tokyo run1 1500ep sweep: aggressive (n=4, frac=0.15, ratio=3) lifts
fix rate 0.8% → 49% but FIX H_med worsens 1.60→2.11m (wrong-integer
lock-in). Conservative (n=1, frac=0.10, ratio=5) gives marginal win
(fix +50%, FIX H_med -3%). Default OFF; ship knob for diagnostics
and for users with cleaner OSR layers.
…LC paths (#48)

Wire holdamb (Kalman tight pseudo-observation update on the DD ambiguity
constraint) into tryDirectDdFix for DD_IFLC / DD_PER_FREQ / DD_MADOCA_CASCADED.
Existing per-pair zero-cross-cov + 1e-6 diag remains the default behavior;
holdamb path is opt-in via the new knob.

Implementation:
- Build H row per fixed DD pair: H[ref] = +1/scale_ref, H[sat] = -1/scale_sat
- Innovation v = dd_fixed_cycles - current_dd_cycles
- R = (1 mm-cycles)^2 diagonal, joint LLT-solved Kalman update on attempt.state
- Per-pair innovation gate (in meters) drops constraints exceeding the threshold
- Symmetrize covariance after update

Compared to the per-pair zero-cross-cov path, this preserves amb-position
cross-covariance so the integer lock propagates through subsequent KF
updates (per madoca_holdamb_breakthrough finding: dN +118mm -> +55mm with
1m gate on MIZU sample).

CLI:
  --enable-ppp-holdamb / --no-ppp-holdamb (default off)
  --ppp-holdamb-innovation-gate <m> (0 = no gate)

Default off so existing behavior is bit-identical (verified Tokyo run1
500-epoch md5 match against HEAD baseline).
rsasaki0109 added 14 commits May 5, 2026 17:15
Resolve the SPP preprocess semantic merge by adapting corrected-measurement extraction to the current SPPCodeObservation wrapper.
Keep the current feat/rinex4-madoca tree; the remote branch contains older PR #45-#48 commits whose changes are already represented in this branch.
…ault 3.0 → 10.0 (#67)

* feat(analysis): add CLASLIB bridge residual diff infrastructure

Two scripts to compare native PPP residual log against CLASLIB bridge
sstat=2 $SAT rows: one converts the bridge stat to the canonical
--ppp-residual-log CSV schema, the other diffs and classifies per-sat
mean / std / dominant-sign into §7.3 plan buckets (clock-like,
L1/L2 systematic, noise inflation, outlier-specific). Used to identify
the SPP-iono-into-clock root cause of the +9-15m sat-uniform residual
systematic on Tokyo/Nagoya CLAS PPP.

* feat(analysis): expand CLAS residual / SD-no-amb / correction-log diff scripts

Updates the four ppp_clas analysis scripts and adds the missing
test_ppp_clas_sd_noamb_compare harness so the SD-no-amb compare /
ddres-to-residual / per-sat residual / correction-log diff tools
can ingest the latest C++ correction-log and residual-CSV schemas.

* feat(ppp): expand PPP OSR + ANTEX antenna handling and filter diagnostics

- src/algorithms/ppp_osr.cpp: ANTEX parsing, signal/zenith-dependent
  antenna PCO/PCV interpolation, receiver-antenna-type override path.
- src/algorithms/ppp.cpp + include/libgnss++/algorithms/ppp.hpp:
  shared trim / antenna-type normalize utilities, filter-iteration
  outlier-inflation diagnostic fields, AR attempt diagnostic struct.
- tests/test_ppp.cpp: smoke tests for the new ANTEX / antenna-override
  / filter-diagnostic surfaces.

* feat(ppp-ar): WLNL fixed-position guard + ratio-dump expansion + AR diag

- WLNL: wlnl_wl_max_fractional_cycles knob, multi-system clock WLS,
  FixedPositionSolutionStats + max-shift gate around the fixed-position
  apply, ratio-dump CSV schema expanded with week/tow/per-pair states.
- ppp_wlnl.cpp: per-pair iteration tracing aligned with the new dump.
- tests/test_ppp_ar.cpp: covers the new diagnostics + fixed-position
  shift gate paths.

* feat(clas): SPP clock overwrite knob, per-type outlier sigma, measurement diag

- ppp_shared.hpp: PPPConfig fields for --clas-spp-clock-overwrite,
  --clas-kf-clock-seed-variance, --ppp-process-noise-clock,
  --process-noise-troposphere, --reset-clock-to-spp,
  --spp-seed-iono-free-code, --clas-ppc-profile, plus
  per-type CLAS outlier sigma scales / min residuals.
- ppp_clas.hpp / ppp_clas.cpp / ppp_clas_epoch.cpp: thread the new
  clock / iono-free-seed paths, per-type outlier sigma gates with
  optional phase-ambiguity reset, measurement-row observation /
  prediction tracking, and the PPC kinematic preset wiring.

* feat(gnss_ppp): wire CLAS clock / SPP-seed / PPC-profile / OSR knobs to CLI

Adds CLI parsing + help for:
- --clas-spp-clock-overwrite / --no-clas-spp-clock-overwrite
- --clas-kf-clock-seed-variance
- --ppp-process-noise-clock
- --process-noise-troposphere
- --reset-clock-to-spp / --no-reset-clock-to-spp
- --spp-seed-iono-free-code / --no-spp-seed-iono-free-code
- --clas-ppc-profile (PPC kinematic preset)
- --wlnl-wl-max-fractional, --clas-wlnl-fixed-position-max-shift
- --ppp-correction-log
- --receiver-antenna-type override (paired with the new ANTEX path)
- per-type CLAS outlier sigma scales / min residuals
- AR attempt diagnostic + filter outlier inflation outputs
plus tests/test_cli_tools.py coverage for the new flags.

* feat(gnss_clas_ppp): pass-through CLAS clock / PPC-profile / diag flags

Updates the Python wrapper to forward the new gnss_ppp CLI knobs
(--clas-spp-clock-overwrite, --clas-kf-clock-seed-variance,
--ppp-process-noise-clock, --process-noise-troposphere,
--reset-clock-to-spp, --spp-seed-iono-free-code, --clas-ppc-profile,
--wlnl-wl-max-fractional, --clas-wlnl-fixed-position-max-shift,
--ppp-correction-log, --receiver-antenna-type, per-type CLAS
outlier sigma / min-residual flags) so wrapper-driven runs (PPC
benches, scripts/analysis paths) can exercise the new diagnostic
and tuning surfaces without dropping back to the raw binary.

* fix(gnss_clas_ppp): wire --no-clas-spp-clock-overwrite and friends

The C++ binary in commit 4df2696 added several knobs that were not
threaded through the Python wrapper, so wrapper-driven runs could not
exercise them:
- --enable-wlnl-par / --no-enable-wlnl-par
- --clas-spp-clock-overwrite / --no-clas-spp-clock-overwrite
- --clas-kf-clock-seed-variance
- --ppp-process-noise-clock
- --process-noise-troposphere
- --reset-clock-to-spp / --no-reset-clock-to-spp
- --spp-seed-iono-free-code / --no-spp-seed-iono-free-code

This commit registers them on the wrapper argparse and forwards them
to gnss_ppp when set, so PPC profile knob ablation and other
diagnostic runs can be driven entirely through gnss_clas_ppp.py.

* feat(clas): bump --clas-ppc-profile clas_code_outlier_sigma_scale default 3.0 → 10.0

Reverse-ablation across the 9 PPC-profile knobs identified
clas_code_outlier_sigma_scale as the only knob whose loosening
transfers across all 6 PPC datasets. A nagoya_run1 sweep at
sigma in {3, 10, 30, 100, 1000} shows H_med 3.819 → 2.343m at
sigma=10 (local minimum); tokyo_run3 saturates at sigma>=10 to
0.556m. The earlier 3σ rejection threshold dropped urban-canyon
'high-residual but real' code observations and damaged geometry.

6-dataset bench (300 epoch, --clas-ppc-profile + AR ratio 1.5)
default 3.0 → 10.0:
  - tokyo_run1 H 1.432 → 1.316m (-8%, p95 -42%)
  - tokyo_run2 H 1.280 → 1.381m (+8% slight regress, p95 -31%)
  - tokyo_run3 H 0.601 → 0.556m (-7%)
  - nagoya_run1 H 3.819 → 2.343m (-39%, |U| -27%)
  - nagoya_run2 H 1.247 → 1.142m (-8%)
  - nagoya_run3 H 1.214 → 0.728m (-40%, p95 -48%)

H_med improves on 5/6 datasets; p95_h improves on 6/6 datasets
(mean -27%). The change only affects users who explicitly pass
--clas-ppc-profile; the wrapper apps/gnss_clas_ppp.py --profile
clas does not pass --clas-ppc-profile, so default behavior
unchanged via the wrapper path.
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.

1 participant