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,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."""
@@ -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())