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
specproc/main.py ADDED
@@ -0,0 +1,388 @@
1
+ """
2
+ SpecProc: Main application entry point.
3
+
4
+ PyQt-based GUI for echelle spectrograph FITS data reduction.
5
+ """
6
+ # 提供图形界面与命令行程序入口。
7
+
8
+ import sys
9
+ import argparse
10
+ import logging
11
+ import shutil
12
+ import time
13
+ from logging.handlers import RotatingFileHandler
14
+ from pathlib import Path
15
+ from typing import Dict, List, Optional, Tuple
16
+
17
+ from astropy.io import fits
18
+
19
+ from specproc.config.config_manager import (
20
+ ConfigManager,
21
+ list_telescope_profiles,
22
+ resolve_telescope_profile,
23
+ )
24
+ from specproc.core.processing_pipeline import ProcessingPipeline
25
+
26
+
27
+ COPY_DEFAULT_CONFIG_SENTINEL = '__COPY_DEFAULT_CONFIG__'
28
+
29
+
30
+ # 设置文件与控制台日志输出。
31
+ def setup_logging(log_file: str = 'specproc.log'):
32
+ """Setup application logging."""
33
+ logger = logging.getLogger()
34
+ logger.setLevel(logging.INFO) # Set to INFO to reduce debug noise
35
+
36
+ # Reduce matplotlib logging - set more specific loggers
37
+ logging.getLogger('matplotlib').setLevel(logging.WARNING)
38
+ logging.getLogger('matplotlib.font_manager').setLevel(logging.WARNING)
39
+ logging.getLogger('matplotlib.pyplot').setLevel(logging.WARNING)
40
+ logging.getLogger('PIL').setLevel(logging.WARNING)
41
+
42
+ # Also disable font manager's verbose output
43
+ import matplotlib
44
+ matplotlib.rcParams['font.family'] = 'sans-serif'
45
+ matplotlib.rcParams['font.sans-serif'] = ['DejaVu Sans']
46
+ matplotlib.rcParams['axes.unicode_minus'] = False
47
+
48
+ # File handler
49
+ file_handler = RotatingFileHandler(
50
+ log_file, maxBytes=10*1024*1024, backupCount=5
51
+ )
52
+ file_handler.setLevel(logging.INFO) # File also at INFO level
53
+
54
+ # Console handler
55
+ console_handler = logging.StreamHandler(sys.stdout)
56
+ console_handler.setLevel(logging.INFO)
57
+
58
+ # Formatter
59
+ formatter = logging.Formatter(
60
+ '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
61
+ )
62
+ file_handler.setFormatter(formatter)
63
+ console_handler.setFormatter(formatter)
64
+
65
+ # Add handlers
66
+ logger.addHandler(file_handler)
67
+ logger.addHandler(console_handler)
68
+
69
+ logger.info("=" * 60)
70
+ logger.info("SpecProc Application Started")
71
+ logger.info("=" * 60)
72
+
73
+
74
+ # 从输入文件或目录收集FITS图像。
75
+ def _collect_input_fits_files(inputs: List[str]) -> List[str]:
76
+ """Collect FITS files from positional CLI inputs (files and/or directories)."""
77
+ paths = inputs if inputs else ['.']
78
+ discovered: List[Path] = []
79
+
80
+ for item in paths:
81
+ p = Path(item).expanduser()
82
+ if p.is_dir():
83
+ # Recursively scan directory and subdirectories for FITS files.
84
+ for pattern in ('*.fits', '*.fit', '*.FITS', '*.FIT'):
85
+ discovered.extend(sorted(p.rglob(pattern)))
86
+ elif p.is_file() and p.suffix.lower() in ('.fits', '.fit'):
87
+ discovered.append(p)
88
+
89
+ # Remove duplicates while preserving deterministic order.
90
+ unique_files: List[str] = []
91
+ seen = set()
92
+ for file_path in sorted(discovered):
93
+ key = str(file_path.resolve())
94
+ if key not in seen:
95
+ seen.add(key)
96
+ unique_files.append(str(file_path.resolve()))
97
+
98
+ return unique_files
99
+
100
+
101
+ # 读取FITS头中的帧类型与曝光时间。
102
+ def _read_frame_type_hint(fits_path: str) -> Tuple[str, Optional[float]]:
103
+ """Read frame-type hints and exposure time from FITS headers."""
104
+ collected: List[str] = []
105
+ exposure_value: Optional[float] = None
106
+
107
+ with fits.open(fits_path) as hdul:
108
+ for idx in range(len(hdul)):
109
+ header = getattr(hdul[idx], 'header', None)
110
+ if header is None:
111
+ continue
112
+
113
+ # Prioritize dedicated type keywords.
114
+ for key in ('OBSTYPE', 'IMAGETYP', 'OBJECT'):
115
+ value = str(header.get(key, '')).strip()
116
+ if value:
117
+ collected.append(value)
118
+
119
+ if exposure_value is None:
120
+ raw_exptime = header.get('EXPTIME', header.get('EXPOSURE', None))
121
+ try:
122
+ if raw_exptime is not None:
123
+ exposure_value = float(raw_exptime)
124
+ except (TypeError, ValueError):
125
+ pass
126
+
127
+ # Some observatories store frame type only in COMMENT.
128
+ comments = header.get('COMMENT', [])
129
+ if isinstance(comments, str):
130
+ if comments.strip():
131
+ collected.append(comments.strip())
132
+ else:
133
+ for comment in comments:
134
+ c = str(comment).strip()
135
+ if c:
136
+ collected.append(c)
137
+
138
+ # Deduplicate while preserving order.
139
+ seen = set()
140
+ unique_values: List[str] = []
141
+ for value in collected:
142
+ key = value.lower()
143
+ if key not in seen:
144
+ seen.add(key)
145
+ unique_values.append(value)
146
+
147
+ return ' | '.join(unique_values), exposure_value
148
+
149
+
150
+ # 根据头信息判断观测帧类别。
151
+ def _classify_obstype(obstype: str) -> Optional[str]:
152
+ """Map header type hints to one of: bias, flat, calib, science."""
153
+ normalized = obstype.strip().lower().replace('_', ' ')
154
+ if not normalized:
155
+ return None
156
+
157
+ bias_tokens = {'bias', 'zero'}
158
+ flat_tokens = {'flat', 'quartz', 'halogen', 'continuum', 'tungsten'}
159
+ calib_tokens = {'thar', 'lamp', 'arc', 'comparison', 'comp', 'fear', 'ne', 'ar', 'he'}
160
+ science_tokens = {'science', 'object', 'target', 'star', 'source'}
161
+
162
+ if any(tok in normalized for tok in bias_tokens):
163
+ return 'bias'
164
+ if any(tok in normalized for tok in flat_tokens):
165
+ return 'flat'
166
+ if any(tok in normalized for tok in calib_tokens):
167
+ return 'calib'
168
+ if any(tok in normalized for tok in science_tokens):
169
+ return 'science'
170
+
171
+ return None
172
+
173
+
174
+ # 分类输入图像并运行命令行归约流程。
175
+ def run_cli_mode(config_path: Optional[str], inputs: List[str]) -> Dict:
176
+ """Run full pipeline in CLI mode using OBSTYPE-based FITS classification."""
177
+ logger = logging.getLogger(__name__)
178
+ started_at = time.perf_counter()
179
+ fits_files = _collect_input_fits_files(inputs)
180
+ if not fits_files:
181
+ raise FileNotFoundError(
182
+ 'No FITS files found. Provide FITS files or directories, e.g. '\
183
+ 'specproc --mode cli 20241102_hrs'
184
+ )
185
+
186
+ config = ConfigManager(config_path)
187
+ grouped: Dict[str, List[str]] = {
188
+ 'bias': [],
189
+ 'flat': [],
190
+ 'calib': [],
191
+ 'science': [],
192
+ }
193
+ unknown: List[Tuple[str, str]] = []
194
+ by_filename = config.get('data', 'frame_type_source', 'header').strip().lower() == 'filename'
195
+ is_slit = config.get('telescope', 'instrument', '').strip().lower() == 'slit'
196
+
197
+ # 读取并规范化帧类型匹配关键词。
198
+ def _keywords(key: str, default: str) -> List[str]:
199
+ return [k.strip().lower() for k in config.get('data', key, default).split(',') if k.strip()]
200
+
201
+ kw = {
202
+ 'bias': _keywords('bias_keywords', 'bias'),
203
+ 'flat': _keywords('flat_keywords', 'flat'),
204
+ 'calib': _keywords('lamp_keywords', 'fear'),
205
+ 'dark': _keywords('dark_keywords', 'dark'),
206
+ }
207
+
208
+ for file_path in fits_files:
209
+ try:
210
+ if by_filename:
211
+ fname = Path(file_path).name.lower()
212
+ category = 'science'
213
+ for cat in ('bias', 'flat', 'calib', 'dark'):
214
+ if any(k in fname for k in kw[cat]):
215
+ category = cat
216
+ break
217
+ if category == 'dark':
218
+ logger.info(f"Dark frame not used: {Path(file_path).name}")
219
+ continue
220
+ grouped[category].append(file_path)
221
+ continue
222
+
223
+ obstype, exposure_value = _read_frame_type_hint(file_path)
224
+ category = _classify_obstype(obstype)
225
+ if category is None and exposure_value is not None and exposure_value > 0:
226
+ category = 'science'
227
+
228
+ if category is None:
229
+ unknown.append((file_path, obstype))
230
+ continue
231
+ grouped[category].append(file_path)
232
+ except Exception as exc:
233
+ logger.warning(f"Skip unreadable FITS file {file_path}: {exc}")
234
+
235
+ if unknown:
236
+ for file_path, obstype in unknown:
237
+ logger.warning(
238
+ f"Unrecognized frame type for {Path(file_path).name}: '{obstype}'. File is skipped."
239
+ )
240
+
241
+ required = ('bias', 'science') if is_slit else ('bias', 'flat', 'calib', 'science')
242
+ missing = [name for name in required if not grouped[name]]
243
+ if missing:
244
+ counts = ', '.join([f"{key}={len(val)}" for key, val in grouped.items()])
245
+ raise RuntimeError(
246
+ f"Frame classification incomplete ({counts}). Missing: {', '.join(missing)}."
247
+ )
248
+
249
+ logger.info(
250
+ "CLI classification result: "
251
+ f"bias={len(grouped['bias'])}, flat={len(grouped['flat'])}, "
252
+ f"calib={len(grouped['calib'])}, science={len(grouped['science'])}"
253
+ )
254
+
255
+ processed_files = grouped['bias'] + grouped['flat'] + grouped['calib'] + grouped['science']
256
+ total_bytes = sum(Path(file_path).stat().st_size for file_path in processed_files if Path(file_path).exists())
257
+ total_mb = total_bytes / (1024.0 * 1024.0)
258
+
259
+ pipeline = ProcessingPipeline(config)
260
+ result = pipeline.run_full_pipeline(
261
+ raw_image_paths=grouped['science'],
262
+ bias_filenames=grouped['bias'],
263
+ flat_filenames=grouped['flat'],
264
+ calib_filenames=grouped['calib'],
265
+ )
266
+
267
+ elapsed_minutes = (time.perf_counter() - started_at) / 60.0
268
+ speed = total_mb / elapsed_minutes if elapsed_minutes > 0 else 0.0
269
+ logger.info(
270
+ f"CLI processing summary: elapsed={elapsed_minutes:.2f} min, "
271
+ f"input_size={total_mb:.2f} MB, throughput={speed:.2f} MB/min"
272
+ )
273
+
274
+ return result
275
+
276
+
277
+ # 将所选默认配置复制到当前目录。
278
+ def copy_default_config_to_cwd(target_filename: str = 'specproc.cfg',
279
+ profile: Optional[str] = None) -> Path:
280
+ """Copy a packaged default config into the current working directory.
281
+
282
+ Args:
283
+ target_filename: Name of the file to create in the current directory.
284
+ profile: Optional telescope profile name (e.g. 'XL216_hrs' for the
285
+ Xinglong 2.16m HRS, 'MSTG193_slit' for the Muztagh-Ata 1.93m slit
286
+ spectrograph). If omitted, the generic default_config.cfg is used.
287
+ """
288
+ if profile:
289
+ default_config = resolve_telescope_profile(profile)
290
+ if default_config is None:
291
+ available = ', '.join(sorted(list_telescope_profiles())) or '(none)'
292
+ raise FileNotFoundError(
293
+ f"Unknown telescope profile '{profile}'. Available profiles: {available}"
294
+ )
295
+ else:
296
+ default_config = Path(__file__).parent / 'config' / 'default_config.cfg'
297
+ if not default_config.exists():
298
+ raise FileNotFoundError(f'Default config file not found: {default_config}')
299
+
300
+ target_path = Path.cwd() / target_filename
301
+ if target_path.exists():
302
+ print(f'Config already exists: {target_path}')
303
+ return target_path
304
+
305
+ shutil.copyfile(default_config, target_path)
306
+ suffix = f' (telescope profile: {profile})' if profile else ''
307
+ print(f'Default config copied to: {target_path}{suffix}')
308
+ return target_path
309
+
310
+
311
+ # 解析启动参数并选择程序运行模式。
312
+ def main(config_path: Optional[str] = None, mode: Optional[str] = None):
313
+ """Main application entry point.
314
+
315
+ Args:
316
+ config_path: Path to configuration file (optional)
317
+ mode: Run mode ('gui' or 'cli')
318
+ """
319
+ # Parse command line arguments first (before QApplication)
320
+ telescope_profiles = sorted(list_telescope_profiles())
321
+ profiles_hint = ', '.join(telescope_profiles) if telescope_profiles else '(none packaged)'
322
+ parser = argparse.ArgumentParser(
323
+ description='SpecProc - Spectral Processing Tool for Echelle Spectrographs',
324
+ epilog='Examples:\n specproc # Start GUI mode\n specproc 20241102_hrs # Auto CLI mode with directory input\n specproc --config # Copy default config to current directory\n specproc --mode cli 20241102_hrs\n specproc --mode cli --config custom.cfg ./raw_data\n specproc --mode cli --config XL216_hrs 20241102_hrs # Process with a packaged telescope profile\n specproc --config MSTG193_slit # Copy a packaged telescope profile to ./specproc.cfg\n\n'
325
+ f'Available telescope profiles: {profiles_hint}',
326
+ formatter_class=argparse.RawDescriptionHelpFormatter
327
+ )
328
+ parser.add_argument('--mode', choices=['gui', 'cli'], default=None,
329
+ help='Run mode: gui or cli (auto: cli when inputs are provided, otherwise gui)')
330
+ parser.add_argument('--config', nargs='?', const=COPY_DEFAULT_CONFIG_SENTINEL, default=None,
331
+ help='Path to configuration file, or the name of a packaged telescope '
332
+ f'profile ({profiles_hint}). With inputs, the profile/config is used '
333
+ 'directly for processing; without inputs, it is copied to ./specproc.cfg. '
334
+ 'Pass --config without a value to copy the generic default config.')
335
+ parser.add_argument('inputs', nargs='*',
336
+ help='FITS files or directories (CLI mode). If omitted, current directory is used.')
337
+ parser.add_argument('--version', action='version', version='SpecProc 1.0.0')
338
+ args = parser.parse_args()
339
+
340
+ # Override with function arguments if provided
341
+ if config_path is not None:
342
+ args.config = config_path
343
+ if mode is not None:
344
+ args.mode = mode
345
+
346
+ if args.config == COPY_DEFAULT_CONFIG_SENTINEL:
347
+ copy_default_config_to_cwd()
348
+ return
349
+
350
+ # If --config was given a value that is not an existing file path, try
351
+ # resolving it as a packaged telescope profile name (e.g. 'XL216_hrs',
352
+ # 'MSTG193_slit'). With inputs provided, use the profile directly for
353
+ # processing; without inputs, bootstrap ./specproc.cfg from it (mirrors
354
+ # the no-value --config behavior above, but for a specific telescope).
355
+ if args.config and not Path(args.config).expanduser().exists():
356
+ profile_path = resolve_telescope_profile(args.config)
357
+ if profile_path is not None:
358
+ if args.inputs:
359
+ args.config = str(profile_path)
360
+ else:
361
+ copy_default_config_to_cwd(profile=args.config)
362
+ return
363
+
364
+ # Auto mode selection:
365
+ # - If positional inputs are provided, default to CLI
366
+ # - Otherwise default to GUI
367
+ if args.mode is None:
368
+ args.mode = 'cli' if args.inputs else 'gui'
369
+
370
+ # Setup logging
371
+ setup_logging()
372
+
373
+ if args.mode == 'cli':
374
+ run_cli_mode(args.config, args.inputs)
375
+ else:
376
+ # GUI mode
377
+ from PyQt5.QtWidgets import QApplication
378
+ from specproc.gui.main_window import MainWindow
379
+
380
+ config = ConfigManager(args.config)
381
+ app = QApplication(sys.argv)
382
+ window = MainWindow(config)
383
+ window.show()
384
+ sys.exit(app.exec_())
385
+
386
+
387
+ if __name__ == '__main__':
388
+ main()
@@ -0,0 +1 @@
1
+ """Plotting and visualization modules."""