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.
Files changed (46) hide show
  1. diskmelts/__init__.py +98 -0
  2. diskmelts/consub/__init__.py +15 -0
  3. diskmelts/consub/_api.py +129 -0
  4. diskmelts/consub/_core.py +447 -0
  5. diskmelts/consub/cont_ranges.csv +206 -0
  6. diskmelts/consub/cont_ranges_PPan.csv +182 -0
  7. diskmelts/consub/regions/carbon_extended.csv +227 -0
  8. diskmelts/consub/regions/carbon_extended.json +29 -0
  9. diskmelts/consub/regions/carbon_rich.csv +360 -0
  10. diskmelts/consub/regions/carbon_rich.json +27 -0
  11. diskmelts/consub/regions/ttauri.csv +421 -0
  12. diskmelts/consub/regions/ttauri.json +17 -0
  13. diskmelts/consub/regions/water.csv +505 -0
  14. diskmelts/consub/regions/water.json +11 -0
  15. diskmelts/fitting.py +1408 -0
  16. diskmelts/models/Trained_model/13C12CH2_single_v2/net_13C12CH2_forward.pt +0 -0
  17. diskmelts/models/Trained_model/13CO2_single_v2/net_13CO2_forward.pt +0 -0
  18. diskmelts/models/Trained_model/C2H2_single_v2/net_C2H2_forward.pt +0 -0
  19. diskmelts/models/Trained_model/C2H4_single_v2/net_C2H4_forward.pt +0 -0
  20. diskmelts/models/Trained_model/C2H6_single_v2/net_C2H6_forward.pt +0 -0
  21. diskmelts/models/Trained_model/C2HD_single_v2/net_C2HD_forward.pt +0 -0
  22. diskmelts/models/Trained_model/C4H2_single_v2/net_C4H2_forward.pt +0 -0
  23. diskmelts/models/Trained_model/CH4_single_v2/net_CH4_forward.pt +0 -0
  24. diskmelts/models/Trained_model/CO17O_single_v2/net_CO17O_forward.pt +0 -0
  25. diskmelts/models/Trained_model/CO18O_single_v2/net_CO18O_forward.pt +0 -0
  26. diskmelts/models/Trained_model/CO2_single_v2/net_CO2_forward.pt +0 -0
  27. diskmelts/models/Trained_model/H2O_wavelength_bank_v2_fixed/H2O_model_bank.json +129 -0
  28. diskmelts/models/Trained_model/H2O_wavelength_bank_v2_fixed/net_H2O_w13p5to19p2_n13to19_t100to1400.pt +0 -0
  29. diskmelts/models/Trained_model/H2O_wavelength_bank_v2_fixed/net_H2O_w19to25_n13to19_t100to1400.pt +0 -0
  30. diskmelts/models/Trained_model/H2O_wavelength_bank_v2_fixed/net_H2O_w4p9to7p4_n13to19_t100to1400.pt +0 -0
  31. diskmelts/models/Trained_model/H2O_wavelength_bank_v2_fixed/net_H2O_w7p2to11p2_n13to19_t100to1400.pt +0 -0
  32. diskmelts/models/Trained_model/H2O_wavelength_bank_v2_fixed/net_H2O_w9p9to15p1_n13to19_t100to1400.pt +0 -0
  33. diskmelts/models/Trained_model/HC3N_single_v2/net_HC3N_forward.pt +0 -0
  34. diskmelts/models/Trained_model/HCN_single_v2/net_HCN_forward.pt +0 -0
  35. diskmelts/plotting.py +536 -0
  36. diskmelts/trainmodel.py +1349 -0
  37. diskmelts/v2_fitting.py +298 -0
  38. diskmelts/v2_grid_fitting.py +570 -0
  39. diskmelts/v2_paper_comparison.py +174 -0
  40. diskmelts/v2_read_plot.py +220 -0
  41. diskmelts/validation.py +505 -0
  42. diskmelts-2.1.dist-info/METADATA +262 -0
  43. diskmelts-2.1.dist-info/RECORD +46 -0
  44. diskmelts-2.1.dist-info/WHEEL +5 -0
  45. diskmelts-2.1.dist-info/licenses/LICENSE +21 -0
  46. 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)