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/.
343 lines
13 KiB
Python
343 lines
13 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
vbs_synth.py - Virtual base station synthesis: geometric displacement engine.
|
|
|
|
Patches a RINEX 2.11 observation file so its observations appear to have been
|
|
collected at a different (virtual) position: per epoch/satellite, the change in
|
|
geometric range (independent light-time iteration + Earth-rotation correction at
|
|
each position, SP3 orbits) is added to every code observable (metres) and every
|
|
carrier-phase observable (cycles, per-signal wavelength). Everything else -
|
|
LLI/SSI flags, S/D observables, obs-type order, epoch structure - is preserved.
|
|
Satellites without SP3 orbits or (GLONASS) without a known frequency slot are
|
|
dropped, with epoch satellite lists rewritten accordingly.
|
|
|
|
The master receiver's clock is inherited by the virtual station (harmless: it is
|
|
one clock, absorbed by the processor's per-epoch receiver-clock estimation; see
|
|
the --perturb-clock-ms test mode). The atmospheric content of the master's
|
|
observations is NOT changed by this engine - a geometric-only virtual base
|
|
carries the master's atmosphere verbatim (Phase 2 applies interpolated
|
|
corrections on top via --iono).
|
|
|
|
Usage:
|
|
vbs_synth.py master.obs --xyz X Y Z --sp3 FILE --nav BRDC.rnx -o out.obs
|
|
[--master-xyz X Y Z] [--perturb-clock-ms N] [--marker NAME]
|
|
"""
|
|
|
|
import argparse
|
|
import math
|
|
import sys
|
|
from datetime import datetime, timedelta, timezone
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
|
|
C = 299792458.0
|
|
OMEGA_E = 7.2921151467e-5 # rad/s
|
|
GAMMA_12 = (1575.42 / 1227.60) ** 2
|
|
|
|
# carrier frequency by (system, band); GLONASS handled via slot
|
|
FREQ = {
|
|
("G", "1"): 1575.42e6, ("G", "2"): 1227.60e6, ("G", "5"): 1176.45e6,
|
|
("E", "1"): 1575.42e6, ("E", "5"): 1176.45e6, # E1, E5a
|
|
("S", "1"): 1575.42e6,
|
|
}
|
|
|
|
|
|
def glo_freq(band, slot):
|
|
if band == "1":
|
|
return 1602.0e6 + slot * 562.5e3
|
|
if band == "2":
|
|
return 1246.0e6 + slot * 437.5e3
|
|
return None
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# SP3 orbits
|
|
# ----------------------------------------------------------------------------
|
|
|
|
class Sp3:
|
|
"""SP3 position source with sliding-window Lagrange interpolation."""
|
|
|
|
def __init__(self, path):
|
|
self.t0 = None
|
|
times = []
|
|
pos = {} # sat -> {tidx: (x,y,z) m}
|
|
tidx = -1
|
|
for line in open(path, errors="replace"):
|
|
if line.startswith("*"):
|
|
p = line.split()
|
|
t = datetime(int(p[1]), int(p[2]), int(p[3]), int(p[4]), int(p[5]),
|
|
int(float(p[6])), tzinfo=timezone.utc)
|
|
if self.t0 is None:
|
|
self.t0 = t
|
|
times.append((t - self.t0).total_seconds())
|
|
tidx += 1
|
|
elif line.startswith("P") and tidx >= 0:
|
|
sat = line[1:4].replace(" ", "0")
|
|
try:
|
|
x, y, z = (float(line[4:18]), float(line[18:32]), float(line[32:46]))
|
|
except ValueError:
|
|
continue
|
|
if abs(x) > 900000 or (x == 0 and y == 0): # bad/absent
|
|
continue
|
|
pos.setdefault(sat, {})[tidx] = (x * 1e3, y * 1e3, z * 1e3)
|
|
self.times = np.array(times)
|
|
self.sats = {}
|
|
n = len(times)
|
|
for sat, d in pos.items():
|
|
if len(d) < n * 0.9: # incomplete arcs: drop satellite
|
|
continue
|
|
arr = np.full((n, 3), np.nan)
|
|
for i, xyz in d.items():
|
|
arr[i] = xyz
|
|
if np.isnan(arr).any():
|
|
continue
|
|
self.sats[sat] = arr
|
|
|
|
def pos(self, sat, t_sec, order=10):
|
|
"""Satellite ECEF position (m) at t_sec (seconds from SP3 start)."""
|
|
arr = self.sats.get(sat)
|
|
if arr is None:
|
|
return None
|
|
i = np.searchsorted(self.times, t_sec)
|
|
half = order // 2
|
|
lo = max(0, min(i - half, len(self.times) - order))
|
|
idx = slice(lo, lo + order)
|
|
tt = self.times[idx]
|
|
out = np.empty(3)
|
|
# Lagrange interpolation per coordinate
|
|
for k in range(3):
|
|
y = arr[idx, k]
|
|
acc = 0.0
|
|
for j in range(len(tt)):
|
|
lj = 1.0
|
|
for m in range(len(tt)):
|
|
if m != j:
|
|
lj *= (t_sec - tt[m]) / (tt[j] - tt[m])
|
|
acc += y[j] * lj
|
|
out[k] = acc
|
|
return out
|
|
|
|
|
|
def glonass_slots(nav_path):
|
|
"""sat 'Rnn' -> frequency slot number, from a RINEX 3 mixed nav file."""
|
|
slots = {}
|
|
lines = open(nav_path, errors="replace").read().splitlines()
|
|
i = 0
|
|
while i < len(lines):
|
|
line = lines[i]
|
|
if line[:1] == "R" and len(line) > 23 and line[1:3].strip().isdigit():
|
|
sat = "R" + line[1:3].replace(" ", "0")
|
|
if sat not in slots and i + 2 < len(lines):
|
|
orbit2 = lines[i + 2]
|
|
try: # 4th 19-char field on broadcast orbit 2
|
|
slots[sat] = int(float(orbit2[4 + 3 * 19: 4 + 4 * 19].replace("D", "E")))
|
|
except ValueError:
|
|
pass
|
|
i += 4
|
|
else:
|
|
i += 1
|
|
return slots
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# Geometry
|
|
# ----------------------------------------------------------------------------
|
|
|
|
def geometric_range(sp3, sat, t_rx_sec, x_rcv):
|
|
"""Light-time-iterated, Earth-rotation-corrected range (m); None if no orbit."""
|
|
tau = 0.075
|
|
rho = None
|
|
for _ in range(3):
|
|
xs = sp3.pos(sat, t_rx_sec - tau)
|
|
if xs is None:
|
|
return None
|
|
ang = OMEGA_E * tau # rotate satellite into reception-time frame
|
|
ca, sa = math.cos(ang), math.sin(ang)
|
|
xr = np.array([xs[0] * ca + xs[1] * sa, -xs[0] * sa + xs[1] * ca, xs[2]])
|
|
rho = float(np.linalg.norm(xr - x_rcv))
|
|
tau = rho / C
|
|
return rho
|
|
|
|
|
|
# ----------------------------------------------------------------------------
|
|
# RINEX 2.11 observation patcher
|
|
# ----------------------------------------------------------------------------
|
|
|
|
def parse_header(lines):
|
|
"""Returns (obs_types, approx_xyz, end_idx)."""
|
|
types, xyz = [], None
|
|
for i, line in enumerate(lines):
|
|
label = line[60:].strip()
|
|
if label == "# / TYPES OF OBSERV":
|
|
p = line[:60].split()
|
|
if types == [] and p and p[0].isdigit():
|
|
types.extend(p[1:])
|
|
else:
|
|
types.extend(p)
|
|
elif label == "APPROX POSITION XYZ":
|
|
xyz = tuple(float(v) for v in line[:60].split())
|
|
elif label == "END OF HEADER":
|
|
return types, xyz, i
|
|
raise ValueError("no END OF HEADER")
|
|
|
|
|
|
def fmt_obs(value, lli, ssi):
|
|
if value is None:
|
|
return " " * 14 + lli + ssi
|
|
return f"{value:14.3f}" + lli + ssi
|
|
|
|
|
|
def synthesize(master_path, out_path, virtual_xyz, sp3, slots, master_xyz=None,
|
|
perturb_ms=0.0, marker="VBS"):
|
|
lines = open(master_path, errors="replace").read().splitlines()
|
|
types, hdr_xyz, hdr_end = parse_header(lines)
|
|
ntypes = len(types)
|
|
nlines_per_sat = (ntypes + 4) // 5
|
|
x_mas = np.array(master_xyz if master_xyz else hdr_xyz)
|
|
x_vrs = np.array(virtual_xyz)
|
|
disp_km = np.linalg.norm(x_vrs - x_mas) / 1e3
|
|
|
|
out = []
|
|
for line in lines[:hdr_end + 1]:
|
|
label = line[60:].strip()
|
|
if label == "APPROX POSITION XYZ":
|
|
out.append(f"{x_vrs[0]:14.4f}{x_vrs[1]:14.4f}{x_vrs[2]:14.4f}"
|
|
+ " " * 18 + "APPROX POSITION XYZ")
|
|
elif label == "MARKER NAME":
|
|
out.append(f"{marker:<60}MARKER NAME")
|
|
elif label == "END OF HEADER":
|
|
out.append(f"{'VBS synthesized from ' + Path(master_path).name:<60}COMMENT")
|
|
out.append(f"{f'displacement {disp_km:.3f} km; geometric only':<60}COMMENT")
|
|
out.append(line)
|
|
else:
|
|
out.append(line)
|
|
|
|
i = hdr_end + 1
|
|
stats = {"epochs": 0, "sats": 0, "dropped": {}, "dr_min": 1e9, "dr_max": -1e9}
|
|
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: # event records: copy verbatim
|
|
out.append(line)
|
|
for k in range(nsat):
|
|
i += 1
|
|
out.append(lines[i])
|
|
i += 1
|
|
continue
|
|
# epoch time (GPS time system)
|
|
p = line[:26].split()
|
|
t_rx = datetime(2000 + int(p[0]), int(p[1]), int(p[2]), int(p[3]), int(p[4]),
|
|
tzinfo=timezone.utc) + timedelta(seconds=float(p[5]))
|
|
t_sec = (t_rx - sp3.t0).total_seconds()
|
|
# satellite list (12 per line, continuations)
|
|
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
|
|
# per-satellite observation blocks
|
|
keep = []
|
|
for sat in sats:
|
|
block = lines[i:i + nlines_per_sat]
|
|
i += nlines_per_sat
|
|
sys_id = sat[0]
|
|
if sys_id == "R" and sat not in slots:
|
|
stats["dropped"][sat] = stats["dropped"].get(sat, 0) + 1
|
|
continue
|
|
rho_m = geometric_range(sp3, sat, t_sec, x_mas)
|
|
if rho_m is None:
|
|
stats["dropped"][sat] = stats["dropped"].get(sat, 0) + 1
|
|
continue
|
|
rho_v = geometric_range(sp3, sat, t_sec, x_vrs)
|
|
dr = rho_v - rho_m
|
|
stats["dr_min"] = min(stats["dr_min"], dr)
|
|
stats["dr_max"] = max(stats["dr_max"], dr)
|
|
# parse + patch the observation fields
|
|
fields = []
|
|
ok = True
|
|
for ti, typ in enumerate(types):
|
|
ln = block[ti // 5] if ti // 5 < len(block) else ""
|
|
seg = ln[(ti % 5) * 16:(ti % 5) * 16 + 16].ljust(16)
|
|
raw, lli, ssi = seg[:14], seg[14], seg[15]
|
|
val = float(raw) if raw.strip() else None
|
|
if val is not None:
|
|
if typ[0] in ("C", "P"):
|
|
val += dr + C * perturb_ms * 1e-3
|
|
elif typ[0] == "L":
|
|
f = (glo_freq(typ[1], slots[sat]) if sys_id == "R"
|
|
else FREQ.get((sys_id, typ[1])))
|
|
if f is None:
|
|
ok = False
|
|
break
|
|
lam = C / f
|
|
val += dr / lam + C * perturb_ms * 1e-3 / lam
|
|
fields.append((val, lli, ssi))
|
|
if not ok:
|
|
stats["dropped"][sat] = stats["dropped"].get(sat, 0) + 1
|
|
continue
|
|
keep.append((sat, fields))
|
|
# rewrite epoch record with surviving satellites
|
|
if not keep:
|
|
continue
|
|
stats["epochs"] += 1
|
|
stats["sats"] += len(keep)
|
|
ids = [s for s, _ in keep]
|
|
head = line[:29] + f"{len(ids):3d}"
|
|
for k in range(0, len(ids), 12):
|
|
seg = "".join(ids[k:k + 12])
|
|
out.append((head if k == 0 else " " * 32) + seg)
|
|
for sat, fields in keep:
|
|
for li in range(nlines_per_sat):
|
|
chunk = fields[li * 5:(li + 1) * 5]
|
|
out.append("".join(fmt_obs(*f) for f in chunk).rstrip())
|
|
|
|
Path(out_path).write_text("\n".join(out) + "\n")
|
|
return stats, disp_km
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("master", type=Path, help="master RINEX 2.11 obs file")
|
|
ap.add_argument("--xyz", nargs=3, type=float, required=True, metavar=("X", "Y", "Z"),
|
|
help="virtual position, ECEF metres (same frame as --master-xyz)")
|
|
ap.add_argument("--sp3", type=Path, required=True)
|
|
ap.add_argument("--nav", type=Path, required=True,
|
|
help="RINEX 3 mixed nav (for GLONASS frequency slots)")
|
|
ap.add_argument("-o", "--out", type=Path, required=True)
|
|
ap.add_argument("--master-xyz", nargs=3, type=float, metavar=("X", "Y", "Z"),
|
|
help="master position override (else RINEX header position)")
|
|
ap.add_argument("--perturb-clock-ms", type=float, default=0.0,
|
|
help="TEST MODE: add a simulated receiver-clock offset to all "
|
|
"code+phase observables")
|
|
ap.add_argument("--marker", default="VBS0")
|
|
args = ap.parse_args()
|
|
|
|
sp3 = Sp3(args.sp3)
|
|
slots = glonass_slots(args.nav)
|
|
stats, disp = synthesize(args.master, args.out, args.xyz, sp3, slots,
|
|
master_xyz=args.master_xyz,
|
|
perturb_ms=args.perturb_clock_ms, marker=args.marker)
|
|
dropped = sum(stats["dropped"].values())
|
|
print(f"[vbs_synth] {args.master.name} -> {args.out.name}: "
|
|
f"displacement {disp:.3f} km, {stats['epochs']} epochs, "
|
|
f"{stats['sats']} sat-epochs kept, {dropped} dropped "
|
|
f"({len(stats['dropped'])} sats), dRho [{stats['dr_min']:.1f}, "
|
|
f"{stats['dr_max']:.1f}] m")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|