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 +769 -0
- sed_tools/__main__.py +4 -0
- sed_tools/_flux.py +40 -0
- sed_tools/_resample.py +95 -0
- sed_tools/api.py +1596 -0
- sed_tools/cli.py +1338 -0
- sed_tools/collision_config.py +395 -0
- sed_tools/combine_stellar_atm.py +631 -0
- sed_tools/flux_cube_tool.py +1091 -0
- sed_tools/grid_densifier.py +323 -0
- sed_tools/mast_spectra_grabber.py +372 -0
- sed_tools/mesa_prepare.py +454 -0
- sed_tools/ml.py +720 -0
- sed_tools/ml_optimized.py +143 -0
- sed_tools/ml_sed_completer.py +1767 -0
- sed_tools/ml_sed_generator.py +1206 -0
- sed_tools/ml_utils.py +80 -0
- sed_tools/models.py +740 -0
- sed_tools/msg_spectra_grabber.py +515 -0
- sed_tools/njm_filter_grabber.py +341 -0
- sed_tools/njm_spectra_grabber.py +795 -0
- sed_tools/precompute_flux_cube.py +698 -0
- sed_tools/sed_unit_converter.py +376 -0
- sed_tools/spectra_cleaner.py +1076 -0
- sed_tools/svo_filter_grabber.py +399 -0
- sed_tools/svo_regen_spectra_lookup.py +161 -0
- sed_tools/svo_spectra_filter.py +481 -0
- sed_tools/svo_spectra_grabber.py +483 -0
- sed_tools/ui_utils.py +182 -0
- sed_tools-0.0.2.dist-info/METADATA +729 -0
- sed_tools-0.0.2.dist-info/RECORD +35 -0
- sed_tools-0.0.2.dist-info/WHEEL +5 -0
- sed_tools-0.0.2.dist-info/entry_points.txt +2 -0
- sed_tools-0.0.2.dist-info/licenses/LICENSE +21 -0
- sed_tools-0.0.2.dist-info/top_level.txt +1 -0
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
|
+
|