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
@@ -0,0 +1,447 @@
1
+ """Continuum fitting internals and diagnostic helpers (wavelengths in microns).
2
+
3
+ Only NumPy, SciPy, and pandas are required for fitting. FITS I/O and plotting
4
+ import astropy and matplotlib lazily.
5
+ """
6
+ from pathlib import Path
7
+ import warnings
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+ from scipy.interpolate import PchipInterpolator
12
+ from scipy.ndimage import maximum_filter1d
13
+ from scipy.sparse import diags
14
+ from scipy.sparse.linalg import spsolve
15
+ from scipy.special import expit
16
+
17
+ HERE = Path(__file__).resolve().parent
18
+ PRESETS = {
19
+ "water": ("H2O",),
20
+ "ttauri": ("H2O", "C2H2", "HCN", "CO2"),
21
+ "carbon_rich": ("H2O", "C2H2", "HCN", "CO2", "13C12CH2",
22
+ "13CO2", "C4H2", "HC3N", "CH4"),
23
+ "carbon_extended": ("H2O", "C2H2", "HCN", "CO2", "13C12CH2",
24
+ "13CO2", "C4H2", "HC3N", "CH4", "C2H4"),
25
+ }
26
+ # C2H6 is opt-in: its local 400 K template is nonfinite at 5.70–7.58 µm.
27
+ # generate_line_free_regions(..., molecules=[...]) supports it conservatively.
28
+ __all__ = ["subtract_continuum", "generate_line_free_regions", "load_regions",
29
+ "plot_consub", "read_spectrum", "save_consub"]
30
+
31
+
32
+ def _regions(regions):
33
+ if regions is None:
34
+ return np.empty((0, 2))
35
+ if isinstance(regions, (str, Path)):
36
+ regions = pd.read_csv(regions)
37
+ if isinstance(regions, pd.DataFrame):
38
+ if not {"xmin", "xmax"}.issubset(regions.columns):
39
+ raise ValueError("Line-free CSV/table must contain xmin and xmax columns")
40
+ regions = regions[["xmin", "xmax"]].to_numpy()
41
+ a = np.asarray(regions, dtype=float)
42
+ if a.size == 0:
43
+ return np.empty((0, 2))
44
+ if a.ndim != 2 or a.shape[1] != 2 or not np.all(np.isfinite(a)) or np.any(a[:, 1] <= a[:, 0]):
45
+ raise ValueError("Regions must be finite (xmin, xmax) pairs with xmin < xmax")
46
+ return a
47
+
48
+
49
+ def _inside(x, regions):
50
+ mask = np.zeros(len(x), dtype=bool)
51
+ for low, high in _regions(regions):
52
+ mask |= (x >= low) & (x <= high)
53
+ return mask
54
+
55
+
56
+ def _intervals(x, mask, min_points=2):
57
+ edges = np.flatnonzero(np.diff(np.r_[False, mask, False].astype(int)))
58
+ boundaries = np.r_[x[0], (x[:-1]+x[1:])/2, x[-1]]
59
+ return pd.DataFrame([(boundaries[a], boundaries[b]) for a, b in edges.reshape(-1, 2)
60
+ if b-a >= min_points], columns=["xmin", "xmax"])
61
+
62
+
63
+ def load_regions(preset="ttauri"):
64
+ """Read shipped candidate windows; these are references, not guarantees."""
65
+ if preset not in PRESETS:
66
+ raise ValueError(f"preset must be one of {tuple(PRESETS)}")
67
+ return pd.read_csv(HERE / "regions" / f"{preset}.csv")
68
+
69
+
70
+ def generate_line_free_regions(grid_root, preset="ttauri", *, molecules=None,
71
+ threshold=0.03, padding_kms=40.0,
72
+ exclude=None, reference_file=None):
73
+ """Find windows faint in every selected model, on the model wavelength axis.
74
+
75
+ H2O: T=800 K, logN=18; all carbon species: T=400 K, logN=16.
76
+ Threshold is a fraction of a rolling 0.5 micron peak, floored at 2% of
77
+ the full model peak. Padding is a wavelength-registration margin; models
78
+ already contain instrumental broadening. Outside model coverage is unknown.
79
+ Optional reference_file intersects the candidates with curated windows.
80
+ """
81
+ if preset not in PRESETS or not 0 < threshold < 1 or padding_kms < 0:
82
+ raise ValueError("Invalid preset, threshold, or padding_kms")
83
+ species = tuple(PRESETS[preset] if molecules is None else molecules)
84
+ if not species:
85
+ raise ValueError("Select at least one molecule")
86
+ x = None
87
+ clean = None
88
+ missing = {}
89
+ for molecule in species:
90
+ temperature, column = (800, 18) if molecule == "H2O" else (400, 16)
91
+ path = Path(grid_root) / molecule / f"T{temperature}N{column}.0.csv"
92
+ table = pd.read_csv(path)
93
+ mx = table["wave"].to_numpy(float)
94
+ my = table["Line"].to_numpy(float)
95
+ if np.any(np.diff(mx) <= 0) or not np.all(np.isfinite(mx)) or not np.any(np.isfinite(my)) or np.nanmax(my) <= 0:
96
+ raise ValueError(f"Invalid template: {path}")
97
+ unknown = ~np.isfinite(my)
98
+ missing[molecule] = int(unknown.sum())
99
+ my = np.where(unknown, 0, my)
100
+ if x is None:
101
+ x, clean = mx, np.ones(len(mx), dtype=bool)
102
+ window = max(3, int(0.5 / np.median(np.diff(mx))) | 1)
103
+ envelope = np.maximum(maximum_filter1d(my, window), 0.02 * np.max(my))
104
+ line = (my / envelope > threshold) | unknown
105
+ # Pad intervals on the native axis before interpolating masks, so a
106
+ # narrow line cannot disappear between samples of a coarser spectrum.
107
+ intervals = _intervals(mx, line, min_points=1).to_numpy()
108
+ if len(intervals):
109
+ intervals[:, 0] *= 1 - padding_kms / 299792.458
110
+ intervals[:, 1] *= 1 + padding_kms / 299792.458
111
+ clean &= ~_inside(x, intervals)
112
+ clean &= (x >= mx[0]) & (x <= mx[-1])
113
+ exclusions = list(_regions(exclude))
114
+ if preset.startswith("carbon_"):
115
+ exclusions.append((12.5, 16.5))
116
+ clean &= ~_inside(x, exclusions)
117
+ if reference_file is not None:
118
+ clean &= _inside(x, reference_file)
119
+ result = _intervals(x, clean)
120
+ result["lam"] = (result.xmin + result.xmax) / 2
121
+ result.attrs.update(preset=preset, molecules=list(species), threshold=threshold,
122
+ padding_kms=padding_kms, unknown_template_pixels=missing)
123
+ return result
124
+
125
+
126
+ def _mad(a):
127
+ a = np.asarray(a)
128
+ return 1.4826 * np.median(np.abs(a - np.median(a)))
129
+
130
+
131
+ def _interp(x, xp, yp):
132
+ if len(xp) == 1:
133
+ return np.full(len(x), yp[0])
134
+ # Constant end corrections avoid unstable polynomial extrapolation.
135
+ return PchipInterpolator(xp, yp)(np.clip(x, xp[0], xp[-1]))
136
+
137
+
138
+ def _widths(x, good, boxsizes):
139
+ dx = np.gradient(x)
140
+ if boxsizes is None:
141
+ # Expand to about 31 clean pixels; retain a physical lower limit so
142
+ # the baseline does not track resolved molecular lines.
143
+ n = np.convolve(good.astype(float), np.ones(min(101, len(x))), "same")
144
+ fraction = np.maximum(n / min(101, len(x)), 0.05)
145
+ return np.clip(31 * dx / fraction, 0.25, 1.0)
146
+ if np.isscalar(boxsizes):
147
+ if not np.isfinite(boxsizes) or boxsizes < 5:
148
+ raise ValueError("boxsizes must be at least 5 pixels")
149
+ return dx * float(boxsizes)
150
+ widths = _widths(x, good, None)
151
+ for low, high, pixels in boxsizes:
152
+ if not np.isfinite([low, high, pixels]).all() or high <= low or pixels < 5:
153
+ raise ValueError("boxsizes entries must be (xmin, xmax, pixels>=5)")
154
+ sel = (x >= low) & (x <= high)
155
+ widths[sel] = dx[sel] * pixels
156
+ return widths
157
+
158
+
159
+ def _local_baseline(x, y, noise, good, widths):
160
+ positions, values = [], []
161
+ # Local linear fits remove slope before clipping, unlike selecting minima.
162
+ centers = np.arange(x[0], x[-1], max(0.04, np.median(widths) / 3))
163
+ centers = np.unique(np.r_[x[0], centers, x[-1]])
164
+ for center in centers:
165
+ width = np.interp(center, x, widths)
166
+ sel = good & (np.abs(x-center) <= width/2)
167
+ if np.count_nonzero(sel) < 5:
168
+ continue
169
+ xx, yy, nn = x[sel]-center, y[sel], noise[sel]
170
+ keep = np.ones(len(xx), dtype=bool)
171
+ for _ in range(6):
172
+ if np.sum(keep) < 4:
173
+ break
174
+ coef = np.polyfit(xx[keep], yy[keep], 1, w=1/nn[keep])
175
+ residual = yy - np.polyval(coef, xx)
176
+ scale = np.maximum(nn, _mad(residual[keep]))
177
+ updated = (residual < 2.5*scale) & (residual > -4*scale)
178
+ if np.array_equal(keep, updated):
179
+ break
180
+ keep = updated
181
+ if np.sum(keep) < 4:
182
+ continue
183
+ # Place the knot at the actual support, not the nominal box center:
184
+ # extrapolating a slope from a tiny off-center cluster creates spikes.
185
+ location = np.median(xx[keep])
186
+ positions.append(center+location)
187
+ values.append(float(np.polyval(coef, location)))
188
+ if len(positions) < 3:
189
+ raise ValueError("Too few continuum windows; widen boxsizes or revise masks")
190
+ knots = pd.DataFrame({"x": positions, "y": values}).groupby("x", sort=True).median()
191
+ return _interp(x, knots.index.to_numpy(), knots.y.to_numpy())
192
+
193
+
194
+ def _arpls(x, y, noise, good, widths):
195
+ # Nonuniform-grid second derivatives, scaled to a median pixel spacing.
196
+ h = np.diff(x) / np.median(np.diff(x))
197
+ a = 2 / (h[:-1] * (h[:-1]+h[1:]))
198
+ c = 2 / (h[1:] * (h[:-1]+h[1:]))
199
+ derivative = diags([a, -a-c, c], [0, 1, 2], shape=(len(x)-2, len(x)))
200
+ lam = (widths[1:-1] / (2*np.pi*np.median(np.diff(x))))**4
201
+ penalty = derivative.T @ diags(lam) @ derivative
202
+ base = good.astype(float) * (np.median(noise[good])/noise)**2
203
+ weights = base.copy()
204
+ converged = False
205
+ for _ in range(40):
206
+ z = spsolve(diags(weights) + penalty, weights*y)
207
+ residual = y-z
208
+ negative = residual[good & (residual < 0)]
209
+ if len(negative) < 3:
210
+ break
211
+ scale = max(np.std(negative), np.median(noise[good])*0.1)
212
+ new = base * expit(-2*(residual - (2*scale-np.mean(negative)))/scale)
213
+ # Strong negative defects must not pin the baseline downward.
214
+ new[residual < -5*np.maximum(noise, scale)] = 0
215
+ if np.linalg.norm(new-weights) / max(np.linalg.norm(weights), 1e-15) < 1e-3:
216
+ converged = True
217
+ break
218
+ weights = new
219
+ return z, converged
220
+
221
+
222
+ def subtract_continuum(wav, flux, *, error=None, exclude=None, boxsizes=None,
223
+ special=None, preset="ttauri", line_free_regions=None,
224
+ reference_regions="curated",
225
+ auto_mask=True, method="robust", shift=True,
226
+ correction_scale=0.75, special_blend=0.15):
227
+ """Return a DataFrame containing the spectrum, continuum and diagnostics.
228
+
229
+ boxsizes: None (automatic), a pixel count, or (xmin,xmax,pixels) rows.
230
+ special: windows where the reference-window correction is suppressed.
231
+ line_free_regions: pairs, CSV path, or xmin/xmax DataFrame; overrides preset.
232
+ exclude always wins over references. Nonfinite flux is retained as NaN;
233
+ wavelength must be finite, positive, unique, and strictly increasing.
234
+ err is measurement/local-noise error; cont_err is a separate diagnostic
235
+ uncertainty, not a calibrated posterior. With preset=None and no custom
236
+ windows, all nonexcluded pixels are used and reference correction is off.
237
+ """
238
+ x, original = np.asarray(wav, float), np.asarray(flux, float)
239
+ if x.ndim != 1 or original.shape != x.shape or len(x) < 15:
240
+ raise ValueError("wav and flux must be matching 1D arrays with >=15 samples")
241
+ if not np.all(np.isfinite(x)) or np.any(x <= 0) or np.any(np.diff(x) <= 0):
242
+ raise ValueError("wav must be finite, positive, and strictly increasing")
243
+ if method not in ("robust", "arpls") or correction_scale <= 0 or special_blend < 0:
244
+ raise ValueError("Invalid method or correction/blend scale")
245
+ if preset is not None and preset not in PRESETS:
246
+ raise ValueError(f"preset must be one of {tuple(PRESETS)}")
247
+ finite = np.isfinite(original)
248
+ if finite.sum() < 15:
249
+ raise ValueError("At least 15 finite flux samples are required")
250
+ original = original.copy()
251
+ original[~finite] = np.nan
252
+ unrestricted = preset is None and line_free_regions is None
253
+ if unrestricted:
254
+ refs = np.array([[x[0], x[-1]]])
255
+ shift = False
256
+ else:
257
+ refs = _regions(load_regions(preset) if line_free_regions is None else line_free_regions)
258
+ excluded = _inside(x, exclude)
259
+ if preset is not None and preset.startswith("carbon_"):
260
+ excluded |= _inside(x, [(12.5, 16.5)])
261
+ reference = _inside(x, refs) & finite & ~excluded
262
+ good = finite & ~excluded
263
+ if auto_mask:
264
+ good &= reference
265
+ if isinstance(reference_regions, str) and reference_regions == "curated":
266
+ if preset is not None:
267
+ filename = "cont_ranges_PPan.csv" if preset.startswith("carbon_") else "cont_ranges.csv"
268
+ reference &= _inside(x, HERE / filename)
269
+ elif reference_regions is not None:
270
+ reference &= _inside(x, reference_regions)
271
+ if good.sum() < 15:
272
+ raise ValueError("Fewer than 15 usable continuum pixels; revise regions")
273
+ y = np.interp(x, x[finite], original[finite])
274
+ # Adjacent differences estimate noise without a continuum-level assumption.
275
+ noise_x, noise_y = [], []
276
+ for lo in np.arange(x[0], x[-1], 0.75):
277
+ adjacent = finite[:-1] & finite[1:] & (x[:-1] >= lo) & (x[1:] < lo+0.75)
278
+ delta = np.diff(y)[adjacent]
279
+ if len(delta) >= 5:
280
+ noise_x.append(lo+0.375)
281
+ noise_y.append(_mad(delta)/np.sqrt(2))
282
+ floor = max(np.nanmedian(np.abs(y))*1e-10, np.finfo(float).eps)
283
+ if not noise_x:
284
+ noise_x, noise_y = [np.median(x)], [_mad(np.diff(y))/np.sqrt(2)]
285
+ estimated_noise = np.maximum(_interp(x, noise_x, noise_y), floor)
286
+ if error is None:
287
+ measurement = estimated_noise.copy()
288
+ else:
289
+ measurement = np.asarray(error, float).copy()
290
+ if measurement.shape != x.shape:
291
+ raise ValueError("error must have the same shape as wav")
292
+ invalid_error = ~np.isfinite(measurement) | (measurement <= 0)
293
+ measurement[invalid_error] = estimated_noise[invalid_error]
294
+ noise = np.maximum(measurement, estimated_noise)
295
+ widths = _widths(x, good, boxsizes)
296
+ # Remove isolated negative spikes from fitting, never from returned flux.
297
+ neighbors = (np.roll(y, 1)+np.roll(y, -1))/2
298
+ spikes = (neighbors-y > 6*noise)
299
+ spikes[[0, -1]] = False
300
+ good &= ~spikes
301
+ reference &= ~spikes
302
+ converged = True
303
+ if method == "robust":
304
+ baseline = _local_baseline(x, y, noise, good, widths)
305
+ else:
306
+ baseline, converged = _arpls(x, y, noise, good, widths)
307
+ if not converged:
308
+ warnings.warn("arPLS did not converge in 40 iterations", stacklevel=2)
309
+ # Bin reference residuals before shape-preserving interpolation. This
310
+ # prevents a dense cluster of narrow windows dominating a global polynomial.
311
+ anchor_x, anchor_y, anchor_err = [], [], []
312
+ for lo in np.arange(x[0], x[-1], correction_scale):
313
+ sel = reference & (x >= lo) & (x < lo+correction_scale)
314
+ if sel.sum() < 5:
315
+ continue
316
+ residual = y[sel]-baseline[sel]
317
+ center = np.median(residual)
318
+ scatter = max(_mad(residual), np.median(noise[sel]))
319
+ accepted = np.abs(residual-center) < 3*scatter
320
+ if accepted.sum() < 5:
321
+ continue
322
+ anchor_x.append(float(np.median(x[sel][accepted])))
323
+ anchor_y.append(float(np.median(residual[accepted])))
324
+ # Samples within one resolution element are correlated; this is still
325
+ # only an indicative anchor error, supplemented by baseline sensitivity.
326
+ anchor_err.append(float(1.253*scatter/np.sqrt(max(1, accepted.sum()/3))))
327
+ if not anchor_x:
328
+ raise ValueError("Too few reference anchors; supply broader line-free windows")
329
+ correction = _interp(x, anchor_x, anchor_y) if shift else np.zeros(len(x))
330
+ taper = np.ones(len(x))
331
+ for lo, hi in _regions(special):
332
+ if special_blend == 0:
333
+ taper[(x >= lo) & (x <= hi)] = 0
334
+ else:
335
+ # Exact zero inside the special window, smooth transitions outside.
336
+ left = np.clip((x-(lo-special_blend))/special_blend, 0, 1)
337
+ right = np.clip(((hi+special_blend)-x)/special_blend, 0, 1)
338
+ blend = (left*left*(3-2*left)) * (right*right*(3-2*right))
339
+ taper *= 1-blend
340
+ correction *= taper
341
+ continuum = baseline+correction
342
+ # Report baseline dependence on a factor-of-two smoothing change separately.
343
+ if method == "robust":
344
+ alternate = _local_baseline(x, y, noise, good, widths*2)
345
+ else:
346
+ alternate, _ = _arpls(x, y, noise, good, widths*2)
347
+ sensitivity = np.abs(alternate-baseline)
348
+ cont_error = np.hypot(_interp(x, anchor_x, anchor_err), sensitivity)
349
+ # Distance to available reference pixels flags interpolation across holes.
350
+ idx = np.searchsorted(x[reference], x)
351
+ left = x[reference][np.clip(idx-1, 0, reference.sum()-1)]
352
+ right = x[reference][np.clip(idx, 0, reference.sum()-1)]
353
+ unsupported = np.minimum(np.abs(x-left), np.abs(x-right)) > np.maximum(widths, correction_scale)/2
354
+ unsupported |= (x < x[reference][0]) | (x > x[reference][-1])
355
+ cont_error[unsupported] = np.nan
356
+ if unsupported.any():
357
+ warnings.warn("Continuum crosses regions with weak support; inspect the plot or "
358
+ "subtract_continuum() diagnostics before using those regions", stacklevel=2)
359
+ result = pd.DataFrame({"wave": x, "flux": original-continuum,
360
+ "err": measurement, "cont": continuum,
361
+ "cont_unshifted": baseline, "raw_flux": original,
362
+ "cont_err": cont_error, "cont_sensitivity": sensitivity,
363
+ "noise": noise, "correction": correction,
364
+ "fit_mask": good, "reference_mask": reference,
365
+ "excluded": excluded, "weak_support": unsupported,
366
+ "box_um": widths})
367
+ result.attrs.update(preset=preset, method=method, converged=converged,
368
+ reference_source="unrestricted" if unrestricted else "windows",
369
+ anchor_wave=anchor_x, anchor_offset=anchor_y,
370
+ anchor_error=anchor_err, special=_regions(special).tolist())
371
+ return result
372
+
373
+
374
+ def read_spectrum(path):
375
+ """Read local JWST FITS or CSV with wave/flux[/err] columns."""
376
+ path = Path(path)
377
+ if path.suffix.lower() in (".fits", ".fit"):
378
+ try:
379
+ from astropy.io import fits
380
+ except ImportError as exc:
381
+ raise ImportError('FITS reading requires astropy: pip install "diskmelts[io]"') from exc
382
+ with fits.open(path) as hdus:
383
+ data = hdus[1].data
384
+ return tuple(np.asarray(data[key], float).copy() for key in
385
+ ("wavelength", "fluxdensity", "fluxdensity_stddev"))
386
+ table = pd.read_csv(path)
387
+ return (table.wave.to_numpy(), table.flux.to_numpy(),
388
+ table.err.to_numpy() if "err" in table else None)
389
+
390
+
391
+ def save_consub(result, path):
392
+ """Write all numeric diagnostics; first four columns match DiskMELTS I/O."""
393
+ path = Path(path)
394
+ path.parent.mkdir(parents=True, exist_ok=True)
395
+ result.to_csv(path, index=False)
396
+ return path
397
+
398
+
399
+ def plot_consub(result, ranges=None, *, title=None, output=None):
400
+ """Plot the continuum and residual with reference, exclusion, and support masks."""
401
+ import matplotlib.pyplot as plt
402
+ x = result.wave.to_numpy()
403
+ if ranges is None:
404
+ ranges = [(x[0], x[-1])]
405
+ if np.asarray(ranges).shape == (2,):
406
+ ranges = [ranges]
407
+ ranges = _regions(ranges)
408
+ if not len(ranges):
409
+ raise ValueError("At least one plotting range is required")
410
+ fig, axes = plt.subplots(2*len(ranges), 1, figsize=(12, 4*len(ranges)),
411
+ squeeze=False, constrained_layout=True)
412
+ for i, (lo, hi) in enumerate(ranges):
413
+ sel = (x >= lo) & (x <= hi)
414
+ if not sel.any():
415
+ plt.close(fig)
416
+ raise ValueError(f"No data in plotting range {lo}, {hi}")
417
+ a, b = axes[2*i, 0], axes[2*i+1, 0]
418
+ a.plot(x[sel], result.raw_flux[sel], color="0.55", lw=0.6, label="Raw")
419
+ if "cont_unshifted" in result:
420
+ a.plot(x[sel], result.cont_unshifted[sel], color="darkorange", lw=1, label="Unshifted continuum")
421
+ a.plot(x[sel], result.cont[sel], color="tab:blue", lw=1.2, label="Continuum")
422
+ ref = sel & result.reference_mask.to_numpy()
423
+ if ref.any():
424
+ a.scatter(x[ref], result.raw_flux[ref], s=7, color="seagreen",
425
+ alpha=.8, zorder=4, label="Calibration pixels")
426
+ b.plot(x[sel], result.flux[sel], color="0.2", lw=0.6)
427
+ if "err" in result:
428
+ b.fill_between(x[sel], -result.err[sel], result.err[sel], color="tab:blue", alpha=.15, label="Measurement/noise error")
429
+ b.axhline(0, color="tab:blue", lw=.7)
430
+ for axis in (a, b):
431
+ if "excluded" in result:
432
+ for j, row in _intervals(x, result.excluded.to_numpy()).iterrows():
433
+ axis.axvspan(row.xmin, row.xmax, color="lightcoral", alpha=.2,
434
+ label="Excluded" if axis is a and j == 0 else None)
435
+ for j, row in _intervals(x, result.weak_support.to_numpy()).iterrows():
436
+ axis.axvspan(row.xmin, row.xmax, color="orange", alpha=.12,
437
+ label="Weak continuum support" if axis is a and j == 0 else None)
438
+ axis.set_xlim(lo, hi)
439
+ axis.set_ylabel("Flux density")
440
+ a.legend(fontsize=8, ncol=4)
441
+ b.set_xlabel("Wavelength (µm)")
442
+ if title:
443
+ fig.suptitle(title)
444
+ if output is not None:
445
+ Path(output).parent.mkdir(parents=True, exist_ok=True)
446
+ fig.savefig(output, dpi=160)
447
+ return fig
@@ -0,0 +1,206 @@
1
+ xmin,xmax,lam
2
+ 4.9116,4.9136,4.9126
3
+ 4.9623,4.9639,4.9631
4
+ 4.974,4.9763,4.97515
5
+ 4.9856,4.9881,4.98685
6
+ 5.0493,5.0506,5.04995
7
+ 5.1011,5.1029,5.102
8
+ 5.1775,5.183,5.18025
9
+ 5.191,5.1936,5.1923
10
+ 5.2881,5.29,5.28905
11
+ 5.3272,5.3293,5.32825
12
+ 5.3953,5.3974,5.39635
13
+ 5.4462,5.4483,5.44725
14
+ 5.4717,5.4747,5.4732
15
+ 5.4958,5.4987,5.49725
16
+ 5.5728,5.5751,5.57395
17
+ 5.6065,5.6092,5.60785
18
+ 5.6356,5.6376,5.6366
19
+ 5.6698,5.6722,5.671
20
+ 5.6964,5.6988,5.6976
21
+ 5.7572,5.7604,5.7588
22
+ 5.8111,5.8143,5.8127
23
+ 5.8538,5.855,5.8544
24
+ 5.9861,5.9875,5.9868
25
+ 5.998,6.0025,6.00025
26
+ 6.0829,6.0858,6.08435
27
+ 6.0918,6.0941,6.09295
28
+ 6.1246,6.133,6.1288
29
+ 6.1781,6.1819,6.18
30
+ 6.1888,6.1914,6.1901
31
+ 6.1957,6.1972,6.19645
32
+ 6.2093,6.2107,6.21
33
+ 6.2307,6.2338,6.23225
34
+ 6.249,6.2522,6.2506
35
+ 6.2739,6.2755,6.2747
36
+ 6.2966,6.2992,6.2979
37
+ 6.3063,6.3123,6.3093
38
+ 6.3214,6.324,6.3227
39
+ 6.3276,6.3302,6.3289
40
+ 6.3472,6.3493,6.34825
41
+ 6.36,6.3631,6.36155
42
+ 6.3934,6.3959,6.39465
43
+ 6.446,6.4479,6.44695
44
+ 6.4627,6.4665,6.4646
45
+ 6.5334,6.5368,6.5351
46
+ 6.6043,6.6091,6.6067
47
+ 6.6506,6.6536,6.6521
48
+ 6.8935,6.8987,6.8961
49
+ 7.0048,7.0063,7.00555
50
+ 7.1594,7.1611,7.16025
51
+ 7.1808,7.1857,7.18325
52
+ 7.2665,7.268,7.26725
53
+ 7.3097,7.3151,7.3124
54
+ 7.4449,7.4465,7.4457
55
+ 7.5599,7.5657,7.5628
56
+ 7.6238,7.6262,7.625
57
+ 7.6344,7.6357,7.63505
58
+ 7.6474,7.6495,7.64845
59
+ 7.672,7.6759,7.67395
60
+ 7.8181,7.8232,7.82065
61
+ 7.8442,7.8482,7.8462
62
+ 7.9177,7.9196,7.91865
63
+ 7.9283,7.9304,7.92935
64
+ 7.949,7.9511,7.95005
65
+ 7.9711,7.9741,7.9726
66
+ 7.9959,7.9991,7.9975
67
+ 8.0023,8.0071,8.0047
68
+ 8.0639,8.0658,8.06485
69
+ 8.0997,8.1019,8.1008
70
+ 8.1343,8.1368,8.13555
71
+ 8.1784,8.1803,8.17935
72
+ 8.2169,8.2202,8.21855
73
+ 8.2911,8.3035,8.2973
74
+ 8.3103,8.3138,8.31205
75
+ 8.3596,8.362,8.3608
76
+ 8.3837,8.3861,8.3849
77
+ 8.4014,8.4095,8.40545
78
+ 8.417,8.4203,8.41865
79
+ 8.4652,8.4684,8.4668
80
+ 8.6232,8.6257,8.62445
81
+ 8.6917,8.6953,8.6935
82
+ 8.7252,8.7291,8.72715
83
+ 8.7383,8.7407,8.7395
84
+ 8.7765,8.7836,8.78005
85
+ 8.8532,8.8579,8.85555
86
+ 8.8919,8.8999,8.8959
87
+ 8.908,8.9115,8.90975
88
+ 8.9367,8.939,8.93785
89
+ 9.0049,9.0184,9.01165
90
+ 9.0718,9.0886,9.0802
91
+ 9.1122,9.1163,9.11425
92
+ 9.1904,9.1928,9.1916
93
+ 9.2005,9.2046,9.20255
94
+ 9.2323,9.2414,9.23685
95
+ 9.2738,9.2761,9.27495
96
+ 9.3016,9.3049,9.30325
97
+ 9.3293,9.3346,9.33195
98
+ 9.3394,9.3418,9.3406
99
+ 9.4133,9.4301,9.4217
100
+ 9.4984,9.5027,9.50055
101
+ 9.5316,9.5572,9.5444
102
+ 9.6112,9.6162,9.6137
103
+ 9.6315,9.6408,9.63615
104
+ 9.6474,9.6537,9.65055
105
+ 9.6896,9.7004,9.695
106
+ 9.7207,9.7429,9.7318
107
+ 9.7633,9.7788,9.77105
108
+ 9.9701,9.9847,9.9774
109
+ 10.1043,10.1081,10.1062
110
+ 10.1215,10.1257,10.1236
111
+ 10.1436,10.1575,10.15055
112
+ 10.1778,10.2147,10.19625
113
+ 10.2385,10.2427,10.2406
114
+ 10.5154,10.5221,10.51875
115
+ 10.6155,10.6235,10.6195
116
+ 10.7052,10.709,10.7071
117
+ 10.7168,10.7208,10.7188
118
+ 10.7408,10.7521,10.74645
119
+ 10.7862,10.7948,10.7905
120
+ 10.8355,10.8396,10.83755
121
+ 10.8587,10.8653,10.862
122
+ 10.9083,10.9278,10.91805
123
+ 10.9468,10.9683,10.95755
124
+ 11.0964,11.1153,11.10585
125
+ 11.1248,11.1353,11.13005
126
+ 11.1826,11.2002,11.1914
127
+ 11.2233,11.2263,11.2248
128
+ 11.2744,11.2873,11.28085
129
+ 11.3325,11.342,11.33725
130
+ 11.3434,11.3528,11.3481
131
+ 11.3667,11.3782,11.37245
132
+ 11.4118,11.4257,11.41875
133
+ 11.4615,11.4741,11.4678
134
+ 11.5074,11.5196,11.5135
135
+ 11.5784,11.5881,11.58325
136
+ 11.6388,11.6419,11.64035
137
+ 11.6573,11.6715,11.6644
138
+ 11.6885,11.6963,11.6924
139
+ 11.8411,11.8494,11.84525
140
+ 11.9943,11.9992,11.99675
141
+ 12.1235,12.1325,12.128
142
+ 12.3011,12.3099,12.3055
143
+ 12.3284,12.3376,12.333
144
+ 12.4631,12.4735,12.4683
145
+ 12.4851,12.492,12.48855
146
+ 12.9342,12.9499,12.94205
147
+ 12.9918,12.9985,12.99515
148
+ 13.0507,13.0559,13.0533
149
+ 13.2126,13.2171,13.21485
150
+ 14.4352,14.4464,14.4408
151
+ 14.5592,14.5773,14.56825
152
+ 14.7371,14.7743,14.7557
153
+ 15.0093,15.0295,15.0194
154
+ 15.2019,15.2213,15.2116
155
+ 15.5324,15.5443,15.53835
156
+ 15.7069,15.7157,15.7113
157
+ 15.9071,15.9201,15.9136
158
+ 15.9353,15.9467,15.941
159
+ 16.088,16.0965,16.09225
160
+ 16.2848,16.2993,16.29205
161
+ 16.3382,16.3597,16.34895
162
+ 16.9533,16.9668,16.96005
163
+ 17.0154,17.0244,17.0199
164
+ 17.2836,17.2999,17.29175
165
+ 17.4159,17.4377,17.4268
166
+ 17.5482,17.5558,17.552
167
+ 17.5816,17.5864,17.584
168
+ 17.6618,17.667,17.6644
169
+ 17.8546,17.8643,17.85945
170
+ 17.8835,17.9003,17.8919
171
+ 17.9729,18.0153,17.9941
172
+ 18.0936,18.1,18.0968
173
+ 18.4465,18.4546,18.45055
174
+ 18.5635,18.5739,18.5687
175
+ 18.7318,18.7417,18.73675
176
+ 18.8036,18.8192,18.8114
177
+ 19.1411,19.1711,19.1561
178
+ 19.3612,19.3736,19.3674
179
+ 19.5081,19.5519,19.53
180
+ 19.7016,19.7154,19.7085
181
+ 19.8536,19.8626,19.8581
182
+ 20.402,20.4148,20.4084
183
+ 20.584,20.63,20.607
184
+ 20.72,20.735,20.7275
185
+ 20.83,20.85,20.84
186
+ 20.9537,20.9769,20.9653
187
+ 21.3018,21.3145,21.30815
188
+ 21.391,21.4083,21.39965
189
+ 21.4802,21.5013,21.49075
190
+ 22.0572,22.0642,22.0607
191
+ 22.1515,22.1722,22.16185
192
+ 22.683,22.6975,22.69025
193
+ 22.9341,22.9496,22.94185
194
+ 23.0229,23.0506,23.03675
195
+ 23.7182,23.7318,23.725
196
+ 23.965,24.044,24.0045
197
+ 24.228,24.2412,24.2346
198
+ 24.2717,24.2872,24.27945
199
+ 24.3374,24.3492,24.3433
200
+ 24.8524,24.8844,24.8684
201
+ 25.2745,25.2984,25.28645
202
+ 25.7801,25.8158,25.79795
203
+ 26.28,26.4003,26.34015
204
+ 26.815,26.89,26.8525
205
+ 26.9341,26.98,26.95705
206
+ 27.1487,27.1768,27.16275