SpecProc 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- specproc/__init__.py +9 -0
- specproc/calib_data/linelists/FeAr.dat +375 -0
- specproc/calib_data/linelists/README +79 -0
- specproc/calib_data/linelists/airvac.cl +61 -0
- specproc/calib_data/linelists/argon.dat +161 -0
- specproc/calib_data/linelists/ctiohear.dat +106 -0
- specproc/calib_data/linelists/ctiohenear.dat +146 -0
- specproc/calib_data/linelists/cuar.dat +459 -0
- specproc/calib_data/linelists/henear.dat +93 -0
- specproc/calib_data/linelists/henearhres.dat +300 -0
- specproc/calib_data/linelists/idhenear.dat +127 -0
- specproc/calib_data/linelists/krypton.dat +109 -0
- specproc/calib_data/linelists/lowskylines.dat +577 -0
- specproc/calib_data/linelists/ohlines.dat +268 -0
- specproc/calib_data/linelists/skylines.dat +1385 -0
- specproc/calib_data/linelists/thar-noao.dat +4169 -0
- specproc/calib_data/linelists/thar.dat +3057 -0
- specproc/calib_data/linelists/thorium.dat +3057 -0
- specproc/calib_data/linelists/vacidhenear.dat +127 -0
- specproc/calib_data/linelists/vacthar.dat +3057 -0
- specproc/calib_data/linelists/vacthorium.dat +3057 -0
- specproc/calib_data/linelists/xenon.dat +89 -0
- specproc/calib_data/telescopes/lijiang24hrs/lijiang24_hrs_thar_lines.csv +550 -0
- specproc/calib_data/telescopes/xinglong216bfosc/BFOSC/345/256/232/346/240/207/347/201/257/350/260/261/350/256/244/350/257/201/346/211/213/345/206/214v171118.pdf +0 -0
- specproc/calib_data/telescopes/xinglong216bfosc//346/212/245/345/221/212/345/233/276.docx +0 -0
- specproc/calib_data/telescopes/xinglong216hrs/216HRS /347/201/257/350/260/261/350/257/201/350/256/244/346/226/207/344/273/266.pdf +0 -0
- specproc/calib_data/telescopes/xinglong216hrs/wlcalib_20211123011_A.png +0 -0
- specproc/calib_data/telescopes/xinglong216hrs/xinglong216_thar_lines.csv +517 -0
- specproc/config/__init__.py +1 -0
- specproc/config/config_manager.py +431 -0
- specproc/core/__init__.py +1 -0
- specproc/core/basic_reduction.py +615 -0
- specproc/core/data_structures.py +525 -0
- specproc/core/de_blazing.py +210 -0
- specproc/core/extraction.py +375 -0
- specproc/core/flat_correction.py +1255 -0
- specproc/core/line_identification.py +302 -0
- specproc/core/order_stitching.py +187 -0
- specproc/core/order_tracing.py +1587 -0
- specproc/core/processing_pipeline.py +1183 -0
- specproc/core/scattered_light.py +654 -0
- specproc/core/slit_pipeline.py +762 -0
- specproc/core/wave_calibration.py +961 -0
- specproc/gui/__init__.py +1 -0
- specproc/gui/line_identifier.py +731 -0
- specproc/gui/main_window.py +2315 -0
- specproc/gui/settings_dialog.py +946 -0
- specproc/main.py +388 -0
- specproc/plotting/__init__.py +1 -0
- specproc/plotting/spectra_plotter.py +483 -0
- specproc/utils/__init__.py +1 -0
- specproc/utils/airvac.py +123 -0
- specproc/utils/fits_io.py +269 -0
- specproc/utils/image_processing.py +426 -0
- specproc/utils/overscan.py +600 -0
- specproc-1.0.0.dist-info/METADATA +668 -0
- specproc-1.0.0.dist-info/RECORD +61 -0
- specproc-1.0.0.dist-info/WHEEL +5 -0
- specproc-1.0.0.dist-info/entry_points.txt +5 -0
- specproc-1.0.0.dist-info/licenses/LICENSE +674 -0
- specproc-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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)
|