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