asterion-solver 0.4.0__py3-none-win_amd64.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.
asterion/__init__.py ADDED
@@ -0,0 +1,41 @@
1
+ # Copyright 2026 the original author or authors.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """
15
+ Asterion Solver, a fast blind astrometric plate solver: finds the celestial coordinates, scale and orientation of
16
+ images which are in memory, such as the frames of a camera.
17
+
18
+ import asterion
19
+
20
+ asterion.install_catalog("tycho2") # once per machine
21
+
22
+ with asterion.Solver() as solver: # the catalogs stay open between solves
23
+ result = solver.solve(frame) # a 2D array of gray levels
24
+ if result.solved:
25
+ print(result.ra, result.dec, result.scale)
26
+ """
27
+ from importlib import metadata as _metadata
28
+
29
+ from ._catalogs import (Catalog, InstallationCancelled, Progress, catalogs, default_catalog_directory, install_catalog,
30
+ version)
31
+ from ._native import AsterionError
32
+ from ._solver import DeepSkyObject, Match, Result, Solver
33
+
34
+ try:
35
+ __version__ = _metadata.version("asterion-solver")
36
+ except _metadata.PackageNotFoundError:
37
+ # not installed: the sources
38
+ __version__ = "unknown"
39
+
40
+ __all__ = ["AsterionError", "Catalog", "DeepSkyObject", "InstallationCancelled", "Match", "Progress", "Result", "Solver",
41
+ "catalogs", "default_catalog_directory", "install_catalog", "version"]
asterion/_catalogs.py ADDED
@@ -0,0 +1,183 @@
1
+ # Copyright 2026 the original author or authors.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """The catalogs of stars, which Asterion installs once on a machine."""
15
+ import os
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+ from typing import Callable, Optional, Union
19
+
20
+ from . import _native
21
+ from ._native import AsterionError
22
+
23
+ PathLike = Union[str, bytes, os.PathLike]
24
+
25
+
26
+ class InstallationCancelled(AsterionError):
27
+ """The progress callback stopped the installation of a catalog. Installing it again resumes it."""
28
+
29
+
30
+ InstallationCancelled.__module__ = "asterion"
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class Catalog:
35
+ """
36
+ A catalog: one which can be installed, or one which is installed in a directory. The numbers of the index are
37
+ None when it isn't installed.
38
+
39
+ Attributes:
40
+ name: its name, such as "gaia-500", or the one of its index file, without .astx
41
+ description: what it is, and the fields of view it's for, or "" if Asterion doesn't know it
42
+ installed: whether it's installed in the directory
43
+ available: whether install_catalog can install it
44
+ size: the size of its index file, in bytes
45
+ stars: the number of stars of its index
46
+ quads: the number of quads of its index, the patterns of stars it searches for
47
+ magnitude_limit: the magnitude of its faintest stars
48
+ min_quad_arcmin: the size of its smallest quads, in arcminutes
49
+ max_quad_deg: the size of its largest quads, in degrees
50
+ error: why its index file can't be read, or None
51
+ """
52
+
53
+ name: str
54
+ description: str
55
+ installed: bool
56
+ available: bool
57
+ size: Optional[int]
58
+ stars: Optional[int]
59
+ quads: Optional[int]
60
+ magnitude_limit: Optional[float]
61
+ min_quad_arcmin: Optional[float]
62
+ max_quad_deg: Optional[float]
63
+ error: Optional[str]
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class Progress:
68
+ """
69
+ An event of the installation of a catalog, the ones of asterion --download-catalog --json.
70
+
71
+ Attributes:
72
+ event: "step" when a step starts, "progress" when it made progress, "detail" when it did something worth
73
+ telling, "done" when it's over, whether it succeeded or failed, or "info" for what isn't part of a step
74
+ step: what the step does, for people, such as "Downloading gaia-500 (1,234 MB)", or None for "info"
75
+ unit: the unit of the work of the step, in plural form, such as "MB"
76
+ completed: the units of work done since the step started
77
+ total: the units of work of the step, or None if it isn't known
78
+ fraction: the part of the step which is done, from 0 to 1, or None if it isn't known
79
+ bytes: the bytes transferred since the step started, or None if the step transfers no data
80
+ message: the message of the "detail" and "info" events
81
+ """
82
+
83
+ event: str
84
+ step: Optional[str]
85
+ unit: Optional[str]
86
+ completed: int
87
+ total: Optional[int]
88
+ fraction: Optional[float]
89
+ bytes: Optional[int]
90
+ message: Optional[str]
91
+
92
+
93
+ def version() -> str:
94
+ """The version of the library, such as "0.4.0"."""
95
+ return _native.string("asterion_version")
96
+
97
+
98
+ def default_catalog_directory() -> Path:
99
+ """The directory of the catalogs which the functions use by default: $ASTERION_CATALOGS, or ~/.asterion/catalogs,
100
+ the one of the command line tool."""
101
+ return Path(_native.string("asterion_default_catalog_directory"))
102
+
103
+
104
+ def catalogs(directory: Optional[PathLike] = None) -> list[Catalog]:
105
+ """The catalogs which can be installed, then the other ones which are installed in a directory, by default the one
106
+ of the command line tool."""
107
+ directory = _native.path(directory)
108
+ count = _native.check(_native.call("asterion_catalogs", directory, None, 0))
109
+ described = (_native.Catalog * count)()
110
+ # an index file may have been removed meanwhile
111
+ count = min(count, _native.check(_native.call("asterion_catalogs", directory, described, count)))
112
+ return [_catalog(catalog) for catalog in described[:count]]
113
+
114
+
115
+ def install_catalog(name: str, directory: Optional[PathLike] = None,
116
+ progress: Optional[Callable[[Progress], Optional[bool]]] = None) -> None:
117
+ """
118
+ Installs a catalog, such as "tycho2" or "gaia-500", the way asterion --download-catalog does, and returns when
119
+ it's over. An interrupted installation resumes its download the next time. Ctrl-C cancels it, between two events
120
+ of its progress. A solver which uses the catalog must be closed before installing it again.
121
+
122
+ Args:
123
+ name: the name of the catalog, as catalogs() lists them
124
+ directory: the directory of the catalogs, by default the one of the command line tool
125
+ progress: receives the progress, on the calling thread: returning True cancels the installation, which raises
126
+ InstallationCancelled. An exception of the callback, such as KeyboardInterrupt, cancels it too, and is
127
+ raised once the installation stopped.
128
+
129
+ Raises:
130
+ InstallationCancelled: if the callback cancelled the installation
131
+ AsterionError: if the catalog is unknown, or can't be installed
132
+ """
133
+ failure = None
134
+
135
+ def receive(event, user_data):
136
+ # Python code which runs during the installation: KeyboardInterrupt is raised there
137
+ nonlocal failure
138
+ try:
139
+ return 1 if progress is not None and progress(_progress(event.contents)) else 0
140
+ except BaseException as e:
141
+ failure = e
142
+ return 1
143
+
144
+ # the callback must stay referenced while the library may call it
145
+ callback = _native.ProgressCallback(receive)
146
+ status = _native.call("asterion_install_catalog", _native.path(directory), name.encode("utf-8"), callback, None)
147
+ if failure is not None:
148
+ raise failure
149
+ if status == _native.CANCELLED:
150
+ raise InstallationCancelled(f"The installation of {name} was cancelled")
151
+ _native.check(status)
152
+
153
+
154
+ def _catalog(catalog):
155
+ def number(value):
156
+ return _native.optional(value) if catalog.installed else None
157
+
158
+ return Catalog(
159
+ name=_native.text(catalog.name),
160
+ description=_native.text(catalog.description),
161
+ installed=bool(catalog.installed),
162
+ available=bool(catalog.available),
163
+ size=catalog.size if catalog.installed else None,
164
+ stars=catalog.stars if catalog.installed else None,
165
+ quads=catalog.quads if catalog.installed else None,
166
+ magnitude_limit=number(catalog.magnitude_limit),
167
+ min_quad_arcmin=number(catalog.min_quad_arcmin),
168
+ max_quad_deg=number(catalog.max_quad_deg),
169
+ error=_native.text(catalog.error) or None,
170
+ )
171
+
172
+
173
+ def _progress(event):
174
+ return Progress(
175
+ event=_native.EVENTS.get(event.event, str(event.event)),
176
+ step=_native.text(event.step),
177
+ unit=_native.text(event.unit),
178
+ completed=event.completed,
179
+ total=event.total if event.total >= 0 else None,
180
+ fraction=_native.optional(event.fraction),
181
+ bytes=event.bytes if event.bytes >= 0 else None,
182
+ message=_native.text(event.message),
183
+ )
asterion/_native.py ADDED
@@ -0,0 +1,259 @@
1
+ # Copyright 2026 the original author or authors.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+ """
15
+ The native library: the structures and the functions of asterion.h, and the GraalVM isolate in which the
16
+ library runs. The isolate is created the first time the library is used, and each Python thread which
17
+ calls the library is attached to it.
18
+ """
19
+ import ctypes as c
20
+ import math
21
+ import os
22
+ import sys
23
+ import threading
24
+ from pathlib import Path
25
+
26
+
27
+ class AsterionError(Exception):
28
+ """An error which the library reports, with its message."""
29
+
30
+
31
+ AsterionError.__module__ = "asterion"
32
+
33
+
34
+ # The structures of asterion.h, field by field: ASTERION_MAX_SIP_ORDER and ASTERION_MAX_SIP_INVERSE_ORDER
35
+ _SIP = c.c_double * 6 * 6
36
+ _SIP_INVERSE = c.c_double * 8 * 8
37
+
38
+
39
+ class Options(c.Structure):
40
+ _fields_ = [(name, c.c_double) for name in
41
+ ("ra", "dec", "radius", "scale_low", "scale_high", "fov", "pixel_size", "timeout", "sigma", "epoch")] + \
42
+ [(name, c.c_int32) for name in ("parity", "sip_order", "binning", "max_stars", "quad_stars", "padding")]
43
+
44
+
45
+ class Solution(c.Structure):
46
+ _fields_ = [(name, c.c_double) for name in
47
+ ("ra", "dec", "scale", "rotation", "field_width", "field_height", "rms", "log_odds", "focal_length",
48
+ "pixel_size", "fwhm_pixels", "fwhm_arcsec")] + \
49
+ [("crpix", c.c_double * 2), ("crval", c.c_double * 2), ("cd", c.c_double * 4),
50
+ ("sip_a", _SIP), ("sip_b", _SIP), ("sip_ap", _SIP_INVERSE), ("sip_bp", _SIP_INVERSE),
51
+ ("read_ms", c.c_double), ("detect_ms", c.c_double), ("solve_ms", c.c_double)] + \
52
+ [(name, c.c_int32) for name in ("sip_order", "sip_inverse_order", "flipped", "matched_stars", "stars")] + \
53
+ [("index", c.c_char * 64), ("padding", c.c_int32)]
54
+
55
+
56
+ class Object(c.Structure):
57
+ _fields_ = [(name, c.c_double) for name in
58
+ ("ra", "dec", "x", "y", "distance", "major_arcmin", "minor_arcmin", "angle", "magnitude")] + \
59
+ [("messier", c.c_int32), ("name", c.c_char * 32), ("designation", c.c_char * 32),
60
+ ("common_name", c.c_char * 128), ("aliases", c.c_char * 256), ("type", c.c_char * 32)]
61
+
62
+
63
+ class Match(c.Structure):
64
+ _fields_ = [(name, c.c_double) for name in ("x", "y", "ra", "dec", "residual")]
65
+
66
+
67
+ class Catalog(c.Structure):
68
+ _fields_ = [("name", c.c_char * 64), ("description", c.c_char * 256), ("installed", c.c_int32), ("available", c.c_int32),
69
+ ("size", c.c_int64), ("stars", c.c_int64), ("quads", c.c_int64), ("magnitude_limit", c.c_double),
70
+ ("min_quad_arcmin", c.c_double), ("max_quad_deg", c.c_double), ("error", c.c_char * 256)]
71
+
72
+
73
+ class Progress(c.Structure):
74
+ _fields_ = [("event", c.c_int32), ("padding", c.c_int32), ("completed", c.c_int64), ("total", c.c_int64),
75
+ ("bytes", c.c_int64), ("fraction", c.c_double), ("step", c.c_char_p), ("unit", c.c_char_p),
76
+ ("message", c.c_char_p)]
77
+
78
+
79
+ ProgressCallback = c.CFUNCTYPE(c.c_int, c.POINTER(Progress), c.c_void_p)
80
+
81
+ UINT8, UINT16, FLOAT32 = 1, 2, 3
82
+ SOLVED, NOT_SOLVED, ERROR, CANCELLED = 1, 0, -1, -2
83
+ PARITIES = {"both": 0, "normal": 1, "flipped": 2}
84
+ EVENTS = {1: "step", 2: "progress", 3: "detail", 4: "done", 5: "info"}
85
+
86
+ _STRUCTURES = {"asterion_options": Options, "asterion_solution": Solution, "asterion_object": Object,
87
+ "asterion_match": Match, "asterion_catalog": Catalog, "asterion_progress": Progress}
88
+
89
+ _p = c.c_void_p
90
+ _SIGNATURES = {
91
+ "graal_create_isolate": (c.c_int, [_p, c.POINTER(_p), c.POINTER(_p)]),
92
+ "graal_attach_thread": (c.c_int, [_p, c.POINTER(_p)]),
93
+ "graal_detach_thread": (c.c_int, [_p]),
94
+ "asterion_version": (c.c_int, [_p, c.c_char_p, c.c_size_t]),
95
+ "asterion_default_catalog_directory": (c.c_int, [_p, c.c_char_p, c.c_size_t]),
96
+ "asterion_catalogs": (c.c_int, [_p, c.c_char_p, c.POINTER(Catalog), c.c_int]),
97
+ "asterion_install_catalog": (c.c_int, [_p, c.c_char_p, c.c_char_p, ProgressCallback, _p]),
98
+ "asterion_open": (c.c_int, [_p, c.c_char_p, c.POINTER(_p)]),
99
+ "asterion_close": (c.c_int, [_p, _p]),
100
+ "asterion_default_options": (c.c_int, [_p, c.POINTER(Options)]),
101
+ "asterion_solve": (c.c_int, [_p, _p, _p, c.c_int, c.c_int, c.c_int, c.c_longlong, c.c_int, c.POINTER(Options),
102
+ c.POINTER(Solution), c.POINTER(_p)]),
103
+ "asterion_result_free": (None, [_p, _p]),
104
+ "asterion_result_json": (c.c_int, [_p, _p, c.c_char_p, c.c_size_t]),
105
+ "asterion_result_object_count": (c.c_int, [_p, _p]),
106
+ "asterion_result_object": (c.c_int, [_p, _p, c.c_int, c.POINTER(Object)]),
107
+ "asterion_result_match_count": (c.c_int, [_p, _p]),
108
+ "asterion_result_match": (c.c_int, [_p, _p, c.c_int, c.POINTER(Match)]),
109
+ "asterion_result_pixel_to_sky": (c.c_int, [_p, _p, c.c_double, c.c_double, c.POINTER(c.c_double), c.POINTER(c.c_double)]),
110
+ "asterion_result_sky_to_pixel": (c.c_int, [_p, _p, c.c_double, c.c_double, c.POINTER(c.c_double), c.POINTER(c.c_double)]),
111
+ "asterion_error": (c.c_int, [_p, c.c_char_p, c.c_size_t]),
112
+ "asterion_sizeof": (c.c_int, [_p, c.c_char_p]),
113
+ }
114
+
115
+
116
+ def _library_file():
117
+ """The library next to this module, or the one which ASTERION_LIBRARY names, to use another build."""
118
+ configured = os.environ.get("ASTERION_LIBRARY")
119
+ if configured:
120
+ return configured
121
+ name = {"win32": "libasterion.dll", "darwin": "libasterion.dylib"}.get(sys.platform, "libasterion.so")
122
+ return str(Path(__file__).with_name(name))
123
+
124
+
125
+ class _Attachment:
126
+ """The thread of the isolate of a Python thread, which is detached when the Python thread ends."""
127
+
128
+ def __init__(self, library, thread):
129
+ self.library = library
130
+ self.thread = thread
131
+ self.ident = threading.get_ident()
132
+ self.pid = os.getpid()
133
+
134
+ def __del__(self):
135
+ # Python releases the thread-local data of a thread when it ends, on that thread: a thread can only detach
136
+ # itself. A forked process has a copy of the isolate, without its threads, which it must not touch. At exit,
137
+ # the library may be gone already
138
+ if threading.get_ident() == self.ident and os.getpid() == self.pid:
139
+ try:
140
+ self.library.graal_detach_thread(self.thread)
141
+ except Exception:
142
+ pass
143
+
144
+
145
+ class _Isolate:
146
+ def __init__(self):
147
+ self._lock = threading.Lock()
148
+ self._library = None
149
+ self._isolate = None
150
+ self._forked = False
151
+ self._local = threading.local()
152
+ if hasattr(os, "register_at_fork"):
153
+ os.register_at_fork(after_in_child=self._after_fork)
154
+
155
+ def _after_fork(self):
156
+ # the isolate of the parent can't be used, and the lock may have been held by one of its threads
157
+ self._lock = threading.Lock()
158
+ self._forked = self._library is not None
159
+
160
+ def usable(self):
161
+ """Whether the library can be called: it's loaded, and this process isn't a fork of the one which loaded it."""
162
+ return self._library is not None and not self._forked
163
+
164
+ def thread(self):
165
+ """The library, and the thread of the isolate of the calling thread, attached the first time."""
166
+ if self._forked:
167
+ raise AsterionError("The library can't be used in a process forked after it was started: "
168
+ "start the processes with multiprocessing.set_start_method('spawn')")
169
+ attachment = getattr(self._local, "attachment", None)
170
+ if attachment is not None:
171
+ return self._library, attachment.thread
172
+ with self._lock:
173
+ if self._library is None:
174
+ self._create()
175
+ return self._library, self._local.attachment.thread
176
+ thread = c.c_void_p()
177
+ if self._library.graal_attach_thread(self._isolate, c.byref(thread)) != 0:
178
+ raise AsterionError("Unable to attach the thread to the isolate of the library")
179
+ # a finalizer which ran meanwhile, on this thread, may have attached it already: to the same isolate thread
180
+ attachment = getattr(self._local, "attachment", None)
181
+ if attachment is None:
182
+ attachment = self._local.attachment = _Attachment(self._library, thread)
183
+ return self._library, attachment.thread
184
+
185
+ def _create(self):
186
+ library = c.CDLL(_library_file())
187
+ for name, (result, arguments) in _SIGNATURES.items():
188
+ function = getattr(library, name)
189
+ function.restype, function.argtypes = result, arguments
190
+ isolate, thread = c.c_void_p(), c.c_void_p()
191
+ if library.graal_create_isolate(None, c.byref(isolate), c.byref(thread)) != 0:
192
+ raise AsterionError("Unable to create the isolate of the library")
193
+ for name, structure in _STRUCTURES.items():
194
+ if library.asterion_sizeof(thread, name.encode()) != c.sizeof(structure):
195
+ raise AsterionError(f"{name} doesn't match the library {_library_file()}")
196
+ self._library, self._isolate = library, isolate
197
+ self._local.attachment = _Attachment(library, thread)
198
+
199
+
200
+ _ISOLATE = _Isolate()
201
+
202
+
203
+ def call(name, *arguments):
204
+ """Calls a function of the library on the thread of the caller, which it receives first."""
205
+ library, thread = _ISOLATE.thread()
206
+ return getattr(library, name)(thread, *arguments)
207
+
208
+
209
+ def release(name, handle):
210
+ """Releases a handle when its object is garbage collected, which may happen at exit, or in a forked process:
211
+ nothing is reported, as nobody can handle it."""
212
+ if _ISOLATE.usable():
213
+ try:
214
+ call(name, handle)
215
+ except Exception:
216
+ pass
217
+
218
+
219
+ def error():
220
+ """The message of the last error of the calling thread."""
221
+ return string("asterion_error")
222
+
223
+
224
+ def check(status):
225
+ """The status of a call, or AsterionError with its message if it failed."""
226
+ if status == ERROR:
227
+ raise AsterionError(error())
228
+ return status
229
+
230
+
231
+ def string(name, *arguments):
232
+ """A string which a function copies to a buffer: the function tells the size it needs, if the first one is
233
+ too small."""
234
+ buffer = c.create_string_buffer(4096)
235
+ length = call(name, *arguments, buffer, len(buffer))
236
+ if length < 0 and name != "asterion_error":
237
+ raise AsterionError(error())
238
+ if length >= len(buffer):
239
+ buffer = c.create_string_buffer(length + 1)
240
+ call(name, *arguments, buffer, len(buffer))
241
+ return buffer.value.decode("utf-8", errors="replace")
242
+
243
+
244
+ def path(directory):
245
+ """A directory for the library, UTF-8 encoded, or None for the default one."""
246
+ if directory is None:
247
+ return None
248
+ directory = os.fspath(directory)
249
+ return directory if isinstance(directory, bytes) else directory.encode("utf-8")
250
+
251
+
252
+ def text(value):
253
+ """A string of a structure, or of a progress event."""
254
+ return value.decode("utf-8", errors="replace") if value is not None else None
255
+
256
+
257
+ def optional(value):
258
+ """A number of a structure, where NAN means unknown: None."""
259
+ return None if math.isnan(value) else value