ruststartracker 0.2.2__tar.gz → 0.2.4__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.3
2
2
  Name: ruststartracker
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: Lightweight Python Star Tracker With Rust Backend
5
5
  License: MIT
6
6
  Author: Nicolas Tobler
@@ -34,6 +34,35 @@ Features:
34
34
 
35
35
  ## Example
36
36
 
37
+ ### Rust
38
+
39
+ See [examples/basic.rs](examples/basic.rs)
40
+
41
+ ```rust
42
+ // Get catalog positions
43
+ let catalog: StarCatalog = StarCatalog::from_gaia(max_magnitude: ...).unwrap();
44
+ let stars_xyz: Vec<[f32; 3]> = catalog.normalized_positions(epoch: ..., observer_position: ...);
45
+ let stars_mag: Vec<f32> = catalog.magnitudes();
46
+
47
+ // Create StarTracker instance (reuse this)
48
+ let star_matcher = StarMatcher::new(
49
+ stars_xyz,
50
+ stars_mag,
51
+ max_lookup_magnitude: ...
52
+ max_inter_star_angle: ...,
53
+ inter_star_angle_tolerance: ...,
54
+ min_matches: ...,
55
+ timeout: ...
56
+ );
57
+
58
+ // Normalized observation in the camera frame
59
+ let obs_xyz_camera: Vec<[f32; 3]> = ...
60
+
61
+ let result = star_matcher.find(&obs_xyz_camera);
62
+ println!("Result: {:?}", result);
63
+ ```
64
+
65
+ ### Python
37
66
  ```python
38
67
  import ruststartracker
39
68
 
@@ -72,11 +101,6 @@ print(result)
72
101
 
73
102
  - Install with `pip install ruststartracker` (Currently only ARM/x86 Linux wheels available).
74
103
 
75
- ## TODOs
76
-
77
- - Improve error messages.
78
- - Return more diagnostic data.
79
-
80
104
  ## Attributions
81
105
 
82
106
  ### Gaia Data
@@ -89,3 +113,14 @@ Gaia DR3 data is © European Space Agency and is released under the [**Creative
89
113
  > Gaia Collaboration, Vallenari et al. (2022), *A\&A* **674**, A1.
90
114
  > [DOI: 10.1051/0004-6361/202243940](https://doi.org/10.1051/0004-6361/202243940)
91
115
 
116
+ ### Hipparcos and Tycho Data
117
+
118
+ This project includes data from the European Space Agency (ESA) mission **Hipparcos**.
119
+
120
+ The Hipparcos and Tycho Catalogues were processed by the Hipparcos and Tycho Data Analysis Consortium.
121
+
122
+ The Hipparcos and Tycho Catalogues are © European Space Agency and are released under the [**Creative Commons Attribution 3.0 IGO (CC BY 3.0 IGO)**](https://creativecommons.org/licenses/by/3.0/igo/) license.
123
+
124
+ > Perryman, M. A. C., et al. (1997), *Astronomy & Astrophysics* **323**, L49-L52.
125
+ 1997A&A...323L..49P
126
+
@@ -13,6 +13,35 @@ Features:
13
13
 
14
14
  ## Example
15
15
 
16
+ ### Rust
17
+
18
+ See [examples/basic.rs](examples/basic.rs)
19
+
20
+ ```rust
21
+ // Get catalog positions
22
+ let catalog: StarCatalog = StarCatalog::from_gaia(max_magnitude: ...).unwrap();
23
+ let stars_xyz: Vec<[f32; 3]> = catalog.normalized_positions(epoch: ..., observer_position: ...);
24
+ let stars_mag: Vec<f32> = catalog.magnitudes();
25
+
26
+ // Create StarTracker instance (reuse this)
27
+ let star_matcher = StarMatcher::new(
28
+ stars_xyz,
29
+ stars_mag,
30
+ max_lookup_magnitude: ...
31
+ max_inter_star_angle: ...,
32
+ inter_star_angle_tolerance: ...,
33
+ min_matches: ...,
34
+ timeout: ...
35
+ );
36
+
37
+ // Normalized observation in the camera frame
38
+ let obs_xyz_camera: Vec<[f32; 3]> = ...
39
+
40
+ let result = star_matcher.find(&obs_xyz_camera);
41
+ println!("Result: {:?}", result);
42
+ ```
43
+
44
+ ### Python
16
45
  ```python
17
46
  import ruststartracker
18
47
 
@@ -51,11 +80,6 @@ print(result)
51
80
 
52
81
  - Install with `pip install ruststartracker` (Currently only ARM/x86 Linux wheels available).
53
82
 
54
- ## TODOs
55
-
56
- - Improve error messages.
57
- - Return more diagnostic data.
58
-
59
83
  ## Attributions
60
84
 
61
85
  ### Gaia Data
@@ -67,3 +91,14 @@ Gaia DR3 data is © European Space Agency and is released under the [**Creative
67
91
 
68
92
  > Gaia Collaboration, Vallenari et al. (2022), *A\&A* **674**, A1.
69
93
  > [DOI: 10.1051/0004-6361/202243940](https://doi.org/10.1051/0004-6361/202243940)
94
+
95
+ ### Hipparcos and Tycho Data
96
+
97
+ This project includes data from the European Space Agency (ESA) mission **Hipparcos**.
98
+
99
+ The Hipparcos and Tycho Catalogues were processed by the Hipparcos and Tycho Data Analysis Consortium.
100
+
101
+ The Hipparcos and Tycho Catalogues are © European Space Agency and are released under the [**Creative Commons Attribution 3.0 IGO (CC BY 3.0 IGO)**](https://creativecommons.org/licenses/by/3.0/igo/) license.
102
+
103
+ > Perryman, M. A. C., et al. (1997), *Astronomy & Astrophysics* **323**, L49-L52.
104
+ 1997A&A...323L..49P
@@ -0,0 +1,126 @@
1
+ """Build rust backend.
2
+
3
+ This script is automatically run when the pyproject is installed.
4
+ """
5
+
6
+ import csv
7
+ import io
8
+ import pathlib
9
+ import shutil
10
+ import subprocess
11
+
12
+
13
+ def download_gaia_data(output_file: pathlib.Path) -> None:
14
+ """Download Gaia data and save it to the specified output file.
15
+
16
+ Args:
17
+ output_file: Path to save the downloaded Gaia data (csv).
18
+ """
19
+ from astroquery.gaia import Gaia
20
+
21
+ Gaia.ROW_LIMIT = -1
22
+
23
+ query = """
24
+ SELECT source_id, ra, dec, parallax, pmra, pmdec, phot_g_mean_mag
25
+ FROM gaiadr3.gaia_source
26
+ WHERE phot_g_mean_mag < 7
27
+ AND parallax > 0
28
+ AND astrometric_params_solved = 31
29
+ AND visibility_periods_used >= 8
30
+ """
31
+
32
+ print("Executing query on Gaia database...")
33
+ job = Gaia.launch_job_async(
34
+ query,
35
+ dump_to_file=True,
36
+ output_format="csv",
37
+ output_file=str(output_file.absolute()),
38
+ verbose=True,
39
+ )
40
+ if not job.is_finished():
41
+ print("Job did not finish successfully.")
42
+ return
43
+
44
+ print(f"Saved to: {output_file}")
45
+
46
+
47
+ def download_hipparcos_data(output_file: pathlib.Path) -> None:
48
+ """Download Hipparcos data and save it to the specified output file.
49
+
50
+ Args:
51
+ output_file: Path to save the downloaded Hipparcos data (csv).
52
+ """
53
+ from astroquery.vizier import Vizier
54
+
55
+ Vizier.ROW_LIMIT = -1
56
+
57
+ print("Executing query on Hipparcos database...")
58
+ query = Vizier.query_constraints(
59
+ catalog=["I/311/hip2"],
60
+ Hpmag="<7",
61
+ )
62
+
63
+ if not query:
64
+ print("Job did not finish successfully.")
65
+ return
66
+
67
+ # Initialize a string buffer to write the CSV data to
68
+ csv_output = io.StringIO()
69
+ writer = csv.writer(csv_output)
70
+
71
+ # Write the header row
72
+ # Use column names that are common in Gaia and Hipparcos
73
+ header = ["source_id", "ra", "dec", "parallax", "pmra", "pmdec", "phot_g_mean_mag"]
74
+ writer.writerow(header)
75
+
76
+ # Iterate over the rows of the Astropy table
77
+ for row in query[0]:
78
+ # Extract the required data points
79
+ source_id = row["HIP"]
80
+ ra = row["RArad"]
81
+ dec = row["DErad"]
82
+ parallax = row["Plx"]
83
+ pmra = row["pmRA"]
84
+ pmdec = row["pmDE"]
85
+ # Use Vmag as the proxy for phot_g_mean_mag
86
+ phot_g_mean_mag = row["Hpmag"]
87
+
88
+ # Create a list for the new row and write it to the CSV writer
89
+ new_row = [source_id, ra, dec, parallax, pmra, pmdec, phot_g_mean_mag]
90
+ writer.writerow(new_row)
91
+
92
+ # Get the complete CSV string from the buffer
93
+ csv_string = csv_output.getvalue()
94
+
95
+ # You can save this string to a file
96
+ with output_file.open("w") as f:
97
+ f.write(csv_string)
98
+
99
+ print(f"Saved to: {output_file}")
100
+
101
+
102
+ def build_script() -> None:
103
+ """Build rust backend and move shared library to correct folder."""
104
+ cwd = pathlib.Path(__file__).parent.expanduser().absolute()
105
+
106
+ gaia_file = cwd / "ruststartracker/gaia_data_j2016.csv"
107
+ if not gaia_file.exists():
108
+ download_gaia_data(gaia_file)
109
+
110
+ hipparcos_file = cwd / "ruststartracker/hipparcos_data_j1991.25.csv"
111
+ if not hipparcos_file.exists():
112
+ download_hipparcos_data(hipparcos_file)
113
+
114
+ subprocess.check_call( # noqa: S603
115
+ ["cargo", "build", "--release", "--features", "improc,gaia,hipparcos"], # noqa: S607
116
+ cwd=cwd,
117
+ stdout=None,
118
+ stderr=None,
119
+ )
120
+ shutil.copy(
121
+ cwd / "target/release/libruststartracker.so", cwd / "ruststartracker/libruststartracker.so"
122
+ )
123
+
124
+
125
+ if __name__ == "__main__":
126
+ build_script()
@@ -1,12 +1,13 @@
1
1
  [tool.poetry]
2
2
  name = "ruststartracker"
3
- version = "0.2.2"
3
+ version = "0.2.4"
4
4
  description = "Lightweight Python Star Tracker With Rust Backend"
5
5
  authors = ["Nicolas Tobler <nitobler@gmail.com>"]
6
6
  readme = "README.md"
7
7
  license = "MIT"
8
8
  include = [
9
9
  { path = "ruststartracker/gaia_data_j2016.csv", format = ["sdist", "wheel"] },
10
+ { path = "ruststartracker/hipparcos_data_j1991.25.csv", format = ["sdist", "wheel"] },
10
11
  { path = "ruststartracker/libruststartracker.so", format = ["wheel"] },
11
12
  ]
12
13
 
@@ -33,6 +34,7 @@ scipy-stubs = "^1.14.1.5"
33
34
  ruff = "^0.9.3"
34
35
  bump-my-version = "^1.2.0"
35
36
  astroquery = "^0.4.10"
37
+ typing-extensions = "^4.12.2"
36
38
 
37
39
  [tool.poetry.build]
38
40
  script = "build_script.py"
@@ -42,7 +44,7 @@ requires = ["poetry-core", "astroquery>=0.4.10"]
42
44
  build-backend = "poetry.core.masonry.api"
43
45
 
44
46
  [tool.bumpversion]
45
- current_version = "0.2.2"
47
+ current_version = "0.2.4"
46
48
  commit = true
47
49
  tag = true
48
50
  tag_name = "v{new_version}"
@@ -7,14 +7,17 @@ import pathlib
7
7
 
8
8
  import numpy as np
9
9
  import numpy.typing as npt
10
+ from typing_extensions import Self
10
11
 
11
12
  AU: float = 149597870.693
12
13
  """Astronomical unit."""
13
14
 
14
- INTERNAL_CATALOG_FILE = (
15
- pathlib.Path(__file__).parent.expanduser().absolute() / "gaia_data_j2016.csv"
15
+ GAIA_CATALOG_FILE = pathlib.Path(__file__).parent.expanduser().absolute() / "gaia_data_j2016.csv"
16
+ """Location of the internal Gaia star catalog file."""
17
+ HIPPARCOS_CATALOG_FILE = (
18
+ pathlib.Path(__file__).parent.expanduser().absolute() / "hipparcos_data_j1991.25.csv"
16
19
  )
17
- """Location of the internal star catalog file."""
20
+ """Location of the internal Hipparcos star catalog file."""
18
21
 
19
22
 
20
23
  def time_to_epoch(t: datetime.datetime) -> float:
@@ -44,24 +47,41 @@ class StarCatalog:
44
47
  """Proper motion of the declination in mad."""
45
48
  magnitude: npt.NDArray[np.float32]
46
49
  """Magnitude values."""
47
- epoch: float = 2016.0
50
+ epoch: float
48
51
  """Epoch of the catalog in years."""
49
52
 
53
+ @classmethod
54
+ def from_gaia(cls, *, max_magnitude: float = 6.0) -> Self:
55
+ """Read internally provided Gaia catalog file.
56
+
57
+ Args:
58
+ max_magnitude: Maximum magnitude to include.
59
+ """
60
+ return cls(GAIA_CATALOG_FILE, epoch=2016.0, max_magnitude=max_magnitude)
61
+
62
+ @classmethod
63
+ def from_hipparcos(cls, *, max_magnitude: float = 6.0) -> Self:
64
+ """Read internally provided Hipparcos catalog file.
65
+
66
+ Args:
67
+ max_magnitude: Maximum magnitude to include.
68
+ """
69
+ return cls(HIPPARCOS_CATALOG_FILE, epoch=1991.25, max_magnitude=max_magnitude)
70
+
50
71
  def __init__(
51
- self, filename: pathlib.Path | str | None = None, max_magnitude: float = 6.0
72
+ self, filename: pathlib.Path | str, *, epoch: float, max_magnitude: float = 6.0
52
73
  ) -> None:
53
74
  """Read catalog from file.
54
75
 
55
76
  Args:
56
77
  filename: Star catalog filename.
78
+ epoch: Epoch of the catalog in years.
57
79
  max_magnitude: Maximum magnitude to include.
58
80
  """
59
- # Use locally included star catalog is no file is given
60
- filename = INTERNAL_CATALOG_FILE if filename is None else pathlib.Path(filename)
61
-
81
+ self.epoch = epoch
62
82
  keep_columns = ("ra", "dec", "parallax", "pmra", "pmdec", "phot_g_mean_mag")
63
83
 
64
- with filename.open("r") as f:
84
+ with pathlib.Path(filename).open("r") as f:
65
85
  it = csv.reader(f, delimiter=",", strict=True)
66
86
 
67
87
  # Get columns
@@ -2,12 +2,15 @@ from collections.abc import Iterator
2
2
 
3
3
  import numpy as np
4
4
  import numpy.typing as npt
5
+ from typing_extensions import Self
5
6
 
6
7
  class StarMatcher:
7
8
  def __init__(
8
9
  self,
9
10
  stars_xyz: npt.NDArray[np.float32],
11
+ stars_mag: npt.NDArray[np.float32],
10
12
  max_inter_star_angle: float,
13
+ max_lookup_magnitude: float,
11
14
  inter_star_angle_tolerance: float,
12
15
  n_minimum_matches: int,
13
16
  timeout_secs: float,
@@ -19,7 +22,7 @@ class StarMatcher:
19
22
  npt.NDArray[np.uint32],
20
23
  npt.NDArray[np.uint32],
21
24
  int,
22
- list[list[float]],
25
+ npt.NDArray[np.float32],
23
26
  float,
24
27
  ]: ...
25
28
 
@@ -44,15 +47,45 @@ class IterTriangleFinder:
44
47
  class UnitVectorLookup:
45
48
  def __init__(self, vec: npt.NDArray[np.float32]) -> None: ...
46
49
  def lookup_nearest(self, key: npt.NDArray[np.float32]) -> int: ...
47
- def get_inter_star_index_numpy(
48
- self, vec: npt.NDArray[np.float32], angle_threshold: float
49
- ) -> tuple[list[list[int]], list[float], list[float]]: ...
50
50
  def get_inter_star_index(
51
- self, vec: npt.NDArray[np.float32], angle_threshold: float
51
+ self,
52
+ stars: npt.NDArray[np.float32],
53
+ magnitudes: npt.NDArray[np.float32],
54
+ max_angle_rad: float,
55
+ max_magnitude: float,
52
56
  ) -> tuple[list[list[int]], list[float], list[float]]: ...
53
57
  def look_up_close_angles(
54
- self, vectors: npt.NDArray[np.float32], max_angle_rad: float
58
+ self,
59
+ vectors: npt.NDArray[np.float32],
60
+ magnitudes: npt.NDArray[np.float32],
61
+ max_angle_rad: float,
62
+ max_magnitude: float,
55
63
  ) -> list[tuple[list[float], float]]: ...
56
64
  def look_up_close_angles_naive(
57
- self, vectors: npt.NDArray[np.float32], max_angle_rad: float
65
+ self,
66
+ vectors: npt.NDArray[np.float32],
67
+ magnitudes: npt.NDArray[np.float32],
68
+ max_angle_rad: float,
69
+ max_magnitude: float,
58
70
  ) -> list[tuple[list[float], float]]: ...
71
+
72
+ def get_threshold_from_histogram(
73
+ img: npt.NDArray[np.uint8],
74
+ *,
75
+ fraction: float,
76
+ ) -> int: ...
77
+ def extract_observations(
78
+ img: npt.NDArray[np.uint8],
79
+ threshold: int,
80
+ min_star_area: int,
81
+ max_star_area: int,
82
+ ) -> tuple[npt.NDArray[np.float32], npt.NDArray[np.float32]]: ...
83
+
84
+ class StarCatalog:
85
+ @classmethod
86
+ def from_gaia(cls, *, max_magnitude: float | None) -> Self: ...
87
+ @classmethod
88
+ def from_hipparcos(cls, *, max_magnitude: float | None) -> Self: ...
89
+ def normalized_positions(
90
+ self, *, epoch: float | None, observer_position: np.ndarray | None
91
+ ) -> npt.NDArray[np.float32]: ...
@@ -87,8 +87,10 @@ class StarTracker:
87
87
  def __init__(
88
88
  self,
89
89
  stars_xyz: npt.NDArray[np.float32],
90
+ stars_mag: npt.NDArray[np.float32],
90
91
  camera_params: CameraParameters,
91
92
  *,
93
+ max_lookup_magnitude: float | None = None,
92
94
  max_inter_star_angle: float | None = None,
93
95
  inter_star_angle_tolerance: float = 0.0008,
94
96
  n_minimum_matches: int = 10,
@@ -98,11 +100,15 @@ class StarTracker:
98
100
 
99
101
  Args:
100
102
  stars_xyz: Positions of catalog stars
103
+ stars_mag: Magnitudes of catalog stars
101
104
  camera_params: Calibrated camera parameters
105
+ max_lookup_magnitude: Maximum magnitude of stars used in the triangulation. Reducing
106
+ this number means only bright stars are used for triangulation. This results in
107
+ faster lookup performance.
102
108
  max_inter_star_angle: Maximum angle between stars that should be indexed.
103
109
  Calculating large inter star angles is expensive. If None, the angle is
104
110
  calculated from the camera field of view.
105
- inter_star_angle_tolerance: Tolerance for inter star angle matching.
111
+ inter_star_angle_tolerance: Tolerance for inter star angle matching in rad.
106
112
  n_minimum_matches: Minimum amount of required matches for a successful
107
113
  attitude estimation
108
114
  timeout_secs: Maximum allowed search time in seconds. A StarTrackerError is raised
@@ -127,8 +133,13 @@ class StarTracker:
127
133
  dot_products = (corner_coords_xyz * np.array([0, 0, 1], dtype=np.float32)).sum(axis=-1)
128
134
  max_inter_star_angle = float(np.arccos(dot_products.max())) * 2
129
135
 
136
+ if max_lookup_magnitude is None:
137
+ max_lookup_magnitude = 100.0 # A very faint star. Almost infinity
138
+
130
139
  self._star_matcher = ruststartracker.libruststartracker.StarMatcher(
131
140
  np.ascontiguousarray(stars_xyz, dtype=np.float32),
141
+ np.ascontiguousarray(stars_mag, dtype=np.float32),
142
+ float(max_lookup_magnitude),
132
143
  float(max_inter_star_angle),
133
144
  float(inter_star_angle_tolerance),
134
145
  int(n_minimum_matches),
@@ -178,6 +189,20 @@ class StarTracker:
178
189
  max_star_area=max_star_area,
179
190
  )
180
191
 
192
+ if threshold is None:
193
+ threshold = ruststartracker.libruststartracker.get_threshold_from_histogram(
194
+ img, fraction=0.99
195
+ )
196
+
197
+ centroids, intensities = ruststartracker.libruststartracker.extract_observations(
198
+ img,
199
+ threshold,
200
+ min_star_area,
201
+ max_star_area,
202
+ )
203
+ centroids = np.array(centroids, dtype=np.float32)
204
+ intensities = np.array(intensities, dtype=np.float32)
205
+
181
206
  # At least 3 observations are required (one triangle)
182
207
  if len(centroids) < 3:
183
208
  raise StarTrackerError("Found too few star candidates (< 3) to continue.")
@@ -238,12 +263,12 @@ class StarTracker:
238
263
  quat, match_ids, obs_indices, n_matches, matched_obs, duration_s = result
239
264
 
240
265
  return StarTrackerResult(
241
- quat=np.asarray(quat, dtype=np.float32),
242
- match_ids=np.asarray(match_ids, dtype=np.uint32),
266
+ quat=quat,
267
+ match_ids=match_ids,
243
268
  n_matches=n_matches,
244
269
  duration_s=duration_s,
245
- mached_obs_x=np.asarray(matched_obs, dtype=np.float32),
246
- obs_indices=np.asarray(obs_indices, dtype=np.uint32),
270
+ mached_obs_x=matched_obs,
271
+ obs_indices=obs_indices,
247
272
  )
248
273
 
249
274
 
@@ -54,9 +54,12 @@ def test_unit_vector_lookup():
54
54
  close_indices_gt = np.concatenate(results, axis=-1).T[args]
55
55
  angles_gt = angles[args]
56
56
 
57
- close_indices, angles, poly = uvl.get_inter_star_index_numpy(vec, angle_threshold)
58
-
59
- close_indices, angles, poly = uvl.get_inter_star_index(vec[:, :3], angle_threshold)
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
+ )
60
63
  close_indices = np.array(close_indices)
61
64
  angles = np.array(angles)
62
65
  poly = np.array(poly)
@@ -101,6 +104,8 @@ def test_star_matcher():
101
104
  vec = rng.normal(size=[n_cat_stars, 3]).astype(np.float32)
102
105
  vec /= np.linalg.norm(vec, axis=-1, keepdims=True)
103
106
 
107
+ magnitudes = rng.uniform(0, 10, size=vec.shape[:1]).astype(np.float32)
108
+
104
109
  key = rng.normal(size=[3]).astype(np.float32)
105
110
  key /= np.linalg.norm(key, axis=-1, keepdims=True)
106
111
 
@@ -113,10 +118,16 @@ def test_star_matcher():
113
118
 
114
119
  rot = scipy.spatial.transform.Rotation.from_rotvec([1, 1, 1])
115
120
 
116
- obs_rotated = rot.apply(obs)
121
+ obs_rotated = rot.apply(obs).astype(np.float32)
117
122
 
118
123
  index = libruststartracker.StarMatcher(
119
- vec, np.radians(10).item(), np.radians(0.1).item(), 4, 999.0
124
+ vec,
125
+ magnitudes,
126
+ 10,
127
+ np.radians(10).item(),
128
+ np.radians(0.1).item(),
129
+ 4,
130
+ 999.0,
120
131
  )
121
132
 
122
133
  res = index.find(obs_rotated)
@@ -124,7 +135,7 @@ def test_star_matcher():
124
135
  assert res is not None
125
136
 
126
137
  quat, match_ids, obs_indices, n_matches, matched_obs, time_s = res
127
- np.testing.assert_allclose(quat, rot.inv().as_quat())
138
+ np.testing.assert_allclose(quat, rot.inv().as_quat(), rtol=1e-6)
128
139
  assert n_matches >= 4
129
140
  assert len(obs_index) == len(match_ids)
130
141
 
@@ -0,0 +1,101 @@
1
+ import datetime
2
+ import time
3
+
4
+ import astropy.time # type: ignore[import]
5
+ import numpy as np
6
+ import pytest
7
+
8
+ import ruststartracker.catalog
9
+ import ruststartracker.libruststartracker
10
+
11
+
12
+ def test_time_to_epoch():
13
+ np.testing.assert_allclose(
14
+ ruststartracker.catalog.time_to_epoch(
15
+ datetime.datetime.fromisoformat("2000-01-01T11:58:56")
16
+ ),
17
+ 2000.0,
18
+ rtol=1e-20,
19
+ atol=1 / (365 * 86400),
20
+ )
21
+
22
+
23
+ @pytest.mark.parametrize(
24
+ "iso_date",
25
+ [
26
+ "2000-01-01T11:58:56",
27
+ "2024-01-01T11:58:56",
28
+ ],
29
+ )
30
+ def test_time_to_epoch_astropy(iso_date: str):
31
+ ground_truth = float(astropy.time.Time(iso_date).jyear) # type: ignore
32
+ np.testing.assert_allclose(
33
+ ruststartracker.catalog.time_to_epoch(datetime.datetime.fromisoformat(iso_date)),
34
+ ground_truth,
35
+ rtol=1e-20,
36
+ atol=64 / (365 * 86400),
37
+ )
38
+
39
+
40
+ def test_extract_observations():
41
+ positions = ruststartracker.catalog.StarCatalog.from_gaia().normalized_positions()
42
+
43
+ assert positions.ndim == 2
44
+ assert positions.shape[1] == 3
45
+ np.testing.assert_allclose(np.linalg.norm(positions, axis=-1), 1.0, rtol=1e-5)
46
+
47
+
48
+ def test_gaia_python_rust():
49
+ t0 = time.monotonic()
50
+ positions = ruststartracker.catalog.StarCatalog.from_gaia().normalized_positions(epoch=2025.0)
51
+ print(f"Python catalog took {time.monotonic() - t0:.3f} seconds")
52
+ t0 = time.monotonic()
53
+ positions2 = ruststartracker.libruststartracker.StarCatalog.from_gaia(
54
+ max_magnitude=6.0
55
+ ).normalized_positions(epoch=2025.0, observer_position=None)
56
+ print(f"Rust catalog took {time.monotonic() - t0:.3f} seconds")
57
+ np.testing.assert_allclose(positions, positions2, rtol=1e-5, atol=1e-5)
58
+
59
+
60
+ def test_hipparcos_python_rust():
61
+ t0 = time.monotonic()
62
+ positions = ruststartracker.catalog.StarCatalog.from_hipparcos().normalized_positions(
63
+ epoch=2025.0
64
+ )
65
+ print(f"Python catalog took {time.monotonic() - t0:.3f} seconds")
66
+ t0 = time.monotonic()
67
+ positions2 = ruststartracker.libruststartracker.StarCatalog.from_hipparcos(
68
+ max_magnitude=6.0
69
+ ).normalized_positions(epoch=2025.0, observer_position=None)
70
+ print(f"Rust catalog took {time.monotonic() - t0:.3f} seconds")
71
+ np.testing.assert_allclose(positions, positions2, rtol=1e-5, atol=1e-5)
72
+
73
+
74
+ def test_compare_hipparcos_gaia():
75
+ def filt(cat: ruststartracker.catalog.StarCatalog, m: float = 0):
76
+ positions = cat.normalized_positions(epoch=2025.0)
77
+ mags = cat.magnitude
78
+ mask = (positions[..., 2] > 0.9) * (mags > 5 - m) * (mags < 8 - m)
79
+ return positions[mask]
80
+
81
+ positions_hipparcos = filt(ruststartracker.catalog.StarCatalog.from_hipparcos(max_magnitude=8))
82
+ positions_gaia = filt(ruststartracker.catalog.StarCatalog.from_gaia(max_magnitude=8), m=0.9)
83
+
84
+ dists = np.linalg.norm(
85
+ positions_hipparcos[np.newaxis, :, :] - positions_gaia[:, np.newaxis, :], axis=-1
86
+ )
87
+ matches = np.min(dists, axis=0) < 0.00005
88
+
89
+ assert np.mean(matches > 0.9)
90
+
91
+ if False:
92
+ import matplotlib.pyplot as plt
93
+
94
+ plt.plot(positions_gaia[..., 0], positions_gaia[..., 1], "+", label="gaia")
95
+ plt.plot(positions_hipparcos[..., 0], positions_hipparcos[..., 1], "x", label="hipparcos")
96
+ plt.legend()
97
+ plt.show()
98
+
99
+
100
+ if __name__ == "__main__":
101
+ pytest.main([__file__])
@@ -24,8 +24,9 @@ def prepare() -> tuple[ruststartracker.StarTracker, np.ndarray]:
24
24
 
25
25
  dist_coefs = np.array([-0.44120807, -0.15954202, 0.00767012, -0.00213292, -1.64788247])
26
26
 
27
- catalog = ruststartracker.StarCatalog()
27
+ catalog = ruststartracker.StarCatalog.from_gaia()
28
28
  star_catalog_vecs = catalog.normalized_positions(epoch=2024)
29
+ star_catalog_magnitudes = catalog.magnitude
29
30
 
30
31
  camera_params = ruststartracker.CameraParameters(
31
32
  camera_matrix=camera_matrix,
@@ -35,6 +36,7 @@ def prepare() -> tuple[ruststartracker.StarTracker, np.ndarray]:
35
36
 
36
37
  st = ruststartracker.StarTracker(
37
38
  star_catalog_vecs,
39
+ star_catalog_magnitudes,
38
40
  camera_params,
39
41
  inter_star_angle_tolerance=np.radians(0.05).item(),
40
42
  n_minimum_matches=5,
@@ -5,16 +5,29 @@ import pytest
5
5
  import scipy.spatial
6
6
 
7
7
  import ruststartracker
8
+ import ruststartracker.libruststartracker
8
9
  import ruststartracker.star
9
10
 
10
11
 
11
- def test_extract_observations():
12
- size_x, size_y = (40, 50)
12
+ @pytest.mark.parametrize("impl", ["python", "rust"])
13
+ def test_extract_observations(impl: str):
14
+ size_x, size_y = (960, 480)
13
15
  img = np.zeros((size_y, size_x), np.uint8)
14
- points = np.array([(3, 5), (23, 13)])
16
+ points = np.array([(3, 5), (23, 13), (30, 50), (230, 130)])
15
17
  for x, y in points:
16
18
  img[y - 1 : y + 3, x - 1 : x + 3] = 50
17
- centers, intensities = ruststartracker.star._extract_observations(img, threshold=30)
19
+
20
+ t0 = time.monotonic()
21
+ if impl == "python":
22
+ centers, intensities = ruststartracker.star._extract_observations(img, threshold=30)
23
+ else:
24
+ centers, intensities = ruststartracker.libruststartracker.extract_observations(
25
+ img, 30, 3, 300
26
+ )
27
+ print(f"Extracting observations took {time.monotonic() - t0:.5f} seconds")
28
+
29
+ assert isinstance(centers, np.ndarray)
30
+ assert isinstance(intensities, np.ndarray)
18
31
  np.testing.assert_almost_equal(centers, points + 0.5)
19
32
  np.testing.assert_almost_equal(intensities, 50 * 16)
20
33
 
@@ -28,6 +41,8 @@ def setup():
28
41
  vec = rng.normal(size=[n_cat_stars, 3]).astype(np.float32)
29
42
  vec /= np.linalg.norm(vec, axis=-1, keepdims=True)
30
43
 
44
+ mag = rng.uniform(0, 10, size=vec.shape[:1]).astype(np.float32)
45
+
31
46
  angle_threshold = np.radians(10)
32
47
  dotp = np.sum([0, 0, 1] * vec, axis=-1)
33
48
  threshold = np.cos(angle_threshold).item()
@@ -55,17 +70,18 @@ def setup():
55
70
  image_patch = img[y - 1 : y + 2, x - 1 : x + 2]
56
71
  image_patch[:] = 50
57
72
 
58
- return img, vec, camera_params
73
+ return img, vec, mag, pixel_in_frame, camera_params
59
74
 
60
75
 
61
76
  def test_star_matcher_success(setup):
62
- img, vec, camera_params = setup
77
+ img, vec, mag, _, camera_params = setup
63
78
 
64
79
  rot = scipy.spatial.transform.Rotation.from_rotvec([1, 1, 1])
65
80
  vec = rot.inv().apply(vec)
66
81
 
67
82
  st = ruststartracker.StarTracker(
68
83
  vec,
84
+ mag,
69
85
  camera_params,
70
86
  inter_star_angle_tolerance=np.radians(0.1).item(),
71
87
  n_minimum_matches=6,
@@ -78,34 +94,51 @@ def test_star_matcher_success(setup):
78
94
 
79
95
 
80
96
  def test_star_matcher_exhaust(setup):
81
- img, vec, camera_params = setup
97
+ img, vec, mag, _, camera_params = setup
82
98
  st = ruststartracker.StarTracker(
83
99
  vec,
100
+ mag,
84
101
  camera_params,
85
102
  inter_star_angle_tolerance=np.radians(0.001).item(),
86
103
  n_minimum_matches=500,
87
104
  timeout_secs=999.0,
88
105
  )
89
- with pytest.raises(ruststartracker.StarTrackerError, match="exhaust"):
106
+ with pytest.raises(ruststartracker.StarTrackerError, match="SearchExhausted"):
90
107
  st.process_image(img)
91
108
 
92
109
 
93
110
  def test_star_matcher_timout(setup):
94
- img, vec, camera_params = setup
95
- timeout = 0.2
111
+ img, vec, mag, _, camera_params = setup
112
+ timeout = 0.0002
96
113
  st = ruststartracker.StarTracker(
97
114
  vec,
115
+ mag,
98
116
  camera_params,
99
117
  inter_star_angle_tolerance=np.radians(0.1).item(),
100
118
  n_minimum_matches=500,
101
119
  timeout_secs=timeout,
102
120
  )
103
121
  t = time.monotonic()
104
- with pytest.raises(ruststartracker.StarTrackerError, match="Timeout reached"):
122
+ with pytest.raises(ruststartracker.StarTrackerError, match="Timeout"):
105
123
  st.process_image(img)
106
124
  passed_time = time.monotonic() - t
107
125
  assert passed_time > timeout
108
126
 
109
127
 
128
+ def test_star_matcher_not_enough_stars(setup):
129
+ _, vec, mag, pixel_in_frame, camera_params = setup
130
+ timeout = 0.2
131
+ st = ruststartracker.StarTracker(
132
+ vec,
133
+ mag,
134
+ camera_params,
135
+ inter_star_angle_tolerance=np.radians(0.1).item(),
136
+ n_minimum_matches=500,
137
+ timeout_secs=timeout,
138
+ )
139
+ with pytest.raises(ruststartracker.StarTrackerError, match="NotEnoughStars"):
140
+ st.process_image_coordiantes(pixel_in_frame[:2])
141
+
142
+
110
143
  if __name__ == "__main__":
111
- pytest.main([__file__])
144
+ pytest.main([__file__, "--capture=no"])
@@ -1,60 +0,0 @@
1
- """Build rust backend.
2
-
3
- This script is automatically run when the pyproject is installed.
4
- """
5
-
6
- import pathlib
7
- import shutil
8
- import subprocess
9
-
10
-
11
- def download_gaia_data(output_file: pathlib.Path) -> None:
12
- """Download Gaia data and save it to the specified output file.
13
-
14
- Args:
15
- output_file: Path to save the downloaded Gaia data (csv).
16
- """
17
- from astroquery.gaia import Gaia
18
-
19
- Gaia.ROW_LIMIT = 10000
20
-
21
- query = """
22
- SELECT source_id, ra, dec, parallax, pmra, pmdec, phot_g_mean_mag
23
- FROM gaiadr3.gaia_source
24
- WHERE phot_g_mean_mag < 7
25
- AND parallax > 0
26
- AND astrometric_params_solved = 31
27
- AND visibility_periods_used >= 8
28
- """
29
-
30
- print("Executing query on Gaia database...")
31
- job = Gaia.launch_job_async(
32
- query,
33
- dump_to_file=True,
34
- output_format="csv",
35
- output_file=str(output_file.absolute()),
36
- verbose=True,
37
- )
38
- if not job.is_finished():
39
- print("Job did not finish successfully.")
40
- return
41
-
42
- print(f"Saved to: {output_file}")
43
-
44
-
45
- def build_script() -> None:
46
- """Build rust backend and move shared library to correct folder."""
47
- cwd = pathlib.Path(__file__).parent.expanduser().absolute()
48
-
49
- gaia_file = cwd / "ruststartracker/gaia_data_j2016.csv"
50
- if not gaia_file.exists():
51
- download_gaia_data(gaia_file)
52
-
53
- subprocess.check_call(["cargo", "build", "--release"], cwd=cwd) # noqa: S603, S607
54
- shutil.copy(
55
- cwd / "target/release/libruststartracker.so", cwd / "ruststartracker/libruststartracker.so"
56
- )
57
-
58
-
59
- if __name__ == "__main__":
60
- build_script()
@@ -1,47 +0,0 @@
1
- import datetime
2
-
3
- import astropy.time # type: ignore[import]
4
- import numpy as np
5
- import pytest
6
-
7
- import ruststartracker.catalog
8
-
9
-
10
- def test_time_to_epoch():
11
- np.testing.assert_allclose(
12
- ruststartracker.catalog.time_to_epoch(
13
- datetime.datetime.fromisoformat("2000-01-01T11:58:56")
14
- ),
15
- 2000.0,
16
- rtol=1e-20,
17
- atol=1 / (365 * 86400),
18
- )
19
-
20
-
21
- @pytest.mark.parametrize(
22
- "iso_date",
23
- [
24
- "2000-01-01T11:58:56",
25
- "2024-01-01T11:58:56",
26
- ],
27
- )
28
- def test_time_to_epoch_astropy(iso_date: str):
29
- ground_truth = float(astropy.time.Time(iso_date).jyear) # type: ignore
30
- np.testing.assert_allclose(
31
- ruststartracker.catalog.time_to_epoch(datetime.datetime.fromisoformat(iso_date)),
32
- ground_truth,
33
- rtol=1e-20,
34
- atol=64 / (365 * 86400),
35
- )
36
-
37
-
38
- def test_extract_observations():
39
- positions = ruststartracker.catalog.StarCatalog().normalized_positions()
40
-
41
- assert positions.ndim == 2
42
- assert positions.shape[1] == 3
43
- np.testing.assert_allclose(np.linalg.norm(positions, axis=-1), 1.0, rtol=1e-5)
44
-
45
-
46
- if __name__ == "__main__":
47
- pytest.main([__file__])
File without changes