firepype 0.0.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.
- firepype/__init__.py +27 -0
- firepype/calibration.py +520 -0
- firepype/cli.py +296 -0
- firepype/coadd.py +105 -0
- firepype/config.py +55 -0
- firepype/detection.py +517 -0
- firepype/extraction.py +198 -0
- firepype/io.py +248 -0
- firepype/pipeline.py +339 -0
- firepype/plotting.py +234 -0
- firepype/telluric.py +1401 -0
- firepype/utils.py +344 -0
- firepype-0.0.1.dist-info/METADATA +153 -0
- firepype-0.0.1.dist-info/RECORD +18 -0
- firepype-0.0.1.dist-info/WHEEL +5 -0
- firepype-0.0.1.dist-info/entry_points.txt +3 -0
- firepype-0.0.1.dist-info/licenses/LICENSE +21 -0
- firepype-0.0.1.dist-info/top_level.txt +1 -0
firepype/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# firepype/__init__.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
__version__ = "0.0.1"
|
|
5
|
+
|
|
6
|
+
from .config import (
|
|
7
|
+
PipelineConfig,
|
|
8
|
+
RunSpec,
|
|
9
|
+
QASettings,
|
|
10
|
+
WavecalSettings,
|
|
11
|
+
ExtractionSettings,
|
|
12
|
+
SlitDetectionSettings,
|
|
13
|
+
)
|
|
14
|
+
from .pipeline import run_ab_pairs
|
|
15
|
+
from .telluric import apply_telluric_correction
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"__version__",
|
|
19
|
+
"PipelineConfig",
|
|
20
|
+
"RunSpec",
|
|
21
|
+
"QASettings",
|
|
22
|
+
"WavecalSettings",
|
|
23
|
+
"ExtractionSettings",
|
|
24
|
+
"SlitDetectionSettings",
|
|
25
|
+
"run_ab_pairs",
|
|
26
|
+
"apply_telluric_correction",
|
|
27
|
+
]
|
firepype/calibration.py
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
# firepype/calibration.py
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Iterable, List, Sequence, Tuple
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import numpy.linalg as npl
|
|
8
|
+
from numpy.polynomial import chebyshev as cheb
|
|
9
|
+
from scipy.ndimage import gaussian_filter1d
|
|
10
|
+
from scipy.signal import find_peaks
|
|
11
|
+
from scipy.optimize import linear_sum_assignment
|
|
12
|
+
|
|
13
|
+
from .utils import cheb_design_matrix, robust_weights
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def find_arc_peaks_1d(
|
|
17
|
+
arc1d: np.ndarray,
|
|
18
|
+
*,
|
|
19
|
+
min_prom_frac: float = 0.008,
|
|
20
|
+
sigma_lo: float = 15.0,
|
|
21
|
+
sigma_hi: float = 0.8,
|
|
22
|
+
distance: int = 3,
|
|
23
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
24
|
+
"""
|
|
25
|
+
Purpose:
|
|
26
|
+
Detect positive and negative peaks in 1D arc profile by removing broad
|
|
27
|
+
baseline with a Gaussian low-pass filter, lightly smoothing residual
|
|
28
|
+
(high-pass), and using adaptive prominence threshold
|
|
29
|
+
Inputs:
|
|
30
|
+
arc1d: 1D array-like arc signal
|
|
31
|
+
min_prom_frac: Minimum prominence as fraction of robust range (default 0.008)
|
|
32
|
+
sigma_lo: Sigma of low-pass Gaussian for baseline (default 15.0)
|
|
33
|
+
sigma_hi: Sigma of Gaussian smoothing for residual (default 0.8)
|
|
34
|
+
distance: Minimum pixel separation between peaks (default 3)
|
|
35
|
+
Returns:
|
|
36
|
+
tuple:
|
|
37
|
+
- pk (np.ndarray): Indices of candidate peaks (merged pos/neg), with
|
|
38
|
+
edge-trimming to avoid boundary artifacts
|
|
39
|
+
- sm (np.ndarray): High-pass smoothed profile used for detection
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
y = np.asarray(arc1d, float)
|
|
43
|
+
n = y.size
|
|
44
|
+
base = gaussian_filter1d(y, sigma=sigma_lo, mode="nearest")
|
|
45
|
+
sm = gaussian_filter1d(y - base, sigma=sigma_hi, mode="nearest")
|
|
46
|
+
p1, p99 = np.percentile(sm, [1, 99])
|
|
47
|
+
mad = np.median(np.abs(sm - np.median(sm))) + 1e-12
|
|
48
|
+
noise = 1.4826 * mad
|
|
49
|
+
prom = max(1.5 * noise, min_prom_frac * (p99 - p1))
|
|
50
|
+
pk_pos, _ = find_peaks(sm, prominence=float(max(prom, 1e-6)), distance=distance)
|
|
51
|
+
pk_neg, _ = find_peaks(-sm, prominence=float(max(prom, 1e-6)), distance=distance)
|
|
52
|
+
pk = np.unique(np.r_[pk_pos, pk_neg])
|
|
53
|
+
pk = pk[(pk > 3) & (pk < n - 3)]
|
|
54
|
+
|
|
55
|
+
return pk.astype(int), sm
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def match_peaks_to_refs(
|
|
59
|
+
px_peaks: Iterable[int],
|
|
60
|
+
ref_lines_um: np.ndarray,
|
|
61
|
+
wl_lo: float,
|
|
62
|
+
wl_hi: float,
|
|
63
|
+
*,
|
|
64
|
+
max_sep: float = 0.012,
|
|
65
|
+
deg_seed: int = 1,
|
|
66
|
+
) -> tuple[np.ndarray, np.ndarray]:
|
|
67
|
+
"""
|
|
68
|
+
Purpose:
|
|
69
|
+
Coarsely match detected pixel peaks to reference wavelength list within
|
|
70
|
+
given wavelength range by:
|
|
71
|
+
- Seeding linear pixel-to-wavelength mapping
|
|
72
|
+
- Building sparse cost matrix with candidate matches near seed
|
|
73
|
+
- Solving aassignment problem and pruning outliers via light fit
|
|
74
|
+
Inputs:
|
|
75
|
+
px_peaks: Iterable of detected peak pixel indices
|
|
76
|
+
ref_lines_um: 1D array of reference line wavelengths in microns
|
|
77
|
+
wl_lo: Lower wavelength bound (microns)
|
|
78
|
+
wl_hi: Upper wavelength bound (microns)
|
|
79
|
+
max_sep: Maximum allowed separation (microns) for accepted matches (default 0.012)
|
|
80
|
+
deg_seed: Degree of Chebyshev fit used only for outlier pruning (default 1)
|
|
81
|
+
Returns:
|
|
82
|
+
tuple:
|
|
83
|
+
- px_m (np.ndarray): Matched pixel indices (int), sorted and monotonic in wavelength
|
|
84
|
+
- wl_m (np.ndarray): Corresponding matched wavelengths (float)
|
|
85
|
+
Empty arrays are returned if insufficient matches found
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
px = np.asarray(px_peaks, float)
|
|
89
|
+
refs = np.asarray(ref_lines_um, float)
|
|
90
|
+
refs = refs[(refs >= wl_lo) & (refs <= wl_hi)]
|
|
91
|
+
|
|
92
|
+
if px.size < 8 or refs.size < 8:
|
|
93
|
+
return np.array([], int), np.array([], float)
|
|
94
|
+
|
|
95
|
+
x = (px - px.min()) / max(px.max() - px.min(), 1.0)
|
|
96
|
+
wl_seed = wl_lo + x * (wl_hi - wl_lo)
|
|
97
|
+
|
|
98
|
+
rows, cols, costs = [], [], []
|
|
99
|
+
|
|
100
|
+
for i, wl_s in enumerate(wl_seed):
|
|
101
|
+
j0 = np.searchsorted(refs, wl_s)
|
|
102
|
+
for j in (j0 - 4, j0 - 3, j0 - 2, j0 - 1, j0, j0 + 1, j0 + 2, j0 + 3, j0 + 4):
|
|
103
|
+
if 0 <= j < refs.size:
|
|
104
|
+
d = abs(refs[j] - wl_s)
|
|
105
|
+
if d <= 3 * max_sep:
|
|
106
|
+
rows.append(i)
|
|
107
|
+
cols.append(j)
|
|
108
|
+
costs.append(d)
|
|
109
|
+
if not rows:
|
|
110
|
+
return np.array([], int), np.array([], float)
|
|
111
|
+
|
|
112
|
+
C = np.full((px.size, refs.size), 1e3, float)
|
|
113
|
+
C[rows, cols] = costs
|
|
114
|
+
r_idx, c_idx = linear_sum_assignment(C)
|
|
115
|
+
ok = C[r_idx, c_idx] <= max_sep
|
|
116
|
+
r_idx = r_idx[ok]
|
|
117
|
+
c_idx = c_idx[ok]
|
|
118
|
+
|
|
119
|
+
if r_idx.size < 6:
|
|
120
|
+
return np.array([], int), np.array([], float)
|
|
121
|
+
|
|
122
|
+
px_m = px[r_idx].astype(int)
|
|
123
|
+
wl_m = refs[c_idx].astype(float)
|
|
124
|
+
order = np.argsort(px_m)
|
|
125
|
+
px_m = px_m[order]
|
|
126
|
+
wl_m = wl_m[order]
|
|
127
|
+
good = np.concatenate(([True], np.diff(wl_m) > 0))
|
|
128
|
+
px_m = px_m[good]
|
|
129
|
+
wl_m = wl_m[good]
|
|
130
|
+
|
|
131
|
+
# Light sanity fit (not used directly; only for outlier pruning)
|
|
132
|
+
n = int(px.max()) + 2
|
|
133
|
+
x_full = np.linspace(-1.0, 1.0, n)
|
|
134
|
+
x_m = np.interp(px_m, np.arange(x_full.size), x_full)
|
|
135
|
+
coef = cheb.chebfit(x_m, wl_m, deg=deg_seed)
|
|
136
|
+
wl_fit = cheb.chebval(x_m, coef)
|
|
137
|
+
res = wl_m - wl_fit
|
|
138
|
+
s = 1.4826 * np.median(np.abs(res - np.median(res)) + 1e-12)
|
|
139
|
+
keep = np.abs(res) <= max(2.5 * s, max_sep)
|
|
140
|
+
|
|
141
|
+
return px_m[keep], wl_m[keep]
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def solve_dispersion_from_arc1d(
|
|
145
|
+
arc1d: np.ndarray,
|
|
146
|
+
*,
|
|
147
|
+
wl_range: tuple[float, float],
|
|
148
|
+
ref_lines_um: np.ndarray,
|
|
149
|
+
deg: int = 3,
|
|
150
|
+
anchors: Sequence[tuple[int, float]] | None = None,
|
|
151
|
+
max_sep: float = 0.012,
|
|
152
|
+
verbose: bool = True,
|
|
153
|
+
anchor_weight: float = 12.0,
|
|
154
|
+
enforce_anchor_order: bool = False,
|
|
155
|
+
) -> np.ndarray:
|
|
156
|
+
"""
|
|
157
|
+
Purpose:
|
|
158
|
+
Fit robust Chebyshev pixel --> wavelength dispersion for 1D arc:
|
|
159
|
+
- Detect peaks and match to reference wavelengths
|
|
160
|
+
- Perform robust IRLS Chebyshev fit (degree deg)
|
|
161
|
+
- Align endpoints to given wl_range
|
|
162
|
+
- Optionally add weighted anchor "pseudo-observations" and enforce
|
|
163
|
+
anchor ordering with light smoothing
|
|
164
|
+
Inputs:
|
|
165
|
+
arc1d: 1D arc spectrum
|
|
166
|
+
wl_range: Tuple (wl_lo, wl_hi) microns for target coverage
|
|
167
|
+
ref_lines_um: 1D array of reference wavelengths (microns)
|
|
168
|
+
deg: Polynomial degree for Chebyshev fit (default 3)
|
|
169
|
+
anchors: Optional list of (pixel_index, wavelength_um) anchors (default None)
|
|
170
|
+
max_sep: Max separation (microns) for matching peaks to refs (default 0.012)
|
|
171
|
+
verbose: Print anchor RMS diagnostics (default True)
|
|
172
|
+
anchor_weight: Multiplicative weight for anchor observations in IRLS (default 12.0)
|
|
173
|
+
enforce_anchor_order: Enforce monotonic constraints at anchors and
|
|
174
|
+
apply light smoothing (default False)
|
|
175
|
+
Returns:
|
|
176
|
+
np.ndarray:
|
|
177
|
+
Wavelength array (microns) of same length as arc1d, strictly increasing
|
|
178
|
+
and clipped/shifted to the range wl_range
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
y = np.asarray(arc1d, float)
|
|
182
|
+
n = y.size
|
|
183
|
+
wl_lo, wl_hi = float(wl_range[0]), float(wl_range[1])
|
|
184
|
+
|
|
185
|
+
# Peaks and matching
|
|
186
|
+
pk, _ = find_arc_peaks_1d(y)
|
|
187
|
+
|
|
188
|
+
if pk.size < 8:
|
|
189
|
+
raise RuntimeError("Too few arc peaks found for dispersion fit")
|
|
190
|
+
|
|
191
|
+
px_m, wl_m = match_peaks_to_refs(
|
|
192
|
+
pk, ref_lines_um, wl_lo, wl_hi, max_sep=max_sep, deg_seed=1
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
if px_m.size < max(8, deg + 3):
|
|
196
|
+
px_m, wl_m = match_peaks_to_refs(
|
|
197
|
+
pk, ref_lines_um, wl_lo, wl_hi, max_sep=1.3 * max_sep, deg_seed=1
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
if px_m.size < max(8, deg + 3):
|
|
201
|
+
raise RuntimeError("Insufficient matched lines for dispersion fit")
|
|
202
|
+
|
|
203
|
+
# Map pixels to Chebyshev domain
|
|
204
|
+
x_full = np.linspace(-1.0, 1.0, n)
|
|
205
|
+
x_m = np.interp(px_m, np.arange(x_full.size), x_full)
|
|
206
|
+
|
|
207
|
+
# Build fit arrays and base weights
|
|
208
|
+
x_fit = x_m.copy()
|
|
209
|
+
y_fit = wl_m.astype(float).copy()
|
|
210
|
+
w0 = np.ones_like(y_fit)
|
|
211
|
+
|
|
212
|
+
# Add optional anchors as weighted pseudo-observations
|
|
213
|
+
if anchors:
|
|
214
|
+
for p, w in anchors:
|
|
215
|
+
p_i = int(p)
|
|
216
|
+
w_v = float(w)
|
|
217
|
+
if 0 <= p_i < n and wl_lo <= w_v <= wl_hi:
|
|
218
|
+
x_fit = np.r_[x_fit, np.interp(p_i, np.arange(x_full.size), x_full)]
|
|
219
|
+
y_fit = np.r_[y_fit, w_v]
|
|
220
|
+
w0 = np.r_[w0, float(anchor_weight)]
|
|
221
|
+
|
|
222
|
+
# Robust IRLS fit
|
|
223
|
+
X = cheb_design_matrix(x_fit, deg)
|
|
224
|
+
w = np.ones_like(y_fit)
|
|
225
|
+
|
|
226
|
+
for _ in range(12):
|
|
227
|
+
w_tot = w * w0
|
|
228
|
+
WX = X * w_tot[:, None]
|
|
229
|
+
Wy = y_fit * w_tot
|
|
230
|
+
coef, *_ = npl.lstsq(WX, Wy, rcond=None)
|
|
231
|
+
res = y_fit - X.dot(coef)
|
|
232
|
+
w_new = robust_weights(res, c=4.685)
|
|
233
|
+
if np.allclose(w, w_new, atol=1e-3):
|
|
234
|
+
break
|
|
235
|
+
w = w_new
|
|
236
|
+
|
|
237
|
+
wl = cheb.chebval(x_full, coef)
|
|
238
|
+
|
|
239
|
+
# Endpoint alignment to wl_range
|
|
240
|
+
span_fit = wl[-1] - wl[0]
|
|
241
|
+
span_tar = wl_hi - wl_lo
|
|
242
|
+
|
|
243
|
+
if abs(span_fit - span_tar) / max(span_tar, 1e-12) > 0.002:
|
|
244
|
+
a = span_tar / (span_fit + 1e-12)
|
|
245
|
+
b = wl_lo - a * wl[0]
|
|
246
|
+
wl = a * wl + b
|
|
247
|
+
else:
|
|
248
|
+
wl = wl + (wl_lo - wl[0])
|
|
249
|
+
|
|
250
|
+
# Enforce monotonicity
|
|
251
|
+
for i in range(1, n):
|
|
252
|
+
if wl[i] <= wl[i - 1]:
|
|
253
|
+
wl[i] = wl[i - 1] + 1e-9
|
|
254
|
+
wl[0], wl[-1] = max(wl[0], wl_lo), min(wl[-1], wl_hi)
|
|
255
|
+
|
|
256
|
+
# Optional anchor order enforcement and light smoothing
|
|
257
|
+
if enforce_anchor_order and anchors:
|
|
258
|
+
anc = [
|
|
259
|
+
(int(p), float(w))
|
|
260
|
+
for (p, w) in anchors
|
|
261
|
+
if 0 <= int(p) < n and wl_lo <= float(w) <= wl_hi
|
|
262
|
+
]
|
|
263
|
+
anc.sort(key=lambda t: t[0])
|
|
264
|
+
|
|
265
|
+
for p_a, w_a in anc:
|
|
266
|
+
wl[: p_a + 1] = np.minimum(wl[: p_a + 1], w_a)
|
|
267
|
+
|
|
268
|
+
for i in range(1, p_a + 1):
|
|
269
|
+
if wl[i] <= wl[i - 1]:
|
|
270
|
+
wl[i] = wl[i - 1] + 1e-9
|
|
271
|
+
|
|
272
|
+
for p_a, w_a in reversed(anc):
|
|
273
|
+
wl[p_a:] = np.maximum(wl[p_a:], w_a)
|
|
274
|
+
|
|
275
|
+
for i in range(p_a + 1, n):
|
|
276
|
+
if wl[i] <= wl[i - 1]:
|
|
277
|
+
wl[i] = wl[i - 1] + 1e-9
|
|
278
|
+
|
|
279
|
+
wl = np.clip(wl, wl_lo, wl_hi)
|
|
280
|
+
wl = gaussian_filter1d(wl, sigma=1.2, mode="nearest")
|
|
281
|
+
|
|
282
|
+
for i in range(1, n):
|
|
283
|
+
if wl[i] <= wl[i - 1]:
|
|
284
|
+
wl[i] = wl[i - 1] + 1e-9
|
|
285
|
+
wl[0], wl[-1] = max(wl[0], wl_lo), min(wl[-1], wl_hi)
|
|
286
|
+
|
|
287
|
+
if verbose and anchors:
|
|
288
|
+
diffs = []
|
|
289
|
+
|
|
290
|
+
for p, w_anchor in anchors:
|
|
291
|
+
p_i = int(p)
|
|
292
|
+
w_v = float(w_anchor)
|
|
293
|
+
if 0 <= p_i < n and wl_lo <= w_v <= wl_hi:
|
|
294
|
+
diffs.append(wl[p_i] - w_v)
|
|
295
|
+
|
|
296
|
+
if diffs:
|
|
297
|
+
rms_nm = np.sqrt(np.mean(np.square(diffs))) * 1e3
|
|
298
|
+
mx_nm = np.max(np.abs(diffs)) * 1e3
|
|
299
|
+
print(
|
|
300
|
+
f"[DISPERSION] anchors: RMS={rms_nm:.2f} nm, "
|
|
301
|
+
f"max|Δ|={mx_nm:.2f} nm over {len(diffs)} anchors"
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
return wl
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def global_wavecal_from_arc1d(
|
|
308
|
+
arc1d: np.ndarray,
|
|
309
|
+
wl_range: tuple[float, float],
|
|
310
|
+
ref_lines_um: np.ndarray,
|
|
311
|
+
anchors_global: Sequence[tuple[int, float]] | None = None,
|
|
312
|
+
*,
|
|
313
|
+
tol_init: float = 0.020,
|
|
314
|
+
tol_refine: float = 0.015,
|
|
315
|
+
verbose: bool = True,
|
|
316
|
+
) -> np.ndarray:
|
|
317
|
+
"""
|
|
318
|
+
Purpose:
|
|
319
|
+
Compatibility wrapper around solve_dispersion_from_arc1d, solving a
|
|
320
|
+
degree-3 robust dispersion with optional global anchors
|
|
321
|
+
Inputs:
|
|
322
|
+
arc1d: 1D arc spectrum
|
|
323
|
+
wl_range: Tuple (wl_lo, wl_hi) microns for target coverage
|
|
324
|
+
ref_lines_um: 1D array of reference wavelengths (microns)
|
|
325
|
+
anchors_global: Optional list of (pixel_index, wavelength_um) anchors
|
|
326
|
+
verbose: Print diagnostics (default True)
|
|
327
|
+
Returns:
|
|
328
|
+
np.ndarray:
|
|
329
|
+
Wavelength array (microns) corresponding to each pixel in arc1d
|
|
330
|
+
"""
|
|
331
|
+
|
|
332
|
+
return solve_dispersion_from_arc1d(
|
|
333
|
+
arc1d,
|
|
334
|
+
wl_range=wl_range,
|
|
335
|
+
ref_lines_um=np.asarray(ref_lines_um, float),
|
|
336
|
+
deg=3,
|
|
337
|
+
anchors=anchors_global,
|
|
338
|
+
max_sep=0.012,
|
|
339
|
+
verbose=verbose,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def average_wavecal_across_cols(
|
|
344
|
+
arc_img: np.ndarray,
|
|
345
|
+
center_col: int,
|
|
346
|
+
*,
|
|
347
|
+
half: int,
|
|
348
|
+
ref_lines_um: np.ndarray,
|
|
349
|
+
wl_range: tuple[float, float] = None,
|
|
350
|
+
anchors: Sequence[tuple[int, float]] | None = None,
|
|
351
|
+
deg: int = 3,
|
|
352
|
+
max_sep: float = 0.012,
|
|
353
|
+
) -> np.ndarray:
|
|
354
|
+
"""
|
|
355
|
+
Purpose:
|
|
356
|
+
Compute averaged wavelength-per-pixel solution across small
|
|
357
|
+
footprint of columns centered on center_col. Each column is extracted
|
|
358
|
+
with local background, solved independently, then averaged and aligned
|
|
359
|
+
to the given wl_range
|
|
360
|
+
Inputs:
|
|
361
|
+
arc_img: 2D arc image array (rows x cols)
|
|
362
|
+
center_col: Central column index around which to average solutions
|
|
363
|
+
half: Use columns in [center_col - half, center_col + half]
|
|
364
|
+
ref_lines_um: 1D array of reference wavelengths (microns)
|
|
365
|
+
wl_range: Tuple (wl_lo, wl_hi) microns for target coverage (required)
|
|
366
|
+
anchors: Optional anchor list passed to the solver (default None)
|
|
367
|
+
deg: Chebyshev degree for per-column dispersion fits (default 3)
|
|
368
|
+
max_sep: Max separation for peak-ref matching (default 0.012)
|
|
369
|
+
Returns:
|
|
370
|
+
np.ndarray:
|
|
371
|
+
Averaged wavelength solution (microns) on the native pixel grid,
|
|
372
|
+
strictly increasing and clipped to wl_range
|
|
373
|
+
Raises:
|
|
374
|
+
ValueError: If wl_range or ref_lines_um are missing/empty
|
|
375
|
+
"""
|
|
376
|
+
|
|
377
|
+
if wl_range is None:
|
|
378
|
+
raise ValueError("average_wavecal_across_cols: wl_range must be provided")
|
|
379
|
+
|
|
380
|
+
if ref_lines_um is None or len(ref_lines_um) == 0:
|
|
381
|
+
raise ValueError("average_wavecal_across_cols: ref_lines_um must be provided")
|
|
382
|
+
|
|
383
|
+
ncols = arc_img.shape[1]
|
|
384
|
+
cols = [center_col + dc for dc in range(-half, half + 1)]
|
|
385
|
+
cols = [c for c in cols if 0 <= c < ncols]
|
|
386
|
+
wl_list = []
|
|
387
|
+
|
|
388
|
+
for c in cols:
|
|
389
|
+
a1d = extract_with_local_bg_simple(arc_img, c)
|
|
390
|
+
wl_c = solve_dispersion_from_arc1d(
|
|
391
|
+
a1d,
|
|
392
|
+
wl_range=wl_range,
|
|
393
|
+
ref_lines_um=np.asarray(ref_lines_um, float),
|
|
394
|
+
deg=deg,
|
|
395
|
+
anchors=anchors,
|
|
396
|
+
max_sep=max_sep,
|
|
397
|
+
verbose=False,
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
if wl_c[0] > wl_c[-1]:
|
|
401
|
+
wl_c = wl_c[::-1]
|
|
402
|
+
wl_list.append(wl_c)
|
|
403
|
+
|
|
404
|
+
wl_avg = np.nanmean(np.vstack(wl_list), axis=0)
|
|
405
|
+
|
|
406
|
+
if wl_avg[0] > wl_avg[-1]:
|
|
407
|
+
wl_avg = wl_avg[::-1]
|
|
408
|
+
|
|
409
|
+
wl_min, wl_max = wl_range
|
|
410
|
+
span_fit = wl_avg[-1] - wl_avg[0]
|
|
411
|
+
span_tar = wl_max - wl_min
|
|
412
|
+
|
|
413
|
+
if abs(span_fit - span_tar) / max(span_tar, 1e-12) > 0.002:
|
|
414
|
+
a = span_tar / (span_fit + 1e-12)
|
|
415
|
+
b = wl_min - a * wl_avg[0]
|
|
416
|
+
wl_avg = a * wl_avg + b
|
|
417
|
+
|
|
418
|
+
else:
|
|
419
|
+
wl_avg = wl_avg + (wl_min - wl_avg[0])
|
|
420
|
+
wl_avg = np.clip(wl_avg, wl_min, wl_max)
|
|
421
|
+
|
|
422
|
+
return wl_avg
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def extract_with_local_bg_simple(
|
|
426
|
+
img: np.ndarray,
|
|
427
|
+
center_col: int,
|
|
428
|
+
*,
|
|
429
|
+
ap: int = 5,
|
|
430
|
+
bg_in: int = 8,
|
|
431
|
+
bg_out: int = 18,
|
|
432
|
+
) -> np.ndarray:
|
|
433
|
+
"""
|
|
434
|
+
Purpose:
|
|
435
|
+
Minimal local background subtraction used by average_wavecal_across_cols:
|
|
436
|
+
median-collapses small aperture around center_col and subtracts
|
|
437
|
+
background estimated from side bands
|
|
438
|
+
Inputs:
|
|
439
|
+
img: 2D image array (rows, cols)
|
|
440
|
+
center_col: Central column index for extraction
|
|
441
|
+
ap: Half-width of extraction aperture in columns (default 5)
|
|
442
|
+
bg_in: Inner offset for background windows (default 8)
|
|
443
|
+
bg_out: Outer offset for background windows (default 18)
|
|
444
|
+
Returns:
|
|
445
|
+
np.ndarray:
|
|
446
|
+
Extracted per-row 1D profile after local background subtraction
|
|
447
|
+
"""
|
|
448
|
+
|
|
449
|
+
nrows, ncols = img.shape
|
|
450
|
+
lo = max(0, center_col - ap)
|
|
451
|
+
hi = min(ncols, center_col + ap + 1)
|
|
452
|
+
bg_left = img[:, max(0, center_col - bg_out) : max(0, center_col - bg_in)]
|
|
453
|
+
bg_right = img[:, min(ncols, center_col + bg_in) : min(ncols, center_col + bg_out)]
|
|
454
|
+
|
|
455
|
+
if bg_left.size == 0 and bg_right.size == 0:
|
|
456
|
+
bg = np.zeros(nrows, dtype=float)
|
|
457
|
+
|
|
458
|
+
else:
|
|
459
|
+
bg = np.median(
|
|
460
|
+
np.concatenate([bg_left, bg_right], axis=1), axis=1
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
spec = np.median(img[:, lo:hi], axis=1) - bg
|
|
464
|
+
|
|
465
|
+
return spec
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
# -------------------- Interp-edge mask (for coaddition step) --------------------
|
|
469
|
+
def mask_interp_edge_artifacts(
|
|
470
|
+
grid_wl: np.ndarray,
|
|
471
|
+
wl_native: np.ndarray,
|
|
472
|
+
f_native: np.ndarray,
|
|
473
|
+
err_native: np.ndarray,
|
|
474
|
+
*,
|
|
475
|
+
min_span_px: int = 10,
|
|
476
|
+
pad_bins: int = 8,
|
|
477
|
+
min_keep_bins: int = 24,
|
|
478
|
+
) -> np.ndarray:
|
|
479
|
+
"""
|
|
480
|
+
Purpose:
|
|
481
|
+
Create a boolean mask on the coadd grid to exclude bins near interpolation
|
|
482
|
+
edges, keeping only interior segments where the native spectrum spans a
|
|
483
|
+
sufficiently long contiguous region. This reduces spurious edge artifacts
|
|
484
|
+
Inputs:
|
|
485
|
+
grid_wl: 1D target wavelength grid for coaddition
|
|
486
|
+
wl_native: 1D native wavelengths where the spectrum is defined
|
|
487
|
+
f_native: 1D native flux (unused for masking, kept for API symmetry)
|
|
488
|
+
err_native: 1D native errors (unused for masking, kept for API symmetry)
|
|
489
|
+
min_span_px: Minimum contiguous native span (pixels) to consider (default 10)
|
|
490
|
+
pad_bins: Bins to exclude from each side of a contiguous span (default 8)
|
|
491
|
+
min_keep_bins: Minimum interior bins to retain after padding (default 24)
|
|
492
|
+
Returns:
|
|
493
|
+
np.ndarray:
|
|
494
|
+
Boolean mask on grid_wl where True indicates bins to keep
|
|
495
|
+
False near edges or outside the native coverage
|
|
496
|
+
"""
|
|
497
|
+
grid_wl = np.asarray(grid_wl, float)
|
|
498
|
+
wl_native = np.asarray(wl_native, float)
|
|
499
|
+
if wl_native.size < max(3, min_span_px) or not np.any(np.isfinite(wl_native)):
|
|
500
|
+
return np.zeros_like(grid_wl, dtype=bool)
|
|
501
|
+
wmin = np.nanmin(wl_native)
|
|
502
|
+
wmax = np.nanmax(wl_native)
|
|
503
|
+
inside = np.isfinite(grid_wl) & (grid_wl > wmin) & (grid_wl < wmax)
|
|
504
|
+
|
|
505
|
+
keep = np.zeros_like(inside, dtype=bool)
|
|
506
|
+
n = inside.size
|
|
507
|
+
i = 0
|
|
508
|
+
while i < n:
|
|
509
|
+
if not inside[i]:
|
|
510
|
+
i += 1
|
|
511
|
+
continue
|
|
512
|
+
j = i
|
|
513
|
+
while j < n and inside[j]:
|
|
514
|
+
j += 1
|
|
515
|
+
ii = i + pad_bins
|
|
516
|
+
jj = j - pad_bins
|
|
517
|
+
if jj - ii >= min_keep_bins:
|
|
518
|
+
keep[ii:jj] = True
|
|
519
|
+
i = j
|
|
520
|
+
return keep
|