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,302 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Helpers for identifying lamp lines in long-slit spectra.
|
|
3
|
+
|
|
4
|
+
Shared by the interactive identification window (``specproc.gui.line_identifier``)
|
|
5
|
+
and the slit pipeline. An *anchor* is a manually identified (pixel, wavelength)
|
|
6
|
+
pair; anchors seed the dispersion solution, which is then extended automatically
|
|
7
|
+
by matching all detected lamp peaks against the full line catalogue.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import itertools
|
|
11
|
+
import logging
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import List, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
from astropy.io import fits
|
|
17
|
+
from scipy import ndimage
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def find_arc_peaks(spec: np.ndarray, snr_threshold: float = 8.0, with_snr: bool = False):
|
|
23
|
+
"""Detect emission peaks above snr_threshold (peak height / background noise).
|
|
24
|
+
|
|
25
|
+
Returns (centroid pixel (0-based), amplitude) tuples, or
|
|
26
|
+
(pixel, amplitude, snr) tuples when with_snr is True.
|
|
27
|
+
"""
|
|
28
|
+
spec = np.nan_to_num(np.asarray(spec, dtype=float))
|
|
29
|
+
s = spec - ndimage.minimum_filter1d(ndimage.gaussian_filter1d(spec, 3), 61)
|
|
30
|
+
s = ndimage.gaussian_filter1d(s, 1.0)
|
|
31
|
+
glob = 1.4826 * np.median(np.abs(s - np.median(s)))
|
|
32
|
+
# Local, one-sided noise: emission lines only add positive outliers, so the gap
|
|
33
|
+
# between the running median and 16th percentile (~1 sigma) ignores them.
|
|
34
|
+
size = min(101, len(s) // 2 * 2 - 1) if len(s) > 4 else 1
|
|
35
|
+
local = ndimage.percentile_filter(s, 50, size=size) - ndimage.percentile_filter(s, 16, size=size)
|
|
36
|
+
noise_arr = np.maximum(local, 0.5 * glob) + 1e-9
|
|
37
|
+
pk = [i for i in range(2, len(s) - 2)
|
|
38
|
+
if s[i] > s[i - 1] and s[i] >= s[i + 1] and s[i] > snr_threshold * noise_arr[i] and s[i] > s[i - 2]]
|
|
39
|
+
out = []
|
|
40
|
+
for i in pk:
|
|
41
|
+
lo, hi = max(i - 3, 0), min(i + 4, len(s))
|
|
42
|
+
w = np.clip(s[lo:hi], 0, None)
|
|
43
|
+
entry = (float((np.arange(lo, hi) * w).sum() / w.sum()), float(s[i]))
|
|
44
|
+
out.append(entry + (float(s[i] / noise_arr[i]),) if with_snr else entry)
|
|
45
|
+
return out
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_linelist(path: str) -> Tuple[np.ndarray, List[str], np.ndarray]:
|
|
49
|
+
"""Read a catalogue: wavelength, label and relative strength (1 when absent).
|
|
50
|
+
|
|
51
|
+
The first column is the wavelength; if the second is numeric it is the line
|
|
52
|
+
strength, otherwise the remaining text is the label.
|
|
53
|
+
"""
|
|
54
|
+
waves, labels, strengths = [], [], []
|
|
55
|
+
for line in Path(path).read_text(errors='ignore').splitlines():
|
|
56
|
+
text = line.strip()
|
|
57
|
+
if not text or text.startswith('#'):
|
|
58
|
+
continue
|
|
59
|
+
parts = text.replace(',', ' ').split(None, 1)
|
|
60
|
+
try:
|
|
61
|
+
waves.append(float(parts[0]))
|
|
62
|
+
except ValueError:
|
|
63
|
+
continue
|
|
64
|
+
rest = parts[1].strip() if len(parts) > 1 else ''
|
|
65
|
+
try:
|
|
66
|
+
strengths.append(float(rest.split()[0]) if rest else 1.0)
|
|
67
|
+
labels.append('')
|
|
68
|
+
except ValueError:
|
|
69
|
+
strengths.append(1.0)
|
|
70
|
+
labels.append(rest)
|
|
71
|
+
order = np.argsort(waves)
|
|
72
|
+
return (np.asarray(waves, dtype=float)[order], [labels[i] for i in order],
|
|
73
|
+
np.asarray(strengths, dtype=float)[order])
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _collapse_image(img: np.ndarray, dispersion_axis: str) -> np.ndarray:
|
|
77
|
+
"""Sum a 2-D lamp image over a band around the brightest spatial position."""
|
|
78
|
+
img = np.nan_to_num(np.asarray(img, dtype=float))
|
|
79
|
+
if dispersion_axis == 'x':
|
|
80
|
+
img = img.T
|
|
81
|
+
profile = ndimage.gaussian_filter1d(img.sum(axis=0), 3)
|
|
82
|
+
c = int(np.argmax(profile))
|
|
83
|
+
lo, hi = max(c - 12, 0), min(c + 13, img.shape[1])
|
|
84
|
+
return img[:, lo:hi].sum(axis=1)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def load_lamp_spectrum(path: str, dispersion_axis: str = 'y') -> np.ndarray:
|
|
88
|
+
"""Load a 1-D lamp spectrum (counts versus 0-based pixel).
|
|
89
|
+
|
|
90
|
+
Accepts a SpecProc ``*_spec.fits`` table (FLUX_SUM/FLUX_OPT), a 1-D or 2-D
|
|
91
|
+
FITS image, or a text/CSV file (last column is the flux).
|
|
92
|
+
"""
|
|
93
|
+
p = Path(path)
|
|
94
|
+
if p.suffix.lower() in ('.fits', '.fit', '.fts'):
|
|
95
|
+
with fits.open(p) as hdul:
|
|
96
|
+
for hdu in hdul:
|
|
97
|
+
names = [n.upper() for n in (getattr(hdu, 'columns', None).names
|
|
98
|
+
if getattr(hdu, 'columns', None) is not None else [])]
|
|
99
|
+
for col in ('FLUX_SUM', 'FLUX_OPT', 'FLUX'):
|
|
100
|
+
if col in names:
|
|
101
|
+
return np.nan_to_num(np.asarray(hdu.data[col], dtype=float))
|
|
102
|
+
for hdu in hdul:
|
|
103
|
+
data = getattr(hdu, 'data', None)
|
|
104
|
+
if data is not None and getattr(data, 'ndim', 0) in (1, 2) and data.dtype.names is None:
|
|
105
|
+
data = np.asarray(data, dtype=float)
|
|
106
|
+
return np.nan_to_num(data) if data.ndim == 1 else _collapse_image(
|
|
107
|
+
data, dispersion_axis.lower())
|
|
108
|
+
raise ValueError(f"No spectrum or image data found in {path}")
|
|
109
|
+
rows = []
|
|
110
|
+
for line in p.read_text(errors='ignore').splitlines():
|
|
111
|
+
text = line.split('#')[0].strip()
|
|
112
|
+
if not text:
|
|
113
|
+
continue
|
|
114
|
+
try:
|
|
115
|
+
rows.append([float(v) for v in text.replace(',', ' ').split()])
|
|
116
|
+
except ValueError:
|
|
117
|
+
continue
|
|
118
|
+
if not rows:
|
|
119
|
+
raise ValueError(f"No numeric data found in {path}")
|
|
120
|
+
return np.array([r[-1] for r in rows], dtype=float)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def load_anchors(path: str) -> np.ndarray:
|
|
124
|
+
"""Read anchors as an (N, 2) array of (0-based pixel, wavelength)."""
|
|
125
|
+
rows = []
|
|
126
|
+
for line in Path(path).read_text().splitlines():
|
|
127
|
+
text = line.split('#')[0].strip()
|
|
128
|
+
if not text:
|
|
129
|
+
continue
|
|
130
|
+
parts = text.replace(',', ' ').split()
|
|
131
|
+
try:
|
|
132
|
+
rows.append((float(parts[0]), float(parts[1])))
|
|
133
|
+
except (ValueError, IndexError):
|
|
134
|
+
continue
|
|
135
|
+
return np.array(rows, dtype=float).reshape(-1, 2)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def save_anchors(path: str, pairs: np.ndarray, note: str = ''):
|
|
139
|
+
"""Write anchors as CSV, readable by the slit pipeline ``identification_file`` option."""
|
|
140
|
+
pairs = np.asarray(pairs, dtype=float).reshape(-1, 2)
|
|
141
|
+
pairs = pairs[np.argsort(pairs[:, 0])]
|
|
142
|
+
lines = ['# SpecProc anchor lines for long-slit wavelength calibration',
|
|
143
|
+
'# pixel (0-based along the dispersion axis), wavelength (Angstrom)']
|
|
144
|
+
if note:
|
|
145
|
+
lines.append('# ' + note)
|
|
146
|
+
lines += ['%.3f, %.4f' % (x, w) for x, w in pairs]
|
|
147
|
+
Path(path).write_text('\n'.join(lines) + '\n')
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def fit_polynomial(pairs: np.ndarray, order: int) -> Optional[np.ndarray]:
|
|
151
|
+
"""Fit wavelength = poly(pixel); the order is limited by the number of pairs."""
|
|
152
|
+
pairs = np.asarray(pairs, dtype=float).reshape(-1, 2)
|
|
153
|
+
if len(pairs) < 2:
|
|
154
|
+
return None
|
|
155
|
+
return np.polyfit(pairs[:, 0], pairs[:, 1], min(order, len(pairs) - 1))
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _match_nearest(pix: np.ndarray, catalog: np.ndarray, coef: np.ndarray, tol: float) -> np.ndarray:
|
|
159
|
+
"""Pair each peak with its nearest catalogue line within tol (each line used once)."""
|
|
160
|
+
wl = np.polyval(coef, pix)
|
|
161
|
+
idx = np.clip(np.searchsorted(catalog, wl), 1, len(catalog) - 1)
|
|
162
|
+
near = np.where(np.abs(catalog[idx - 1] - wl) < np.abs(catalog[idx] - wl), idx - 1, idx)
|
|
163
|
+
dist = np.abs(catalog[near] - wl)
|
|
164
|
+
best = {}
|
|
165
|
+
for i in np.where(dist < tol)[0]:
|
|
166
|
+
j = near[i]
|
|
167
|
+
if j not in best or dist[i] < dist[best[j]]:
|
|
168
|
+
best[j] = i
|
|
169
|
+
sel = np.array(sorted(best.values()), dtype=int)
|
|
170
|
+
if len(sel) == 0:
|
|
171
|
+
return np.empty((0, 2))
|
|
172
|
+
return np.column_stack([pix[sel], catalog[near[sel]]])
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def pattern_match_linear(peak_pix, catalog, peak_amp=None, wave_range=None, pixel_range=None,
|
|
176
|
+
n_peaks: int = 15, max_catalog: int = 150, ratio_eps: float = 0.01):
|
|
177
|
+
"""Find a linear pixel->wavelength map from the pattern of line spacings.
|
|
178
|
+
|
|
179
|
+
For three lines the ratio (middle-first)/(last-first) is unchanged by a linear
|
|
180
|
+
dispersion, in either direction. Triples of the strongest lamp peaks are paired
|
|
181
|
+
with catalogue triples of equal ratio; each pairing gives a linear hypothesis
|
|
182
|
+
that is scored by how many lamp peaks land on a catalogue line. When
|
|
183
|
+
``wave_range`` and ``pixel_range`` are given, hypotheses whose wavelength
|
|
184
|
+
coverage falls outside the range are discarded. Returns the coefficients
|
|
185
|
+
``[scale, offset]`` of the best hypothesis, or None.
|
|
186
|
+
"""
|
|
187
|
+
pix = np.asarray(peak_pix, dtype=float)
|
|
188
|
+
cat = np.sort(np.asarray(catalog, dtype=float))
|
|
189
|
+
if len(pix) < 3 or len(cat) < 3:
|
|
190
|
+
return None
|
|
191
|
+
amp = np.ones(len(pix)) if peak_amp is None else np.asarray(peak_amp, dtype=float)
|
|
192
|
+
key = pix[np.argsort(-amp)[:n_peaks]]
|
|
193
|
+
key.sort()
|
|
194
|
+
span = pix.max() - pix.min()
|
|
195
|
+
if pixel_range is None:
|
|
196
|
+
pixel_range = (float(pix.min()), float(pix.max()))
|
|
197
|
+
# Catalogue lines used for hashing: evenly thinned if too many.
|
|
198
|
+
sub = cat if len(cat) <= max_catalog else cat[np.linspace(0, len(cat) - 1, max_catalog).astype(int)]
|
|
199
|
+
ia, ib, ic = np.array(list(itertools.combinations(range(len(sub)), 3))).T
|
|
200
|
+
ca, cb, cc = sub[ia], sub[ib], sub[ic]
|
|
201
|
+
cr = (cb - ca) / (cc - ca)
|
|
202
|
+
order_ = np.argsort(cr)
|
|
203
|
+
ca, cc, cr = ca[order_], cc[order_], cr[order_]
|
|
204
|
+
|
|
205
|
+
best, best_key = None, (0, 0.0)
|
|
206
|
+
min_span = 0.15 * span
|
|
207
|
+
for i, j, k in itertools.combinations(range(len(key)), 3):
|
|
208
|
+
p1, p2, p3 = key[i], key[j], key[k]
|
|
209
|
+
if p3 - p1 < min_span:
|
|
210
|
+
continue
|
|
211
|
+
r = (p2 - p1) / (p3 - p1)
|
|
212
|
+
for reverse in (False, True):
|
|
213
|
+
target = 1 - r if reverse else r
|
|
214
|
+
lo, hi = np.searchsorted(cr, [target - ratio_eps, target + ratio_eps])
|
|
215
|
+
if hi <= lo:
|
|
216
|
+
continue
|
|
217
|
+
w1, w3 = (cc[lo:hi], ca[lo:hi]) if reverse else (ca[lo:hi], cc[lo:hi])
|
|
218
|
+
scale = (w3 - w1) / (p3 - p1)
|
|
219
|
+
offset = w1 - scale * p1
|
|
220
|
+
if wave_range is not None:
|
|
221
|
+
wlo, whi = sorted(map(float, wave_range))
|
|
222
|
+
margin = 0.05 * (whi - wlo)
|
|
223
|
+
e0 = scale * pixel_range[0] + offset
|
|
224
|
+
e1 = scale * pixel_range[1] + offset
|
|
225
|
+
ok = (np.minimum(e0, e1) >= wlo - margin) & (np.maximum(e0, e1) <= whi + margin)
|
|
226
|
+
scale, offset = scale[ok], offset[ok]
|
|
227
|
+
if len(scale) == 0:
|
|
228
|
+
continue
|
|
229
|
+
wl = scale[:, None] * pix[None, :] + offset[:, None]
|
|
230
|
+
idx = np.clip(np.searchsorted(cat, wl), 1, len(cat) - 1)
|
|
231
|
+
dist = np.minimum(np.abs(cat[idx] - wl), np.abs(cat[idx - 1] - wl))
|
|
232
|
+
tol_w = np.maximum(1.5 * np.abs(scale), 1e-3)[:, None]
|
|
233
|
+
hit = dist < tol_w
|
|
234
|
+
score = hit.sum(axis=1)
|
|
235
|
+
rms = np.sqrt((np.where(hit, dist, 0) ** 2).sum(axis=1) / np.maximum(score, 1))
|
|
236
|
+
n = int(np.argmax(score - 1e-6 * rms))
|
|
237
|
+
cand = (int(score[n]), -float(rms[n]))
|
|
238
|
+
if cand > best_key:
|
|
239
|
+
best_key, best = cand, np.array([scale[n], offset[n]])
|
|
240
|
+
if best is None or best_key[0] < 4:
|
|
241
|
+
return None
|
|
242
|
+
return best
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def refine_with_catalog(peak_pix, anchors: np.ndarray, catalog: np.ndarray, order: int,
|
|
246
|
+
tol: float = 3.0, min_tol: float = 0.4, max_iter: int = 6,
|
|
247
|
+
initial_range: Optional[Tuple[float, float]] = None,
|
|
248
|
+
initial_pixel_range: Optional[Tuple[float, float]] = None,
|
|
249
|
+
peak_amp=None, initial_coef=None):
|
|
250
|
+
"""Match detected lamp peaks to a catalogue, optionally without manual anchors.
|
|
251
|
+
|
|
252
|
+
With anchors, they give the rough solution and are always retained. With no
|
|
253
|
+
anchors, the starting solution comes from line-spacing pattern matching
|
|
254
|
+
(``pattern_match_linear``). Matching then uses a shrinking tolerance while the
|
|
255
|
+
polynomial order grows. ``initial_coef`` (polynomial coefficients, pixel ->
|
|
256
|
+
wavelength) replaces the starting solution, e.g. a hand-aligned overlay.
|
|
257
|
+
Returns (pairs, coef).
|
|
258
|
+
"""
|
|
259
|
+
anchors = np.asarray(anchors, dtype=float).reshape(-1, 2)
|
|
260
|
+
pix = np.asarray(peak_pix, dtype=float)
|
|
261
|
+
catalog = np.asarray(catalog, dtype=float)
|
|
262
|
+
catalog = np.sort(catalog[np.isfinite(catalog)])
|
|
263
|
+
if len(catalog) < 2 or len(pix) == 0:
|
|
264
|
+
return anchors, fit_polynomial(anchors, order)
|
|
265
|
+
|
|
266
|
+
if len(anchors) == 0:
|
|
267
|
+
coef = (np.asarray(initial_coef, dtype=float) if initial_coef is not None else
|
|
268
|
+
pattern_match_linear(pix, catalog, peak_amp, initial_range, initial_pixel_range))
|
|
269
|
+
if coef is None:
|
|
270
|
+
return np.empty((0, 2)), None
|
|
271
|
+
pairs = np.empty((0, 2))
|
|
272
|
+
for it in range(max_iter):
|
|
273
|
+
cur_tol = max(tol / (it + 1), min_tol)
|
|
274
|
+
matched = _match_nearest(pix, catalog, coef, cur_tol)
|
|
275
|
+
if len(matched) < 2:
|
|
276
|
+
break
|
|
277
|
+
pairs = matched
|
|
278
|
+
coef = fit_polynomial(pairs, min(order, it + 1))
|
|
279
|
+
if len(pairs) < 2:
|
|
280
|
+
return np.empty((0, 2)), None
|
|
281
|
+
return pairs[np.argsort(pairs[:, 0])], fit_polynomial(pairs, order)
|
|
282
|
+
|
|
283
|
+
coef = (np.asarray(initial_coef, dtype=float) if initial_coef is not None
|
|
284
|
+
else fit_polynomial(anchors, 1))
|
|
285
|
+
if coef is None:
|
|
286
|
+
return anchors, fit_polynomial(anchors, order)
|
|
287
|
+
|
|
288
|
+
pairs = anchors
|
|
289
|
+
for it in range(max_iter):
|
|
290
|
+
cur_tol = max(tol / (it + 1), min_tol)
|
|
291
|
+
matched = _match_nearest(pix, catalog, coef, cur_tol)
|
|
292
|
+
if len(matched):
|
|
293
|
+
near_anchor = np.min(np.abs(matched[:, :1] - anchors[:, 0][None, :]), axis=1) < 1.5
|
|
294
|
+
same_line = np.isin(np.round(matched[:, 1], 4), np.round(anchors[:, 1], 4))
|
|
295
|
+
matched = matched[~(near_anchor | same_line)]
|
|
296
|
+
candidate = np.vstack([anchors, matched])
|
|
297
|
+
if len(candidate) < 2:
|
|
298
|
+
break
|
|
299
|
+
pairs = candidate
|
|
300
|
+
coef = fit_polynomial(pairs, min(order, it + 1))
|
|
301
|
+
coef = fit_polynomial(pairs, order)
|
|
302
|
+
return pairs[np.argsort(pairs[:, 0])], coef
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Order stitching stage.
|
|
3
|
+
|
|
4
|
+
Merge overlapping neighboring echelle orders into one continuous 1D spectrum.
|
|
5
|
+
"""
|
|
6
|
+
# 对重叠的相邻光谱阶次进行拼接。
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional, Tuple
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
from specproc.config.config_manager import ConfigManager
|
|
15
|
+
from specproc.core.data_structures import SpectraSet
|
|
16
|
+
from specproc.plotting.spectra_plotter import plot_spectrum_to_file
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# 筛除无效数据并按波长排序。
|
|
22
|
+
def _clean_order_arrays(wavelength: np.ndarray, flux: np.ndarray,
|
|
23
|
+
error: Optional[np.ndarray] = None) -> Tuple[np.ndarray, np.ndarray, Optional[np.ndarray]]:
|
|
24
|
+
"""Clean and sort one order by wavelength."""
|
|
25
|
+
good = np.isfinite(wavelength) & np.isfinite(flux)
|
|
26
|
+
if error is not None:
|
|
27
|
+
good &= np.isfinite(error)
|
|
28
|
+
|
|
29
|
+
wl = wavelength[good]
|
|
30
|
+
fl = flux[good]
|
|
31
|
+
er = error[good] if error is not None else None
|
|
32
|
+
|
|
33
|
+
if wl.size == 0:
|
|
34
|
+
return wl, fl, er
|
|
35
|
+
|
|
36
|
+
idx = np.argsort(wl)
|
|
37
|
+
wl = wl[idx]
|
|
38
|
+
fl = fl[idx]
|
|
39
|
+
if er is not None:
|
|
40
|
+
er = er[idx]
|
|
41
|
+
return wl, fl, er
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# 缩放并合并重叠阶次的光谱。
|
|
45
|
+
def stitch_orders(spectra_set: SpectraSet,
|
|
46
|
+
min_overlap_points: int = 20) -> Tuple[np.ndarray, np.ndarray, Optional[np.ndarray]]:
|
|
47
|
+
"""
|
|
48
|
+
Stitch all orders into one continuous spectrum.
|
|
49
|
+
|
|
50
|
+
The algorithm scales neighboring orders in overlapping wavelength regions,
|
|
51
|
+
then merges overlap by weighted averaging.
|
|
52
|
+
"""
|
|
53
|
+
if spectra_set is None or len(spectra_set.spectra) == 0:
|
|
54
|
+
raise RuntimeError("No spectra available for order stitching")
|
|
55
|
+
|
|
56
|
+
items = []
|
|
57
|
+
for spec in spectra_set.spectra.values():
|
|
58
|
+
wl, fl, er = _clean_order_arrays(spec.wavelength, spec.flux, spec.error)
|
|
59
|
+
if wl.size > 0:
|
|
60
|
+
items.append((np.nanmedian(wl), wl, fl, er, spec.aperture))
|
|
61
|
+
|
|
62
|
+
if not items:
|
|
63
|
+
raise RuntimeError("No valid wavelength/flux points for stitching")
|
|
64
|
+
|
|
65
|
+
items.sort(key=lambda t: t[0])
|
|
66
|
+
|
|
67
|
+
stitched_wl = items[0][1].copy()
|
|
68
|
+
stitched_fl = items[0][2].copy()
|
|
69
|
+
stitched_er = items[0][3].copy() if items[0][3] is not None else None
|
|
70
|
+
|
|
71
|
+
for _, next_wl, next_fl, next_er, aperture in items[1:]:
|
|
72
|
+
w_lo = max(stitched_wl.min(), next_wl.min())
|
|
73
|
+
w_hi = min(stitched_wl.max(), next_wl.max())
|
|
74
|
+
|
|
75
|
+
if w_hi > w_lo:
|
|
76
|
+
prev_overlap = (stitched_wl >= w_lo) & (stitched_wl <= w_hi)
|
|
77
|
+
next_overlap = (next_wl >= w_lo) & (next_wl <= w_hi)
|
|
78
|
+
|
|
79
|
+
if np.sum(prev_overlap) >= min_overlap_points and np.sum(next_overlap) >= min_overlap_points:
|
|
80
|
+
grid_n = int(min(np.sum(prev_overlap), np.sum(next_overlap)))
|
|
81
|
+
grid = np.linspace(w_lo, w_hi, max(grid_n, min_overlap_points))
|
|
82
|
+
|
|
83
|
+
prev_interp = np.interp(grid, stitched_wl, stitched_fl)
|
|
84
|
+
next_interp = np.interp(grid, next_wl, next_fl)
|
|
85
|
+
|
|
86
|
+
good = np.isfinite(prev_interp) & np.isfinite(next_interp) & (np.abs(next_interp) > 1e-12)
|
|
87
|
+
if np.any(good):
|
|
88
|
+
scale = np.nanmedian(prev_interp[good] / next_interp[good])
|
|
89
|
+
if np.isfinite(scale) and scale > 0:
|
|
90
|
+
next_fl = next_fl * scale
|
|
91
|
+
if next_er is not None:
|
|
92
|
+
next_er = next_er * scale
|
|
93
|
+
logger.info(f"Order stitching: scaled aperture {aperture} by factor {scale:.5f}")
|
|
94
|
+
|
|
95
|
+
prev_interp = np.interp(grid, stitched_wl, stitched_fl)
|
|
96
|
+
next_interp = np.interp(grid, next_wl, next_fl)
|
|
97
|
+
|
|
98
|
+
if stitched_er is not None and next_er is not None:
|
|
99
|
+
prev_ei = np.interp(grid, stitched_wl, stitched_er)
|
|
100
|
+
next_ei = np.interp(grid, next_wl, next_er)
|
|
101
|
+
w1 = 1.0 / np.clip(prev_ei, 1e-12, None) ** 2
|
|
102
|
+
w2 = 1.0 / np.clip(next_ei, 1e-12, None) ** 2
|
|
103
|
+
overlap_flux = (prev_interp * w1 + next_interp * w2) / (w1 + w2 + 1e-12)
|
|
104
|
+
overlap_err = np.sqrt(1.0 / (w1 + w2 + 1e-12))
|
|
105
|
+
else:
|
|
106
|
+
overlap_flux = 0.5 * (prev_interp + next_interp)
|
|
107
|
+
overlap_err = None
|
|
108
|
+
|
|
109
|
+
prev_keep = stitched_wl < w_lo
|
|
110
|
+
next_keep = next_wl > w_hi
|
|
111
|
+
|
|
112
|
+
new_wl = np.concatenate([stitched_wl[prev_keep], grid, next_wl[next_keep]])
|
|
113
|
+
new_fl = np.concatenate([stitched_fl[prev_keep], overlap_flux, next_fl[next_keep]])
|
|
114
|
+
|
|
115
|
+
if stitched_er is not None and next_er is not None and overlap_err is not None:
|
|
116
|
+
new_er = np.concatenate([stitched_er[prev_keep], overlap_err, next_er[next_keep]])
|
|
117
|
+
else:
|
|
118
|
+
new_er = None
|
|
119
|
+
|
|
120
|
+
stitched_wl, stitched_fl, stitched_er = new_wl, new_fl, new_er
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
stitched_wl = np.concatenate([stitched_wl, next_wl])
|
|
124
|
+
stitched_fl = np.concatenate([stitched_fl, next_fl])
|
|
125
|
+
if stitched_er is not None and next_er is not None:
|
|
126
|
+
stitched_er = np.concatenate([stitched_er, next_er])
|
|
127
|
+
else:
|
|
128
|
+
stitched_er = None
|
|
129
|
+
|
|
130
|
+
idx = np.argsort(stitched_wl)
|
|
131
|
+
stitched_wl = stitched_wl[idx]
|
|
132
|
+
stitched_fl = stitched_fl[idx]
|
|
133
|
+
if stitched_er is not None:
|
|
134
|
+
stitched_er = stitched_er[idx]
|
|
135
|
+
|
|
136
|
+
return stitched_wl, stitched_fl, stitched_er
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# 将拼接光谱保存为FITS表格。
|
|
140
|
+
def save_stitched_spectrum(output_path: str, wavelength: np.ndarray,
|
|
141
|
+
flux: np.ndarray, error: Optional[np.ndarray] = None):
|
|
142
|
+
"""Save stitched 1D spectrum as FITS table."""
|
|
143
|
+
from astropy.io import fits
|
|
144
|
+
|
|
145
|
+
out = Path(output_path)
|
|
146
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
147
|
+
|
|
148
|
+
cols = [
|
|
149
|
+
fits.Column(name='WAVELENGTH', format='D', array=wavelength),
|
|
150
|
+
fits.Column(name='FLUX', format='D', array=flux),
|
|
151
|
+
]
|
|
152
|
+
if error is not None:
|
|
153
|
+
cols.append(fits.Column(name='ERROR', format='D', array=error))
|
|
154
|
+
|
|
155
|
+
table = fits.BinTableHDU.from_columns(cols)
|
|
156
|
+
pri = fits.PrimaryHDU()
|
|
157
|
+
pri.header['NSPEC'] = (len(wavelength), 'Number of stitched pixels')
|
|
158
|
+
pri.header['CONTENT'] = ('Stitched 1D spectrum', 'Order stitching output')
|
|
159
|
+
|
|
160
|
+
fits.HDUList([pri, table]).writeto(str(out), overwrite=True)
|
|
161
|
+
logger.info(f"Saved stitched spectrum to {out}")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# 执行阶次拼接并保存结果。
|
|
165
|
+
def process_order_stitching_stage(spectra_set: SpectraSet,
|
|
166
|
+
output_dir_base: str,
|
|
167
|
+
output_subdir: str = 'step8_stitching',
|
|
168
|
+
save_plots: bool = True,
|
|
169
|
+
fig_format: str = 'png') -> Tuple[np.ndarray, np.ndarray, Optional[np.ndarray]]:
|
|
170
|
+
"""Run order stitching stage and save outputs."""
|
|
171
|
+
wl, fl, er = stitch_orders(spectra_set)
|
|
172
|
+
|
|
173
|
+
out_dir = Path(output_dir_base) / output_subdir
|
|
174
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
175
|
+
|
|
176
|
+
save_stitched_spectrum(str(out_dir / 'stitched_spectrum.fits'), wl, fl, er)
|
|
177
|
+
|
|
178
|
+
if save_plots:
|
|
179
|
+
plot_spectrum_to_file(
|
|
180
|
+
wl,
|
|
181
|
+
fl,
|
|
182
|
+
str(out_dir / f'stitched_spectrum.{fig_format}'),
|
|
183
|
+
er,
|
|
184
|
+
'Stitched 1D Spectrum'
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
return wl, fl, er
|