sed-tools 0.0.2__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.
sed_tools/__init__.py ADDED
@@ -0,0 +1,769 @@
1
+ """High level API for working with pre-computed stellar SED libraries.
2
+
3
+ This module exposes the :class:`SED` facade together with helper classes that
4
+ wrap the existing command line tooling in a programmatic friendly interface.
5
+ Users can discover locally available model grids, load a specific grid into
6
+ memory and evaluate spectra or synthetic photometry directly from Python code.
7
+
8
+ Typical usage::
9
+
10
+ from sed_tools import SED
11
+
12
+ sed = SED() # discover default data directories
13
+ matches = sed.find_model(5777, 4.44, 0) # inspect available grids
14
+ model = sed.model(matches[0].name)
15
+ spec = model(5777, 4.44, 0.0) # interpolate a spectrum
16
+ gaia = spec.photometry("GAIA") # synthetic GAIA magnitudes
17
+
18
+ For range-based discovery a convenience wrapper is also provided::
19
+
20
+ from sed_tools import find_atmospheres
21
+
22
+ grids = find_atmospheres(teff_range=(5000, 6500), metallicity_range=(-0.5, 0.5))
23
+
24
+ The package reuses the heavy lifting that already powers the interactive tools
25
+ shipped with SED Tools so that workflows built on the CLI continue to operate
26
+ unchanged while pipelines can opt into the same functionality via imports.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import os
32
+ import sys
33
+ from typing import Optional, Sequence, Union
34
+
35
+ import h5py
36
+ import numpy as np
37
+
38
+ from .mast_spectra_grabber import MASTSpectraGrabber
39
+ from .models import (DATA_DIR_DEFAULT, FILTER_DIR_DEFAULT, SED,
40
+ STELLAR_DIR_DEFAULT, EvaluatedSED, ModelMatch,
41
+ PhotometryResult, SEDModel)
42
+ from .msg_spectra_grabber import \
43
+ MSGSpectraGrabber # MSG (Townsend) .h5 → .txt + lookup_table.csv
44
+ from .njm_filter_grabber import NJMFilterGrabber
45
+ from .njm_spectra_grabber import NJMSpectraGrabber # Niall J Miller
46
+ from .precompute_flux_cube import \
47
+ precompute_flux_cube # builds flux cube from lookup + .txt
48
+ from .spectra_cleaner import clean_model_dir
49
+ from .svo_regen_spectra_lookup import parse_metadata, regenerate_lookup_table
50
+ from .svo_spectra_grabber import \
51
+ SVOSpectraGrabber # SVO spectra → .txt + lookup_table.csv
52
+
53
+ from .api import (
54
+ SED, # This replaces/extends the existing SED from models
55
+ Catalog,
56
+ CatalogInfo,
57
+ Spectrum,
58
+ Filters,
59
+ MLCompleter,
60
+ query,
61
+ fetch,
62
+ local,
63
+ )
64
+
65
+
66
+ __version__ = "0.1.0"
67
+
68
+
69
+ def ensure_dir(path: str) -> None:
70
+ os.makedirs(path, exist_ok=True)
71
+
72
+ def list_txt_spectra(model_dir: str) -> List[str]:
73
+ return sorted([f for f in os.listdir(model_dir) if f.lower().endswith(".txt")])
74
+
75
+ def load_txt_spectrum(txt_path: str) -> Tuple[np.ndarray, np.ndarray]:
76
+ wl, fl = [], []
77
+ with open(txt_path, "r", encoding="utf-8", errors="ignore") as f:
78
+ for line in f:
79
+ s = line.strip()
80
+ if not s or s.startswith("#"):
81
+ continue
82
+ parts = s.split()
83
+ if len(parts) >= 2:
84
+ try:
85
+ wl.append(float(parts[0]))
86
+ fl.append(float(parts[1]))
87
+ except ValueError:
88
+ continue
89
+ return np.asarray(wl, dtype=float), np.asarray(fl, dtype=float)
90
+
91
+ def numeric_from(meta: Dict[str, str], key_candidates: List[str], default: float = np.nan) -> float:
92
+ """Extract first numeric token from any of the candidate keys (case-insensitive)."""
93
+ lower = {k.lower(): v for k, v in meta.items()}
94
+ for ck in key_candidates:
95
+ if ck.lower() in lower:
96
+ m = re.search(r"[-+]?\d*\.?\d+(?:[eE][-+]?\d+)?", lower[ck.lower()])
97
+ if m:
98
+ try:
99
+ return float(m.group(0))
100
+ except ValueError:
101
+ pass
102
+ return default
103
+ # ------------ HDF5 bundling ------------
104
+
105
+ def build_h5_bundle_from_txt(model_dir: str, out_h5: str) -> None:
106
+ """
107
+ Create an HDF5 file bundling all .txt spectra under groups:
108
+ /spectra/<filename>/lambda (float64)
109
+ /spectra/<filename>/flux (float64)
110
+ And store key metadata as HDF5 attributes on each group:
111
+ attrs: teff, logg, feh (if found), plus all raw header pairs in attrs["raw:<key>"]
112
+ """
113
+ txt_files = list_txt_spectra(model_dir)
114
+ if not txt_files:
115
+ print(f"[H5 bundle] No .txt spectra found in {model_dir}; skipping.")
116
+ return
117
+
118
+ ensure_dir(os.path.dirname(out_h5))
119
+ with h5py.File(out_h5, "w") as h5:
120
+ spectra_grp = h5.create_group("spectra")
121
+ for fname in txt_files:
122
+ path = os.path.join(model_dir, fname)
123
+ try:
124
+ wl, fl = load_txt_spectrum(path)
125
+ if wl.size == 0 or fl.size == 0:
126
+ print(f"[H5 bundle] Empty or invalid spectrum: {fname}")
127
+ continue
128
+ g = spectra_grp.create_group(fname)
129
+ g.create_dataset("lambda", data=wl, dtype="f8")
130
+ g.create_dataset("flux", data=fl, dtype="f8")
131
+
132
+ meta = parse_metadata(path)
133
+ # canonical numeric attrs
134
+ teff = numeric_from(meta, ["Teff", "teff", "T_eff"])
135
+ logg = numeric_from(meta, ["logg", "Logg", "log_g"])
136
+ feh = numeric_from(meta, ["FeH", "feh", "metallicity", "[Fe/H]", "meta"])
137
+ if not np.isnan(teff): g.attrs["teff"] = teff
138
+ if not np.isnan(logg): g.attrs["logg"] = logg
139
+ if not np.isnan(feh): g.attrs["feh"] = feh
140
+ # stash raw header lines so nothing is lost
141
+ for k, v in meta.items():
142
+ g.attrs[f"raw:{k}"] = v
143
+ except Exception as e:
144
+ print(f"[H5 bundle] Error on {fname}: {e}")
145
+
146
+ print(f"[H5 bundle] Wrote {out_h5}")
147
+
148
+ def run_rebuild_flow(base_dir: str = STELLAR_DIR_DEFAULT,
149
+ models: List[str] = None,
150
+ rebuild_h5: bool = True,
151
+ rebuild_flux_cube: bool = True) -> None:
152
+ """
153
+ Rebuild lookup_table.csv (+ optional HDF5 bundle and flux cube)
154
+ for existing local model directories. Now cleans spectra first.
155
+ """
156
+ import glob
157
+ ensure_dir(base_dir)
158
+
159
+ # discover local model dirs by presence of .txt or .h5
160
+ cand = []
161
+ for name in sorted(os.listdir(base_dir)):
162
+ p = os.path.join(base_dir, name)
163
+ if not os.path.isdir(p):
164
+ continue
165
+ has_txt = any(fn.lower().endswith(".txt") for fn in os.listdir(p))
166
+ has_h5 = any(fn.lower().endswith(".h5") for fn in os.listdir(p))
167
+ if has_txt or has_h5:
168
+ cand.append(name)
169
+
170
+ if not cand:
171
+ print(f"No local models found under {base_dir}")
172
+ return
173
+
174
+ if models is None:
175
+ print("\nLocal models available for rebuild:")
176
+ print("-" * 64)
177
+ for i, m in enumerate(cand, 1):
178
+ print(f"{i:3d}. {m}")
179
+ print("\nSelect indices (comma / ranges like 3-6) or 'all':")
180
+ raw = input("> ").strip().lower()
181
+ if raw == "all":
182
+ selected = cand
183
+ else:
184
+ idxs = []
185
+ for token in raw.split(","):
186
+ token = token.strip()
187
+ if "-" in token:
188
+ a, b = token.split("-", 1)
189
+ idxs += list(range(int(a), int(b)+1))
190
+ else:
191
+ if token:
192
+ idxs.append(int(token))
193
+ selected = [cand[i-1] for i in idxs if 1 <= i <= len(cand)]
194
+ else:
195
+ selected = models
196
+
197
+ if not selected:
198
+ print("Nothing selected.")
199
+ return
200
+
201
+ # optional SVO lookup regen helper
202
+ regen = None
203
+ try:
204
+ from .svo_regen_spectra_lookup import \
205
+ regenerate_lookup_table as regen # type: ignore
206
+ except Exception:
207
+ regen = None
208
+
209
+ # cleaner
210
+ try:
211
+ from .spectra_cleaner import clean_model_dir
212
+ except Exception as e:
213
+ clean_model_dir = None
214
+ print(f"[clean] cleaner unavailable: {e}")
215
+
216
+ for model_name in selected:
217
+ print("\n" + "="*64)
218
+ print(f"[rebuild] {model_name}")
219
+ model_dir = os.path.join(base_dir, model_name)
220
+
221
+ # 0) CLEAN FIRST (fix λ<=0, repair index grids via HDF5 when possible)
222
+ if clean_model_dir:
223
+ try:
224
+ summary = clean_model_dir(model_dir, try_h5_recovery=True, rebuild_lookup=True)
225
+ print(f"[clean] {model_name}: total={summary['total']}, "
226
+ f"fixed={len(summary['fixed'])}, recovered={len(summary['recovered'])}, "
227
+ f"skipped={len(summary['skipped'])}, deleted={len(summary['deleted'])}")
228
+ except Exception as e:
229
+ print(f"[clean] failed for {model_name}: {e}")
230
+
231
+ # If no .txt remain, skip this model
232
+ txts = glob.glob(os.path.join(model_dir, "*.txt"))
233
+ if not txts:
234
+ print(f"[rebuild] no spectra (.txt) present after cleaning; skipping {model_name}.")
235
+ continue
236
+
237
+ # 1) lookup table
238
+ try:
239
+ if regen:
240
+ regen(model_dir)
241
+ print("[rebuild] lookup_table.csv via svo_regen_spectra_lookup")
242
+ else:
243
+ regenerate_lookup_table(model_dir)
244
+ except Exception as e:
245
+ print(f"[rebuild] lookup failed: {e}")
246
+
247
+ # 2) HDF5 bundle (from .txt), ensure it exists
248
+ if rebuild_h5:
249
+ out_h5 = os.path.join(model_dir, f"{model_name}.h5")
250
+ try:
251
+ build_h5_bundle_from_txt(model_dir, out_h5)
252
+ except Exception as e:
253
+ print(f"[rebuild] h5 bundle failed: {e}")
254
+
255
+ # 3) flux cube
256
+ if rebuild_flux_cube:
257
+ out_flux = os.path.join(model_dir, "flux_cube.bin")
258
+ try:
259
+ precompute_flux_cube(model_dir, out_flux)
260
+ except Exception as e:
261
+ print(f"[rebuild] flux cube failed: {e}")
262
+
263
+ print("\nRebuild complete.")
264
+
265
+ import glob
266
+ import os
267
+ from typing import List, Tuple
268
+
269
+ # assumes in scope:
270
+ # ensure_dir, STELLAR_DIR_DEFAULT
271
+ # SVOSpectraGrabber, MSGSpectraGrabber, MASTSpectraGrabber
272
+ # build_h5_bundle_from_txt, regenerate_lookup_table, precompute_flux_cube
273
+ # and: from SED_tools.cli import _prompt_choice
274
+
275
+ def _srcs(s: str) -> List[str]:
276
+ s = s.lower()
277
+ return {"svo":["svo"], "msg":["msg"], "mast":["mast"], "both":["svo","msg"], "all":["svo","msg","mast"]}[s]
278
+
279
+ class _Opt:
280
+ __slots__ = ("src","name","label")
281
+ def __init__(self, src, name):
282
+ self.src, self.name = src, name
283
+ self.label = f"{name} [{src}]"
284
+
285
+ def run_spectra_flow(
286
+ source: str,
287
+ base_dir: str = STELLAR_DIR_DEFAULT,
288
+ models: List[str] = None,
289
+ workers: int = 5,
290
+ force_bundle_h5: bool = True,
291
+ build_flux_cube: bool = True
292
+ ) -> None:
293
+ from .spectra_cleaner import clean_model_dir
294
+
295
+ ensure_dir(base_dir)
296
+ src_list = _srcs(source)
297
+
298
+ grabs = {}
299
+ if "svo" in src_list: grabs["svo"] = SVOSpectraGrabber(base_dir=base_dir, max_workers=workers)
300
+ if "msg" in src_list: grabs["msg"] = MSGSpectraGrabber(base_dir=base_dir, max_workers=workers)
301
+ if "mast" in src_list: grabs["mast"] = MASTSpectraGrabber(base_dir=base_dir, max_workers=workers)
302
+
303
+ discovered: List[Tuple[str,str]] = [(s, m) for s in src_list for m in grabs[s].discover_models()]
304
+ if models is None and not discovered:
305
+ print("No models discovered."); return
306
+
307
+ if models is not None:
308
+ if len(models)==1 and models[0].lower()=="all":
309
+ chosen = discovered
310
+ else:
311
+ chosen = []
312
+ for m in models:
313
+ if ":" in m:
314
+ src, name = m.split(":",1); src, name = src.strip().lower(), name.strip()
315
+ else:
316
+ if len(src_list)!=1: raise ValueError(f"Ambiguous '{m}' with source='{source}'. Use 'src:model'.")
317
+ src, name = src_list[0], m.strip()
318
+ if (src,name) not in discovered: raise ValueError(f"Model '{name}' not found in '{src}'.")
319
+ chosen.append((src,name))
320
+ else:
321
+ opts = [_Opt(s,n) for s,n in discovered]
322
+ idx = _prompt_choice(opts, label="Spectral models", allow_back=True)
323
+ if idx is None or idx==-1: print("No model selected."); return
324
+ sel = opts[idx]; chosen = [(sel.src, sel.name)]
325
+
326
+ for src, name in chosen:
327
+ print("\n"+"="*64); print(f"[{src}] {name}")
328
+ model_dir = os.path.join(base_dir, name); ensure_dir(model_dir)
329
+
330
+ g = grabs[src]
331
+ meta = g.get_model_metadata(name)
332
+ if not meta: print(f"[{src}] No metadata for {name}; skip."); continue
333
+ n_written = g.download_model_spectra(name, meta)
334
+ print(f"[{src}] wrote {n_written} spectra -> {model_dir}")
335
+
336
+ summary = clean_model_dir(model_dir, try_h5_recovery=True, rebuild_lookup=True)
337
+ print(f"[clean] total={summary['total']} fixed={len(summary['fixed'])} "
338
+ f"recovered={len(summary['recovered'])} skipped={len(summary['skipped'])} "
339
+ f"deleted={len(summary['deleted'])}")
340
+
341
+ if not glob.glob(os.path.join(model_dir, "*.txt")):
342
+ print(f"[{src}] no .txt after cleaning; skip downstream."); continue
343
+
344
+ if src == "msg":
345
+ out_h5 = os.path.join(model_dir, f"{name}_bundle.h5")
346
+ if force_bundle_h5 and not os.path.exists(out_h5):
347
+ build_h5_bundle_from_txt(model_dir, out_h5)
348
+ else:
349
+ out_h5 = os.path.join(model_dir, f"{name}.h5")
350
+ if force_bundle_h5 or not os.path.exists(out_h5):
351
+ build_h5_bundle_from_txt(model_dir, out_h5)
352
+
353
+ print("[lookup] rebuilding lookup_table.csv"); regenerate_lookup_table(model_dir)
354
+
355
+ if build_flux_cube:
356
+ precompute_flux_cube(model_dir, os.path.join(model_dir,"flux_cube.bin"))
357
+
358
+ print("\nDone.")
359
+
360
+
361
+ #!/usr/bin/env python3
362
+ """
363
+ ADD THIS FUNCTION TO sed_tools/__init__.py or sed_tools/cli.py
364
+
365
+ Place it after run_rebuild_flow() function.
366
+ """
367
+
368
+ def run_combine_flow(
369
+ base_dir: str = STELLAR_DIR_DEFAULT,
370
+ output_name: str = "combined_models",
371
+ interactive: bool = True
372
+ ) -> None:
373
+ """
374
+ Combine multiple stellar atmosphere grids into unified omni grid.
375
+
376
+ This creates a single flux cube spanning the parameter space of all
377
+ selected models, normalized to a reference (preferably Kurucz).
378
+
379
+ Args:
380
+ base_dir: Base directory containing stellar_models/ subdirectories
381
+ output_name: Name for the output combined model directory
382
+ interactive: If True, prompt user to select models
383
+ """
384
+ from .combine_stellar_atm import (build_combined_flux_cube,
385
+ create_common_wavelength_grid,
386
+ create_unified_grid, find_stellar_models,
387
+ load_model_data, save_combined_data,
388
+ select_models_interactive,
389
+ visualize_parameter_space)
390
+
391
+ ensure_dir(base_dir)
392
+
393
+ # Find available models
394
+ model_dirs = find_stellar_models(base_dir)
395
+
396
+ if not model_dirs:
397
+ print(f"No stellar models found in {base_dir}")
398
+ print("Download some models first using option 1 (Spectra)")
399
+ return
400
+
401
+ print(f"\nFound {len(model_dirs)} stellar atmosphere models")
402
+
403
+ # Select models to combine
404
+ if interactive:
405
+ selected = select_models_interactive(model_dirs)
406
+ else:
407
+ selected = model_dirs
408
+
409
+ if not selected:
410
+ print("No models selected.")
411
+ return
412
+
413
+ if len(selected) < 2:
414
+ print("Warning: Need at least 2 models for meaningful combination.")
415
+ if interactive:
416
+ confirm = input("Continue with 1 model? [y/N] ").strip().lower()
417
+ if not confirm.startswith('y'):
418
+ return
419
+
420
+ print(f"\nSelected {len(selected)} models to combine:")
421
+ for name, _ in selected:
422
+ print(f" - {name}")
423
+
424
+ # Get output name
425
+ if interactive:
426
+ user_output = input(f"\nOutput directory name [{output_name}]: ").strip()
427
+ if user_output:
428
+ output_name = user_output
429
+
430
+ # Load model data
431
+ print("\nLoading model data...")
432
+ all_models = []
433
+ for name, path in selected:
434
+ print(f" Loading {name}...")
435
+ all_models.append(load_model_data(path))
436
+
437
+ # Create unified grids
438
+ print("\nCreating unified parameter grids...")
439
+ teff_grid, logg_grid, meta_grid = create_unified_grid(all_models)
440
+ wavelength_grid = create_common_wavelength_grid(all_models)
441
+
442
+ # Show grid info
443
+ total_points = len(teff_grid) * len(logg_grid) * len(meta_grid)
444
+ print(f"\nUnified grid dimensions:")
445
+ print(f" Teff: {len(teff_grid)} points " +
446
+ f"({teff_grid.min():.0f} - {teff_grid.max():.0f} K)")
447
+ print(f" log g: {len(logg_grid)} points " +
448
+ f"({logg_grid.min():.2f} - {logg_grid.max():.2f})")
449
+ print(f" [M/H]: {len(meta_grid)} points " +
450
+ f"({meta_grid.min():.2f} - {meta_grid.max():.2f})")
451
+ print(f" λ: {len(wavelength_grid)} points " +
452
+ f"({wavelength_grid.min():.0f} - {wavelength_grid.max():.0f} Å)")
453
+ print(f"\nTotal grid points: {total_points:,}")
454
+
455
+ # Confirm before proceeding
456
+ if interactive:
457
+ confirm = input("\nProceed with building combined flux cube? [Y/n] ").strip().lower()
458
+ if confirm and not confirm.startswith('y'):
459
+ print("Cancelled.")
460
+ return
461
+
462
+ # Build combined flux cube
463
+ print("\nBuilding combined flux cube...")
464
+ print("(This may take several minutes for large grids)")
465
+ flux_cube, source_map = build_combined_flux_cube(
466
+ all_models, teff_grid, logg_grid, meta_grid, wavelength_grid
467
+ )
468
+
469
+ # Save combined data
470
+ output_dir = os.path.join(base_dir, output_name)
471
+ print(f"\nSaving combined model to: {output_dir}")
472
+ save_combined_data(
473
+ output_dir,
474
+ teff_grid,
475
+ logg_grid,
476
+ meta_grid,
477
+ wavelength_grid,
478
+ flux_cube,
479
+ all_models
480
+ )
481
+
482
+ # Create visualizations
483
+ print("\nCreating parameter space visualizations...")
484
+ visualize_parameter_space(
485
+ teff_grid, logg_grid, meta_grid, source_map, all_models, output_dir
486
+ )
487
+
488
+ # Success message
489
+ print("\n" + "="*70)
490
+ print("SUCCESS: Omni grid created!")
491
+ print("="*70)
492
+ print(f"\nYour combined model is ready at:")
493
+ print(f" {output_dir}")
494
+ print(f"\nContents:")
495
+ print(f" • flux_cube.bin - MESA-ready flux cube")
496
+ print(f" • lookup_table.csv - Combined model inventory")
497
+ print(f" • parameter_space_visualization.png - Coverage maps")
498
+ print(f" • Original spectra from all source models")
499
+ print(f"\nTo use in MESA, set in your inlist:")
500
+ print(f" stellar_atm = '/colors/data/stellar_models/{output_name}/'")
501
+ print()
502
+
503
+
504
+
505
+
506
+
507
+ def menu() -> str:
508
+ print("\nWhat would you like to run?")
509
+ print(" 1) Spectra (NJM / SVO / MSG / MAST)")
510
+ print(" 2) Filters (NJM / SVO)")
511
+ print(" 3) Rebuild (lookup + HDF5 + flux cube)")
512
+ print(" 4) Combine grids into omni grid") # ← ADD THIS LINE
513
+ print(" 0) Quit")
514
+ choice = input("> ").strip()
515
+ mapping = {
516
+ "1": "spectra",
517
+ "2": "filters",
518
+ "3": "rebuild",
519
+ "4": "combine", # ← ADD THIS LINE
520
+ "0": "quit"
521
+ }
522
+ return mapping.get(choice, "")
523
+
524
+
525
+ def run_filters_flow(base_dir: str = FILTER_DIR_DEFAULT) -> None:
526
+ """Interactive nested filter downloader that mirrors the spectra workflow."""
527
+ ensure_dir(base_dir)
528
+ try:
529
+ from .svo_filter_grabber import run_interactive as _run_filter_cli
530
+ except Exception as exc:
531
+ print("This feature needs requests/bs4/astroquery available:", exc)
532
+ return
533
+
534
+ _run_filter_cli(base_dir)
535
+
536
+
537
+
538
+ def find_atmospheres(
539
+ *,
540
+ teff_range: Optional[Sequence[float]] = None,
541
+ logg_range: Optional[Sequence[float]] = None,
542
+ metallicity_range: Optional[Sequence[float]] = None,
543
+ limit: Optional[int] = None,
544
+ allow_partial: bool = False,
545
+ model_root: Optional[Union[str, os.PathLike[str]]] = None,
546
+ filter_root: Optional[Union[str, os.PathLike[str]]] = None,
547
+ ) -> list[ModelMatch]:
548
+ """Discover locally available model grids matching the requested ranges."""
549
+
550
+ sed = SED(model_root=model_root, filter_root=filter_root)
551
+ return sed.find_atmospheres(
552
+ teff_range=teff_range,
553
+ logg_range=logg_range,
554
+ metallicity_range=metallicity_range,
555
+ limit=limit,
556
+ allow_partial=allow_partial,
557
+ )
558
+
559
+
560
+ def find_atm(**kwargs) -> list[ModelMatch]:
561
+ """Alias for :func:`find_atmospheres` matching the historical API sketch."""
562
+
563
+ for legacy_key in ("Z_range", "z_range"):
564
+ if legacy_key in kwargs and "metallicity_range" not in kwargs:
565
+ kwargs["metallicity_range"] = kwargs.pop(legacy_key)
566
+ if "logg_range" not in kwargs and "log_g_range" in kwargs:
567
+ kwargs["logg_range"] = kwargs.pop("log_g_range")
568
+ return find_atmospheres(**kwargs)
569
+
570
+
571
+ __all__ = [
572
+ # Core API
573
+ "SED",
574
+ "Catalog",
575
+ "CatalogInfo",
576
+ "Spectrum",
577
+ "Filters",
578
+ "MLCompleter",
579
+
580
+ # Convenience functions
581
+ "query",
582
+ "fetch",
583
+ "local",
584
+
585
+ # Existing exports (keep these)
586
+ "SEDModel",
587
+ "EvaluatedSED",
588
+ "PhotometryResult",
589
+ "ModelMatch",
590
+ "run_combine_flow",
591
+ "DATA_DIR_DEFAULT",
592
+ "STELLAR_DIR_DEFAULT",
593
+ "FILTER_DIR_DEFAULT",
594
+ "find_atmospheres",
595
+ "find_atm",
596
+ "__version__",
597
+ ]
598
+
599
+
600
+
601
+
602
+
603
+
604
+
605
+
606
+ import math
607
+ import os
608
+ import re
609
+ import shutil
610
+ import sys
611
+ from typing import Any, List, Optional, Sequence, Tuple
612
+
613
+
614
+ def _prompt_choice(
615
+ options: Sequence,
616
+ label: str,
617
+ allow_back: bool = False,
618
+ page_size: int = 100,
619
+ max_label: int = -1,
620
+ min_cols: int = 1,
621
+ max_cols: int = 3,
622
+ use_color: bool = True,
623
+ ) -> Optional[int]:
624
+ """
625
+ Plain-ASCII picker with stable IDs, paging, grid columns, and simple filters.
626
+ - Stable IDs: 1..N (do not renumber after filtering/paging)
627
+ - Pagination: n / p / g <page>
628
+ - Filters: /text (substring), !text (negated), //regex (case-insensitive)
629
+ - Select by ID: 'id <N>' or just '<N>'
630
+ Returns 0-based index, -1 for back (if allowed), None for quit.
631
+ """
632
+ if not options:
633
+ print(f"No {label} options available.")
634
+ return None
635
+
636
+
637
+ if max_label < 0:
638
+ max_label = max(len(getattr(x, "label", str(x))) for x in options) + 4
639
+
640
+
641
+ # ---- setup ----
642
+ labels: List[str] = [getattr(o, "label", str(o)) for o in options]
643
+ N = len(labels)
644
+ page = 1
645
+ filt: Optional[Tuple[str, str]] = None # ('substr'|'neg'|'regex', pattern)
646
+
647
+ # color toggles
648
+ use_color = use_color and sys.stdout.isatty() and ("NO_COLOR" not in os.environ)
649
+ BOLD = "\x1b[1m" if use_color else ""
650
+ DIM = "\x1b[2m" if use_color else ""
651
+ CYAN = "\x1b[36m" if use_color else ""
652
+ YELL = "\x1b[33m" if use_color else ""
653
+ RED = "\x1b[31m" if use_color else ""
654
+ GREEN = "\x1b[32m" if use_color else ""
655
+ YELLOW = "\x1b[33m" if use_color else ""
656
+ BLUE = "\x1b[34m" if use_color else ""
657
+ MAGENTA = "\x1b[35m" if use_color else ""
658
+ WHITE = "\x1b[37m" if use_color else ""
659
+ RESET = "\x1b[0m" if use_color else ""
660
+
661
+ def term_width() -> int:
662
+ try: return shutil.get_terminal_size().columns
663
+ except: return 80
664
+
665
+ def apply_filter(idx: List[int]) -> List[int]:
666
+ if filt is None: return idx
667
+ kind, patt = filt
668
+ if kind == "substr":
669
+ p = patt.lower()
670
+ return [i for i in idx if p in labels[i].lower()]
671
+ if kind == "neg":
672
+ p = patt.lower()
673
+ return [i for i in idx if p not in labels[i].lower()]
674
+ # regex
675
+ rx = re.compile(patt, re.I)
676
+ return [i for i in idx if rx.search(labels[i])]
677
+
678
+ def page_slice(total: int, p: int) -> slice:
679
+ pmax = max(1, math.ceil(total / page_size))
680
+ if p > pmax: p = pmax
681
+ if p < 1: p = 1
682
+ a = (p - 1) * page_size
683
+ b = min(a + page_size, total)
684
+ return slice(a, b)
685
+
686
+ def ellipsize(s: str) -> str:
687
+ return s if len(s) <= max_label else s[:max_label - 1] + "…"
688
+
689
+ def hl(s: str) -> str:
690
+ if not use_color or filt is None: return s
691
+ kind, patt = filt
692
+ if kind != "substr" or not patt: return s
693
+ rx = re.compile(re.escape(patt), re.I)
694
+ return rx.sub(lambda m: f"{YELL}{m.group(0)}{RESET}", s)
695
+
696
+ def grid_print(visible_ids: List[int]) -> None:
697
+ width = max(80, term_width())
698
+ names = [labels[i] for i in visible_ids]
699
+ col_w = 6 + max_label + 2 # "[####] " + label + gap
700
+ cols = max(min_cols, min(max_cols, max(1, width // col_w)))
701
+ cells = [f"[{GREEN}{i+1:4d}{RESET}] {hl(ellipsize(s))}" for i, s in zip(visible_ids, names)]
702
+ while len(cells) % cols: cells.append("")
703
+ rows = [cells[k:k+cols] for k in range(0, len(cells), cols)]
704
+ print(f"\n{BOLD}{label}{RESET} ({CYAN}{len(all_idx)}{RESET} total):")
705
+ print("─" * min(80, width))
706
+ for r in rows:
707
+ print(" ".join(x.ljust(col_w - 2) for x in r))
708
+
709
+ # ---- loop ----
710
+ all_idx = list(range(N))
711
+ while True:
712
+ kept = apply_filter(all_idx)
713
+ sl = page_slice(len(kept), page)
714
+ view = kept[sl]
715
+ start, end = sl.start + 1, sl.stop
716
+ grid_print(view)
717
+
718
+ ftxt = ""
719
+ if filt:
720
+ kind, patt = filt
721
+ ftxt = f' {DIM}filter="/{patt}"{RESET}' if kind == "substr" else (
722
+ f' {DIM}filter="!{patt}"{RESET}' if kind == "neg" else
723
+ f' {DIM}filter="//{patt}"{RESET}')
724
+ if end < len(kept):
725
+ print(f"{DIM}Showing {start}–{end} of {len(kept)}{ftxt}{RESET}")
726
+ controls = f"{DIM}n, p, g <page>, /text, !text, //regex, id <N> (or just N), clear"
727
+ if allow_back: controls += ", b"
728
+ else:
729
+ controls = f"{DIM}/text, !text, //regex, id <N> (or just N), clear"
730
+ controls += ", q" + RESET
731
+ print(controls)
732
+
733
+ inp = input("> ").strip()
734
+ if not inp: continue
735
+ low = inp.lower()
736
+
737
+ if low in ("q", "quit", "exit"): return None
738
+ if allow_back and low in ("b", "back"): return -1
739
+ if low == "n": page += 1; continue
740
+ if low == "p": page -= 1; continue
741
+ if low.startswith("g "):
742
+ parts = low.split()
743
+ if len(parts) == 2 and parts[1].isdigit(): page = int(parts[1])
744
+ continue
745
+ if low == "clear": filt = None; page = 1; continue
746
+ if low.startswith("//"): patt = inp[2:].strip(); filt = ("regex", patt) if patt else None; page = 1; continue
747
+ if low.startswith("!"): patt = inp[1:].strip(); filt = ("neg", patt) if patt else None; page = 1; continue
748
+ if low.startswith("/"): patt = inp[1:].strip(); filt = ("substr", patt)if patt else None; page = 1; continue
749
+ if low.startswith("id "):
750
+ parts = low.split()
751
+ if len(parts) == 2 and parts[1].isdigit():
752
+ k = int(parts[1])
753
+ if 1 <= k <= N: return k - 1
754
+ continue
755
+ if inp.isdigit():
756
+ k = int(inp)
757
+ if 1 <= k <= N: return k - 1
758
+ continue
759
+
760
+ # default: substring filter
761
+ filt = ("substr", inp); page = 1
762
+
763
+
764
+
765
+
766
+
767
+
768
+
769
+