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