ruststartracker 0.2.9__tar.gz → 0.2.10__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ruststartracker
3
- Version: 0.2.9
3
+ Version: 0.2.10
4
4
  Summary: Lightweight Python Star Tracker With Rust Backend
5
5
  License: MIT
6
6
  License-File: LICENSE
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "ruststartracker"
3
- version = "0.2.9"
3
+ version = "0.2.10"
4
4
  description = "Lightweight Python Star Tracker With Rust Backend"
5
5
  authors = ["Nicolas Tobler <nitobler@gmail.com>"]
6
6
  readme = "README.md"
@@ -44,7 +44,7 @@ requires = ["poetry-core", "astroquery>=0.4.10"]
44
44
  build-backend = "poetry.core.masonry.api"
45
45
 
46
46
  [tool.bumpversion]
47
- current_version = "0.2.9"
47
+ current_version = "0.2.10"
48
48
  commit = true
49
49
  tag = true
50
50
  tag_name = "v{new_version}"
@@ -1,5 +1,3 @@
1
- from collections.abc import Iterator
2
-
3
1
  import numpy as np
4
2
  import numpy.typing as npt
5
3
  from typing_extensions import Self
@@ -26,24 +24,6 @@ class StarMatcher:
26
24
  float,
27
25
  ]: ...
28
26
 
29
- class TriangleFinder:
30
- def __init__(
31
- self,
32
- ab: npt.NDArray[np.float32],
33
- ac: npt.NDArray[np.float32],
34
- bc: npt.NDArray[np.float32],
35
- ) -> None: ...
36
- def get(self) -> list[int]: ...
37
-
38
- class IterTriangleFinder:
39
- def __init__(
40
- self,
41
- ab: npt.NDArray[np.float32],
42
- ac: npt.NDArray[np.float32],
43
- bc: npt.NDArray[np.float32],
44
- ) -> None: ...
45
- def __iter__(self) -> Iterator[list[int]]: ...
46
-
47
27
  class UnitVectorLookup:
48
28
  def __init__(self, vec: npt.NDArray[np.float32]) -> None: ...
49
29
  def lookup_nearest(self, key: npt.NDArray[np.float32]) -> int: ...
@@ -53,7 +33,9 @@ class UnitVectorLookup:
53
33
  magnitudes: npt.NDArray[np.float32],
54
34
  max_angle_rad: float,
55
35
  max_magnitude: float,
56
- ) -> tuple[list[list[int]], list[float], list[float]]: ...
36
+ inter_star_angle: float,
37
+ tolerance_angle: float,
38
+ ) -> tuple[list[list[int]], list[float], list[float], list[list[int]]]: ...
57
39
  def look_up_close_angles(
58
40
  self,
59
41
  vectors: npt.NDArray[np.float32],
@@ -0,0 +1,257 @@
1
+ import os
2
+
3
+ import numpy as np
4
+ import pytest
5
+ import scipy.spatial
6
+
7
+ from ruststartracker import libruststartracker
8
+
9
+
10
+ def test_lookup_nearest():
11
+ rng = np.random.default_rng(42)
12
+ n_vecs = 2617
13
+
14
+ vec = rng.normal(size=[n_vecs, 3]).astype(np.float32)
15
+ vec /= np.linalg.norm(vec, axis=-1, keepdims=True)
16
+
17
+ uvl = libruststartracker.UnitVectorLookup(vec)
18
+
19
+ keys = rng.normal(size=[10, 3]).astype(np.float32)
20
+ keys /= np.linalg.norm(keys, axis=-1, keepdims=True)
21
+
22
+ for key in keys:
23
+ np.testing.assert_array_equal(
24
+ uvl.lookup_nearest(key),
25
+ np.linalg.norm(vec - key, axis=-1).argmin().item(),
26
+ )
27
+
28
+
29
+ def test_close_angle_lookup():
30
+ rng = np.random.default_rng(42)
31
+ n_vecs = 2617
32
+
33
+ vec = rng.normal(size=[n_vecs, 3]).astype(np.float32)
34
+ vec /= np.linalg.norm(vec, axis=-1, keepdims=True)
35
+
36
+ uvl = libruststartracker.UnitVectorLookup(vec)
37
+
38
+ angle_threshold = np.radians(15)
39
+
40
+ threshold = np.cos(angle_threshold).item()
41
+ pairs_gt = []
42
+ angles_gt = []
43
+ for a in range(len(vec)):
44
+ dotp = np.sum(vec[a] * vec[a + 1 :], axis=-1)
45
+ b = np.nonzero(dotp >= threshold)[0]
46
+ pairs_gt.append(np.array([np.full(len(b), a), (a + 1) + b]))
47
+ angles_gt.append(np.arccos(dotp[b]))
48
+ angles_gt = np.concatenate(angles_gt, axis=0)
49
+ pairs_gt = np.concatenate(pairs_gt, axis=-1).T
50
+ args = np.argsort(angles_gt)
51
+ pairs_gt = pairs_gt[args]
52
+ angles_gt = angles_gt[args]
53
+
54
+ res = uvl.look_up_close_angles(
55
+ np.array(vec, dtype=np.float32),
56
+ np.ones(len(vec), dtype=np.float32),
57
+ np.cos(angle_threshold).item(),
58
+ 10,
59
+ )
60
+ pairs = np.array([r[0] for r in res])
61
+ angles_proxy = np.array([r[1] for r in res])
62
+ angles = np.arccos(angles_proxy)
63
+ args = np.argsort(angles)
64
+ pairs = pairs[args]
65
+ angles = angles[args]
66
+
67
+ # Check if the same angles are returned (order has already been normalized by the sorting)
68
+ np.testing.assert_allclose(angles, angles_gt)
69
+
70
+ # There are some cases where the float32 accuracy is insufficient to tell
71
+ # angles apart. Consequently the order may be slightly different. However,
72
+ # we're able to test if the not-matching indices align with items that have
73
+ # at minimum one other angle of the exact same value
74
+ i = (pairs != pairs_gt).any(axis=-1)
75
+ assert np.mean(i) < 0.1, "More than 10 percent of pairs do not match"
76
+ assert (np.unique(angles[i], return_counts=True)[-1] >= 2).all()
77
+
78
+
79
+ def test_get_inter_star_index():
80
+ os.environ["RUST_BACKTRACE"] = "1"
81
+
82
+ rng = np.random.default_rng(42)
83
+ n_vecs = 2617
84
+
85
+ vec = rng.normal(size=[n_vecs, 3]).astype(np.float32)
86
+ vec /= np.linalg.norm(vec, axis=-1, keepdims=True)
87
+
88
+ uvl = libruststartracker.UnitVectorLookup(vec)
89
+
90
+ angle_threshold = np.radians(15)
91
+
92
+ threshold = np.cos(angle_threshold).item()
93
+ pairs_gt = []
94
+ angles_gt = []
95
+ for a in range(len(vec)):
96
+ dotp = np.sum(vec[a] * vec[a + 1 :], axis=-1)
97
+ b = np.nonzero(dotp >= threshold)[0]
98
+ pairs_gt.append(np.array([np.full(len(b), a), (a + 1) + b]))
99
+ angles_gt.append(np.arccos(dotp[b]))
100
+ angles_gt = np.concatenate(angles_gt, axis=0)
101
+ pairs_gt = np.concatenate(pairs_gt, axis=-1).T
102
+ args = np.argsort(angles_gt)
103
+ pairs_gt = pairs_gt[args]
104
+ angles_gt = angles_gt[args]
105
+
106
+ angle_proxy_gt = np.cos(angles_gt)
107
+
108
+ lookup_center = np.radians(7).item()
109
+ lookup_tolerance = np.radians(0.1).item()
110
+
111
+ pairs, angles_proxy, poly, looked_up_pairs = uvl.get_inter_star_index(
112
+ np.array(vec, dtype=np.float32),
113
+ np.ones(len(vec), dtype=np.float32),
114
+ angle_threshold,
115
+ 10,
116
+ lookup_center,
117
+ lookup_tolerance,
118
+ )
119
+ pairs = np.array(pairs)
120
+ angles_proxy = np.array(angles_proxy)
121
+ poly = np.array(poly)[::-1] # Reverse polynomial to match numpy's polyval order
122
+ looked_up_pairs = np.array(looked_up_pairs)
123
+
124
+ # angles are monotonically increasing, so the angle proxy should decreasing
125
+ assert np.diff(angles_proxy).max() <= 0, "Angle proxy is not sorted"
126
+ np.testing.assert_allclose(angles_proxy, angle_proxy_gt, rtol=1e-4, atol=1e-12)
127
+
128
+ # There are some cases where the float32 accuracy is insufficient to tell
129
+ # angles apart. Consequently the order may be slightly different. However,
130
+ # we're able to test if the not-matching indices align with items that have
131
+ # at minimum one other angle of the exact same value
132
+ i = (pairs != pairs_gt).any(axis=-1)
133
+ assert np.mean(i) < 0.1, "More than 10 percent of pairs do not match"
134
+ assert (np.unique(angles_proxy[i], return_counts=True)[-1] >= 2).all()
135
+
136
+ scale = len(pairs_gt) + 1
137
+
138
+ lookup_index = np.polyval(poly, (1.0 - angles_proxy) * 10.0) * scale
139
+
140
+ transformed_angle_proxy_gt = (1.0 - angle_proxy_gt) * 10.0
141
+
142
+ indices = np.arange(angle_proxy_gt.size)
143
+ scaled_indices = indices * (1.0 / scale)
144
+ poly_gt = np.polyfit(transformed_angle_proxy_gt, scaled_indices, 2)
145
+ lookup_index_gt = np.polyval(poly_gt, transformed_angle_proxy_gt) * scale
146
+ max_val = (lookup_index_gt - indices).max()
147
+ min_val = (lookup_index_gt - indices).min()
148
+ print(max_val, min_val)
149
+ poly_gt[-1] -= max_val / scale
150
+ lookup_index_gt = np.polyval(poly_gt, transformed_angle_proxy_gt) * scale
151
+
152
+ if False:
153
+ import matplotlib.pyplot as plt
154
+
155
+ fig, axs = plt.subplots()
156
+ axs.plot(angle_proxy_gt, label="angle proxy gt")
157
+ axs.plot(angles_proxy, label="angle proxy")
158
+ axs.legend()
159
+
160
+ fig, axs = plt.subplots(2, sharex=True)
161
+ axs[0].plot(angle_proxy_gt, lookup_index_gt, "--", label="lookup index gt")
162
+ axs[0].plot(angle_proxy_gt, indices, label="actual index gt")
163
+ axs[0].plot(angles_proxy, lookup_index, "--", label="lookup index")
164
+ axs[0].plot(angles_proxy, indices, label="actual index")
165
+ axs[0].legend()
166
+
167
+ axs[1].plot(angle_proxy_gt, lookup_index_gt - indices, label="lookup error gt")
168
+ axs[1].plot(angles_proxy, lookup_index - indices, label="lookup error")
169
+ axs[1].legend()
170
+
171
+ fig, axs = plt.subplots()
172
+ axs.plot(poly_gt, "x", label="polynomial fit gt")
173
+ axs.plot(poly, "x", label="polynomial fit")
174
+ axs.legend()
175
+ plt.show()
176
+
177
+ np.testing.assert_allclose(lookup_index_gt - indices, lookup_index - indices, atol=0.1)
178
+
179
+ # Fail if the polynomial fit is not accurate enough to represent the angles closely
180
+ assert (
181
+ max_val - min_val < 400
182
+ ), "Polynomial fit is not accurate enough to preserve order of angles"
183
+
184
+ # Check if the polynomial fit is correct
185
+ np.testing.assert_allclose(poly, poly_gt, rtol=1e-3, atol=1e-5)
186
+
187
+ # Check if the looked up pairs are correct
188
+
189
+ mask = (angles_gt > lookup_center - lookup_tolerance) * (
190
+ angles_gt < lookup_center + lookup_tolerance
191
+ )
192
+
193
+ matching_pairs_gt = pairs_gt[mask]
194
+
195
+ # Due to f32 precision limitations and cosines close to 1.0,
196
+ # we may miss some pairs that are very close to the lookup center.
197
+ min_len = min(len(matching_pairs_gt), len(looked_up_pairs))
198
+ assert (
199
+ min_len / len(matching_pairs_gt) > 0.95
200
+ ), "looked up less than 95 percent of the correct pairs"
201
+
202
+ # There are some cases where the float32 accuracy is insufficient to tell
203
+ # angles apart. Consequently the order may be slightly different. However,
204
+ # we're able to test if the not-matching indices align with items that have
205
+ # at minimum one other angle of the exact same value
206
+ i = (matching_pairs_gt[:min_len] != looked_up_pairs[:min_len]).any(axis=-1)
207
+ assert np.mean(i) < 0.1, "More than 10 percent of pairs do not match"
208
+
209
+
210
+ def test_star_matcher():
211
+ rng = np.random.default_rng(42)
212
+
213
+ os.environ["RUST_BACKTRACE"] = "1"
214
+
215
+ n_cat_stars = 2617
216
+
217
+ vec = rng.normal(size=[n_cat_stars, 3]).astype(np.float32)
218
+ vec /= np.linalg.norm(vec, axis=-1, keepdims=True)
219
+
220
+ magnitudes = rng.uniform(0, 10, size=vec.shape[:1]).astype(np.float32)
221
+
222
+ key = rng.normal(size=[3]).astype(np.float32)
223
+ key /= np.linalg.norm(key, axis=-1, keepdims=True)
224
+
225
+ angle_threshold = np.radians(7)
226
+ dotp = np.sum(key * vec, axis=-1)
227
+ threshold = np.cos(angle_threshold).item()
228
+ b = np.nonzero(dotp >= threshold)[0]
229
+ obs_index = rng.permutation(b)
230
+ obs = vec[obs_index]
231
+
232
+ rot = scipy.spatial.transform.Rotation.from_rotvec([1, 1, 1])
233
+
234
+ obs_rotated = rot.apply(obs).astype(np.float32)
235
+
236
+ index = libruststartracker.StarMatcher(
237
+ vec,
238
+ magnitudes,
239
+ 10,
240
+ np.radians(10).item(),
241
+ np.radians(0.1).item(),
242
+ 4,
243
+ 999.0,
244
+ )
245
+
246
+ res = index.find(obs_rotated)
247
+
248
+ assert res is not None
249
+
250
+ quat, match_ids, obs_indices, n_matches, matched_obs, time_s = res
251
+ np.testing.assert_allclose(quat, rot.inv().as_quat(), rtol=1e-6)
252
+ assert n_matches >= 4
253
+ assert len(obs_index) == len(match_ids)
254
+
255
+
256
+ if __name__ == "__main__":
257
+ pytest.main([__file__])
@@ -1,144 +0,0 @@
1
- import os
2
-
3
- import numpy as np
4
- import pytest
5
- import scipy.spatial
6
-
7
- from ruststartracker import libruststartracker
8
-
9
-
10
- def test_triangle_finder():
11
- ab = np.array([[234, 5643], [1, 2], [2, 4], [3, 9], [2, 6]])
12
- ac = np.array([[345, 2343], [8, 2], [3, 4], [1, 7], [0, 5], [3, 1]])
13
- bc = np.array([[435, 4355], [1, 0], [4, 8], [8, 1], [1, 9]])
14
-
15
- f = libruststartracker.TriangleFinder(ab, ac, bc)
16
- assert f.get() == [1, 2, 8]
17
- assert list(libruststartracker.IterTriangleFinder(ab, ac, bc)) == [
18
- [1, 2, 8],
19
- [2, 4, 8],
20
- [3, 9, 1],
21
- ]
22
-
23
-
24
- def test_unit_vector_lookup():
25
- rng = np.random.default_rng(42)
26
- n_vecs = 2617
27
-
28
- vec = rng.normal(size=[n_vecs, 3]).astype(np.float32)
29
- vec /= np.linalg.norm(vec, axis=-1, keepdims=True)
30
-
31
- uvl = libruststartracker.UnitVectorLookup(vec)
32
-
33
- keys = rng.normal(size=[10, 3]).astype(np.float32)
34
- keys /= np.linalg.norm(keys, axis=-1, keepdims=True)
35
-
36
- for key in keys:
37
- np.testing.assert_array_equal(
38
- uvl.lookup_nearest(key),
39
- np.linalg.norm(vec - key, axis=-1).argmin().item(),
40
- )
41
-
42
- angle_threshold = np.radians(15)
43
-
44
- threshold = np.cos(angle_threshold).item()
45
- results = []
46
- angles = []
47
- for a in range(len(vec)):
48
- dotp = np.sum(vec[a] * vec[a + 1 :], axis=-1)
49
- b = np.nonzero(dotp >= threshold)[0]
50
- results.append(np.array([np.full(len(b), a), (a + 1) + b]))
51
- angles.append(np.arccos(dotp[b]))
52
- angles = np.concatenate(angles, axis=0)
53
- args = np.argsort(angles)
54
- close_indices_gt = np.concatenate(results, axis=-1).T[args]
55
- angles_gt = angles[args]
56
-
57
- close_indices, angles, poly = uvl.get_inter_star_index(
58
- np.array(vec[:, :3], dtype=np.float32),
59
- np.ones(len(vec), dtype=np.float32),
60
- angle_threshold,
61
- 10,
62
- )
63
- close_indices = np.array(close_indices)
64
- angles = np.array(angles)
65
- poly = np.array(poly)
66
-
67
- # There are some cases where the float32 accuracy is insufficient to tell
68
- # angles apart. Consequently the order may be alightly different. However,
69
- # we're able to test if the not-matching indices align with items that have
70
- # at minimum one other angle of the exact same value
71
- i = (close_indices != close_indices_gt).any(axis=-1)
72
- assert (np.unique(angles[i], return_counts=True)[-1] >= 2).all()
73
-
74
- np.testing.assert_allclose(angles, angles_gt)
75
-
76
- actual_index = np.arange(angles_gt.size)
77
-
78
- poly_gt = np.polyfit(angles_gt, actual_index, 2)
79
- lookup_index = np.polyval(poly_gt, angles_gt)
80
-
81
- poly_gt[-1] -= (lookup_index - actual_index).max()
82
- lookup_index = np.polyval(poly_gt, angles_gt)
83
-
84
- if False:
85
- import matplotlib.pyplot as plt
86
-
87
- fig, axs = plt.subplots(2)
88
- axs[0].plot(angles, lookup_index, "--")
89
- axs[0].plot(angles, actual_index)
90
- axs[1].plot(lookup_index - actual_index)
91
- fig.savefig("angles.png")
92
- plt.close(fig)
93
-
94
- np.testing.assert_allclose(poly, poly_gt[::-1], rtol=0.001)
95
-
96
-
97
- def test_star_matcher():
98
- rng = np.random.default_rng(42)
99
-
100
- os.environ["RUST_BACKTRACE"] = "1"
101
-
102
- n_cat_stars = 2617
103
-
104
- vec = rng.normal(size=[n_cat_stars, 3]).astype(np.float32)
105
- vec /= np.linalg.norm(vec, axis=-1, keepdims=True)
106
-
107
- magnitudes = rng.uniform(0, 10, size=vec.shape[:1]).astype(np.float32)
108
-
109
- key = rng.normal(size=[3]).astype(np.float32)
110
- key /= np.linalg.norm(key, axis=-1, keepdims=True)
111
-
112
- angle_threshold = np.radians(7)
113
- dotp = np.sum(key * vec, axis=-1)
114
- threshold = np.cos(angle_threshold).item()
115
- b = np.nonzero(dotp >= threshold)[0]
116
- obs_index = rng.permutation(b)
117
- obs = vec[obs_index]
118
-
119
- rot = scipy.spatial.transform.Rotation.from_rotvec([1, 1, 1])
120
-
121
- obs_rotated = rot.apply(obs).astype(np.float32)
122
-
123
- index = libruststartracker.StarMatcher(
124
- vec,
125
- magnitudes,
126
- 10,
127
- np.radians(10).item(),
128
- np.radians(0.1).item(),
129
- 4,
130
- 999.0,
131
- )
132
-
133
- res = index.find(obs_rotated)
134
-
135
- assert res is not None
136
-
137
- quat, match_ids, obs_indices, n_matches, matched_obs, time_s = res
138
- np.testing.assert_allclose(quat, rot.inv().as_quat(), rtol=1e-6)
139
- assert n_matches >= 4
140
- assert len(obs_index) == len(match_ids)
141
-
142
-
143
- if __name__ == "__main__":
144
- pytest.main([__file__])