GPU GNSS positioning for urban canyons: particle filters, ray-traced NLOS and factor graphs on real city data.
Left: GPU particle filter on a real UrbanNav Odaiba drive. Right: ray-traced LOS/NLOS over PLATEAU 3D buildings.
v0.4 release notes · v0.3 release audit · Live results snapshot · Technical report · Benchmarks · Examples · Input shapes · GSDC2023 solution · Experiment log · Decisions · How it's built
A research workspace for GNSS positioning in dense cities, where buildings block and reflect signals. CUDA/C++ kernels and Python tools run GPU particle filters, double-difference carrier tracking, ray tracing against 3D city meshes and factor-graph optimization, scored against RTKLIB and EKF baselines on public data (UrbanNav, PLATEAU, PPC, GSDC2023).
Rebuild the v0.4 reproducibility archive (including the historical v0.3 audit evidence):
python tools/build_release_bundle.py --output dist/reproducibility --archive dist/gnss_gpu-v0.4.0-reproducibility.zip- 🛰️ Fills RTK gaps. On UrbanNav Odaiba, RTK has the better median (libgnss++ 0.07 m, RTKLIB demo5 0.34 m) but misses 3–22% of epochs. Filling libgnss++ gaps with the GPU PF (1.50 m median, 98% coverage) raises epochs within 5 m from 74.5% to 87.8% (RTKLIB 86.6%; Shinjuku 76.4% → 84.2%, RTKLIB 73.5%).
- ⚡ Fast. A 1,000,000-particle predict → weight → resample step takes 32 ms on a 6 GB Turing GPU; a 10,000-epoch batch WLS solve ~3 ms (benchmarks).
- 🏙️ City-aware NLOS. LOS/NLOS from PLATEAU 3D meshes, 57.8× BVH speedup.
- 📈 Reproducible scoring. Same-input, same-metric comparisons; the live snapshot is built from committed CSVs.
Urban RTK usually commits to carrier integers after a ratio test, and a wrong fix then stays until something notices it. RB-FGO-PF keeps every carrier ambiguity float in one GNSS/IMU factor graph (GTSAM) and lets particles carry the integer hypotheses ("basins"), so a wrong fix keeps competing with the right one. Results are on the six PPC 2024 runs (Tokyo 1–3, Nagoya 1–3) in real time: each epoch uses only past and current observations, as the challenge required.
| PPC OFFICIAL, real time (% of distance within 0.5 m) | Tokyo 1 | Tokyo 2 | Tokyo 3 | Nagoya 1 | Nagoya 2 | Nagoya 3 | Mean |
|---|---|---|---|---|---|---|---|
| One filter (3-seed mean) | 85.2 | 93.7 | 92.3 | 77.2 | 57.5 | 83.7 | 81.6 |
| Ensemble of 4 filters | 86.9 | 95.8 | 93.7 | 80.2 | 61.1 | 90.4 | 84.7 |
| Ensemble + 3D city map (7 filters) | 92.2 | 96.2 | 94.4 | 81.6 | 62.1 | 91.0 | 86.3 |
The PPC2024 winner (TURING) scored 85.6% on the hidden half it was ranked on and 78.7% on the public half.
| Correct FIX / epochs (wrong FIX / FIX) | Tokyo 1 | Tokyo 2 | Tokyo 3 | Nagoya 1 | Nagoya 2 | Nagoya 3 |
|---|---|---|---|---|---|---|
| inuex35 TC-FGO (local reproduction) | 46.7% (5.72%) | 59.7% (1.78%) | 58.2% (2.06%) | – | – | – |
| libgnss++ GTSAM FGO | 37.7% (29.9%) | 77.3% (1.57%) | 68.2% (1.56%) | – | – | – |
libgnss++ KF-RTK (ppc-demo, low-cost preset) |
68.0% (8.76%) | 76.7% (1.80%) | 71.6% (3.01%) | 64.9% (14.8%) | 48.6% (2.15%) | 29.8% (29.4%) |
| RB-FGO-PF, real time | 62.0% (0.00%) | 82.3% (0.00%) | 77.8% (0.00%) | 70.0% (0.00%) | 52.0% (0.06%) | 31.4% (0.00%) |
A FIX is correct when its 3D error is under 0.5 m; one script scores every system.
flowchart TD
subgraph F["Each of 4 to 7 filters"]
G["Shared float factor graph (GTSAM)<br/>IMU, DD code and carrier"] --> K["Top-K integer candidates"]
K --> P["64 particles, each an integer set (basin),<br/>weighted by the carrier fit and resampled"]
P -.->|"agreed integers"| G
P --> L["FIX label"]
P --> C["Position candidates"]
end
Obs["Rover + base GNSS, IMU"] --> G
Map["PLATEAU 3D city model<br/>(map-aided filters only)"] -.->|"blocked satellites"| G
C --> E["Per-epoch choice:<br/>classifier + height bias"]
L --> E
E --> Out["Position + FIX / FLOAT"]
- Filter. Each particle holds one set of double-differenced integers and is weighted by how well it fits this epoch's carrier and code; resampling drops sets that stop fitting. FIX is reported when the particles agree on the integers and the fix stays within 0.5 m of the graph's float position; an accepted integer set is kept while it still fits the carrier.
- Ensemble. Filters with different seeds, and one that also uses one-band carriers, run side by side. Each epoch a classifier, trained on the other five runs, picks one of their positions. Unfixed positions are lowered by 0.10–0.15 m, because reflected signals lift the float solution in street canyons. The FIX label comes from the four map-free filters.
- 3D city map (optional). Three more filters trace a ray from the
predicted antenna position to each satellite through the PLATEAU building
models and drop or down-weight blocked satellites
(
plateau_los.py, CPU).
Not like-for-like with PPC2024: teams were ranked on a hidden half of these
runs, our classifier and height bias were trained on them (leaving out the
run being scored), and the 3D map is extra data. Nagoya 2's 375 s street
canyon stays meters off for every filter. Ensemble scores:
rt_ensemble.py. Details, including
offline outputs (15 s lag, backward pass):
technical report ·
benchmark record ·
design ·
code.
The particle cloud is localized on the real street network (UrbanNav Odaiba).
| Method | Dataset | P50 | RMS 2D |
|---|---|---|---|
| PF 100K (DD + smoother + stop-detect) | UrbanNav Tokyo Odaiba | 1.50 m | 4.06 m |
| RTKLIB demo5 (rtklibexplorer, 2026-09) | UrbanNav Tokyo Odaiba | 0.34 m | 40.89 m |
libgnss++ RTK, low-cost preset (78.5% coverage) |
UrbanNav Tokyo Odaiba | 0.07 m | 1.97 m |
| libgnss++ RTK + PF gap fill (98.2% coverage) | UrbanNav Tokyo Odaiba | 0.20 m | 3.55 m |
PF anchored to libgnss++ RTK FIX (preset urbannav_rtk_anchored, 98.2% coverage) |
UrbanNav Tokyo Odaiba | 0.10 m | 0.77 m |
| PF + RobustClear-10K (external mainline) | UrbanNav, 5 seq / 2 cities | — | 66.6 m |
| EKF baseline | UrbanNav, 5 seq / 2 cities | — | 93.25 m |
Odaiba rows: 2026-10-04, public subset, one shared base calibration
(calibration,
RTK record,
PF record).
P50/RMS are over each method's own output epochs. PF 100K is preset
odaiba_stop_detect, 5 seeds. The anchored PF redraws the cloud at every RTK
FIX and is within 3 m on 97% of reference epochs (RTKLIB demo5: 69%)
(record); it also
improves all three PPC Tokyo routes
(PPC record). Without a wheel
odometer, use preset rtk_anchored_doppler (6 PPC routes: 90.5% within 5 m vs
78.6% for RTK alone). Anchored row: gnssplusplus 9d89a58a; other rows
304798e7.
Full epoch denominator, no truth or runtime FGO, declared false FIX ≤ 1%. libgnss++ is an external reference only.
| Full-epoch matched 3D | PF-only locked current | latest libgnss++ reference | v0.3 promotion floor |
|---|---|---|---|
| Tokyo run1 | 46.51% (5,546 / 11,924) | 80.02% | 45.00% |
| Nagoya run1 | 75.37% (5,715 / 7,583) | 85.84% | 69.55% non-degradation |
WP173 declares FIX once an MLAMBDA ratio ≥ 3.0 holds for 5 epochs with 6+ satellites and the WP172 consensus gate: Tokyo 10.87%, Nagoya 18.07% of epochs, zero audited false FIX. Candidate supply costs 42.7 ms/epoch, under the 100 ms budget.
The external-validation RMS is high because it averages the hardest deep-urban sequences; what matters is the gap to EKF and RTKLIB on the same epochs. See the results snapshot.
Open the Odaiba particle-cloud video
Zero-data terminal demo (Odaiba comparison, PPC held-out check, PLATEAU mask replay gain):
PYTHONPATH=python:. python3 examples/demo_pf_localization_improvement.pyModels why urban pseudoranges are biased — knife-edge (ITU-R P.526) and UTD (Kouyoumjian–Pathak) diffraction plus reflection over PLATEAU meshes — and scores it against real UrbanNav residuals.
Open the full LOS/NLOS deck.gl sweep
Correcting each satellite to transmission time (with Sagnac) gives a clean NLOS reference (LOS median 1.0 m, AUC 0.92). On it, UTD matches the measured bias distribution better than knife-edge, as in Zhang & Hsu (2021).
| Diffraction model | Wasserstein-1 ↓ | KS ↓ |
|---|---|---|
| knife-edge (ITU-R P.526) | 1.84 | 0.46 |
| UTD (Kouyoumjian–Pathak) | 1.70 | 0.29 |
UrbanNav Odaiba, 60 epochs, 249k-triangle PLATEAU mesh. Reproduce with
PYTHONPATH=examples python examples/plot_nlos_diffraction_figure.py Odaiba 60.
gnss_gpu.scenario simulates what a receiver sees at a place or along a route
(visible satellites, LOS/NLOS, pseudorange errors, C/N0, Doppler) and exports
RINEX 3.04 OBS (--rinex-out). gnss_gpu.coverage_map batches all grid
cells × satellites into single CUDA launches to map visibility, availability,
DOP and expected error over a district in seconds.
PYTHONPATH=python python examples/demo_scenario_engine.py # observables at a point
PYTHONPATH=python python examples/demo_coverage_map.py # per-cell quality map (PNG + deck.gl)Use cases on real Odaiba data (UrbanNav route + PLATEAU mesh):
| Use case | Demo | What it surfaces |
|---|---|---|
| Test-course / route evaluation | demo_route_accuracy.py |
Availability (≥4 LOS) stays 100% while expected HPE spikes past 300 m on near-singular geometry. |
| RTK base-station placement | demo_rtk_base_placement.py |
Candidates tie on LOS count and HDOP; the common-view satellite count with the rover route ranks them. |
| Site multipath assessment | demo_urban_multipath.py |
Sky map of LOS/NLOS tracks with multipath excess and C/N0 (UTD): 28.6% NLOS, 18.9 dB-Hz LOS-vs-NLOS C/N0 gap. |
Every model is scored against measured residuals, and the pipeline is open from CityGML parsing to the CUDA kernels.
Needs an NVIDIA GPU, a current driver and a CUDA Toolkit with nvcc.
git clone --recurse-submodules https://github.com/rsasaki0109/gnss_gpu.git
cd gnss_gpu
python3 -m venv .venv
# Linux/macOS: source .venv/bin/activate
# Windows PowerShell: .venv\Scripts\Activate.ps1
python3 -m pip install --upgrade pip
python3 python/gnss_gpu/cli.py doctor
python3 python/gnss_gpu/cli.py build
gnss-gpu doctor
gnss-gpu run --preset signal-acquisitiondoctor checks the driver, GPU, compiler, CMake, bindings and a CUDA
round-trip. On Windows, use python in a Developer PowerShell; set the
architecture with gnss-gpu build --architecture 89.
gnss-gpu run --preset plateau-nlos # PLATEAU mask/replay suite
gnss-gpu data inspect data/urbannav/Tokyo/Odaiba
gnss-gpu run --preset urbannav-pf --input data/urbannav/Tokyo/Odaiba
gnss-gpu compare runs/urbannav-pf-baseline runs/urbannav-pf-candidateEach run writes a runs/ directory with a v1 manifest.json (git SHA, GPU,
input hashes, parameters, metrics, artifacts); compare writes
comparison.md (--json PATH for JSON). Local data (rover/base RINEX, nav,
reference.csv, optional imu.csv) is never downloaded implicitly. Fetch the
Tokyo subset with:
python experiments/fetch_urbannav_subset.py --run Odaiba --output-dir data/urbannav/Tokyo--allow-cpu-fallback is for CPU smoke tests only.
git clone --recurse-submodules https://github.com/rsasaki0109/gnss_gpu.git
cd gnss_gpu
python3 -m venv .venv && source .venv/bin/activate
python3 -m pip install --upgrade pip
python3 -m pip install -r requirements.txt
python3 -m pip install pytest pandas scipy requests matplotlib plotlyNaive WLS vs robust SPP on a simulated urban canyon:
PYTHONPATH=python python3 examples/demo_urban_canyon_sim.pymethod P50 err RMS err
--------------------------------------------------
naive WLS (L2) 10.30 m 10.21 m
robust SPP (Cauchy) 2.00 m 2.39 m
--------------------------------------------------
robust vs naive: 81% better P50, 77% better RMS
import numpy as np
from gnss_gpu import robust_spp
sat_ecef = np.asarray(...) # shape: (n_sat, 3), metres
pseudoranges = np.asarray(...) # shape: (n_sat,), metres
weights = np.ones(len(pseudoranges))
coarse_ecef = np.asarray(...) # shape: (3,), metres
position_ecef = robust_spp(
sat_ecef, pseudoranges, weights=weights,
init_pos=coarse_ecef, weight_func="cauchy", threshold=15.0,
)Invalid shapes, non-finite values, negative weights or options raise
ValueError. NLOS simulation and the PLATEAU mask replay suite:
PYTHONPATH=python python3 examples/demo_nlos_simulation.py
PYTHONPATH=python:. python3 experiments/run_plateau_nlos_demo_suite.pyThe suite replays SPP/PF/FGO with the mask (tow,epoch_idx,prn,is_los) and
writes JSON/Markdown/CSV summaries.
| Replay consumer | Baseline RMS | Mask-soft RMS | RMS gain |
|---|---|---|---|
| SPP | 11.85 m | 4.07 m | 65.6% |
| PF | 11.18 m | 1.40 m | 87.4% |
| local-FGO | 8.10 m | 0.38 m | 95.4% |
PYTHONPATH=python pytest tests/test_*_wrapper.py -q # CPU-only smoke test
PYTHONPATH=python python3 -m pytest tests/ -q # full suite (CUDA tests skipped)
python3 python/gnss_gpu/cli.py build # build native kernels
gnss-gpu doctorGPU demos need a prior build. On CPU-only installs, native calls raise
gnss_gpu.NativeBackendUnavailableError; build --dry-run shows the build
command.
ros2/gnss_gpu_ros gates multipath/NLOS spikes in
sensor_msgs/NavSatFix (Hampel + CV Kalman) and publishes an RViz path.
ros2 run gnss_gpu_ros robust_navsat_filter --ros-args -r fix:=/your_gnss_driver/fixpython/gnss_gpu/ Reusable Python package code
src/ CUDA/C++ kernels and native bindings
examples/ Runnable demos (start here)
benchmarks/ GPU throughput benchmarks (+ RESULTS.md)
experiments/ Experiment runners, sweeps, reports, one-off probes
experiments/results/ Generated CSV/HTML/plot outputs
docs/ Generated visual snapshot site (the live demo)
ros2/gnss_gpu_ros/ ROS 2 robust NavSatFix filter node
internal_docs/ Working notes, decisions, handoffs, current state
third_party/gnssplusplus/ C++ GNSS/RTK/PPP/CLAS solver subproject
tests/ Python tests for stable helpers and experiment logic
flowchart LR
Data["PPC / UrbanNav / GSDC data"] --> Lib["libgnss++\nSPP/RTK/diagnostics"]
Lib --> Floor[".pos / diagnostics\nhybrid floor and candidates"]
Data --> GPU["gnss_gpu\nPF/RBPF/DD/FGO experiments"]
Floor --> GPU
GPU --> Score["honest scoring\nCSV/HTML reports\nKaggle/PPC artifacts"]
| Goal | First place to look |
|---|---|
| See the live, regenerated results | Results snapshot site |
| Run a demo | examples/ |
| Check GPU throughput | benchmarks/RESULTS.md |
| Continue current GSDC2023 Kaggle work | internal_docs/plan.md |
| Understand current PPC production state | internal_docs/ppc_current_status.md |
| Read the RB-FGO-PF technical report | docs/rbpf_fgo_report.md |
| Find durable decisions and negative results | internal_docs/decisions.md |
| Work on reusable Python code | python/gnss_gpu/ |
| Work on native CUDA/C++ code | src/ |
| Work on the C++ GNSS solver baseline | third_party/gnssplusplus/README.md |
Experiment-first: stable code lives in python/gnss_gpu/ and src/; runs,
sweeps and reports in experiments/ and internal_docs/. Trust a generated
CSV/HTML only if experiments/results/README.md
lists it and internal_docs/plan.md records its
build command.
- Stable code goes in
python/gnss_gpu/orsrc/; variant-heavy logic stays inexperiments/until it survives fixed evaluation. - Promote on same-input, same-metric comparisons, not one pilot split.
- Record durable decisions in
internal_docs/decisions.md. - Never vendor, link or derive code/config from GPL-3.0 sources such as
gici-open.







