#!/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_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() 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": pvt.append((datetime(m.year, m.month, m.day, m.hour, m.min, m.second, tzinfo=timezone.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((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((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((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=["gnss", "sv", "cno", "elev", "azim", "prRes", "quality", "used", "health"]), "sig": pd.DataFrame(sig_rows, columns=["gnss", "sv", "sig", "cno", "quality", "corrSource", "ionoModel", "prUsed", "prRes"]), "rf": pd.DataFrame(rf, columns=["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): 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 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 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 # 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 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 / "merged.atx" build_merged_atx([ant1, ant2], merged) conf = write_conf(outdir / "ppk.conf", ant1=ant1, ant2=ant2, atx=merged) 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"ppk: 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"ppk: 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"ppk_{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 ppk {name} produced no solution: {r.stderr[-800:]}") continue out[name] = read_pos(pos) qc = Counter(out[name]["Q"]) log(f"ppk {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): # best available source, in order of accuracy for source in ("ppk_kinematic", "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_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" 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): 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"] 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 = [] if best is not None and len(best): dn, de, _ = llh_to_enu(best["lat"], best["lon"], best["height"], *refpos) series.append((track_source, de, dn, SERIES[0])) dn_o, de_o, _ = llh_to_enu(pvt["lat"], pvt["lon"], pvt["height"], *refpos) series.append(("onboard (NAV-PVT)", de_o, dn_o, SERIES[1])) 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"{radius_label}{pct} {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=SERIES[0], linewidth=1.4, label=track_source) ax.plot(pvt["utc"], on_enu[i], color=SERIES[1], linewidth=1.4, label="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]) hdr = data["rawx_hdr"] 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(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 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", "Satellite counts and C/N0 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 C/N0 (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 position per satellite)", color=INK, fontsize=10, fontweight="bold") save(f, "fig4_skyplot.png", "Sky plot colored by C/N0") # ---- 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 hAcc") ax1.plot(pvt["utc"], pvt["vAcc"], color=SERIES[1], linewidth=1.0, linestyle="--", label="onboard vAcc") 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=SERIES[0], linewidth=1.4, label=f"{track_source} sd horiz") ax1.plot(best["utc"], best["sdu"], color=SERIES[0], linewidth=1.0, linestyle="--", label=f"{track_source} sd up") ax1.legend(loc="upper right", fontsize=8, frameon=False, ncols=2, labelcolor=INK2) _style_ax(ax1, None, "1-σ accuracy (m)", "Reported accuracy estimates") 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", "pDOP", None) f.autofmt_xdate() save(f, "fig5_accuracy.png", "Accuracy estimates and 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 = data["rf"].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, "jamInd (0-255)", "RF 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, RF health, timing jitter") log(f"figures: {len(figs)} PNGs -> {figdir}") return figs # ---------------------------------------------------------------------------- # Stage 7: diagnostics + report # ---------------------------------------------------------------------------- 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): rawx, pvt, sat, sig, rf = data["rawx"], data["pvt"], data["sat"], data["sig"], data["rf"] # --- reference position: PPK static end-state (only if --static was flagged) # > PPK kinematic mean > onboard mean. In kinematic mode the reference is a # dispersion anchor only - "scatter" includes any real platform motion. ref_source = "onboard session mean" ref = (float(pvt["lat"].mean()), float(pvt["lon"].mean()), float(pvt["height"].mean())) if "ppk_static" in solutions and len(solutions["ppk_static"]): last = solutions["ppk_static"].iloc[-1] ref = (float(last["lat"]), float(last["lon"]), float(last["height"])) ref_source = f"PPK static final epoch (Q={QUALITY_NAME.get(int(last['Q']))})" elif "ppk_kinematic" in solutions and len(solutions["ppk_kinematic"]): d = solutions["ppk_kinematic"] ref = (float(d["lat"].mean()), float(d["lon"].mean()), float(d["height"].mean())) ref_source = "PPK kinematic session mean (dispersion anchor; includes platform motion)" # --- 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.rename(columns={})[["utc", "lat", "lon", "height"]].copy() for name, df in [("onboard", onboard_df)] + list(solutions.items()): s = solution_stats(df, ref) if s: pos_section[name] = s pos_section["onboard_reported_accuracy"] = { "hAcc_m": {"mean": round(float(pvt["hAcc"].mean()), 3), "min": round(float(pvt["hAcc"].min()), 3), "max": round(float(pvt["hAcc"].max()), 3)}, "vAcc_m": {"mean": round(float(pvt["vAcc"].mean()), 3)}, "pDOP": {"mean": round(float(pvt["pDOP"].mean()), 2), "max": round(float(pvt["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")} # --- 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["tAcc_ns"].mean()), 1), "min": int(pvt["tAcc_ns"].min()), "max": int(pvt["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[["utc", "lat", "lon", "height"]].copy() on["utc_s"] = on["utc"].dt.round("1s") for name, df in solutions.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)", "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"] 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**: `{s['track_solution_source']}`\n") ref = d["position_error"]["reference"] L.append("## Position summary\n") L.append(f"Motion mode: **{s.get('motion_mode', '?')}**\n") L.append(f"Reference ({ref['source']}): **{ref['lat']:.9f}°, {ref['lon']:.9f}°**, " f"h = {ref['ellip_height_m']:.3f} m\n") if not s.get("motion_mode", "").startswith("static"): L.append("> Kinematic mode: RMS/CEP dispersion figures include any real platform " "motion, not just measurement error.\n") L.append("| Solution | Epochs | Offset from ref N/E/U (m) | RMS N/E/U (m) | CEP50 | CEP95 | Fix rate |") L.append("|---|---|---|---|---|---|---|") for name in ("ppk_kinematic", "ppk_static", "ppp", "single", "onboard"): st = d["position_error"].get(name) if not st: continue 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"| {name} | {st['epochs']} | {off} | {rms} | {st['cep50_m']:.3f} | " f"{st['cep95_m']:.3f} | {fr} |") oa = d["position_error"]["onboard_reported_accuracy"] L.append(f"\nOnboard self-reported: hAcc {oa['hAcc_m']['mean']} m mean " f"({oa['hAcc_m']['min']}-{oa['hAcc_m']['max']}), pDOP {oa['pDOP']['mean']}\n") if "ppk_base" in d["position_error"]: b = d["position_error"]["ppk_base"] L.append(f"PPK base: **{b['id']}** at {b['distance_km']} km, antenna `{b['antenna']}`\n") if d.get("validation_vs_onboard"): L.append("## Validation against onboard (NAV-PVT) positions\n") L.append("Computed solutions cross-checked epoch-by-epoch against the receiver's " "own embedded position solution:\n") L.append("| Solution | Matched epochs | Mean offset N/E/U (m) | RMS diff N/E/U (m) | Horiz 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"| {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("| GNSS | Sats | Signals | Meas | C/N0 by band (mean dBHz) | Slips | SFRBX | prRes RMS |") 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 `{k}`: {v}") L.append(f"- Signal iono-model census: {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 across " f"{len(gf)} satellites: **{med:.2f} m at L1** " f"(~{np.median([r['TECU_median'] for r in gf]):.1f} TECU); " f"per-satellite detail in diagnostics.json") t = d["timing"] L.append("\n## Timing\n") L.append(f"- Epoch interval jitter: mean {t['epoch_jitter_ms']['mean']} ms, " f"σ {t['epoch_jitter_ms']['std']} ms, max |{t['epoch_jitter_ms']['max_abs']}| ms") L.append(f"- Receiver time accuracy estimate (tAcc): 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']} (valid={t['leap_seconds']['valid_flag']})") L.append("\n## RF health\n") L.append("| Block | jamInd mean/max | jamState≠0 | noise/ms | AGC | Antenna |") L.append("|---|---|---|---|---|---|") for b, v in d["rf_health"].items(): L.append(f"| {b} | {v['jamInd_mean']}/{v['jamInd_max']} | {v['jammingState_nonzero']} " f"| {v['noisePerMS_mean']} | {v['agcCnt_mean']:.0f} | {v['antStatus']} |") if d["warnings"]: L.append("\n## Warnings\n") for w in d["warnings"]: L.append(f"- ⚠ {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(best_name)), ("CEP50", 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(("PPK 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(best_name)}
"""] 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") parts.append(f"

Position summary

Motion mode: " f"{esc(motion)}{motion_note}
Reference " f"({esc(ref['source'])}): {ref['lat']:.9f}°, {ref['lon']:.9f}°" f", h = {ref['ellip_height_m']:.3f} m
") rows = [] for name in ("ppk_kinematic", "ppk_static", "ppp", "single", "onboard"): st = pe.get(name) if not st: continue rows.append([esc(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 ref N/E/U (m)", "RMS N/E/U (m)", "CEP50 (m)", "CEP95 (m)", "Fix rate"], rows)) if val: parts.append("

Validation against onboard (NAV-PVT)

") rows = [[esc(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 N/E/U (m)", "Horiz offset", "Threshold", "Verdict"], rows)) parts.append("

Constellations

") 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(["GNSS", "Sats", "Signals", "Meas", "C/N0 by band (dB-Hz)", "Slips", "SFRBX", "prRes RMS (m)"], rows)) io = d["ionosphere"] parts.append("

Ionosphere

") t = d["timing"] parts.append("

Timing

") parts.append("

RF 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(["Block", "jamInd mean/max", "jamState≠0", "noise/ms", "AGC", "Antenna"], 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("--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("--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("--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"]) 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) 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 = {} 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 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) diag = stage_diagnostics(outdir, data, rinex_info, solutions, ppk_result, track_source, warnings, args) figs = stage_figures(outdir, data, solutions, track_source, diag) 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()