#!/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//. 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 [--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 .rtcm3 alongside .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"![{title}](figures/{name})\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"{esc(h)}" for h in headers) trs = "".join("" + "".join(f"{c}" for c in r) + "" for r in rows) return f"{th}{trs}
" 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"""

GPS Session Report

{esc(Path(s['file']).name)} · {esc(mod)} · {esc(s.get('module_fw'))}
{esc(s['start_utc'])} → {esc(s['end_utc'])} (GPS week {s['gps_week']}) · track source: {esc(sol_disp(best_name))} ({esc(best_name)})
"""] wm = s.get("quality_metrics_exclude_first_min") if wm: parts.append( f"
Note: 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.
") parts.append('
' + "".join( f'
{esc(k)}
{esc(v)}
' for k, v in cards) + "
") 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("

Position summary

Solution methods:
    " + "".join(f"
  • {esc(t)}: {esc(x)}
  • " for t, x in method_definitions(present)) + "
") parts.append(f"
Motion mode: " f"{esc(motion)}{motion_note}
Reference " f"({esc(ref_src)}): {ref['lat']:.9f}°, {ref['lon']:.9f}°" f", ellipsoidal height {ref['ellip_height_m']:.3f} m
") 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("

Validation against the receiver's onboard solution

") 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"{v['verdict']}"] 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("

Constellations

C/N0 is the " "carrier-to-noise density ratio (signal strength, dB-Hz).
") 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("

Ionosphere

    ") for k, v in io["broadcast_klobuchar"].items(): parts.append(f"
  • Broadcast {esc(IONO_DISPLAY.get(k, k))}: {esc(v)}
  • ") parts.append(f"
  • Ionospheric correction model used per signal: " f"{esc(io['ionoModel_census'])}
  • ") 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"
  • Dual-frequency geometry-free slant delay, median across " f"{len(gf)} satellites: {med_m:.2f} m at the L1 frequency " f"(about {med_t:.0f} total electron content units, TECU)
  • ") parts.append("
") t = d["timing"] parts.append("

Timing

    ") parts.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
  • ") parts.append(f"
  • Receiver time accuracy estimate: mean {t['tAcc_ns']['mean']} ns " f"({t['tAcc_ns']['min']}–{t['tAcc_ns']['max']} ns)
  • ") parts.append(f"
  • Leap seconds: {t['leap_seconds']['rawx']} " f"(receiver-validated: {t['leap_seconds']['valid_flag']})
") parts.append("

Radio-frequency interference and antenna health

") 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("

Warnings

") for w in d["warnings"]: parts.append(f"
{esc(w)}
") if figs: parts.append("

Figures

") for name, title in figs: p = outdir / "figures" / name if p.is_file(): b64 = base64.b64encode(p.read_bytes()).decode() parts.append(f"
{esc(title)}" f"
") (outdir / "report.html").write_text( f"" f"" f"GPS Report - {H.escape(Path(s['file']).stem)}" f"{''.join(parts)}") 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: .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()