Initial commit of fully working GPS processing pipeline. See README for more information.
This commit is contained in:
commit
65a4836608
8 changed files with 2620 additions and 0 deletions
18
.gitignore
vendored
Normal file
18
.gitignore
vendored
Normal 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
193
README.md
Normal 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
42
notes/RTK Processing Notes.txt
Executable 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
141
notes/combine_rinex.sh
Executable 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
1584
process_gps.py
Normal file
File diff suppressed because it is too large
Load diff
3
run.sh
Executable file
3
run.sh
Executable 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
193
setup.sh
Executable 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
446
stream_gps.py
Normal 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()
|
||||||
Loading…
Add table
Reference in a new issue