ublox-gps-data-processing/experiments/vbs_iono.py

296 lines
12 KiB
Python
Raw Normal View History

Add GCGC virtual-reference-station solution, VBS exploration suite, and report improvements Pipeline (process_gps.py): - New standard solution ppk_vrs: a virtual reference station synthesized at the capture position from the GCGC master station the CORS stage already fetched (zero baseline). Gated on GCGC availability; --no-vrs to disable. Engine validated in experiments/ (gates G1/G2a) and benchmarked equivalent to Trimble Pivot's commercial VRS on ground truth. - Track output now selects among PPK-family solutions by measured dispersion, not nominal baseline (protects against poor network-edge VRS data). - Warm-up exclusion: first 5 minutes (--warmup-min) removed from all quality metrics and figures; prominent note in both reports; data outputs unchanged. - Professional report language: abbreviations expanded on first use, solution methods defined, no shorthand in tables or figure labels; fig5 reframed as "self-reported precision (uncalibrated) vs measured dispersion" with the optimism factor annotated. - Local-base robustness: refuse to combine base files at different positions; RTCM logs and Data Shop RINEX accepted for the parallel GCGC method. Streaming (stream_gps.py): - Credentials via gitignored gcgc.env (template gcgc.env.example), env vars take precedence; RTCM correction stream recorded to stream_<ts>.rtcm3 for zero-baseline post-processing; live GGA uplink and sea dynamic model remain the buoy defaults; warm-up excluded from session statistics. Experiments (new): - vbs_synth.py: geometric virtual-base synthesis engine (RINEX 2.11 patcher, SP3 orbits, light-time + Earth-rotation per position, clock-robust). - vbs_iono.py: carrier-leveled slant-ionosphere estimation + station-network interpolation with leave-one-out validation (median 7 cm; 1.2 cm / 10 km growth; DCBs as daily constants via median polish). - vbs_compare.py: observation-domain comparison of commercial VRS files vs synthesized bases (ambiguity-detrended correction content). - FINDINGS.md: gate results, commercial benchmark, offshore analysis, and promotion rationale. Docs: README validation-results section explaining why the synthesized VRS (kinematic) is the preferred solution for buoy deployments; VRS orders and experiment data moved under gitignored experiments/data/.
2026-07-27 21:50:10 -04:00
#!/usr/bin/env python3
"""
vbs_iono.py - Carrier-leveled slant ionosphere estimation + LIM cross-validation.
Phase 2a of the VBS exploration (gate G2a): measures whether multi-station
ionosphere interpolation can be accurate enough to help a virtual base station,
BEFORE building the application layer.
Per station/satellite arc: geometry-free carrier LG = lambda1*L1 - lambda2*L2
(clock-immune by construction) leveled to the geometry-free code PG = P2 - P1
(arc median), giving slant iono delay at L1 (metres; contains per-station DCB,
handled as a per-station bias in the fit).
LIM (Wanninger linear interpolation model): per satellite, per time bin, fit
I = a0 + a1*dN + a2*dE across stations, plus one shared per-station bias b_r
(gauge: b=0 at the first station). Leave-one-out: predict each held-out station
from the others; report residual scatter (after removing the held-out station's
own median offset, which is its unobservable DCB) vs its distance from the
network centroid.
Usage:
vbs_iono.py --stations msin:X:Y:Z mary:X:Y:Z ... --dir data/204 --sp3 FILE
[--bin-s 300] [--elev-mask 20]
"""
import argparse
import math
import sys
from collections import defaultdict
from datetime import datetime, timezone
from pathlib import Path
import numpy as np
sys.path.insert(0, str(Path(__file__).resolve().parent))
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from vbs_synth import Sp3, parse_header, FREQ, glo_freq, glonass_slots # noqa: E402
C = 299792458.0
F1, F2 = 1575.42e6, 1227.60e6
GAMMA = (F1 / F2) ** 2
L1M = C / F1
L2M = C / F2
def read_gf(path, slots):
"""Per (sat) -> time-sorted arrays (t_sec_of_day, LG_m, PG_m) for GPS+GLO."""
lines = open(path, errors="replace").read().splitlines()
types, xyz, hdr_end = parse_header(lines)
nlps = (len(types) + 4) // 5
idx = {t: k for k, t in enumerate(types)}
need = all(t in idx for t in ("L1", "L2", "P2")) and ("P1" in idx or "C1" in idx)
if not need:
raise ValueError(f"{path}: missing L1/L2/P1/P2")
i_l1, i_l2 = idx["L1"], idx["L2"]
i_p1, i_c1 = idx.get("P1"), idx.get("C1")
i_p2, i_c2 = idx.get("P2"), idx.get("C2")
out = defaultdict(list)
i = hdr_end + 1
while i < len(lines):
line = lines[i]
if len(line) < 32 or not line[:26].strip():
i += 1
continue
try:
flag = int(line[26:29]); nsat = int(line[29:32])
except ValueError:
i += 1
continue
if flag > 1:
i += nsat + 1
continue
p = line[:26].split()
tsec = int(p[3]) * 3600 + int(p[4]) * 60 + float(p[5])
sats = []
nlin = (nsat + 11) // 12
for k in range(nlin):
seg = lines[i + k][32:68]
for j in range(12):
s = seg[j * 3:(j + 1) * 3]
if s.strip():
sats.append(s.replace(" ", "0"))
i += nlin
for sat in sats:
block = lines[i:i + nlps]
i += nlps
sysid = sat[0]
if sysid == "G":
l1f, l2f = F1, F2
elif sysid == "R" and sat in slots:
l1f, l2f = glo_freq("1", slots[sat]), glo_freq("2", slots[sat])
else:
continue
def val(ti):
ln = block[ti // 5] if ti // 5 < len(block) else ""
seg = ln[(ti % 5) * 16:(ti % 5) * 16 + 14]
return float(seg) if seg.strip() else None
l1, l2 = val(i_l1), val(i_l2)
# value-level P/C fallback: receivers fill P1 or C1 (and P2 or C2)
# depending on satellite/tracking mode
p1 = val(i_p1) if i_p1 is not None else None
if p1 is None and i_c1 is not None:
p1 = val(i_c1)
p2 = val(i_p2) if i_p2 is not None else None
if p2 is None and i_c2 is not None:
p2 = val(i_c2)
if None in (l1, l2, p1, p2):
continue
gamma = (l1f / l2f) ** 2
lg = (C / l1f) * l1 - (C / l2f) * l2 # metres
pg = p2 - p1
# normalize to L1(GPS)-equivalent iono metres
out[sat].append((tsec, lg / (gamma - 1) * (l1f / F1) ** 2,
pg / (gamma - 1) * (l1f / F1) ** 2))
return {s: np.array(v) for s, v in out.items()}, np.array(xyz)
def leveled_stec(gf, gap_s=120.0, jump_m=0.5, min_arc=10):
"""Level carrier GF to code GF per arc -> (t, stec_L1_m) arrays per sat."""
out = {}
for sat, arr in gf.items():
t, lg, pg = arr[:, 0], arr[:, 1], arr[:, 2]
order = np.argsort(t)
t, lg, pg = t[order], lg[order], pg[order]
arcs = []
start = 0
for k in range(1, len(t)):
if t[k] - t[k - 1] > gap_s or abs(lg[k] - lg[k - 1]) > jump_m:
arcs.append((start, k))
start = k
arcs.append((start, len(t)))
ts, ss = [], []
for a, b in arcs:
if b - a < min_arc:
continue
bias = np.median(lg[a:b] - pg[a:b])
ts.append(t[a:b])
ss.append(lg[a:b] - bias)
if ts:
out[sat] = (np.concatenate(ts), np.concatenate(ss))
return out
def elevation(sp3, sat, tsec_gps_day0, xyz):
pos = sp3.pos(sat, tsec_gps_day0)
if pos is None:
return -90.0
d = pos - xyz
up = xyz / np.linalg.norm(xyz)
return math.degrees(math.asin(np.dot(d, up) / np.linalg.norm(d)))
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--stations", nargs="+", required=True,
metavar="name:X:Y:Z", help="station obs basenames + ECEF")
ap.add_argument("--dir", type=Path, required=True)
ap.add_argument("--suffix", default="2040.26o")
ap.add_argument("--sp3", type=Path, required=True)
ap.add_argument("--nav", type=Path, required=True)
ap.add_argument("--bin-s", type=float, default=300.0)
ap.add_argument("--elev-mask", type=float, default=20.0)
ap.add_argument("--gps-only", action="store_true",
help="exclude GLONASS (per-slot inter-channel code biases "
"break the single per-station bias model)")
args = ap.parse_args()
sp3 = Sp3(args.sp3)
slots = glonass_slots(args.nav)
stations = {}
for spec in args.stations:
name, x, y, z = spec.split(":")
stations[name] = np.array([float(x), float(y), float(z)])
# ENU offsets (km) about network centroid, for the LIM fit
from process_gps import ecef_to_llh, llh_to_enu # noqa: E402
cen = np.mean(list(stations.values()), axis=0)
cen_llh = ecef_to_llh(*cen)
offs = {}
for name, xyz in stations.items():
llh = ecef_to_llh(*xyz)
dn, de, _ = llh_to_enu(llh[0], llh[1], llh[2], *cen_llh)
offs[name] = (float(dn) / 1e3, float(de) / 1e3) # km N, km E
print("[vbs_iono] loading + leveling", flush=True)
stec = {}
for name in stations:
gf, _ = read_gf(args.dir / f"{name}{args.suffix}", slots)
if args.gps_only:
gf = {s: v for s, v in gf.items() if s.startswith("G")}
stec[name] = leveled_stec(gf)
n = sum(len(v[0]) for v in stec[name].values())
print(f" {name}: {len(stec[name])} sats, {n} leveled samples")
# time-bin medians: station -> sat -> {bin: stec}
binned = {}
for name, sats in stec.items():
b = {}
for sat, (t, s) in sats.items():
k = (t // args.bin_s).astype(int)
d = {}
for kk in np.unique(k):
d[int(kk)] = float(np.median(s[k == kk]))
b[sat] = d
binned[name] = b
names = list(stations)
# Stage 1: per-station bias (DCB) as a DAILY constant via median polish.
# Leaving these as free per-bin parameters makes the 4-station LIM fit
# ill-conditioned (bias ~ degenerate with a satellite-common gradient) and
# extrapolation explodes; hardware DCBs are stable over a day, so estimate
# them once with full-day redundancy.
bias = {n: 0.0 for n in names}
for _ in range(4):
net_med = {}
for n in names:
for sat, d in binned[n].items():
for k, v in d.items():
net_med.setdefault((sat, k), []).append(v - bias[n])
net_med = {sk: float(np.median(v)) for sk, v in net_med.items() if len(v) >= 3}
for n in names:
diffs = [binned[n][sat][k] - m for (sat, k), m in net_med.items()
if sat in binned[n] and k in binned[n][sat]]
if diffs:
bias[n] = float(np.median(diffs))
print(" station daily biases (m):",
{n: round(b, 2) for n, b in bias.items()})
for n in names:
for sat in binned[n]:
for k in binned[n][sat]:
binned[n][sat][k] -= bias[n]
print(f"\n[vbs_iono] LOOCV over {len(names)} stations, bin={args.bin_s:.0f}s, "
f"elev mask {args.elev_mask} deg")
results = {}
for held in names:
fit_names = [n for n in names if n != held]
residuals = []
allbins = set()
for sat in binned[held]:
allbins.update(binned[held][sat].keys())
for bin_k in sorted(allbins):
t_mid = (bin_k + 0.5) * args.bin_s
# collect sats present at held-out + >=3 fit stations, above mask
rows, sats_ok = [], []
for sat in binned[held]:
if bin_k not in binned[held][sat]:
continue
have = [n for n in fit_names
if sat in binned[n] and bin_k in binned[n][sat]]
if len(have) < 3:
continue
if elevation(sp3, sat, t_mid, stations[held]) < args.elev_mask:
continue
sats_ok.append((sat, have))
if len(sats_ok) < 3:
continue
# per-satellite LIM planes; station biases already removed (stage 1)
dn, de = offs[held]
for sat, have in sats_ok:
A = np.array([(1.0, offs[n][0], offs[n][1]) for n in have])
y = np.array([binned[n][sat][bin_k] for n in have])
sol, *_ = np.linalg.lstsq(A, y, rcond=None)
pred = sol[0] + sol[1] * dn + sol[2] * de
residuals.append(pred - binned[held][sat][bin_k])
residuals = np.array(residuals)
if len(residuals) == 0:
continue
# remove held-out station's unobservable DCB offset
residuals = residuals - np.median(residuals)
dist_cen = math.hypot(*offs[held])
results[held] = (dist_cen, residuals)
print(f" hold {held}: n={len(residuals):5d} centroid dist {dist_cen:5.1f} km "
f"median|r|={np.median(np.abs(residuals)):.3f} m "
f"95%|r|={np.percentile(np.abs(residuals), 95):.3f} m")
med = {n: float(np.median(np.abs(r))) for n, (d, r) in results.items()}
dists = {n: d for n, (d, r) in results.items()}
if len(results) >= 3:
x = np.array([dists[n] for n in results])
yv = np.array([med[n] for n in results])
slope = np.polyfit(x, yv, 1)[0] * 10 # m per 10 km
print(f"\n residual-vs-distance slope: {slope*100:.1f} cm per 10 km")
overall = np.concatenate([r for _, r in results.values()])
print(f" OVERALL: median|r|={np.median(np.abs(overall)):.3f} m, "
f"95%|r|={np.percentile(np.abs(overall), 95):.3f} m "
f"(gate G2a: median <= ~0.10 m)")
if __name__ == "__main__":
main()