Pipeline (process_gps.py): - New standard solution ppk_vrs: a virtual reference station synthesized at the capture position from the GCGC master station the CORS stage already fetched (zero baseline). Gated on GCGC availability; --no-vrs to disable. Engine validated in experiments/ (gates G1/G2a) and benchmarked equivalent to Trimble Pivot's commercial VRS on ground truth. - Track output now selects among PPK-family solutions by measured dispersion, not nominal baseline (protects against poor network-edge VRS data). - Warm-up exclusion: first 5 minutes (--warmup-min) removed from all quality metrics and figures; prominent note in both reports; data outputs unchanged. - Professional report language: abbreviations expanded on first use, solution methods defined, no shorthand in tables or figure labels; fig5 reframed as "self-reported precision (uncalibrated) vs measured dispersion" with the optimism factor annotated. - Local-base robustness: refuse to combine base files at different positions; RTCM logs and Data Shop RINEX accepted for the parallel GCGC method. Streaming (stream_gps.py): - Credentials via gitignored gcgc.env (template gcgc.env.example), env vars take precedence; RTCM correction stream recorded to stream_<ts>.rtcm3 for zero-baseline post-processing; live GGA uplink and sea dynamic model remain the buoy defaults; warm-up excluded from session statistics. Experiments (new): - vbs_synth.py: geometric virtual-base synthesis engine (RINEX 2.11 patcher, SP3 orbits, light-time + Earth-rotation per position, clock-robust). - vbs_iono.py: carrier-leveled slant-ionosphere estimation + station-network interpolation with leave-one-out validation (median 7 cm; 1.2 cm / 10 km growth; DCBs as daily constants via median polish). - vbs_compare.py: observation-domain comparison of commercial VRS files vs synthesized bases (ambiguity-detrended correction content). - FINDINGS.md: gate results, commercial benchmark, offshore analysis, and promotion rationale. Docs: README validation-results section explaining why the synthesized VRS (kinematic) is the preferred solution for buoy deployments; VRS orders and experiment data moved under gitignored experiments/data/.
2152 lines
103 KiB
Python
2152 lines
103 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
process_gps.py - Post-processing + diagnostics pipeline for u-blox ZED-X20P captures.
|
|
|
|
Pipeline (Mode 2 of the GPS project):
|
|
1. inventory - parse the .ubx (pyubx2): RXM-RAWX/SFRBX, NAV-PVT/SAT/SIG, MON-RF
|
|
2. rinex - convert to RINEX 3.04 obs+nav (RTKLIB demo5 convbin)
|
|
3. single - standalone single-point solution (rnx2rtkp -p 0), ~1 m, offline-safe
|
|
4. ppk - PPK vs auto-fetched NGS CORS base (cm-level when fixed)
|
|
5. ppp - optional (--ppp) PPP vs open IGS orbit/clock products (BKG/ESA)
|
|
6. track - track.csv (decimal-degree lat/lon @ --interval) + track.kml
|
|
7. diagnostics - diagnostics.json + report.md (constellation, iono, error, timing, RF)
|
|
|
|
Outputs land in processed/<capture-stem>/. External stages (rinex/single/ppk/ppp)
|
|
are idempotent: outputs are reused if present, so the script can be re-run later to
|
|
pick up CORS/IGS data that had not been published yet. Run setup.sh first.
|
|
|
|
Motion policy (buoy deployment): processing is ALWAYS kinematic by default - static
|
|
is never inferred from the data, because a slowly drifting platform is
|
|
indistinguishable from measurement noise over short windows. Pass --static only
|
|
when the antenna is independently known to have been fixed.
|
|
|
|
Usage:
|
|
venv/bin/python process_gps.py <capture.ubx> [--interval N] [--static]
|
|
[--no-ppk] [--ppp] [--base SSSS] [--max-base-km KM]
|
|
[--force-stage {rinex,single,ppk,ppp,track}]
|
|
"""
|
|
|
|
import argparse
|
|
import gzip
|
|
import json
|
|
import math
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
from collections import Counter
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
from pyubx2 import UBXReader, UBX_PROTOCOL
|
|
|
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
TOOLS = SCRIPT_DIR / "tools"
|
|
BIN = TOOLS / "bin"
|
|
ANTEX_DIR = TOOLS / "antex"
|
|
CACHE = TOOLS / "cache"
|
|
# combine_rinex.sh lives in notes/ since the repo reorganization; accept either spot
|
|
COMBINE_SH = next((p for p in (SCRIPT_DIR / "notes" / "combine_rinex.sh",
|
|
SCRIPT_DIR / "combine_rinex.sh") if p.is_file()),
|
|
SCRIPT_DIR / "notes" / "combine_rinex.sh")
|
|
|
|
GPS_EPOCH = datetime(1980, 1, 6, tzinfo=timezone.utc)
|
|
CORS_MIRRORS = [
|
|
"https://geodesy.noaa.gov/corsdata",
|
|
"https://noaa-cors-pds.s3.amazonaws.com",
|
|
]
|
|
IGS_PRODUCT_MIRRORS = [ # (base url, filename prefix regex)
|
|
("https://igs.bkg.bund.de/root_ftp/IGS/products", r"IGS0OPS(FIN|RAP)"),
|
|
("http://navigation-office.esa.int/products/gnss-products", r"ESA0OPS(FIN|RAP)"),
|
|
]
|
|
|
|
GNSS_NAME = {0: "GPS", 1: "SBAS", 2: "Galileo", 3: "BeiDou", 5: "QZSS", 6: "GLONASS", 7: "NavIC"}
|
|
QUALITY_NAME = {1: "fix", 2: "float", 3: "sbas", 4: "dgps", 5: "single", 6: "ppp"}
|
|
IONO_MODEL = {0: "none/default", 1: "Klobuchar (GPS)", 2: "SBAS", 3: "Klobuchar (BDS)",
|
|
4: "NTCM (GAL)", 8: "dual-frequency"}
|
|
CORR_SOURCE = {0: "none", 1: "SBAS", 2: "BDS", 3: "RTCM2", 4: "RTCM3 OSR", 5: "RTCM3 SSR",
|
|
6: "QZSS SLAS", 7: "SPARTN", 9: "CLAS", 10: "LPP OSR", 11: "LPP SSR", 12: "GAL HAS"}
|
|
ANT_STATUS = {0: "init", 1: "unknown", 2: "OK", 3: "short", 4: "open"}
|
|
|
|
# Carrier frequency [Hz] by (gnssId, sigId) - u-blox F9/X20 signal identifiers.
|
|
SIG_FREQ = {
|
|
(0, 0): ("L1C/A", 1575.42e6), (0, 3): ("L2CL", 1227.60e6), (0, 4): ("L2CM", 1227.60e6),
|
|
(0, 6): ("L5I", 1176.45e6), (0, 7): ("L5Q", 1176.45e6),
|
|
(1, 0): ("L1C/A", 1575.42e6),
|
|
(2, 0): ("E1C", 1575.42e6), (2, 1): ("E1B", 1575.42e6),
|
|
(2, 3): ("E5aI", 1176.45e6), (2, 4): ("E5aQ", 1176.45e6),
|
|
(2, 5): ("E5bI", 1207.14e6), (2, 6): ("E5bQ", 1207.14e6),
|
|
(2, 8): ("E6B", 1278.75e6), (2, 9): ("E6C", 1278.75e6),
|
|
(3, 0): ("B1I D1", 1561.098e6), (3, 1): ("B1I D2", 1561.098e6),
|
|
(3, 2): ("B2I D1", 1207.14e6), (3, 3): ("B2I D2", 1207.14e6),
|
|
(3, 4): ("B3I", 1268.52e6),
|
|
(3, 5): ("B1Cp", 1575.42e6), (3, 6): ("B1Cd", 1575.42e6),
|
|
(3, 7): ("B2ap", 1176.45e6), (3, 8): ("B2ad", 1176.45e6),
|
|
(5, 0): ("L1C/A", 1575.42e6), (5, 1): ("L1S", 1575.42e6),
|
|
(5, 4): ("L2CM", 1227.60e6), (5, 5): ("L2CL", 1227.60e6),
|
|
(5, 8): ("L5I", 1176.45e6), (5, 9): ("L5Q", 1176.45e6),
|
|
(7, 0): ("L5A", 1176.45e6),
|
|
}
|
|
|
|
|
|
def sig_info(gnss, sig, freq_slot):
|
|
"""(signal name, carrier freq Hz) - GLONASS FDMA needs the frequency slot."""
|
|
if gnss == 6:
|
|
k = freq_slot - 7
|
|
if sig == 0:
|
|
return ("L1OF", 1602.0e6 + k * 562.5e3)
|
|
if sig == 2:
|
|
return ("L2OF", 1246.0e6 + k * 437.5e3)
|
|
return (f"GLO sig{sig}", 0.0)
|
|
return SIG_FREQ.get((gnss, sig), (f"sig{sig}", 0.0))
|
|
|
|
|
|
def band_of(freq_hz):
|
|
if freq_hz > 1.5e9:
|
|
return "L1"
|
|
if freq_hz > 1.26e9:
|
|
return "L6/E6"
|
|
if freq_hz > 1.2e9:
|
|
return "L2"
|
|
if freq_hz > 0:
|
|
return "L5"
|
|
return "?"
|
|
|
|
|
|
def log(msg):
|
|
print(f"[process_gps] {msg}", flush=True)
|
|
|
|
|
|
def fetch(url, dest=None, timeout=120):
|
|
"""Download url; return bytes (and write dest if given). None on failure."""
|
|
try:
|
|
req = urllib.request.Request(url, headers={"User-Agent": "process_gps.py"})
|
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
data = r.read()
|
|
if dest is not None:
|
|
Path(dest).write_bytes(data)
|
|
return data
|
|
except (urllib.error.URLError, urllib.error.HTTPError, OSError):
|
|
return None
|
|
|
|
|
|
def run(cmd, **kw):
|
|
"""subprocess.run with logging; returns CompletedProcess."""
|
|
log(" $ " + " ".join(str(c) for c in cmd))
|
|
return subprocess.run([str(c) for c in cmd], capture_output=True, text=True, **kw)
|
|
|
|
|
|
def gps_to_utc(week, tow, leap_s):
|
|
return GPS_EPOCH + timedelta(weeks=week, seconds=tow - leap_s)
|
|
|
|
|
|
def haversine_km(lat1, lon1, lat2, lon2):
|
|
p = math.pi / 180
|
|
a = (math.sin((lat2 - lat1) * p / 2) ** 2
|
|
+ math.cos(lat1 * p) * math.cos(lat2 * p) * math.sin((lon2 - lon1) * p / 2) ** 2)
|
|
return 12742 * math.asin(math.sqrt(a))
|
|
|
|
|
|
def llh_to_ecef(lat, lon, h):
|
|
"""Geodetic (deg, m) -> ECEF (m), WGS84."""
|
|
a, f = 6378137.0, 1 / 298.257223563
|
|
e2 = f * (2 - f)
|
|
phi, lam = math.radians(lat), math.radians(lon)
|
|
n = a / math.sqrt(1 - e2 * math.sin(phi) ** 2)
|
|
return ((n + h) * math.cos(phi) * math.cos(lam),
|
|
(n + h) * math.cos(phi) * math.sin(lam),
|
|
(n * (1 - e2) + h) * math.sin(phi))
|
|
|
|
|
|
def ecef_to_llh(x, y, z):
|
|
"""ECEF -> geodetic lat/lon (deg), height (m); Bowring's method, WGS84."""
|
|
a, f = 6378137.0, 1 / 298.257223563
|
|
b, e2 = a * (1 - f), f * (2 - f)
|
|
ep2 = (a * a - b * b) / (b * b)
|
|
p = math.hypot(x, y)
|
|
th = math.atan2(a * z, b * p)
|
|
lat = math.atan2(z + ep2 * b * math.sin(th) ** 3, p - e2 * a * math.cos(th) ** 3)
|
|
n = a / math.sqrt(1 - e2 * math.sin(lat) ** 2)
|
|
return math.degrees(lat), math.degrees(math.atan2(y, x)), p / math.cos(lat) - n
|
|
|
|
|
|
def llh_to_enu(lat, lon, h, lat0, lon0, h0):
|
|
"""Small-extent geodetic -> local ENU (m) about (lat0, lon0, h0). Arrays OK."""
|
|
a, f = 6378137.0, 1 / 298.257223563
|
|
e2 = f * (2 - f)
|
|
phi = math.radians(lat0)
|
|
n_rad = a / math.sqrt(1 - e2 * math.sin(phi) ** 2) # prime vertical
|
|
m_rad = a * (1 - e2) / (1 - e2 * math.sin(phi) ** 2) ** 1.5 # meridian
|
|
d_n = np.radians(np.asarray(lat) - lat0) * m_rad
|
|
d_e = np.radians(np.asarray(lon) - lon0) * n_rad * math.cos(phi)
|
|
d_u = np.asarray(h) - h0
|
|
return d_n, d_e, d_u
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Stage 1: UBX inventory
|
|
# ----------------------------------------------------------------------------
|
|
|
|
def parse_ubx(ubx_path):
|
|
"""Single streaming pass over the capture; returns dict of DataFrames + summary."""
|
|
log(f"parsing {ubx_path.name} with pyubx2 ...")
|
|
counts = Counter()
|
|
rawx_hdr, rawx_meas = [], []
|
|
pvt, sat, sig_rows, rf = [], [], [], []
|
|
sfrbx_gnss = Counter()
|
|
cur_utc = None # running epoch time for sat/sig/rf tagging
|
|
|
|
with open(ubx_path, "rb") as f:
|
|
ubr = UBXReader(f, protfilter=UBX_PROTOCOL, quitonerror=0)
|
|
for _, m in ubr:
|
|
if m is None:
|
|
continue
|
|
ident = m.identity
|
|
counts[ident] += 1
|
|
if ident == "RXM-RAWX":
|
|
ei = len(rawx_hdr)
|
|
rawx_hdr.append((m.week, m.rcvTow, m.leapS, m.leapSec, m.numMeas))
|
|
for i in range(1, m.numMeas + 1):
|
|
s = f"_{i:02d}"
|
|
gnss = getattr(m, "gnssId" + s)
|
|
sid = getattr(m, "sigId" + s)
|
|
fslot = getattr(m, "freqId" + s)
|
|
name, freq = sig_info(gnss, sid, fslot)
|
|
rawx_meas.append((ei, gnss, getattr(m, "svId" + s), sid, name, freq,
|
|
getattr(m, "cno" + s), getattr(m, "locktime" + s),
|
|
getattr(m, "prStd" + s), getattr(m, "cpStd" + s),
|
|
getattr(m, "prValid" + s), getattr(m, "cpValid" + s),
|
|
getattr(m, "halfCyc" + s), getattr(m, "prMes" + s)))
|
|
elif ident == "RXM-SFRBX":
|
|
sfrbx_gnss[m.gnssId] += 1
|
|
elif ident == "NAV-PVT":
|
|
cur_utc = datetime(m.year, m.month, m.day, m.hour, m.min, m.second,
|
|
tzinfo=timezone.utc)
|
|
pvt.append((cur_utc,
|
|
m.fixType, m.carrSoln, m.diffSoln, m.numSV,
|
|
m.lat, m.lon, m.height / 1000.0, m.hMSL / 1000.0,
|
|
m.hAcc / 1000.0, m.vAcc / 1000.0, m.tAcc, m.pDOP,
|
|
m.gSpeed / 1000.0))
|
|
elif ident == "NAV-SAT":
|
|
for i in range(1, m.numSvs + 1):
|
|
s = f"_{i:02d}"
|
|
sat.append((cur_utc, getattr(m, "gnssId" + s), getattr(m, "svId" + s),
|
|
getattr(m, "cno" + s), getattr(m, "elev" + s),
|
|
getattr(m, "azim" + s), getattr(m, "prRes" + s),
|
|
getattr(m, "qualityInd" + s), getattr(m, "svUsed" + s),
|
|
getattr(m, "health" + s)))
|
|
elif ident == "NAV-SIG":
|
|
for i in range(1, m.numSigs + 1):
|
|
s = f"_{i:02d}"
|
|
sig_rows.append((cur_utc, getattr(m, "gnssId" + s), getattr(m, "svId" + s),
|
|
getattr(m, "sigId" + s), getattr(m, "cno" + s),
|
|
getattr(m, "qualityInd" + s), getattr(m, "corrSource" + s),
|
|
getattr(m, "ionoModel" + s), getattr(m, "prUsed" + s),
|
|
getattr(m, "prRes" + s)))
|
|
elif ident == "MON-RF":
|
|
for i in range(1, m.nBlocks + 1):
|
|
s = f"_{i:02d}"
|
|
rf.append((cur_utc, getattr(m, "blockId" + s), getattr(m, "jammingState" + s),
|
|
getattr(m, "antStatus" + s), getattr(m, "antPower" + s),
|
|
getattr(m, "noisePerMS" + s), getattr(m, "agcCnt" + s),
|
|
getattr(m, "jamInd" + s)))
|
|
|
|
data = {
|
|
"counts": counts,
|
|
"sfrbx_gnss": sfrbx_gnss,
|
|
"rawx_hdr": pd.DataFrame(rawx_hdr, columns=["week", "rcvTow", "leapS", "leapSecValid", "numMeas"]),
|
|
"rawx": pd.DataFrame(rawx_meas, columns=["epoch", "gnss", "sv", "sig", "signame",
|
|
"freq", "cno", "locktime", "prStd", "cpStd",
|
|
"prValid", "cpValid", "halfCyc", "prMes"]),
|
|
"pvt": pd.DataFrame(pvt, columns=["utc", "fixType", "carrSoln", "diffSoln", "numSV",
|
|
"lat", "lon", "height", "hMSL", "hAcc", "vAcc",
|
|
"tAcc_ns", "pDOP", "gSpeed"]),
|
|
"sat": pd.DataFrame(sat, columns=["utc", "gnss", "sv", "cno", "elev", "azim",
|
|
"prRes", "quality", "used", "health"]),
|
|
"sig": pd.DataFrame(sig_rows, columns=["utc", "gnss", "sv", "sig", "cno",
|
|
"quality", "corrSource", "ionoModel",
|
|
"prUsed", "prRes"]),
|
|
"rf": pd.DataFrame(rf, columns=["utc", "blockId", "jammingState", "antStatus",
|
|
"antPower", "noisePerMS", "agcCnt", "jamInd"]),
|
|
}
|
|
|
|
# session window in UTC from RAWX (receiver time is GPS-aligned)
|
|
hdr = data["rawx_hdr"]
|
|
if len(hdr):
|
|
leap = int(hdr["leapS"].iloc[0])
|
|
data["t_start"] = gps_to_utc(int(hdr["week"].iloc[0]), float(hdr["rcvTow"].iloc[0]), leap)
|
|
data["t_end"] = gps_to_utc(int(hdr["week"].iloc[-1]), float(hdr["rcvTow"].iloc[-1]), leap)
|
|
# uc2x sidecar metadata (JSON header at start of the u-center 2 index file)
|
|
data["meta"] = {}
|
|
uc2x = ubx_path.with_suffix(".uc2x")
|
|
if uc2x.is_file():
|
|
blob = uc2x.read_bytes()[:4096]
|
|
m = re.search(rb'\{"time".*?\}', blob)
|
|
if m:
|
|
try:
|
|
data["meta"] = json.loads(m.group(0))
|
|
except json.JSONDecodeError:
|
|
pass
|
|
return data
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Stage 2: RINEX conversion
|
|
# ----------------------------------------------------------------------------
|
|
|
|
def stage_rinex(ubx_path, outdir, n_rawx_epochs, warnings):
|
|
obs, nav = outdir / "rover.obs", outdir / "rover.nav"
|
|
if obs.is_file() and nav.is_file():
|
|
log("rinex: reusing existing rover.obs / rover.nav")
|
|
else:
|
|
r = run([BIN / "convbin", "-r", "ubx", "-v", "3.04", "-od", "-os", "-oi", "-ot", "-ol",
|
|
"-f", "4", "-o", obs, "-n", nav, ubx_path])
|
|
if not obs.is_file() or not nav.is_file():
|
|
raise RuntimeError(f"convbin failed:\n{r.stderr[-2000:]}")
|
|
if (TOOLS / "STOCK_RTKLIB").exists():
|
|
warnings.append("STOCK RTKLIB in use: BeiDou and L5/E5 observations are MISSING "
|
|
"from the RINEX output (~30% of this receiver's measurements). "
|
|
"Re-run setup.sh where the demo5 build can succeed.")
|
|
|
|
header, sys_lines, iono = [], [], {}
|
|
with open(obs, errors="replace") as f:
|
|
for line in f:
|
|
header.append(line)
|
|
if "SYS / # / OBS TYPES" in line and line[0] != " ":
|
|
sys_lines.append(line[0])
|
|
if "END OF HEADER" in line:
|
|
break
|
|
n_epochs = sum(1 for line in open(obs, errors="replace") if line.startswith(">"))
|
|
with open(nav, errors="replace") as f:
|
|
for line in f:
|
|
if "IONOSPHERIC CORR" in line:
|
|
iono[line[:4].strip()] = [float(x.replace("D", "E"))
|
|
for x in line[5:60].split()]
|
|
if "END OF HEADER" in line:
|
|
break
|
|
nav_counts = Counter(line[0] for line in open(nav, errors="replace")
|
|
if re.match(r"^[GRECJSI][0-9]{2} ", line))
|
|
|
|
if n_epochs != n_rawx_epochs:
|
|
warnings.append(f"RINEX epoch count ({n_epochs}) != RAWX epoch count ({n_rawx_epochs})")
|
|
if "C" not in sys_lines:
|
|
warnings.append("BeiDou missing from RINEX obs (stock convbin limitation?)")
|
|
log(f"rinex: {n_epochs} epochs, systems {sys_lines}, "
|
|
f"nav ephemerides {dict(nav_counts)}, iono keys {list(iono)}")
|
|
return {"obs": obs, "nav": nav, "n_epochs": n_epochs, "systems": sys_lines,
|
|
"iono": iono, "nav_counts": dict(nav_counts)}
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# RTKLIB config + solution helpers
|
|
# ----------------------------------------------------------------------------
|
|
|
|
def build_merged_atx(names, dest):
|
|
"""Extract ANTEX blocks for `names` from ngs20/igs20 into one small file."""
|
|
found = []
|
|
with open(dest, "w") as out:
|
|
out.write(" 1.4 M "
|
|
"ANTEX VERSION / SYST\n")
|
|
out.write("A "
|
|
"PCV TYPE / REFANT\n")
|
|
out.write(" "
|
|
"END OF HEADER\n")
|
|
remaining = {n for n in names if n}
|
|
for atx in ("ngs20.atx", "igs20.atx"):
|
|
p = ANTEX_DIR / atx
|
|
if not p.is_file() or not remaining:
|
|
continue
|
|
block, keep = [], False
|
|
for line in open(p, encoding="utf-8", errors="replace"):
|
|
if "START OF ANTENNA" in line:
|
|
block, keep = [line], False
|
|
continue
|
|
if not block:
|
|
continue
|
|
block.append(line)
|
|
if "TYPE / SERIAL" in line:
|
|
name = line[:20].rstrip()
|
|
if name in remaining:
|
|
keep = True
|
|
if "END OF ANTENNA" in line:
|
|
if keep:
|
|
out.write(" "
|
|
"START OF ANTENNA\n")
|
|
out.writelines(block)
|
|
found.append(line and name)
|
|
remaining.discard(name)
|
|
block = []
|
|
return found
|
|
|
|
|
|
def rover_antenna():
|
|
aj = TOOLS / "antenna.json"
|
|
if aj.is_file():
|
|
return json.loads(aj.read_text()).get("antex_name")
|
|
return None
|
|
|
|
|
|
def write_conf(path, ant1=None, ant2=None, atx=None, base_from_header=False):
|
|
lines = [
|
|
"pos1-elmask =15",
|
|
"pos1-navsys =45", # GPS+GLO+GAL+BDS
|
|
"pos2-armode =continuous",
|
|
"pos2-gloarmode =off", # mixed-manufacturer base/rover: GLONASS AR off
|
|
"out-outstat =residual",
|
|
]
|
|
if base_from_header: # no -r on CLI: take base pos from RINEX header
|
|
lines.append("ant2-postype =rinexhead")
|
|
if ant1:
|
|
lines.append(f"ant1-anttype ={ant1}")
|
|
if ant2:
|
|
lines.append(f"ant2-anttype ={ant2}")
|
|
if atx:
|
|
lines.append(f"file-rcvantfile ={atx}")
|
|
lines.append(f"file-satantfile ={ANTEX_DIR / 'igs20.atx'}")
|
|
path.write_text("\n".join(lines) + "\n")
|
|
return path
|
|
|
|
|
|
def read_pos(pos_path):
|
|
"""Parse an RTKLIB .pos file (-t -u => UTC, decimal degrees) into a DataFrame."""
|
|
rows = []
|
|
if not Path(pos_path).is_file():
|
|
return pd.DataFrame()
|
|
for line in open(pos_path, errors="replace"):
|
|
if line.startswith("%") or not line.strip():
|
|
continue
|
|
p = line.split()
|
|
if len(p) < 9:
|
|
continue
|
|
try:
|
|
t = datetime.strptime(p[0] + " " + p[1], "%Y/%m/%d %H:%M:%S.%f"
|
|
).replace(tzinfo=timezone.utc)
|
|
rows.append((t, float(p[2]), float(p[3]), float(p[4]), int(p[5]), int(p[6]),
|
|
float(p[7]), float(p[8]), float(p[9]),
|
|
float(p[13]) if len(p) > 13 else 0.0))
|
|
except (ValueError, IndexError):
|
|
continue
|
|
return pd.DataFrame(rows, columns=["utc", "lat", "lon", "height", "Q", "ns",
|
|
"sdn", "sde", "sdu", "ratio"])
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Stage 3: single-point solution
|
|
# ----------------------------------------------------------------------------
|
|
|
|
def stage_single(outdir, obs, nav, conf):
|
|
pos = outdir / "single.pos"
|
|
if not pos.is_file():
|
|
r = run([BIN / "rnx2rtkp", "-k", conf, "-p", "0", "-f", "3", "-t", "-u",
|
|
"-o", pos, obs, nav])
|
|
if not pos.is_file():
|
|
raise RuntimeError(f"rnx2rtkp single failed:\n{r.stderr[-1500:]}")
|
|
df = read_pos(pos)
|
|
log(f"single: {len(df)} epochs")
|
|
return df
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Stage 4: PPK vs NGS CORS
|
|
# ----------------------------------------------------------------------------
|
|
|
|
def load_cors_catalog():
|
|
cat = CACHE / "itrf2020_geo.comp.txt"
|
|
if not cat.is_file():
|
|
CACHE.mkdir(parents=True, exist_ok=True)
|
|
if fetch("https://geodesy.noaa.gov/corsdata/coord/coord_20/itrf2020_geo.comp.txt",
|
|
cat) is None:
|
|
return []
|
|
stations = []
|
|
for line in open(cat, errors="replace"):
|
|
p = line.split()
|
|
if len(p) >= 17 and p[1] == "2020.00":
|
|
try:
|
|
lat = int(p[2]) + int(p[3]) / 60 + float(p[4]) / 3600
|
|
lat = lat if p[5] == "N" else -lat
|
|
lon = int(p[6]) + int(p[7]) / 60 + float(p[8]) / 3600
|
|
lon = lon if p[9] == "E" else -lon
|
|
stations.append({"id": p[0], "lat": lat, "lon": lon,
|
|
"height": float(p[10]), "state": p[15], "status": p[16]})
|
|
except ValueError:
|
|
continue
|
|
return stations
|
|
|
|
|
|
def cors_station_xyz(ssss, epoch_year):
|
|
"""ITRF2020 ECEF of a CORS station, velocity-propagated to epoch_year."""
|
|
txt = fetch(f"https://geodesy.noaa.gov/corsdata/coord/coord_20/{ssss.lower()}_20.coord.txt")
|
|
if txt is None:
|
|
return None
|
|
text = txt.decode(errors="replace")
|
|
itrf = text[text.find("ITRF2020 POSITION"):]
|
|
try:
|
|
x = float(re.search(r"X\s*=\s*(-?[\d.]+)\s*m", itrf).group(1))
|
|
y = float(re.search(r"Y\s*=\s*(-?[\d.]+)\s*m", itrf).group(1))
|
|
z = float(re.search(r"Z\s*=\s*(-?[\d.]+)\s*m", itrf).group(1))
|
|
vx = float(re.search(r"VX\s*=\s*(-?[\d.]+)\s*m/yr", itrf).group(1))
|
|
vy = float(re.search(r"VY\s*=\s*(-?[\d.]+)\s*m/yr", itrf).group(1))
|
|
vz = float(re.search(r"VZ\s*=\s*(-?[\d.]+)\s*m/yr", itrf).group(1))
|
|
except AttributeError:
|
|
return None
|
|
dt = epoch_year - 2020.0
|
|
return (x + vx * dt, y + vy * dt, z + vz * dt)
|
|
|
|
|
|
def download_base_hours(station, t0, t1, dest_dir, warnings):
|
|
"""Fetch CORS RINEX obs covering [t0, t1]; returns list of decompressed files."""
|
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
ssss = station.lower()
|
|
files = []
|
|
hours = []
|
|
t = t0.replace(minute=0, second=0, microsecond=0)
|
|
while t <= t1:
|
|
hours.append(t)
|
|
t += timedelta(hours=1)
|
|
age_days = (datetime.now(timezone.utc) - t1).days
|
|
|
|
for t in hours:
|
|
yy, ddd = t.strftime("%y"), t.strftime("%j")
|
|
letter = chr(ord("a") + t.hour)
|
|
candidates = [f"{ssss}{ddd}{letter}.{yy}o.gz", f"{ssss}{ddd}{letter}.{yy}d.gz"]
|
|
if age_days >= 2: # hourly files expire after ~2 days; daily file fallback
|
|
candidates += [f"{ssss}{ddd}0.{yy}o.gz", f"{ssss}{ddd}0.{yy}d.gz"]
|
|
got = None
|
|
for cand in candidates:
|
|
local_gz = dest_dir / cand
|
|
local = dest_dir / cand[:-3]
|
|
if local.is_file():
|
|
got = local
|
|
break
|
|
for mirror in CORS_MIRRORS:
|
|
url = f"{mirror}/rinex/{t.year}/{ddd}/{ssss}/{cand}"
|
|
if fetch(url, local_gz) is not None:
|
|
local.write_bytes(gzip.decompress(local_gz.read_bytes()))
|
|
if cand.endswith(f"d.gz"): # Hatanaka -> RINEX
|
|
crx2rnx = BIN / "CRX2RNX"
|
|
rnx = local.with_suffix(local.suffix[:-1] + "o")
|
|
r = run([crx2rnx, "-f", local])
|
|
local = rnx if rnx.is_file() else None
|
|
got = local
|
|
break
|
|
if got:
|
|
break
|
|
if got and got not in files:
|
|
files.append(got)
|
|
elif not got:
|
|
warnings.append(f"CORS {station}: no file for {t:%Y-%m-%d %H}:00 UTC yet")
|
|
return files
|
|
|
|
|
|
def rinex_first_obs(path):
|
|
"""UTC datetime of TIME OF FIRST OBS from a RINEX obs header, or None."""
|
|
try:
|
|
with open(path, errors="replace") as f:
|
|
for line in f:
|
|
if "TIME OF FIRST OBS" in line:
|
|
p = line.split()
|
|
return datetime(int(p[0]), int(p[1]), int(p[2]), int(p[3]),
|
|
int(p[4]), int(float(p[5])), tzinfo=timezone.utc)
|
|
if "END OF HEADER" in line:
|
|
break
|
|
except (OSError, ValueError, IndexError):
|
|
pass
|
|
return None
|
|
|
|
|
|
def find_gcgc_base(capture, t_start, t_end):
|
|
"""Auto-discover local GCGC/VRS base data for the parallel PPK method:
|
|
the capture's own recorded correction stream (stream_gps.py writes
|
|
<stem>.rtcm3 alongside <stem>.ubx), plus files dropped into gcgc_base/
|
|
whose observation window overlaps the capture."""
|
|
found = []
|
|
sidecar = capture.with_suffix(".rtcm3")
|
|
if sidecar.is_file() and sidecar.stat().st_size:
|
|
found.append(sidecar)
|
|
gdir = SCRIPT_DIR / "gcgc_base"
|
|
if gdir.is_dir():
|
|
for f in sorted(gdir.iterdir()):
|
|
suf = f.suffix.lower()
|
|
if suf in (".rtcm3", ".rtcm"):
|
|
found.append(f)
|
|
elif suf in (".obs", ".rnx", ".crx") or re.fullmatch(r"\.\d\do", suf):
|
|
t = rinex_first_obs(f)
|
|
if t is not None and t <= t_end and t >= t_start - timedelta(hours=26):
|
|
found.append(f)
|
|
return found
|
|
|
|
|
|
def prepare_local_base(outdir, files, t0, warnings, tag):
|
|
"""Stage local base observation file(s): RINEX, or RTCM3 correction logs
|
|
(e.g. a recorded GCGC VRS stream) which are converted via convbin."""
|
|
base_dir = outdir / "base" / tag
|
|
base_dir.mkdir(parents=True, exist_ok=True)
|
|
staged = []
|
|
for i, f in enumerate(Path(p) for p in files):
|
|
if not f.is_file():
|
|
warnings.append(f"base file not found: {f}")
|
|
continue
|
|
if f.suffix.lower() in (".rtcm3", ".rtcm"):
|
|
conv = base_dir / f"base_{i:02d}.obs"
|
|
if not conv.is_file():
|
|
run([BIN / "convbin", "-r", "rtcm3", "-v", "3.04",
|
|
"-tr", t0.strftime("%Y/%m/%d"), t0.strftime("%H:%M:%S"),
|
|
"-o", conv, f])
|
|
if not conv.is_file():
|
|
warnings.append(f"RTCM->RINEX conversion failed for {f.name}")
|
|
continue
|
|
staged.append(conv)
|
|
else:
|
|
local = base_dir / f"base_{i:02d}{f.suffix}"
|
|
if not local.is_file():
|
|
shutil.copy(f, local) # space-free names for combine_rinex.sh
|
|
staged.append(local)
|
|
if not staged:
|
|
return None
|
|
# never combine files from DIFFERENT base positions (e.g. several VRS points):
|
|
# keep only files agreeing with the first-seen position within 100 m
|
|
positions = []
|
|
for f in staged:
|
|
for line in open(f, errors="replace"):
|
|
if "APPROX POSITION XYZ" in line:
|
|
try:
|
|
positions.append(np.array([float(v) for v in line[:60].split()]))
|
|
except ValueError:
|
|
positions.append(None)
|
|
break
|
|
if "END OF HEADER" in line:
|
|
positions.append(None)
|
|
break
|
|
if len([p for p in positions if p is not None]) > 1:
|
|
ref = next(p for p in positions if p is not None)
|
|
same = [f for f, p in zip(staged, positions)
|
|
if p is None or np.linalg.norm(p - ref) < 100.0]
|
|
if len(same) < len(staged):
|
|
warnings.append(f"{tag}: {len(staged) - len(same)} base file(s) at a "
|
|
"different position excluded from combining (multiple "
|
|
"VRS points? process them separately via --base-obs)")
|
|
staged = same
|
|
if len(staged) == 1:
|
|
return staged[0]
|
|
combined = base_dir / "base_combined.obs"
|
|
if not combined.is_file():
|
|
r = run(["bash", COMBINE_SH, combined.name, *[f.name for f in staged]],
|
|
cwd=base_dir)
|
|
if not combined.is_file() or r.returncode != 0:
|
|
warnings.append(f"combine_rinex.sh failed for local base files: "
|
|
f"{r.stdout[-400:]}")
|
|
return None
|
|
return combined
|
|
|
|
|
|
def stage_ppk(outdir, obs, nav, data, args, warnings):
|
|
result = {"status": "skipped"}
|
|
t0 = data["t_start"] - timedelta(minutes=5)
|
|
t1 = data["t_end"] + timedelta(minutes=5)
|
|
mean_lat, mean_lon = data["pvt"]["lat"].mean(), data["pvt"]["lon"].mean()
|
|
|
|
stations = load_cors_catalog()
|
|
if not stations:
|
|
warnings.append("CORS catalog unavailable (offline?) - PPK skipped")
|
|
return result
|
|
for s in stations:
|
|
s["km"] = haversine_km(mean_lat, mean_lon, s["lat"], s["lon"])
|
|
usable = sorted((s for s in stations if s["status"] == "Operational"
|
|
and s["km"] <= args.max_base_km), key=lambda s: s["km"])
|
|
if args.base:
|
|
usable = [s for s in stations if s["id"].upper() == args.base.upper()] + usable
|
|
if not usable:
|
|
warnings.append(f"no operational CORS station within {args.max_base_km} km")
|
|
return result
|
|
|
|
base_obs = None
|
|
base_station = None
|
|
for s in usable[:3]:
|
|
log(f"ppk: trying CORS {s['id']} ({s['km']:.1f} km, {s['state']})")
|
|
base_dir = outdir / "base" / s["id"].lower()
|
|
files = download_base_hours(s["id"], t0, t1, base_dir, warnings)
|
|
if not files:
|
|
continue
|
|
if len(files) == 1:
|
|
base_obs = files[0]
|
|
else:
|
|
base_obs = base_dir / "base_combined.obs"
|
|
if not base_obs.is_file():
|
|
# combine_rinex.sh word-splits its args: run in base_dir with bare
|
|
# filenames so paths never contain spaces
|
|
r = run(["bash", COMBINE_SH, base_obs.name, *[f.name for f in files]],
|
|
cwd=base_dir)
|
|
if not base_obs.is_file() or r.returncode != 0:
|
|
warnings.append(f"combine_rinex.sh failed for {s['id']}: {r.stdout[-500:]}")
|
|
base_obs = None
|
|
continue
|
|
base_station = s
|
|
break
|
|
if base_obs is None:
|
|
result["status"] = "pending"
|
|
result["message"] = ("CORS base data not yet published (typical lag ~1 h). "
|
|
"Re-run this script later; completed stages are cached.")
|
|
log("ppk: " + result["message"])
|
|
return result
|
|
return finish_ppk(outdir, obs, nav, data, args, warnings, result,
|
|
base_obs, base_station, mean_lat, mean_lon)
|
|
|
|
|
|
def fetch_esa_sp3(outdir, day, warnings):
|
|
"""Multi-constellation ESA orbit product (FIN preferred, else RAP, G+R+)."""
|
|
week = int((day - GPS_EPOCH).days // 7)
|
|
yyyyddd = day.strftime("%Y%j")
|
|
prod_dir = outdir / "igs"
|
|
prod_dir.mkdir(exist_ok=True)
|
|
for kind in ("FIN", "RAP"):
|
|
for f in prod_dir.glob(f"ESA0OPS{kind}_{yyyyddd}0000*ORB.SP3"):
|
|
return f
|
|
listing = fetch(f"http://navigation-office.esa.int/products/gnss-products/{week}/",
|
|
timeout=60)
|
|
if listing is None:
|
|
return None
|
|
names = set(re.findall(r'href="(ESA0OPS(?:FIN|RAP)_[^"]+ORB\.SP3\.gz)"',
|
|
listing.decode(errors="replace")))
|
|
for kind in ("FIN", "RAP"):
|
|
cand = [n for n in names if kind in n and yyyyddd + "0000" in n]
|
|
if cand:
|
|
gz = prod_dir / cand[0]
|
|
if fetch(f"http://navigation-office.esa.int/products/gnss-products/"
|
|
f"{week}/{cand[0]}", gz):
|
|
out = gz.with_suffix("")
|
|
out.write_bytes(gzip.decompress(gz.read_bytes()))
|
|
return out
|
|
return None
|
|
|
|
|
|
def stage_ppk_vrs(outdir, obs, nav, data, args, warnings, ppk_result):
|
|
"""Standard GCGC-provided VRS solution: synthesize a virtual base at the
|
|
capture's own position from the GCGC master station the NGS stage already
|
|
fetched (engine validated in experiments/: gates G1/G2a green; benchmarked
|
|
equivalent to Trimble Pivot VRS at station MARY, 2026-07-27).
|
|
Only provided when GCGC is available: the master must belong to the GCGC
|
|
network (agency header or a Mississippi station)."""
|
|
result = {"status": "skipped"}
|
|
st = ppk_result.get("station")
|
|
base_obs = ppk_result.get("base_obs")
|
|
master_xyz = ppk_result.get("base_xyz")
|
|
if not st or not base_obs or not Path(base_obs).is_file():
|
|
return result
|
|
# --- GCGC availability gate
|
|
agency = ""
|
|
for line in open(base_obs, errors="replace"):
|
|
if "OBSERVER / AGENCY" in line:
|
|
agency = line[:60]
|
|
if "END OF HEADER" in line:
|
|
break
|
|
if "GCGC" not in agency.upper() and st.get("state") != "MS":
|
|
log(f"ppk_vrs: master {st['id']} is not a GCGC station - "
|
|
"GCGC VRS solution not provided")
|
|
return result
|
|
if master_xyz is None:
|
|
warnings.append("ppk_vrs: master ITRF position unavailable - skipped "
|
|
"(frame-consistent synthesis requires it)")
|
|
return result
|
|
|
|
sp3_path = fetch_esa_sp3(outdir, data["t_start"], warnings)
|
|
if sp3_path is None:
|
|
result["status"] = "pending"
|
|
result["message"] = ("ESA orbit products for this date not yet published "
|
|
"(~1 day lag) - GCGC VRS solution added on a later re-run")
|
|
log("ppk_vrs: " + result["message"])
|
|
return result
|
|
|
|
sys.path.insert(0, str(SCRIPT_DIR / "experiments"))
|
|
from vbs_synth import Sp3, glonass_slots, synthesize
|
|
|
|
pvt = data["pvt"]
|
|
vxyz = llh_to_ecef(float(pvt["lat"].mean()), float(pvt["lon"].mean()),
|
|
float(pvt["height"].mean()))
|
|
vrs_obs = outdir / "vrs_base.obs"
|
|
if not vrs_obs.is_file():
|
|
sp3 = Sp3(sp3_path)
|
|
slots = glonass_slots(nav) # rover.nav is RINEX 3 mixed nav
|
|
stats, disp = synthesize(base_obs, vrs_obs, vxyz, sp3, slots,
|
|
master_xyz=master_xyz, marker="VRS0")
|
|
dropped = sum(stats["dropped"].values())
|
|
log(f"ppk_vrs: synthesized virtual base at capture position from "
|
|
f"{st['id']} ({disp:.1f} km displacement), {stats['epochs']} epochs, "
|
|
f"{dropped} sat-epochs without orbits dropped")
|
|
if stats["epochs"] == 0:
|
|
warnings.append("ppk_vrs: synthesis produced no epochs - skipped")
|
|
vrs_obs.unlink(missing_ok=True)
|
|
result["status"] = "failed"
|
|
return result
|
|
|
|
base_station = {"id": f"VRS@{st['id']}", "km": 0.0, "state": st.get("state", "-"),
|
|
"status": "synthesized", "xyz": vxyz}
|
|
return finish_ppk(outdir, obs, nav, data, args, warnings, result,
|
|
vrs_obs, base_station, float(pvt["lat"].mean()),
|
|
float(pvt["lon"].mean()), prefix="ppk_vrs")
|
|
|
|
|
|
def stage_ppk_gcgc(outdir, obs, nav, data, args, warnings):
|
|
"""Parallel PPK method using local GCGC data: auto-discovered (recorded VRS
|
|
sidecar, gcgc_base/ folder) or explicitly supplied via --base-obs."""
|
|
result = {"status": "skipped"}
|
|
t0 = data["t_start"] - timedelta(minutes=5)
|
|
mean_lat, mean_lon = data["pvt"]["lat"].mean(), data["pvt"]["lon"].mean()
|
|
|
|
files = ([Path(p) for p in args.base_obs] if args.base_obs
|
|
else find_gcgc_base(args.capture, data["t_start"], data["t_end"]))
|
|
if not files:
|
|
return result
|
|
log(f"ppk_gcgc: local base data found: {', '.join(f.name for f in files)}")
|
|
base_obs = prepare_local_base(outdir, files, t0, warnings, tag="gcgc")
|
|
if base_obs is None:
|
|
result["status"] = "failed"
|
|
return result
|
|
# distance from base RINEX header position, if present (VRS => ~0 km)
|
|
km = float("nan")
|
|
for line in open(base_obs, errors="replace"):
|
|
if "APPROX POSITION XYZ" in line:
|
|
try:
|
|
x, y, z = (float(v) for v in line[:60].split())
|
|
lat0, lon0, _ = ecef_to_llh(x, y, z)
|
|
km = haversine_km(mean_lat, mean_lon, lat0, lon0)
|
|
except ValueError:
|
|
pass
|
|
if "END OF HEADER" in line:
|
|
break
|
|
base_station = {"id": "GCGC", "km": km, "state": "-", "status": "local",
|
|
"local": True}
|
|
return finish_ppk(outdir, obs, nav, data, args, warnings, result,
|
|
base_obs, base_station, mean_lat, mean_lon, prefix="ppk_gcgc")
|
|
|
|
|
|
def finish_ppk(outdir, obs, nav, data, args, warnings, result,
|
|
base_obs, base_station, mean_lat, mean_lon, prefix="ppk"):
|
|
t0 = data["t_start"] - timedelta(minutes=5)
|
|
t1 = data["t_end"] + timedelta(minutes=5)
|
|
|
|
# base antenna type from its RINEX header; base ECEF from NGS coord file
|
|
ant2 = None
|
|
for line in open(base_obs, errors="replace"):
|
|
if "ANT # / TYPE" in line:
|
|
ant2 = line[20:40].rstrip()
|
|
if "END OF HEADER" in line:
|
|
break
|
|
if base_station.get("xyz") is not None: # synthesized base: position known
|
|
xyz = base_station["xyz"]
|
|
elif base_station.get("local"):
|
|
xyz = tuple(args.base_xyz) if args.base_xyz else None
|
|
if xyz is None:
|
|
warnings.append(f"{prefix}: no --base-xyz given, using the base RINEX "
|
|
"header position (verify its datum; GCGC uses NAD83, "
|
|
"~1.5 m from ITRF in CONUS)")
|
|
else:
|
|
epoch_year = data["t_start"].year + data["t_start"].timetuple().tm_yday / 365.25
|
|
xyz = cors_station_xyz(base_station["id"], epoch_year)
|
|
if xyz is None:
|
|
warnings.append(f"NGS coord file for {base_station['id']} unavailable - "
|
|
"using base RINEX header position (NAD83, ~1.5 m frame offset)")
|
|
|
|
ant1 = rover_antenna()
|
|
merged = outdir / f"{prefix}_merged.atx"
|
|
build_merged_atx([ant1, ant2], merged)
|
|
conf = write_conf(outdir / f"{prefix}.conf", ant1=ant1, ant2=ant2, atx=merged,
|
|
base_from_header=(xyz is None))
|
|
|
|
ts = ["-ts", t0.strftime("%Y/%m/%d"), t0.strftime("%H:%M:%S"),
|
|
"-te", t1.strftime("%Y/%m/%d"), t1.strftime("%H:%M:%S")]
|
|
rpos = ["-r", *map(str, xyz)] if xyz else []
|
|
|
|
# Motion policy: NEVER infer static (deployment platform is a buoy - slow drift
|
|
# within metres looks like noise). Static only when explicitly flagged.
|
|
dn, de, _ = llh_to_enu(data["pvt"]["lat"], data["pvt"]["lon"], data["pvt"]["height"],
|
|
mean_lat, mean_lon, 0)
|
|
spread95 = float(np.percentile(np.hypot(dn, de), 95))
|
|
static = args.static
|
|
if static and spread95 > 5.0:
|
|
warnings.append(f"--static flagged but onboard track spread is {spread95:.1f} m "
|
|
f"(95th pct) - the static solution may be averaging real motion")
|
|
elif not static and spread95 < 5.0:
|
|
log(f"{prefix}: track spread {spread95:.2f} m would pass as static, but processing "
|
|
f"kinematic (default policy; use --static only for a verified fixed antenna)")
|
|
log(f"{prefix}: base={base_station['id']} ({base_station['km']:.1f} km) "
|
|
f"ant2={ant2!r} static={static} (95% spread {spread95:.2f} m)")
|
|
|
|
out = {}
|
|
runs = [("kinematic", "2")] + ([("static", "3")] if static else [])
|
|
for name, mode in runs:
|
|
pos = outdir / f"{prefix}_{name}.pos"
|
|
if not pos.is_file():
|
|
r = run([BIN / "rnx2rtkp", "-k", conf, "-p", mode, "-f", "3", "-t", "-u",
|
|
*ts, *rpos, "-o", pos, obs, base_obs, nav])
|
|
if not pos.is_file() or not read_pos(pos).shape[0]:
|
|
warnings.append(f"rnx2rtkp {prefix} {name} produced no solution: "
|
|
f"{r.stderr[-800:]}")
|
|
continue
|
|
out[name] = read_pos(pos)
|
|
qc = Counter(out[name]["Q"])
|
|
log(f"{prefix} {name}: {len(out[name])} epochs, Q={dict(qc)}")
|
|
|
|
result.update({"status": "ok" if out else "failed", "station": base_station,
|
|
"static": static, "solutions": out, "base_obs": str(base_obs),
|
|
"ant2": ant2, "base_xyz": xyz, "spread95": spread95})
|
|
return result
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Stage 5: PPP vs open IGS products (optional)
|
|
# ----------------------------------------------------------------------------
|
|
|
|
def stage_ppp(outdir, obs, nav, data, args, warnings):
|
|
day = data["t_start"]
|
|
week = int((day - GPS_EPOCH).days // 7)
|
|
yyyyddd = day.strftime("%Y%j")
|
|
prod_dir = outdir / "igs"
|
|
prod_dir.mkdir(exist_ok=True)
|
|
|
|
sp3 = clk = None
|
|
for base_url, prefix in IGS_PRODUCT_MIRRORS:
|
|
listing = fetch(f"{base_url}/{week}/", timeout=60)
|
|
if listing is None:
|
|
continue
|
|
names = set(re.findall(r'href="([^"]+\.(?:SP3|CLK)\.gz)"', listing.decode(errors="replace")))
|
|
for kind in ("FIN", "RAP"):
|
|
cand_sp3 = [n for n in names if re.match(prefix, n) and kind in n
|
|
and yyyyddd + "0000" in n and n.endswith("ORB.SP3.gz")]
|
|
cand_clk = [n for n in names if re.match(prefix, n) and kind in n
|
|
and yyyyddd + "0000" in n and n.endswith("CLK.CLK.gz")]
|
|
if cand_sp3 and cand_clk:
|
|
fs, fc = prod_dir / cand_sp3[0], prod_dir / cand_clk[0]
|
|
if ((fs.with_suffix("").is_file() or fetch(f"{base_url}/{week}/{cand_sp3[0]}", fs))
|
|
and (fc.with_suffix("").is_file() or fetch(f"{base_url}/{week}/{cand_clk[0]}", fc))):
|
|
for fgz in (fs, fc):
|
|
if fgz.is_file() and not fgz.with_suffix("").is_file():
|
|
fgz.with_suffix("").write_bytes(gzip.decompress(fgz.read_bytes()))
|
|
sp3, clk = fs.with_suffix(""), fc.with_suffix("")
|
|
log(f"ppp: using {sp3.name} + {clk.name}")
|
|
break
|
|
if sp3:
|
|
break
|
|
if sp3 is None:
|
|
msg = ("IGS precise products for this date not yet published "
|
|
"(rapid ~1 day lag). Re-run with --ppp later.")
|
|
log("ppp: " + msg)
|
|
return {"status": "pending", "message": msg}
|
|
|
|
ant1 = rover_antenna()
|
|
merged = outdir / "merged_ppp.atx"
|
|
build_merged_atx([ant1], merged)
|
|
conf = write_conf(outdir / "ppp.conf", ant1=ant1, atx=merged)
|
|
pos = outdir / "ppp.pos"
|
|
ppp_mode = "7" if args.static else "6" # ppp-static only when flagged static
|
|
if pos.is_file() and not read_pos(pos).shape[0]:
|
|
pos.unlink() # stale empty output - retry
|
|
if not pos.is_file():
|
|
run([BIN / "rnx2rtkp", "-k", conf, "-p", ppp_mode, "-f", "3", "-t", "-u",
|
|
"-o", pos, obs, nav, sp3, clk])
|
|
if not pos.is_file() or not read_pos(pos).shape[0]:
|
|
pos.unlink(missing_ok=True) # empty output: let future runs retry
|
|
mode_name = "static" if args.static else "kinematic"
|
|
warnings.append(f"PPP ({mode_name}) produced no solution - RTKLIB's "
|
|
"PPP-kinematic engine is a known weak point. PPK kinematic "
|
|
"remains the post-processed reference solution. "
|
|
"(PPP-static is available only with --static.)")
|
|
return {"status": "failed"}
|
|
df = read_pos(pos)
|
|
log(f"ppp: {len(df)} epochs")
|
|
warnings.append("PPP note: 36-minute session is short for full PPP convergence; "
|
|
"treat PPP as a cross-check, not truth.")
|
|
return {"status": "ok", "df": df, "sp3": sp3.name, "clk": clk.name}
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Stage 6: track outputs
|
|
# ----------------------------------------------------------------------------
|
|
|
|
def stage_track(outdir, solutions, pvt, interval, warmup_cut=None):
|
|
# Among PPK-family solutions, pick by measured quality (empirical horizontal
|
|
# scatter about own mean) - a nominally-better base can produce a worse
|
|
# solution (seen with a network-edge Pivot VRS), so rank on evidence.
|
|
# PPP/single remain ordered fallbacks.
|
|
def scatter_h(df):
|
|
dn, de, _ = llh_to_enu(df["lat"], df["lon"], df["height"],
|
|
float(df["lat"].mean()), float(df["lon"].mean()), 0)
|
|
return float(np.sqrt(np.mean((dn - dn.mean()) ** 2 + (de - de.mean()) ** 2)))
|
|
|
|
ppk_family = [n for n in ("ppk_gcgc_kinematic", "ppk_vrs_kinematic",
|
|
"ppk_kinematic")
|
|
if n in solutions and len(solutions[n])]
|
|
if ppk_family:
|
|
source = min(ppk_family,
|
|
key=lambda n: scatter_h(after_warmup(solutions[n], warmup_cut)[0]))
|
|
best = solutions[source]
|
|
else:
|
|
for source in ("ppp", "single"):
|
|
if source in solutions and len(solutions[source]):
|
|
best = solutions[source]
|
|
break
|
|
else:
|
|
return None, None
|
|
|
|
df = best.copy()
|
|
df["utc_s"] = df["utc"].dt.round("1s")
|
|
ob = pvt.copy()
|
|
ob["utc_s"] = ob["utc"].dt.round("1s")
|
|
ob = ob[["utc_s", "lat", "lon", "height", "hAcc", "fixType"]].rename(
|
|
columns={"lat": "onboard_lat", "lon": "onboard_lon", "height": "onboard_height",
|
|
"hAcc": "onboard_hAcc_m", "fixType": "onboard_fixType"})
|
|
df = df.merge(ob, on="utc_s", how="left").drop(columns=["utc_s"])
|
|
df["source"] = source
|
|
if interval > 1:
|
|
epoch_s = (df["utc"] - pd.Timestamp(0, tz="utc")).dt.total_seconds().round()
|
|
df = df[epoch_s % interval == 0]
|
|
csv_path = outdir / "track.csv"
|
|
with open(csv_path, "w") as f:
|
|
f.write(f"# GPS track - solution source: {source}\n")
|
|
f.write("# lat/lon: decimal degrees; heights: ellipsoidal metres\n")
|
|
if source.startswith("ppk"):
|
|
f.write("# datum note: PPK positions are in the CORS base frame "
|
|
"(ITRF2020 current epoch when -r was applied)\n")
|
|
cols = ["utc", "lat", "lon", "height", "Q", "ns", "sdn", "sde", "sdu",
|
|
"source", "onboard_lat", "onboard_lon", "onboard_height",
|
|
"onboard_hAcc_m", "onboard_fixType"]
|
|
df.to_csv(f, index=False, columns=cols,
|
|
float_format="%.9f", date_format="%Y-%m-%dT%H:%M:%S.%fZ")
|
|
# KML for the chosen solution
|
|
kml_src = {"ppk_gcgc_kinematic": "ppk_gcgc_kinematic.pos",
|
|
"ppk_vrs_kinematic": "ppk_vrs_kinematic.pos",
|
|
"ppk_kinematic": "ppk_kinematic.pos", "ppp": "ppp.pos",
|
|
"single": "single.pos"}[source]
|
|
run([BIN / "pos2kml", outdir / kml_src])
|
|
kml = (outdir / kml_src).with_suffix(".kml")
|
|
if kml.is_file():
|
|
shutil.move(kml, outdir / "track.kml")
|
|
log(f"track: {len(df)} rows from {source} -> track.csv"
|
|
+ (" + track.kml" if (outdir / 'track.kml').is_file() else ""))
|
|
return df, source
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Figures (dataviz reference palette; static light-mode PNGs)
|
|
# ----------------------------------------------------------------------------
|
|
# Categorical slots (validated adjacent-pairs, light mode): fixed constellation order.
|
|
SERIES = ["#2a78d6", "#eb6834", "#1baf7a", "#eda100", "#e87ba4"] # blue orange aqua yellow magenta
|
|
CONST_ORDER = ["GPS", "Galileo", "BeiDou", "GLONASS", "SBAS"]
|
|
CONST_COLOR = dict(zip(CONST_ORDER, SERIES))
|
|
SEQ_RAMP = ["#cde2fb", "#9ec5f4", "#6da7ec", "#3987e5", "#256abf", "#184f95", "#0d366b"]
|
|
SURFACE, INK, INK2, MUTED = "#fcfcfb", "#0b0b0b", "#52514e", "#898781"
|
|
GRID, BASELINE = "#e1e0d9", "#c3c2b7"
|
|
# color follows the solution entity, never its rank in a given report
|
|
SOL_COLOR = {"ppk_kinematic": SERIES[0], "onboard": SERIES[1],
|
|
"ppk_gcgc_kinematic": SERIES[2], "single": SERIES[3], "ppp": SERIES[4],
|
|
"ppk_vrs_kinematic": "#008300"} # palette slot 6 (green)
|
|
# display/ranking order for solution tables (best geometry first)
|
|
SOLUTION_ORDER = ("ppk_gcgc_kinematic", "ppk_gcgc_static",
|
|
"ppk_vrs_kinematic", "ppk_vrs_static",
|
|
"ppk_kinematic", "ppk_static", "ppp", "single", "onboard")
|
|
|
|
# Human-readable names for reports and figure legends. Abbreviations are
|
|
# expanded once in the "Solution methods" section of each report.
|
|
SOLUTION_DISPLAY = {
|
|
"ppk_gcgc_kinematic": "PPK - local GCGC base data (kinematic)",
|
|
"ppk_gcgc_static": "PPK - local GCGC base data (static)",
|
|
"ppk_vrs_kinematic": "PPK - synthesized virtual reference station (kinematic)",
|
|
"ppk_vrs_static": "PPK - synthesized virtual reference station (static)",
|
|
"ppk_kinematic": "PPK - CORS base station (kinematic)",
|
|
"ppk_static": "PPK - CORS base station (static)",
|
|
"ppp": "Precise point positioning",
|
|
"single": "Single-point (standalone)",
|
|
"onboard": "Receiver onboard solution",
|
|
}
|
|
SOL_SHORT = {
|
|
"ppk_gcgc_kinematic": "PPK - local GCGC base",
|
|
"ppk_vrs_kinematic": "PPK - synthesized virtual base",
|
|
"ppk_kinematic": "PPK - CORS base",
|
|
"ppp": "precise point positioning",
|
|
"single": "single-point",
|
|
"onboard": "receiver onboard",
|
|
}
|
|
IONO_DISPLAY = {
|
|
"GPSA": "GPS Klobuchar ionospheric model, alpha coefficients",
|
|
"GPSB": "GPS Klobuchar ionospheric model, beta coefficients",
|
|
"BDSA": "BeiDou Klobuchar ionospheric model, alpha coefficients",
|
|
"BDSB": "BeiDou Klobuchar ionospheric model, beta coefficients",
|
|
"GAL": "Galileo NeQuick ionospheric model coefficients",
|
|
}
|
|
|
|
|
|
def sol_disp(name):
|
|
return SOLUTION_DISPLAY.get(name, name)
|
|
|
|
|
|
def method_definitions(present):
|
|
"""First-use expansion of every method abbreviation, for both reports."""
|
|
defs = []
|
|
if any(n.startswith("ppk") for n in present):
|
|
defs.append(("Post-processed kinematic (PPK)",
|
|
"carrier-phase differential positioning computed after the "
|
|
"fact against a base station"))
|
|
if any(n.startswith("ppk_vrs") for n in present):
|
|
defs.append(("Synthesized virtual reference station (VRS)",
|
|
"a base observation file synthesized at the capture position "
|
|
"from Gulf Coast Geospatial Center (GCGC) network station "
|
|
"data, giving a zero-length baseline"))
|
|
if any(n.startswith("ppk_gcgc") for n in present):
|
|
defs.append(("Local GCGC base data",
|
|
"Gulf Coast Geospatial Center (GCGC) data supplied locally: "
|
|
"a recorded correction stream or a Reference Data Shop "
|
|
"download"))
|
|
if "ppk_kinematic" in present or "ppk_static" in present:
|
|
defs.append(("CORS base station",
|
|
"the nearest National Geodetic Survey Continuously Operating "
|
|
"Reference Station (CORS)"))
|
|
if "ppp" in present:
|
|
defs.append(("Precise point positioning (PPP)",
|
|
"single-receiver technique using precise satellite orbit and "
|
|
"clock products instead of a base station"))
|
|
if "onboard" in present:
|
|
defs.append(("Receiver onboard solution",
|
|
"the u-blox receiver's own real-time output, aided by a "
|
|
"satellite-based augmentation system (SBAS)"))
|
|
return defs
|
|
|
|
|
|
def _style_ax(ax, xlabel=None, ylabel=None, title=None):
|
|
ax.set_facecolor(SURFACE)
|
|
for side in ("top", "right"):
|
|
ax.spines[side].set_visible(False)
|
|
for side in ("left", "bottom"):
|
|
ax.spines[side].set_color(BASELINE)
|
|
ax.tick_params(colors=MUTED, labelsize=8)
|
|
ax.grid(True, color=GRID, linewidth=0.7, alpha=0.9)
|
|
ax.set_axisbelow(True)
|
|
if xlabel:
|
|
ax.set_xlabel(xlabel, color=INK2, fontsize=9)
|
|
if ylabel:
|
|
ax.set_ylabel(ylabel, color=INK2, fontsize=9)
|
|
if title:
|
|
ax.set_title(title, color=INK, fontsize=10, loc="left", fontweight="bold")
|
|
|
|
|
|
def stage_figures(outdir, data, solutions, track_source, diag, warmup_cut=None):
|
|
import matplotlib
|
|
matplotlib.use("Agg")
|
|
import matplotlib.pyplot as plt
|
|
from matplotlib.lines import Line2D
|
|
from matplotlib.colors import LinearSegmentedColormap
|
|
|
|
figdir = outdir / "figures"
|
|
figdir.mkdir(exist_ok=True)
|
|
figs = []
|
|
pvt, rawx, sat = data["pvt"], data["rawx"], data["sat"]
|
|
rf_df, hdr = data["rf"], data["rawx_hdr"]
|
|
n_warm = 0
|
|
# warm-up window excluded from every plot (user requirement)
|
|
if warmup_cut is not None and len(hdr):
|
|
leap = int(hdr["leapS"].iloc[0])
|
|
n_warm = sum(1 for w, t in zip(hdr["week"], hdr["rcvTow"])
|
|
if gps_to_utc(int(w), float(t), leap) < warmup_cut)
|
|
if n_warm < len(hdr):
|
|
hdr = hdr.iloc[n_warm:]
|
|
rawx = rawx[rawx["epoch"] >= n_warm]
|
|
pvt, _ = after_warmup(pvt, warmup_cut)
|
|
sat = sat[sat["utc"] >= warmup_cut]
|
|
rf_df = rf_df[rf_df["utc"] >= warmup_cut]
|
|
solutions = {n: after_warmup(df, warmup_cut)[0]
|
|
for n, df in solutions.items()}
|
|
else:
|
|
n_warm = 0
|
|
ref = diag["position_error"]["reference"]
|
|
refpos = (ref["lat"], ref["lon"], ref["ellip_height_m"])
|
|
best = solutions.get(track_source) if track_source else None
|
|
seq_cmap = LinearSegmentedColormap.from_list("seq", SEQ_RAMP)
|
|
|
|
def newfig(w=8.0, h=4.5):
|
|
f = plt.figure(figsize=(w, h), facecolor=SURFACE, dpi=150)
|
|
return f
|
|
|
|
def save(f, name, title):
|
|
f.savefig(figdir / name, facecolor=SURFACE, bbox_inches="tight")
|
|
plt.close(f)
|
|
figs.append((name, title))
|
|
|
|
# ---- 1. horizontal position scatter (track map) with CEP circles --------
|
|
f = newfig(6.4, 6.0)
|
|
ax = f.add_subplot(111)
|
|
series = []
|
|
shown = [track_source] if best is not None and len(best) else []
|
|
shown += [n for n in ("ppk_gcgc_kinematic", "ppk_vrs_kinematic", "ppk_kinematic")
|
|
if n != track_source and n in solutions and len(solutions[n])]
|
|
for name in shown[:2]: # best + at most one other PPK method
|
|
df = solutions[name]
|
|
dn, de, _ = llh_to_enu(df["lat"], df["lon"], df["height"], *refpos)
|
|
series.append((SOL_SHORT.get(name, name), de, dn,
|
|
SOL_COLOR.get(name, SERIES[0])))
|
|
dn_o, de_o, _ = llh_to_enu(pvt["lat"], pvt["lon"], pvt["height"], *refpos)
|
|
series.append(("receiver onboard", de_o, dn_o, SOL_COLOR["onboard"]))
|
|
for name, de, dn, color in series:
|
|
ax.scatter(de, dn, s=6, c=color, alpha=0.5, linewidths=0, label=name)
|
|
for name, de, dn, color in series: # mean markers with surface ring
|
|
ax.scatter([np.mean(de)], [np.mean(dn)], s=90, c=color, edgecolors=SURFACE,
|
|
linewidths=2, zorder=5)
|
|
is_static = diag["session"].get("motion_mode", "").startswith("static")
|
|
radius_label = "CEP" if is_static else "R" # kinematic: radii include motion
|
|
if best is not None and len(best):
|
|
dn, de = series[0][2], series[0][1]
|
|
r = np.hypot(dn - dn.mean(), de - de.mean())
|
|
for pct, ls in ((50, "-"), (95, "--")):
|
|
rad = np.percentile(r, pct)
|
|
circ = plt.Circle((de.mean(), dn.mean()), rad, fill=False,
|
|
color=MUTED, linewidth=1.0, linestyle=ls)
|
|
ax.add_patch(circ)
|
|
ax.annotate(f"{pct}% radius: {rad:.2f} m", (de.mean(), dn.mean() + rad),
|
|
color=INK2, fontsize=8, ha="center", va="bottom")
|
|
ax.set_aspect("equal")
|
|
ax.legend(loc="upper right", fontsize=8, frameon=False, labelcolor=INK2)
|
|
scatter_word = "scatter" if is_static else "dispersion (incl. platform motion)"
|
|
_style_ax(ax, "East (m)", "North (m)",
|
|
f"Horizontal position {scatter_word}")
|
|
save(f, "fig1_track_scatter.png",
|
|
f"Horizontal position {scatter_word} with {radius_label}50/{radius_label}95 radii")
|
|
|
|
# ---- 2. ENU error time series ------------------------------------------
|
|
f = newfig(8.5, 6.0)
|
|
axes = f.subplots(3, 1, sharex=True)
|
|
comps = ["North", "East", "Up"]
|
|
on_enu = llh_to_enu(pvt["lat"], pvt["lon"], pvt["height"], *refpos)
|
|
best_enu = (llh_to_enu(best["lat"], best["lon"], best["height"], *refpos)
|
|
if best is not None and len(best) else None)
|
|
for i, ax in enumerate(axes):
|
|
if best_enu is not None:
|
|
ax.plot(best["utc"], best_enu[i], color=SOL_COLOR.get(track_source, SERIES[0]), linewidth=1.4,
|
|
label=SOL_SHORT.get(track_source, track_source))
|
|
ax.plot(pvt["utc"], on_enu[i], color=SERIES[1], linewidth=1.4,
|
|
label="receiver onboard")
|
|
ax.axhline(0, color=BASELINE, linewidth=0.8)
|
|
_style_ax(ax, None, f"{comps[i]} (m)")
|
|
axes[0].legend(loc="lower right", bbox_to_anchor=(1, 1.02), fontsize=8,
|
|
frameon=False, ncols=2, labelcolor=INK2)
|
|
axes[0].set_title("Position error vs reference, by component", color=INK,
|
|
fontsize=10, loc="left", fontweight="bold")
|
|
axes[-1].set_xlabel("UTC", color=INK2, fontsize=9)
|
|
f.autofmt_xdate()
|
|
save(f, "fig2_enu_timeseries.png", "Per-component position error time series")
|
|
|
|
# ---- 3. satellites: counts over time + C/N0 heatmap --------------------
|
|
f = newfig(8.5, 6.4)
|
|
gs = f.add_gridspec(2, 1, height_ratios=[1.15, 1], hspace=0.42)
|
|
ax = f.add_subplot(gs[0])
|
|
times = [gps_to_utc(int(w), t, int(hdr["leapS"].iloc[0]))
|
|
for w, t in zip(hdr["week"], hdr["rcvTow"])]
|
|
end_vals = {}
|
|
for name in CONST_ORDER:
|
|
gid = {v: k for k, v in GNSS_NAME.items()}[name]
|
|
per_epoch = rawx[rawx["gnss"] == gid].groupby("epoch")["sv"].nunique()
|
|
counts = per_epoch.reindex(range(n_warm, n_warm + len(hdr)), fill_value=0)
|
|
ax.plot(times, counts, color=CONST_COLOR[name], linewidth=1.4, label=name)
|
|
end_vals[name] = float(counts.iloc[-1])
|
|
# selective direct labels: only when the line end is clearly separated
|
|
for name, v in end_vals.items():
|
|
if all(abs(v - o) > 1.0 for n, o in end_vals.items() if n != name):
|
|
ax.annotate(name, (times[-1], v), xytext=(4, 0),
|
|
textcoords="offset points", color=CONST_COLOR[name],
|
|
fontsize=8, va="center")
|
|
# legend sits in the title-pad band, directly above the axes; title above it
|
|
ax.legend(loc="lower left", fontsize=8, frameon=False, ncols=5,
|
|
bbox_to_anchor=(0, 1.01), labelcolor=INK2)
|
|
_style_ax(ax, None, "satellites tracked")
|
|
ax.set_title("Satellites tracked per constellation", color=INK, fontsize=10,
|
|
loc="left", fontweight="bold", pad=26)
|
|
|
|
axc = f.add_subplot(gs[1])
|
|
bands = ["L1", "L2", "L5", "L6/E6"]
|
|
mat = np.full((len(CONST_ORDER), len(bands)), np.nan)
|
|
for i, cname in enumerate(CONST_ORDER):
|
|
gid = {v: k for k, v in GNSS_NAME.items()}[cname]
|
|
g = rawx[rawx["gnss"] == gid]
|
|
for j, b in enumerate(bands):
|
|
sel = g[g["freq"].map(band_of) == b]["cno"]
|
|
if len(sel):
|
|
mat[i, j] = sel.mean()
|
|
im = axc.imshow(mat, cmap=seq_cmap, aspect="auto", vmin=25, vmax=45)
|
|
axc.set_xticks(range(len(bands)), bands)
|
|
axc.set_yticks(range(len(CONST_ORDER)), CONST_ORDER)
|
|
for i in range(mat.shape[0]):
|
|
for j in range(mat.shape[1]):
|
|
if not np.isnan(mat[i, j]):
|
|
axc.text(j, i, f"{mat[i, j]:.1f}", ha="center", va="center",
|
|
color=INK if mat[i, j] < 38 else SURFACE, fontsize=9)
|
|
axc.set_title("Mean carrier-to-noise density ratio (C/N0) by constellation "
|
|
"and band (dB-Hz)", color=INK,
|
|
fontsize=10, loc="left", fontweight="bold")
|
|
axc.tick_params(colors=MUTED, labelsize=9)
|
|
for sp in axc.spines.values():
|
|
sp.set_visible(False)
|
|
save(f, "fig3_satellites.png",
|
|
"Satellites tracked and carrier-to-noise density ratio by band")
|
|
|
|
# ---- 4. skyplot ---------------------------------------------------------
|
|
f = newfig(6.4, 6.4)
|
|
ax = f.add_subplot(111, projection="polar")
|
|
ax.set_facecolor(SURFACE)
|
|
ax.set_theta_zero_location("N")
|
|
ax.set_theta_direction(-1)
|
|
sky = (sat[(sat["elev"] > 0) & (sat["cno"] > 0)]
|
|
.groupby(["gnss", "sv"]).agg(elev=("elev", "mean"), azim=("azim", "mean"),
|
|
cno=("cno", "mean")).reset_index())
|
|
sc = ax.scatter(np.radians(sky["azim"]), 90 - sky["elev"], c=sky["cno"],
|
|
cmap=seq_cmap, s=42, vmin=25, vmax=45,
|
|
edgecolors=SURFACE, linewidths=1)
|
|
ax.set_rlim(0, 90)
|
|
ax.set_rgrids([30, 60], ["60°", "30°"], color=MUTED, fontsize=8)
|
|
ax.set_thetagrids([0, 90, 180, 270], ["N", "E", "S", "W"], color=INK2)
|
|
ax.grid(color=GRID, linewidth=0.7)
|
|
cb = f.colorbar(sc, ax=ax, shrink=0.7, pad=0.08)
|
|
cb.set_label("mean carrier-to-noise density ratio (dB-Hz)", color=INK2, fontsize=9)
|
|
cb.ax.tick_params(colors=MUTED, labelsize=8)
|
|
cb.outline.set_visible(False)
|
|
ax.set_title("Sky plot (session-mean elevation/azimuth per satellite)", color=INK,
|
|
fontsize=10, fontweight="bold")
|
|
save(f, "fig4_skyplot.png",
|
|
"Sky plot colored by carrier-to-noise density ratio")
|
|
|
|
# ---- 5. accuracy metrics over time -------------------------------------
|
|
f = newfig(8.5, 5.4)
|
|
ax1, ax2 = f.subplots(2, 1, sharex=True)
|
|
ax1.plot(pvt["utc"], pvt["hAcc"], color=SERIES[1], linewidth=1.4,
|
|
label="onboard horizontal accuracy estimate (calibrated)")
|
|
ax1.plot(pvt["utc"], pvt["vAcc"], color=SERIES[1], linewidth=1.0,
|
|
linestyle="--", label="onboard vertical accuracy estimate")
|
|
if best is not None and "sdn" in best and best["sdn"].abs().sum() > 0:
|
|
sdh = np.hypot(best["sdn"], best["sde"])
|
|
ax1.plot(best["utc"], sdh, color=SOL_COLOR.get(track_source, SERIES[0]), linewidth=1.4,
|
|
label=f"{SOL_SHORT.get(track_source, track_source)} "
|
|
f"formal 1-sigma (optimistic)")
|
|
ax1.plot(best["utc"], best["sdu"], color=SOL_COLOR.get(track_source, SERIES[0]), linewidth=1.0,
|
|
linestyle="--",
|
|
label=f"{SOL_SHORT.get(track_source, track_source)} "
|
|
f"formal 1-sigma, up")
|
|
# measured dispersion reference line: the gap between this and the
|
|
# formal curve IS the message (filter self-estimates are uncalibrated)
|
|
dnb, deb, _ = llh_to_enu(best["lat"], best["lon"], best["height"], *refpos)
|
|
disp_rms = float(np.sqrt(np.mean((dnb - dnb.mean()) ** 2
|
|
+ (deb - deb.mean()) ** 2)))
|
|
ax1.axhline(disp_rms, color=MUTED, linewidth=1.2, linestyle=":")
|
|
ax1.annotate(f"measured horizontal dispersion RMS {disp_rms:.2f} m "
|
|
f"(formal 1-sigma underestimates by about "
|
|
f"{disp_rms/max(sdh.mean(),1e-3):.0f}x)",
|
|
(pvt["utc"].iloc[0], disp_rms), xytext=(4, 4),
|
|
textcoords="offset points", color=INK2, fontsize=8)
|
|
ax1.legend(loc="upper right", fontsize=8, frameon=False, ncols=2, labelcolor=INK2)
|
|
_style_ax(ax1, None, "1-sigma estimate (m)",
|
|
"Self-reported precision (uncalibrated) vs measured dispersion")
|
|
ax2.plot(pvt["utc"], pvt["pDOP"], color=SERIES[2], linewidth=1.4)
|
|
ax2.annotate("PDOP", (pvt["utc"].iloc[-1], pvt["pDOP"].iloc[-1]),
|
|
xytext=(4, 0), textcoords="offset points", color=SERIES[2],
|
|
fontsize=8, va="center")
|
|
_style_ax(ax2, "UTC", "position dilution of precision", None)
|
|
f.autofmt_xdate()
|
|
save(f, "fig5_accuracy.png", "Self-reported precision vs measured dispersion; DOP")
|
|
|
|
# ---- 6. ionosphere + RF -------------------------------------------------
|
|
f = newfig(8.5, 6.2)
|
|
gs = f.add_gridspec(2, 2, height_ratios=[1.2, 1], hspace=0.5, wspace=0.3)
|
|
axi = f.add_subplot(gs[0, :])
|
|
gf = diag["ionosphere"]["geometry_free_per_satellite"]
|
|
if gf:
|
|
labels = [f"{r['gnss'][:3]}{r['sv']:02d}" for r in gf]
|
|
vals = [r["iono_L1_m_median"] for r in gf]
|
|
cols = [CONST_COLOR.get(r["gnss"], MUTED) for r in gf]
|
|
x = np.arange(len(gf))
|
|
axi.bar(x, vals, color=cols, width=0.7)
|
|
axi.set_xticks(x, labels, rotation=90, fontsize=6.5)
|
|
handles = [Line2D([0], [0], marker="s", linestyle="", color=CONST_COLOR[c],
|
|
label=c) for c in CONST_ORDER if any(r["gnss"] == c for r in gf)]
|
|
axi.legend(handles=handles, loc="upper right", fontsize=8, frameon=False,
|
|
ncols=len(handles), labelcolor=INK2)
|
|
_style_ax(axi, None, "slant delay @L1 (m)",
|
|
"Dual-frequency geometry-free ionospheric delay (session median per satellite)")
|
|
|
|
axr = f.add_subplot(gs[1, 0])
|
|
rfagg = rf_df.groupby("blockId")["jamInd"].agg(["mean", "max"])
|
|
x = np.arange(len(rfagg))
|
|
axr.bar(x, rfagg["mean"], color=SERIES[0], width=0.55, label="mean")
|
|
axr.scatter(x, rfagg["max"], color=INK2, s=24, zorder=5, label="max")
|
|
axr.set_xticks(x, [f"block {b}" for b in rfagg.index])
|
|
axr.set_ylim(0, 255)
|
|
axr.axhline(255 * 0.6, color=MUTED, linewidth=0.8, linestyle="--")
|
|
axr.annotate("interference concern >153", (0, 158), color=MUTED, fontsize=7)
|
|
axr.legend(loc="upper right", fontsize=8, frameon=False, labelcolor=INK2)
|
|
_style_ax(axr, None, "jamming indicator (0-255)",
|
|
"Radio-frequency interference indicator")
|
|
|
|
axj = f.add_subplot(gs[1, 1])
|
|
jitter_ms = (np.diff(hdr["rcvTow"].to_numpy()) - 1.0) * 1000
|
|
axj.hist(jitter_ms, bins=40, color=SERIES[0])
|
|
_style_ax(axj, "epoch interval error (ms)", "epochs", "Measurement epoch jitter")
|
|
save(f, "fig6_iono_rf.png",
|
|
"Ionospheric delay, radio-frequency health, timing jitter")
|
|
|
|
log(f"figures: {len(figs)} PNGs -> {figdir}")
|
|
return figs
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Stage 7: diagnostics + report
|
|
# ----------------------------------------------------------------------------
|
|
|
|
def after_warmup(df, cut, time_col="utc"):
|
|
"""Drop rows before the warm-up cutoff (quality metrics only - data outputs
|
|
keep everything). Falls back to the full data if the session is shorter
|
|
than the warm-up window."""
|
|
if df is None or cut is None or not len(df):
|
|
return df, False
|
|
out = df[df[time_col] >= cut]
|
|
if not len(out):
|
|
return df, True
|
|
return out, False
|
|
|
|
|
|
def solution_stats(df, ref):
|
|
"""Error metrics for one solution DataFrame about reference (lat, lon, h)."""
|
|
if df is None or not len(df):
|
|
return None
|
|
dn, de, du = llh_to_enu(df["lat"], df["lon"], df["height"], *ref)
|
|
horiz = np.hypot(dn - dn.mean(), de - de.mean()) # scatter about own mean
|
|
stats = {
|
|
"epochs": int(len(df)),
|
|
"mean_lat": float(df["lat"].mean()), "mean_lon": float(df["lon"].mean()),
|
|
"mean_height": float(df["height"].mean()),
|
|
"offset_from_ref_NEU_m": [float(dn.mean()), float(de.mean()), float(du.mean())],
|
|
"rms_NEU_m": [float(np.sqrt(np.mean((dn - dn.mean()) ** 2))),
|
|
float(np.sqrt(np.mean((de - de.mean()) ** 2))),
|
|
float(np.sqrt(np.mean((du - du.mean()) ** 2)))],
|
|
"cep50_m": float(np.percentile(horiz, 50)),
|
|
"cep95_m": float(np.percentile(horiz, 95)),
|
|
"2drms_m": float(2 * np.sqrt(np.var(dn) + np.var(de))),
|
|
}
|
|
if "sdn" in df and df["sdn"].abs().sum() > 0:
|
|
stats["formal_sd_NEU_m_mean"] = [float(df["sdn"].mean()), float(df["sde"].mean()),
|
|
float(df["sdu"].mean())]
|
|
if "Q" in df:
|
|
qc = Counter(df["Q"])
|
|
stats["quality_histogram"] = {QUALITY_NAME.get(k, str(k)): int(v)
|
|
for k, v in sorted(qc.items())}
|
|
if qc.get(1) or qc.get(2): # fix rate only meaningful for RTK/PPK
|
|
stats["fix_rate"] = float(qc.get(1, 0) / len(df))
|
|
return stats
|
|
|
|
|
|
def geometry_free_iono(rawx):
|
|
"""Per-satellite slant iono estimate (m at L1) from dual-frequency pseudoranges."""
|
|
valid = rawx[(rawx["prValid"] == 1) & (rawx["freq"] > 0)]
|
|
out = []
|
|
for (gnss, sv), g in valid.groupby(["gnss", "sv"]):
|
|
freqs = g.groupby("freq")["prMes"].count()
|
|
if len(freqs) < 2:
|
|
continue
|
|
f1 = max(freqs.index) # highest frequency (L1-ish)
|
|
f2 = min(freqs.index) # lowest (L5/L2)
|
|
if f1 - f2 < 50e6:
|
|
continue
|
|
a = g[g["freq"] == f1].set_index("epoch")["prMes"]
|
|
b = g[g["freq"] == f2].set_index("epoch")["prMes"]
|
|
both = pd.concat([a.groupby(level=0).first(), b.groupby(level=0).first()],
|
|
axis=1, keys=["p1", "p2"]).dropna()
|
|
if len(both) < 30:
|
|
continue
|
|
gamma = (f1 / f2) ** 2
|
|
i_f1 = (both["p2"] - both["p1"]) / (gamma - 1) # iono delay at f1, metres
|
|
i_l1 = i_f1 * (f1 / 1575.42e6) ** 2 # scale to L1
|
|
tecu = i_f1 * f1 ** 2 / 40.3 / 1e16 # TECU (1e16 el/m^2)
|
|
out.append({"gnss": GNSS_NAME.get(gnss, gnss), "sv": int(sv),
|
|
"pair": f"{band_of(f1)}/{band_of(f2)}", "n": int(len(both)),
|
|
"iono_L1_m_median": round(float(i_l1.median()), 3),
|
|
"iono_L1_m_iqr": round(float(i_l1.quantile(.75) - i_l1.quantile(.25)), 3),
|
|
"TECU_median": round(float(tecu.median()), 2)})
|
|
return sorted(out, key=lambda r: (r["gnss"], r["sv"]))
|
|
|
|
|
|
def stage_diagnostics(outdir, data, rinex_info, solutions, ppk_result, track_source,
|
|
warnings, args, gcgc_result=None, vrs_result=None,
|
|
warmup_cut=None):
|
|
rawx, pvt, sat, sig, rf = data["rawx"], data["pvt"], data["sat"], data["sig"], data["rf"]
|
|
|
|
# Quality metrics exclude the receiver warm-up window (user requirement:
|
|
# first N minutes are still converging). Data outputs are NOT filtered.
|
|
pvt_m, warm_fb = after_warmup(pvt, warmup_cut)
|
|
if warm_fb:
|
|
warnings.append(f"session shorter than the {args.warmup_min:g} min warm-up "
|
|
"window - quality metrics use the full session")
|
|
msol = {}
|
|
for name, df in solutions.items():
|
|
msol[name], _ = after_warmup(df, warmup_cut)
|
|
if warmup_cut is not None and not warm_fb:
|
|
hdr = data["rawx_hdr"]
|
|
leap = int(hdr["leapS"].iloc[0]) if len(hdr) else 18
|
|
n_warm = sum(1 for w, t in zip(hdr["week"], hdr["rcvTow"])
|
|
if gps_to_utc(int(w), float(t), leap) < warmup_cut) if len(hdr) else 0
|
|
rawx = rawx[rawx["epoch"] >= n_warm]
|
|
sat = sat[sat["utc"] >= warmup_cut]
|
|
sig = sig[sig["utc"] >= warmup_cut]
|
|
rf = rf[rf["utc"] >= warmup_cut]
|
|
|
|
# --- reference position: PPK static end-state (only if --static was flagged)
|
|
# > PPK kinematic mean > onboard mean; GCGC (shortest baseline) preferred
|
|
# over NGS at each tier. In kinematic mode the reference is a dispersion
|
|
# anchor only - "scatter" includes any real platform motion.
|
|
ref_source = "onboard session mean"
|
|
ref = (float(pvt_m["lat"].mean()), float(pvt_m["lon"].mean()),
|
|
float(pvt_m["height"].mean()))
|
|
for name in ("ppk_gcgc_static", "ppk_vrs_static", "ppk_static"):
|
|
if name in msol and len(msol[name]):
|
|
last = msol[name].iloc[-1]
|
|
ref = (float(last["lat"]), float(last["lon"]), float(last["height"]))
|
|
ref_source = (f"{name} final epoch (Q={QUALITY_NAME.get(int(last['Q']))})")
|
|
break
|
|
else:
|
|
for name in ("ppk_gcgc_kinematic", "ppk_vrs_kinematic", "ppk_kinematic"):
|
|
if name in msol and len(msol[name]):
|
|
d = msol[name]
|
|
ref = (float(d["lat"].mean()), float(d["lon"].mean()),
|
|
float(d["height"].mean()))
|
|
ref_source = (f"{name} session mean (dispersion anchor; "
|
|
"includes platform motion)")
|
|
break
|
|
|
|
# --- constellation metrics
|
|
per_gnss = {}
|
|
for gnss, g in rawx.groupby("gnss"):
|
|
name = GNSS_NAME.get(gnss, str(gnss))
|
|
bands = {}
|
|
for band, gb in g.groupby(g["freq"].map(band_of)):
|
|
bands[band] = {"signals": int(gb.groupby(["sv", "sig"]).ngroups),
|
|
"meas": int(len(gb)),
|
|
"cno_mean": round(float(gb["cno"].mean()), 1),
|
|
"cno_min": int(gb["cno"].min()), "cno_max": int(gb["cno"].max())}
|
|
# cycle-slip proxy: locktime decreases per (sv, sig) stream
|
|
slips = 0
|
|
for _, gs in g.groupby(["sv", "sig"]):
|
|
lt = gs.sort_values("epoch")["locktime"].to_numpy()
|
|
slips += int((np.diff(lt) < 0).sum())
|
|
used = sat[(sat["gnss"] == gnss) & (sat["used"] == 1)]
|
|
prres = sat[(sat["gnss"] == gnss) & (sat["used"] == 1) & (sat["prRes"] != 0)]["prRes"]
|
|
per_gnss[name] = {
|
|
"satellites_tracked": int(g["sv"].nunique()),
|
|
"satellites_used_avg": round(len(used) / max(1, data["counts"]["NAV-SAT"]), 1),
|
|
"signals_tracked": int(g.groupby(["sv", "sig"]).ngroups),
|
|
"measurements": int(len(g)),
|
|
"bands": bands,
|
|
"cycle_slip_events": slips,
|
|
"sfrbx_frames": int(data["sfrbx_gnss"].get(gnss, 0)),
|
|
"prRes_rms_m": round(float(np.sqrt((prres ** 2).mean())), 2) if len(prres) else None,
|
|
}
|
|
|
|
# --- ionosphere
|
|
iono_gf = geometry_free_iono(rawx)
|
|
iono_section = {
|
|
"broadcast_klobuchar": rinex_info.get("iono", {}),
|
|
"ionoModel_census": {IONO_MODEL.get(k, str(k)): int(v)
|
|
for k, v in sig["ionoModel"].value_counts().items()},
|
|
"corrSource_census": {CORR_SOURCE.get(k, str(k)): int(v)
|
|
for k, v in sig["corrSource"].value_counts().items()},
|
|
"geometry_free_per_satellite": iono_gf,
|
|
"note": "geometry-free estimates are slant (not vertical) delays derived from "
|
|
"dual-frequency code; median over session, metres at L1 frequency",
|
|
}
|
|
|
|
# --- positional error metrics
|
|
pos_section = {"reference": {"source": ref_source, "lat": ref[0], "lon": ref[1],
|
|
"ellip_height_m": ref[2]}}
|
|
onboard_df = pvt_m.rename(columns={})[["utc", "lat", "lon", "height"]].copy()
|
|
for name, df in [("onboard", onboard_df)] + list(msol.items()):
|
|
s = solution_stats(df, ref)
|
|
if s:
|
|
pos_section[name] = s
|
|
pos_section["onboard_reported_accuracy"] = {
|
|
"hAcc_m": {"mean": round(float(pvt_m["hAcc"].mean()), 3),
|
|
"min": round(float(pvt_m["hAcc"].min()), 3),
|
|
"max": round(float(pvt_m["hAcc"].max()), 3)},
|
|
"vAcc_m": {"mean": round(float(pvt_m["vAcc"].mean()), 3)},
|
|
"pDOP": {"mean": round(float(pvt_m["pDOP"].mean()), 2),
|
|
"max": round(float(pvt_m["pDOP"].max()), 2)},
|
|
}
|
|
if ppk_result.get("station"):
|
|
st = ppk_result["station"]
|
|
pos_section["ppk_base"] = {"id": st["id"], "distance_km": round(st["km"], 1),
|
|
"antenna": ppk_result.get("ant2"),
|
|
"ecef_itrf2020": ppk_result.get("base_xyz")}
|
|
if gcgc_result and gcgc_result.get("station"):
|
|
st = gcgc_result["station"]
|
|
pos_section["ppk_gcgc_base"] = {
|
|
"id": st["id"],
|
|
"distance_km": None if math.isnan(st["km"]) else round(st["km"], 1),
|
|
"antenna": gcgc_result.get("ant2"),
|
|
"base_obs": gcgc_result.get("base_obs"),
|
|
"ecef": gcgc_result.get("base_xyz")}
|
|
if vrs_result and vrs_result.get("station"):
|
|
st = vrs_result["station"]
|
|
pos_section["ppk_vrs_base"] = {
|
|
"id": st["id"], "distance_km": 0.0,
|
|
"antenna": vrs_result.get("ant2"),
|
|
"ecef": vrs_result.get("base_xyz"),
|
|
"note": "virtual base synthesized at the capture position "
|
|
"(validated engine, experiments/FINDINGS.md)"}
|
|
|
|
# --- timing metrics
|
|
jitter_ms = (np.diff(data["rawx_hdr"]["rcvTow"].to_numpy()) - 1.0) * 1000
|
|
timing = {
|
|
"epochs": int(len(data["rawx_hdr"])),
|
|
"nominal_interval_s": 1.0,
|
|
"epoch_jitter_ms": {"mean": round(float(jitter_ms.mean()), 4),
|
|
"std": round(float(jitter_ms.std()), 4),
|
|
"max_abs": round(float(np.abs(jitter_ms).max()), 4)},
|
|
"tAcc_ns": {"mean": round(float(pvt_m["tAcc_ns"].mean()), 1),
|
|
"min": int(pvt_m["tAcc_ns"].min()),
|
|
"max": int(pvt_m["tAcc_ns"].max())},
|
|
"leap_seconds": {"rawx": int(data["rawx_hdr"]["leapS"].iloc[0]),
|
|
"valid_flag": bool(data["rawx_hdr"]["leapSecValid"].iloc[0])},
|
|
}
|
|
|
|
# --- validation of computed solutions against the receiver's own embedded
|
|
# position solution (NAV-PVT), matched epoch-by-epoch
|
|
validation = {}
|
|
on = pvt_m[["utc", "lat", "lon", "height"]].copy()
|
|
on["utc_s"] = on["utc"].dt.round("1s")
|
|
for name, df in msol.items():
|
|
if df is None or not len(df):
|
|
continue
|
|
d = df.copy()
|
|
d["utc_s"] = d["utc"].dt.round("1s")
|
|
j = d.merge(on, on="utc_s", suffixes=("", "_ob"))
|
|
if not len(j):
|
|
continue
|
|
dn, de, du = llh_to_enu(j["lat"], j["lon"], j["height"],
|
|
float(j["lat_ob"].mean()), float(j["lon_ob"].mean()),
|
|
float(j["height_ob"].mean()))
|
|
dn_o, de_o, du_o = llh_to_enu(j["lat_ob"], j["lon_ob"], j["height_ob"],
|
|
float(j["lat_ob"].mean()), float(j["lon_ob"].mean()),
|
|
float(j["height_ob"].mean()))
|
|
ddn, dde, ddu = dn - dn_o, de - de_o, du - du_o
|
|
horiz = float(np.hypot(ddn.mean(), dde.mean()))
|
|
# agreement thresholds vs the onboard ~1 m (1-sigma) SBAS solution, scaled
|
|
# by solution quality: ambiguity-fixed PPK must agree tightly; float PPK
|
|
# carries metre-level bias uncertainty; single-point is the loosest
|
|
if name.startswith(("ppk", "ppp")):
|
|
fixed = (df["Q"] == 1).mean() > 0.5 if "Q" in df else False
|
|
thresh_h = 1.5 if fixed else 4.0
|
|
else:
|
|
thresh_h = 5.0
|
|
validation[name] = {
|
|
"matched_epochs": int(len(j)),
|
|
"mean_offset_NEU_m": [round(float(ddn.mean()), 3), round(float(dde.mean()), 3),
|
|
round(float(ddu.mean()), 3)],
|
|
"rms_diff_NEU_m": [round(float(np.sqrt((ddn ** 2).mean())), 3),
|
|
round(float(np.sqrt((dde ** 2).mean())), 3),
|
|
round(float(np.sqrt((ddu ** 2).mean())), 3)],
|
|
"horizontal_offset_m": round(horiz, 3),
|
|
"threshold_m": thresh_h,
|
|
"verdict": "PASS" if horiz < thresh_h else "FAIL",
|
|
}
|
|
log(f"validate {name} vs onboard: horiz offset {horiz:.3f} m "
|
|
f"(threshold {thresh_h} m) -> {validation[name]['verdict']}")
|
|
|
|
# --- RF health
|
|
rf_section = {}
|
|
for block, g in rf.groupby("blockId"):
|
|
rf_section[f"block_{block}"] = {
|
|
"jamInd_mean": round(float(g["jamInd"].mean()), 1),
|
|
"jamInd_max": int(g["jamInd"].max()),
|
|
"jammingState_nonzero": int((g["jammingState"] != 0).sum()),
|
|
"noisePerMS_mean": round(float(g["noisePerMS"].mean()), 1),
|
|
"agcCnt_mean": round(float(g["agcCnt"].mean()), 0),
|
|
"antStatus": ANT_STATUS.get(int(g["antStatus"].mode()[0]), "?"),
|
|
}
|
|
|
|
diagnostics = {
|
|
"session": {
|
|
"file": str(args.capture),
|
|
"motion_mode": "static (explicitly flagged)" if args.static
|
|
else "kinematic (default)",
|
|
"quality_metrics_exclude_first_min": (args.warmup_min
|
|
if warmup_cut is not None else None),
|
|
"receiver": data["meta"].get("extensions", []),
|
|
"module_fw": data["meta"].get("swVersion"),
|
|
"start_utc": data["t_start"].isoformat(),
|
|
"end_utc": data["t_end"].isoformat(),
|
|
"duration_min": round((data["t_end"] - data["t_start"]).total_seconds() / 60, 1),
|
|
"gps_week": int(data["rawx_hdr"]["week"].iloc[0]),
|
|
"message_counts": {k: int(v) for k, v in data["counts"].most_common()},
|
|
"track_solution_source": track_source,
|
|
},
|
|
"constellations": per_gnss,
|
|
"ionosphere": iono_section,
|
|
"position_error": pos_section,
|
|
"validation_vs_onboard": validation,
|
|
"timing": timing,
|
|
"rf_health": rf_section,
|
|
"warnings": warnings,
|
|
}
|
|
(outdir / "diagnostics.json").write_text(json.dumps(diagnostics, indent=2, default=str))
|
|
log(f"diagnostics: diagnostics.json written ({len(warnings)} warnings)")
|
|
return diagnostics
|
|
|
|
|
|
def write_report(outdir, d, figs=()):
|
|
L = []
|
|
s = d["session"]
|
|
pe = d["position_error"]
|
|
L.append(f"# GPS Session Report - {Path(s['file']).name}\n")
|
|
L.append(f"- **Receiver**: {s.get('module_fw')} "
|
|
f"({', '.join(x for x in (s.get('receiver') or []) if 'MOD=' in x)})")
|
|
L.append(f"- **Window**: {s['start_utc']} -> {s['end_utc']} ({s['duration_min']} min, "
|
|
f"GPS week {s['gps_week']})")
|
|
L.append(f"- **Track solution**: {sol_disp(s['track_solution_source'])} "
|
|
f"(`{s['track_solution_source']}`)\n")
|
|
|
|
ref = pe["reference"]
|
|
L.append("## Position summary\n")
|
|
wm = s.get("quality_metrics_exclude_first_min")
|
|
if wm:
|
|
L.append(f"> **Note:** the first {wm:g} minutes of the session (receiver "
|
|
"warm-up) are excluded from all metrics and figures below. "
|
|
"track.csv retains the full session.\n")
|
|
present = [n for n in SOLUTION_ORDER if pe.get(n)]
|
|
L.append("**Solution methods**\n")
|
|
for term, desc in method_definitions(present):
|
|
L.append(f"- **{term}**: {desc}")
|
|
L.append("")
|
|
ref_src = ref["source"]
|
|
for k in sorted(SOLUTION_DISPLAY, key=len, reverse=True):
|
|
ref_src = ref_src.replace(k, SOLUTION_DISPLAY[k])
|
|
L.append(f"Motion mode: **{s.get('motion_mode', '?')}**\n")
|
|
L.append(f"Reference ({ref_src}): **{ref['lat']:.9f} deg, {ref['lon']:.9f} deg**, "
|
|
f"ellipsoidal height {ref['ellip_height_m']:.3f} m\n")
|
|
if not s.get("motion_mode", "").startswith("static"):
|
|
L.append("> Kinematic mode: dispersion figures include any real platform "
|
|
"motion, not just measurement error.\n")
|
|
L.append("| Solution | Epochs | Offset from reference, north/east/up (m) | "
|
|
"RMS dispersion, north/east/up (m) | 50% radius (m) | 95% radius (m) | "
|
|
"Ambiguity fix rate |")
|
|
L.append("|---|---|---|---|---|---|---|")
|
|
for name in present:
|
|
st = pe[name]
|
|
off = "/".join(f"{v:+.3f}" for v in st["offset_from_ref_NEU_m"])
|
|
rms = "/".join(f"{v:.3f}" for v in st["rms_NEU_m"])
|
|
fr = f"{st['fix_rate']*100:.1f}%" if "fix_rate" in st else "-"
|
|
L.append(f"| {sol_disp(name)} | {st['epochs']} | {off} | {rms} | "
|
|
f"{st['cep50_m']:.3f} | {st['cep95_m']:.3f} | {fr} |")
|
|
oa = pe["onboard_reported_accuracy"]
|
|
L.append(f"\nOnboard self-reported horizontal accuracy estimate: mean "
|
|
f"{oa['hAcc_m']['mean']} m (range {oa['hAcc_m']['min']}-"
|
|
f"{oa['hAcc_m']['max']} m); mean position dilution of precision "
|
|
f"{oa['pDOP']['mean']}\n")
|
|
if "ppk_base" in pe:
|
|
b = pe["ppk_base"]
|
|
L.append(f"CORS base station: **{b['id']}** at {b['distance_km']} km, "
|
|
f"antenna `{b['antenna']}`\n")
|
|
if "ppk_gcgc_base" in pe:
|
|
b = pe["ppk_gcgc_base"]
|
|
dist = (f"{b['distance_km']} km" if b.get("distance_km") is not None
|
|
else "unknown baseline")
|
|
L.append(f"Local GCGC base (parallel method): **{b['id']}** at {dist}, "
|
|
f"antenna `{b['antenna']}`, observation file "
|
|
f"`{Path(str(b.get('base_obs'))).name}`\n")
|
|
if "ppk_vrs_base" in pe:
|
|
b = pe["ppk_vrs_base"]
|
|
L.append(f"Synthesized virtual reference station: **{b['id']}** at the "
|
|
f"capture position (zero baseline), antenna `{b['antenna']}`\n")
|
|
|
|
if d.get("validation_vs_onboard"):
|
|
L.append("## Validation against the receiver's onboard solution\n")
|
|
L.append("Each computed solution is cross-checked epoch by epoch against "
|
|
"the position solution embedded in the capture (UBX-NAV-PVT "
|
|
"messages):\n")
|
|
L.append("| Solution | Matched epochs | Mean offset, north/east/up (m) | "
|
|
"RMS difference, north/east/up (m) | Horizontal offset | "
|
|
"Threshold | Verdict |")
|
|
L.append("|---|---|---|---|---|---|---|")
|
|
for name, v in d["validation_vs_onboard"].items():
|
|
off = "/".join(f"{x:+.3f}" for x in v["mean_offset_NEU_m"])
|
|
rms = "/".join(f"{x:.3f}" for x in v["rms_diff_NEU_m"])
|
|
L.append(f"| {sol_disp(name)} | {v['matched_epochs']} | {off} | {rms} | "
|
|
f"{v['horizontal_offset_m']} m | < {v['threshold_m']} m | "
|
|
f"**{v['verdict']}** |")
|
|
L.append("")
|
|
|
|
if figs:
|
|
L.append("## Figures\n")
|
|
for name, title in figs:
|
|
L.append(f"### {title}\n")
|
|
L.append(f"\n")
|
|
|
|
L.append("## Constellations\n")
|
|
L.append("C/N0 is the carrier-to-noise density ratio (signal strength, "
|
|
"dB-Hz).\n")
|
|
L.append("| Constellation | Satellites | Signals | Measurements | "
|
|
"Mean C/N0 by band (dB-Hz) | Cycle-slip events | Ephemeris frames | "
|
|
"Pseudorange residual RMS (m) |")
|
|
L.append("|---|---|---|---|---|---|---|---|")
|
|
for name, c in d["constellations"].items():
|
|
bands = ", ".join(f"{b}: {v['cno_mean']}" for b, v in sorted(c["bands"].items()))
|
|
L.append(f"| {name} | {c['satellites_tracked']} | {c['signals_tracked']} | "
|
|
f"{c['measurements']} | {bands} | {c['cycle_slip_events']} | "
|
|
f"{c['sfrbx_frames']} | {c['prRes_rms_m'] or '-'} |")
|
|
|
|
L.append("\n## Ionosphere\n")
|
|
for k, v in d["ionosphere"]["broadcast_klobuchar"].items():
|
|
L.append(f"- Broadcast {IONO_DISPLAY.get(k, k)}: {v}")
|
|
L.append(f"- Ionospheric correction model used per signal: "
|
|
f"{d['ionosphere']['ionoModel_census']}")
|
|
gf = d["ionosphere"]["geometry_free_per_satellite"]
|
|
if gf:
|
|
med = np.median([r["iono_L1_m_median"] for r in gf])
|
|
L.append(f"- Dual-frequency geometry-free slant delay, session median "
|
|
f"across {len(gf)} satellites: **{med:.2f} m at the L1 "
|
|
f"frequency** (about "
|
|
f"{np.median([r['TECU_median'] for r in gf]):.1f} total electron "
|
|
f"content units, TECU); per-satellite detail in diagnostics.json")
|
|
|
|
t = d["timing"]
|
|
L.append("\n## Timing\n")
|
|
L.append(f"- Measurement epoch interval jitter: mean "
|
|
f"{t['epoch_jitter_ms']['mean']} ms, standard deviation "
|
|
f"{t['epoch_jitter_ms']['std']} ms, maximum "
|
|
f"|{t['epoch_jitter_ms']['max_abs']}| ms")
|
|
L.append(f"- Receiver time accuracy estimate: mean {t['tAcc_ns']['mean']} ns "
|
|
f"({t['tAcc_ns']['min']}-{t['tAcc_ns']['max']} ns)")
|
|
L.append(f"- Leap seconds: {t['leap_seconds']['rawx']} "
|
|
f"(receiver-validated: {t['leap_seconds']['valid_flag']})")
|
|
|
|
L.append("\n## Radio-frequency interference and antenna health\n")
|
|
L.append("| Radio-frequency block | Jamming indicator (0-255), mean/max | "
|
|
"Jamming alerts | Noise level (per ms) | Automatic gain control | "
|
|
"Antenna status |")
|
|
L.append("|---|---|---|---|---|---|")
|
|
for b, v in d["rf_health"].items():
|
|
L.append(f"| {b} | {v['jamInd_mean']}/{v['jamInd_max']} | "
|
|
f"{v['jammingState_nonzero']} "
|
|
f"| {v['noisePerMS_mean']} | {v['agcCnt_mean']:.0f} | "
|
|
f"{v['antStatus']} |")
|
|
|
|
if d["warnings"]:
|
|
L.append("\n## Warnings\n")
|
|
for w in d["warnings"]:
|
|
L.append(f"- WARNING: {w}")
|
|
(outdir / "report.md").write_text("\n".join(L) + "\n")
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
|
|
def write_html_report(outdir, d, figs=()):
|
|
"""Self-contained report.html: summary cards, tables, embedded figures."""
|
|
import base64
|
|
import html as H
|
|
|
|
def esc(x):
|
|
return H.escape(str(x))
|
|
|
|
def table(headers, rows):
|
|
th = "".join(f"<th>{esc(h)}</th>" for h in headers)
|
|
trs = "".join("<tr>" + "".join(f"<td>{c}</td>" for c in r) + "</tr>"
|
|
for r in rows)
|
|
return f"<table><thead><tr>{th}</tr></thead><tbody>{trs}</tbody></table>"
|
|
|
|
s = d["session"]
|
|
pe = d["position_error"]
|
|
ref = pe["reference"]
|
|
best_name = s.get("track_solution_source") or "onboard"
|
|
best = pe.get(best_name, pe.get("onboard", {}))
|
|
val = d.get("validation_vs_onboard", {})
|
|
val_ok = all(v["verdict"] == "PASS" for v in val.values()) if val else None
|
|
|
|
cards = [
|
|
("Best solution", esc(SOL_SHORT.get(best_name, best_name))),
|
|
("50% radius", f"{best.get('cep50_m', float('nan')):.2f} m" if best else "-"),
|
|
("Epochs", f"{s.get('message_counts', {}).get('NAV-PVT', best.get('epochs', 0))}"),
|
|
("Duration", f"{s['duration_min']} min"),
|
|
("Validation", "-" if val_ok is None else ("all PASS" if val_ok else "FAIL")),
|
|
]
|
|
if "fix_rate" in best:
|
|
cards.append(("Ambiguity fix rate", f"{best['fix_rate']*100:.0f}%"))
|
|
|
|
mod = ", ".join(x for x in (s.get("receiver") or []) if "MOD=" in x) or "u-blox"
|
|
parts = [f"""<style>
|
|
:root {{ --surface:#fcfcfb; --page:#f9f9f7; --ink:#0b0b0b; --ink2:#52514e;
|
|
--muted:#898781; --grid:#e1e0d9; --accent:#2a78d6; --border:rgba(11,11,11,.10); }}
|
|
@media (prefers-color-scheme: dark) {{ :root:not([data-theme="light"]) {{
|
|
--surface:#1a1a19; --page:#0d0d0d; --ink:#fff; --ink2:#c3c2b7;
|
|
--grid:#2c2c2a; --border:rgba(255,255,255,.10); }} }}
|
|
:root[data-theme="dark"] {{ --surface:#1a1a19; --page:#0d0d0d; --ink:#fff;
|
|
--ink2:#c3c2b7; --grid:#2c2c2a; --border:rgba(255,255,255,.10); }}
|
|
body {{ background:var(--page); color:var(--ink); margin:0 auto; max-width:1000px;
|
|
padding:2rem 1.2rem; font:15px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif; }}
|
|
h1 {{ font-size:1.5rem; margin:.2rem 0 .3rem; }}
|
|
h2 {{ font-size:1.1rem; margin:2rem 0 .6rem; }}
|
|
.sub {{ color:var(--ink2); font-size:.9rem; margin-bottom:1.2rem; }}
|
|
.cards {{ display:flex; flex-wrap:wrap; gap:.7rem; margin:1rem 0; }}
|
|
.card {{ background:var(--surface); border:1px solid var(--border); border-radius:10px;
|
|
padding:.7rem 1rem; min-width:8.5rem; }}
|
|
.card .k {{ color:var(--muted); font-size:.75rem; text-transform:uppercase;
|
|
letter-spacing:.04em; }}
|
|
.card .v {{ font-size:1.35rem; font-weight:650; }}
|
|
table {{ border-collapse:collapse; width:100%; margin:.6rem 0 1rem;
|
|
font-variant-numeric:tabular-nums; font-size:.87rem; }}
|
|
th {{ text-align:left; color:var(--ink2); font-weight:600; }}
|
|
th,td {{ padding:.35rem .6rem; border-bottom:1px solid var(--grid); }}
|
|
.pass {{ color:#006300; font-weight:650; }} .fail {{ color:#d03b3b; font-weight:650; }}
|
|
figure {{ background:#fcfcfb; border:1px solid var(--border); border-radius:10px;
|
|
padding:.8rem; margin:1rem 0; overflow-x:auto; }}
|
|
figure img {{ max-width:100%; height:auto; display:block; margin:0 auto; }}
|
|
figcaption {{ color:var(--ink2); font-size:.85rem; padding-top:.5rem; }}
|
|
.warn {{ background:var(--surface); border-left:3px solid #fab219; padding:.5rem .8rem;
|
|
margin:.4rem 0; border-radius:0 8px 8px 0; font-size:.9rem; }}
|
|
code {{ color:var(--ink2); }}
|
|
</style>
|
|
<h1>GPS Session Report</h1>
|
|
<div class="sub">{esc(Path(s['file']).name)} · {esc(mod)} ·
|
|
{esc(s.get('module_fw'))}<br>{esc(s['start_utc'])} → {esc(s['end_utc'])}
|
|
(GPS week {s['gps_week']}) · track source: {esc(sol_disp(best_name))}
|
|
(<code>{esc(best_name)}</code>)</div>"""]
|
|
wm = s.get("quality_metrics_exclude_first_min")
|
|
if wm:
|
|
parts.append(
|
|
f"<div class='warn'><b>Note:</b> the first {wm:g} minutes of the session "
|
|
"(receiver warm-up) are not displayed - all metrics and figures below "
|
|
"exclude them. track.csv retains the full session.</div>")
|
|
|
|
parts.append('<div class="cards">' + "".join(
|
|
f'<div class="card"><div class="k">{esc(k)}</div><div class="v">{esc(v)}</div></div>'
|
|
for k, v in cards) + "</div>")
|
|
|
|
motion = s.get("motion_mode", "?")
|
|
motion_note = ("" if motion.startswith("static") else
|
|
" · dispersion figures include any real platform motion")
|
|
present = [n for n in SOLUTION_ORDER if pe.get(n)]
|
|
ref_src = ref["source"]
|
|
for k in sorted(SOLUTION_DISPLAY, key=len, reverse=True):
|
|
ref_src = ref_src.replace(k, SOLUTION_DISPLAY[k])
|
|
parts.append("<h2>Position summary</h2><div class='sub'><b>Solution methods:</b><ul>"
|
|
+ "".join(f"<li><b>{esc(t)}</b>: {esc(x)}</li>"
|
|
for t, x in method_definitions(present))
|
|
+ "</ul></div>")
|
|
parts.append(f"<div class='sub'>Motion mode: "
|
|
f"<b>{esc(motion)}</b>{motion_note}<br>Reference "
|
|
f"({esc(ref_src)}): <b>{ref['lat']:.9f}°, {ref['lon']:.9f}°"
|
|
f"</b>, ellipsoidal height {ref['ellip_height_m']:.3f} m</div>")
|
|
rows = []
|
|
for name in SOLUTION_ORDER:
|
|
st = pe.get(name)
|
|
if not st:
|
|
continue
|
|
rows.append([esc(sol_disp(name)), st["epochs"],
|
|
"/".join(f"{v:+.3f}" for v in st["offset_from_ref_NEU_m"]),
|
|
"/".join(f"{v:.3f}" for v in st["rms_NEU_m"]),
|
|
f"{st['cep50_m']:.3f}", f"{st['cep95_m']:.3f}",
|
|
f"{st['fix_rate']*100:.1f}%" if "fix_rate" in st else "-"])
|
|
parts.append(table(["Solution", "Epochs", "Offset from reference, north/east/up (m)",
|
|
"RMS dispersion, north/east/up (m)", "50% radius (m)",
|
|
"95% radius (m)", "Ambiguity fix rate"], rows))
|
|
|
|
if val:
|
|
parts.append("<h2>Validation against the receiver's onboard solution</h2>")
|
|
rows = [[esc(sol_disp(n)), v["matched_epochs"],
|
|
"/".join(f"{x:+.3f}" for x in v["mean_offset_NEU_m"]),
|
|
f"{v['horizontal_offset_m']} m", f"< {v['threshold_m']} m",
|
|
f"<span class='{v['verdict'].lower()}'>{v['verdict']}</span>"]
|
|
for n, v in val.items()]
|
|
parts.append(table(["Solution", "Matched epochs",
|
|
"Mean offset, north/east/up (m)", "Horizontal offset",
|
|
"Threshold", "Verdict"], rows))
|
|
|
|
parts.append("<h2>Constellations</h2><div class='sub'>C/N0 is the "
|
|
"carrier-to-noise density ratio (signal strength, dB-Hz).</div>")
|
|
rows = []
|
|
for name, c in d["constellations"].items():
|
|
bands = ", ".join(f"{b}: {v['cno_mean']}" for b, v in sorted(c["bands"].items()))
|
|
rows.append([esc(name), c["satellites_tracked"], c["signals_tracked"],
|
|
c["measurements"], esc(bands), c["cycle_slip_events"],
|
|
c["sfrbx_frames"], c["prRes_rms_m"] or "-"])
|
|
parts.append(table(["Constellation", "Satellites", "Signals", "Measurements",
|
|
"Mean C/N0 by band (dB-Hz)", "Cycle-slip events",
|
|
"Ephemeris frames", "Pseudorange residual RMS (m)"], rows))
|
|
|
|
io = d["ionosphere"]
|
|
parts.append("<h2>Ionosphere</h2><ul>")
|
|
for k, v in io["broadcast_klobuchar"].items():
|
|
parts.append(f"<li>Broadcast {esc(IONO_DISPLAY.get(k, k))}: {esc(v)}</li>")
|
|
parts.append(f"<li>Ionospheric correction model used per signal: "
|
|
f"{esc(io['ionoModel_census'])}</li>")
|
|
gf = io["geometry_free_per_satellite"]
|
|
if gf:
|
|
med_m = float(np.median([r["iono_L1_m_median"] for r in gf]))
|
|
med_t = float(np.median([r["TECU_median"] for r in gf]))
|
|
parts.append(f"<li>Dual-frequency geometry-free slant delay, median across "
|
|
f"{len(gf)} satellites: <b>{med_m:.2f} m at the L1 frequency</b> "
|
|
f"(about {med_t:.0f} total electron content units, TECU)</li>")
|
|
parts.append("</ul>")
|
|
|
|
t = d["timing"]
|
|
parts.append("<h2>Timing</h2><ul>")
|
|
parts.append(f"<li>Measurement epoch interval jitter: mean "
|
|
f"{t['epoch_jitter_ms']['mean']} ms, standard deviation "
|
|
f"{t['epoch_jitter_ms']['std']} ms, maximum "
|
|
f"|{t['epoch_jitter_ms']['max_abs']}| ms</li>")
|
|
parts.append(f"<li>Receiver time accuracy estimate: mean {t['tAcc_ns']['mean']} ns "
|
|
f"({t['tAcc_ns']['min']}–{t['tAcc_ns']['max']} ns)</li>")
|
|
parts.append(f"<li>Leap seconds: {t['leap_seconds']['rawx']} "
|
|
f"(receiver-validated: {t['leap_seconds']['valid_flag']})</li></ul>")
|
|
|
|
parts.append("<h2>Radio-frequency interference and antenna health</h2>")
|
|
rows = [[esc(b), f"{v['jamInd_mean']}/{v['jamInd_max']}", v["jammingState_nonzero"],
|
|
v["noisePerMS_mean"], f"{v['agcCnt_mean']:.0f}", esc(v["antStatus"])]
|
|
for b, v in d["rf_health"].items()]
|
|
parts.append(table(["Radio-frequency block", "Jamming indicator (0-255), mean/max",
|
|
"Jamming alerts", "Noise level (per ms)",
|
|
"Automatic gain control", "Antenna status"], rows))
|
|
|
|
if d["warnings"]:
|
|
parts.append("<h2>Warnings</h2>")
|
|
for w in d["warnings"]:
|
|
parts.append(f"<div class='warn'>{esc(w)}</div>")
|
|
|
|
if figs:
|
|
parts.append("<h2>Figures</h2>")
|
|
for name, title in figs:
|
|
p = outdir / "figures" / name
|
|
if p.is_file():
|
|
b64 = base64.b64encode(p.read_bytes()).decode()
|
|
parts.append(f"<figure><img src='data:image/png;base64,{b64}' "
|
|
f"alt='{esc(title)}'><figcaption>{esc(title)}"
|
|
f"</figcaption></figure>")
|
|
|
|
(outdir / "report.html").write_text(
|
|
f"<!doctype html><html><head><meta charset='utf-8'>"
|
|
f"<meta name='viewport' content='width=device-width,initial-scale=1'>"
|
|
f"<title>GPS Report - {H.escape(Path(s['file']).stem)}</title></head>"
|
|
f"<body>{''.join(parts)}</body></html>")
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("capture", type=Path, help="input .ubx capture")
|
|
ap.add_argument("--interval", type=int, default=1, help="track.csv output interval, s")
|
|
ap.add_argument("--warmup-min", type=float, default=5.0,
|
|
help="receiver warm-up window excluded from all quality metrics "
|
|
"and figures (data outputs keep the full session)")
|
|
ap.add_argument("--static", action="store_true",
|
|
help="antenna was VERIFIABLY stationary: enables static solutions and "
|
|
"static error framing. Never set for buoy/drifting deployments, "
|
|
"slow motion within a few metres is indistinguishable from noise "
|
|
"and would be averaged away.")
|
|
ap.add_argument("--no-ppk", action="store_true")
|
|
ap.add_argument("--no-vrs", action="store_true",
|
|
help="skip the synthesized GCGC VRS solution")
|
|
ap.add_argument("--ppp", action="store_true", help="also run PPP vs open IGS products")
|
|
ap.add_argument("--base", help="force a CORS station id (e.g. MSEV)")
|
|
ap.add_argument("--base-obs", nargs="+", metavar="FILE",
|
|
help="explicit local base file(s) for the parallel GCGC/local "
|
|
"PPK method (RINEX obs and/or .rtcm3 logs). Normally not "
|
|
"needed: <capture>.rtcm3 sidecars and gcgc_base/ are "
|
|
"discovered automatically")
|
|
ap.add_argument("--base-xyz", nargs=3, type=float, metavar=("X", "Y", "Z"),
|
|
help="ECEF coordinates for a --base-obs base (else its RINEX "
|
|
"header position is used)")
|
|
ap.add_argument("--max-base-km", type=float, default=150.0)
|
|
ap.add_argument("--force-stage", action="append", default=[],
|
|
choices=["rinex", "single", "ppk", "ppp", "track"])
|
|
args = ap.parse_args()
|
|
|
|
if not args.capture.is_file():
|
|
sys.exit(f"no such file: {args.capture}")
|
|
for tool in ("convbin", "rnx2rtkp", "pos2kml"):
|
|
if not (BIN / tool).is_file():
|
|
sys.exit(f"missing tools/bin/{tool} - run setup.sh first")
|
|
|
|
outdir = SCRIPT_DIR / "processed" / args.capture.stem
|
|
outdir.mkdir(parents=True, exist_ok=True)
|
|
# --force-stage: remove that stage's outputs so it re-runs
|
|
force_files = {"rinex": ["rover.obs", "rover.nav"], "single": ["single.pos"],
|
|
"ppk": ["ppk_kinematic.pos", "ppk_static.pos"],
|
|
"ppp": ["ppp.pos"], "track": ["track.csv", "track.kml"]}
|
|
for st in args.force_stage:
|
|
for fn in force_files[st]:
|
|
(outdir / fn).unlink(missing_ok=True)
|
|
|
|
warnings = []
|
|
data = parse_ubx(args.capture)
|
|
n_rawx = len(data["rawx_hdr"])
|
|
warmup_cut = None
|
|
if args.warmup_min > 0:
|
|
if "t_start" in data:
|
|
warmup_cut = data["t_start"] + timedelta(minutes=args.warmup_min)
|
|
elif len(data["pvt"]):
|
|
warmup_cut = data["pvt"]["utc"].iloc[0] + timedelta(minutes=args.warmup_min)
|
|
log(f"inventory: {sum(data['counts'].values())} UBX messages, {n_rawx} RAWX epochs, "
|
|
f"{len(data['pvt'])} PVT epochs")
|
|
|
|
if n_rawx == 0:
|
|
warnings.append("NO RAW OBSERVATIONS (RXM-RAWX) in this capture - it is a "
|
|
"debug/NMEA-style log. RINEX conversion and corrected positioning "
|
|
"are impossible; diagnostics below cover onboard data only.")
|
|
solutions = {}
|
|
if len(data["pvt"]):
|
|
diag = stage_diagnostics(outdir, data, {"iono": {}}, solutions, {}, None,
|
|
warnings, args, warmup_cut=warmup_cut)
|
|
write_report(outdir, diag)
|
|
write_html_report(outdir, diag)
|
|
else:
|
|
log("no NAV-PVT either; nothing to report")
|
|
sys.exit(0)
|
|
|
|
rinex_info = stage_rinex(args.capture, outdir, n_rawx, warnings)
|
|
solutions = {}
|
|
solutions["single"] = stage_single(outdir, rinex_info["obs"], rinex_info["nav"],
|
|
write_conf(outdir / "single.conf",
|
|
ant1=rover_antenna(),
|
|
atx=None))
|
|
ppk_result, gcgc_result, vrs_result = {}, {}, {}
|
|
if not args.no_ppk:
|
|
ppk_result = stage_ppk(outdir, rinex_info["obs"], rinex_info["nav"], data, args, warnings)
|
|
for name, df in (ppk_result.get("solutions") or {}).items():
|
|
solutions[f"ppk_{name}"] = df
|
|
# parallel PPK vs local GCGC data (recorded VRS sidecar / gcgc_base/ files)
|
|
gcgc_result = stage_ppk_gcgc(outdir, rinex_info["obs"], rinex_info["nav"],
|
|
data, args, warnings)
|
|
for name, df in (gcgc_result.get("solutions") or {}).items():
|
|
solutions[f"ppk_gcgc_{name}"] = df
|
|
# standard GCGC-provided VRS solution (synthesized; only when the master
|
|
# is a GCGC station)
|
|
if not args.no_vrs:
|
|
vrs_result = stage_ppk_vrs(outdir, rinex_info["obs"], rinex_info["nav"],
|
|
data, args, warnings, ppk_result)
|
|
for name, df in (vrs_result.get("solutions") or {}).items():
|
|
solutions[f"ppk_vrs_{name}"] = df
|
|
if args.ppp:
|
|
ppp_result = stage_ppp(outdir, rinex_info["obs"], rinex_info["nav"], data, args,
|
|
warnings)
|
|
if ppp_result.get("status") == "ok":
|
|
solutions["ppp"] = ppp_result["df"]
|
|
|
|
track_df, track_source = stage_track(outdir, solutions, data["pvt"], args.interval,
|
|
warmup_cut=warmup_cut)
|
|
diag = stage_diagnostics(outdir, data, rinex_info, solutions, ppk_result, track_source,
|
|
warnings, args, gcgc_result=gcgc_result,
|
|
vrs_result=vrs_result, warmup_cut=warmup_cut)
|
|
figs = stage_figures(outdir, data, solutions, track_source, diag,
|
|
warmup_cut=warmup_cut)
|
|
write_report(outdir, diag, figs)
|
|
write_html_report(outdir, diag, figs)
|
|
log("report: report.md + report.html written")
|
|
|
|
log(f"done -> {outdir}")
|
|
if ppk_result.get("status") == "pending":
|
|
log("NOTE: " + ppk_result["message"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|