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,1587 @@
1
+ """
2
+ Order tracing stage — Step 2 of the spectral reduction pipeline.
3
+
4
+ This module combines master flat frames, detects and traces echelle or grating
5
+ orders, defines their boundaries, and saves the results for subsequent
6
+ pipeline stages. The main entry point is `process_order_tracing_stage`.
7
+
8
+ The main entry point is process_order_tracing_stage(). FlatFieldProcessor is the
9
+ class that wraps all processing state.
10
+ """
11
+ # 合并平场、追踪光谱级次并确定孔径边界。
12
+
13
+ import numpy as np
14
+ import logging
15
+ from pathlib import Path
16
+ from typing import List, Tuple, Dict, Optional, Any
17
+ from scipy.signal import find_peaks
18
+ from scipy.ndimage import gaussian_filter1d
19
+ from scipy.interpolate import UnivariateSpline
20
+ from numpy.polynomial import chebyshev as cheb
21
+ from concurrent.futures import ThreadPoolExecutor
22
+ import json
23
+
24
+ from specproc.utils.fits_io import read_fits_image, write_fits_image
25
+ from specproc.utils.image_processing import combine_images, find_bad_pixels, estimate_background_2d
26
+ from specproc.core.data_structures import ApertureSet, ApertureLocation, FlatField
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ # 处理平场并追踪光谱级次
32
+ class FlatFieldProcessor:
33
+ """Handles flat field processing and order tracing."""
34
+
35
+ # 初始化对象状态
36
+ def __init__(self):
37
+ """
38
+ Initialize flat field processor.
39
+ """
40
+ self.flat_data: Optional[np.ndarray] = None
41
+ self.flat_mask: Optional[np.ndarray] = None
42
+ self.flat_norm: Optional[np.ndarray] = None
43
+ self.scattered_light: Optional[np.ndarray] = None
44
+ self.order_diagnostics = {}
45
+ self.base_header = None
46
+
47
+ # 合并平场帧并生成主平场
48
+ def combine_flat_frames(self, flat_filenames: List[str],
49
+ combine_method: str = 'median',
50
+ combine_sigma: float = 3.0,
51
+ mosaic_maxcount: float = 65535) -> Tuple[np.ndarray, np.ndarray]:
52
+ """
53
+ Combine multiple flat frames to create master flat.
54
+
55
+ Args:
56
+ flat_filenames: List of flat FITS file paths
57
+ combine_method: Combination method
58
+ combine_sigma: Sigma for clipping
59
+ mosaic_maxcount: Cutoff for bad pixels
60
+
61
+ Returns:
62
+ Tuple of (combined_flat, flat_mask)
63
+ """
64
+ logger.info(f"Combining {len(flat_filenames)} flat frames...")
65
+
66
+ if not flat_filenames:
67
+ raise ValueError("No flat frames provided")
68
+
69
+ # Read all flat images
70
+ flat_images = []
71
+ flat_exptimes = []
72
+ for filename in flat_filenames:
73
+ try:
74
+ img, header = read_fits_image(filename)
75
+ flat_images.append(img)
76
+ if self.base_header is None and header is not None:
77
+ self.base_header = header.copy()
78
+
79
+ exptime = np.nan
80
+ if header is not None:
81
+ exptime = header.get('EXPTIME', np.nan)
82
+ flat_exptimes.append(float(exptime) if exptime is not None else np.nan)
83
+ except Exception as e:
84
+ logger.warning(f"Failed to read flat frame {filename}: {e}")
85
+ continue
86
+
87
+ if not flat_images:
88
+ raise RuntimeError("Could not read any flat frames")
89
+
90
+ # Convert to array
91
+ images_array = np.array(flat_images, dtype=np.float32)
92
+ logger.info(f"Successfully read {len(images_array)} flat frames")
93
+
94
+ # If flat exposures differ a lot, scale to a common exposure level first.
95
+ # This avoids biasing the combined flat toward longest-exposure frames.
96
+ exptime_array = np.array(flat_exptimes[:len(images_array)], dtype=np.float64)
97
+ if len(exptime_array) == len(images_array):
98
+ valid = np.isfinite(exptime_array) & (exptime_array > 0)
99
+ if np.all(valid) and len(exptime_array) > 1:
100
+ exptime_ratio = np.max(exptime_array) / np.min(exptime_array)
101
+ if exptime_ratio > 1.1:
102
+ ref_exptime = float(np.median(exptime_array))
103
+ images_array = images_array / exptime_array[:, None, None] * ref_exptime
104
+ logger.info(
105
+ f"Flat EXPTIME range is large (min={np.min(exptime_array):.2f}, "
106
+ f"max={np.max(exptime_array):.2f}); scaled to reference {ref_exptime:.2f}s"
107
+ )
108
+
109
+ # Combine (default workflow: median + sigma clipping for cosmic/bad pixels)
110
+ method = combine_method
111
+ if method == 'median':
112
+ from astropy.stats import sigma_clip
113
+ sigma = combine_sigma
114
+ clipped = sigma_clip(images_array, sigma=sigma, axis=0, masked=True)
115
+ combined = np.ma.median(clipped, axis=0).filled(np.nan)
116
+ # Fallback for any fully masked pixels
117
+ nanpix = ~np.isfinite(combined)
118
+ if np.any(nanpix):
119
+ combined[nanpix] = np.median(images_array, axis=0)[nanpix]
120
+ combined = combined.astype(np.float32)
121
+ else:
122
+ combined, _ = combine_images(images_array, method=method)
123
+
124
+ # Detect bad pixels
125
+ bad_mask = combined > mosaic_maxcount
126
+
127
+ self.flat_data = combined
128
+ self.flat_mask = bad_mask.astype(np.int16)
129
+
130
+ logger.info(f"Master flat created: shape {combined.shape}, "
131
+ f"saturated pixels: {np.sum(bad_mask)}")
132
+
133
+ return combined, self.flat_mask
134
+
135
+ # 检测并追踪平场中的级次
136
+ def detect_orders(self, snr_threshold: float = 5.0,
137
+ min_trace_coverage: float = 0.2,
138
+ trace_degree: int = 4,
139
+ output_dir_base: str = '') -> ApertureSet:
140
+ """
141
+ Detect grating orders in flat field with simple tracing.
142
+
143
+ For grating spectrographs, orders run horizontally (left to right) across
144
+ the detector. The algorithm collapses the image along the dispersion direction
145
+ (X) to find initial order centroids along Y, then traces each order across
146
+ the image using polynomial fitting.
147
+
148
+ Args:
149
+ snr_threshold: Detection threshold (multiples of sigma above baseline)
150
+ gap_fill_snr: SNR threshold for gap fill.
151
+ min_trace_coverage: Minimum fraction of detector width a traced order must cover.
152
+ trace_degree: Polynomial degree for fitting the trace.
153
+ output_dir_base: Output directory for diagnostics.
154
+ Returns:
155
+ ApertureSet with detected orders
156
+ """
157
+ if self.flat_data is None:
158
+ raise RuntimeError("No flat data")
159
+
160
+ logger.info("Detecting grating orders (simple tracing)...")
161
+
162
+ tracer = GratingOrderTracer(
163
+ data=self.flat_data,
164
+ mask=self.flat_mask,
165
+ snr_threshold=snr_threshold,
166
+ min_trace_coverage=min_trace_coverage,
167
+ trace_degree=trace_degree,
168
+ )
169
+ apertures = tracer.trace_orders()
170
+ self.order_diagnostics = tracer.get_diagnostics()
171
+
172
+ return apertures
173
+
174
+ # 保存主平场与诊断图
175
+ def save_flat_field(self, output_path: str,
176
+ save_plots: bool = True,
177
+ fig_format: str = 'png'):
178
+ """Save flat field data to FITS file.
179
+
180
+ MasterFlat.fits contains only the combined flat image (PRIMARY) and
181
+ the bad-pixel MASK extension so its size is comparable to a single
182
+ input flat frame.
183
+ """
184
+ if self.flat_data is None:
185
+ raise RuntimeError("No flat data to save")
186
+
187
+ output_path = Path(output_path)
188
+ output_path.parent.mkdir(parents=True, exist_ok=True)
189
+
190
+ from astropy.io import fits
191
+
192
+ # ---- MasterFlat.fits: combined image + mask only ----
193
+ if self.base_header is not None:
194
+ if isinstance(self.base_header, fits.Header):
195
+ header = self.base_header.copy()
196
+ else:
197
+ header = fits.Header()
198
+ for k, v in self.base_header.items():
199
+ try:
200
+ header[k] = v
201
+ except Exception:
202
+ pass
203
+ else:
204
+ header = fits.Header()
205
+ header['EXTNAME'] = 'MASTER FLAT'
206
+ primary_hdu = fits.PrimaryHDU(data=self.flat_data.astype(np.float32), header=header)
207
+ hdul = fits.HDUList([
208
+ primary_hdu,
209
+ fits.ImageHDU(data=self.flat_mask, name='MASK'),
210
+ ])
211
+ hdul.writeto(str(output_path), overwrite=True)
212
+ logger.info(f"Saved master flat to {output_path}")
213
+
214
+ # Save diagnostic plots if enabled
215
+ if save_plots:
216
+ from specproc.plotting.spectra_plotter import plot_2d_image_to_file
217
+ out_dir = Path(output_path).parent
218
+ plot_2d_image_to_file(self.flat_data, str(out_dir / f'MasterFlat.{fig_format}'), "Master Flat Field")
219
+
220
+
221
+ # 检测并追踪光栅级次
222
+ class GratingOrderTracer:
223
+ """
224
+ Detects and traces orders for grating spectrographs.
225
+ """
226
+ # 初始化对象状态
227
+ def __init__(self, data: np.ndarray, mask: Optional[np.ndarray] = None, **kwargs):
228
+ self.data = np.asarray(data, dtype=np.float64)
229
+ self.mask = mask if mask is not None else np.zeros_like(data, dtype=bool)
230
+ self.h, self.w = self.data.shape
231
+ self.params = {
232
+ 'snr_threshold': 5.0,
233
+ 'min_trace_coverage': 0.20,
234
+ 'trace_degree': 4,
235
+ }
236
+ self.params.update(kwargs)
237
+ self.diagnostics = {}
238
+
239
+ # 提取级次种子剖面并估算间距
240
+ def _get_seed_profile(self) -> Tuple[np.ndarray, dict]:
241
+ """Extracts and analyzes a 1D cross-section to find order seeds."""
242
+ x_center = self.w // 2
243
+ half_band = max(5, self.w // 20)
244
+ x1, x2 = max(0, x_center - half_band), min(self.w, x_center + half_band + 1)
245
+ profile = np.median(self.data[:, x1:x2], axis=1)
246
+ profile = gaussian_filter1d(profile, sigma=1.5)
247
+
248
+ # Estimate dynamic order separation
249
+ rough_peaks, _ = find_peaks(gaussian_filter1d(profile, 2.0), distance=5, prominence=np.percentile(profile, 99) * 0.02)
250
+ if len(rough_peaks) >= 5:
251
+ diffs = np.diff(rough_peaks)
252
+ y_mids = 0.5 * (rough_peaks[:-1] + rough_peaks[1:])
253
+ med_diff = float(np.median(diffs))
254
+ # Filter out anomalously large diffs (e.g. missing orders) before fitting
255
+ valid = (diffs > 0.5 * med_diff) & (diffs < 1.5 * med_diff)
256
+ if np.sum(valid) >= 3:
257
+ deg = min(2, np.sum(valid) - 1)
258
+ coefs = np.polyfit(y_mids[valid], diffs[valid], deg)
259
+ med_sep = med_diff
260
+ local_sep_func = lambda y: float(np.clip(np.polyval(coefs, y), med_sep * 0.4, med_sep * 2.5))
261
+ else:
262
+ med_sep = med_diff
263
+ local_sep_func = lambda y: float(med_sep)
264
+ else:
265
+ med_sep = 30.0
266
+ local_sep_func = lambda y: float(med_sep)
267
+
268
+ # Background estimation
269
+ from scipy.ndimage import minimum_filter1d
270
+ bg_size = int(2.5 * med_sep)
271
+ envelope = gaussian_filter1d(minimum_filter1d(profile, size=bg_size), sigma=bg_size / 4.0)
272
+ seed_pure = profile - envelope
273
+
274
+ # Noise estimation
275
+ valid_prof = seed_pure[np.isfinite(seed_pure)]
276
+ s_med = np.median(np.sort(valid_prof)[:len(valid_prof)//4])
277
+ s_sig = np.median(np.abs(np.diff(valid_prof))) / 0.9539
278
+ noise, baseline = max(s_sig, 0.1), s_med
279
+
280
+ diags = {'profile': profile, 'envelope': envelope, 'noise': noise, 'baseline': baseline,
281
+ 'local_sep_func': local_sep_func, 'med_sep': med_sep, 'x1': x1, 'x2': x2}
282
+ return seed_pure, diags
283
+
284
+ # 检测峰值并补入缺失种子
285
+ def _find_seeds(self, seed_profile: np.ndarray, diags: dict) -> np.ndarray:
286
+ """Finds initial peak locations (seeds) in the 1D profile."""
287
+ det_threshold = diags['baseline'] + self.params['snr_threshold'] * diags['noise']
288
+ prominence = max(0.5 * diags['noise'], 0.1 * self.params['snr_threshold'] * diags['noise'])
289
+
290
+ # Initial peak finding with a small distance to not miss close orders
291
+ peaks, props = find_peaks(seed_profile, height=det_threshold, distance=3, prominence=prominence)
292
+
293
+ if len(peaks) < 5:
294
+ logger.info(f"Found {len(peaks)} seed peaks (SNR > {self.params['snr_threshold']:.1f})")
295
+ return np.asarray(sorted(peaks), dtype=int)
296
+
297
+ # --- Gap detection and seed insertion ---
298
+ sorted_peaks = np.asarray(sorted(peaks), dtype=int)
299
+ diffs = np.diff(sorted_peaks)
300
+ local_sep_func = diags.get('local_sep_func')
301
+ if not local_sep_func:
302
+ med_sep = diags.get('med_sep', np.median(diffs) if len(diffs) > 0 else 30.0)
303
+ local_sep_func = lambda y: float(med_sep)
304
+
305
+ new_seeds = list(sorted_peaks)
306
+ for i in range(len(diffs)):
307
+ gap = diffs[i]
308
+ mid_y = (sorted_peaks[i] + sorted_peaks[i+1]) / 2.0
309
+ expected_sep = local_sep_func(mid_y)
310
+ gap_thresh = expected_sep * 1.5 # A gap is > 1.5x the local expected separation
311
+
312
+ if gap > gap_thresh:
313
+ # Found a gap, try to insert seeds
314
+ n_missing = int(round(gap / expected_sep)) - 1
315
+ if n_missing > 0 and n_missing <= 5: # Limit insertions
316
+ logger.info(f"Gap detected between {sorted_peaks[i]} and {sorted_peaks[i+1]} (gap={gap:.1f}, expected_sep={expected_sep:.1f}). Trying to insert {n_missing} seed(s).")
317
+ for j in range(1, n_missing + 1):
318
+ # Predict seed position
319
+ predicted_y = int(round(sorted_peaks[i] + j * gap / (n_missing + 1)))
320
+
321
+ # Search for a peak near the predicted position, even if it's below the main SNR threshold
322
+ search_win = max(2, int(expected_sep * 0.2))
323
+ y1 = max(0, predicted_y - search_win)
324
+ y2 = min(len(seed_profile), predicted_y + search_win + 1)
325
+
326
+ if y2 > y1:
327
+ local_profile = seed_profile[y1:y2]
328
+ # Use a lower threshold for gap filling
329
+ gap_fill_thresh = diags['baseline'] + 1.0 * diags['noise'] # Lower SNR for gaps
330
+ local_peaks, _ = find_peaks(local_profile, height=gap_fill_thresh, distance=3)
331
+ if len(local_peaks) > 0:
332
+ best_local_peak_idx = np.argmax(local_profile[local_peaks])
333
+ inserted_y = y1 + local_peaks[best_local_peak_idx]
334
+ new_seeds.append(inserted_y)
335
+ logger.info(f" -> Inserted seed at y={inserted_y} (predicted at {predicted_y})")
336
+
337
+ final_seeds = np.asarray(sorted(list(set(new_seeds))), dtype=int)
338
+ logger.info(f"Found {len(peaks)} initial peaks, {len(final_seeds)} seeds after gap filling.")
339
+ return final_seeds
340
+
341
+ # 从种子沿列追踪单个级次
342
+ def _trace_one_order(self, seed_y: float, diags: dict) -> Optional[Tuple[np.ndarray, np.ndarray]]:
343
+ """Traces a single order starting from a seed."""
344
+ xs, ys = [], []
345
+
346
+ # 在指定列精修级次峰位
347
+ def refine_peak(x, y_guess, search_half):
348
+ y0 = int(round(y_guess))
349
+ y1, y2 = max(0, y0 - search_half), min(self.h, y0 + search_half + 1)
350
+ if y2 - y1 < 3: return None
351
+ col = self.data[y1:y2, x]
352
+ if not np.any(np.isfinite(col)): return None
353
+
354
+ loc = int(np.nanargmax(col))
355
+
356
+ # A true peak must be a local maximum, not just the edge of the window
357
+ if loc == 0 or loc == len(col) - 1:
358
+ return None, 0.0
359
+
360
+ bg_local = np.nanmin(col)
361
+ flux_bg_sub = col[loc] - bg_local
362
+ # To prevent losing the track over absorption lines, we lower the hard cutoff
363
+ # to 3.0 sigma. If it's between 3.0 and snr_threshold, we will use it for
364
+ # steering but NOT for final trace fitting.
365
+ if flux_bg_sub < 3.0 * diags['noise']:
366
+ return None, 0.0
367
+
368
+ # Sub-pixel centroiding
369
+ hw = 2
370
+ c1, c2 = max(0, loc - hw), min(len(col), loc + hw + 1)
371
+ sub_col = col[c1:c2]
372
+ valid = np.isfinite(sub_col)
373
+ if np.sum(valid) >= 3:
374
+ yy = np.arange(c1, c2)[valid]
375
+ bg = np.min(sub_col[valid])
376
+ weights = np.maximum(0, sub_col[valid] - bg)
377
+ sum_w = np.sum(weights)
378
+ if sum_w > 0:
379
+ peak_y_subpix = float(y1 + np.sum(yy * weights) / sum_w)
380
+ peak_snr = flux_bg_sub / diags['noise']
381
+ return peak_y_subpix, peak_snr
382
+ peak_snr = flux_bg_sub / diags['noise']
383
+ return float(y1 + loc), peak_snr # Fallback to integer pixel if sub-pixel fails
384
+
385
+ # Start from the center, calculate dynamic search window
386
+ initial_sep = diags['local_sep_func'](seed_y)
387
+ initial_search_half = max(3, int(initial_sep * 0.25))
388
+
389
+ res = refine_peak(self.w // 2, seed_y, initial_search_half)
390
+ if res[0] is None: return None
391
+ start_y, start_snr = res
392
+
393
+ xs.append(float(self.w // 2))
394
+ ys.append(start_y)
395
+
396
+ # Trace right and left
397
+ for direction in [1, -1]:
398
+ hist_x = [float(self.w // 2)]
399
+ hist_y = [start_y]
400
+ miss_count = 0 # Counter for consecutive columns with signal below threshold or no peak
401
+
402
+ x_range = range(self.w // 2 + direction, self.w if direction == 1 else -1, direction)
403
+ for x in x_range:
404
+ # Extrapolate y_guess using a polynomial fit to the recent trace history
405
+ if len(hist_x) >= 15:
406
+ # Use quadratic fit for better curve handling
407
+ n_pts, deg = 15, 2
408
+ elif len(hist_x) >= 5:
409
+ # Use linear fit for stability
410
+ n_pts, deg = 5, 1
411
+ else:
412
+ n_pts, deg = 0, 0
413
+
414
+ if n_pts > 0:
415
+ fit_x = np.array(hist_x[-n_pts:])
416
+ fit_y = np.array(hist_y[-n_pts:])
417
+ # Shift x to be around 0 for better numerical stability
418
+ x0 = fit_x[-1]
419
+ try:
420
+ coeffs = np.polyfit(fit_x - x0, fit_y, deg)
421
+ y_guess = np.polyval(coeffs, x - x0)
422
+ except (np.linalg.LinAlgError, ValueError):
423
+ y_guess = hist_y[-1] # Fallback
424
+ elif len(hist_x) >= 2:
425
+ slope = (hist_y[-1] - hist_y[-2]) / (hist_x[-1] - hist_x[-2])
426
+ y_guess = hist_y[-1] + slope * (x - hist_x[-1])
427
+ else:
428
+ y_guess = hist_y[-1]
429
+
430
+ # Dynamically adjust search window based on the smooth separation curve
431
+ local_sep = diags['local_sep_func'](y_guess)
432
+ search_half = max(2, int(local_sep * 0.25))
433
+
434
+ res = refine_peak(x, y_guess, search_half)
435
+ if res[0] is not None and abs(res[0] - y_guess) <= search_half:
436
+ y_new, peak_snr = res
437
+ # Update steering history even if SNR is low (e.g. inside absorption line)
438
+ # so we don't drift off the track.
439
+ hist_x.append(float(x))
440
+ hist_y.append(y_new)
441
+
442
+ if peak_snr >= self.params['snr_threshold']:
443
+ # Strong peak. Save to final trace points.
444
+ xs.append(float(x))
445
+ ys.append(y_new)
446
+ miss_count = 0
447
+ else:
448
+ # Weak peak (absorption line). Steer, but don't save to final trace.
449
+ miss_count += 1
450
+ else:
451
+ # Completely lost the peak (e.g., edge of detector)
452
+ miss_count += 1
453
+
454
+ # Allow a coasting distance of 50 pixels to jump over wide absorption lines.
455
+ max_miss = 50
456
+ if miss_count > max_miss:
457
+ break
458
+
459
+ if len(xs) < self.params['min_trace_coverage'] * self.w:
460
+ return None
461
+
462
+ xs, ys = np.array(xs), np.array(ys)
463
+ order = np.argsort(xs)
464
+ return xs[order], ys[order]
465
+
466
+ # 检测并拟合全部级次轨迹
467
+ def trace_orders(self) -> ApertureSet:
468
+ """Main method to perform order tracing."""
469
+ seed_profile, diags = self._get_seed_profile()
470
+ self.diagnostics.update(diags)
471
+ self.diagnostics['seed_profile'] = seed_profile
472
+ seeds = self._find_seeds(seed_profile, diags)
473
+ self.diagnostics['seeds'] = seeds
474
+
475
+ aperture_set = ApertureSet()
476
+ order_id_counter = 1
477
+ for seed in seeds:
478
+ trace_result = self._trace_one_order(float(seed), diags)
479
+ if trace_result is None:
480
+ continue
481
+
482
+ x_pts, y_pts = trace_result
483
+ if len(x_pts) < 10: continue
484
+
485
+ # Fit with Chebyshev polynomial
486
+ deg = min(self.params['trace_degree'], len(x_pts) - 2)
487
+ if deg < 1: continue
488
+ domain = (0.0, float(self.w - 1))
489
+ x_mapped = 2.0 * (x_pts - domain[0]) / (domain[1] - domain[0]) - 1.0
490
+ center_coef = cheb.chebfit(x_mapped, y_pts, deg)
491
+
492
+ # Estimate width
493
+ local_sep = diags['local_sep_func'](float(seed))
494
+ width = 0.4 * local_sep # Simplified width estimation
495
+
496
+ ap = ApertureLocation(
497
+ aperture=order_id_counter, order=order_id_counter,
498
+ center_coef=center_coef, width=width,
499
+ is_chebyshev=True, domain=domain
500
+ )
501
+ aperture_set.add_aperture(ap)
502
+ order_id_counter += 1
503
+
504
+ logger.info(f"Successfully traced {aperture_set.norders} orders.")
505
+ return aperture_set
506
+
507
+ # 返回级次追踪诊断数据
508
+ def get_diagnostics(self) -> dict:
509
+ return self.diagnostics
510
+
511
+
512
+ # 根据级次间隙分配物理序号
513
+ def assign_order_indices(apertures: ApertureSet, gap_fill_factor: float = 1.35) -> dict:
514
+ """
515
+ Assign integer order indices *m* to detected apertures, detecting gaps.
516
+
517
+ Walks ordered apertures from bottom (small Y) to top (large Y) and increments *m*
518
+ by 1 for each normal step. When the gap between two consecutive centres
519
+ exceeds *gap_fill_factor × local_expected_sep*, it skips the appropriate number
520
+ of *m* values, reserving slots for missing orders.
521
+
522
+ The expected separation is modelled as a smooth polynomial of sequential
523
+ index to adapt to monotonically changing spacing (e.g., in echelle spectra).
524
+
525
+ Args:
526
+ apertures: The set of detected apertures.
527
+ gap_fill_factor: Factor to identify a gap. A separation > factor * expected
528
+ is considered a gap.
529
+
530
+ Returns:
531
+ A dictionary mapping original aperture_id to the new gap-aware index `m`.
532
+ """
533
+ order_ids = apertures.get_orders()
534
+ if len(order_ids) < 2:
535
+ return {oid: i for i, oid in enumerate(order_ids)}
536
+
537
+ x_ref = apertures.get_aperture(order_ids[0]).domain[1] / 2.0
538
+ centres = {oid: float(apertures.get_aperture(oid).get_position(x_ref)) for oid in order_ids}
539
+
540
+ sorted_ids = sorted(order_ids, key=lambda oid: centres[oid])
541
+ diffs = np.array([centres[sorted_ids[i + 1]] - centres[sorted_ids[i]] for i in range(len(sorted_ids) - 1)])
542
+ med_sep = float(np.median(diffs)) if len(diffs) > 0 else 30.0
543
+
544
+ # Build a smooth, robust model for the expected local separation.
545
+ n_diffs = len(diffs)
546
+ if n_diffs >= 6:
547
+ idx_arr = np.arange(n_diffs, dtype=float)
548
+ fit_deg = min(3, n_diffs - 2)
549
+ good = np.ones(n_diffs, dtype=bool)
550
+
551
+ # Iteratively clip outliers to get a robust fit for separation trend
552
+ for _ in range(4):
553
+ if np.sum(good) < fit_deg + 2:
554
+ break
555
+ coef_tmp = np.polyfit(idx_arr[good], diffs[good], fit_deg)
556
+ model_tmp = np.polyval(coef_tmp, idx_arr)
557
+ resid = diffs - model_tmp
558
+ std_r = np.std(resid[good])
559
+ if std_r < 1e-6:
560
+ break
561
+ new_good = np.abs(resid) < 2.5 * std_r
562
+ if np.array_equal(new_good, good):
563
+ break
564
+ good = new_good
565
+
566
+ sep_coef = np.polyfit(idx_arr[good], diffs[good], fit_deg)
567
+ expected_seps = np.polyval(sep_coef, idx_arr)
568
+ inlier_min = np.min(diffs[good]) if np.any(good) else med_sep * 0.5
569
+ inlier_max = np.max(diffs[good]) if np.any(good) else med_sep * 2.0
570
+ expected_seps = np.clip(expected_seps, inlier_min * 0.5, inlier_max * 2.0)
571
+ else:
572
+ expected_seps = np.full(n_diffs, med_sep)
573
+
574
+ mapping = {}
575
+ m = 0
576
+ mapping[sorted_ids[0]] = m
577
+ for i in range(len(diffs)):
578
+ gap = diffs[i]
579
+ local_sep = float(expected_seps[i])
580
+ ratio = gap / max(local_sep, 1.0)
581
+ n_skip = int(round(ratio)) if ratio >= gap_fill_factor else 1
582
+ m += n_skip
583
+ mapping[sorted_ids[i + 1]] = m
584
+
585
+ logger.info(f"Order index assignment: {len(mapping)} apertures, m_max={m}, "
586
+ f"med_sep={med_sep:.1f}px, gap_factor={gap_fill_factor:.2f}")
587
+ return mapping
588
+
589
+
590
+ # 按缺失级次重编号孔径
591
+ def renumber_apertures_with_gaps(apertures: ApertureSet, gap_fill_factor: float = 1.35) -> ApertureSet:
592
+ """
593
+ Renumber aperture IDs to reflect physical order indices, including gaps.
594
+
595
+ After initial detection, apertures might have sequential IDs (1, 2, 3, ...).
596
+ This function re-assigns IDs based on the gap-aware index `m` from
597
+ `assign_order_indices`, so that missing orders create gaps in the IDs
598
+ (e.g., 1, 2, 4, 5 if order 3 is missing). The new ID is `m + 1`.
599
+
600
+ Args:
601
+ apertures: The original ApertureSet.
602
+ gap_fill_factor: Factor for gap detection, passed to `assign_order_indices`.
603
+
604
+ Returns:
605
+ A new ApertureSet with renumbered apertures.
606
+ """
607
+ if apertures.norders == 0:
608
+ return apertures
609
+
610
+ order_map = assign_order_indices(apertures, gap_fill_factor)
611
+ new_set = ApertureSet()
612
+ for old_id, m in order_map.items():
613
+ ap = apertures.get_aperture(old_id)
614
+ new_id = m + 1
615
+ # Create a new ApertureLocation with the updated ID
616
+ new_ap = ApertureLocation(
617
+ aperture=new_id,
618
+ order=new_id,
619
+ center_coef=ap.center_coef.copy() if ap.center_coef is not None else None,
620
+ lower_coef=ap.lower_coef.copy() if ap.lower_coef is not None else None,
621
+ upper_coef=ap.upper_coef.copy() if ap.upper_coef is not None else None,
622
+ width=ap.width,
623
+ is_chebyshev=ap.is_chebyshev,
624
+ domain=ap.domain,
625
+ is_interpolated=ap.is_interpolated,
626
+ )
627
+ # Preserve custom attributes if they exist
628
+ if getattr(ap, 'center_arr', None) is not None:
629
+ new_ap.center_arr = ap.center_arr.copy()
630
+ if getattr(ap, 'w_up_cheb_coef', None) is not None:
631
+ new_ap.w_up_cheb_coef = ap.w_up_cheb_coef.copy()
632
+ if getattr(ap, 'w_low_cheb_coef', None) is not None:
633
+ new_ap.w_low_cheb_coef = ap.w_low_cheb_coef.copy()
634
+
635
+ new_set.add_aperture(new_ap)
636
+
637
+ if set(apertures.get_orders()) != set(new_set.get_orders()):
638
+ logger.info(f"Renumbered apertures with gap-aware IDs: {apertures.norders} -> {new_set.norders} orders.")
639
+ return new_set
640
+
641
+
642
+ # 根据平场峰值精修预测轨迹
643
+ def _refine_predicted_trace(
644
+ flat_data: np.ndarray,
645
+ pred_center: np.ndarray,
646
+ local_sep: float,
647
+ trace_degree: int = 3,
648
+ step: int = 20
649
+ ) -> Optional[np.ndarray]:
650
+ """
651
+ Refine a predicted trace by finding peaks in the actual flat image.
652
+
653
+ Args:
654
+ flat_data: The 2D flat field image.
655
+ pred_center: The predicted center trace array.
656
+ local_sep: The local order separation in pixels.
657
+ trace_degree: The polynomial degree for fitting the refined trace.
658
+ step: The column step for sampling peaks.
659
+
660
+ Returns:
661
+ A refined center trace array, or None if refinement fails.
662
+ """
663
+ h, w = flat_data.shape
664
+ search_half = max(6, int(0.4 * local_sep))
665
+ xs, ys = [], []
666
+
667
+ for x in range(0, w, step):
668
+ y_guess = pred_center[x]
669
+ y0 = int(round(y_guess))
670
+ y1 = max(0, y0 - search_half)
671
+ y2 = min(h, y0 + search_half + 1)
672
+ if y2 - y1 < 5:
673
+ continue
674
+
675
+ col = flat_data[y1:y2, x].astype(float)
676
+ if not np.any(np.isfinite(col)):
677
+ continue
678
+
679
+ loc = int(np.nanargmax(col))
680
+ peak_val = col[loc]
681
+ if not np.isfinite(peak_val) or peak_val <= 0:
682
+ continue
683
+
684
+ # Centroid refinement
685
+ hw = 3
686
+ g1, g2 = max(0, loc - hw), min(col.size, loc + hw + 1)
687
+ sub = col[g1:g2]
688
+ valid_sub = np.isfinite(sub)
689
+ if np.sum(valid_sub) < 3:
690
+ refined_y = float(y1 + loc)
691
+ else:
692
+ yy = np.arange(g1, g2)[valid_sub]
693
+ weights = sub[valid_sub]
694
+ bg = np.min(weights)
695
+ weights = np.maximum(0, weights - bg)
696
+ sum_w = np.sum(weights)
697
+ if sum_w > 0:
698
+ refined_y = float(y1 + np.sum(yy * weights) / sum_w)
699
+ else:
700
+ refined_y = float(y1 + loc)
701
+
702
+ if abs(refined_y - y_guess) <= search_half:
703
+ xs.append(float(x))
704
+ ys.append(refined_y)
705
+
706
+ if len(xs) < max(5, int(0.15 * (w / step))):
707
+ return None
708
+
709
+ xs_arr, ys_arr = np.asarray(xs), np.asarray(ys)
710
+ deg = min(trace_degree, max(1, len(xs_arr) - 2))
711
+
712
+ # Robust polynomial fit with sigma-clipping
713
+ for _ in range(3):
714
+ if len(xs_arr) < deg + 2:
715
+ return None
716
+ coef = np.polyfit(xs_arr, ys_arr, deg)
717
+ resid = ys_arr - np.polyval(coef, xs_arr)
718
+ sigma = max(0.5, float(np.std(resid)))
719
+ keep = np.abs(resid) < 2.5 * sigma
720
+ if np.all(keep):
721
+ break
722
+ xs_arr, ys_arr = xs_arr[keep], ys_arr[keep]
723
+
724
+ final_coef = np.polyfit(xs_arr, ys_arr, deg)
725
+ return np.polyval(final_coef, np.arange(w, dtype=float))
726
+
727
+
728
+ # 插值补齐并外推缺失级次
729
+ def fill_missing_orders_by_interpolation(
730
+ apertures: ApertureSet,
731
+ image_width: int,
732
+ image_height: int,
733
+ trace_degree: int = 3,
734
+ n_extend_below: int = 0,
735
+ n_extend_above: int = 0,
736
+ flat_data: Optional[np.ndarray] = None,
737
+ gap_fill_factor: float = 1.35
738
+ ) -> ApertureSet:
739
+ """
740
+ Fill interior gaps and extend edges of an ApertureSet.
741
+
742
+ Interior gaps are filled by interpolating traces between bounding real orders.
743
+ Edge orders are extrapolated based on the spacing of the nearest real orders.
744
+ If `flat_data` is provided, interpolated traces are refined against the image.
745
+
746
+ Args:
747
+ apertures: The ApertureSet, assumed to be renumbered with gaps.
748
+ image_width: The width of the detector image.
749
+ image_height: The height of the detector image.
750
+ trace_degree: Polynomial degree for fitting new traces.
751
+ n_extend_below: Number of orders to extrapolate below the first real order.
752
+ n_extend_above: Number of orders to extrapolate above the last real order.
753
+ flat_data: Optional 2D flat image for refining interpolated traces.
754
+ gap_fill_factor: Factor used for gap detection.
755
+
756
+ Returns:
757
+ A new ApertureSet containing original, interpolated, and extrapolated orders.
758
+ """
759
+ if apertures.norders < 2:
760
+ return apertures.copy()
761
+
762
+ order_map = assign_order_indices(apertures, gap_fill_factor)
763
+ m_to_oid = {m: oid for oid, m in order_map.items()}
764
+ sorted_m = sorted(m_to_oid.keys())
765
+
766
+ filled_set = ApertureSet()
767
+ x_all = np.arange(image_width, dtype=float)
768
+ x_mapped = 2.0 * x_all / (image_width - 1.0) - 1.0
769
+
770
+ # --- 1. Fill interior gaps by interpolation ---
771
+ for i in range(len(sorted_m) - 1):
772
+ m_lo, m_hi = sorted_m[i], sorted_m[i + 1]
773
+ oid_lo, oid_hi = m_to_oid[m_lo], m_to_oid[m_hi]
774
+
775
+ # Add the lower-bound real order
776
+ filled_set.add_aperture(apertures.get_aperture(oid_lo))
777
+
778
+ n_gap = m_hi - m_lo - 1
779
+ if n_gap <= 0:
780
+ continue
781
+
782
+ ap_lo = apertures.get_aperture(oid_lo)
783
+ ap_hi = apertures.get_aperture(oid_hi)
784
+ traces = {
785
+ "cen": (ap_lo.get_position(x_all), ap_hi.get_position(x_all)),
786
+ "low": (ap_lo.get_lower(x_all), ap_hi.get_lower(x_all)),
787
+ "upp": (ap_lo.get_upper(x_all), ap_hi.get_upper(x_all)),
788
+ }
789
+
790
+ for k in range(1, n_gap + 1):
791
+ frac = k / (n_gap + 1.0)
792
+ pred_traces = {key: val[0] + frac * (val[1] - val[0]) for key, val in traces.items()}
793
+
794
+ yc_mid = pred_traces["cen"][image_width // 2]
795
+ if not (0 <= yc_mid < image_height):
796
+ continue
797
+
798
+ final_center = pred_traces["cen"]
799
+ if flat_data is not None:
800
+ local_sep = float(np.median(traces["cen"][1] - traces["cen"][0])) / (n_gap + 1)
801
+ refined = _refine_predicted_trace(flat_data, pred_traces["cen"], local_sep, trace_degree)
802
+ if refined is not None:
803
+ final_center = refined
804
+ logger.info(f" Refined interpolated order m={m_lo + k}")
805
+
806
+ # Reconstruct boundaries relative to the (potentially refined) center
807
+ half_width_low = pred_traces["cen"] - pred_traces["low"]
808
+ half_width_upp = pred_traces["upp"] - pred_traces["cen"]
809
+ final_lower = final_center - half_width_low
810
+ final_upper = final_center + half_width_upp
811
+
812
+ new_id = m_lo + k + 1
813
+ ap = ApertureLocation(
814
+ aperture=new_id, order=new_id,
815
+ center_coef=cheb.chebfit(x_mapped, final_center, trace_degree),
816
+ lower_coef=cheb.chebfit(x_mapped, final_lower, trace_degree),
817
+ upper_coef=cheb.chebfit(x_mapped, final_upper, trace_degree),
818
+ width=float(np.median(final_upper - final_lower)),
819
+ is_chebyshev=True,
820
+ domain=(0.0, float(image_width - 1.0)),
821
+ is_interpolated=True,
822
+ )
823
+ filled_set.add_aperture(ap)
824
+ logger.info(f" Filled gap order m={m_lo + k} (ID={new_id}) at y~{yc_mid:.1f}")
825
+
826
+ # Add the last real order
827
+ filled_set.add_aperture(apertures.get_aperture(m_to_oid[sorted_m[-1]]))
828
+
829
+ # --- 2. Extrapolate orders at the edges ---
830
+ if n_extend_below > 0:
831
+ _extrapolate_edge_orders(filled_set, 'below', n_extend_below, image_width, image_height, trace_degree)
832
+ if n_extend_above > 0:
833
+ _extrapolate_edge_orders(filled_set, 'above', n_extend_above, image_width, image_height, trace_degree)
834
+
835
+ logger.info(f"Interpolation/extrapolation complete: {apertures.norders} -> {filled_set.norders} orders.")
836
+ return filled_set
837
+
838
+
839
+ # 沿探测器边缘外推虚拟级次
840
+ def _extrapolate_edge_orders(
841
+ apertures: ApertureSet,
842
+ side: str,
843
+ n_orders: int,
844
+ image_width: int,
845
+ image_height: int,
846
+ trace_degree: int,
847
+ n_ref: int = 8
848
+ ):
849
+ """Helper to extrapolate and add virtual orders to an ApertureSet in-place."""
850
+ real_oids = sorted(apertures.get_orders())
851
+ if len(real_oids) < 2:
852
+ return
853
+
854
+ if side == 'below':
855
+ ref_oids = real_oids[:min(n_ref, len(real_oids))]
856
+ direction = -1
857
+ anchor_ap = apertures.get_aperture(ref_oids[0])
858
+ start_idx = -1
859
+ else: # 'above'
860
+ ref_oids = real_oids[max(0, len(real_oids) - n_ref):]
861
+ direction = 1
862
+ anchor_ap = apertures.get_aperture(ref_oids[-1])
863
+ start_idx = len(ref_oids) - 2
864
+
865
+ x_all = np.arange(image_width, dtype=float)
866
+ ref_centers = np.array([apertures.get_aperture(oid).get_position(x_all) for oid in ref_oids])
867
+ spacings = np.diff(ref_centers, axis=0)
868
+
869
+ # Model spacing trend with a simple linear fit
870
+ if spacings.shape[0] >= 2:
871
+ j_sp = np.arange(spacings.shape[0], dtype=float)
872
+ b_sp = np.polyfit(j_sp, spacings, 1)[0] # slope only
873
+ else:
874
+ b_sp = np.zeros(image_width)
875
+
876
+ last_center = anchor_ap.get_position(x_all)
877
+ last_lower = anchor_ap.get_lower(x_all)
878
+ last_upper = anchor_ap.get_upper(x_all)
879
+ last_sep = spacings[start_idx]
880
+
881
+ for k in range(1, n_orders + 1):
882
+ # Extrapolate spacing, center, and width
883
+ current_sep = last_sep + k * b_sp * direction
884
+ new_center = last_center + current_sep * direction
885
+ width = last_upper - last_lower
886
+ new_lower, new_upper = new_center - width / 2, new_center + width / 2
887
+
888
+ if (side == 'below' and np.max(new_upper) < 0) or \
889
+ (side == 'above' and np.min(new_lower) >= image_height):
890
+ break # Stop if order is completely off-detector
891
+
892
+ x_mapped = 2.0 * x_all / (image_width - 1.0) - 1.0
893
+
894
+ new_id = anchor_ap.aperture + k * direction
895
+ ap = ApertureLocation(
896
+ aperture=new_id, order=new_id,
897
+ center_coef=cheb.chebfit(x_mapped, new_center, trace_degree),
898
+ lower_coef=cheb.chebfit(x_mapped, new_lower, trace_degree),
899
+ upper_coef=cheb.chebfit(x_mapped, new_upper, trace_degree),
900
+ width=float(np.median(width)),
901
+ is_chebyshev=True,
902
+ domain=(0.0, float(image_width - 1.0)),
903
+ is_interpolated=True,
904
+ )
905
+ apertures.add_aperture(ap)
906
+ logger.info(f" Extrapolated virtual order (ID={new_id}) at y~{new_center[image_width//2]:.1f}")
907
+
908
+
909
+ # 由级次间谷值拟合背景包络
910
+ def _fit_background_envelope_1d(cross_section, order_centers_y, half_win=4):
911
+ """Fit a smooth background baseline envelope using inter-order valley samples.
912
+
913
+ At each inter-order midpoint (and detector edges) takes the local minimum inside a
914
+ ±half_win pixel window as a valley sample, then fits a smoothing cubic spline through
915
+ all samples to produce a continuous background baseline across the whole column.
916
+
917
+ Parameters
918
+ ----------
919
+ cross_section : 1-D array – the CCD column cross-section
920
+ order_centers_y : list[float] – center pixel of each order (sorted ascending)
921
+ half_win : int – half-width of local-minimum window at each sample point
922
+
923
+ Returns
924
+ -------
925
+ envelope : np.ndarray, shape (n,) – background baseline at every pixel
926
+ """
927
+
928
+ n = len(cross_section)
929
+ cs = cross_section.astype(float)
930
+ centers = sorted(float(c) for c in order_centers_y)
931
+
932
+ # 求指定位置窗口内的局部最小值
933
+ def _local_min(pos):
934
+ i0 = max(0, int(round(pos)) - half_win)
935
+ i1 = min(n, int(round(pos)) + half_win + 1)
936
+ seg = cs[i0:i1]
937
+ return float(np.nanmin(seg)) if seg.size > 0 else float(cs[int(np.clip(round(pos), 0, n-1))])
938
+
939
+ sample_y = []
940
+ sample_v = []
941
+
942
+ # Lower detector edge
943
+ gap0 = (centers[1] - centers[0]) if len(centers) > 1 else 20.0
944
+ edge_lo = max(0.0, centers[0] - gap0 * 0.6)
945
+ sample_y.append(edge_lo); sample_v.append(_local_min(edge_lo))
946
+
947
+ # Midpoints between consecutive orders
948
+ for k in range(len(centers) - 1):
949
+ mid = (centers[k] + centers[k + 1]) * 0.5
950
+ sample_y.append(mid); sample_v.append(_local_min(mid))
951
+
952
+ # Upper detector edge
953
+ gap1 = (centers[-1] - centers[-2]) if len(centers) > 1 else 20.0
954
+ edge_hi = min(float(n - 1), centers[-1] + gap1 * 0.6)
955
+ sample_y.append(edge_hi); sample_v.append(_local_min(edge_hi))
956
+
957
+ sy = np.array(sample_y, dtype=float)
958
+ sv = np.array(sample_v, dtype=float)
959
+
960
+ # Deduplicate
961
+ _, ui = np.unique(sy, return_index=True)
962
+ sy, sv = sy[ui], sv[ui]
963
+
964
+ x_all = np.arange(n, dtype=float)
965
+ if len(sy) >= 4:
966
+ spl = UnivariateSpline(sy, sv, k=3, s=0, ext=3)
967
+ envelope = spl(x_all)
968
+ elif len(sy) >= 2:
969
+ envelope = np.interp(x_all, sy, sv)
970
+ else:
971
+ envelope = np.full(n, sv[0] if sv.size > 0 else float(np.nanmedian(cs)))
972
+
973
+ # Clamp: envelope must not exceed the actual cross-section
974
+ return np.clip(envelope, np.nanmin(cs), np.nanmax(cs))
975
+
976
+
977
+ # 求级次轮廓与背景的交点
978
+ def _find_profile_roots_1d(cross_section, center_idx, search_lo, search_hi,
979
+ threshold_frac=0.05, bg_envelope=None,
980
+ noise_floor=None):
981
+ """Find where an order profile's wings blend into the background envelope.
982
+
983
+ Walks outward from *center_idx*; stops when ``cs[j] - bg_envelope[j]`` drops to
984
+ ``threshold_frac * (peak - bg_at_peak)``. Position is subpixel-interpolated.
985
+
986
+ Parameters
987
+ ----------
988
+ cross_section : 1-D array
989
+ center_idx : int
990
+ search_lo : float – lower search limit (inter-order midpoint)
991
+ search_hi : float – upper search limit
992
+ threshold_frac : float – fraction of peak amplitude above envelope for the root
993
+ bg_envelope : 1-D array or None – smooth background baseline; if None a flat
994
+ local-percentile estimate is used (legacy behaviour)
995
+ noise_floor : float or None – minimum excess above envelope in counts
996
+
997
+ Returns
998
+ -------
999
+ root_lo, root_hi : float – subpixel crossing positions
1000
+ bg_at_peak : float – envelope value at the order center
1001
+ threshold : float – absolute intensity level of the crossing
1002
+ """
1003
+ n = len(cross_section)
1004
+ cs = cross_section.astype(float)
1005
+ ci = int(np.clip(center_idx, 0, n - 1))
1006
+ slo = int(np.clip(float(search_lo), 0, n - 1))
1007
+ shi = int(np.clip(float(search_hi), 0, n - 1))
1008
+
1009
+ peak = cs[ci]
1010
+
1011
+ if bg_envelope is None:
1012
+ region = cs[slo:shi + 1]
1013
+ flat_bg = float(np.nanpercentile(region, 10)) if region.size > 3 else float(np.nanmin(region) if region.size else 0.0)
1014
+ bg_envelope = np.full(n, flat_bg)
1015
+
1016
+ bg_at_peak = float(bg_envelope[ci])
1017
+ amp_at_peak = max(float(peak) - bg_at_peak, 1.0)
1018
+ excess_thresh = threshold_frac * amp_at_peak
1019
+
1020
+ # Hybrid threshold: percentage of peak OR noise floor, whichever is larger.
1021
+ if noise_floor is not None and np.isfinite(noise_floor):
1022
+ excess_thresh = max(excess_thresh, float(noise_floor))
1023
+
1024
+ # Keep threshold physically meaningful for this order.
1025
+ excess_thresh = float(np.clip(excess_thresh, 0.0, 0.9 * amp_at_peak))
1026
+
1027
+ # 计算像素相对背景包络的超额信号
1028
+ def _excess(j):
1029
+ return cs[j] - float(bg_envelope[int(np.clip(j, 0, n - 1))])
1030
+
1031
+ # --- lower crossing: walk ci → slo ---
1032
+ root_lo = float(slo)
1033
+ for j in range(ci, slo - 1, -1):
1034
+ if _excess(j) <= excess_thresh:
1035
+ if j + 1 <= ci:
1036
+ e0, e1 = _excess(j), _excess(j + 1) # e0 ≤ thresh < e1
1037
+ denom = e1 - e0
1038
+ t = float(np.clip((excess_thresh - e0) / denom, 0.0, 1.0)) if denom > 0 else 0.0
1039
+ root_lo = float(j) + t
1040
+ else:
1041
+ root_lo = float(j)
1042
+ break
1043
+
1044
+ # --- upper crossing: walk ci → shi ---
1045
+ root_hi = float(shi)
1046
+ for j in range(ci, shi + 1):
1047
+ if _excess(j) <= excess_thresh:
1048
+ if j - 1 >= ci:
1049
+ e0, e1 = _excess(j - 1), _excess(j) # e0 > thresh ≥ e1
1050
+ denom = e0 - e1
1051
+ t = float(np.clip((e0 - excess_thresh) / denom, 0.0, 1.0)) if denom > 0 else 0.0
1052
+ root_hi = float(j - 1) + t
1053
+ else:
1054
+ root_hi = float(j)
1055
+ break
1056
+
1057
+ threshold = bg_at_peak + excess_thresh
1058
+ return root_lo, root_hi, bg_at_peak, threshold
1059
+
1060
+
1061
+ # 执行平场合并与级次追踪流程
1062
+ def process_order_tracing_stage(flat_filenames: List[str],
1063
+ output_dir_base: str = './midpath',
1064
+ # Flat combination parameters
1065
+ combine_method: str = 'median',
1066
+ combine_sigma: float = 3.0,
1067
+ mosaic_maxcount: float = 65535,
1068
+ # Order detection parameters
1069
+ snr_threshold: float = 5.0,
1070
+ gap_fill_factor: float = 1.6,
1071
+ min_trace_coverage: float = 0.20,
1072
+ trace_degree: int = 4,
1073
+ # Profile boundary parameters
1074
+ width_cheb_degree: int = 3,
1075
+ aperture_boundary_snr: float = 3.0,
1076
+ # Gap fill / extend parameters
1077
+ n_extend_below: int = 0,
1078
+ n_extend_above: int = 0,
1079
+ gap_fill_factor_interp: float = 1.35,
1080
+ # Diagnostic parameters
1081
+ save_plots: bool = True,
1082
+ fig_format: str = 'png') -> Tuple[FlatField, ApertureSet]:
1083
+ """_
1084
+ Execute flat fielding stage.
1085
+
1086
+ Args:
1087
+ flat_filenames: List of flat frame paths
1088
+ output_dir_base: Base output directory
1089
+
1090
+ Returns:
1091
+ Tuple of (FlatField, ApertureSet)
1092
+ """
1093
+ processor = FlatFieldProcessor()
1094
+
1095
+ # Combine flat frames
1096
+ flat_data, flat_mask = processor.combine_flat_frames(
1097
+ flat_filenames,
1098
+ combine_method=combine_method,
1099
+ combine_sigma=combine_sigma,
1100
+ mosaic_maxcount=mosaic_maxcount)
1101
+
1102
+ # Save master flat field as FITS and PNG (combined image + mask only)
1103
+ logger.info("Saving master flat field...")
1104
+ base_output_path = output_dir_base
1105
+ flat_file = Path(base_output_path) / 'step2_trace' / 'MasterFlat.fits'
1106
+ flat_file.parent.mkdir(parents=True, exist_ok=True)
1107
+ processor.save_flat_field(str(flat_file), save_plots=save_plots, fig_format=fig_format)
1108
+ logger.info(f"Saved master flat field to {flat_file}")
1109
+
1110
+ # Detect orders — uses raw combined flat, no normalization needed
1111
+ logger.info("Detecting grating orders...")
1112
+ apertures = processor.detect_orders( # Pass relevant params from config
1113
+ snr_threshold=snr_threshold,
1114
+ min_trace_coverage=min_trace_coverage,
1115
+ trace_degree=trace_degree,
1116
+ output_dir_base=output_dir_base)
1117
+ logger.info(f"Order detection completed: {apertures.norders} orders found")
1118
+
1119
+ # Renumber apertures with gap-aware physical order indices.
1120
+ # If spatial analysis detects missing orders, aperture IDs skip the
1121
+ # missing positions (e.g. 1,...,49,51,...,100) so that the downstream
1122
+ # 2D surface fitting uses correct order index m for every aperture.
1123
+ apertures = renumber_apertures_with_gaps(apertures, gap_fill_factor)
1124
+
1125
+ # Pre-compute profile-root-based boundaries for order mask + diagnostic plots.
1126
+ # _find_profile_roots_1d finds where the signal drops to aperture_boundary_snr × σ
1127
+ # above background, which is always strictly inside the inter-order midpoint. This
1128
+ # guarantees adjacent order masks never overlap even for densely packed echelle orders.
1129
+ logger.info("Pre-computing profile-root boundaries for order mask and diagnostics...")
1130
+ height, width = flat_data.shape
1131
+ x_coords_all = np.arange(width)
1132
+ center_col = width // 2
1133
+
1134
+ # 估算背景噪声阈值
1135
+ def _estimate_noise_floor(cross_section_1d, bg_envelope_1d, centers_y):
1136
+ """Estimate background noise via iterative MAD sigma-clipping."""
1137
+ residual = cross_section_1d - bg_envelope_1d
1138
+ valid = np.isfinite(residual)
1139
+ res = residual[valid]
1140
+
1141
+ if res.size < 5:
1142
+ return float(np.nanstd(residual))
1143
+
1144
+ sorted_res = np.sort(res)
1145
+ bg_pixels = sorted_res[:max(5, len(sorted_res) // 4)]
1146
+ med = float(np.nanmedian(bg_pixels))
1147
+ mad = float(np.nanmedian(np.abs(bg_pixels - med)))
1148
+ sigma_est = max(1.4826 * mad, 1e-6)
1149
+ keep = res < (med + 3.0 * sigma_est)
1150
+
1151
+ for _ in range(5):
1152
+ if np.sum(keep) < 5: break
1153
+ sub = res[keep]
1154
+ med = float(np.nanmedian(sub))
1155
+ mad = float(np.nanmedian(np.abs(sub - med)))
1156
+ sigma_est = max(1.4826 * mad, 1e-6)
1157
+ new_keep = res < (med + 3.0 * sigma_est)
1158
+ if np.array_equal(new_keep, keep): break
1159
+ keep = new_keep
1160
+
1161
+ if np.sum(keep) >= 5:
1162
+ sub = res[keep]
1163
+ med = float(np.nanmedian(sub))
1164
+ mad = float(np.nanmedian(np.abs(sub - med)))
1165
+ sigma_bg = max(1.4826 * mad, 1e-6)
1166
+ else:
1167
+ sigma_bg = max(sigma_est, 1e-6)
1168
+
1169
+ return sigma_bg * aperture_boundary_snr
1170
+
1171
+ ordered_apertures = sorted(apertures.apertures.items(), key=lambda x: x[0])
1172
+ valid_orders = []
1173
+ for order_idx, (aperture_id, aperture) in enumerate(ordered_apertures, start=1):
1174
+ center_pos = aperture.get_position(x_coords_all)
1175
+ valid_center = np.isfinite(center_pos) & (center_pos >= 0) & (center_pos < height)
1176
+ if np.sum(valid_center) < 10:
1177
+ continue
1178
+ valid_orders.append({
1179
+ 'order_idx': order_idx,
1180
+ 'aperture_id': aperture_id,
1181
+ 'aperture': aperture,
1182
+ 'valid_center': valid_center,
1183
+ })
1184
+
1185
+ y_lo_all = np.full((len(valid_orders), width), np.nan, dtype=float)
1186
+ y_hi_all = np.full((len(valid_orders), width), np.nan, dtype=float)
1187
+ if len(valid_orders) >= 1:
1188
+ # 计算单列级次边界
1189
+ def process_column(xi):
1190
+ x = x_coords_all[xi]
1191
+ cross_section_col = flat_data[:, x].astype(float)
1192
+ centers = []
1193
+ active_rows = []
1194
+ for row_idx, item in enumerate(valid_orders):
1195
+ cy = float(item['aperture'].get_position(x))
1196
+ if not np.isfinite(cy) or cy < -5 or cy > height + 5:
1197
+ continue
1198
+ centers.append(float(np.clip(cy, 0, height - 1)))
1199
+ active_rows.append((row_idx, cy))
1200
+ if len(centers) == 0:
1201
+ return xi, None
1202
+ bg_env_col = _fit_background_envelope_1d(cross_section_col, centers)
1203
+ noise_floor_col = _estimate_noise_floor(cross_section_col, bg_env_col, centers)
1204
+ results = []
1205
+ for local_i, (row_idx, cy_raw) in enumerate(active_rows):
1206
+ cy_y = float(np.clip(cy_raw, 0, height - 1))
1207
+ cy_idx = int(round(cy_y))
1208
+ if local_i > 0:
1209
+ search_lo = 0.5 * (centers[local_i] + centers[local_i - 1])
1210
+ else:
1211
+ gap = (centers[1] - centers[0]) if len(centers) > 1 else 20.0
1212
+ search_lo = max(0.0, centers[local_i] - 0.5 * gap)
1213
+ if local_i < len(centers) - 1:
1214
+ search_hi = 0.5 * (centers[local_i] + centers[local_i + 1])
1215
+ else:
1216
+ gap = (centers[-1] - centers[-2]) if len(centers) > 1 else 20.0
1217
+ search_hi = min(float(height - 1), centers[local_i] + 0.5 * gap)
1218
+ root_lo, root_hi, _bg_at_peak, _threshold = _find_profile_roots_1d(
1219
+ cross_section_col, cy_idx, search_lo, search_hi,
1220
+ threshold_frac=0.0, bg_envelope=bg_env_col, noise_floor=noise_floor_col)
1221
+ results.append((row_idx, root_lo, root_hi))
1222
+ return xi, results
1223
+
1224
+ with ThreadPoolExecutor() as executor:
1225
+ for xi, results in executor.map(process_column, range(width)):
1226
+ if results is None:
1227
+ continue
1228
+ for row_idx, root_lo, root_hi in results:
1229
+ y_lo_all[row_idx, xi] = float(np.clip(root_lo, 0, height - 1))
1230
+ y_hi_all[row_idx, xi] = float(np.clip(root_hi, 0, height - 1))
1231
+
1232
+ logger.info("Filtering abnormal boundary widths via cross-order interpolation...")
1233
+ centers_all = np.full((len(valid_orders), width), np.nan)
1234
+ for row_idx, item in enumerate(valid_orders):
1235
+ centers_all[row_idx, :] = item['aperture'].get_position(x_coords_all)
1236
+
1237
+ w_up_all = y_hi_all - centers_all
1238
+ w_low_all = centers_all - y_lo_all
1239
+
1240
+ from scipy.ndimage import median_filter
1241
+ for xi in range(width):
1242
+ for w_arr in (w_up_all, w_low_all):
1243
+ col_w = w_arr[:, xi]
1244
+ valid = np.isfinite(col_w) & (col_w > 0)
1245
+ if np.sum(valid) >= 3:
1246
+ idx_valid = np.where(valid)[0]
1247
+ val_valid = col_w[valid]
1248
+
1249
+ if len(val_valid) >= 5:
1250
+ trend = median_filter(val_valid, size=5, mode='nearest')
1251
+ else:
1252
+ trend = np.full_like(val_valid, np.median(val_valid))
1253
+
1254
+ # 识别异常偏宽的边界:宽度 > 趋势宽度的 1.4 倍,或者比趋势宽出 4 个像素
1255
+ outlier_mask = (val_valid > 1.4 * trend) | (val_valid > trend + 4.0)
1256
+
1257
+ if np.any(outlier_mask):
1258
+ good_mask = ~outlier_mask
1259
+ if np.sum(good_mask) >= 2:
1260
+ val_valid[outlier_mask] = np.interp(idx_valid[outlier_mask], idx_valid[good_mask], val_valid[good_mask])
1261
+ elif np.sum(good_mask) == 1:
1262
+ val_valid[outlier_mask] = val_valid[good_mask][0]
1263
+ col_w[idx_valid] = val_valid
1264
+
1265
+ y_hi_all = centers_all + w_up_all
1266
+ y_lo_all = centers_all - w_low_all
1267
+
1268
+ # Resolve overlaps in raw boundaries before fitting to guide the polynomials
1269
+ for i in range(len(valid_orders) - 1):
1270
+ for xi in range(width):
1271
+ if y_hi_all[i, xi] >= y_lo_all[i+1, xi] - 1.0:
1272
+ c1 = int(round(centers_all[i, xi]))
1273
+ c2 = int(round(centers_all[i+1, xi]))
1274
+ c1 = max(0, min(c1, height - 1))
1275
+ c2 = max(0, min(c2, height - 1))
1276
+ if c2 > c1 + 2:
1277
+ valley = c1 + np.argmin(flat_data[c1:c2, xi])
1278
+ else:
1279
+ valley = int(round(0.5 * (c1 + c2)))
1280
+ y_hi_all[i, xi] = valley - 0.5
1281
+ y_lo_all[i+1, xi] = valley + 0.5
1282
+
1283
+ x_all_f = x_coords_all.astype(float)
1284
+
1285
+ logger.info(f"Hybrid smoothing: Chebyshev center (deg {trace_degree}) + Chebyshev width (deg {width_cheb_degree})")
1286
+
1287
+ _trace_coefs = {}
1288
+
1289
+ for row_idx, item in enumerate(valid_orders):
1290
+ aperture = item['aperture']
1291
+ valid_center = item['valid_center']
1292
+ center_raw = aperture.get_position(x_all_f)
1293
+ y_lo_raw = y_lo_all[row_idx, :]
1294
+ y_hi_raw = y_hi_all[row_idx, :]
1295
+ valid_bnd = valid_center & np.isfinite(y_lo_raw) & np.isfinite(y_hi_raw)
1296
+ n_valid = int(np.sum(valid_bnd))
1297
+ if n_valid < 10:
1298
+ continue
1299
+
1300
+ center_smooth = center_raw
1301
+ w_up_raw = y_hi_raw[valid_bnd] - center_smooth[valid_bnd]
1302
+ w_low_raw = center_smooth[valid_bnd] - y_lo_raw[valid_bnd]
1303
+
1304
+ x_bnd = x_all_f[valid_bnd]
1305
+ span = x_bnd[-1] - x_bnd[0] if len(x_bnd) > 0 else 0
1306
+ deg = min(width_cheb_degree, n_valid - 1) if span >= 0.6 * width else (min(1, width_cheb_degree, n_valid - 1) if span >= 0.3 * width else 0)
1307
+
1308
+ coef_w_up = cheb.chebfit(x_bnd, w_up_raw, deg)
1309
+ coef_w_low = cheb.chebfit(x_bnd, w_low_raw, deg)
1310
+
1311
+ w_up_smooth = np.maximum(cheb.chebval(x_all_f, coef_w_up), 0.0)
1312
+ w_low_smooth = np.maximum(cheb.chebval(x_all_f, coef_w_low), 0.0)
1313
+
1314
+ hi_final = np.clip(center_smooth + w_up_smooth, 0, height - 1)
1315
+ lo_final = np.clip(center_smooth - w_low_smooth, 0, height - 1)
1316
+
1317
+ y_lo_all[row_idx, :] = np.where(valid_center, lo_final, np.nan)
1318
+ y_hi_all[row_idx, :] = np.where(valid_center, hi_final, np.nan)
1319
+
1320
+ _trace_coefs[item['aperture_id']] = {
1321
+ 'center_arr': center_smooth.tolist(),
1322
+ 'w_up_cheb': coef_w_up.tolist(),
1323
+ 'w_low_cheb': coef_w_low.tolist(),
1324
+ }
1325
+
1326
+ logger.info("Hybrid smoothing complete (Chebyshev center + Chebyshev width → traces)")
1327
+
1328
+ for item in valid_orders:
1329
+ aid = item['aperture_id']
1330
+ if aid in _trace_coefs:
1331
+ bc = _trace_coefs[aid]
1332
+ ap = item['aperture']
1333
+ ap.center_arr = np.asarray(bc['center_arr'], dtype=float)
1334
+ ap.w_up_cheb_coef = np.asarray(bc['w_up_cheb'], dtype=float)
1335
+ ap.w_low_cheb_coef = np.asarray(bc['w_low_cheb'], dtype=float)
1336
+
1337
+ filled_apertures = fill_missing_orders_by_interpolation(
1338
+ apertures, width, height, trace_degree=trace_degree,
1339
+ n_extend_below=n_extend_below, n_extend_above=n_extend_above,
1340
+ flat_data=flat_data, gap_fill_factor=gap_fill_factor_interp)
1341
+
1342
+ if filled_apertures and filled_apertures.norders > apertures.norders:
1343
+ apertures = filled_apertures
1344
+ logger.info(f"Gap-fill added {filled_apertures.norders - apertures.norders} predicted order(s)")
1345
+
1346
+ coefs_path = Path(base_output_path) / 'step2_trace' / 'Orders_trace_coefs.json'
1347
+ with open(coefs_path, 'w') as f:
1348
+ json.dump({'orders': _trace_coefs}, f, indent=2)
1349
+ logger.info(f"Saved trace coefs ({len(_trace_coefs)} orders) to {coefs_path.name}")
1350
+
1351
+ all_ap_ids = sorted(list(apertures.apertures.keys()))
1352
+ n_ap = len(all_ap_ids)
1353
+ lo_traces = np.zeros((n_ap, width))
1354
+ hi_traces = np.zeros((n_ap, width))
1355
+ centers = np.zeros((n_ap, width))
1356
+
1357
+ for i, ap_id in enumerate(all_ap_ids):
1358
+ ap = apertures.get_aperture(ap_id)
1359
+ centers[i] = ap.get_position(x_all_f)
1360
+ lo_traces[i] = ap.get_lower(x_all_f)
1361
+ hi_traces[i] = ap.get_upper(x_all_f)
1362
+
1363
+ # Enforce valley gap for the final mask and plot
1364
+ for i in range(n_ap - 1):
1365
+ for xi in range(width):
1366
+ if hi_traces[i, xi] >= lo_traces[i+1, xi] - 1.0:
1367
+ c1 = int(round(centers[i, xi]))
1368
+ c2 = int(round(centers[i+1, xi]))
1369
+ c1 = max(0, min(c1, height - 1))
1370
+ c2 = max(0, min(c2, height - 1))
1371
+ if c2 > c1 + 2:
1372
+ valley = c1 + np.argmin(flat_data[c1:c2, xi])
1373
+ else:
1374
+ valley = int(round(0.5 * (c1 + c2)))
1375
+ hi_traces[i, xi] = valley - 0.5
1376
+ lo_traces[i+1, xi] = valley + 0.5
1377
+
1378
+ # Use uint8 for boolean mask (1 = order, 0 = background)
1379
+ order_mask = np.zeros((height, width), dtype=np.uint8)
1380
+ for i, ap_id in enumerate(all_ap_ids):
1381
+ for xi in range(width):
1382
+ if np.isfinite(lo_traces[i, xi]) and np.isfinite(hi_traces[i, xi]):
1383
+ r0 = max(0, int(np.ceil(lo_traces[i, xi])))
1384
+ r1 = min(height, int(np.floor(hi_traces[i, xi])) + 1)
1385
+ if r1 > r0:
1386
+ order_mask[r0:r1, xi] = 1
1387
+
1388
+ mask_path = Path(base_output_path) / 'step2_trace' / 'Orders_mask.fits'
1389
+ write_fits_image(str(mask_path), order_mask, dtype='uint8')
1390
+ logger.info(f"Saved order boolean mask to {mask_path}")
1391
+
1392
+ if save_plots and apertures.norders > 0:
1393
+ logger.info("Generating diagnostic plots...")
1394
+ out_dir = Path(base_output_path) / 'step2_trace'
1395
+ from specproc.plotting.spectra_plotter import plot_2d_image_to_file
1396
+ import matplotlib.pyplot as plt
1397
+ plt.figure(figsize=(12, 10))
1398
+ vmin, vmax = np.percentile(flat_data, [1, 99])
1399
+ plt.imshow(flat_data, aspect='auto', origin='lower', cmap='viridis', vmin=vmin, vmax=vmax)
1400
+ plt.colorbar(label='Counts')
1401
+
1402
+ for i, ap_id in enumerate(all_ap_ids):
1403
+ ap = apertures.get_aperture(ap_id)
1404
+ center_pos = centers[i]
1405
+ lower_pos = lo_traces[i]
1406
+ upper_pos = hi_traces[i]
1407
+
1408
+ is_filled = getattr(ap, 'is_interpolated', False)
1409
+ alpha_val = 0.7 if is_filled else 1.0
1410
+
1411
+ # 中心轨迹线用间隔大一点的虚线,上下边缘用点线
1412
+ # 颜色与背景光谱的 color map 对比明显
1413
+ plt.plot(x_coords_all, center_pos, color='red', linestyle='--', linewidth=0.5, alpha=alpha_val)
1414
+ plt.plot(x_coords_all, lower_pos, color='orange', linestyle=':', linewidth=0.4, alpha=alpha_val)
1415
+ plt.plot(x_coords_all, upper_pos, color='magenta', linestyle=':', linewidth=0.4, alpha=alpha_val)
1416
+
1417
+ if np.any(np.isfinite(center_pos)):
1418
+ x_anno = x_coords_all[np.isfinite(center_pos)][-1]
1419
+ y_anno = center_pos[np.isfinite(center_pos)][-1]
1420
+ plt.text(x_anno + 5, y_anno, str(ap_id), color='red', fontsize=5, ha='left', va='center', alpha=alpha_val, clip_on=False)
1421
+
1422
+ plt.xlim(0, width - 1)
1423
+ plt.ylim(bottom=0)
1424
+ plt.xlabel('Pixel (X)')
1425
+ plt.ylabel('Pixel (Y)')
1426
+ plt.title('Aperture Traces on Combined Master Flat')
1427
+ plt.grid(False)
1428
+ traces_plot_file = out_dir / f'order_traces.{fig_format}'
1429
+ plt.savefig(str(traces_plot_file), dpi=150, bbox_inches='tight')
1430
+ plt.close()
1431
+ logger.info(f"Saved order traces plot to {traces_plot_file}")
1432
+
1433
+ # Plot seed peaks diagnostic
1434
+ diags = processor.order_diagnostics
1435
+ if 'seed_profile' in diags and 'seeds' in diags:
1436
+ logger.info("Plotting seed peaks diagnostic...")
1437
+ seed_profile = diags['seed_profile']
1438
+ seeds = diags['seeds']
1439
+ envelope = diags.get('envelope')
1440
+
1441
+ fig, ax = plt.subplots(figsize=(12, 8))
1442
+ y_coords = np.arange(len(seed_profile))
1443
+ if envelope is not None and 'profile' in diags:
1444
+ ax.plot(y_coords, diags['profile'], 'k-', linewidth=0.7, alpha=1.0, label='Raw Profile')
1445
+ ax.plot(y_coords, envelope, 'g--', linewidth=0.7, label='Background Envelope')
1446
+
1447
+ det_threshold_flux = snr_threshold * diags.get('noise', 1.0)
1448
+ ax.plot(y_coords, envelope + det_threshold_flux, color='magenta', linestyle=':', linewidth=0.8, alpha=0.8, label=f'{snr_threshold} $\\sigma$ Detection Threshold')
1449
+
1450
+ # Add markings for each aperture
1451
+ max_prof_val = np.nanmax(diags['profile']) if 'profile' in diags else 1000
1452
+ x1_col = diags.get('x1', max(0, width // 2 - 5))
1453
+ x2_col = diags.get('x2', min(width, width // 2 + 6))
1454
+ x_cols_eval = np.arange(x1_col, x2_col, dtype=float)
1455
+ for ap_id, ap in apertures.apertures.items():
1456
+ center_y = np.nanmedian(ap.get_position(x_cols_eval))
1457
+ lower_y = np.nanmedian(ap.get_lower(x_cols_eval))
1458
+ upper_y = np.nanmedian(ap.get_upper(x_cols_eval))
1459
+
1460
+ if np.isfinite(center_y):
1461
+ if len(seeds) > 0:
1462
+ seed_idx = np.argmin(np.abs(seeds - center_y))
1463
+ if np.abs(seeds[seed_idx] - center_y) < diags.get('med_sep', 30) * 0.75:
1464
+ peak_y_coord = seeds[seed_idx]
1465
+ if 'profile' in diags and envelope is not None and 0 <= peak_y_coord < len(diags['profile']):
1466
+ peak_top = diags['profile'][peak_y_coord]
1467
+
1468
+ # 红色短线和级次序号标记在 peaks 顶部
1469
+ v_offset = 0.015 * max_prof_val
1470
+ ax.vlines(peak_y_coord, peak_top + v_offset, peak_top + v_offset + 0.025 * max_prof_val, color='red', linewidth=1.5, zorder=10)
1471
+ ax.text(peak_y_coord, peak_top + v_offset + 0.03 * max_prof_val, str(ap_id), color='red', ha='center', va='bottom', fontsize=7)
1472
+
1473
+ # 青色水平 error bar,两边的 cap 底部卡在实际的级次边缘流量对应的位置
1474
+ err_lower = center_y - lower_y
1475
+ err_upper = upper_y - center_y
1476
+ if err_lower > 0 and err_upper > 0:
1477
+ idx_lower = int(np.clip(round(lower_y), 0, len(diags['profile']) - 1))
1478
+ idx_upper = int(np.clip(round(upper_y), 0, len(diags['profile']) - 1))
1479
+ edge_flux = (diags['profile'][idx_lower] + diags['profile'][idx_upper]) / 2.0
1480
+ ax.errorbar(peak_y_coord, edge_flux, xerr=[[err_lower], [err_upper]], fmt='none', ecolor='cyan', capsize=2.0, elinewidth=0.6, capthick=0.4, zorder=10)
1481
+
1482
+ ax.set_xlabel('Pixel (Y)')
1483
+ ax.set_ylabel('Flux (Counts)')
1484
+ ax.set_title('Aperture Seeds Detection')
1485
+ ax.legend()
1486
+ ax.grid(True, alpha=0.3)
1487
+ seed_peaks_plot_file = out_dir / f'order_seed_peaks.{fig_format}'
1488
+ plt.savefig(str(seed_peaks_plot_file), dpi=150, bbox_inches='tight')
1489
+ plt.close(fig)
1490
+ logger.info(f"Saved seed peaks plot to {seed_peaks_plot_file}")
1491
+
1492
+ # Plot peak SNR diagnostic
1493
+ logger.info("Plotting peak SNR diagnostic...")
1494
+ noise = diags.get('noise', 1.0)
1495
+ local_sep_func = diags.get('local_sep_func')
1496
+ fig, ax1 = plt.subplots(figsize=(12, 8))
1497
+
1498
+ plot_data = []
1499
+ for ap_id, ap in apertures.apertures.items():
1500
+ center_y = np.nanmedian(ap.get_position(x_cols_eval))
1501
+ if np.isfinite(center_y) and len(seeds) > 0:
1502
+ seed_idx = np.argmin(np.abs(seeds - center_y))
1503
+ if np.abs(seeds[seed_idx] - center_y) < diags.get('med_sep', 30) * 0.75:
1504
+ s_y = seeds[seed_idx]
1505
+ if 0 <= s_y < len(seed_profile):
1506
+ snr_val = seed_profile[s_y] / noise
1507
+ plot_data.append((s_y, snr_val, ap_id))
1508
+
1509
+ plot_data.sort(key=lambda x: x[0])
1510
+
1511
+ if len(plot_data) > 0:
1512
+ plot_seeds, plot_snrs, plot_labels = zip(*plot_data)
1513
+ ax1.plot(plot_seeds, plot_snrs, 'b-o', linewidth=1.0, markersize=4, label='Peak SNR')
1514
+
1515
+ # 根据图像截面长度(完整空间方向跨度)稍微左右宽展几列
1516
+ x_margin = len(seed_profile) * 0.02
1517
+ ax1.set_xlim(-x_margin, len(seed_profile) - 1 + x_margin)
1518
+
1519
+ # 提高 Y 轴上限,为顶部居中的图例留出空间
1520
+ snr_max = max(plot_snrs)
1521
+ ax1.set_ylim(bottom=-snr_max * 0.05, top=max(snr_threshold * 1.5, snr_max * 1.20))
1522
+
1523
+ ax1.axhline(y=snr_threshold, color='magenta', linestyle=':', linewidth=1.2, alpha=0.8, label=f'{snr_threshold} $\\sigma$ Threshold')
1524
+
1525
+ ax1.set_xlabel('Pixel (Y)')
1526
+ ax1.set_ylabel('Signal-to-Noise Ratio (SNR)', color='b')
1527
+ ax1.tick_params(axis='y', labelcolor='b')
1528
+ ax1.set_title('Peak SNR and Inter-Aperture Separation')
1529
+ ax1.grid(True, alpha=0.3, axis='x')
1530
+
1531
+ if local_sep_func and len(plot_data) > 0:
1532
+ ax2 = ax1.twinx()
1533
+ separations = [local_sep_func(y) for y in plot_seeds]
1534
+
1535
+ # Inter-order Separation 只画点
1536
+ ax2.plot(plot_seeds, separations, 'rs', markersize=3, alpha=0.7, label='Inter-aperture Separation')
1537
+
1538
+ # 画出前面多项式拟合得到的光滑曲线
1539
+ x_min, x_max = ax1.get_xlim()
1540
+ y_eval = np.linspace(max(0, x_min), min(len(seed_profile)-1, x_max), 300)
1541
+ sep_eval = [local_sep_func(y) for y in y_eval]
1542
+ ax2.plot(y_eval, sep_eval, 'r-', linewidth=1.0, alpha=0.4, label='Smooth Fit Curve')
1543
+
1544
+ ax2.set_ylabel('Inter-aperture Separation (pixels)', color='r')
1545
+ ax2.tick_params(axis='y', labelcolor='r')
1546
+
1547
+ # 提高右侧 Y 轴上限,防止级次间距曲线撞到顶部图例
1548
+ sep_max = max(sep_eval) if len(sep_eval) > 0 else 10.0
1549
+ ax2.set_ylim(bottom=-sep_max * 0.05, top=sep_max * 1.20)
1550
+
1551
+ snr_range = max(plot_snrs) - min(plot_snrs) if len(plot_snrs) > 1 else 10.0
1552
+ y_range = max(sep_eval) - min(sep_eval) if len(sep_eval) > 1 else 10.0
1553
+ for y, sep, snr, label in zip(plot_seeds, separations, plot_snrs, plot_labels):
1554
+ ax1.text(y, snr + snr_range * 0.015, str(label), va='bottom', ha='center', color='blue', fontsize=7, rotation=90)
1555
+ ax2.text(y, sep + y_range * 0.015, str(label), va='bottom', ha='center', color='red', fontsize=7, rotation=90)
1556
+
1557
+ # 合并两个图的图例到一起
1558
+ lines1, labels1 = ax1.get_legend_handles_labels()
1559
+ lines2, labels2 = ax2.get_legend_handles_labels()
1560
+ ax1.legend(lines1 + lines2, labels1 + labels2, loc='upper center', ncol=3)
1561
+ elif len(plot_data) > 0:
1562
+ snr_range = max(plot_snrs) - min(plot_snrs) if len(plot_snrs) > 1 else 10.0
1563
+ for y, snr, label in zip(plot_seeds, plot_snrs, plot_labels):
1564
+ ax1.text(y, snr + snr_range * 0.015, str(label), va='bottom', ha='center', color='blue', fontsize=7, rotation=90)
1565
+ ax1.legend(loc='upper center', ncol=2)
1566
+
1567
+ fig.tight_layout()
1568
+ snr_plot_file = out_dir / f'order_seed_snr.{fig_format}'
1569
+ plt.savefig(str(snr_plot_file), dpi=150, bbox_inches='tight')
1570
+ plt.close(fig)
1571
+ logger.info(f"Saved peak SNR plot to {snr_plot_file}")
1572
+
1573
+ logger.info("Creating FlatField object...")
1574
+ flat_field = FlatField(
1575
+ flat_data=processor.flat_data,
1576
+ flat_mask=processor.flat_mask,
1577
+ flat_norm=None,
1578
+ flat_sens=None,
1579
+ scattered_light=None,
1580
+ smoothed_model=None,
1581
+ pixel_flat=None,
1582
+ illumination_flat=None,
1583
+ aperture_set=apertures,
1584
+ )
1585
+
1586
+ logger.info("Step 2 order tracing completed successfully")
1587
+ return flat_field, apertures