SpecProc 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. specproc/__init__.py +9 -0
  2. specproc/calib_data/linelists/FeAr.dat +375 -0
  3. specproc/calib_data/linelists/README +79 -0
  4. specproc/calib_data/linelists/airvac.cl +61 -0
  5. specproc/calib_data/linelists/argon.dat +161 -0
  6. specproc/calib_data/linelists/ctiohear.dat +106 -0
  7. specproc/calib_data/linelists/ctiohenear.dat +146 -0
  8. specproc/calib_data/linelists/cuar.dat +459 -0
  9. specproc/calib_data/linelists/henear.dat +93 -0
  10. specproc/calib_data/linelists/henearhres.dat +300 -0
  11. specproc/calib_data/linelists/idhenear.dat +127 -0
  12. specproc/calib_data/linelists/krypton.dat +109 -0
  13. specproc/calib_data/linelists/lowskylines.dat +577 -0
  14. specproc/calib_data/linelists/ohlines.dat +268 -0
  15. specproc/calib_data/linelists/skylines.dat +1385 -0
  16. specproc/calib_data/linelists/thar-noao.dat +4169 -0
  17. specproc/calib_data/linelists/thar.dat +3057 -0
  18. specproc/calib_data/linelists/thorium.dat +3057 -0
  19. specproc/calib_data/linelists/vacidhenear.dat +127 -0
  20. specproc/calib_data/linelists/vacthar.dat +3057 -0
  21. specproc/calib_data/linelists/vacthorium.dat +3057 -0
  22. specproc/calib_data/linelists/xenon.dat +89 -0
  23. specproc/calib_data/telescopes/lijiang24hrs/lijiang24_hrs_thar_lines.csv +550 -0
  24. specproc/calib_data/telescopes/xinglong216bfosc/BFOSC/345/256/232/346/240/207/347/201/257/350/260/261/350/256/244/350/257/201/346/211/213/345/206/214v171118.pdf +0 -0
  25. specproc/calib_data/telescopes/xinglong216bfosc//346/212/245/345/221/212/345/233/276.docx +0 -0
  26. specproc/calib_data/telescopes/xinglong216hrs/216HRS /347/201/257/350/260/261/350/257/201/350/256/244/346/226/207/344/273/266.pdf +0 -0
  27. specproc/calib_data/telescopes/xinglong216hrs/wlcalib_20211123011_A.png +0 -0
  28. specproc/calib_data/telescopes/xinglong216hrs/xinglong216_thar_lines.csv +517 -0
  29. specproc/config/__init__.py +1 -0
  30. specproc/config/config_manager.py +431 -0
  31. specproc/core/__init__.py +1 -0
  32. specproc/core/basic_reduction.py +615 -0
  33. specproc/core/data_structures.py +525 -0
  34. specproc/core/de_blazing.py +210 -0
  35. specproc/core/extraction.py +375 -0
  36. specproc/core/flat_correction.py +1255 -0
  37. specproc/core/line_identification.py +302 -0
  38. specproc/core/order_stitching.py +187 -0
  39. specproc/core/order_tracing.py +1587 -0
  40. specproc/core/processing_pipeline.py +1183 -0
  41. specproc/core/scattered_light.py +654 -0
  42. specproc/core/slit_pipeline.py +762 -0
  43. specproc/core/wave_calibration.py +961 -0
  44. specproc/gui/__init__.py +1 -0
  45. specproc/gui/line_identifier.py +731 -0
  46. specproc/gui/main_window.py +2315 -0
  47. specproc/gui/settings_dialog.py +946 -0
  48. specproc/main.py +388 -0
  49. specproc/plotting/__init__.py +1 -0
  50. specproc/plotting/spectra_plotter.py +483 -0
  51. specproc/utils/__init__.py +1 -0
  52. specproc/utils/airvac.py +123 -0
  53. specproc/utils/fits_io.py +269 -0
  54. specproc/utils/image_processing.py +426 -0
  55. specproc/utils/overscan.py +600 -0
  56. specproc-1.0.0.dist-info/METADATA +668 -0
  57. specproc-1.0.0.dist-info/RECORD +61 -0
  58. specproc-1.0.0.dist-info/WHEEL +5 -0
  59. specproc-1.0.0.dist-info/entry_points.txt +5 -0
  60. specproc-1.0.0.dist-info/licenses/LICENSE +674 -0
  61. specproc-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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