asteroidpipeline 0.2.1__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.
- asteroid_ui/__init__.py +3 -0
- asteroid_ui/__main__.py +3 -0
- asteroid_ui/app.py +89 -0
- asteroid_ui/backend.py +126 -0
- asteroid_ui/clipboard.py +27 -0
- asteroid_ui/config.py +58 -0
- asteroid_ui/designations.py +75 -0
- asteroid_ui/engine/__init__.py +129 -0
- asteroid_ui/engine/server.jl +291 -0
- asteroid_ui/imaging.py +181 -0
- asteroid_ui/julia/AsteroidPipeline/LICENSE +21 -0
- asteroid_ui/julia/AsteroidPipeline/Project.toml +53 -0
- asteroid_ui/julia/AsteroidPipeline/src/AsteroidPipeline.jl +62 -0
- asteroid_ui/julia/AsteroidPipeline/src/astrometric_refinement.jl +230 -0
- asteroid_ui/julia/AsteroidPipeline/src/astrometry.jl +162 -0
- asteroid_ui/julia/AsteroidPipeline/src/calibrated_photometry.jl +135 -0
- asteroid_ui/julia/AsteroidPipeline/src/campaign.jl +702 -0
- asteroid_ui/julia/AsteroidPipeline/src/crossmatch.jl +434 -0
- asteroid_ui/julia/AsteroidPipeline/src/detection.jl +208 -0
- asteroid_ui/julia/AsteroidPipeline/src/digest2.jl +119 -0
- asteroid_ui/julia/AsteroidPipeline/src/injection.jl +181 -0
- asteroid_ui/julia/AsteroidPipeline/src/known_nearby.jl +156 -0
- asteroid_ui/julia/AsteroidPipeline/src/linking.jl +241 -0
- asteroid_ui/julia/AsteroidPipeline/src/linking_multinight.jl +109 -0
- asteroid_ui/julia/AsteroidPipeline/src/mpc_export.jl +276 -0
- asteroid_ui/julia/AsteroidPipeline/src/mpcorb.jl +175 -0
- asteroid_ui/julia/AsteroidPipeline/src/neocp.jl +149 -0
- asteroid_ui/julia/AsteroidPipeline/src/observatories.jl +166 -0
- asteroid_ui/julia/AsteroidPipeline/src/pipeline.jl +626 -0
- asteroid_ui/julia/AsteroidPipeline/src/platesolve.jl +144 -0
- asteroid_ui/julia/AsteroidPipeline/src/psf.jl +202 -0
- asteroid_ui/julia/AsteroidPipeline/src/reference.jl +174 -0
- asteroid_ui/julia/AsteroidPipeline/src/rotation.jl +89 -0
- asteroid_ui/julia/AsteroidPipeline/src/stack_difference.jl +316 -0
- asteroid_ui/julia/AsteroidPipeline/src/variables.jl +298 -0
- asteroid_ui/julia/AsteroidPipeline/src/zogy.jl +205 -0
- asteroid_ui/labels.py +122 -0
- asteroid_ui/profiles.py +99 -0
- asteroid_ui/screens/__init__.py +0 -0
- asteroid_ui/screens/analysis.py +138 -0
- asteroid_ui/screens/check.py +134 -0
- asteroid_ui/screens/help.py +28 -0
- asteroid_ui/screens/home.py +164 -0
- asteroid_ui/screens/open.py +142 -0
- asteroid_ui/screens/profile.py +99 -0
- asteroid_ui/screens/reason.py +40 -0
- asteroid_ui/screens/report.py +118 -0
- asteroid_ui/screens/results.py +411 -0
- asteroid_ui/screens/settings.py +130 -0
- asteroid_ui/screens/summary.py +59 -0
- asteroid_ui/sets.py +128 -0
- asteroid_ui/styles.tcss +299 -0
- asteroid_ui/texts.py +119 -0
- asteroid_ui/theme.py +22 -0
- asteroid_ui/widgets.py +77 -0
- asteroidpipeline-0.2.1.dist-info/METADATA +95 -0
- asteroidpipeline-0.2.1.dist-info/RECORD +60 -0
- asteroidpipeline-0.2.1.dist-info/WHEEL +4 -0
- asteroidpipeline-0.2.1.dist-info/entry_points.txt +2 -0
- asteroidpipeline-0.2.1.dist-info/licenses/LICENSE +21 -0
asteroid_ui/__init__.py
ADDED
asteroid_ui/__main__.py
ADDED
asteroid_ui/app.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from textual.app import App
|
|
4
|
+
from textual.reactive import reactive
|
|
5
|
+
|
|
6
|
+
# textual-image finds out which graphics the terminal supports (Kitty, Sixel...)
|
|
7
|
+
# by asking it with escape sequences; that only works before Textual takes the
|
|
8
|
+
# terminal over, so it is imported here, at start-up.
|
|
9
|
+
try:
|
|
10
|
+
import textual_image.widget # noqa: F401
|
|
11
|
+
except ImportError:
|
|
12
|
+
pass
|
|
13
|
+
|
|
14
|
+
from asteroid_ui.backend import Backend
|
|
15
|
+
from asteroid_ui.config import load_config
|
|
16
|
+
from asteroid_ui.texts import STATUS_MESSAGES
|
|
17
|
+
from asteroid_ui.theme import GRUVBOX
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AsteroidApp(App):
|
|
21
|
+
CSS_PATH = "styles.tcss"
|
|
22
|
+
TITLE = "asteroids"
|
|
23
|
+
SUB_TITLE = "AsteroidPipeline"
|
|
24
|
+
|
|
25
|
+
engine_status: reactive[str] = reactive("Starting the analysis engine (Julia)… this can take a minute.")
|
|
26
|
+
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
super().__init__()
|
|
29
|
+
self.config = load_config()
|
|
30
|
+
self.backend = Backend()
|
|
31
|
+
self.backend.on_status = lambda code: setattr(self, "engine_status", STATUS_MESSAGES.get(code, code))
|
|
32
|
+
|
|
33
|
+
def on_mount(self) -> None:
|
|
34
|
+
self.register_theme(GRUVBOX)
|
|
35
|
+
self.theme = "gruvbox-asteroides"
|
|
36
|
+
from asteroid_ui.screens.home import HomeScreen
|
|
37
|
+
|
|
38
|
+
self.push_screen(HomeScreen())
|
|
39
|
+
self.run_worker(self._start_backend())
|
|
40
|
+
|
|
41
|
+
async def _start_backend(self) -> None:
|
|
42
|
+
await self.backend.start()
|
|
43
|
+
await self.backend.ready.wait()
|
|
44
|
+
self.engine_status = f"✗ {self.backend.failed}" if self.backend.failed else "Analysis engine ready."
|
|
45
|
+
|
|
46
|
+
async def on_unmount(self) -> None:
|
|
47
|
+
await self.backend.stop()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
USAGE = """\
|
|
51
|
+
usage: asteroidpipeline [--setup | --version | --help]
|
|
52
|
+
|
|
53
|
+
Find moving objects in astronomical image sets (.zip files, folders or FITS
|
|
54
|
+
files), check the candidates, and write MPC reports.
|
|
55
|
+
|
|
56
|
+
--setup install the analysis engine (Julia packages) and exit;
|
|
57
|
+
the app otherwise does it on its first run
|
|
58
|
+
--version print the version and exit
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def main() -> None:
|
|
63
|
+
import sys
|
|
64
|
+
|
|
65
|
+
args = sys.argv[1:]
|
|
66
|
+
if "--help" in args or "-h" in args:
|
|
67
|
+
print(USAGE)
|
|
68
|
+
return
|
|
69
|
+
if "--version" in args:
|
|
70
|
+
from asteroid_ui import __version__
|
|
71
|
+
|
|
72
|
+
print(f"asteroidpipeline {__version__}")
|
|
73
|
+
return
|
|
74
|
+
if "--setup" in args:
|
|
75
|
+
from asteroid_ui import engine
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
julia = engine.julia_executable()
|
|
79
|
+
print(f"Setting up the analysis engine with {julia} (several minutes)…")
|
|
80
|
+
engine.setup(julia, print)
|
|
81
|
+
except engine.EngineError as e:
|
|
82
|
+
sys.exit(str(e))
|
|
83
|
+
print("Done.")
|
|
84
|
+
return
|
|
85
|
+
AsteroidApp().run()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
main()
|
asteroid_ui/backend.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Client for the analysis engine (ui/backend/server.jl).
|
|
2
|
+
|
|
3
|
+
The engine is a Julia process kept open for the whole session: loading and
|
|
4
|
+
compiling AsteroidPipeline takes ~20 s, so it is paid once. It speaks JSON,
|
|
5
|
+
one message per line (see server.jl).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import itertools
|
|
12
|
+
import json
|
|
13
|
+
from collections.abc import Awaitable, Callable
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from asteroid_ui import engine
|
|
17
|
+
from asteroid_ui.config import BACKEND_LOG
|
|
18
|
+
|
|
19
|
+
Event = dict[str, Any]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class BackendError(Exception):
|
|
23
|
+
"""The engine answered with an error, or could not be started."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Backend:
|
|
27
|
+
def __init__(self) -> None:
|
|
28
|
+
self._proc: asyncio.subprocess.Process | None = None
|
|
29
|
+
self._ids = itertools.count(1)
|
|
30
|
+
self._queues: dict[int, asyncio.Queue[Event]] = {}
|
|
31
|
+
self._lock = asyncio.Lock() # the engine handles one command at a time
|
|
32
|
+
self.ready = asyncio.Event()
|
|
33
|
+
self.failed: str | None = None
|
|
34
|
+
self.on_status: Callable[[str], None] = lambda message: None
|
|
35
|
+
|
|
36
|
+
@property
|
|
37
|
+
def busy(self) -> bool:
|
|
38
|
+
return self._lock.locked()
|
|
39
|
+
|
|
40
|
+
async def start(self) -> None:
|
|
41
|
+
"""Find Julia, set the engine up if this is the first run (or an upgrade), and start it."""
|
|
42
|
+
try:
|
|
43
|
+
julia = await asyncio.to_thread(engine.julia_executable)
|
|
44
|
+
if await asyncio.to_thread(engine.needs_setup):
|
|
45
|
+
self.on_status("Installing the analysis engine — the first time only, it takes several minutes…")
|
|
46
|
+
loop = asyncio.get_running_loop()
|
|
47
|
+
|
|
48
|
+
def progress(line: str) -> None:
|
|
49
|
+
loop.call_soon_threadsafe(self.on_status, f"Installing the analysis engine: {line[:90]}")
|
|
50
|
+
|
|
51
|
+
await asyncio.to_thread(engine.setup, julia, progress)
|
|
52
|
+
self.on_status("Starting the analysis engine…")
|
|
53
|
+
except engine.EngineError as e:
|
|
54
|
+
self.failed = str(e)
|
|
55
|
+
self.ready.set()
|
|
56
|
+
return
|
|
57
|
+
BACKEND_LOG.parent.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
log = open(BACKEND_LOG, "a", buffering=1)
|
|
59
|
+
self._proc = await asyncio.create_subprocess_exec(
|
|
60
|
+
*engine.server_command(julia),
|
|
61
|
+
stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=log,
|
|
62
|
+
limit=64 * 1024 * 1024,
|
|
63
|
+
)
|
|
64
|
+
asyncio.create_task(self._read_loop())
|
|
65
|
+
|
|
66
|
+
async def _read_loop(self) -> None:
|
|
67
|
+
assert self._proc and self._proc.stdout
|
|
68
|
+
while line := await self._proc.stdout.readline():
|
|
69
|
+
try:
|
|
70
|
+
event = json.loads(line)
|
|
71
|
+
except ValueError:
|
|
72
|
+
continue
|
|
73
|
+
if event.get("event") == "ready":
|
|
74
|
+
self.ready.set()
|
|
75
|
+
continue
|
|
76
|
+
if event.get("event") == "status":
|
|
77
|
+
self.on_status(event.get("message", ""))
|
|
78
|
+
queue = self._queues.get(event.get("id"))
|
|
79
|
+
if queue is not None:
|
|
80
|
+
await queue.put(event)
|
|
81
|
+
code = await self._proc.wait()
|
|
82
|
+
self.failed = f"The analysis engine stopped (exit code {code}). Details in {BACKEND_LOG}"
|
|
83
|
+
self.ready.set()
|
|
84
|
+
for queue in self._queues.values():
|
|
85
|
+
await queue.put({"event": "error", "message": self.failed})
|
|
86
|
+
|
|
87
|
+
async def request(self, cmd: str, on_event: Callable[[Event], Awaitable[None] | None] | None = None,
|
|
88
|
+
**args: Any) -> Event:
|
|
89
|
+
"""Send a command and wait for its final event (`result`, `report`, `report_check`, `inspection` or `error`).
|
|
90
|
+
|
|
91
|
+
Events along the way (progress, status) go to `on_event`.
|
|
92
|
+
"""
|
|
93
|
+
await self.ready.wait()
|
|
94
|
+
if self.failed:
|
|
95
|
+
raise BackendError(self.failed)
|
|
96
|
+
async with self._lock:
|
|
97
|
+
assert self._proc and self._proc.stdin
|
|
98
|
+
request_id = next(self._ids)
|
|
99
|
+
queue: asyncio.Queue[Event] = asyncio.Queue()
|
|
100
|
+
self._queues[request_id] = queue
|
|
101
|
+
try:
|
|
102
|
+
self._proc.stdin.write((json.dumps({"cmd": cmd, "id": request_id, **args}) + "\n").encode())
|
|
103
|
+
await self._proc.stdin.drain()
|
|
104
|
+
while True:
|
|
105
|
+
event = await queue.get()
|
|
106
|
+
kind = event.get("event")
|
|
107
|
+
if kind == "error":
|
|
108
|
+
raise BackendError(event.get("message", "unknown error"))
|
|
109
|
+
if kind in ("result", "report", "report_check", "inspection"):
|
|
110
|
+
return event
|
|
111
|
+
if on_event is not None:
|
|
112
|
+
maybe = on_event(event)
|
|
113
|
+
if asyncio.iscoroutine(maybe):
|
|
114
|
+
await maybe
|
|
115
|
+
finally:
|
|
116
|
+
del self._queues[request_id]
|
|
117
|
+
|
|
118
|
+
async def stop(self) -> None:
|
|
119
|
+
if self._proc and self._proc.returncode is None:
|
|
120
|
+
try:
|
|
121
|
+
assert self._proc.stdin
|
|
122
|
+
self._proc.stdin.write(b'{"cmd": "quit"}\n')
|
|
123
|
+
await self._proc.stdin.drain()
|
|
124
|
+
await asyncio.wait_for(self._proc.wait(), 3)
|
|
125
|
+
except (asyncio.TimeoutError, BrokenPipeError, ConnectionResetError):
|
|
126
|
+
self._proc.kill()
|
asteroid_ui/clipboard.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Copy text to the clipboard.
|
|
2
|
+
|
|
3
|
+
Textual's own copy (an OSC 52 escape sequence) works in most terminals, but not
|
|
4
|
+
all of them allow it; the desktop's clipboard tool is used as well when there
|
|
5
|
+
is one, so a copied report is really there to paste.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
|
|
13
|
+
from textual.app import App
|
|
14
|
+
|
|
15
|
+
_TOOLS = (["wl-copy"], ["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def copy_text(app: App, text: str) -> None:
|
|
19
|
+
app.copy_to_clipboard(text)
|
|
20
|
+
for cmd in _TOOLS:
|
|
21
|
+
if shutil.which(cmd[0]):
|
|
22
|
+
try:
|
|
23
|
+
subprocess.run(cmd, input=text.encode(), timeout=3, check=False,
|
|
24
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
|
25
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
26
|
+
continue
|
|
27
|
+
break
|
asteroid_ui/config.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Paths and preferences.
|
|
2
|
+
|
|
3
|
+
Everything the user can change lives in ~/.config/asteroid-ui/config.json and
|
|
4
|
+
in the Settings screen; the rest follows from where this package sits in the
|
|
5
|
+
repository.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import asdict, dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
_XDG_CONFIG = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
|
16
|
+
_XDG_CACHE = Path(os.environ.get("XDG_CACHE_HOME", Path.home() / ".cache"))
|
|
17
|
+
_XDG_DATA = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local" / "share"))
|
|
18
|
+
CONFIG_FILE = _XDG_CONFIG / "asteroid-ui" / "config.json"
|
|
19
|
+
CACHE_DIR = _XDG_CACHE / "asteroid-ui"
|
|
20
|
+
SETS_CACHE = CACHE_DIR / "sets"
|
|
21
|
+
BACKEND_LOG = CACHE_DIR / "engine.log"
|
|
22
|
+
# Every candidate a person has labelled, with its pixels: training data for a
|
|
23
|
+
# future classifier. Kept apart from the cache, which can be thrown away.
|
|
24
|
+
DATA_DIR = _XDG_DATA / "asteroid-ui"
|
|
25
|
+
DATASET_DIR = DATA_DIR / "dataset"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Config:
|
|
30
|
+
# Shown big on the start screen: your team's name, or the program's.
|
|
31
|
+
title: str = "NEO Hunters"
|
|
32
|
+
# Folder searched (with its subfolders) for image-set .zip files.
|
|
33
|
+
downloads: str = str(Path.home() / "Downloads")
|
|
34
|
+
# The MPC report's MEA and CON lines: who measures (you, your team) and
|
|
35
|
+
# how to reach them. The telescope's own lines (COD, OBS, TEL) come with
|
|
36
|
+
# its profile (profiles.py).
|
|
37
|
+
measurers: str = ""
|
|
38
|
+
contact: str = ""
|
|
39
|
+
# Your own temporary designations for the candidates you report: a prefix
|
|
40
|
+
# ("NHU") and the next number (NHU0002). Empty: the program's own (P2).
|
|
41
|
+
designation_prefix: str = ""
|
|
42
|
+
next_designation: int = 1
|
|
43
|
+
# A report file to check before submitting it (from this program or any other).
|
|
44
|
+
report_to_check: str = ""
|
|
45
|
+
# Animation speed, images per second (at most 3: no fast flashing).
|
|
46
|
+
fps: float = 1.5
|
|
47
|
+
|
|
48
|
+
def save(self) -> None:
|
|
49
|
+
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
CONFIG_FILE.write_text(json.dumps(asdict(self), indent=2, ensure_ascii=False))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load_config() -> Config:
|
|
54
|
+
try:
|
|
55
|
+
data = json.loads(CONFIG_FILE.read_text())
|
|
56
|
+
except (OSError, ValueError):
|
|
57
|
+
return Config()
|
|
58
|
+
return Config(**{k: v for k, v in data.items() if k in Config.__dataclass_fields__})
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Temporary designations for the candidates you report.
|
|
2
|
+
|
|
3
|
+
The MPC's 80-column format has 7 characters for an observer's own
|
|
4
|
+
designation of an object it doesn't know yet. Teams number their finds with
|
|
5
|
+
a prefix of their own — "NHU0001", "NHU0002"… — set in Settings. Each
|
|
6
|
+
candidate keeps the designation it was first given, whenever its report is
|
|
7
|
+
written again; numbers are never reused.
|
|
8
|
+
|
|
9
|
+
A candidate on the MPC's NEO Confirmation Page is reported under the NEOCP's
|
|
10
|
+
designation instead: those observations help confirm someone's find.
|
|
11
|
+
Without a prefix, reports use the program's own: "P" and the tracklet's
|
|
12
|
+
number.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from asteroid_ui.config import CONFIG_FILE
|
|
21
|
+
|
|
22
|
+
DESIGNATIONS_FILE = CONFIG_FILE.parent / "designations.json"
|
|
23
|
+
MAX_LENGTH = 7
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _load() -> dict[str, Any]:
|
|
27
|
+
try:
|
|
28
|
+
return json.loads(DESIGNATIONS_FILE.read_text())
|
|
29
|
+
except (OSError, ValueError):
|
|
30
|
+
return {"assigned": {}}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _save(data: dict[str, Any]) -> None:
|
|
34
|
+
DESIGNATIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
35
|
+
DESIGNATIONS_FILE.write_text(json.dumps(data, indent=1))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def valid_prefix(prefix: str) -> bool:
|
|
39
|
+
return prefix == "" or (prefix.isalnum() and prefix.isascii() and len(prefix) <= MAX_LENGTH - 2)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def neocp_designation(tracklet: dict[str, Any]) -> str | None:
|
|
43
|
+
check = tracklet.get("neocp") or {}
|
|
44
|
+
if check.get("verdict") != "on_neocp":
|
|
45
|
+
return None
|
|
46
|
+
agreeing = [o for o in check.get("objects", []) if o.get("agreement") == "motion"]
|
|
47
|
+
return agreeing[0]["designation"] if agreeing else None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def assign(config, set_name: str, tracklets: list[dict[str, Any]]) -> dict[str, str]:
|
|
51
|
+
"""Designation for each of `tracklets` (tracklet id, as a string, => designation),
|
|
52
|
+
giving new numbers to those that have none yet."""
|
|
53
|
+
data = _load()
|
|
54
|
+
assigned: dict[str, str] = data.setdefault("assigned", {})
|
|
55
|
+
out: dict[str, str] = {}
|
|
56
|
+
prefix = config.designation_prefix.strip().upper()
|
|
57
|
+
for t in tracklets:
|
|
58
|
+
neocp = neocp_designation(t)
|
|
59
|
+
if neocp:
|
|
60
|
+
out[str(t["id"])] = neocp
|
|
61
|
+
continue
|
|
62
|
+
if not prefix:
|
|
63
|
+
continue
|
|
64
|
+
key = f"{set_name}/{t['id']}"
|
|
65
|
+
if key not in assigned:
|
|
66
|
+
digits = MAX_LENGTH - len(prefix)
|
|
67
|
+
number = max(int(config.next_designation), 1)
|
|
68
|
+
if len(str(number)) > digits:
|
|
69
|
+
raise ValueError(f"{prefix} has room for {digits} digits; {number} doesn't fit")
|
|
70
|
+
assigned[key] = f"{prefix}{number:0{digits}d}"
|
|
71
|
+
config.next_designation = number + 1
|
|
72
|
+
config.save()
|
|
73
|
+
out[str(t["id"])] = assigned[key]
|
|
74
|
+
_save(data)
|
|
75
|
+
return out
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""The analysis engine: AsteroidPipeline.jl, run by Julia.
|
|
2
|
+
|
|
3
|
+
The Julia package travels inside this Python package (julia/AsteroidPipeline,
|
|
4
|
+
put there when the wheel is built; in a checkout of the repository, the
|
|
5
|
+
repository itself is used). The first time the app runs — and again after an
|
|
6
|
+
upgrade — it gets an environment ready for it:
|
|
7
|
+
|
|
8
|
+
1. find Julia: $ASTEROIDPIPELINE_JULIA, `julia` on the PATH, or, if neither,
|
|
9
|
+
a private copy that `juliapkg` downloads;
|
|
10
|
+
2. make a Julia environment in the user's data folder with AsteroidPipeline
|
|
11
|
+
and the few packages server.jl needs, and precompile it (several minutes,
|
|
12
|
+
once).
|
|
13
|
+
|
|
14
|
+
After that, server.jl starts in seconds.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import os
|
|
21
|
+
import shutil
|
|
22
|
+
import subprocess
|
|
23
|
+
from collections.abc import Callable
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from asteroid_ui import __version__
|
|
27
|
+
from asteroid_ui.config import BACKEND_LOG, DATA_DIR
|
|
28
|
+
|
|
29
|
+
ENGINE_DIR = Path(__file__).resolve().parent
|
|
30
|
+
SERVER = ENGINE_DIR / "server.jl"
|
|
31
|
+
_BUNDLED = ENGINE_DIR.parent / "julia" / "AsteroidPipeline"
|
|
32
|
+
_CHECKOUT = ENGINE_DIR.parents[2] # ui/asteroid_ui/engine -> repository root
|
|
33
|
+
_SERVER_PACKAGES = ["FITSIO", "JSON", "WCS", "TypedTables", "Dates", "Statistics"]
|
|
34
|
+
MIN_JULIA = (1, 11)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class EngineError(Exception):
|
|
38
|
+
"""The engine can't be found or set up; the message says what to do."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def julia_package() -> Path:
|
|
42
|
+
"""Where AsteroidPipeline.jl's source is: bundled in the wheel, or this checkout."""
|
|
43
|
+
for candidate in (_BUNDLED, _CHECKOUT):
|
|
44
|
+
if (candidate / "Project.toml").is_file() and (candidate / "src" / "AsteroidPipeline.jl").is_file():
|
|
45
|
+
return candidate
|
|
46
|
+
raise EngineError("AsteroidPipeline.jl's source is missing from this installation; reinstall it.")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def env_dir() -> Path:
|
|
50
|
+
return DATA_DIR / "engine"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def julia_executable() -> str:
|
|
54
|
+
explicit = os.environ.get("ASTEROIDPIPELINE_JULIA")
|
|
55
|
+
if explicit:
|
|
56
|
+
return explicit
|
|
57
|
+
found = shutil.which("julia")
|
|
58
|
+
if found:
|
|
59
|
+
return found
|
|
60
|
+
try:
|
|
61
|
+
import juliapkg # downloads a private Julia if there is none
|
|
62
|
+
except ImportError:
|
|
63
|
+
raise EngineError("Julia isn't installed. Install it from https://julialang.org/install "
|
|
64
|
+
"(or `pip install juliapkg` to have it downloaded) and start the app again.") from None
|
|
65
|
+
return juliapkg.executable()
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def julia_version(julia: str) -> tuple[int, ...]:
|
|
69
|
+
out = subprocess.run([julia, "--startup-file=no", "-e", "print(VERSION.major, ' ', VERSION.minor)"],
|
|
70
|
+
capture_output=True, text=True, timeout=120)
|
|
71
|
+
try:
|
|
72
|
+
return tuple(int(x) for x in out.stdout.split())
|
|
73
|
+
except ValueError:
|
|
74
|
+
raise EngineError(f"Could not run Julia ({julia}): {out.stderr.strip()[:300]}") from None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _fingerprint(package: Path) -> str:
|
|
78
|
+
"""Changes whenever the engine must be set up again: app version, package sources' manifest, Julia."""
|
|
79
|
+
h = hashlib.sha256(__version__.encode())
|
|
80
|
+
h.update((package / "Project.toml").read_bytes())
|
|
81
|
+
h.update(str(package).encode())
|
|
82
|
+
for f in sorted((package / "src").glob("*.jl")):
|
|
83
|
+
h.update(f.name.encode())
|
|
84
|
+
h.update(str(f.stat().st_size).encode())
|
|
85
|
+
return h.hexdigest()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def needs_setup() -> bool:
|
|
89
|
+
try:
|
|
90
|
+
stamp = (env_dir() / "ready").read_text().strip()
|
|
91
|
+
except OSError:
|
|
92
|
+
return True
|
|
93
|
+
return stamp != _fingerprint(julia_package())
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def setup(julia: str, on_line: Callable[[str], None] = lambda line: None) -> None:
|
|
97
|
+
"""Make the engine's Julia environment; lines of Julia's output go to `on_line` and the log."""
|
|
98
|
+
version = julia_version(julia)
|
|
99
|
+
if version < MIN_JULIA:
|
|
100
|
+
raise EngineError(f"Julia {'.'.join(map(str, version))} is too old; AsteroidPipeline needs "
|
|
101
|
+
f"{'.'.join(map(str, MIN_JULIA))} or newer.")
|
|
102
|
+
package = julia_package()
|
|
103
|
+
env = env_dir()
|
|
104
|
+
if env.exists():
|
|
105
|
+
shutil.rmtree(env)
|
|
106
|
+
env.mkdir(parents=True)
|
|
107
|
+
script = (
|
|
108
|
+
"using Pkg; "
|
|
109
|
+
f"Pkg.develop(path={str(package)!r}); "
|
|
110
|
+
f"Pkg.add({_SERVER_PACKAGES!r}); "
|
|
111
|
+
"Pkg.instantiate(); Pkg.precompile()"
|
|
112
|
+
).replace("'", '"')
|
|
113
|
+
BACKEND_LOG.parent.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
with open(BACKEND_LOG, "a", buffering=1) as log:
|
|
115
|
+
log.write(f"--- setting up the engine in {env} with {julia}\n")
|
|
116
|
+
proc = subprocess.Popen([julia, f"--project={env}", "--startup-file=no", "-e", script],
|
|
117
|
+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
|
|
118
|
+
assert proc.stdout is not None
|
|
119
|
+
for line in proc.stdout:
|
|
120
|
+
log.write(line)
|
|
121
|
+
if line.strip():
|
|
122
|
+
on_line(line.rstrip())
|
|
123
|
+
if proc.wait() != 0:
|
|
124
|
+
raise EngineError(f"Setting up the analysis engine failed; details in {BACKEND_LOG}")
|
|
125
|
+
(env / "ready").write_text(_fingerprint(package))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def server_command(julia: str) -> list[str]:
|
|
129
|
+
return [julia, f"--project={env_dir()}", "--startup-file=no", str(SERVER)]
|