Initial commit of fully working GPS processing pipeline. See README for more information.

This commit is contained in:
= 2026-07-25 14:36:41 -04:00
commit 65a4836608
8 changed files with 2620 additions and 0 deletions

18
.gitignore vendored Normal file
View file

@ -0,0 +1,18 @@
# Python environment
venv/
__pycache__/
*.pyc
# Bootstrapped tooling: RTKLIB build, ANTEX models, CORS catalog cache
# (fully reproducible with setup.sh)
tools/
# Pipeline outputs (regenerate with process_gps.py)
processed/
# Live streaming session outputs (stream_gps.py)
stream_*.csv
# Raw GPS capture data
*.ubx
*.uc2x

193
README.md Normal file
View file

@ -0,0 +1,193 @@
# u-blox GPS Module Data Procesing (u-blox ZED-X20P)
Tooling for evaluating u-blox ZED-X20P all-band GNSS modules, targeting **buoy
deployments**. Converts raw u-center capture files (`.ubx`) into corrected
positions and produces a full diagnostics report, and supports live streaming
with real-time corrections.
Two operating modes:
| Mode | Script | Accuracy | Correction source |
|---|---|---|---|
| Post-processing | `process_gps.py` | dm-cm (PPK) | NGS CORS base data, auto-fetched (free, open) |
| Real-time streaming | `stream_gps.py` | 1-3 cm (RTK fix) | GCGC RTN NTRIP (free registration) or Galileo HAS (FW ≥ HPG 2.10) |
Both consume/produce standard formats (RINEX 3, `.pos`, CSV, KML) via RTKLIB
(demo5 fork) and widely used open-source Python libraries (pyubx2, pygnssutils,
numpy, pandas, matplotlib).
## Motion policy (important)
**Processing never assumes the antenna was stationary.** Buoys drift slowly and
drift within a few metres is statistically indistinguishable from measurement
noise. Everything defaults to kinematic; pass `--static` to `process_gps.py`
only when the antenna is independently known to have been fixed (e.g. bench/roof test).
## Quick start
```bash
bash setup.sh # one-time environment bootstrap
venv/bin/python process_gps.py <capture.ubx> # process a capture
# results in processed/<capture-stem>/ (report.html is the human summary)
```
## Requirements
Linux with `python3` (3.10+), `gcc`/`make`, `git`, `curl`, and network access
for `setup.sh` downloads. No sudo required and everything installs into the
project directory (`venv/`, `tools/`).
---
## setup.sh
Environment bootstrap (safe to re-run; each step skips if already satisfied):
1. **Python venv** (`venv/`): pyubx2, pygnssutils, pyserial, numpy, pandas,
pyproj, matplotlib.
2. **RTKLIB demo5 v2.5.1** built from source into `tools/bin/`
(`convbin`, `rnx2rtkp`, `pos2kml`). The demo5 fork is required: stock
RTKLIB 2.4.3 silently drops all BeiDou and GPS L5 / Galileo E5 observations
from this receiver (~30% of the measurements). If the build fails, setup
falls back to the stock apt package and creates a `tools/STOCK_RTKLIB`
marker — `process_gps.py` then adds a prominent data-loss warning to every
report until a demo5 build succeeds.
3. **Antenna calibration models** into `tools/antex/`: `igs20.atx` (IGS, covers
CORS base-station antennas) and `ngs20.atx` (NGS absolute calibrations,
covers the rover antenna). The rover antenna is resolved automatically and
written to `tools/antenna.json` (see *Antenna configuration* below).
4. **CRX2RNX** (Hatanaka RINEX decompressor) — fallback for CORS stations that
only publish compressed `.d` observation files.
5. **NGS CORS station catalog** cache (used for nearest-base auto-selection).
6. Self-check summary of every tool and the resolved antenna.
## process_gps.py
```
venv/bin/python process_gps.py <capture.ubx> [options]
```
Stages (idempotent — outputs are cached, so re-running later only fills in
what was previously unavailable, e.g. CORS/IGS data that had not been
published yet):
1. **inventory** — parses the `.ubx` (RXM-RAWX/SFRBX raw observations,
NAV-PVT/SAT/SIG, MON-RF) and the `.uc2x` sidecar's session metadata.
A capture without raw observations (e.g. u-center debug-mode logs) gets a
diagnostics-only report and a clear notice.
2. **rinex** — RINEX 3.04 obs+nav via demo5 `convbin`; verifies epoch count and
BDS/L5 presence; extracts broadcast ionosphere parameters.
3. **single** — standalone single-point solution (~1 m, works offline).
4. **ppk** — differential post-processing against the nearest operational NGS
CORS station (auto-selected by distance from the capture's own mean
position; MSEV at 34 km for the Hattiesburg test site). Hourly base files
are fetched automatically (~1 h publication lag; if not yet published the
stage reports "re-run later" and everything else proceeds). Base antenna
type is read from the base RINEX header; base coordinates are ITRF2020,
velocity-propagated to the capture epoch.
5. **ppp** (`--ppp`) — precise point positioning against IGS rapid/final
orbit+clock products (anonymous download from BKG/ESA, ~1 day lag).
Kinematic PPP by default per the motion policy; note RTKLIB's PPP-kinematic
engine is weak and may yield no solution — PPK is the reference method.
6. **track**`track.csv` (UTC + decimal-degree lat/lon + per-epoch quality
and 1-σ sd columns + onboard comparison columns) and `track.kml`.
7. **diagnostics**`diagnostics.json`, `report.md`, `report.html`
(self-contained, figures embedded) covering: constellation metrics (per-band
C/N0, cycle slips, residuals), ionosphere (broadcast Klobuchar + measured
dual-frequency slant delay/TECU), positional error metrics (RMS/R50/R95
dispersion, formal σ, inter-solution offsets), **validation of every
computed solution against the receiver's own onboard NAV-PVT positions**
(epoch-matched, PASS/FAIL), timing (epoch jitter, tAcc, leap seconds), and
RF health (jamming indicator, AGC, antenna supervisor state).
### Options
| Flag | Meaning |
|---|---|
| `--interval N` | `track.csv` output interval in seconds (default 1 = native rate) |
| `--static` | antenna verifiably stationary: enables static PPK/PPP solutions and static error framing. **Never for buoys.** |
| `--no-ppk` | skip base-station download/processing |
| `--ppp` | additionally run PPP against IGS precise products |
| `--base SSSS` | force a specific CORS station id (e.g. `MSEV`) |
| `--max-base-km KM` | base-station search radius (default 150) |
| `--force-stage S` | delete and re-run one stage (`rinex`,`single`,`ppk`,`ppp`,`track`) |
## Antenna configuration
Receiver-antenna phase-center corrections (PCO/PCV) matter at the cm level and
are applied during PPK/PPP via ANTEX calibration files.
- **Automatic**: `setup.sh` downloads `ngs20.atx`/`igs20.atx` and searches for
the project's antenna (ArduSimple calibrated quad-band, NGS calibration
AS-ANT3BCAL01). The resolved 20-character ANTEX type string is written to
`tools/antenna.json`:
```json
{ "antex_name": "AS-ANT3BCAL NONE", "source": "ngs20.atx" }
```
- **Different antenna**: edit `antex_name` in `tools/antenna.json` to the exact
ANTEX type string of your antenna (padding/spacing matters, copy it verbatim
from the `TYPE / SERIAL NO` line of an antenna block in
`tools/antex/ngs20.atx` or `igs20.atx`). Browse available calibrations at
https://geodesy.noaa.gov/ANTCAL/ . If your antenna has an NGS calibration but
a different model string, also update the `ANT3B` search pattern in `setup.sh`
so future setups re-resolve it automatically.
- **No calibration available**: set `antex_name` to `null` (or leave it
unresolved) — processing continues without rover PCO/PCV and the report
carries a warning. Expect a few cm of systematic error, mostly in height.
- **Base antenna** needs no configuration: its type is read from the CORS RINEX
header and looked up in the same ANTEX files.
- **Antenna height**: assumed 0 (positions refer to the antenna reference
point). For a surveyed monument, subtract your ARP offset downstream.
## Other important settings and inputs
**Capture requirements.** Input `.ubx` files must contain raw observations:
UBX-RXM-RAWX + UBX-RXM-SFRBX enabled (plus NAV-PVT; NAV-SAT/NAV-SIG/MON-RF
enrich diagnostics). In u-center, do **not** record with the "debug messages"
option — it floods the log with undocumented TRK/TUN/DBG/SEC messages and, in
past sessions here, raw output was configured in flash but not active in RAM.
The optional `.uc2x` sidecar (u-center 2 index) is only used for its session
metadata header.
**Real-time credentials (`stream_gps.py`).** Free registration at
http://rtn.usm.edu/RegisterAccount.aspx (Mississippi GCGC RTN), then:
```bash
export GCGC_USER=<username> GCGC_PASS=<password>
venv/bin/python stream_gps.py --port /dev/ttyUSB0 # or COM7 on Windows
```
Key streaming defaults (buoy-oriented): receiver dynamic-platform model `sea`
(`--dynmodel` to override), **live** NMEA GGA uplink so the network-RTK virtual
reference follows platform drift (`--gga-fixed` for bench tests), raw
RXM-RAWX/SFRBX always logged so every session is post-processable, and
`--replay <file.ubx>` offline test mode. Galileo HAS (~20 cm, no internet)
activates automatically as fallback when firmware ≥ HPG 2.10 is detected.
**Datums.** PPK output is in the CORS base frame (ITRF2020, current epoch);
GCGC real-time corrections are NAD83(2011) epoch 2010.0 — a constant ~1.5 m
offset from ITRF/WGS84 in CONUS. Each CSV records its frame in the header;
comparisons in the report account for the reference used. Onboard receiver
positions are WGS84-aligned.
**Data-source timing.** CORS hourly files: ~1 h lag, expire after ~2 days
(daily files remain, decimated to 30 s after 30 days). IGS rapid products:
~1 day lag. The pipeline caches everything it fetches under
`processed/<stem>/` and tells you when a re-run will find more data.
## Repository layout
```
setup.sh environment bootstrap (run first)
process_gps.py post-processing pipeline (Mode 2)
stream_gps.py real-time streaming client (Mode 1)
run.sh convenience wrapper
notes/ manual workflow notes + combine_rinex.sh helper
tools/ built binaries, ANTEX models, caches (generated)
venv/ Python environment (generated)
processed/<capture>/ per-capture outputs: RINEX, .pos solutions,
track.csv, track.kml, figures/, diagnostics.json,
report.md, report.html (generated)
```

42
notes/RTK Processing Notes.txt Executable file
View file

@ -0,0 +1,42 @@
### THESE ARE NOTES FROM PREVIOUS WORK, KEPT FOR REFERENCE. NONE OF THESE STEPS ARE REQUIRED TO USE THE GPS DATA PROCESSING SCRIPTS IN THE PARENT DIRECTORY.
## Download the CRX2RNX tool precompiled binary
# Download the pre-compiled Linux binary
cd /tmp
wget https://terras.gsi.go.jp/ja/crx2rnx/RNXCMP_4.2.0_Linux_gcc_64bit.tar.gz
# Extract
tar -xzf RNXCMP_4.2.0_Linux_gcc_64bit.tar.gz
# Install to system path
sudo cp RNXCMP_4.2.0_Linux_gcc_64bit/CRX2RNX /usr/local/bin/
# Make executable
sudo chmod +x /usr/local/bin/CRX2RNX
# Create lowercase symlink (rnx2rtkp looks for lowercase)
sudo ln -s /usr/local/bin/CRX2RNX /usr/local/bin/crx2rnx
# Test
crx2rnx -h
## Download CORS corrections from here: https://www.ngs.noaa.gov/CORS/
## If base station CORS files are compressed (.25d format), decompress them
ls msin*.25d | xargs -n 1 -P 4 crx2rnx
## Combine base station observations into one file
./combine_rinex.sh msin_combined.obs msin*.25o
## Perform corrections on a .obs file using base station files as reference
cd CORS\ STATION\ MSIN\ NOV\ 2025/
rnx2rtkp \ -t -u \
-o ../../A/DATA/result.pos \
../../A/DATA/20251120_033544.006_UTC_GPS_A_combined.obs \
msin_combined.obs \
../../A/DATA/20251120_033544.006_UTC_GPS_A_combined.nav
## The result.pos file now contains PPK corrected GPS data in LLH format

141
notes/combine_rinex.sh Executable file
View file

@ -0,0 +1,141 @@
#!/bin/bash
# combine_rinex.sh - Combine RINEX observation files (supports both 2.x and 3.x)
if [ $# -lt 2 ]; then
echo "Usage: $0 output.obs input_files..."
echo "Example: $0 combined.obs msin*.25o"
echo "Example: $0 combined.obs msin3240.25o msin3250.25o msin3260.25o"
exit 1
fi
output="$1"
shift
# Sort files chronologically (RINEX naming is sortable)
files=$(printf '%s\n' "$@" | sort)
if [ -z "$files" ]; then
echo "Error: No input files specified"
exit 1
fi
# Get first file
first_file=$(echo "$files" | head -1)
# Verify all files exist
echo "Verifying input files..."
for file in $files; do
if [ ! -f "$file" ]; then
echo "Error: File not found: $file"
exit 1
fi
done
file_count=$(echo "$files" | wc -w)
echo "Found $file_count files to combine"
echo ""
# Get header from first file
header_lines=$(grep -n "END OF HEADER" "$first_file" | cut -d: -f1)
if [ -z "$header_lines" ]; then
echo "Error: No RINEX header found in $first_file"
exit 1
fi
# Detect RINEX version from first line
rinex_version=$(head -1 "$first_file" | awk '{print substr($1,1,1)}')
if [ "$rinex_version" = "3" ]; then
echo "Detected: RINEX 3.x format"
epoch_pattern="^>"
elif [ "$rinex_version" = "2" ]; then
echo "Detected: RINEX 2.x format"
epoch_pattern="^ [0-9][0-9] [ 0-9][0-9] [ 0-9][0-9]"
else
echo "Warning: Could not detect RINEX version, assuming 2.x"
epoch_pattern="^ [0-9][0-9] [ 0-9][0-9] [ 0-9][0-9]"
fi
echo ""
# Copy header from first file
head -n $header_lines "$first_file" > "$output"
echo "✓ Added header from $first_file"
# Append data from all files (skip their headers)
total_lines=0
for file in $files; do
data_start=$(($(grep -n "END OF HEADER" "$file" | cut -d: -f1) + 1))
data_lines=$(tail -n +$data_start "$file" | wc -l)
tail -n +$data_start "$file" >> "$output"
total_lines=$((total_lines + data_lines))
echo "✓ Added data from $file ($data_lines lines)"
done
echo ""
# Count epochs based on detected version
if [ "$rinex_version" = "3" ]; then
epochs=$(grep -c "^>" "$output" 2>/dev/null || echo "0")
else
epochs=$(grep -c "^ [0-9][0-9] [ 0-9][0-9] [ 0-9][0-9]" "$output" 2>/dev/null || echo "0")
fi
# Get file size
file_size=$(ls -lh "$output" | awk '{print $5}')
echo "========================================="
echo "✓ Combined file created: $output"
echo " RINEX Version: $rinex_version.x"
echo " Size: $file_size"
echo " Files combined: $file_count"
echo " Total data lines: $total_lines"
echo " Total epochs: $epochs"
echo "========================================="
# Verify reasonable results
if [ $epochs -eq 0 ]; then
echo ""
echo "⚠ WARNING: No epochs detected!"
echo " This may indicate a problem with the combined file."
echo " Checking first few lines of output..."
echo ""
head -40 "$output"
exit 1
fi
# Show time range if epochs found
if [ $epochs -gt 0 ]; then
echo ""
echo "Time range:"
if [ "$rinex_version" = "3" ]; then
# RINEX 3.x format: > YYYY MM DD HH MM SS.SSSSSSS
first_epoch=$(grep "^>" "$output" | head -1)
last_epoch=$(grep "^>" "$output" | tail -1)
first_time=$(echo "$first_epoch" | awk '{printf "%s-%02d-%02d %02d:%02d:%06.3f", $2, $3, $4, $5, $6, $7}')
last_time=$(echo "$last_epoch" | awk '{printf "%s-%02d-%02d %02d:%02d:%06.3f", $2, $3, $4, $5, $6, $7}')
else
# RINEX 2.x format: YY MM DD HH MM SS.SSSSSSS
first_epoch=$(grep "^ [0-9][0-9] [ 0-9][0-9] [ 0-9][0-9]" "$output" | head -1)
last_epoch=$(grep "^ [0-9][0-9] [ 0-9][0-9] [ 0-9][0-9]" "$output" | tail -1)
first_time=$(echo "$first_epoch" | awk '{printf "20%02d-%02d-%02d %02d:%02d:%011.7f", $1, $2, $3, $4, $5, $6}')
last_time=$(echo "$last_epoch" | awk '{printf "20%02d-%02d-%02d %02d:%02d:%011.7f", $1, $2, $3, $4, $5, $6}')
fi
echo " First: $first_time"
echo " Last: $last_time"
fi
# Verify single header (no corruption)
header_count=$(grep -c "END OF HEADER" "$output")
if [ $header_count -ne 1 ]; then
echo ""
echo "⚠ WARNING: Found $header_count headers (expected 1)"
echo " The file may be corrupted!"
exit 1
fi
echo ""
echo "✓ File validation passed"

1584
process_gps.py Normal file

File diff suppressed because it is too large Load diff

3
run.sh Executable file
View file

@ -0,0 +1,3 @@
source ./venv/bin/activate
python3 process_gps.py -h
python3 process_gps.py 2026-7-23_124722_serial-COM7.ubx

193
setup.sh Executable file
View file

@ -0,0 +1,193 @@
#!/usr/bin/env bash
# setup.sh - One-time environment bootstrap for the GPS processing pipeline.
#
# Installs (all local to this directory, no sudo required):
# venv/ Python env: pyubx2, pygnssutils, pyserial, numpy, pandas, pyproj
# tools/bin/ RTKLIB demo5 v2.5.1 (convbin, rnx2rtkp, pos2kml) [stock apt fallback]
# tools/antex/ igs20.atx + ngs20.atx antenna models; tools/antenna.json (rover antenna)
# tools/bin/CRX2RNX Hatanaka RINEX decompressor (fallback for .d CORS files)
# tools/cache/ NGS CORS station catalog
#
# Idempotent: re-running skips anything already installed.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
TOOLS="$SCRIPT_DIR/tools"
VENV="$SCRIPT_DIR/venv"
DEMO5_TAG="v2.5.1"
DEMO5_REPO="https://github.com/rtklibexplorer/RTKLIB"
PASS=()
WARN=()
say() { printf '\n=== %s ===\n' "$*"; }
ok() { printf ' [ok] %s\n' "$*"; PASS+=("$*"); }
warn() { printf ' [WARN] %s\n' "$*"; WARN+=("$*"); }
skip() { printf ' [skip] %s (already present)\n' "$*"; }
fetch() { # fetch <url> <dest>
curl -fsSL --max-time 300 -o "$2" "$1"
}
# ---------------------------------------------------------------- 1. Python venv
say "1/6 Python virtual environment"
if [ -x "$VENV/bin/python" ]; then
skip "venv"
else
python3 -m venv "$VENV" || { echo "FATAL: venv creation failed"; exit 1; }
fi
if "$VENV/bin/python" -c "import pyubx2, pygnssutils, serial, numpy, pandas, pyproj, matplotlib" 2>/dev/null; then
skip "python packages"
else
"$VENV/bin/pip" install --quiet --upgrade pip
"$VENV/bin/pip" install --quiet pyubx2 pygnssutils pyserial numpy pandas pyproj matplotlib \
|| { echo "FATAL: pip install failed"; exit 1; }
fi
"$VENV/bin/python" -c "import pyubx2, pygnssutils, serial, numpy, pandas, pyproj, matplotlib" \
&& ok "venv with pyubx2/pygnssutils/pyserial/numpy/pandas/pyproj/matplotlib" \
|| { echo "FATAL: package import check failed"; exit 1; }
mkdir -p "$TOOLS/bin" "$TOOLS/antex" "$TOOLS/cache" "$TOOLS/src"
# ---------------------------------------------------------------- 2. RTKLIB demo5
say "2/6 RTKLIB demo5 $DEMO5_TAG (convbin, rnx2rtkp, pos2kml)"
build_demo5_app() { # build_demo5_app <appname>
local app="$1"
local gccdir="$TOOLS/src/RTKLIB/app/consapp/$app/gcc"
[ -d "$gccdir" ] || return 1
# -lgfortran in some makefiles is vestigial; try without it first.
if ! make -C "$gccdir" -j"$(nproc)" LDLIBS="-lm" >"$TOOLS/src/build_$app.log" 2>&1; then
make -C "$gccdir" clean >/dev/null 2>&1
make -C "$gccdir" -j"$(nproc)" >>"$TOOLS/src/build_$app.log" 2>&1 || return 1
fi
cp "$gccdir/$app" "$TOOLS/bin/$app"
}
if [ -x "$TOOLS/bin/convbin" ] && [ -x "$TOOLS/bin/rnx2rtkp" ] && [ -x "$TOOLS/bin/pos2kml" ]; then
skip "RTKLIB binaries"
[ -f "$TOOLS/STOCK_RTKLIB" ] && warn "using STOCK RTKLIB (BDS/L5 signals will be missing from RINEX)"
else
if [ ! -d "$TOOLS/src/RTKLIB" ]; then
git clone --depth 1 --branch "$DEMO5_TAG" "$DEMO5_REPO" "$TOOLS/src/RTKLIB" \
|| warn "git clone of demo5 RTKLIB failed"
fi
built=true
for app in convbin rnx2rtkp pos2kml; do
if build_demo5_app "$app"; then
printf ' built %s\n' "$app"
else
warn "demo5 build failed for $app (see tools/src/build_$app.log)"
built=false
fi
done
if $built; then
rm -f "$TOOLS/STOCK_RTKLIB"
ok "RTKLIB demo5 $DEMO5_TAG built from source"
else
# Fallback: stock RTKLIB 2.4.3 from the Ubuntu archive (works, but silently
# drops BeiDou + L5/E5 observations from this receiver's raw data).
echo " falling back to stock RTKLIB 2.4.3 from apt archive..."
tmpd="$(mktemp -d)"
if (cd "$tmpd" && apt-get download rtklib >/dev/null 2>&1 && dpkg -x rtklib_*.deb x); then
cp "$tmpd"/x/usr/bin/{convbin,rnx2rtkp,pos2kml} "$TOOLS/bin/"
touch "$TOOLS/STOCK_RTKLIB"
warn "STOCK RTKLIB installed - BDS/L5 will be MISSING from RINEX output"
else
warn "stock RTKLIB fallback also failed - pipeline cannot run"
fi
rm -rf "$tmpd"
fi
fi
for app in convbin rnx2rtkp pos2kml; do
[ -x "$TOOLS/bin/$app" ] || warn "$app missing from tools/bin"
done
# ---------------------------------------------------------------- 3. Antenna models
say "3/6 Antenna calibration models (ANTEX)"
if [ -s "$TOOLS/antex/igs20.atx" ]; then skip "igs20.atx"; else
fetch "https://files.igs.org/pub/station/general/igs20.atx" "$TOOLS/antex/igs20.atx" \
&& ok "igs20.atx (IGS antenna models incl. CORS base antennas)" \
|| warn "igs20.atx download failed"
fi
if [ -s "$TOOLS/antex/ngs20.atx" ]; then skip "ngs20.atx"; else
fetch "https://geodesy.noaa.gov/ANTCAL/LoadFile?file=ngs20.atx" "$TOOLS/antex/ngs20.atx" \
&& ok "ngs20.atx (NGS absolute calibrations incl. ArduSimple)" \
|| warn "ngs20.atx download failed"
fi
# Resolve the rover antenna (NGS calibration AS-ANT3BCAL01, ArduSimple) to its
# exact 20-char ANTEX type string, consumed by process_gps.py as ant1-anttype.
"$VENV/bin/python" - "$TOOLS" <<'EOF'
import json, re, sys, pathlib
tools = pathlib.Path(sys.argv[1])
result = {"antex_name": None, "source": None, "candidates": []}
for atx in ("ngs20.atx", "igs20.atx"):
p = tools / "antex" / atx
if not p.is_file():
continue
for line in p.open(encoding="utf-8", errors="replace"):
if "TYPE / SERIAL" in line and "ANT3B" in line[:20].upper():
name = line[:20].rstrip()
result["candidates"].append({"name": name, "file": atx})
if result["antex_name"] is None:
result["antex_name"], result["source"] = name, atx
(tools / "antenna.json").write_text(json.dumps(result, indent=2))
if result["antex_name"]:
print(f' [ok] rover antenna resolved: "{result["antex_name"]}" ({result["source"]})')
else:
print(" [WARN] no ANT3B antenna found in ANTEX files - PPK will run without rover PCO/PCV")
EOF
# ---------------------------------------------------------------- 4. CRX2RNX
say "4/6 CRX2RNX (Hatanaka RINEX decompressor, fallback for .d CORS files)"
if [ -x "$TOOLS/bin/CRX2RNX" ]; then
skip "CRX2RNX"
else
tmpd="$(mktemp -d)"
if fetch "https://terras.gsi.go.jp/ja/crx2rnx/RNXCMP_4.2.0_Linux_gcc_64bit.tar.gz" "$tmpd/rnxcmp.tar.gz" \
&& tar -xzf "$tmpd/rnxcmp.tar.gz" -C "$tmpd" \
&& cp "$tmpd"/RNXCMP_*/bin/CRX2RNX "$TOOLS/bin/CRX2RNX" 2>/dev/null \
|| cp "$tmpd"/RNXCMP_*/CRX2RNX "$TOOLS/bin/CRX2RNX" 2>/dev/null; then
chmod +x "$TOOLS/bin/CRX2RNX"
ok "CRX2RNX 4.2.0"
else
warn "CRX2RNX install failed (only needed for CORS stations without plain .o files)"
fi
rm -rf "$tmpd"
fi
# ---------------------------------------------------------------- 5. CORS catalog
say "5/6 NGS CORS station catalog"
if [ -s "$TOOLS/cache/itrf2020_geo.comp.txt" ]; then
skip "station catalog"
else
fetch "https://geodesy.noaa.gov/corsdata/coord/coord_20/itrf2020_geo.comp.txt" \
"$TOOLS/cache/itrf2020_geo.comp.txt" \
&& ok "CORS station catalog (ITRF2020 coordinates)" \
|| warn "CORS catalog download failed (process_gps.py will retry at run time)"
fi
# ---------------------------------------------------------------- 6. Self-check
say "6/6 Self-check summary"
for app in convbin rnx2rtkp pos2kml; do
if [ -x "$TOOLS/bin/$app" ]; then
printf ' %-10s %s\n' "$app" "$("$TOOLS/bin/$app" -h 2>&1 | grep -m1 -iE 'synops|usage|version' || echo present)"
fi
done
[ -x "$TOOLS/bin/CRX2RNX" ] && printf ' %-10s %s\n' "CRX2RNX" "present"
[ -s "$TOOLS/cache/itrf2020_geo.comp.txt" ] && printf ' %-10s %d stations\n' "catalog" "$(wc -l < "$TOOLS/cache/itrf2020_geo.comp.txt")"
[ -s "$TOOLS/antenna.json" ] && printf ' %-10s %s\n' "antenna" "$(cat "$TOOLS/antenna.json" | "$VENV/bin/python" -c 'import json,sys; print(json.load(sys.stdin)["antex_name"])')"
echo
if [ ${#WARN[@]} -gt 0 ]; then
echo "Completed with ${#WARN[@]} warning(s):"
printf ' - %s\n' "${WARN[@]}"
else
echo "Setup complete - no warnings."
fi
echo
echo "Mode 1 (real-time RTK) additionally requires free registration at"
echo " http://rtn.usm.edu/RegisterAccount.aspx (Mississippi GCGC RTN)"
echo "then: export GCGC_USER=<user> GCGC_PASS=<pass>"

446
stream_gps.py Normal file
View file

@ -0,0 +1,446 @@
#!/usr/bin/env python3
"""
stream_gps.py - Mode 1: continuous real-time positioning for the u-blox ZED-X20P.
Best-available correction tier is selected automatically:
1. Network RTK via NTRIP (default: Mississippi GCGC RTN, free registration at
http://rtn.usm.edu/RegisterAccount.aspx) -> onboard RTK engine, 1-3 cm.
Credentials: env GCGC_USER / GCGC_PASS (or --ntrip-user/--ntrip-pass).
2. Galileo HAS (E6-B, no internet needed) -> ~20-25 cm. Requires firmware
HPG >= 2.10 (auto-probed via MON-VER; this project's module reports 2.10).
3. Neither available -> the onboard multiband+SBAS solution (~1 m) cannot be
improved in real time; per project policy the enhanced mode is SKIPPED
(run with --log-only to still record a .ubx for post-processing).
Every session simultaneously logs raw RXM-RAWX/SFRBX to a .ubx file, so a
streaming session can always be post-processed later with process_gps.py.
Buoy deployment defaults: the receiver's dynamic platform model is set to "sea"
(--dynmodel to override) and the NTRIP GGA uplink sends the LIVE position so the
VRS reference follows platform drift (--gga-fixed to disable for a fixed antenna).
Outputs: live console status, stream_<ts>.csv (decimal-degree lat/lon),
stream_<ts>.ubx (raw capture).
Usage:
venv/bin/python stream_gps.py --port /dev/ttyUSB0 (Linux)
venv/bin/python stream_gps.py --port COM7 (Windows)
venv/bin/python stream_gps.py --replay capture.ubx (offline test)
Datum note: NTRIP corrections define the output frame. GCGC RTN broadcasts
NAD83(2011) epoch 2010.00 - constant ~1.5 m from WGS84/ITRF in CONUS; the CSV
header records which tier (and therefore frame) was active.
"""
import argparse
import csv
import os
import queue
import signal
import socket
import sys
import threading
import time
from collections import Counter
from datetime import datetime, timezone
from pathlib import Path
from pyubx2 import UBXMessage, UBXReader, UBX_PROTOCOL, SET_LAYER_RAM
FIX_NAME = {0: "none", 1: "DR", 2: "2D", 3: "3D", 4: "GNSS+DR", 5: "time-only"}
CARR_NAME = {0: "", 1: "RTK-FLOAT", 2: "RTK-FIXED"}
# UBX-CFG keys (u-blox X20 HPG interface descriptions; NAVCOR from HPG 2.10 JSON)
K = {
"UART1_BAUD": 0x40520001,
"UART1_IN_RTCM3": 0x10730004,
"USB_IN_RTCM3": 0x10770004,
"PVT_UART1": 0x20910007, "PVT_USB": 0x20910009,
"HPPOSLLH_UART1": 0x20910034, "HPPOSLLH_USB": 0x20910036,
"RAWX_UART1": 0x209102a5, "RAWX_USB": 0x209102a7,
"SFRBX_UART1": 0x20910232, "SFRBX_USB": 0x20910234,
"NAVCOR_ENABLE_HOST": 0x100d0001, # HPG >= 2.10
"NAVCOR_ENABLE_GAL_HAS": 0x100d0002, # HPG >= 2.10
"DYNMODEL": 0x20110021, # CFG-NAVSPG-DYNMODEL
}
# CFG-NAVSPG-DYNMODEL values; default "sea" for the buoy deployment
DYNMODEL = {"portable": 0, "stationary": 2, "pedestrian": 3, "automotive": 4,
"sea": 5, "airborne1g": 6, "airborne2g": 7, "airborne4g": 8, "wrist": 9}
def log(msg):
print(f"[stream_gps] {msg}", flush=True)
# ----------------------------------------------------------------------------
# Receiver configuration
# ----------------------------------------------------------------------------
def valset(ser, ubr, cfg, label):
"""Send CFG-VALSET (RAM) and wait briefly for ACK; returns True on ACK-ACK."""
msg = UBXMessage.config_set(layers=SET_LAYER_RAM, transaction=0, cfgData=cfg)
ser.write(msg.serialize())
t_end = time.time() + 2.0
while time.time() < t_end:
try:
_, parsed = ubr.read()
except Exception:
continue
if parsed is None:
continue
if parsed.identity == "ACK-ACK":
return True
if parsed.identity == "ACK-NAK":
log(f" NAK for {label}")
return False
log(f" no ACK for {label} (continuing)")
return False
def poll_fw_version(ser, ubr):
"""Poll MON-VER; returns firmware string like 'HPG 2.10' or None."""
ser.write(UBXMessage("MON", "MON-VER", 2).serialize()) # 2 = POLL msgmode
t_end = time.time() + 3.0
while time.time() < t_end:
try:
_, parsed = ubr.read()
except Exception:
continue
if parsed is not None and parsed.identity == "MON-VER":
sw = getattr(parsed, "swVersion", b"")
sw = sw.decode(errors="replace") if isinstance(sw, bytes) else str(sw)
return sw.strip("\x00 ")
return None
def configure_receiver(ser, ubr, want_has, dynmodel):
"""Enable RTCM input, HP output, raw logging; optionally HAS. RAM layer only."""
base_cfg = [
(K["UART1_IN_RTCM3"], 1), (K["USB_IN_RTCM3"], 1),
(K["PVT_UART1"], 1), (K["PVT_USB"], 1),
(K["HPPOSLLH_UART1"], 1), (K["HPPOSLLH_USB"], 1),
(K["RAWX_UART1"], 1), (K["RAWX_USB"], 1),
(K["SFRBX_UART1"], 1), (K["SFRBX_USB"], 1),
(K["DYNMODEL"], DYNMODEL[dynmodel]),
]
ok = valset(ser, ubr, base_cfg, f"base output/input config (dynmodel={dynmodel})")
if want_has:
ok_has = valset(ser, ubr, [(K["NAVCOR_ENABLE_HOST"], 0),
(K["NAVCOR_ENABLE_GAL_HAS"], 1)], "GAL HAS enable")
return ok, ok_has
return ok, False
# ----------------------------------------------------------------------------
# NTRIP
# ----------------------------------------------------------------------------
def caster_reachable(server, port, timeout=8):
try:
with socket.create_connection((server, port), timeout=timeout):
return True
except OSError:
return False
class SerialRTCMSink:
"""Write-only wrapper handed to GNSSNTRIPClient as its output stream."""
def __init__(self, ser):
self._ser = ser
self.bytes_out = 0
def write(self, data):
# GNSSNTRIPClient may hand us (raw, parsed) tuples or raw bytes
if isinstance(data, tuple):
data = data[0]
if isinstance(data, (bytes, bytearray)):
self._ser.write(bytes(data))
self.bytes_out += len(data)
# queue-style interface (pygnssutils uses .put on Queue outputs)
def put(self, item):
self.write(item)
def start_ntrip(args, sink, reflat, reflon, app=None):
"""app (with .get_coordinates()) => live GGA that follows a drifting platform;
without app, falls back to a fixed reference position."""
from pygnssutils import GNSSNTRIPClient
client = GNSSNTRIPClient(app=app)
kwargs = dict(
server=args.ntrip_caster, port=args.ntrip_port, https=0,
mountpoint=args.ntrip_mount, datatype="RTCM",
ntripuser=args.ntrip_user, ntrippassword=args.ntrip_pass,
ggainterval=args.gga_interval,
ggamode=0 if app is not None else 1, # 0 = live from receiver, 1 = fixed
reflat=reflat, reflon=reflon, refalt=0.0, refsep=0.0,
output=sink,
)
ok = client.run(**kwargs)
return client if ok else None
# ----------------------------------------------------------------------------
# Main streaming loop
# ----------------------------------------------------------------------------
class Session:
def __init__(self, args):
self.args = args
self.q = queue.Queue()
self.stop = threading.Event()
self.fix_hist = Counter()
self.hacc = []
self.tier = "none"
# latest fix, served to GNSSNTRIPClient.get_coordinates() for live GGA
self.live = {"lat": 0.0, "lon": 0.0, "alt": 0.0, "sep": 0.0}
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
self.csv_path = Path(f"stream_{ts}.csv")
self.ubx_path = Path(f"stream_{ts}.ubx")
def get_coordinates(self):
"""pygnssutils app interface: live position for the NTRIP GGA uplink,
so the VRS reference follows a drifting platform (buoy)."""
return dict(self.live)
def reader_thread(self, stream, raw_log):
"""Serial/file -> raw .ubx tee -> parsed NAV messages -> queue."""
ubr = UBXReader(stream, protfilter=UBX_PROTOCOL, quitonerror=0)
while not self.stop.is_set():
try:
raw, parsed = ubr.read()
except Exception:
if self.args.replay:
break
continue
if raw is None:
if self.args.replay:
break
continue
if raw_log:
raw_log.write(raw)
if parsed is None:
continue
if parsed.identity in ("NAV-PVT", "NAV-HPPOSLLH"):
self.q.put(parsed)
self.q.put(None)
def run(self):
args = self.args
# ---------------- input: serial or replay file ----------------------
if args.replay:
stream = open(args.replay, "rb")
ser = None
log(f"REPLAY mode from {args.replay} (no config writes, no NTRIP)")
else:
import serial
from serial.tools import list_ports
if not args.port:
ports = [p.device for p in list_ports.comports()]
sys.exit(f"--port required. Detected ports: {ports or 'none'}")
ser = serial.Serial(args.port, args.baud, timeout=1)
stream = ser
log(f"connected {args.port} @ {args.baud}")
cfg_ubr = UBXReader(ser, protfilter=UBX_PROTOCOL, quitonerror=0)
fw = poll_fw_version(ser, cfg_ubr)
log(f"firmware: {fw or 'unknown'}")
fw_has = bool(fw and any(
fw.split("HPG")[-1].strip().startswith(v)
for v in ("2.1", "2.2", "3.")))
# ------------- correction tier selection -------------------------
ntrip_ok = False
if not args.log_only and args.ntrip_user and args.ntrip_pass:
ntrip_ok = caster_reachable(args.ntrip_caster, args.ntrip_port)
if not ntrip_ok:
log(f"NTRIP caster {args.ntrip_caster}:{args.ntrip_port} unreachable")
elif not args.log_only:
log("no NTRIP credentials (set GCGC_USER/GCGC_PASS or --ntrip-user/-pass)")
want_has = not ntrip_ok and not args.log_only and fw_has
if ntrip_ok:
self.tier = f"NTRIP RTK ({args.ntrip_caster}/{args.ntrip_mount})"
elif want_has:
self.tier = "Galileo HAS (E6-B, ~20 cm)"
elif args.log_only:
self.tier = "log-only (onboard solution)"
else:
log("no correction source available that improves on the onboard "
"solution (NTRIP unreachable/no credentials; HAS needs FW >= "
"HPG 2.10). Skipping enhanced streaming per project policy.")
log("use --log-only to record raw data for post-processing anyway.")
ser.close()
return 2
ok_base, ok_has = configure_receiver(ser, cfg_ubr, want_has, args.dynmodel)
log(f"receiver configured (base={'ok' if ok_base else 'partial'}"
+ (f", HAS={'ok' if ok_has else 'failed'}" if want_has else "") + ")")
log(f"correction tier: {self.tier}")
# ---------------- outputs -------------------------------------------
raw_log = None if args.replay else open(self.ubx_path, "wb")
csv_f = open(self.csv_path, "w", newline="")
csv_f.write(f"# stream_gps.py session - correction tier: {self.tier}\n")
csv_f.write("# datum: NAD83(2011) if GCGC NTRIP tier, else WGS84/ITRF\n")
writer = csv.writer(csv_f)
writer.writerow(["utc", "lat_deg", "lon_deg", "ellip_height_m",
"hAcc_m", "vAcc_m", "fixType", "carrSoln", "numSV"])
# ---------------- threads -------------------------------------------
t_read = threading.Thread(target=self.reader_thread, args=(stream, raw_log),
daemon=True)
t_read.start()
ntrip_client, sink = None, None
if ser is not None and self.tier.startswith("NTRIP"):
# need a first fix for the GGA reference position
reflat, reflon = args.lat, args.lon
if reflat is None:
log("waiting for first fix to seed NTRIP GGA position ...")
first = self._wait_first_fix(timeout=60)
if first is None:
log("no fix within 60 s; using 0/0 GGA (VRS may reject)")
reflat = reflon = 0.0
else:
reflat, reflon = first
sink = SerialRTCMSink(ser)
app = None if args.gga_fixed else self
log("GGA uplink: " + ("fixed reference" if app is None
else "live (follows platform drift)"))
ntrip_client = start_ntrip(args, sink, reflat, reflon, app=app)
if ntrip_client is None:
log("NTRIP connection failed (check credentials/mountpoint) - "
"continuing with onboard solution only")
self.tier += " [FAILED - onboard only]"
signal.signal(signal.SIGINT, lambda *_: self.stop.set())
# ---------------- consume + display ---------------------------------
last_pvt = {}
try:
while not self.stop.is_set():
try:
m = self.q.get(timeout=1.0)
except queue.Empty:
continue
if m is None:
break
if m.identity == "NAV-PVT":
last_pvt = dict(
utc=datetime(m.year, m.month, m.day, m.hour, m.min, m.second,
tzinfo=timezone.utc),
fixType=m.fixType, carrSoln=m.carrSoln, numSV=m.numSV,
lat=m.lat, lon=m.lon, height=m.height / 1000.0,
hAcc=m.hAcc / 1000.0, vAcc=m.vAcc / 1000.0)
elif m.identity == "NAV-HPPOSLLH" and last_pvt:
# high-precision refinement: lat/lon + Hp components (deg)
last_pvt["lat"] = m.lat + m.latHp
last_pvt["lon"] = m.lon + m.lonHp
last_pvt["height"] = (m.height + m.heightHp) / 1000.0
last_pvt["hAcc"] = m.hAcc / 1000.0
last_pvt["vAcc"] = m.vAcc / 1000.0
continue
if not last_pvt:
continue
p = last_pvt
self.live.update(lat=p["lat"], lon=p["lon"], alt=p["height"])
self.fix_hist[(p["fixType"], p["carrSoln"])] += 1
self.hacc.append(p["hAcc"])
writer.writerow([p["utc"].isoformat(), f"{p['lat']:.9f}",
f"{p['lon']:.9f}", f"{p['height']:.3f}",
f"{p['hAcc']:.3f}", f"{p['vAcc']:.3f}",
p["fixType"], p["carrSoln"], p["numSV"]])
n_epochs = sum(self.fix_hist.values())
if not sys.stdout.isatty() and n_epochs % 60 != 1:
continue # avoid log spam when piped
fixlab = FIX_NAME.get(p["fixType"], "?")
if p["carrSoln"]:
fixlab += "/" + CARR_NAME[p["carrSoln"]]
rtcm = f" rtcm {sink.bytes_out//1024} kB" if sink else ""
end = " " if sys.stdout.isatty() else "\n"
status = (f"\r{p['utc']:%H:%M:%S} {fixlab:<12s} "
f"{p['lat']:+.9f} {p['lon']:+.9f} h={p['height']:8.3f} m "
f"hAcc={p['hAcc']:6.3f} m sv={p['numSV']:2d}{rtcm}{end}")
sys.stdout.write(status)
sys.stdout.flush()
finally:
self.stop.set()
print()
if ntrip_client is not None:
try:
ntrip_client.stop()
except Exception:
pass
csv_f.close()
if raw_log:
raw_log.close()
if ser:
ser.close()
self._summary()
return 0
def _wait_first_fix(self, timeout):
t_end = time.time() + timeout
while time.time() < t_end:
try:
m = self.q.get(timeout=1.0)
except queue.Empty:
continue
if m is not None and m.identity == "NAV-PVT" and m.fixType >= 2:
self.live.update(lat=m.lat, lon=m.lon, alt=m.height / 1000.0)
return (m.lat, m.lon)
return None
def _summary(self):
n = sum(self.fix_hist.values())
if not n:
log("no position epochs received")
return
log(f"session summary: {n} epochs, tier = {self.tier}")
for (fix, carr), c in sorted(self.fix_hist.items(), key=lambda kv: -kv[1]):
lab = FIX_NAME.get(fix, "?") + ("/" + CARR_NAME[carr] if carr else "")
log(f" {lab:<14s} {c:6d} ({100*c/n:.1f}%)")
h = sorted(self.hacc)
log(f" hAcc: mean {sum(h)/len(h):.3f} m, median {h[len(h)//2]:.3f} m, "
f"95% {h[int(len(h)*0.95)]:.3f} m")
if not self.args.replay:
log(f" outputs: {self.csv_path}, {self.ubx_path}")
log(f" post-process the raw log: venv/bin/python process_gps.py {self.ubx_path}")
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--port", help="serial port (COM7, /dev/ttyUSB0, ...)")
ap.add_argument("--baud", type=int, default=38400)
ap.add_argument("--replay", type=Path, help="read a .ubx file instead of serial "
"(offline parser/CSV test mode)")
ap.add_argument("--log-only", action="store_true",
help="skip corrections; just log raw data + onboard positions")
ap.add_argument("--lat", type=float, help="initial latitude for NTRIP GGA (else first fix)")
ap.add_argument("--lon", type=float, help="initial longitude for NTRIP GGA")
ap.add_argument("--gga-fixed", action="store_true",
help="send a FIXED GGA reference instead of live position "
"(only for a truly stationary antenna; default is live, "
"so the VRS reference follows a drifting buoy)")
ap.add_argument("--dynmodel", default="sea", choices=sorted(DYNMODEL),
help="receiver dynamic platform model, set in RAM at startup "
"(default: sea, for buoy deployment)")
ap.add_argument("--ntrip-caster", default="rtn.usm.edu")
ap.add_argument("--ntrip-port", type=int, default=2101)
ap.add_argument("--ntrip-mount", default="VRS_GNSS_RTCM34_NAD83")
ap.add_argument("--ntrip-user", default=os.environ.get("GCGC_USER"))
ap.add_argument("--ntrip-pass", default=os.environ.get("GCGC_PASS"))
ap.add_argument("--gga-interval", type=int, default=15)
args = ap.parse_args()
if not args.replay and not args.port:
ap.error("one of --port or --replay is required")
sys.exit(Session(args).run())
if __name__ == "__main__":
main()