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 ADDED
@@ -0,0 +1,76 @@
1
+ """Shared RTKLIB-based GNSS post-processing tools."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ from .kinematic import interpolate
6
+ from .refsys import (
7
+ GPS_EPOCH,
8
+ GPST_UTC_OFFSET_S,
9
+ GRS80,
10
+ WGS84,
11
+ Ellipsoid,
12
+ ENUFrame,
13
+ enu_diff,
14
+ gpst_to_local,
15
+ utc_to_gpst,
16
+ )
17
+ from .rinex import (
18
+ find_rinex_obs,
19
+ header_llh,
20
+ obs_summary,
21
+ plot_visibility,
22
+ read_visibility,
23
+ rinex_span,
24
+ satellite_stats,
25
+ signal_stats,
26
+ trim_rinex,
27
+ )
28
+ from .rtklib import (
29
+ RtkConfig,
30
+ navsys_mask,
31
+ read_header,
32
+ read_pos,
33
+ read_ref_pos,
34
+ run_rnx2rtkp,
35
+ )
36
+ from .static import (
37
+ baselines_to_df,
38
+ df_to_baselines,
39
+ loop_closures,
40
+ process_baseline,
41
+ solve_static,
42
+ )
43
+
44
+ __version__ = version("gnsskit")
45
+ __all__ = [
46
+ "GPST_UTC_OFFSET_S",
47
+ "GPS_EPOCH",
48
+ "GRS80",
49
+ "WGS84",
50
+ "ENUFrame",
51
+ "Ellipsoid",
52
+ "RtkConfig",
53
+ "baselines_to_df",
54
+ "df_to_baselines",
55
+ "enu_diff",
56
+ "find_rinex_obs",
57
+ "gpst_to_local",
58
+ "header_llh",
59
+ "interpolate",
60
+ "loop_closures",
61
+ "navsys_mask",
62
+ "obs_summary",
63
+ "plot_visibility",
64
+ "process_baseline",
65
+ "read_header",
66
+ "read_pos",
67
+ "read_ref_pos",
68
+ "read_visibility",
69
+ "rinex_span",
70
+ "run_rnx2rtkp",
71
+ "satellite_stats",
72
+ "signal_stats",
73
+ "solve_static",
74
+ "trim_rinex",
75
+ "utc_to_gpst",
76
+ ]
@@ -0,0 +1,5 @@
1
+ """Kinematic processing: interpolation and averaging of trajectories from .pos solutions."""
2
+
3
+ from .trajectory import average, interpolate
4
+
5
+ __all__ = ["average", "interpolate"]
@@ -0,0 +1,84 @@
1
+ """Sampling a kinematic trajectory (`read_pos` output): at given times or inside windows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import numpy as np
6
+ import pandas as pd
7
+
8
+ from ..refsys import GPS_EPOCH, enu_diff
9
+ from ..rtklib.pos import POS_COORD_COLS, POS_SD_COLS
10
+
11
+
12
+ def interpolate(pos: pd.DataFrame, gps_s: np.ndarray) -> pd.DataFrame:
13
+ """Linear interpolation of the trajectory at the given times.
14
+
15
+ Q is the worse of the two neighbouring epochs, ns the smaller. Times outside the
16
+ trajectory are not allowed.
17
+
18
+ Args:
19
+ pos: Trajectory from `read_pos`, with at least two epochs.
20
+ gps_s: Times to interpolate at, seconds since the GPS epoch.
21
+
22
+ Returns:
23
+ DataFrame in the format of `pos`, one row per requested time.
24
+ """
25
+ fmt = pos.attrs["format"]
26
+ t = pos["gps_s"].to_numpy()
27
+ idx = np.searchsorted(t, gps_s).clip(1, len(pos) - 1)
28
+ lo, hi = idx - 1, idx
29
+ q, ratio = pos["Q"].to_numpy(), pos["ratio"].to_numpy()
30
+ cols = POS_COORD_COLS[fmt] + POS_SD_COLS[fmt]
31
+ out = pd.DataFrame({c: np.interp(gps_s, t, pos[c]) for c in cols})
32
+ out["Q"] = np.maximum(q[lo], q[hi])
33
+ out["ns"] = np.minimum(pos["ns"].to_numpy()[lo], pos["ns"].to_numpy()[hi])
34
+ out["ratio"] = np.where(q[lo] == 1, ratio[lo], ratio[hi])
35
+ out["age"] = 0.0
36
+ out["gps_s"] = gps_s
37
+ out["t"] = GPS_EPOCH + pd.to_timedelta(gps_s, unit="s")
38
+ out.attrs["format"] = fmt
39
+ return out
40
+
41
+
42
+ def average(
43
+ pos: pd.DataFrame, start: pd.Timestamp, end: pd.Timestamp, fix_only: bool = True
44
+ ) -> dict[str, float | int | bool]:
45
+ """Mean position of the llh epochs in [start, end] with the scatter in E, N, U.
46
+
47
+ Args:
48
+ pos: llh trajectory from `read_pos`.
49
+ start: Window start, GPST.
50
+ end: Window end, GPST.
51
+ fix_only: Average the fixed epochs (Q == 1) only.
52
+
53
+ Returns:
54
+ Dict with n_epochs, fix_pct, n_used and, if any epoch is used, fixed, lat, lon, h
55
+ [deg, deg, m] and sd_e, sd_n, sd_u [m] (sample standard deviations).
56
+
57
+ Raises:
58
+ ValueError: If `pos` is not in llh format.
59
+ """
60
+ if pos.attrs.get("format") != "llh":
61
+ raise ValueError("average needs a llh trajectory (out-solformat=llh)")
62
+ seg = pos[(pos.t >= start) & (pos.t <= end)]
63
+ n_all = len(seg)
64
+ row: dict[str, float | int | bool] = {
65
+ "n_epochs": n_all,
66
+ "fix_pct": 100.0 * (seg.Q == 1).sum() / n_all if n_all else 0.0,
67
+ }
68
+ if fix_only:
69
+ seg = seg[seg.Q == 1]
70
+ row["n_used"] = len(seg)
71
+ if len(seg):
72
+ lat, lon, h = seg.lat.mean(), seg.lon.mean(), seg.h.mean()
73
+ e, n, u = enu_diff(seg.lat, seg.lon, seg.h, lat, lon, h).T
74
+ ddof = 1 if len(seg) > 1 else 0
75
+ row |= {
76
+ "fixed": bool((seg.Q == 1).all()),
77
+ "lat": lat,
78
+ "lon": lon,
79
+ "h": h,
80
+ "sd_e": e.std(ddof=ddof),
81
+ "sd_n": n.std(ddof=ddof),
82
+ "sd_u": u.std(ddof=ddof),
83
+ }
84
+ return row
@@ -0,0 +1,16 @@
1
+ """Precise Point Positioning (PPP) with RTKLIB. Placeholder: nothing is implemented yet.
2
+
3
+ Planned scope:
4
+ - single-receiver static and kinematic solutions with rnx2rtkp (`posmode` "ppp-static",
5
+ "ppp-kine" or "ppp-fixed"), reusing `gnsskit.rtklib` for configuration, running and
6
+ reading the .pos output;
7
+ - helpers for the precise products PPP needs: SP3 orbits, CLK clocks, ANTEX antenna
8
+ models (satellite and receiver), and optionally DCB/OSB biases and ocean loading;
9
+ - a PPP preset of `RtkConfig` (dual-frequency iono-free, `sateph="precise"`,
10
+ `tropopt="est-ztd"`).
11
+
12
+ To do before the first function:
13
+ - add "ppp-kine", "ppp-static" and "ppp-fixed" to `PosMode` in `gnsskit.rtklib.config`.
14
+ """
15
+
16
+ __all__: list[str] = []
gnsskit/py.typed ADDED
File without changes
gnsskit/refsys.py ADDED
@@ -0,0 +1,308 @@
1
+ """Reference systems: time (GPST, UTC) and space (ellipsoid, ECEF, geodetic, local ENU).
2
+
3
+ The default ellipsoid is GRS80, the ellipsoid of ITRS, ETRS89/ETRF2000 and the Italian
4
+ RDN2008. WGS84 has the same semi-major axis and a flattening that differs in the 11th digit:
5
+ the two give the same coordinates to about 0.1 mm.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from functools import cached_property
12
+ from typing import TypeVar
13
+
14
+ import numpy as np
15
+ import numpy.typing as npt
16
+ import pandas as pd
17
+
18
+ # =============================================================================
19
+ # Time systems (GPST, UTC)
20
+ # =============================================================================
21
+
22
+ GPS_EPOCH = pd.Timestamp("1980-01-06")
23
+ GPST_UTC_OFFSET_S = 18 # GPST - UTC leap seconds (valid since 2017)
24
+
25
+ T = TypeVar("T", pd.Timestamp, pd.Series)
26
+
27
+
28
+ def utc_to_gpst(t: T, leap_s: int = GPST_UTC_OFFSET_S) -> T:
29
+ """Shift UTC time(s) to GPST.
30
+
31
+ Args:
32
+ t: Timestamp or Series of UTC times.
33
+ leap_s: GPST - UTC [s].
34
+
35
+ Returns:
36
+ The shifted time(s), same type as `t`.
37
+ """
38
+ return t + pd.Timedelta(seconds=leap_s)
39
+
40
+
41
+ def gpst_to_local(t: T, tz: str = "Europe/Rome", leap_s: int = GPST_UTC_OFFSET_S) -> T:
42
+ """Convert GPST time(s) to local civil time, with daylight saving time.
43
+
44
+ Args:
45
+ t: Timestamp or Series of GPST times (naive).
46
+ tz: IANA time zone of the result.
47
+ leap_s: GPST - UTC [s].
48
+
49
+ Returns:
50
+ The local time(s) as naive datetimes, same type as `t`.
51
+ """
52
+ utc = t - pd.Timedelta(seconds=leap_s)
53
+ if isinstance(utc, pd.Series):
54
+ return utc.dt.tz_localize("UTC").dt.tz_convert(tz).dt.tz_localize(None)
55
+ return utc.tz_localize("UTC").tz_convert(tz).tz_localize(None)
56
+
57
+
58
+ def gps_seconds(t: pd.Timestamp | pd.Series) -> float | pd.Series:
59
+ """Seconds since the GPS epoch (1980-01-06), which avoids week roll-over problems.
60
+
61
+ Args:
62
+ t: Timestamp or Series of GPST times.
63
+
64
+ Returns:
65
+ Seconds as a float, or a Series of floats.
66
+ """
67
+ return (t - GPS_EPOCH) / pd.Timedelta(seconds=1)
68
+
69
+
70
+ # =============================================================================
71
+ # Reference ellipsoids
72
+ # =============================================================================
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class Ellipsoid:
77
+ """Reference ellipsoid of revolution."""
78
+
79
+ a: float # semi-major axis [m]
80
+ inv_f: float # inverse flattening
81
+
82
+ @property
83
+ def e2(self) -> float:
84
+ """First eccentricity squared."""
85
+ f = 1 / self.inv_f
86
+ return f * (2 - f)
87
+
88
+
89
+ GRS80 = Ellipsoid(6378137.0, 298.257222101)
90
+ WGS84 = Ellipsoid(6378137.0, 298.257223563)
91
+
92
+ ArrayLike = float | np.ndarray | pd.Series
93
+
94
+
95
+ # =============================================================================
96
+ # Global coordinates: geodetic <-> ECEF
97
+ # =============================================================================
98
+
99
+
100
+ def llh_to_ecef(
101
+ lat: ArrayLike, lon: ArrayLike, h: ArrayLike, ellipsoid: Ellipsoid = GRS80
102
+ ) -> np.ndarray:
103
+ """ECEF coordinates from geodetic coordinates.
104
+
105
+ Args:
106
+ lat: Latitude [deg].
107
+ lon: Longitude [deg].
108
+ h: Ellipsoidal height [m].
109
+ ellipsoid: Reference ellipsoid.
110
+
111
+ Returns:
112
+ Array of shape (..., 3) with X, Y, Z [m].
113
+ """
114
+ a, e2 = ellipsoid.a, ellipsoid.e2
115
+ phi, lam = np.radians(np.asarray(lat, float)), np.radians(np.asarray(lon, float))
116
+ n = a / np.sqrt(1 - e2 * np.sin(phi) ** 2)
117
+ h = np.asarray(h, float)
118
+ return np.stack(
119
+ [
120
+ (n + h) * np.cos(phi) * np.cos(lam),
121
+ (n + h) * np.cos(phi) * np.sin(lam),
122
+ (n * (1 - e2) + h) * np.sin(phi),
123
+ ],
124
+ axis=-1,
125
+ )
126
+
127
+
128
+ def ecef_to_llh(
129
+ x: ArrayLike, y: ArrayLike, z: ArrayLike, ellipsoid: Ellipsoid = GRS80
130
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
131
+ """Geodetic coordinates from ECEF coordinates.
132
+
133
+ Args:
134
+ x: ECEF X [m].
135
+ y: ECEF Y [m].
136
+ z: ECEF Z [m].
137
+ ellipsoid: Reference ellipsoid.
138
+
139
+ Returns:
140
+ (lat [deg], lon [deg], ellipsoidal height [m]).
141
+ """
142
+ a, e2 = ellipsoid.a, ellipsoid.e2
143
+ x, y, z = (np.asarray(v, float) for v in (x, y, z))
144
+ p = np.hypot(x, y)
145
+ lat = np.arctan2(z, p * (1 - e2))
146
+ for _ in range(10): # fixed-point iteration, converges to sub-mm in a few steps
147
+ n = a / np.sqrt(1 - e2 * np.sin(lat) ** 2)
148
+ h = p / np.cos(lat) - n
149
+ lat = np.arctan2(z, p * (1 - e2 * n / (n + h)))
150
+ return np.degrees(lat), np.degrees(np.arctan2(y, x)), h
151
+
152
+
153
+ # =============================================================================
154
+ # Local frames: ENU rotation, ENU frame and small-difference offsets
155
+ # =============================================================================
156
+
157
+
158
+ def ecef_to_enu_matrix(lat: float, lon: float) -> np.ndarray:
159
+ """Rotation matrix from ECEF to local east-north-up.
160
+
161
+ Args:
162
+ lat: Latitude of the local origin [deg].
163
+ lon: Longitude of the local origin [deg].
164
+
165
+ Returns:
166
+ (3, 3) matrix R, with enu = R @ (ecef difference).
167
+ """
168
+ phi, lam = np.radians(lat), np.radians(lon)
169
+ return np.array(
170
+ [
171
+ [-np.sin(lam), np.cos(lam), 0.0],
172
+ [-np.sin(phi) * np.cos(lam), -np.sin(phi) * np.sin(lam), np.cos(phi)],
173
+ [np.cos(phi) * np.cos(lam), np.cos(phi) * np.sin(lam), np.sin(phi)],
174
+ ]
175
+ )
176
+
177
+
178
+ @dataclass(frozen=True, eq=False)
179
+ class ENUFrame:
180
+ """Local east-north-up frame with origin at a point, tangent to the ellipsoid there.
181
+
182
+ Points are shifted to the origin and rotated; vectors (baselines) and covariance matrices
183
+ are only rotated: enu = R (xyz - origin), denu = R dxyz, C_enu = R C_xyz R^T.
184
+ Methods take one (3,) array or a stack (n, 3); covariances (3, 3) or (n, 3, 3).
185
+ The axes are east, north and up of the tangent plane at the origin: for points far
186
+ from it, "up" is not their local vertical (the two differ by about d / 6371 km rad).
187
+ """
188
+
189
+ origin: np.ndarray # (3,) ECEF of the origin [m]
190
+ ellipsoid: Ellipsoid = GRS80
191
+
192
+ @classmethod
193
+ def from_ecef(cls, xyz: npt.ArrayLike, ellipsoid: Ellipsoid = GRS80) -> ENUFrame:
194
+ """Frame with origin at an ECEF point [m]."""
195
+ return cls(np.asarray(xyz, float).reshape(3), ellipsoid)
196
+
197
+ @classmethod
198
+ def from_llh(cls, lat: float, lon: float, h: float, ellipsoid: Ellipsoid = GRS80) -> ENUFrame:
199
+ """Frame with origin at a geodetic point (lat, lon [deg], ellipsoidal h [m])."""
200
+ return cls(llh_to_ecef(lat, lon, h, ellipsoid), ellipsoid)
201
+
202
+ @cached_property
203
+ def llh(self) -> tuple[float, float, float]:
204
+ """Geodetic coordinates of the origin: lat, lon [deg], h [m]."""
205
+ x, y, z = self.origin
206
+ lat, lon, h = ecef_to_llh(x, y, z, self.ellipsoid)
207
+ return float(lat), float(lon), float(h)
208
+
209
+ @cached_property
210
+ def rotation(self) -> np.ndarray:
211
+ """(3, 3) rotation R from ECEF to ENU; its inverse is R^T."""
212
+ lat, lon, _ = self.llh
213
+ return ecef_to_enu_matrix(lat, lon)
214
+
215
+ # --- points: shift to the origin and rotate ---------------------------------
216
+
217
+ def to_enu(self, xyz: npt.ArrayLike) -> np.ndarray:
218
+ """ENU coordinates [m] of ECEF points [m]."""
219
+ return self.vector_to_enu(np.asarray(xyz, float) - self.origin)
220
+
221
+ def to_ecef(self, enu: npt.ArrayLike) -> np.ndarray:
222
+ """ECEF coordinates [m] of ENU points [m]."""
223
+ return self.origin + self.vector_to_ecef(enu)
224
+
225
+ def llh_to_enu(self, lat: ArrayLike, lon: ArrayLike, h: ArrayLike) -> np.ndarray:
226
+ """ENU coordinates [m] of geodetic points (lat, lon [deg], h [m])."""
227
+ return self.to_enu(llh_to_ecef(lat, lon, h, self.ellipsoid))
228
+
229
+ def enu_to_llh(self, enu: npt.ArrayLike) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
230
+ """Geodetic coordinates (lat, lon [deg], h [m]) of ENU points [m]."""
231
+ xyz = self.to_ecef(enu)
232
+ return ecef_to_llh(xyz[..., 0], xyz[..., 1], xyz[..., 2], self.ellipsoid)
233
+
234
+ # --- vectors and covariances: rotation only ---------------------------------
235
+
236
+ def vector_to_enu(self, dxyz: npt.ArrayLike) -> np.ndarray:
237
+ """ENU components of ECEF vectors (e.g. baselines)."""
238
+ return np.asarray(dxyz, float) @ self.rotation.T
239
+
240
+ def vector_to_ecef(self, denu: npt.ArrayLike) -> np.ndarray:
241
+ """ECEF components of ENU vectors."""
242
+ return np.asarray(denu, float) @ self.rotation
243
+
244
+ def cov_to_enu(self, cov: npt.ArrayLike) -> np.ndarray:
245
+ """ENU covariances R C R^T of ECEF covariances."""
246
+ r = self.rotation
247
+ return r @ np.asarray(cov, float) @ r.T
248
+
249
+ def cov_to_ecef(self, cov: npt.ArrayLike) -> np.ndarray:
250
+ """ECEF covariances R^T C R of ENU covariances."""
251
+ r = self.rotation
252
+ return r.T @ np.asarray(cov, float) @ r
253
+
254
+
255
+ def radii(
256
+ lat_deg: ArrayLike, h: ArrayLike, ellipsoid: Ellipsoid = GRS80
257
+ ) -> tuple[np.ndarray, np.ndarray]:
258
+ """Meridian and prime-vertical radii plus height: (Rm + h, (Rn + h) cos(lat)).
259
+
260
+ Multiplying them by a latitude / longitude difference in radians gives metres.
261
+
262
+ Args:
263
+ lat_deg: Latitude [deg].
264
+ h: Ellipsoidal height [m].
265
+ ellipsoid: Reference ellipsoid.
266
+
267
+ Returns:
268
+ (Rm + h, (Rn + h) cos(lat)) [m].
269
+ """
270
+ phi = np.radians(np.asarray(lat_deg, dtype=float))
271
+ a, e2 = ellipsoid.a, ellipsoid.e2
272
+ w = np.sqrt(1 - e2 * np.sin(phi) ** 2)
273
+ h = np.asarray(h, dtype=float)
274
+ return a * (1 - e2) / w**3 + h, (a / w + h) * np.cos(phi)
275
+
276
+
277
+ def enu_diff(
278
+ lat: ArrayLike,
279
+ lon: ArrayLike,
280
+ h: ArrayLike,
281
+ lat0: ArrayLike,
282
+ lon0: ArrayLike,
283
+ h0: ArrayLike,
284
+ ellipsoid: Ellipsoid = GRS80,
285
+ ) -> np.ndarray:
286
+ """(E, N, U) offsets of points from reference points, by small-difference approximation.
287
+
288
+ Each point gets its own reference point (vectorised), e.g. epochs vs their mean. The
289
+ error grows as d^2 / (2 R): < 0.01 mm at 10 m, 0.8 mm at 100 m, 8 cm at 1 km. For
290
+ longer offsets use `ENUFrame`, which is exact.
291
+
292
+ Args:
293
+ lat: Latitude of the points [deg].
294
+ lon: Longitude of the points [deg].
295
+ h: Ellipsoidal height of the points [m].
296
+ lat0: Latitude of the reference points [deg].
297
+ lon0: Longitude of the reference points [deg].
298
+ h0: Ellipsoidal height of the reference points [m].
299
+ ellipsoid: Reference ellipsoid.
300
+
301
+ Returns:
302
+ Array of shape (n, 3) with E, N, U [m].
303
+ """
304
+ rm, rn = radii(lat0, h0, ellipsoid)
305
+ e = np.radians(np.asarray(lon, dtype=float) - np.asarray(lon0, dtype=float)) * rn
306
+ n = np.radians(np.asarray(lat, dtype=float) - np.asarray(lat0, dtype=float)) * rm
307
+ u = np.asarray(h, dtype=float) - np.asarray(h0, dtype=float)
308
+ return np.column_stack([e, n, u])