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,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