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,762 @@
1
+ """
2
+ Long-slit spectrograph reduction pipeline (e.g. Muztagh-Ata 1.93m slit spectrograph).
3
+
4
+ Unlike the echelle pipeline there are no orders, no inter-order background and no
5
+ blaze function: one wide slit band is recorded on the CCD. Steps:
6
+
7
+ 1. Overscan removal, master bias subtraction
8
+ 2. Pixel-response flat (flat / smoothed flat inside the slit band)
9
+ 3. Cosmic-ray removal, flat division
10
+ 4. Slit trace on the master flat (+ constant star offset) and sky subtraction
11
+ 5. Sum and optimal (Horne) extraction along the trace
12
+ 6. Wavelength calibration of the arc extracted along the same trace
13
+ """
14
+ # 实现狭缝模式下的平场、提取与波长定标流程。
15
+
16
+ import csv
17
+ import os
18
+ import logging
19
+ from pathlib import Path
20
+ from typing import Dict, List, Optional, Tuple
21
+
22
+ import numpy as np
23
+ from astropy.io import fits
24
+ from scipy import ndimage
25
+
26
+ from specproc.core.data_structures import Spectrum, SpectraSet
27
+ from specproc.core.line_identification import find_arc_peaks, refine_with_catalog
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ # 查找可用的配置文件路径。
33
+ def _resolve_path(path_str: str) -> Optional[Path]:
34
+ """Resolve a path (absolute, cwd-relative or package-relative)."""
35
+ if not path_str:
36
+ return None
37
+ p = Path(path_str).expanduser()
38
+ if p.is_absolute():
39
+ return p if p.exists() else None
40
+ if (Path.cwd() / p).exists():
41
+ return Path.cwd() / p
42
+ pkg = Path(__file__).resolve().parent.parent / p
43
+ return pkg if pkg.exists() else None
44
+
45
+
46
+ # 管理单孔径狭缝光谱归约流程。
47
+ class SlitPipeline:
48
+ """Reduction pipeline for single-aperture long-slit spectra."""
49
+
50
+ # 读取狭缝归约参数并初始化状态。
51
+ def __init__(self, config):
52
+ self.config = config
53
+ c = config
54
+ self.out = Path(c.get_output_path())
55
+ self.save_plots = c.get_bool('reduce', 'save_plots', True)
56
+ self.fig_format = c.get('reduce', 'fig_format', 'png')
57
+ self.gain = c.get_float('telescope.detector', 'gain', 1.0)
58
+ self.readnoise = c.get_float('telescope.detector', 'readnoise', 5.0)
59
+
60
+ self.overscan_start = c.get_int('data', 'overscan_start_column', -1)
61
+ self.dispersion_axis = c.get('slit', 'dispersion_axis', 'y').strip().lower()
62
+ self.band_x = (c.get_int('slit', 'band_start', -1), c.get_int('slit', 'band_end', -1))
63
+ self.band_frac = c.get_float('slit', 'band_threshold', 0.05)
64
+ self.flat_smooth = c.get_int('slit', 'flat_smooth_pixels', 21)
65
+ self.flat_edge_margin = c.get_int('slit', 'flat_edge_margin', 40)
66
+ self.trace_order = c.get_int('slit', 'trace_order', 2)
67
+ self.trace_nbins = c.get_int('slit', 'trace_bins', 40)
68
+ self.aperture = c.get_int('slit', 'extract_half_width', 12)
69
+ self.sky_gap = c.get_int('slit', 'sky_inner_offset', 20)
70
+ self.aperture_mode = c.get('slit', 'aperture_mode', 'fixed').strip().lower()
71
+ self.aperture_fwhm_factor = c.get_float('slit', 'aperture_fwhm_factor', 1.7)
72
+ self.sky_fwhm_factor = c.get_float('slit', 'sky_fwhm_factor', 3.0)
73
+ self.sky_order = c.get_int('slit', 'sky_order', 1)
74
+ self.wave_order = c.get_int('slit', 'wave_poly_order', 3)
75
+ self.wave_hint = (c.get_float('slit', 'wave_start', 0.0), c.get_float('slit', 'wave_end', 0.0))
76
+ self.match_tol = c.get_float('slit', 'match_tolerance', 3.0)
77
+ self.peak_snr = c.get_float('slit', 'peak_snr', 8.0)
78
+ self.snr_half_window = c.get_int('slit', 'snr_half_window', 50)
79
+ self.snr_flux_frac = c.get_float('slit', 'snr_effective_fraction', 0.1)
80
+ self.cosmic = c.get_bool('reduce', 'cosmic_enabled', True)
81
+
82
+ # ------------------------------------------------------------------ I/O
83
+ # 读取图像并处理方向与过扫描区域。
84
+ def _read(self, path: str) -> Tuple[np.ndarray, fits.Header]:
85
+ with fits.open(path) as hdul:
86
+ data = np.asarray(hdul[0].data, dtype=np.float64)
87
+ header = hdul[0].header.copy()
88
+ if self.overscan_start > 0 and data.shape[1] >= self.overscan_start:
89
+ # Skip the first overscan columns (they contain image bleed)
90
+ ov = data[:, self.overscan_start + 3:]
91
+ level = np.median(ov, axis=1) if ov.size else 0.0
92
+ data = data[:, :self.overscan_start - 1] - np.asarray(level)[:, None]
93
+ if self.dispersion_axis == 'x':
94
+ data = data.T
95
+ return data, header
96
+
97
+ # 保存浮点图像及其头信息。
98
+ def _write(self, path: Path, data: np.ndarray, header: Optional[fits.Header] = None):
99
+ path.parent.mkdir(parents=True, exist_ok=True)
100
+ fits.PrimaryHDU(np.asarray(data, dtype=np.float32), header).writeto(path, overwrite=True)
101
+
102
+ # 绘制图像并叠加轨迹标记。
103
+ def _plot_image(self, path: Path, data: np.ndarray, title: str, overlay=None):
104
+ if not self.save_plots:
105
+ return
106
+ import matplotlib
107
+ matplotlib.use('Agg')
108
+ import matplotlib.pyplot as plt
109
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
110
+ lo, hi = np.nanpercentile(data, [2, 99.5])
111
+ fig, ax = plt.subplots(figsize=(6, 7))
112
+ ax.imshow(data, origin='lower', aspect='auto', cmap='gray', vmin=lo, vmax=hi)
113
+ if overlay:
114
+ for xs, ys, style in overlay:
115
+ ax.plot(xs, ys, style, lw=0.8)
116
+ ax.set_xlabel('spatial pixel')
117
+ ax.set_ylabel('dispersion pixel')
118
+ ax.set_title(title)
119
+ fig.savefig(Path(str(path) + '.' + self.fig_format), dpi=110, bbox_inches='tight')
120
+ plt.close(fig)
121
+
122
+ # --------------------------------------------------------------- stage 1
123
+ # 合并偏置帧并保存主偏置图。
124
+ def _master_bias(self, bias_files: List[str]) -> np.ndarray:
125
+ stack = np.array([self._read(f)[0] for f in bias_files])
126
+ master = np.median(stack, axis=0)
127
+ self._write(self.out / 'step1_basic' / 'bias_subtracted' / 'master_bias.fits', master)
128
+ logger.info(f"Master bias from {len(bias_files)} frames: median={np.median(master):.2f}")
129
+ return master
130
+
131
+ # 扣除主偏置并保存校正图像。
132
+ def _debias(self, path: str, bias: np.ndarray):
133
+ data, hdr = self._read(path)
134
+ data = data - bias
135
+ self._write(self.out / 'step1_basic' / 'bias_subtracted' / f'{Path(path).stem}.fits', data, hdr)
136
+ return data, hdr
137
+
138
+ # --------------------------------------------------------------- stage 2
139
+ # 确定狭缝光带的空间范围。
140
+ def _detect_band(self, flat: np.ndarray) -> Tuple[int, int]:
141
+ if self.band_x[0] > 0 and self.band_x[1] > 0:
142
+ return self.band_x[0] - 1, self.band_x[1]
143
+ prof = ndimage.uniform_filter1d(np.median(flat, axis=0), 15)
144
+ peak = prof.max()
145
+ base = np.median(prof[:50]) if prof.size > 100 else 0.0
146
+ thr = base + self.band_frac * (peak - base)
147
+ idx = np.where(prof > thr)[0]
148
+ return int(idx.min()), int(idx.max()) + 1
149
+
150
+ # 构建像素平场并追踪狭缝区域。
151
+ def _make_flat(self, flat_files: List[str], bias: np.ndarray):
152
+ stack = np.array([self._debias(f, bias)[0] for f in flat_files])
153
+ master = np.median(stack, axis=0)
154
+ x0, x1 = self._detect_band(master)
155
+ logger.info(f"Slit band detected at spatial columns {x0 + 1}-{x1} (1-based)")
156
+
157
+ smooth = ndimage.median_filter(master, size=(self.flat_smooth, self.flat_smooth))
158
+ smooth = ndimage.gaussian_filter(smooth, self.flat_smooth / 3.0)
159
+ pix = np.ones_like(master)
160
+ valid = smooth > 0.05 * np.nanmax(smooth[:, x0:x1])
161
+ region = np.zeros_like(valid)
162
+ m = min(self.flat_edge_margin, (x1 - x0) // 4)
163
+ region[:, x0 + m:x1 - m] = True
164
+ use = valid & region
165
+ pix[use] = master[use] / smooth[use]
166
+ pix[(pix < 0.5) | (pix > 1.5) | ~np.isfinite(pix)] = 1.0
167
+
168
+ d = self.out / 'step4_flat_corrected'
169
+ self._write(d / 'master_flat.fits', master)
170
+ self._write(d / 'pixel_flat.fits', pix)
171
+ self._plot_image(d / 'master_flat', master, 'Master flat',
172
+ [([x0, x0], [0, master.shape[0]], 'r-'), ([x1, x1], [0, master.shape[0]], 'r-')])
173
+ d2 = self.out / 'step2_trace'
174
+ centre, left, right, (ty, tx) = self._trace_flat(master, (x0, x1))
175
+ yy = np.arange(master.shape[0])
176
+ self._plot_image(d2 / 'master_flat_trace', master, 'Master flat trace',
177
+ [(centre, yy, 'r-'), (left, yy, 'y-'), (right, yy, 'y-'), (tx, ty, 'c.')])
178
+ np.savetxt(d2 / 'master_flat_trace.txt', np.column_stack([yy + 1, centre + 1, left + 1, right + 1]),
179
+ header='dispersion_pixel centre left_edge right_edge (spatial columns, 1-based)',
180
+ fmt='%.3f')
181
+ self._flat_trace = (centre, left, right)
182
+ self._master_flat = master
183
+ return pix, (x0, x1)
184
+
185
+ # --------------------------------------------------------------- stage 3
186
+ # 探测并校正图像中的宇宙射线。
187
+ def _cosmic(self, data: np.ndarray) -> np.ndarray:
188
+ if self.cosmic:
189
+ try:
190
+ import astroscrappy
191
+ mask, clean = astroscrappy.detect_cosmics(
192
+ data + 0.0, gain=self.gain, readnoise=self.readnoise,
193
+ sigclip=self.config.get_float('reduce', 'cosmic_sigclip', 5.0),
194
+ objlim=self.config.get_float('reduce', 'cosmic_objlim', 5.0),
195
+ satlevel=1e9, cleantype='meanmask')
196
+ logger.info(f"Cosmic rays flagged: {int(mask.sum())} px")
197
+ data = clean
198
+ except Exception as exc:
199
+ logger.warning(f"Cosmic ray removal failed: {exc}")
200
+ return data
201
+
202
+ # --------------------------------------------------------------- stage 4
203
+ # 追踪平场狭缝中心线及边界。
204
+ def _trace_flat(self, master: np.ndarray, band: Tuple[int, int]):
205
+ """Trace the slit band on the master flat (as the echelle orders are).
206
+
207
+ In each row bin the left/right band edges are located at 50% of the
208
+ local plateau level; the centre line is their midpoint. Centre and
209
+ edges are fitted with polynomials of order `trace_order`.
210
+ """
211
+ x0, x1 = band
212
+ ny = master.shape[0]
213
+ pad = 40
214
+ a0, a1 = max(x0 - pad, 0), min(x1 + pad, master.shape[1])
215
+ edges = np.linspace(0, ny, self.trace_nbins + 1).astype(int)
216
+ ys, cl, cr, lev = [], [], [], []
217
+ for a, b in zip(edges[:-1], edges[1:]):
218
+ prof = ndimage.gaussian_filter1d(np.median(master[a:b, a0:a1], axis=0), 3)
219
+ plateau = np.percentile(prof, 90)
220
+ base = np.median(np.concatenate([prof[:20], prof[-20:]]))
221
+ if plateau - base <= 0:
222
+ continue
223
+ half = base + 0.5 * (plateau - base)
224
+ idx = np.where(prof > half)[0]
225
+ if idx.size < 50:
226
+ continue
227
+ i0, i1 = idx.min(), idx.max()
228
+ # linear interpolation at the half-level crossings
229
+ xl = i0 - 1 + (half - prof[i0 - 1]) / (prof[i0] - prof[i0 - 1]) if i0 > 0 else i0
230
+ xr = i1 + (prof[i1] - half) / (prof[i1] - prof[i1 + 1]) if i1 < len(prof) - 1 else i1
231
+ ys.append(0.5 * (a + b))
232
+ cl.append(a0 + xl)
233
+ cr.append(a0 + xr)
234
+ lev.append(plateau - base)
235
+ ys, cl, cr, lev = map(np.asarray, (ys, cl, cr, lev))
236
+ good = lev > 0.1 * np.max(lev)
237
+ if good.sum() < self.trace_order + 2:
238
+ raise RuntimeError("Flat trace failed: too few valid bins")
239
+ mid = 0.5 * (cl + cr)
240
+ for _ in range(4):
241
+ coef = np.polyfit(ys[good], mid[good], self.trace_order)
242
+ res = mid - np.polyval(coef, ys)
243
+ sg = 1.4826 * np.median(np.abs(res[good])) + 0.3
244
+ good = good & (np.abs(res) < 3 * sg)
245
+ yy = np.arange(ny)
246
+ centre = np.polyval(np.polyfit(ys[good], mid[good], self.trace_order), yy)
247
+ left = np.polyval(np.polyfit(ys[good], cl[good], self.trace_order), yy)
248
+ right = np.polyval(np.polyfit(ys[good], cr[good], self.trace_order), yy)
249
+ logger.info(f"Flat trace: centre {centre[0] + 1:.1f}->{centre[-1] + 1:.1f}, "
250
+ f"slit width {np.median(right - left):.1f} px")
251
+ return centre, left, right, (ys[good], mid[good])
252
+
253
+ # 测量星像相对平场中心的偏移。
254
+ def _star_offset(self, img: np.ndarray, centre: np.ndarray, band: Tuple[int, int]) -> float:
255
+ """Constant offset (px) of the star from the flat-derived slit centre line."""
256
+ x0, x1 = band
257
+ ny = img.shape[0]
258
+ edges = np.linspace(0, ny, self.trace_nbins + 1).astype(int)
259
+ offs, fl = [], []
260
+ for a, b in zip(edges[:-1], edges[1:]):
261
+ c = centre[(a + b) // 2]
262
+ lo = int(max(c - 0.4 * (x1 - x0), x0))
263
+ hi = int(min(c + 0.4 * (x1 - x0), x1))
264
+ prof = np.median(img[a:b, lo:hi], axis=0)
265
+ prof = ndimage.gaussian_filter1d(prof - np.median(prof), 1.5)
266
+ pk = int(np.argmax(prof))
267
+ l2, h2 = max(pk - 6, 0), min(pk + 7, prof.size)
268
+ w = np.clip(prof[l2:h2], 0, None)
269
+ if w.sum() <= 0:
270
+ continue
271
+ offs.append(lo + (np.arange(l2, h2) * w).sum() / w.sum() - c)
272
+ fl.append(prof[pk])
273
+ offs, fl = np.asarray(offs), np.asarray(fl)
274
+ if offs.size < 3:
275
+ raise RuntimeError("Star offset measurement failed: too few valid bins")
276
+ sel = fl > 0.3 * np.median(fl)
277
+ med = np.median(offs[sel])
278
+ sel &= np.abs(offs - med) < 3 * (1.4826 * np.median(np.abs(offs[sel] - med)) + 0.3)
279
+ return float(np.median(offs[sel]))
280
+
281
+ # 估算星像空间剖面的半高全宽。
282
+ def _measure_fwhm(self, img: np.ndarray, band: Tuple[int, int]) -> Optional[float]:
283
+ """FWHM (px) of the spatial profile, stacked along the whole dispersion axis.
284
+
285
+ The profile is aligned on the master-flat centre line plus the star offset,
286
+ collapsed with a median over row bins (robust to cosmic rays), the
287
+ background is removed with a linear fit to its wings and a Gaussian is fitted.
288
+ Returns None if the profile is too faint or the fit is implausible.
289
+ """
290
+ from scipy.optimize import curve_fit
291
+ centre = self._flat_trace[0]
292
+ trace = centre + self._star_offset(img, centre, band)
293
+ ny, nx = img.shape
294
+ half = 40
295
+ k = np.arange(-half, half + 1)
296
+ cols = np.clip(np.rint(trace)[:, None].astype(int) + k[None, :], 0, nx - 1)
297
+ cut = np.take_along_axis(img, cols, axis=1)
298
+ edges = np.linspace(0, ny, self.trace_nbins + 1).astype(int)
299
+ prof = np.mean([np.median(cut[a:b], axis=0) for a, b in zip(edges[:-1], edges[1:])], axis=0)
300
+ wing = np.abs(k) > 25
301
+ bg = np.polyfit(k[wing], prof[wing], 1)
302
+ prof = prof - np.polyval(bg, k)
303
+ noise = 1.4826 * np.median(np.abs(prof[wing])) + 1e-9
304
+ if prof.max() < 8 * noise:
305
+ return None
306
+ g = lambda x, a, m, sg: a * np.exp(-0.5 * ((x - m) / sg) ** 2)
307
+ try:
308
+ (a, m, sg), _ = curve_fit(g, k, prof, p0=[prof.max(), k[np.argmax(prof)], 3.0], maxfev=2000)
309
+ except Exception:
310
+ return None
311
+ fwhm = 2.3548 * abs(sg)
312
+ return float(fwhm) if 2.0 <= fwhm <= 30.0 and abs(m) < 15 else None
313
+
314
+ # 根据星像宽度设置提取孔径。
315
+ def _set_aperture(self, science_files, bias, band):
316
+ if self.aperture_mode != 'auto':
317
+ logger.info(f"Aperture (fixed): half-width {self.aperture} px, sky starts at {self.sky_gap} px")
318
+ return
319
+ fwhms = []
320
+ for f in science_files:
321
+ data, _ = self._read(f)
322
+ f_i = self._measure_fwhm(data - bias, band)
323
+ logger.info(f"Spatial FWHM of {Path(f).name}: " + (f"{f_i:.2f} px" if f_i else "not measurable"))
324
+ if f_i:
325
+ fwhms.append(f_i)
326
+ if not fwhms:
327
+ logger.warning(f"FWHM not measurable for any frame; using fixed aperture "
328
+ f"(half-width {self.aperture} px, sky from {self.sky_gap} px)")
329
+ return
330
+ fwhm = float(np.median(fwhms))
331
+ self.aperture = int(np.ceil(self.aperture_fwhm_factor * fwhm))
332
+ self.sky_gap = int(np.ceil(self.sky_fwhm_factor * fwhm))
333
+ logger.info(f"Auto aperture: median FWHM={fwhm:.2f} px -> extraction half-width "
334
+ f"{self.aperture} px, sky region starts at {self.sky_gap} px")
335
+
336
+ # 拟合并扣除狭缝区域的天空背景。
337
+ def _subtract_sky(self, img: np.ndarray, trace: np.ndarray, band: Tuple[int, int]):
338
+ # Only the flat-corrected region is used (edge margin excluded)
339
+ x0, x1 = band
340
+ m = min(self.flat_edge_margin, (x1 - x0) // 4)
341
+ x0, x1 = x0 + m, x1 - m
342
+ ny, nx = img.shape
343
+ xi = np.arange(nx)
344
+ sky = np.zeros_like(img)
345
+ for y in range(ny):
346
+ m = (xi >= x0) & (xi < x1) & (np.abs(xi - trace[y]) > self.sky_gap)
347
+ if m.sum() < 10:
348
+ continue
349
+ xm, vm = xi[m], img[y, m]
350
+ c = np.polyfit(xm, vm, self.sky_order)
351
+ r = vm - np.polyval(c, xm)
352
+ s = 1.4826 * np.median(np.abs(r)) + 1e-6
353
+ k = np.abs(r) < 3 * s
354
+ if k.sum() > self.sky_order + 2:
355
+ c = np.polyfit(xm[k], vm[k], self.sky_order)
356
+ sky[y, x0:x1] = np.polyval(c, xi[x0:x1])
357
+ return img - sky, sky
358
+
359
+ # --------------------------------------------------------------- stage 5
360
+ # 沿狭缝轨迹计算求和与最优光谱。
361
+ def _extract(self, img: np.ndarray, trace: np.ndarray, sky_sub_noise=None):
362
+ ny, nx = img.shape
363
+ hw = self.aperture
364
+ offs = np.arange(-hw, hw + 1)
365
+ cols = np.rint(trace)[:, None].astype(int) + offs[None, :]
366
+ cols = np.clip(cols, 0, nx - 1)
367
+ cut = np.take_along_axis(img, cols, axis=1)
368
+ flux_sum = cut.sum(axis=1)
369
+
370
+ # Spatial profile in trace-relative coordinates (sub-pixel shifts accounted for)
371
+ rel = cols - trace[:, None]
372
+ tot = np.where(np.abs(flux_sum) > 0, flux_sum, np.nan)
373
+ bright = flux_sum > np.percentile(flux_sum, 50)
374
+ bins = np.arange(-hw - 0.5, hw + 1.5, 1.0)
375
+ prof = np.zeros(len(offs))
376
+ for i in range(len(offs)):
377
+ sel = (rel >= bins[i]) & (rel < bins[i + 1]) & bright[:, None]
378
+ vals = (cut / tot[:, None])[sel]
379
+ prof[i] = np.nanmedian(vals) if vals.size else 0.0
380
+ prof = np.clip(prof, 0, None)
381
+ prof /= prof.sum() if prof.sum() > 0 else 1.0
382
+
383
+ var = self.readnoise ** 2 / (self.gain ** 2) + np.clip(cut, 0, None) / max(self.gain, 1e-9)
384
+ # outlier rejection in profile fit
385
+ P = prof[None, :]
386
+ model = flux_sum[:, None] * P
387
+ ok = (cut - model) ** 2 < 25 * var
388
+ w = ok * P / var
389
+ num = (w * cut).sum(axis=1)
390
+ den = (w * P).sum(axis=1)
391
+ flux_opt = np.where(den > 0, num / np.where(den > 0, den, 1), flux_sum)
392
+ err_opt = np.where(den > 0, 1 / np.sqrt(np.where(den > 0, den, 1)), np.nan)
393
+ return flux_sum, flux_opt, err_opt, prof
394
+
395
+ # --------------------------------------------------------------- stage 6
396
+ # 检测并定位标定光谱的发射峰。
397
+ @staticmethod
398
+ def _find_peaks(spec: np.ndarray, snr: float = 8.0):
399
+ return find_arc_peaks(spec, snr)
400
+
401
+ # 读取并排序参考波长目录。
402
+ def _load_catalog(self) -> np.ndarray:
403
+ path = _resolve_path(self.config.get('telescope.linelist', 'full_linelist', ''))
404
+ if path is None:
405
+ return np.array([])
406
+ w = []
407
+ for line in path.read_text(errors='ignore').splitlines():
408
+ parts = line.split()
409
+ if not parts or parts[0].startswith('#'):
410
+ continue
411
+ try:
412
+ w.append(float(parts[0]))
413
+ except ValueError:
414
+ pass
415
+ return np.array(sorted(w))
416
+
417
+ # 读取像素与波长的人工对应表。
418
+ def _load_id_file(self) -> Optional[np.ndarray]:
419
+ path = _resolve_path(self.config.get('slit', 'identification_file', ''))
420
+ if path is None:
421
+ return None
422
+ rows = []
423
+ for line in path.read_text().splitlines():
424
+ line = line.split('#')[0].strip()
425
+ if not line:
426
+ continue
427
+ parts = line.replace(',', ' ').split()
428
+ try:
429
+ rows.append((float(parts[0]), float(parts[1])))
430
+ except (ValueError, IndexError):
431
+ continue
432
+ if len(rows) < 2:
433
+ logger.warning(f"Identification file {path} has too few lines")
434
+ return None
435
+ logger.info(f"Using {len(rows)} identified (anchor) lines from {path}")
436
+ return np.array(rows)
437
+
438
+ # 依据波长提示匹配谱线与目录。
439
+ def _auto_identify(self, peaks, npix, catalog):
440
+ """Match peaks to catalogue assuming a linear solution from wave_start/wave_end hints."""
441
+ w0, w1 = self.wave_hint
442
+ if w0 <= 0 or w1 <= 0 or len(catalog) == 0:
443
+ return None
444
+ pix = np.array([p[0] for p in peaks])
445
+ coef = np.polyfit([0, npix - 1], [w0, w1], 1)
446
+ pairs = None
447
+ for it in range(8):
448
+ wl = np.polyval(coef, pix)
449
+ tol = max(self.match_tol / (it + 1), 0.4)
450
+ idx = np.searchsorted(catalog, wl)
451
+ idx = np.clip(idx, 1, len(catalog) - 1)
452
+ near = np.where(np.abs(catalog[idx - 1] - wl) < np.abs(catalog[idx] - wl), idx - 1, idx)
453
+ d = catalog[near] - wl
454
+ sel = np.abs(d) < tol
455
+ if sel.sum() < self.wave_order + 2:
456
+ return None
457
+ pairs = np.column_stack([pix[sel], catalog[near][sel]])
458
+ order = min(self.wave_order, 1 if it < 2 else self.wave_order)
459
+ coef = np.polyfit(pairs[:, 0], pairs[:, 1], order)
460
+ if order < self.wave_order:
461
+ coef = np.concatenate([np.zeros(self.wave_order - order), coef])
462
+ return pairs
463
+
464
+ # 拟合并评估标定帧的波长解。
465
+ def _wavelength_solution(self, arc_flux: np.ndarray, d: Path, tag: str = 'arc'):
466
+ npix = len(arc_flux)
467
+ d.mkdir(parents=True, exist_ok=True)
468
+ peaks = self._find_peaks(arc_flux, self.peak_snr)
469
+ with open(d / f'{tag}_peaks.csv', 'w', newline='') as f:
470
+ w = csv.writer(f)
471
+ w.writerow(['pixel', 'flux'])
472
+ w.writerows(peaks)
473
+ logger.info(f"Arc: {len(peaks)} peaks written to {d / f'{tag}_peaks.csv'}")
474
+
475
+ pairs = self._load_id_file()
476
+ source = 'identification_file'
477
+ if pairs is not None:
478
+ catalog = self._load_catalog()
479
+ if len(catalog):
480
+ n_anchor = len(pairs)
481
+ pairs, _ = refine_with_catalog([p[0] for p in peaks], pairs, catalog,
482
+ self.wave_order, self.match_tol)
483
+ logger.info(f"Anchors {n_anchor} -> {len(pairs)} lines after full-catalogue matching")
484
+ source = 'anchors+catalog'
485
+ if pairs is None:
486
+ pairs = self._auto_identify(peaks, npix, self._load_catalog())
487
+ source = 'auto_match'
488
+ if pairs is None or len(pairs) < self.wave_order + 2:
489
+ logger.warning("No wavelength solution: provide [slit] identification_file or "
490
+ "wave_start/wave_end hints. Spectra are saved on a pixel axis.")
491
+ return None, None, source
492
+
493
+ x, y = pairs[:, 0], pairs[:, 1]
494
+ keep = np.ones(len(x), bool)
495
+ for _ in range(5):
496
+ coef = np.polyfit(x[keep], y[keep], self.wave_order)
497
+ res = y - np.polyval(coef, x)
498
+ rms = np.std(res[keep])
499
+ new = np.abs(res) < max(3 * rms, 0.3)
500
+ if new.sum() < self.wave_order + 2 or (new == keep).all():
501
+ break
502
+ keep = new
503
+ coef = np.polyfit(x[keep], y[keep], self.wave_order)
504
+ rms = np.std((y - np.polyval(coef, x))[keep])
505
+ logger.info(f"Wavelength solution ({source}): {keep.sum()}/{len(x)} lines, rms={rms:.3f} A")
506
+ return coef, (x, y, keep, rms), source
507
+
508
+ # ------------------------------------------------------------------ main
509
+ # 执行狭缝图像校正、提取与定标。
510
+ def run(self, science_files: List[str], bias_files: List[str],
511
+ flat_files: List[str], arc_files: List[str]) -> Dict[str, SpectraSet]:
512
+ logger.info("=" * 60)
513
+ logger.info("STARTING SLIT SPECTRUM REDUCTION PIPELINE")
514
+ logger.info("=" * 60)
515
+ self.out.mkdir(parents=True, exist_ok=True)
516
+
517
+ for f in bias_files + flat_files + arc_files + science_files:
518
+ data, hdr = self._read(f)
519
+ self._write(self.out / 'step1_basic' / 'overscan_corrected' / f'{Path(f).stem}.fits', data, hdr)
520
+
521
+ bias = self._master_bias(bias_files)
522
+ if flat_files:
523
+ pixflat, band = self._make_flat(flat_files, bias)
524
+ else:
525
+ raise RuntimeError("Slit mode needs flat frames: the trace is derived from the master flat.")
526
+
527
+ self._set_aperture(science_files, bias, band)
528
+
529
+ # Lamp frames are never combined: each science frame uses the single
530
+ # lamp frame closest in time (DATE-OBS, then file mtime as tie-break).
531
+ arcs = []
532
+ for f in arc_files:
533
+ img_a, hdr_a = self._debias(f, bias)
534
+ arcs.append((self._frame_time(f, hdr_a), os.path.getmtime(f), f,
535
+ self._extract_calib(img_a, hdr_a, Path(f).stem)))
536
+ if flat_files:
537
+ self._extract_calib(self._master_flat, fits.Header(), 'master_flat')
538
+
539
+ results: Dict[str, SpectraSet] = {}
540
+ for path in science_files:
541
+ name = Path(path).stem
542
+ logger.info(f"Reducing {name}")
543
+ img, hdr = self._debias(path, bias)
544
+ img = self._cosmic(img)
545
+ self._write(self.out / 'step1_basic' / 'cosmic_corrected' / f'{name}.fits', img, hdr)
546
+
547
+ centre, left, right = self._flat_trace
548
+ offset = self._star_offset(img, centre, band)
549
+ trace = centre + offset
550
+ logger.info(f"Star offset from flat trace: {offset:+.2f} px")
551
+ d2 = self.out / 'step2_trace'
552
+ yy = np.arange(len(trace))
553
+ self._plot_image(d2 / f'{name}_trace', img, f'{name} trace (master flat + {offset:+.1f} px)',
554
+ [(trace, yy, 'r-'), (left, yy, 'y-'), (right, yy, 'y-')])
555
+ np.savetxt(d2 / f'{name}_trace.txt', np.column_stack([yy + 1, trace + 1]),
556
+ header=f'dispersion_pixel spatial_column (1-based); master-flat centre {offset:+.3f} px',
557
+ fmt='%.3f')
558
+
559
+ img = img / pixflat
560
+ d4f = self.out / 'step4_flat_corrected'
561
+ self._write(d4f / f'{name}.fits', img, hdr)
562
+
563
+ sub, sky = self._subtract_sky(img, trace, band)
564
+ self._write(self.out / 'step5_extraction' / f'{name}_skysub.fits', sub, hdr)
565
+
566
+ fsum, fopt, eopt, prof = self._extract(sub, trace)
567
+ d4 = self.out / 'step5_extraction'
568
+ d5 = self.out / 'step7_wavelength'
569
+ pixel = np.arange(len(fsum), dtype=float)
570
+
571
+ wave = pixel + 1.0
572
+ wave_unit = 'pixel'
573
+ snr_pix = self._estimate_snr(fopt)
574
+ sky_1d = sky[np.arange(len(trace)), np.clip(np.rint(trace).astype(int), 0, sky.shape[1] - 1)]
575
+ self._plot_sky(d4 / f'{name}_sky_pixel', wave, sky_1d, 'pixel', name)
576
+ self._plot_spectrum(d4 / f'{name}_spec_pixel', wave, fsum, fopt, 'pixel', name, snr_pix)
577
+ snr_wave = []
578
+ if arcs:
579
+ t_sci = self._frame_time(path, hdr)
580
+ m_sci = os.path.getmtime(path)
581
+ _, _, arc_name, arc_flux = min(
582
+ arcs, key=lambda a: (abs(a[0] - t_sci), abs(a[1] - m_sci)))
583
+ logger.info(f"Using lamp frame {Path(arc_name).name} for {name} (closest in time)")
584
+ coef, info, src = self._wavelength_solution(arc_flux, d5, Path(arc_name).stem)
585
+ if coef is not None:
586
+ wave = np.polyval(coef, pixel)
587
+ wave_unit = 'Angstrom'
588
+ self._plot_arc(d5 / f'{name}_arc', arc_flux, info, coef)
589
+ snr_wave = self._estimate_snr(fopt, wave)
590
+ self._plot_sky(d5 / f'{name}_sky_wave', wave, sky_1d, 'Angstrom', name)
591
+ self._plot_spectrum(d5 / f'{name}_spec_wave', wave, fsum, fopt,
592
+ 'Angstrom', name, snr_wave)
593
+
594
+ exptime = float(hdr.get('EXPTIME', 1.0) or 1.0)
595
+ spec = Spectrum(aperture=1, order=1, wavelength=wave, flux=fopt, error=eopt,
596
+ background=sky[np.arange(len(trace)), np.clip(np.rint(trace).astype(int), 0, sky.shape[1] - 1)],
597
+ exptime=exptime)
598
+ sset = SpectraSet()
599
+ sset.add_spectrum(spec)
600
+ results[path] = sset
601
+
602
+ self._write_spectrum(d4 / f'{name}_spec.fits', wave, fsum, fopt, eopt, spec.background, wave_unit, hdr,
603
+ has_snr=snr_pix + snr_wave)
604
+ logger.info("Slit reduction finished")
605
+ return results
606
+
607
+ # 估算指定波段位置的光谱信噪比。
608
+ def _estimate_snr(self, flux: np.ndarray, wave: Optional[np.ndarray] = None):
609
+ """SNR at 25/50/75% of the effective spectral range.
610
+
611
+ The effective range is the contiguous span around the flux peak where the smoothed flux exceeds
612
+ snr_effective_fraction of its maximum. In each window (+-snr_half_window
613
+ px) the signal is the median flux and the noise is the standard deviation
614
+ of the residual to a quadratic fit (dimensionless ratio). `x` is the
615
+ position: pixel (1-based) without `wave`, wavelength in Angstrom with it.
616
+ """
617
+ sm = ndimage.uniform_filter1d(np.nan_to_num(flux), 51)
618
+ above = sm > self.snr_flux_frac * sm.max()
619
+ pk = int(np.argmax(sm))
620
+ lo = hi = pk
621
+ while lo > 0 and above[lo - 1]:
622
+ lo -= 1
623
+ while hi < len(sm) - 1 and above[hi + 1]:
624
+ hi += 1
625
+ if hi - lo < 20:
626
+ return []
627
+ out = []
628
+ for frac in (0.25, 0.5, 0.75):
629
+ c = int(round(lo + frac * (hi - lo)))
630
+ a, b = max(c - self.snr_half_window, 0), min(c + self.snr_half_window + 1, len(flux))
631
+ seg = np.asarray(flux[a:b], dtype=float)
632
+ x = np.arange(len(seg))
633
+ keep = np.isfinite(seg)
634
+ for _ in range(3):
635
+ coef = np.polyfit(x[keep], seg[keep], 2)
636
+ res = seg - np.polyval(coef, x)
637
+ sig = 1.4826 * np.median(np.abs(res[keep])) + 1e-12
638
+ keep = np.isfinite(seg) & (np.abs(res) < 4 * sig)
639
+ noise = np.std(res[keep])
640
+ signal = float(np.median(seg[keep]))
641
+ snr = signal / noise if noise > 0 else np.nan
642
+ entry = {'frac': frac, 'index': c, 'signal': signal, 'noise': float(noise),
643
+ 'snr': float(snr), 'unit': 'pixel'}
644
+ entry['x'] = float(wave[c]) if wave is not None else float(c + 1)
645
+ if wave is not None:
646
+ entry['unit'] = 'Angstrom'
647
+ out.append(entry)
648
+ txt = ', '.join(f"{int(e['frac'] * 100)}%@{e['x']:.1f}: {e['snr']:.1f}" for e in out)
649
+ logger.info(f"SNR at {out[0]['unit']} positions: {txt}")
650
+ return out
651
+
652
+ # 读取观测时间并回退到文件时间。
653
+ def _frame_time(self, path: str, hdr) -> float:
654
+ key = self.config.get('data', 'statime_key', 'DATE-OBS')
655
+ try:
656
+ from astropy.time import Time
657
+ return float(Time(hdr[key]).unix)
658
+ except Exception:
659
+ return float(os.path.getmtime(path))
660
+
661
+ # 沿主平场轨迹提取标定光谱。
662
+ def _extract_calib(self, img: np.ndarray, hdr, name: str) -> np.ndarray:
663
+ """Extract a flat/lamp frame along the master-flat centre line (no sky subtraction)."""
664
+ centre = self._flat_trace[0]
665
+ fsum, fopt, eopt, _ = self._extract(img, centre)
666
+ d = self.out / 'step5_extraction'
667
+ self._write_spectrum(d / f'{name}_spec.fits', np.arange(len(fsum)) + 1.0, fsum, fopt, eopt,
668
+ np.zeros_like(fsum), 'pixel', hdr)
669
+ self._plot_spectrum(d / f'{name}_spec_pixel', np.arange(len(fsum)) + 1.0, fsum, fopt,
670
+ 'pixel', name)
671
+ return fsum
672
+
673
+ # 将光谱、背景和信噪比写入FITS。
674
+ def _write_spectrum(self, path, wave, fsum, fopt, err, sky, unit, hdr, has_snr=()):
675
+ path.parent.mkdir(parents=True, exist_ok=True)
676
+ cols = [fits.Column(name='WAVELENGTH', format='D', array=wave),
677
+ fits.Column(name='FLUX_OPT', format='D', array=fopt),
678
+ fits.Column(name='FLUX_SUM', format='D', array=fsum),
679
+ fits.Column(name='ERROR', format='D', array=np.nan_to_num(err)),
680
+ fits.Column(name='SKY', format='D', array=sky)]
681
+ t = fits.BinTableHDU.from_columns(cols)
682
+ t.header['WAVEUNIT'] = unit
683
+ for e in has_snr:
684
+ key = ('SNRP' if e['unit'] == 'pixel' else 'SNRW') + str(int(e['frac'] * 100))
685
+ t.header[key] = (round(e['snr'], 2), f"SNR at {int(e['frac'] * 100)}% of range")
686
+ t.header['EXPTIME'] = float(hdr.get('EXPTIME', 0) or 0)
687
+ t.header['OBJECT'] = str(hdr.get('OBJECT', path.stem.split('_')[0]))
688
+ fits.HDUList([fits.PrimaryHDU(), t]).writeto(path, overwrite=True)
689
+
690
+ # 绘制求和与最优提取光谱。
691
+ def _plot_spectrum(self, path, wave, fsum, fopt, unit, title, snr=()):
692
+ if not self.save_plots:
693
+ return
694
+ import matplotlib
695
+ matplotlib.use('Agg')
696
+ import matplotlib.pyplot as plt
697
+ Path(path).parent.mkdir(parents=True, exist_ok=True)
698
+ fig, ax = plt.subplots(figsize=(10, 4))
699
+ ax.plot(wave, fsum, lw=0.5, color='0.6', label='sum')
700
+ ax.plot(wave, fopt, lw=0.6, color='b', label='optimal')
701
+ per = 'pixel' if unit == 'pixel' else 'A'
702
+ ymax = np.nanmax(fopt)
703
+ for k, e in enumerate(snr):
704
+ ax.axvline(e['x'], color='r', ls='--', lw=0.6)
705
+ ax.text(e['x'], 0.96 - 0.08 * (k % 2), f"SNR={e['snr']:.1f}@{e['x']:.0f} {per}", transform=ax.get_xaxis_transform(),
706
+ ha='center', va='top', color='r', fontsize=9,
707
+ bbox=dict(fc='w', ec='r', lw=0.5, alpha=0.9))
708
+ ax.set_ylim(top=ymax * 1.25)
709
+ ax.set_xlabel('Wavelength (A)' if unit == 'Angstrom' else 'Pixel')
710
+ ax.set_ylabel('Counts')
711
+ ax.set_title(title)
712
+ ax.legend(loc='center left')
713
+ for ext in {self.fig_format, 'pdf'}:
714
+ fig.savefig(Path(str(path) + '.' + ext), dpi=110, bbox_inches='tight')
715
+ plt.close(fig)
716
+
717
+ # 绘制天空光谱并标记发射线。
718
+ def _plot_sky(self, path, x, sky, unit, title):
719
+ """Night-sky spectrum (per spatial pixel) with detected emission lines marked."""
720
+ peaks = self._find_peaks(np.nan_to_num(sky))
721
+ idx = np.arange(len(sky))
722
+ px = np.array([np.interp(c, idx, x) for c, _ in peaks])
723
+ pa = np.array([a for _, a in peaks])
724
+ np.savetxt(Path(str(path) + '_lines.csv'), np.column_stack([px, pa]) if len(px) else np.empty((0, 2)),
725
+ delimiter=',', header=f'position({unit}),amplitude', fmt='%.3f', comments='# ')
726
+ if not self.save_plots:
727
+ return
728
+ import matplotlib
729
+ matplotlib.use('Agg')
730
+ import matplotlib.pyplot as plt
731
+ fig, ax = plt.subplots(figsize=(10, 4))
732
+ ax.plot(x, sky, lw=0.5, color='k')
733
+ top = np.argsort(pa)[::-1][:25] if len(pa) else []
734
+ for i in top:
735
+ ax.axvline(px[i], color='r', lw=0.4, alpha=0.6)
736
+ ax.text(px[i], 1.0, f'{px[i]:.1f}', rotation=90, transform=ax.get_xaxis_transform(),
737
+ ha='center', va='bottom', color='r', fontsize=6)
738
+ ax.set_xlabel('Wavelength (A)' if unit == 'Angstrom' else 'Pixel')
739
+ ax.set_ylabel('Sky counts / spatial pixel')
740
+ ax.set_title(f'{title} night-sky spectrum ({len(peaks)} lines)', pad=28)
741
+ for ext in {self.fig_format, 'pdf'}:
742
+ fig.savefig(Path(str(path) + '.' + ext), dpi=110, bbox_inches='tight')
743
+ plt.close(fig)
744
+
745
+ # 绘制标定光谱及波长拟合残差。
746
+ def _plot_arc(self, path, arc, info, coef):
747
+ if not self.save_plots:
748
+ return
749
+ import matplotlib
750
+ matplotlib.use('Agg')
751
+ import matplotlib.pyplot as plt
752
+ x, y, keep, rms = info
753
+ fig, (a1, a2) = plt.subplots(2, 1, figsize=(10, 6))
754
+ a1.plot(np.polyval(coef, np.arange(len(arc))), arc, lw=0.5)
755
+ for w in y[keep]:
756
+ a1.axvline(w, color='r', lw=0.3)
757
+ a1.set_xlabel('Wavelength (A)')
758
+ a2.plot(y[keep], (y - np.polyval(coef, x))[keep], 'k.')
759
+ a2.set_ylabel('residual (A)')
760
+ a2.set_title(f'rms={rms:.3f} A')
761
+ fig.savefig(Path(str(path) + '.' + self.fig_format), dpi=110, bbox_inches='tight')
762
+ plt.close(fig)