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
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
"""Least-squares adjustment of a GNSS baseline network with fixed points.
|
|
2
|
+
|
|
3
|
+
Notation (as in the course notebooks):
|
|
4
|
+
y = A x + b baselines X_rover - X_base = dxyz, fixed points in b
|
|
5
|
+
y0 = y + v, v ~ N(0, Q) observations y0 (n values), parameters x (m values)
|
|
6
|
+
N = A^T Q^-1 A
|
|
7
|
+
X_hat = N^-1 A^T Q^-1 (y0 - b), Y_hat = A X_hat + b, v_hat = y0 - Y_hat
|
|
8
|
+
sigma_v_hat = v_hat^T Q^-1 v_hat / (n - m) a-posteriori variance factor
|
|
9
|
+
Cxx = sigma_v_hat N^-1, Cyy = sigma_v_hat A N^-1 A^T, Cvv = sigma_v_hat (Q - A N^-1 A^T)
|
|
10
|
+
|
|
11
|
+
Stochastic model of a baseline (`stochastic` argument of `adjust_network`):
|
|
12
|
+
"rtklib_scaled": formal RTKLIB covariance times a variance factor k_s (default, see K_S).
|
|
13
|
+
"rtklib": formal covariance of the RTKLIB solution; optimistic (sigma_v_hat >> 1),
|
|
14
|
+
because RTKLIB treats the epochs as independent.
|
|
15
|
+
"apriori": east/north/up errors, uncorrelated, with sd = a + b[ppm] * length.
|
|
16
|
+
|
|
17
|
+
Scaling Q by k_s leaves X_hat, Cxx, Cvv and tau unchanged and divides sigma_v_hat by k_s:
|
|
18
|
+
only the global test depends on it.
|
|
19
|
+
|
|
20
|
+
Layout: the stochastic model (Q) and the functional model (A, b) are built from the
|
|
21
|
+
baselines, then `weighted_least_squares` solves the generic problem, which does not depend
|
|
22
|
+
on GNSS and can be reused for other adjustments.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
from typing import Literal
|
|
30
|
+
|
|
31
|
+
import numpy as np
|
|
32
|
+
import pandas as pd
|
|
33
|
+
|
|
34
|
+
from ..refsys import ENUFrame, ecef_to_llh
|
|
35
|
+
from .baseline import BaselineResult
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
from pyproj import Transformer
|
|
39
|
+
from scipy.stats import chi2
|
|
40
|
+
except ImportError as e: # pragma: no cover
|
|
41
|
+
raise ImportError(
|
|
42
|
+
"gnsskit.static.network needs the 'network' extra: pip install 'gnsskit[network]'"
|
|
43
|
+
) from e
|
|
44
|
+
|
|
45
|
+
# =============================================================================
|
|
46
|
+
# Defaults and thresholds
|
|
47
|
+
# =============================================================================
|
|
48
|
+
|
|
49
|
+
# Default a-priori baseline precision as (constant [m], ppm of the length)
|
|
50
|
+
SIGMA_H = (5e-3, 1.0) # horizontal: 5 mm + 1 ppm
|
|
51
|
+
SIGMA_V = (10e-3, 2.0) # vertical: 10 mm + 2 ppm
|
|
52
|
+
|
|
53
|
+
# Variance factor of the RTKLIB covariance for 5 s epochs. RTKLIB treats the epochs as independent; with errors correlated as exp(-dt / T), the variance of the mean is too small by (1 + rho) / (1 - rho) ~ 2 T / dt. T ~ 60 s (multipath at a static site), dt = 5 s -> 24.
|
|
54
|
+
# For another interval use k_s ~ 2 * 60 / dt, e.g. 120 for 1 s, 8 for 15 s.
|
|
55
|
+
K_S = 25.0
|
|
56
|
+
|
|
57
|
+
# RTKLIB prints standard deviations rounded to 0.1 mm, which can make a 3x3 covariance matrix non-positive-definite; its eigenvalues are floored at this variance before inversion.
|
|
58
|
+
MIN_VARIANCE = 1e-6**2 # m^2, (1 um)^2: far below any GNSS precision, touches only broken matrices
|
|
59
|
+
|
|
60
|
+
TAU_CRITICAL = 3.29 # two-sided 0.1 % level of a standard normal (Baarda data snooping)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# =============================================================================
|
|
64
|
+
# Generic weighted least squares
|
|
65
|
+
# =============================================================================
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True)
|
|
69
|
+
class LsqSolution:
|
|
70
|
+
"""Solution of y0 = A x + b + v, v ~ N(0, sigma^2 Q) (see the module docstring)."""
|
|
71
|
+
|
|
72
|
+
X_hat: np.ndarray # (m,) estimated parameters
|
|
73
|
+
Y_hat: np.ndarray # (n,) adjusted observations
|
|
74
|
+
v_hat: np.ndarray # (n,) residuals
|
|
75
|
+
sigma_v_hat: float # a-posteriori variance factor, expected ~1
|
|
76
|
+
Cxx: np.ndarray # (m, m) covariance of the parameters
|
|
77
|
+
Cyy: np.ndarray # (n, n) covariance of the adjusted observations
|
|
78
|
+
Cvv: np.ndarray # (n, n) covariance of the residuals
|
|
79
|
+
R: np.ndarray # (n, n) redundancy matrix, trace = n - m
|
|
80
|
+
tau: np.ndarray # (n,) normalised residuals |v_i| / sd(v_i)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def weighted_least_squares(
|
|
84
|
+
A: np.ndarray, Q: np.ndarray, y0: np.ndarray, b: np.ndarray | None = None
|
|
85
|
+
) -> LsqSolution:
|
|
86
|
+
"""Weighted least-squares solution of y0 = A x + b + v with observation covariance Q.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
A: (n, m) design matrix, full column rank.
|
|
90
|
+
Q: (n, n) a-priori covariance of the observations (cofactor matrix).
|
|
91
|
+
y0: (n,) observations.
|
|
92
|
+
b: (n,) known terms. None for zeros.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Estimates, their covariances scaled by the a-posteriori variance factor, the
|
|
96
|
+
redundancy matrix and the normalised residuals.
|
|
97
|
+
"""
|
|
98
|
+
b = np.zeros_like(y0) if b is None else b
|
|
99
|
+
n, m = A.shape
|
|
100
|
+
|
|
101
|
+
Q_inv = np.linalg.inv(Q) # weight matrix
|
|
102
|
+
N = A.T @ Q_inv @ A # normal matrix
|
|
103
|
+
X_hat = np.linalg.solve(N, A.T @ Q_inv @ (y0 - b))
|
|
104
|
+
Y_hat = A @ X_hat + b
|
|
105
|
+
v_hat = y0 - Y_hat
|
|
106
|
+
sigma_v_hat = float(v_hat @ Q_inv @ v_hat / (n - m))
|
|
107
|
+
|
|
108
|
+
N_inv = np.linalg.inv(N)
|
|
109
|
+
Q_vv = Q - A @ N_inv @ A.T # cofactor matrix of the residuals
|
|
110
|
+
Cvv = sigma_v_hat * Q_vv
|
|
111
|
+
return LsqSolution(
|
|
112
|
+
X_hat=X_hat,
|
|
113
|
+
Y_hat=Y_hat,
|
|
114
|
+
v_hat=v_hat,
|
|
115
|
+
sigma_v_hat=sigma_v_hat,
|
|
116
|
+
Cxx=sigma_v_hat * N_inv,
|
|
117
|
+
Cyy=sigma_v_hat * (A @ N_inv @ A.T),
|
|
118
|
+
Cvv=Cvv,
|
|
119
|
+
R=Q_vv @ Q_inv,
|
|
120
|
+
tau=np.abs(v_hat) / np.sqrt(np.maximum(np.diag(Cvv), 1e-30)),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# =============================================================================
|
|
125
|
+
# Network adjustment result
|
|
126
|
+
# =============================================================================
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True)
|
|
130
|
+
class Adjustment:
|
|
131
|
+
"""Result of `adjust_network`. Observations are ordered baseline by baseline (dx, dy, dz),
|
|
132
|
+
parameters point by point (X, Y, Z), the points following `unknowns`."""
|
|
133
|
+
|
|
134
|
+
unknowns: list[str] # adjusted (free) points
|
|
135
|
+
fixed: dict[str, np.ndarray] # fixed points, ECEF m
|
|
136
|
+
baselines: list[BaselineResult]
|
|
137
|
+
y0: np.ndarray # (n,) observations
|
|
138
|
+
b: np.ndarray # (n,) known terms (fixed points)
|
|
139
|
+
A: np.ndarray # (n, m) design matrix
|
|
140
|
+
Q: np.ndarray # (n, n) covariance of the observations
|
|
141
|
+
X_hat: np.ndarray # (m,) estimated coordinates
|
|
142
|
+
Y_hat: np.ndarray # (n,) adjusted observations
|
|
143
|
+
v_hat: np.ndarray # (n,) residuals
|
|
144
|
+
sigma_v_hat: float # a-posteriori variance factor, expected ~1
|
|
145
|
+
Cxx: np.ndarray # (m, m) covariance of the parameters
|
|
146
|
+
Cyy: np.ndarray # (n, n) covariance of the adjusted observations
|
|
147
|
+
Cvv: np.ndarray # (n, n) covariance of the residuals
|
|
148
|
+
R: np.ndarray # (n, n) redundancy matrix, trace = n - m
|
|
149
|
+
tau: np.ndarray # (n,) normalised residuals
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def n(self) -> int:
|
|
153
|
+
"""Number of observations."""
|
|
154
|
+
return self.y0.size
|
|
155
|
+
|
|
156
|
+
@property
|
|
157
|
+
def m(self) -> int:
|
|
158
|
+
"""Number of estimated parameters."""
|
|
159
|
+
return self.X_hat.size
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def dof(self) -> int:
|
|
163
|
+
"""Degrees of freedom, n - m."""
|
|
164
|
+
return self.n - self.m
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def xyz(self) -> np.ndarray:
|
|
168
|
+
"""(len(unknowns), 3) adjusted ECEF coordinates."""
|
|
169
|
+
return self.X_hat.reshape(-1, 3)
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def sigma_v_hat_bounds(self) -> tuple[float, float]:
|
|
173
|
+
"""95 % acceptance interval of sigma_v_hat under the chi-square test."""
|
|
174
|
+
lo, hi = chi2.ppf([0.025, 0.975], self.dof) / self.dof
|
|
175
|
+
return float(lo), float(hi)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# =============================================================================
|
|
179
|
+
# Stochastic model: covariance Q of the baselines
|
|
180
|
+
# =============================================================================
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _make_pd(cov: np.ndarray) -> np.ndarray:
|
|
184
|
+
"""Covariance with eigenvalues floored at MIN_VARIANCE; unchanged if already above it."""
|
|
185
|
+
w, vec = np.linalg.eigh(cov)
|
|
186
|
+
if w.min() >= MIN_VARIANCE:
|
|
187
|
+
return cov
|
|
188
|
+
return (vec * np.maximum(w, MIN_VARIANCE)) @ vec.T
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _apriori_block(
|
|
192
|
+
length: float,
|
|
193
|
+
R_enu: np.ndarray,
|
|
194
|
+
sigma_h: tuple[float, float],
|
|
195
|
+
sigma_v: tuple[float, float],
|
|
196
|
+
) -> np.ndarray:
|
|
197
|
+
"""ECEF 3x3 covariance of a baseline: east/north/up errors a + b[ppm] * length."""
|
|
198
|
+
sd_h = sigma_h[0] + sigma_h[1] * 1e-6 * length
|
|
199
|
+
sd_v = sigma_v[0] + sigma_v[1] * 1e-6 * length
|
|
200
|
+
Q_enu = np.diag([sd_h**2, sd_h**2, sd_v**2])
|
|
201
|
+
return R_enu.T @ Q_enu @ R_enu
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _observation_covariance(
|
|
205
|
+
baselines: Sequence[BaselineResult],
|
|
206
|
+
fixed: Mapping[str, np.ndarray],
|
|
207
|
+
stochastic: str,
|
|
208
|
+
sigma_h: tuple[float, float],
|
|
209
|
+
sigma_v: tuple[float, float],
|
|
210
|
+
k_s: float,
|
|
211
|
+
) -> np.ndarray:
|
|
212
|
+
"""Block-diagonal ECEF covariance Q of the observations, one 3x3 block per baseline.
|
|
213
|
+
|
|
214
|
+
"apriori" blocks are built in east, north, up and rotated to ECEF; the RTKLIB blocks are
|
|
215
|
+
already ECEF and only made positive definite (and scaled by `k_s` for "rtklib_scaled").
|
|
216
|
+
"""
|
|
217
|
+
# One east-north-up rotation, taken at the first fixed point, serves a small network
|
|
218
|
+
R_enu = ENUFrame.from_ecef(next(iter(fixed.values()))).rotation
|
|
219
|
+
|
|
220
|
+
Q = np.zeros((3 * len(baselines),) * 2)
|
|
221
|
+
for k, bl in enumerate(baselines):
|
|
222
|
+
if stochastic == "apriori":
|
|
223
|
+
block = _apriori_block(bl.length, R_enu, sigma_h, sigma_v)
|
|
224
|
+
else:
|
|
225
|
+
block = _make_pd(bl.cov) * (k_s if stochastic == "rtklib_scaled" else 1.0)
|
|
226
|
+
Q[3 * k : 3 * k + 3, 3 * k : 3 * k + 3] = block
|
|
227
|
+
return Q
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# =============================================================================
|
|
231
|
+
# Functional model: design matrix A and known terms b
|
|
232
|
+
# =============================================================================
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _design_matrix(
|
|
236
|
+
baselines: Sequence[BaselineResult],
|
|
237
|
+
unknowns: Sequence[str],
|
|
238
|
+
fixed: Mapping[str, np.ndarray],
|
|
239
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
240
|
+
"""Design matrix A and known terms b of y = A x + b.
|
|
241
|
+
|
|
242
|
+
A has +I (3x3) in the columns of the rover and -I in those of the base. When the rover
|
|
243
|
+
or the base is a fixed point, its coordinates are moved to the known terms b.
|
|
244
|
+
"""
|
|
245
|
+
col = {p: 3 * j for j, p in enumerate(unknowns)}
|
|
246
|
+
A = np.zeros((3 * len(baselines), 3 * len(unknowns)))
|
|
247
|
+
b = np.zeros(3 * len(baselines))
|
|
248
|
+
for k, bl in enumerate(baselines):
|
|
249
|
+
rows = slice(3 * k, 3 * k + 3)
|
|
250
|
+
for point, sign in ((bl.rover, +1), (bl.base, -1)):
|
|
251
|
+
if point in col:
|
|
252
|
+
A[rows, col[point] : col[point] + 3] = sign * np.eye(3)
|
|
253
|
+
else:
|
|
254
|
+
b[rows] += sign * fixed[point]
|
|
255
|
+
return A, b
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# =============================================================================
|
|
259
|
+
# Network adjustment
|
|
260
|
+
# =============================================================================
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def adjust_network(
|
|
264
|
+
baselines: Sequence[BaselineResult],
|
|
265
|
+
fixed: Mapping[str, Sequence[float] | np.ndarray],
|
|
266
|
+
stochastic: Literal["rtklib_scaled", "rtklib", "apriori"] = "rtklib_scaled",
|
|
267
|
+
sigma_h: tuple[float, float] = SIGMA_H,
|
|
268
|
+
sigma_v: tuple[float, float] = SIGMA_V,
|
|
269
|
+
k_s: float = K_S,
|
|
270
|
+
) -> Adjustment:
|
|
271
|
+
"""Weighted least squares of the baseline vectors with the `fixed` points held.
|
|
272
|
+
|
|
273
|
+
The default model is the RTKLIB covariance scaled by `k_s` = 25 (sd x 5), a value for
|
|
274
|
+
baselines processed at 5 s epochs: RTKLIB treats the epochs as independent, while real
|
|
275
|
+
errors are correlated over about a minute (see K_S). For another processing interval dt
|
|
276
|
+
use k_s ~ 120 / dt [s]. Choose k_s before looking at the network: setting it to the
|
|
277
|
+
sigma_v_hat of the "rtklib" model makes the global test meaningless.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
baselines: Baselines (base -> rover vectors) forming the network.
|
|
281
|
+
fixed: Point name -> ECEF coordinates [m] of the held points.
|
|
282
|
+
stochastic: Baseline error model: "rtklib_scaled" (RTKLIB covariance x `k_s`), "rtklib" (formal RTKLIB covariance) or "apriori" (ENU errors from `sigma_h`/`sigma_v`); see the module docstring.
|
|
283
|
+
sigma_h: A-priori horizontal precision as (constant [m], ppm of the length). Used by the "apriori" model only.
|
|
284
|
+
sigma_v: A-priori vertical precision, same form. "apriori" only.
|
|
285
|
+
k_s: Variance factor of the RTKLIB covariance. "rtklib_scaled" only.
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
The adjustment, with parameters for the points not in `fixed`.
|
|
289
|
+
|
|
290
|
+
Raises:
|
|
291
|
+
ValueError: If `stochastic` is not one of the three models.
|
|
292
|
+
"""
|
|
293
|
+
if stochastic not in ("rtklib_scaled", "rtklib", "apriori"):
|
|
294
|
+
raise ValueError(
|
|
295
|
+
f"stochastic must be 'rtklib_scaled', 'rtklib' or 'apriori', not {stochastic!r}"
|
|
296
|
+
)
|
|
297
|
+
if stochastic == "rtklib_scaled":
|
|
298
|
+
print(
|
|
299
|
+
f"adjust_network: RTKLIB covariance x k_s = {k_s:g} (sd x {np.sqrt(k_s):.1f}); "
|
|
300
|
+
"k_s = 25 suits 5 s epochs, use k_s ~ 120 / dt[s] for another interval"
|
|
301
|
+
)
|
|
302
|
+
fixed_xyz = {p: np.asarray(x, dtype=float) for p, x in fixed.items()}
|
|
303
|
+
points = dict.fromkeys(p for bl in baselines for p in (bl.base, bl.rover))
|
|
304
|
+
unknowns = [p for p in points if p not in fixed_xyz]
|
|
305
|
+
|
|
306
|
+
y0 = np.concatenate([bl.dxyz for bl in baselines])
|
|
307
|
+
Q = _observation_covariance(baselines, fixed_xyz, stochastic, sigma_h, sigma_v, k_s)
|
|
308
|
+
A, b = _design_matrix(baselines, unknowns, fixed_xyz)
|
|
309
|
+
sol = weighted_least_squares(A, Q, y0, b)
|
|
310
|
+
|
|
311
|
+
return Adjustment(
|
|
312
|
+
unknowns=unknowns,
|
|
313
|
+
fixed=fixed_xyz,
|
|
314
|
+
baselines=list(baselines),
|
|
315
|
+
y0=y0,
|
|
316
|
+
b=b,
|
|
317
|
+
A=A,
|
|
318
|
+
Q=Q,
|
|
319
|
+
X_hat=sol.X_hat,
|
|
320
|
+
Y_hat=sol.Y_hat,
|
|
321
|
+
v_hat=sol.v_hat,
|
|
322
|
+
sigma_v_hat=sol.sigma_v_hat,
|
|
323
|
+
Cxx=sol.Cxx,
|
|
324
|
+
Cyy=sol.Cyy,
|
|
325
|
+
Cvv=sol.Cvv,
|
|
326
|
+
R=sol.R,
|
|
327
|
+
tau=sol.tau,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# =============================================================================
|
|
332
|
+
# Result tables: residuals and coordinates
|
|
333
|
+
# =============================================================================
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def residual_table(adj: Adjustment) -> pd.DataFrame:
|
|
337
|
+
"""Residuals, redundancy number and normalised residual of every baseline.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
adj: Result of `adjust_network`.
|
|
341
|
+
|
|
342
|
+
Returns:
|
|
343
|
+
One row per baseline: session, base, rover, vx_mm, vy_mm, vz_mm [mm], redundancy,
|
|
344
|
+
tau (largest of the three normalised residuals) and outlier (tau > TAU_CRITICAL).
|
|
345
|
+
"""
|
|
346
|
+
tau = adj.tau.reshape(-1, 3).max(axis=1)
|
|
347
|
+
return pd.DataFrame({
|
|
348
|
+
"session": [bl.session for bl in adj.baselines],
|
|
349
|
+
"base": [bl.base for bl in adj.baselines],
|
|
350
|
+
"rover": [bl.rover for bl in adj.baselines],
|
|
351
|
+
"vx_mm": adj.v_hat[0::3] * 1e3,
|
|
352
|
+
"vy_mm": adj.v_hat[1::3] * 1e3,
|
|
353
|
+
"vz_mm": adj.v_hat[2::3] * 1e3,
|
|
354
|
+
"redundancy": np.diag(adj.R).reshape(-1, 3).sum(axis=1),
|
|
355
|
+
"tau": tau,
|
|
356
|
+
"outlier": tau > TAU_CRITICAL,
|
|
357
|
+
}) # fmt: skip
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def ecef_table(adj: Adjustment) -> pd.DataFrame:
|
|
361
|
+
"""Adjusted ECEF coordinates and standard deviations of the unknown points.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
adj: Result of `adjust_network`.
|
|
365
|
+
|
|
366
|
+
Returns:
|
|
367
|
+
DataFrame indexed by point with X, Y, Z, sd_X, sd_Y, sd_Z [m].
|
|
368
|
+
"""
|
|
369
|
+
sd = np.sqrt(np.diag(adj.Cxx))
|
|
370
|
+
return pd.DataFrame(
|
|
371
|
+
{
|
|
372
|
+
"point": adj.unknowns,
|
|
373
|
+
"X": adj.X_hat[0::3], "Y": adj.X_hat[1::3], "Z": adj.X_hat[2::3],
|
|
374
|
+
"sd_X": sd[0::3], "sd_Y": sd[1::3], "sd_Z": sd[2::3],
|
|
375
|
+
}
|
|
376
|
+
).set_index("point") # fmt: skip
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _ecef_to_utm(epsg: int) -> Callable[[np.ndarray], np.ndarray]:
|
|
380
|
+
"""Function ECEF -> (E, N, ellipsoidal height) of the UTM zone `epsg`."""
|
|
381
|
+
to_utm = Transformer.from_crs("EPSG:4978", f"EPSG:{epsg}", always_xy=True)
|
|
382
|
+
return lambda xyz: np.array(to_utm.transform(*xyz))
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _propagate(
|
|
386
|
+
f: Callable[[np.ndarray], np.ndarray],
|
|
387
|
+
X: np.ndarray,
|
|
388
|
+
C: np.ndarray,
|
|
389
|
+
step: float = 1.0,
|
|
390
|
+
) -> np.ndarray:
|
|
391
|
+
"""Covariance of f(X) as J C J^T, with the Jacobian J by central differences [step m]."""
|
|
392
|
+
J = np.column_stack([(f(X + step * e) - f(X - step * e)) / (2 * step) for e in np.eye(3)])
|
|
393
|
+
return J @ C @ J.T
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def utm_table(adj: Adjustment, utm_epsg: int = 32632) -> pd.DataFrame:
|
|
397
|
+
"""UTM coordinates (ellipsoidal height) with propagated standard deviations.
|
|
398
|
+
|
|
399
|
+
The covariance is propagated as C_utm = J C J^T, with J = d(E, N, h) / d(X, Y, Z)
|
|
400
|
+
computed by central differences. Fixed points are listed with zero standard deviation.
|
|
401
|
+
The input ECEF coordinates are treated as WGS84, which equals ETRF2000 / RDN2008 within
|
|
402
|
+
the accuracy of GNSS; for those frames pass their UTM code (e.g. 7791 for RDN2008 UTM 32N).
|
|
403
|
+
|
|
404
|
+
Args:
|
|
405
|
+
adj: Result of `adjust_network`.
|
|
406
|
+
utm_epsg: EPSG code of the UTM zone (32632 = WGS84 UTM 32N, 7791 = RDN2008 UTM 32N).
|
|
407
|
+
|
|
408
|
+
Returns:
|
|
409
|
+
DataFrame with point, E, N, h_ell, sd_E, sd_N, sd_h [m]; unknown points first.
|
|
410
|
+
"""
|
|
411
|
+
ecef_to_utm = _ecef_to_utm(utm_epsg)
|
|
412
|
+
rows = []
|
|
413
|
+
for j, point in enumerate(adj.unknowns):
|
|
414
|
+
X = adj.X_hat[3 * j : 3 * j + 3] # adjusted ECEF coordinates
|
|
415
|
+
C = adj.Cxx[3 * j : 3 * j + 3, 3 * j : 3 * j + 3] # their 3x3 covariance
|
|
416
|
+
sd = np.sqrt(np.diag(_propagate(ecef_to_utm, X, C)))
|
|
417
|
+
rows.append([point, *ecef_to_utm(X), *sd])
|
|
418
|
+
for point, X in adj.fixed.items():
|
|
419
|
+
rows.append([point, *ecef_to_utm(X), 0.0, 0.0, 0.0])
|
|
420
|
+
return pd.DataFrame(rows, columns=["point", "E", "N", "h_ell", "sd_E", "sd_N", "sd_h"])
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def coordinate_table(adj: Adjustment, utm_epsg: int = 32632) -> pd.DataFrame:
|
|
424
|
+
"""All adjusted points (fixed ones last): ECEF, geographic and UTM, with standard deviations.
|
|
425
|
+
|
|
426
|
+
Geographic coordinates are on GRS80 (ellipsoidal height), UTM coordinates in `utm_epsg`.
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
adj: Result of `adjust_network`.
|
|
430
|
+
utm_epsg: EPSG code of the UTM zone (32632 = WGS84 UTM 32N, 7791 = RDN2008 UTM 32N).
|
|
431
|
+
|
|
432
|
+
Returns:
|
|
433
|
+
DataFrame indexed by point, with the ECEF, geographic and UTM columns.
|
|
434
|
+
"""
|
|
435
|
+
utm = utm_table(adj, utm_epsg).set_index("point")
|
|
436
|
+
ecef = ecef_table(adj)
|
|
437
|
+
fixed = pd.DataFrame(
|
|
438
|
+
[[*X, 0.0, 0.0, 0.0] for X in adj.fixed.values()],
|
|
439
|
+
index=pd.Index(list(adj.fixed), name="point"),
|
|
440
|
+
columns=ecef.columns,
|
|
441
|
+
)
|
|
442
|
+
table = pd.concat([ecef, fixed]).join(utm.drop(columns="h_ell"))
|
|
443
|
+
lat, lon, h = ecef_to_llh(*table[["X", "Y", "Z"]].to_numpy().T)
|
|
444
|
+
table.insert(6, "lat", lat)
|
|
445
|
+
table.insert(7, "lon", lon)
|
|
446
|
+
table.insert(8, "h_ell", h)
|
|
447
|
+
table.insert(0, "fixed", table.index.isin(list(adj.fixed)))
|
|
448
|
+
return table.reset_index()
|
gnsskit/static/solve.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Static solution of a rover over a time window (one rnx2rtkp run, last filter epoch)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tempfile
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
from ..rtklib.config import RtkConfig
|
|
12
|
+
from ..rtklib.pos import read_pos
|
|
13
|
+
from ..rtklib.runner import run_rnx2rtkp
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def solve_static(
|
|
17
|
+
rover: Path | str,
|
|
18
|
+
base: Path | str,
|
|
19
|
+
nav: Sequence[Path | str],
|
|
20
|
+
config: RtkConfig,
|
|
21
|
+
start: pd.Timestamp,
|
|
22
|
+
end: pd.Timestamp,
|
|
23
|
+
exe: str = "rnx2rtkp",
|
|
24
|
+
tint: float | None = None,
|
|
25
|
+
) -> pd.Series | None:
|
|
26
|
+
"""Static solution over [start, end], run in a temporary directory.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
rover: Rover RINEX observation file.
|
|
30
|
+
base: Base RINEX observation file.
|
|
31
|
+
nav: Navigation, precise orbit and clock files (see `run_rnx2rtkp`).
|
|
32
|
+
config: RTKLIB processing options.
|
|
33
|
+
start: Processing start time, GPST.
|
|
34
|
+
end: Processing end time, GPST.
|
|
35
|
+
exe: rnx2rtkp executable name or path.
|
|
36
|
+
tint: Processing interval [s]. None for every epoch.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
The last (cumulative) epoch of the forward filter as a row of the `read_pos` table,
|
|
40
|
+
or None if there is no solution.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
FileNotFoundError: If the executable is not found.
|
|
44
|
+
RuntimeError: If rnx2rtkp exits with a non-zero code.
|
|
45
|
+
"""
|
|
46
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
47
|
+
out = run_rnx2rtkp(
|
|
48
|
+
rover, base, nav, config, Path(tmp) / "out.pos",
|
|
49
|
+
start=start, end=end, tint=tint, exe=exe,
|
|
50
|
+
) # fmt: skip
|
|
51
|
+
if not out.exists() or out.stat().st_size == 0:
|
|
52
|
+
return None
|
|
53
|
+
pos = read_pos(out)
|
|
54
|
+
return pos.iloc[-1] if len(pos) else None
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gnsskit
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: RTKLIB-based GNSS post-processing toolkit: RINEX tools, RTKLIB wrappers, static baselines and network adjustment, kinematic trajectories
|
|
5
|
+
Keywords: gnss,gps,rtklib,ppk,surveying,least-squares
|
|
6
|
+
Author: Francesco Ioli
|
|
7
|
+
Author-email: Francesco Ioli <francesco.ioli@polimi.it>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Dist: numpy
|
|
20
|
+
Requires-Dist: pandas>=2
|
|
21
|
+
Requires-Dist: scipy ; extra == 'network'
|
|
22
|
+
Requires-Dist: pyproj ; extra == 'network'
|
|
23
|
+
Requires-Dist: matplotlib ; extra == 'plot'
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Project-URL: Repository, https://github.com/franioli/gnsskit
|
|
26
|
+
Project-URL: Issues, https://github.com/franioli/gnsskit/issues
|
|
27
|
+
Provides-Extra: network
|
|
28
|
+
Provides-Extra: plot
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# gnsskit
|
|
32
|
+
|
|
33
|
+
Shared tools for post-processing GNSS data with [RTKLIB](https://www.rtklib.com/) `rnx2rtkp`, used by
|
|
34
|
+
[djippk](https://github.com/franioli/djippk) (drone PPK) and [stopgo](https://github.com/franioli/stopgo)
|
|
35
|
+
(stop-and-go surveys).
|
|
36
|
+
|
|
37
|
+
- **RTKLIB wrappers** (`gnsskit.rtklib`): `RtkConfig`, a typed, immutable set of `rnx2rtkp` options
|
|
38
|
+
(`from_file`, `updated`, `with_options`, `with_base_llh`, `write`); `run_rnx2rtkp` and `find_rnx2rtkp`;
|
|
39
|
+
`read_pos` (llh, xyz and enu `.pos` files).
|
|
40
|
+
- **RINEX** (`gnsskit.rinex`): `rinex_span`, `header_llh`, `trim_rinex`; inspection with `obs_summary`
|
|
41
|
+
(span, sampling, gaps, satellites), `read_visibility` and `plot_visibility` (RTKPLOT-style satellite
|
|
42
|
+
visibility, needs the `plot` extra).
|
|
43
|
+
- **Static** (`gnsskit.static`): `solve_static` (one solution over a time window), `process_baseline`
|
|
44
|
+
(static baselines with covariance), baseline IO and `loop_closures` (misclosure of every triangle of baselines); least-squares adjustment of a redundant baseline
|
|
45
|
+
network in `gnsskit.static.network` (needs the `network` extra).
|
|
46
|
+
- **Kinematic** (`gnsskit.kinematic`): trajectory interpolation and averaging.
|
|
47
|
+
- **Reference systems** (`gnsskit.refsys`): GPST/UTC, ellipsoids, ECEF/geodetic conversions and
|
|
48
|
+
`ENUFrame`, a local east-north-up frame at a point (points, vectors and covariances, both ways).
|
|
49
|
+
|
|
50
|
+
## Install
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
pip install gnsskit # library
|
|
54
|
+
pip install "gnsskit[network]" # + network adjustment (scipy, pyproj)
|
|
55
|
+
pip install "gnsskit[network,plot]" # + satellite visibility plots (matplotlib)
|
|
56
|
+
pip install git+https://github.com/franioli/gnsskit # development version
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`rnx2rtkp` is not installed by pip. Build the [demo5 fork](https://github.com/rtklibexplorer/RTKLIB) and put it on
|
|
60
|
+
your `PATH`; if it is missing, `run_rnx2rtkp` raises an error with instructions. Developed with `rnx2rtkp ver.EX 2.5.1`.
|
|
61
|
+
|
|
62
|
+
## Example
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from gnsskit import RtkConfig, read_pos, run_rnx2rtkp
|
|
66
|
+
|
|
67
|
+
config = RtkConfig(posmode="static", navsys="GEC", solformat="llh").with_base_llh(46.2, 9.8, 2300.0)
|
|
68
|
+
pos_file = run_rnx2rtkp(rover_obs, base_obs, [nav], config, "out.pos")
|
|
69
|
+
pos = read_pos(pos_file)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
See [example/](https://github.com/franioli/gnsskit/tree/main/example) for complete static, kinematic and network adjustment examples.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
gnsskit/__init__.py,sha256=Vc0w5O7xgj98-Qy20FGIfKqso_5g4-v42eUZk2b3A_E,1360
|
|
2
|
+
gnsskit/kinematic/__init__.py,sha256=acQv70N3oaqU_yc2W1KI10eO1lAcrgNW5zDcQBHZMr4,177
|
|
3
|
+
gnsskit/kinematic/trajectory.py,sha256=UdVASx4N7jBRrDxM0c_WeeoRgvKvDcqHnGLxyDISwd8,2949
|
|
4
|
+
gnsskit/ppp/__init__.py,sha256=dwsBzFMH3ZFIt5H7FmtgjnUto69LwK4Rak5WgWdyA9A,758
|
|
5
|
+
gnsskit/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
gnsskit/refsys.py,sha256=AxtzYyf800vDS9IihBMjY2Hm8RFhK0T9jVZlsG0a1V4,10791
|
|
7
|
+
gnsskit/rinex.py,sha256=UPAU4JLaAeVBF73jMKE_r4ia5thoT5J3VirP8oexafU,17184
|
|
8
|
+
gnsskit/rtklib/__init__.py,sha256=36tnultivDVoy8lYGQNDGN3wjJ3TXMZr5FkTucmypsE,460
|
|
9
|
+
gnsskit/rtklib/config.py,sha256=NyCLeLjaMw2hqGLXzsORQr-6gyiy9-eSObu7GthtyAc,13699
|
|
10
|
+
gnsskit/rtklib/pos.py,sha256=EfGQF7-3DOPTNPAhib5tivPJV2_bBylaywjV2mjDDPA,5888
|
|
11
|
+
gnsskit/rtklib/runner.py,sha256=EgWaUFY9GkaNfGldWDKDULSg1qwcv-2INzn5PV8p6Mc,5083
|
|
12
|
+
gnsskit/static/__init__.py,sha256=ZR0KDTSTb5PuEjKSaxM1Xr_GLbb_kM5uAS1SI7QVzo4,758
|
|
13
|
+
gnsskit/static/baseline.py,sha256=QfKljWZThrIhj23yUr-4ricUo2yAPdAksXxW6xd8cGg,11632
|
|
14
|
+
gnsskit/static/closure.py,sha256=xFFSLAfaYw63Js8HfoeesuSbfHaTyqKGLgJ_z_qhLq8,3551
|
|
15
|
+
gnsskit/static/network.py,sha256=XeAi7y1X6yK-8nDsl1ZlXcRaq2brxyYCn2sqPs6M2XQ,17997
|
|
16
|
+
gnsskit/static/solve.py,sha256=11vsP3LRz028rsYT5obonHcRkJ1ZHDrf7pJh6joI_C8,1728
|
|
17
|
+
gnsskit-0.4.0.dist-info/licenses/LICENSE,sha256=dLFf8Hru3SXGRjRJtNJ6yGCpMYutI00NKvyX8Q5oyTo,1076
|
|
18
|
+
gnsskit-0.4.0.dist-info/WHEEL,sha256=si2p8vW03RnhyBnhWl4tj-ObJa78SpE-c9HsEtblGR0,80
|
|
19
|
+
gnsskit-0.4.0.dist-info/METADATA,sha256=opONMGjSqWXYsTYMOr-uWoK6DH73ryj-g4Za2WaVlJE,3512
|
|
20
|
+
gnsskit-0.4.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023-2026 Francesco Ioli
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|