diskmelts 2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- diskmelts/__init__.py +98 -0
- diskmelts/consub/__init__.py +15 -0
- diskmelts/consub/_api.py +129 -0
- diskmelts/consub/_core.py +447 -0
- diskmelts/consub/cont_ranges.csv +206 -0
- diskmelts/consub/cont_ranges_PPan.csv +182 -0
- diskmelts/consub/regions/carbon_extended.csv +227 -0
- diskmelts/consub/regions/carbon_extended.json +29 -0
- diskmelts/consub/regions/carbon_rich.csv +360 -0
- diskmelts/consub/regions/carbon_rich.json +27 -0
- diskmelts/consub/regions/ttauri.csv +421 -0
- diskmelts/consub/regions/ttauri.json +17 -0
- diskmelts/consub/regions/water.csv +505 -0
- diskmelts/consub/regions/water.json +11 -0
- diskmelts/fitting.py +1408 -0
- diskmelts/models/Trained_model/13C12CH2_single_v2/net_13C12CH2_forward.pt +0 -0
- diskmelts/models/Trained_model/13CO2_single_v2/net_13CO2_forward.pt +0 -0
- diskmelts/models/Trained_model/C2H2_single_v2/net_C2H2_forward.pt +0 -0
- diskmelts/models/Trained_model/C2H4_single_v2/net_C2H4_forward.pt +0 -0
- diskmelts/models/Trained_model/C2H6_single_v2/net_C2H6_forward.pt +0 -0
- diskmelts/models/Trained_model/C2HD_single_v2/net_C2HD_forward.pt +0 -0
- diskmelts/models/Trained_model/C4H2_single_v2/net_C4H2_forward.pt +0 -0
- diskmelts/models/Trained_model/CH4_single_v2/net_CH4_forward.pt +0 -0
- diskmelts/models/Trained_model/CO17O_single_v2/net_CO17O_forward.pt +0 -0
- diskmelts/models/Trained_model/CO18O_single_v2/net_CO18O_forward.pt +0 -0
- diskmelts/models/Trained_model/CO2_single_v2/net_CO2_forward.pt +0 -0
- diskmelts/models/Trained_model/H2O_wavelength_bank_v2_fixed/H2O_model_bank.json +129 -0
- diskmelts/models/Trained_model/H2O_wavelength_bank_v2_fixed/net_H2O_w13p5to19p2_n13to19_t100to1400.pt +0 -0
- diskmelts/models/Trained_model/H2O_wavelength_bank_v2_fixed/net_H2O_w19to25_n13to19_t100to1400.pt +0 -0
- diskmelts/models/Trained_model/H2O_wavelength_bank_v2_fixed/net_H2O_w4p9to7p4_n13to19_t100to1400.pt +0 -0
- diskmelts/models/Trained_model/H2O_wavelength_bank_v2_fixed/net_H2O_w7p2to11p2_n13to19_t100to1400.pt +0 -0
- diskmelts/models/Trained_model/H2O_wavelength_bank_v2_fixed/net_H2O_w9p9to15p1_n13to19_t100to1400.pt +0 -0
- diskmelts/models/Trained_model/HC3N_single_v2/net_HC3N_forward.pt +0 -0
- diskmelts/models/Trained_model/HCN_single_v2/net_HCN_forward.pt +0 -0
- diskmelts/plotting.py +536 -0
- diskmelts/trainmodel.py +1349 -0
- diskmelts/v2_fitting.py +298 -0
- diskmelts/v2_grid_fitting.py +570 -0
- diskmelts/v2_paper_comparison.py +174 -0
- diskmelts/v2_read_plot.py +220 -0
- diskmelts/validation.py +505 -0
- diskmelts-2.1.dist-info/METADATA +262 -0
- diskmelts-2.1.dist-info/RECORD +46 -0
- diskmelts-2.1.dist-info/WHEEL +5 -0
- diskmelts-2.1.dist-info/licenses/LICENSE +21 -0
- diskmelts-2.1.dist-info/top_level.txt +1 -0
diskmelts/fitting.py
ADDED
|
@@ -0,0 +1,1408 @@
|
|
|
1
|
+
"""
|
|
2
|
+
diskmelts/fitting.py — Spectral fitting with pretrained forward surrogate models.
|
|
3
|
+
|
|
4
|
+
This module provides all fitting and model-loading utilities.
|
|
5
|
+
|
|
6
|
+
Forward model convention:
|
|
7
|
+
flux = A * peak(T, logN) * shape(T, logN)
|
|
8
|
+
|
|
9
|
+
Global-to-local search strategy in fit_molecules:
|
|
10
|
+
1. Draw Sobol or Latin-hypercube samples over all nonlinear parameters (T, logN).
|
|
11
|
+
2. For each nonlinear trial, solve all linear amplitudes A together with NNLS.
|
|
12
|
+
3. Refine the best distinct candidates with scaled L-BFGS-B and Nelder-Mead.
|
|
13
|
+
|
|
14
|
+
Public functions
|
|
15
|
+
----------------
|
|
16
|
+
load_models : load per-molecule .pt checkpoints from disk
|
|
17
|
+
load_model_bank : load a segmented checkpoint manifest
|
|
18
|
+
generate_spectrum : evaluate (T, logN, A) → flux via the two-MLP forward model
|
|
19
|
+
fit_nested : single-molecule retrieval with analytic amplitude
|
|
20
|
+
fit_molecules : multi-molecule global Sobol search + L-BFGS-B refinement
|
|
21
|
+
load_observed_spectrum: load a continuum-subtracted spectrum from CSV
|
|
22
|
+
detect_stage_molecules: screen for molecular detections above a noise threshold
|
|
23
|
+
save_fit_outputs : write best-fit CSV, top-list CSV, and spectrum to disk
|
|
24
|
+
save_running_spectrum: write residual / running-model spectrum to disk
|
|
25
|
+
print_fit_params : pretty-print best-fit parameters and uncertainties
|
|
26
|
+
save_fitted_comparison: upsert best-fit parameters into a comparison CSV
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import numpy as np
|
|
32
|
+
import pandas as pd
|
|
33
|
+
import torch
|
|
34
|
+
import scipy.optimize
|
|
35
|
+
from scipy.stats import qmc
|
|
36
|
+
|
|
37
|
+
from diskmelts.trainmodel import pretrain_forward_model # load-only (n_epochs=0)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# ---------------------------------------------------------------------------
|
|
41
|
+
# Module-level defaults
|
|
42
|
+
# ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
_DEFAULT_T_BOUNDS = (200.0, 1400.0)
|
|
45
|
+
_DEFAULT_LOGN_BOUNDS = (14.0, 19.0)
|
|
46
|
+
_DEFAULT_LOGA_BOUNDS = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Internal spectral helpers (forward model evaluation)
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
def _wav_mask(wav, ranges):
|
|
54
|
+
"""Boolean mask for wavelengths within any of the given (lo, hi) ranges."""
|
|
55
|
+
if isinstance(ranges[0], (int, float)):
|
|
56
|
+
ranges = [ranges]
|
|
57
|
+
mask = np.zeros(len(wav), dtype=bool)
|
|
58
|
+
for lo, hi in ranges:
|
|
59
|
+
mask |= (wav >= lo) & (wav <= hi)
|
|
60
|
+
return mask
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _single_model_flux_on_wav(pretrained_mol, T, logN, A, wav_grid):
|
|
64
|
+
"""
|
|
65
|
+
Evaluate one molecule's two-MLP forward model at (T, logN, A).
|
|
66
|
+
|
|
67
|
+
The model is evaluated on its native wavelength axis then interpolated onto
|
|
68
|
+
wav_grid, so wav_grid can have any sampling.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
pretrained_mol (dict): single-molecule entry from load_models
|
|
72
|
+
T (float): temperature in K
|
|
73
|
+
logN (float): log10 column density
|
|
74
|
+
A (float): linear area-scaling factor
|
|
75
|
+
wav_grid (np.ndarray): output wavelength grid in µm (any sampling)
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
(np.ndarray): model flux on wav_grid (Jy)
|
|
79
|
+
"""
|
|
80
|
+
net_shape = pretrained_mol['net_shape']
|
|
81
|
+
net_peak = pretrained_mol['net_peak']
|
|
82
|
+
xp_sc = pretrained_mol['xp_sc']
|
|
83
|
+
yp_sc_shape = pretrained_mol['yp_sc_shape']
|
|
84
|
+
yp_sc_peak = pretrained_mol['yp_sc_peak']
|
|
85
|
+
pca = pretrained_mol.get('pca', None)
|
|
86
|
+
wav_net = pretrained_mol['wav']
|
|
87
|
+
device = next(net_shape.parameters()).device
|
|
88
|
+
|
|
89
|
+
input_T = np.log10(T) if pretrained_mol.get('temperature_transform') == 'log10' else T
|
|
90
|
+
T_s = float((input_T - xp_sc.mean_[0]) / xp_sc.scale_[0])
|
|
91
|
+
logN_s = float((logN - xp_sc.mean_[1]) / xp_sc.scale_[1])
|
|
92
|
+
x_s = torch.tensor([[T_s, logN_s]], dtype=torch.float32, device=device)
|
|
93
|
+
|
|
94
|
+
net_shape.eval()
|
|
95
|
+
net_peak.eval()
|
|
96
|
+
with torch.no_grad():
|
|
97
|
+
z_shape_s = net_shape(x_s)[0].cpu().numpy()
|
|
98
|
+
z_peak_s = net_peak(x_s)[0].cpu().numpy()
|
|
99
|
+
|
|
100
|
+
shape = z_shape_s * yp_sc_shape.scale_ + yp_sc_shape.mean_
|
|
101
|
+
log10p = float((z_peak_s * yp_sc_peak.scale_ + yp_sc_peak.mean_)[0])
|
|
102
|
+
if pca is not None:
|
|
103
|
+
shape = pca.inverse_transform(shape[np.newaxis])[0]
|
|
104
|
+
|
|
105
|
+
flux_net = A * (10.0 ** log10p) * np.maximum(shape, 0.0)
|
|
106
|
+
return np.interp(wav_grid, wav_net, flux_net, left=0.0, right=0.0)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _tile_axis_weight(values, bounds):
|
|
110
|
+
"""Smooth positive interior weight, zero outside one tile's bounds."""
|
|
111
|
+
values = np.asarray(values, dtype=np.float64)
|
|
112
|
+
lo, hi = map(float, bounds)
|
|
113
|
+
inside = (values >= lo) & (values <= hi)
|
|
114
|
+
scaled = np.minimum((values - lo) / (hi - lo), (hi - values) / (hi - lo))
|
|
115
|
+
return np.where(inside, np.maximum(scaled, 1.0e-6), 0.0)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _mol_flux_on_wav(pretrained_mol, T, logN, A, wav_grid):
|
|
119
|
+
"""Evaluate either one checkpoint or an overlapping model bank."""
|
|
120
|
+
if not pretrained_mol.get('is_model_bank', False):
|
|
121
|
+
return _single_model_flux_on_wav(pretrained_mol, T, logN, A, wav_grid)
|
|
122
|
+
|
|
123
|
+
wav_grid = np.asarray(wav_grid, dtype=np.float64)
|
|
124
|
+
weighted_flux = np.zeros_like(wav_grid)
|
|
125
|
+
total_weight = np.zeros_like(wav_grid)
|
|
126
|
+
for tile in pretrained_mol['tiles']:
|
|
127
|
+
T_weight = float(_tile_axis_weight([T], tile['T_range'])[0])
|
|
128
|
+
logN_weight = float(_tile_axis_weight([logN], tile['logN_range'])[0])
|
|
129
|
+
if T_weight == 0.0 or logN_weight == 0.0:
|
|
130
|
+
continue
|
|
131
|
+
wav_weight = _tile_axis_weight(wav_grid, tile['wav_range'])
|
|
132
|
+
if not np.any(wav_weight):
|
|
133
|
+
continue
|
|
134
|
+
weight = T_weight * logN_weight * wav_weight
|
|
135
|
+
flux = _single_model_flux_on_wav(tile['model'], T, logN, A, wav_grid)
|
|
136
|
+
weighted_flux += weight * flux
|
|
137
|
+
total_weight += weight
|
|
138
|
+
|
|
139
|
+
result = np.zeros_like(wav_grid)
|
|
140
|
+
covered = total_weight > 0
|
|
141
|
+
result[covered] = weighted_flux[covered] / total_weight[covered]
|
|
142
|
+
return result
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
# Public: load models and generate spectra
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
def _ranges_overlap(first, second):
|
|
150
|
+
return max(first[0], second[0]) <= min(first[1], second[1])
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def load_model_bank(
|
|
154
|
+
manifest_path,
|
|
155
|
+
device=None,
|
|
156
|
+
wav_ranges=None,
|
|
157
|
+
T_range=None,
|
|
158
|
+
logN_range=None,
|
|
159
|
+
):
|
|
160
|
+
"""Load an overlapping bank of segmented checkpoints from a JSON manifest.
|
|
161
|
+
|
|
162
|
+
Optional range filters avoid loading tiles that cannot contribute to a
|
|
163
|
+
planned fit. The returned object can be passed anywhere a normal
|
|
164
|
+
single-molecule pretrained model is accepted.
|
|
165
|
+
"""
|
|
166
|
+
manifest_path = os.path.abspath(os.fspath(manifest_path))
|
|
167
|
+
with open(manifest_path, 'r', encoding='utf-8') as stream:
|
|
168
|
+
manifest = json.load(stream)
|
|
169
|
+
if manifest.get('schema_version') != 1:
|
|
170
|
+
raise ValueError(f'unsupported model-bank manifest: {manifest_path}')
|
|
171
|
+
if not manifest.get('mol') or not manifest.get('tiles'):
|
|
172
|
+
raise ValueError(f'incomplete model-bank manifest: {manifest_path}')
|
|
173
|
+
|
|
174
|
+
if device is None:
|
|
175
|
+
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
|
176
|
+
if wav_ranges is not None and isinstance(wav_ranges[0], (int, float)):
|
|
177
|
+
wav_ranges = [wav_ranges]
|
|
178
|
+
|
|
179
|
+
manifest_dir = os.path.dirname(manifest_path)
|
|
180
|
+
loaded_tiles = []
|
|
181
|
+
for spec in manifest['tiles']:
|
|
182
|
+
wav_range = tuple(map(float, spec['wav_range']))
|
|
183
|
+
tile_T_range = tuple(map(float, spec['T_range']))
|
|
184
|
+
tile_logN_range = tuple(map(float, spec['logN_range']))
|
|
185
|
+
if wav_ranges is not None and not any(
|
|
186
|
+
_ranges_overlap(wav_range, requested) for requested in wav_ranges
|
|
187
|
+
):
|
|
188
|
+
continue
|
|
189
|
+
if T_range is not None and not _ranges_overlap(tile_T_range, T_range):
|
|
190
|
+
continue
|
|
191
|
+
if logN_range is not None and not _ranges_overlap(tile_logN_range, logN_range):
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
path = spec['path']
|
|
195
|
+
if not os.path.isabs(path):
|
|
196
|
+
path = os.path.join(manifest_dir, path)
|
|
197
|
+
model = pretrain_forward_model(
|
|
198
|
+
mol=manifest['mol'],
|
|
199
|
+
pretrain_csv=None,
|
|
200
|
+
device=device,
|
|
201
|
+
n_epochs=0,
|
|
202
|
+
model_path=path,
|
|
203
|
+
)
|
|
204
|
+
for key, expected in (
|
|
205
|
+
('wav_range', wav_range),
|
|
206
|
+
('T_range', tile_T_range),
|
|
207
|
+
('logN_range', tile_logN_range),
|
|
208
|
+
):
|
|
209
|
+
stored = model.get(key)
|
|
210
|
+
if stored is not None and not np.allclose(stored, expected):
|
|
211
|
+
raise ValueError(
|
|
212
|
+
f'checkpoint {path!r} has {key}={stored}, but its '
|
|
213
|
+
f'manifest entry specifies {expected}'
|
|
214
|
+
)
|
|
215
|
+
loaded_tiles.append({
|
|
216
|
+
'model': model,
|
|
217
|
+
'path': os.path.abspath(path),
|
|
218
|
+
'wav_range': wav_range,
|
|
219
|
+
'T_range': tile_T_range,
|
|
220
|
+
'logN_range': tile_logN_range,
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
if not loaded_tiles:
|
|
224
|
+
raise ValueError('model-bank filters selected zero checkpoints')
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
'is_model_bank': True,
|
|
228
|
+
'mol': manifest['mol'],
|
|
229
|
+
'manifest_path': manifest_path,
|
|
230
|
+
'tiles': loaded_tiles,
|
|
231
|
+
'wav_ranges': sorted(set(tile['wav_range'] for tile in loaded_tiles)),
|
|
232
|
+
'T_ranges': sorted(set(tile['T_range'] for tile in loaded_tiles)),
|
|
233
|
+
'logN_ranges': sorted(set(tile['logN_range'] for tile in loaded_tiles)),
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
def load_models(
|
|
237
|
+
model_paths,
|
|
238
|
+
pretrain_csv_paths=None,
|
|
239
|
+
wav_ranges=None,
|
|
240
|
+
n_pca=None,
|
|
241
|
+
device=None,
|
|
242
|
+
):
|
|
243
|
+
"""
|
|
244
|
+
Load pretrained per-molecule forward models from disk.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
model_paths (dict): mol -> path to a .pt checkpoint or model-bank JSON
|
|
248
|
+
pretrain_csv_paths (dict or None): legacy mol -> pretrain CSV path.
|
|
249
|
+
Only needed for old checkpoints that
|
|
250
|
+
do not contain input scaling metadata
|
|
251
|
+
and their wavelength axis.
|
|
252
|
+
wav_ranges (dict or None): legacy mol -> (lo_µm, hi_µm), paired with
|
|
253
|
+
pretrain_csv_paths.
|
|
254
|
+
n_pca (int or dict or None): legacy PCA component count. Self-contained
|
|
255
|
+
checkpoints store and load their own PCA.
|
|
256
|
+
device (torch.device or None): auto-selects CUDA if available
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
(dict): mol -> pretrained dict with keys
|
|
260
|
+
'net_shape', 'net_peak', 'xp_sc', 'yp_sc_shape',
|
|
261
|
+
'yp_sc_peak', 'pca', 'wav'
|
|
262
|
+
"""
|
|
263
|
+
if device is None:
|
|
264
|
+
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
|
265
|
+
pretrain_csv_paths = pretrain_csv_paths or {}
|
|
266
|
+
wav_ranges = wav_ranges or {}
|
|
267
|
+
pretrained = {}
|
|
268
|
+
for mol in model_paths:
|
|
269
|
+
model_path = model_paths[mol]
|
|
270
|
+
if os.fspath(model_path).lower().endswith('.json'):
|
|
271
|
+
pretrained[mol] = load_model_bank(model_path, device=device)
|
|
272
|
+
if pretrained[mol]['mol'] != mol:
|
|
273
|
+
raise ValueError(
|
|
274
|
+
f'model bank {model_path!r} contains '
|
|
275
|
+
f'{pretrained[mol]["mol"]!r}, not requested key {mol!r}'
|
|
276
|
+
)
|
|
277
|
+
print(f' Loaded {mol} model bank from {model_path}')
|
|
278
|
+
continue
|
|
279
|
+
mol_n_pca = n_pca[mol] if isinstance(n_pca, dict) else n_pca
|
|
280
|
+
kw = {} if mol_n_pca is None else {'n_pca': mol_n_pca}
|
|
281
|
+
pretrained[mol] = pretrain_forward_model(
|
|
282
|
+
mol=mol,
|
|
283
|
+
pretrain_csv=pretrain_csv_paths.get(mol),
|
|
284
|
+
wav_range=wav_ranges.get(mol, (11.0, 19.0)),
|
|
285
|
+
device=device,
|
|
286
|
+
n_epochs=0,
|
|
287
|
+
model_path=model_path,
|
|
288
|
+
seed=42,
|
|
289
|
+
**kw,
|
|
290
|
+
)
|
|
291
|
+
print(f' Loaded {mol} from {model_path}')
|
|
292
|
+
return pretrained
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def generate_spectrum(T, logN, A, pretrained_mol, obs_wav=None):
|
|
296
|
+
"""
|
|
297
|
+
Generate a model spectrum for one molecule using the two-MLP forward model.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
T (float): temperature in K
|
|
301
|
+
logN (float): log10 column density
|
|
302
|
+
A (float): linear area-scaling factor
|
|
303
|
+
pretrained_mol (dict): single-molecule pretrained dict, e.g. pretrained['H2O']
|
|
304
|
+
obs_wav (np.ndarray or None): output wavelength grid in µm; if None,
|
|
305
|
+
returns flux on the model's native grid
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
(np.ndarray): model flux in Jy on obs_wav (or native grid if None)
|
|
309
|
+
"""
|
|
310
|
+
if obs_wav is None:
|
|
311
|
+
if pretrained_mol.get('is_model_bank', False):
|
|
312
|
+
obs_wav = np.unique(np.concatenate([
|
|
313
|
+
tile['model']['wav'] for tile in pretrained_mol['tiles']
|
|
314
|
+
]))
|
|
315
|
+
else:
|
|
316
|
+
obs_wav = pretrained_mol['wav']
|
|
317
|
+
return _mol_flux_on_wav(pretrained_mol, T, logN, A, obs_wav)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _model_bank_bounds(pretrained_mol, key, fallback):
|
|
321
|
+
if not pretrained_mol.get('is_model_bank', False):
|
|
322
|
+
return fallback
|
|
323
|
+
ranges = pretrained_mol[key]
|
|
324
|
+
return (min(item[0] for item in ranges), max(item[1] for item in ranges))
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _check_bank_wavelength_coverage(pretrained_mol, wavelengths):
|
|
328
|
+
if not pretrained_mol.get('is_model_bank', False):
|
|
329
|
+
return
|
|
330
|
+
covered = np.zeros(len(wavelengths), dtype=bool)
|
|
331
|
+
for lo, hi in pretrained_mol['wav_ranges']:
|
|
332
|
+
covered |= (wavelengths >= lo) & (wavelengths <= hi)
|
|
333
|
+
if not np.all(covered):
|
|
334
|
+
missing = wavelengths[~covered]
|
|
335
|
+
raise ValueError(
|
|
336
|
+
f'model bank does not cover {len(missing)} selected wavelength pixels; '
|
|
337
|
+
f'first uncovered wavelength is {missing[0]:.6g} um'
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _refine_scaled(objective, initial, bounds, loss_scale):
|
|
342
|
+
"""Refine in unit coordinates with differences resolvable by float32 MLPs."""
|
|
343
|
+
limits = np.asarray(bounds, dtype=float)
|
|
344
|
+
origin, span = limits[:, 0], limits[:, 1] - limits[:, 0]
|
|
345
|
+
scale = max(float(loss_scale), np.finfo(float).tiny)
|
|
346
|
+
result = scipy.optimize.minimize(
|
|
347
|
+
lambda z: objective(origin + z * span) / scale,
|
|
348
|
+
(np.asarray(initial) - origin) / span,
|
|
349
|
+
method='L-BFGS-B', bounds=[(0.0, 1.0)] * len(initial),
|
|
350
|
+
options={'eps': 1e-4, 'ftol': 1e-12, 'gtol': 1e-7, 'maxiter': 300},
|
|
351
|
+
)
|
|
352
|
+
# A derivative-free polish avoids stagnation at ReLU/float32 boundaries.
|
|
353
|
+
polished = scipy.optimize.minimize(
|
|
354
|
+
lambda z: objective(origin + z * span) / scale,
|
|
355
|
+
result.x, method='Nelder-Mead', bounds=[(0.0, 1.0)] * len(initial),
|
|
356
|
+
options={'xatol': 1e-6, 'fatol': 1e-12, 'maxiter': 300},
|
|
357
|
+
)
|
|
358
|
+
if polished.fun < result.fun:
|
|
359
|
+
result = polished
|
|
360
|
+
result.x = origin + result.x * span
|
|
361
|
+
return result
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def fit_nested(
|
|
365
|
+
obs_wav,
|
|
366
|
+
obs_flux,
|
|
367
|
+
mol,
|
|
368
|
+
pretrained,
|
|
369
|
+
fit_ranges,
|
|
370
|
+
T_bounds=None,
|
|
371
|
+
logN_bounds=None,
|
|
372
|
+
loga_bounds=None,
|
|
373
|
+
n_restarts=20,
|
|
374
|
+
n_top=1,
|
|
375
|
+
seed=None,
|
|
376
|
+
):
|
|
377
|
+
"""
|
|
378
|
+
Retrieve (T, logN, A) with scaled nonlinear optimization and analytic area.
|
|
379
|
+
|
|
380
|
+
Outer loop: scaled L-BFGS-B over (T, logN), followed by Nelder-Mead.
|
|
381
|
+
Inner loop: exact one-component bounded least squares solves for A ≥ 0.
|
|
382
|
+
|
|
383
|
+
Args:
|
|
384
|
+
obs_wav (np.ndarray): observed wavelength axis in µm
|
|
385
|
+
obs_flux (np.ndarray): observed flux in Jy
|
|
386
|
+
mol (str): molecule name, e.g. 'H2O', 'C2H2'
|
|
387
|
+
pretrained (dict): output of load_models
|
|
388
|
+
fit_ranges: (lo, hi) or [(lo1,hi1), (lo2,hi2)] µm
|
|
389
|
+
T_bounds (tuple or None): (T_min, T_max) K (default (200, 1400))
|
|
390
|
+
logN_bounds (tuple or None): (logN_min, logN_max) (default (14, 19))
|
|
391
|
+
loga_bounds (tuple or None): (log10A_min, log10A_max) (default None = no upper limit)
|
|
392
|
+
n_restarts (int): number of random L-BFGS-B restarts (default 20)
|
|
393
|
+
n_top (int): if > 1, include top-n restart solutions in 'top_list'
|
|
394
|
+
seed (int or None): random seed
|
|
395
|
+
|
|
396
|
+
Returns:
|
|
397
|
+
(dict) with keys:
|
|
398
|
+
'params' : {'T', 'logN', 'A'} — best-fit parameters
|
|
399
|
+
'model_flux' : np.ndarray on obs_wav — model spectrum
|
|
400
|
+
'residual' : np.ndarray on obs_wav — obs_flux minus model_flux
|
|
401
|
+
'top_list' : list of dicts (only present when n_top > 1)
|
|
402
|
+
"""
|
|
403
|
+
obs_wav = np.asarray(obs_wav, dtype=np.float64)
|
|
404
|
+
obs_flux = np.asarray(obs_flux, dtype=np.float64)
|
|
405
|
+
|
|
406
|
+
if T_bounds is None:
|
|
407
|
+
T_bounds = _model_bank_bounds(
|
|
408
|
+
pretrained[mol], 'T_ranges', _DEFAULT_T_BOUNDS
|
|
409
|
+
)
|
|
410
|
+
if logN_bounds is None:
|
|
411
|
+
logN_bounds = _model_bank_bounds(
|
|
412
|
+
pretrained[mol], 'logN_ranges', _DEFAULT_LOGN_BOUNDS
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
if loga_bounds is not None:
|
|
416
|
+
_A_lo = 10.0 ** loga_bounds[0]
|
|
417
|
+
_A_hi = 10.0 ** loga_bounds[1]
|
|
418
|
+
else:
|
|
419
|
+
_A_lo, _A_hi = 0.0, np.inf
|
|
420
|
+
|
|
421
|
+
rng = np.random.default_rng(seed)
|
|
422
|
+
|
|
423
|
+
fit_mask = _wav_mask(obs_wav, fit_ranges)
|
|
424
|
+
wav_fit = obs_wav[fit_mask]
|
|
425
|
+
obs_fit = obs_flux[fit_mask]
|
|
426
|
+
if len(wav_fit) == 0:
|
|
427
|
+
raise ValueError('fit_ranges select zero observed wavelength pixels')
|
|
428
|
+
_check_bank_wavelength_coverage(pretrained[mol], wav_fit)
|
|
429
|
+
|
|
430
|
+
def _nnls_step(x0):
|
|
431
|
+
spec = _mol_flux_on_wav(pretrained[mol], x0[0], x0[1], 1.0, wav_fit)
|
|
432
|
+
# One linear component has an exact bounded least-squares solution.
|
|
433
|
+
# Scaling avoids absolute NNLS tolerances on very faint spectra.
|
|
434
|
+
spec_scale = max(float(np.max(np.abs(spec))), np.finfo(float).tiny)
|
|
435
|
+
s = spec / spec_scale
|
|
436
|
+
denom = float(np.dot(s, s))
|
|
437
|
+
A = float(np.clip(np.dot(s, obs_fit) / denom / spec_scale, _A_lo, _A_hi)) if denom > 0 else _A_lo
|
|
438
|
+
resid = obs_fit - spec * A
|
|
439
|
+
return float(np.sum(resid ** 2)), A
|
|
440
|
+
|
|
441
|
+
bounds = [T_bounds, logN_bounds]
|
|
442
|
+
all_res = []
|
|
443
|
+
for _ in range(n_restarts):
|
|
444
|
+
x0 = np.array([rng.uniform(*T_bounds), rng.uniform(*logN_bounds)])
|
|
445
|
+
opt = _refine_scaled(
|
|
446
|
+
lambda p: _nnls_step(p)[0], x0, bounds, np.dot(obs_fit, obs_fit))
|
|
447
|
+
loss, A = _nnls_step(opt.x)
|
|
448
|
+
all_res.append((loss, opt.x.copy(), A))
|
|
449
|
+
all_res.sort(key=lambda t: t[0])
|
|
450
|
+
|
|
451
|
+
_, best_x, best_A = all_res[0]
|
|
452
|
+
params = {'T': float(best_x[0]), 'logN': float(best_x[1]), 'A': float(best_A)}
|
|
453
|
+
model_flux = _mol_flux_on_wav(pretrained[mol], params['T'], params['logN'],
|
|
454
|
+
params['A'], obs_wav)
|
|
455
|
+
result = {
|
|
456
|
+
'mol': mol,
|
|
457
|
+
'params': params,
|
|
458
|
+
'model_flux': model_flux,
|
|
459
|
+
'residual': obs_flux - model_flux,
|
|
460
|
+
}
|
|
461
|
+
if n_top > 1:
|
|
462
|
+
top_list = []
|
|
463
|
+
for rank, (loss, x, A) in enumerate(all_res[:n_top]):
|
|
464
|
+
top_list.append({
|
|
465
|
+
'rank': rank + 1, 'mse_loss': loss,
|
|
466
|
+
'T': float(x[0]), 'logN': float(x[1]), 'A': float(A),
|
|
467
|
+
})
|
|
468
|
+
result['top_list'] = top_list
|
|
469
|
+
return result
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _as_list(x):
|
|
473
|
+
"""Return x as a list, treating strings as one item."""
|
|
474
|
+
if isinstance(x, str):
|
|
475
|
+
return [x]
|
|
476
|
+
return list(x)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _range_list(ranges):
|
|
480
|
+
"""Normalize a single (lo, hi) range or a list of ranges."""
|
|
481
|
+
if isinstance(ranges[0], (int, float)):
|
|
482
|
+
return [tuple(ranges)]
|
|
483
|
+
return [tuple(r) for r in ranges]
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _union_ranges(ranges):
|
|
487
|
+
"""Deduplicate wavelength ranges while preserving order."""
|
|
488
|
+
out = []
|
|
489
|
+
seen = set()
|
|
490
|
+
for r in ranges:
|
|
491
|
+
key = tuple(r)
|
|
492
|
+
if key not in seen:
|
|
493
|
+
out.append(key)
|
|
494
|
+
seen.add(key)
|
|
495
|
+
return out
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _build_components(molecules, h2o_components=1, component_names=None):
|
|
499
|
+
"""
|
|
500
|
+
Expand molecule names into fitted components.
|
|
501
|
+
|
|
502
|
+
Each component has a unique label and a model_mol key. For two-component
|
|
503
|
+
water, both components use model_mol='H2O' but have labels H2O_1/H2O_2.
|
|
504
|
+
"""
|
|
505
|
+
molecules = _as_list(molecules)
|
|
506
|
+
components = []
|
|
507
|
+
for mol in molecules:
|
|
508
|
+
if mol == 'H2O' and h2o_components == 2:
|
|
509
|
+
components.extend([
|
|
510
|
+
{'label': 'H2O_1', 'model_mol': 'H2O'},
|
|
511
|
+
{'label': 'H2O_2', 'model_mol': 'H2O'},
|
|
512
|
+
])
|
|
513
|
+
else:
|
|
514
|
+
components.append({'label': mol, 'model_mol': mol})
|
|
515
|
+
|
|
516
|
+
if component_names is not None:
|
|
517
|
+
component_names = _as_list(component_names)
|
|
518
|
+
if len(component_names) != len(components):
|
|
519
|
+
raise ValueError('component_names must match the number of fitted components')
|
|
520
|
+
for comp, label in zip(components, component_names):
|
|
521
|
+
comp['label'] = label
|
|
522
|
+
return components
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _fit_ranges_for_components(fit_ranges, components):
|
|
526
|
+
"""
|
|
527
|
+
Build a joint fitting range from either a shared range/list or mol -> ranges.
|
|
528
|
+
"""
|
|
529
|
+
if isinstance(fit_ranges, dict):
|
|
530
|
+
ranges = []
|
|
531
|
+
for comp in components:
|
|
532
|
+
label = comp['label']
|
|
533
|
+
mol = comp['model_mol']
|
|
534
|
+
if label in fit_ranges:
|
|
535
|
+
ranges.extend(_range_list(fit_ranges[label]))
|
|
536
|
+
elif mol in fit_ranges:
|
|
537
|
+
ranges.extend(_range_list(fit_ranges[mol]))
|
|
538
|
+
else:
|
|
539
|
+
raise KeyError(f'fit_ranges has no entry for {label!r} or {mol!r}')
|
|
540
|
+
return _union_ranges(ranges)
|
|
541
|
+
return _range_list(fit_ranges)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _draw_unit_samples(n_samples, n_dim, method='sobol', seed=None):
|
|
545
|
+
"""Draw approximately space-filling samples in [0, 1]^n_dim."""
|
|
546
|
+
method = method.lower()
|
|
547
|
+
if method == 'sobol':
|
|
548
|
+
m = int(np.ceil(np.log2(max(n_samples, 2))))
|
|
549
|
+
sampler = qmc.Sobol(d=n_dim, scramble=True, seed=seed)
|
|
550
|
+
return sampler.random_base2(m)[:n_samples]
|
|
551
|
+
if method in ('latin', 'lhs', 'latin_hypercube'):
|
|
552
|
+
sampler = qmc.LatinHypercube(d=n_dim, seed=seed)
|
|
553
|
+
return sampler.random(n_samples)
|
|
554
|
+
if method == 'random':
|
|
555
|
+
return np.random.default_rng(seed).random((n_samples, n_dim))
|
|
556
|
+
raise ValueError("sample_method must be 'sobol', 'latin_hypercube', or 'random'")
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _scale_unit_samples(unit, bounds):
|
|
560
|
+
"""Scale unit-cube samples to physical bounds."""
|
|
561
|
+
lows = np.array([b[0] for b in bounds], dtype=np.float64)
|
|
562
|
+
highs = np.array([b[1] for b in bounds], dtype=np.float64)
|
|
563
|
+
return lows + unit * (highs - lows)
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _is_ordered_water(params, components):
|
|
567
|
+
"""Require increasing T for duplicated H2O components to reduce label switching."""
|
|
568
|
+
h2o_idx = [i for i, c in enumerate(components) if c['model_mol'] == 'H2O']
|
|
569
|
+
if len(h2o_idx) < 2:
|
|
570
|
+
return True
|
|
571
|
+
temps = [params[2 * i] for i in h2o_idx]
|
|
572
|
+
return all(t1 <= t2 for t1, t2 in zip(temps[:-1], temps[1:]))
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def _distinct_solutions(solutions, n_keep, components, min_distance=0.03):
|
|
576
|
+
"""
|
|
577
|
+
Keep top solutions that are separated in normalized nonlinear parameter space.
|
|
578
|
+
"""
|
|
579
|
+
if n_keep <= 0:
|
|
580
|
+
return []
|
|
581
|
+
kept = []
|
|
582
|
+
for sol in solutions:
|
|
583
|
+
if not kept:
|
|
584
|
+
kept.append(sol)
|
|
585
|
+
continue
|
|
586
|
+
p = sol['x_scaled']
|
|
587
|
+
distances = [np.linalg.norm(p - k['x_scaled']) / np.sqrt(len(p)) for k in kept]
|
|
588
|
+
if min(distances) >= min_distance:
|
|
589
|
+
kept.append(sol)
|
|
590
|
+
if len(kept) >= n_keep:
|
|
591
|
+
break
|
|
592
|
+
return kept
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _weighted_quantile(values, quantiles, weights=None):
|
|
596
|
+
"""Compute weighted quantiles for one-dimensional values."""
|
|
597
|
+
values = np.asarray(values, dtype=np.float64)
|
|
598
|
+
quantiles = np.asarray(quantiles, dtype=np.float64)
|
|
599
|
+
if weights is None:
|
|
600
|
+
return np.quantile(values, quantiles)
|
|
601
|
+
|
|
602
|
+
weights = np.asarray(weights, dtype=np.float64)
|
|
603
|
+
if values.size == 0 or weights.size == 0 or np.sum(weights) <= 0:
|
|
604
|
+
return np.full(len(quantiles), np.nan)
|
|
605
|
+
|
|
606
|
+
order = np.argsort(values)
|
|
607
|
+
values = values[order]
|
|
608
|
+
weights = weights[order]
|
|
609
|
+
cdf = np.cumsum(weights)
|
|
610
|
+
cdf = cdf / cdf[-1]
|
|
611
|
+
return np.interp(quantiles, cdf, values)
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _top_list_uncertainty(top_list, sigma=None):
|
|
615
|
+
"""
|
|
616
|
+
Summarize parameter uncertainty from retained top solutions.
|
|
617
|
+
|
|
618
|
+
If sigma is provided, solutions are weighted as exp(-0.5 * delta_sse/sigma^2).
|
|
619
|
+
Otherwise the 16/50/84 percentiles are unweighted. This is an exploration
|
|
620
|
+
uncertainty from the global search, not a full posterior sample.
|
|
621
|
+
"""
|
|
622
|
+
if not top_list:
|
|
623
|
+
return {}
|
|
624
|
+
|
|
625
|
+
if sigma is not None and sigma > 0:
|
|
626
|
+
losses = np.array([s['mse_loss'] for s in top_list], dtype=np.float64)
|
|
627
|
+
delta = losses - np.nanmin(losses)
|
|
628
|
+
weights = np.exp(-0.5 * delta / sigma ** 2)
|
|
629
|
+
else:
|
|
630
|
+
weights = None
|
|
631
|
+
|
|
632
|
+
labels = list(top_list[0]['params'].keys())
|
|
633
|
+
uncertainty = {}
|
|
634
|
+
for label in labels:
|
|
635
|
+
uncertainty[label] = {}
|
|
636
|
+
for key in ('T', 'logN', 'log10A'):
|
|
637
|
+
vals = [s['params'][label][key] for s in top_list if label in s['params']]
|
|
638
|
+
q16, q50, q84 = _weighted_quantile(vals, [0.16, 0.50, 0.84], weights)
|
|
639
|
+
uncertainty[label][key] = {
|
|
640
|
+
'q16': float(q16),
|
|
641
|
+
'q50': float(q50),
|
|
642
|
+
'q84': float(q84),
|
|
643
|
+
'minus': float(q50 - q16),
|
|
644
|
+
'plus': float(q84 - q50),
|
|
645
|
+
}
|
|
646
|
+
return uncertainty
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def fit_molecules(
|
|
650
|
+
obs_wav,
|
|
651
|
+
obs_flux,
|
|
652
|
+
mol,
|
|
653
|
+
pretrained,
|
|
654
|
+
fit_ranges,
|
|
655
|
+
h2o_components=1,
|
|
656
|
+
component_names=None,
|
|
657
|
+
T_bounds=None,
|
|
658
|
+
logN_bounds=None,
|
|
659
|
+
loga_bounds=None,
|
|
660
|
+
n_samples=20000,
|
|
661
|
+
sample_method='sobol',
|
|
662
|
+
n_refine=32,
|
|
663
|
+
n_top=10,
|
|
664
|
+
distinct_min_distance=0.03,
|
|
665
|
+
sigma=None,
|
|
666
|
+
seed=None,
|
|
667
|
+
verbose=True,
|
|
668
|
+
T_prior=None,
|
|
669
|
+
T_prior_log10=False,
|
|
670
|
+
shared_area_groups=None,
|
|
671
|
+
isotopologue_groups=None,
|
|
672
|
+
Tshare=None,
|
|
673
|
+
Ashare=None,
|
|
674
|
+
):
|
|
675
|
+
"""
|
|
676
|
+
Fit one or more selected molecules with global screening + local optimisation.
|
|
677
|
+
|
|
678
|
+
Nonlinear parameters are (T, logN) for every fitted component. Linear
|
|
679
|
+
amplitudes A are solved together at every trial. This is usually better
|
|
680
|
+
than pure random L-BFGS-B restarts because the initial candidates cover the
|
|
681
|
+
allowed parameter volume more evenly.
|
|
682
|
+
|
|
683
|
+
Args:
|
|
684
|
+
obs_wav (np.ndarray): observed wavelength axis in µm.
|
|
685
|
+
obs_flux (np.ndarray): observed flux in Jy.
|
|
686
|
+
mol (str or list[str]): molecule(s) to fit, e.g. 'H2O' or
|
|
687
|
+
['C2H2', 'HCN', 'CO2'].
|
|
688
|
+
pretrained (dict): mol -> pretrained surrogate dict from load_models.
|
|
689
|
+
fit_ranges: shared (lo, hi), shared list of ranges, or dict mapping
|
|
690
|
+
molecule/component labels to ranges. Dict ranges are unioned for a
|
|
691
|
+
joint fit.
|
|
692
|
+
h2o_components (int): 1 or 2. If 2 and molecules contains 'H2O', two
|
|
693
|
+
H2O components are fitted using the same pretrained['H2O'] model.
|
|
694
|
+
component_names (list[str] or None): optional custom labels for fitted
|
|
695
|
+
components. Useful for names like ['H2O_warm', 'H2O_hot'].
|
|
696
|
+
T_bounds (tuple or dict or None): default (200, 1400). A dict can use
|
|
697
|
+
molecule names or component labels.
|
|
698
|
+
logN_bounds (tuple or dict or None): default (14, 19). A dict can use
|
|
699
|
+
molecule names or component labels.
|
|
700
|
+
loga_bounds (tuple or None): bounds on log10(A). None means A >= 0
|
|
701
|
+
with no upper bound; otherwise bounded least squares is used.
|
|
702
|
+
shared_area_groups (list of sequences or None): component labels whose
|
|
703
|
+
emitting areas must be equal, e.g. [('C2H2', '13C12CH2')]. Each
|
|
704
|
+
component may appear in at most one group. Unlisted components
|
|
705
|
+
retain independent areas.
|
|
706
|
+
isotopologue_groups (list of sequences or None): each group names its
|
|
707
|
+
main species first, followed by one or more rare isotopologues.
|
|
708
|
+
Tshare (list or None): one mode per isotopologue group: 'free', 'equal',
|
|
709
|
+
or 'less'. 'less' requires every rare T <= the main T.
|
|
710
|
+
Ashare (list of bool or None): one flag per isotopologue group. True
|
|
711
|
+
fits one common emitting area; False keeps independent areas.
|
|
712
|
+
n_samples (int): number of global screening samples.
|
|
713
|
+
sample_method (str): 'sobol', 'latin_hypercube', or 'random'.
|
|
714
|
+
n_refine (int): number of distinct global candidates to refine locally.
|
|
715
|
+
n_top (int): number of ranked refined solutions to return.
|
|
716
|
+
distinct_min_distance (float): normalized distance used to avoid
|
|
717
|
+
refining many nearly identical global candidates.
|
|
718
|
+
sigma (float or None): optional noise std in Jy. If provided,
|
|
719
|
+
top-solution uncertainties are likelihood-weighted by SSE. If
|
|
720
|
+
None, unweighted top-solution percentiles are returned.
|
|
721
|
+
seed (int or None): random seed.
|
|
722
|
+
verbose (bool): print progress and best losses.
|
|
723
|
+
T_prior (dict or None): Gaussian prior on temperature for each
|
|
724
|
+
component, keyed by component label. Each value is a tuple
|
|
725
|
+
(T_center, T_std) in Kelvin. Example::
|
|
726
|
+
|
|
727
|
+
T_prior = {
|
|
728
|
+
'H2O_warm': (400.0, 100.0),
|
|
729
|
+
'H2O_hot': (800.0, 100.0),
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
The prior is added to the SSE as a penalty term scaled by
|
|
733
|
+
``sigma**2``, so both data and prior are in the same Jy² units:
|
|
734
|
+
|
|
735
|
+
loss = SSE + sigma² × Σ ((T_i − T0_i) / σ_Ti)²
|
|
736
|
+
|
|
737
|
+
``sigma`` must be provided when ``T_prior`` is set; if it is None
|
|
738
|
+
the prior term is skipped with a warning.
|
|
739
|
+
T_prior_log10 (bool): if True, the prior is applied in log10(T) space
|
|
740
|
+
and the values in ``T_prior`` are interpreted as (log10_T_center,
|
|
741
|
+
log10_T_std). Example::
|
|
742
|
+
|
|
743
|
+
T_prior = {
|
|
744
|
+
'H2O_warm': (2.6, 0.2), # log10(T_warm) ~ N(2.6, 0.2)
|
|
745
|
+
'H2O_hot': (2.9, 0.1), # log10(T_hot) ~ N(2.9, 0.1)
|
|
746
|
+
}
|
|
747
|
+
T_prior_log10 = True
|
|
748
|
+
|
|
749
|
+
Returns:
|
|
750
|
+
dict with keys:
|
|
751
|
+
'components' : list of component metadata.
|
|
752
|
+
'params' : label -> {'T', 'logN', 'A', 'log10A', 'model_mol'}.
|
|
753
|
+
'model_flux' : total model flux on obs_wav.
|
|
754
|
+
'component_fluxes' : label -> model flux on obs_wav.
|
|
755
|
+
'residual' : obs_flux - model_flux.
|
|
756
|
+
'fit_ranges' : actual joint ranges used.
|
|
757
|
+
'shared_area_groups': all applied groups of tied component labels.
|
|
758
|
+
'isotopologue_groups', 'Tshare', 'Ashare': group constraints used.
|
|
759
|
+
'top_list' : ranked refined solutions.
|
|
760
|
+
'global_top' : best unrefined global candidates.
|
|
761
|
+
'uncertainty' : label -> parameter percentile summary.
|
|
762
|
+
"""
|
|
763
|
+
if h2o_components not in (1, 2):
|
|
764
|
+
raise ValueError('h2o_components must be 1 or 2')
|
|
765
|
+
|
|
766
|
+
obs_wav = np.asarray(obs_wav, dtype=np.float64)
|
|
767
|
+
obs_flux = np.asarray(obs_flux, dtype=np.float64)
|
|
768
|
+
|
|
769
|
+
if loga_bounds is None:
|
|
770
|
+
loga_bounds = _DEFAULT_LOGA_BOUNDS
|
|
771
|
+
|
|
772
|
+
components = _build_components(mol, h2o_components, component_names)
|
|
773
|
+
for comp in components:
|
|
774
|
+
if comp['model_mol'] not in pretrained:
|
|
775
|
+
raise KeyError(f"pretrained has no model for {comp['model_mol']!r}")
|
|
776
|
+
|
|
777
|
+
labels = [comp['label'] for comp in components]
|
|
778
|
+
if isotopologue_groups is None:
|
|
779
|
+
if Tshare is not None or Ashare is not None:
|
|
780
|
+
raise ValueError('Tshare and Ashare require isotopologue_groups')
|
|
781
|
+
iso_groups = []
|
|
782
|
+
else:
|
|
783
|
+
iso_groups = [tuple(group) for group in isotopologue_groups]
|
|
784
|
+
if any(len(group) < 2 or len(set(group)) != len(group) for group in iso_groups):
|
|
785
|
+
raise ValueError('each isotopologue group needs a main and distinct rare labels')
|
|
786
|
+
unknown = set().union(*map(set, iso_groups)) - set(labels) if iso_groups else set()
|
|
787
|
+
if unknown:
|
|
788
|
+
raise ValueError(f'isotopologue_groups contains unknown component labels: {sorted(unknown)}')
|
|
789
|
+
members = [label for group in iso_groups for label in group]
|
|
790
|
+
if len(set(members)) != len(members):
|
|
791
|
+
raise ValueError('isotopologue_groups cannot overlap')
|
|
792
|
+
modes = ['free'] * len(iso_groups) if Tshare is None else list(Tshare)
|
|
793
|
+
area_flags = [False] * len(iso_groups) if Ashare is None else list(Ashare)
|
|
794
|
+
if len(modes) != len(iso_groups) or any(mode not in ('free', 'equal', 'less') for mode in modes):
|
|
795
|
+
raise ValueError('Tshare needs one free, equal, or less mode per isotopologue group')
|
|
796
|
+
if len(area_flags) != len(iso_groups) or any(not isinstance(flag, (bool, np.bool_))
|
|
797
|
+
for flag in area_flags):
|
|
798
|
+
raise ValueError('Ashare needs one Boolean per isotopologue group')
|
|
799
|
+
|
|
800
|
+
all_area_groups = [tuple(group) for group in shared_area_groups or ()]
|
|
801
|
+
all_area_groups.extend(group for group, share in zip(iso_groups, area_flags) if share)
|
|
802
|
+
area_group_for = list(range(len(components)))
|
|
803
|
+
used_labels = set()
|
|
804
|
+
for group in all_area_groups:
|
|
805
|
+
group = tuple(group)
|
|
806
|
+
if len(group) < 2 or len(set(group)) != len(group):
|
|
807
|
+
raise ValueError('each shared_area_groups entry needs at least two distinct labels')
|
|
808
|
+
unknown = set(group) - set(labels)
|
|
809
|
+
if unknown:
|
|
810
|
+
raise ValueError(f'shared_area_groups contains unknown component labels: {sorted(unknown)}')
|
|
811
|
+
overlap = used_labels.intersection(group)
|
|
812
|
+
if overlap:
|
|
813
|
+
raise ValueError(f'area-sharing groups overlap at component labels: {sorted(overlap)}')
|
|
814
|
+
used_labels.update(group)
|
|
815
|
+
anchor = labels.index(group[0])
|
|
816
|
+
for label in group:
|
|
817
|
+
area_group_for[labels.index(label)] = anchor
|
|
818
|
+
area_group_ids = list(dict.fromkeys(area_group_for))
|
|
819
|
+
area_group_members = [[i for i, group_id in enumerate(area_group_for)
|
|
820
|
+
if group_id == anchor] for anchor in area_group_ids]
|
|
821
|
+
|
|
822
|
+
joint_ranges = _fit_ranges_for_components(fit_ranges, components)
|
|
823
|
+
fit_mask = _wav_mask(obs_wav, joint_ranges)
|
|
824
|
+
wav_fit = obs_wav[fit_mask]
|
|
825
|
+
obs_fit = obs_flux[fit_mask]
|
|
826
|
+
if len(wav_fit) == 0:
|
|
827
|
+
raise ValueError('fit_ranges select zero observed wavelength pixels')
|
|
828
|
+
|
|
829
|
+
for comp in components:
|
|
830
|
+
_check_bank_wavelength_coverage(pretrained[comp['model_mol']], wav_fit)
|
|
831
|
+
|
|
832
|
+
def _bounds_for(comp, bound_spec, default):
|
|
833
|
+
if bound_spec is None:
|
|
834
|
+
return default
|
|
835
|
+
if isinstance(bound_spec, dict):
|
|
836
|
+
if comp['label'] in bound_spec:
|
|
837
|
+
return tuple(bound_spec[comp['label']])
|
|
838
|
+
if comp['model_mol'] in bound_spec:
|
|
839
|
+
return tuple(bound_spec[comp['model_mol']])
|
|
840
|
+
return default
|
|
841
|
+
return tuple(bound_spec)
|
|
842
|
+
|
|
843
|
+
component_bounds = []
|
|
844
|
+
for comp in components:
|
|
845
|
+
model = pretrained[comp['model_mol']]
|
|
846
|
+
default_T = _model_bank_bounds(model, 'T_ranges', _DEFAULT_T_BOUNDS)
|
|
847
|
+
default_logN = _model_bank_bounds(model, 'logN_ranges', _DEFAULT_LOGN_BOUNDS)
|
|
848
|
+
bounds = (_bounds_for(comp, T_bounds, default_T),
|
|
849
|
+
_bounds_for(comp, logN_bounds, default_logN))
|
|
850
|
+
if any(len(pair) != 2 or not np.all(np.isfinite(pair)) or pair[0] >= pair[1]
|
|
851
|
+
for pair in bounds):
|
|
852
|
+
raise ValueError(f'{comp["label"]} needs finite increasing T and logN bounds')
|
|
853
|
+
component_bounds.append(bounds)
|
|
854
|
+
|
|
855
|
+
# Optimize only independent temperatures. An equal group has one T
|
|
856
|
+
# coordinate. A less group has its main T plus one [0, 1] coordinate per
|
|
857
|
+
# rare species, mapped into [rare_min, min(rare_max, main_T)]. Thus neither
|
|
858
|
+
# global screening nor local refinement can violate the requested relation.
|
|
859
|
+
group_for = {}
|
|
860
|
+
for group, mode in zip(iso_groups, modes):
|
|
861
|
+
if mode != 'free':
|
|
862
|
+
for label in group:
|
|
863
|
+
group_for[labels.index(label)] = (labels.index(group[0]), mode)
|
|
864
|
+
main_T_bounds = {}
|
|
865
|
+
for group, mode in zip(iso_groups, modes):
|
|
866
|
+
if mode == 'free':
|
|
867
|
+
continue
|
|
868
|
+
indices = [labels.index(label) for label in group]
|
|
869
|
+
if mode == 'equal':
|
|
870
|
+
lo = max(component_bounds[i][0][0] for i in indices)
|
|
871
|
+
hi = min(component_bounds[i][0][1] for i in indices)
|
|
872
|
+
else:
|
|
873
|
+
main = indices[0]
|
|
874
|
+
lo = max(component_bounds[main][0][0],
|
|
875
|
+
*(component_bounds[i][0][0] for i in indices[1:]))
|
|
876
|
+
hi = component_bounds[main][0][1]
|
|
877
|
+
if not lo < hi:
|
|
878
|
+
raise ValueError(f'Tshare={mode!r} has no feasible temperature range for {group}')
|
|
879
|
+
main_T_bounds[indices[0]] = (lo, hi)
|
|
880
|
+
|
|
881
|
+
nonlinear_bounds = []
|
|
882
|
+
temperature_indices = {}
|
|
883
|
+
column_indices = {}
|
|
884
|
+
for i, comp in enumerate(components):
|
|
885
|
+
relation = group_for.get(i)
|
|
886
|
+
if relation is None or relation[0] == i:
|
|
887
|
+
temperature_indices[i] = len(nonlinear_bounds)
|
|
888
|
+
nonlinear_bounds.append(main_T_bounds.get(i, component_bounds[i][0]))
|
|
889
|
+
elif relation[1] == 'less':
|
|
890
|
+
temperature_indices[i] = len(nonlinear_bounds)
|
|
891
|
+
nonlinear_bounds.append((0.0, 1.0))
|
|
892
|
+
column_indices[i] = len(nonlinear_bounds)
|
|
893
|
+
nonlinear_bounds.append(component_bounds[i][1])
|
|
894
|
+
|
|
895
|
+
def _physical_params(x):
|
|
896
|
+
"""Expand optimization coordinates to per-component (T, logN)."""
|
|
897
|
+
physical = np.empty(2 * len(components), dtype=np.float64)
|
|
898
|
+
for i in range(len(components)):
|
|
899
|
+
physical[2 * i + 1] = x[column_indices[i]]
|
|
900
|
+
relation = group_for.get(i)
|
|
901
|
+
if relation is None or relation[0] == i:
|
|
902
|
+
physical[2 * i] = x[temperature_indices[i]]
|
|
903
|
+
for i, (main, mode) in group_for.items():
|
|
904
|
+
if i == main:
|
|
905
|
+
continue
|
|
906
|
+
if mode == 'equal':
|
|
907
|
+
physical[2 * i] = physical[2 * main]
|
|
908
|
+
else:
|
|
909
|
+
lo, hi = component_bounds[i][0]
|
|
910
|
+
available_hi = min(hi, physical[2 * main])
|
|
911
|
+
physical[2 * i] = min(
|
|
912
|
+
physical[2 * main],
|
|
913
|
+
lo + x[temperature_indices[i]] * (available_hi - lo),
|
|
914
|
+
)
|
|
915
|
+
return physical
|
|
916
|
+
|
|
917
|
+
if loga_bounds is None:
|
|
918
|
+
A_bounds = (0.0, np.inf)
|
|
919
|
+
else:
|
|
920
|
+
A_bounds = (10.0 ** float(loga_bounds[0]), 10.0 ** float(loga_bounds[1]))
|
|
921
|
+
|
|
922
|
+
def _matrix_for(params):
|
|
923
|
+
cols = []
|
|
924
|
+
for i, comp in enumerate(components):
|
|
925
|
+
T = params[2 * i]
|
|
926
|
+
logN = params[2 * i + 1]
|
|
927
|
+
cols.append(_mol_flux_on_wav(pretrained[comp['model_mol']], T, logN, 1.0, wav_fit))
|
|
928
|
+
return np.column_stack(cols)
|
|
929
|
+
|
|
930
|
+
def _solve_amplitudes(params):
|
|
931
|
+
physical = _physical_params(params)
|
|
932
|
+
if not _is_ordered_water(physical, components):
|
|
933
|
+
return np.inf, np.zeros(len(components)), None
|
|
934
|
+
M = _matrix_for(physical)
|
|
935
|
+
M_group = np.column_stack([M[:, members].sum(axis=1)
|
|
936
|
+
for members in area_group_members])
|
|
937
|
+
# A shared flux scale preserves the physical amplitude bounds while
|
|
938
|
+
# making solver tolerances independent of the spectrum's brightness.
|
|
939
|
+
flux_scale = max(float(np.max(np.abs(obs_fit))),
|
|
940
|
+
float(np.max(np.abs(M_group))), np.finfo(float).tiny)
|
|
941
|
+
M_scaled, obs_scaled = M_group / flux_scale, obs_fit / flux_scale
|
|
942
|
+
if np.isfinite(A_bounds[1]):
|
|
943
|
+
res = scipy.optimize.lsq_linear(
|
|
944
|
+
M_scaled,
|
|
945
|
+
obs_scaled,
|
|
946
|
+
bounds=(np.full(len(area_group_members), A_bounds[0]),
|
|
947
|
+
np.full(len(area_group_members), A_bounds[1])),
|
|
948
|
+
)
|
|
949
|
+
A_group = np.asarray(res.x, dtype=np.float64)
|
|
950
|
+
else:
|
|
951
|
+
A_group, _ = scipy.optimize.nnls(M_scaled, obs_scaled)
|
|
952
|
+
A = np.empty(len(components), dtype=np.float64)
|
|
953
|
+
for members, value in zip(area_group_members, A_group):
|
|
954
|
+
A[members] = value
|
|
955
|
+
residual = obs_fit - M @ A
|
|
956
|
+
loss = float(np.sum(residual ** 2))
|
|
957
|
+
return loss, A, M
|
|
958
|
+
|
|
959
|
+
if T_prior is not None and (sigma is None or sigma <= 0):
|
|
960
|
+
print(' Warning: T_prior supplied but sigma is None or ≤ 0 — prior skipped.')
|
|
961
|
+
_prior_scale = (sigma ** 2
|
|
962
|
+
if (T_prior is not None and sigma is not None and sigma > 0)
|
|
963
|
+
else 0.0)
|
|
964
|
+
|
|
965
|
+
def _prior_penalty(x):
|
|
966
|
+
"""Sum of (T_i − T0_i)² / σ_Ti² for components with a T prior.
|
|
967
|
+
|
|
968
|
+
When T_prior_log10 is True the penalty is evaluated in log10(T) space
|
|
969
|
+
and T_prior values are (log10_T_center, log10_T_std).
|
|
970
|
+
"""
|
|
971
|
+
if T_prior is None or _prior_scale == 0.0:
|
|
972
|
+
return 0.0
|
|
973
|
+
penalty = 0.0
|
|
974
|
+
physical = _physical_params(x)
|
|
975
|
+
for i, comp in enumerate(components):
|
|
976
|
+
label = comp['label']
|
|
977
|
+
if label in T_prior:
|
|
978
|
+
center, std = T_prior[label]
|
|
979
|
+
T_val = physical[2 * i]
|
|
980
|
+
t = np.log10(max(T_val, 1e-10)) if T_prior_log10 else T_val
|
|
981
|
+
penalty += ((t - center) / std) ** 2
|
|
982
|
+
return penalty
|
|
983
|
+
|
|
984
|
+
def _penalized_loss(x):
|
|
985
|
+
"""SSE + Gaussian prior penalty, both in Jy²."""
|
|
986
|
+
sse, A, M = _solve_amplitudes(x)
|
|
987
|
+
return sse + _prior_scale * _prior_penalty(x), A, M
|
|
988
|
+
|
|
989
|
+
def _objective(x):
|
|
990
|
+
loss, _, _ = _penalized_loss(x)
|
|
991
|
+
return loss
|
|
992
|
+
|
|
993
|
+
n_dim = len(nonlinear_bounds)
|
|
994
|
+
unit = _draw_unit_samples(n_samples, n_dim, method=sample_method, seed=seed)
|
|
995
|
+
samples = _scale_unit_samples(unit, nonlinear_bounds)
|
|
996
|
+
|
|
997
|
+
if verbose:
|
|
998
|
+
labels = ', '.join(c['label'] for c in components)
|
|
999
|
+
print(f'Global nested fit: {labels}')
|
|
1000
|
+
print(f' components={len(components)} nonlinear_dim={n_dim} samples={n_samples}')
|
|
1001
|
+
print(f' fit pixels={len(wav_fit)} ranges={joint_ranges}')
|
|
1002
|
+
|
|
1003
|
+
global_results = []
|
|
1004
|
+
for i, x in enumerate(samples):
|
|
1005
|
+
loss, A, _ = _penalized_loss(x)
|
|
1006
|
+
global_results.append({
|
|
1007
|
+
'loss': loss,
|
|
1008
|
+
'x': x.copy(),
|
|
1009
|
+
'x_scaled': unit[i].copy(),
|
|
1010
|
+
'A': A.copy(),
|
|
1011
|
+
})
|
|
1012
|
+
|
|
1013
|
+
global_results.sort(key=lambda r: r['loss'])
|
|
1014
|
+
global_candidates = _distinct_solutions(
|
|
1015
|
+
global_results,
|
|
1016
|
+
n_keep=max(n_refine, n_top),
|
|
1017
|
+
components=components,
|
|
1018
|
+
min_distance=distinct_min_distance,
|
|
1019
|
+
)
|
|
1020
|
+
refine_starts = global_candidates[:n_refine]
|
|
1021
|
+
|
|
1022
|
+
refined = []
|
|
1023
|
+
for cand in refine_starts:
|
|
1024
|
+
opt = _refine_scaled(_objective, cand['x'], nonlinear_bounds,
|
|
1025
|
+
np.dot(obs_fit, obs_fit))
|
|
1026
|
+
loss, A, _ = _penalized_loss(opt.x)
|
|
1027
|
+
scaled = np.array([
|
|
1028
|
+
(opt.x[j] - nonlinear_bounds[j][0]) /
|
|
1029
|
+
(nonlinear_bounds[j][1] - nonlinear_bounds[j][0])
|
|
1030
|
+
for j in range(n_dim)
|
|
1031
|
+
])
|
|
1032
|
+
refined.append({
|
|
1033
|
+
'loss': loss,
|
|
1034
|
+
'x': opt.x.copy(),
|
|
1035
|
+
'x_scaled': scaled,
|
|
1036
|
+
'A': A.copy(),
|
|
1037
|
+
'success': bool(opt.success),
|
|
1038
|
+
'message': str(opt.message),
|
|
1039
|
+
'start_loss': cand['loss'],
|
|
1040
|
+
})
|
|
1041
|
+
|
|
1042
|
+
refined.sort(key=lambda r: r['loss'])
|
|
1043
|
+
top_refined = _distinct_solutions(
|
|
1044
|
+
refined,
|
|
1045
|
+
n_keep=n_top,
|
|
1046
|
+
components=components,
|
|
1047
|
+
min_distance=distinct_min_distance,
|
|
1048
|
+
)
|
|
1049
|
+
if not top_refined:
|
|
1050
|
+
raise RuntimeError('no valid fitted solution found')
|
|
1051
|
+
|
|
1052
|
+
best = top_refined[0]
|
|
1053
|
+
|
|
1054
|
+
def _decode_solution(sol, rank=None):
|
|
1055
|
+
params = {}
|
|
1056
|
+
physical = _physical_params(sol['x'])
|
|
1057
|
+
for i, comp in enumerate(components):
|
|
1058
|
+
A = float(sol['A'][i])
|
|
1059
|
+
params[comp['label']] = {
|
|
1060
|
+
'T': float(physical[2 * i]),
|
|
1061
|
+
'logN': float(physical[2 * i + 1]),
|
|
1062
|
+
'A': A,
|
|
1063
|
+
'log10A': float(np.log10(max(A, 1e-300))),
|
|
1064
|
+
'model_mol': comp['model_mol'],
|
|
1065
|
+
}
|
|
1066
|
+
out = {
|
|
1067
|
+
'mse_loss': float(sol['loss']),
|
|
1068
|
+
'params': params,
|
|
1069
|
+
'success': sol.get('success', None),
|
|
1070
|
+
'message': sol.get('message', ''),
|
|
1071
|
+
'start_loss': float(sol.get('start_loss', np.nan)),
|
|
1072
|
+
}
|
|
1073
|
+
if rank is not None:
|
|
1074
|
+
out['rank'] = rank
|
|
1075
|
+
return out
|
|
1076
|
+
|
|
1077
|
+
best_params = _decode_solution(best)['params']
|
|
1078
|
+
component_fluxes = {}
|
|
1079
|
+
total_flux = np.zeros_like(obs_wav, dtype=np.float64)
|
|
1080
|
+
for label, p in best_params.items():
|
|
1081
|
+
flux = _mol_flux_on_wav(
|
|
1082
|
+
pretrained[p['model_mol']],
|
|
1083
|
+
p['T'],
|
|
1084
|
+
p['logN'],
|
|
1085
|
+
p['A'],
|
|
1086
|
+
obs_wav,
|
|
1087
|
+
)
|
|
1088
|
+
component_fluxes[label] = flux
|
|
1089
|
+
total_flux += flux
|
|
1090
|
+
|
|
1091
|
+
top_list = [_decode_solution(sol, rank=i + 1) for i, sol in enumerate(top_refined)]
|
|
1092
|
+
global_top = [_decode_solution(sol, rank=i + 1)
|
|
1093
|
+
for i, sol in enumerate(global_candidates[:n_top])]
|
|
1094
|
+
uncertainty = _top_list_uncertainty(top_list, sigma=sigma)
|
|
1095
|
+
|
|
1096
|
+
if verbose:
|
|
1097
|
+
print(f' best SSE={best["loss"]:.6g}')
|
|
1098
|
+
for label, p in best_params.items():
|
|
1099
|
+
print(f' {label:8s} T={p["T"]:.1f} logN={p["logN"]:.3f} '
|
|
1100
|
+
f'log10A={p["log10A"]:.3f}')
|
|
1101
|
+
|
|
1102
|
+
return {
|
|
1103
|
+
'mol': mol,
|
|
1104
|
+
'components': components,
|
|
1105
|
+
'params': best_params,
|
|
1106
|
+
'model_flux': total_flux,
|
|
1107
|
+
'component_fluxes': component_fluxes,
|
|
1108
|
+
'residual': obs_flux - total_flux,
|
|
1109
|
+
'fit_ranges': joint_ranges,
|
|
1110
|
+
'shared_area_groups': all_area_groups,
|
|
1111
|
+
'isotopologue_groups': iso_groups,
|
|
1112
|
+
'Tshare': modes,
|
|
1113
|
+
'Ashare': area_flags,
|
|
1114
|
+
'top_list': top_list,
|
|
1115
|
+
'global_top': global_top,
|
|
1116
|
+
'uncertainty': uncertainty,
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
def load_observed_spectrum(path, skiprows=1, delimiter=',', wav_col=0, flux_col=1):
|
|
1121
|
+
"""
|
|
1122
|
+
Load an observed spectrum and remove NaN/blank flux pixels.
|
|
1123
|
+
|
|
1124
|
+
Args:
|
|
1125
|
+
path (str): input CSV or text file.
|
|
1126
|
+
skiprows (int): number of header rows to skip.
|
|
1127
|
+
delimiter (str): file delimiter.
|
|
1128
|
+
wav_col (int): wavelength column index.
|
|
1129
|
+
flux_col (int): flux column index.
|
|
1130
|
+
|
|
1131
|
+
Returns:
|
|
1132
|
+
tuple[np.ndarray, np.ndarray]: wavelength and flux arrays.
|
|
1133
|
+
"""
|
|
1134
|
+
data = np.loadtxt(path, skiprows=skiprows, delimiter=delimiter, dtype='str')
|
|
1135
|
+
wav = np.array([np.float64(row[wav_col]) for row in data])
|
|
1136
|
+
flux = np.array([
|
|
1137
|
+
np.float64(row[flux_col]) if row[flux_col] != '' else np.nan
|
|
1138
|
+
for row in data
|
|
1139
|
+
])
|
|
1140
|
+
valid = np.isfinite(wav) & np.isfinite(flux)
|
|
1141
|
+
return wav[valid], flux[valid]
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
def save_fit_outputs(result, output_dir, output_prefix):
|
|
1145
|
+
"""
|
|
1146
|
+
Save fitted spectrum, best parameters, and top solutions to CSV files.
|
|
1147
|
+
|
|
1148
|
+
Args:
|
|
1149
|
+
result (dict): result returned by fit_molecules, with obs_wav/obs_flux
|
|
1150
|
+
added by the caller.
|
|
1151
|
+
output_dir (str): directory for output CSV files.
|
|
1152
|
+
output_prefix (str): filename prefix.
|
|
1153
|
+
|
|
1154
|
+
Returns:
|
|
1155
|
+
dict: output kind -> saved path.
|
|
1156
|
+
"""
|
|
1157
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
1158
|
+
|
|
1159
|
+
spectrum_df = pd.DataFrame({
|
|
1160
|
+
'wave': result['obs_wav'],
|
|
1161
|
+
'flux': result['obs_flux'],
|
|
1162
|
+
'model': result['model_flux'],
|
|
1163
|
+
'residual': result['residual'],
|
|
1164
|
+
})
|
|
1165
|
+
for label, flux in result['component_fluxes'].items():
|
|
1166
|
+
spectrum_df[f'{label}_model'] = flux
|
|
1167
|
+
spectrum_path = os.path.join(output_dir, f'{output_prefix}_fit_spectrum.csv')
|
|
1168
|
+
spectrum_df.to_csv(spectrum_path, index=False)
|
|
1169
|
+
|
|
1170
|
+
param_rows = []
|
|
1171
|
+
for label, p in result['params'].items():
|
|
1172
|
+
u = result.get('uncertainty', {}).get(label, {})
|
|
1173
|
+
u_T = u.get('T', {})
|
|
1174
|
+
u_logN = u.get('logN', {})
|
|
1175
|
+
u_log10A = u.get('log10A', {})
|
|
1176
|
+
param_rows.append({
|
|
1177
|
+
'component': label,
|
|
1178
|
+
'model_mol': p['model_mol'],
|
|
1179
|
+
'T': p['T'],
|
|
1180
|
+
'T_minus': u_T.get('minus', np.nan),
|
|
1181
|
+
'T_plus': u_T.get('plus', np.nan),
|
|
1182
|
+
'logN': p['logN'],
|
|
1183
|
+
'logN_minus': u_logN.get('minus', np.nan),
|
|
1184
|
+
'logN_plus': u_logN.get('plus', np.nan),
|
|
1185
|
+
'A': p['A'],
|
|
1186
|
+
'log10A': p['log10A'],
|
|
1187
|
+
'log10A_minus': u_log10A.get('minus', np.nan),
|
|
1188
|
+
'log10A_plus': u_log10A.get('plus', np.nan),
|
|
1189
|
+
})
|
|
1190
|
+
params_path = os.path.join(output_dir, f'{output_prefix}_fit_params.csv')
|
|
1191
|
+
pd.DataFrame(param_rows).to_csv(params_path, index=False)
|
|
1192
|
+
|
|
1193
|
+
top_rows = []
|
|
1194
|
+
for sol in result['top_list']:
|
|
1195
|
+
row = {'rank': sol['rank'], 'mse_loss': sol['mse_loss']}
|
|
1196
|
+
for label, p in sol['params'].items():
|
|
1197
|
+
row[f'{label}_T'] = p['T']
|
|
1198
|
+
row[f'{label}_logN'] = p['logN']
|
|
1199
|
+
row[f'{label}_log10A'] = p['log10A']
|
|
1200
|
+
top_rows.append(row)
|
|
1201
|
+
top_path = os.path.join(output_dir, f'{output_prefix}_fit_top.csv')
|
|
1202
|
+
pd.DataFrame(top_rows).to_csv(top_path, index=False)
|
|
1203
|
+
|
|
1204
|
+
return {
|
|
1205
|
+
'spectrum': spectrum_path,
|
|
1206
|
+
'params': params_path,
|
|
1207
|
+
'top': top_path,
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
|
|
1211
|
+
def detect_stage_molecules(obs_wav, obs_flux, stage, sigma_noise,
|
|
1212
|
+
screening=True, sigma_factor=3.0):
|
|
1213
|
+
"""
|
|
1214
|
+
Return the subset of molecules in stage['mol'] that pass a peak detection test.
|
|
1215
|
+
|
|
1216
|
+
For single-molecule stages (detect_peaks is a list of (lo, hi) ranges): the
|
|
1217
|
+
stage passes if the maximum flux in any listed window exceeds the threshold.
|
|
1218
|
+
For multi-molecule stages (detect_peaks is a dict {mol: (lo, hi)}): each
|
|
1219
|
+
molecule is checked independently; only detected ones are returned.
|
|
1220
|
+
If screening is False, or detect_peaks is absent/None, all molecules are
|
|
1221
|
+
returned without checking.
|
|
1222
|
+
|
|
1223
|
+
Args:
|
|
1224
|
+
obs_wav (np.ndarray): wavelength axis in µm (typically the running residual).
|
|
1225
|
+
obs_flux (np.ndarray): flux to check for detections.
|
|
1226
|
+
stage (dict): stage config dict with keys 'mol' and optionally 'detect_peaks'.
|
|
1227
|
+
sigma_noise (float or None): per-pixel noise std in Jy; if None, screening
|
|
1228
|
+
is skipped regardless of the screening flag.
|
|
1229
|
+
screening (bool): master toggle; pass DETECTION_SCREENING from the script.
|
|
1230
|
+
sigma_factor (float): detection threshold = sigma_factor × sigma_noise.
|
|
1231
|
+
|
|
1232
|
+
Returns:
|
|
1233
|
+
list[str]: molecule names that passed detection (subset of stage['mol']).
|
|
1234
|
+
"""
|
|
1235
|
+
mols = _as_list(stage['mol'])
|
|
1236
|
+
detect_peaks = stage.get('detect_peaks')
|
|
1237
|
+
|
|
1238
|
+
if not screening or detect_peaks is None or sigma_noise is None:
|
|
1239
|
+
return mols
|
|
1240
|
+
|
|
1241
|
+
thr = sigma_factor * sigma_noise
|
|
1242
|
+
|
|
1243
|
+
if isinstance(detect_peaks, dict):
|
|
1244
|
+
detected = []
|
|
1245
|
+
for mol in mols:
|
|
1246
|
+
if mol not in detect_peaks:
|
|
1247
|
+
detected.append(mol)
|
|
1248
|
+
continue
|
|
1249
|
+
lo, hi = detect_peaks[mol]
|
|
1250
|
+
mask = (obs_wav >= lo) & (obs_wav <= hi)
|
|
1251
|
+
if mask.any() and np.nanmax(obs_flux[mask]) > thr:
|
|
1252
|
+
detected.append(mol)
|
|
1253
|
+
else:
|
|
1254
|
+
print(f' [{mol}] not detected (peak < {sigma_factor:.1f}σ) — excluded')
|
|
1255
|
+
return detected
|
|
1256
|
+
else:
|
|
1257
|
+
for lo, hi in detect_peaks:
|
|
1258
|
+
mask = (obs_wav >= lo) & (obs_wav <= hi)
|
|
1259
|
+
if mask.any() and np.nanmax(obs_flux[mask]) > thr:
|
|
1260
|
+
return mols
|
|
1261
|
+
mol_str = ', '.join(mols)
|
|
1262
|
+
print(f' [{mol_str}] not detected (all peaks < {sigma_factor:.1f}σ) — stage skipped')
|
|
1263
|
+
return []
|
|
1264
|
+
|
|
1265
|
+
|
|
1266
|
+
def save_running_spectrum(obs_wav, residual, cumulative_model, output_dir, prefix):
|
|
1267
|
+
"""
|
|
1268
|
+
Save the running residual and cumulative model after a fitting stage.
|
|
1269
|
+
|
|
1270
|
+
Args:
|
|
1271
|
+
obs_wav (np.ndarray): wavelength axis in µm.
|
|
1272
|
+
residual (np.ndarray): current running residual (obs minus all stages so far).
|
|
1273
|
+
cumulative_model (np.ndarray): sum of all stage model fluxes fitted so far.
|
|
1274
|
+
output_dir (str): output directory (created if missing).
|
|
1275
|
+
prefix (str): filename prefix; file is saved as {prefix}_running.csv.
|
|
1276
|
+
|
|
1277
|
+
Returns:
|
|
1278
|
+
str: path to the saved CSV.
|
|
1279
|
+
"""
|
|
1280
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
1281
|
+
path = os.path.join(output_dir, f'{prefix}_running.csv')
|
|
1282
|
+
pd.DataFrame({
|
|
1283
|
+
'wave': obs_wav,
|
|
1284
|
+
'residual': residual,
|
|
1285
|
+
'cumulative_model': cumulative_model,
|
|
1286
|
+
}).to_csv(path, index=False)
|
|
1287
|
+
return path
|
|
1288
|
+
|
|
1289
|
+
|
|
1290
|
+
def print_fit_params(fit):
|
|
1291
|
+
"""
|
|
1292
|
+
Print best-fit parameters with ± uncertainties for all components in a fit result.
|
|
1293
|
+
|
|
1294
|
+
Args:
|
|
1295
|
+
fit (dict): result dict returned by fit_molecules.
|
|
1296
|
+
"""
|
|
1297
|
+
for label, p in fit['params'].items():
|
|
1298
|
+
u = fit.get('uncertainty', {}).get(label, {})
|
|
1299
|
+
u_T = u.get('T', {})
|
|
1300
|
+
u_logN = u.get('logN', {})
|
|
1301
|
+
u_log10A = u.get('log10A', {})
|
|
1302
|
+
print(
|
|
1303
|
+
f' {label:14s} '
|
|
1304
|
+
f'T={p["T"]:.1f} (+{u_T.get("plus", float("nan")):.1f}/'
|
|
1305
|
+
f'-{u_T.get("minus", float("nan")):.1f}) K '
|
|
1306
|
+
f'logN={p["logN"]:.3f} (+{u_logN.get("plus", float("nan")):.3f}/'
|
|
1307
|
+
f'-{u_logN.get("minus", float("nan")):.3f}) '
|
|
1308
|
+
f'log10A={p["log10A"]:.3f} (+{u_log10A.get("plus", float("nan")):.3f}/'
|
|
1309
|
+
f'-{u_log10A.get("minus", float("nan")):.3f})'
|
|
1310
|
+
)
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def save_fitted_comparison(csv_path, source_name, all_fits):
|
|
1314
|
+
"""
|
|
1315
|
+
Upsert one row of best-fit parameters into a comparison CSV.
|
|
1316
|
+
|
|
1317
|
+
The CSV schema matches Parameter_Comparison.csv (log10 units for N and A,
|
|
1318
|
+
K for T). If the file does not exist it is created. If *source_name* is
|
|
1319
|
+
already present its values are updated in-place; otherwise a new row is
|
|
1320
|
+
appended.
|
|
1321
|
+
|
|
1322
|
+
Args:
|
|
1323
|
+
csv_path (str): path to the comparison CSV.
|
|
1324
|
+
source_name (str): identifier for this source (used as the key).
|
|
1325
|
+
all_fits (dict): stage-name → fit-result dict from the fitting loop.
|
|
1326
|
+
"""
|
|
1327
|
+
all_params = {}
|
|
1328
|
+
all_unc = {}
|
|
1329
|
+
for fit in all_fits.values():
|
|
1330
|
+
all_params.update(fit['params'])
|
|
1331
|
+
all_unc.update(fit.get('uncertainty', {}))
|
|
1332
|
+
|
|
1333
|
+
def _v(comp, key):
|
|
1334
|
+
if comp not in all_params:
|
|
1335
|
+
return ''
|
|
1336
|
+
return round(all_params[comp][key], 4)
|
|
1337
|
+
|
|
1338
|
+
def _e(comp, key, which):
|
|
1339
|
+
if comp not in all_unc:
|
|
1340
|
+
return ''
|
|
1341
|
+
v = all_unc[comp].get(key, {}).get(which, float('nan'))
|
|
1342
|
+
return round(v, 4) if np.isfinite(v) else ''
|
|
1343
|
+
|
|
1344
|
+
row = {
|
|
1345
|
+
'Source': source_name,
|
|
1346
|
+
'H2O_N_warm': _v('H2O_warm', 'logN'),
|
|
1347
|
+
'H2O_N_warm_err_low': _e('H2O_warm', 'logN', 'minus'),
|
|
1348
|
+
'H2O_N_warm_err_high': _e('H2O_warm', 'logN', 'plus'),
|
|
1349
|
+
'H2O_N_hot': _v('H2O_hot', 'logN'),
|
|
1350
|
+
'H2O_N_hot_err_low': _e('H2O_hot', 'logN', 'minus'),
|
|
1351
|
+
'H2O_N_hot_err_high': _e('H2O_hot', 'logN', 'plus'),
|
|
1352
|
+
'H2O_T_warm': _v('H2O_warm', 'T'),
|
|
1353
|
+
'H2O_T_warm_err_low': _e('H2O_warm', 'T', 'minus'),
|
|
1354
|
+
'H2O_T_warm_err_high': _e('H2O_warm', 'T', 'plus'),
|
|
1355
|
+
'H2O_T_hot': _v('H2O_hot', 'T'),
|
|
1356
|
+
'H2O_T_hot_err_low': _e('H2O_hot', 'T', 'minus'),
|
|
1357
|
+
'H2O_T_hot_err_high': _e('H2O_hot', 'T', 'plus'),
|
|
1358
|
+
'H2O_A_warm': _v('H2O_warm', 'log10A'),
|
|
1359
|
+
'H2O_A_warm_err_low': _e('H2O_warm', 'log10A', 'minus'),
|
|
1360
|
+
'H2O_A_warm_err_high': _e('H2O_warm', 'log10A', 'plus'),
|
|
1361
|
+
'H2O_A_hot': _v('H2O_hot', 'log10A'),
|
|
1362
|
+
'H2O_A_hot_err_low': _e('H2O_hot', 'log10A', 'minus'),
|
|
1363
|
+
'H2O_A_hot_err_high': _e('H2O_hot', 'log10A', 'plus'),
|
|
1364
|
+
'C2H2_N': _v('C2H2', 'logN'),
|
|
1365
|
+
'C2H2_N_err_low': _e('C2H2', 'logN', 'minus'),
|
|
1366
|
+
'C2H2_N_err_high': _e('C2H2', 'logN', 'plus'),
|
|
1367
|
+
'C2H2_T': _v('C2H2', 'T'),
|
|
1368
|
+
'C2H2_T_err_low': _e('C2H2', 'T', 'minus'),
|
|
1369
|
+
'C2H2_T_err_high': _e('C2H2', 'T', 'plus'),
|
|
1370
|
+
'C2H2_A': _v('C2H2', 'log10A'),
|
|
1371
|
+
'C2H2_A_err_low': _e('C2H2', 'log10A', 'minus'),
|
|
1372
|
+
'C2H2_A_err_high': _e('C2H2', 'log10A', 'plus'),
|
|
1373
|
+
'HCN_N': _v('HCN', 'logN'),
|
|
1374
|
+
'HCN_N_err_low': _e('HCN', 'logN', 'minus'),
|
|
1375
|
+
'HCN_N_err_high': _e('HCN', 'logN', 'plus'),
|
|
1376
|
+
'HCN_T': _v('HCN', 'T'),
|
|
1377
|
+
'HCN_T_err_low': _e('HCN', 'T', 'minus'),
|
|
1378
|
+
'HCN_T_err_high': _e('HCN', 'T', 'plus'),
|
|
1379
|
+
'HCN_A': _v('HCN', 'log10A'),
|
|
1380
|
+
'HCN_A_err_low': _e('HCN', 'log10A', 'minus'),
|
|
1381
|
+
'HCN_A_err_high': _e('HCN', 'log10A', 'plus'),
|
|
1382
|
+
'CO2_N': _v('CO2', 'logN'),
|
|
1383
|
+
'CO2_N_err_low': _e('CO2', 'logN', 'minus'),
|
|
1384
|
+
'CO2_N_err_high': _e('CO2', 'logN', 'plus'),
|
|
1385
|
+
'CO2_T': _v('CO2', 'T'),
|
|
1386
|
+
'CO2_T_err_low': _e('CO2', 'T', 'minus'),
|
|
1387
|
+
'CO2_T_err_high': _e('CO2', 'T', 'plus'),
|
|
1388
|
+
'CO2_A': _v('CO2', 'log10A'),
|
|
1389
|
+
'CO2_A_err_low': _e('CO2', 'log10A', 'minus'),
|
|
1390
|
+
'CO2_A_err_high': _e('CO2', 'log10A', 'plus'),
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
if os.path.exists(csv_path):
|
|
1394
|
+
df = pd.read_csv(csv_path)
|
|
1395
|
+
mask = df['Source'] == source_name
|
|
1396
|
+
if mask.any():
|
|
1397
|
+
for col, val in row.items():
|
|
1398
|
+
if col in df.columns:
|
|
1399
|
+
df.loc[mask, col] = val
|
|
1400
|
+
print(f' Updated existing row for {source_name!r} in {os.path.basename(csv_path)}')
|
|
1401
|
+
else:
|
|
1402
|
+
df = pd.concat([df, pd.DataFrame([row])], ignore_index=True)
|
|
1403
|
+
print(f' Appended new row for {source_name!r} to {os.path.basename(csv_path)}')
|
|
1404
|
+
else:
|
|
1405
|
+
df = pd.DataFrame([row])
|
|
1406
|
+
print(f' Created {os.path.basename(csv_path)} with first row for {source_name!r}')
|
|
1407
|
+
|
|
1408
|
+
df.to_csv(csv_path, index=False)
|