gpstec 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gpstec/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ """gpstec: calibrated GPS total electron content from NovAtel GPStation-6 and other dual-frequency receivers.
2
+
3
+ Copyright (c) 2026 Dipanjan Banerjee. See the LICENSE file for terms.
4
+ """
5
+ __version__ = "1.0.0"
6
+ __author__ = "Dipanjan Banerjee"
7
+ __copyright__ = "Copyright (c) 2026 Dipanjan Banerjee"
gpstec/__main__.py ADDED
@@ -0,0 +1,112 @@
1
+ """Command line interface.
2
+
3
+ gpstec init write a station.toml template to edit
4
+ gpstec run -c station.toml -o results FILES...
5
+ gpstec install-convbin build RTKLIB convbin (needed for .GPS input)
6
+ gpstec --version
7
+ """
8
+ import argparse
9
+ import glob
10
+ import os
11
+ import shutil
12
+ import subprocess
13
+ import sys
14
+ from importlib import resources
15
+ from pathlib import Path
16
+
17
+ from . import __author__, __copyright__, __version__
18
+
19
+ TEMPLATE = "station_template.toml"
20
+
21
+
22
+ def _init(path, force=False):
23
+ path = Path(path)
24
+ if path.exists() and not force:
25
+ print(f"{path} already exists (use --force to overwrite).")
26
+ return 1
27
+ text = resources.files("gpstec").joinpath("data", TEMPLATE).read_text(encoding="utf-8")
28
+ path.write_text(text, encoding="utf-8")
29
+ print(f"Wrote {path}. Edit the [station] block (name, lat, lon, height, utc_offset_h), then run:")
30
+ print(f" gpstec run -c {path} -o results your_file.GPS")
31
+ return 0
32
+
33
+
34
+ def _install_convbin(dest):
35
+ dest = Path(dest).expanduser()
36
+ dest.mkdir(parents=True, exist_ok=True)
37
+ for tool in ("git", "make", "gcc"):
38
+ if not shutil.which(tool):
39
+ print(f"'{tool}' is needed to build convbin. On Ubuntu/Debian: sudo apt install git build-essential")
40
+ return 1
41
+ src = Path.home() / ".cache" / "gpstec-rtklib"
42
+ if src.exists():
43
+ shutil.rmtree(src)
44
+ subprocess.run(["git", "clone", "--depth", "1", "https://github.com/rtklibexplorer/RTKLIB.git", str(src)],
45
+ check=True)
46
+ build = src / "app" / "consapp" / "convbin" / "gcc"
47
+ subprocess.run(["make", "-C", str(build)], check=True)
48
+ shutil.copy2(build / "convbin", dest / "convbin")
49
+ print(f"convbin installed to {dest / 'convbin'}")
50
+ if str(dest) not in os.environ.get("PATH", "").split(os.pathsep):
51
+ print(f"Make sure {dest} is on your PATH, or set convbin = \"{dest / 'convbin'}\" in station.toml.")
52
+ return 0
53
+
54
+
55
+ def main(argv=None):
56
+ ap = argparse.ArgumentParser(
57
+ prog="gpstec",
58
+ description="Calibrated GPS total electron content (TEC) from NovAtel GPStation-6 and other "
59
+ "dual-frequency receivers.",
60
+ epilog=f"{__copyright__}. https://github.com/dipanjanbanerjee/gpstec",
61
+ formatter_class=argparse.RawTextHelpFormatter)
62
+ ap.add_argument("-V", "--version", action="version",
63
+ version=f"gpstec {__version__}\n{__copyright__}\nAuthor: {__author__}")
64
+ sub = ap.add_subparsers(dest="cmd", required=True)
65
+
66
+ i = sub.add_parser("init", help="write a station.toml configuration template")
67
+ i.add_argument("path", nargs="?", default="station.toml")
68
+ i.add_argument("--force", action="store_true", help="overwrite an existing file")
69
+
70
+ c = sub.add_parser("install-convbin", help="download and build RTKLIB convbin (Linux/macOS)")
71
+ c.add_argument("--dest", default="~/.local/bin", help="where to put convbin (default ~/.local/bin)")
72
+
73
+ r = sub.add_parser("run", help="process .GPS receiver files or RINEX files")
74
+ r.add_argument("files", nargs="+", help=".GPS receiver files or RINEX observation files (wildcards OK)")
75
+ r.add_argument("-c", "--config", help="TOML config file (station position, settings)")
76
+ r.add_argument("-o", "--out", default="tec_output", help="output folder")
77
+ r.add_argument("--nav", help="navigation file (if not next to the observation file)")
78
+ r.add_argument("--sp3", nargs="*", help="precise orbit SP3 file(s) instead of broadcast")
79
+ r.add_argument("--dcb", nargs="*", help="CODE P1P2/P1C1 .DCB or Bias-SINEX files")
80
+ r.add_argument("--elev", type=float, help="elevation cut for VTEC output (default 50)")
81
+ r.add_argument("--rx-bias", help="receiver bias: lsq, minstd, gim or a number in TECU")
82
+ r.add_argument("--no-cmn", action="store_true", help="skip writing the large .Cmn-style file")
83
+ a = ap.parse_args(argv)
84
+
85
+ if a.cmd == "init":
86
+ return _init(a.path, a.force)
87
+ if a.cmd == "install-convbin":
88
+ return _install_convbin(a.dest)
89
+
90
+ from . import config, pipeline # heavy imports only when processing
91
+ print(f"gpstec {__version__} · {__copyright__}")
92
+ station, settings = config.load(a.config)
93
+ if a.elev is not None:
94
+ settings.output_elevation = a.elev
95
+ if a.dcb:
96
+ settings.dcb_files = a.dcb
97
+ settings.sat_dcb = "file"
98
+ if a.rx_bias:
99
+ try:
100
+ settings.receiver_bias = float(a.rx_bias)
101
+ except ValueError:
102
+ settings.receiver_bias = a.rx_bias
103
+ files = []
104
+ for f in a.files:
105
+ files += sorted(glob.glob(f)) or [f]
106
+ pipeline.run([Path(f) for f in files], station, settings, a.out, nav=a.nav, sp3=a.sp3,
107
+ write_cmn=not a.no_cmn)
108
+ return 0
109
+
110
+
111
+ if __name__ == "__main__":
112
+ sys.exit(main())
gpstec/calibrate.py ADDED
@@ -0,0 +1,175 @@
1
+ """Removing the satellite and receiver code biases from leveled slant TEC.
2
+
3
+ Convention: STEC = STEC_raw + B_sat[prn] + B_rx (all in TECU)
4
+
5
+ Satellite biases come from CODE DCB files when available, otherwise they are
6
+ estimated from the data together with the receiver bias ("lsq").
7
+
8
+ Receiver bias:
9
+ minstd Pick the B_rx that makes the VTEC seen by different satellites at the
10
+ same moment agree best (smallest spread). This is the method used by
11
+ Gopi Seemala's GPS-TEC program (after Ma & Maruyama, 2003).
12
+ lsq Fit STEC_raw = VTEC(t, ipp)/mf - B_sat - B_rx by least squares, with a
13
+ VTEC that is linear in pierce-point lat/lon in 15-minute blocks. Gives
14
+ satellite and receiver biases with no external files (sum of satellite
15
+ biases = 0, the same convention CODE uses).
16
+ """
17
+ import numpy as np
18
+ import pandas as pd
19
+ from scipy import sparse
20
+ from scipy.sparse.linalg import lsqr
21
+
22
+ from .orbits import gps_seconds
23
+
24
+
25
+ def apply_sat_bias(d, sat_bias):
26
+ d = d.copy()
27
+ d["b_sat"] = d["prn"].map(sat_bias).astype(float)
28
+ missing = sorted(d.loc[d["b_sat"].isna(), "prn"].unique())
29
+ if missing:
30
+ print(f" warning: no satellite bias for PRN {missing}; those satellites are dropped")
31
+ d = d[d["b_sat"].notna()].copy()
32
+ d["stec_sat"] = d["stec_raw"] + d["b_sat"]
33
+ return d
34
+
35
+
36
+ def _sample(d, el_min, step_s=30):
37
+ s = d[d["el"] >= el_min]
38
+ t = gps_seconds(s["time"].to_numpy())
39
+ return s[np.isclose(t % step_s, 0, atol=0.01)]
40
+
41
+
42
+ def receiver_bias_minstd(d, el_min=30.0, step_s=30, search=(-150.0, 150.0)):
43
+ """Receiver bias (TECU) minimising the spread of simultaneous VTEC values."""
44
+ s = _sample(d, el_min, step_s)
45
+ if s.empty:
46
+ raise ValueError(f"No data above {el_min} deg elevation to estimate the receiver bias")
47
+ epoch = s["time"].to_numpy()
48
+ _, inv = np.unique(epoch, return_inverse=True)
49
+ stec = s["stec_sat"].to_numpy()
50
+ mf = s["mf"].to_numpy()
51
+ nep = inv.max() + 1
52
+ cnt = np.bincount(inv, minlength=nep)
53
+ ok = cnt[inv] >= 2
54
+
55
+ def cost(b):
56
+ v = (stec + b) * mf
57
+ m = np.bincount(inv, v, nep) / np.maximum(cnt, 1)
58
+ r = (v - m[inv]) ** 2
59
+ var = np.bincount(inv, np.where(ok, r, 0.0), nep) / np.maximum(cnt, 1)
60
+ return np.sqrt(var[cnt >= 2]).mean()
61
+
62
+ grid = np.arange(search[0], search[1] + 1e-9, 1.0)
63
+ c = np.array([cost(b) for b in grid])
64
+ b0 = grid[c.argmin()]
65
+ fine = np.arange(b0 - 1.0, b0 + 1.0 + 1e-9, 0.01)
66
+ cf = np.array([cost(b) for b in fine])
67
+ return float(fine[cf.argmin()]), float(cf.min())
68
+
69
+
70
+ def sample_for_fit(d, el_min=30.0, step_s=30):
71
+ """The small subset of columns/rows the bias fit needs (to combine many days)."""
72
+ return _sample(d, el_min, step_s)[["time", "prn", "el", "mf", "ipp_lat", "ipp_lon", "stec_raw"]].copy()
73
+
74
+
75
+ def biases_lsq(d, el_min=30.0, step_s=30, block_min=15.0, smooth=0.5, presampled=False):
76
+ """Joint least-squares estimate of satellite and receiver biases (TECU).
77
+
78
+ Returns (sat_bias dict, receiver_bias, rms of fit).
79
+ """
80
+ s = (d if presampled else _sample(d, el_min, step_s)).reset_index(drop=True)
81
+ if s.empty:
82
+ raise ValueError("No data for the bias fit")
83
+ t = gps_seconds(s["time"].to_numpy())
84
+ blk = ((t - t.min()) // (block_min * 60)).astype(int)
85
+ nb = blk.max() + 1
86
+ prns = np.sort(s["prn"].unique())
87
+ pidx = np.searchsorted(prns, s["prn"].to_numpy())
88
+ ns = len(prns)
89
+ lat0, lon0 = np.median(s["ipp_lat"]), np.median(s["ipp_lon"])
90
+ dlat = s["ipp_lat"].to_numpy() - lat0
91
+ dlon = s["ipp_lon"].to_numpy() - lon0
92
+ inv_mf = 1.0 / s["mf"].to_numpy()
93
+ y = s["stec_raw"].to_numpy()
94
+ n = len(s)
95
+ w = np.sin(np.radians(s["el"].to_numpy())) # elevation weighting
96
+
97
+ # unknown layout: [a0 (nb), a1 (nb), a2 (nb), b_sat (ns), b_rx]
98
+ rows = np.arange(n)
99
+ cols, vals, rr = [], [], []
100
+ for off, coef in ((0, inv_mf), (nb, inv_mf * dlat), (2 * nb, inv_mf * dlon)):
101
+ rr.append(rows); cols.append(off + blk); vals.append(coef)
102
+ rr.append(rows); cols.append(3 * nb + pidx); vals.append(-np.ones(n))
103
+ rr.append(rows); cols.append(np.full(n, 3 * nb + ns)); vals.append(-np.ones(n))
104
+ nun = 3 * nb + ns + 1
105
+ A = sparse.csr_matrix((np.concatenate(vals) * np.tile(w, 5), (np.concatenate(rr), np.concatenate(cols))), shape=(n, nun))
106
+ yw = y * w
107
+
108
+ # constraints: sum of satellite biases = 0 (strong), smooth VTEC/gradients in time (weak)
109
+ extra_r, extra_c, extra_v, extra_y = [], [], [], []
110
+ r0 = 0
111
+ big = 1e3
112
+ extra_r += [r0] * ns; extra_c += list(range(3 * nb, 3 * nb + ns)); extra_v += [big] * ns; extra_y.append(0.0); r0 += 1
113
+ for off in (0, nb, 2 * nb):
114
+ for kk in range(nb - 1):
115
+ extra_r += [r0, r0]; extra_c += [off + kk, off + kk + 1]; extra_v += [smooth, -smooth]; extra_y.append(0.0); r0 += 1
116
+ C = sparse.csr_matrix((extra_v, (extra_r, extra_c)), shape=(r0, nun))
117
+ AA = sparse.vstack([A, C]).tocsr()
118
+ yy = np.concatenate([yw, extra_y])
119
+ sol = lsqr(AA, yy, atol=1e-12, btol=1e-12, iter_lim=20000)[0]
120
+ resid = A @ sol - yw
121
+ sat = {int(p): float(b) for p, b in zip(prns, sol[3 * nb:3 * nb + ns])}
122
+ return sat, float(sol[-1]), float(np.sqrt(np.mean((resid / w) ** 2)))
123
+
124
+
125
+ def apply_receiver_bias(d, b_rx):
126
+ d = d.copy()
127
+ d["b_rx"] = b_rx
128
+ d["stec"] = d["stec_sat"] + b_rx
129
+ d["vtec"] = d["stec"] * d["mf"]
130
+ return d
131
+
132
+
133
+ def receiver_bias_lsq_fixed_sats(d, sta_lat, sta_lon, el_min=20.0, step_s=30, block_min=15.0, smooth=0.5):
134
+ """Receiver bias (TECU) by least squares when satellite biases are already applied.
135
+
136
+ Fits stec_sat = VTEC(t, ipp)/mf - B_rx, with VTEC linear in pierce-point offset
137
+ in 15-minute blocks. Uses the change of the mapping factor with elevation to
138
+ separate B_rx from VTEC; best on night data, where gradients are weak.
139
+ """
140
+ s = _sample(d, el_min, step_s).reset_index(drop=True)
141
+ if len(s) < 50:
142
+ raise ValueError("Not enough data for the least-squares receiver bias")
143
+ t = gps_seconds(s["time"].to_numpy())
144
+ blk = ((t - t.min()) // (block_min * 60)).astype(int)
145
+ nb = blk.max() + 1
146
+ n = len(s)
147
+ r = np.arange(n)
148
+ inv = 1.0 / s["mf"].to_numpy()
149
+ dlat = s["ipp_lat"].to_numpy() - sta_lat
150
+ dlon = (s["ipp_lon"].to_numpy() - sta_lon) * np.cos(np.radians(sta_lat))
151
+ w = np.sin(np.radians(s["el"].to_numpy()))
152
+ rows = np.concatenate([r, r, r, r])
153
+ cols = np.concatenate([blk, nb + blk, 2 * nb + blk, np.full(n, 3 * nb)])
154
+ vals = np.concatenate([inv, inv * dlat, inv * dlon, -np.ones(n)]) * np.tile(w, 4)
155
+ nun = 3 * nb + 1
156
+ A = sparse.csr_matrix((vals, (rows, cols)), shape=(n, nun))
157
+ er, ec, ev = [], [], []
158
+ k = 0
159
+ for off in (0, nb, 2 * nb):
160
+ for j in range(nb - 1):
161
+ er += [k, k]; ec += [off + j, off + j + 1]; ev += [smooth, -smooth]; k += 1
162
+ C = sparse.csr_matrix((ev, (er, ec)), shape=(k, nun))
163
+ sol = lsqr(sparse.vstack([A, C]).tocsr(), np.r_[s["stec_sat"].to_numpy() * w, np.zeros(k)],
164
+ atol=1e-12, btol=1e-12, iter_lim=20000)[0]
165
+ return float(sol[-1])
166
+
167
+
168
+ def receiver_bias_floor(d, el_min=10.0, pct=99.5):
169
+ """Smallest receiver bias that keeps (almost) every satellite's VTEC >= 0.
170
+
171
+ VTEC = (stec_sat + B_rx) * mf >= 0 <=> B_rx >= -stec_sat. Using the 99.5th
172
+ percentile instead of the maximum ignores the noisiest 0.5 % of points.
173
+ """
174
+ s = d[d["el"] >= el_min]
175
+ return float(np.percentile(-s["stec_sat"].to_numpy(), pct)) if len(s) else -np.inf
gpstec/config.py ADDED
@@ -0,0 +1,85 @@
1
+ """Station and processing settings, loaded from a TOML file."""
2
+ from dataclasses import dataclass, field, fields
3
+ from pathlib import Path
4
+ try:
5
+ import tomllib
6
+ except ImportError: # Python < 3.11
7
+ import tomli as tomllib
8
+
9
+ import numpy as np
10
+
11
+ from .geometry import geodetic_to_ecef
12
+
13
+
14
+ @dataclass
15
+ class Station:
16
+ name: str = "stat"
17
+ lat: float = None # degrees North (required: the surveyed antenna position)
18
+ lon: float = None # degrees East
19
+ height: float = 0.0 # metres above the WGS84 ellipsoid
20
+ utc_offset_h: float = 0.0 # for the local-time axis on plots (e.g. 5.5 for IST)
21
+
22
+ @property
23
+ def ecef(self):
24
+ return geodetic_to_ecef(self.lat, self.lon, self.height)
25
+
26
+
27
+ @dataclass
28
+ class Settings:
29
+ # geometry
30
+ shell_height_km: float = 350.0
31
+ # elevation limits (degrees)
32
+ mask_elevation: float = 10.0 # ignore anything lower, everywhere
33
+ level_elevation: float = 20.0 # phase-to-code levelling uses points above this
34
+ calib_elevation: float = 30.0 # receiver-bias estimation uses points above this
35
+ calib_night_lt: list = field(default_factory=lambda: [22.0, 6.0]) # local-time window for the
36
+ # receiver bias (quiet night: weak gradients); [] = whole day
37
+ output_elevation: float = 50.0 # VTEC products use points above this
38
+ # arcs
39
+ max_gap_s: float = 60.0
40
+ slip_threshold_m: float = 0.25
41
+ min_arc_s: float = 600.0
42
+ # signals
43
+ pair: str = "L1L2"
44
+ # biases
45
+ sat_dcb: str = "auto" # "auto" | "file" | "estimate"
46
+ dcb_files: list = field(default_factory=list) # CODE P1P2*.DCB / P1C1*.DCB (or .BSX)
47
+ dcb_dir: str = "dcb" # cache for downloaded CODE files
48
+ receiver_bias: str = "lsq" # "lsq" | "minstd" | "gim" | number in TECU
49
+ lsq_elevation: float = 20.0 # elevation cut-off for the least-squares receiver bias
50
+ receiver_bias_multiday: str = "per_day" # "per_day" (each day on its own) | "median" over the run
51
+ enforce_nonnegative: bool = True # raise the receiver bias if it would make VTEC negative
52
+ floor_percentile: float = 99.5 # ...ignoring the noisiest 0.5 % of points
53
+ gim: str = "auto" # CODE global ionosphere map as a reference: "auto" | "off"
54
+ gim_dir: str = "gim"
55
+ # daily products
56
+ bin_minutes: float = 1.0
57
+ clip_sigma: float = 2.0 # outlier clipping inside each bin
58
+ min_sats_per_bin: int = 1
59
+ smooth_knot_min: float = 60.0 # spacing of the smooth station-VTEC curve (minutes)
60
+ # figures
61
+ figure_formats: list = field(default_factory=lambda: ["png", "pdf"]) # also "svg", "eps", "tiff"
62
+ figure_dpi: int = 300 # raster resolution (PNG/TIFF)
63
+ figure_width_in: float = 7.2 # 7.2 in = full page width in most journals; 3.5 = one column
64
+ figure_titles: bool = True # False: no title line (the caption goes in the paper)
65
+ # tools
66
+ convbin: str = "convbin" # path to RTKLIB convbin(.exe)
67
+ convbin_format: str = "nov" # NovAtel OEM6/OEM7 (GPStation-6)
68
+
69
+
70
+ def load(path=None):
71
+ """Return (Station, Settings). Missing keys keep their defaults."""
72
+ st, se = Station(), Settings()
73
+ if path:
74
+ with open(path, "rb") as fh:
75
+ cfg = tomllib.load(fh)
76
+ for obj, sec in ((st, "station"), (se, "processing")):
77
+ known = {f.name for f in fields(obj)}
78
+ for key, val in cfg.get(sec, {}).items():
79
+ if key not in known:
80
+ raise KeyError(f"Unknown setting [{sec}] {key} in {path}")
81
+ setattr(obj, key, val)
82
+ if st.lat is None or st.lon is None:
83
+ raise ValueError("The station position is not set. Run `gpstec init`, put your antenna's "
84
+ "lat/lon/height in the [station] block of station.toml, and pass it with -c.")
85
+ return st, se
gpstec/constants.py ADDED
@@ -0,0 +1,24 @@
1
+ """Physical and GPS constants used across the package."""
2
+
3
+ C = 299_792_458.0 # speed of light (m/s)
4
+ F1 = 1575.42e6 # GPS L1 (Hz)
5
+ F2 = 1227.60e6 # GPS L2 (Hz)
6
+ F5 = 1176.45e6 # GPS L5 (Hz)
7
+ LAMBDA1 = C / F1 # L1 wavelength (m)
8
+ LAMBDA2 = C / F2 # L2 wavelength (m)
9
+ LAMBDA5 = C / F5
10
+
11
+ # 1 TECU = 1e16 electrons / m^2. Ionospheric group delay on frequency f is
12
+ # 40.3 * TEC / f^2 metres, so (P2 - P1) in metres converts to TECU with:
13
+ K_L1L2 = F1**2 * F2**2 / (F1**2 - F2**2) / 40.3 / 1e16 # ~9.52 TECU per metre
14
+ K_L1L5 = F1**2 * F5**2 / (F1**2 - F5**2) / 40.3 / 1e16 # ~7.76 TECU per metre
15
+ TECU_PER_NS_L1L2 = K_L1L2 * C * 1e-9 # ~2.85 TECU per ns of DCB
16
+
17
+ # WGS-84
18
+ WGS84_A = 6378137.0
19
+ WGS84_F = 1 / 298.257223563
20
+ WGS84_E2 = WGS84_F * (2 - WGS84_F)
21
+ GM = 3.986005e14 # IS-GPS-200 value
22
+ OMEGA_E = 7.2921151467e-5 # Earth rotation rate (rad/s)
23
+
24
+ RE_KM = 6371.0 # mean Earth radius for the thin-shell model
gpstec/continuity.py ADDED
@@ -0,0 +1,97 @@
1
+ """Midnight continuity check between consecutive days.
2
+
3
+ The receiver file of day X ends at 00:00:00 of day X+1 and the next file starts one
4
+ second later, so the two files see the same satellites through the same patch of
5
+ ionosphere at the same moment. The real VTEC cannot jump in one second, so any
6
+ difference at midnight comes from the processing:
7
+
8
+ * the receiver bias, which each day estimates on its own, and
9
+ * the phase levelling of arcs that cross midnight, which is done separately in
10
+ each file with only part of the arc.
11
+
12
+ For every satellite seen on both sides, each file's VTEC is fitted with a straight
13
+ line over its last (or first) 10 minutes and evaluated at the boundary, and the
14
+ two values are compared. The median over satellites is the "jump". The part that
15
+ the receiver-bias difference explains is reported separately.
16
+ """
17
+ import numpy as np
18
+ import pandas as pd
19
+
20
+ EDGE_MIN = 10.0
21
+ MAX_GAP_S = 300.0
22
+
23
+
24
+ def edges(d, el_min=30.0, edge_min=EDGE_MIN):
25
+ """Rows near the start and end of one file, kept for the boundary comparison."""
26
+ t0, t1 = d["time"].min(), d["time"].max()
27
+ w = pd.Timedelta(minutes=edge_min)
28
+ m = (d["el"] >= el_min) & d["vtec"].notna() & ((d["time"] <= t0 + w) | (d["time"] >= t1 - w))
29
+ cols = ["time", "prn", "el", "vtec", "stec", "arc"]
30
+ e = d.loc[m, [c for c in cols if c in d]].copy()
31
+ return {"start": t0, "end": t1, "rows": e}
32
+
33
+
34
+ def _value_at(rows, t_ref):
35
+ """Linear fit of VTEC against time, evaluated at t_ref; None if too few points."""
36
+ if len(rows) < 20:
37
+ return None
38
+ x = (rows["time"] - t_ref).dt.total_seconds().to_numpy()
39
+ y = rows["vtec"].to_numpy()
40
+ if np.ptp(x) < 60:
41
+ return float(np.median(y))
42
+ a, b = np.polyfit(x, y, 1)
43
+ return float(b)
44
+
45
+
46
+ def compare(prev, nxt, el_out, mf_out=0.9):
47
+ """Compare two consecutive files. ``prev``/``nxt`` are dicts with keys
48
+ edges (from :func:`edges`), b_rx, date_end_series, series (daily product DataFrames)."""
49
+ ea, eb = prev["edges"], nxt["edges"]
50
+ gap = (eb["start"] - ea["end"]).total_seconds()
51
+ if not (-3600 <= gap <= MAX_GAP_S):
52
+ return None
53
+ t_ref = ea["end"] + (eb["start"] - ea["end"]) / 2
54
+ w = pd.Timedelta(minutes=EDGE_MIN)
55
+ ra = ea["rows"][ea["rows"]["time"] >= ea["end"] - w]
56
+ rb = eb["rows"][eb["rows"]["time"] <= eb["start"] + w]
57
+ per = []
58
+ for prn in sorted(set(ra["prn"]) & set(rb["prn"])):
59
+ a, b = ra[ra["prn"] == prn], rb[rb["prn"] == prn]
60
+ va, vb = _value_at(a, t_ref), _value_at(b, t_ref)
61
+ if va is None or vb is None:
62
+ continue
63
+ el = float(np.median(np.r_[a["el"].to_numpy()[-5:], b["el"].to_numpy()[:5]]))
64
+ mf = float(np.median(np.r_[(a["vtec"] / a["stec"]).to_numpy()[-5:], (b["vtec"] / b["stec"]).to_numpy()[:5]]))
65
+ per.append({"prn": int(prn), "el": round(el, 1), "vtec_end_prev": round(va, 2),
66
+ "vtec_start_next": round(vb, 2), "jump": round(vb - va, 2),
67
+ "jump_from_rx_bias": round((nxt["b_rx"] - prev["b_rx"]) * mf, 2)})
68
+ per = pd.DataFrame(per)
69
+ res = {"boundary_ut": str(t_ref.floor("s")), "prev_date": prev["date"], "next_date": nxt["date"],
70
+ "file_gap_s": round(gap, 1), "n_sats_el30": int(len(per)),
71
+ "receiver_bias_prev": round(prev["b_rx"], 2), "receiver_bias_next": round(nxt["b_rx"], 2)}
72
+ if len(per):
73
+ hi = per[per["el"] >= el_out]
74
+ res["jump_median_el30"] = round(float(per["jump"].median()), 2)
75
+ res["jump_spread_el30"] = round(float(per["jump"].std(ddof=0)), 2) if len(per) > 1 else 0.0
76
+ res["n_sats_out"] = int(len(hi))
77
+ res["jump_median_out"] = round(float(hi["jump"].median()), 2) if len(hi) else None
78
+ res["jump_from_rx_bias"] = round(float(per["jump_from_rx_bias"].median()), 2)
79
+ res["jump_from_levelling"] = round(res["jump_median_el30"] - res["jump_from_rx_bias"], 2)
80
+ # smooth station curve: last value of day X vs first value of day X+1
81
+ sa, sb = prev["series"], nxt["series"]
82
+ if sa is not None and sb is not None:
83
+ va = sa.dropna(subset=["vtec_smooth"])
84
+ vb = sb.dropna(subset=["vtec_smooth"])
85
+ if len(va) and len(vb) and va["ut_hour"].iloc[-1] > 23.5 and vb["ut_hour"].iloc[0] < 0.5:
86
+ res["smooth_end_prev"] = round(float(va["vtec_smooth"].iloc[-1]), 2)
87
+ res["smooth_start_next"] = round(float(vb["vtec_smooth"].iloc[0]), 2)
88
+ res["smooth_jump"] = round(res["smooth_start_next"] - res["smooth_end_prev"], 2)
89
+ unc = [u for u in (prev.get("unc"), nxt.get("unc")) if u]
90
+ tol = max(1.0, float(np.hypot(*unc)) if len(unc) == 2 else (unc[0] if unc else 1.0))
91
+ res["tolerance_tecu"] = round(tol, 2)
92
+ j = res.get("jump_median_el30")
93
+ if j is None:
94
+ res["status"] = "no common satellites"
95
+ else:
96
+ res["status"] = "OK" if abs(j) <= tol else "CHECK"
97
+ return res, per
gpstec/convert.py ADDED
@@ -0,0 +1,77 @@
1
+ """Receiver .GPS (NovAtel binary) -> RINEX, using RTKLIB's convbin."""
2
+ from pathlib import Path
3
+ import shutil
4
+ import subprocess
5
+
6
+ import numpy as np
7
+
8
+
9
+ def find_convbin(hint="convbin"):
10
+ local = Path.home() / ".local" / "bin" / "convbin"
11
+ for cand in (hint, "convbin", "convbin.exe", str(local)):
12
+ p = shutil.which(cand) or (cand if Path(cand).is_file() else None)
13
+ if p:
14
+ return p
15
+ raise FileNotFoundError(
16
+ "RTKLIB convbin was not found. Run `gpstec install-convbin`, or install RTKLIB yourself "
17
+ "and set `convbin = \"/path/to/convbin\"` under [processing] in your config file.")
18
+
19
+
20
+ def gps_to_rinex(gps_file, out_dir, station, settings, freqs=None):
21
+ """Convert one receiver file to RINEX 2.11 obs + nav.
22
+
23
+ The station position from the config is written into the header
24
+ (APPROX POSITION XYZ), so the files are also safe to use in other programs.
25
+ Returns (obs_path, nav_path).
26
+ """
27
+ gps_file = Path(gps_file)
28
+ out_dir = Path(out_dir)
29
+ out_dir.mkdir(parents=True, exist_ok=True)
30
+ exe = find_convbin(settings.convbin)
31
+ x, y, z = station.ecef
32
+ stem = gps_file.stem
33
+ obs = out_dir / f"{stem}.obs"
34
+ nav = out_dir / f"{stem}.nav"
35
+ freqs = freqs or (3 if settings.pair == "L1L5" else 2)
36
+ cmd = [exe, "-r", settings.convbin_format, "-v", "2.11", "-f", str(freqs),
37
+ "-od", "-os", "-y", "R", "-y", "E", "-y", "J", "-y", "S", "-y", "C",
38
+ "-hm", station.name, "-hp", f"{x:.4f}/{y:.4f}/{z:.4f}",
39
+ "-o", str(obs), "-n", str(nav), str(gps_file)]
40
+ print(" " + " ".join(cmd))
41
+ res = subprocess.run(cmd, capture_output=True, text=True)
42
+ if res.returncode != 0 or not obs.exists():
43
+ raise RuntimeError(f"convbin failed for {gps_file}:\n{res.stdout}\n{res.stderr}")
44
+ obs = rinex2_rename(obs, station.name)
45
+ if nav.exists():
46
+ new_nav = obs.with_name(obs.name[:-1] + "n")
47
+ if new_nav.exists():
48
+ new_nav.unlink()
49
+ nav = nav.rename(new_nav)
50
+ else:
51
+ nav = None
52
+ return obs, nav
53
+
54
+
55
+ def rinex2_rename(path, site, kind="o"):
56
+ """Rename to the RINEX 2 short name ssssDDD0.YYo (what Gopi's software expects)."""
57
+ path = Path(path)
58
+ first = _first_obs_time(path)
59
+ if first is None:
60
+ return path
61
+ doy = (first.astype("datetime64[D]") - first.astype("datetime64[Y]")).astype(int) + 1
62
+ yy = first.astype("datetime64[Y]").astype(int) + 1970
63
+ new = path.with_name(f"{site[:4].lower():<4}{doy:03d}0.{yy % 100:02d}{kind}")
64
+ if new.exists():
65
+ new.unlink()
66
+ path.rename(new)
67
+ return new
68
+
69
+
70
+ def _first_obs_time(path):
71
+ with open(path, errors="replace") as fh:
72
+ for ln in fh:
73
+ if "TIME OF FIRST OBS" in ln:
74
+ y, mo, d = ln[:6].strip(), ln[6:12].strip(), ln[12:18].strip()
75
+ return np.datetime64(f"{int(y):04d}-{int(mo):02d}-{int(d):02d}")
76
+ if "END OF HEADER" in ln:
77
+ return None
@@ -0,0 +1,54 @@
1
+ # gpstec configuration file. Created by `gpstec init`; edit the [station] block for your receiver.
2
+ # Every value is optional: anything left out keeps its default.
3
+
4
+ [station]
5
+ name = "skbu"
6
+ lat = 23.3607 # degrees N (the surveyed antenna position; never trust the RINEX header)
7
+ lon = 86.3408 # degrees E
8
+ height = 285.279 # metres (ellipsoidal)
9
+ utc_offset_h = 5.5 # local time axis on plots (IST)
10
+
11
+ [processing]
12
+ shell_height_km = 350.0
13
+
14
+ # Elevation limits (degrees)
15
+ mask_elevation = 10.0 # data below this is ignored completely
16
+ level_elevation = 20.0 # phase-to-code levelling uses points above this
17
+ calib_elevation = 30.0 # receiver-bias estimation uses points above this
18
+ calib_night_lt = [22.0, 6.0] # ...and only this local-time window ([] = whole day)
19
+ output_elevation = 50.0 # VTEC products (CSV, Std, plots) use satellites above this
20
+
21
+ # Satellite biases:
22
+ # "auto" use dcb_files if given, else download CODE monthly files into dcb_dir,
23
+ # else estimate them from the data (less accurate)
24
+ # "file" require dcb_files
25
+ # "estimate" always estimate from the data
26
+ sat_dcb = "auto"
27
+ dcb_files = [] # e.g. ["dcb/P1P22212.DCB", "dcb/P1C12212.DCB"]
28
+ dcb_dir = "dcb"
29
+
30
+ # Receiver bias (night data only, each day on its own):
31
+ # "lsq" least squares with north/east gradients (default; most stable on 25-26 Dec 2022)
32
+ # "minstd" minimum spread between satellites (Gopi-style; drifts with strong gradients)
33
+ # "gim" match the CODE global ionosphere map; or a fixed number in TECU
34
+ receiver_bias = "lsq"
35
+ lsq_elevation = 20.0
36
+ receiver_bias_multiday = "per_day" # each day calibrated on its own ("median" = one value per run)
37
+ enforce_nonnegative = true # never let the receiver bias make VTEC negative
38
+
39
+ gim = "auto" # CODE global ionosphere map as reference line + check ("off" to skip)
40
+ gim_dir = "gim"
41
+
42
+ bin_minutes = 1.0
43
+ smooth_knot_min = 60.0 # smoothness of the station VTEC curve (smaller = more detail)
44
+ clip_sigma = 2.0
45
+
46
+ # Figures (publication quality: vector PDF + 300 dpi PNG by default)
47
+ figure_formats = ["png", "pdf"] # any of "png", "pdf", "svg", "eps", "tiff"
48
+ figure_dpi = 300
49
+ figure_width_in = 7.2 # 7.2 in = full page width; 3.5 in = one journal column
50
+ figure_titles = true # false = no title line (put the caption in the paper)
51
+
52
+ # RTKLIB convbin, needed only for .GPS input (`gpstec install-convbin` builds it)
53
+ convbin = "convbin" # or a full path, e.g. "/home/you/.local/bin/convbin"
54
+ convbin_format = "nov" # NovAtel OEM6/OEM7 binary (GPStation-6)