starbash 0.1.9__py3-none-any.whl → 0.1.15__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.
- repo/__init__.py +1 -1
- repo/manager.py +14 -23
- repo/repo.py +52 -10
- starbash/__init__.py +10 -3
- starbash/aliases.py +145 -0
- starbash/analytics.py +3 -2
- starbash/app.py +512 -473
- starbash/check_version.py +18 -0
- starbash/commands/__init__.py +2 -1
- starbash/commands/info.py +88 -14
- starbash/commands/process.py +76 -24
- starbash/commands/repo.py +41 -68
- starbash/commands/select.py +141 -142
- starbash/commands/user.py +88 -23
- starbash/database.py +219 -112
- starbash/defaults/starbash.toml +24 -3
- starbash/exception.py +21 -0
- starbash/main.py +29 -7
- starbash/paths.py +35 -5
- starbash/processing.py +724 -0
- starbash/recipes/README.md +3 -0
- starbash/recipes/master_bias/starbash.toml +16 -19
- starbash/recipes/master_dark/starbash.toml +33 -0
- starbash/recipes/master_flat/starbash.toml +26 -18
- starbash/recipes/osc.py +190 -0
- starbash/recipes/osc_dual_duo/starbash.toml +54 -44
- starbash/recipes/osc_simple/starbash.toml +82 -0
- starbash/recipes/osc_single_duo/starbash.toml +51 -32
- starbash/recipes/seestar/starbash.toml +82 -0
- starbash/recipes/starbash.toml +30 -9
- starbash/selection.py +32 -36
- starbash/templates/repo/master.toml +7 -3
- starbash/templates/repo/processed.toml +15 -0
- starbash/templates/userconfig.toml +9 -0
- starbash/toml.py +13 -13
- starbash/tool.py +230 -96
- starbash-0.1.15.dist-info/METADATA +216 -0
- starbash-0.1.15.dist-info/RECORD +45 -0
- starbash/recipes/osc_dual_duo/starbash.py +0 -151
- starbash-0.1.9.dist-info/METADATA +0 -145
- starbash-0.1.9.dist-info/RECORD +0 -37
- {starbash-0.1.9.dist-info → starbash-0.1.15.dist-info}/WHEEL +0 -0
- {starbash-0.1.9.dist-info → starbash-0.1.15.dist-info}/entry_points.txt +0 -0
- {starbash-0.1.9.dist-info → starbash-0.1.15.dist-info}/licenses/LICENSE +0 -0
starbash/app.py
CHANGED
|
@@ -1,32 +1,18 @@
|
|
|
1
1
|
import logging
|
|
2
|
-
|
|
3
|
-
import
|
|
2
|
+
import shutil
|
|
3
|
+
from datetime import datetime
|
|
4
4
|
from pathlib import Path
|
|
5
|
-
import tempfile
|
|
6
|
-
import typer
|
|
7
|
-
import tomlkit
|
|
8
|
-
from tomlkit.toml_file import TOMLFile
|
|
9
|
-
import glob
|
|
10
5
|
from typing import Any
|
|
6
|
+
|
|
7
|
+
import rich.console
|
|
8
|
+
import typer
|
|
11
9
|
from astropy.io import fits
|
|
12
|
-
import itertools
|
|
13
|
-
from rich.progress import track
|
|
14
10
|
from rich.logging import RichHandler
|
|
15
|
-
import
|
|
16
|
-
from datetime import datetime
|
|
17
|
-
import rich.console
|
|
18
|
-
import copy
|
|
11
|
+
from rich.progress import track
|
|
19
12
|
|
|
20
13
|
import starbash
|
|
21
|
-
from
|
|
22
|
-
from starbash.
|
|
23
|
-
from repo import Repo, repo_suffix
|
|
24
|
-
from starbash.toml import toml_from_template
|
|
25
|
-
from starbash.tool import Tool, expand_context, expand_context_unsafe
|
|
26
|
-
from repo import RepoManager
|
|
27
|
-
from starbash.tool import tools
|
|
28
|
-
from starbash.paths import get_user_config_dir, get_user_data_dir
|
|
29
|
-
from starbash.selection import Selection, where_tuple
|
|
14
|
+
from repo import Repo, RepoManager, repo_suffix
|
|
15
|
+
from starbash.aliases import Aliases, normalize_target_name
|
|
30
16
|
from starbash.analytics import (
|
|
31
17
|
NopAnalytics,
|
|
32
18
|
analytics_exception,
|
|
@@ -34,17 +20,30 @@ from starbash.analytics import (
|
|
|
34
20
|
analytics_shutdown,
|
|
35
21
|
analytics_start_transaction,
|
|
36
22
|
)
|
|
37
|
-
|
|
38
|
-
|
|
23
|
+
from starbash.check_version import check_version
|
|
24
|
+
from starbash.database import (
|
|
25
|
+
Database,
|
|
26
|
+
ImageRow,
|
|
27
|
+
SearchCondition,
|
|
28
|
+
SessionRow,
|
|
29
|
+
get_column_name,
|
|
30
|
+
)
|
|
31
|
+
from starbash.paths import get_user_config_dir, get_user_config_path
|
|
32
|
+
from starbash.selection import Selection, build_search_conditions
|
|
33
|
+
from starbash.toml import toml_from_template
|
|
34
|
+
from starbash.tool import preflight_tools
|
|
39
35
|
|
|
40
36
|
|
|
41
|
-
def setup_logging(
|
|
37
|
+
def setup_logging(console: rich.console.Console):
|
|
42
38
|
"""
|
|
43
39
|
Configures basic logging.
|
|
44
40
|
"""
|
|
45
|
-
|
|
41
|
+
from starbash import _is_test_env # Lazy import to avoid circular dependency
|
|
42
|
+
|
|
46
43
|
handlers = (
|
|
47
|
-
[RichHandler(console=console, rich_tracebacks=True)]
|
|
44
|
+
[RichHandler(console=console, rich_tracebacks=True, markup=True)]
|
|
45
|
+
if not _is_test_env
|
|
46
|
+
else []
|
|
48
47
|
)
|
|
49
48
|
logging.basicConfig(
|
|
50
49
|
level=starbash.log_filter_level, # use the global log filter level
|
|
@@ -54,12 +53,6 @@ def setup_logging(stderr: bool = False):
|
|
|
54
53
|
)
|
|
55
54
|
|
|
56
55
|
|
|
57
|
-
def get_user_config_path() -> Path:
|
|
58
|
-
"""Returns the path to the user config file."""
|
|
59
|
-
config_dir = get_user_config_dir()
|
|
60
|
-
return config_dir / repo_suffix
|
|
61
|
-
|
|
62
|
-
|
|
63
56
|
def create_user() -> Path:
|
|
64
57
|
"""Create user directories if they don't exist yet."""
|
|
65
58
|
path = get_user_config_path()
|
|
@@ -70,7 +63,12 @@ def create_user() -> Path:
|
|
|
70
63
|
|
|
71
64
|
|
|
72
65
|
def copy_images_to_dir(images: list[ImageRow], output_dir: Path) -> None:
|
|
73
|
-
"""Copy images to the specified output directory (using symbolic links if possible).
|
|
66
|
+
"""Copy images to the specified output directory (using symbolic links if possible).
|
|
67
|
+
|
|
68
|
+
This function requires that "abspath" already be populated in each ImageRow. Normally
|
|
69
|
+
the caller does this by calling Starbash._add_image_abspath() on the image.
|
|
70
|
+
"""
|
|
71
|
+
from starbash import console # Lazy import to avoid circular dependency
|
|
74
72
|
|
|
75
73
|
# Export images
|
|
76
74
|
console.print(f"[cyan]Exporting {len(images)} images to {output_dir}...[/cyan]")
|
|
@@ -81,7 +79,7 @@ def copy_images_to_dir(images: list[ImageRow], output_dir: Path) -> None:
|
|
|
81
79
|
|
|
82
80
|
for image in images:
|
|
83
81
|
# Get the source path from the image metadata
|
|
84
|
-
source_path = Path(image.get("
|
|
82
|
+
source_path = Path(image.get("abspath", ""))
|
|
85
83
|
|
|
86
84
|
if not source_path.exists():
|
|
87
85
|
console.print(f"[red]Warning: Source file not found: {source_path}[/red]")
|
|
@@ -109,7 +107,7 @@ def copy_images_to_dir(images: list[ImageRow], output_dir: Path) -> None:
|
|
|
109
107
|
error_count += 1
|
|
110
108
|
|
|
111
109
|
# Print summary
|
|
112
|
-
console.print(
|
|
110
|
+
console.print("[green]Export complete![/green]")
|
|
113
111
|
if linked_count > 0:
|
|
114
112
|
console.print(f" Linked: {linked_count} files")
|
|
115
113
|
if copied_count > 0:
|
|
@@ -118,14 +116,6 @@ def copy_images_to_dir(images: list[ImageRow], output_dir: Path) -> None:
|
|
|
118
116
|
console.print(f" [red]Errors: {error_count} files[/red]")
|
|
119
117
|
|
|
120
118
|
|
|
121
|
-
def imagetyp_equals(imagetyp1: str, imagetyp2: str) -> bool:
|
|
122
|
-
"""Imagetyps (BIAS, Dark, FLAT, flats) have a number of slightly different convetions.
|
|
123
|
-
Do a sloppy equality check.
|
|
124
|
-
|
|
125
|
-
Eventually handle non english variants by using the repos aliases table."""
|
|
126
|
-
return imagetyp1.strip().lower() == imagetyp2.strip().lower()
|
|
127
|
-
|
|
128
|
-
|
|
129
119
|
class Starbash:
|
|
130
120
|
"""The main Starbash application class."""
|
|
131
121
|
|
|
@@ -133,23 +123,55 @@ class Starbash:
|
|
|
133
123
|
"""
|
|
134
124
|
Initializes the Starbash application by loading configurations
|
|
135
125
|
and setting up the repository manager.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
cmd (str): The command name or identifier for the current Starbash session.
|
|
129
|
+
stderr_logging (bool): Whether to enable logging to stderr.
|
|
130
|
+
no_progress (bool): Whether to disable the (asynchronous) progress display (because it breaks typer.ask)
|
|
136
131
|
"""
|
|
137
|
-
|
|
132
|
+
from starbash import _is_test_env # Lazy import to avoid circular dependency
|
|
133
|
+
|
|
134
|
+
# It is important to disable fancy colors and line wrapping if running under test - because
|
|
135
|
+
# those tests will be string parsing our output.
|
|
136
|
+
console = rich.console.Console(
|
|
137
|
+
force_terminal=False if _is_test_env else None,
|
|
138
|
+
width=999999 if _is_test_env else None, # Disable line wrapping in tests
|
|
139
|
+
stderr=stderr_logging,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
starbash.console = console # Update the global console to use the progress version
|
|
143
|
+
|
|
144
|
+
setup_logging(starbash.console)
|
|
138
145
|
logging.info("Starbash starting...")
|
|
139
146
|
|
|
140
147
|
# Load app defaults and initialize the repository manager
|
|
148
|
+
self._init_repos()
|
|
149
|
+
self._init_analytics(cmd) # after init repos so we have user prefs
|
|
150
|
+
check_version()
|
|
151
|
+
self._init_aliases()
|
|
152
|
+
|
|
153
|
+
logging.info(f"Repo manager initialized with {len(self.repo_manager.repos)} repos.")
|
|
154
|
+
# self.repo_manager.dump()
|
|
155
|
+
|
|
156
|
+
self._db = None # Lazy initialization - only create when accessed
|
|
157
|
+
|
|
158
|
+
# Initialize selection state (stored in user config repo)
|
|
159
|
+
self.selection = Selection(self.user_repo)
|
|
160
|
+
preflight_tools()
|
|
161
|
+
|
|
162
|
+
def _init_repos(self) -> None:
|
|
163
|
+
"""Initialize all repositories managed by the RepoManager."""
|
|
141
164
|
self.repo_manager = RepoManager()
|
|
142
165
|
self.repo_manager.add_repo("pkg://defaults")
|
|
143
166
|
|
|
144
167
|
# Add user prefs as a repo
|
|
145
168
|
self.user_repo = self.repo_manager.add_repo("file://" + str(create_user()))
|
|
146
169
|
|
|
170
|
+
def _init_analytics(self, cmd: str) -> None:
|
|
147
171
|
self.analytics = NopAnalytics()
|
|
148
172
|
if self.user_repo.get("analytics.enabled", True):
|
|
149
173
|
include_user = self.user_repo.get("analytics.include_user", False)
|
|
150
|
-
user_email = (
|
|
151
|
-
self.user_repo.get("user.email", None) if include_user else None
|
|
152
|
-
)
|
|
174
|
+
user_email = self.user_repo.get("user.email", None) if include_user else None
|
|
153
175
|
if user_email is not None:
|
|
154
176
|
user_email = str(user_email)
|
|
155
177
|
analytics_setup(allowed=True, user_email=user_email)
|
|
@@ -157,19 +179,10 @@ class Starbash:
|
|
|
157
179
|
self.analytics = analytics_start_transaction(name="App session", op=cmd)
|
|
158
180
|
self.analytics.__enter__()
|
|
159
181
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
)
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
self._db = None # Lazy initialization - only create when accessed
|
|
166
|
-
self.session_query = None # None means search all sessions
|
|
167
|
-
|
|
168
|
-
# Initialize selection state (stored in user config repo)
|
|
169
|
-
self.selection = Selection(self.user_repo)
|
|
170
|
-
|
|
171
|
-
# FIXME, call reindex somewhere and also index whenever new repos are added
|
|
172
|
-
# self.reindex_repos()
|
|
182
|
+
def _init_aliases(self) -> None:
|
|
183
|
+
alias_dict = self.repo_manager.get("aliases", {})
|
|
184
|
+
assert isinstance(alias_dict, dict), "Aliases config must be a dictionary"
|
|
185
|
+
self.aliases = Aliases(alias_dict)
|
|
173
186
|
|
|
174
187
|
@property
|
|
175
188
|
def db(self) -> Database:
|
|
@@ -214,36 +227,87 @@ class Starbash:
|
|
|
214
227
|
self.close()
|
|
215
228
|
return handled
|
|
216
229
|
|
|
217
|
-
def _add_session(self,
|
|
230
|
+
def _add_session(self, header: dict) -> None:
|
|
218
231
|
"""We just added a new image, create or update its session entry as needed."""
|
|
219
|
-
|
|
232
|
+
image_doc_id: int = header[Database.ID_KEY] # this key is required to exist
|
|
220
233
|
image_type = header.get(Database.IMAGETYP_KEY)
|
|
221
234
|
date = header.get(Database.DATE_OBS_KEY)
|
|
222
235
|
if not date or not image_type:
|
|
223
236
|
logging.warning(
|
|
224
|
-
"Image %s missing either DATE-OBS or IMAGETYP FITS header, skipping...",
|
|
225
|
-
|
|
237
|
+
"Image '%s' missing either DATE-OBS or IMAGETYP FITS header, skipping...",
|
|
238
|
+
header.get("path", "unspecified"),
|
|
226
239
|
)
|
|
227
240
|
else:
|
|
228
241
|
exptime = header.get(Database.EXPTIME_KEY, 0)
|
|
229
|
-
|
|
242
|
+
|
|
230
243
|
new = {
|
|
231
|
-
Database.
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
Database.
|
|
236
|
-
Database.
|
|
237
|
-
Database.
|
|
238
|
-
Database.
|
|
239
|
-
Database.
|
|
244
|
+
get_column_name(Database.START_KEY): date,
|
|
245
|
+
get_column_name(
|
|
246
|
+
Database.END_KEY
|
|
247
|
+
): date, # FIXME not quite correct, should be longer by exptime
|
|
248
|
+
get_column_name(Database.IMAGE_DOC_KEY): image_doc_id,
|
|
249
|
+
get_column_name(Database.IMAGETYP_KEY): image_type,
|
|
250
|
+
get_column_name(Database.NUM_IMAGES_KEY): 1,
|
|
251
|
+
get_column_name(Database.EXPTIME_TOTAL_KEY): exptime,
|
|
252
|
+
get_column_name(Database.EXPTIME_KEY): exptime,
|
|
240
253
|
}
|
|
254
|
+
|
|
255
|
+
filter = header.get(Database.FILTER_KEY)
|
|
256
|
+
if filter:
|
|
257
|
+
new[get_column_name(Database.FILTER_KEY)] = filter
|
|
258
|
+
|
|
259
|
+
telescop = header.get(Database.TELESCOP_KEY)
|
|
260
|
+
if telescop:
|
|
261
|
+
new[get_column_name(Database.TELESCOP_KEY)] = telescop
|
|
262
|
+
|
|
263
|
+
obj = header.get(Database.OBJECT_KEY)
|
|
264
|
+
if obj:
|
|
265
|
+
new[get_column_name(Database.OBJECT_KEY)] = obj
|
|
266
|
+
|
|
241
267
|
session = self.db.get_session(new)
|
|
242
268
|
self.db.upsert_session(new, existing=session)
|
|
243
269
|
|
|
244
|
-
def
|
|
245
|
-
|
|
246
|
-
|
|
270
|
+
def add_local_repo(self, path: str, repo_type: str | None = None) -> None:
|
|
271
|
+
"""Add a local repository located at the specified path. If necessary toml config files
|
|
272
|
+
will be created at the root of the repository."""
|
|
273
|
+
|
|
274
|
+
p = Path(path)
|
|
275
|
+
console = starbash.console
|
|
276
|
+
|
|
277
|
+
repo_toml = p / repo_suffix # the starbash.toml file at the root of the repo
|
|
278
|
+
if repo_toml.exists():
|
|
279
|
+
logging.warning("Using existing repository config file: %s", repo_toml)
|
|
280
|
+
else:
|
|
281
|
+
if repo_type:
|
|
282
|
+
console.print(f"Creating {repo_type} repository: {p}")
|
|
283
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
284
|
+
|
|
285
|
+
toml_from_template(
|
|
286
|
+
f"repo/{repo_type}",
|
|
287
|
+
p / repo_suffix,
|
|
288
|
+
overrides={
|
|
289
|
+
"REPO_TYPE": repo_type,
|
|
290
|
+
"REPO_PATH": str(p),
|
|
291
|
+
},
|
|
292
|
+
)
|
|
293
|
+
else:
|
|
294
|
+
# No type specified, therefore (for now) assume we are just using this as an input
|
|
295
|
+
# repo (and it must exist)
|
|
296
|
+
if not p.exists():
|
|
297
|
+
console.print(f"[red]Error: Repo path does not exist: {p}[/red]")
|
|
298
|
+
raise typer.Exit(code=1)
|
|
299
|
+
|
|
300
|
+
console.print(f"Adding repository: {p}")
|
|
301
|
+
|
|
302
|
+
repo = self.user_repo.add_repo_ref(p)
|
|
303
|
+
if repo:
|
|
304
|
+
self.reindex_repo(repo)
|
|
305
|
+
|
|
306
|
+
# we don't yet always write default config files at roots of repos, but it would be easy to add here
|
|
307
|
+
# r.write_config()
|
|
308
|
+
self.user_repo.write_config()
|
|
309
|
+
|
|
310
|
+
def guess_sessions(self, ref_session: SessionRow, want_type: str) -> list[SessionRow]:
|
|
247
311
|
"""Given a particular session type (i.e. FLAT or BIAS etc...) and an
|
|
248
312
|
existing session (which is assumed to generally be a LIGHT frame based session):
|
|
249
313
|
|
|
@@ -267,17 +331,6 @@ class Starbash:
|
|
|
267
331
|
|
|
268
332
|
"""
|
|
269
333
|
# Get reference image to access CCD-TEMP and DATE-OBS
|
|
270
|
-
metadata: dict = ref_session.get("metadata", {})
|
|
271
|
-
ref_temp = metadata.get("CCD-TEMP", None)
|
|
272
|
-
ref_date_str = metadata.get(Database.DATE_OBS_KEY)
|
|
273
|
-
|
|
274
|
-
# Parse reference date for time delta calculations
|
|
275
|
-
ref_date = None
|
|
276
|
-
if ref_date_str:
|
|
277
|
-
try:
|
|
278
|
-
ref_date = datetime.fromisoformat(ref_date_str)
|
|
279
|
-
except (ValueError, TypeError):
|
|
280
|
-
logging.warning(f"Malformed session ref date: {ref_date_str}")
|
|
281
334
|
|
|
282
335
|
# Build search conditions - MUST match criteria
|
|
283
336
|
conditions = {
|
|
@@ -286,13 +339,42 @@ class Starbash:
|
|
|
286
339
|
}
|
|
287
340
|
|
|
288
341
|
# For FLAT frames, filter must match the reference session
|
|
289
|
-
if want_type.
|
|
290
|
-
conditions[Database.FILTER_KEY] = ref_session[
|
|
291
|
-
get_column_name(Database.FILTER_KEY)
|
|
292
|
-
]
|
|
342
|
+
if want_type.lower() == "flat":
|
|
343
|
+
conditions[Database.FILTER_KEY] = ref_session[get_column_name(Database.FILTER_KEY)]
|
|
293
344
|
|
|
294
345
|
# Search for candidate sessions
|
|
295
|
-
candidates = self.db.search_session(
|
|
346
|
+
candidates = self.db.search_session(build_search_conditions(conditions))
|
|
347
|
+
|
|
348
|
+
return self.score_candidates(candidates, ref_session)
|
|
349
|
+
|
|
350
|
+
def score_candidates(
|
|
351
|
+
self, candidates: list[dict[str, Any]], ref_session: SessionRow
|
|
352
|
+
) -> list[SessionRow]:
|
|
353
|
+
"""Given a list of images or sessions, try to rank that list by desirability.
|
|
354
|
+
|
|
355
|
+
Return a list of possible images/sessions which would be acceptable. The more desirable
|
|
356
|
+
matches are first in the list. Possibly in the future I might have a 'score' and reason
|
|
357
|
+
given for each ranking.
|
|
358
|
+
|
|
359
|
+
The following critera MUST match to be acceptable:
|
|
360
|
+
* matches requested imagetyp.
|
|
361
|
+
* same filter as reference session (in the case want_type==FLAT only)
|
|
362
|
+
* same telescope as reference session
|
|
363
|
+
|
|
364
|
+
Quality is determined by (most important first):
|
|
365
|
+
* temperature of CCD-TEMP is closer to the reference session
|
|
366
|
+
* smaller DATE-OBS delta to the reference session
|
|
367
|
+
|
|
368
|
+
Eventually the code will check the following for 'nice to have' (but not now):
|
|
369
|
+
* TBD
|
|
370
|
+
|
|
371
|
+
Possibly eventually this code could be moved into recipes.
|
|
372
|
+
|
|
373
|
+
"""
|
|
374
|
+
|
|
375
|
+
metadata: dict = ref_session.get("metadata", {})
|
|
376
|
+
ref_temp = metadata.get("CCD-TEMP", None)
|
|
377
|
+
ref_date_str = metadata.get(Database.DATE_OBS_KEY)
|
|
296
378
|
|
|
297
379
|
# Now score and sort the candidates
|
|
298
380
|
scored_candidates = []
|
|
@@ -318,61 +400,59 @@ class Starbash:
|
|
|
318
400
|
# If we can't parse temps, give a neutral score
|
|
319
401
|
score += 0
|
|
320
402
|
|
|
321
|
-
#
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
except (ValueError, TypeError):
|
|
335
|
-
logging.warning(
|
|
336
|
-
f"Could not parse candidate date: {candidate_date_str}"
|
|
337
|
-
)
|
|
403
|
+
# Parse reference date for time delta calculations
|
|
404
|
+
candidate_date_str = candidate_image.get(Database.DATE_OBS_KEY)
|
|
405
|
+
if ref_date_str and candidate_date_str:
|
|
406
|
+
try:
|
|
407
|
+
ref_date = datetime.fromisoformat(ref_date_str)
|
|
408
|
+
candidate_date = datetime.fromisoformat(candidate_date_str)
|
|
409
|
+
time_delta = abs((ref_date - candidate_date).total_seconds())
|
|
410
|
+
# Closer in time = better score
|
|
411
|
+
# Same day ≈ 100, 7 days ≈ 37, 30 days ≈ 9
|
|
412
|
+
# Using 7-day half-life
|
|
413
|
+
score += 100 * (2.718 ** (-time_delta / (7 * 86400)))
|
|
414
|
+
except (ValueError, TypeError):
|
|
415
|
+
logging.warning("Malformed date - ignoring entry")
|
|
338
416
|
|
|
339
417
|
scored_candidates.append((score, candidate))
|
|
340
418
|
|
|
341
419
|
except (AssertionError, KeyError) as e:
|
|
342
420
|
# If we can't get the session image, log and skip this candidate
|
|
343
|
-
logging.warning(
|
|
344
|
-
f"Could not score candidate session {candidate.get('id')}: {e}"
|
|
345
|
-
)
|
|
421
|
+
logging.warning(f"Could not score candidate session {candidate.get('id')}: {e}")
|
|
346
422
|
continue
|
|
347
423
|
|
|
348
|
-
# Sort by score (highest first)
|
|
424
|
+
# Sort by score (highest first)
|
|
349
425
|
scored_candidates.sort(key=lambda x: x[0], reverse=True)
|
|
350
426
|
|
|
351
|
-
return [candidate for
|
|
427
|
+
return [candidate for _, candidate in scored_candidates]
|
|
352
428
|
|
|
353
|
-
def search_session(self) -> list[SessionRow]:
|
|
429
|
+
def search_session(self, conditions: list[SearchCondition] | None = None) -> list[SessionRow]:
|
|
354
430
|
"""Search for sessions, optionally filtered by the current selection."""
|
|
355
431
|
# Get query conditions from selection
|
|
356
|
-
conditions
|
|
432
|
+
if conditions is None:
|
|
433
|
+
conditions = self.selection.get_query_conditions()
|
|
434
|
+
|
|
435
|
+
self.add_filter_not_masters(conditions) # we never return processed masters as sessions
|
|
357
436
|
return self.db.search_session(conditions)
|
|
358
437
|
|
|
359
|
-
def
|
|
438
|
+
def _add_image_abspath(self, image: ImageRow) -> ImageRow:
|
|
360
439
|
"""Reconstruct absolute path from image row containing repo_url and relative path.
|
|
361
440
|
|
|
362
441
|
Args:
|
|
363
442
|
image: Image record with 'repo_url' and 'path' (relative) fields
|
|
364
443
|
|
|
365
444
|
Returns:
|
|
366
|
-
Modified image record with '
|
|
445
|
+
Modified image record with 'abspath' as absolute path
|
|
367
446
|
"""
|
|
368
|
-
|
|
369
|
-
|
|
447
|
+
if not image.get("abspath"):
|
|
448
|
+
repo_url = image.get(Database.REPO_URL_KEY)
|
|
449
|
+
relative_path = image.get("path")
|
|
370
450
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
451
|
+
if repo_url and relative_path:
|
|
452
|
+
repo = self.repo_manager.get_repo_by_url(repo_url)
|
|
453
|
+
if repo:
|
|
454
|
+
absolute_path = repo.resolve_path(relative_path)
|
|
455
|
+
image["abspath"] = str(absolute_path)
|
|
376
456
|
|
|
377
457
|
return image
|
|
378
458
|
|
|
@@ -380,15 +460,73 @@ class Starbash:
|
|
|
380
460
|
"""
|
|
381
461
|
Get the reference ImageRow for a session with absolute path.
|
|
382
462
|
"""
|
|
463
|
+
from starbash.database import SearchCondition
|
|
464
|
+
|
|
383
465
|
images = self.db.search_image(
|
|
384
|
-
|
|
466
|
+
[SearchCondition("i.id", "=", session[get_column_name(Database.IMAGE_DOC_KEY)])]
|
|
385
467
|
)
|
|
386
|
-
assert (
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
468
|
+
assert len(images) == 1, f"Expected exactly one reference for session, found {len(images)}"
|
|
469
|
+
return self._add_image_abspath(images[0])
|
|
470
|
+
|
|
471
|
+
def get_master_images(
|
|
472
|
+
self, imagetyp: str | None = None, reference_session: SessionRow | None = None
|
|
473
|
+
) -> list[ImageRow]:
|
|
474
|
+
"""Return a list of the specified master imagetyp (bias, flat etc...)
|
|
475
|
+
(or any type if not specified).
|
|
476
|
+
|
|
477
|
+
The first image will be the 'best' remaining entries progressively worse matches.
|
|
478
|
+
|
|
479
|
+
(the following is not yet implemented)
|
|
480
|
+
If reference_session is provided it will be used to refine the search as follows:
|
|
481
|
+
* The telescope must match
|
|
482
|
+
* The image resolutions and binnings must match
|
|
483
|
+
* The filter must match (for FLAT frames only)
|
|
484
|
+
* Preferably the master date_obs would be either before or slightly after (<24 hrs) the reference session start time
|
|
485
|
+
* Preferably the master date_obs should be the closest in date to the reference session start time
|
|
486
|
+
* The camera temperature should be as close as possible to the reference session camera temperature
|
|
487
|
+
"""
|
|
488
|
+
master_repo = self.repo_manager.get_repo_by_kind("master")
|
|
489
|
+
|
|
490
|
+
if master_repo is None:
|
|
491
|
+
logging.warning("No master repo configured - skipping master frame load.")
|
|
492
|
+
return []
|
|
493
|
+
|
|
494
|
+
# Search for images in the master repo only
|
|
495
|
+
from starbash.database import SearchCondition
|
|
496
|
+
|
|
497
|
+
search_conditions = [SearchCondition("r.url", "=", master_repo.url)]
|
|
498
|
+
if imagetyp:
|
|
499
|
+
search_conditions.append(SearchCondition("i.imagetyp", "=", imagetyp))
|
|
500
|
+
|
|
501
|
+
images = self.db.search_image(search_conditions)
|
|
390
502
|
|
|
391
|
-
|
|
503
|
+
# FIXME - move this into a general filter function
|
|
504
|
+
# For flat frames, filter images based on matching reference_session filter
|
|
505
|
+
if reference_session and imagetyp and self.aliases.normalize(imagetyp) == "flat":
|
|
506
|
+
ref_filter = self.aliases.normalize(
|
|
507
|
+
reference_session.get(get_column_name(Database.FILTER_KEY), "None")
|
|
508
|
+
)
|
|
509
|
+
if ref_filter:
|
|
510
|
+
# Filter images to only those with matching filter in metadata
|
|
511
|
+
filtered_images = []
|
|
512
|
+
for img in images:
|
|
513
|
+
img_filter = img.get(Database.FILTER_KEY, "None")
|
|
514
|
+
if img_filter == ref_filter:
|
|
515
|
+
filtered_images.append(img)
|
|
516
|
+
images = filtered_images
|
|
517
|
+
|
|
518
|
+
return images
|
|
519
|
+
|
|
520
|
+
def add_filter_not_masters(self, conditions: list[SearchCondition]) -> None:
|
|
521
|
+
"""Add conditions to filter out master and processed repos from image searches."""
|
|
522
|
+
master_repo = self.repo_manager.get_repo_by_kind("master")
|
|
523
|
+
if master_repo is not None:
|
|
524
|
+
conditions.append(SearchCondition("r.url", "<>", master_repo.url))
|
|
525
|
+
processed_repo = self.repo_manager.get_repo_by_kind("processed")
|
|
526
|
+
if processed_repo is not None:
|
|
527
|
+
conditions.append(SearchCondition("r.url", "<>", processed_repo.url))
|
|
528
|
+
|
|
529
|
+
def get_session_images(self, session: SessionRow, processed_ok: bool = False) -> list[ImageRow]:
|
|
392
530
|
"""
|
|
393
531
|
Get all images belonging to a specific session.
|
|
394
532
|
|
|
@@ -399,6 +537,9 @@ class Starbash:
|
|
|
399
537
|
Args:
|
|
400
538
|
session_id: The database ID of the session
|
|
401
539
|
|
|
540
|
+
processed_ok: If True, include images which were processed by apps (i.e. stacked or other procesing)
|
|
541
|
+
Normally image pipelines don't want to accidentially consume those files.
|
|
542
|
+
|
|
402
543
|
Returns:
|
|
403
544
|
List of image records (dictionaries with path, metadata, etc.)
|
|
404
545
|
Returns empty list if session not found or has no images.
|
|
@@ -406,20 +547,48 @@ class Starbash:
|
|
|
406
547
|
Raises:
|
|
407
548
|
ValueError: If session_id is not found in the database
|
|
408
549
|
"""
|
|
409
|
-
|
|
410
|
-
conditions = {
|
|
411
|
-
Database.FILTER_KEY: session[get_column_name(Database.FILTER_KEY)],
|
|
412
|
-
Database.IMAGETYP_KEY: session[get_column_name(Database.IMAGETYP_KEY)],
|
|
413
|
-
Database.OBJECT_KEY: session[get_column_name(Database.OBJECT_KEY)],
|
|
414
|
-
Database.TELESCOP_KEY: session[get_column_name(Database.TELESCOP_KEY)],
|
|
415
|
-
"date_start": session[get_column_name(Database.START_KEY)],
|
|
416
|
-
"date_end": session[get_column_name(Database.END_KEY)],
|
|
417
|
-
}
|
|
550
|
+
from starbash.database import SearchCondition
|
|
418
551
|
|
|
419
|
-
#
|
|
552
|
+
# Query images that match ALL session criteria including date range
|
|
553
|
+
# Note: We need to search JSON metadata for FILTER, IMAGETYP, OBJECT, TELESCOP
|
|
554
|
+
# since they're not indexed columns in the images table
|
|
555
|
+
conditions = [
|
|
556
|
+
SearchCondition("i.date_obs", ">=", session[get_column_name(Database.START_KEY)]),
|
|
557
|
+
SearchCondition("i.date_obs", "<=", session[get_column_name(Database.END_KEY)]),
|
|
558
|
+
SearchCondition("i.imagetyp", "=", session[get_column_name(Database.IMAGETYP_KEY)]),
|
|
559
|
+
]
|
|
560
|
+
|
|
561
|
+
# Note: not needed here, because we filter this earlier - when building the
|
|
562
|
+
# list of candidate sessions.
|
|
563
|
+
# we never want to return 'master' or 'processed' images as part of the session image paths
|
|
564
|
+
# (because we will be passing these tool siril or whatever to generate masters or
|
|
565
|
+
# some other downstream image)
|
|
566
|
+
# self.add_filter_not_masters(conditions)
|
|
567
|
+
|
|
568
|
+
# Single query with indexed date conditions
|
|
420
569
|
images = self.db.search_image(conditions)
|
|
570
|
+
|
|
571
|
+
# We no lognger filter by target(object) because it might not be set anyways
|
|
572
|
+
filtered_images = []
|
|
573
|
+
for img in images:
|
|
574
|
+
# "HISTORY" nodes are added by processing tools (Siril etc...), we never want to accidentally read those images
|
|
575
|
+
has_history = img.get("HISTORY")
|
|
576
|
+
|
|
577
|
+
# images that were stacked seem to always have a STACKCNT header set
|
|
578
|
+
is_stacked = img.get("STACKCNT")
|
|
579
|
+
|
|
580
|
+
if (
|
|
581
|
+
img.get(Database.FILTER_KEY) == session[get_column_name(Database.FILTER_KEY)]
|
|
582
|
+
# and img.get(Database.OBJECT_KEY)
|
|
583
|
+
# == session[get_column_name(Database.OBJECT_KEY)]
|
|
584
|
+
and img.get(Database.TELESCOP_KEY)
|
|
585
|
+
== session[get_column_name(Database.TELESCOP_KEY)]
|
|
586
|
+
and (processed_ok or (not has_history and not is_stacked))
|
|
587
|
+
):
|
|
588
|
+
filtered_images.append(img)
|
|
589
|
+
|
|
421
590
|
# Reconstruct absolute paths for all images
|
|
422
|
-
return [self.
|
|
591
|
+
return [self._add_image_abspath(img) for img in filtered_images]
|
|
423
592
|
|
|
424
593
|
def remove_repo_ref(self, url: str) -> None:
|
|
425
594
|
"""
|
|
@@ -437,7 +606,7 @@ class Starbash:
|
|
|
437
606
|
repo_refs = self.user_repo.config.get("repo-ref")
|
|
438
607
|
|
|
439
608
|
if not repo_refs:
|
|
440
|
-
raise ValueError(
|
|
609
|
+
raise ValueError("No repository references found in user configuration.")
|
|
441
610
|
|
|
442
611
|
# Find and remove the matching repo-ref
|
|
443
612
|
found = False
|
|
@@ -447,6 +616,7 @@ class Starbash:
|
|
|
447
616
|
# Match by converting to file:// URL format if needed
|
|
448
617
|
if ref_dir == url or f"file://{ref_dir}" == url:
|
|
449
618
|
repo_refs.remove(ref)
|
|
619
|
+
|
|
450
620
|
found = True
|
|
451
621
|
break
|
|
452
622
|
|
|
@@ -456,24 +626,91 @@ class Starbash:
|
|
|
456
626
|
# Write the updated config
|
|
457
627
|
self.user_repo.write_config()
|
|
458
628
|
|
|
459
|
-
def
|
|
629
|
+
def add_image(self, repo: Repo, f: Path, force: bool = False) -> dict[str, Any] | None:
|
|
630
|
+
"""Read FITS header from file and add/update image entry in the database."""
|
|
631
|
+
|
|
632
|
+
path = repo.get_path()
|
|
633
|
+
if not path:
|
|
634
|
+
raise ValueError(f"Repo path not found for {repo}")
|
|
635
|
+
|
|
636
|
+
whitelist = None
|
|
637
|
+
config = self.repo_manager.merged.get("config")
|
|
638
|
+
if config:
|
|
639
|
+
whitelist = config.get("fits-whitelist", None)
|
|
640
|
+
|
|
641
|
+
# Convert absolute path to relative path within repo
|
|
642
|
+
relative_path = f.relative_to(path)
|
|
643
|
+
|
|
644
|
+
found = self.db.get_image(repo.url, str(relative_path))
|
|
645
|
+
|
|
646
|
+
# for debugging sometimes we want to limit scanning to a single directory or file
|
|
647
|
+
# debug_target = "masters-raw/2025-09-09/DARK"
|
|
648
|
+
debug_target = None
|
|
649
|
+
if debug_target:
|
|
650
|
+
if str(relative_path).startswith(debug_target):
|
|
651
|
+
logging.error("Debugging %s...", f)
|
|
652
|
+
found = False
|
|
653
|
+
else:
|
|
654
|
+
found = True # skip processing
|
|
655
|
+
force = False
|
|
656
|
+
|
|
657
|
+
if not found or force:
|
|
658
|
+
# Read and log the primary header (HDU 0)
|
|
659
|
+
with fits.open(str(f), memmap=False) as hdul:
|
|
660
|
+
# convert headers to dict
|
|
661
|
+
hdu0: Any = hdul[0]
|
|
662
|
+
header = hdu0.header
|
|
663
|
+
if type(header).__name__ == "Unknown":
|
|
664
|
+
raise ValueError("FITS header has Unknown type: %s", f)
|
|
665
|
+
|
|
666
|
+
items = header.items()
|
|
667
|
+
headers = {}
|
|
668
|
+
for key, value in items:
|
|
669
|
+
if (not whitelist) or (key in whitelist):
|
|
670
|
+
headers[key] = value
|
|
671
|
+
|
|
672
|
+
# Some device software (old Asiair versions) fails to populate TELESCOP, in that case fall back to
|
|
673
|
+
# CREATOR (see doc/fits/malformedasimaster.txt for an example)
|
|
674
|
+
if Database.TELESCOP_KEY not in headers:
|
|
675
|
+
creator = headers.get("CREATOR")
|
|
676
|
+
if creator:
|
|
677
|
+
headers[Database.TELESCOP_KEY] = creator
|
|
678
|
+
|
|
679
|
+
logging.debug("Headers for %s: %s", f, headers)
|
|
680
|
+
|
|
681
|
+
# Store relative path in database
|
|
682
|
+
headers["path"] = str(relative_path)
|
|
683
|
+
image_doc_id = self.db.upsert_image(headers, repo.url)
|
|
684
|
+
headers[Database.ID_KEY] = image_doc_id
|
|
685
|
+
|
|
686
|
+
if not found:
|
|
687
|
+
return headers
|
|
688
|
+
|
|
689
|
+
return None
|
|
690
|
+
|
|
691
|
+
def add_image_and_session(self, repo: Repo, f: Path, force: bool = False) -> None:
|
|
692
|
+
"""Read FITS header from file and add/update image entry in the database."""
|
|
693
|
+
headers = self.add_image(repo, f, force=force)
|
|
694
|
+
if headers:
|
|
695
|
+
# Update the session infos, but ONLY on first file scan
|
|
696
|
+
# (otherwise invariants will get messed up)
|
|
697
|
+
self._add_session(headers)
|
|
698
|
+
|
|
699
|
+
def reindex_repo(self, repo: Repo, subdir: str | None = None):
|
|
460
700
|
"""Reindex all repositories managed by the RepoManager."""
|
|
461
701
|
|
|
462
702
|
# make sure this new repo is listed in the repos table
|
|
463
703
|
self.repo_db_update() # not really ideal, a more optimal version would just add the new repo
|
|
464
704
|
|
|
465
|
-
|
|
466
|
-
if repo.is_scheme("file") and repo.kind != "recipe":
|
|
467
|
-
logging.debug("Reindexing %s...", repo.url)
|
|
705
|
+
path = repo.get_path()
|
|
468
706
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
whitelist = config.get("fits-whitelist", None)
|
|
707
|
+
repo_kind = repo.kind()
|
|
708
|
+
if path and repo.is_scheme("file") and repo_kind != "recipe":
|
|
709
|
+
logging.debug("Reindexing %s...", repo.url)
|
|
473
710
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
711
|
+
if subdir:
|
|
712
|
+
path = path / subdir
|
|
713
|
+
# used to debug
|
|
477
714
|
|
|
478
715
|
# Find all FITS files under this repo path
|
|
479
716
|
for f in track(
|
|
@@ -481,350 +718,152 @@ class Starbash:
|
|
|
481
718
|
description=f"Indexing {repo.url}...",
|
|
482
719
|
):
|
|
483
720
|
# progress.console.print(f"Indexing {f}...")
|
|
484
|
-
|
|
485
|
-
#
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
hdu0: Any = hdul[0]
|
|
494
|
-
header = hdu0.header
|
|
495
|
-
if type(header).__name__ == "Unknown":
|
|
496
|
-
raise ValueError("FITS header has Unknown type: %s", f)
|
|
497
|
-
|
|
498
|
-
items = header.items()
|
|
499
|
-
headers = {}
|
|
500
|
-
for key, value in items:
|
|
501
|
-
if (not whitelist) or (key in whitelist):
|
|
502
|
-
headers[key] = value
|
|
503
|
-
logging.debug("Headers for %s: %s", f, headers)
|
|
504
|
-
# Store relative path in database
|
|
505
|
-
headers["path"] = str(relative_path)
|
|
506
|
-
image_doc_id = self.db.upsert_image(headers, repo.url)
|
|
507
|
-
|
|
508
|
-
if not found:
|
|
509
|
-
# Update the session infos, but ONLY on first file scan
|
|
510
|
-
# (otherwise invariants will get messed up)
|
|
511
|
-
self._add_session(str(f), image_doc_id, header)
|
|
512
|
-
|
|
513
|
-
except Exception as e:
|
|
514
|
-
logging.warning("Failed to read FITS header for %s: %s", f, e)
|
|
515
|
-
|
|
516
|
-
def reindex_repos(self, force: bool = False):
|
|
721
|
+
if repo_kind == "master":
|
|
722
|
+
# for master repos we only add to the image table
|
|
723
|
+
self.add_image(repo, f, force=True)
|
|
724
|
+
elif repo_kind == "processed":
|
|
725
|
+
pass # we never add processed images to our db
|
|
726
|
+
else:
|
|
727
|
+
self.add_image_and_session(repo, f, force=starbash.force_regen)
|
|
728
|
+
|
|
729
|
+
def reindex_repos(self):
|
|
517
730
|
"""Reindex all repositories managed by the RepoManager."""
|
|
518
731
|
logging.debug("Reindexing all repositories...")
|
|
519
732
|
|
|
520
733
|
for repo in track(self.repo_manager.repos, description="Reindexing repos..."):
|
|
521
|
-
self.reindex_repo(repo
|
|
522
|
-
|
|
523
|
-
def run_all_stages(self):
|
|
524
|
-
"""On the currently active session, run all processing stages"""
|
|
525
|
-
logging.info("--- Running all stages ---")
|
|
734
|
+
self.reindex_repo(repo)
|
|
526
735
|
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
flat_pipeline_steps = list(itertools.chain.from_iterable(pipeline_definitions))
|
|
736
|
+
def get_recipes(self) -> list[Repo]:
|
|
737
|
+
"""Get all recipe repos available, sorted by priority (lower number first).
|
|
530
738
|
|
|
531
|
-
|
|
532
|
-
try:
|
|
533
|
-
sorted_pipeline = sorted(flat_pipeline_steps, key=lambda s: s["priority"])
|
|
534
|
-
except KeyError as e:
|
|
535
|
-
# Re-raise as a ValueError with a more descriptive message.
|
|
536
|
-
raise ValueError(
|
|
537
|
-
f"invalid stage definition: a stage is missing the required 'priority' key"
|
|
538
|
-
) from e
|
|
539
|
-
|
|
540
|
-
logging.info(
|
|
541
|
-
f"Found {len(sorted_pipeline)} pipeline steps to run in order of priority."
|
|
542
|
-
)
|
|
543
|
-
|
|
544
|
-
self.init_context()
|
|
545
|
-
# 4. Iterate through the sorted pipeline and execute the associated tasks.
|
|
546
|
-
for step in sorted_pipeline:
|
|
547
|
-
step_name = step.get("name")
|
|
548
|
-
if not step_name:
|
|
549
|
-
raise ValueError("Invalid pipeline step found: missing 'name' key.")
|
|
550
|
-
self.run_pipeline_step(step_name)
|
|
551
|
-
|
|
552
|
-
def run_pipeline_step(self, step_name: str):
|
|
553
|
-
logging.info(f"--- Running pipeline step: '{step_name}' ---")
|
|
554
|
-
|
|
555
|
-
# 3. Get all available task definitions (the `[[stage]]` tables with tool, script, when).
|
|
556
|
-
task_definitions = self.repo_manager.merged.getall("stage")
|
|
557
|
-
all_tasks = list(itertools.chain.from_iterable(task_definitions))
|
|
558
|
-
|
|
559
|
-
# Find all tasks that should run during this pipeline step.
|
|
560
|
-
tasks_to_run = [task for task in all_tasks if task.get("when") == step_name]
|
|
561
|
-
for task in tasks_to_run:
|
|
562
|
-
self.run_stage(task)
|
|
563
|
-
|
|
564
|
-
def run_master_stages(self):
|
|
565
|
-
"""Generate any missing master frames
|
|
566
|
-
|
|
567
|
-
Steps:
|
|
568
|
-
* set all_tasks to be all tasks for when == "setup.masters"
|
|
569
|
-
* loop over all currently unfiltered sessions
|
|
570
|
-
* for each session loop across all_tasks
|
|
571
|
-
* if task input.type == the imagetyp for this current session
|
|
572
|
-
* add_input_to_context() add the input files to the context (from the session)
|
|
573
|
-
* run_stage(task) to generate the new master frame
|
|
739
|
+
Recipes without a priority are placed at the end of the list.
|
|
574
740
|
"""
|
|
575
|
-
|
|
576
|
-
for session in sessions:
|
|
577
|
-
imagetyp = session[get_column_name(Database.IMAGETYP_KEY)]
|
|
578
|
-
logging.debug(
|
|
579
|
-
f"Processing session ID {session[get_column_name(Database.ID_KEY)]} with imagetyp '{imagetyp}'"
|
|
580
|
-
)
|
|
741
|
+
recipes = [r for r in self.repo_manager.repos if r.kind() == "recipe"]
|
|
581
742
|
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
tasks_to_run = [
|
|
588
|
-
task for task in all_tasks if task.get("when") == "setup.masters"
|
|
589
|
-
]
|
|
590
|
-
|
|
591
|
-
for task in tasks_to_run:
|
|
592
|
-
input_config = task.get("input", {})
|
|
593
|
-
input_type = input_config.get("type")
|
|
594
|
-
if imagetyp_equals(input_type, imagetyp):
|
|
595
|
-
logging.info(
|
|
596
|
-
f" Running master stage task for imagetyp '{imagetyp}'"
|
|
597
|
-
)
|
|
743
|
+
# Sort recipes by priority (lower number first). If no priority specified,
|
|
744
|
+
# use float('inf') to push those to the end of the list.
|
|
745
|
+
def priority_key(r: Repo) -> float:
|
|
746
|
+
priority = r.get("recipe.priority")
|
|
747
|
+
return float(priority) if priority is not None else float("inf")
|
|
598
748
|
|
|
599
|
-
|
|
600
|
-
# probably override this and place it somewhere persistent.
|
|
601
|
-
with tempfile.TemporaryDirectory(prefix="session_tmp_") as temp_dir:
|
|
602
|
-
logging.debug(
|
|
603
|
-
f"Created temporary session directory: {temp_dir}"
|
|
604
|
-
)
|
|
605
|
-
self.init_context()
|
|
606
|
-
self.context["process_dir"] = temp_dir
|
|
607
|
-
self.add_session_to_context(session)
|
|
608
|
-
self.run_stage(task)
|
|
609
|
-
|
|
610
|
-
def init_context(self) -> None:
|
|
611
|
-
"""Do common session init"""
|
|
612
|
-
|
|
613
|
-
# Context is preserved through all stages, so each stage can add new symbols to it for use by later stages
|
|
614
|
-
self.context = {}
|
|
615
|
-
|
|
616
|
-
# Update the context with runtime values.
|
|
617
|
-
runtime_context = {
|
|
618
|
-
"masters": "/workspaces/starbash/images/masters", # FIXME find this the correct way
|
|
619
|
-
}
|
|
620
|
-
self.context.update(runtime_context)
|
|
621
|
-
|
|
622
|
-
def add_session_to_context(self, session: SessionRow) -> None:
|
|
623
|
-
"""adds to context from the indicated session:
|
|
624
|
-
* input_files - all of the files mentioned in the session
|
|
625
|
-
* instrument - for the session
|
|
626
|
-
* date - the localtimezone date of the session
|
|
627
|
-
* imagetyp - the imagetyp of the session
|
|
628
|
-
* session - the current session row (joined with a typical image) (can be used to
|
|
629
|
-
find things like telescope, temperature ...)
|
|
630
|
-
"""
|
|
631
|
-
# Get images for this session
|
|
632
|
-
images = self.get_session_images(session)
|
|
633
|
-
logging.debug(f"Adding {len(images)} files as context.input_files")
|
|
634
|
-
self.context["input_files"] = [
|
|
635
|
-
img["path"] for img in images
|
|
636
|
-
] # Pass in the file list via the context dict
|
|
637
|
-
|
|
638
|
-
# it is okay to give them the actual session row, because we're never using it again
|
|
639
|
-
self.context["session"] = session
|
|
749
|
+
recipes.sort(key=priority_key)
|
|
640
750
|
|
|
641
|
-
|
|
642
|
-
if instrument:
|
|
643
|
-
self.context["instrument"] = instrument
|
|
751
|
+
return recipes
|
|
644
752
|
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
date = session.get(get_column_name(Database.START_KEY))
|
|
650
|
-
if date:
|
|
651
|
-
self.context["date"] = to_shortdate(date)
|
|
652
|
-
|
|
653
|
-
def add_input_files(self, stage: dict) -> None:
|
|
654
|
-
"""adds to context.input_files based on the stage input config"""
|
|
655
|
-
input_config = stage.get("input")
|
|
656
|
-
input_required = False
|
|
657
|
-
if input_config:
|
|
658
|
-
# if there is an "input" dict, we assume input.required is true if unset
|
|
659
|
-
input_required = input_config.get("required", True)
|
|
660
|
-
source = input_config.get("source")
|
|
661
|
-
if source is None:
|
|
662
|
-
raise ValueError(
|
|
663
|
-
f"Stage '{stage.get('name')}' has invalid 'input' configuration: missing 'source'"
|
|
664
|
-
)
|
|
665
|
-
if source == "path":
|
|
666
|
-
# The path might contain context variables that need to be expanded.
|
|
667
|
-
# path_pattern = expand_context(input_config["path"], context)
|
|
668
|
-
path_pattern = input_config["path"]
|
|
669
|
-
input_files = glob.glob(path_pattern, recursive=True)
|
|
670
|
-
|
|
671
|
-
self.context["input_files"] = (
|
|
672
|
-
input_files # Pass in the file list via the context dict
|
|
673
|
-
)
|
|
674
|
-
elif source == "repo":
|
|
675
|
-
# We expect that higher level code has already added the correct input files
|
|
676
|
-
# to the context
|
|
677
|
-
if not "input_files" in self.context:
|
|
678
|
-
raise RuntimeError(
|
|
679
|
-
"Input config specifies 'repo' but no 'input_files' found in context"
|
|
680
|
-
)
|
|
681
|
-
else:
|
|
682
|
-
raise ValueError(
|
|
683
|
-
f"Stage '{stage.get('name')}' has invalid 'input' source: {source}"
|
|
684
|
-
)
|
|
685
|
-
|
|
686
|
-
# FIXME compare context.output to see if it already exists and is newer than the input files, if so skip processing
|
|
687
|
-
else:
|
|
688
|
-
# The script doesn't mention input, therefore assume it doesn't want input_files
|
|
689
|
-
if "input_files" in self.context:
|
|
690
|
-
del self.context["input_files"]
|
|
753
|
+
def get_recipe_for_session(self, session: SessionRow, step: dict[str, Any]) -> Repo | None:
|
|
754
|
+
"""Try to find a recipe that can be used to process the given session for the given step name
|
|
755
|
+
(master-dark, master-bias, light, stack, etc...)
|
|
691
756
|
|
|
692
|
-
if
|
|
693
|
-
|
|
757
|
+
* if a recipe doesn't have a matching recipe.stage.<step_name> it is not considered
|
|
758
|
+
* As part of this checking we will look at recipe.auto.require.* conditions to see if the recipe
|
|
759
|
+
is suitable for this session.
|
|
760
|
+
* the imagetyp of this session matches step.input
|
|
694
761
|
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
Sets the following context variables:
|
|
699
|
-
- context.output.root_path - base path of the destination repo
|
|
700
|
-
- context.output.base_path - full path without file extension
|
|
701
|
-
- context.output.suffix - file extension (e.g., .fits or .fit.gz)
|
|
702
|
-
- context.output.full_path - complete output file path
|
|
762
|
+
Currently we return just one Repo but eventually we should support multiple matching recipes
|
|
763
|
+
and make the user pick (by throwing an exception?).
|
|
703
764
|
"""
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
# No output configuration, remove any existing output from context
|
|
707
|
-
if "output" in self.context:
|
|
708
|
-
del self.context["output"]
|
|
709
|
-
return
|
|
765
|
+
# Get all recipe repos - FIXME add a getall(kind) to RepoManager
|
|
766
|
+
recipe_repos = self.get_recipes()
|
|
710
767
|
|
|
711
|
-
|
|
712
|
-
if not
|
|
713
|
-
raise ValueError(
|
|
714
|
-
f"Stage '{stage.get('description', 'unknown')}' has 'output' config but missing 'dest'"
|
|
715
|
-
)
|
|
716
|
-
|
|
717
|
-
if dest == "repo":
|
|
718
|
-
# Find the destination repo by type/kind
|
|
719
|
-
output_type = output_config.get("type")
|
|
720
|
-
if not output_type:
|
|
721
|
-
raise ValueError(
|
|
722
|
-
f"Stage '{stage.get('description', 'unknown')}' has output.dest='repo' but missing 'type'"
|
|
723
|
-
)
|
|
768
|
+
step_name = step.get("name")
|
|
769
|
+
if not step_name:
|
|
770
|
+
raise ValueError("Invalid pipeline step found: missing 'name' key.")
|
|
724
771
|
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
raise ValueError(
|
|
729
|
-
f"No repository found with kind '{output_type}' for output destination"
|
|
730
|
-
)
|
|
772
|
+
input_name = step.get("input")
|
|
773
|
+
if not input_name:
|
|
774
|
+
raise ValueError("Invalid pipeline step found: missing 'input' key.")
|
|
731
775
|
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
776
|
+
# if input type is recipe we don't check for filetype match - because we'll just use files already in
|
|
777
|
+
# the tempdir
|
|
778
|
+
if input_name != "recipe":
|
|
779
|
+
imagetyp = session.get(get_column_name(Database.IMAGETYP_KEY))
|
|
735
780
|
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
f"Repository '{dest_repo.url}' is missing 'repo.relative' configuration"
|
|
781
|
+
if not imagetyp or input_name != self.aliases.normalize(imagetyp):
|
|
782
|
+
logging.debug(
|
|
783
|
+
f"Session imagetyp '{imagetyp}' does not match step input '{input_name}', skipping"
|
|
740
784
|
)
|
|
785
|
+
return None
|
|
741
786
|
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
full_path = repo_base / repo_relative
|
|
745
|
-
|
|
746
|
-
# base_path but without spaces - because Siril doesn't like that
|
|
747
|
-
full_path = Path(str(full_path).replace(" ", r"_"))
|
|
748
|
-
|
|
749
|
-
base_path = full_path.parent / full_path.stem
|
|
750
|
-
|
|
751
|
-
# Set context variables as documented in the TOML
|
|
752
|
-
self.context["output"] = {
|
|
753
|
-
# "root_path": repo_relative, not needed I think
|
|
754
|
-
"base_path": base_path,
|
|
755
|
-
# "suffix": full_path.suffix, not needed I think
|
|
756
|
-
"full_path": full_path,
|
|
757
|
-
}
|
|
787
|
+
# Get session metadata for checking requirements
|
|
788
|
+
session_metadata = session.get("metadata", {})
|
|
758
789
|
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
"""
|
|
766
|
-
Executes a single processing stage.
|
|
790
|
+
for repo in recipe_repos:
|
|
791
|
+
# Check if this recipe has the requested stage
|
|
792
|
+
stage_config = repo.get(f"recipe.stage.{step_name}")
|
|
793
|
+
if not stage_config:
|
|
794
|
+
logging.debug(f"Recipe {repo.url} does not have stage '{step_name}', skipping")
|
|
795
|
+
continue
|
|
767
796
|
|
|
768
|
-
|
|
769
|
-
stage: A dictionary representing the stage configuration, containing
|
|
770
|
-
at least 'tool' and 'script' keys.
|
|
771
|
-
"""
|
|
772
|
-
stage_desc = stage.get("description", "(missing description)")
|
|
773
|
-
stage_disabled = stage.get("disabled", False)
|
|
774
|
-
if stage_disabled:
|
|
775
|
-
logging.info(f"Skipping disabled stage: {stage_desc}")
|
|
776
|
-
return
|
|
797
|
+
# Check auto.require conditions if they exist
|
|
777
798
|
|
|
778
|
-
|
|
799
|
+
# If requirements are specified, check if session matches
|
|
800
|
+
required_filters = repo.get("recipe.auto.require.filter", [])
|
|
801
|
+
if required_filters:
|
|
802
|
+
session_filter = self.aliases.normalize(
|
|
803
|
+
session_metadata.get(Database.FILTER_KEY), lenient=True
|
|
804
|
+
)
|
|
779
805
|
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
raise ValueError(
|
|
788
|
-
f"Tool '{tool_name}' for stage '{stage.get('name')}' not found."
|
|
789
|
-
)
|
|
790
|
-
logging.debug(f" Using tool: {tool_name}")
|
|
806
|
+
# Session must have AT LEAST one filter that matches one of the required filters
|
|
807
|
+
if not session_filter or session_filter not in required_filters:
|
|
808
|
+
logging.debug(
|
|
809
|
+
f"Recipe {repo.url} requires filters {required_filters}, "
|
|
810
|
+
f"session has '{session_filter}', skipping"
|
|
811
|
+
)
|
|
812
|
+
continue
|
|
791
813
|
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
script = source.read(script_filename)
|
|
796
|
-
else:
|
|
797
|
-
script = stage.get("script")
|
|
814
|
+
required_color = repo.get("recipe.auto.require.color", False)
|
|
815
|
+
if required_color:
|
|
816
|
+
session_bayer = session_metadata.get("BAYERPAT")
|
|
798
817
|
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
818
|
+
# Session must be color (i.e. have a BAYERPAT header)
|
|
819
|
+
if not session_bayer:
|
|
820
|
+
logging.debug(
|
|
821
|
+
f"Recipe {repo.url} requires a color camera, "
|
|
822
|
+
f"but session has no BAYERPAT header, skipping"
|
|
823
|
+
)
|
|
824
|
+
continue
|
|
825
|
+
|
|
826
|
+
required_cameras = repo.get("recipe.auto.require.camera", [])
|
|
827
|
+
if required_cameras:
|
|
828
|
+
session_camera = self.aliases.normalize(
|
|
829
|
+
session_metadata.get("INSTRUME"), lenient=True
|
|
830
|
+
) # Camera identifier
|
|
831
|
+
|
|
832
|
+
# Session must have a camera that matches one of the required cameras
|
|
833
|
+
if not session_camera or session_camera not in required_cameras:
|
|
834
|
+
logging.debug(
|
|
835
|
+
f"Recipe {repo.url} requires cameras {required_cameras}, "
|
|
836
|
+
f"session has '{session_camera}', skipping"
|
|
837
|
+
)
|
|
838
|
+
continue
|
|
803
839
|
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
self.context.update(stage_context)
|
|
808
|
-
self.add_input_files(stage)
|
|
809
|
-
self.add_output_path(stage)
|
|
810
|
-
|
|
811
|
-
# if the output path already exists and is newer than all input files, skip processing
|
|
812
|
-
output_info: dict | None = self.context.get("output")
|
|
813
|
-
if output_info:
|
|
814
|
-
output_path = output_info.get("full_path")
|
|
815
|
-
|
|
816
|
-
if output_path and os.path.exists(output_path):
|
|
817
|
-
logging.info(
|
|
818
|
-
f"Output file already exists, skipping processing: {output_path}"
|
|
819
|
-
)
|
|
820
|
-
return
|
|
840
|
+
# This recipe matches!
|
|
841
|
+
logging.info(f"Selected recipe {repo.url} for stage '{step_name}' ")
|
|
842
|
+
return repo
|
|
821
843
|
|
|
822
|
-
|
|
844
|
+
# No matching recipe found
|
|
845
|
+
return None
|
|
823
846
|
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
847
|
+
def filter_sessions_with_lights(self, sessions: list[SessionRow]) -> list[SessionRow]:
|
|
848
|
+
"""Filter sessions to only those that contain light frames."""
|
|
849
|
+
filtered_sessions: list[SessionRow] = []
|
|
850
|
+
for s in sessions:
|
|
851
|
+
imagetyp_val = s.get(get_column_name(Database.IMAGETYP_KEY))
|
|
852
|
+
if imagetyp_val is None:
|
|
853
|
+
continue
|
|
854
|
+
if self.aliases.normalize(str(imagetyp_val)) == "light":
|
|
855
|
+
filtered_sessions.append(s)
|
|
856
|
+
return filtered_sessions
|
|
828
857
|
|
|
829
|
-
|
|
830
|
-
|
|
858
|
+
def filter_sessions_by_target(
|
|
859
|
+
self, sessions: list[SessionRow], target: str
|
|
860
|
+
) -> list[SessionRow]:
|
|
861
|
+
"""Filter sessions to only those that match the given target name."""
|
|
862
|
+
filtered_sessions: list[SessionRow] = []
|
|
863
|
+
for s in sessions:
|
|
864
|
+
obj_val = s.get(get_column_name(Database.OBJECT_KEY))
|
|
865
|
+
if obj_val is None:
|
|
866
|
+
continue
|
|
867
|
+
if normalize_target_name(str(obj_val)) == target:
|
|
868
|
+
filtered_sessions.append(s)
|
|
869
|
+
return filtered_sessions
|