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 +7 -0
- gpstec/__main__.py +112 -0
- gpstec/calibrate.py +175 -0
- gpstec/config.py +85 -0
- gpstec/constants.py +24 -0
- gpstec/continuity.py +97 -0
- gpstec/convert.py +77 -0
- gpstec/data/station_template.toml +54 -0
- gpstec/dcb.py +226 -0
- gpstec/geometry.py +61 -0
- gpstec/gim.py +156 -0
- gpstec/orbits.py +215 -0
- gpstec/pipeline.py +456 -0
- gpstec/plots.py +398 -0
- gpstec/products.py +154 -0
- gpstec/rinex_obs.py +231 -0
- gpstec/tec.py +196 -0
- gpstec-1.0.0.dist-info/METADATA +157 -0
- gpstec-1.0.0.dist-info/RECORD +23 -0
- gpstec-1.0.0.dist-info/WHEEL +5 -0
- gpstec-1.0.0.dist-info/entry_points.txt +2 -0
- gpstec-1.0.0.dist-info/licenses/LICENSE +21 -0
- gpstec-1.0.0.dist-info/top_level.txt +1 -0
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)
|