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/io.py ADDED
@@ -0,0 +1,248 @@
1
+ # firepype/io.py
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ from typing import Iterable, List, Tuple
6
+
7
+ import numpy as np
8
+ from astropy.io import fits
9
+
10
+
11
+ def parse_id_list(spec: str) -> List[int]:
12
+ """
13
+ Parse a frame-ID specification like "1-4, 8, 10-7" into a sorted, unique
14
+ list of integers: [1, 2, 3, 4, 7, 8, 9, 10].
15
+
16
+ - Ranges are inclusive.
17
+ - Ranges may be descending (e.g., "10-7").
18
+ - Whitespace is ignored.
19
+ - Duplicates are removed (e.g., "1-4,3-6" -> 1..6).
20
+ """
21
+ ids: set[int] = set()
22
+ for part in spec.split(","):
23
+ token = part.strip()
24
+ if not token:
25
+ continue
26
+ if "-" in token:
27
+ a_str, b_str = token.split("-", 1)
28
+ a = int(a_str.strip())
29
+ b = int(b_str.strip())
30
+ lo, hi = (a, b) if a <= b else (b, a)
31
+ ids.update(range(lo, hi + 1))
32
+ else:
33
+ ids.add(int(token))
34
+ return sorted(ids)
35
+
36
+
37
+ def pairs_from_ids(ids: Iterable[int]) -> List[Tuple[int, int]]:
38
+ """
39
+ Purpose:
40
+ Form consecutive AB pairs from a list of frame IDs. If count is odd,
41
+ last frame is dropped to maintain pairing
42
+ Inputs:
43
+ ids: Iterable of frame IDs (ints). Order doesn't matter
44
+ Returns:
45
+ list[tuple[int, int]]:
46
+ List of (A_id, B_id) pairs formed consecutively after sorting
47
+ e.g. [1,2,3,4] -> [(1,2),(3,4)]
48
+ """
49
+
50
+ ids_sorted = sorted(int(x) for x in ids)
51
+ if len(ids_sorted) < 2:
52
+ return []
53
+ if len(ids_sorted) % 2 != 0:
54
+ # Drop the last ID to maintain pairs
55
+ ids_sorted = ids_sorted[:-1]
56
+ return [(ids_sorted[i], ids_sorted[i + 1]) for i in range(0, len(ids_sorted), 2)]
57
+
58
+
59
+ def build_fire_path(
60
+ base_dir: str | Path,
61
+ num: int,
62
+ prefix: str = "fire_",
63
+ pad: int = 4,
64
+ ext: str = ".fits",
65
+ ) -> str:
66
+ """
67
+ Purpose:
68
+ Build standard FIRE file path from components
69
+ e.g. build_fire_path('/data/raw', 23) -> '/data/raw/fire_0023.fits
70
+ Inputs:
71
+ base_dir: Base directory containing the files
72
+ num: Integer ID to be zero-padded
73
+ prefix: Filename prefix (default 'fire_')
74
+ pad: Zero-padding width for the ID (default 4)
75
+ ext: File extension, including dot (default '.fits')
76
+ Returns:
77
+ The constructed file path as a POSIX string
78
+ """
79
+
80
+ base = Path(base_dir)
81
+ return (base / f"{prefix}{num:0{pad}d}{ext}").as_posix()
82
+
83
+
84
+ def load_fits(path: str | Path):
85
+ """
86
+ Purpose:
87
+ Load primary HDU data and header from a FITS file with validation
88
+ Inputs:
89
+ path: Path to the FITS file
90
+ Returns:
91
+ tuple:
92
+ - data (np.ndarray): Primary HDU data as float32
93
+ - header (fits.Header): Copy of the primary header
94
+ Raises:
95
+ FileNotFoundError: If FITS file does not exist
96
+ ValueError: If FITS file has no primary data
97
+ """
98
+
99
+ p = Path(path)
100
+ if not p.exists():
101
+ raise FileNotFoundError(f"FITS file not found: {p}")
102
+ with fits.open(p.as_posix()) as hdul:
103
+ data = hdul[0].data
104
+ header = hdul[0].header.copy()
105
+ if data is None:
106
+ raise ValueError(f"No primary data in FITS file: {p}")
107
+ return np.asarray(data, dtype=np.float32), header
108
+
109
+
110
+ def save_fits(path: str | Path, data, header):
111
+ """
112
+ Purpose:
113
+ Save array and header as a primary FITS HDU. Parent directories
114
+ are created if needed
115
+ Inputs:
116
+ path: Output file path
117
+ data: Numpy array to write as primary image
118
+ header: fits.Header to attach to the primary HDU
119
+ Returns:
120
+ Saves FITS file
121
+ """
122
+
123
+ p = Path(path)
124
+ p.parent.mkdir(parents=True, exist_ok=True)
125
+ fits.writeto(p.as_posix(), data, header, overwrite=True)
126
+
127
+
128
+ def write_coadd_fits_with_header(
129
+ path: str | Path,
130
+ wl_um: np.ndarray,
131
+ flux: np.ndarray,
132
+ base_header: fits.Header,
133
+ frames_used: List[int],
134
+ spectra_used_count: int,
135
+ wl_range: Tuple[float, float],
136
+ grid_step: float,
137
+ extra_history: List[str] | None = None,
138
+ ):
139
+ """
140
+ Purpose:
141
+ Write co-added spectrum to 2-HDU FITS file:
142
+ - Primary HDU copies base_header and is augmented with metadata
143
+ - Binary table HDU contains columns wavelength_um (D) and flux (E)
144
+ Inputs:
145
+ path: Output FITS path
146
+ wl_um: 1D array of wavelengths in microns
147
+ flux: 1D array of flux values
148
+ base_header: Header to copy into the primary HDU
149
+ frames_used: List of frame IDs that contributed to the coadd
150
+ spectra_used_count: Number of spectra accumulated in the coadd
151
+ wl_range: Tuple (wl_lo, wl_hi) in microns for metadata
152
+ grid_step: Wavelength step (microns) of the coadd grid
153
+ extra_history: Optional list of strings to append as HISTORY
154
+ Returns:
155
+ Saves FITS file with header metadata and table
156
+ """
157
+
158
+ wl = np.asarray(wl_um, float)
159
+ fx = np.asarray(flux, np.float32)
160
+
161
+ cols = [
162
+ fits.Column(name="wavelength_um", array=wl, format="D"),
163
+ fits.Column(name="flux", array=fx, format="E"),
164
+ ]
165
+
166
+ table_hdu = fits.BinTableHDU.from_columns(cols)
167
+ table_hdu.header["TTYPE1"] = "wavelength_um"
168
+ table_hdu.header["TTYPE2"] = "flux"
169
+ table_hdu.header["BUNIT"] = ("arb", "Flux units (median counts arbitrary)")
170
+
171
+ prim_hdu = fits.PrimaryHDU(header=base_header.copy())
172
+ prim = prim_hdu.header
173
+ prim["PIPESTEP"] = ("COADD", "This file is a co-added 1D spectrum")
174
+ prim["COADDS"] = (int(spectra_used_count), "Number of spectra accumulated in coadd")
175
+ if frames_used:
176
+ prim["COADF1"] = (str(frames_used[0]), "First raw frame used")
177
+ prim["COADFN"] = (str(frames_used[-1]), "Last raw frame used")
178
+ prim["COADLST"] = (
179
+ ",".join(map(str, frames_used))[:68],
180
+ "List of frames (truncated)",
181
+ )
182
+
183
+ prim["WLRANGE"] = (
184
+ f"{wl_range[0]:.3f}-{wl_range[1]:.3f}",
185
+ "Wavelength range (micron)",
186
+ )
187
+
188
+ prim["DLAM"] = (float(grid_step), "Coadd wavelength step (micron)")
189
+
190
+ if extra_history:
191
+ for h in extra_history:
192
+ s = str(h).encode("ascii", "replace").decode("ascii")
193
+ s = s.replace("µm", "um").replace("μm", "um")
194
+ prim["HISTORY"] = s
195
+
196
+ hdul = fits.HDUList([prim_hdu, table_hdu])
197
+ p = Path(path)
198
+ p.parent.mkdir(parents=True, exist_ok=True)
199
+ hdul.writeto(p.as_posix(), overwrite=True)
200
+
201
+
202
+ def write_spectrum_with_err(
203
+ path: str | Path,
204
+ wl_um: np.ndarray,
205
+ flux: np.ndarray,
206
+ err: np.ndarray | None,
207
+ base_header: fits.Header,
208
+ extra_history: List[str] | None = None,
209
+ ):
210
+ """
211
+ Purpose:
212
+ Write spectrum with optional errors to 2-HDU FITS file:
213
+ - Primary HDU carries base_header (and optional HISTORY)
214
+ - Binary table HDU includes wavelength_um, flux, and optional flux_err
215
+ Inputs:
216
+ path: Output FITS path
217
+ wl_um: 1D array of wavelengths in microns
218
+ flux: 1D array of flux values
219
+ err: Optional 1D array of flux errors (same length as flux)
220
+ base_header: Header to copy into primary HDU
221
+ extra_history: Optional list of strings to append as HISTORY
222
+ Returns:
223
+ Saves FITS file with spectrum and error table
224
+ """
225
+
226
+ wl = np.asarray(wl_um, float)
227
+ fx = np.asarray(flux, np.float32)
228
+
229
+ cols = [
230
+ fits.Column(name="wavelength_um", array=wl, format="D"),
231
+ fits.Column(name="flux", array=fx, format="E"),
232
+ ]
233
+
234
+ if err is not None:
235
+ ee = np.asarray(err, np.float32)
236
+ cols.append(fits.Column(name="flux_err", array=ee, format="E"))
237
+
238
+ table_hdu = fits.BinTableHDU.from_columns(cols)
239
+ prim_hdu = fits.PrimaryHDU(header=base_header.copy())
240
+ if extra_history:
241
+ for h in extra_history:
242
+ s = str(h).encode("ascii", "ignore").decode("ascii")
243
+ prim_hdu.header["HISTORY"] = s
244
+
245
+ hdul = fits.HDUList([prim_hdu, table_hdu])
246
+ p = Path(path)
247
+ p.parent.mkdir(parents=True, exist_ok=True)
248
+ hdul.writeto(p.as_posix(), overwrite=True)
firepype/pipeline.py ADDED
@@ -0,0 +1,339 @@
1
+ # firepype/pipeline.py
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ import numpy as np
6
+
7
+ from .config import PipelineConfig
8
+ from .io import (
9
+ parse_id_list,
10
+ pairs_from_ids,
11
+ build_fire_path,
12
+ load_fits,
13
+ save_fits,
14
+ write_spectrum_with_err,
15
+ )
16
+
17
+ from .utils import (
18
+ load_line_list_to_microns,
19
+ assert_monotonic_and_align,
20
+ orient_to_increasing,
21
+ clean_bool_runs,
22
+ )
23
+
24
+ from .detection import (
25
+ detect_slit_edges,
26
+ detect_objects_in_slit,
27
+ find_arc_trace_col_strong,
28
+ estimate_parity,
29
+ refine_neg_column_local,
30
+ estimate_negative_scale_robust,
31
+ )
32
+
33
+ from .extraction import extract_with_local_bg, extract_cols_median_with_err
34
+ from .calibration import average_wavecal_across_cols, mask_interp_edge_artifacts
35
+ from .coadd import CoaddAccumulator
36
+ from .plotting import (
37
+ plot_arc_trace_on_raw,
38
+ plot_arc_1d_with_line_labels,
39
+ plot_1d_spectrum,
40
+ )
41
+
42
+
43
+ def run_ab_pairs(cfg: PipelineConfig):
44
+ """
45
+ Purpose:
46
+ Execute AB-pair near-IR reduction pipeline to produce co-added 1D spectrum.
47
+ For each AB pair:
48
+ - Load A and B frames, form A-B and B-A subtractions
49
+ - Detect slit edges and object positions on subtracted frame
50
+ - Choose strong arc column in the arc image near the object
51
+ - Solve wavelength solution across nearby columns
52
+ - Determine parity and extract positive/negative spectra with errors
53
+ - Refine negative-object column and estimate scale factor
54
+ - Combine positive and scaled-negative spectra
55
+ - Reorient, clean, interpolate to grid, mask edges
56
+ - Accumulate into a variance-weighted coadd
57
+ Writes intermediate subtractions, optional QA plots, and a final coadd FITS
58
+ Inputs:
59
+ cfg: PipelineConfig
60
+ Configuration object containing:
61
+ - run: paths, user_spec (frame IDs), out_dir, arc_path, ref_list_path
62
+ - slit: slit_x_hint, slit_hint_expand, row_fraction
63
+ - extraction: ap_radius, bg_in, bg_out, footprint_half, row_fraction
64
+ - wavecal: wl_range, grid_step, deg, max_sep, band_anchors_global
65
+ - qa: qa_dir, save_figs, verbose
66
+ Returns:
67
+ tuple:
68
+ - coadd_grid (np.ndarray): Common wavelength grid in microns
69
+ - coadd_flux (np.ndarray): Co-added flux on coadd_grid
70
+ - coadd_err (np.ndarray): 1-sigma uncertainties on coadd_grid
71
+ Raises:
72
+ RuntimeError:
73
+ If no AB pairs can be formed from cfg.run.user_spec
74
+ """
75
+
76
+ # Parse frame IDs and build AB pairs
77
+ ids = parse_id_list(cfg.run.user_spec)
78
+ pairs = pairs_from_ids(ids)
79
+ if not pairs:
80
+ raise RuntimeError("No AB pairs formed. Provide an even number of frames in user_spec")
81
+
82
+ # Prepare grid and accumulator
83
+ wl_lo, wl_hi = cfg.wavecal.wl_range
84
+ coadd_grid = np.arange(wl_lo, wl_hi + cfg.wavecal.grid_step / 2.0, cfg.wavecal.grid_step)
85
+ coadd = CoaddAccumulator(coadd_grid)
86
+
87
+ # Load calibration data
88
+ refs = load_line_list_to_microns(str(cfg.run.ref_list_path))
89
+ arc_data, _ = load_fits(str(cfg.run.arc_path))
90
+
91
+ base_header = None
92
+ spectra_used = 0
93
+
94
+ # Ensure output dirs
95
+ Path(cfg.run.out_dir).mkdir(parents=True, exist_ok=True)
96
+ Path(cfg.qa.qa_dir).mkdir(parents=True, exist_ok=True)
97
+
98
+ # Process AB pairs
99
+ for (A_id, B_id) in pairs:
100
+ A_path = build_fire_path(str(cfg.run.raw_dir), A_id)
101
+ B_path = build_fire_path(str(cfg.run.raw_dir), B_id)
102
+
103
+ if base_header is None:
104
+ _, hdr0 = load_fits(A_path)
105
+ base_header = hdr0
106
+
107
+ A, hdrA = load_fits(A_path)
108
+ B, hdrB = load_fits(B_path)
109
+
110
+ # AB and BA subtractions
111
+ A_sub = A - B
112
+ B_sub = B - A
113
+
114
+ # Save subtracted frames
115
+ save_fits(Path(cfg.run.out_dir) / f"A_sub_{A_id}-{B_id}.fits", A_sub, hdrA)
116
+ save_fits(Path(cfg.run.out_dir) / f"B_sub_{A_id}-{B_id}.fits", B_sub, hdrB)
117
+
118
+ # Process A and B differences
119
+ for tag, data_sub, hdr in [("A", A_sub, hdrA), ("B", B_sub, hdrB)]:
120
+
121
+ # Slit edges and object positions
122
+ left_edge, right_edge, *_ = detect_slit_edges(
123
+ data_sub,
124
+ x_hint=cfg.slit.slit_x_hint,
125
+ hint_expand=cfg.slit.slit_hint_expand,
126
+ row_frac=cfg.slit.row_fraction,
127
+ debug=cfg.qa.verbose,
128
+ )
129
+
130
+ obj_pos_abs, obj_neg_abs, _, _ = detect_objects_in_slit(
131
+ data_sub,
132
+ left_edge,
133
+ right_edge,
134
+ row_frac=(0.40, 0.80),
135
+ debug=cfg.qa.verbose,
136
+ )
137
+
138
+ # Choose strong arc column near the positive object
139
+ arc_col = find_arc_trace_col_strong(
140
+ arc_data,
141
+ approx_col=obj_pos_abs,
142
+ search_half=240,
143
+ x_hint=cfg.slit.slit_x_hint,
144
+ row_frac=cfg.extraction.row_fraction,
145
+ debug_print=cfg.qa.verbose,
146
+ )
147
+
148
+ # Optional QA plots
149
+ if cfg.qa.save_figs:
150
+ plot_arc_trace_on_raw(
151
+ arc_data,
152
+ center_col=arc_col,
153
+ ap=int(cfg.extraction.ap_radius),
154
+ bg_in=int(cfg.extraction.bg_in),
155
+ bg_out=int(cfg.extraction.bg_out),
156
+ half=int(cfg.extraction.footprint_half),
157
+ row_frac=cfg.extraction.row_fraction,
158
+ title=f"ARC trace — {tag}_sub {A_id}-{B_id} (ARC col {arc_col})",
159
+ save_path=Path(cfg.qa.qa_dir)
160
+ / f"arc_trace_AUTO_{tag}_{A_id}-{B_id}_col{arc_col}.pdf",
161
+ show=False,
162
+ )
163
+
164
+ arc_1d = extract_with_local_bg(
165
+ arc_data,
166
+ arc_col,
167
+ ap=cfg.extraction.ap_radius,
168
+ bg_in=cfg.extraction.bg_in,
169
+ bg_out=cfg.extraction.bg_out,
170
+ )
171
+
172
+ plot_arc_1d_with_line_labels(
173
+ arc_1d,
174
+ wl_range=cfg.wavecal.wl_range,
175
+ ref_lines_um=refs,
176
+ anchors=cfg.wavecal.band_anchors_global,
177
+ solver_deg=cfg.wavecal.deg,
178
+ solver_max_sep=cfg.wavecal.max_sep,
179
+ arc_col=arc_col,
180
+ title_tag=f"{tag}_sub {A_id}-{B_id}",
181
+ save_path=Path(cfg.qa.qa_dir)
182
+ / f"arc_1d_with_labels_{tag}_{A_id}-{B_id}_col{arc_col}.pdf",
183
+ show=False,
184
+ )
185
+
186
+ # Wavecal
187
+ wavelengths_per_pixel = average_wavecal_across_cols(
188
+ arc_data,
189
+ arc_col,
190
+ half=int(cfg.extraction.footprint_half),
191
+ ref_lines_um=refs,
192
+ wl_range=cfg.wavecal.wl_range,
193
+ anchors=cfg.wavecal.band_anchors_global,
194
+ deg=cfg.wavecal.deg,
195
+ max_sep=cfg.wavecal.max_sep,
196
+ )
197
+
198
+ # Parity and extraction
199
+ par = estimate_parity(data_sub, arc_col, obj_neg_abs)
200
+ data_sub_par = data_sub if par >= 0 else (-data_sub)
201
+
202
+ pos_spec, pos_err = extract_cols_median_with_err(
203
+ data_sub_par,
204
+ arc_col,
205
+ half=int(cfg.extraction.footprint_half),
206
+ ap=int(cfg.extraction.ap_radius),
207
+ bg_in=int(cfg.extraction.bg_in),
208
+ bg_out=int(cfg.extraction.bg_out),
209
+ )
210
+
211
+ neg_spec, neg_err = extract_cols_median_with_err(
212
+ data_sub_par,
213
+ obj_neg_abs,
214
+ half=int(cfg.extraction.footprint_half),
215
+ ap=int(cfg.extraction.ap_radius),
216
+ bg_in=int(cfg.extraction.bg_in),
217
+ bg_out=int(cfg.extraction.bg_out),
218
+ )
219
+
220
+ # Refine negative column and scale
221
+ obj_neg_ref = refine_neg_column_local(
222
+ data_sub_par,
223
+ arc_col,
224
+ obj_neg_abs,
225
+ search_half=8,
226
+ ap=int(cfg.extraction.ap_radius),
227
+ )
228
+
229
+ g = estimate_negative_scale_robust(
230
+ data_sub_par,
231
+ arc_col,
232
+ obj_neg_ref,
233
+ ap=int(cfg.extraction.ap_radius),
234
+ row_exclude_frac=(0.40, 0.80),
235
+ g_limits=(0.1, 10.0),
236
+ )
237
+
238
+ # Combine and orient
239
+ sci_combined = pos_spec - g * neg_spec
240
+ sci_err = np.sqrt(pos_err**2 + (g * neg_err) ** 2)
241
+
242
+ wavelengths_per_pixel, sci_combined = orient_to_increasing(
243
+ wavelengths_per_pixel, sci_combined
244
+ )
245
+
246
+ _, sci_err = orient_to_increasing(wavelengths_per_pixel, sci_err)
247
+
248
+ wl_m, sci_m = assert_monotonic_and_align(
249
+ wavelengths_per_pixel, sci_combined, name=f"{tag}_{A_id}-{B_id}"
250
+ )
251
+
252
+ wl_m, err_m = assert_monotonic_and_align(
253
+ wavelengths_per_pixel, sci_err, name=f"{tag}_ERR_{A_id}-{B_id}"
254
+ )
255
+
256
+ # Interpolate to coadd grid and mask edges
257
+ if wl_m.size >= 5:
258
+ K = 6
259
+ if wl_m.size > 2 * K:
260
+ wl_trim = wl_m[K:-K]
261
+ sci_trim = sci_m[K:-K]
262
+ err_trim = err_m[K:-K]
263
+ else:
264
+ wl_trim, sci_trim, err_trim = wl_m, sci_m, err_m
265
+
266
+ f_interp = np.interp(
267
+ coadd_grid, wl_trim, sci_trim, left=np.nan, right=np.nan
268
+ )
269
+
270
+ e_interp = np.interp(
271
+ coadd_grid, wl_trim, err_trim, left=np.nan, right=np.nan
272
+ )
273
+
274
+ edge_mask = mask_interp_edge_artifacts(
275
+ coadd_grid,
276
+ wl_trim,
277
+ sci_trim,
278
+ err_trim,
279
+ min_span_px=10,
280
+ pad_bins=6,
281
+ min_keep_bins=18,
282
+ )
283
+
284
+ m_valid = (
285
+ edge_mask
286
+ & np.isfinite(f_interp)
287
+ & np.isfinite(e_interp)
288
+ & (e_interp > 0)
289
+ )
290
+
291
+ m_valid = clean_bool_runs(m_valid, min_run=18)
292
+
293
+ if np.count_nonzero(m_valid) >= 5:
294
+ coadd.add_spectrum(f_interp, e_interp, mask=m_valid)
295
+ spectra_used += 1
296
+
297
+ # Finalize coadd
298
+ coadd_flux, coadd_err, coadd_mask = coadd.finalize()
299
+
300
+ out_fits = (
301
+ Path(cfg.run.out_dir)
302
+ / f"coadd_spectrum_{cfg.run.user_spec.replace(',','-').replace(' ','')}.fits"
303
+ )
304
+
305
+ history = [
306
+ f"Frames: {','.join(map(str, parse_id_list(cfg.run.user_spec)))[:60]}",
307
+ f"WL_RANGE={cfg.wavecal.wl_range}, GRID_STEP={cfg.wavecal.grid_step}",
308
+ "Wavecal: robust peak matching + Chebyshev fit; endpoint alignment; anchors",
309
+ "Interpolation edges masked; small islands removed",
310
+ ]
311
+
312
+ write_spectrum_with_err(
313
+ out_fits,
314
+ coadd_grid.astype(float),
315
+ coadd_flux.astype(np.float32),
316
+ coadd_err.astype(np.float32),
317
+ base_header=base_header if base_header is not None else None,
318
+ extra_history=history,
319
+ )
320
+
321
+ # Optional final plot
322
+ if cfg.qa.save_figs:
323
+ mask = coadd_mask & np.isfinite(coadd_err) & (coadd_err > 0)
324
+ pdf = (
325
+ Path(cfg.qa.qa_dir)
326
+ / f"coadd_{cfg.run.user_spec.replace(',','-').replace(' ','')}.pdf"
327
+ )
328
+
329
+ plot_1d_spectrum(
330
+ coadd_grid[mask],
331
+ coadd_flux[mask],
332
+ "Co-added spectrum",
333
+ pdf,
334
+ xlabel="Wavelength (um)",
335
+ ylabel="Flux (arb.)",
336
+ show=False,
337
+ )
338
+
339
+ return coadd_grid, coadd_flux, coadd_err