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