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,483 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Plotting and visualization utilities for spectral data.
|
|
3
|
+
|
|
4
|
+
Provides functions for creating diagnostic plots and result visualization.
|
|
5
|
+
"""
|
|
6
|
+
# 提供光谱、图像及处理诊断图的绘制功能。
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import matplotlib.pyplot as plt
|
|
10
|
+
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
|
|
11
|
+
from matplotlib.figure import Figure
|
|
12
|
+
from typing import Optional, List
|
|
13
|
+
import logging
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# 在界面中绘制光谱和二维图像。
|
|
19
|
+
class MatplotlibCanvas(FigureCanvas):
|
|
20
|
+
"""Matplotlib canvas widget for PyQt5."""
|
|
21
|
+
|
|
22
|
+
# 初始化绘图画布及坐标轴。
|
|
23
|
+
def __init__(self, parent=None, width=5, height=4, dpi=100):
|
|
24
|
+
"""Initialize canvas."""
|
|
25
|
+
self.fig = Figure(figsize=(width, height), dpi=dpi)
|
|
26
|
+
self.axes = self.fig.add_subplot(111)
|
|
27
|
+
super().__init__(self.fig)
|
|
28
|
+
self.setParent(parent)
|
|
29
|
+
|
|
30
|
+
# 在画布上绘制一维光谱。
|
|
31
|
+
def plot_spectrum(self, wavelength: np.ndarray, flux: np.ndarray,
|
|
32
|
+
error: Optional[np.ndarray] = None, title: str = ""):
|
|
33
|
+
"""Plot 1D spectrum."""
|
|
34
|
+
self.axes.clear()
|
|
35
|
+
self.axes.plot(wavelength, flux, 'b-', linewidth=0.7, label="Flux")
|
|
36
|
+
|
|
37
|
+
if error is not None:
|
|
38
|
+
self.axes.fill_between(wavelength, flux - error, flux + error,
|
|
39
|
+
alpha=0.3, color='blue', label="Error")
|
|
40
|
+
|
|
41
|
+
self.axes.set_xlabel("Wavelength (Angstrom)")
|
|
42
|
+
self.axes.set_ylabel("Flux")
|
|
43
|
+
self.axes.set_title(title)
|
|
44
|
+
self.axes.legend()
|
|
45
|
+
self.axes.grid(True, alpha=0.3)
|
|
46
|
+
self.fig.tight_layout()
|
|
47
|
+
self.draw()
|
|
48
|
+
|
|
49
|
+
# 在画布上绘制带色标的二维图像。
|
|
50
|
+
def plot_2d_image(self, image: np.ndarray, title: str = "",
|
|
51
|
+
vmin: Optional[float] = None, vmax: Optional[float] = None):
|
|
52
|
+
"""Plot 2D image with colorbar."""
|
|
53
|
+
self.axes.clear()
|
|
54
|
+
|
|
55
|
+
if vmin is None:
|
|
56
|
+
vmin = np.percentile(image, 1)
|
|
57
|
+
if vmax is None:
|
|
58
|
+
vmax = np.percentile(image, 99)
|
|
59
|
+
|
|
60
|
+
im = self.axes.imshow(image, cmap='viridis', origin='lower',
|
|
61
|
+
vmin=vmin, vmax=vmax)
|
|
62
|
+
self.axes.set_title(title)
|
|
63
|
+
self.axes.set_xlabel("Column")
|
|
64
|
+
self.axes.set_ylabel("Row")
|
|
65
|
+
|
|
66
|
+
cbar = self.fig.colorbar(im, ax=self.axes)
|
|
67
|
+
cbar.set_label("Counts")
|
|
68
|
+
|
|
69
|
+
self.fig.tight_layout()
|
|
70
|
+
self.draw()
|
|
71
|
+
|
|
72
|
+
# 绘制图像并叠加孔径追踪边界。
|
|
73
|
+
def plot_aperture_traces(self, image: np.ndarray, apertures: dict,
|
|
74
|
+
title: str = ""):
|
|
75
|
+
"""Plot 2D image with aperture traces overlaid."""
|
|
76
|
+
self.axes.clear()
|
|
77
|
+
|
|
78
|
+
# Plot image
|
|
79
|
+
im = self.axes.imshow(image, cmap='gray', origin='lower')
|
|
80
|
+
|
|
81
|
+
# Overlay apertures
|
|
82
|
+
cols = np.arange(image.shape[1])
|
|
83
|
+
colors = plt.cm.rainbow(np.linspace(0, 1, len(apertures)))
|
|
84
|
+
|
|
85
|
+
for (aperture_id, aperture), color in zip(apertures.items(), colors):
|
|
86
|
+
center_pos = aperture.get_position(cols)
|
|
87
|
+
self.axes.plot(cols, center_pos, color=color, linewidth=1,
|
|
88
|
+
label=f"Aperture {aperture_id}")
|
|
89
|
+
|
|
90
|
+
# Add extraction limits
|
|
91
|
+
upper = aperture.get_upper(cols)
|
|
92
|
+
lower = aperture.get_lower(cols)
|
|
93
|
+
self.axes.fill_between(cols, lower, upper, alpha=0.2, color=color)
|
|
94
|
+
|
|
95
|
+
self.axes.set_title(title)
|
|
96
|
+
self.axes.set_xlabel("Column")
|
|
97
|
+
self.axes.set_ylabel("Row")
|
|
98
|
+
self.axes.legend(fontsize=8, loc='upper right')
|
|
99
|
+
|
|
100
|
+
self.fig.tight_layout()
|
|
101
|
+
self.draw()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# 绘制并保存一维光谱图。
|
|
105
|
+
def plot_spectrum_to_file(wavelength: np.ndarray, flux: np.ndarray,
|
|
106
|
+
output_path: str, error: Optional[np.ndarray] = None,
|
|
107
|
+
title: str = ""):
|
|
108
|
+
"""Save spectrum plot to file."""
|
|
109
|
+
fig, ax = plt.subplots(figsize=(12, 6))
|
|
110
|
+
|
|
111
|
+
ax.plot(wavelength, flux, 'b-', linewidth=0.7, label="Flux")
|
|
112
|
+
|
|
113
|
+
if error is not None:
|
|
114
|
+
ax.fill_between(wavelength, flux - error, flux + error,
|
|
115
|
+
alpha=0.3, color='blue', label="Error")
|
|
116
|
+
|
|
117
|
+
ax.set_xlabel("Wavelength (Angstrom)")
|
|
118
|
+
ax.set_ylabel("Flux")
|
|
119
|
+
ax.set_title(title)
|
|
120
|
+
ax.legend()
|
|
121
|
+
ax.grid(True, alpha=0.3)
|
|
122
|
+
|
|
123
|
+
fig.tight_layout()
|
|
124
|
+
fig.savefig(output_path, dpi=150)
|
|
125
|
+
plt.close(fig)
|
|
126
|
+
|
|
127
|
+
logger.info(f"Saved spectrum plot to {output_path}")
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# 绘制并保存二维图像。
|
|
131
|
+
def plot_2d_image_to_file(image: np.ndarray, output_path: str,
|
|
132
|
+
title: str = "", cmap: str = 'viridis',
|
|
133
|
+
vmin=None, vmax=None):
|
|
134
|
+
"""Save 2D image plot to file."""
|
|
135
|
+
fig, ax = plt.subplots(figsize=(10, 8))
|
|
136
|
+
|
|
137
|
+
if vmin is None:
|
|
138
|
+
vmin = np.percentile(image, 1)
|
|
139
|
+
if vmax is None:
|
|
140
|
+
vmax = np.percentile(image, 99)
|
|
141
|
+
|
|
142
|
+
im = ax.imshow(image, cmap=cmap, origin='lower', vmin=vmin, vmax=vmax)
|
|
143
|
+
ax.set_title(title)
|
|
144
|
+
ax.set_xlabel("Column")
|
|
145
|
+
ax.set_ylabel("Row")
|
|
146
|
+
|
|
147
|
+
cbar = fig.colorbar(im, ax=ax)
|
|
148
|
+
cbar.set_label("Counts")
|
|
149
|
+
|
|
150
|
+
fig.tight_layout()
|
|
151
|
+
fig.savefig(output_path, dpi=150)
|
|
152
|
+
plt.close(fig)
|
|
153
|
+
|
|
154
|
+
logger.info(f"Saved 2D image plot to {output_path}")
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# 绘制并保存过扫描校正图像。
|
|
158
|
+
def plot_overscan_corrected_image(image: np.ndarray, output_path: str,
|
|
159
|
+
title: str = "", cmap: str = 'viridis',
|
|
160
|
+
overscan_start_col: int = -1,
|
|
161
|
+
original_width: int = None):
|
|
162
|
+
"""Save overscan-corrected image plot (without marking overscan region).
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
image: The corrected image (may have overscan region trimmed)
|
|
166
|
+
output_path: Path to save the plot
|
|
167
|
+
title: Plot title
|
|
168
|
+
cmap: Colormap for the image
|
|
169
|
+
overscan_start_col: 1-based column where overscan starts
|
|
170
|
+
original_width: Original image width before trimming (optional)
|
|
171
|
+
"""
|
|
172
|
+
fig, ax = plt.subplots(figsize=(12, 8))
|
|
173
|
+
|
|
174
|
+
vmin = np.percentile(image, 1)
|
|
175
|
+
vmax = np.percentile(image, 99)
|
|
176
|
+
|
|
177
|
+
im = ax.imshow(image, cmap=cmap, origin='lower', vmin=vmin, vmax=vmax)
|
|
178
|
+
|
|
179
|
+
height, current_width = image.shape
|
|
180
|
+
|
|
181
|
+
# Set title (no overscan region marking as requested)
|
|
182
|
+
ax.set_title(f"{title}")
|
|
183
|
+
|
|
184
|
+
ax.set_xlabel("Column (0-based)")
|
|
185
|
+
ax.set_ylabel("Row (0-based)")
|
|
186
|
+
|
|
187
|
+
cbar = fig.colorbar(im, ax=ax)
|
|
188
|
+
cbar.set_label("Counts")
|
|
189
|
+
|
|
190
|
+
fig.tight_layout()
|
|
191
|
+
fig.savefig(output_path, dpi=150)
|
|
192
|
+
plt.close(fig)
|
|
193
|
+
|
|
194
|
+
logger.info(f"Saved overscan-corrected image plot to {output_path}")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
# 绘制过扫描剖面及拟合残差。
|
|
198
|
+
def plot_overscan_profile(overscan_profile: dict, output_path: str,
|
|
199
|
+
title: str = "Overscan Profile"):
|
|
200
|
+
"""Plot overscan profile (smoothed or fitted curve).
|
|
201
|
+
|
|
202
|
+
Args:
|
|
203
|
+
overscan_profile: Dictionary containing overscan profile data
|
|
204
|
+
For non-split profiles: 'raw', 'smoothed' or 'fitted', 'method',
|
|
205
|
+
'window_length', 'polyorder', etc.
|
|
206
|
+
For split profiles (split=True): 'upper' and 'lower' dicts with same structure,
|
|
207
|
+
plus 'split': True and 'method'.
|
|
208
|
+
output_path: Path to save the plot
|
|
209
|
+
title: Plot title
|
|
210
|
+
"""
|
|
211
|
+
method = overscan_profile.get('method', 'unknown')
|
|
212
|
+
is_split = overscan_profile.get('split', False)
|
|
213
|
+
|
|
214
|
+
has_model = False
|
|
215
|
+
if is_split:
|
|
216
|
+
bottom = overscan_profile.get('bottom', {})
|
|
217
|
+
top = overscan_profile.get('top', {})
|
|
218
|
+
has_model = (
|
|
219
|
+
('fitted' in bottom or 'smoothed' in bottom) or
|
|
220
|
+
('fitted' in top or 'smoothed' in top)
|
|
221
|
+
)
|
|
222
|
+
else:
|
|
223
|
+
has_model = ('fitted' in overscan_profile) or ('smoothed' in overscan_profile)
|
|
224
|
+
|
|
225
|
+
if has_model:
|
|
226
|
+
fig, (ax, ax_res) = plt.subplots(
|
|
227
|
+
2, 1,
|
|
228
|
+
figsize=(10, 8),
|
|
229
|
+
sharex=True,
|
|
230
|
+
gridspec_kw={'height_ratios': [3, 1]}
|
|
231
|
+
)
|
|
232
|
+
else:
|
|
233
|
+
fig, ax = plt.subplots(figsize=(10, 6))
|
|
234
|
+
ax_res = None
|
|
235
|
+
|
|
236
|
+
# 生成过扫描方法的图标题。
|
|
237
|
+
def _get_method_title(profile: dict, method_name: str) -> str:
|
|
238
|
+
display_method = 'mean_savgol' if method_name == 'iraf_savgol' else method_name
|
|
239
|
+
if display_method == 'mean_polynomial':
|
|
240
|
+
poly_type = profile.get('poly_type', 'polynomial')
|
|
241
|
+
poly_order = profile.get('poly_order', 'N/A')
|
|
242
|
+
return f" - {display_method} ({poly_type}, order={poly_order})"
|
|
243
|
+
if display_method == 'mean_savgol':
|
|
244
|
+
poly_order = profile.get('polyorder', 'N/A')
|
|
245
|
+
return f" - {display_method} (order={poly_order})"
|
|
246
|
+
if display_method == 'mean_only':
|
|
247
|
+
return f" - {display_method}"
|
|
248
|
+
return f" - {display_method}"
|
|
249
|
+
|
|
250
|
+
method_title = _get_method_title(overscan_profile, method)
|
|
251
|
+
|
|
252
|
+
if is_split:
|
|
253
|
+
# Handle split profile (top and bottom halves)
|
|
254
|
+
# numpy: image[0,:] = ds9 row 1, image[4135,:] = ds9 row 4136
|
|
255
|
+
# ds9: x=0 (left) = small row, x=total_height-1 (right) = large row
|
|
256
|
+
# 'bottom' = image[:mid_row,:] = small rows (ds9 bottom half)
|
|
257
|
+
# 'top' = image[mid_row:,:] = large rows (ds9 top half)
|
|
258
|
+
bottom = overscan_profile.get('bottom', {}) # image[:mid_row,:] = small rows
|
|
259
|
+
top = overscan_profile.get('top', {}) # image[mid_row:,:] = large rows
|
|
260
|
+
|
|
261
|
+
n_bottom = len(bottom.get('raw', []))
|
|
262
|
+
n_top = len(top.get('raw', []))
|
|
263
|
+
total_height = n_bottom + n_top
|
|
264
|
+
mid_row = n_bottom
|
|
265
|
+
|
|
266
|
+
# Bottom half ('bottom' = image[:mid_row,:] = small rows) -> plot on left
|
|
267
|
+
if 'raw' in bottom:
|
|
268
|
+
# x=0 = ds9 row 1, x=n_bottom-1 = ds9 row mid_row
|
|
269
|
+
row_indices = np.arange(n_bottom)
|
|
270
|
+
ax.plot(row_indices, bottom['raw'], 'b.', alpha=0.5, markersize=4)
|
|
271
|
+
|
|
272
|
+
bottom_model = None
|
|
273
|
+
if 'fitted' in bottom:
|
|
274
|
+
bottom_model = bottom['fitted']
|
|
275
|
+
ax.plot(row_indices, bottom_model, 'g-', linewidth=2)
|
|
276
|
+
elif 'smoothed' in bottom:
|
|
277
|
+
bottom_model = bottom['smoothed']
|
|
278
|
+
ax.plot(row_indices, bottom_model, 'r-', linewidth=2)
|
|
279
|
+
|
|
280
|
+
if ax_res is not None and bottom_model is not None:
|
|
281
|
+
residual_bottom = np.asarray(bottom['raw']) - np.asarray(bottom_model)
|
|
282
|
+
ax_res.plot(row_indices, residual_bottom, 'k.', alpha=0.6, markersize=3)
|
|
283
|
+
|
|
284
|
+
# Top half ('top' = image[mid_row:,:] = large rows) -> plot on right
|
|
285
|
+
if 'raw' in top:
|
|
286
|
+
# x=n_bottom = ds9 row mid_row+1, x=total_height-1 = ds9 row total_height
|
|
287
|
+
row_indices = np.arange(n_bottom, total_height)
|
|
288
|
+
ax.plot(row_indices, top['raw'], 'c.', alpha=0.5, markersize=4)
|
|
289
|
+
|
|
290
|
+
top_model = None
|
|
291
|
+
if 'fitted' in top:
|
|
292
|
+
top_model = top['fitted']
|
|
293
|
+
ax.plot(row_indices, top_model, 'orange', linewidth=2)
|
|
294
|
+
elif 'smoothed' in top:
|
|
295
|
+
top_model = top['smoothed']
|
|
296
|
+
ax.plot(row_indices, top_model, 'm-', linewidth=2)
|
|
297
|
+
|
|
298
|
+
if ax_res is not None and top_model is not None:
|
|
299
|
+
residual_top = np.asarray(top['raw']) - np.asarray(top_model)
|
|
300
|
+
ax_res.plot(row_indices, residual_top, 'k.', alpha=0.6, markersize=3)
|
|
301
|
+
|
|
302
|
+
# Add vertical line at split boundary
|
|
303
|
+
ax.axvline(
|
|
304
|
+
x=n_bottom,
|
|
305
|
+
color='gray',
|
|
306
|
+
linestyle='--',
|
|
307
|
+
linewidth=1,
|
|
308
|
+
alpha=0.7,
|
|
309
|
+
label=f'Split at row {n_bottom}'
|
|
310
|
+
)
|
|
311
|
+
if ax_res is not None:
|
|
312
|
+
ax_res.axvline(x=n_bottom, color='gray', linestyle='--', linewidth=1, alpha=0.7)
|
|
313
|
+
|
|
314
|
+
# For split mode, enrich title details using one half profile if available.
|
|
315
|
+
detail_source = bottom if bottom else top
|
|
316
|
+
method_title = _get_method_title(detail_source, method)
|
|
317
|
+
|
|
318
|
+
else:
|
|
319
|
+
# Non-split profile (single region)
|
|
320
|
+
n = len(overscan_profile['raw'])
|
|
321
|
+
# ds9: row 1 at bottom (left), row n at top (right)
|
|
322
|
+
row_indices = np.arange(n) # 0 to n-1 corresponds to ds9 row 1 to row n
|
|
323
|
+
|
|
324
|
+
# Plot raw overscan values
|
|
325
|
+
ax.plot(row_indices, overscan_profile['raw'], 'b.', alpha=0.5, label='Raw (sigma-clipped mean)')
|
|
326
|
+
|
|
327
|
+
# Plot smoothed curve if available
|
|
328
|
+
model = None
|
|
329
|
+
if 'smoothed' in overscan_profile:
|
|
330
|
+
model = overscan_profile['smoothed']
|
|
331
|
+
ax.plot(row_indices, overscan_profile['smoothed'], 'r-', linewidth=2,
|
|
332
|
+
label=f"Savitzky-Golay smoothed (window={overscan_profile.get('window_length', 'N/A')}, order={overscan_profile.get('polyorder', 'N/A')})")
|
|
333
|
+
method_title = _get_method_title(overscan_profile, method)
|
|
334
|
+
|
|
335
|
+
# Plot polynomial fitted curve if available
|
|
336
|
+
if 'fitted' in overscan_profile:
|
|
337
|
+
model = overscan_profile['fitted']
|
|
338
|
+
ax.plot(row_indices, overscan_profile['fitted'], 'g-', linewidth=2,
|
|
339
|
+
label=f"Polynomial fit (order={overscan_profile.get('poly_order', 'N/A')})")
|
|
340
|
+
method_title = _get_method_title(overscan_profile, method)
|
|
341
|
+
|
|
342
|
+
# If coefficients are available, display them
|
|
343
|
+
if 'coefficients' in overscan_profile:
|
|
344
|
+
coeffs = overscan_profile['coefficients']
|
|
345
|
+
coeff_text = "Coefficients: "
|
|
346
|
+
for i, coeff in enumerate(coeffs[::-1]): # Reverse for standard polynomial notation
|
|
347
|
+
if i == 0:
|
|
348
|
+
coeff_text += f"{coeff:.3f}"
|
|
349
|
+
elif i == 1:
|
|
350
|
+
coeff_text += f" + {coeff:.3f}x"
|
|
351
|
+
else:
|
|
352
|
+
coeff_text += f" + {coeff:.3f}x^{i}"
|
|
353
|
+
|
|
354
|
+
ax.text(0.02, 0.02, coeff_text,
|
|
355
|
+
transform=ax.transAxes, fontsize=9,
|
|
356
|
+
verticalalignment='bottom',
|
|
357
|
+
bbox=dict(boxstyle='round,pad=0.3', facecolor='white', alpha=0.8))
|
|
358
|
+
|
|
359
|
+
if ax_res is not None and model is not None:
|
|
360
|
+
residual = np.asarray(overscan_profile['raw']) - np.asarray(model)
|
|
361
|
+
ax_res.plot(row_indices, residual, 'k.', alpha=0.6, markersize=3)
|
|
362
|
+
|
|
363
|
+
ax.set_xlabel("ds9 Row Number (1=bottom, increasing upward)")
|
|
364
|
+
ax.set_ylabel("Overscan Level (counts)")
|
|
365
|
+
ax.set_title(f"{title}{method_title}")
|
|
366
|
+
|
|
367
|
+
# Add grid
|
|
368
|
+
ax.grid(True, alpha=0.3)
|
|
369
|
+
|
|
370
|
+
# Add legend
|
|
371
|
+
ax.legend(loc='best')
|
|
372
|
+
|
|
373
|
+
if ax_res is not None:
|
|
374
|
+
ax_res.axhline(y=0.0, color='gray', linestyle='--', linewidth=1, alpha=0.7)
|
|
375
|
+
ax_res.set_ylabel("Residual")
|
|
376
|
+
ax_res.set_xlabel("ds9 Row Number (1=bottom, increasing upward)")
|
|
377
|
+
ax_res.grid(True, alpha=0.3)
|
|
378
|
+
else:
|
|
379
|
+
ax.set_xlabel("ds9 Row Number (1=bottom, increasing upward)")
|
|
380
|
+
|
|
381
|
+
# Tight layout
|
|
382
|
+
plt.tight_layout()
|
|
383
|
+
|
|
384
|
+
# Save the plot
|
|
385
|
+
plt.savefig(output_path, dpi=150, bbox_inches='tight')
|
|
386
|
+
plt.close(fig)
|
|
387
|
+
|
|
388
|
+
logger.info(f"Saved overscan profile plot to {output_path}")
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
# 绘制背景模型及其残差。
|
|
392
|
+
def plot_background_residuals(science_image: np.ndarray, background: np.ndarray,
|
|
393
|
+
output_path: str):
|
|
394
|
+
"""Plot background estimation and residuals."""
|
|
395
|
+
fig, axes = plt.subplots(1, 3, figsize=(15, 4))
|
|
396
|
+
|
|
397
|
+
# Original
|
|
398
|
+
im0 = axes[0].imshow(science_image, cmap='viridis', origin='lower')
|
|
399
|
+
axes[0].set_title("Original Image")
|
|
400
|
+
fig.colorbar(im0, ax=axes[0])
|
|
401
|
+
|
|
402
|
+
# Background
|
|
403
|
+
im1 = axes[1].imshow(background, cmap='viridis', origin='lower')
|
|
404
|
+
axes[1].set_title("Background Model")
|
|
405
|
+
fig.colorbar(im1, ax=axes[1])
|
|
406
|
+
|
|
407
|
+
# Residual
|
|
408
|
+
residual = science_image - background
|
|
409
|
+
im2 = axes[2].imshow(residual, cmap='viridis', origin='lower')
|
|
410
|
+
axes[2].set_title("Residual (after background subtraction)")
|
|
411
|
+
fig.colorbar(im2, ax=axes[2])
|
|
412
|
+
|
|
413
|
+
fig.tight_layout()
|
|
414
|
+
fig.savefig(output_path, dpi=150)
|
|
415
|
+
plt.close(fig)
|
|
416
|
+
|
|
417
|
+
logger.info(f"Saved background residuals plot to {output_path}")
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
# 绘制波长标定拟合结果。
|
|
421
|
+
def plot_wavelength_calibration(line_pixels: np.ndarray, line_wavelengths: np.ndarray,
|
|
422
|
+
fitted_wavelengths: np.ndarray,
|
|
423
|
+
output_path: str):
|
|
424
|
+
"""Plot wavelength calibration fit."""
|
|
425
|
+
fig, axes = plt.subplots(2, 1, figsize=(12, 8))
|
|
426
|
+
|
|
427
|
+
# Wavelength vs pixel
|
|
428
|
+
axes[0].plot(line_pixels, line_wavelengths, 'bo', label="Detected lines")
|
|
429
|
+
axes[0].plot(line_pixels, fitted_wavelengths, 'r-', linewidth=2,
|
|
430
|
+
label="Polynomial fit")
|
|
431
|
+
axes[0].set_xlabel("Pixel")
|
|
432
|
+
axes[0].set_ylabel("Wavelength (Angstrom)")
|
|
433
|
+
axes[0].set_title("Wavelength Calibration")
|
|
434
|
+
axes[0].legend()
|
|
435
|
+
axes[0].grid(True, alpha=0.3)
|
|
436
|
+
|
|
437
|
+
# Residuals
|
|
438
|
+
residuals = line_wavelengths - fitted_wavelengths
|
|
439
|
+
axes[1].plot(line_pixels, residuals, 'go')
|
|
440
|
+
axes[1].axhline(0, color='r', linestyle='--')
|
|
441
|
+
axes[1].set_xlabel("Pixel")
|
|
442
|
+
axes[1].set_ylabel("Residual (Angstrom)")
|
|
443
|
+
axes[1].set_title(f"Calibration Residuals (RMS: {np.std(residuals):.4f} Å)")
|
|
444
|
+
axes[1].grid(True, alpha=0.3)
|
|
445
|
+
|
|
446
|
+
fig.tight_layout()
|
|
447
|
+
fig.savefig(output_path, dpi=150)
|
|
448
|
+
plt.close(fig)
|
|
449
|
+
|
|
450
|
+
logger.info(f"Saved wavelength calibration plot to {output_path}")
|
|
451
|
+
|
|
452
|
+
# 将多条光谱绘制到同一 PDF。
|
|
453
|
+
def plot_spectra_to_pdf(spectra_set, output_path: str, title_prefix: str = "Spectrum",
|
|
454
|
+
xlabel: str = "Wavelength (Angstrom)", id_label: str = "Aperture"):
|
|
455
|
+
"""Save all spectra in a SpectraSet to a single multi-page PDF file."""
|
|
456
|
+
from matplotlib.backends.backend_pdf import PdfPages
|
|
457
|
+
|
|
458
|
+
with PdfPages(output_path) as pdf:
|
|
459
|
+
for aperture_id in sorted(spectra_set.spectra.keys()):
|
|
460
|
+
spectrum = spectra_set.get_spectrum(aperture_id)
|
|
461
|
+
if len(spectrum.wavelength) == 0:
|
|
462
|
+
continue
|
|
463
|
+
|
|
464
|
+
fig, ax = plt.subplots(figsize=(12, 6))
|
|
465
|
+
ax.plot(spectrum.wavelength, spectrum.flux, 'b-', linewidth=0.7, label="Flux")
|
|
466
|
+
|
|
467
|
+
# Skip plotting error band by default in PDF to massively speed up rendering and reduce file size
|
|
468
|
+
# if spectrum.error is not None:
|
|
469
|
+
# ax.fill_between(spectrum.wavelength, spectrum.flux - spectrum.error,
|
|
470
|
+
# spectrum.flux + spectrum.error,
|
|
471
|
+
# alpha=0.3, color='blue', label="Error")
|
|
472
|
+
|
|
473
|
+
ax.set_xlabel(xlabel)
|
|
474
|
+
ax.set_ylabel("Flux")
|
|
475
|
+
ax.set_title(f"{title_prefix} - {id_label} {aperture_id}")
|
|
476
|
+
ax.legend()
|
|
477
|
+
ax.grid(True, alpha=0.3)
|
|
478
|
+
|
|
479
|
+
fig.tight_layout()
|
|
480
|
+
pdf.savefig(fig)
|
|
481
|
+
plt.close(fig)
|
|
482
|
+
|
|
483
|
+
logger.info(f"Saved all spectra to PDF: {output_path}")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Utility functions for SpecProc."""
|
specproc/utils/airvac.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Air <-> vacuum wavelength conversion for line-list files.
|
|
2
|
+
|
|
3
|
+
Port of the IRAF ``airvac.cl`` script. The refractive index formula is from
|
|
4
|
+
Allen's Astrophysical Quantities (1973). Wavelengths are in Angstroms.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
specproc-air2vac INPUT OUTPUT [-t T] [-p P] [-f F]
|
|
8
|
+
specproc-vac2air INPUT OUTPUT [-t T] [-p P] [-f F]
|
|
9
|
+
python -m specproc.utils.airvac INPUT OUTPUT --type air2vac|vac2air
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def refractive_index(wavelength, t=15.0, p=760.0, f=4.0):
|
|
19
|
+
"""Refractive index of air at the given vacuum-like wavelength (Angstrom).
|
|
20
|
+
|
|
21
|
+
t: temperature (C), p: pressure (mmHg), f: water vapour pressure (mmHg).
|
|
22
|
+
"""
|
|
23
|
+
w = np.asarray(wavelength, dtype=float)
|
|
24
|
+
b = (10000.0 / w) ** 2
|
|
25
|
+
a = 64.328 + 29498.1 / (146.0 - b) + 255.4 / (41.0 - b)
|
|
26
|
+
a = a * p * (1.0 + (1.049 - 0.0157 * t) * 1e-6 * p) / (720.883 * (1.0 + 0.003661 * t))
|
|
27
|
+
a = a - (0.0624 - 0.000680 * b) / (1.0 + 0.003661 * t) * f
|
|
28
|
+
return 1.0 + a / 1e6
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def air_to_vac(wavelength, t=15.0, p=760.0, f=4.0):
|
|
32
|
+
"""Convert air wavelengths to vacuum wavelengths (same as IRAF airvac)."""
|
|
33
|
+
return refractive_index(wavelength, t, p, f) * np.asarray(wavelength, dtype=float)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def vac_to_air(wavelength, t=15.0, p=760.0, f=4.0):
|
|
37
|
+
"""Convert vacuum wavelengths to air wavelengths (same as IRAF airvac)."""
|
|
38
|
+
return np.asarray(wavelength, dtype=float) / refractive_index(wavelength, t, p, f)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def convert_line(line, conv_type="air2vac", t=15.0, p=760.0, f=4.0):
|
|
42
|
+
"""Convert the first column of one line; keep the remaining text as is.
|
|
43
|
+
|
|
44
|
+
Blank lines, comments and lines not starting with a number are unchanged.
|
|
45
|
+
"""
|
|
46
|
+
parts = line.split(None, 1)
|
|
47
|
+
if not parts:
|
|
48
|
+
return line
|
|
49
|
+
try:
|
|
50
|
+
w = float(parts[0])
|
|
51
|
+
except ValueError:
|
|
52
|
+
return line
|
|
53
|
+
func = air_to_vac if conv_type == "air2vac" else vac_to_air
|
|
54
|
+
new_w = float(func(w, t, p, f))
|
|
55
|
+
label = parts[1].strip() if len(parts) > 1 else ""
|
|
56
|
+
return "%.4f %s" % (new_w, label) if label else "%.4f" % new_w
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def convert_file(input_path, output_path, conv_type="air2vac", t=15.0, p=760.0, f=4.0):
|
|
60
|
+
"""Convert a line-list file; returns the number of converted lines."""
|
|
61
|
+
if conv_type not in ("air2vac", "vac2air"):
|
|
62
|
+
raise ValueError("conv_type must be 'air2vac' or 'vac2air'")
|
|
63
|
+
count = 0
|
|
64
|
+
with open(input_path, "r") as fin, open(output_path, "w") as fout:
|
|
65
|
+
fout.write("# airvac (%s, %s, type=%s, t=%.6g, p=%.6g, f=%.6g)\n"
|
|
66
|
+
% (input_path, output_path, conv_type, t, p, f))
|
|
67
|
+
for raw in fin:
|
|
68
|
+
line = raw.rstrip("\r\n")
|
|
69
|
+
new = convert_line(line, conv_type, t, p, f)
|
|
70
|
+
if new is not line and new != line:
|
|
71
|
+
count += 1
|
|
72
|
+
fout.write(new + "\n")
|
|
73
|
+
return count
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def main(argv=None, conv_type=None):
|
|
77
|
+
prog = "python -m specproc.utils.airvac" if conv_type is None else "specproc-" + conv_type
|
|
78
|
+
direction = {
|
|
79
|
+
"air2vac": ("air", "vacuum"),
|
|
80
|
+
"vac2air": ("vacuum", "air"),
|
|
81
|
+
}
|
|
82
|
+
parser = argparse.ArgumentParser(
|
|
83
|
+
prog=prog,
|
|
84
|
+
description=("Convert a line-list file between air and vacuum wavelengths "
|
|
85
|
+
"(Angstrom). Comments and text after the first column are preserved.")
|
|
86
|
+
if conv_type is None else
|
|
87
|
+
"Convert line-list wavelengths from %s to %s (Angstrom). "
|
|
88
|
+
"Comments and text after the first column are preserved."
|
|
89
|
+
% direction[conv_type])
|
|
90
|
+
parser.add_argument("input", help="input line-list file")
|
|
91
|
+
parser.add_argument("output", help="output line-list file")
|
|
92
|
+
if conv_type is None:
|
|
93
|
+
parser.add_argument("--type", dest="conv_type", default="air2vac",
|
|
94
|
+
choices=["air2vac", "vac2air"],
|
|
95
|
+
help="conversion (default: air2vac)")
|
|
96
|
+
parser.add_argument("-t", type=float, default=15.0, help="temperature in C (default: 15)")
|
|
97
|
+
parser.add_argument("-p", type=float, default=760.0, help="pressure in mmHg (default: 760)")
|
|
98
|
+
parser.add_argument("-f", type=float, default=4.0,
|
|
99
|
+
help="water vapour pressure in mmHg (default: 4)")
|
|
100
|
+
args = parser.parse_args(argv)
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
conversion = conv_type or args.conv_type
|
|
104
|
+
n = convert_file(args.input, args.output, conversion, args.t, args.p, args.f)
|
|
105
|
+
except (OSError, ValueError) as exc:
|
|
106
|
+
print("Error: %s" % exc, file=sys.stderr)
|
|
107
|
+
return 1
|
|
108
|
+
print("Converted %d wavelengths (%s): %s" % (n, conversion, args.output))
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def air2vac_main():
|
|
113
|
+
"""Command-line entry point for air-to-vacuum conversion."""
|
|
114
|
+
return main(conv_type="air2vac")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def vac2air_main():
|
|
118
|
+
"""Command-line entry point for vacuum-to-air conversion."""
|
|
119
|
+
return main(conv_type="vac2air")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
if __name__ == "__main__":
|
|
123
|
+
sys.exit(main())
|