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.
@@ -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]])
@@ -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
+ ]