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.
- specproc/__init__.py +9 -0
- specproc/calib_data/linelists/FeAr.dat +375 -0
- specproc/calib_data/linelists/README +79 -0
- specproc/calib_data/linelists/airvac.cl +61 -0
- specproc/calib_data/linelists/argon.dat +161 -0
- specproc/calib_data/linelists/ctiohear.dat +106 -0
- specproc/calib_data/linelists/ctiohenear.dat +146 -0
- specproc/calib_data/linelists/cuar.dat +459 -0
- specproc/calib_data/linelists/henear.dat +93 -0
- specproc/calib_data/linelists/henearhres.dat +300 -0
- specproc/calib_data/linelists/idhenear.dat +127 -0
- specproc/calib_data/linelists/krypton.dat +109 -0
- specproc/calib_data/linelists/lowskylines.dat +577 -0
- specproc/calib_data/linelists/ohlines.dat +268 -0
- specproc/calib_data/linelists/skylines.dat +1385 -0
- specproc/calib_data/linelists/thar-noao.dat +4169 -0
- specproc/calib_data/linelists/thar.dat +3057 -0
- specproc/calib_data/linelists/thorium.dat +3057 -0
- specproc/calib_data/linelists/vacidhenear.dat +127 -0
- specproc/calib_data/linelists/vacthar.dat +3057 -0
- specproc/calib_data/linelists/vacthorium.dat +3057 -0
- specproc/calib_data/linelists/xenon.dat +89 -0
- specproc/calib_data/telescopes/lijiang24hrs/lijiang24_hrs_thar_lines.csv +550 -0
- 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
- specproc/calib_data/telescopes/xinglong216bfosc//346/212/245/345/221/212/345/233/276.docx +0 -0
- 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
- specproc/calib_data/telescopes/xinglong216hrs/wlcalib_20211123011_A.png +0 -0
- specproc/calib_data/telescopes/xinglong216hrs/xinglong216_thar_lines.csv +517 -0
- specproc/config/__init__.py +1 -0
- specproc/config/config_manager.py +431 -0
- specproc/core/__init__.py +1 -0
- specproc/core/basic_reduction.py +615 -0
- specproc/core/data_structures.py +525 -0
- specproc/core/de_blazing.py +210 -0
- specproc/core/extraction.py +375 -0
- specproc/core/flat_correction.py +1255 -0
- specproc/core/line_identification.py +302 -0
- specproc/core/order_stitching.py +187 -0
- specproc/core/order_tracing.py +1587 -0
- specproc/core/processing_pipeline.py +1183 -0
- specproc/core/scattered_light.py +654 -0
- specproc/core/slit_pipeline.py +762 -0
- specproc/core/wave_calibration.py +961 -0
- specproc/gui/__init__.py +1 -0
- specproc/gui/line_identifier.py +731 -0
- specproc/gui/main_window.py +2315 -0
- specproc/gui/settings_dialog.py +946 -0
- specproc/main.py +388 -0
- specproc/plotting/__init__.py +1 -0
- specproc/plotting/spectra_plotter.py +483 -0
- specproc/utils/__init__.py +1 -0
- specproc/utils/airvac.py +123 -0
- specproc/utils/fits_io.py +269 -0
- specproc/utils/image_processing.py +426 -0
- specproc/utils/overscan.py +600 -0
- specproc-1.0.0.dist-info/METADATA +668 -0
- specproc-1.0.0.dist-info/RECORD +61 -0
- specproc-1.0.0.dist-info/WHEEL +5 -0
- specproc-1.0.0.dist-info/entry_points.txt +5 -0
- specproc-1.0.0.dist-info/licenses/LICENSE +674 -0
- specproc-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1183 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main processing pipeline orchestrator.
|
|
3
|
+
|
|
4
|
+
Coordinates all processing stages and manages workflow execution.
|
|
5
|
+
"""
|
|
6
|
+
# 负责协调各阶段并跟踪光谱归约流程。
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import pickle
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import List, Callable, Optional, Union, Dict
|
|
12
|
+
import numpy as np
|
|
13
|
+
from specproc.config.config_manager import ConfigManager
|
|
14
|
+
from specproc.core.data_structures import ProcessingState, WaveCalib, SpectraSet
|
|
15
|
+
from specproc.core.basic_reduction import (
|
|
16
|
+
process_overscan_stage,
|
|
17
|
+
process_bias_stage,
|
|
18
|
+
process_cosmic_stage,
|
|
19
|
+
)
|
|
20
|
+
from specproc.core.order_tracing import process_order_tracing_stage
|
|
21
|
+
from specproc.core.flat_correction import process_flat_correction_stage
|
|
22
|
+
from specproc.core.wave_calibration import WavelengthCalibrator, process_wavelength_stage
|
|
23
|
+
from specproc.core.scattered_light import process_background_stage
|
|
24
|
+
from specproc.core.extraction import process_extraction_stage
|
|
25
|
+
from specproc.core.de_blazing import process_de_blazing_stage
|
|
26
|
+
from specproc.core.order_stitching import process_order_stitching_stage
|
|
27
|
+
from specproc.utils.fits_io import read_fits_image, write_fits_image
|
|
28
|
+
from specproc.plotting.spectra_plotter import plot_2d_image_to_file
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# 管理光谱数据归约的各处理阶段。
|
|
34
|
+
class ProcessingPipeline:
|
|
35
|
+
"""Main spectral reduction pipeline."""
|
|
36
|
+
|
|
37
|
+
# 初始化配置、状态与进度回调。
|
|
38
|
+
def __init__(self, config: ConfigManager):
|
|
39
|
+
"""
|
|
40
|
+
Initialize processing pipeline.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
config: Configuration manager
|
|
44
|
+
"""
|
|
45
|
+
self.config = config
|
|
46
|
+
self.state = ProcessingState()
|
|
47
|
+
self.progress_callback = None
|
|
48
|
+
|
|
49
|
+
# 设置流程进度通知回调。
|
|
50
|
+
def set_progress_callback(self, callback: Callable[[float, str], None]):
|
|
51
|
+
"""
|
|
52
|
+
Set callback for progress updates.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
callback: Function taking (progress_fraction, stage_name)
|
|
56
|
+
"""
|
|
57
|
+
self.progress_callback = callback
|
|
58
|
+
|
|
59
|
+
# 更新并通知当前处理进度。
|
|
60
|
+
def _report_progress(self, progress: float, stage: str):
|
|
61
|
+
"""Report progress to callback."""
|
|
62
|
+
self.state.progress = progress
|
|
63
|
+
self.state.current_stage = stage
|
|
64
|
+
if self.progress_callback:
|
|
65
|
+
self.progress_callback(progress, stage)
|
|
66
|
+
logger.info(f"{stage}: {progress*100:.1f}%")
|
|
67
|
+
|
|
68
|
+
# 执行原始图像过扫描校正。
|
|
69
|
+
def stage_overscan_correction(self, raw_filenames: List[str]) -> List[str]:
|
|
70
|
+
"""
|
|
71
|
+
Execute overscan correction stage (STAGE 0 - must be first!).
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
raw_filenames: List of raw image paths
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
List of overscan-corrected image paths
|
|
78
|
+
"""
|
|
79
|
+
logger.info("=" * 50)
|
|
80
|
+
logger.info("STEP 1: BASIC PRE-PROCESSING (OVERSCAN)")
|
|
81
|
+
logger.info("=" * 50)
|
|
82
|
+
|
|
83
|
+
self._report_progress(0.01, "Overscan Correction")
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
# --- Extract config for overscan stage ---
|
|
87
|
+
overscan_kwargs = {
|
|
88
|
+
'overscan_start_column': self.config.get_int('data', 'overscan_start_column', -1),
|
|
89
|
+
'overscan_method': self.config.get('data', 'overscan_method', 'mean_only'),
|
|
90
|
+
'overscan_smooth_window': self.config.get_int('data', 'overscan_smooth_window', -1),
|
|
91
|
+
'overscan_poly_order': self.config.get_int('data', 'overscan_poly_order', 3),
|
|
92
|
+
'overscan_poly_type': self.config.get('data', 'overscan_poly_type', 'legendre'),
|
|
93
|
+
'trim_x_start': self.config.get_int('data', 'trim_x_start', -1),
|
|
94
|
+
'trim_x_end': self.config.get_int('data', 'trim_x_end', -1),
|
|
95
|
+
'trim_y_start': self.config.get_int('data', 'trim_y_start', -1),
|
|
96
|
+
'trim_y_end': self.config.get_int('data', 'trim_y_end', -1),
|
|
97
|
+
'save_plots': self.config.get_bool('reduce', 'save_plots', True),
|
|
98
|
+
'fig_format': self.config.get('reduce', 'fig_format', 'png')
|
|
99
|
+
}
|
|
100
|
+
corrected_files = process_overscan_stage(
|
|
101
|
+
raw_filenames, self.config.get_output_path(), **overscan_kwargs
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
self._report_progress(0.05, "Overscan Correction Complete")
|
|
105
|
+
logger.info("✓ Overscan correction stage complete")
|
|
106
|
+
|
|
107
|
+
return corrected_files
|
|
108
|
+
|
|
109
|
+
except Exception as e:
|
|
110
|
+
logger.error(f"Error in overscan correction: {e}")
|
|
111
|
+
# Return original files if overscan correction fails
|
|
112
|
+
return raw_filenames
|
|
113
|
+
|
|
114
|
+
# 合并偏置帧并生成主偏置图。
|
|
115
|
+
def stage_bias_correction(self, bias_filenames: List[str]) -> np.ndarray:
|
|
116
|
+
"""
|
|
117
|
+
Execute bias correction stage.
|
|
118
|
+
|
|
119
|
+
Note: Bias frames are short exposure (0s), so no cosmic ray correction needed.
|
|
120
|
+
Use mean/median combination directly.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
bias_filenames: List of bias frame paths
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
Master bias image
|
|
127
|
+
"""
|
|
128
|
+
logger.info("=" * 50)
|
|
129
|
+
logger.info("STEP 1: BASIC PRE-PROCESSING (BIAS)")
|
|
130
|
+
logger.info("=" * 50)
|
|
131
|
+
|
|
132
|
+
self._report_progress(0.05, "Bias Correction")
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
# --- Extract config for bias stage ---
|
|
136
|
+
bias_kwargs = {
|
|
137
|
+
'combine_method': self.config.get('reduce.bias', 'combine_method', 'median'),
|
|
138
|
+
'combine_sigma': self.config.get_float('reduce.bias', 'combine_sigma', 3.0),
|
|
139
|
+
'save_plots': self.config.get_bool('reduce', 'save_plots', True),
|
|
140
|
+
'fig_format': self.config.get('reduce', 'fig_format', 'png'),
|
|
141
|
+
}
|
|
142
|
+
master_bias, bias_file = process_bias_stage(
|
|
143
|
+
bias_filenames, self.config.get_output_path(), **bias_kwargs
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
self.state.bias_frame = master_bias
|
|
147
|
+
self.state.bias_done = True
|
|
148
|
+
|
|
149
|
+
self._report_progress(0.15, "Bias Correction Complete")
|
|
150
|
+
logger.info("✓ Bias correction stage complete")
|
|
151
|
+
|
|
152
|
+
return master_bias
|
|
153
|
+
|
|
154
|
+
except Exception as e:
|
|
155
|
+
logger.error(f"Error in bias correction: {e}")
|
|
156
|
+
raise
|
|
157
|
+
|
|
158
|
+
# 用主偏置图校正并保存图像。
|
|
159
|
+
def _apply_master_bias_to_files(self, input_files: List[str], master_bias: np.ndarray,
|
|
160
|
+
output_subdir: str) -> List[str]:
|
|
161
|
+
"""Apply master bias to a list of files and save corrected copies."""
|
|
162
|
+
if not input_files:
|
|
163
|
+
return []
|
|
164
|
+
|
|
165
|
+
output_dir = Path(self.config.get_output_path()) / 'step1_basic' / 'bias_subtracted'
|
|
166
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
save_plots = self.config.get_bool('reduce', 'save_plots', True)
|
|
168
|
+
fig_format = self.config.get('reduce', 'fig_format', 'png')
|
|
169
|
+
|
|
170
|
+
corrected_files = []
|
|
171
|
+
for filename in input_files:
|
|
172
|
+
image, header = read_fits_image(filename)
|
|
173
|
+
|
|
174
|
+
if image.shape != master_bias.shape:
|
|
175
|
+
raise ValueError(
|
|
176
|
+
f"Cannot apply bias to {filename}: image shape {image.shape} "
|
|
177
|
+
f"!= master bias shape {master_bias.shape}"
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
corrected = image.astype(np.float32) - master_bias.astype(np.float32)
|
|
181
|
+
header['BIASCOR'] = (True, 'Bias correction applied')
|
|
182
|
+
|
|
183
|
+
output_file = output_dir / Path(filename).name
|
|
184
|
+
write_fits_image(str(output_file), corrected, header=header, dtype='float32')
|
|
185
|
+
|
|
186
|
+
if save_plots:
|
|
187
|
+
plot_file = output_dir / f"{Path(filename).stem}_bias_corrected.{fig_format}"
|
|
188
|
+
plot_2d_image_to_file(
|
|
189
|
+
corrected,
|
|
190
|
+
str(plot_file),
|
|
191
|
+
f"Bias Corrected {output_subdir.capitalize()} Image"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
corrected_files.append(str(output_file))
|
|
195
|
+
|
|
196
|
+
logger.info(
|
|
197
|
+
f"Applied master bias to {len(corrected_files)} frame(s) for {output_subdir}; "
|
|
198
|
+
f"saved in {output_dir}"
|
|
199
|
+
)
|
|
200
|
+
return corrected_files
|
|
201
|
+
|
|
202
|
+
# 处理平场并建立光谱孔径。
|
|
203
|
+
def stage_flat_fielding(self, flat_filenames: List[str]) -> tuple:
|
|
204
|
+
"""
|
|
205
|
+
Execute flat fielding stage.
|
|
206
|
+
|
|
207
|
+
Note: Flat frames should have cosmic ray correction applied before this stage.
|
|
208
|
+
|
|
209
|
+
Args:
|
|
210
|
+
flat_filenames: List of flat frame paths
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
Tuple of (FlatField, ApertureSet)
|
|
214
|
+
"""
|
|
215
|
+
logger.info("=" * 50)
|
|
216
|
+
logger.info("STEP 2: ORDER TRACING")
|
|
217
|
+
logger.info("=" * 50)
|
|
218
|
+
|
|
219
|
+
self._report_progress(0.15, "Flat Fielding")
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
# --- Extract config for flat / order tracing stage ---
|
|
223
|
+
output_dir_base = self.config.get_output_path()
|
|
224
|
+
flat_kwargs = {
|
|
225
|
+
'output_dir_base': output_dir_base,
|
|
226
|
+
'combine_method': self.config.get('reduce.flat', 'combine_method', 'median'),
|
|
227
|
+
'combine_sigma': self.config.get_float('reduce.bias', 'combine_sigma', 3.0),
|
|
228
|
+
'mosaic_maxcount': self.config.get_float('reduce.flat', 'mosaic_maxcount', 65535),
|
|
229
|
+
'snr_threshold': self.config.get_float('reduce.trace', 'snr_threshold', 5.0),
|
|
230
|
+
'gap_fill_factor': self.config.get_float('reduce.trace', 'gap_fill_factor', 1.6),
|
|
231
|
+
'min_trace_coverage': self.config.get_float('reduce.trace', 'min_trace_coverage', 0.20),
|
|
232
|
+
'trace_degree': self.config.get_int('reduce.trace', 'degree', 4),
|
|
233
|
+
'width_cheb_degree': self.config.get_int('reduce.trace', 'width_cheb_degree', 3),
|
|
234
|
+
'n_extend_below': self.config.get_int('reduce.trace', 'n_extend_below', 0),
|
|
235
|
+
'n_extend_above': self.config.get_int('reduce.trace', 'n_extend_above', 0),
|
|
236
|
+
'aperture_boundary_snr': self.config.get_float('reduce.trace', 'aperture_boundary_snr', 3.0),
|
|
237
|
+
'gap_fill_factor_interp': self.config.get_float('reduce.trace', 'gap_fill_factor', 1.35),
|
|
238
|
+
'save_plots': self.config.get_bool('reduce', 'save_plots', True),
|
|
239
|
+
'fig_format': self.config.get('reduce', 'fig_format', 'png'),
|
|
240
|
+
}
|
|
241
|
+
flat_field, apertures = process_order_tracing_stage(
|
|
242
|
+
flat_filenames, **flat_kwargs
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
self.state.flat_field = flat_field
|
|
246
|
+
self.state.apertures = apertures
|
|
247
|
+
self.state.flat_done = True
|
|
248
|
+
self.state.trace_done = True
|
|
249
|
+
|
|
250
|
+
self._report_progress(0.30, "Flat Fielding Complete")
|
|
251
|
+
logger.info("✓ Flat fielding stage complete")
|
|
252
|
+
|
|
253
|
+
return flat_field, apertures
|
|
254
|
+
|
|
255
|
+
except Exception as e:
|
|
256
|
+
logger.error(f"Error in flat fielding: {e}")
|
|
257
|
+
raise
|
|
258
|
+
|
|
259
|
+
# 从标定帧建立波长定标模型。
|
|
260
|
+
def stage_wavelength_calibration(self, calib_filename: str) -> WaveCalib:
|
|
261
|
+
"""
|
|
262
|
+
Execute wavelength calibration stage on calibration frame.
|
|
263
|
+
|
|
264
|
+
Args:
|
|
265
|
+
calib_filename: Path to calibration (ThAr) frame
|
|
266
|
+
|
|
267
|
+
Returns:
|
|
268
|
+
WaveCalib object
|
|
269
|
+
"""
|
|
270
|
+
self._report_progress(0.30, "Wavelength Calibration")
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
logger.info(f"Building wavelength solution from calibration frame: {Path(calib_filename).name}")
|
|
274
|
+
lamp_img, _ = read_fits_image(calib_filename)
|
|
275
|
+
calib_name = Path(calib_filename).stem
|
|
276
|
+
|
|
277
|
+
extract_kwargs = {
|
|
278
|
+
'optimal_sigma': self.config.get_float('reduce.extract', 'optimal_sigma', 3.0),
|
|
279
|
+
'save_plots': self.config.get_bool('reduce', 'save_plots', True),
|
|
280
|
+
'fig_format': self.config.get('reduce', 'fig_format', 'png'),
|
|
281
|
+
}
|
|
282
|
+
lamp_spectra = process_extraction_stage(
|
|
283
|
+
lamp_img, self.state.apertures,
|
|
284
|
+
output_dir_base=self.config.get_output_path(),
|
|
285
|
+
wavelength_calib=None,
|
|
286
|
+
flat_field=self.state.flat_field,
|
|
287
|
+
method_override='optimal',
|
|
288
|
+
output_filename=f'{calib_name}_1D_optimal.fits',
|
|
289
|
+
plot_prefix=f'{calib_name}_1D_optimal',
|
|
290
|
+
**extract_kwargs
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
wave_calib = process_wavelength_stage(
|
|
294
|
+
lamp_spectra=lamp_spectra,
|
|
295
|
+
config=self.config,
|
|
296
|
+
output_dir_base=self.config.get_output_path(),
|
|
297
|
+
lamp_type=self.config.get('telescope.linelist', 'linelist_type', 'ThAr'),
|
|
298
|
+
save_plots=self.config.get_bool('reduce', 'save_plots', True),
|
|
299
|
+
fig_format=self.config.get('reduce', 'fig_format', 'png'),
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
self.state.wavelength_calib = wave_calib
|
|
303
|
+
self.state.wavelength_done = True
|
|
304
|
+
|
|
305
|
+
self._report_progress(0.30, "Wavelength Calibration Complete")
|
|
306
|
+
logger.info(f"✓ Wavelength solution ready: {calib_name}")
|
|
307
|
+
|
|
308
|
+
return wave_calib
|
|
309
|
+
|
|
310
|
+
except Exception as e:
|
|
311
|
+
logger.error(f"Error in wavelength calibration: {e}")
|
|
312
|
+
raise
|
|
313
|
+
|
|
314
|
+
# 为提取光谱应用波长定标。
|
|
315
|
+
def stage_wavelength_calibration_on_spectra(self, spectra_set: SpectraSet,
|
|
316
|
+
wave_calib: WaveCalib,
|
|
317
|
+
science_name: str = 'science',
|
|
318
|
+
method: str = 'optimal') -> SpectraSet:
|
|
319
|
+
"""
|
|
320
|
+
Apply wavelength calibration to extracted science spectra.
|
|
321
|
+
|
|
322
|
+
This is the correct order: extract 1D spectra first, then apply wavelength calibration.
|
|
323
|
+
Following the gamse standard pipeline.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
spectra_set: Extracted spectra without wavelength information
|
|
327
|
+
wave_calib: Wavelength calibration model to apply
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
SpectraSet with wavelength-calibrated spectra
|
|
331
|
+
"""
|
|
332
|
+
self._report_progress(0.82, "Applying Wavelength Calibration")
|
|
333
|
+
|
|
334
|
+
try:
|
|
335
|
+
# --- Apply Order Offset (Renumbering) ---
|
|
336
|
+
delta_m = getattr(wave_calib, 'delta_m', 0)
|
|
337
|
+
if delta_m != 0:
|
|
338
|
+
spectra_set.shift_orders(delta_m)
|
|
339
|
+
|
|
340
|
+
# Apply wavelength calibration to each extracted spectrum
|
|
341
|
+
calibrator = WavelengthCalibrator()
|
|
342
|
+
calibrator.wave_calib = wave_calib
|
|
343
|
+
|
|
344
|
+
calibrated_spectra = SpectraSet()
|
|
345
|
+
|
|
346
|
+
for spectrum in spectra_set.spectra.values():
|
|
347
|
+
# Create pixel array
|
|
348
|
+
n_pixels = len(spectrum.flux)
|
|
349
|
+
pixel_array = np.arange(n_pixels)
|
|
350
|
+
|
|
351
|
+
# Apply wavelength calibration
|
|
352
|
+
wavelengths = calibrator.apply_wavelength_calibration(
|
|
353
|
+
spectrum.flux, pixel_array, aperture_y=float(spectrum.aperture)
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
# Update spectrum with wavelengths
|
|
357
|
+
calibrated_spectrum = spectrum.copy()
|
|
358
|
+
calibrated_spectrum.wavelength = wavelengths
|
|
359
|
+
|
|
360
|
+
calibrated_spectra.add_spectrum(calibrated_spectrum)
|
|
361
|
+
|
|
362
|
+
# === 将波长定标后的科学光谱独立保存到 Step 7 对应目录 ===
|
|
363
|
+
if self.config.get_bool('reduce.save_intermediate', 'save_wlcalib', True):
|
|
364
|
+
out_dir = Path(self.config.get_output_path()) / 'step7_wavelength'
|
|
365
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
366
|
+
|
|
367
|
+
from specproc.core.de_blazing import save_deblazed_spectra
|
|
368
|
+
save_deblazed_spectra(
|
|
369
|
+
str(out_dir / f'{science_name}_1D_{method}_wavecal.fits'),
|
|
370
|
+
calibrated_spectra,
|
|
371
|
+
content_desc='Wavelength calibrated spectra',
|
|
372
|
+
log_desc='wavelength-calibrated'
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
if self.config.get_bool('reduce', 'save_plots', True):
|
|
376
|
+
from specproc.plotting.spectra_plotter import plot_spectra_to_pdf
|
|
377
|
+
pdf_path = out_dir / f"{science_name}_1D_{method}_wavecal.pdf"
|
|
378
|
+
plot_spectra_to_pdf(calibrated_spectra, str(pdf_path),
|
|
379
|
+
title_prefix="Wavelength Calibrated Spectrum", xlabel=r"Wavelength ($\AA$)",
|
|
380
|
+
id_label="Order")
|
|
381
|
+
|
|
382
|
+
self._report_progress(0.88, "Wavelength Calibration Complete")
|
|
383
|
+
logger.info(
|
|
384
|
+
f"Applied wavelength calibration: target={science_name}, method={method}, "
|
|
385
|
+
f"orders={len(calibrated_spectra.spectra)}"
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
return calibrated_spectra
|
|
389
|
+
|
|
390
|
+
except Exception as e:
|
|
391
|
+
logger.error(f"Error in wavelength calibration of spectra: {e}")
|
|
392
|
+
raise
|
|
393
|
+
|
|
394
|
+
# 去除科学图像中的宇宙射线。
|
|
395
|
+
def stage_cosmic_correction_science(self, science_image: np.ndarray,
|
|
396
|
+
science_name: str = 'science') -> np.ndarray:
|
|
397
|
+
"""
|
|
398
|
+
Execute cosmic ray correction stage for science images only.
|
|
399
|
+
|
|
400
|
+
This stage is placed after background subtraction and before spectrum extraction.
|
|
401
|
+
|
|
402
|
+
Why only science images need cosmic ray correction:
|
|
403
|
+
- Bias frames: 0s exposure, no cosmic rays accumulate
|
|
404
|
+
- Flat frames: Multiple frames combined, cosmic rays removed during combination
|
|
405
|
+
- ThAr frames: Lamp spectra, typically no cosmic ray correction needed
|
|
406
|
+
- Science frames: Long exposure (minutes to hours), single frame, cosmic rays must be removed
|
|
407
|
+
|
|
408
|
+
Args:
|
|
409
|
+
science_image: Background-corrected 2D science image
|
|
410
|
+
|
|
411
|
+
Returns:
|
|
412
|
+
Cosmic-ray corrected science image
|
|
413
|
+
"""
|
|
414
|
+
logger.info("=" * 50)
|
|
415
|
+
logger.info("STEP 1 (sub): COSMIC RAY CORRECTION (Science Frame)")
|
|
416
|
+
logger.info("=" * 50)
|
|
417
|
+
|
|
418
|
+
self._report_progress(0.45, "Cosmic Ray Correction")
|
|
419
|
+
|
|
420
|
+
try:
|
|
421
|
+
# Reuse the shared Step 1 cosmic detector so selected-step execution
|
|
422
|
+
# and full-pipeline execution behave consistently.
|
|
423
|
+
cosmic_kwargs = {
|
|
424
|
+
'cosmic_sigclip': self.config.get_float('reduce', 'cosmic_sigclip', 5.0),
|
|
425
|
+
'cosmic_objlim': self.config.get_float('reduce', 'cosmic_objlim', 5.0),
|
|
426
|
+
'cosmic_gain': self.config.get_float('reduce', 'cosmic_gain', 1.0),
|
|
427
|
+
'cosmic_readnoise': self.config.get_float('reduce', 'cosmic_readnoise', 5.0),
|
|
428
|
+
'save_plots': self.config.get_bool('reduce', 'save_plots', True),
|
|
429
|
+
'fig_format': self.config.get('reduce', 'fig_format', 'png'),
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
out_dir = Path(self.config.get_output_path()) / 'step1_basic' / 'cosmic_corrected'
|
|
433
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
434
|
+
|
|
435
|
+
# Create a temporary file to pass to process_cosmic_stage
|
|
436
|
+
temp_input_path = out_dir / f"{science_name}_precosmic.fits"
|
|
437
|
+
write_fits_image(str(temp_input_path), science_image, dtype='float32')
|
|
438
|
+
|
|
439
|
+
corrected_files = process_cosmic_stage(
|
|
440
|
+
[str(temp_input_path)],
|
|
441
|
+
output_dir_base=self.config.get_output_path(),
|
|
442
|
+
**cosmic_kwargs
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
corrected_image, _ = read_fits_image(corrected_files[0])
|
|
446
|
+
temp_input_path.unlink() # Clean up temporary file
|
|
447
|
+
|
|
448
|
+
if self.config.get_bool('reduce', 'save_plots', True):
|
|
449
|
+
fig_format = self.config.get('reduce', 'fig_format', 'png')
|
|
450
|
+
plot_2d_image_to_file(
|
|
451
|
+
corrected_image,
|
|
452
|
+
str(out_dir / f'{science_name}_science_cosmic_corrected.{fig_format}'),
|
|
453
|
+
'Science After Cosmic Ray Correction'
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
self._report_progress(0.48, "Cosmic Ray Correction Complete")
|
|
457
|
+
logger.info("✓ Cosmic ray correction complete")
|
|
458
|
+
|
|
459
|
+
return corrected_image
|
|
460
|
+
|
|
461
|
+
except Exception as e:
|
|
462
|
+
logger.error(f"Error in cosmic ray correction: {e}")
|
|
463
|
+
raise
|
|
464
|
+
|
|
465
|
+
# 从科学图像提取一维光谱。
|
|
466
|
+
def stage_extraction(self, science_image: np.ndarray, science_name: str = 'science',
|
|
467
|
+
calib_image_paths: Optional[List[str]] = None,
|
|
468
|
+
master_flat_path: Optional[str] = None) -> SpectraSet:
|
|
469
|
+
"""
|
|
470
|
+
Execute spectrum extraction stage and output both sum/optimal products.
|
|
471
|
+
|
|
472
|
+
Args:
|
|
473
|
+
science_image: Background-corrected 2D science image
|
|
474
|
+
science_name: Base name of the science image
|
|
475
|
+
calib_image_paths: List of paths to calibration images
|
|
476
|
+
master_flat_path: Path to the MasterFlat image
|
|
477
|
+
|
|
478
|
+
Returns:
|
|
479
|
+
SpectraSet with extracted 1D spectra (without wavelength calibration)
|
|
480
|
+
"""
|
|
481
|
+
logger.info("=" * 50)
|
|
482
|
+
logger.info("STEP 5: SPECTRUM EXTRACTION")
|
|
483
|
+
logger.info("=" * 50)
|
|
484
|
+
|
|
485
|
+
self._report_progress(0.74, "Spectrum Extraction")
|
|
486
|
+
|
|
487
|
+
if self.state.apertures is None:
|
|
488
|
+
raise RuntimeError("Apertures not available. Run flat fielding stage first.")
|
|
489
|
+
|
|
490
|
+
try:
|
|
491
|
+
# --- Extract config for extraction stage ---
|
|
492
|
+
output_dir_base = self.config.get_output_path()
|
|
493
|
+
extract_kwargs = {
|
|
494
|
+
'output_dir_base': output_dir_base,
|
|
495
|
+
'optimal_sigma': self.config.get_float('reduce.extract', 'optimal_sigma', 3.0),
|
|
496
|
+
'save_plots': self.config.get_bool('reduce', 'save_plots', True),
|
|
497
|
+
'fig_format': self.config.get('reduce', 'fig_format', 'png'),
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
# 1. Science Extraction
|
|
501
|
+
extracted_sum = process_extraction_stage(
|
|
502
|
+
science_image, self.state.apertures,
|
|
503
|
+
wavelength_calib=None,
|
|
504
|
+
flat_field=self.state.flat_field,
|
|
505
|
+
method_override='sum',
|
|
506
|
+
output_filename=f'{science_name}_1D_sum.fits',
|
|
507
|
+
plot_prefix=f'{science_name}_1D_sum',
|
|
508
|
+
**extract_kwargs,
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
extracted_optimal = process_extraction_stage(
|
|
512
|
+
science_image, self.state.apertures,
|
|
513
|
+
wavelength_calib=None,
|
|
514
|
+
flat_field=self.state.flat_field,
|
|
515
|
+
method_override='optimal',
|
|
516
|
+
output_filename=f'{science_name}_1D_optimal.fits',
|
|
517
|
+
plot_prefix=f'{science_name}_1D_optimal',
|
|
518
|
+
**extract_kwargs,
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
# 2. MasterFlat Extraction
|
|
522
|
+
if master_flat_path and Path(master_flat_path).exists():
|
|
523
|
+
try:
|
|
524
|
+
mf_img, _ = read_fits_image(master_flat_path)
|
|
525
|
+
mf_name = "MasterFlat"
|
|
526
|
+
process_extraction_stage(
|
|
527
|
+
mf_img, self.state.apertures,
|
|
528
|
+
wavelength_calib=None, flat_field=self.state.flat_field,
|
|
529
|
+
method_override='sum', output_filename=f'{mf_name}_1D_sum.fits',
|
|
530
|
+
plot_prefix=f'{mf_name}_1D_sum', **extract_kwargs
|
|
531
|
+
)
|
|
532
|
+
process_extraction_stage(
|
|
533
|
+
mf_img, self.state.apertures,
|
|
534
|
+
wavelength_calib=None, flat_field=self.state.flat_field,
|
|
535
|
+
method_override='optimal', output_filename=f'{mf_name}_1D_optimal.fits',
|
|
536
|
+
plot_prefix=f'{mf_name}_1D_optimal', **extract_kwargs
|
|
537
|
+
)
|
|
538
|
+
logger.info(f"✓ Extracted MasterFlat 1D spectra")
|
|
539
|
+
except Exception as e:
|
|
540
|
+
logger.warning(f"Failed to extract MasterFlat: {e}")
|
|
541
|
+
|
|
542
|
+
# 3. Calibration Extraction
|
|
543
|
+
if calib_image_paths:
|
|
544
|
+
for calib_image_path in calib_image_paths:
|
|
545
|
+
if Path(calib_image_path).exists():
|
|
546
|
+
try:
|
|
547
|
+
c_img, _ = read_fits_image(calib_image_path)
|
|
548
|
+
c_name = Path(calib_image_path).stem
|
|
549
|
+
process_extraction_stage(
|
|
550
|
+
c_img, self.state.apertures,
|
|
551
|
+
wavelength_calib=None, flat_field=self.state.flat_field,
|
|
552
|
+
method_override='sum', output_filename=f'{c_name}_1D_sum.fits',
|
|
553
|
+
plot_prefix=f'{c_name}_1D_sum', **extract_kwargs
|
|
554
|
+
)
|
|
555
|
+
process_extraction_stage(
|
|
556
|
+
c_img, self.state.apertures,
|
|
557
|
+
wavelength_calib=None, flat_field=self.state.flat_field,
|
|
558
|
+
method_override='optimal', output_filename=f'{c_name}_1D_optimal.fits',
|
|
559
|
+
plot_prefix=f'{c_name}_1D_optimal', **extract_kwargs
|
|
560
|
+
)
|
|
561
|
+
logger.info(f"✓ Extracted Calibration 1D spectra for {c_name}")
|
|
562
|
+
except Exception as e:
|
|
563
|
+
logger.warning(f"Failed to extract Calibration {Path(calib_image_path).name}: {e}")
|
|
564
|
+
|
|
565
|
+
# Keep optimal extraction as default downstream product.
|
|
566
|
+
extracted_spectra = extracted_optimal
|
|
567
|
+
self.state.extracted_spectra = extracted_spectra
|
|
568
|
+
self.state.extraction_done = True
|
|
569
|
+
|
|
570
|
+
self._report_progress(0.80, "Spectrum Extraction Complete")
|
|
571
|
+
logger.info(
|
|
572
|
+
f"✓ Spectrum extraction stage complete (sum={extracted_sum.norders} orders, "
|
|
573
|
+
f"optimal={extracted_optimal.norders} orders)"
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
return extracted_spectra
|
|
577
|
+
|
|
578
|
+
except Exception as e:
|
|
579
|
+
logger.error(f"Error in spectrum extraction: {e}")
|
|
580
|
+
raise
|
|
581
|
+
|
|
582
|
+
# 校正提取光谱的闪耀响应。
|
|
583
|
+
def stage_de_blazing(self, spectra_set: SpectraSet, science_name: str = 'science') -> SpectraSet:
|
|
584
|
+
"""
|
|
585
|
+
Execute de-blazing correction stage.
|
|
586
|
+
|
|
587
|
+
Args:
|
|
588
|
+
spectra_set: Extracted spectra
|
|
589
|
+
science_name: Base name of the science image
|
|
590
|
+
|
|
591
|
+
Returns:
|
|
592
|
+
De-blazed spectra
|
|
593
|
+
"""
|
|
594
|
+
logger.info("=" * 50)
|
|
595
|
+
logger.info("STEP 6: DE-BLAZING (Blaze Function Correction)")
|
|
596
|
+
logger.info("=" * 50)
|
|
597
|
+
|
|
598
|
+
self._report_progress(0.90, "De-blazing Correction")
|
|
599
|
+
|
|
600
|
+
try:
|
|
601
|
+
# --- Extract config for de-blazing stage ---
|
|
602
|
+
de_blazed_spectra = process_de_blazing_stage(
|
|
603
|
+
spectra_set,
|
|
604
|
+
output_dir_base=self.config.get_output_path(),
|
|
605
|
+
flat_field=self.state.flat_field,
|
|
606
|
+
save_deblaze=self.config.get_bool('reduce.save_intermediate', 'save_deblaze', True),
|
|
607
|
+
output_filename=f'{science_name}_1D_optimal_Deblaze.fits',
|
|
608
|
+
plot_prefix=f'{science_name}_1D_optimal_Deblaze',
|
|
609
|
+
save_plots=self.config.get_bool('reduce', 'save_plots', True),
|
|
610
|
+
fig_format=self.config.get('reduce', 'fig_format', 'png'),
|
|
611
|
+
)
|
|
612
|
+
|
|
613
|
+
self.state.de_blazed_spectra = de_blazed_spectra
|
|
614
|
+
self.state.de_blazing_done = True
|
|
615
|
+
|
|
616
|
+
self._report_progress(0.94, "De-blazing Complete")
|
|
617
|
+
logger.info("✓ De-blazing stage complete")
|
|
618
|
+
|
|
619
|
+
return de_blazed_spectra
|
|
620
|
+
|
|
621
|
+
except Exception as e:
|
|
622
|
+
logger.error(f"Error in de-blazing: {e}")
|
|
623
|
+
raise
|
|
624
|
+
|
|
625
|
+
# 保存平场模型及校正产物。
|
|
626
|
+
def _save_step4_flat_products(self, out_dir: Path):
|
|
627
|
+
"""Delegate Step-4 flat-model persistence to flat_correction module."""
|
|
628
|
+
if self.state.flat_field is None:
|
|
629
|
+
return
|
|
630
|
+
from specproc.core.flat_correction import save_flat_correction_products
|
|
631
|
+
save_flat_correction_products(
|
|
632
|
+
self.state.flat_field, self.state.apertures, out_dir,
|
|
633
|
+
fig_format=self.config.get('reduce', 'fig_format', 'png'),
|
|
634
|
+
save_plots=self.config.get_bool('reduce', 'save_plots', True),
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
# 建模并扣除平场与科学图像的散射光。
|
|
638
|
+
def stage_scattered_light_subtraction_science(self, science_image: np.ndarray,
|
|
639
|
+
science_name: str = 'science') -> np.ndarray:
|
|
640
|
+
"""Step 3: build/subtract scattered-light models for master flat and current science frame."""
|
|
641
|
+
logger.info("=" * 50)
|
|
642
|
+
logger.info("STEP 3: SCATTERED LIGHT MODELING AND SUBTRACTION")
|
|
643
|
+
logger.info("=" * 50)
|
|
644
|
+
|
|
645
|
+
self._report_progress(0.50, "Step 3: Master Flat Scattered Light")
|
|
646
|
+
|
|
647
|
+
if self.state.flat_field is None or self.state.apertures is None:
|
|
648
|
+
raise RuntimeError("Flat/apertures unavailable for Step 3")
|
|
649
|
+
|
|
650
|
+
# --- Extract config for background stage ---
|
|
651
|
+
output_dir_base = self.config.get_output_path()
|
|
652
|
+
# 读取用户设置的mask参数
|
|
653
|
+
mask_margin_pixels = self.config.get_int('reduce.background', 'mask_margin_pixels', 1)
|
|
654
|
+
n_mask_below = self.config.get_int('reduce.trace', 'n_mask_below', 4)
|
|
655
|
+
n_mask_above = self.config.get_int('reduce.trace', 'n_mask_above', 4)
|
|
656
|
+
|
|
657
|
+
out_dir = Path(output_dir_base) / 'step3_scatterlight'
|
|
658
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
659
|
+
|
|
660
|
+
from specproc.core.scattered_light import create_widened_mask
|
|
661
|
+
h, w = self.state.flat_field.flat_data.shape
|
|
662
|
+
|
|
663
|
+
step3_mask, lo_traces, hi_traces, full_ids = create_widened_mask(
|
|
664
|
+
self.state.apertures, self.state.flat_field.flat_data, mask_margin_pixels,
|
|
665
|
+
n_mask_below, n_mask_above
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
write_fits_image(str(out_dir / 'Scattered_Light_Mask.fits'), step3_mask, dtype='uint8')
|
|
669
|
+
|
|
670
|
+
if self.config.get_bool('reduce', 'save_plots', True):
|
|
671
|
+
import matplotlib.pyplot as plt
|
|
672
|
+
fig_format = self.config.get('reduce', 'fig_format', 'png')
|
|
673
|
+
plt.figure(figsize=(12, 10))
|
|
674
|
+
plt.imshow(step3_mask, aspect='auto', origin='lower', cmap='gray', vmin=0, vmax=1)
|
|
675
|
+
|
|
676
|
+
x_anno = w - 1
|
|
677
|
+
for i, ap_id in enumerate(full_ids):
|
|
678
|
+
yc = 0.5 * (lo_traces[i, x_anno] + hi_traces[i, x_anno])
|
|
679
|
+
if np.isfinite(yc) and 0 <= yc < h:
|
|
680
|
+
plt.text(x_anno + 5, yc, str(ap_id), color='red', fontsize=3.5, ha='left', va='center', clip_on=False)
|
|
681
|
+
|
|
682
|
+
plt.title(f'Step 3 Widened Mask (margin={mask_margin_pixels})\nWhite=Masked (Orders + Virtual), Black=Background')
|
|
683
|
+
plt.xlabel('Pixel (X)')
|
|
684
|
+
plt.ylabel('Pixel (Y)')
|
|
685
|
+
plt.xlim(0, w - 1)
|
|
686
|
+
plt.ylim(0, h - 1)
|
|
687
|
+
plot_file = out_dir / f'scattered_light_mask.{fig_format}'
|
|
688
|
+
plt.savefig(str(plot_file), dpi=150, bbox_inches='tight')
|
|
689
|
+
plt.close()
|
|
690
|
+
|
|
691
|
+
bg_kwargs = {
|
|
692
|
+
'output_subdir': 'step3_scatterlight',
|
|
693
|
+
'mask_margin_scale': 1.0,
|
|
694
|
+
'mask_margin_pixels': 0, # Already widened by us
|
|
695
|
+
'order_mask': step3_mask,
|
|
696
|
+
'poly_order': self.config.get_int('reduce.background', 'poly_order', 3),
|
|
697
|
+
'bg_method': self.config.get('reduce.background', 'method', 'convolution'),
|
|
698
|
+
'sigma_clip_val': self.config.get_float('reduce.background', 'sigma_clip', 3.0),
|
|
699
|
+
'maxiters': self.config.get_int('reduce.background', 'sigma_clip_maxiters', 4),
|
|
700
|
+
'bspline_smooth': self.config.get_float('reduce.background', 'bspline_smooth', 1.0),
|
|
701
|
+
'n_mask_below': n_mask_below,
|
|
702
|
+
'n_mask_above': n_mask_above,
|
|
703
|
+
'clip_mode': self.config.get('reduce.background', 'sigma_clip_mode', 'upper'),
|
|
704
|
+
'split_row': self.config.get_int('data', 'detector_split_row', 2068),
|
|
705
|
+
'kernel_sigma_x': self.config.get_float('reduce.background', 'kernel_sigma_x', 13.0),
|
|
706
|
+
'kernel_sigma_y': self.config.get_float('reduce.background', 'kernel_sigma_y', 13.0),
|
|
707
|
+
'spline_smooth_factor': self.config.get_float('reduce.background', 'spline_smooth_factor', 1.0),
|
|
708
|
+
'spline_post_smooth_x': self.config.get_float('reduce.background', 'spline_post_smooth_x', 5.0),
|
|
709
|
+
'save_plots': self.config.get_bool('reduce', 'save_plots', True),
|
|
710
|
+
'fig_format': self.config.get('reduce', 'fig_format', 'png'),
|
|
711
|
+
}
|
|
712
|
+
flat_background = process_background_stage(
|
|
713
|
+
self.state.flat_field.flat_data,
|
|
714
|
+
output_dir_base,
|
|
715
|
+
apertures=self.state.apertures,
|
|
716
|
+
output_tag='MasterFlat_',
|
|
717
|
+
**bg_kwargs,
|
|
718
|
+
)
|
|
719
|
+
flat_clean = np.clip(
|
|
720
|
+
self.state.flat_field.flat_data.astype(np.float32) - flat_background.astype(np.float32),
|
|
721
|
+
1e-6,
|
|
722
|
+
None,
|
|
723
|
+
)
|
|
724
|
+
self.state.flat_field.scattered_light = flat_background
|
|
725
|
+
|
|
726
|
+
master_flat_path = Path(output_dir_base) / 'step2_trace' / 'MasterFlat.fits'
|
|
727
|
+
_, flat_header = read_fits_image(str(master_flat_path)) if master_flat_path.exists() else (None, None)
|
|
728
|
+
if flat_header is not None:
|
|
729
|
+
flat_header['BKGSCAT'] = (True, 'Scattered light background subtracted')
|
|
730
|
+
|
|
731
|
+
write_fits_image(str(out_dir / 'MasterFlat.fits'), flat_clean.astype(np.float32), header=flat_header, dtype='float32')
|
|
732
|
+
|
|
733
|
+
self._report_progress(0.56, "Step 3: Science Scattered Light")
|
|
734
|
+
science_background = process_background_stage(
|
|
735
|
+
science_image,
|
|
736
|
+
output_dir_base,
|
|
737
|
+
output_tag=f'{science_name}_',
|
|
738
|
+
apertures=self.state.apertures,
|
|
739
|
+
**bg_kwargs,
|
|
740
|
+
)
|
|
741
|
+
|
|
742
|
+
corrected = science_image.astype(np.float32) - science_background.astype(np.float32)
|
|
743
|
+
|
|
744
|
+
# The corrected image with original filename is already saved by process_background_stage.
|
|
745
|
+
|
|
746
|
+
self._report_progress(0.60, "Science Scattered Light Subtraction Complete")
|
|
747
|
+
return corrected
|
|
748
|
+
|
|
749
|
+
# 选择已完成校正的标定文件。
|
|
750
|
+
def get_best_calib_file(self, raw_calib_path: str) -> str:
|
|
751
|
+
"""Find the most processed version of the calibration file."""
|
|
752
|
+
if not raw_calib_path:
|
|
753
|
+
return raw_calib_path
|
|
754
|
+
calib_name = Path(raw_calib_path).stem
|
|
755
|
+
orig_name = Path(raw_calib_path).name
|
|
756
|
+
out_dir = Path(self.config.get_output_path())
|
|
757
|
+
|
|
758
|
+
flat_path = out_dir / 'step4_flat_corrected' / orig_name
|
|
759
|
+
if flat_path.exists():
|
|
760
|
+
return str(flat_path)
|
|
761
|
+
|
|
762
|
+
bias_path = out_dir / 'step1_basic' / 'bias_subtracted' / orig_name
|
|
763
|
+
if bias_path.exists():
|
|
764
|
+
return str(bias_path)
|
|
765
|
+
|
|
766
|
+
overscan_path = out_dir / 'step1_basic' / 'overscan_corrected' / orig_name
|
|
767
|
+
if overscan_path.exists():
|
|
768
|
+
return str(overscan_path)
|
|
769
|
+
|
|
770
|
+
return raw_calib_path
|
|
771
|
+
|
|
772
|
+
# 依次执行完整光谱归约流程。
|
|
773
|
+
def run_full_pipeline(self, raw_image_paths: Union[str, List[str]],
|
|
774
|
+
bias_filenames: List[str],
|
|
775
|
+
flat_filenames: List[str],
|
|
776
|
+
calib_filenames: List[str]) -> Dict[str, SpectraSet]:
|
|
777
|
+
"""
|
|
778
|
+
Execute complete spectral reduction pipeline.
|
|
779
|
+
|
|
780
|
+
Args:
|
|
781
|
+
raw_image_paths: List of paths to science FITS files
|
|
782
|
+
bias_filenames: List of bias frame paths
|
|
783
|
+
flat_filenames: List of flat frame paths
|
|
784
|
+
calib_filenames: List of wavelength calibration frame paths
|
|
785
|
+
|
|
786
|
+
Returns:
|
|
787
|
+
Dict mapping science filenames to SpectraSet with final extracted spectra
|
|
788
|
+
"""
|
|
789
|
+
if isinstance(raw_image_paths, str):
|
|
790
|
+
raw_image_paths = [raw_image_paths]
|
|
791
|
+
|
|
792
|
+
if self.config.get('telescope', 'instrument', '').strip().lower() == 'slit':
|
|
793
|
+
from specproc.core.slit_pipeline import SlitPipeline
|
|
794
|
+
return SlitPipeline(self.config).run(
|
|
795
|
+
raw_image_paths, bias_filenames, flat_filenames, calib_filenames)
|
|
796
|
+
|
|
797
|
+
logger.info("=" * 60)
|
|
798
|
+
logger.info("STARTING FULL SPECTRAL REDUCTION PIPELINE")
|
|
799
|
+
logger.info("=" * 60)
|
|
800
|
+
|
|
801
|
+
# Reset state
|
|
802
|
+
self.state.reset()
|
|
803
|
+
|
|
804
|
+
# Create output directories
|
|
805
|
+
self.config.create_directories()
|
|
806
|
+
|
|
807
|
+
try:
|
|
808
|
+
# Step 1: Overscan subtraction & trimming for all inputs
|
|
809
|
+
all_raw_files = raw_image_paths + bias_filenames + flat_filenames + calib_filenames
|
|
810
|
+
corrected_files = self.stage_overscan_correction(all_raw_files)
|
|
811
|
+
|
|
812
|
+
# Extract corrected filenames
|
|
813
|
+
n_sci = len(raw_image_paths)
|
|
814
|
+
science_corrected_files = corrected_files[:n_sci]
|
|
815
|
+
bias_filenames_corr = corrected_files[n_sci:n_sci+len(bias_filenames)]
|
|
816
|
+
flat_start = n_sci + len(bias_filenames)
|
|
817
|
+
flat_end = flat_start + len(flat_filenames)
|
|
818
|
+
flat_filenames_corr = corrected_files[flat_start:flat_end]
|
|
819
|
+
calib_filenames_corr = corrected_files[flat_end:]
|
|
820
|
+
|
|
821
|
+
# Step 2: Bias subtraction
|
|
822
|
+
master_bias = self.stage_bias_correction(bias_filenames_corr)
|
|
823
|
+
|
|
824
|
+
# Apply bias to science/flat/calib frames.
|
|
825
|
+
science_filenames_bias = self._apply_master_bias_to_files(
|
|
826
|
+
science_corrected_files, master_bias, 'science'
|
|
827
|
+
)
|
|
828
|
+
if science_filenames_bias:
|
|
829
|
+
science_corrected_files = science_filenames_bias
|
|
830
|
+
|
|
831
|
+
flat_filenames_bias = self._apply_master_bias_to_files(flat_filenames_corr, master_bias, 'flat')
|
|
832
|
+
calib_corrected = self._apply_master_bias_to_files(calib_filenames_corr, master_bias, 'calib')
|
|
833
|
+
if calib_corrected:
|
|
834
|
+
calib_filenames_corr = calib_corrected
|
|
835
|
+
|
|
836
|
+
# Optional Step 1 sub-step: cosmic-ray removal on science frames.
|
|
837
|
+
science_pre_flat_files = science_corrected_files
|
|
838
|
+
if self.config.get_bool('reduce', 'cosmic_enabled', True):
|
|
839
|
+
self._report_progress(0.20, "Cosmic Ray Correction")
|
|
840
|
+
cosmic_kwargs = {
|
|
841
|
+
'cosmic_sigclip': self.config.get_float('reduce', 'cosmic_sigclip', 5.0),
|
|
842
|
+
'cosmic_objlim': self.config.get_float('reduce', 'cosmic_objlim', 5.0),
|
|
843
|
+
'cosmic_gain': self.config.get_float('reduce', 'cosmic_gain', 1.0),
|
|
844
|
+
'cosmic_readnoise': self.config.get_float('reduce', 'cosmic_readnoise', 5.0),
|
|
845
|
+
'save_plots': self.config.get_bool('reduce', 'save_plots', True),
|
|
846
|
+
'fig_format': self.config.get('reduce', 'fig_format', 'png'),
|
|
847
|
+
}
|
|
848
|
+
science_pre_flat_files = process_cosmic_stage(
|
|
849
|
+
science_corrected_files,
|
|
850
|
+
output_dir_base=self.config.get_output_path(),
|
|
851
|
+
output_subdir='step1_basic/cosmic_corrected',
|
|
852
|
+
**cosmic_kwargs,
|
|
853
|
+
)
|
|
854
|
+
|
|
855
|
+
# Determine which flats to use for master flat:
|
|
856
|
+
# If overscan/bias correction was run (i.e. flat_filenames_bias exists and is not empty), use those;
|
|
857
|
+
# otherwise, fall back to raw flat_filenames.
|
|
858
|
+
if flat_filenames_bias and all(Path(f).exists() for f in flat_filenames_bias):
|
|
859
|
+
master_flat_files = flat_filenames_bias
|
|
860
|
+
else:
|
|
861
|
+
master_flat_files = flat_filenames
|
|
862
|
+
|
|
863
|
+
# Step 2: Order tracing and flat model products
|
|
864
|
+
flat_field, apertures = self.stage_flat_fielding(master_flat_files)
|
|
865
|
+
|
|
866
|
+
# Step 3: Scattered light subtraction
|
|
867
|
+
self._report_progress(0.40, "Scattered Light Subtraction")
|
|
868
|
+
logger.info("=" * 50)
|
|
869
|
+
logger.info("STEP 3: SCATTERED LIGHT MODELING AND SUBTRACTION")
|
|
870
|
+
logger.info("=" * 50)
|
|
871
|
+
|
|
872
|
+
out_dir = Path(self.config.get_output_path()) / 'step3_scatterlight'
|
|
873
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
874
|
+
from specproc.core.scattered_light import create_widened_mask, process_background_stage
|
|
875
|
+
|
|
876
|
+
mask_margin_pixels = self.config.get_int('reduce.background', 'mask_margin_pixels', 1)
|
|
877
|
+
n_mask_below = self.config.get_int('reduce.trace', 'n_mask_below', 4)
|
|
878
|
+
n_mask_above = self.config.get_int('reduce.trace', 'n_mask_above', 4)
|
|
879
|
+
|
|
880
|
+
step3_mask, _, _, _ = create_widened_mask(
|
|
881
|
+
apertures, flat_field.flat_data, mask_margin_pixels, n_mask_below, n_mask_above
|
|
882
|
+
)
|
|
883
|
+
write_fits_image(str(out_dir / 'Scattered_Light_Mask.fits'), step3_mask, dtype='uint8')
|
|
884
|
+
|
|
885
|
+
bg_kwargs = {
|
|
886
|
+
'output_subdir': 'step3_scatterlight',
|
|
887
|
+
'mask_margin_scale': 1.0,
|
|
888
|
+
'mask_margin_pixels': 0,
|
|
889
|
+
'order_mask': step3_mask,
|
|
890
|
+
'poly_order': self.config.get_int('reduce.background', 'poly_order', 3),
|
|
891
|
+
'bg_method': self.config.get('reduce.background', 'method', 'convolution'),
|
|
892
|
+
'sigma_clip_val': self.config.get_float('reduce.background', 'sigma_clip', 3.0),
|
|
893
|
+
'maxiters': self.config.get_int('reduce.background', 'sigma_clip_maxiters', 4),
|
|
894
|
+
'bspline_smooth': self.config.get_float('reduce.background', 'bspline_smooth', 1.0),
|
|
895
|
+
'n_mask_below': n_mask_below,
|
|
896
|
+
'n_mask_above': n_mask_above,
|
|
897
|
+
'clip_mode': self.config.get('reduce.background', 'sigma_clip_mode', 'upper'),
|
|
898
|
+
'split_row': self.config.get_int('data', 'detector_split_row', 2068),
|
|
899
|
+
'kernel_sigma_x': self.config.get_float('reduce.background', 'kernel_sigma_x', 13.0),
|
|
900
|
+
'kernel_sigma_y': self.config.get_float('reduce.background', 'kernel_sigma_y', 13.0),
|
|
901
|
+
'spline_smooth_factor': self.config.get_float('reduce.background', 'spline_smooth_factor', 1.0),
|
|
902
|
+
'spline_post_smooth_x': self.config.get_float('reduce.background', 'spline_post_smooth_x', 5.0),
|
|
903
|
+
'save_plots': self.config.get_bool('reduce', 'save_plots', True),
|
|
904
|
+
'fig_format': self.config.get('reduce', 'fig_format', 'png'),
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
self._report_progress(0.45, "Step 3: Master Flat Scattered Light")
|
|
908
|
+
flat_background = process_background_stage(
|
|
909
|
+
flat_field.flat_data, self.config.get_output_path(),
|
|
910
|
+
apertures=apertures, output_tag='MasterFlat_', **bg_kwargs
|
|
911
|
+
)
|
|
912
|
+
flat_clean = np.clip(
|
|
913
|
+
flat_field.flat_data.astype(np.float32) - flat_background.astype(np.float32), 1e-6, None
|
|
914
|
+
)
|
|
915
|
+
flat_field.scattered_light = flat_background
|
|
916
|
+
|
|
917
|
+
master_flat_path = Path(self.config.get_output_path()) / 'step2_trace' / 'MasterFlat.fits'
|
|
918
|
+
_, flat_header = read_fits_image(str(master_flat_path)) if master_flat_path.exists() else (None, None)
|
|
919
|
+
if flat_header is not None:
|
|
920
|
+
flat_header['BKGSCAT'] = (True, 'Scattered light background subtracted')
|
|
921
|
+
write_fits_image(str(out_dir / 'MasterFlat.fits'), flat_clean.astype(np.float32), header=flat_header, dtype='float32')
|
|
922
|
+
|
|
923
|
+
self._report_progress(0.50, "Step 3: Science Scattered Light")
|
|
924
|
+
science_scatter_files = []
|
|
925
|
+
for sci_file in science_pre_flat_files:
|
|
926
|
+
sci_img, sci_header = read_fits_image(sci_file)
|
|
927
|
+
sci_name = Path(sci_file).stem
|
|
928
|
+
sci_bg = process_background_stage(
|
|
929
|
+
sci_img, self.config.get_output_path(),
|
|
930
|
+
output_tag=f'{sci_name}_', apertures=apertures, **bg_kwargs
|
|
931
|
+
)
|
|
932
|
+
sci_clean = sci_img.astype(np.float32) - sci_bg.astype(np.float32)
|
|
933
|
+
if sci_header is not None:
|
|
934
|
+
sci_header['BKGSCAT'] = (True, 'Scattered light background subtracted')
|
|
935
|
+
out_path = out_dir / Path(sci_file).name
|
|
936
|
+
write_fits_image(str(out_path), sci_clean, header=sci_header, dtype='float32')
|
|
937
|
+
science_scatter_files.append(str(out_path))
|
|
938
|
+
|
|
939
|
+
# Step 4: Build 2D flat-field model and apply it
|
|
940
|
+
self._report_progress(0.60, "2D Flat Fielding")
|
|
941
|
+
logger.info("=" * 50)
|
|
942
|
+
logger.info("STEP 4: 2D FLAT-FIELD CORRECTION")
|
|
943
|
+
logger.info("=" * 50)
|
|
944
|
+
|
|
945
|
+
# --- Build the flat model ONCE using the cleaned master flat ---
|
|
946
|
+
logger.info("Building 2D flat-field model from MasterFlat...")
|
|
947
|
+
from specproc.core.flat_correction import process_flat_correction_stage
|
|
948
|
+
flat_corr_kwargs = {
|
|
949
|
+
'blaze_smooth_factor': self.config.get_float('reduce.flat', 'blaze_smooth_factor', 1.0),
|
|
950
|
+
'width_smooth_window': self.config.get_int('reduce.flat', 'width_smooth_window', 41),
|
|
951
|
+
'profile_bin_step': self.config.get_float('reduce.flat', 'profile_bin_step', 0.01),
|
|
952
|
+
'n_profile_segments': self.config.get_int('reduce.flat', 'n_profile_segments', 100),
|
|
953
|
+
'profile_smooth_sigma': self.config.get_float('reduce.flat', 'profile_smooth_sigma', 6.0),
|
|
954
|
+
'pixel_flat_min': self.config.get_float('reduce.flat', 'pixel_flat_min', 0.5),
|
|
955
|
+
'pixel_flat_max': self.config.get_float('reduce.flat', 'pixel_flat_max', 1.5),
|
|
956
|
+
'fringe_orders': self.config.get_int('reduce.flat', 'fringe_orders', 20),
|
|
957
|
+
'save_plots': self.config.get_bool('reduce', 'save_plots', True),
|
|
958
|
+
'fig_format': self.config.get('reduce', 'fig_format', 'png'),
|
|
959
|
+
}
|
|
960
|
+
# This call builds the model and saves all diagnostics.
|
|
961
|
+
# The returned 'corrected_flat' is the pixel_flat map itself.
|
|
962
|
+
# The important part is that flat_field object is updated in-place.
|
|
963
|
+
_ = process_flat_correction_stage(
|
|
964
|
+
science_image=flat_clean,
|
|
965
|
+
flat_field=flat_field,
|
|
966
|
+
output_dir_base=self.config.get_output_path(),
|
|
967
|
+
apertures=apertures,
|
|
968
|
+
science_name="MasterFlat", # Use a specific name for diagnostics
|
|
969
|
+
**flat_corr_kwargs
|
|
970
|
+
)
|
|
971
|
+
logger.info("✓ 2D flat-field model built and diagnostics saved.")
|
|
972
|
+
|
|
973
|
+
# --- Apply the correction map to all science and calib frames ---
|
|
974
|
+
flat_corr_map = flat_field.pixel_flat
|
|
975
|
+
if flat_corr_map is None:
|
|
976
|
+
raise RuntimeError("Failed to build pixel_flat map in Step 4.")
|
|
977
|
+
|
|
978
|
+
safe_flat = flat_corr_map.astype(np.float32)
|
|
979
|
+
bad = (~np.isfinite(safe_flat)) | (safe_flat <= 0.05)
|
|
980
|
+
if np.any(bad):
|
|
981
|
+
safe_flat = safe_flat.copy()
|
|
982
|
+
safe_flat[bad] = 1.0
|
|
983
|
+
|
|
984
|
+
out_dir_f = Path(self.config.get_output_path()) / 'step4_flat_corrected'
|
|
985
|
+
out_dir_f.mkdir(parents=True, exist_ok=True)
|
|
986
|
+
|
|
987
|
+
# Apply to science files
|
|
988
|
+
science_flat2d_files = []
|
|
989
|
+
logger.info("Applying 2D flat correction to science frames...")
|
|
990
|
+
for sci_file in science_scatter_files:
|
|
991
|
+
sci_img, sci_header = read_fits_image(sci_file)
|
|
992
|
+
sci_name = Path(sci_file).stem
|
|
993
|
+
corrected = sci_img.astype(np.float32) / safe_flat
|
|
994
|
+
|
|
995
|
+
out_path = out_dir_f / Path(sci_file).name
|
|
996
|
+
if sci_header is not None:
|
|
997
|
+
sci_header['FLATCOR'] = (True, '2D pixel flat correction applied')
|
|
998
|
+
write_fits_image(str(out_path), corrected, header=sci_header, dtype='float32')
|
|
999
|
+
|
|
1000
|
+
if self.config.get_bool('reduce', 'save_plots', True):
|
|
1001
|
+
from specproc.plotting.spectra_plotter import plot_2d_image_to_file
|
|
1002
|
+
fig_format = self.config.get('reduce', 'fig_format', 'png')
|
|
1003
|
+
plot_2d_image_to_file(corrected, str(out_dir_f / f"{sci_name}_flat2d_corrected.{fig_format}"), f"Science After 2D Flat Correction: {sci_name}")
|
|
1004
|
+
|
|
1005
|
+
science_flat2d_files.append(str(out_path))
|
|
1006
|
+
|
|
1007
|
+
# Apply to calib files
|
|
1008
|
+
calib_filenames_final = []
|
|
1009
|
+
logger.info("Applying 2D flat correction to calibration frames...")
|
|
1010
|
+
for c_file in calib_filenames_corr:
|
|
1011
|
+
calib_img, calib_header = read_fits_image(c_file)
|
|
1012
|
+
calib_name = Path(c_file).stem
|
|
1013
|
+
corrected = calib_img.astype(np.float32) / safe_flat
|
|
1014
|
+
|
|
1015
|
+
out_path = out_dir_f / Path(c_file).name
|
|
1016
|
+
if calib_header is not None:
|
|
1017
|
+
calib_header['FLATCOR'] = (True, '2D pixel flat correction applied')
|
|
1018
|
+
write_fits_image(str(out_path), corrected, header=calib_header, dtype='float32')
|
|
1019
|
+
|
|
1020
|
+
if self.config.get_bool('reduce', 'save_plots', True):
|
|
1021
|
+
from specproc.plotting.spectra_plotter import plot_2d_image_to_file
|
|
1022
|
+
fig_format = self.config.get('reduce', 'fig_format', 'png')
|
|
1023
|
+
plot_2d_image_to_file(corrected, str(out_dir_f / f"{calib_name}_flat2d_corrected.{fig_format}"), f"Calibration Frame After 2D Flat Correction: {calib_name}")
|
|
1024
|
+
|
|
1025
|
+
calib_filenames_final.append(str(out_path))
|
|
1026
|
+
|
|
1027
|
+
# Step 5: 1D Extraction
|
|
1028
|
+
self._report_progress(0.70, "Spectrum Extraction")
|
|
1029
|
+
logger.info("=" * 50)
|
|
1030
|
+
logger.info("STEP 5: SPECTRUM EXTRACTION")
|
|
1031
|
+
logger.info("=" * 50)
|
|
1032
|
+
extract_kwargs = {
|
|
1033
|
+
'output_dir_base': self.config.get_output_path(),
|
|
1034
|
+
'optimal_sigma': self.config.get_float('reduce.extract', 'optimal_sigma', 3.0),
|
|
1035
|
+
'save_plots': self.config.get_bool('reduce', 'save_plots', True),
|
|
1036
|
+
'fig_format': self.config.get('reduce', 'fig_format', 'png'),
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
mf_path = Path(self.config.get_output_path()) / 'step4_flat_corrected' / 'MasterFlat.fits'
|
|
1040
|
+
if mf_path.exists():
|
|
1041
|
+
mf_img, _ = read_fits_image(str(mf_path))
|
|
1042
|
+
process_extraction_stage(mf_img, apertures, wavelength_calib=None, flat_field=flat_field, method_override='sum', output_filename='MasterFlat_1D_sum.fits', plot_prefix='MasterFlat_1D_sum', **extract_kwargs)
|
|
1043
|
+
process_extraction_stage(mf_img, apertures, wavelength_calib=None, flat_field=flat_field, method_override='optimal', output_filename='MasterFlat_1D_optimal.fits', plot_prefix='MasterFlat_1D_optimal', **extract_kwargs)
|
|
1044
|
+
|
|
1045
|
+
for c_file in calib_filenames_final:
|
|
1046
|
+
c_img, _ = read_fits_image(c_file)
|
|
1047
|
+
c_name = Path(c_file).stem
|
|
1048
|
+
process_extraction_stage(c_img, apertures, wavelength_calib=None, flat_field=flat_field, method_override='sum', output_filename=f'{c_name}_1D_sum.fits', plot_prefix=f'{c_name}_1D_sum', **extract_kwargs)
|
|
1049
|
+
process_extraction_stage(c_img, apertures, wavelength_calib=None, flat_field=flat_field, method_override='optimal', output_filename=f'{c_name}_1D_optimal.fits', plot_prefix=f'{c_name}_1D_optimal', **extract_kwargs)
|
|
1050
|
+
|
|
1051
|
+
extracted_science_dict = {}
|
|
1052
|
+
for sci_file in science_flat2d_files:
|
|
1053
|
+
sci_img, _ = read_fits_image(sci_file)
|
|
1054
|
+
sci_name = Path(sci_file).stem
|
|
1055
|
+
process_extraction_stage(sci_img, apertures, wavelength_calib=None, flat_field=flat_field, method_override='sum', output_filename=f'{sci_name}_1D_sum.fits', plot_prefix=f'{sci_name}_1D_sum', **extract_kwargs)
|
|
1056
|
+
opt_spec = process_extraction_stage(sci_img, apertures, wavelength_calib=None, flat_field=flat_field, method_override='optimal', output_filename=f'{sci_name}_1D_optimal.fits', plot_prefix=f'{sci_name}_1D_optimal', **extract_kwargs)
|
|
1057
|
+
extracted_science_dict[sci_name] = opt_spec
|
|
1058
|
+
|
|
1059
|
+
# Step 6: De-blazing
|
|
1060
|
+
self._report_progress(0.80, "De-blazing")
|
|
1061
|
+
logger.info("=" * 50)
|
|
1062
|
+
logger.info("STEP 6: DE-BLAZING")
|
|
1063
|
+
logger.info("=" * 50)
|
|
1064
|
+
def do_deblaze(target_name, spectra_obj=None):
|
|
1065
|
+
from specproc.core.extraction import load_extracted_spectra
|
|
1066
|
+
db_opt = None
|
|
1067
|
+
for method in ['sum', 'optimal']:
|
|
1068
|
+
spectra = spectra_obj if (method == 'optimal' and spectra_obj is not None) else None
|
|
1069
|
+
if spectra is None:
|
|
1070
|
+
try: spectra = load_extracted_spectra(str(Path(self.config.get_output_path()) / 'step5_extraction' / f'{target_name}_1D_{method}.fits'))
|
|
1071
|
+
except Exception: pass
|
|
1072
|
+
if spectra is not None:
|
|
1073
|
+
db_spec = process_de_blazing_stage(
|
|
1074
|
+
spectra, self.config.get_output_path(), flat_field=flat_field,
|
|
1075
|
+
save_deblaze=self.config.get_bool('reduce.save_intermediate', 'save_deblaze', True),
|
|
1076
|
+
output_filename=f'{target_name}_1D_{method}_Deblaze.fits',
|
|
1077
|
+
plot_prefix=f'{target_name}_1D_{method}_Deblaze',
|
|
1078
|
+
save_plots=self.config.get_bool('reduce', 'save_plots', True),
|
|
1079
|
+
fig_format=self.config.get('reduce', 'fig_format', 'png')
|
|
1080
|
+
)
|
|
1081
|
+
if method == 'optimal': db_opt = db_spec
|
|
1082
|
+
return db_opt
|
|
1083
|
+
|
|
1084
|
+
do_deblaze("MasterFlat")
|
|
1085
|
+
for c_file in calib_filenames_final:
|
|
1086
|
+
do_deblaze(Path(c_file).stem)
|
|
1087
|
+
|
|
1088
|
+
deblazed_science_dict = {}
|
|
1089
|
+
for sci_name, opt_spec in extracted_science_dict.items():
|
|
1090
|
+
db_opt = do_deblaze(sci_name, opt_spec)
|
|
1091
|
+
if db_opt is not None:
|
|
1092
|
+
deblazed_science_dict[sci_name] = db_opt
|
|
1093
|
+
|
|
1094
|
+
# Step 7: Wavelength Calibration
|
|
1095
|
+
self._report_progress(0.90, "Wavelength Calibration")
|
|
1096
|
+
logger.info("=" * 50)
|
|
1097
|
+
logger.info("STEP 7: WAVELENGTH CALIBRATION")
|
|
1098
|
+
logger.info("=" * 50)
|
|
1099
|
+
|
|
1100
|
+
import os
|
|
1101
|
+
from astropy.time import Time
|
|
1102
|
+
time_key = self.config.get('data', 'statime_key', 'DATE-OBS')
|
|
1103
|
+
def get_file_time(filepath):
|
|
1104
|
+
if not filepath or not Path(filepath).exists(): return 0
|
|
1105
|
+
try:
|
|
1106
|
+
_, hdr = read_fits_image(filepath)
|
|
1107
|
+
if hdr and time_key in hdr:
|
|
1108
|
+
return Time(hdr[time_key]).unix
|
|
1109
|
+
except Exception: pass
|
|
1110
|
+
return os.path.getmtime(filepath)
|
|
1111
|
+
|
|
1112
|
+
calibrations = []
|
|
1113
|
+
for c_file in (calib_filenames_final if calib_filenames_final else calib_filenames):
|
|
1114
|
+
if not c_file: continue
|
|
1115
|
+
wave_calib = self.stage_wavelength_calibration(c_file)
|
|
1116
|
+
c_time = get_file_time(c_file)
|
|
1117
|
+
calibrations.append((c_time, wave_calib, Path(c_file).stem))
|
|
1118
|
+
|
|
1119
|
+
if not calibrations:
|
|
1120
|
+
raise RuntimeError("No wavelength calibrations could be generated.")
|
|
1121
|
+
|
|
1122
|
+
calibrated_science_dict = {}
|
|
1123
|
+
|
|
1124
|
+
targets = [("MasterFlat", None)]
|
|
1125
|
+
for c_file in (calib_filenames_final if calib_filenames_final else calib_filenames):
|
|
1126
|
+
if c_file: targets.append((Path(c_file).stem, c_file))
|
|
1127
|
+
for sci_name in extracted_science_dict.keys():
|
|
1128
|
+
sci_file_match = next((f for f in science_scatter_files if Path(f).stem == sci_name), None)
|
|
1129
|
+
if not sci_file_match:
|
|
1130
|
+
sci_file_match = next((f for f in raw_image_paths if Path(f).stem == sci_name), None)
|
|
1131
|
+
targets.append((sci_name, sci_file_match))
|
|
1132
|
+
|
|
1133
|
+
for target_name, target_file in targets:
|
|
1134
|
+
target_time = get_file_time(target_file)
|
|
1135
|
+
closest_calib = min(calibrations, key=lambda x: abs(x[0] - target_time))
|
|
1136
|
+
wave_calib = closest_calib[1]
|
|
1137
|
+
logger.info(f"Using calibration {closest_calib[2]} for target {target_name} (closest in time)")
|
|
1138
|
+
|
|
1139
|
+
calib_opt = None
|
|
1140
|
+
for method in ['sum', 'optimal']:
|
|
1141
|
+
from specproc.core.extraction import load_extracted_spectra
|
|
1142
|
+
spectra = None
|
|
1143
|
+
db_path = Path(self.config.get_output_path()) / 'step6_deblazing' / f'{target_name}_1D_{method}_Deblaze.fits'
|
|
1144
|
+
if db_path.exists():
|
|
1145
|
+
try: spectra = load_extracted_spectra(str(db_path))
|
|
1146
|
+
except Exception: pass
|
|
1147
|
+
if spectra is None:
|
|
1148
|
+
ex_path = Path(self.config.get_output_path()) / 'step5_extraction' / f'{target_name}_1D_{method}.fits'
|
|
1149
|
+
if ex_path.exists():
|
|
1150
|
+
try: spectra = load_extracted_spectra(str(ex_path))
|
|
1151
|
+
except Exception: pass
|
|
1152
|
+
|
|
1153
|
+
if spectra is not None:
|
|
1154
|
+
calib_spec = self.stage_wavelength_calibration_on_spectra(
|
|
1155
|
+
spectra, wave_calib, science_name=target_name, method=method)
|
|
1156
|
+
if method == 'optimal':
|
|
1157
|
+
calib_opt = calib_spec
|
|
1158
|
+
|
|
1159
|
+
if calib_opt is not None and target_name in extracted_science_dict:
|
|
1160
|
+
calibrated_science_dict[target_name] = calib_opt
|
|
1161
|
+
|
|
1162
|
+
self._report_progress(1.0, "Pipeline Complete")
|
|
1163
|
+
|
|
1164
|
+
logger.info("=" * 60)
|
|
1165
|
+
logger.info("PIPELINE EXECUTION COMPLETED SUCCESSFULLY")
|
|
1166
|
+
logger.info("=" * 60)
|
|
1167
|
+
|
|
1168
|
+
return calibrated_science_dict
|
|
1169
|
+
|
|
1170
|
+
except Exception as e:
|
|
1171
|
+
logger.error(f"Pipeline execution failed: {e}")
|
|
1172
|
+
logger.error("=" * 60)
|
|
1173
|
+
raise
|
|
1174
|
+
|
|
1175
|
+
# 返回当前流程处理状态。
|
|
1176
|
+
def get_state(self) -> ProcessingState:
|
|
1177
|
+
"""Get current processing state."""
|
|
1178
|
+
return self.state
|
|
1179
|
+
|
|
1180
|
+
# 重置所有流程状态。
|
|
1181
|
+
def reset(self):
|
|
1182
|
+
"""Reset pipeline state."""
|
|
1183
|
+
self.state.reset()
|