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/__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,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
|
gnsskit/ppp/__init__.py
ADDED
|
@@ -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])
|