SpecProc 1.0.0__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.
Files changed (61) hide show
  1. specproc/__init__.py +9 -0
  2. specproc/calib_data/linelists/FeAr.dat +375 -0
  3. specproc/calib_data/linelists/README +79 -0
  4. specproc/calib_data/linelists/airvac.cl +61 -0
  5. specproc/calib_data/linelists/argon.dat +161 -0
  6. specproc/calib_data/linelists/ctiohear.dat +106 -0
  7. specproc/calib_data/linelists/ctiohenear.dat +146 -0
  8. specproc/calib_data/linelists/cuar.dat +459 -0
  9. specproc/calib_data/linelists/henear.dat +93 -0
  10. specproc/calib_data/linelists/henearhres.dat +300 -0
  11. specproc/calib_data/linelists/idhenear.dat +127 -0
  12. specproc/calib_data/linelists/krypton.dat +109 -0
  13. specproc/calib_data/linelists/lowskylines.dat +577 -0
  14. specproc/calib_data/linelists/ohlines.dat +268 -0
  15. specproc/calib_data/linelists/skylines.dat +1385 -0
  16. specproc/calib_data/linelists/thar-noao.dat +4169 -0
  17. specproc/calib_data/linelists/thar.dat +3057 -0
  18. specproc/calib_data/linelists/thorium.dat +3057 -0
  19. specproc/calib_data/linelists/vacidhenear.dat +127 -0
  20. specproc/calib_data/linelists/vacthar.dat +3057 -0
  21. specproc/calib_data/linelists/vacthorium.dat +3057 -0
  22. specproc/calib_data/linelists/xenon.dat +89 -0
  23. specproc/calib_data/telescopes/lijiang24hrs/lijiang24_hrs_thar_lines.csv +550 -0
  24. specproc/calib_data/telescopes/xinglong216bfosc/BFOSC/345/256/232/346/240/207/347/201/257/350/260/261/350/256/244/350/257/201/346/211/213/345/206/214v171118.pdf +0 -0
  25. specproc/calib_data/telescopes/xinglong216bfosc//346/212/245/345/221/212/345/233/276.docx +0 -0
  26. specproc/calib_data/telescopes/xinglong216hrs/216HRS /347/201/257/350/260/261/350/257/201/350/256/244/346/226/207/344/273/266.pdf +0 -0
  27. specproc/calib_data/telescopes/xinglong216hrs/wlcalib_20211123011_A.png +0 -0
  28. specproc/calib_data/telescopes/xinglong216hrs/xinglong216_thar_lines.csv +517 -0
  29. specproc/config/__init__.py +1 -0
  30. specproc/config/config_manager.py +431 -0
  31. specproc/core/__init__.py +1 -0
  32. specproc/core/basic_reduction.py +615 -0
  33. specproc/core/data_structures.py +525 -0
  34. specproc/core/de_blazing.py +210 -0
  35. specproc/core/extraction.py +375 -0
  36. specproc/core/flat_correction.py +1255 -0
  37. specproc/core/line_identification.py +302 -0
  38. specproc/core/order_stitching.py +187 -0
  39. specproc/core/order_tracing.py +1587 -0
  40. specproc/core/processing_pipeline.py +1183 -0
  41. specproc/core/scattered_light.py +654 -0
  42. specproc/core/slit_pipeline.py +762 -0
  43. specproc/core/wave_calibration.py +961 -0
  44. specproc/gui/__init__.py +1 -0
  45. specproc/gui/line_identifier.py +731 -0
  46. specproc/gui/main_window.py +2315 -0
  47. specproc/gui/settings_dialog.py +946 -0
  48. specproc/main.py +388 -0
  49. specproc/plotting/__init__.py +1 -0
  50. specproc/plotting/spectra_plotter.py +483 -0
  51. specproc/utils/__init__.py +1 -0
  52. specproc/utils/airvac.py +123 -0
  53. specproc/utils/fits_io.py +269 -0
  54. specproc/utils/image_processing.py +426 -0
  55. specproc/utils/overscan.py +600 -0
  56. specproc-1.0.0.dist-info/METADATA +668 -0
  57. specproc-1.0.0.dist-info/RECORD +61 -0
  58. specproc-1.0.0.dist-info/WHEEL +5 -0
  59. specproc-1.0.0.dist-info/entry_points.txt +5 -0
  60. specproc-1.0.0.dist-info/licenses/LICENSE +674 -0
  61. specproc-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,210 @@
1
+ """
2
+ De-blazing correction stage.
3
+
4
+ Corrects for the blaze function of the spectrograph grating
5
+ to flatten the spectral response.
6
+ """
7
+ # 根据平场闪耀轮廓校正提取光谱响应。
8
+
9
+ import numpy as np
10
+ import logging
11
+ from pathlib import Path
12
+ from typing import Optional
13
+ from specproc.core.data_structures import SpectraSet, FlatField
14
+ from specproc.plotting.spectra_plotter import plot_spectrum_to_file, plot_spectra_to_pdf
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ # 用平场闪耀轮廓校正光谱。
20
+ def apply_de_blazing(spectra_set: SpectraSet, flat_field: FlatField) -> SpectraSet:
21
+ """
22
+ Apply de-blazing correction to extracted spectra.
23
+
24
+ Divides each spectrum by its corresponding blaze profile
25
+ to remove wavelength-dependent efficiency variations.
26
+
27
+ Args:
28
+ spectra_set: Extracted spectra to correct
29
+ flat_field: Flat field data containing blaze profiles
30
+
31
+ Returns:
32
+ De-blazed spectra set
33
+ """
34
+ if flat_field.blaze_profiles is None or len(flat_field.blaze_profiles) == 0:
35
+ logger.warning("No blaze profiles available, skipping de-blazing")
36
+ return spectra_set
37
+
38
+ corrected_spectra = SpectraSet()
39
+
40
+ for spectrum in spectra_set.spectra.values():
41
+ aperture_id = spectrum.aperture
42
+
43
+ if aperture_id in flat_field.blaze_profiles:
44
+ blaze_profile = flat_field.blaze_profiles[aperture_id]
45
+
46
+ # Ensure blaze profile matches spectrum length
47
+ if len(blaze_profile) == len(spectrum.flux):
48
+ blaze_peak = np.nanmax(blaze_profile)
49
+ if not np.isfinite(blaze_peak) or blaze_peak <= 0:
50
+ blaze_peak = 1.0
51
+
52
+ blaze_threshold = 0.02 * blaze_peak
53
+
54
+ # --- FIX: Contiguous valid region ---
55
+ # Only keep the contiguous region around the peak that is above the threshold.
56
+ # This strictly ignores any artificial "up-swings" or polynomial ringing at the extreme edges.
57
+ bad_blaze_mask = np.ones_like(blaze_profile, dtype=bool)
58
+ peak_idx = int(np.nanargmax(blaze_profile))
59
+
60
+ # Walk left from the peak
61
+ left_bound = peak_idx
62
+ while left_bound > 0 and blaze_profile[left_bound - 1] >= blaze_threshold:
63
+ left_bound -= 1
64
+
65
+ # Walk right from the peak
66
+ right_bound = peak_idx
67
+ while right_bound < len(blaze_profile) - 1 and blaze_profile[right_bound + 1] >= blaze_threshold:
68
+ right_bound += 1
69
+
70
+ # The contiguous region from left_bound to right_bound is good.
71
+ bad_blaze_mask[left_bound:right_bound + 1] = False
72
+
73
+ # Avoid division by zero or very small numbers by setting them to NaN
74
+ blaze_for_division = np.where(bad_blaze_mask, np.nan, blaze_profile)
75
+
76
+ # Apply de-blazing, ignoring division warnings for NaNs
77
+ with np.errstate(divide='ignore', invalid='ignore'):
78
+ corrected_flux = spectrum.flux / blaze_for_division
79
+ corrected_error = spectrum.error / blaze_for_division if spectrum.error is not None else None
80
+
81
+ # In regions of low blaze, the result is unreliable. Set flux and error to NaN.
82
+ corrected_flux[bad_blaze_mask] = np.nan
83
+ if corrected_error is not None:
84
+ corrected_error[bad_blaze_mask] = np.nan
85
+
86
+ # Clean up any other non-finite values that might have resulted from the division
87
+ corrected_flux[~np.isfinite(corrected_flux)] = np.nan
88
+ if corrected_error is not None:
89
+ corrected_error[~np.isfinite(corrected_error)] = np.nan
90
+
91
+ # Create corrected spectrum
92
+ corrected_spectrum = spectrum.copy()
93
+ corrected_spectrum.flux = corrected_flux
94
+ corrected_spectrum.error = corrected_error
95
+ corrected_spectra.add_spectrum(corrected_spectrum)
96
+ else:
97
+ logger.warning(f"Blaze profile length mismatch for aperture {aperture_id}, skipping")
98
+ corrected_spectra.add_spectrum(spectrum)
99
+ else:
100
+ logger.warning(f"No blaze profile for aperture {aperture_id}, skipping de-blazing")
101
+ corrected_spectra.add_spectrum(spectrum)
102
+
103
+ logger.info(f"✓ De-blazing complete: {len(corrected_spectra.spectra)} spectra processed")
104
+ return corrected_spectra
105
+
106
+
107
+ # 将去闪耀光谱保存为FITS表格。
108
+ def save_deblazed_spectra(output_path: str, spectra_set: SpectraSet,
109
+ content_desc: str = 'De-blazed calibrated spectra',
110
+ log_desc: str = 'de-blazed'):
111
+ """Save de-blazed spectra to FITS file."""
112
+ if spectra_set is None:
113
+ raise RuntimeError("No de-blazed spectra to save")
114
+
115
+ from astropy.io import fits
116
+ from pathlib import Path
117
+
118
+ output_path = Path(output_path)
119
+ output_path.parent.mkdir(parents=True, exist_ok=True)
120
+
121
+ # Create structured array for table
122
+ norders = spectra_set.norders
123
+ max_pixels = max([s.npixel() for s in spectra_set.spectra.values()])
124
+
125
+ # Create binary table
126
+ cols = []
127
+ for aperture_id in spectra_set.get_orders():
128
+ spec = spectra_set.get_spectrum(aperture_id)
129
+
130
+ # Ensure arrays are floating-point so NaN padding is always valid.
131
+ wl_arr = np.asarray(spec.wavelength, dtype=np.float64)
132
+ fl_arr = np.asarray(spec.flux, dtype=np.float64)
133
+
134
+ # Pad arrays to same length
135
+ wl = np.pad(wl_arr, (0, max_pixels - len(wl_arr)), constant_values=np.nan)
136
+ fl = np.pad(fl_arr, (0, max_pixels - len(fl_arr)), constant_values=np.nan)
137
+
138
+ cols.append(fits.Column(name=f'WAV_{aperture_id:02d}', format='D', array=wl))
139
+ cols.append(fits.Column(name=f'FLUX_{aperture_id:02d}', format='D', array=fl))
140
+
141
+ if spec.error is not None:
142
+ err_arr = np.asarray(spec.error, dtype=np.float64)
143
+ err = np.pad(err_arr, (0, max_pixels - len(err_arr)), constant_values=np.nan)
144
+ cols.append(fits.Column(name=f'ERR_{aperture_id:02d}', format='D', array=err))
145
+
146
+ coldefs = fits.ColDefs(cols)
147
+ table_hdu = fits.BinTableHDU.from_columns(coldefs)
148
+
149
+ # Create header
150
+ header = fits.Header()
151
+ header['NORDERS'] = norders
152
+ header['NPX'] = max_pixels
153
+ header['CONTENTS'] = content_desc
154
+ header['COMMENT'] = 'Wavelength in Angstroms, Flux in ADU'
155
+
156
+ primary_hdu = fits.PrimaryHDU(header=header)
157
+
158
+ hdul = fits.HDUList([primary_hdu, table_hdu])
159
+ hdul.writeto(str(output_path), overwrite=True)
160
+
161
+ logger.info(f"Saved {norders} {log_desc} spectra to {output_path}")
162
+
163
+
164
+ # 执行去闪耀校正并保存结果。
165
+ def process_de_blazing_stage(spectra_set: SpectraSet,
166
+ output_dir_base: str,
167
+ flat_field: Optional[FlatField] = None,
168
+ save_output: bool = True,
169
+ save_deblaze: bool = True,
170
+ output_filename: str = 'deblazed_spectra.fits',
171
+ plot_prefix: str = 'deblazed',
172
+ save_plots: bool = True,
173
+ fig_format: str = 'png') -> SpectraSet:
174
+ """
175
+ Execute de-blazing correction stage.
176
+
177
+ Args:
178
+ spectra_set: Extracted spectra
179
+ output_dir_base: Base output directory
180
+ flat_field: Flat field data with blaze profiles
181
+ save_output: Whether to save de-blazed spectra to file
182
+ save_deblaze: Whether to save de-blazed intermediate products
183
+ output_filename: Filename for the saved FITS file
184
+ plot_prefix: Prefix for saved plot files
185
+ save_plots: Whether to save diagnostic plots
186
+ fig_format: Format for diagnostic plots
187
+
188
+ Returns:
189
+ De-blazed spectra set
190
+ """
191
+ if flat_field is None:
192
+ logger.warning("No flat field provided, skipping de-blazing")
193
+ return spectra_set
194
+
195
+ deblazed_spectra = apply_de_blazing(spectra_set, flat_field)
196
+
197
+ # Save de-blazed spectra if enabled
198
+ if save_output and save_deblaze:
199
+ deblazed_file = Path(output_dir_base) / 'step6_deblazing' / output_filename
200
+ deblazed_file.parent.mkdir(parents=True, exist_ok=True)
201
+ save_deblazed_spectra(str(deblazed_file), deblazed_spectra)
202
+
203
+ # Save diagnostic plots if enabled
204
+ if save_plots:
205
+ out_dir = deblazed_file.parent
206
+ pdf_name = f"{plot_prefix}.pdf"
207
+ pdf_path = out_dir / pdf_name
208
+ plot_spectra_to_pdf(deblazed_spectra, str(pdf_path), title_prefix="De-blazed Spectrum (pixels)", xlabel="Pixel")
209
+
210
+ return deblazed_spectra
@@ -0,0 +1,375 @@
1
+ """
2
+ Spectrum extraction stage of spectral reduction pipeline.
3
+
4
+ Handles 1D spectrum extraction using sum or optimal (Gaussian) methods.
5
+ """
6
+ # 从二维图像中提取求和或最优一维光谱。
7
+
8
+ import numpy as np
9
+ import logging
10
+ from pathlib import Path
11
+ from typing import Tuple, Optional
12
+ from scipy.optimize import curve_fit
13
+ from specproc.core.data_structures import Spectrum, SpectraSet, ApertureSet, WaveCalib, FlatField
14
+ from specproc.plotting.spectra_plotter import plot_spectrum_to_file, plot_spectra_to_pdf
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ # 负责从二维图像提取一维光谱。
20
+ class SpectrumExtractor:
21
+ """Handles 1D spectrum extraction from 2D images."""
22
+
23
+ # 设置最优提取阈值与默认方法。
24
+ def __init__(self, optimal_sigma: float = 3.0, method: str = 'optimal'):
25
+ """
26
+ Initialize spectrum extractor.
27
+
28
+ Args:
29
+ optimal_sigma: Gaussian sigma for optimal extraction profile.
30
+ method: Default extraction method ('optimal' or 'sum').
31
+ """
32
+ self.optimal_sigma = optimal_sigma
33
+ self.method = method
34
+ self.extracted_spectra = None
35
+
36
+ # 对孔径内像素求和提取光谱。
37
+ def sum_extraction(self, image: np.ndarray, aperture_positions: np.ndarray,
38
+ lower_bounds: np.ndarray = None, upper_bounds: np.ndarray = None) -> np.ndarray:
39
+ """
40
+ Extract spectrum using simple aperture sum.
41
+
42
+ Args:
43
+ image: 2D image array
44
+ aperture_positions: Aperture center positions (per column)
45
+ lower_bounds: Lower boundary positions (per column, absolute y)
46
+ upper_bounds: Upper boundary positions (per column, absolute y)
47
+
48
+ Returns:
49
+ 1D spectrum array
50
+ """
51
+ logger.debug("Performing sum extraction...")
52
+
53
+ rows, cols = image.shape
54
+
55
+ if len(aperture_positions) != cols:
56
+ raise ValueError("Aperture positions length must match image width")
57
+
58
+ spectrum = np.zeros(cols)
59
+
60
+ for x in range(cols):
61
+ center = aperture_positions[x]
62
+ if lower_bounds is not None and upper_bounds is not None:
63
+ y_min = max(0, int(np.floor(lower_bounds[x])))
64
+ y_max = min(rows, int(np.ceil(upper_bounds[x])) + 1)
65
+ else:
66
+ y_min = max(0, int(center - 5))
67
+ y_max = min(rows, int(center + 5) + 1)
68
+
69
+ if y_max > y_min:
70
+ spectrum[x] = np.sum(image[y_min:y_max, x])
71
+ else:
72
+ spectrum[x] = 0.0
73
+
74
+ logger.debug(f"Sum extraction complete: {len(spectrum)} pixels")
75
+
76
+ return spectrum
77
+
78
+ # 按空间光强剖面最优提取光谱。
79
+ def optimal_extraction(self, image: np.ndarray, aperture_positions: np.ndarray,
80
+ spatial_profile_2d: Optional[np.ndarray] = None,
81
+ lower_bounds: np.ndarray = None,
82
+ upper_bounds: np.ndarray = None) -> Tuple[np.ndarray, np.ndarray]:
83
+ """
84
+ Extract spectrum using optimal extraction.
85
+
86
+ Uses a profile-weighted approach. If a flat-field-derived profile is
87
+ provided, use it; otherwise fallback to Gaussian profile.
88
+
89
+ Args:
90
+ image: 2D image array
91
+ aperture_positions: Aperture center positions (per column)
92
+ profile: Optional spatial profile shape (relative weights)
93
+ lower_bounds: Lower boundary positions (per column, absolute y)
94
+ upper_bounds: Upper boundary positions (per column, absolute y)
95
+
96
+ Returns:
97
+ Tuple of (spectrum, error)
98
+ """
99
+ logger.debug("Performing optimal extraction...")
100
+
101
+ rows, cols = image.shape
102
+
103
+ if len(aperture_positions) != cols:
104
+ raise ValueError("Aperture positions length must match image width")
105
+
106
+ spectrum = np.zeros(cols)
107
+ error = np.zeros(cols)
108
+
109
+ for x in range(cols):
110
+ center = aperture_positions[x]
111
+ if lower_bounds is not None and upper_bounds is not None:
112
+ y_min = max(0, int(np.floor(lower_bounds[x])))
113
+ y_max = min(rows, int(np.ceil(upper_bounds[x])) + 1)
114
+ else:
115
+ y_min = max(0, int(center - 5))
116
+ y_max = min(rows, int(center + 5) + 1)
117
+
118
+ if y_max <= y_min:
119
+ spectrum[x] = 0.0
120
+ error[x] = 0.0
121
+ continue
122
+
123
+ sub_image = image[y_min:y_max, x]
124
+ local_y = np.arange(y_min, y_max) - center
125
+
126
+ weights = None
127
+ if spatial_profile_2d is not None:
128
+ p_col = spatial_profile_2d[y_min:y_max, x].astype(np.float64)
129
+ sum_p = np.sum(p_col)
130
+ if sum_p > 0:
131
+ weights = p_col / sum_p
132
+
133
+ if weights is None:
134
+ sigma = self.optimal_sigma
135
+ weights = np.exp(-0.5 * (local_y / sigma) ** 2)
136
+ weights /= np.sum(weights) if np.sum(weights) > 0 else 1.0
137
+
138
+ weighted = sub_image * weights
139
+ spectrum[x] = np.sum(weighted)
140
+ error[x] = np.sqrt(np.sum((sub_image * weights) ** 2))
141
+
142
+ logger.debug(f"Optimal extraction complete: {len(spectrum)} pixels")
143
+
144
+ return spectrum, error
145
+
146
+ # 对全部孔径执行光谱提取。
147
+ def extract_aperture_set(self, image: np.ndarray, apertures: ApertureSet,
148
+ wavelength_calib: Optional[WaveCalib] = None,
149
+ flat_field: Optional[FlatField] = None,
150
+ method_override: Optional[str] = None) -> SpectraSet:
151
+ """
152
+ Extract all apertures from 2D image.
153
+
154
+ Args:
155
+ image: 2D science image
156
+ apertures: ApertureSet with aperture definitions
157
+ wavelength_calib: Optional wavelength calibration
158
+
159
+ Returns:
160
+ SpectraSet with extracted spectra
161
+ """
162
+ logger.info(f"Extracting {apertures.norders} apertures...")
163
+
164
+ spectra_set = SpectraSet()
165
+
166
+ method = method_override if method_override is not None else self.method
167
+
168
+ _, cols = image.shape
169
+
170
+ for aperture_id in apertures.get_orders():
171
+ aperture = apertures.get_aperture(aperture_id)
172
+ if aperture is None:
173
+ continue
174
+
175
+ x_pix = np.arange(cols)
176
+ center_pos = aperture.get_position(x_pix)
177
+ lower_bounds = aperture.get_lower(x_pix)
178
+ upper_bounds = aperture.get_upper(x_pix)
179
+
180
+ spatial_profile_2d = None
181
+ if flat_field is not None:
182
+ if flat_field.smoothed_model is not None:
183
+ spatial_profile_2d = flat_field.smoothed_model
184
+
185
+ if method == 'optimal':
186
+ flux, error = self.optimal_extraction(image, center_pos,
187
+ spatial_profile_2d=spatial_profile_2d,
188
+ lower_bounds=lower_bounds,
189
+ upper_bounds=upper_bounds)
190
+ else:
191
+ flux = self.sum_extraction(image, center_pos,
192
+ lower_bounds=lower_bounds,
193
+ upper_bounds=upper_bounds)
194
+ error = np.sqrt(np.abs(flux))
195
+
196
+ # Apply wavelength calibration if available
197
+ if wavelength_calib is not None:
198
+ wavelength = wavelength_calib.apply_to_pixel(x_pix,
199
+ aperture_id * np.ones_like(x_pix))
200
+ else:
201
+ wavelength = x_pix
202
+
203
+ # Create Spectrum object
204
+ spec = Spectrum(
205
+ aperture=aperture_id,
206
+ order=aperture.order,
207
+ wavelength=wavelength,
208
+ flux=flux,
209
+ error=error,
210
+ background=None,
211
+ mask=None
212
+ )
213
+
214
+ spectra_set.add_spectrum(spec)
215
+
216
+ self.extracted_spectra = spectra_set
217
+ logger.info(f"Extraction complete: {spectra_set.norders} spectra")
218
+
219
+ return spectra_set
220
+
221
+ # 将提取的光谱集合写入FITS。
222
+ def save_spectra(self, output_path: str, spectra_set: SpectraSet):
223
+ """Save extracted spectra to FITS file."""
224
+ if spectra_set is None:
225
+ raise RuntimeError("No spectra to save")
226
+
227
+ from astropy.io import fits
228
+ from pathlib import Path
229
+
230
+ output_path = Path(output_path)
231
+ output_path.parent.mkdir(parents=True, exist_ok=True)
232
+
233
+ # Create structured array for table
234
+ norders = spectra_set.norders
235
+ max_pixels = max([s.npixel() for s in spectra_set.spectra.values()])
236
+
237
+
238
+ # Create binary table
239
+ cols = []
240
+ for aperture_id in spectra_set.get_orders():
241
+ spec = spectra_set.get_spectrum(aperture_id)
242
+
243
+ # Pad arrays to same length
244
+ wl = np.pad(spec.wavelength, (0, max_pixels - len(spec.wavelength)))
245
+ fl = np.pad(spec.flux, (0, max_pixels - len(spec.flux)))
246
+
247
+ cols.append(fits.Column(name=f'WAV_{aperture_id:02d}', format='D', array=wl))
248
+ cols.append(fits.Column(name=f'FLUX_{aperture_id:02d}', format='D', array=fl))
249
+
250
+ if spec.error is not None:
251
+ err = np.pad(spec.error, (0, max_pixels - len(spec.error)))
252
+ cols.append(fits.Column(name=f'ERR_{aperture_id:02d}', format='D', array=err))
253
+
254
+ # Create HDUs
255
+ primary_hdu = fits.PrimaryHDU()
256
+ table_hdu = fits.BinTableHDU.from_columns(cols)
257
+ table_hdu.header['NORDERS'] = norders
258
+
259
+ hdul = fits.HDUList([primary_hdu, table_hdu])
260
+ hdul.writeto(str(output_path), overwrite=True)
261
+
262
+ logger.info(f"Saved {norders} spectra to {output_path}")
263
+
264
+
265
+ # 执行光谱提取并保存相关产物。
266
+ def process_extraction_stage(science_image: np.ndarray,
267
+ apertures: ApertureSet,
268
+ output_dir_base: str,
269
+ wavelength_calib: Optional[WaveCalib] = None,
270
+ flat_field: Optional[FlatField] = None,
271
+ method_override: Optional[str] = None,
272
+ output_filename: str = 'extracted_spectra.fits',
273
+ plot_prefix: str = 'extracted',
274
+ optimal_sigma: float = 3.0,
275
+ extraction_method: str = 'optimal',
276
+ save_plots: bool = True,
277
+ fig_format: str = 'png') -> SpectraSet:
278
+ """
279
+ Execute spectrum extraction stage.
280
+
281
+ Args:
282
+ science_image: 2D science image
283
+ apertures: Detected apertures
284
+ output_dir_base: Base output directory
285
+ wavelength_calib: Wavelength calibration
286
+ flat_field: Flat field data
287
+ method_override: Override default extraction method
288
+ output_filename: Name for output FITS file
289
+ plot_prefix: Prefix for diagnostic plots
290
+ optimal_sigma: Sigma for Gaussian profile in optimal extraction
291
+ extraction_method: Default extraction method
292
+ save_plots: Whether to save diagnostic plots
293
+ fig_format: Format for diagnostic plots
294
+
295
+ Returns:
296
+ SpectraSet with extracted 1D spectra
297
+ """
298
+ extractor = SpectrumExtractor(optimal_sigma=optimal_sigma, method=extraction_method)
299
+
300
+ # Extract spectra
301
+ spectra_set = extractor.extract_aperture_set(science_image, apertures,
302
+ wavelength_calib, flat_field,
303
+ method_override=method_override)
304
+
305
+ # Save spectra
306
+ spectra_file = Path(output_dir_base) / 'step5_extraction' / output_filename
307
+ spectra_file.parent.mkdir(parents=True, exist_ok=True)
308
+ extractor.save_spectra(str(spectra_file), spectra_set)
309
+
310
+ # Save diagnostic plots if enabled
311
+ if save_plots:
312
+ out_dir = spectra_file.parent
313
+ if wavelength_calib is not None:
314
+ pdf_path = out_dir / f"{plot_prefix}_all_apertures.pdf"
315
+ plot_spectra_to_pdf(spectra_set, str(pdf_path), title_prefix="Extracted Spectrum")
316
+ else:
317
+ # If no wavelength calibration, plot raw pixels
318
+ pdf_name = f"{plot_prefix}.pdf"
319
+ pdf_path = out_dir / pdf_name
320
+ plot_spectra_to_pdf(spectra_set, str(pdf_path), title_prefix="Extracted Spectrum (pixels)", xlabel="Pixel")
321
+
322
+ return spectra_set
323
+
324
+ # 从FITS文件载入提取光谱。
325
+ def load_extracted_spectra(filepath: str) -> SpectraSet:
326
+ """Load extracted spectra from FITS file."""
327
+ from astropy.io import fits
328
+ from specproc.core.data_structures import Spectrum, SpectraSet
329
+
330
+ spectra_set = SpectraSet()
331
+
332
+ with fits.open(filepath) as hdul:
333
+ if len(hdul) < 2:
334
+ raise ValueError("FITS file does not contain a binary table")
335
+
336
+ table_hdu = hdul[1]
337
+
338
+ # Extract unique aperture IDs from column names
339
+ aperture_ids = set()
340
+ for col in table_hdu.columns:
341
+ if col.name.startswith('FLUX_'):
342
+ try:
343
+ aperture_ids.add(int(col.name.split('_')[1]))
344
+ except ValueError:
345
+ pass
346
+
347
+ for ap_id in sorted(list(aperture_ids)):
348
+ wav_col = f'WAV_{ap_id:02d}'
349
+ flux_col = f'FLUX_{ap_id:02d}'
350
+ err_col = f'ERR_{ap_id:02d}'
351
+
352
+ if wav_col in table_hdu.data.columns.names and flux_col in table_hdu.data.columns.names:
353
+ wav = table_hdu.data[wav_col]
354
+ flux = table_hdu.data[flux_col]
355
+ err = table_hdu.data[err_col] if err_col in table_hdu.data.columns.names else None
356
+
357
+ if len(wav.shape) > 1 and wav.shape[0] == 1:
358
+ wav = wav[0]
359
+ if len(flux.shape) > 1 and flux.shape[0] == 1:
360
+ flux = flux[0]
361
+ if err is not None and len(err.shape) > 1 and err.shape[0] == 1:
362
+ err = err[0]
363
+
364
+ spec = Spectrum(
365
+ aperture=ap_id,
366
+ order=ap_id, # order is same as aperture for now
367
+ wavelength=wav,
368
+ flux=flux,
369
+ error=err,
370
+ background=None,
371
+ mask=None
372
+ )
373
+ spectra_set.add_spectrum(spec)
374
+
375
+ return spectra_set