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/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
+ ]