gri-convolve 0.2.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,5 @@
1
+ """Convolve points and ellipses/ellipsoids."""
2
+
3
+ from .convolve import cluster_convolve, convolve, smart_convolve
4
+
5
+ __all__ = ["cluster_convolve", "convolve", "smart_convolve"]
@@ -0,0 +1,5 @@
1
+ """Altitude function to provide to smart_convolve."""
2
+
3
+ from .nearest import nearest
4
+
5
+ __all__ = ["nearest"]
@@ -0,0 +1,28 @@
1
+ """Simple nearest distance altitude setter for smart convolve."""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ import numpy as np
6
+
7
+ if TYPE_CHECKING:
8
+ from collections.abc import Sequence
9
+
10
+ from gri_pos import LLA
11
+
12
+
13
+ def nearest(inputs: Sequence[tuple[LLA, list[LLA]]]) -> list[float]:
14
+ """Take a list of (LLA, list[LLA]) and output the nearest altitude in list.
15
+
16
+ Args:
17
+ inputs(Sequence[tuple[LLA, list[LLA]]]): The LLA point to find the nearest
18
+ altitude to, and the list of LLA points to search for the nearest point.
19
+
20
+ Returns:
21
+ list of altitudes in the same order as points
22
+ """
23
+ alts: list[float] = []
24
+ for lla, points in inputs:
25
+ dist = [lla.dist_surface_simple_m(p) for p in points]
26
+ idx = np.argmin(dist)
27
+ alts.append(points[idx].alt_m)
28
+ return alts
@@ -0,0 +1,7 @@
1
+ """Methods to convolve points and ellipses/ellipsoids."""
2
+
3
+ from .cluster_convolve import cluster_convolve
4
+ from .convolve import convolve
5
+ from .smart_convolve import smart_convolve
6
+
7
+ __all__ = ["cluster_convolve", "convolve", "smart_convolve"]
@@ -0,0 +1,186 @@
1
+ # ruff: noqa: T201
2
+ """Test methods to compare various convolution methods. Not for production use."""
3
+
4
+ # NOTE: Test functions ... this should not be used. Use convolve instead.
5
+
6
+ from typing import TYPE_CHECKING
7
+
8
+ import numpy as np
9
+ from gri_ell import Ell
10
+ from gri_pos import Pos
11
+ from gri_utils import conversion
12
+ from scipy.linalg import block_diag
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Generator, Sequence
16
+
17
+
18
+ def atwa_convolve(ells: Sequence[Ell] | Generator[Ell]) -> Ell:
19
+ """Compare various convolve types.
20
+
21
+ Args:
22
+ ells(Sequence[Ell]): A set of ell objects. Uses xyz and info.
23
+
24
+ Returns:
25
+ Ell
26
+ """
27
+ np.set_printoptions(suppress=True, precision=5)
28
+ ell = _position(ells)
29
+ print("\nATWAI_ENU")
30
+ # print(np.array(ell.cov.semi_axis_vec))
31
+ print(ell.cov)
32
+ print(ell.ellipse)
33
+
34
+ ell2 = _std_cov(ell, ells)
35
+ print("\nSTD COV")
36
+ # print(np.array(ell2.cov.semi_axis_vec))
37
+ print(ell2.cov)
38
+ print(ell2.ellipse)
39
+
40
+ ell3 = _bart_cov(ell, ells)
41
+ print("\nBART COV")
42
+ # print(np.array(ell2.cov.semi_axis_vec))
43
+ print(ell3.cov)
44
+ print(ell3.ellipse)
45
+
46
+ ell4 = _bart_cov2(ell, ells)
47
+ print("\nBART COV 2")
48
+ # print(np.array(ell2.cov.semi_axis_vec))
49
+ print(ell4.cov)
50
+ print(ell4.ellipse)
51
+
52
+ return ell3
53
+
54
+
55
+ def _position(ells: Sequence[Ell] | Generator[Ell]) -> Ell:
56
+ """Get the position of the convolved point with direct covariance."""
57
+ # 3Nx3 identity stack
58
+ a = np.vstack([np.identity(3) for _ in ells])
59
+ # 3Nx3N diagonalized information matrices
60
+ w = block_diag(*(ell.info for ell in ells))
61
+ # 3Nx1 xyz positions
62
+ y = np.concatenate([e.xyz for e in ells])
63
+ atw = a.T @ w # 3x3N
64
+ atwa = atw @ a # 3x3
65
+ atwy = atw @ y # 3x1
66
+ atwai = np.linalg.inv(atwa) # 3x3
67
+ xyz = atwai @ atwy # 3x1
68
+ atwai = conversion.xyz_to_enu_cov(xyz, atwai)
69
+ return Ell.COV(Pos.XYZ(xyz), atwai)
70
+
71
+
72
+ def _std_cov(pos: Pos, ellipsoids: Sequence[Ell] | Generator[Ell]) -> Ell:
73
+ """Get the covariance the standard way."""
74
+ norm = 0
75
+ s = np.zeros((3, 3), dtype=float)
76
+ s_model = np.zeros((3, 3), dtype=float)
77
+ n = 0
78
+ for e in ellipsoids:
79
+ n += 1
80
+ w = e.info # xyz, 1/m^2, 1 sigma
81
+ s_model += w
82
+ del_xyz = pos.delta_xyz(e)
83
+ norm += float(del_xyz @ w @ del_xyz)
84
+ s += np.outer(del_xyz, del_xyz)
85
+ s_model = np.linalg.inv(s_model)
86
+ x_2 = norm
87
+ s_mod_inflated = (x_2 + 1) / n * s_model
88
+ s_sample = s / n**2
89
+ cov = s_mod_inflated + s_sample
90
+ cov = conversion.xyz_to_enu_cov(pos.xyz, cov)
91
+ return Ell.COV(pos, cov)
92
+
93
+
94
+ def _bart_cov(pos: Pos, ellipsoids: Sequence[Ell] | Generator[Ell]) -> Ell:
95
+ """Get the sample_smi variation of covariance."""
96
+ norm = 0
97
+ s = np.zeros((3, 3), dtype=float)
98
+ s_model = np.zeros((3, 3), dtype=float)
99
+ n = 0
100
+ for e in ellipsoids:
101
+ n += 1
102
+ w = e.info # xyz, 1/m^2, 1 sigma
103
+ s_model += w
104
+ del_xyz = pos.delta_xyz(e)
105
+ norm += float(del_xyz @ w @ del_xyz)
106
+ # Bart's correction is to s
107
+ theta = e.ellipse.ori_rad
108
+ del_n1 = (pos.lla.lat_deg - e.lla.lat_deg) * 60 * 1.852 * 1000
109
+ avg_lat = (pos.lla.lat_deg + e.lla.lat_deg) / 2
110
+ del_e1 = (
111
+ (pos.lla.lon_deg - e.lla.lon_deg)
112
+ * 60
113
+ * 1.852
114
+ * np.cos(np.radians(avg_lat))
115
+ * 1000
116
+ )
117
+ del_smi = del_e1 * np.cos(theta) - del_n1 * np.sin(theta)
118
+ del_e = del_smi * np.cos(theta)
119
+ del_n = -del_smi * np.sin(theta)
120
+ enu_vec = np.array((del_e, del_n, pos.lla.alt_m - e.lla.alt_m))
121
+ s += np.outer(enu_vec, enu_vec)
122
+ # End bart's correction
123
+ s_model = np.linalg.inv(s_model)
124
+ x_2 = norm
125
+ s_mod_inflated = (x_2 + 1) / n * s_model
126
+ s_mod_inflated = conversion.xyz_to_enu_cov(pos.xyz, s_mod_inflated)
127
+ s_sample = s / n**2
128
+ cov = s_mod_inflated + s_sample
129
+ return Ell.COV(pos, cov)
130
+
131
+
132
+ def _bart_cov2(pos: Pos, ellipsoids: Sequence[Ell] | Generator[Ell]) -> Ell:
133
+ """Original implementations."""
134
+ # s_model_inflated section
135
+ n = len(list(ellipsoids))
136
+ # chi squared
137
+ chi_squared = 0
138
+ for e in ellipsoids:
139
+ w = e.info # xyz, 1/m^2, 95%
140
+ del_xyz = pos.delta_xyz(e)
141
+ chi_squared += float(del_xyz @ w @ del_xyz)
142
+
143
+ # inflation factor
144
+ inflation_factor = (chi_squared + 1) / n
145
+
146
+ # s model
147
+ s_model = np.zeros((3, 3))
148
+ for e in ellipsoids:
149
+ s_model += e.info
150
+ s_model = np.linalg.inv(s_model)
151
+
152
+ # inflated model
153
+ s_model_inflated = inflation_factor * s_model
154
+
155
+ # rotate from ecef to enu
156
+ s_model_inflated = conversion.xyz_to_enu_cov(pos.xyz, s_model_inflated)
157
+
158
+ # s_sample_minor
159
+ delta_enus = []
160
+ for e in ellipsoids:
161
+ orient_rad = e.ellipse.ori_rad
162
+
163
+ delta_north = 1.852 * 60 * (pos.lla.lat_deg - e.lla.lat_deg) * 1000
164
+ delta_east = (
165
+ 1.852
166
+ * 60
167
+ * (pos.lla.lon_deg - e.lla.lon_deg)
168
+ * np.cos(np.radians((pos.lla.lat_deg + e.lla.lat_deg) / 2))
169
+ * 1000
170
+ )
171
+
172
+ delta_smi = delta_east * np.cos(orient_rad) - delta_north * np.sin(orient_rad)
173
+
174
+ delta_e = delta_smi * np.cos(orient_rad)
175
+ delta_n = -delta_smi * np.sin(orient_rad)
176
+ delta_up = pos.lla.alt_m - e.lla.alt_m
177
+
178
+ delta_enus.append(np.array([[delta_e], [delta_n], [delta_up]]))
179
+
180
+ s_sample_minor = 0
181
+ for enu in delta_enus:
182
+ s_sample_minor += enu @ enu.T
183
+ s_sample_minor /= n**2
184
+
185
+ cov = s_model_inflated + s_sample_minor
186
+ return Ell.COV(pos, cov)
@@ -0,0 +1,260 @@
1
+ """Find multiple convolved points with outlier detection."""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ import numpy as np
6
+ from gri_ell import Ell
7
+ from gri_pos import Pos
8
+
9
+ from .smart_convolve import smart_convolve
10
+
11
+ if TYPE_CHECKING:
12
+ from collections.abc import Callable, Generator, Sequence
13
+
14
+ from gri_pos import LLA
15
+
16
+
17
+ def cluster_convolve( # noqa: PLR0913
18
+ ellipsoids: Sequence[Ell] | Generator[Ell],
19
+ *,
20
+ max_norm: float = 2,
21
+ min_pts: int = 3,
22
+ max_pts: int | None = None,
23
+ min_sma_m: float = 0,
24
+ max_ori_spread: bool = True,
25
+ alt_post_process: Callable[[Sequence[tuple[LLA, list[LLA]]]], Sequence[float]]
26
+ | None = None,
27
+ alt_post_process_fudge_m: float = 0,
28
+ # Can give a function that does the spline in utils, can take nearest, nearest+int,
29
+ # spline, spline+int # spline would need scipy in utils ... do we want that? Probs.
30
+ # alt can be handled by:
31
+ # dted: https://pypi.org/project/dted/
32
+ # elevation: https://pypi.org/project/elevation/
33
+ # google: https://developers.google.com/maps/documentation/javascript/elevation
34
+ # https://airsci.com/google-maps-elevation-api-python-example/
35
+ ) -> tuple[list[Ell], list[list[int]], list[int]]:
36
+ """Find the convolved point location from all the points.
37
+
38
+ Returns a list of Ell convolved locations, the list of Ells per position that were
39
+ used by that location (such that return[1][1] is the list of Ells in return[1]), and
40
+ a list of remaining / discarded Ells.
41
+
42
+ Args:
43
+ ellipsoids(Sequence[Ell]|Generator[Ell]): A set of ell objects. Uses xyz and
44
+ info.
45
+ max_norm(float, optional): Maximum mahalanobis distance from convolved point to
46
+ allow. If an input it outside that range (input ell to point distance), add
47
+ it to the discard list and return it. Defaults to 2.0
48
+ min_pts(int, optional): Minimum allowed points in a convolved answer. Defaults
49
+ to 3
50
+ max_pts(int, optional): Maximum allowed points in a convolved answer. If there
51
+ are leftover points (eg: 5 points passed, 3 max and 3 min, 2 left over that
52
+ cannot be used), they will be added to discard. This takes more processing.
53
+ min_sma_m(float, optional): Do not allow final convolved sma to go below min
54
+ sma unless min_pts is reached. This will split up convolutions. If there are
55
+ leftover points that cannot be used, they will be added to discard. This
56
+ is iterative and takes a lot more processing.
57
+ max_ori_spread(bool, optional): If set to true, for any clusters that need to be
58
+ split up for min_sma or max_pts, first sort the data by orientation, then
59
+ split into quarters and shuffle so that maximal orientation differences are
60
+ seen by the convolver.
61
+ alt_post_process(Callable[[Sequence[tuple[LLA,Sequence[LLA]]]], Sequence[float]], optional):
62
+ A function that takes a list of (LLA,list[LLA]) where LLA is an np.ndarray
63
+ vector that is latitude degrees, longitude degrees, altitude meters. The
64
+ first element is the convolved location and the second element is the list
65
+ of positions (Ells) as LLAs that went into that location. Sequences are
66
+ required to enable batch processing by the function.
67
+
68
+ It should then output a list of altitudes in the same order as the input
69
+ list.
70
+
71
+ This package provides altitude.nearest, which replaces the Ell altitude with
72
+ the nearest Ell altitude (often a decent estimate).
73
+ alt_post_process_fudge_m(float, optional): Adds a set amount of meters to the
74
+ altitude after any other alt_post_process function.
75
+
76
+ Returns:
77
+ (list[Ell], list[list[used Ells indexes]], list[discarded Ells indexes])
78
+ """ # noqa: E501
79
+ # To keep track of the index, make it a list of tuple[idx,ell]
80
+ ells = [(idx, ell) for idx, ell in enumerate(ellipsoids)]
81
+ if max_ori_spread:
82
+ # sort by ori, split into quarters and shuffle
83
+ ells = _max_spread_ori(ells)
84
+
85
+ locations, loc_ells, discards = _first_pass(ells, max_norm, min_pts)
86
+
87
+ # Now we have the collections that should go together (roughly), let's post process
88
+ reprocess: list[int] = []
89
+ for idx, (loc, loc_ell) in enumerate(zip(locations, loc_ells, strict=False)):
90
+ if (
91
+ max_pts is not None and len(loc_ell) > max_pts
92
+ ) or loc.ellipse.sma_95 < min_sma_m:
93
+ reprocess.append(idx)
94
+ for idx in reprocess:
95
+ new_locs, new_ells, new_discards = _reprocess(
96
+ [ells[i] for i in loc_ells[idx]],
97
+ max_norm,
98
+ min_pts,
99
+ max_pts,
100
+ min_sma_m,
101
+ max_ori_spread,
102
+ )
103
+ locations.extend(new_locs)
104
+ loc_ells.extend(new_ells)
105
+ discards.extend(new_discards)
106
+ for idx in reprocess[::-1]:
107
+ del locations[idx]
108
+ del loc_ells[idx]
109
+
110
+ # Adjust the altitude with a post process function if supplied and add any
111
+ # fudge altitude distances
112
+ if alt_post_process is not None or alt_post_process_fudge_m != 0:
113
+ locations = _fix_alts(
114
+ ells,
115
+ locations,
116
+ loc_ells,
117
+ alt_post_process,
118
+ alt_post_process_fudge_m,
119
+ )
120
+
121
+ # Sort the index lists
122
+ discards.sort()
123
+ loc_ells = [sorted(idxs) for idxs in loc_ells]
124
+ # Now sort by min ell sma (smallest to largest)
125
+ if len(locations) > 0:
126
+ tups = sorted(
127
+ zip(locations, loc_ells, strict=True),
128
+ key=lambda x: x[0].ellipse.sma_95,
129
+ )
130
+ locations, loc_ells = zip(*tups, strict=True)
131
+ return list(locations), list(loc_ells), discards
132
+
133
+
134
+ def _max_spread_ori(ells: list[tuple[int, Ell]]) -> list[tuple[int, Ell]]:
135
+ """Spread the data into quarters by ori."""
136
+ ells = sorted(ells, key=lambda x: x[1].ellipse.ori_deg)
137
+ a = np.array([e[0] for e in ells], dtype=int)
138
+ add = np.array([-1] * int(np.ceil(len(a) / 4) * 4 - len(a)), dtype=int)
139
+ a = np.append(a, add)
140
+ a = a.reshape((4, -1))
141
+ a = a.T.flatten()
142
+ a = a[a >= 0]
143
+ return [ells[idx] for idx in a]
144
+
145
+
146
+ def _reprocess( # noqa: PLR0913
147
+ loc_ells: list[tuple[int, Ell]],
148
+ max_norm: float = 2,
149
+ min_pts: int = 3,
150
+ max_pts: int | None = None,
151
+ min_sma_m: float = 0,
152
+ max_ori_spread: bool = False, # noqa: FBT001, FBT002
153
+ ) -> tuple[list[Ell], list[list[int]], list[int]]:
154
+ """Find any indexes (loc,loc_ells) that we need to reprocess for max_pts or min_sma.
155
+
156
+ Reprocess them in place. Remove from lists, add to tail end with new answers.
157
+ """
158
+ if max_ori_spread:
159
+ # respread
160
+ loc_ells = _max_spread_ori(loc_ells)
161
+
162
+ if max_pts is None:
163
+ max_pts = len(loc_ells)
164
+
165
+ start = 0
166
+ stop = max_pts
167
+ ells = [ell for _, ell in loc_ells]
168
+ new_locs: list[Ell] = []
169
+ new_loc_ells: list[list[int]] = []
170
+ new_discarded: list[int] = []
171
+ while stop - start >= min_pts:
172
+ ans = smart_convolve(
173
+ ells[start:stop],
174
+ max_norm=max_norm,
175
+ min_pts=min_pts,
176
+ )
177
+ if ans is None:
178
+ # odd case where we can't find an answer within max_pts but could with
179
+ # the whole number of ells. Skip this batch
180
+ start = stop
181
+ stop = min(start + max_pts + 1, len(ells))
182
+ new_discarded.extend(list(range(start, stop)))
183
+ continue
184
+ ell, used, discard = ans
185
+ if min_sma_m > 0 and ell.ellipse.sma_95 < min_sma_m and stop - start > min_pts:
186
+ stop -= 1
187
+ continue
188
+ # discards are unlikely in this case, just skip handling them
189
+ new_discarded.extend([loc_ells[d + start][0] for d in discard])
190
+ new_locs.append(ell)
191
+ new_loc_ells.append([loc_ells[u + start][0] for u in used])
192
+ start = stop
193
+ stop = min(start + max_pts, len(ells))
194
+ # Add any unused at the tail
195
+ new_discarded.extend(range(start, stop))
196
+ return new_locs, new_loc_ells, new_discarded
197
+
198
+
199
+ def _fix_alts(
200
+ ells: list[tuple[int, Ell]],
201
+ locations: list[Ell],
202
+ loc_ells: list[list[int]],
203
+ alt_post_process: Callable[[Sequence[tuple[LLA, list[LLA]]]], Sequence[float]]
204
+ | None = None,
205
+ alt_post_process_fudge_m: float = 0,
206
+ ) -> list[Ell]:
207
+ """Use the provided function to adjust altitudes, and add any fudge factor."""
208
+ # Get base alts
209
+ if alt_post_process is not None:
210
+ # Convert to LLAs
211
+ pos = [e.lla for e in locations]
212
+ pos_lists = [[ells[idx][1].lla for idx in idx_list] for idx_list in loc_ells]
213
+ # Get alts
214
+ alts = alt_post_process(list(zip(pos, pos_lists, strict=True)))
215
+ else:
216
+ # Get existing alts
217
+ alts = [e.lla.alt_m for e in locations]
218
+ # Replace alts
219
+ new_ell: list[Ell] = []
220
+ for alt, ell in zip(alts, locations, strict=True):
221
+ new_ell.append(
222
+ Ell.COV(
223
+ Pos.LLA(
224
+ ell.lla.lat_deg,
225
+ ell.lla.lon_deg,
226
+ alt + alt_post_process_fudge_m,
227
+ ),
228
+ ell.cov,
229
+ ),
230
+ )
231
+ return new_ell
232
+
233
+
234
+ def _first_pass(
235
+ ells: list[tuple[int, Ell]],
236
+ max_norm: float = 2,
237
+ min_pts: int = 3,
238
+ ) -> tuple[list[Ell], list[list[int]], list[int]]:
239
+ """First smart convolve pass.
240
+
241
+ Get all convolved locations to cluster data regardless of max size or min sma.
242
+ """
243
+ locations: list[Ell] = []
244
+ loc_ells: list[list[int]] = []
245
+ # To keep track of the index, make it a list of tuple[idx,ell]
246
+ # First pass
247
+ while ans := smart_convolve(
248
+ [ell for _, ell in ells],
249
+ max_norm=max_norm,
250
+ min_pts=min_pts,
251
+ ):
252
+ ell, used, discard = ans
253
+ # Add location
254
+ locations.append(ell)
255
+ # Add the original indexes to the location ell
256
+ loc_ells.append([e[0] for idx, e in enumerate(ells) if idx in used])
257
+ # redo ells list to be the remainint tuples of unused ells
258
+ ells = [e for idx, e in enumerate(ells) if idx in discard]
259
+
260
+ return locations, loc_ells, [e[0] for e in ells]
@@ -0,0 +1,88 @@
1
+ """Convolution of all points.
2
+
3
+ Find the center of the points via convolution and find the error region via different
4
+ inflation methods.
5
+ """
6
+
7
+ from typing import TYPE_CHECKING, Literal
8
+
9
+ import numpy as np
10
+ from gri_ell import Ell
11
+ from gri_pos import Pos
12
+ from gri_utils import conversion
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Sequence
16
+
17
+
18
+ def convolve(
19
+ ellipsoids: Sequence[Ell],
20
+ *,
21
+ inflation: Literal["none", "std", "bart"] = "bart",
22
+ ) -> Ell:
23
+ """Find the convolved point location from all the points.
24
+
25
+ Does not throw away any points. Choose an inflation technique to estimate the error
26
+ region.
27
+
28
+ Args:
29
+ ellipsoids: A set of ell objects. Uses xyz and info.
30
+ inflation: "none" performs strict covariance calculations by combining
31
+ information matrices. "std" inflates covariance based on the sample
32
+ distribution, "bart" inflates covariances based on SMI reduction rules.
33
+
34
+ Returns:
35
+ Ell
36
+ """
37
+ # Find the position and basic uninflated covariance
38
+ s_model = np.add.reduce([e.info for e in ellipsoids]) # xyz, 1/m^2, 1-sigma
39
+ s_var = np.add.reduce([e.info @ e.xyz for e in ellipsoids])
40
+ cov = np.linalg.inv(s_model)
41
+ pos = Pos.XYZ(cov @ s_var)
42
+ ell = Ell.COV(pos, cov)
43
+ if inflation == "none":
44
+ return ell
45
+
46
+ # Begin normalized and smi-normalized inflation of covariance
47
+ s_model = cov
48
+ n = len(ellipsoids)
49
+ x_2 = 0
50
+ s = np.zeros((3, 3), dtype=float)
51
+ for e in ellipsoids:
52
+ del_xyz = pos.delta_xyz(e)
53
+ x_2 += float(del_xyz @ e.info @ del_xyz)
54
+ if inflation == "std":
55
+ s += np.outer(del_xyz, del_xyz)
56
+ elif inflation == "bart":
57
+ theta = e.ellipse.ori_rad
58
+
59
+ delta_north = 1.852 * 60 * 1000 * (pos.lla.lat_deg - e.lla.lat_deg)
60
+ delta_east = (
61
+ 1.852
62
+ * 60
63
+ * 1000
64
+ * (pos.lla.lon_deg - e.lla.lon_deg)
65
+ * np.cos(np.radians((pos.lla.lat_deg + e.lla.lat_deg) / 2))
66
+ )
67
+
68
+ delta_smi = delta_east * np.cos(theta) - delta_north * np.sin(theta)
69
+
70
+ del_e = delta_smi * np.cos(theta)
71
+ del_n = -delta_smi * np.sin(theta)
72
+ del_u = pos.lla.alt_m - e.lla.alt_m
73
+
74
+ enu_vec = np.array((del_e, del_n, del_u))
75
+
76
+ s += np.outer(enu_vec, enu_vec)
77
+ else:
78
+ raise ValueError("Bad inflation setting")
79
+ s_sample = s / n**2
80
+ s_mod_inflated = (x_2 + 1) / n * s_model
81
+ # s_mod_inflated is in xyz
82
+ # std s_sample is in xyz, bart s_sample is in enu
83
+ if inflation == "std":
84
+ cov = conversion.xyz_to_enu_cov(pos.xyz, s_mod_inflated + s_sample)
85
+ else:
86
+ s_mod_inflated = conversion.xyz_to_enu_cov(pos.xyz, s_mod_inflated)
87
+ cov = s_mod_inflated + s_sample
88
+ return Ell.COV(pos, cov)
@@ -0,0 +1,72 @@
1
+ """Find convolved point with outlier detection."""
2
+
3
+ from typing import TYPE_CHECKING
4
+
5
+ import numpy as np
6
+ from gri_utils import constants
7
+
8
+ from .convolve import convolve
9
+
10
+ if TYPE_CHECKING:
11
+ from collections.abc import Generator, Sequence
12
+
13
+ from gri_ell import Ell
14
+
15
+
16
+ def smart_convolve(
17
+ ellipsoids: Sequence[Ell] | Generator[Ell],
18
+ *,
19
+ max_norm: float = 2,
20
+ min_pts: int = 3,
21
+ ) -> None | tuple[Ell, list[int], list[int]]:
22
+ """Find the convolved point location from all the points.
23
+
24
+ Returns an Ell (or None), the list of Ells (by index) used to make that
25
+ ellipsoid, and the remaining Ells (by index) that were excluded.
26
+
27
+ Useful to tell all the points that should go together (by norm) and remove the
28
+ points that do not belong.
29
+
30
+ Note that outlier convolve can (incorrectly) return no answer when an answer does
31
+ exist if the data is not pre-clustered decently. EG: a valid cluster of five points
32
+ and a invalid group of twenty points further away that does not convolve will
33
+ exclude the five points first by norm, then exclude each other. Roughly pre-cluster
34
+ your data before using this function.
35
+
36
+ Args:
37
+ ellipsoids(Sequence[Ell]|Generator[Ell]): A set of ell objects. Uses xyz and
38
+ info.
39
+ max_norm(float, optional): Maximum ellipse norm distance from convolved point to
40
+ allow. If an input it outside that range (input ell to point distance), add
41
+ it to the discard list and return it. Defaults to 2.0
42
+ min_pts(int, optional): Minimum allowed points in a convolved answer. Defaults
43
+ to 3
44
+
45
+ Returns:
46
+ tuple[Ell, list[used Ells idx], list[discarded Ells idx]] or None
47
+ """
48
+ # To keep track of the index, make it a list of tuple[idx,ell]
49
+ ells = [(idx, ell) for idx, ell in enumerate(ellipsoids)]
50
+ # Verify length is minimal
51
+ if len(ells) < min_pts:
52
+ return None
53
+ unused: list[int] = []
54
+ # loop as long as we have enough ells until none are discarded or all are discarded.
55
+ while len(ells) >= min_pts:
56
+ ell, discard_idx = _test_norm([ell for _, ell in ells], max_norm)
57
+ if discard_idx is None:
58
+ # Answer found!
59
+ return ell, [idx for idx, _ in ells], unused
60
+ # Remove index from ells and add to unused
61
+ unused.append(ells[discard_idx][0])
62
+ del ells[discard_idx]
63
+ # Not enough left and no answer found
64
+ return None
65
+
66
+
67
+ def _test_norm(ells: list[Ell], max_norm: float) -> tuple[Ell, int | None]:
68
+ """Get a discarded index at max norm distance or None if all are contained."""
69
+ ell = convolve(ells, inflation="none")
70
+ norms = [e.dist_mahalanobis(ell) / constants.SIG_TO_95_3D**0.5 for e in ells]
71
+ idx = int(np.argmax(norms))
72
+ return ell, idx if norms[idx] > max_norm else None
gri_convolve/py.typed ADDED
File without changes
@@ -0,0 +1,175 @@
1
+ Metadata-Version: 2.4
2
+ Name: gri-convolve
3
+ Version: 0.2.0
4
+ Project-URL: repository, https://gitlab.com/geosol-foss/python/gri-convolve
5
+ Project-URL: homepage, https://geosolresearch.com
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.14
9
+ Requires-Dist: gri-ell
10
+ Requires-Dist: gri-pos
11
+ Requires-Dist: gri-utils
12
+ Requires-Dist: numpy>=2.3.3
13
+ Description-Content-Type: text/markdown
14
+
15
+ [![GeoSol Research Logo](https://geosolresearch.com/logos/foss_logo.png "GeoSol Research")](https://geosolresearch.com)
16
+
17
+ # Convolve (Ellipsoid Fusion)
18
+
19
+ Ellipsoid convolution functions for combining geolocation estimates with outlier detection and multi-cluster support.
20
+
21
+ ## Overview
22
+
23
+ gri-convolve provides three functions of increasing sophistication for fusing collections of `Ell` (ellipsoid) objects into combined position estimates:
24
+
25
+ - **`convolve`** -- combine all input ellipsoids into a single fused result with no outlier rejection
26
+ - **`smart_convolve`** -- iteratively remove outliers by Mahalanobis distance before fusing
27
+ - **`cluster_convolve`** -- find multiple clusters within a dataset and fuse each independently
28
+
29
+ Each function operates on `Ell` objects from gri-ell, which pair a 3D position with a statistical covariance (or information matrix). The output is one or more fused `Ell` objects representing the combined position estimate and its uncertainty.
30
+
31
+ Requires Python 3.14+.
32
+
33
+ ## Mathematical Background
34
+
35
+ **Information matrix fusion.** Given N ellipsoids, each with position `x_k` and information matrix `I_k` (the inverse of the covariance matrix, in XYZ coordinates, 1/m^2, 1-sigma), the fused position and information matrix are:
36
+
37
+ S = sum(I_k) (combined information matrix)
38
+ x = S^{-1} sum(I_k x_k) (fused position)
39
+
40
+ This is the maximum-likelihood estimator under Gaussian assumptions.
41
+
42
+ **Inflation methods.** The raw fusion above underestimates uncertainty when inputs are inconsistent. Three modes control how the output covariance is inflated:
43
+
44
+ - `"none"` -- strict information matrix combination (no inflation)
45
+ - `"std"` -- inflate by the sample scatter of input positions in XYZ
46
+ - `"bart"` -- inflate along the semi-major axis direction in ENU (recommended default)
47
+
48
+ **Outlier detection.** `smart_convolve` computes the Mahalanobis distance from each input to the fused point:
49
+
50
+ d_M = sqrt((x - mu)^T I (x - mu))
51
+
52
+ where `mu` is the fused position and `I` is its information matrix. Distances are normalized to 95% confidence scale. Inputs exceeding `max_norm` are iteratively removed, worst first.
53
+
54
+ Reference: Mahalanobis, P.C. (1936). "On the generalized distance in statistics."
55
+
56
+ ## Installation
57
+
58
+ ```bash
59
+ pip install gri-convolve
60
+ ```
61
+
62
+ For development:
63
+
64
+ ```bash
65
+ git clone https://gitlab.com/geosol-foss/python/gri-convolve.git
66
+ cd gri-convolve
67
+ . .init_venv.sh
68
+ ```
69
+
70
+ ## Quick Start
71
+
72
+ ```python
73
+ from gri_convolve import convolve, smart_convolve, cluster_convolve
74
+ from gri_ell import Ell
75
+ from gri_pos import Pos
76
+ import numpy as np
77
+
78
+ # Create some ellipsoids at nearby positions
79
+ e1 = Ell.from_2d(Pos.LLA(40.0, -105.0, 1600), 100, 50, 45)
80
+ e2 = Ell.from_2d(Pos.LLA(40.001, -105.001, 1610), 120, 60, 30)
81
+ e3 = Ell.from_2d(Pos.LLA(40.0005, -104.999, 1605), 90, 45, 50)
82
+
83
+ # Simple fusion
84
+ fused = convolve([e1, e2, e3])
85
+ print(fused.lla) # Fused position
86
+ print(fused.ellipse.sma_95) # Fused semi-major axis (95%, meters)
87
+ ```
88
+
89
+ ## `convolve()`
90
+
91
+ Fuses all input ellipsoids into a single result. No outlier detection.
92
+
93
+ ```python
94
+ fused = convolve(ellipsoids, inflation="bart")
95
+ ```
96
+
97
+ **Parameters:**
98
+
99
+ - `ellipsoids` -- sequence of `Ell` objects
100
+ - `inflation` -- `"none"`, `"std"`, or `"bart"` (default: `"bart"`)
101
+
102
+ **Returns:** A single fused `Ell`.
103
+
104
+ ## `smart_convolve()`
105
+
106
+ Fuses with iterative outlier rejection. Computes the fused point, finds the input with the largest normalized Mahalanobis distance, and removes it if it exceeds `max_norm`. Repeats until all remaining inputs are within tolerance or fewer than `min_pts` remain.
107
+
108
+ ```python
109
+ result = smart_convolve(ellipsoids, max_norm=2.0, min_pts=3)
110
+ if result is not None:
111
+ fused_ell, used_indices, discarded_indices = result
112
+ ```
113
+
114
+ **Parameters:**
115
+
116
+ - `ellipsoids` -- sequence of `Ell` objects
117
+ - `max_norm` -- maximum allowed normalized distance (default: 2.0)
118
+ - `min_pts` -- minimum inputs required for a valid result (default: 3)
119
+
120
+ **Returns:** `(Ell, list[int], list[int])` or `None` if no valid cluster is found.
121
+
122
+ Pre-cluster your data before calling `smart_convolve`. Without pre-clustering, a large group of scattered noise points can cause valid clusters to be discarded first.
123
+
124
+ ## `cluster_convolve()`
125
+
126
+ Finds multiple clusters within a dataset by iteratively applying `smart_convolve`. After finding the largest valid cluster, the discarded points are passed back in to find additional clusters.
127
+
128
+ ```python
129
+ locations, used_per_location, discarded = cluster_convolve(
130
+ ellipsoids,
131
+ max_norm=2.0,
132
+ min_pts=3,
133
+ max_pts=10,
134
+ min_sma_m=50.0,
135
+ )
136
+
137
+ for loc, indices in zip(locations, used_per_location):
138
+ print(f"Cluster at {loc.lla} using {len(indices)} inputs")
139
+ ```
140
+
141
+ **Parameters:**
142
+
143
+ - `ellipsoids` -- sequence of `Ell` objects
144
+ - `max_norm` -- maximum normalized Mahalanobis distance (default: 2.0)
145
+ - `min_pts` -- minimum inputs per cluster (default: 3)
146
+ - `max_pts` -- maximum inputs per cluster; splits larger groups (default: None)
147
+ - `min_sma_m` -- minimum semi-major axis for output ellipsoids in meters (default: 0)
148
+ - `max_ori_spread` -- sort by orientation before splitting for diversity (default: True)
149
+ - `alt_post_process` -- callback for altitude correction (e.g., snap to terrain)
150
+
151
+ **Returns:** `(list[Ell], list[list[int]], list[int])`
152
+
153
+ ## Units and Conventions
154
+
155
+ - Positions are in ECEF XYZ (meters) internally
156
+ - Information matrices are in XYZ, 1/m^2, 1-sigma
157
+ - Covariance matrices are in ENU, m^2, 1-sigma
158
+ - Output ellipse parameters (SMA, SMI, orientation) are at 95% confidence
159
+ - Mahalanobis distances are normalized to 95% scale for `max_norm` comparisons
160
+
161
+ ## Dependencies
162
+
163
+ - **gri-ell**: Ellipsoid objects with position and covariance
164
+ - **gri-pos**: Position objects (XYZ, LLA coordinates)
165
+ - **gri-utils**: Coordinate conversions and constants
166
+ - **numpy**: Array operations
167
+
168
+
169
+ ## Other Projects
170
+
171
+ Current list of other [GRI FOSS Projects](.docs_other_projects.md) we are building and maintaining.
172
+
173
+ ## License
174
+
175
+ MIT License. See [LICENSE](LICENSE) for details.
@@ -0,0 +1,13 @@
1
+ gri_convolve/__init__.py,sha256=UAP0TyrpUN1IvpcIikEv_pgp8nPs6QQhsEBz_KVQ5-I,175
2
+ gri_convolve/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ gri_convolve/altitude/__init__.py,sha256=la-somGa2CQ2jv0D13fMFuQVIeWrBA-TdQVebCxpejY,107
4
+ gri_convolve/altitude/nearest.py,sha256=JX1kU0rS0kGAw-euofynnyDsDjTPIi-ji67qOrtd6Co,820
5
+ gri_convolve/convolve/__init__.py,sha256=zDdlA3mhvg0CeylrN3hec1f-RhQ5Ugr7ao5lkA76hGo,242
6
+ gri_convolve/convolve/atwa_convolve.py,sha256=wm_VpJkWL05yPt3Jl3vTIR--TL55OjinQ19m7PEcA_Q,5563
7
+ gri_convolve/convolve/cluster_convolve.py,sha256=kfHgLYCrN00ze52FeWz6QGPTqLt947n_HssgLjVPbcE,10142
8
+ gri_convolve/convolve/convolve.py,sha256=homB2MrfGabMslD6qbP93miK9NMU4eaKisyLbeRC7MQ,2851
9
+ gri_convolve/convolve/smart_convolve.py,sha256=k5MAgXL6aLoFisl2oIR5E7qaHQ6w-uFt6NwxdYUDO4w,2809
10
+ gri_convolve-0.2.0.dist-info/METADATA,sha256=D3KxLJyZsUM_CxM4dDtOLl4KrPi6Yofzp6jN3JFQhKA,6382
11
+ gri_convolve-0.2.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
12
+ gri_convolve-0.2.0.dist-info/licenses/LICENSE,sha256=Noh51pACFQty7ATtDK2D_6HNrc_UIcsyCFYJY0I9zEM,1077
13
+ gri_convolve-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.28.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 GeoSol Research Inc.
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.