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,731 @@
1
+ """
2
+ Interactive lamp-line identification for long-slit spectra.
3
+
4
+ The window shows the extracted 1-D lamp spectrum (top, versus pixel) above the
5
+ reference line list drawn as delta functions (bottom, versus wavelength, zero
6
+ baseline). Compare the two patterns by eye, pair a few obvious lines (click a
7
+ lamp peak, then the matching reference line), and press "Auto identify": the
8
+ manual pairs seed a dispersion solution and all lamp peaks above the SNR
9
+ threshold are matched to the catalogue. Save the result and use it as
10
+ ``[slit] identification_file``.
11
+
12
+ Run with ``specproc-identify`` or ``python -m specproc.gui.line_identifier``.
13
+ """
14
+
15
+ import argparse
16
+ import sys
17
+ from pathlib import Path
18
+ from typing import Optional
19
+
20
+ import numpy as np
21
+ from PyQt5 import QtWidgets
22
+ from PyQt5.QtCore import Qt
23
+ from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg, NavigationToolbar2QT
24
+ from matplotlib.figure import Figure
25
+
26
+ from specproc.core.line_identification import (
27
+ find_arc_peaks, fit_polynomial, load_anchors, load_lamp_spectrum, load_linelist,
28
+ refine_with_catalog, save_anchors)
29
+
30
+ MIN_DETECT_SNR = 3.0
31
+
32
+
33
+ class LineIdentifier(QtWidgets.QMainWindow):
34
+ """Lamp spectrum above, reference delta-function spectrum below."""
35
+
36
+ def __init__(self, lamp_path: Optional[str] = None, linelist_path: Optional[str] = None,
37
+ anchor_path: Optional[str] = None, dispersion_axis: str = 'y'):
38
+ super().__init__()
39
+ self.setWindowTitle('SpecProc - Lamp Line Identification')
40
+ self.resize(1450, 850)
41
+ self.dispersion_axis = dispersion_axis
42
+ self.flux = np.array([])
43
+ self.peaks = np.empty((0, 3)) # pixel, amplitude, snr
44
+ self.ref_wave = np.array([])
45
+ self.ref_label = []
46
+ self.ref_strength = np.array([])
47
+ self.manual = [] # (pixel, wavelength) picked by hand
48
+ self.auto = [] # (pixel, wavelength) from auto identification
49
+ self.coef = None
50
+ self.sel_peak: Optional[float] = None
51
+ self.sel_ref: Optional[float] = None
52
+ self.lamp_path = ''
53
+ self.anchor_path = ''
54
+ self._syncing = False
55
+ self._ref_texts = []
56
+ self._build_ui()
57
+ if linelist_path:
58
+ self.load_linelist(linelist_path)
59
+ if lamp_path:
60
+ self.load_lamp(lamp_path)
61
+ if anchor_path:
62
+ self.load_anchor_file(anchor_path)
63
+
64
+ # ------------------------------------------------------------------ UI
65
+ def _build_ui(self):
66
+ QW = QtWidgets
67
+ tb = QW.QToolBar('Files')
68
+ tb.setMovable(False)
69
+ self.addToolBar(tb)
70
+ for text, slot in (('Load reference lines…', self.choose_linelist),
71
+ ('Load lamp spectrum…', self.choose_lamp),
72
+ ('Load identified lines…', self.choose_anchors),
73
+ ('Save identified lines…', self.save_dialog)):
74
+ tb.addAction(text).triggered.connect(slot)
75
+
76
+ central = QW.QWidget()
77
+ self.setCentralWidget(central)
78
+ layout = QW.QHBoxLayout(central)
79
+
80
+ left = QW.QVBoxLayout()
81
+ self.fig = Figure(figsize=(10, 7))
82
+ gs = self.fig.add_gridspec(2, 1, height_ratios=[3, 2], hspace=0.28)
83
+ self.ax = self.fig.add_subplot(gs[0])
84
+ self.ref_ax = self.fig.add_subplot(gs[1])
85
+ self.canvas = FigureCanvasQTAgg(self.fig)
86
+ self.toolbar = NavigationToolbar2QT(self.canvas, self)
87
+ left.addWidget(self.toolbar)
88
+ left.addWidget(self.canvas, 1)
89
+ self.canvas.mpl_connect('button_press_event', self.on_click)
90
+ self.canvas.mpl_connect('motion_notify_event', self.on_move)
91
+ self.canvas.mpl_connect('scroll_event', self.on_scroll)
92
+ self.canvas.mpl_connect('key_press_event', self.on_key)
93
+ self.canvas.setFocusPolicy(Qt.StrongFocus)
94
+ self.ax.callbacks.connect('xlim_changed', lambda ax: self.sync_reference_view())
95
+
96
+ opts = QW.QHBoxLayout()
97
+ self.log_box = QW.QCheckBox('Log y')
98
+ self.log_box.setChecked(True)
99
+ self.log_box.stateChanged.connect(lambda *_: self.redraw())
100
+ self.norm_box = QW.QCheckBox('Normalize')
101
+ self.norm_box.setToolTip('Show the lamp spectrum scaled to 0-1 (baseline removed, '
102
+ 'brightest line = 1)')
103
+ self.norm_box.stateChanged.connect(lambda *_: self.redraw())
104
+ self.sync_box = QW.QCheckBox('Sync reference view')
105
+ self.sync_box.setChecked(True)
106
+ self.sync_box.setToolTip('Once a dispersion model exists (≥2 pairs or a λ hint), '
107
+ 'zooming the lamp spectrum moves the reference panel too.')
108
+ self.sync_box.stateChanged.connect(lambda *_: self.sync_reference_view())
109
+ self.lo_spin = QW.QDoubleSpinBox()
110
+ self.hi_spin = QW.QDoubleSpinBox()
111
+ for sp in (self.lo_spin, self.hi_spin):
112
+ sp.setRange(0.0, 100000.0)
113
+ sp.setDecimals(1)
114
+ sp.setSingleStep(50.0)
115
+ sp.valueChanged.connect(lambda *_: self.redraw())
116
+ self.w0_spin = QW.QDoubleSpinBox()
117
+ self.w1_spin = QW.QDoubleSpinBox()
118
+ for sp in (self.w0_spin, self.w1_spin):
119
+ sp.setRange(0.0, 30000.0)
120
+ sp.setDecimals(1)
121
+ sp.setSpecialValueText('off')
122
+ sp.valueChanged.connect(lambda *_: self.update_model())
123
+ for w in (self.log_box, self.norm_box, self.sync_box,
124
+ QW.QLabel('λ hint at first/last pixel'), self.w0_spin, self.w1_spin):
125
+ opts.addWidget(w)
126
+ opts.addStretch(1)
127
+ left.addLayout(opts)
128
+ ov = QW.QHBoxLayout()
129
+ self.ov_box = QW.QCheckBox('Overlay reference lines on lamp spectrum')
130
+ self.ov_box.setToolTip('Draws the catalogue as vertical lines on the lamp spectrum using '
131
+ 'the current model. Align them with shift/stretch (or the arrow '
132
+ 'keys: ←/→ shift, ↑/↓ stretch), then press Fit overlay.')
133
+ self.ov_box.stateChanged.connect(lambda *_: self.redraw(keep_view=True))
134
+ self.flip_box = QW.QCheckBox('Flip')
135
+ self.flip_box.setToolTip('Wavelength decreases with pixel (used before any model exists)')
136
+ self.flip_box.stateChanged.connect(lambda *_: self.redraw(keep_view=True))
137
+ self.shift_spin = QW.QDoubleSpinBox()
138
+ self.shift_spin.setRange(-5000.0, 5000.0)
139
+ self.shift_spin.setDecimals(2)
140
+ self.shift_spin.setSingleStep(1.0)
141
+ self.shift_spin.setSuffix(' Å')
142
+ self.stretch_spin = QW.QDoubleSpinBox()
143
+ self.stretch_spin.setRange(-50.0, 50.0)
144
+ self.stretch_spin.setDecimals(2)
145
+ self.stretch_spin.setSingleStep(0.1)
146
+ self.stretch_spin.setSuffix(' %')
147
+ for sp in (self.shift_spin, self.stretch_spin):
148
+ sp.valueChanged.connect(lambda *_: self.redraw(keep_view=True))
149
+ fit_btn = QW.QPushButton('Fit overlay')
150
+ fit_btn.setToolTip('Match lamp peaks to the overlaid lines and fit the dispersion')
151
+ fit_btn.clicked.connect(self.fit_overlay)
152
+ reset_btn = QW.QPushButton('Reset shift/stretch')
153
+ reset_btn.clicked.connect(self.reset_overlay)
154
+ for w in (self.ov_box, self.flip_box, QW.QLabel('Shift'), self.shift_spin,
155
+ QW.QLabel('Stretch'), self.stretch_spin, reset_btn, fit_btn):
156
+ ov.addWidget(w)
157
+ ov.addStretch(1)
158
+ left.addLayout(ov)
159
+ rng = QW.QHBoxLayout()
160
+ rng.addWidget(QW.QLabel('Reference wavelength range: low'))
161
+ rng.addWidget(self.lo_spin)
162
+ rng.addWidget(QW.QLabel('upper'))
163
+ rng.addWidget(self.hi_spin)
164
+ rng.addWidget(QW.QLabel('Å R=λ/Δλ'))
165
+ self.res_spin = QW.QDoubleSpinBox()
166
+ self.res_spin.setRange(0.0, 1000000.0)
167
+ self.res_spin.setDecimals(0)
168
+ self.res_spin.setSingleStep(500.0)
169
+ self.res_spin.setSpecialValueText('off')
170
+ self.res_spin.setToolTip('Spectral resolution. Draws the reference lines broadened to '
171
+ 'this resolution and sets the final match tolerance to one FWHM.')
172
+ self.res_spin.valueChanged.connect(lambda *_: self.redraw(keep_view=True))
173
+ rng.addWidget(self.res_spin)
174
+ rng.addStretch(1)
175
+ left.addLayout(rng)
176
+ layout.addLayout(left, 1)
177
+
178
+ right = QW.QVBoxLayout()
179
+ self.pick_label = QW.QLabel()
180
+ self.pick_label.setWordWrap(True)
181
+ right.addWidget(self.pick_label)
182
+ pick = QW.QHBoxLayout()
183
+ add_btn = QW.QPushButton('Add pair')
184
+ add_btn.setToolTip('Pair the selected lamp peak with the selected reference line')
185
+ add_btn.clicked.connect(self.add_pair)
186
+ cancel = QW.QPushButton('Cancel selection')
187
+ cancel.clicked.connect(self.cancel_selection)
188
+ pick.addWidget(add_btn)
189
+ pick.addWidget(cancel)
190
+ right.addLayout(pick)
191
+
192
+ right.addWidget(QW.QLabel('Identified lines (pixel is 0-based)'))
193
+ self.table = QW.QTableWidget(0, 4)
194
+ self.table.setHorizontalHeaderLabels(['Type', 'Pixel', 'λ (Å)', 'Resid. (Å)'])
195
+ self.table.horizontalHeader().setStretchLastSection(True)
196
+ self.table.setSelectionBehavior(QW.QAbstractItemView.SelectRows)
197
+ self.table.setEditTriggers(QW.QAbstractItemView.NoEditTriggers)
198
+ right.addWidget(self.table, 1)
199
+ btns = QW.QHBoxLayout()
200
+ for text, slot in (('Remove selected', self.remove_selected),
201
+ ('Clear manual', self.clear_manual),
202
+ ('Clear auto', self.clear_auto)):
203
+ b = QW.QPushButton(text)
204
+ b.clicked.connect(slot)
205
+ btns.addWidget(b)
206
+ right.addLayout(btns)
207
+
208
+ box = QW.QGroupBox('Auto identification')
209
+ form = QW.QFormLayout(box)
210
+ self.snr_spin = QW.QDoubleSpinBox()
211
+ self.snr_spin.setRange(MIN_DETECT_SNR, 1000.0)
212
+ self.snr_spin.setValue(8.0)
213
+ self.snr_spin.setToolTip('Only lamp peaks with peak/noise above this value are matched '
214
+ '(also set [slit] peak_snr in the config).')
215
+ self.snr_spin.valueChanged.connect(lambda *_: self.redraw(keep_view=True))
216
+ self.order_spin = QW.QSpinBox()
217
+ self.order_spin.setRange(1, 7)
218
+ self.order_spin.setValue(3)
219
+ self.order_spin.valueChanged.connect(lambda *_: self.update_model())
220
+ self.tol_spin = QW.QDoubleSpinBox()
221
+ self.tol_spin.setRange(0.1, 100.0)
222
+ self.tol_spin.setValue(3.0)
223
+ self.tol_spin.setSuffix(' Å')
224
+ form.addRow('Peak SNR threshold', self.snr_spin)
225
+ form.addRow('Polynomial order', self.order_spin)
226
+ form.addRow('Initial match tolerance', self.tol_spin)
227
+ run = QW.QPushButton('Auto identify')
228
+ run.clicked.connect(self.auto_identify)
229
+ form.addRow(run)
230
+ right.addWidget(box)
231
+
232
+ self.fit_label = QW.QLabel('')
233
+ self.fit_label.setWordWrap(True)
234
+ right.addWidget(self.fit_label)
235
+ help_txt = QW.QLabel(
236
+ 'Click a lamp peak (top), then the matching reference line (bottom): the pair is '
237
+ 'added. Right-click near an identified line to remove it. Zoom/pan with the toolbar '
238
+ '(clicks are ignored while a tool is active) or the mouse wheel.')
239
+ help_txt.setWordWrap(True)
240
+ right.addWidget(help_txt)
241
+ wrap = QW.QWidget()
242
+ wrap.setLayout(right)
243
+ wrap.setFixedWidth(400)
244
+ layout.addWidget(wrap)
245
+ self.update_pick_label()
246
+ self.statusBar().showMessage('Load a reference line list and a lamp spectrum to begin.')
247
+
248
+ # ------------------------------------------------------------ file I/O
249
+ def choose_linelist(self):
250
+ path, _ = QtWidgets.QFileDialog.getOpenFileName(
251
+ self, 'Reference line list', '', 'Line lists (*.dat *.txt *.csv);;All files (*)')
252
+ if path:
253
+ self.load_linelist(path)
254
+
255
+ def choose_lamp(self):
256
+ path, _ = QtWidgets.QFileDialog.getOpenFileName(
257
+ self, 'Extracted 1-D lamp spectrum', '',
258
+ 'Spectra (*.fits *.fit *.txt *.csv *.dat);;All files (*)')
259
+ if path:
260
+ self.load_lamp(path)
261
+
262
+ def choose_anchors(self):
263
+ path, _ = QtWidgets.QFileDialog.getOpenFileName(
264
+ self, 'Identified lines', '', 'CSV/text (*.csv *.txt);;All files (*)')
265
+ if path:
266
+ self.load_anchor_file(path)
267
+
268
+ def load_linelist(self, path: str):
269
+ try:
270
+ self.ref_wave, self.ref_label, self.ref_strength = load_linelist(path)
271
+ except Exception as exc:
272
+ QtWidgets.QMessageBox.warning(self, 'Reference lines', str(exc))
273
+ return
274
+ for sp, v in ((self.lo_spin, self.ref_wave[0] - 1), (self.hi_spin, self.ref_wave[-1] + 1)):
275
+ sp.blockSignals(True)
276
+ sp.setValue(max(v, 0.0))
277
+ sp.blockSignals(False)
278
+ self.redraw()
279
+ self.statusBar().showMessage(f'{len(self.ref_wave)} reference lines loaded from {path}')
280
+
281
+ def load_lamp(self, path: str):
282
+ try:
283
+ self.flux = load_lamp_spectrum(path, self.dispersion_axis)
284
+ except Exception as exc:
285
+ QtWidgets.QMessageBox.warning(self, 'Lamp spectrum', str(exc))
286
+ return
287
+ self.lamp_path = path
288
+ self.peaks = np.array(find_arc_peaks(self.flux, MIN_DETECT_SNR, with_snr=True),
289
+ dtype=float).reshape(-1, 3)
290
+ self.sel_peak = self.sel_ref = None
291
+ self.update_pick_label()
292
+ self.coef = self.current_coef()
293
+ self.redraw()
294
+ self.statusBar().showMessage(f'{len(self.flux)} pixels, {len(self.peaks)} peaks '
295
+ f'(SNR ≥ {MIN_DETECT_SNR:g}): {path}')
296
+
297
+ def load_anchor_file(self, path: str):
298
+ try:
299
+ pairs = load_anchors(path)
300
+ except Exception as exc:
301
+ QtWidgets.QMessageBox.warning(self, 'Identified lines', str(exc))
302
+ return
303
+ self.manual = [(float(x), float(w)) for x, w in pairs]
304
+ self.auto = []
305
+ self.anchor_path = path
306
+ self.update_model()
307
+ self.statusBar().showMessage(f'{len(self.manual)} lines loaded as manual pairs from {path}')
308
+
309
+ def save_dialog(self):
310
+ pairs = self.all_pairs()
311
+ if not pairs:
312
+ QtWidgets.QMessageBox.information(self, 'Save', 'No identified lines to save.')
313
+ return
314
+ if self.anchor_path:
315
+ default = self.anchor_path
316
+ elif self.lamp_path:
317
+ default = str(Path(self.lamp_path).with_suffix('')) + '_ids.csv'
318
+ else:
319
+ default = 'ids.csv'
320
+ path, _ = QtWidgets.QFileDialog.getSaveFileName(
321
+ self, 'Save identified lines', default, 'CSV (*.csv);;All files (*)')
322
+ if not path:
323
+ return
324
+ note = (f'lamp: {Path(self.lamp_path).name}; {len(self.manual)} manual + '
325
+ f'{len(self.auto)} auto (SNR>={self.snr_spin.value():g})')
326
+ save_anchors(path, np.array(pairs), note=note)
327
+ self.anchor_path = path
328
+ self.statusBar().showMessage(f'{len(pairs)} lines saved to {path}')
329
+
330
+ # ------------------------------------------------------------- state
331
+ def min_tol(self):
332
+ """Final match tolerance: one resolution FWHM at mid-range, else the default 0.4 Å."""
333
+ R = self.res_spin.value()
334
+ if R <= 0:
335
+ return 0.4
336
+ return max((self.lo_spin.value() + self.hi_spin.value()) / 2 / R, 0.05)
337
+
338
+ def display_flux(self):
339
+ """Lamp flux as plotted: raw counts, or 0-1 with the baseline removed."""
340
+ if not self.norm_box.isChecked() or len(self.flux) == 0:
341
+ return self.flux
342
+ base = np.percentile(self.flux, 1)
343
+ top = max(np.nanmax(self.flux) - base, 1e-12)
344
+ out = (self.flux - base) / top
345
+ return np.clip(out, 1e-3, None) if self.log_box.isChecked() else out
346
+
347
+ def ref_mask(self):
348
+ return (self.ref_wave >= self.lo_spin.value()) & (self.ref_wave <= self.hi_spin.value())
349
+
350
+ def all_pairs(self):
351
+ return sorted(self.manual + self.auto)
352
+
353
+ def current_coef(self):
354
+ """Dispersion model from identified pairs or the wavelength hints."""
355
+ if len(self.manual) >= 2:
356
+ return fit_polynomial(np.array(self.manual), self.order_spin.value())
357
+ pairs = self.all_pairs()
358
+ if len(pairs) >= 2:
359
+ return fit_polynomial(np.array(pairs), self.order_spin.value())
360
+ w0, w1 = self.w0_spin.value(), self.w1_spin.value()
361
+ if w0 > 0 and w1 > 0 and len(self.flux) > 1:
362
+ return np.polyfit([0, len(self.flux) - 1], [w0, w1], 1)
363
+ return None
364
+
365
+ def overlay_model(self):
366
+ """Pixel -> wavelength model for the overlay: base model plus shift and stretch."""
367
+ n = len(self.flux)
368
+ if n < 2:
369
+ return None
370
+ pix = np.arange(n, dtype=float)
371
+ if self.coef is not None:
372
+ base = np.polyval(self.coef, pix)
373
+ else:
374
+ lo, hi = self.lo_spin.value(), self.hi_spin.value()
375
+ if hi <= lo:
376
+ return None
377
+ base = np.linspace(hi, lo, n) if self.flip_box.isChecked() else np.linspace(lo, hi, n)
378
+ mid = base[n // 2]
379
+ return mid + (base - mid) * (1 + self.stretch_spin.value() / 100) + self.shift_spin.value()
380
+
381
+ def reset_overlay(self):
382
+ for sp in (self.shift_spin, self.stretch_spin):
383
+ sp.blockSignals(True)
384
+ sp.setValue(0)
385
+ sp.blockSignals(False)
386
+ self.redraw(keep_view=True)
387
+
388
+ def fit_overlay(self):
389
+ model = self.overlay_model()
390
+ if model is None or len(self.peaks) == 0 or len(self.ref_wave) == 0:
391
+ self.statusBar().showMessage('Load lamp and line list and set the wavelength range.')
392
+ return
393
+ pix = np.arange(len(model), dtype=float)
394
+ start = np.polyfit(pix, model, min(self.order_spin.value(), 3))
395
+ use = self.peaks[:, 2] >= self.snr_spin.value()
396
+ pairs, _ = refine_with_catalog(self.peaks[use, 0], np.array(self.manual).reshape(-1, 2),
397
+ self.ref_wave[self.ref_mask()],
398
+ self.order_spin.value(), self.tol_spin.value(),
399
+ min_tol=self.min_tol(), initial_coef=start)
400
+ if len(pairs) < 2:
401
+ self.statusBar().showMessage('Too few lines matched; align the overlay better or '
402
+ 'increase the match tolerance.')
403
+ return
404
+ manual_px = np.array([m[0] for m in self.manual])
405
+ if len(manual_px):
406
+ pairs = pairs[np.min(np.abs(pairs[:, :1] - manual_px[None, :]), axis=1) > 1.0]
407
+ self.auto = [(float(x), float(w)) for x, w in pairs]
408
+ for sp in (self.shift_spin, self.stretch_spin):
409
+ sp.blockSignals(True)
410
+ sp.setValue(0)
411
+ sp.blockSignals(False)
412
+ self.update_model()
413
+ self.statusBar().showMessage(f'Fit overlay: {len(self.auto)} auto + {len(self.manual)} '
414
+ f'manual lines; {self.fit_label.text()}')
415
+
416
+ def on_key(self, event):
417
+ key = event.key
418
+ if key in ('left', 'right') and self.ov_box.isChecked():
419
+ model = self.overlay_model()
420
+ step = abs(np.median(np.diff(model))) if model is not None else 1.0
421
+ self.shift_spin.setValue(self.shift_spin.value() + (step if key == 'right' else -step))
422
+ elif key in ('up', 'down') and self.ov_box.isChecked():
423
+ self.stretch_spin.setValue(self.stretch_spin.value() + (0.05 if key == 'up' else -0.05))
424
+
425
+ def update_model(self):
426
+ self.manual.sort()
427
+ self.coef = self.current_coef()
428
+ self.refresh_table()
429
+ self.redraw(keep_view=True)
430
+ self.sync_reference_view()
431
+
432
+ def refresh_table(self):
433
+ rows = [('manual', x, w) for x, w in self.manual] + [('auto', x, w) for x, w in self.auto]
434
+ rows.sort(key=lambda r: r[1])
435
+ pairs = np.array([(r[1], r[2]) for r in rows]).reshape(-1, 2)
436
+ coef = fit_polynomial(pairs, self.order_spin.value()) if len(pairs) > 2 else None
437
+ self.table.setRowCount(len(rows))
438
+ for i, (kind, x, w) in enumerate(rows):
439
+ res = '%.3f' % (w - np.polyval(coef, x)) if coef is not None else ''
440
+ for j, text in enumerate((kind, '%.2f' % x, '%.4f' % w, res)):
441
+ self.table.setItem(i, j, QtWidgets.QTableWidgetItem(text))
442
+ if coef is not None:
443
+ rms = np.std(pairs[:, 1] - np.polyval(coef, pairs[:, 0]))
444
+ self.fit_label.setText(f'{len(self.manual)} manual + {len(self.auto)} auto lines, '
445
+ f'order {len(coef) - 1}, rms = {rms:.3f} Å')
446
+ else:
447
+ if self.auto:
448
+ self.fit_label.setText(f'{len(self.auto)} auto-identified lines')
449
+ else:
450
+ self.fit_label.setText(
451
+ 'Manual pairs are optional. Auto identify can start from the low/upper '
452
+ 'wavelength range and tries both dispersion directions.')
453
+
454
+ # -------------------------------------------------- picking and editing
455
+ def update_pick_label(self):
456
+ peak = 'none' if self.sel_peak is None else 'pixel %.2f' % self.sel_peak
457
+ ref = 'none' if self.sel_ref is None else '%.4f Å' % self.sel_ref
458
+ self.pick_label.setText(f'Selected lamp peak: {peak}\nSelected reference line: {ref}')
459
+
460
+ def cancel_selection(self):
461
+ self.sel_peak = self.sel_ref = None
462
+ self.update_pick_label()
463
+ self.redraw(keep_view=True)
464
+
465
+ def add_pair(self):
466
+ if self.sel_peak is None or self.sel_ref is None:
467
+ self.statusBar().showMessage('Select both a lamp peak and a reference line first.')
468
+ return
469
+ x, w = self.sel_peak, self.sel_ref
470
+ self.manual = [m for m in self.manual if abs(m[0] - x) > 1.0 and abs(m[1] - w) > 1e-3]
471
+ self.auto = [a for a in self.auto if abs(a[0] - x) > 1.0 and abs(a[1] - w) > 1e-3]
472
+ self.manual.append((x, w))
473
+ self.sel_peak = self.sel_ref = None
474
+ self.update_pick_label()
475
+ self.update_model()
476
+
477
+ def remove_selected(self):
478
+ for r in sorted({i.row() for i in self.table.selectedIndexes()}):
479
+ kind = self.table.item(r, 0).text()
480
+ key = (float(self.table.item(r, 1).text()), float(self.table.item(r, 2).text()))
481
+ lst = self.manual if kind == 'manual' else self.auto
482
+ lst[:] = [p for p in lst if abs(p[0] - key[0]) > 0.01 or abs(p[1] - key[1]) > 1e-4]
483
+ self.update_model()
484
+
485
+ def clear_manual(self):
486
+ self.manual = []
487
+ self.update_model()
488
+
489
+ def clear_auto(self):
490
+ self.auto = []
491
+ self.update_model()
492
+
493
+ def auto_identify(self):
494
+ if len(self.ref_wave) == 0 or len(self.peaks) == 0:
495
+ self.statusBar().showMessage('Load a reference line list and a lamp spectrum first.')
496
+ return
497
+ low, high = self.lo_spin.value(), self.hi_spin.value()
498
+ if high <= low:
499
+ self.statusBar().showMessage('Set a valid wavelength range: upper must exceed low.')
500
+ return
501
+ use = self.peaks[:, 2] >= self.snr_spin.value()
502
+ pairs, _ = refine_with_catalog(self.peaks[use, 0], np.array(self.manual).reshape(-1, 2),
503
+ self.ref_wave[self.ref_mask()],
504
+ self.order_spin.value(), self.tol_spin.value(),
505
+ min_tol=self.min_tol(),
506
+ initial_range=(low, high) if not self.manual else None,
507
+ initial_pixel_range=(0, len(self.flux) - 1),
508
+ peak_amp=self.peaks[use, 1])
509
+ if len(pairs) == 0:
510
+ self.statusBar().showMessage(
511
+ 'No lines matched. Check the wavelength range, SNR threshold, line list, '
512
+ 'and initial match tolerance.')
513
+ return
514
+ manual_px = np.array([m[0] for m in self.manual])
515
+ if len(manual_px):
516
+ pairs = pairs[np.min(np.abs(pairs[:, :1] - manual_px[None, :]), axis=1) > 1.0]
517
+ self.auto = [(float(x), float(w)) for x, w in pairs]
518
+ self.update_model()
519
+ self.statusBar().showMessage(
520
+ f'Auto identify: {len(self.auto)} lines matched with {len(self.manual)} manual '
521
+ f'({int(use.sum())} peaks with SNR ≥ {self.snr_spin.value():g})')
522
+
523
+ # ---------------------------------------------------------- plotting
524
+ def redraw(self, keep_view: bool = False):
525
+ view_top = (self.ax.get_xlim(), self.ax.get_ylim()) if keep_view and len(self.flux) else None
526
+ view_ref = self.ref_ax.get_xlim() if keep_view and len(self.ref_wave) else None
527
+ self._syncing = True
528
+ self.ax.clear()
529
+ self.ref_ax.clear()
530
+ self._ref_texts = []
531
+ thr = self.snr_spin.value()
532
+ if len(self.flux):
533
+ x = np.arange(len(self.flux))
534
+ disp = self.display_flux()
535
+ self.ax.plot(x, disp, color='tab:blue', lw=0.7)
536
+ if self.log_box.isChecked():
537
+ self.ax.set_yscale('log')
538
+ pos = disp[disp > 0]
539
+ self.ax.set_ylim(max(np.percentile(pos, 5) if len(pos) else 1, 1e-3),
540
+ np.nanmax(disp) * 3)
541
+ elif self.norm_box.isChecked():
542
+ self.ax.set_ylim(-0.05, 1.1)
543
+ if len(self.peaks):
544
+ strong = self.peaks[:, 2] >= thr
545
+ for sel, color, size in ((strong, 'tab:red', 5), (~strong, '0.6', 3)):
546
+ p = self.peaks[sel]
547
+ self.ax.plot(p[:, 0], np.interp(p[:, 0], x, disp) * 1.08, 'v',
548
+ color=color, ms=size, ls='none')
549
+ self.ax.set_xlim(-0.5, len(self.flux) - 0.5)
550
+ for pairs, color in ((self.manual, 'tab:green'), (self.auto, 'tab:cyan')):
551
+ for px, wv in pairs:
552
+ self.ax.axvline(px, color=color, lw=1.0, alpha=0.9)
553
+ self.ax.text(px, 1.0, '%.1f' % wv, rotation=90, ha='right', va='top',
554
+ transform=self.ax.get_xaxis_transform(), color=color, fontsize=7)
555
+ if self.sel_peak is not None:
556
+ self.ax.axvline(self.sel_peak, color='orange', lw=1.6, ls='--')
557
+ model = self.overlay_model() if self.ov_box.isChecked() and len(self.ref_wave) else None
558
+ if model is not None and np.all(np.diff(model) != 0) and \
559
+ (np.all(np.diff(model) > 0) or np.all(np.diff(model) < 0)):
560
+ m = self.ref_mask() & (self.ref_wave >= model.min()) & (self.ref_wave <= model.max())
561
+ order = np.argsort(model)
562
+ px = np.interp(self.ref_wave[m], model[order], x[order])
563
+ st = self.ref_strength[m] / max(np.nanmax(self.ref_strength), 1e-12)
564
+ self.ax.vlines(px, 0, np.maximum(st, 0.3) * 0.4, color='tab:orange', lw=0.8,
565
+ alpha=0.85, transform=self.ax.get_xaxis_transform())
566
+ self.ax.set_xlabel('Pixel (0-based)')
567
+ self.ax.set_ylabel('Normalized flux' if self.norm_box.isChecked() else 'Counts')
568
+ self.ax.set_title((Path(self.lamp_path).name if self.lamp_path else 'Lamp spectrum') +
569
+ f' (red ▼: peaks with SNR ≥ {thr:g}; grey: weaker)')
570
+
571
+ if len(self.ref_wave):
572
+ strength = self.ref_strength / max(np.nanmax(self.ref_strength), 1e-12)
573
+ m = self.ref_mask()
574
+ self.ref_ax.vlines(self.ref_wave[m], 0, np.maximum(strength[m], 0.15), color='k', lw=0.8)
575
+ self.ref_ax.axhline(0, color='0.5', lw=0.6)
576
+ R = self.res_spin.value()
577
+ lo, hi = self.lo_spin.value(), self.hi_spin.value()
578
+ if R > 0 and hi > lo and m.any():
579
+ fw = (lo + hi) / 2 / R
580
+ grid = np.linspace(lo, hi, int(min(max((hi - lo) / (fw / 6), 2), 40000)))
581
+ sig = fw / 2.355
582
+ prof = np.zeros_like(grid)
583
+ for wv, st in zip(self.ref_wave[m], np.maximum(strength[m], 0.15)):
584
+ j = np.searchsorted(grid, [wv - 5 * sig, wv + 5 * sig])
585
+ prof[j[0]:j[1]] += st * np.exp(-0.5 * ((grid[j[0]:j[1]] - wv) / sig) ** 2)
586
+ self.ref_ax.plot(grid, np.minimum(prof, 1.2), color='tab:purple', lw=0.7, alpha=0.8)
587
+ for pairs, color, lw in ((self.manual, 'tab:green', 1.2), (self.auto, 'tab:cyan', 0.8)):
588
+ for _, w in pairs:
589
+ self.ref_ax.axvline(w, color=color, lw=lw, alpha=0.8)
590
+ if self.sel_ref is not None:
591
+ self.ref_ax.axvline(self.sel_ref, color='orange', lw=1.6, ls='--')
592
+ self.ref_ax.set_xlim(*(view_ref or (self.lo_spin.value() - 20, self.hi_spin.value() + 20)))
593
+ self.ref_ax.set_ylim(0, 1.25)
594
+ self.ref_ax.set_xlabel('Wavelength (Å)')
595
+ self.ref_ax.set_ylabel('Reference (relative)')
596
+ if view_top:
597
+ self.ax.set_xlim(*view_top[0])
598
+ self.ax.set_ylim(*view_top[1])
599
+ self._syncing = False
600
+ self.label_reference_lines()
601
+ self.canvas.draw_idle()
602
+
603
+ def label_reference_lines(self):
604
+ """Write wavelengths above the stems when few enough are in view."""
605
+ for t in self._ref_texts:
606
+ t.remove()
607
+ self._ref_texts = []
608
+ if len(self.ref_wave) == 0:
609
+ return
610
+ lo, hi = sorted(self.ref_ax.get_xlim())
611
+ sel = np.where((self.ref_wave >= lo) & (self.ref_wave <= hi) & self.ref_mask())[0]
612
+ if len(sel) > 60:
613
+ return
614
+ strength = self.ref_strength / max(np.nanmax(self.ref_strength), 1e-12)
615
+ for i in sel:
616
+ text = '%.2f' % self.ref_wave[i] + (' ' + self.ref_label[i] if self.ref_label[i] else '')
617
+ self._ref_texts.append(self.ref_ax.text(
618
+ self.ref_wave[i], max(strength[i], 0.15) + 0.02, text, rotation=90, ha='center',
619
+ va='bottom', fontsize=7))
620
+
621
+ def sync_reference_view(self):
622
+ """Move the reference panel to the wavelength range predicted for the lamp view."""
623
+ if self._syncing or self.coef is None or not self.sync_box.isChecked() \
624
+ or len(self.ref_wave) == 0 or len(self.flux) == 0:
625
+ return
626
+ x0, x1 = self.ax.get_xlim()
627
+ lo, hi = np.polyval(self.coef, [max(x0, 0), min(x1, len(self.flux) - 1)])
628
+ if lo != hi:
629
+ self._syncing = True
630
+ self.ref_ax.set_xlim(*sorted((lo, hi)))
631
+ self._syncing = False
632
+ self.label_reference_lines()
633
+ self.canvas.draw_idle()
634
+
635
+ # ------------------------------------------------------------ events
636
+ @staticmethod
637
+ def _tol(ax):
638
+ return 0.015 * abs(np.diff(ax.get_xlim())[0])
639
+
640
+ def on_click(self, event):
641
+ if event.inaxes not in (self.ax, self.ref_ax) or event.xdata is None or self.toolbar.mode:
642
+ return
643
+ top = event.inaxes is self.ax
644
+ if event.button == 3:
645
+ self.remove_nearest(event.xdata, top)
646
+ return
647
+ if event.button != 1:
648
+ return
649
+ if top:
650
+ if len(self.peaks) == 0:
651
+ return
652
+ i = int(np.argmin(np.abs(self.peaks[:, 0] - event.xdata)))
653
+ if abs(self.peaks[i, 0] - event.xdata) > self._tol(self.ax):
654
+ self.statusBar().showMessage('No detected peak near the click; zoom in and retry.')
655
+ return
656
+ self.sel_peak = float(self.peaks[i, 0])
657
+ else:
658
+ if len(self.ref_wave) == 0:
659
+ return
660
+ cand = np.where(self.ref_mask())[0]
661
+ if len(cand) == 0:
662
+ return
663
+ i = int(cand[np.argmin(np.abs(self.ref_wave[cand] - event.xdata))])
664
+ if abs(self.ref_wave[i] - event.xdata) > self._tol(self.ref_ax):
665
+ self.statusBar().showMessage('No reference line near the click; zoom in and retry.')
666
+ return
667
+ self.sel_ref = float(self.ref_wave[i])
668
+ self.update_pick_label()
669
+ if self.sel_peak is not None and self.sel_ref is not None:
670
+ self.add_pair()
671
+ else:
672
+ self.redraw(keep_view=True)
673
+
674
+ def remove_nearest(self, xdata, top: bool):
675
+ col = 0 if top else 1
676
+ tol = self._tol(self.ax if top else self.ref_ax)
677
+ best = None
678
+ for lst in (self.manual, self.auto):
679
+ for p in lst:
680
+ d = abs(p[col] - xdata)
681
+ if d <= tol and (best is None or d < best[0]):
682
+ best = (d, lst, p)
683
+ if best:
684
+ best[1].remove(best[2])
685
+ self.update_model()
686
+
687
+ def on_move(self, event):
688
+ if event.xdata is None:
689
+ return
690
+ if event.inaxes is self.ax:
691
+ msg = 'pixel %.1f' % event.xdata
692
+ if self.coef is not None:
693
+ msg += ' λ≈%.2f Å' % np.polyval(self.coef, event.xdata)
694
+ self.statusBar().showMessage(msg)
695
+ elif event.inaxes is self.ref_ax:
696
+ self.statusBar().showMessage('λ = %.2f Å' % event.xdata)
697
+
698
+ def on_scroll(self, event):
699
+ if event.inaxes not in (self.ax, self.ref_ax) or event.xdata is None:
700
+ return
701
+ scale = 0.7 if event.button == 'up' else 1 / 0.7
702
+ ax = event.inaxes
703
+ x0, x1 = ax.get_xlim()
704
+ new = (event.xdata - (event.xdata - x0) * scale, event.xdata + (x1 - event.xdata) * scale)
705
+ if ax is self.ax and len(self.flux):
706
+ new = (max(new[0], -0.5), min(new[1], len(self.flux) - 0.5))
707
+ ax.set_xlim(*new)
708
+ if ax is self.ref_ax:
709
+ self.label_reference_lines()
710
+ self.canvas.draw_idle()
711
+
712
+
713
+ def main(argv=None):
714
+ parser = argparse.ArgumentParser(
715
+ prog='specproc-identify',
716
+ description='Compare an extracted lamp spectrum with a reference line list, pick lines '
717
+ 'by hand and auto-identify the rest.')
718
+ parser.add_argument('--lamp', help='extracted 1-D lamp spectrum (SpecProc *_spec.fits or text)')
719
+ parser.add_argument('--linelist', help='reference line list (e.g. calib_data/linelists/fear.dat)')
720
+ parser.add_argument('--anchors', help='existing identified-lines file to continue editing')
721
+ parser.add_argument('--dispersion-axis', choices=['x', 'y'], default='y',
722
+ help='dispersion axis when --lamp is a 2-D image (default: y)')
723
+ args = parser.parse_args(argv)
724
+ app = QtWidgets.QApplication.instance() or QtWidgets.QApplication(sys.argv[:1])
725
+ win = LineIdentifier(args.lamp, args.linelist, args.anchors, args.dispersion_axis)
726
+ win.show()
727
+ return app.exec_()
728
+
729
+
730
+ if __name__ == '__main__':
731
+ sys.exit(main())