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
|
@@ -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
|