Urban driving: bridge RTK outages with IMU, NHC, and ZUPT¶
Use this route for a road vehicle that needs a continuous trajectory through urban canyons, under trees, or in short tunnels. The accuracy contract has two distinct states:
| State | Position source | Practical interpretation |
|---|---|---|
RTK FIXED |
Carrier-phase RTK | Centimetre-class is plausible after it has been checked against truth |
| RTK degraded or absent | IMU propagation, optionally constrained by NHC/ZUPT | Continuity bridge; uncertainty and drift grow with time |
IMU, NHC, and ZUPT do not turn an RTK outage into centimetre-accurate FIX. They keep the trajectory continuous until trustworthy GNSS can anchor it again. This distinction is the core of the recipe.
Inputs and first artifact¶
The public PPC Tokyo runs already have the required files:
data/PPC-Dataset/tokyo/run1/
rover.obs base.obs base.nav imu.csv reference.csv
Build the existing fusion command and check the dataset before starting:
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build --target gnss_fuse --parallel 2
test -s data/PPC-Dataset/tokyo/run1/rover.obs
test -s data/PPC-Dataset/tokyo/run1/base.obs
test -s data/PPC-Dataset/tokyo/run1/base.nav
test -s data/PPC-Dataset/tokyo/run1/imu.csv
mkdir -p output/use_cases/urban_fusion
Run a 200-epoch wiring check. It writes the pre-fusion RTK stream and the continuous fused stream from the same observations in one pass:
python3 apps/gnss.py fuse \
--data-dir data/PPC-Dataset/tokyo/run1 \
--lever-arm 0.31,0,-0.55 \
--preset low-cost \
--zupt --no-nhc \
--max-epochs 200 \
--rtk-pos-out output/use_cases/urban_fusion/rtk.pos \
--out output/use_cases/urban_fusion/fused_zupt.pos \
2>&1 | tee output/use_cases/urban_fusion/fused_zupt.log
python3 apps/gnss.py pos2kml \
output/use_cases/urban_fusion/fused_zupt.pos \
output/use_cases/urban_fusion/fused_zupt.kml \
--status all
--lever-arm x,y,z is the IMU-to-antenna offset in the PPC vehicle coordinates,
in metres. Tokyo's published PPC value is 0.31,0,-0.55; keep its signed Z
component (do not replace it with +0.55). Measure it for a different vehicle.
The complete IMU CSV and axis contract is in
GNSS/IMU fusion.
Frame contract: the ESKF's internal toPositionSolution() is IMU-origin
position/velocity and is retained for propagation and RTK coupling. The
external fused .pos and KML written by this guide are antenna-frame outputs
from toAntennaPositionSolution(), using p + R*r and
v + R*(omega x r) plus the corresponding H P H^T covariance projection.
The RTK .pos and PPC reference.csv are also antenna-frame, so the bridge
scorer compares like frames. Keep this distinction in any archived summary
or manifest. The urban-bridge-score summary records this as
coordinate_frame_contract; a raw internal ESKF solution must not be scored
as antenna data.
Keep --navi776-tc out of the first loose-coupling baseline. It enables a
separate RTK-hosted tight-coupling combination and must be scored as its own
variant rather than silently changing the RTK stream under this recipe.
The constraint recipe¶
Start with ZUPT enabled and NHC disabled, as in the command above.
- ZUPT constrains velocity to zero only during a detected stop. It limits stationary drift and helps bias estimation, but cannot help while moving.
- NHC constrains lateral and vertical body-frame velocity. Enable it only for a wheeled vehicle with little side slip; sharp turns, ice, banked roads, and aggressive manoeuvres violate the model.
- Neither constraint should be judged by RTK FIX rate. Judge the fused path during RTK-degraded spans and check that GNSS reacquisition has no jump.
For a new vehicle, replay the same precomputed RTK stream so the only changed variables are the motion constraints:
python3 apps/gnss.py fuse \
--data-dir data/PPC-Dataset/tokyo/run1 \
--gnss-pos output/use_cases/urban_fusion/rtk.pos \
--lever-arm 0.31,0,-0.55 --no-zupt --no-nhc \
--out output/use_cases/urban_fusion/fused_imu_only.pos
python3 apps/gnss.py fuse \
--data-dir data/PPC-Dataset/tokyo/run1 \
--gnss-pos output/use_cases/urban_fusion/rtk.pos \
--lever-arm 0.31,0,-0.55 --zupt --nhc \
--out output/use_cases/urban_fusion/fused_zupt_nhc.pos
Keep NHC only if truth-based errors improve in the RTK-degraded segments and turns do not regress. The separate fixed-lag FGO audit found no FIX gain from NHC-only or ZUPT-only on its frozen Tokyo slice, while the combined setting introduced two wrong fixes. Consequently the FGO switches remain default-off; see the NHC/ZUPT FIX-rate audit. That FGO result is a warning against treating a motion constraint as ambiguity-fixing evidence; it is not a claim that the ESKF continuity bridge is unusable.
Inspect the hand-off¶
Render RTK and fused tracks with the same status-coloured plotter. The
trackplot command's optional second input is specifically an RTKLIB-format
file, so two libgnss++ .pos files are plotted separately:
python3 apps/gnss.py trackplot \
output/use_cases/urban_fusion/fused_zupt.pos
python3 apps/gnss.py trackplot \
output/use_cases/urban_fusion/rtk.pos
gnss stats is useful for epoch/status counts, but its spread around a single
mean position is not an accuracy metric for a moving vehicle. Use
reference.csv and the full-run validation workflow for accuracy. The
published PPC snapshot in GNSS/IMU fusion reports about
99% fused availability versus 69--92% for RTK alone, but also shows that long
urban dead-reckoning gaps can increase horizontal RMSE.
For each RTK-degraded interval, inspect:
- error at the last trustworthy RTK anchor;
- maximum horizontal error and drift rate during the bridge;
- the position jump when trustworthy RTK returns; and
- whether ZUPT/NHC was applied only when its vehicle-motion assumption held.
Do that inspection mechanically on a full run with urban-bridge-score:
python3 apps/gnss.py urban-bridge-score \
--rtk-pos output/use_cases/urban_fusion/rtk.pos \
--fused-pos output/use_cases/urban_fusion/fused_zupt.pos \
--reference-csv data/PPC-Dataset/tokyo/run1/reference.csv \
--summary-json output/use_cases/urban_fusion/bridge_score.json \
--segments-csv output/use_cases/urban_fusion/bridge_segments.csv
The scorer limits the evaluation window to the RTK file's time span. Its JSON records SHA-256 provenance, RTK/fused availability, fixed-epoch P95 error, bridge coverage, and optional pass/fail thresholds. Each CSV row records one RTK-degraded segment, including whether it has FIX anchors on both sides, maximum horizontal error, error-growth rate, and reacquisition discontinuity.
R1 Tokyo run1 frozen candidate¶
The R1 candidate keeps the PPC lever-arm contract (0.31,0,-0.55) and adds
the deterministic Doppler recovery policy. A velocity update is gated at
--max-velocity-nis 25; after three consecutive trusted, finite/PSD Doppler
rejections, only the lever-arm-compensated velocity is re-anchored, with a
20 m/s correction bound. A position update is gated at the application default
and a position-only recovery requires 30 consecutive FIXED rejections;
FLOAT epochs reset that patience. The position recovery has no arbitrary
distance cap by default, because a returning accurate FIX can be far from the
IMU after a long outage. Position, attitude, and bias states are not overwritten
by the velocity recovery.
Use these explicit options for the frozen run1 replay (do not open run2/run3 until this run1 artifact has passed the project's accuracy review):
python3 apps/gnss.py fuse \
--data-dir data/PPC-Dataset/tokyo/run1 \
--lever-arm 0.31,0,-0.55 \
--preset low-cost \
--zupt --no-nhc \
--max-velocity-nis 25 \
--max-consecutive-velocity-gate-rejections 3 \
--max-gnss-velocity-reanchor-mps 20 \
--rtk-pos-out output/use_cases/urban_fusion_full/r1_frozen.rtk.pos \
--out output/use_cases/urban_fusion_full/r1_frozen.fused.pos \
2>&1 | tee output/use_cases/urban_fusion_full/r1_frozen.log
python3 apps/gnss.py urban-bridge-score \
--rtk-pos output/use_cases/urban_fusion_full/r1_frozen.rtk.pos \
--fused-pos output/use_cases/urban_fusion_full/r1_frozen.fused.pos \
--reference-csv data/PPC-Dataset/tokyo/run1/reference.csv \
--summary-json output/use_cases/urban_fusion_full/r1_frozen_score.json \
--segments-csv output/use_cases/urban_fusion_full/r1_frozen_segments.csv \
--require-fused-bridge-coverage-min 99 \
--require-max-bridge-error-max 75 \
--require-max-reacquisition-jump-max 15 \
--require-fixed-p95-regression-max 5 \
--require-fused-availability-at-least-rtk \
--require-no-nonfinite
For a reproducible hand-off, the same frozen recipe can be materialized as a
single bundle command. It reuses fuse, pos2kml, trackplot, and
urban-bridge-score; no separate shell tee or manual conversion is needed:
python3 apps/gnss.py urban-continuity-bundle \
--data-dir data/PPC-Dataset/tokyo/run1 \
--reference-csv data/PPC-Dataset/tokyo/run1/reference.csv \
--output-dir output/use_cases/urban_fusion_full/r1_bundle
The bundle emits rtk.pos, fused.pos, fused.kml, fused_trajectory.png,
bundle.log, score.json, segments.csv, and manifest.json. The manifest
records the exact dispatcher and child argv, input/output byte counts and
SHA-256 values, binary path/hash and version-probe availability, frame and
lever-arm contracts, frozen gate results, each step exit status, and measured
maximum propagation age. Its operational age recommendation is 60 s; the
Tokyo run1 validation observed 87.2 s, so a bridge over the recommendation is
reported as operationally unusable even when the score gate passes. The
manifest is written incrementally so a failed conversion, plot, or score still
leaves the log and failure evidence.
The checked artifacts used the same options with the names
negative_z_velocity_reanchor_3500* (first 3,500 epochs) and
negative_z_velocity_reanchor_unbounded_run1* (full run1):
| R1 artifact | Fused availability | Bridge coverage | Max bridge H error | Max reacquisition step | Fixed-P95 regression vs RTK |
|---|---|---|---|---|---|
| 3,500-epoch gate | 99.600% | 99.334% | 55.580 m | 10.154 m | 2.068 m |
| Full run1 | 99.724% | 99.233% | 64.898 m | 10.154 m | 2.718 m |
Both artifacts contain no non-finite fused epochs. The full-run score passes
the frozen gates (coverage >=99%, max bridge <=75 m, reacquisition <=15 m,
fixed-P95 regression <=5 m, and fused availability >= RTK availability). Its
gated score and per-bridge ledger are archived at
output/use_cases/urban_fusion_full/negative_z_velocity_reanchor_unbounded_run1_gated_score.json
and
output/use_cases/urban_fusion_full/negative_z_velocity_reanchor_unbounded_run1_gated_segments.csv.
These numbers are a run1 candidate result, not permission to tune or evaluate
the run2/run3 holdouts.
Continuity is not the same as an operationally usable bridge. In this
low-cost Tokyo run1, the largest propagation_age_s in the segment ledger is
87.2 s (segment 169). A deployment profile should set a vehicle- and
environment-specific maximum bridge age; 60 s is a conservative starting
recommendation for this profile. Mark any segment over that limit as
unusable for the application, even when the fused file remains continuous,
and surface the condition to the integrity/mission layer rather than hiding it
behind the availability percentage.
R1 frozen release decision and holdout evidence¶
The run1 candidate was frozen before evaluating the holdouts. The score gates are the coverage, bridge-error, reacquisition, fixed-P95-regression, fused-availability, and non-finite limits shown above. The following table is the immutable post-freeze record; percentages are reported by the bundle scorer and are not rounded for the gate decision:
| Bundle | Reference epochs | RTK fixed | Complete bridges | Fused availability vs RTK | Coverage | Max bridge H | Reacquisition | Fixed-P95 regression | Max propagation age | Score gate |
|---|---|---|---|---|---|---|---|---|---|---|
r1_frozen_bundle (run1) |
11,951 | 8,430 | 205 | 99.723872% vs 99.113045% | 99.233172% | 64.899308 m | 10.154390 m | 2.686921 m | 87.2 s | PASS |
r1_holdout_run2 |
9,151 | 7,230 | 131 | 99.890722% vs 99.595673% | 99.791775% | 14.504349 m | 1.620029 m | 0.358979 m | 96.6 s | PASS |
r1_holdout_run3 |
15,301 | 11,071 | 234 | 99.934645% vs 99.993464% | 99.905437% | 26.238413 m | 10.523244 m | 0.422489 m | 63.4 s | FAIL (availability only) |
All three bundles have nonfinite=0. The run2 and run3 artifacts are
archived at output/use_cases/urban_fusion_full/r1_holdout_run2/ and
output/use_cases/urban_fusion_full/r1_holdout_run3/; run1 is archived at
output/use_cases/urban_fusion_full/r1_frozen_bundle/ and its exact repeat
is byte-for-byte deterministic. Run3 was not rerun, retuned, or downgraded to
development data. Its single fused-availability failure keeps the R1 profile
offered for field use on hold; do not change the frozen settings using
these holdout observations. A future release decision must either accept the
explicit hold or produce a separately approved candidate on newly authorized
development data.
The 60 s operational bridge-age recommendation is a separate integrity gate: run1 (87.2 s), run2 (96.6 s), and run3 (63.4 s) all exceed it. Thus, even the run2 score pass is not a field-usable result under the current checklist. See the R1-09 field checklist for the usable/degraded/unusable classification and the one-command hand-off.
R1-05 also replayed the same saved RTK stream with the frozen lever arm and recovery settings while changing only the motion constraints. The replay comparison is useful for rejecting NHC, but its saved-RTK timestamp population is not the in-process full-run denominator above:
| Replay profile | Bridge coverage | Max bridge H error | Max reacquisition step |
|---|---|---|---|
| IMU only | 96.876% | 33.679 m | 1.024 m |
| ZUPT | 96.876% | 84.230 m | 3.375 m |
| ZUPT + NHC | 96.876% | 166.179 m | 3.706 m |
The ZUPT and ZUPT+NHC regressions support keeping NHC out of the frozen
run1 recipe. Artifacts are under
output/use_cases/urban_fusion_full/r1_profile_compare/; do not compare
their availability directly with the in-process R1 candidate.
Historical pre-R1 full-run evidence¶
The pre-R1 Tokyo run1 replay (11,951 reference epochs) shows why the bridge scorer is mandatory:
| Fusion profile | Fused availability | Bridge coverage | Max bridge H error | Max reacquisition step | Fixed-P95 regression vs RTK |
|---|---|---|---|---|---|
| IMU only | 99.03% | 96.88% | 1,396.9 km | 297.85 m | +1,322.1 km |
| ZUPT, no NHC | 99.72% | 99.23% | 1,321.30 m | 62.20 m | +58.54 m |
| ZUPT + NHC | 99.03% | 96.88% | 3,047.73 m | 6.82 m | +2,998.37 m |
These historical profiles increase output continuity but fail an
application-accuracy gate. The IMU-only and ZUPT+NHC rows reuse the saved RTK
.pos; that replay surface outputs fused epochs at saved GNSS timestamps and
therefore has a slightly different availability denominator from the
in-process ZUPT row. Compare accuracy only after preserving that provenance
distinction.
Exit criteria¶
The short wiring check passes when all artifacts exist and the fused stream covers more than a trivial interval:
test -s output/use_cases/urban_fusion/rtk.pos
test -s output/use_cases/urban_fusion/fused_zupt.pos
test -s output/use_cases/urban_fusion/fused_zupt.kml
test -s output/use_cases/urban_fusion/fused_zupt.log
test "$(grep -vc '^%' output/use_cases/urban_fusion/fused_zupt.pos)" -ge 100
grep -q '<coordinates>' output/use_cases/urban_fusion/fused_zupt.kml
A vehicle profile is ready for a longer evaluation only when the full run,
scored against reference.csv, has no unacceptable bridge drift or
reacquisition jump. Archive the command line, lever arm, IMU axis convention,
RTK and fused .pos files, KML, and log together.
Boundary and next step¶
This offline route demonstrates RTK-to-IMU continuity using existing commands and public data. It does not establish lane-level safety, integrity risk, sensor time synchronisation, or universal centimetre accuracy. Production work also needs calibrated latency and lever arm, IMU thermal/bias testing, fault detection, and a maximum allowed dead-reckoning time.
Next step: preserve the frozen run1 and holdout bundles as release evidence, keep the R1 field profile on hold because run3 misses the availability gate, and do not tune or rerun either holdout. The general artifact, validation, and benchmark contracts are documented in interfaces, validation, and benchmarks.