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,323 @@
1
+ """Static GNSS baselines: data model, extraction from RTKLIB solutions, processing, storage.
2
+
3
+ A baseline is the ECEF vector base -> rover with its 3x3 covariance and the quality
4
+ indicators of the solution. `BaselineResult` is independent of RTKLIB: the RTKLIB-specific
5
+ part is only how it is built (`baseline_from_solution`, `extract_baseline`) and computed
6
+ (`process_baseline`), so other solvers can produce the same objects for `gnsskit.static.network`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Hashable, Mapping, Sequence
12
+ from dataclasses import dataclass, replace
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ import numpy as np
17
+ import pandas as pd
18
+
19
+ from ..rtklib.config import RtkConfig
20
+ from ..rtklib.pos import POS_COORD_COLS, pos_covariance, read_pos, read_ref_pos
21
+ from ..rtklib.runner import run_rnx2rtkp
22
+
23
+ # =============================================================================
24
+ # Data model
25
+ # =============================================================================
26
+
27
+ # Upper-triangular entries of the 3x3 covariance -> suffix of the table column
28
+ _COV_ENTRIES = {
29
+ "xx": (0, 0),
30
+ "xy": (0, 1),
31
+ "xz": (0, 2),
32
+ "yy": (1, 1),
33
+ "yz": (1, 2),
34
+ "zz": (2, 2),
35
+ }
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class BaselineResult:
40
+ """Final static solution of one baseline: the vector base -> rover (ECEF) + quality."""
41
+
42
+ rover: str
43
+ base: str
44
+ session: str
45
+ epoch: pd.Timestamp # epoch of the solution (GPST)
46
+ dxyz: np.ndarray # (3,) m, X_rover - X_base
47
+ cov: np.ndarray # (3, 3) m^2, formal covariance of dxyz
48
+ q: int # 1 = fixed, 2 = float
49
+ ns: int # satellites used
50
+ ratio: float # ambiguity validation ratio of the final epoch
51
+ pos_file: Path | None = None
52
+ # Statistics over all the epochs of the solution (NaN if unknown); with RTKLIB
53
+ # out-solstatic=single there is only the final epoch, so they say nothing
54
+ n_epochs: int = 0 # solution epochs
55
+ fix_pct: float = float("nan") # % of fixed epochs (Q == 1)
56
+ ratio_median: float = float("nan") # median ratio over the epochs
57
+
58
+ @property
59
+ def length(self) -> float:
60
+ """3D baseline length [m]."""
61
+ return float(np.linalg.norm(self.dxyz))
62
+
63
+ def is_good(self, min_ratio: float = 3.0, min_sats: int = 6) -> bool:
64
+ """Whether the solution is fixed, with enough satellites and a sufficient ratio.
65
+
66
+ Args:
67
+ min_ratio: Minimum ambiguity validation ratio.
68
+ min_sats: Minimum number of satellites used.
69
+
70
+ Returns:
71
+ True if Q is 1 (fixed), ratio >= `min_ratio` and ns >= `min_sats`.
72
+ """
73
+ return self.q == 1 and self.ratio >= min_ratio and self.ns >= min_sats
74
+
75
+ def to_row(self) -> dict[str, Any]:
76
+ """Flat dict of the baseline: one table row (see `baselines_to_df`)."""
77
+ return {
78
+ "session": self.session,
79
+ "base": self.base,
80
+ "rover": self.rover,
81
+ "epoch": self.epoch,
82
+ "dx": self.dxyz[0],
83
+ "dy": self.dxyz[1],
84
+ "dz": self.dxyz[2],
85
+ **{f"cov_{k}": self.cov[i, j] for k, (i, j) in _COV_ENTRIES.items()},
86
+ "length": self.length,
87
+ "Q": self.q,
88
+ "ns": self.ns,
89
+ "ratio": self.ratio,
90
+ "n_epochs": self.n_epochs,
91
+ "fix_pct": self.fix_pct,
92
+ "ratio_median": self.ratio_median,
93
+ "pos_file": str(self.pos_file) if self.pos_file else "",
94
+ }
95
+
96
+ @classmethod
97
+ def from_row(cls, row: Mapping[Hashable, Any]) -> BaselineResult:
98
+ """Inverse of `to_row`; `length` is recomputed, not read.
99
+
100
+ Tables without the epoch statistics (older files) give n_epochs 0 and NaN.
101
+ """
102
+ cov = np.zeros((3, 3))
103
+ for k, (i, j) in _COV_ENTRIES.items():
104
+ cov[i, j] = cov[j, i] = row[f"cov_{k}"]
105
+ session, pos_file = row.get("session"), row.get("pos_file")
106
+ return cls(
107
+ rover=str(row["rover"]),
108
+ base=str(row["base"]),
109
+ session=session if isinstance(session, str) else "",
110
+ epoch=_timestamp(row["epoch"]),
111
+ dxyz=np.array([row["dx"], row["dy"], row["dz"]], dtype=float),
112
+ cov=cov,
113
+ q=int(row["Q"]),
114
+ ns=int(row["ns"]),
115
+ ratio=float(row["ratio"]),
116
+ pos_file=Path(pos_file) if isinstance(pos_file, str) and pos_file else None,
117
+ n_epochs=int(_float(row.get("n_epochs"), 0.0)),
118
+ fix_pct=_float(row.get("fix_pct")),
119
+ ratio_median=_float(row.get("ratio_median")),
120
+ )
121
+
122
+
123
+ def _float(value: Any, missing: float = float("nan")) -> float:
124
+ """Float from a table cell; `missing` for an absent or empty cell."""
125
+ return missing if value is None or value == "" or pd.isna(value) else float(value)
126
+
127
+
128
+ def _timestamp(value: Any) -> pd.Timestamp:
129
+ """Timestamp from a value, rejecting missing epochs (NaT)."""
130
+ t = pd.Timestamp(value)
131
+ if not isinstance(t, pd.Timestamp):
132
+ raise ValueError(f"invalid baseline epoch: {value!r}")
133
+ return t
134
+
135
+
136
+ # =============================================================================
137
+ # Construction from RTKLIB solutions
138
+ # =============================================================================
139
+
140
+
141
+ def baseline_from_solution(
142
+ sol: pd.Series | Mapping[str, Any],
143
+ base_xyz: Sequence[float] | np.ndarray,
144
+ rover: str,
145
+ base: str,
146
+ session: str = "",
147
+ pos_file: Path | str | None = None,
148
+ ) -> BaselineResult:
149
+ """Baseline from one epoch of an xyz `read_pos` table and the base position.
150
+
151
+ Args:
152
+ sol: One epoch (row) of an xyz `read_pos` table.
153
+ base_xyz: (3,) ECEF base coordinates [m] used in the processing.
154
+ rover: Rover point name.
155
+ base: Base point name.
156
+ session: Session label stored in the result.
157
+ pos_file: Source .pos file, stored for reference.
158
+
159
+ Returns:
160
+ The baseline rover - base. Its covariance is the formal covariance of the rover
161
+ solution, which is also that of the baseline because RTKLIB holds the base fixed;
162
+ it is optimistic (see `gnsskit.static.network.K_S`).
163
+ """
164
+ xyz = np.array([sol[c] for c in POS_COORD_COLS["xyz"]], dtype=float)
165
+ return BaselineResult(
166
+ rover=rover,
167
+ base=base,
168
+ session=session,
169
+ epoch=_timestamp(sol["t"]),
170
+ dxyz=xyz - np.asarray(base_xyz, dtype=float),
171
+ cov=pos_covariance(sol, "xyz"),
172
+ q=int(sol["Q"]),
173
+ ns=int(sol["ns"]),
174
+ ratio=float(sol["ratio"]),
175
+ pos_file=Path(pos_file) if pos_file is not None else None,
176
+ )
177
+
178
+
179
+ def extract_baseline(
180
+ pos_file: Path | str, rover: str, base: str, session: str = ""
181
+ ) -> BaselineResult | None:
182
+ """Final-epoch baseline from an xyz .pos file, with statistics over all its epochs.
183
+
184
+ The vector is the last solution minus the base position in the `% ref pos` header, so the
185
+ file needs `out-solformat=xyz` and `out-outhead=on`. The ratio of the last epoch alone is
186
+ a noisy indicator: `fix_pct` and `ratio_median` describe the whole session, if the file
187
+ has all the epochs (RTKLIB out-solstatic=all, the default).
188
+
189
+ Args:
190
+ pos_file: RTKLIB .pos file.
191
+ rover: Rover point name.
192
+ base: Base point name.
193
+ session: Session label stored in the result.
194
+
195
+ Returns:
196
+ The baseline, or None if the file has no solution epoch.
197
+
198
+ Raises:
199
+ ValueError: If the file is not in xyz format or has no `% ref pos` header line.
200
+ """
201
+ pos = read_pos(pos_file)
202
+ if pos.attrs["format"] != "xyz":
203
+ raise ValueError(f"{pos_file}: need out-solformat=xyz to extract a baseline")
204
+ if pos.empty:
205
+ return None
206
+ bl = baseline_from_solution(
207
+ pos.iloc[-1], read_ref_pos(pos_file), rover, base, session, pos_file
208
+ )
209
+ return replace(
210
+ bl,
211
+ n_epochs=len(pos),
212
+ fix_pct=100.0 * float((pos["Q"] == 1).mean()),
213
+ ratio_median=float(pos["ratio"].median()),
214
+ )
215
+
216
+
217
+ # =============================================================================
218
+ # Processing with rnx2rtkp
219
+ # =============================================================================
220
+
221
+
222
+ def process_baseline(
223
+ rover: str,
224
+ base: str,
225
+ rover_obs: Path | str,
226
+ base_obs: Path | str,
227
+ nav: Sequence[Path | str],
228
+ config: RtkConfig,
229
+ out: Path | str,
230
+ start: pd.Timestamp | None = None,
231
+ end: pd.Timestamp | None = None,
232
+ tint: float | None = None,
233
+ session: str = "",
234
+ exe: str = "rnx2rtkp",
235
+ ) -> BaselineResult | None:
236
+ """Run rnx2rtkp for one baseline, keep the .pos file and extract the result.
237
+
238
+ Args:
239
+ rover: Rover point name.
240
+ base: Base point name.
241
+ rover_obs: Rover RINEX observation file.
242
+ base_obs: Base RINEX observation file.
243
+ nav: Navigation, precise orbit and clock files (see `run_rnx2rtkp`).
244
+ config: RTKLIB options; needs `solformat="xyz"`, `outhead=True` and a base position
245
+ (e.g. `base_postype="rinexhead"`), which is written in the `% ref pos` header.
246
+ out: Output .pos file, kept on disk.
247
+ start: Processing start time, GPST.
248
+ end: Processing end time, GPST.
249
+ tint: Processing interval [s]. None for every epoch.
250
+ session: Session label stored in the result.
251
+ exe: rnx2rtkp executable name or path.
252
+
253
+ Returns:
254
+ The baseline, or None if rnx2rtkp produced no solution.
255
+
256
+ Raises:
257
+ FileNotFoundError: If the executable is not found.
258
+ RuntimeError: If rnx2rtkp exits with a non-zero code.
259
+ ValueError: If the .pos file is not in xyz format.
260
+ """
261
+ run_rnx2rtkp(rover_obs, base_obs, nav, config, out, start=start, end=end, tint=tint, exe=exe)
262
+ return extract_baseline(out, rover, base, session)
263
+
264
+
265
+ # =============================================================================
266
+ # Tables and storage
267
+ # =============================================================================
268
+
269
+
270
+ def baselines_to_df(results: Sequence[BaselineResult]) -> pd.DataFrame:
271
+ """Tabulate baselines, one row per baseline.
272
+
273
+ Args:
274
+ results: Baselines to tabulate.
275
+
276
+ Returns:
277
+ DataFrame with columns session, base, rover, epoch, dx, dy, dz, the covariance terms
278
+ cov_xx, cov_xy, cov_xz, cov_yy, cov_yz, cov_zz [m^2], length, Q, ns, ratio (final
279
+ epoch), n_epochs, fix_pct, ratio_median (all epochs) and pos_file (empty string if
280
+ unknown).
281
+ """
282
+ return pd.DataFrame([b.to_row() for b in results])
283
+
284
+
285
+ def df_to_baselines(df: pd.DataFrame) -> list[BaselineResult]:
286
+ """Inverse of `baselines_to_df`.
287
+
288
+ Args:
289
+ df: Table with the columns written by `baselines_to_df`.
290
+
291
+ Returns:
292
+ One `BaselineResult` per row.
293
+ """
294
+ return [BaselineResult.from_row(r) for r in df.to_dict("records")]
295
+
296
+
297
+ def write_baselines(results: Sequence[BaselineResult], path: Path | str) -> Path:
298
+ """Save the baselines as CSV (see `baselines_to_df`).
299
+
300
+ Args:
301
+ results: Baselines to save.
302
+ path: Output CSV file; parent directories are created.
303
+
304
+ Returns:
305
+ The output path.
306
+ """
307
+ path = Path(path)
308
+ path.parent.mkdir(parents=True, exist_ok=True)
309
+ baselines_to_df(results).to_csv(path, index=False)
310
+ return path
311
+
312
+
313
+ def read_baselines(path: Path | str) -> list[BaselineResult]:
314
+ """Load baselines saved with `write_baselines`.
315
+
316
+ Args:
317
+ path: CSV file.
318
+
319
+ Returns:
320
+ The baselines; point and session names stay strings (e.g. '063604').
321
+ """
322
+ df = pd.read_csv(path, dtype={"session": str, "base": str, "rover": str}, keep_default_na=False)
323
+ return df_to_baselines(df)
@@ -0,0 +1,79 @@
1
+ """Loop closures of a baseline network: a quick consistency check before the adjustment.
2
+
3
+ Three points A, B, C joined by the baselines A->B, B->C and C->A close on themselves:
4
+ the sum of the three vectors must be zero. The misclosure shows blunders (wrong fixes,
5
+ wrong antenna heights, wrong point names) without any stochastic model.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections import defaultdict
11
+ from collections.abc import Sequence
12
+ from itertools import combinations, product
13
+
14
+ import numpy as np
15
+ import pandas as pd
16
+
17
+ from ..refsys import ENUFrame
18
+ from .baseline import BaselineResult
19
+
20
+ COLUMNS = ["loop", "baselines", "dx", "dy", "dz", "closure", "perimeter", "ppm"]
21
+
22
+
23
+ def _vector(bl: BaselineResult, start: str, end: str) -> np.ndarray:
24
+ """Vector start -> end of a baseline, reversed if it was measured the other way round."""
25
+ return bl.dxyz if (bl.base, bl.rover) == (start, end) else -bl.dxyz
26
+
27
+
28
+ def loop_closures(
29
+ baselines: Sequence[BaselineResult], origin: Sequence[float] | np.ndarray | None = None
30
+ ) -> pd.DataFrame:
31
+ """Misclosure of every triangle that can be formed with the baselines.
32
+
33
+ A triangle is any three points that are pairwise joined by at least one baseline. If a
34
+ pair has several baselines (repeated sessions), every combination is reported. Each side
35
+ is taken in the direction a -> b -> c -> a, reversing the baselines measured the other
36
+ way round, so the misclosure is the sum of the three vectors.
37
+
38
+ Args:
39
+ baselines: Baselines of the network.
40
+ origin: ECEF coordinates [m] of a point of the network (e.g. the fixed one). If
41
+ given, the misclosure is also expressed in the east, north, up frame there.
42
+
43
+ Returns:
44
+ DataFrame, largest misclosure first, one row per loop: `loop` (points), `baselines`
45
+ ("session:base>rover" of the three sides), the misclosure `dx`, `dy`, `dz` [m], its
46
+ norm `closure` [m], the `perimeter` [m], `ppm` (closure / perimeter) and, with
47
+ `origin`, `dE`, `dN`, `dU` and `dH` (horizontal norm) [m]. Empty if no triangle.
48
+ """
49
+ sides_of: dict[frozenset[str], list[BaselineResult]] = defaultdict(list)
50
+ for bl in baselines:
51
+ sides_of[frozenset((bl.base, bl.rover))].append(bl)
52
+ points = sorted({p for pair in sides_of for p in pair})
53
+
54
+ frame = ENUFrame.from_ecef(origin) if origin is not None else None
55
+
56
+ rows = []
57
+ for a, b, c in combinations(points, 3):
58
+ sides = [(a, b), (b, c), (c, a)]
59
+ if not all(frozenset(s) in sides_of for s in sides):
60
+ continue
61
+ for combo in product(*(sides_of[frozenset(s)] for s in sides)):
62
+ d = np.sum([_vector(bl, *side) for bl, side in zip(combo, sides)], axis=0)
63
+ perimeter = sum(bl.length for bl in combo)
64
+ row = {
65
+ "loop": f"{a}-{b}-{c}",
66
+ "baselines": " ".join(f"{bl.session}:{bl.base}>{bl.rover}" for bl in combo),
67
+ "dx": d[0], "dy": d[1], "dz": d[2],
68
+ "closure": float(np.linalg.norm(d)),
69
+ "perimeter": perimeter,
70
+ "ppm": 1e6 * float(np.linalg.norm(d)) / perimeter,
71
+ } # fmt: skip
72
+ if frame is not None:
73
+ e, n, u = frame.vector_to_enu(d)
74
+ row |= {"dE": e, "dN": n, "dU": u, "dH": float(np.hypot(e, n))}
75
+ rows.append(row)
76
+ cols = COLUMNS + (["dE", "dN", "dU", "dH"] if frame is not None else [])
77
+ return pd.DataFrame(rows, columns=cols).sort_values(
78
+ "closure", ascending=False, ignore_index=True
79
+ )