gnsskit 0.4.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.
- gnsskit/__init__.py +76 -0
- gnsskit/kinematic/__init__.py +5 -0
- gnsskit/kinematic/trajectory.py +84 -0
- gnsskit/ppp/__init__.py +16 -0
- gnsskit/py.typed +0 -0
- gnsskit/refsys.py +308 -0
- gnsskit/rinex.py +467 -0
- gnsskit/rtklib/__init__.py +17 -0
- gnsskit/rtklib/config.py +313 -0
- gnsskit/rtklib/pos.py +158 -0
- gnsskit/rtklib/runner.py +146 -0
- gnsskit/static/__init__.py +31 -0
- gnsskit/static/baseline.py +323 -0
- gnsskit/static/closure.py +79 -0
- gnsskit/static/network.py +448 -0
- gnsskit/static/solve.py +54 -0
- gnsskit-0.4.0.dist-info/METADATA +72 -0
- gnsskit-0.4.0.dist-info/RECORD +20 -0
- gnsskit-0.4.0.dist-info/WHEEL +4 -0
- gnsskit-0.4.0.dist-info/licenses/LICENSE +21 -0
gnsskit/rinex.py
ADDED
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
"""RINEX 3 observation files: header and time span, trimming, quality inspection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
from .refsys import GRS80, Ellipsoid, ecef_to_llh
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from matplotlib.figure import Figure
|
|
19
|
+
|
|
20
|
+
SYSTEMS = "GRECJSI" # RINEX satellite system letters
|
|
21
|
+
OBS_SUFFIX = re.compile(r"\.(\d\d[oO]|obs)$") # observation files: .26O, .26o, .obs
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# =============================================================================
|
|
25
|
+
# Record parsing (private)
|
|
26
|
+
# =============================================================================
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _epoch_time(line: str) -> pd.Timestamp:
|
|
30
|
+
"""Timestamp of a RINEX 3 epoch record (the text after `>`)."""
|
|
31
|
+
y, mo, d, h, mi, s = line.split()[:6]
|
|
32
|
+
return pd.Timestamp(int(y), int(mo), int(d), int(h), int(mi)) + pd.Timedelta(seconds=float(s))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _header_time(t: pd.Timestamp) -> str:
|
|
36
|
+
sec = t.second + t.microsecond / 1e6
|
|
37
|
+
return f"{t.year:6d}{t.month:6d}{t.day:6d}{t.hour:6d}{t.minute:6d}{sec:13.7f} GPS"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# =============================================================================
|
|
41
|
+
# Header and time span
|
|
42
|
+
# =============================================================================
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def rinex_span(obs: Path | str) -> tuple[pd.Timestamp, pd.Timestamp]:
|
|
46
|
+
"""First and last epoch of a RINEX 3 observation file.
|
|
47
|
+
|
|
48
|
+
Event records without a timestamp are skipped. The epochs are returned as written in the
|
|
49
|
+
file (GPST for GPS-based files).
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
obs: RINEX 3 observation file.
|
|
53
|
+
|
|
54
|
+
Returns:
|
|
55
|
+
(first, last) epoch.
|
|
56
|
+
|
|
57
|
+
Raises:
|
|
58
|
+
ValueError: If no epoch is found.
|
|
59
|
+
"""
|
|
60
|
+
first = last = None
|
|
61
|
+
with Path(obs).open() as f:
|
|
62
|
+
for line in f:
|
|
63
|
+
if not line.startswith(">"):
|
|
64
|
+
continue
|
|
65
|
+
try:
|
|
66
|
+
last = _epoch_time(line[1:])
|
|
67
|
+
except ValueError:
|
|
68
|
+
continue
|
|
69
|
+
if first is None:
|
|
70
|
+
first = last
|
|
71
|
+
if first is None or last is None:
|
|
72
|
+
raise ValueError(f"no RINEX 3 epochs found in {obs}")
|
|
73
|
+
return first, last
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def header_llh(obs: Path | str, ellipsoid: Ellipsoid = GRS80) -> tuple[float, float, float]:
|
|
77
|
+
"""Antenna position from the RINEX header, as geodetic lat, lon and ellipsoidal height.
|
|
78
|
+
|
|
79
|
+
Uses `APPROX POSITION XYZ` plus the vertical `ANTENNA: DELTA H/E/N`, if present.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
obs: RINEX observation file.
|
|
83
|
+
ellipsoid: Reference ellipsoid of the conversion.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
(lat [deg], lon [deg], height [m]).
|
|
87
|
+
|
|
88
|
+
Raises:
|
|
89
|
+
ValueError: If there is no valid (non-zero) APPROX POSITION XYZ.
|
|
90
|
+
"""
|
|
91
|
+
xyz, dh = None, 0.0
|
|
92
|
+
with Path(obs).open() as f:
|
|
93
|
+
for line in f:
|
|
94
|
+
if "APPROX POSITION XYZ" in line:
|
|
95
|
+
xyz = tuple(map(float, line[:42].split()))
|
|
96
|
+
elif "ANTENNA: DELTA H/E/N" in line:
|
|
97
|
+
dh = float(line[:14])
|
|
98
|
+
elif "END OF HEADER" in line:
|
|
99
|
+
break
|
|
100
|
+
if xyz is None or xyz == (0.0, 0.0, 0.0):
|
|
101
|
+
raise ValueError(f"no valid APPROX POSITION XYZ in {obs}")
|
|
102
|
+
lat, lon, h = ecef_to_llh(xyz[0], xyz[1], xyz[2], ellipsoid)
|
|
103
|
+
return float(lat), float(lon), float(h) + dh
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# =============================================================================
|
|
107
|
+
# Trimming and decimation
|
|
108
|
+
# =============================================================================
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _convbin_trim(
|
|
112
|
+
src: Path,
|
|
113
|
+
dst: Path,
|
|
114
|
+
start: pd.Timestamp | None,
|
|
115
|
+
end: pd.Timestamp | None,
|
|
116
|
+
interval: float | None,
|
|
117
|
+
) -> bool:
|
|
118
|
+
"""Trim with RTKLIB `convbin`; False if it is not installed or fails."""
|
|
119
|
+
exe = shutil.which("convbin")
|
|
120
|
+
if exe is None:
|
|
121
|
+
return False
|
|
122
|
+
with src.open() as f:
|
|
123
|
+
version = f.readline()[:9].strip()
|
|
124
|
+
cmd = [exe, "-r", "rinex", "-v", version, "-od", "-os"] # keep Doppler and SNR
|
|
125
|
+
if end is not None:
|
|
126
|
+
# convbin excludes the epoch at -te, and truncates it
|
|
127
|
+
end += pd.Timedelta(seconds=1)
|
|
128
|
+
for flag, t in (("-ts", start), ("-te", end)):
|
|
129
|
+
if t is not None:
|
|
130
|
+
cmd += [flag, t.strftime("%Y/%m/%d"), t.strftime("%H:%M:%S")]
|
|
131
|
+
if interval is not None:
|
|
132
|
+
cmd += ["-ti", str(interval)]
|
|
133
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
134
|
+
tmp = dst.with_name(f".{dst.name}.tmp") # an existing dst is only replaced on success
|
|
135
|
+
proc = subprocess.run([*cmd, "-o", str(tmp), str(src)], check=False, capture_output=True)
|
|
136
|
+
ok = proc.returncode == 0 and tmp.exists() and tmp.stat().st_size > 0
|
|
137
|
+
if ok:
|
|
138
|
+
tmp.replace(dst)
|
|
139
|
+
else:
|
|
140
|
+
tmp.unlink(missing_ok=True)
|
|
141
|
+
return ok
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def trim_rinex(
|
|
145
|
+
src: Path | str,
|
|
146
|
+
dst: Path | str,
|
|
147
|
+
start: pd.Timestamp | None = None,
|
|
148
|
+
end: pd.Timestamp | None = None,
|
|
149
|
+
interval: float | None = None,
|
|
150
|
+
use_convbin: bool = False,
|
|
151
|
+
marker: str | None = None,
|
|
152
|
+
) -> Path:
|
|
153
|
+
"""Write a RINEX 3 observation file trimmed to [start, end] and decimated.
|
|
154
|
+
|
|
155
|
+
The pure-Python path drops event records without a timestamp, updates the `INTERVAL`,
|
|
156
|
+
`TIME OF FIRST OBS` and `TIME OF LAST OBS` header lines and copies the rest of the
|
|
157
|
+
header unchanged. RTKLIB `convbin` writes its own header instead, drops QZSS, and
|
|
158
|
+
excludes the epoch at `end`; it is run with `end` + 1 s, so with data above 1 Hz and no
|
|
159
|
+
`interval` it may keep epochs up to 1 s after `end`.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
src: Input RINEX 3 observation file.
|
|
163
|
+
dst: Output file; parent directories are created.
|
|
164
|
+
start: First epoch to keep, GPST. None for the start of the file.
|
|
165
|
+
end: Last epoch to keep, GPST. None for the end of the file.
|
|
166
|
+
interval: Keep the epochs whose time within the day is a multiple of this [s].
|
|
167
|
+
None keeps every epoch.
|
|
168
|
+
use_convbin: Try RTKLIB `convbin` first (it must be on PATH) and fall back to the
|
|
169
|
+
Python implementation if it is missing or fails.
|
|
170
|
+
marker: New `MARKER NAME` for the header. None keeps the original. Ignored by
|
|
171
|
+
`convbin`, which writes its own header.
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
The output path.
|
|
175
|
+
|
|
176
|
+
Raises:
|
|
177
|
+
ValueError: If no epoch is kept.
|
|
178
|
+
"""
|
|
179
|
+
if use_convbin and _convbin_trim(Path(src), Path(dst), start, end, interval):
|
|
180
|
+
return Path(dst)
|
|
181
|
+
step_ms = None if interval is None else round(interval * 1000)
|
|
182
|
+
header: list[str] = []
|
|
183
|
+
body: list[str] = []
|
|
184
|
+
epochs: list[pd.Timestamp] = []
|
|
185
|
+
keep = False
|
|
186
|
+
with Path(src).open() as f:
|
|
187
|
+
in_header = True
|
|
188
|
+
for line in f:
|
|
189
|
+
if in_header:
|
|
190
|
+
header.append(line)
|
|
191
|
+
in_header = "END OF HEADER" not in line
|
|
192
|
+
continue
|
|
193
|
+
if line.startswith(">"):
|
|
194
|
+
try:
|
|
195
|
+
t = _epoch_time(line[1:])
|
|
196
|
+
except ValueError: # event record: dropped together with its lines
|
|
197
|
+
keep = False
|
|
198
|
+
continue
|
|
199
|
+
ms = round((t - t.normalize()).total_seconds() * 1000)
|
|
200
|
+
keep = (
|
|
201
|
+
(start is None or t >= start)
|
|
202
|
+
and (end is None or t <= end)
|
|
203
|
+
and (step_ms is None or ms % step_ms == 0)
|
|
204
|
+
)
|
|
205
|
+
if keep:
|
|
206
|
+
epochs.append(t)
|
|
207
|
+
if keep:
|
|
208
|
+
body.append(line)
|
|
209
|
+
if not epochs:
|
|
210
|
+
raise ValueError(f"{src}: no epochs kept")
|
|
211
|
+
|
|
212
|
+
def field(value: str, label: str) -> str:
|
|
213
|
+
return f"{value:<60}{label}\n"
|
|
214
|
+
|
|
215
|
+
for i, line in enumerate(header):
|
|
216
|
+
if interval is not None and "INTERVAL" in line:
|
|
217
|
+
header[i] = field(f"{interval:10.3f}", "INTERVAL")
|
|
218
|
+
elif marker is not None and "MARKER NAME" in line:
|
|
219
|
+
header[i] = field(marker, "MARKER NAME")
|
|
220
|
+
elif "TIME OF FIRST OBS" in line:
|
|
221
|
+
header[i] = field(_header_time(epochs[0]), "TIME OF FIRST OBS")
|
|
222
|
+
elif "TIME OF LAST OBS" in line:
|
|
223
|
+
header[i] = field(_header_time(epochs[-1]), "TIME OF LAST OBS")
|
|
224
|
+
dst = Path(dst)
|
|
225
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
226
|
+
dst.write_text("".join(header + body))
|
|
227
|
+
return dst
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# =============================================================================
|
|
231
|
+
# Quality inspection: satellite visibility, gaps, summary
|
|
232
|
+
# =============================================================================
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def read_visibility(obs: Path | str) -> pd.DataFrame:
|
|
236
|
+
"""Satellites tracked at each epoch of a RINEX 3 observation file.
|
|
237
|
+
|
|
238
|
+
A satellite counts as tracked if its record has at least one non-blank observation.
|
|
239
|
+
Event records (epoch flag != 0) are skipped and counted in `attrs["n_events"]`.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
obs: RINEX 3 observation file.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
DataFrame with columns `t` (epoch, as written in the file) and `sat` (e.g. "G05"),
|
|
246
|
+
one row per tracked satellite and epoch. `attrs` holds `marker` and `n_events`.
|
|
247
|
+
"""
|
|
248
|
+
times: list[pd.Timestamp] = []
|
|
249
|
+
sats: list[str] = []
|
|
250
|
+
marker, n_events = Path(obs).stem, 0
|
|
251
|
+
t = None
|
|
252
|
+
with Path(obs).open() as f:
|
|
253
|
+
for line in f:
|
|
254
|
+
if "END OF HEADER" in line:
|
|
255
|
+
break
|
|
256
|
+
if "MARKER NAME" in line[60:] and line[:60].strip():
|
|
257
|
+
marker = line[:60].strip()
|
|
258
|
+
for line in f:
|
|
259
|
+
if line.startswith(">"):
|
|
260
|
+
if line[31:32] != "0":
|
|
261
|
+
t = None
|
|
262
|
+
n_events += 1
|
|
263
|
+
continue
|
|
264
|
+
t = _epoch_time(line[1:])
|
|
265
|
+
elif t is not None and line[:1] in SYSTEMS and line[3:].strip():
|
|
266
|
+
times.append(t)
|
|
267
|
+
sats.append(line[:3])
|
|
268
|
+
df = pd.DataFrame({"t": times, "sat": sats})
|
|
269
|
+
df.attrs.update(marker=marker, n_events=n_events)
|
|
270
|
+
return df
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def obs_summary(obs: Path | str, gap_factor: float = 1.5) -> dict[str, object]:
|
|
274
|
+
"""Summary of a RINEX 3 observation file: time span, sampling, gaps and satellites.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
obs: RINEX 3 observation file.
|
|
278
|
+
gap_factor: An epoch spacing larger than `gap_factor` times the median interval
|
|
279
|
+
is a gap.
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
Dict with `marker`, `first`, `last`, `duration_s`, `interval_s`, `n_epochs`,
|
|
283
|
+
`n_gaps`, `max_gap_s`, `n_events`, `mean_sats` (per epoch) and `n_sats` (unique
|
|
284
|
+
satellites per system).
|
|
285
|
+
|
|
286
|
+
Raises:
|
|
287
|
+
ValueError: If the file has no observation epochs.
|
|
288
|
+
"""
|
|
289
|
+
vis = read_visibility(obs)
|
|
290
|
+
if vis.empty:
|
|
291
|
+
raise ValueError(f"no RINEX 3 observations found in {obs}")
|
|
292
|
+
epochs = pd.Series(vis.t.unique())
|
|
293
|
+
step = epochs.diff().dropna().dt.total_seconds()
|
|
294
|
+
interval = float(step.median()) if len(step) else float("nan")
|
|
295
|
+
gaps = step[step > gap_factor * interval] if len(step) else step
|
|
296
|
+
return {
|
|
297
|
+
"marker": vis.attrs["marker"],
|
|
298
|
+
"first": epochs.iloc[0],
|
|
299
|
+
"last": epochs.iloc[-1],
|
|
300
|
+
"duration_s": float((epochs.iloc[-1] - epochs.iloc[0]).total_seconds()),
|
|
301
|
+
"interval_s": interval,
|
|
302
|
+
"n_epochs": len(epochs),
|
|
303
|
+
"n_gaps": len(gaps),
|
|
304
|
+
"max_gap_s": float(gaps.max()) if len(gaps) else 0.0,
|
|
305
|
+
"n_events": vis.attrs["n_events"],
|
|
306
|
+
"mean_sats": float(len(vis) / len(epochs)),
|
|
307
|
+
"n_sats": vis.groupby(vis.sat.str[0]).sat.nunique().to_dict(),
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def find_rinex_obs(paths: Sequence[Path | str]) -> list[Path]:
|
|
312
|
+
"""RINEX observation files from a mix of files and folders.
|
|
313
|
+
|
|
314
|
+
Files are kept as given. A folder is replaced by the observation files directly inside it
|
|
315
|
+
(names ending in `.YYo`, `.YYO` or `.obs`, sorted by name; subfolders are not searched), so
|
|
316
|
+
navigation files next to them are ignored.
|
|
317
|
+
|
|
318
|
+
Args:
|
|
319
|
+
paths: Observation files and/or folders.
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
The observation files, in the order of `paths`.
|
|
323
|
+
"""
|
|
324
|
+
files: list[Path] = []
|
|
325
|
+
for p in map(Path, paths):
|
|
326
|
+
if p.is_dir():
|
|
327
|
+
files += sorted(f for f in p.iterdir() if OBS_SUFFIX.search(f.name))
|
|
328
|
+
else:
|
|
329
|
+
files.append(p)
|
|
330
|
+
return files
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def read_obs_types(obs: Path | str) -> dict[str, list[str]]:
|
|
334
|
+
"""Observation codes of each satellite system, in file order (`SYS / # / OBS TYPES`).
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
obs: RINEX 3 observation file.
|
|
338
|
+
|
|
339
|
+
Returns:
|
|
340
|
+
{system letter: [codes]}, e.g. {"G": ["C1C", "L1C", "D1C", "S1C", ...]}.
|
|
341
|
+
"""
|
|
342
|
+
types: dict[str, list[str]] = {}
|
|
343
|
+
system = ""
|
|
344
|
+
with Path(obs).open() as f:
|
|
345
|
+
for line in f:
|
|
346
|
+
if "END OF HEADER" in line:
|
|
347
|
+
break
|
|
348
|
+
if line[60:].startswith("SYS / # / OBS TYPES"):
|
|
349
|
+
if line[0] != " ": # first line of a system; the others continue its list
|
|
350
|
+
system = line[0]
|
|
351
|
+
types[system] = []
|
|
352
|
+
types[system] += line[7:60].split()
|
|
353
|
+
return types
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def satellite_stats(obs: Path | str) -> pd.DataFrame:
|
|
357
|
+
"""Tracking statistics of every satellite of a RINEX 3 observation file.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
obs: RINEX 3 observation file.
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
DataFrame indexed by satellite with first and last epoch tracked, `n_epochs` (epochs
|
|
364
|
+
tracked), `of_file` (n_epochs / epochs of the file) and `of_arc` (n_epochs / epochs
|
|
365
|
+
between its first and last, which is below 1 if the satellite was lost and regained).
|
|
366
|
+
"""
|
|
367
|
+
vis = read_visibility(obs)
|
|
368
|
+
epochs = np.sort(vis.t.unique())
|
|
369
|
+
g = vis.groupby("sat").t
|
|
370
|
+
df = pd.DataFrame({"first": g.min(), "last": g.max(), "n_epochs": g.size()})
|
|
371
|
+
arc = np.searchsorted(epochs, df["last"].to_numpy(), "right") - np.searchsorted(
|
|
372
|
+
epochs, df["first"].to_numpy(), "left"
|
|
373
|
+
)
|
|
374
|
+
df["of_file"] = df.n_epochs / len(epochs)
|
|
375
|
+
df["of_arc"] = df.n_epochs / arc
|
|
376
|
+
return df
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def signal_stats(obs: Path | str) -> pd.DataFrame:
|
|
380
|
+
"""Completeness and signal strength of every observation code of each system.
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
obs: RINEX 3 observation file.
|
|
384
|
+
|
|
385
|
+
Returns:
|
|
386
|
+
DataFrame indexed by (system, code) with `n_obs` (non-blank values),
|
|
387
|
+
`completeness` (n_obs / satellite records of that system: how often the satellite
|
|
388
|
+
has the code when it is tracked) and `mean_snr` (mean of the S codes in the unit of
|
|
389
|
+
the file, normally dB-Hz; NaN for the other codes).
|
|
390
|
+
"""
|
|
391
|
+
types = read_obs_types(obs)
|
|
392
|
+
n_rec = dict.fromkeys(types, 0)
|
|
393
|
+
n_obs = {(s, c): 0 for s, codes in types.items() for c in codes}
|
|
394
|
+
snr = {k: 0.0 for k in n_obs if k[1][0] == "S"}
|
|
395
|
+
valid = False
|
|
396
|
+
with Path(obs).open() as f:
|
|
397
|
+
for line in f:
|
|
398
|
+
if "END OF HEADER" in line:
|
|
399
|
+
break
|
|
400
|
+
for line in f:
|
|
401
|
+
if line.startswith(">"):
|
|
402
|
+
valid = line[31:32] == "0" # skip event records
|
|
403
|
+
elif valid and line[:1] in types:
|
|
404
|
+
s = line[0]
|
|
405
|
+
n_rec[s] += 1
|
|
406
|
+
for k, code in enumerate(types[s]):
|
|
407
|
+
field = line[3 + 16 * k : 17 + 16 * k]
|
|
408
|
+
if field.strip():
|
|
409
|
+
n_obs[s, code] += 1
|
|
410
|
+
if code[0] == "S":
|
|
411
|
+
snr[s, code] += float(field)
|
|
412
|
+
rows = [
|
|
413
|
+
{
|
|
414
|
+
"system": s,
|
|
415
|
+
"code": c,
|
|
416
|
+
"n_obs": n,
|
|
417
|
+
"completeness": n / n_rec[s] if n_rec[s] else np.nan,
|
|
418
|
+
"mean_snr": snr[s, c] / n if (s, c) in snr and n else np.nan,
|
|
419
|
+
}
|
|
420
|
+
for (s, c), n in n_obs.items()
|
|
421
|
+
]
|
|
422
|
+
return pd.DataFrame(rows).set_index(["system", "code"])
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def plot_visibility(
|
|
426
|
+
obs_files: Sequence[Path | str], out: Path | str | None = None, dpi: int = 150
|
|
427
|
+
) -> Figure:
|
|
428
|
+
"""Satellite visibility plot (as in RTKPLOT), one panel per observation file.
|
|
429
|
+
|
|
430
|
+
Needs matplotlib. Colours: GPS blue, GLONASS red, Galileo green, BeiDou orange.
|
|
431
|
+
|
|
432
|
+
Args:
|
|
433
|
+
obs_files: RINEX 3 observation files.
|
|
434
|
+
out: If given, the figure is also saved to this path.
|
|
435
|
+
dpi: Resolution of the saved figure.
|
|
436
|
+
|
|
437
|
+
Returns:
|
|
438
|
+
The matplotlib figure.
|
|
439
|
+
"""
|
|
440
|
+
try:
|
|
441
|
+
import matplotlib.dates as mdates
|
|
442
|
+
from matplotlib.figure import Figure
|
|
443
|
+
except ImportError as e: # pragma: no cover
|
|
444
|
+
raise ImportError("plot_visibility needs matplotlib: pip install 'gnsskit[plot]'") from e
|
|
445
|
+
|
|
446
|
+
colors = {"G": "tab:blue", "R": "tab:red", "E": "tab:green", "C": "tab:orange",
|
|
447
|
+
"J": "tab:purple", "S": "tab:gray", "I": "tab:brown"} # fmt: skip
|
|
448
|
+
fig = Figure(figsize=(12, 3.5 * len(obs_files))) # no pyplot: no GUI backend needed
|
|
449
|
+
axes = fig.subplots(len(obs_files), 1, squeeze=False)
|
|
450
|
+
for ax, path in zip(axes[:, 0], obs_files):
|
|
451
|
+
vis = read_visibility(path)
|
|
452
|
+
sats = sorted(vis.sat.unique())
|
|
453
|
+
for i, sat in enumerate(sats):
|
|
454
|
+
t = vis.t[vis.sat == sat]
|
|
455
|
+
ax.plot(t, [i] * len(t), "|", ms=3, color=colors[sat[0]])
|
|
456
|
+
ax.set_yticks(range(len(sats)), sats, fontsize=5)
|
|
457
|
+
ax.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M"))
|
|
458
|
+
span = f"{vis.t.min():%H:%M:%S} - {vis.t.max():%H:%M:%S}" if len(vis) else "no data"
|
|
459
|
+
ax.set_title(
|
|
460
|
+
f"{Path(path).name} [{vis.attrs['marker']}] {span} ({len(sats)} sats)",
|
|
461
|
+
fontsize=9,
|
|
462
|
+
)
|
|
463
|
+
ax.grid(alpha=0.3)
|
|
464
|
+
fig.tight_layout()
|
|
465
|
+
if out is not None:
|
|
466
|
+
fig.savefig(out, dpi=dpi)
|
|
467
|
+
return fig
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Thin wrappers around RTKLIB: processing options, running rnx2rtkp, reading .pos output."""
|
|
2
|
+
|
|
3
|
+
from .config import RtkConfig, navsys_mask
|
|
4
|
+
from .pos import pos_covariance, read_header, read_pos, read_ref_pos
|
|
5
|
+
from .runner import build_command, find_rnx2rtkp, run_rnx2rtkp
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"RtkConfig",
|
|
9
|
+
"build_command",
|
|
10
|
+
"find_rnx2rtkp",
|
|
11
|
+
"navsys_mask",
|
|
12
|
+
"pos_covariance",
|
|
13
|
+
"read_header",
|
|
14
|
+
"read_pos",
|
|
15
|
+
"read_ref_pos",
|
|
16
|
+
"run_rnx2rtkp",
|
|
17
|
+
]
|