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.
Files changed (44) hide show
  1. repo/__init__.py +1 -1
  2. repo/manager.py +14 -23
  3. repo/repo.py +52 -10
  4. starbash/__init__.py +10 -3
  5. starbash/aliases.py +145 -0
  6. starbash/analytics.py +3 -2
  7. starbash/app.py +512 -473
  8. starbash/check_version.py +18 -0
  9. starbash/commands/__init__.py +2 -1
  10. starbash/commands/info.py +88 -14
  11. starbash/commands/process.py +76 -24
  12. starbash/commands/repo.py +41 -68
  13. starbash/commands/select.py +141 -142
  14. starbash/commands/user.py +88 -23
  15. starbash/database.py +219 -112
  16. starbash/defaults/starbash.toml +24 -3
  17. starbash/exception.py +21 -0
  18. starbash/main.py +29 -7
  19. starbash/paths.py +35 -5
  20. starbash/processing.py +724 -0
  21. starbash/recipes/README.md +3 -0
  22. starbash/recipes/master_bias/starbash.toml +16 -19
  23. starbash/recipes/master_dark/starbash.toml +33 -0
  24. starbash/recipes/master_flat/starbash.toml +26 -18
  25. starbash/recipes/osc.py +190 -0
  26. starbash/recipes/osc_dual_duo/starbash.toml +54 -44
  27. starbash/recipes/osc_simple/starbash.toml +82 -0
  28. starbash/recipes/osc_single_duo/starbash.toml +51 -32
  29. starbash/recipes/seestar/starbash.toml +82 -0
  30. starbash/recipes/starbash.toml +30 -9
  31. starbash/selection.py +32 -36
  32. starbash/templates/repo/master.toml +7 -3
  33. starbash/templates/repo/processed.toml +15 -0
  34. starbash/templates/userconfig.toml +9 -0
  35. starbash/toml.py +13 -13
  36. starbash/tool.py +230 -96
  37. starbash-0.1.15.dist-info/METADATA +216 -0
  38. starbash-0.1.15.dist-info/RECORD +45 -0
  39. starbash/recipes/osc_dual_duo/starbash.py +0 -151
  40. starbash-0.1.9.dist-info/METADATA +0 -145
  41. starbash-0.1.9.dist-info/RECORD +0 -37
  42. {starbash-0.1.9.dist-info → starbash-0.1.15.dist-info}/WHEEL +0 -0
  43. {starbash-0.1.9.dist-info → starbash-0.1.15.dist-info}/entry_points.txt +0 -0
  44. {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
- from importlib import resources
3
- import os
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 shutil
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 starbash import console, _is_test_env, to_shortdate
22
- from starbash.database import Database, SessionRow, ImageRow, get_column_name
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
- # Type aliases for better documentation
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(stderr: bool = False):
37
+ def setup_logging(console: rich.console.Console):
42
38
  """
43
39
  Configures basic logging.
44
40
  """
45
- console = rich.console.Console(stderr=stderr)
41
+ from starbash import _is_test_env # Lazy import to avoid circular dependency
42
+
46
43
  handlers = (
47
- [RichHandler(console=console, rich_tracebacks=True)] if not _is_test_env else []
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("path", ""))
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(f"[green]Export complete![/green]")
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
- setup_logging(stderr=stderr_logging)
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
- logging.info(
161
- f"Repo manager initialized with {len(self.repo_manager.repos)} repos."
162
- )
163
- # self.repo_manager.dump()
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, f: str, image_doc_id: int, header: dict) -> None:
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
- filter = header.get(Database.FILTER_KEY, "unspecified")
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
- f,
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
- telescop = header.get(Database.TELESCOP_KEY, "unspecified")
242
+
230
243
  new = {
231
- Database.FILTER_KEY: filter,
232
- Database.START_KEY: date,
233
- Database.END_KEY: date, # FIXME not quite correct, should be longer by exptime
234
- Database.IMAGE_DOC_KEY: image_doc_id,
235
- Database.IMAGETYP_KEY: image_type,
236
- Database.NUM_IMAGES_KEY: 1,
237
- Database.EXPTIME_TOTAL_KEY: exptime,
238
- Database.OBJECT_KEY: header.get(Database.OBJECT_KEY, "unspecified"),
239
- Database.TELESCOP_KEY: telescop,
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 guess_sessions(
245
- self, ref_session: SessionRow, want_type: str
246
- ) -> list[SessionRow]:
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.upper() == "FLAT":
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(where_tuple(conditions))
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
- # Score by date/time proximity (secondary importance)
322
- if ref_date is not None:
323
- candidate_date_str = candidate_image.get(Database.DATE_OBS_KEY)
324
- if candidate_date_str:
325
- try:
326
- candidate_date = datetime.fromisoformat(candidate_date_str)
327
- time_delta = abs(
328
- (ref_date - candidate_date).total_seconds()
329
- )
330
- # Closer in time = better score
331
- # Same day ≈ 100, 7 days 37, 30 days 9
332
- # Using 7-day half-life
333
- score += 100 * (2.718 ** (-time_delta / (7 * 86400)))
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) and return just the sessions
424
+ # Sort by score (highest first)
349
425
  scored_candidates.sort(key=lambda x: x[0], reverse=True)
350
426
 
351
- return [candidate for score, candidate in scored_candidates]
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 = self.selection.get_query_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 _reconstruct_image_path(self, image: ImageRow) -> ImageRow:
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 'path' as absolute path
445
+ Modified image record with 'abspath' as absolute path
367
446
  """
368
- repo_url = image.get("repo_url")
369
- relative_path = image.get("path")
447
+ if not image.get("abspath"):
448
+ repo_url = image.get(Database.REPO_URL_KEY)
449
+ relative_path = image.get("path")
370
450
 
371
- if repo_url and relative_path:
372
- repo = self.repo_manager.get_repo_by_url(repo_url)
373
- if repo:
374
- absolute_path = repo.resolve_path(relative_path)
375
- image["path"] = str(absolute_path)
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
- {Database.ID_KEY: session[get_column_name(Database.IMAGE_DOC_KEY)]}
466
+ [SearchCondition("i.id", "=", session[get_column_name(Database.IMAGE_DOC_KEY)])]
385
467
  )
386
- assert (
387
- len(images) == 1
388
- ), f"Expected exactly one reference for session, found {len(images)}"
389
- return self._reconstruct_image_path(images[0])
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
- def get_session_images(self, session: SessionRow) -> list[ImageRow]:
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
- # Query images that match ALL session criteria including date range
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
- # Single query with all conditions
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._reconstruct_image_path(img) for img in images] if images else []
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(f"No repository references found in user configuration.")
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 reindex_repo(self, repo: Repo, force: bool = False):
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
- # FIXME, add a method to get just the repos that contain images
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
- whitelist = None
470
- config = self.repo_manager.merged.get("config")
471
- if config:
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
- path = repo.get_path()
475
- if not path:
476
- raise ValueError(f"Repo path not found for {repo}")
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
- try:
485
- # Convert absolute path to relative path within repo
486
- relative_path = f.relative_to(path)
487
-
488
- found = self.db.get_image(repo.url, str(relative_path))
489
- if not found or force:
490
- # Read and log the primary header (HDU 0)
491
- with fits.open(str(f), memmap=False) as hdul:
492
- # convert headers to dict
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, force=force)
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
- # 1. Get all pipeline definitions (the `[[stages]]` tables with name and priority).
528
- pipeline_definitions = self.repo_manager.merged.getall("stages")
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
- # 2. Sort the pipeline steps by their 'priority' field.
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
- sessions = self.search_session()
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
- # 3. Get all available task definitions (the `[[stage]]` tables with tool, script, when).
583
- task_definitions = self.repo_manager.merged.getall("stage")
584
- all_tasks = list(itertools.chain.from_iterable(task_definitions))
585
-
586
- # Find all tasks that should run during the "setup.masters" step.
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
- # Create a default process dir in /tmp, though more advanced 'session' based workflows will
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
- instrument = session.get(get_column_name(Database.TELESCOP_KEY))
642
- if instrument:
643
- self.context["instrument"] = instrument
751
+ return recipes
644
752
 
645
- imagetyp = session.get(get_column_name(Database.IMAGETYP_KEY))
646
- if imagetyp:
647
- self.context["imagetyp"] = imagetyp
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 input_required and not "input_files" in self.context:
693
- raise RuntimeError("No input files found for stage")
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
- def add_output_path(self, stage: dict) -> None:
696
- """Adds output path information to context based on the stage output config.
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
- output_config = stage.get("output")
705
- if not output_config:
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
- dest = output_config.get("dest")
712
- if not dest:
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
- # Find the repo with matching kind
726
- dest_repo = self.repo_manager.get_repo_by_kind(output_type)
727
- if not dest_repo:
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
- repo_base = dest_repo.get_path()
733
- if not repo_base:
734
- raise ValueError(f"Repository '{dest_repo.url}' has no filesystem path")
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
- repo_relative: str | None = dest_repo.get("repo.relative")
737
- if not repo_relative:
738
- raise ValueError(
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
- # we support context variables in the relative path
743
- repo_relative = expand_context_unsafe(repo_relative, self.context)
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
- else:
760
- raise ValueError(
761
- f"Unsupported output destination type: {dest}. Only 'repo' is currently supported."
762
- )
763
-
764
- def run_stage(self, stage: dict) -> None:
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
- Args:
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
- logging.info(f"Running stage: {stage_desc}")
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
- tool_name = stage.get("tool")
781
- if not tool_name:
782
- raise ValueError(
783
- f"Stage '{stage.get('name')}' is missing a 'tool' definition."
784
- )
785
- tool: Tool | None = tools.get(tool_name)
786
- if not tool:
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
- script_filename = stage.get("script-file", tool.default_script_file)
793
- if script_filename:
794
- source = stage.source # type: ignore (was monkeypatched by repo)
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
- if script is None:
800
- raise ValueError(
801
- f"Stage '{stage.get('name')}' is missing a 'script' or 'script-file' definition."
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
- # This allows recipe TOML to define their own default variables.
805
- # (apply all of the changes to context that the task demands)
806
- stage_context = stage.get("context", {})
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
- tool.run_in_temp_dir(script, context=self.context)
844
+ # No matching recipe found
845
+ return None
823
846
 
824
- # verify context.output was created if it was specified
825
- output_info: dict | None = self.context.get("output")
826
- if output_info:
827
- output_path = output_info.get("full_path")
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
- if not output_path or not os.path.exists(output_path):
830
- raise RuntimeError(f"Expected output file not found: {output_path}")
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