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,961 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Wavelength calibration stage of spectral reduction pipeline.
|
|
3
|
+
|
|
4
|
+
Handles ThAr line identification, polynomial fitting,
|
|
5
|
+
and wavelength solution computation.
|
|
6
|
+
"""
|
|
7
|
+
# 匹配参考发射线并拟合二维波长定标模型。
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import matplotlib.pyplot as plt
|
|
11
|
+
from matplotlib.backends.backend_pdf import PdfPages
|
|
12
|
+
from matplotlib import cm
|
|
13
|
+
import logging
|
|
14
|
+
from typing import Tuple, Optional, List, Dict
|
|
15
|
+
from scipy.optimize import curve_fit
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from specproc.core.data_structures import WaveCalib, Spectrum, SpectraSet
|
|
18
|
+
from specproc.config.config_manager import ConfigManager
|
|
19
|
+
from specproc.utils.fits_io import read_fits_image
|
|
20
|
+
from specproc.plotting.spectra_plotter import plot_wavelength_calibration
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# 负责建立并应用波长定标模型
|
|
26
|
+
class WavelengthCalibrator:
|
|
27
|
+
"""Handles wavelength calibration from reference lines."""
|
|
28
|
+
|
|
29
|
+
# 初始化对象状态
|
|
30
|
+
def __init__(self):
|
|
31
|
+
"""Initialize wavelength calibrator."""
|
|
32
|
+
self.wave_calib = None
|
|
33
|
+
self.reference_lines = {}
|
|
34
|
+
|
|
35
|
+
# 读取灯谱参考波长列表
|
|
36
|
+
def load_line_list(self, filepath: str) -> np.ndarray:
|
|
37
|
+
"""
|
|
38
|
+
Load reference line list for calibration lamps from a file.
|
|
39
|
+
Assumes wavelength is the first column.
|
|
40
|
+
"""
|
|
41
|
+
logger.info(f"Loading line list from {filepath}...")
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
wavelengths = []
|
|
45
|
+
with open(filepath, 'r', encoding='utf-8') as f:
|
|
46
|
+
for line in f:
|
|
47
|
+
line = line.split('#')[0].strip()
|
|
48
|
+
if not line:
|
|
49
|
+
continue
|
|
50
|
+
parts = line.split()
|
|
51
|
+
if parts:
|
|
52
|
+
try:
|
|
53
|
+
wavelengths.append(float(parts[0]))
|
|
54
|
+
except ValueError:
|
|
55
|
+
pass
|
|
56
|
+
wavelengths = np.array(wavelengths)
|
|
57
|
+
wavelengths = wavelengths[~np.isnan(wavelengths)]
|
|
58
|
+
return np.sort(wavelengths)
|
|
59
|
+
except Exception as e:
|
|
60
|
+
logger.error(f"Failed to load linelist from {filepath}: {e}")
|
|
61
|
+
return np.array([])
|
|
62
|
+
|
|
63
|
+
# 检测光谱中的发射线峰
|
|
64
|
+
def detect_lines_in_spectrum(self, spectrum: np.ndarray,
|
|
65
|
+
wavelength: Optional[np.ndarray] = None,
|
|
66
|
+
threshold: float = 10.0) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
67
|
+
"""
|
|
68
|
+
Detect emission lines in spectrum.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
spectrum: 1D flux array
|
|
72
|
+
wavelength: Optional wavelength array (for initial guess)
|
|
73
|
+
threshold: DetectionThreshold in sigma
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
Tuple of (pixel_positions, line_strengths, line_snrs)
|
|
77
|
+
"""
|
|
78
|
+
from scipy.signal import find_peaks
|
|
79
|
+
|
|
80
|
+
# 修复:使用相邻像素差值的 MAD (中位数绝对偏差) 来稳健估计高斯背景噪声。
|
|
81
|
+
# 发射线光谱中存在极少数超强峰,如果用 np.std(spectrum) 会导致算出的噪声大得离谱,
|
|
82
|
+
# 从而把成百上千条正常强度的发射线全部当成背景过滤掉!
|
|
83
|
+
diffs = np.diff(spectrum)
|
|
84
|
+
# 相邻相减后噪声放大了 sqrt(2) 倍,且 MAD 到 Sigma 的转换系数为 0.6745
|
|
85
|
+
mad_noise = np.nanmedian(np.abs(diffs - np.nanmedian(diffs))) / (np.sqrt(2) * 0.6745)
|
|
86
|
+
|
|
87
|
+
if mad_noise < 1e-6:
|
|
88
|
+
mad_noise = np.nanstd(spectrum)
|
|
89
|
+
|
|
90
|
+
# 使用 prominence (相对局部背景的凸起高度) 代替绝对 height
|
|
91
|
+
prom_thresh = max(threshold * mad_noise, 1e-3)
|
|
92
|
+
|
|
93
|
+
# distance=5 强制要求相邻的 peaks 至少相隔 5 个像素,防止将混叠的“宽肩膀”识别为多根线
|
|
94
|
+
peaks, properties = find_peaks(spectrum, prominence=prom_thresh, distance=5)
|
|
95
|
+
|
|
96
|
+
logger.debug(f"Detected {len(peaks)} emission lines")
|
|
97
|
+
|
|
98
|
+
snrs = properties['prominences'] / mad_noise if mad_noise > 0 else np.zeros_like(peaks, dtype=float)
|
|
99
|
+
return peaks, spectrum[peaks], snrs
|
|
100
|
+
|
|
101
|
+
# 读取级次与波长锚点
|
|
102
|
+
def load_anchor_file(self, anchor_file: str) -> Dict[int, np.ndarray]:
|
|
103
|
+
"""
|
|
104
|
+
读取灯谱证认的发射线锚点文件。
|
|
105
|
+
预期仅包含两列信息:True_Order(真实级次号), Wavelength(波长)。
|
|
106
|
+
"""
|
|
107
|
+
anchors = {}
|
|
108
|
+
try:
|
|
109
|
+
# 使用 genfromtxt 解析 CSV,自动过滤掉带有字符串的 Header 行
|
|
110
|
+
data = np.genfromtxt(anchor_file, delimiter=',')
|
|
111
|
+
data = data[~np.isnan(data).any(axis=1)]
|
|
112
|
+
|
|
113
|
+
if data.shape[1] >= 2:
|
|
114
|
+
for row in data:
|
|
115
|
+
m_ref = int(row[0])
|
|
116
|
+
wave = float(row[1])
|
|
117
|
+
if m_ref not in anchors:
|
|
118
|
+
anchors[m_ref] = []
|
|
119
|
+
anchors[m_ref].append(wave)
|
|
120
|
+
for m in anchors:
|
|
121
|
+
anchors[m] = np.sort(np.array(anchors[m]))
|
|
122
|
+
else:
|
|
123
|
+
logger.error("Anchor file must contain at least 2 columns: Order and Wavelength.")
|
|
124
|
+
raise ValueError(
|
|
125
|
+
"提供的锚点文件只有 1 列。请检查是否为 [级次号, 波长] 格式。"
|
|
126
|
+
)
|
|
127
|
+
except Exception as e:
|
|
128
|
+
logger.error(f"Failed to load anchor file {anchor_file}: {e}")
|
|
129
|
+
raise
|
|
130
|
+
return anchors
|
|
131
|
+
|
|
132
|
+
# 从文件恢复波长定标模型
|
|
133
|
+
def load_calibration(self, filepath: str) -> WaveCalib:
|
|
134
|
+
"""
|
|
135
|
+
从先前保存的 FITS 文件中加载波长定标模型 (Pre-computed Calibration File).
|
|
136
|
+
"""
|
|
137
|
+
from astropy.io import fits
|
|
138
|
+
logger.info(f"Loading precomputed wavelength calibration from {filepath}...")
|
|
139
|
+
with fits.open(filepath) as hdul:
|
|
140
|
+
hdr = hdul[0].header
|
|
141
|
+
coefs = hdul[0].data
|
|
142
|
+
wave_calib = WaveCalib(
|
|
143
|
+
poly_coef=coefs,
|
|
144
|
+
xorder=hdr.get('XORDER', 4),
|
|
145
|
+
yorder=hdr.get('YORDER', 4),
|
|
146
|
+
poly_type=hdr.get('POLY_TYP', 'chebyshev'),
|
|
147
|
+
domain_x=(hdr.get('X_MIN', 0.0), hdr.get('X_MAX', 4000.0)),
|
|
148
|
+
domain_y=(hdr.get('Y_MIN', 0.0), hdr.get('Y_MAX', 150.0)),
|
|
149
|
+
line_pixels=np.array([]),
|
|
150
|
+
line_orders=np.array([]),
|
|
151
|
+
line_catalog=np.array([]),
|
|
152
|
+
rms=hdr.get('RMS', 0.0),
|
|
153
|
+
nlines=hdr.get('NLINES', 0),
|
|
154
|
+
calib_type=hdr.get('CALIBTYP', 'Unknown')
|
|
155
|
+
)
|
|
156
|
+
wave_calib.delta_m = hdr.get('DELTAM', 0)
|
|
157
|
+
self.wave_calib = wave_calib
|
|
158
|
+
return wave_calib
|
|
159
|
+
|
|
160
|
+
# 搜索级次偏移并匹配锚点
|
|
161
|
+
def find_order_offset_and_match(self, detected_peaks: Dict[int, np.ndarray], anchors: Dict[int, np.ndarray]) -> Tuple[int, List[Tuple[float, int, float]]]:
|
|
162
|
+
"""
|
|
163
|
+
寻找最佳偏移 (delta_m = True_Order - Aperture_Number) 并返回成功匹配的锚点 (X_obs, True_Order, wavelength)。
|
|
164
|
+
两阶段盲搜法:
|
|
165
|
+
1. 仅使用少量均匀分布的孔径(Aperture)和强峰,显式区分正反色散方向快速搜索最佳 delta_m。
|
|
166
|
+
2. 利用确定的方向和偏移量,对全量孔径进行秒级匹配。
|
|
167
|
+
"""
|
|
168
|
+
all_m_obs = sorted(list(detected_peaks.keys()))
|
|
169
|
+
if not all_m_obs:
|
|
170
|
+
return 0, []
|
|
171
|
+
|
|
172
|
+
# ====================================================================
|
|
173
|
+
# Phase 1: 抽取约三分之一均匀分布的孔径进行极速盲搜,确定最佳偏移量
|
|
174
|
+
# ====================================================================
|
|
175
|
+
step = 3 if len(all_m_obs) >= 3 else 1
|
|
176
|
+
search_apertures = all_m_obs[::step]
|
|
177
|
+
|
|
178
|
+
best_offset = 0
|
|
179
|
+
best_direction = 1 # 1: Left-to-Right, -1: Right-to-Left
|
|
180
|
+
max_total_matches = -1
|
|
181
|
+
|
|
182
|
+
# 动态计算 delta_m 的合理搜索范围
|
|
183
|
+
min_anchor_m = min(anchors.keys())
|
|
184
|
+
|
|
185
|
+
search_range_start = 0
|
|
186
|
+
search_range_end = min_anchor_m
|
|
187
|
+
|
|
188
|
+
logger.info(f"Phase 1: Fast blind matching on {len(search_apertures)} selected apertures. Searching delta_m from {search_range_start} to {search_range_end}...")
|
|
189
|
+
|
|
190
|
+
for direction in [1, -1]:
|
|
191
|
+
for delta_m in range(search_range_start, search_range_end + 1):
|
|
192
|
+
total_matches = 0
|
|
193
|
+
for m_obs in search_apertures:
|
|
194
|
+
X = detected_peaks[m_obs]
|
|
195
|
+
|
|
196
|
+
m_ref = m_obs + delta_m
|
|
197
|
+
if m_ref not in anchors: continue
|
|
198
|
+
|
|
199
|
+
W = anchors[m_ref]
|
|
200
|
+
if len(W) < 2 or len(X) < 2: continue
|
|
201
|
+
|
|
202
|
+
best_inliers = []
|
|
203
|
+
|
|
204
|
+
# 遍历所有可能的锚点波长对和观测峰对
|
|
205
|
+
for k in range(len(W) - 1):
|
|
206
|
+
for l in range(k + 1, len(W)):
|
|
207
|
+
dw = W[l] - W[k]
|
|
208
|
+
for i in range(len(X) - 1):
|
|
209
|
+
for j in range(i + 1, len(X)):
|
|
210
|
+
dx = X[j] - X[i]
|
|
211
|
+
if dx == 0: continue
|
|
212
|
+
|
|
213
|
+
# 色散率 a = direction * |dw/dx|
|
|
214
|
+
a_test = direction * abs(dw / dx)
|
|
215
|
+
if not (0.002 < abs(a_test) < 0.5): continue
|
|
216
|
+
|
|
217
|
+
# 正向色散:波长随 X 增加。所以较小的 X[i] 对应较小的 W[k]
|
|
218
|
+
# 反向色散:波长随 X 减小。所以较大的 X[j] 对应较小的 W[k]
|
|
219
|
+
b = W[k] - a_test * (X[i] if direction == 1 else X[j])
|
|
220
|
+
|
|
221
|
+
W_pred = a_test * X + b
|
|
222
|
+
diffs = np.abs(W[:, None] - W_pred[None, :])
|
|
223
|
+
min_dist_idx = np.argmin(diffs, axis=0)
|
|
224
|
+
min_dist = np.min(diffs, axis=0)
|
|
225
|
+
|
|
226
|
+
valid = min_dist < 1.5
|
|
227
|
+
if np.sum(valid) > len(best_inliers):
|
|
228
|
+
matched_w = min_dist_idx[valid]
|
|
229
|
+
_, unique_indices = np.unique(matched_w, return_index=True)
|
|
230
|
+
if len(unique_indices) > len(best_inliers):
|
|
231
|
+
valid_x_idx = np.where(valid)[0][unique_indices]
|
|
232
|
+
valid_w_idx = matched_w[unique_indices]
|
|
233
|
+
best_inliers = [(X[vx], m_ref, W[vw]) for vx, vw in zip(valid_x_idx, valid_w_idx)]
|
|
234
|
+
|
|
235
|
+
if len(best_inliers) >= max(2, min(3, len(W))):
|
|
236
|
+
total_matches += len(best_inliers)
|
|
237
|
+
|
|
238
|
+
if total_matches > max_total_matches:
|
|
239
|
+
max_total_matches = total_matches
|
|
240
|
+
best_offset = delta_m
|
|
241
|
+
best_direction = direction
|
|
242
|
+
|
|
243
|
+
if max_total_matches <= 0:
|
|
244
|
+
logger.warning("盲配模式匹配失败!未找到合适的级次(Order)偏移。请检查峰值提取、锚点文件或放宽色散率限制。")
|
|
245
|
+
return 0, []
|
|
246
|
+
|
|
247
|
+
dir_str = "Left-to-Right (+)" if best_direction == 1 else "Right-to-Left (-)"
|
|
248
|
+
logger.info(f"Phase 1 complete: Found delta_m={best_offset}, dispersion direction={dir_str}")
|
|
249
|
+
|
|
250
|
+
# ====================================================================
|
|
251
|
+
# Phase 2: 使用确定的最佳偏移量和色散方向,全量孔径(Apertures)提取匹配点
|
|
252
|
+
# ====================================================================
|
|
253
|
+
logger.info(f"Phase 2: Extracting matched anchors across all apertures using delta_m={best_offset}...")
|
|
254
|
+
best_global_matched_points = []
|
|
255
|
+
global_total_matches = 0
|
|
256
|
+
|
|
257
|
+
for m_obs in all_m_obs:
|
|
258
|
+
X = detected_peaks[m_obs]
|
|
259
|
+
m_ref = m_obs + best_offset
|
|
260
|
+
if m_ref not in anchors: continue
|
|
261
|
+
W = anchors[m_ref]
|
|
262
|
+
if len(W) < 2 or len(X) < 2: continue
|
|
263
|
+
|
|
264
|
+
best_inliers = []
|
|
265
|
+
for k in range(len(W) - 1):
|
|
266
|
+
for l in range(k + 1, len(W)):
|
|
267
|
+
dw = W[l] - W[k]
|
|
268
|
+
for i in range(len(X) - 1):
|
|
269
|
+
for j in range(i + 1, len(X)):
|
|
270
|
+
dx = X[j] - X[i]
|
|
271
|
+
if dx == 0: continue
|
|
272
|
+
|
|
273
|
+
# 直接使用 Phase 1 确定的最佳色散方向
|
|
274
|
+
a_test = best_direction * abs(dw / dx)
|
|
275
|
+
if not (0.002 < abs(a_test) < 0.5): continue
|
|
276
|
+
|
|
277
|
+
b = W[k] - a_test * (X[i] if best_direction == 1 else X[j])
|
|
278
|
+
|
|
279
|
+
W_pred = a_test * X + b
|
|
280
|
+
diffs = np.abs(W[:, None] - W_pred[None, :])
|
|
281
|
+
min_dist_idx = np.argmin(diffs, axis=0)
|
|
282
|
+
min_dist = np.min(diffs, axis=0)
|
|
283
|
+
|
|
284
|
+
valid = min_dist < 2.5 # 放宽初筛容差以包容边缘非线性
|
|
285
|
+
if np.sum(valid) > len(best_inliers):
|
|
286
|
+
matched_w = min_dist_idx[valid]
|
|
287
|
+
_, unique_indices = np.unique(matched_w, return_index=True)
|
|
288
|
+
|
|
289
|
+
# 二次多项式精校准 (针对非线性强烈的蓝端级次)
|
|
290
|
+
if len(unique_indices) >= 3:
|
|
291
|
+
vx = X[np.where(valid)[0][unique_indices]]
|
|
292
|
+
vw = W[matched_w[unique_indices]]
|
|
293
|
+
try:
|
|
294
|
+
p2 = np.polyfit(vx, vw, 2)
|
|
295
|
+
W_pred2 = np.polyval(p2, X)
|
|
296
|
+
diffs2 = np.abs(W[:, None] - W_pred2[None, :])
|
|
297
|
+
min_dist_idx2 = np.argmin(diffs2, axis=0)
|
|
298
|
+
min_dist2 = np.min(diffs2, axis=0)
|
|
299
|
+
valid2 = min_dist2 < 1.5 # 二次拟合后收紧容差
|
|
300
|
+
matched_w2 = min_dist_idx2[valid2]
|
|
301
|
+
_, uniq2 = np.unique(matched_w2, return_index=True)
|
|
302
|
+
if len(uniq2) >= len(unique_indices):
|
|
303
|
+
valid = valid2
|
|
304
|
+
matched_w = matched_w2
|
|
305
|
+
unique_indices = uniq2
|
|
306
|
+
except Exception:
|
|
307
|
+
pass
|
|
308
|
+
|
|
309
|
+
if len(unique_indices) > len(best_inliers):
|
|
310
|
+
valid_x_idx = np.where(valid)[0][unique_indices]
|
|
311
|
+
valid_w_idx = matched_w[unique_indices]
|
|
312
|
+
best_inliers = [(X[vx], m_ref, W[vw]) for vx, vw in zip(valid_x_idx, valid_w_idx)]
|
|
313
|
+
|
|
314
|
+
if len(best_inliers) >= max(2, min(3, len(W))):
|
|
315
|
+
global_total_matches += len(best_inliers)
|
|
316
|
+
best_global_matched_points.extend(best_inliers)
|
|
317
|
+
|
|
318
|
+
logger.info(f"Blind match fully complete: delta_m={best_offset} with {global_total_matches} lines matched across all apertures.")
|
|
319
|
+
|
|
320
|
+
return best_offset, best_global_matched_points
|
|
321
|
+
|
|
322
|
+
# 用粗定标匹配完整谱线目录
|
|
323
|
+
def match_full_catalog(self, detected_peaks: Dict[int, np.ndarray], rough_calib: WaveCalib, delta_m: int, full_linelist: np.ndarray, tolerance: float = 0.5) -> Tuple[np.ndarray, np.ndarray]:
|
|
324
|
+
"""
|
|
325
|
+
利用拟合的 2D 粗校准模型,预测所有 detected_peaks 的波长并匹配全量线库。
|
|
326
|
+
"""
|
|
327
|
+
pix_pos = []
|
|
328
|
+
matched_wave = []
|
|
329
|
+
|
|
330
|
+
if len(full_linelist) == 0:
|
|
331
|
+
logger.error("全量线库为空,跳过全量匹配阶段。")
|
|
332
|
+
return np.array([]), np.array([])
|
|
333
|
+
|
|
334
|
+
for m_obs, peaks_x in detected_peaks.items():
|
|
335
|
+
if len(peaks_x) == 0:
|
|
336
|
+
continue
|
|
337
|
+
m_true = m_obs + delta_m
|
|
338
|
+
|
|
339
|
+
wave_pred = rough_calib.apply_to_pixel(peaks_x, np.full_like(peaks_x, m_true))
|
|
340
|
+
|
|
341
|
+
# 全库匹配寻找最近线
|
|
342
|
+
for x_val, w_pred in zip(peaks_x, wave_pred):
|
|
343
|
+
dist = np.abs(full_linelist - w_pred)
|
|
344
|
+
idx = np.argmin(dist)
|
|
345
|
+
if dist[idx] < tolerance:
|
|
346
|
+
pix_pos.append([x_val, m_true])
|
|
347
|
+
matched_wave.append(full_linelist[idx])
|
|
348
|
+
|
|
349
|
+
return np.array(pix_pos), np.array(matched_wave)
|
|
350
|
+
|
|
351
|
+
# 拟合像素与级次的二维波长关系
|
|
352
|
+
def fit_wavelength_polynomial(self, pixel_positions: np.ndarray,
|
|
353
|
+
wavelengths: np.ndarray,
|
|
354
|
+
xorder: int = 4, yorder: int = 4,
|
|
355
|
+
poly_type: str = 'chebyshev',
|
|
356
|
+
max_residual: float = 0.5) -> WaveCalib:
|
|
357
|
+
"""
|
|
358
|
+
Fit 2D wavelength polynomial.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
pixel_positions: Array of (x, y) pixel coordinates
|
|
362
|
+
wavelengths: Corresponding wavelengths
|
|
363
|
+
xorder: Polynomial order in X
|
|
364
|
+
yorder: Polynomial order in Y
|
|
365
|
+
poly_type: Polynomial basis ('chebyshev', 'legendre' or 'polynomial')
|
|
366
|
+
max_residual: Absolute maximum residual (in Angstroms) to keep a line
|
|
367
|
+
|
|
368
|
+
Returns:
|
|
369
|
+
WaveCalib object with polynomial solution
|
|
370
|
+
"""
|
|
371
|
+
logger.info(f"Fitting wavelength 2D {poly_type} polynomial (orders: {xorder}, {yorder})...")
|
|
372
|
+
|
|
373
|
+
if len(pixel_positions) != len(wavelengths):
|
|
374
|
+
raise ValueError("Pixel positions and wavelengths must have same length")
|
|
375
|
+
|
|
376
|
+
# Extract x, y coordinates
|
|
377
|
+
if pixel_positions.ndim == 2:
|
|
378
|
+
x_pix = pixel_positions[:, 0]
|
|
379
|
+
y_pix = pixel_positions[:, 1]
|
|
380
|
+
else:
|
|
381
|
+
x_pix = pixel_positions
|
|
382
|
+
y_pix = np.zeros_like(x_pix)
|
|
383
|
+
|
|
384
|
+
# 改为拟合 m * lambda
|
|
385
|
+
m_lambda = wavelengths * y_pix
|
|
386
|
+
|
|
387
|
+
# 获取参与定标数据的边界值,用于归一化
|
|
388
|
+
domain_x = (float(np.min(x_pix)), float(np.max(x_pix)))
|
|
389
|
+
domain_y = (float(np.min(y_pix)), float(np.max(y_pix)))
|
|
390
|
+
|
|
391
|
+
min_required = (xorder + 1) * (yorder + 1)
|
|
392
|
+
|
|
393
|
+
# Auto-downgrade polynomial order if not enough points
|
|
394
|
+
orig_xorder, orig_yorder = xorder, yorder
|
|
395
|
+
while len(m_lambda) < min_required and (xorder > 1 or yorder > 1):
|
|
396
|
+
if xorder > yorder:
|
|
397
|
+
xorder -= 1
|
|
398
|
+
elif yorder > xorder:
|
|
399
|
+
yorder -= 1
|
|
400
|
+
else:
|
|
401
|
+
xorder -= 1
|
|
402
|
+
yorder -= 1
|
|
403
|
+
min_required = (xorder + 1) * (yorder + 1)
|
|
404
|
+
|
|
405
|
+
if len(m_lambda) < min_required:
|
|
406
|
+
raise RuntimeError(f"Not enough matched lines ({len(m_lambda)}) to fit even a 1x1 2D polynomial. "
|
|
407
|
+
f"Requires at least {min_required} lines. Please check your data and anchor files.")
|
|
408
|
+
|
|
409
|
+
if xorder != orig_xorder or yorder != orig_yorder:
|
|
410
|
+
logger.warning(f"Auto-downgraded polynomial order from {orig_xorder}x{orig_yorder} "
|
|
411
|
+
f"to {xorder}x{yorder} due to limited points ({len(m_lambda)}).")
|
|
412
|
+
|
|
413
|
+
# 将像素和级次归一化到 [-1, 1] 区间
|
|
414
|
+
x_norm = 2.0 * (x_pix - domain_x[0]) / max(domain_x[1] - domain_x[0], 1e-6) - 1.0
|
|
415
|
+
y_norm = 2.0 * (y_pix - domain_y[0]) / max(domain_y[1] - domain_y[0], 1e-6) - 1.0
|
|
416
|
+
|
|
417
|
+
# 利用 numpy.polynomial 的 Vander 函数快速构建 2D 正交基设计矩阵
|
|
418
|
+
if poly_type == 'chebyshev':
|
|
419
|
+
from numpy.polynomial.chebyshev import chebvander2d
|
|
420
|
+
A = chebvander2d(x_norm, y_norm, [xorder, yorder])
|
|
421
|
+
elif poly_type == 'legendre':
|
|
422
|
+
from numpy.polynomial.legendre import legvander2d
|
|
423
|
+
A = legvander2d(x_norm, y_norm, [xorder, yorder])
|
|
424
|
+
else:
|
|
425
|
+
from numpy.polynomial.polynomial import polyvander2d
|
|
426
|
+
A = polyvander2d(x_norm, y_norm, [xorder, yorder])
|
|
427
|
+
|
|
428
|
+
# Solve least squares
|
|
429
|
+
try:
|
|
430
|
+
keep = np.ones(len(m_lambda), dtype=bool)
|
|
431
|
+
coeffs_1d = None
|
|
432
|
+
for _ in range(5):
|
|
433
|
+
if np.sum(keep) < (xorder + 1) * (yorder + 1):
|
|
434
|
+
logger.warning("Too few points left for 2D poly fit after clipping.")
|
|
435
|
+
break
|
|
436
|
+
|
|
437
|
+
A_fit = A[keep]
|
|
438
|
+
m_lambda_fit = m_lambda[keep]
|
|
439
|
+
|
|
440
|
+
coeffs_1d, residuals_lstsq, rank, s = np.linalg.lstsq(A_fit, m_lambda_fit, rcond=None)
|
|
441
|
+
|
|
442
|
+
pred_m_lambda = A @ coeffs_1d
|
|
443
|
+
pred_lambda = pred_m_lambda / y_pix
|
|
444
|
+
residuals = pred_lambda - wavelengths
|
|
445
|
+
|
|
446
|
+
rms = np.sqrt(np.mean(residuals[keep] ** 2))
|
|
447
|
+
|
|
448
|
+
# 修复:设置 3-sigma 剔除的下限 (例如 0.1 埃)。
|
|
449
|
+
# 防止刚性模型(如低阶多项式)在早期迭代时因为核心点拟合得太好导致 rms 极小,
|
|
450
|
+
# 进而产生“滚雪球”式的过度剔除 (Over-clipping)。
|
|
451
|
+
clip_threshold = max(3.0 * rms, 0.1)
|
|
452
|
+
|
|
453
|
+
# Clip by both robust 3-sigma and absolute maximum residual limit
|
|
454
|
+
new_keep = (np.abs(residuals) < clip_threshold) & (np.abs(residuals) <= max_residual)
|
|
455
|
+
if np.array_equal(new_keep, keep):
|
|
456
|
+
break
|
|
457
|
+
keep = new_keep
|
|
458
|
+
|
|
459
|
+
n_rejected = len(keep) - np.sum(keep)
|
|
460
|
+
if n_rejected > 0:
|
|
461
|
+
logger.info(f"Sigma clipping rejected {n_rejected} outliers.")
|
|
462
|
+
|
|
463
|
+
if coeffs_1d is None:
|
|
464
|
+
raise RuntimeError(f"Failed to fit 2D polynomial (xorder={xorder}, yorder={yorder}). "
|
|
465
|
+
f"Too many outliers rejected. Not enough valid points ({np.sum(keep)}) remaining.")
|
|
466
|
+
|
|
467
|
+
pred_m_lambda = A @ coeffs_1d
|
|
468
|
+
pred_lambda = pred_m_lambda / y_pix
|
|
469
|
+
rms = np.sqrt(np.mean((pred_lambda[keep] - wavelengths[keep]) ** 2))
|
|
470
|
+
|
|
471
|
+
logger.info(f"Wavelength fit RMS: {rms:.4f} Angstrom (on {np.sum(keep)} lines)")
|
|
472
|
+
|
|
473
|
+
# Reshape coefficients into 2D array
|
|
474
|
+
poly_coef = coeffs_1d.reshape((xorder + 1, yorder + 1))
|
|
475
|
+
|
|
476
|
+
# Create WaveCalib object (only storing valid inliers in standard fields)
|
|
477
|
+
wave_calib = WaveCalib(
|
|
478
|
+
poly_coef=poly_coef,
|
|
479
|
+
xorder=xorder,
|
|
480
|
+
yorder=yorder,
|
|
481
|
+
poly_type=poly_type,
|
|
482
|
+
domain_x=domain_x,
|
|
483
|
+
domain_y=domain_y,
|
|
484
|
+
line_pixels=x_pix[keep],
|
|
485
|
+
line_orders=y_pix[keep],
|
|
486
|
+
line_catalog=wavelengths[keep],
|
|
487
|
+
rms=rms,
|
|
488
|
+
nlines=int(np.sum(keep)),
|
|
489
|
+
calib_type='ThAr'
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
# Attach full point arrays for diagnostic plotting
|
|
493
|
+
wave_calib.all_line_pixels = x_pix
|
|
494
|
+
wave_calib.all_line_orders = y_pix
|
|
495
|
+
wave_calib.all_line_catalog = wavelengths
|
|
496
|
+
wave_calib.keep_mask = keep
|
|
497
|
+
|
|
498
|
+
self.wave_calib = wave_calib
|
|
499
|
+
return wave_calib
|
|
500
|
+
|
|
501
|
+
except Exception as e:
|
|
502
|
+
logger.error(f"Error fitting wavelength polynomial: {e}")
|
|
503
|
+
raise
|
|
504
|
+
|
|
505
|
+
# 将波长定标应用于一维光谱
|
|
506
|
+
def apply_wavelength_calibration(self, spectrum: np.ndarray,
|
|
507
|
+
pixel_array: np.ndarray,
|
|
508
|
+
aperture_y: float = 0.0) -> np.ndarray:
|
|
509
|
+
"""
|
|
510
|
+
Apply wavelength calibration to spectrum pixels.
|
|
511
|
+
|
|
512
|
+
Args:
|
|
513
|
+
spectrum: 1D spectrum array
|
|
514
|
+
pixel_array: Pixel coordinate array
|
|
515
|
+
aperture_y: Y coordinate (True Order number) of aperture
|
|
516
|
+
|
|
517
|
+
Returns:
|
|
518
|
+
Wavelength array
|
|
519
|
+
"""
|
|
520
|
+
if self.wave_calib is None:
|
|
521
|
+
raise RuntimeError("No wavelength calibration available")
|
|
522
|
+
|
|
523
|
+
# Apply polynomial
|
|
524
|
+
wavelength = self.wave_calib.apply_to_pixel(pixel_array, aperture_y * np.ones_like(pixel_array))
|
|
525
|
+
|
|
526
|
+
return wavelength
|
|
527
|
+
|
|
528
|
+
# 保存波长定标系数和元数据
|
|
529
|
+
def save_calibration(self, output_path: str):
|
|
530
|
+
"""Save wavelength calibration to FITS file."""
|
|
531
|
+
if self.wave_calib is None:
|
|
532
|
+
raise RuntimeError("No wavelength calibration to save")
|
|
533
|
+
|
|
534
|
+
from astropy.io import fits
|
|
535
|
+
|
|
536
|
+
output_path = Path(output_path)
|
|
537
|
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
538
|
+
|
|
539
|
+
# Build a proper fits.Header (PrimaryHDU rejects plain dicts)
|
|
540
|
+
hdr = fits.Header()
|
|
541
|
+
hdr['XORDER'] = self.wave_calib.xorder
|
|
542
|
+
hdr['YORDER'] = self.wave_calib.yorder
|
|
543
|
+
hdr['RMS'] = self.wave_calib.rms
|
|
544
|
+
hdr['NLINES'] = self.wave_calib.nlines
|
|
545
|
+
hdr['CALIBTYP'] = self.wave_calib.calib_type
|
|
546
|
+
hdr['DELTAM'] = getattr(self.wave_calib, 'delta_m', 0)
|
|
547
|
+
hdr['POLY_TYP'] = getattr(self.wave_calib, 'poly_type', 'chebyshev')
|
|
548
|
+
if hasattr(self.wave_calib, 'domain_x'):
|
|
549
|
+
hdr['X_MIN'], hdr['X_MAX'] = self.wave_calib.domain_x
|
|
550
|
+
hdr['Y_MIN'], hdr['Y_MAX'] = self.wave_calib.domain_y
|
|
551
|
+
|
|
552
|
+
hdul = fits.HDUList([
|
|
553
|
+
fits.PrimaryHDU(data=self.wave_calib.poly_coef, header=hdr),
|
|
554
|
+
])
|
|
555
|
+
|
|
556
|
+
hdul.writeto(str(output_path), overwrite=True)
|
|
557
|
+
logger.info(f"Saved wavelength calibration to {output_path}")
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
# 绘制定标散点与残差诊断图
|
|
561
|
+
def _plot_calib_diagnostic(wave_calib: WaveCalib, plot_file: str, max_residual: float = 0.5):
|
|
562
|
+
"""生成波长定标诊断图(散点图及拟合残差)。"""
|
|
563
|
+
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(10, 8), sharex=True, gridspec_kw={'height_ratios': [3, 1]})
|
|
564
|
+
|
|
565
|
+
x_pix = getattr(wave_calib, 'all_line_pixels', wave_calib.line_pixels)
|
|
566
|
+
m_orders = getattr(wave_calib, 'all_line_orders', wave_calib.line_orders)
|
|
567
|
+
wave_ref = getattr(wave_calib, 'all_line_catalog', wave_calib.line_catalog)
|
|
568
|
+
keep_mask = getattr(wave_calib, 'keep_mask', np.ones(len(x_pix), dtype=bool))
|
|
569
|
+
|
|
570
|
+
n_total = len(x_pix)
|
|
571
|
+
n_valid = wave_calib.nlines
|
|
572
|
+
|
|
573
|
+
wave_pred = wave_calib.apply_to_pixel(x_pix, m_orders)
|
|
574
|
+
residuals = wave_pred - wave_ref
|
|
575
|
+
|
|
576
|
+
unique_orders = np.unique(m_orders)
|
|
577
|
+
cmap = cm.get_cmap('turbo')
|
|
578
|
+
|
|
579
|
+
for i, m in enumerate(unique_orders):
|
|
580
|
+
mask = (m_orders == m)
|
|
581
|
+
color = cmap(i / max(1, len(unique_orders) - 1))
|
|
582
|
+
|
|
583
|
+
# 上图:拟合曲线与参考点
|
|
584
|
+
label_str = f'Order {int(m)}' if len(unique_orders) <= 15 else None
|
|
585
|
+
|
|
586
|
+
inlier_mask = mask & keep_mask
|
|
587
|
+
if np.any(inlier_mask):
|
|
588
|
+
ax1.scatter(x_pix[inlier_mask], wave_ref[inlier_mask], color=color, s=15, alpha=0.8, label=label_str)
|
|
589
|
+
ax2.scatter(x_pix[inlier_mask], residuals[inlier_mask], color=color, s=15, alpha=0.8)
|
|
590
|
+
|
|
591
|
+
outlier_mask = mask & ~keep_mask
|
|
592
|
+
if np.any(outlier_mask):
|
|
593
|
+
ax1.scatter(x_pix[outlier_mask], wave_ref[outlier_mask], color='gray', marker='x', s=15, alpha=0.6)
|
|
594
|
+
ax2.scatter(x_pix[outlier_mask], residuals[outlier_mask], color='gray', marker='x', s=15, alpha=0.6)
|
|
595
|
+
|
|
596
|
+
x_smooth = np.linspace(x_pix[mask].min(), x_pix[mask].max(), 100)
|
|
597
|
+
wave_smooth = wave_calib.apply_to_pixel(x_smooth, np.full_like(x_smooth, m))
|
|
598
|
+
ax1.plot(x_smooth, wave_smooth, color=color, alpha=0.5)
|
|
599
|
+
|
|
600
|
+
ax1.set_ylabel(r'Wavelength ($\AA$)')
|
|
601
|
+
ax1.set_title(fr'Wavelength Calibration (RMS = {wave_calib.rms:.4f} $\AA$, N = {n_valid}/{n_total})')
|
|
602
|
+
if len(unique_orders) <= 15:
|
|
603
|
+
ax1.legend(loc='center left', bbox_to_anchor=(1, 0.5), fontsize='small')
|
|
604
|
+
ax1.grid(True, alpha=0.3)
|
|
605
|
+
|
|
606
|
+
ax2.axhline(0, color='k', linestyle='--', alpha=0.5)
|
|
607
|
+
ax2.axhline(max_residual, color='r', linestyle=':', alpha=0.6)
|
|
608
|
+
ax2.axhline(-max_residual, color='r', linestyle=':', alpha=0.6)
|
|
609
|
+
ax2.set_xlabel('Pixel (X)')
|
|
610
|
+
ax2.set_ylabel(r'Residual ($\AA$)')
|
|
611
|
+
|
|
612
|
+
# 动态对称缩放 Y 轴,包含异常值
|
|
613
|
+
max_data_res = np.max(np.abs(residuals)) if len(residuals) > 0 else 0.1
|
|
614
|
+
y_lim = max(max_data_res * 1.1, max_residual * 1.5)
|
|
615
|
+
ax2.set_ylim(-y_lim, y_lim)
|
|
616
|
+
ax2.grid(True, alpha=0.3)
|
|
617
|
+
|
|
618
|
+
plt.tight_layout()
|
|
619
|
+
plt.savefig(plot_file, dpi=150)
|
|
620
|
+
plt.close(fig)
|
|
621
|
+
|
|
622
|
+
# 绘制二维定标曲面与残差图
|
|
623
|
+
def _plot_calib_surface_diagnostic(wave_calib: WaveCalib, plot_file: str, max_residual: float = 0.5):
|
|
624
|
+
"""生成二维波长定标拟合曲面和残差诊断图。"""
|
|
625
|
+
fig = plt.figure(figsize=(14, 6))
|
|
626
|
+
|
|
627
|
+
ax1 = fig.add_subplot(121, projection='3d')
|
|
628
|
+
x_pix = getattr(wave_calib, 'all_line_pixels', wave_calib.line_pixels)
|
|
629
|
+
m_orders = getattr(wave_calib, 'all_line_orders', wave_calib.line_orders)
|
|
630
|
+
wave_ref = getattr(wave_calib, 'all_line_catalog', wave_calib.line_catalog)
|
|
631
|
+
keep_mask = getattr(wave_calib, 'keep_mask', np.ones(len(x_pix), dtype=bool))
|
|
632
|
+
|
|
633
|
+
n_total = len(x_pix)
|
|
634
|
+
n_valid = wave_calib.nlines
|
|
635
|
+
|
|
636
|
+
# 生成用于绘制平滑曲面的网格
|
|
637
|
+
x_grid = np.linspace(x_pix.min(), x_pix.max(), 50)
|
|
638
|
+
m_grid = np.linspace(m_orders.min(), m_orders.max(), 50)
|
|
639
|
+
X, M = np.meshgrid(x_grid, m_grid)
|
|
640
|
+
WAVE = wave_calib.apply_to_pixel(X, M)
|
|
641
|
+
|
|
642
|
+
# 绘制预测曲面和实际匹配散点
|
|
643
|
+
ax1.plot_surface(X, M, WAVE, cmap='viridis', alpha=0.6, edgecolor='none')
|
|
644
|
+
|
|
645
|
+
ax1.scatter(x_pix[keep_mask], m_orders[keep_mask], wave_ref[keep_mask], color='r', s=10, alpha=0.8, label='Matched Lines')
|
|
646
|
+
if np.any(~keep_mask):
|
|
647
|
+
ax1.scatter(x_pix[~keep_mask], m_orders[~keep_mask], wave_ref[~keep_mask], color='gray', marker='x', s=10, alpha=0.6, label='Rejected Lines')
|
|
648
|
+
|
|
649
|
+
ax1.set_xlabel('Pixel (X)')
|
|
650
|
+
ax1.set_ylabel('Order (m)')
|
|
651
|
+
ax1.set_zlabel(r'Wavelength ($\AA$)')
|
|
652
|
+
ax1.set_title(f'2D Wavelength Solution Surface\n({wave_calib.poly_type.capitalize()} Poly, {wave_calib.xorder}x{wave_calib.yorder})')
|
|
653
|
+
|
|
654
|
+
# 2. 拟合残差图 (二维散点,颜色区分级次)
|
|
655
|
+
ax2 = fig.add_subplot(122)
|
|
656
|
+
residuals = wave_calib.apply_to_pixel(x_pix, m_orders) - wave_ref
|
|
657
|
+
|
|
658
|
+
scatter = ax2.scatter(x_pix[keep_mask], residuals[keep_mask], c=m_orders[keep_mask], cmap='turbo', s=15, alpha=0.8)
|
|
659
|
+
if np.any(~keep_mask):
|
|
660
|
+
ax2.scatter(x_pix[~keep_mask], residuals[~keep_mask], color='gray', marker='x', s=15, alpha=0.6)
|
|
661
|
+
|
|
662
|
+
ax2.axhline(0, color='k', linestyle='--', alpha=0.5)
|
|
663
|
+
ax2.axhline(max_residual, color='r', linestyle=':', alpha=0.8, label=rf'Limit ($\pm${max_residual:.2f})')
|
|
664
|
+
ax2.axhline(-max_residual, color='r', linestyle=':', alpha=0.8)
|
|
665
|
+
ax2.legend(loc='upper right', fontsize='small')
|
|
666
|
+
ax2.set_xlabel('Pixel (X)')
|
|
667
|
+
ax2.set_ylabel(r'Residual ($\AA$)')
|
|
668
|
+
ax2.set_title(fr'Fitting Residuals (RMS = {wave_calib.rms:.4f} $\AA$, N = {n_valid}/{n_total})')
|
|
669
|
+
|
|
670
|
+
# 动态对称缩放 Y 轴,包含异常值
|
|
671
|
+
max_data_res = np.max(np.abs(residuals)) if len(residuals) > 0 else 0.1
|
|
672
|
+
y_lim = max(max_data_res * 1.1, max_residual * 1.5)
|
|
673
|
+
ax2.set_ylim(-y_lim, y_lim)
|
|
674
|
+
ax2.grid(True, alpha=0.3)
|
|
675
|
+
fig.colorbar(scatter, ax=ax2, label='Order (m)')
|
|
676
|
+
|
|
677
|
+
plt.tight_layout()
|
|
678
|
+
plt.savefig(plot_file, dpi=150)
|
|
679
|
+
plt.close(fig)
|
|
680
|
+
|
|
681
|
+
# 绘制各级次锚点匹配并保存为 PDF
|
|
682
|
+
def _plot_matched_anchors_pdf(lamp_spectra, detected_peaks: Dict[int, np.ndarray],
|
|
683
|
+
matched_anchors: List[Tuple[float, int, float]],
|
|
684
|
+
delta_m: int,
|
|
685
|
+
pdf_path: str):
|
|
686
|
+
"""将每个级次的光谱及匹配到的锚点画入一个多页 PDF 文件。"""
|
|
687
|
+
with PdfPages(pdf_path) as pdf:
|
|
688
|
+
anchor_dict = {}
|
|
689
|
+
for x_obs, m_true, wave in matched_anchors:
|
|
690
|
+
if m_true not in anchor_dict:
|
|
691
|
+
anchor_dict[m_true] = []
|
|
692
|
+
anchor_dict[m_true].append((x_obs, wave))
|
|
693
|
+
|
|
694
|
+
spectra_dict = getattr(lamp_spectra, 'spectra', lamp_spectra) if not isinstance(lamp_spectra, dict) else lamp_spectra
|
|
695
|
+
|
|
696
|
+
for m_obs in sorted(spectra_dict.keys()):
|
|
697
|
+
m_true = m_obs + delta_m
|
|
698
|
+
spec = spectra_dict[m_obs]
|
|
699
|
+
flux = getattr(spec, 'flux', spec)
|
|
700
|
+
|
|
701
|
+
fig, ax = plt.subplots(figsize=(10, 4))
|
|
702
|
+
ax.plot(flux, label='Lamp Flux', color='k', linewidth=0.7)
|
|
703
|
+
|
|
704
|
+
# 计算并绘制 SNR=50 的近似阈值基准线
|
|
705
|
+
if len(flux) > 1:
|
|
706
|
+
diffs = np.diff(flux)
|
|
707
|
+
mad_noise = np.nanmedian(np.abs(diffs - np.nanmedian(diffs))) / (np.sqrt(2) * 0.6745)
|
|
708
|
+
if mad_noise < 1e-6:
|
|
709
|
+
mad_noise = np.nanstd(flux)
|
|
710
|
+
snr50_level = np.nanmedian(flux) + 50.0 * mad_noise
|
|
711
|
+
ax.axhline(y=snr50_level, color='r', linestyle=':', linewidth=1.2, alpha=0.6, label='SNR=50 Threshold')
|
|
712
|
+
|
|
713
|
+
if m_obs in detected_peaks and len(detected_peaks[m_obs]) > 0:
|
|
714
|
+
p_x = detected_peaks[m_obs].astype(int)
|
|
715
|
+
ax.plot(p_x, flux[p_x], 'r+', markersize=6, label='Detected Peaks')
|
|
716
|
+
|
|
717
|
+
# 绘制锚点匹配的标记 (向下短箭头 + 双行波长文本)
|
|
718
|
+
if m_true in anchor_dict:
|
|
719
|
+
max_flux = np.nanmax(flux) if len(flux) > 0 and np.nanmax(flux) > 0 else 1.0
|
|
720
|
+
for x_obs, wave in anchor_dict[m_true]:
|
|
721
|
+
wave_str = f"{wave:.4f}"
|
|
722
|
+
int_part, frac_part = wave_str.split('.')
|
|
723
|
+
label_text = f"{int_part}.\n{frac_part}"
|
|
724
|
+
|
|
725
|
+
peak_idx = int(np.round(x_obs))
|
|
726
|
+
peak_y = flux[peak_idx] if 0 <= peak_idx < len(flux) else max_flux
|
|
727
|
+
|
|
728
|
+
ax.annotate(label_text,
|
|
729
|
+
xy=(x_obs, peak_y + max_flux * 0.02),
|
|
730
|
+
xytext=(0, 25),
|
|
731
|
+
textcoords="offset points",
|
|
732
|
+
arrowprops=dict(arrowstyle="->", color='b', lw=1.2),
|
|
733
|
+
color='b', ha='center', va='bottom', fontsize=8, linespacing=0.8)
|
|
734
|
+
|
|
735
|
+
ax.set_title(f"True Order {m_true} (Aperture: {m_obs}, Offset: {delta_m})")
|
|
736
|
+
ax.set_xlabel("Pixel (X)")
|
|
737
|
+
ax.set_ylabel("Flux")
|
|
738
|
+
|
|
739
|
+
# 留出顶部空间写波长文本,避免箭头和文字出界
|
|
740
|
+
max_val = np.nanmax(flux) if len(flux) > 0 and np.nanmax(flux) > 0 else 1.0
|
|
741
|
+
ax.set_ylim(bottom=0, top=max_val * 1.4)
|
|
742
|
+
ax.legend(loc='upper right')
|
|
743
|
+
|
|
744
|
+
plt.tight_layout()
|
|
745
|
+
pdf.savefig(fig)
|
|
746
|
+
plt.close(fig)
|
|
747
|
+
|
|
748
|
+
# 查找配置数据文件的实际路径
|
|
749
|
+
def _resolve_data_path(filepath: str) -> str:
|
|
750
|
+
"""Resolve calibration data path (supports running from arbitrary working directories)."""
|
|
751
|
+
if not filepath:
|
|
752
|
+
return filepath
|
|
753
|
+
|
|
754
|
+
path = Path(filepath)
|
|
755
|
+
# 如果是绝对路径或者在当前工作目录下能直接找到,直接返回
|
|
756
|
+
if path.is_absolute() or path.exists():
|
|
757
|
+
return str(path)
|
|
758
|
+
|
|
759
|
+
# 如果在当前目录找不到,回退到源代码根目录寻找
|
|
760
|
+
# __file__ 当前位于 src/specproc/core/wave_calibration.py
|
|
761
|
+
pkg_dir = Path(__file__).parent.parent
|
|
762
|
+
project_root = pkg_dir.parent.parent
|
|
763
|
+
|
|
764
|
+
if (project_root / filepath).exists():
|
|
765
|
+
return str(project_root / filepath)
|
|
766
|
+
|
|
767
|
+
if (pkg_dir / filepath).exists():
|
|
768
|
+
return str(pkg_dir / filepath)
|
|
769
|
+
|
|
770
|
+
return filepath
|
|
771
|
+
|
|
772
|
+
# 完成灯谱识别、拟合与定标验证
|
|
773
|
+
def process_wavelength_stage(lamp_spectra: SpectraSet,
|
|
774
|
+
config: ConfigManager,
|
|
775
|
+
output_dir_base: str,
|
|
776
|
+
anchor_file: Optional[str] = None,
|
|
777
|
+
lamp_type: str = 'ThAr',
|
|
778
|
+
save_plots: bool = True,
|
|
779
|
+
fig_format: str = 'png',
|
|
780
|
+
lamp_name: str = 'wavelength_calibration') -> WaveCalib:
|
|
781
|
+
"""
|
|
782
|
+
执行波长定标 (Step 7).
|
|
783
|
+
|
|
784
|
+
Args:
|
|
785
|
+
lamp_spectra: 1D 提取的灯谱 SpectraSet
|
|
786
|
+
config: ConfigManager 实例
|
|
787
|
+
output_dir_base: 基础输出目录
|
|
788
|
+
anchor_file: 灯谱证认锚点 CSV 文件路径。为空则从 config 读取默认路径
|
|
789
|
+
|
|
790
|
+
Returns:
|
|
791
|
+
WaveCalib object
|
|
792
|
+
"""
|
|
793
|
+
logger.info("Starting wavelength calibration stage...")
|
|
794
|
+
calibrator = WavelengthCalibrator()
|
|
795
|
+
|
|
796
|
+
# 1. 载入全量线库
|
|
797
|
+
full_linelist_path = config.get('telescope.linelist', 'full_linelist', 'calib_data/linelists/thar-noao.dat')
|
|
798
|
+
full_linelist = calibrator.load_line_list(_resolve_data_path(full_linelist_path))
|
|
799
|
+
|
|
800
|
+
# 2. 从 1D 灯谱中提取观测 peaks
|
|
801
|
+
detected_peaks = {}
|
|
802
|
+
detected_snrs = {}
|
|
803
|
+
spectra_dict = getattr(lamp_spectra, 'spectra', lamp_spectra) if not isinstance(lamp_spectra, dict) else lamp_spectra
|
|
804
|
+
|
|
805
|
+
for m_obs, spectrum in spectra_dict.items():
|
|
806
|
+
flux = spectrum.flux if hasattr(spectrum, 'flux') else spectrum
|
|
807
|
+
# 统一使用恒定信噪比阈值 10.0 (过滤掉容易引起错配的微弱/混叠线)
|
|
808
|
+
peaks, _, peak_snrs = calibrator.detect_lines_in_spectrum(flux, threshold=10.0)
|
|
809
|
+
detected_peaks[m_obs] = peaks
|
|
810
|
+
detected_snrs[m_obs] = peak_snrs
|
|
811
|
+
|
|
812
|
+
use_precomputed = config.get_bool('telescope.linelist', 'use_precomputed_calibration', False)
|
|
813
|
+
precomputed_file = config.get('telescope.linelist', 'precomputed_calib_file', '')
|
|
814
|
+
|
|
815
|
+
prev_calib = None
|
|
816
|
+
if use_precomputed and precomputed_file:
|
|
817
|
+
precomputed_path = _resolve_data_path(precomputed_file)
|
|
818
|
+
if Path(precomputed_path).exists():
|
|
819
|
+
try:
|
|
820
|
+
prev_calib = calibrator.load_calibration(precomputed_path)
|
|
821
|
+
logger.info(f"Successfully loaded historical calibration. delta_m = {prev_calib.delta_m}.")
|
|
822
|
+
except Exception as e:
|
|
823
|
+
logger.warning(f"Failed to load precomputed calibration: {e}")
|
|
824
|
+
else:
|
|
825
|
+
logger.warning(f"Precomputed calibration file not found: {precomputed_path}")
|
|
826
|
+
|
|
827
|
+
if prev_calib is not None:
|
|
828
|
+
# 使用历史定标直接预测并全库匹配,完全跳过盲搜
|
|
829
|
+
delta_m = prev_calib.delta_m
|
|
830
|
+
pix_pos = []
|
|
831
|
+
matched_wave = []
|
|
832
|
+
wave_tol = config.get_float('reduce.wlcalib', 'rms_threshold', 0.5) * 4.0 # 预测容差可适当放宽
|
|
833
|
+
|
|
834
|
+
for m_obs, peaks_x in detected_peaks.items():
|
|
835
|
+
if len(peaks_x) == 0: continue
|
|
836
|
+
m_true = m_obs + delta_m
|
|
837
|
+
wave_pred = prev_calib.apply_to_pixel(peaks_x, np.full_like(peaks_x, m_true))
|
|
838
|
+
|
|
839
|
+
for x_val, w_pred in zip(peaks_x, wave_pred):
|
|
840
|
+
dist = np.abs(full_linelist - w_pred)
|
|
841
|
+
idx = np.argmin(dist)
|
|
842
|
+
if dist[idx] < wave_tol:
|
|
843
|
+
pix_pos.append([x_val, m_true])
|
|
844
|
+
matched_wave.append(full_linelist[idx])
|
|
845
|
+
|
|
846
|
+
pix_pos = np.array(pix_pos)
|
|
847
|
+
matched_wave = np.array(matched_wave)
|
|
848
|
+
logger.info(f"Matched {len(matched_wave)} lines using precomputed historical model.")
|
|
849
|
+
matched_anchors = [] # 占位
|
|
850
|
+
|
|
851
|
+
else:
|
|
852
|
+
# 3. 载入锚点文件
|
|
853
|
+
if anchor_file is None:
|
|
854
|
+
anchor_file = config.get('telescope.linelist', 'anchor_file', 'calib_data/telescopes/xinglong216hrs/xinglong_thar_lines.csv')
|
|
855
|
+
|
|
856
|
+
anchors = calibrator.load_anchor_file(_resolve_data_path(anchor_file))
|
|
857
|
+
|
|
858
|
+
# 读取剔除最高/最低级次配置
|
|
859
|
+
discard_top = config.get_int('reduce.wlcalib', 'discard_highest_apertures', 0)
|
|
860
|
+
discard_bottom = config.get_int('reduce.wlcalib', 'discard_lowest_apertures', 0)
|
|
861
|
+
all_m_obs = sorted(list(detected_peaks.keys()))
|
|
862
|
+
|
|
863
|
+
start_idx = discard_bottom
|
|
864
|
+
end_idx = len(all_m_obs) - discard_top
|
|
865
|
+
if start_idx < end_idx:
|
|
866
|
+
valid_m_obs = all_m_obs[start_idx:end_idx]
|
|
867
|
+
if discard_bottom > 0 or discard_top > 0:
|
|
868
|
+
logger.info(f"Discarding {discard_bottom} bottom and {discard_top} top apertures. Using {len(valid_m_obs)} for anchors.")
|
|
869
|
+
else:
|
|
870
|
+
valid_m_obs = all_m_obs
|
|
871
|
+
logger.warning("Discard parameters too large, ignoring discard configuration.")
|
|
872
|
+
|
|
873
|
+
# 锚点盲配:每个级次仅使用信噪比 > 50 的最亮前 10 个峰
|
|
874
|
+
logger.info("Using top 10 detected peaks per aperture with SNR > 50 for anchor matching.")
|
|
875
|
+
peaks_for_matching = {}
|
|
876
|
+
for m_obs in valid_m_obs:
|
|
877
|
+
if m_obs in detected_peaks:
|
|
878
|
+
snrs = detected_snrs[m_obs]
|
|
879
|
+
high_snr_mask = snrs > 50.0
|
|
880
|
+
if np.sum(high_snr_mask) > 0:
|
|
881
|
+
valid_peaks = detected_peaks[m_obs][high_snr_mask]
|
|
882
|
+
valid_snrs = snrs[high_snr_mask]
|
|
883
|
+
top_n = min(10, len(valid_peaks))
|
|
884
|
+
top_indices = np.argsort(valid_snrs)[-top_n:]
|
|
885
|
+
top_indices = np.sort(top_indices)
|
|
886
|
+
peaks_for_matching[m_obs] = valid_peaks[top_indices]
|
|
887
|
+
|
|
888
|
+
# 4. 匹配锚点寻找最佳级次偏移 delta_m
|
|
889
|
+
delta_m, matched_anchors = calibrator.find_order_offset_and_match(peaks_for_matching, anchors)
|
|
890
|
+
|
|
891
|
+
# 5. 基于锚点拟合 2D 粗定标模型
|
|
892
|
+
anchor_pix = np.array([[pt[0], pt[1]] for pt in matched_anchors])
|
|
893
|
+
anchor_wave = np.array([pt[2] for pt in matched_anchors])
|
|
894
|
+
poly_type = config.get('reduce.wlcalib', 'poly_type', 'chebyshev')
|
|
895
|
+
|
|
896
|
+
rough_xorder = min(config.get_int('reduce.wlcalib', 'xorder', 4), 3)
|
|
897
|
+
rough_yorder = min(config.get_int('reduce.wlcalib', 'yorder', 4), 3)
|
|
898
|
+
max_res = config.get_float('reduce.wlcalib', 'rms_threshold', 0.5)
|
|
899
|
+
|
|
900
|
+
logger.info(f"Fitting initial rough 2D polynomial (orders: {rough_xorder}, {rough_yorder}) using {len(anchor_wave)} anchor points...")
|
|
901
|
+
rough_calib = calibrator.fit_wavelength_polynomial(
|
|
902
|
+
anchor_pix, anchor_wave,
|
|
903
|
+
xorder=rough_xorder, yorder=rough_yorder, poly_type=poly_type,
|
|
904
|
+
max_residual=max_res * 5.0 # Allow larger residuals for rough fit
|
|
905
|
+
)
|
|
906
|
+
|
|
907
|
+
# 6. 使用粗定标模型预测波长,并进行全库匹配
|
|
908
|
+
match_tol = config.get_float('reduce.wlcalib', 'match_tolerance', 2.0)
|
|
909
|
+
# 全库匹配:放开手脚,使用信噪比 > 5 的所有检测线
|
|
910
|
+
valid_detected_peaks = {m: detected_peaks[m] for m in valid_m_obs if m in detected_peaks}
|
|
911
|
+
pix_pos, matched_wave = calibrator.match_full_catalog(
|
|
912
|
+
valid_detected_peaks, rough_calib, delta_m, full_linelist, tolerance=match_tol
|
|
913
|
+
)
|
|
914
|
+
logger.info(f"Successfully matched {len(matched_wave)} lines from the full catalog (tolerance={match_tol:.2f} A).")
|
|
915
|
+
|
|
916
|
+
# 输出仅包含锚点匹配的干净诊断图 PDF
|
|
917
|
+
if save_plots and matched_anchors:
|
|
918
|
+
pdf_path = Path(output_dir_base) / 'step7_wavelength' / f'wcal_{lamp_name}_matched_anchors.pdf'
|
|
919
|
+
pdf_path.parent.mkdir(parents=True, exist_ok=True)
|
|
920
|
+
_plot_matched_anchors_pdf(lamp_spectra, valid_detected_peaks, matched_anchors, delta_m, str(pdf_path))
|
|
921
|
+
logger.info(f"Saved matched anchors diagnostic PDF to {pdf_path}")
|
|
922
|
+
|
|
923
|
+
# 7. 全局精确 2D 拟合
|
|
924
|
+
x_order = config.get_int('reduce.wlcalib', 'xorder', 4)
|
|
925
|
+
y_order = config.get_int('reduce.wlcalib', 'yorder', 4)
|
|
926
|
+
min_required = (x_order + 1) * (y_order + 1)
|
|
927
|
+
|
|
928
|
+
if len(matched_wave) < min_required:
|
|
929
|
+
logger.warning(f"Only {len(matched_wave)} lines matched from full catalog (need {min_required}). "
|
|
930
|
+
"Falling back to using only matched anchor points.")
|
|
931
|
+
if len(matched_anchors) >= min_required:
|
|
932
|
+
pix_pos = np.array([[pt[0], pt[1]] for pt in matched_anchors])
|
|
933
|
+
matched_wave = np.array([pt[2] for pt in matched_anchors])
|
|
934
|
+
logger.info(f"Using {len(matched_wave)} anchor points for final 2D fit.")
|
|
935
|
+
else:
|
|
936
|
+
raise RuntimeError(f"Not enough lines matched from either catalog ({len(matched_wave)}) or anchors ({len(matched_anchors)}) "
|
|
937
|
+
f"for 2D polynomial fitting (requires {min_required}).")
|
|
938
|
+
|
|
939
|
+
poly_type = config.get('reduce.wlcalib', 'poly_type', 'chebyshev')
|
|
940
|
+
wave_calib = calibrator.fit_wavelength_polynomial(
|
|
941
|
+
pix_pos, matched_wave, xorder=x_order, yorder=y_order, poly_type=poly_type, max_residual=max_res
|
|
942
|
+
)
|
|
943
|
+
wave_calib.delta_m = delta_m
|
|
944
|
+
|
|
945
|
+
# 保存校准结果
|
|
946
|
+
calib_file = Path(output_dir_base) / 'step7_wavelength' / f'wcal_{lamp_name}.fits'
|
|
947
|
+
calib_file.parent.mkdir(parents=True, exist_ok=True)
|
|
948
|
+
calibrator.save_calibration(str(calib_file))
|
|
949
|
+
|
|
950
|
+
# 绘制验证图并保存
|
|
951
|
+
if save_plots and getattr(wave_calib, 'line_pixels', None) is not None:
|
|
952
|
+
out_dir = calib_file.parent
|
|
953
|
+
plot_file = out_dir / f'wcal_{lamp_name}.{fig_format}'
|
|
954
|
+
surf_plot_file = out_dir / f'wcal_{lamp_name}_surface.{fig_format}'
|
|
955
|
+
try:
|
|
956
|
+
_plot_calib_diagnostic(wave_calib, str(plot_file), max_res)
|
|
957
|
+
_plot_calib_surface_diagnostic(wave_calib, str(surf_plot_file), max_res)
|
|
958
|
+
except Exception as e:
|
|
959
|
+
logger.warning(f"Could not generate wavelength calibration plot: {e}")
|
|
960
|
+
|
|
961
|
+
return wave_calib
|