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/rtklib/config.py
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""Typed configuration of RTKLIB rnx2rtkp: `RtkConfig` and the GNSS system masks."""
|
|
2
|
+
# ruff: noqa: RUF009 (`_opt(...)` in dataclass defaults is intentional)
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from collections.abc import Mapping
|
|
7
|
+
from dataclasses import dataclass, field, fields, replace
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Literal, get_args, get_origin, get_type_hints
|
|
10
|
+
|
|
11
|
+
# Bit values of the `pos1-navsys` mask; the mask is the sum over the chosen systems.
|
|
12
|
+
NAVSYS_BITS = {"G": 1, "S": 2, "R": 4, "E": 8, "J": 16, "C": 32, "I": 64}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def navsys_mask(systems: str) -> int:
|
|
16
|
+
"""RTKLIB navsys bitmask from system letters, e.g. 'GEC' -> 41.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
systems: Letters from G (GPS), S (SBAS), R (GLONASS), E (Galileo), J (QZSS),
|
|
20
|
+
C (BeiDou), I (NavIC); case-insensitive.
|
|
21
|
+
|
|
22
|
+
Returns:
|
|
23
|
+
Sum of the bit values of the chosen systems.
|
|
24
|
+
|
|
25
|
+
Raises:
|
|
26
|
+
ValueError: If a letter is not a known system.
|
|
27
|
+
"""
|
|
28
|
+
try:
|
|
29
|
+
return sum(NAVSYS_BITS[s] for s in set(systems.upper()))
|
|
30
|
+
except KeyError as e:
|
|
31
|
+
raise ValueError(
|
|
32
|
+
f"unknown GNSS system {e}; use letters from {''.join(NAVSYS_BITS)}"
|
|
33
|
+
) from None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Allowed values of the enum-like options (checked in RtkConfig.__post_init__)
|
|
37
|
+
PosMode = Literal["single", "dgps", "kinematic", "static", "static-start", "fixed"]
|
|
38
|
+
Frequency = Literal["l1", "l1+l2", "l1+l2+l5", "l1+l2+l5+l6"]
|
|
39
|
+
SolType = Literal["forward", "backward", "combined", "combined-nophasereset"]
|
|
40
|
+
IonoOpt = Literal["off", "brdc", "sbas", "dual-freq", "est-stec", "ionex-tec", "qzs-brdc"]
|
|
41
|
+
TropOpt = Literal["off", "saas", "sbas", "est-ztd", "est-ztdgrad"]
|
|
42
|
+
SatEph = Literal["brdc", "precise", "brdc+sbas", "brdc+ssrapc", "brdc+ssrcom"]
|
|
43
|
+
ArMode = Literal["off", "continuous", "instantaneous", "fix-and-hold"]
|
|
44
|
+
GloArMode = Literal["off", "on", "autocal", "fix-and-hold"]
|
|
45
|
+
PosType = Literal["llh", "xyz", "single", "posfile", "rinexhead", "rtcm"]
|
|
46
|
+
SolFormat = Literal["llh", "xyz", "enu", "nmea"]
|
|
47
|
+
TimeSys = Literal["gpst", "utc", "jst"]
|
|
48
|
+
OutStat = Literal["off", "state", "residual"]
|
|
49
|
+
TimeForm = Literal["tow", "hms"]
|
|
50
|
+
DegForm = Literal["deg", "dms"]
|
|
51
|
+
HeightType = Literal["ellipsoidal", "geodetic"]
|
|
52
|
+
GeoidModel = Literal["internal", "egm96", "egm08_2.5", "egm08_1", "gsi2000"]
|
|
53
|
+
|
|
54
|
+
# Tuple-valued options expand to three RTKLIB keys (ant1 = rover, ant2 = base)
|
|
55
|
+
Triple = tuple[float, float, float]
|
|
56
|
+
_ROVER_POS = ("ant1-pos1", "ant1-pos2", "ant1-pos3")
|
|
57
|
+
_BASE_POS = ("ant2-pos1", "ant2-pos2", "ant2-pos3")
|
|
58
|
+
_ROVER_ANTDEL = ("ant1-antdele", "ant1-antdeln", "ant1-antdelu")
|
|
59
|
+
_BASE_ANTDEL = ("ant2-antdele", "ant2-antdeln", "ant2-antdelu")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _opt(key: str | tuple[str, ...], default: object = None) -> Any:
|
|
63
|
+
"""Config field mapped to one RTKLIB key, or to several for tuple values."""
|
|
64
|
+
return field(default=default, metadata={"key": key})
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _literal_values(hint: Any) -> tuple[str, ...]:
|
|
68
|
+
"""Allowed strings of a (possibly Optional) Literal type hint; empty otherwise."""
|
|
69
|
+
if get_origin(hint) is Literal:
|
|
70
|
+
return get_args(hint)
|
|
71
|
+
return sum((_literal_values(a) for a in get_args(hint)), ())
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _render(value: object) -> str:
|
|
75
|
+
"""Value as written in a .conf file (bools become on/off)."""
|
|
76
|
+
return "on" if value is True else "off" if value is False else str(value)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class RtkConfig:
|
|
81
|
+
"""Typed rnx2rtkp options. `None` leaves the option at RTKLIB's default (not written).
|
|
82
|
+
|
|
83
|
+
Defaults are a static, dual-frequency, short-baseline setup (GPS+Galileo). Make
|
|
84
|
+
variants with `.updated(elmask=10)`; options without a field go in `extra` as raw
|
|
85
|
+
RTKLIB keys (e.g. `extra={"stats-eratio1": "300"}`) and override the fields.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
# --- processing (pos1) ---
|
|
89
|
+
posmode: PosMode | None = _opt("pos1-posmode", "static")
|
|
90
|
+
frequency: Frequency | None = _opt("pos1-frequency", "l1+l2")
|
|
91
|
+
soltype: SolType | None = _opt("pos1-soltype", "forward")
|
|
92
|
+
elmask: float | None = _opt("pos1-elmask", 15.0) # deg
|
|
93
|
+
dynamics: bool | None = _opt("pos1-dynamics", False) # receiver dynamics model
|
|
94
|
+
ionoopt: IonoOpt | None = _opt("pos1-ionoopt", "brdc")
|
|
95
|
+
tropopt: TropOpt | None = _opt("pos1-tropopt", "saas")
|
|
96
|
+
sateph: SatEph | None = _opt("pos1-sateph", "brdc")
|
|
97
|
+
sat_pcv: bool | None = _opt("pos1-posopt1", True) # satellite antenna PCO/PCV
|
|
98
|
+
rcv_pcv: bool | None = _opt("pos1-posopt2", True) # receiver antenna PCO/PCV
|
|
99
|
+
phase_windup: bool | None = _opt("pos1-posopt3")
|
|
100
|
+
exclude_eclipse: bool | None = _opt("pos1-posopt4") # drop eclipsing satellites
|
|
101
|
+
exclsats: str | None = _opt("pos1-exclsats") # e.g. "G04 +E11" (+ = force include)
|
|
102
|
+
snrmask_r: bool | None = _opt("pos1-snrmask_r") # rover SNR mask
|
|
103
|
+
snrmask_b: bool | None = _opt("pos1-snrmask_b") # base SNR mask
|
|
104
|
+
# System letters (see NAVSYS_BITS) or an integer bitmask
|
|
105
|
+
navsys: str | int | None = _opt("pos1-navsys", "GE")
|
|
106
|
+
|
|
107
|
+
# --- ambiguity resolution and filter (pos2) ---
|
|
108
|
+
armode: ArMode | None = _opt("pos2-armode", "continuous")
|
|
109
|
+
gloarmode: GloArMode | None = _opt("pos2-gloarmode", "off")
|
|
110
|
+
bdsarmode: bool | None = _opt("pos2-bdsarmode")
|
|
111
|
+
arthres: float | None = _opt("pos2-arthres", 3.0) # ratio-test threshold
|
|
112
|
+
arelmask: float | None = _opt("pos2-arelmask", 15.0) # deg, min elevation for AR
|
|
113
|
+
arlockcnt: int | None = _opt("pos2-arlockcnt")
|
|
114
|
+
arminfix: int | None = _opt("pos2-arminfix") # consecutive fixes before hold
|
|
115
|
+
minfixsats: int | None = _opt("pos2-minfixsats", 4)
|
|
116
|
+
minholdsats: int | None = _opt("pos2-minholdsats")
|
|
117
|
+
mindropsats: int | None = _opt("pos2-mindropsats")
|
|
118
|
+
slipthres: float | None = _opt("pos2-slipthres") # m, cycle-slip threshold
|
|
119
|
+
maxage: float | None = _opt("pos2-maxage") # s, max base-rover time difference
|
|
120
|
+
syncsol: bool | None = _opt("pos2-syncsol")
|
|
121
|
+
timeinterp: bool | None = _opt("misc-timeinterp") # interpolate base obs to rover epochs
|
|
122
|
+
|
|
123
|
+
# --- observation and process noise (stats) ---
|
|
124
|
+
eratio1: float | None = _opt("stats-eratio1") # code/phase error ratio, L1
|
|
125
|
+
eratio2: float | None = _opt("stats-eratio2") # code/phase error ratio, L2
|
|
126
|
+
errphase: float | None = _opt("stats-errphase") # m, phase error, constant term
|
|
127
|
+
errphaseel: float | None = _opt("stats-errphaseel") # m, elevation-dependent term
|
|
128
|
+
errphasebl: float | None = _opt("stats-errphasebl") # m/10km, baseline term
|
|
129
|
+
prnbias: float | None = _opt("stats-prnbias") # ambiguity process noise
|
|
130
|
+
prniono: float | None = _opt("stats-prniono")
|
|
131
|
+
prntrop: float | None = _opt("stats-prntrop")
|
|
132
|
+
|
|
133
|
+
# --- antennas and positions ---
|
|
134
|
+
rover_anttype: str | None = _opt("ant1-anttype", "*") # "*" = from RINEX header
|
|
135
|
+
base_anttype: str | None = _opt("ant2-anttype", "*")
|
|
136
|
+
rover_postype: PosType | None = _opt("ant1-postype")
|
|
137
|
+
rover_pos: Triple | None = _opt(_ROVER_POS)
|
|
138
|
+
base_postype: PosType | None = _opt("ant2-postype", "rinexhead")
|
|
139
|
+
base_pos: Triple | None = _opt(_BASE_POS)
|
|
140
|
+
# Marker -> ARP offset (E, N, U) in m; "*" antenna types read it from the RINEX header
|
|
141
|
+
rover_antdel: Triple | None = _opt(_ROVER_ANTDEL)
|
|
142
|
+
base_antdel: Triple | None = _opt(_BASE_ANTDEL)
|
|
143
|
+
satantfile: str | None = _opt("file-satantfile") # ANTEX files
|
|
144
|
+
rcvantfile: str | None = _opt("file-rcvantfile")
|
|
145
|
+
|
|
146
|
+
# --- output ---
|
|
147
|
+
solformat: SolFormat | None = _opt("out-solformat", "xyz")
|
|
148
|
+
outhead: bool | None = _opt("out-outhead", True)
|
|
149
|
+
outopt: bool | None = _opt("out-outopt") # also print the options in the header
|
|
150
|
+
timesys: TimeSys | None = _opt("out-timesys", "gpst")
|
|
151
|
+
timeform: TimeForm | None = _opt("out-timeform")
|
|
152
|
+
timendec: int | None = _opt("out-timendec") # decimals of the time
|
|
153
|
+
degform: DegForm | None = _opt("out-degform")
|
|
154
|
+
height: HeightType | None = _opt("out-height")
|
|
155
|
+
geoid: GeoidModel | None = _opt("out-geoid")
|
|
156
|
+
outstat: OutStat | None = _opt("out-outstat")
|
|
157
|
+
# "on": write the single-point solution at epochs where the RTK solution fails (outage)
|
|
158
|
+
outsingle: Literal["on", "off"] | None = _opt("out-outsingle", "off")
|
|
159
|
+
# static modes: "all" epochs (cumulative solution) or "single", only the final solution (one line, time-tagged at the first epoch of the processed span)
|
|
160
|
+
solstatic: Literal["all", "single"] | None = _opt("out-solstatic")
|
|
161
|
+
|
|
162
|
+
# --- anything else, as raw RTKLIB keys ---
|
|
163
|
+
extra: Mapping[str, str] = field(default_factory=dict)
|
|
164
|
+
|
|
165
|
+
def __post_init__(self) -> None:
|
|
166
|
+
"""Reject values outside a field's Literal choices."""
|
|
167
|
+
hints = get_type_hints(type(self))
|
|
168
|
+
for f in fields(self):
|
|
169
|
+
allowed = _literal_values(hints[f.name])
|
|
170
|
+
value = getattr(self, f.name)
|
|
171
|
+
if allowed and value is not None and value not in allowed:
|
|
172
|
+
raise ValueError(f"{f.name}={value!r}: expected one of {allowed}")
|
|
173
|
+
|
|
174
|
+
# --- construction ---
|
|
175
|
+
|
|
176
|
+
@classmethod
|
|
177
|
+
def empty(cls) -> RtkConfig:
|
|
178
|
+
"""Config with every field unset: only what is added to it is written.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
The empty config.
|
|
182
|
+
"""
|
|
183
|
+
unset: dict[str, Any] = {f.name: None for f in fields(cls) if f.name != "extra"}
|
|
184
|
+
return cls(**unset)
|
|
185
|
+
|
|
186
|
+
@classmethod
|
|
187
|
+
def kinematic(cls) -> RtkConfig:
|
|
188
|
+
"""Defaults for a moving rover: kinematic mode, forward+backward combined.
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
The config.
|
|
192
|
+
"""
|
|
193
|
+
return cls(posmode="kinematic", soltype="combined", timeinterp=True)
|
|
194
|
+
|
|
195
|
+
@classmethod
|
|
196
|
+
def from_file(cls, path: Path | str, defaults: RtkConfig | None = None) -> RtkConfig:
|
|
197
|
+
"""Load an RTKLIB .conf file into `extra`, on top of `defaults`.
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
path: RTKLIB .conf file; `#` comments are ignored.
|
|
201
|
+
defaults: Config to start from. `RtkConfig()` if None; pass `RtkConfig.empty()`
|
|
202
|
+
to get only the keys of the file.
|
|
203
|
+
|
|
204
|
+
Returns:
|
|
205
|
+
The config; keys of the file override the same keys already in `defaults.extra`.
|
|
206
|
+
"""
|
|
207
|
+
extra = {}
|
|
208
|
+
for line in Path(path).read_text().splitlines():
|
|
209
|
+
line = line.split("#", 1)[0].strip()
|
|
210
|
+
if "=" in line:
|
|
211
|
+
key, value = line.split("=", 1)
|
|
212
|
+
extra[key.strip()] = value.strip()
|
|
213
|
+
base = cls() if defaults is None else defaults
|
|
214
|
+
return replace(base, extra={**base.extra, **extra})
|
|
215
|
+
|
|
216
|
+
def updated(self, **changes: Any) -> RtkConfig:
|
|
217
|
+
"""Copy with some fields changed.
|
|
218
|
+
|
|
219
|
+
Raw keys in `extra` (e.g. loaded by `from_file`) that map to a changed field are dropped,
|
|
220
|
+
so the new value wins over them.
|
|
221
|
+
|
|
222
|
+
Args:
|
|
223
|
+
**changes: Field names and new values.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
The new config.
|
|
227
|
+
|
|
228
|
+
Raises:
|
|
229
|
+
ValueError: If a value is not allowed for its field.
|
|
230
|
+
"""
|
|
231
|
+
keys: set[str] = set()
|
|
232
|
+
for f in fields(self):
|
|
233
|
+
if f.name in changes:
|
|
234
|
+
key = f.metadata["key"]
|
|
235
|
+
keys.update([key] if isinstance(key, str) else key)
|
|
236
|
+
extra = {k: v for k, v in self.extra.items() if k not in keys}
|
|
237
|
+
return replace(self, **changes, extra=extra)
|
|
238
|
+
|
|
239
|
+
def with_options(self, options: Mapping[str, object]) -> RtkConfig:
|
|
240
|
+
"""Copy with raw RTKLIB keys set (they override the fields).
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
options: RTKLIB key -> value, e.g. {"pos2-armode": "fix-and-hold"}.
|
|
244
|
+
|
|
245
|
+
Returns:
|
|
246
|
+
The new config.
|
|
247
|
+
"""
|
|
248
|
+
extra = {**self.extra, **{k: _render(v) for k, v in options.items()}}
|
|
249
|
+
return replace(self, extra=extra)
|
|
250
|
+
|
|
251
|
+
def with_base_llh(
|
|
252
|
+
self, lat: float, lon: float, h: float, antenna_h: float | None = None
|
|
253
|
+
) -> RtkConfig:
|
|
254
|
+
"""Copy with the base at the given geodetic position.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
lat: Latitude [deg].
|
|
258
|
+
lon: Longitude [deg].
|
|
259
|
+
h: Ellipsoidal height [m].
|
|
260
|
+
antenna_h: Vertical offset marker -> ARP [m]. It has no effect with
|
|
261
|
+
`base_anttype="*"`, where RTKLIB reads it from the RINEX header.
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
The new config.
|
|
265
|
+
"""
|
|
266
|
+
changes: dict[str, Any] = {"base_postype": "llh", "base_pos": (lat, lon, h)}
|
|
267
|
+
if antenna_h is not None:
|
|
268
|
+
changes["base_antdel"] = (0.0, 0.0, antenna_h)
|
|
269
|
+
return self.updated(**changes)
|
|
270
|
+
|
|
271
|
+
@property
|
|
272
|
+
def has_base_position(self) -> bool:
|
|
273
|
+
"""Whether the base coordinates are set, via `base_pos` or raw `ant2-pos*` keys."""
|
|
274
|
+
return self.base_pos is not None or all(
|
|
275
|
+
k in self.extra for k in ("ant2-pos1", "ant2-pos2", "ant2-pos3")
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# --- output ---
|
|
279
|
+
|
|
280
|
+
@property
|
|
281
|
+
def options(self) -> dict[str, str]:
|
|
282
|
+
"""RTKLIB `key -> value` strings: set fields in declaration order, then `extra`."""
|
|
283
|
+
opts: dict[str, str] = {}
|
|
284
|
+
for f in fields(self):
|
|
285
|
+
value = getattr(self, f.name)
|
|
286
|
+
if f.name == "extra" or value is None:
|
|
287
|
+
continue
|
|
288
|
+
key = f.metadata["key"]
|
|
289
|
+
if isinstance(key, tuple):
|
|
290
|
+
opts.update({k: _render(v) for k, v in zip(key, value, strict=True)})
|
|
291
|
+
elif key == "pos1-navsys" and isinstance(value, str):
|
|
292
|
+
opts[key] = str(navsys_mask(value))
|
|
293
|
+
else:
|
|
294
|
+
opts[key] = _render(value)
|
|
295
|
+
return {**opts, **self.extra}
|
|
296
|
+
|
|
297
|
+
def to_text(self) -> str:
|
|
298
|
+
"""The .conf file content, one `key =value` line per option."""
|
|
299
|
+
return "".join(f"{k:<19}={v}\n" for k, v in self.options.items())
|
|
300
|
+
|
|
301
|
+
def write(self, path: Path | str) -> Path:
|
|
302
|
+
"""Write the .conf file (creating parent directories).
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
path: Output file.
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
The output path.
|
|
309
|
+
"""
|
|
310
|
+
path = Path(path)
|
|
311
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
312
|
+
path.write_text(self.to_text())
|
|
313
|
+
return path
|
gnsskit/rtklib/pos.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Reading RTKLIB .pos solution files."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
from ..refsys import GPST_UTC_OFFSET_S, gps_seconds, utc_to_gpst
|
|
12
|
+
|
|
13
|
+
# .pos output formats (`out-solformat`). Column names for the coordinates and for the
|
|
14
|
+
# 6 covariance terms (3 standard deviations + 3 covariances), as printed by RTKLIB.
|
|
15
|
+
POS_COORD_COLS = {
|
|
16
|
+
"llh": ["lat", "lon", "h"],
|
|
17
|
+
"xyz": ["x", "y", "z"],
|
|
18
|
+
"enu": ["e", "n", "u"],
|
|
19
|
+
}
|
|
20
|
+
POS_SD_COLS = {
|
|
21
|
+
"llh": ["sdn", "sde", "sdu", "sdne", "sdeu", "sdun"],
|
|
22
|
+
"xyz": ["sdx", "sdy", "sdz", "sdxy", "sdyz", "sdzx"],
|
|
23
|
+
"enu": ["sde", "sdn", "sdu", "sden", "sdnu", "sdue"],
|
|
24
|
+
}
|
|
25
|
+
# First coordinate label in the .pos column header -> format
|
|
26
|
+
POS_FORMAT_BY_HEADER = {
|
|
27
|
+
"latitude(deg)": "llh",
|
|
28
|
+
"x-ecef(m)": "xyz",
|
|
29
|
+
"e-baseline(m)": "enu",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _pos_header(path: Path) -> tuple[str, str]:
|
|
34
|
+
"""Time system ('GPST' or 'UTC') and coordinate format ('llh', 'xyz', 'enu') of a .pos.
|
|
35
|
+
|
|
36
|
+
Raises ValueError for unsupported formats (e.g. degrees-minutes-seconds, JST). Without
|
|
37
|
+
a column header line (out-outhead=off) the RTKLIB default is assumed: GPST, llh in deg.
|
|
38
|
+
"""
|
|
39
|
+
with path.open() as f:
|
|
40
|
+
for line in f:
|
|
41
|
+
if not line.startswith("%"):
|
|
42
|
+
break
|
|
43
|
+
tokens = line.lstrip("% ").split()
|
|
44
|
+
# The column header is the only comment line that starts with a time system
|
|
45
|
+
if len(tokens) < 2 or tokens[0].upper() not in ("GPST", "UTC", "JST"):
|
|
46
|
+
continue
|
|
47
|
+
tsys = tokens[0].upper()
|
|
48
|
+
if tsys == "JST":
|
|
49
|
+
raise ValueError(f"{path}: unsupported time system JST, use GPST or UTC")
|
|
50
|
+
if tokens[1] not in POS_FORMAT_BY_HEADER:
|
|
51
|
+
raise ValueError(
|
|
52
|
+
f"{path}: unsupported coordinate format '{tokens[1]}', "
|
|
53
|
+
"use llh (deg), xyz or enu"
|
|
54
|
+
)
|
|
55
|
+
return tsys, POS_FORMAT_BY_HEADER[tokens[1]]
|
|
56
|
+
return "GPST", "llh"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def read_pos(
|
|
60
|
+
paths: Path | str | Sequence[Path | str], leap_s: int = GPST_UTC_OFFSET_S
|
|
61
|
+
) -> pd.DataFrame:
|
|
62
|
+
"""Read one or more RTKLIB .pos files into a time-sorted DataFrame.
|
|
63
|
+
|
|
64
|
+
The coordinate format is detected from the header: llh (lat, lon, h), xyz (x, y, z) or
|
|
65
|
+
enu (e, n, u), with the matching covariance columns (see POS_SD_COLS), and stored in
|
|
66
|
+
`df.attrs["format"]`. Only `out-timeform=hms` is supported. Files with no solution rows
|
|
67
|
+
are skipped.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
paths: One .pos file or a sequence of them.
|
|
71
|
+
leap_s: GPST - UTC [s], used to shift UTC files to GPST.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
DataFrame with the coordinates, Q, ns, the covariance columns, age and ratio, plus
|
|
75
|
+
`t` (GPST timestamp) and `gps_s` (seconds since the GPS epoch). May be empty.
|
|
76
|
+
|
|
77
|
+
Raises:
|
|
78
|
+
ValueError: If the files mix coordinate formats, or use dms or JST.
|
|
79
|
+
"""
|
|
80
|
+
files = [Path(paths)] if isinstance(paths, (str, Path)) else [Path(p) for p in paths]
|
|
81
|
+
headers = {p: _pos_header(p) for p in files}
|
|
82
|
+
formats = {fmt for _, fmt in headers.values()}
|
|
83
|
+
if len(formats) > 1:
|
|
84
|
+
raise ValueError(f"cannot mix .pos coordinate formats: {sorted(formats)}")
|
|
85
|
+
fmt = formats.pop() if formats else "llh"
|
|
86
|
+
names = [
|
|
87
|
+
"date", "time", *POS_COORD_COLS[fmt], "Q", "ns", *POS_SD_COLS[fmt], "age", "ratio"
|
|
88
|
+
] # fmt: skip
|
|
89
|
+
|
|
90
|
+
frames = []
|
|
91
|
+
for p, (tsys, _) in headers.items():
|
|
92
|
+
try:
|
|
93
|
+
df = pd.read_csv(p, comment="%", sep=r"\s+", header=None, names=names)
|
|
94
|
+
except pd.errors.EmptyDataError:
|
|
95
|
+
continue
|
|
96
|
+
df["t"] = pd.to_datetime(df.date + " " + df.time)
|
|
97
|
+
if tsys == "UTC":
|
|
98
|
+
df["t"] = utc_to_gpst(df["t"], leap_s)
|
|
99
|
+
frames.append(df)
|
|
100
|
+
if frames:
|
|
101
|
+
out = pd.concat(frames, ignore_index=True).sort_values("t").reset_index(drop=True)
|
|
102
|
+
out["gps_s"] = gps_seconds(out["t"])
|
|
103
|
+
else:
|
|
104
|
+
out = pd.DataFrame(columns=[*names, "t", "gps_s"])
|
|
105
|
+
out.attrs["format"] = fmt
|
|
106
|
+
return out
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def read_header(path: Path | str) -> list[str]:
|
|
110
|
+
"""The `%` header lines of a .pos file.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
path: RTKLIB .pos file.
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
The header lines, without line endings.
|
|
117
|
+
"""
|
|
118
|
+
return [ln.rstrip("\r\n") for ln in Path(path).read_text().splitlines() if ln.startswith("%")]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def read_ref_pos(path: Path | str) -> np.ndarray:
|
|
122
|
+
"""Base position printed in the `% ref pos` header line of an xyz .pos file.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
path: RTKLIB .pos file written with `out-outhead=on`.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
(3,) ECEF coordinates [m]; for a baseline run these are the base coordinates.
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
ValueError: If there is no `% ref pos` header line.
|
|
132
|
+
"""
|
|
133
|
+
with Path(path).open() as f:
|
|
134
|
+
for line in f:
|
|
135
|
+
if not line.startswith("%"):
|
|
136
|
+
break
|
|
137
|
+
if line.startswith("% ref pos"):
|
|
138
|
+
return np.array(line.split(":", 1)[1].split(), dtype=float)
|
|
139
|
+
raise ValueError(f"{path}: no '% ref pos' header line (needs out-outhead=on)")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def pos_covariance(row: pd.Series | Mapping[str, float], fmt: str = "xyz") -> np.ndarray:
|
|
143
|
+
"""3x3 covariance of one .pos solution epoch from its standard-deviation columns.
|
|
144
|
+
|
|
145
|
+
RTKLIB prints each off-diagonal term as sign(cov) * sqrt(|cov|), so it is squared back
|
|
146
|
+
here. The axes follow the coordinate columns of the format: X, Y, Z for xyz; N, E, U for
|
|
147
|
+
llh; E, N, U for enu.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
row: One epoch of a `read_pos` table (any mapping with the `POS_SD_COLS[fmt]` keys).
|
|
151
|
+
fmt: .pos format, "xyz", "llh" or "enu".
|
|
152
|
+
|
|
153
|
+
Returns:
|
|
154
|
+
(3, 3) covariance [m^2].
|
|
155
|
+
"""
|
|
156
|
+
s1, s2, s3, s12, s23, s31 = (float(row[c]) for c in POS_SD_COLS[fmt])
|
|
157
|
+
c12, c23, c31 = (np.sign(v) * v**2 for v in (s12, s23, s31))
|
|
158
|
+
return np.array([[s1**2, c12, c31], [c12, s2**2, c23], [c31, c23, s3**2]])
|
gnsskit/rtklib/runner.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Running the RTKLIB rnx2rtkp executable: locate it, build the command line, run it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import pandas as pd
|
|
13
|
+
|
|
14
|
+
from .config import RtkConfig
|
|
15
|
+
|
|
16
|
+
RTKLIB_URL = "https://github.com/rtklibexplorer/RTKLIB"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def install_hint() -> str:
|
|
20
|
+
"""How to install rnx2rtkp on the current platform."""
|
|
21
|
+
if sys.platform == "win32":
|
|
22
|
+
return (
|
|
23
|
+
f"Download an RTKLIB release (demo5 recommended: {RTKLIB_URL}/releases), "
|
|
24
|
+
"unzip it, and add the folder containing rnx2rtkp.exe to your PATH, "
|
|
25
|
+
"or pass --exe C:\\path\\to\\rnx2rtkp.exe."
|
|
26
|
+
)
|
|
27
|
+
return (
|
|
28
|
+
f"Build it from source (demo5 recommended):\n"
|
|
29
|
+
f" git clone {RTKLIB_URL}\n"
|
|
30
|
+
" cd RTKLIB/app/consapp/rnx2rtkp/gcc && make && sudo make install\n"
|
|
31
|
+
"or copy the binary to a folder on your PATH. Debian/Ubuntu may also offer an "
|
|
32
|
+
"older build (sudo apt install rtklib). Alternatively pass --exe /path/to/rnx2rtkp."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def find_rnx2rtkp(exe: str = "rnx2rtkp") -> str:
|
|
37
|
+
"""Resolve the rnx2rtkp executable on PATH.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
exe: Executable name or path.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Absolute path of the executable.
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
FileNotFoundError: If the executable is not found (the message has install hints).
|
|
47
|
+
"""
|
|
48
|
+
path = shutil.which(exe)
|
|
49
|
+
if path is None:
|
|
50
|
+
raise FileNotFoundError(f"'{exe}' not found on PATH. {install_hint()}")
|
|
51
|
+
return path
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _fmt_time(t: pd.Timestamp) -> list[str]:
|
|
55
|
+
"""Timestamp as the [date, time] argument pair rnx2rtkp expects."""
|
|
56
|
+
return [t.strftime("%Y/%m/%d"), t.strftime("%H:%M:%S")]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_command(
|
|
60
|
+
conf: Path | str,
|
|
61
|
+
rover: Path | str,
|
|
62
|
+
base: Path | str,
|
|
63
|
+
nav: Sequence[Path | str],
|
|
64
|
+
out: Path | str,
|
|
65
|
+
start: pd.Timestamp | None = None,
|
|
66
|
+
end: pd.Timestamp | None = None,
|
|
67
|
+
exe: str = "rnx2rtkp",
|
|
68
|
+
tint: float | None = None,
|
|
69
|
+
) -> list[str]:
|
|
70
|
+
"""Build the rnx2rtkp command line.
|
|
71
|
+
|
|
72
|
+
Argument order is rover observations, base observations, then navigation files.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
conf: RTKLIB configuration file (`-k`).
|
|
76
|
+
rover: Rover RINEX observation file.
|
|
77
|
+
base: Base RINEX observation file.
|
|
78
|
+
nav: Navigation, precise orbit (SP3) and clock (CLK) files; rnx2rtkp tells them
|
|
79
|
+
apart by file extension.
|
|
80
|
+
out: Output .pos file (`-o`).
|
|
81
|
+
start: Processing start time, GPST (`-ts`). None for the start of the data.
|
|
82
|
+
end: Processing end time, GPST (`-te`). None for the end of the data.
|
|
83
|
+
exe: rnx2rtkp executable.
|
|
84
|
+
tint: Processing interval [s] (`-ti`). None for every epoch.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
The command as a list of arguments.
|
|
88
|
+
"""
|
|
89
|
+
cmd = [exe, "-k", str(conf)]
|
|
90
|
+
if start is not None:
|
|
91
|
+
cmd += ["-ts", *_fmt_time(start)]
|
|
92
|
+
if end is not None:
|
|
93
|
+
cmd += ["-te", *_fmt_time(end)]
|
|
94
|
+
if tint is not None:
|
|
95
|
+
cmd += ["-ti", str(tint)]
|
|
96
|
+
return cmd + ["-o", str(out), str(rover), str(base), *map(str, nav)]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def run_rnx2rtkp(
|
|
100
|
+
rover: Path | str,
|
|
101
|
+
base: Path | str,
|
|
102
|
+
nav: Sequence[Path | str],
|
|
103
|
+
config: RtkConfig,
|
|
104
|
+
out: Path | str,
|
|
105
|
+
*,
|
|
106
|
+
start: pd.Timestamp | None = None,
|
|
107
|
+
end: pd.Timestamp | None = None,
|
|
108
|
+
tint: float | None = None,
|
|
109
|
+
exe: str = "rnx2rtkp",
|
|
110
|
+
) -> Path:
|
|
111
|
+
"""Run rnx2rtkp on a rover/base pair and return the output .pos path.
|
|
112
|
+
|
|
113
|
+
The config is written to a temporary file for the run. A zero exit code does not
|
|
114
|
+
guarantee a solution: with e.g. missing precise products rnx2rtkp may write a .pos file
|
|
115
|
+
with no epochs, which `read_pos` returns as an empty DataFrame.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
rover: Rover RINEX observation file.
|
|
119
|
+
base: Base RINEX observation file.
|
|
120
|
+
nav: Navigation files (broadcast), and precise orbit (SP3) and clock (CLK) files when
|
|
121
|
+
`config.sateph` is "precise".
|
|
122
|
+
config: RTKLIB processing options.
|
|
123
|
+
out: Output .pos file; parent directories are created.
|
|
124
|
+
start: Processing start time, GPST. None for the start of the data.
|
|
125
|
+
end: Processing end time, GPST. None for the end of the data.
|
|
126
|
+
tint: Processing interval [s]; decimates the processing, needed when rover and base
|
|
127
|
+
sampling differ. None for every epoch.
|
|
128
|
+
exe: rnx2rtkp executable name or path.
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
Path of the written .pos file (`out`).
|
|
132
|
+
|
|
133
|
+
Raises:
|
|
134
|
+
FileNotFoundError: If the executable is not found.
|
|
135
|
+
RuntimeError: If rnx2rtkp exits with a non-zero code.
|
|
136
|
+
"""
|
|
137
|
+
exe = find_rnx2rtkp(exe)
|
|
138
|
+
out = Path(out)
|
|
139
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
140
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
141
|
+
conf = config.write(Path(tmp) / "rtk.conf")
|
|
142
|
+
cmd = build_command(conf, rover, base, nav, out, start, end, exe, tint)
|
|
143
|
+
proc = subprocess.run(cmd, check=False, capture_output=True, text=True)
|
|
144
|
+
if proc.returncode != 0:
|
|
145
|
+
raise RuntimeError(f"rnx2rtkp failed ({proc.returncode}): {proc.stderr.strip()[-500:]}")
|
|
146
|
+
return out
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Static processing: single solutions over a window, baselines and their network adjustment.
|
|
2
|
+
|
|
3
|
+
The least-squares adjustment lives in `gnsskit.static.network` and is not imported here,
|
|
4
|
+
because it needs the `network` extra (scipy, pyproj).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .baseline import (
|
|
8
|
+
BaselineResult,
|
|
9
|
+
baseline_from_solution,
|
|
10
|
+
baselines_to_df,
|
|
11
|
+
df_to_baselines,
|
|
12
|
+
extract_baseline,
|
|
13
|
+
process_baseline,
|
|
14
|
+
read_baselines,
|
|
15
|
+
write_baselines,
|
|
16
|
+
)
|
|
17
|
+
from .closure import loop_closures
|
|
18
|
+
from .solve import solve_static
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"BaselineResult",
|
|
22
|
+
"baseline_from_solution",
|
|
23
|
+
"baselines_to_df",
|
|
24
|
+
"df_to_baselines",
|
|
25
|
+
"extract_baseline",
|
|
26
|
+
"loop_closures",
|
|
27
|
+
"process_baseline",
|
|
28
|
+
"read_baselines",
|
|
29
|
+
"solve_static",
|
|
30
|
+
"write_baselines",
|
|
31
|
+
]
|