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,1255 @@
1
+ """
2
+ 2D flat-field correction stage — Step 4 of the spectral reduction pipeline.
3
+
4
+ Applies the pixel-to-pixel flat correction map (built in Step 2 / order_tracing)
5
+ to a science image, and persists all flat-model artefacts to the step4 output
6
+ directory.
7
+
8
+ Main entry point: process_flat_correction_stage()
9
+ """
10
+ # 构建二维平场响应模型并校正科学图像。
11
+
12
+ import logging
13
+ import pickle
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ import numpy as np
18
+
19
+
20
+
21
+ from scipy import ndimage
22
+ from scipy.signal import savgol_filter
23
+ from scipy.interpolate import UnivariateSpline
24
+ from scipy.ndimage import gaussian_filter1d
25
+ from typing import Dict, Tuple, List
26
+ from specproc.core.data_structures import FlatField, ApertureSet
27
+ from specproc.utils.fits_io import write_fits_image
28
+ from specproc.plotting.spectra_plotter import plot_2d_image_to_file
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ # 保存平场校正模型及诊断产物
34
+ def save_flat_correction_products(flat_field: FlatField,
35
+ apertures: Optional[ApertureSet],
36
+ out_dir: Path,
37
+ processor=None,
38
+ fig_format: str = 'png',
39
+ save_plots: bool = True) -> None:
40
+ """
41
+ Persist Step-4 flat-model artefacts (blaze profiles, sensitivity map,
42
+ pixel flat, 2D correction map, response map, diagnostic plots).
43
+
44
+ Parameters
45
+ ----------
46
+ processor : FlatCorrectionModelBuilder, optional
47
+ If provided, re-use its already-computed response_map and diagnostics
48
+ instead of rebuilding from scratch (saves ~5 s).
49
+ """
50
+ out_dir.mkdir(parents=True, exist_ok=True)
51
+
52
+ if flat_field.blaze_profiles:
53
+ with open(out_dir / 'blaze_profiles.pkl', 'wb') as f:
54
+ pickle.dump(flat_field.blaze_profiles, f)
55
+
56
+ if flat_field.cross_profiles:
57
+ with open(out_dir / 'cross_profiles.pkl', 'wb') as f:
58
+ pickle.dump(flat_field.cross_profiles, f)
59
+
60
+ # If processor is provided, save its diagnostics directly.
61
+ if processor is not None:
62
+ # Compute clean_flat for diagnostics
63
+ clean_flat = flat_field.flat_data
64
+ if flat_field.scattered_light is not None:
65
+ clean_flat = np.clip(
66
+ flat_field.flat_data.astype(np.float32) - flat_field.scattered_light.astype(np.float32),
67
+ 1e-6,
68
+ None,
69
+ )
70
+
71
+ clean_flat_corrected = None
72
+ flat_corr = flat_field.pixel_flat if flat_field.pixel_flat is not None else flat_field.flat_corr_2d
73
+ if flat_corr is not None:
74
+ safe_flat = flat_corr.astype(np.float32)
75
+ bad = (~np.isfinite(safe_flat)) | (safe_flat <= 0.05)
76
+ if np.any(bad):
77
+ safe_flat = safe_flat.copy()
78
+ safe_flat[bad] = 1.0
79
+ clean_flat_corrected = clean_flat.astype(np.float32) / safe_flat
80
+
81
+ processor.save_step4_diagnostics(
82
+ out_dir,
83
+ clean_flat=clean_flat.astype(np.float32),
84
+ clean_flat_corrected=clean_flat_corrected,
85
+ fig_format=fig_format,
86
+ save_plots=save_plots
87
+ )
88
+
89
+
90
+
91
+ # 构建二维平场响应模型
92
+ class FlatCorrectionModelBuilder:
93
+ # 初始化对象状态
94
+ def __init__(self):
95
+ self.flat_data = None
96
+ self.flat_mask = None
97
+ self.response_map = None
98
+ self.smoothed_model = None
99
+ self.pixel_flat = None
100
+ self.illumination_flat = None
101
+ self.flat_corr_2d = None
102
+ self.flat_sens = None
103
+ self.order_diagnostics = {}
104
+
105
+ # 平滑单级次一维响应曲线
106
+ def _smooth_order_response(self, flux_1d: np.ndarray, method: str = 'savgol',
107
+ window: int = 21, bspline_smooth: float = 0.5) -> np.ndarray:
108
+ """Smooth per-order 1D blaze extraction with selectable method."""
109
+ x = np.arange(flux_1d.size)
110
+ y = np.asarray(flux_1d, dtype=float)
111
+
112
+ valid = np.isfinite(y) & (y > 0)
113
+ if np.sum(valid) < max(20, int(0.2 * y.size)):
114
+ med = np.nanmedian(y[valid]) if np.any(valid) else 1.0
115
+ return np.full_like(y, med if np.isfinite(med) and med > 0 else 1.0)
116
+
117
+ y_interp = np.interp(x, x[valid], y[valid])
118
+ win = max(5, int(window))
119
+ win = min(win, y.size - 1 if y.size % 2 == 0 else y.size)
120
+ if win < 7:
121
+ win = 7
122
+ if win % 2 == 0:
123
+ win += 1
124
+
125
+ m = (method or 'savgol').strip().lower()
126
+ if m == 'median':
127
+ smooth = ndimage.median_filter(y_interp, size=win, mode='nearest')
128
+ smooth = savgol_filter(smooth, window_length=win, polyorder=min(3, win - 2), mode='interp')
129
+ elif m == 'bspline':
130
+ k = min(3, max(1, len(x) - 1))
131
+ s = max(0.0, float(bspline_smooth)) * len(x)
132
+ smooth = UnivariateSpline(x, y_interp, k=k, s=s)(x)
133
+ else:
134
+ smooth = savgol_filter(y_interp, window_length=win, polyorder=min(3, win - 2), mode='interp')
135
+
136
+ # Iterative clipping similar to apnormalize-style robust fitting.
137
+ fit = smooth.copy()
138
+ keep = np.ones_like(y_interp, dtype=bool)
139
+ for _ in range(3):
140
+ resid = y_interp - fit
141
+ sigma = np.std(resid[keep]) if np.any(keep) else 0.0
142
+ if sigma <= 0:
143
+ break
144
+ new_keep = np.abs(resid) < 3.0 * sigma
145
+ if np.array_equal(new_keep, keep):
146
+ break
147
+ keep = new_keep
148
+ if np.sum(keep) < 10:
149
+ break
150
+ fit = np.interp(x, x[keep], y_interp[keep])
151
+ fit = savgol_filter(fit, window_length=win, polyorder=3, mode='interp')
152
+
153
+ # Safety: no negative or zero values
154
+ fit = np.maximum(fit, 1e-6)
155
+ return fit
156
+
157
+ # 自动拟合并平滑级次光栅响应
158
+ def _smooth_blaze_auto_bspline(self, flux_1d: np.ndarray,
159
+ smooth_factor: float = 1.0,
160
+ prefilter_size: int = 15) -> np.ndarray:
161
+ """Automated B-spline blaze extraction.
162
+
163
+ Uses SciPy's automated knot selection driven by a dynamic smoothing condition
164
+ derived from local noise variance. This eliminates the need for manual knot
165
+ spacing and perfectly tracks complex order shapes natively.
166
+ """
167
+ from scipy.interpolate import splrep, splev
168
+
169
+ x = np.arange(flux_1d.size)
170
+ y = np.asarray(flux_1d, dtype=float)
171
+
172
+ valid = np.isfinite(y) & (y > 0)
173
+ n_valid = int(np.sum(valid))
174
+ if n_valid < max(20, int(0.2 * y.size)):
175
+ med = np.nanmedian(y[valid]) if np.any(valid) else 1.0
176
+ return np.full_like(y, med if np.isfinite(med) and med > 0 else 1.0)
177
+
178
+ actual_prefilter = min(prefilter_size, max(3, n_valid // 5))
179
+ if actual_prefilter % 2 == 0:
180
+ actual_prefilter += 1
181
+
182
+ # Pre-clean: median filter kills local outliers
183
+ y_clean = ndimage.median_filter(
184
+ np.interp(x, x[valid], y[valid]), size=actual_prefilter, mode='nearest')
185
+
186
+ # Fit region: > 1% of peak to allow knots to reach the very edges naturally
187
+ peak = np.max(y_clean)
188
+ sig_mask = y_clean > (peak * 0.01)
189
+ x_fit = x[sig_mask].astype(float)
190
+ y_fit = y_clean[sig_mask]
191
+
192
+ if len(x_fit) < 10:
193
+ return np.maximum(y_clean, 1.0)
194
+
195
+ # BUG FIX: Estimate noise from the RAW unsmoothed data!
196
+ # Using y_fit (which is median-filtered) yields a variance near 0,
197
+ # causing 's' to be tiny and forcing B-spline to overfit all wiggles.
198
+ y_raw = np.interp(x, x[valid], y[valid])
199
+ y_raw_fit = y_raw[sig_mask]
200
+
201
+ # Estimate true photon noise for 's' parameter (MAD of raw differences)
202
+ noise_est = np.nanmedian(np.abs(np.diff(y_raw_fit))) / 0.6745
203
+ var_est = max(noise_est ** 2, 1.0)
204
+
205
+ # s = N * variance is the statistical sweet spot.
206
+ s_val = smooth_factor * len(x_fit) * var_est
207
+
208
+ # Cubic B-spline fit with automatic knot selection!
209
+ tck = splrep(x_fit, y_fit, k=3, s=s_val)
210
+
211
+ # Natural extrapolation to the full array
212
+ blaze_smooth = splev(x, tck)
213
+
214
+ # --- NEW: Wing Deviation Check & Local Smoothing ---
215
+ # Check outer 10% of the array for wild extrapolations (up/down curls)
216
+ n_pts = len(x)
217
+ wing_len = int(0.1 * n_pts)
218
+
219
+ if wing_len > 10:
220
+ trans_len = max(1, int(0.05 * n_pts)) # Transition zone is the inner 5% of the wing
221
+ flat_len = wing_len - trans_len # Absolute local smoothing zone is the outer 5%
222
+
223
+ # Threshold: 10x noise or 5% of peak, whichever is larger
224
+ dev_threshold = max(10.0 * noise_est, 0.05 * peak)
225
+
226
+ macro_trend = None # Compute lazily to save time if not needed
227
+
228
+ # 1. Left wing check
229
+ left_wing_spline = blaze_smooth[:wing_len]
230
+ left_wing_raw = y_clean[:wing_len]
231
+ left_dev = np.max(np.abs(left_wing_spline - left_wing_raw))
232
+
233
+ # Trigger if deviation is too large OR if the spline dives below 1.0 (causing a flat cutoff)
234
+ if left_dev > dev_threshold or np.min(left_wing_spline) < 1.0:
235
+ if macro_trend is None:
236
+ macro_trend = ndimage.gaussian_filter1d(y_clean, sigma=max(20.0, wing_len / 4.0))
237
+
238
+ # Cosine blend: 1.0 at x=0, smoothly transitions to 0.0 at x=wing_len
239
+ w_l = np.ones(wing_len, dtype=float)
240
+ x_trans = np.arange(trans_len, dtype=float)
241
+ w_l[flat_len:] = 0.5 * (1.0 + np.cos(np.pi * x_trans / trans_len))
242
+ blaze_smooth[:wing_len] = w_l * macro_trend[:wing_len] + (1.0 - w_l) * left_wing_spline
243
+
244
+ # 2. Right wing check
245
+ right_wing_spline = blaze_smooth[-wing_len:]
246
+ right_wing_raw = y_clean[-wing_len:]
247
+ right_dev = np.max(np.abs(right_wing_spline - right_wing_raw))
248
+
249
+ if right_dev > dev_threshold or np.min(right_wing_spline) < 1.0:
250
+ if macro_trend is None:
251
+ macro_trend = ndimage.gaussian_filter1d(y_clean, sigma=max(20.0, wing_len / 4.0))
252
+
253
+ # Cosine blend: 0.0 at x=start_idx, smoothly transitions to 1.0 at the end
254
+ w_r = np.ones(wing_len, dtype=float)
255
+ x_trans = np.arange(trans_len, dtype=float)
256
+ w_r[:trans_len] = 0.5 * (1.0 - np.cos(np.pi * x_trans / trans_len))
257
+
258
+ start_idx = n_pts - wing_len
259
+ blaze_smooth[start_idx:] = w_r * macro_trend[start_idx:] + (1.0 - w_r) * right_wing_spline
260
+
261
+ # Safety: no negative or zero values
262
+ return blaze_smooth
263
+
264
+ # 用切比雪夫多项式平滑光栅响应
265
+ def _smooth_blaze_chebyshev(self, flux_1d: np.ndarray,
266
+ degree: int = 5,
267
+ prefilter_size: int = 51) -> np.ndarray:
268
+ """Smooth blaze using a low-order Chebyshev polynomial.
269
+
270
+ This enforces a rigid global bow-shape curve, which is perfect for very bright
271
+ orders where B-splines might accidentally overfit high-frequency interference
272
+ fringes (ripples).
273
+ """
274
+ from numpy.polynomial.chebyshev import chebfit, chebval
275
+
276
+ x = np.arange(flux_1d.size)
277
+ y = np.asarray(flux_1d, dtype=float)
278
+
279
+ valid = np.isfinite(y) & (y > 0)
280
+ n_valid = int(np.sum(valid))
281
+ if n_valid < max(20, int(0.2 * y.size)):
282
+ med = np.nanmedian(y[valid]) if np.any(valid) else 1.0
283
+ return np.full_like(y, med if np.isfinite(med) and med > 0 else 1.0)
284
+
285
+ actual_prefilter = min(prefilter_size, max(3, n_valid // 5))
286
+ if actual_prefilter % 2 == 0:
287
+ actual_prefilter += 1
288
+
289
+ # Pre-clean: median filter kills local outliers
290
+ y_clean = ndimage.median_filter(
291
+ np.interp(x, x[valid], y[valid]), size=actual_prefilter, mode='nearest')
292
+
293
+ # Determine the valid (signal) region: > 5% of peak
294
+ peak = np.max(y_clean)
295
+ sig_mask = y_clean > (peak * 0.05)
296
+ sig_idx = np.where(sig_mask)[0]
297
+ if sig_idx.size < degree + 2:
298
+ return y_clean.copy()
299
+
300
+ x_fit = x[sig_mask].astype(float)
301
+ y_fit = y_clean[sig_mask]
302
+
303
+ # Map x to canonical [-1, 1] domain for Chebyshev
304
+ x_min, x_max = x_fit[0], x_fit[-1]
305
+ span = max(x_max - x_min, 1e-6)
306
+ x_mapped = 2.0 * (x_fit - x_min) / span - 1.0
307
+
308
+ # Robust iterative fitting with sigma-clipping
309
+ keep = np.ones(len(x_fit), dtype=bool)
310
+ coef = None
311
+ for _ in range(5):
312
+ if np.sum(keep) < degree + 2:
313
+ break
314
+ coef = chebfit(x_mapped[keep], y_fit[keep], degree)
315
+ resid = y_fit - chebval(x_mapped, coef)
316
+ std = np.std(resid[keep])
317
+ if std < 1e-5:
318
+ break
319
+ # Symmetric clip is ideal for symmetric interference ripples
320
+ new_keep = np.abs(resid) < 2.5 * std
321
+ if np.array_equal(new_keep, keep):
322
+ break
323
+ keep = new_keep
324
+
325
+ if coef is None:
326
+ coef = chebfit(x_mapped, y_fit, degree)
327
+
328
+ # Evaluate on all pixels, but clamp domain to [-1, 1] to avoid wild polynomial extrapolation
329
+ x_all_mapped = 2.0 * (x.astype(float) - x_min) / span - 1.0
330
+ x_all_mapped_clamped = np.clip(x_all_mapped, -1.0, 1.0)
331
+ blaze_smooth = chebval(x_all_mapped_clamped, coef)
332
+
333
+ # Safety clamp: no negative or zero values
334
+ blaze_smooth = np.maximum(blaze_smooth, 1.0)
335
+
336
+ return blaze_smooth
337
+
338
+ # 用上包络样条提取光栅响应
339
+ def _smooth_blaze_upper_envelope_bspline(self, flux_1d: np.ndarray,
340
+ knot_spacing: int = 200,
341
+ edge_nknots: int = 6,
342
+ prefilter_size: int = 15) -> np.ndarray:
343
+ """Smooth blaze using an Iterative Asymmetric Upper-Envelope B-spline.
344
+
345
+ This method forces the spline to rest on the 'peaks' of the interference fringes
346
+ (the upper envelope) by assigning high weights to points above the fit and extremely
347
+ low weights to points in the fringe valleys.
348
+ """
349
+ from scipy.interpolate import splrep, splev
350
+
351
+ x = np.arange(flux_1d.size)
352
+ y = np.asarray(flux_1d, dtype=float)
353
+
354
+ valid = np.isfinite(y) & (y > 0)
355
+ n_valid = int(np.sum(valid))
356
+ if n_valid < max(20, int(0.2 * y.size)):
357
+ med = np.nanmedian(y[valid]) if np.any(valid) else 1.0
358
+ return np.full_like(y, med if np.isfinite(med) and med > 0 else 1.0)
359
+
360
+ actual_prefilter = min(prefilter_size, max(3, n_valid // 5))
361
+ if actual_prefilter % 2 == 0:
362
+ actual_prefilter += 1
363
+
364
+ # Pre-clean: median filter kills sharp shot-noise so peaks are reliable
365
+ y_clean = ndimage.median_filter(
366
+ np.interp(x, x[valid], y[valid]), size=actual_prefilter, mode='nearest')
367
+
368
+ # Determine the valid (signal) region: > 5% of peak
369
+ peak = np.max(y_clean)
370
+ sig_mask = y_clean > (peak * 0.05)
371
+ sig_idx = np.where(sig_mask)[0]
372
+ if sig_idx.size < 50:
373
+ return y_clean.copy()
374
+
375
+ x_fit = x[sig_mask].astype(float)
376
+ y_fit = y_clean[sig_mask]
377
+
378
+ # --- Build knot vector ---
379
+ start, end = float(x_fit[0]), float(x_fit[-1])
380
+ span = end - start
381
+ edge_zone = max(50.0, span * 0.10)
382
+
383
+ mid_knots = np.arange(start + edge_zone, end - edge_zone, knot_spacing, dtype=float)
384
+ left_offsets = np.geomspace(10, edge_zone, edge_nknots)
385
+ right_offsets = np.geomspace(10, edge_zone, edge_nknots)
386
+
387
+ all_knots = np.sort(np.unique(np.concatenate([start + left_offsets, mid_knots, end - right_offsets])))
388
+ all_knots = all_knots[(all_knots > x_fit[0]) & (all_knots < x_fit[-1])]
389
+
390
+ # --- Iterative Asymmetric Fitting (Upper Envelope) ---
391
+ weights = np.ones(len(x_fit), dtype=float)
392
+ tck = None
393
+
394
+ for iteration in range(5):
395
+ try:
396
+ if all_knots.size >= 2:
397
+ tck = splrep(x_fit, y_fit, w=weights, t=all_knots, k=3, task=-1)
398
+ else:
399
+ tck = splrep(x_fit, y_fit, w=weights, k=3, s=len(x_fit))
400
+ except Exception:
401
+ tck = splrep(x_fit, y_fit, w=weights, k=3, s=len(x_fit)*2.0)
402
+
403
+ current_fit = splev(x_fit, tck)
404
+ resid = y_fit - current_fit
405
+
406
+ # Asymmetric weighting: Points ABOVE the fit (peaks) get weight 1.0.
407
+ # Points BELOW the fit (fringe valleys) get weight 0.01 to ignore them.
408
+ weights = np.where(resid > 0, 1.0, 0.01)
409
+
410
+ # Force high weights at the very edges to anchor the spline from flying away
411
+ weights[:20] = 1.0
412
+ weights[-20:] = 1.0
413
+
414
+ blaze_smooth = splev(x, tck)
415
+
416
+ return np.maximum(blaze_smooth, 1.0)
417
+
418
+ # 用切比雪夫上包络提取响应
419
+ def _smooth_blaze_upper_envelope_chebyshev(self, flux_1d: np.ndarray,
420
+ degree: int = 4,
421
+ prefilter_size: int = 21) -> np.ndarray:
422
+ """Smooth blaze using an Iterative Asymmetric Upper-Envelope Chebyshev polynomial.
423
+
424
+ Combines the extreme rigidity of a low-order Chebyshev polynomial (to bridge over
425
+ deep local pits and fringes) with the asymmetric weighting of an upper-envelope
426
+ fit (to perfectly rest on the wave peaks without being dragged down by the valleys).
427
+ """
428
+ from numpy.polynomial.chebyshev import chebfit, chebval
429
+
430
+ x = np.arange(flux_1d.size)
431
+ y = np.asarray(flux_1d, dtype=float)
432
+
433
+ valid = np.isfinite(y) & (y > 0)
434
+ n_valid = int(np.sum(valid))
435
+ if n_valid < max(20, int(0.2 * y.size)):
436
+ med = np.nanmedian(y[valid]) if np.any(valid) else 1.0
437
+ return np.full_like(y, med if np.isfinite(med) and med > 0 else 1.0)
438
+
439
+ actual_prefilter = min(prefilter_size, max(3, n_valid // 5))
440
+ if actual_prefilter % 2 == 0:
441
+ actual_prefilter += 1
442
+
443
+ # Pre-clean: median filter kills sharp shot-noise so peaks are reliable
444
+ y_clean = ndimage.median_filter(
445
+ np.interp(x, x[valid], y[valid]), size=actual_prefilter, mode='nearest')
446
+
447
+ # Determine the valid (signal) region: > 5% of peak
448
+ peak = np.max(y_clean)
449
+ sig_mask = y_clean > (peak * 0.05)
450
+ sig_idx = np.where(sig_mask)[0]
451
+ if sig_idx.size < degree + 2:
452
+ return y_clean.copy()
453
+
454
+ x_fit = x[sig_mask].astype(float)
455
+ y_fit = y_clean[sig_mask]
456
+
457
+ x_min, x_max = x_fit[0], x_fit[-1]
458
+ span = max(x_max - x_min, 1e-6)
459
+ x_mapped = 2.0 * (x_fit - x_min) / span - 1.0
460
+
461
+ # --- Iterative Asymmetric Fitting (Upper Envelope) ---
462
+ weights = np.ones(len(x_fit), dtype=float)
463
+ coef = None
464
+
465
+ edge_len = min(20, len(x_fit) // 10)
466
+
467
+ for iteration in range(5):
468
+ coef = chebfit(x_mapped, y_fit, degree, w=weights)
469
+ current_fit = chebval(x_mapped, coef)
470
+ resid = y_fit - current_fit
471
+
472
+ # Asymmetric weighting: Points ABOVE the fit (peaks) get weight 1.0.
473
+ # Points BELOW the fit (fringe valleys) get weight 0.01.
474
+ weights = np.where(resid > 0, 1.0, 0.01)
475
+
476
+ # Force high weights at the very edges to anchor the polynomial
477
+ if edge_len > 0:
478
+ weights[:edge_len] = 1.0
479
+ weights[-edge_len:] = 1.0
480
+
481
+ # Evaluate on all pixels, clamping the domain to [-1, 1] to avoid wild extrapolation
482
+ x_all_mapped = 2.0 * (x.astype(float) - x_min) / span - 1.0
483
+ x_all_mapped_clamped = np.clip(x_all_mapped, -1.0, 1.0)
484
+ blaze_smooth = chebval(x_all_mapped_clamped, coef)
485
+
486
+ return np.maximum(blaze_smooth, 1.0)
487
+
488
+ # 构建级次响应图与像素平场图
489
+ def build_order_response_map(self, apertures: ApertureSet,
490
+ source_image: Optional[np.ndarray] = None,
491
+ blaze_smooth_factor: float = 1.0,
492
+ width_smooth_window: int = 41,
493
+ profile_bin_step: float = 0.01,
494
+ n_profile_segments: int = 100,
495
+ profile_smooth_sigma: float = 6.0,
496
+ pixel_flat_min: float = 0.5,
497
+ pixel_flat_max: float = 1.5,
498
+ fringe_orders: int = 20
499
+ ) -> Tuple[np.ndarray, Dict[int, np.ndarray], Dict[int, np.ndarray], np.ndarray, np.ndarray, Optional[np.ndarray]]:
500
+ """
501
+ Build per-order response model and convert it to a 2D sensitivity map.
502
+
503
+ This follows the echelle flat-field philosophy used in IRAF/PypeIt/REDUCE:
504
+ estimate order-by-order blaze/response first, then construct a 2D correction map.
505
+
506
+ Improvements over a single global profile:
507
+ 1. **Segmented cross-dispersion profiles**: The profile is built in
508
+ ``n_profile_segments`` bins along X, then linearly interpolated at
509
+ each column, so the model tracks PSF shape changes (aberrations,
510
+ defocus, vignetting).
511
+ 2. **Sparse-knots B-spline blaze fitting**: Interior knots are placed
512
+ at wide spacing (``blaze_knot_spacing``) to crush fringe ripples,
513
+ while dense knots near edges track the steep blaze roll-off
514
+ — so the 2D model is fringe-free and the resulting pixel_flat
515
+ absorbs fringe structure for correction.
516
+ """
517
+ if self.flat_data is None and source_image is None:
518
+ raise RuntimeError("No flat data")
519
+
520
+ image = source_image if source_image is not None else self.flat_data
521
+ image = np.asarray(image, dtype=np.float64)
522
+ height, width = image.shape
523
+ x_coords = np.arange(width)
524
+
525
+ # Clamp segments to a sane range
526
+ n_profile_segments = max(1, min(n_profile_segments, 200))
527
+
528
+ response_map = np.zeros_like(image, dtype=np.float32)
529
+ coverage = np.zeros_like(image, dtype=bool)
530
+
531
+ blaze_profiles: Dict[int, np.ndarray] = {}
532
+ cross_profiles: Dict[int, np.ndarray] = {}
533
+ order_diag: Dict[int, Dict[str, np.ndarray]] = {}
534
+
535
+ # --- First Pass: Extract raw blaze for all orders and compute intensity percentiles ---
536
+ extracted_raw_blaze = {}
537
+ extracted_width_raw = {}
538
+ extracted_bounds = {}
539
+
540
+ for aperture_id, aperture in apertures.apertures.items():
541
+ lower = aperture.get_lower(x_coords)
542
+ upper = aperture.get_upper(x_coords)
543
+
544
+ # 1D aperture sum extraction inside traced boundaries.
545
+ raw_blaze = np.full(width, np.nan, dtype=float)
546
+ width_raw = np.full(width, np.nan, dtype=float)
547
+ per_col_bounds = []
548
+
549
+ for x in x_coords:
550
+ y_lo = min(lower[x], upper[x])
551
+ y_hi = max(lower[x], upper[x])
552
+ y1 = max(0, int(np.floor(y_lo)))
553
+ y2 = min(height, int(np.ceil(y_hi + 1)))
554
+ if y2 - y1 < 3:
555
+ per_col_bounds.append(None)
556
+ continue
557
+
558
+ stripe = image[y1:y2, x]
559
+ yy = np.arange(y1, y2)
560
+ if self.flat_mask is not None:
561
+ local_bad = self.flat_mask[y1:y2, x] > 0
562
+ if np.all(local_bad):
563
+ per_col_bounds.append(None)
564
+ continue
565
+ stripe = stripe[~local_bad]
566
+ yy = yy[~local_bad]
567
+
568
+ if stripe.size < 3:
569
+ per_col_bounds.append(None)
570
+ continue
571
+
572
+ raw_blaze[x] = np.sum(stripe)
573
+ width_raw[x] = max(1.0, y_hi - y_lo)
574
+ per_col_bounds.append((yy, stripe))
575
+
576
+ extracted_raw_blaze[aperture_id] = raw_blaze
577
+ extracted_width_raw[aperture_id] = width_raw
578
+ extracted_bounds[aperture_id] = per_col_bounds
579
+
580
+ # Determine cutoff for fringe correction based on user parameter
581
+ oids = sorted(extracted_raw_blaze.keys())
582
+ if oids and fringe_orders > 0:
583
+ n_fringed = min(len(oids), fringe_orders)
584
+ fringed_oids = set(oids[:n_fringed])
585
+ is_fringed = {oid: (oid in fringed_oids) for oid in oids}
586
+ logger.info(f"Applying Upper Envelope Chebyshev Fit to the first {n_fringed} red-end orders.")
587
+ else:
588
+ is_fringed = {}
589
+ n_fringed = 0
590
+
591
+ # --- Second Pass: Smooth blaze and reconstruct 2D model with dynamic parameters ---
592
+ for aperture_id, aperture in apertures.apertures.items():
593
+ center = aperture.get_position(x_coords)
594
+ lower = aperture.get_lower(x_coords)
595
+ upper = aperture.get_upper(x_coords)
596
+
597
+ raw_blaze = extracted_raw_blaze[aperture_id]
598
+ width_raw = extracted_width_raw[aperture_id]
599
+ per_col_bounds = extracted_bounds[aperture_id]
600
+
601
+ # Dynamic rule based on auto-detected fringes
602
+ if is_fringed.get(aperture_id, False):
603
+ # Use Iterative Asymmetric Upper-Envelope Chebyshev to track peaks with rigid bow shape.
604
+ blaze_1d = self._smooth_blaze_upper_envelope_chebyshev(
605
+ raw_blaze,
606
+ degree=6,
607
+ prefilter_size=21
608
+ )
609
+ fit_method_label = 'Upper Env Chebyshev'
610
+ else:
611
+ # Medium & Dark blue orders: use automated B-spline.
612
+ # Create a two-step "transition zone" just past the fringe zone:
613
+ # +1 to +5 orders: multiply smooth factor by 50.0
614
+ # +6 to +15 orders: multiply smooth factor by 15.0
615
+ # This irons out small-scale wiggles gradually before reverting to normal flexibility.
616
+ current_factor = blaze_smooth_factor
617
+ order_idx = oids.index(aperture_id) if aperture_id in oids else -1
618
+
619
+ if n_fringed <= order_idx < n_fringed + 5:
620
+ current_factor *= 50.0
621
+ fit_method_label = 'Rigid Auto B-spline'
622
+ elif n_fringed + 5 <= order_idx < n_fringed + 15:
623
+ current_factor *= 15.0
624
+ fit_method_label = 'Rigid Auto B-spline'
625
+ else:
626
+ fit_method_label = 'Auto B-spline'
627
+
628
+ blaze_1d = self._smooth_blaze_auto_bspline(
629
+ raw_blaze,
630
+ smooth_factor=current_factor,
631
+ prefilter_size=21
632
+ )
633
+
634
+ width_smooth = self._smooth_order_response(
635
+ width_raw,
636
+ method='savgol',
637
+ window=width_smooth_window,
638
+ bspline_smooth=0.0,
639
+ )
640
+ width_smooth = np.clip(width_smooth, 1.0, None)
641
+
642
+ norm = np.nanmedian(blaze_1d[np.isfinite(blaze_1d) & (blaze_1d > 0)])
643
+ if not np.isfinite(norm) or norm <= 0:
644
+ norm = 1.0
645
+ blaze = blaze_1d / norm
646
+ blaze = np.where(np.isfinite(blaze) & (blaze > 1e-6), blaze, 1.0)
647
+ blaze_profiles[aperture_id] = blaze.astype(np.float32)
648
+
649
+ valid_cols = np.where(np.isfinite(raw_blaze))[0]
650
+ if valid_cols.size == 0:
651
+ logger.warning(f"Order {aperture_id}: no valid columns — skipped")
652
+ continue
653
+
654
+ sample_cols = set()
655
+ if valid_cols.size > 0:
656
+ sample_cols = set(np.unique(np.linspace(valid_cols.min(), valid_cols.max(), num=min(n_profile_segments, valid_cols.size), dtype=int)))
657
+
658
+ # ==============================================================
659
+ # Build segmented cross-dispersion profiles along X
660
+ # ==============================================================
661
+ step = max(0.002, float(profile_bin_step))
662
+ n_seg = n_profile_segments
663
+
664
+ # Determine column ranges for each segment
665
+ x_lo_seg = valid_cols.min()
666
+ x_hi_seg = valid_cols.max()
667
+ seg_edges = np.linspace(x_lo_seg, x_hi_seg + 1, n_seg + 1).astype(int)
668
+ seg_centers = 0.5 * (seg_edges[:-1] + seg_edges[1:]) # X center of each segment
669
+
670
+ # Build one profile per segment
671
+ seg_profiles_x = [] # list of (prof_x_i,) arrays — normalized y coordinates
672
+ seg_profiles_y = [] # list of (prof_y_i,) arrays — profile values
673
+ common_yn_grid = None # will be unified after first pass
674
+
675
+ for si in range(n_seg):
676
+ xs0, xs1 = int(seg_edges[si]), int(seg_edges[si + 1])
677
+ yn_seg = []
678
+ fn_seg = []
679
+ for x in range(xs0, min(xs1, width)):
680
+ if per_col_bounds[x] is None:
681
+ continue
682
+ yy, stripe = per_col_bounds[x]
683
+ bval = blaze_1d[x]
684
+ wval = width_smooth[x]
685
+ if (not np.isfinite(bval)) or bval <= 0 or (not np.isfinite(wval)) or wval <= 0:
686
+ continue
687
+ yn = (yy.astype(float) - center[x]) / wval
688
+ fn = stripe / bval
689
+ good = np.isfinite(yn) & np.isfinite(fn)
690
+ if np.any(good):
691
+ yn_seg.append(yn[good])
692
+ fn_seg.append(fn[good])
693
+
694
+ if not yn_seg:
695
+ seg_profiles_x.append(None)
696
+ seg_profiles_y.append(None)
697
+ continue
698
+
699
+ yn_cat = np.concatenate(yn_seg)
700
+ fn_cat = np.concatenate(fn_seg)
701
+
702
+ yn_min = max(-1.2, float(np.nanpercentile(yn_cat, 1)))
703
+ yn_max_v = min(1.2, float(np.nanpercentile(yn_cat, 99)))
704
+ if yn_max_v - yn_min < 0.2:
705
+ yn_min, yn_max_v = -0.8, 0.8
706
+ bins = np.arange(yn_min, yn_max_v + step, step)
707
+ if bins.size < 8:
708
+ bins = np.linspace(-0.8, 0.8, 81)
709
+
710
+ idx = np.digitize(yn_cat, bins) - 1
711
+ px, py = [], []
712
+ for i in range(len(bins) - 1):
713
+ pick = idx == i
714
+ if np.sum(pick) < 6:
715
+ continue
716
+ px.append(0.5 * (bins[i] + bins[i + 1]))
717
+ py.append(np.nanmedian(fn_cat[pick]))
718
+
719
+ if len(px) < 8:
720
+ seg_profiles_x.append(None)
721
+ seg_profiles_y.append(None)
722
+ continue
723
+
724
+ px = np.asarray(px, dtype=float)
725
+ py = np.asarray(py, dtype=float)
726
+ py = np.where(np.isfinite(py), py, 1.0)
727
+ py = np.clip(py, 1e-6, None)
728
+
729
+ # Smooth the binned profile
730
+ py = self._smooth_order_response(
731
+ py,
732
+ method='savgol',
733
+ window=max(11, int(11 * max(1.0, step / 0.01))),
734
+ bspline_smooth=0.0,
735
+ )
736
+
737
+ seg_profiles_x.append(px)
738
+ seg_profiles_y.append(py)
739
+
740
+ # Count valid segments
741
+ valid_seg_idx = [i for i in range(n_seg) if seg_profiles_x[i] is not None]
742
+
743
+ if not valid_seg_idx:
744
+ logger.warning(f"Order {aperture_id}: no valid profile segments — skipped")
745
+ continue
746
+
747
+ # Build a common y_norm grid that covers all segments
748
+ all_yn_min = min(seg_profiles_x[i].min() for i in valid_seg_idx)
749
+ all_yn_max = max(seg_profiles_x[i].max() for i in valid_seg_idx)
750
+ common_yn = np.arange(all_yn_min, all_yn_max + step, step)
751
+
752
+ # Resample each segment profile onto the common grid
753
+ seg_on_grid = np.full((n_seg, common_yn.size), np.nan, dtype=float)
754
+ for i in valid_seg_idx:
755
+ seg_on_grid[i] = np.interp(common_yn, seg_profiles_x[i], seg_profiles_y[i],
756
+ left=1e-6, right=1e-6)
757
+
758
+ # Fill missing segments by nearest-neighbour copy
759
+ for i in range(n_seg):
760
+ if i in valid_seg_idx:
761
+ continue
762
+ # Find nearest valid segment
763
+ dists = [abs(i - vi) for vi in valid_seg_idx]
764
+ nearest = valid_seg_idx[int(np.argmin(dists))]
765
+ seg_on_grid[i] = seg_on_grid[nearest]
766
+
767
+ # ----------------------------------------------------------
768
+ # Smooth profiles along dispersion direction (axis 0 = segment index)
769
+ # so that CSP varies continuously from segment to segment.
770
+ # ----------------------------------------------------------
771
+ if n_seg >= 3 and profile_smooth_sigma > 0:
772
+ for j in range(seg_on_grid.shape[1]):
773
+ col = seg_on_grid[:, j]
774
+ ok = np.isfinite(col)
775
+ if np.sum(ok) < 3:
776
+ continue
777
+ # Interpolate NaN gaps before smoothing
778
+ idx_ok = np.where(ok)[0]
779
+ col_filled = np.interp(np.arange(n_seg), idx_ok, col[idx_ok])
780
+ seg_on_grid[:, j] = gaussian_filter1d(
781
+ col_filled, sigma=profile_smooth_sigma, mode='nearest')
782
+
783
+ # Store the composite profile for diagnostics (average of all segments)
784
+ with np.errstate(all='ignore'):
785
+ mean_prof_y = np.nanmean(seg_on_grid, axis=0)
786
+ mean_prof_y = np.where(np.isfinite(mean_prof_y), mean_prof_y, 1e-6)
787
+ csum = np.nansum(mean_prof_y)
788
+ if np.isfinite(csum) and csum > 0:
789
+ cross_profiles[aperture_id] = (mean_prof_y / csum).astype(np.float32)
790
+
791
+ sampled_realspace = []
792
+
793
+ # ==============================================================
794
+ # Reconstruct 2D model: Model(X,Y) = P(y_norm; X) × Blaze(X)
795
+ # with P interpolated between segment profiles
796
+ # ==============================================================
797
+ for x in x_coords:
798
+ y_lo = min(lower[x], upper[x])
799
+ y_hi = max(lower[x], upper[x])
800
+ y1 = max(0, int(np.floor(y_lo)))
801
+ y2 = min(height, int(np.ceil(y_hi + 1)))
802
+ if y2 - y1 < 3:
803
+ continue
804
+
805
+ bval = blaze_1d[x]
806
+ wval = width_smooth[x]
807
+ if (not np.isfinite(bval)) or bval <= 0 or (not np.isfinite(wval)) or wval <= 0:
808
+ continue
809
+
810
+ yy = np.arange(y1, y2, dtype=float)
811
+ yreq = (yy - center[x]) / wval
812
+
813
+ # Interpolate profile between the two nearest segments
814
+ if n_seg == 1:
815
+ pval = np.interp(yreq, common_yn, seg_on_grid[0], left=1e-6, right=1e-6)
816
+ else:
817
+ # Find the fractional segment index for this column
818
+ frac = np.clip((float(x) - seg_centers[0]) / max(1.0, seg_centers[-1] - seg_centers[0]),
819
+ 0.0, 1.0)
820
+ seg_f = frac * (n_seg - 1)
821
+ si_lo = max(0, min(int(np.floor(seg_f)), n_seg - 2))
822
+ si_hi = si_lo + 1
823
+ w_hi = seg_f - si_lo
824
+ w_lo = 1.0 - w_hi
825
+
826
+ p_lo = np.interp(yreq, common_yn, seg_on_grid[si_lo], left=1e-6, right=1e-6)
827
+ p_hi = np.interp(yreq, common_yn, seg_on_grid[si_hi], left=1e-6, right=1e-6)
828
+ pval = w_lo * p_lo + w_hi * p_hi
829
+
830
+ model_col = pval * bval
831
+
832
+ if x in sample_cols:
833
+ sampled_realspace.append({
834
+ 'x': int(x),
835
+ 'y': yy.astype(np.float32),
836
+ 'ynorm': yreq.astype(np.float32),
837
+ 'profile_val': pval.astype(np.float32),
838
+ 'model_flux': model_col.astype(np.float32),
839
+ })
840
+
841
+ response_map[y1:y2, x] = model_col.astype(np.float32)
842
+ coverage[y1:y2, x] = True
843
+
844
+ order_diag[aperture_id] = {
845
+ 'raw_blaze': raw_blaze.astype(np.float32),
846
+ 'blaze_fit': blaze_1d.astype(np.float32),
847
+ 'width_smooth': width_smooth.astype(np.float32),
848
+ 'profile_x': common_yn.astype(np.float32),
849
+ 'profile_y': mean_prof_y.astype(np.float32),
850
+ 'seg_on_grid': seg_on_grid.astype(np.float32),
851
+ 'seg_centers': seg_centers.astype(np.float32),
852
+ 'sampled_realspace': sampled_realspace,
853
+ 'n_profile_segments': n_seg,
854
+ 'n_valid_segments': len(valid_seg_idx),
855
+ 'blaze_method': fit_method_label,
856
+ }
857
+
858
+ # Smoothed model keeps blaze + cross-order envelope, without pixel noise.
859
+ smoothed_model = response_map.astype(np.float32)
860
+
861
+ # Pixel-to-pixel flat: high-frequency component around unity.
862
+
863
+ pixel_flat = np.ones_like(image, dtype=np.float32)
864
+ pixel_flat[coverage] = image[coverage] / (smoothed_model[coverage] + 1e-10)
865
+ bad_cov = coverage & (~np.isfinite(pixel_flat) | (pixel_flat < pixel_flat_min) | (pixel_flat > pixel_flat_max))
866
+ if np.any(bad_cov):
867
+ pixel_flat[bad_cov] = 1.0
868
+ if np.any(coverage):
869
+ pf_med = np.nanmedian(pixel_flat[coverage][np.isfinite(pixel_flat[coverage])])
870
+ if np.isfinite(pf_med) and pf_med > 0:
871
+ pixel_flat[coverage] = pixel_flat[coverage] / pf_med
872
+ pixel_flat = np.where(np.isfinite(pixel_flat), pixel_flat, 1.0)
873
+ pixel_flat[~coverage] = 1.0
874
+
875
+ # 将级次上下边界 1 pixel 的区域强制设为 1.0,防止边缘极低信噪比带来的除法突变
876
+ from scipy.ndimage import binary_erosion
877
+ eroded_coverage = binary_erosion(coverage, structure=np.ones((3, 1), dtype=bool))
878
+ pixel_flat[coverage & (~eroded_coverage)] = 1.0
879
+
880
+ # 进一步消除级次内部的异常突变:将信噪比低不可靠的极端像元限制得更严格些
881
+ edge_mask = coverage & ((pixel_flat < 0.8) | (pixel_flat > 1.2))
882
+ if np.any(edge_mask):
883
+ pixel_flat[edge_mask] = 1.0
884
+ pixel_flat = np.clip(pixel_flat, 0.1, 10.0).astype(np.float32)
885
+
886
+ # Illumination flat is intentionally not used/output in pixel-to-pixel-only workflow.
887
+ illumination_flat = None
888
+
889
+ # Final 2D correction map equals pixel flat in this workflow.
890
+ flat_corr_2d = np.clip(pixel_flat, 0.1, 10.0).astype(np.float32)
891
+
892
+ self.response_map = response_map
893
+ self.smoothed_model = smoothed_model
894
+ self.pixel_flat = pixel_flat
895
+ self.illumination_flat = illumination_flat
896
+ self.flat_corr_2d = flat_corr_2d
897
+ self.flat_sens = None
898
+ self.order_diagnostics = order_diag
899
+ logger.info("Built Step4 flat model via 1D blaze + normalized-profile reconstruction")
900
+ return None, blaze_profiles, cross_profiles, smoothed_model, pixel_flat, illumination_flat
901
+
902
+ # 保存二维平场模型与诊断图
903
+ def save_step4_diagnostics(self, output_dir: Path,
904
+ clean_flat: Optional[np.ndarray] = None,
905
+ clean_flat_corrected: Optional[np.ndarray] = None,
906
+ fig_format: str = 'png',
907
+ save_plots: bool = True):
908
+ """Save step4 diagnostic artifacts: blaze/profile/inverse-map/model/pixel-flat and corrected flat."""
909
+ out_dir = Path(output_dir)
910
+ out_dir.mkdir(parents=True, exist_ok=True)
911
+
912
+ # fig_format and save_plots are now passed via method parameters
913
+ # (kept as instance defaults for backward compat with save_step4_diagnostics signature)
914
+
915
+ # ---- FITS outputs ----
916
+ if self.response_map is not None:
917
+ write_fits_image(str(out_dir / 'flat_smoothed_model.fits'), self.response_map.astype(np.float32), dtype='float32')
918
+ if self.pixel_flat is not None:
919
+ write_fits_image(str(out_dir / 'flat_pixel_2d.fits'), self.pixel_flat.astype(np.float32), dtype='float32')
920
+ if clean_flat_corrected is not None:
921
+ write_fits_image(str(out_dir / 'MasterFlat.fits'), clean_flat_corrected.astype(np.float32), dtype='float32')
922
+
923
+ # ---- 2D overview PNG plots ----
924
+ if save_plots:
925
+ if self.response_map is not None:
926
+ plot_2d_image_to_file(self.response_map, str(out_dir / f'flat_smoothed_model.{fig_format}'), 'Reconstructed 2D Flat Model')
927
+ if self.pixel_flat is not None:
928
+ plot_2d_image_to_file(self.pixel_flat, str(out_dir / f'flat_pixel_2d.{fig_format}'),
929
+ 'Pixel-to-Pixel Flat', vmin=0.85, vmax=1.15)
930
+ if clean_flat_corrected is not None:
931
+ plot_2d_image_to_file(clean_flat_corrected, str(out_dir / f'MasterFlat.{fig_format}'), 'Master Flat After 2D Flat Correction')
932
+
933
+ if not self.order_diagnostics:
934
+ return
935
+
936
+ import matplotlib.pyplot as plt
937
+ from matplotlib.cm import ScalarMappable
938
+ from matplotlib.colors import Normalize
939
+
940
+ blaze_dir = out_dir / 'blaze_per_aperture'
941
+ profile_dir = out_dir / 'profile_per_aperture'
942
+ realspace_dir = out_dir / 'realspace_profile_per_aperture'
943
+ blaze_dir.mkdir(parents=True, exist_ok=True)
944
+ profile_dir.mkdir(parents=True, exist_ok=True)
945
+ realspace_dir.mkdir(parents=True, exist_ok=True)
946
+
947
+ # Collect data for combined overview plots.
948
+ all_order_ids = sorted(self.order_diagnostics.keys())
949
+
950
+ # ---- Per-order diagnostic loop ----
951
+ for aperture_id, diag in self.order_diagnostics.items():
952
+ raw_blaze = diag.get('raw_blaze')
953
+ blaze_fit = diag.get('blaze_fit')
954
+ blaze_method = diag.get('blaze_method', 'B-spline Fit')
955
+ profile_x = diag.get('profile_x')
956
+ profile_y = diag.get('profile_y')
957
+ seg_on_grid = diag.get('seg_on_grid')
958
+ seg_centers = diag.get('seg_centers')
959
+ sampled_realspace = diag.get('sampled_realspace', [])
960
+ n_seg = diag.get('n_profile_segments', 1)
961
+ n_valid_seg = diag.get('n_valid_segments', 0)
962
+
963
+ # Save numerical data
964
+ np.savez(
965
+ blaze_dir / f'aperture_{aperture_id:03d}_blaze.npz',
966
+ raw_blaze=raw_blaze,
967
+ blaze_fit=blaze_fit,
968
+ width_smooth=diag.get('width_smooth'),
969
+ )
970
+ np.savez(
971
+ profile_dir / f'aperture_{aperture_id:03d}_profile.npz',
972
+ y_norm=profile_x,
973
+ profile_mean=profile_y,
974
+ seg_on_grid=seg_on_grid,
975
+ seg_centers=seg_centers,
976
+ )
977
+
978
+ if not save_plots:
979
+ continue
980
+
981
+ # --- Per-order blaze plot ---
982
+ if raw_blaze is not None and blaze_fit is not None:
983
+ plt.figure(figsize=(10, 4))
984
+ x = np.arange(raw_blaze.size)
985
+ plt.plot(x, raw_blaze, color='0.6', linewidth=0.8, label='Raw Blaze 1D')
986
+ plt.plot(x, blaze_fit, color='tab:red', linewidth=1.4, label=blaze_method)
987
+ plt.xlabel('X (column)')
988
+ plt.ylabel('Flux')
989
+ plt.title(f'Aperture {aperture_id} — Blaze (segments={n_seg}, valid={n_valid_seg})')
990
+ plt.legend()
991
+ plt.grid(alpha=0.2)
992
+ plt.tight_layout()
993
+ plt.savefig(blaze_dir / f'aperture_{aperture_id:03d}_blaze.{fig_format}', dpi=150)
994
+ plt.close()
995
+
996
+ # --- Per-order profile plot (2D heatmap + mean) ---
997
+ if profile_x is not None and profile_y is not None:
998
+ fig = plt.figure(figsize=(10, 4.5))
999
+
1000
+ if seg_on_grid is not None and seg_centers is not None and n_seg > 1:
1001
+ ax1 = plt.subplot(121)
1002
+ # seg_on_grid is shape (n_seg, n_y). We transpose to (n_y, n_seg)
1003
+ img_data = seg_on_grid.T
1004
+ # Origin='lower' so y goes up.
1005
+ extent = [seg_centers[0], seg_centers[-1], profile_x[0], profile_x[-1]]
1006
+ im = ax1.imshow(img_data, aspect='auto', origin='lower', extent=extent, cmap='viridis')
1007
+ ax1.set_xlabel('Dispersion X (pixel)')
1008
+ ax1.set_ylabel('Spatial y_norm')
1009
+ ax1.set_title('Profile Variation along Dispersion')
1010
+ fig.colorbar(im, ax=ax1, label='Normalized Flux')
1011
+
1012
+ ax2 = plt.subplot(122)
1013
+
1014
+ # Draw segment profiles as colored lines
1015
+ cmap = plt.cm.plasma
1016
+ norm = Normalize(vmin=seg_centers[0], vmax=seg_centers[-1])
1017
+ for i in range(n_seg):
1018
+ color = cmap(norm(seg_centers[i]))
1019
+ ax2.plot(profile_x, seg_on_grid[i], color=color, linewidth=0.8, alpha=0.6)
1020
+
1021
+ # Add colorbar for lines
1022
+ sm = ScalarMappable(cmap=cmap, norm=norm)
1023
+ sm.set_array([])
1024
+ fig.colorbar(sm, ax=ax2, label='Dispersion X (pixel)')
1025
+ else:
1026
+ ax2 = plt.subplot(111)
1027
+
1028
+ ax2.plot(profile_x, profile_y, color='black', linewidth=1.8, label='Mean profile', zorder=10)
1029
+ ax2.set_xlabel('Spatial y_norm')
1030
+ ax2.set_ylabel('Normalized Flux')
1031
+ ax2.set_title(f'Aperture {aperture_id} — Cross-Dispersion Profile')
1032
+ ax2.grid(alpha=0.2)
1033
+ ax2.legend()
1034
+
1035
+ plt.suptitle(f'Aperture {aperture_id} — Cross-Dispersion Profile ({n_seg} segments)')
1036
+ plt.tight_layout()
1037
+ plt.savefig(profile_dir / f'aperture_{aperture_id:03d}_profile.{fig_format}', dpi=150)
1038
+ plt.close()
1039
+
1040
+ # --- Per-order real-space profile plot ---
1041
+ if sampled_realspace:
1042
+ fig = plt.figure(figsize=(10, 4.5))
1043
+ sampled_realspace.sort(key=lambda d: d['x'])
1044
+ x_vals = [d['x'] for d in sampled_realspace]
1045
+
1046
+ cmap = plt.cm.plasma
1047
+ norm = Normalize(vmin=min(x_vals), vmax=max(x_vals))
1048
+
1049
+ if len(sampled_realspace) > 1:
1050
+ ax1 = plt.subplot(121)
1051
+ xs_arr, ys_arr, fs_arr = [], [], []
1052
+ for prof in sampled_realspace:
1053
+ xs_arr.extend([prof['x']] * len(prof['y']))
1054
+ ys_arr.extend(prof['y'])
1055
+ fs_arr.extend(prof['model_flux'])
1056
+
1057
+ sc = ax1.scatter(xs_arr, ys_arr, c=fs_arr, cmap='viridis', s=5, marker='s', edgecolors='none')
1058
+ ax1.set_xlabel('Dispersion X (pixel)')
1059
+ ax1.set_ylabel('Physical Y (pixel)')
1060
+ ax1.set_title('Real-Space Model Heatmap')
1061
+ fig.colorbar(sc, ax=ax1, label='Model Flux')
1062
+
1063
+ ax2 = plt.subplot(122)
1064
+ else:
1065
+ ax2 = plt.subplot(111)
1066
+
1067
+ for prof in sampled_realspace:
1068
+ color = cmap(norm(prof['x']))
1069
+ ax2.plot(prof['y'], prof['model_flux'], color=color, linewidth=0.8, alpha=0.6)
1070
+
1071
+ if len(sampled_realspace) > 1:
1072
+ sm = ScalarMappable(cmap=cmap, norm=norm)
1073
+ sm.set_array([])
1074
+ fig.colorbar(sm, ax=ax2, label='Dispersion X (pixel)')
1075
+
1076
+ ax2.set_xlabel('Physical Y (pixel)')
1077
+ ax2.set_ylabel('Model Flux')
1078
+ ax2.set_title(f'Aperture {aperture_id} — Real-Space Profiles')
1079
+ ax2.grid(alpha=0.2)
1080
+
1081
+ plt.suptitle(f'Aperture {aperture_id} — Real-Space Profiles')
1082
+ plt.tight_layout()
1083
+ plt.savefig(realspace_dir / f'aperture_{aperture_id:03d}_realspace_profiles.{fig_format}', dpi=150)
1084
+ plt.close()
1085
+
1086
+ # ================================================================
1087
+ # Combined overview: all orders' blaze in one figure
1088
+ # ================================================================
1089
+ if save_plots and all_order_ids:
1090
+ n_orders = len(all_order_ids)
1091
+ cmap = plt.cm.turbo
1092
+ # Reverse: low order IDs (bottom, long wavelength) → red,
1093
+ # high order IDs (top, short wavelength) → blue.
1094
+ norm_c = Normalize(vmin=0, vmax=max(1, n_orders - 1))
1095
+
1096
+ # 按级次索引返回绘图颜色
1097
+ def _order_color(i):
1098
+ return cmap(norm_c(n_orders - 1 - i))
1099
+
1100
+ # --- All-orders blaze overview ---
1101
+ fig, ax = plt.subplots(figsize=(14, 6), constrained_layout=True)
1102
+ for i, oid in enumerate(all_order_ids):
1103
+ diag = self.order_diagnostics[oid]
1104
+ bfit = diag.get('blaze_fit')
1105
+ if bfit is None:
1106
+ continue
1107
+ x = np.arange(bfit.size)
1108
+ clr = _order_color(i)
1109
+ ax.plot(x, bfit, color=clr, linewidth=0.6, alpha=0.8)
1110
+ ax.set_xlabel('X (column)')
1111
+ ax.set_ylabel('Heavy-Smooth Blaze')
1112
+ ax.set_title(f'All Apertures Blaze Overview ({n_orders} apertures)')
1113
+ ax.grid(alpha=0.15)
1114
+ cmap_r = plt.cm.turbo_r
1115
+ sm = ScalarMappable(cmap=cmap_r, norm=norm_c)
1116
+ sm.set_array([])
1117
+ cbar = fig.colorbar(sm, ax=ax, shrink=0.8, pad=0.02)
1118
+ order_ids_arr = np.array(all_order_ids)
1119
+ cbar.set_ticks(np.linspace(0, n_orders - 1, min(10, n_orders)))
1120
+ cbar.set_ticklabels([str(order_ids_arr[int(t)]) for t in np.linspace(0, n_orders - 1, min(10, n_orders))])
1121
+ cbar.set_label('Aperture ID')
1122
+ fig.savefig(out_dir / f'all_apertures_blaze_overview.{fig_format}', dpi=150)
1123
+ plt.close(fig)
1124
+
1125
+ # --- All-orders profile overview ---
1126
+ fig, ax = plt.subplots(figsize=(10, 7))
1127
+ for i, oid in enumerate(all_order_ids):
1128
+ diag = self.order_diagnostics[oid]
1129
+ px = diag.get('profile_x')
1130
+ py = diag.get('profile_y')
1131
+ if px is None or py is None:
1132
+ continue
1133
+ clr = _order_color(i)
1134
+ ax.plot(px, py, color=clr, linewidth=0.7, alpha=0.8)
1135
+ ax.set_xlabel('y_norm')
1136
+ ax.set_ylabel('Normalized Flux')
1137
+ ax.set_title(f'All Apertures Cross-Dispersion Profile Overview ({n_orders} apertures)')
1138
+ ax.grid(alpha=0.15)
1139
+ sm = ScalarMappable(cmap=cmap_r, norm=norm_c)
1140
+ sm.set_array([])
1141
+ cbar = fig.colorbar(sm, ax=ax, shrink=0.7, pad=0.02)
1142
+ cbar.set_ticks(np.linspace(0, n_orders - 1, min(10, n_orders)))
1143
+ cbar.set_ticklabels([str(order_ids_arr[int(t)]) for t in np.linspace(0, n_orders - 1, min(10, n_orders))])
1144
+ cbar.set_label('Aperture ID')
1145
+ fig.tight_layout()
1146
+ fig.savefig(out_dir / f'all_apertures_profile_overview.{fig_format}', dpi=150)
1147
+ plt.close(fig)
1148
+
1149
+ logger.info(f"Step 4 diagnostics saved to {out_dir} "
1150
+ f"({n_orders} apertures, format={fig_format})")
1151
+
1152
+
1153
+
1154
+ # 构建平场模型并校正科学图像
1155
+ def process_flat_correction_stage(science_image: np.ndarray,
1156
+ flat_field: FlatField,
1157
+ output_dir_base: str,
1158
+ apertures: Optional[ApertureSet] = None,
1159
+ science_name: str = 'science',
1160
+ # Blaze / profile parameters for on-demand model build
1161
+ blaze_smooth_factor: float = 1.0,
1162
+ width_smooth_window: int = 41,
1163
+ profile_bin_step: float = 0.01,
1164
+ n_profile_segments: int = 100,
1165
+ profile_smooth_sigma: float = 6.0,
1166
+ pixel_flat_min: float = 0.5,
1167
+ pixel_flat_max: float = 1.5,
1168
+ fringe_orders: int = 20,
1169
+ save_plots: bool = True,
1170
+ fig_format: str = 'png') -> np.ndarray:
1171
+ """
1172
+ Step 4: apply the 2D pixel-flat correction map to a science image.
1173
+ """
1174
+
1175
+ processor = None
1176
+ if apertures is None:
1177
+ raise RuntimeError("Apertures are required to build the flat model, but none were provided.")
1178
+ logger.info(" Building 2D flat model (blaze × profile → pixel flat)...")
1179
+ processor = FlatCorrectionModelBuilder()
1180
+ processor.flat_data = flat_field.flat_data
1181
+ processor.flat_mask = flat_field.flat_mask
1182
+
1183
+ clean_flat = flat_field.flat_data
1184
+ if flat_field.scattered_light is not None:
1185
+ clean_flat = np.clip(
1186
+ flat_field.flat_data.astype(np.float32) - flat_field.scattered_light.astype(np.float32),
1187
+ 1e-6,
1188
+ None,
1189
+ )
1190
+
1191
+ flat_sens, blaze_profiles, cross_profiles, smoothed_model, pixel_flat, illum = (
1192
+ processor.build_order_response_map(
1193
+ apertures, source_image=clean_flat,
1194
+ blaze_smooth_factor=blaze_smooth_factor,
1195
+ width_smooth_window=width_smooth_window,
1196
+ profile_bin_step=profile_bin_step,
1197
+ n_profile_segments=n_profile_segments,
1198
+ profile_smooth_sigma=profile_smooth_sigma,
1199
+ pixel_flat_min=pixel_flat_min,
1200
+ pixel_flat_max=pixel_flat_max,
1201
+ fringe_orders=fringe_orders)
1202
+ )
1203
+ flat_field.flat_sens = flat_sens
1204
+ flat_field.blaze_profiles = blaze_profiles
1205
+ flat_field.cross_profiles = cross_profiles
1206
+ flat_field.smoothed_model = smoothed_model
1207
+ flat_field.pixel_flat = pixel_flat
1208
+ flat_field.illumination_flat = illum
1209
+
1210
+ flat_corr = flat_field.pixel_flat
1211
+ if flat_corr is None:
1212
+ flat_corr = flat_field.flat_corr_2d
1213
+ if flat_corr is None:
1214
+ raise RuntimeError("No flat correction map available. Run Step 2 first.")
1215
+
1216
+ if flat_corr.shape != science_image.shape:
1217
+ raise RuntimeError(
1218
+ f"Science image shape {science_image.shape} does not match "
1219
+ f"flat correction shape {flat_corr.shape}"
1220
+ )
1221
+
1222
+ safe_flat = flat_corr.astype(np.float32)
1223
+ bad = (~np.isfinite(safe_flat)) | (safe_flat <= 0.05)
1224
+ if np.any(bad):
1225
+ safe_flat = safe_flat.copy()
1226
+ safe_flat[bad] = 1.0
1227
+
1228
+ corrected = science_image.astype(np.float32) / safe_flat
1229
+
1230
+ out_dir = Path(output_dir_base) / 'step4_flat_corrected'
1231
+ out_dir.mkdir(parents=True, exist_ok=True)
1232
+
1233
+ # When building the model, save all flat-model artefacts.
1234
+ # The processor object is only available during the model build step.
1235
+ save_flat_correction_products(flat_field, apertures, out_dir, processor=processor,
1236
+ fig_format=fig_format, save_plots=save_plots)
1237
+
1238
+ # The corrected master flat is already saved as part of the diagnostics.
1239
+ # Only save other science/calib images here to avoid redundancy.
1240
+ if science_name != "MasterFlat":
1241
+ write_fits_image(
1242
+ str(out_dir / f'{science_name}.fits'),
1243
+ corrected,
1244
+ dtype='float32',
1245
+ )
1246
+
1247
+ if save_plots:
1248
+ plot_2d_image_to_file(
1249
+ corrected,
1250
+ str(out_dir / f'{science_name}_flat2d_corrected.{fig_format}'),
1251
+ f'Image After 2D Flat Correction: {science_name}',
1252
+ )
1253
+
1254
+ logger.info(f"✓ 2D flat correction complete: {science_name}")
1255
+ return corrected