solarviewer 1.0.2__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 (82) hide show
  1. solar_radio_image_viewer/__init__.py +12 -0
  2. solar_radio_image_viewer/assets/add_tab_default.png +0 -0
  3. solar_radio_image_viewer/assets/add_tab_default_light.png +0 -0
  4. solar_radio_image_viewer/assets/add_tab_hover.png +0 -0
  5. solar_radio_image_viewer/assets/add_tab_hover_light.png +0 -0
  6. solar_radio_image_viewer/assets/browse.png +0 -0
  7. solar_radio_image_viewer/assets/browse_light.png +0 -0
  8. solar_radio_image_viewer/assets/close_tab_default.png +0 -0
  9. solar_radio_image_viewer/assets/close_tab_default_light.png +0 -0
  10. solar_radio_image_viewer/assets/close_tab_hover.png +0 -0
  11. solar_radio_image_viewer/assets/close_tab_hover_light.png +0 -0
  12. solar_radio_image_viewer/assets/ellipse_selection.png +0 -0
  13. solar_radio_image_viewer/assets/ellipse_selection_light.png +0 -0
  14. solar_radio_image_viewer/assets/icons8-ellipse-90.png +0 -0
  15. solar_radio_image_viewer/assets/icons8-ellipse-90_light.png +0 -0
  16. solar_radio_image_viewer/assets/icons8-info-90.png +0 -0
  17. solar_radio_image_viewer/assets/icons8-info-90_light.png +0 -0
  18. solar_radio_image_viewer/assets/profile.png +0 -0
  19. solar_radio_image_viewer/assets/profile_light.png +0 -0
  20. solar_radio_image_viewer/assets/rectangle_selection.png +0 -0
  21. solar_radio_image_viewer/assets/rectangle_selection_light.png +0 -0
  22. solar_radio_image_viewer/assets/reset.png +0 -0
  23. solar_radio_image_viewer/assets/reset_light.png +0 -0
  24. solar_radio_image_viewer/assets/ruler.png +0 -0
  25. solar_radio_image_viewer/assets/ruler_light.png +0 -0
  26. solar_radio_image_viewer/assets/search.png +0 -0
  27. solar_radio_image_viewer/assets/search_light.png +0 -0
  28. solar_radio_image_viewer/assets/settings.png +0 -0
  29. solar_radio_image_viewer/assets/settings_light.png +0 -0
  30. solar_radio_image_viewer/assets/splash.fits +0 -0
  31. solar_radio_image_viewer/assets/zoom_60arcmin.png +0 -0
  32. solar_radio_image_viewer/assets/zoom_60arcmin_light.png +0 -0
  33. solar_radio_image_viewer/assets/zoom_in.png +0 -0
  34. solar_radio_image_viewer/assets/zoom_in_light.png +0 -0
  35. solar_radio_image_viewer/assets/zoom_out.png +0 -0
  36. solar_radio_image_viewer/assets/zoom_out_light.png +0 -0
  37. solar_radio_image_viewer/create_video.py +1345 -0
  38. solar_radio_image_viewer/dialogs.py +2665 -0
  39. solar_radio_image_viewer/from_simpl/__init__.py +184 -0
  40. solar_radio_image_viewer/from_simpl/caltable_visualizer.py +1001 -0
  41. solar_radio_image_viewer/from_simpl/dynamic_spectra_dialog.py +332 -0
  42. solar_radio_image_viewer/from_simpl/make_dynamic_spectra.py +351 -0
  43. solar_radio_image_viewer/from_simpl/pipeline_logger_gui.py +1232 -0
  44. solar_radio_image_viewer/from_simpl/simpl_theme.py +352 -0
  45. solar_radio_image_viewer/from_simpl/utils.py +984 -0
  46. solar_radio_image_viewer/from_simpl/view_dynamic_spectra_GUI.py +1975 -0
  47. solar_radio_image_viewer/helioprojective.py +1916 -0
  48. solar_radio_image_viewer/helioprojective_viewer.py +817 -0
  49. solar_radio_image_viewer/helioviewer_browser.py +1514 -0
  50. solar_radio_image_viewer/main.py +148 -0
  51. solar_radio_image_viewer/move_phasecenter.py +1269 -0
  52. solar_radio_image_viewer/napari_viewer.py +368 -0
  53. solar_radio_image_viewer/noaa_events/__init__.py +32 -0
  54. solar_radio_image_viewer/noaa_events/noaa_events.py +430 -0
  55. solar_radio_image_viewer/noaa_events/noaa_events_gui.py +1922 -0
  56. solar_radio_image_viewer/norms.py +293 -0
  57. solar_radio_image_viewer/radio_data_downloader/__init__.py +25 -0
  58. solar_radio_image_viewer/radio_data_downloader/radio_data_downloader.py +756 -0
  59. solar_radio_image_viewer/radio_data_downloader/radio_data_downloader_gui.py +528 -0
  60. solar_radio_image_viewer/searchable_combobox.py +220 -0
  61. solar_radio_image_viewer/solar_context/__init__.py +41 -0
  62. solar_radio_image_viewer/solar_context/active_regions.py +371 -0
  63. solar_radio_image_viewer/solar_context/cme_alerts.py +234 -0
  64. solar_radio_image_viewer/solar_context/context_images.py +297 -0
  65. solar_radio_image_viewer/solar_context/realtime_data.py +528 -0
  66. solar_radio_image_viewer/solar_data_downloader/__init__.py +35 -0
  67. solar_radio_image_viewer/solar_data_downloader/solar_data_downloader.py +1667 -0
  68. solar_radio_image_viewer/solar_data_downloader/solar_data_downloader_cli.py +901 -0
  69. solar_radio_image_viewer/solar_data_downloader/solar_data_downloader_gui.py +1210 -0
  70. solar_radio_image_viewer/styles.py +643 -0
  71. solar_radio_image_viewer/utils/__init__.py +32 -0
  72. solar_radio_image_viewer/utils/rate_limiter.py +255 -0
  73. solar_radio_image_viewer/utils.py +952 -0
  74. solar_radio_image_viewer/video_dialog.py +2629 -0
  75. solar_radio_image_viewer/video_utils.py +656 -0
  76. solar_radio_image_viewer/viewer.py +11174 -0
  77. solarviewer-1.0.2.dist-info/METADATA +343 -0
  78. solarviewer-1.0.2.dist-info/RECORD +82 -0
  79. solarviewer-1.0.2.dist-info/WHEEL +5 -0
  80. solarviewer-1.0.2.dist-info/entry_points.txt +8 -0
  81. solarviewer-1.0.2.dist-info/licenses/LICENSE +21 -0
  82. solarviewer-1.0.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,332 @@
1
+ """
2
+ Dynamic Spectra Dialog
3
+
4
+ GUI dialog for creating dynamic spectra from MS files.
5
+ Uses the make_dynamic_spectra.py processing functions.
6
+ """
7
+
8
+ import os
9
+ import sys
10
+ from PyQt5.QtWidgets import (
11
+ QDialog, QVBoxLayout, QHBoxLayout, QGridLayout, QLabel, QLineEdit,
12
+ QPushButton, QFileDialog, QSpinBox, QDoubleSpinBox, QGroupBox,
13
+ QProgressBar, QTextEdit, QCheckBox, QMessageBox, QApplication
14
+ )
15
+ from PyQt5.QtCore import Qt, QThread, pyqtSignal
16
+ from PyQt5.QtGui import QFont
17
+
18
+ from .simpl_theme import apply_theme, get_theme_from_args
19
+
20
+
21
+ class ProcessingThread(QThread):
22
+ """Thread for running dynamic spectra processing in isolated subprocess."""
23
+ progress = pyqtSignal(str)
24
+ finished = pyqtSignal(bool, str)
25
+
26
+ def __init__(self, params):
27
+ super().__init__()
28
+ self.params = params
29
+
30
+ def run(self):
31
+ """Run make_dynamic_spectra in a separate subprocess to avoid casacore/Qt conflicts."""
32
+ import subprocess
33
+ import json
34
+
35
+ try:
36
+ # Build command line arguments for make_dynamic_spectra.py
37
+ script_path = os.path.join(os.path.dirname(__file__), 'make_dynamic_spectra.py')
38
+
39
+ cmd = [
40
+ sys.executable, script_path,
41
+ '--indir', self.params['indir'],
42
+ '--outfits', self.params['outfits'],
43
+ '--binwidth', str(self.params['binwidth']),
44
+ '--ncpu', str(self.params['ncpu']),
45
+ '--uvmin', str(self.params['uvmin']),
46
+ '--uvmax', str(self.params['uvmax']),
47
+ ]
48
+
49
+ # Add optional frequency range
50
+ if self.params.get('startfreq') and self.params.get('endfreq'):
51
+ cmd.extend(['--startfreq', str(self.params['startfreq'])])
52
+ cmd.extend(['--endfreq', str(self.params['endfreq'])])
53
+
54
+ # Add plot options
55
+ if self.params.get('saveplot'):
56
+ cmd.append('--saveplot')
57
+ if self.params.get('plot_filename'):
58
+ cmd.extend(['--plotfile', self.params['plot_filename']])
59
+
60
+ self.progress.emit(f"Running: {os.path.basename(script_path)}")
61
+ self.progress.emit(f"Input: {self.params['indir']}")
62
+ self.progress.emit(f"Output: {self.params['outfits']}")
63
+ self.progress.emit("")
64
+
65
+ # Run in subprocess (completely isolates casacore from Qt)
66
+ process = subprocess.Popen(
67
+ cmd,
68
+ stdout=subprocess.PIPE,
69
+ stderr=subprocess.STDOUT,
70
+ text=True,
71
+ bufsize=1
72
+ )
73
+
74
+ # Stream output in real-time
75
+ for line in iter(process.stdout.readline, ''):
76
+ if line:
77
+ self.progress.emit(line.rstrip())
78
+
79
+ process.wait()
80
+
81
+ if process.returncode == 0:
82
+ self.finished.emit(True, f"Success! Created: {self.params['outfits']}")
83
+ else:
84
+ self.finished.emit(False, f"Processing failed with exit code {process.returncode}")
85
+
86
+ except Exception as e:
87
+ self.finished.emit(False, f"Error: {str(e)}")
88
+
89
+
90
+ class DynamicSpectraDialog(QDialog):
91
+ """Dialog for creating dynamic spectra from MS files."""
92
+
93
+ def __init__(self, parent=None, theme="dark"):
94
+ super().__init__(parent)
95
+ self.theme = theme
96
+ self.processing_thread = None
97
+ self._setup_ui()
98
+
99
+ def _setup_ui(self):
100
+ self.setWindowTitle("Create Dynamic Spectra")
101
+ self.setMinimumSize(600, 500)
102
+
103
+ layout = QVBoxLayout(self)
104
+ layout.setSpacing(12)
105
+
106
+ # Input/Output Group
107
+ io_group = QGroupBox("Input / Output")
108
+ io_layout = QGridLayout(io_group)
109
+
110
+ # Input directory
111
+ io_layout.addWidget(QLabel("MS Directory:"), 0, 0)
112
+ self.input_edit = QLineEdit()
113
+ self.input_edit.setPlaceholderText("Directory containing MS files...")
114
+ io_layout.addWidget(self.input_edit, 0, 1)
115
+ self.input_browse_btn = QPushButton("Browse...")
116
+ self.input_browse_btn.clicked.connect(self._browse_input)
117
+ io_layout.addWidget(self.input_browse_btn, 0, 2)
118
+
119
+ # Output FITS file
120
+ io_layout.addWidget(QLabel("Output FITS:"), 1, 0)
121
+ self.output_edit = QLineEdit()
122
+ self.output_edit.setPlaceholderText("Output FITS file path...")
123
+ self.output_edit.setText("dynamic_spectrum.fits")
124
+ io_layout.addWidget(self.output_edit, 1, 1)
125
+ self.output_browse_btn = QPushButton("Browse...")
126
+ self.output_browse_btn.clicked.connect(self._browse_output)
127
+ io_layout.addWidget(self.output_browse_btn, 1, 2)
128
+
129
+ layout.addWidget(io_group)
130
+
131
+ # Parameters Group
132
+ params_group = QGroupBox("Processing Parameters")
133
+ params_layout = QGridLayout(params_group)
134
+
135
+ # Time binwidth
136
+ params_layout.addWidget(QLabel("Time Bin Width (s):"), 0, 0)
137
+ self.binwidth_spin = QDoubleSpinBox()
138
+ self.binwidth_spin.setRange(0.1, 60.0)
139
+ self.binwidth_spin.setValue(1.0)
140
+ self.binwidth_spin.setDecimals(1)
141
+ params_layout.addWidget(self.binwidth_spin, 0, 1)
142
+
143
+ # Number of CPUs
144
+ params_layout.addWidget(QLabel("CPU Cores:"), 0, 2)
145
+ self.ncpu_spin = QSpinBox()
146
+ self.ncpu_spin.setRange(1, os.cpu_count() or 8)
147
+ self.ncpu_spin.setValue(min(10, os.cpu_count() or 8))
148
+ params_layout.addWidget(self.ncpu_spin, 0, 3)
149
+
150
+ # UV range
151
+ params_layout.addWidget(QLabel("UV Min (λ):"), 1, 0)
152
+ self.uvmin_spin = QDoubleSpinBox()
153
+ self.uvmin_spin.setRange(0, 10000)
154
+ self.uvmin_spin.setValue(130.0)
155
+ params_layout.addWidget(self.uvmin_spin, 1, 1)
156
+
157
+ params_layout.addWidget(QLabel("UV Max (λ):"), 1, 2)
158
+ self.uvmax_spin = QDoubleSpinBox()
159
+ self.uvmax_spin.setRange(0, 10000)
160
+ self.uvmax_spin.setValue(500.0)
161
+ params_layout.addWidget(self.uvmax_spin, 1, 3)
162
+
163
+ # Frequency range (optional)
164
+ params_layout.addWidget(QLabel("Start Freq (MHz):"), 2, 0)
165
+ self.startfreq_spin = QDoubleSpinBox()
166
+ self.startfreq_spin.setRange(0, 1000)
167
+ self.startfreq_spin.setValue(0)
168
+ self.startfreq_spin.setSpecialValueText("Auto")
169
+ params_layout.addWidget(self.startfreq_spin, 2, 1)
170
+
171
+ params_layout.addWidget(QLabel("End Freq (MHz):"), 2, 2)
172
+ self.endfreq_spin = QDoubleSpinBox()
173
+ self.endfreq_spin.setRange(0, 1000)
174
+ self.endfreq_spin.setValue(0)
175
+ self.endfreq_spin.setSpecialValueText("Auto")
176
+ params_layout.addWidget(self.endfreq_spin, 2, 3)
177
+
178
+ layout.addWidget(params_group)
179
+
180
+ # Options Group
181
+ options_group = QGroupBox("Options")
182
+ options_layout = QHBoxLayout(options_group)
183
+
184
+ self.save_plot_check = QCheckBox("Save Plot")
185
+ self.save_plot_check.setChecked(True)
186
+ options_layout.addWidget(self.save_plot_check)
187
+
188
+ options_layout.addStretch()
189
+
190
+ layout.addWidget(options_group)
191
+
192
+ # Progress section
193
+ progress_group = QGroupBox("Progress")
194
+ progress_layout = QVBoxLayout(progress_group)
195
+
196
+ self.progress_bar = QProgressBar()
197
+ self.progress_bar.setRange(0, 0) # Indeterminate
198
+ self.progress_bar.setVisible(False)
199
+ progress_layout.addWidget(self.progress_bar)
200
+
201
+ self.log_text = QTextEdit()
202
+ self.log_text.setReadOnly(True)
203
+ self.log_text.setMaximumHeight(120)
204
+ self.log_text.setFont(QFont("Consolas", 9))
205
+ progress_layout.addWidget(self.log_text)
206
+
207
+ layout.addWidget(progress_group)
208
+
209
+ # Buttons
210
+ button_layout = QHBoxLayout()
211
+ button_layout.addStretch()
212
+
213
+ self.run_btn = QPushButton("Create Dynamic Spectra")
214
+ self.run_btn.setMinimumWidth(180)
215
+ self.run_btn.clicked.connect(self._run_processing)
216
+ button_layout.addWidget(self.run_btn)
217
+
218
+ self.close_btn = QPushButton("Close")
219
+ self.close_btn.clicked.connect(self.close)
220
+ button_layout.addWidget(self.close_btn)
221
+
222
+ layout.addLayout(button_layout)
223
+
224
+ def _browse_input(self):
225
+ dir_path = QFileDialog.getExistingDirectory(
226
+ self, "Select MS Directory",
227
+ self.input_edit.text() or os.path.expanduser("~")
228
+ )
229
+ if dir_path:
230
+ self.input_edit.setText(dir_path)
231
+ # Auto-set output path
232
+ if not self.output_edit.text() or self.output_edit.text() == "dynamic_spectrum.fits":
233
+ self.output_edit.setText(os.path.join(dir_path, "dynamic_spectrum.fits"))
234
+
235
+ def _browse_output(self):
236
+ file_path, _ = QFileDialog.getSaveFileName(
237
+ self, "Save FITS File",
238
+ self.output_edit.text() or "dynamic_spectrum.fits",
239
+ "FITS Files (*.fits);;All Files (*)"
240
+ )
241
+ if file_path:
242
+ self.output_edit.setText(file_path)
243
+
244
+ def _run_processing(self):
245
+ # Validate input
246
+ if not self.input_edit.text():
247
+ QMessageBox.warning(self, "Missing Input", "Please select an MS directory.")
248
+ return
249
+
250
+ if not os.path.isdir(self.input_edit.text()):
251
+ QMessageBox.warning(self, "Invalid Input", "The specified directory does not exist.")
252
+ return
253
+
254
+ if not self.output_edit.text():
255
+ QMessageBox.warning(self, "Missing Output", "Please specify an output FITS file path.")
256
+ return
257
+
258
+ # Check for MS files
259
+ import glob
260
+ ms_files = glob.glob(os.path.join(self.input_edit.text(), "*.MS"))
261
+ if not ms_files:
262
+ QMessageBox.warning(self, "No MS Files", "No .MS files found in the specified directory.")
263
+ return
264
+
265
+ # Prepare parameters
266
+ params = {
267
+ 'indir': self.input_edit.text(),
268
+ 'outfits': self.output_edit.text(),
269
+ 'binwidth': self.binwidth_spin.value(),
270
+ 'ncpu': self.ncpu_spin.value(),
271
+ 'uvmin': self.uvmin_spin.value(),
272
+ 'uvmax': self.uvmax_spin.value(),
273
+ 'startfreq': self.startfreq_spin.value() if self.startfreq_spin.value() > 0 else None,
274
+ 'endfreq': self.endfreq_spin.value() if self.endfreq_spin.value() > 0 else None,
275
+ 'saveplot': self.save_plot_check.isChecked(),
276
+ 'plot_filename': self.output_edit.text().replace('.fits', '.png') if self.save_plot_check.isChecked() else None
277
+ }
278
+
279
+ # Start processing
280
+ self.log_text.clear()
281
+ self.log_text.append(f"Found {len(ms_files)} MS files")
282
+ self.log_text.append(f"Output: {params['outfits']}")
283
+ self.log_text.append("Starting processing...")
284
+
285
+ self.progress_bar.setVisible(True)
286
+ self.run_btn.setEnabled(False)
287
+
288
+ self.processing_thread = ProcessingThread(params)
289
+ self.processing_thread.progress.connect(self._on_progress)
290
+ self.processing_thread.finished.connect(self._on_finished)
291
+ self.processing_thread.start()
292
+
293
+ def _on_progress(self, message):
294
+ self.log_text.append(message)
295
+ # Auto-scroll to bottom
296
+ scrollbar = self.log_text.verticalScrollBar()
297
+ scrollbar.setValue(scrollbar.maximum())
298
+
299
+ def _on_finished(self, success, message):
300
+ self.progress_bar.setVisible(False)
301
+ self.run_btn.setEnabled(True)
302
+
303
+ self.log_text.append("")
304
+ self.log_text.append(f"{'✓' if success else '✗'} {message}")
305
+
306
+ if success:
307
+ QMessageBox.information(self, "Success", message)
308
+ else:
309
+ QMessageBox.warning(self, "Processing Failed", message)
310
+
311
+
312
+ def main():
313
+ """Main entry point for standalone dialog."""
314
+ # Clear Qt environment variables to avoid opencv conflicts
315
+ for key in ['QT_PLUGIN_PATH', 'QT_QPA_PLATFORM_PLUGIN_PATH']:
316
+ if key in os.environ:
317
+ del os.environ[key]
318
+
319
+ app = QApplication(sys.argv)
320
+
321
+ # Get theme from command line
322
+ theme = get_theme_from_args()
323
+ apply_theme(app, theme)
324
+
325
+ dialog = DynamicSpectraDialog(theme=theme)
326
+ dialog.show()
327
+
328
+ sys.exit(app.exec_())
329
+
330
+
331
+ if __name__ == "__main__":
332
+ main()
@@ -0,0 +1,351 @@
1
+ """
2
+ Create Dynamic Spectra from MS Files
3
+
4
+ This script:
5
+ 1. Accepts a directory (--indir) containing MS files (each representing one subband).
6
+ 2. Processes each MS file in parallel (using --ncpu processes) to:
7
+ - Read TIME, DATA, FLAG, and UVW columns using casacore.
8
+ - Filter rows with uv distance (in wavelengths) outside the range [130, 500].
9
+ - Bin data in time with a specified bin width (in seconds) and compute the mean amplitude.
10
+ 3. Combines the results into a dynamic spectrum (2D array: time x subband).
11
+ 4. Reads each MS file's frequency (median from the SPECTRAL_WINDOW table) and converts it to MHz.
12
+ 5. Saves the dynamic spectrum to a FITS file. In the FITS file:
13
+ - The primary HDU contains the dynamic spectrum.
14
+ - A BinTableHDU contains the time axis in MJD and a second column with UTC strings.
15
+ - A second BinTableHDU contains the subband frequencies in MHz.
16
+ 6. Optionally saves and/or displays a plot. The plot's x-axis is in UTC.
17
+
18
+ Usage:
19
+ python create_dynamic_spectra.py --indir /path/to/msfiles --outfits dynamic_spectra.fits --binwidth 1.0 --ncpu 10 --saveplot False --showplot False
20
+ """
21
+
22
+ import os
23
+ import sys
24
+ import glob
25
+ import argparse
26
+ import re
27
+ import numpy as np
28
+ import matplotlib.pyplot as plt
29
+ from casacore.tables import table
30
+ from astropy.io import fits
31
+ from astropy.time import Time
32
+ import concurrent.futures
33
+ from matplotlib.dates import DateFormatter, date2num
34
+ import logging
35
+
36
+ try:
37
+ from pipeline.tasks.basic_functions import MS_inquiry
38
+ pipeline_run = True
39
+ except ImportError:
40
+ pipeline_run = False
41
+
42
+ # ------------------ Helper Functions ------------------
43
+ def extract_sb_number(filename):
44
+ """Extract integer following '_SB' from the filename for sorting."""
45
+ m = re.search(r'_SB(\d+)_', filename)
46
+ return int(m.group(1)) if m else -1
47
+
48
+ def read_frequency(ms_file):
49
+ """Read the median channel frequency (Hz) from the SPECTRAL_WINDOW subtable."""
50
+ sp_tab = table(os.path.join(ms_file, "SPECTRAL_WINDOW"), ack=False, readonly=True)
51
+ chan_freq = sp_tab.getcol("CHAN_FREQ")
52
+ median_freq = np.median(chan_freq)
53
+ sp_tab.close()
54
+ return median_freq
55
+
56
+ def read_ms(ms_file, binwidth=1.0, uv_min=130.0, uv_max=500.0):
57
+ """
58
+ Read one MS file and produce binned mean amplitude values.
59
+
60
+ - Reads TIME, DATA, FLAG, and UVW columns.
61
+ - Computes uv distance (in wavelengths) using wavelength = 299792458/freq.
62
+ - Filters rows with uv distance outside [uv_min, uv_max] lambda.
63
+ - Bins the data in time bins of width binwidth (seconds) and computes the mean amplitude.
64
+
65
+ Returns:
66
+ bin_centers (np.array): Array of bin center times (in seconds).
67
+ mean_amp (np.array): Mean amplitude for each bin.
68
+ """
69
+ try:
70
+ sp_tab = table(os.path.join(ms_file, "SPECTRAL_WINDOW"), ack=False, readonly=True)
71
+ chan_freq = sp_tab.getcol("CHAN_FREQ")
72
+ freq = np.median(chan_freq)
73
+ sp_tab.close()
74
+ except Exception as e:
75
+ print(f"Error reading frequency from {ms_file}: {e}")
76
+ raise
77
+
78
+ wavelength = 299792458.0 / freq # in meters
79
+ print(f"Processing {os.path.basename(ms_file)}: freq = {freq/1e6:.2f} MHz, λ = {wavelength*1e2:.2f} cm")
80
+
81
+ try:
82
+ tb = table(ms_file, ack=False, readonly=True)
83
+ times = tb.getcol("TIME") # seconds (assumed absolute)
84
+ # For simplicity, take the first correlation from DATA.
85
+ data = tb.getcol("DATA")[:,:,0] # shape: (nrow, nchan)
86
+ flags = tb.getcol("FLAG")
87
+ uvw = tb.getcol("UVW") # shape: (nrow, 3)
88
+ tb.close()
89
+ except Exception as e:
90
+ print(f"Error reading MS columns from {ms_file}: {e}")
91
+ raise
92
+
93
+ uv_dist = np.sqrt(uvw[:,0]**2 + uvw[:,1]**2)
94
+ uv_lambda = uv_dist / wavelength
95
+ valid_mask = (uv_lambda >= uv_min) & (uv_lambda <= uv_max)
96
+ if np.sum(valid_mask) == 0:
97
+ print(f"Warning: No valid data in uv-lambda range for {ms_file}.")
98
+ return np.array([]), np.array([])
99
+
100
+ times = times[valid_mask]
101
+ data = data[valid_mask]
102
+ flags = flags[valid_mask]
103
+ #times = np.unique(times)
104
+
105
+ min_time = np.min(times)
106
+ max_time = np.max(times)
107
+ edges = np.arange(min_time, max_time + binwidth, binwidth)
108
+
109
+ # Use vectorized binning.
110
+ # Compute mean amplitude per row (averaged over channels).
111
+ row_mean = np.mean(np.abs(data), axis=1)
112
+ # Digitize times into bins.
113
+ bin_idx = np.digitize(times, edges) - 1 # 0-indexed
114
+ nbins = len(edges) - 1
115
+ sum_per_bin = np.bincount(bin_idx, weights=row_mean, minlength=nbins)
116
+ count_per_bin = np.bincount(bin_idx, minlength=nbins)
117
+ mean_amp = np.full(nbins, np.nan)
118
+ valid = count_per_bin > 0
119
+ mean_amp[valid] = sum_per_bin[valid] / count_per_bin[valid]
120
+ bin_centers = (edges[:-1] + edges[1:]) / 2.0
121
+
122
+ return bin_centers, mean_amp
123
+
124
+ def create_dynamic_spectra(indir, binwidth=1.0, ncpu=10, uv_min=130.0, uv_max=500.0, startfreq=None, endfreq=None):
125
+ """
126
+ Process all MS files in 'indir' in parallel to create a dynamic spectrum.
127
+
128
+ Each MS file (each representing one subband) is processed to extract its
129
+ time bin centers and mean amplitude (filtered for uv-lambda between uv_min and uv_max).
130
+
131
+ Returns:
132
+ all_times (np.array): Sorted array of unique time bin centers (in seconds).
133
+ dynamic_spectra (2D np.array): Array of shape (Ntime, Nsub) with mean amplitudes.
134
+ Missing values are filled with NaN.
135
+ subband_indices (list): List of subband numbers corresponding to each column.
136
+ subband_freqs (list): List of frequencies (in MHz) for each subband.
137
+ """
138
+ if (startfreq is None and endfreq is None) or (startfreq == 0.0 and endfreq == 0.0):
139
+ msfiles = sorted(glob.glob(os.path.join(indir, "*.MS")), key=lambda x: extract_sb_number(os.path.basename(x)))
140
+ elif startfreq<0.0 or endfreq<0.0:
141
+ raise RuntimeError(f"Invalid frequency range: {startfreq} to {endfreq}. Exiting...")
142
+ else:
143
+ from pipeline.tasks.basic_functions import get_filtered_MSs_for_given_freq_range
144
+ msfiles = get_filtered_MSs_for_given_freq_range(indir, startfreq, endfreq)
145
+ if len(msfiles) == 0:
146
+ raise RuntimeError(f"No MS files found in {indir}")
147
+
148
+ results = {}
149
+ subband_freqs = []
150
+ with concurrent.futures.ProcessPoolExecutor(max_workers=ncpu) as executor:
151
+ futures = {executor.submit(read_ms, msfile, binwidth, uv_min=uv_min, uv_max=uv_max): msfile for msfile in msfiles}
152
+ for future in concurrent.futures.as_completed(futures):
153
+ msfile = futures[future]
154
+ try:
155
+ t, amp = future.result()
156
+ results[msfile] = (t, amp)
157
+ except Exception as e:
158
+ print(f"Error processing {msfile}: {e}")
159
+ for msfile in msfiles:
160
+ try:
161
+ freq = read_frequency(msfile)
162
+ subband_freqs.append(freq/1e6)
163
+ except Exception as e:
164
+ print(f"Error reading frequency from {msfile}: {e}")
165
+ subband_freqs.append(np.nan)
166
+
167
+ all_times_set = set()
168
+ subband_indices = []
169
+ for msfile in msfiles:
170
+ if msfile in results:
171
+ t, amp = results[msfile]
172
+ all_times_set.update(t)
173
+ subband_indices.append(extract_sb_number(os.path.basename(msfile)))
174
+ else:
175
+ subband_indices.append(extract_sb_number(os.path.basename(msfile)))
176
+ all_times = np.array(sorted(list(all_times_set)))
177
+ Ntime = len(all_times)
178
+ Nsub = len(msfiles)
179
+ dynamic_spectra = np.full((Ntime, Nsub), np.nan, dtype=np.float32)
180
+ for s, msfile in enumerate(msfiles):
181
+ if msfile not in results:
182
+ continue
183
+ t, amp = results[msfile]
184
+ time_amp = {t[i]: amp[i] for i in range(len(t))}
185
+ for i, gt in enumerate(all_times):
186
+ if gt in time_amp:
187
+ dynamic_spectra[i, s] = time_amp[gt]
188
+ return all_times, dynamic_spectra, subband_indices, subband_freqs
189
+
190
+ def write_fits(all_times, dynamic_spectra, subband_freqs, outfits):
191
+ """
192
+ Write the dynamic spectrum to a FITS file.
193
+
194
+ The primary HDU holds the dynamic spectrum (axes: TIME x Subband).
195
+ Two BinTable HDUs are added:
196
+ - One for the time axis (TIME_MJD and UTC columns).
197
+ - One for the subband frequencies (FREQ_MHz column).
198
+ """
199
+ # Convert global time (in seconds) to MJD and UTC.
200
+ time_mjd = all_times / 86400.0
201
+ utc_times = Time(time_mjd, format='mjd', scale='utc').iso
202
+
203
+ # Primary HDU with dynamic spectrum.
204
+ hdu = fits.PrimaryHDU(dynamic_spectra)
205
+ hdr = hdu.header
206
+ hdr["EXTNAME"] = "DYNAMIC_SPECTRUM"
207
+ hdr["BUNIT"] = "Amplitude"
208
+ hdr["COMMENT"] = "Axis0 = time (s), Axis1 = subband (sorted by _SBxxx_ in filename)"
209
+
210
+ # BinTable HDU for time axis.
211
+ col_time = fits.Column(name="TIME_MJD", format="E", array=time_mjd)
212
+ col_utc = fits.Column(name="UTC", format="20A", array=np.array(utc_times))
213
+ time_hdu = fits.BinTableHDU.from_columns([col_time, col_utc])
214
+ time_hdu.name = "TIME_AXIS"
215
+
216
+ # BinTable HDU for frequency axis.
217
+ col_freq = fits.Column(name="FREQ_MHz", format="E", array=np.array(subband_freqs))
218
+ freq_hdu = fits.BinTableHDU.from_columns([col_freq])
219
+ freq_hdu.name = "FREQ_AXIS"
220
+
221
+ hdul = fits.HDUList([hdu, time_hdu, freq_hdu])
222
+ hdul.writeto(outfits, overwrite=True)
223
+ print(f"Dynamic spectra saved to {outfits}")
224
+
225
+ def run_dynamic_spectra(indir, outfits, binwidth=1.0, ncpu=10, uvmin=130.0, uvmax=500.0, startfreq=None, endfreq=None, saveplot=False, showplot=False, logger=None, plot_filename=None):
226
+ """
227
+ Process all MS files in 'indir' to create a dynamic spectrum and save to a FITS file.
228
+
229
+ Parameters
230
+ ----------
231
+ indir : str
232
+ Directory containing MS files.
233
+ outfits : str
234
+ Output FITS file path.
235
+ binwidth : float, optional
236
+ Time bin width in seconds, by default 1.0
237
+ ncpu : int, optional
238
+ Number of CPU cores to use, by default 10
239
+ uvmin : float, optional
240
+ Minimum UV baseline in lambda, by default 130.0
241
+ uvmax : float, optional
242
+ Maximum UV baseline in lambda, by default 500.0
243
+ startfreq : float, optional
244
+ Start frequency in MHz, by default None
245
+ endfreq : float, optional
246
+ End frequency in MHz, by default None
247
+ saveplot : bool, optional
248
+ Whether to save the plot, by default False
249
+ showplot : bool, optional
250
+ Whether to show the plot, by default False
251
+ logger : logging.Logger, optional
252
+ Logger instance, by default None
253
+ plot_filename : str, optional
254
+ Path where the plot should be saved, by default None
255
+ If None but saveplot is True, "dynamic_spectrum.png" will be used
256
+
257
+ Returns
258
+ -------
259
+ str
260
+ Path to the output FITS file.
261
+ """
262
+ if logger is None:
263
+ logger = logging.getLogger("create_dynamic_spectra")
264
+
265
+ try:
266
+ all_times, dynamic_spectra, subband_indices, subband_freqs = create_dynamic_spectra(
267
+ indir, binwidth=binwidth, ncpu=ncpu, uv_min=uvmin, uv_max=uvmax, startfreq=startfreq, endfreq=endfreq)
268
+ except Exception as e:
269
+ if logger:
270
+ logger.exception(f"Error creating dynamic spectra: {e}")
271
+ else:
272
+ print(f"Error creating dynamic spectra: {e}")
273
+ return None
274
+
275
+ # For plotting, convert time (in seconds) to UTC datetime numbers.
276
+ time_mjd = all_times / 86400.0
277
+ utc_dt = Time(time_mjd, format='mjd', scale='utc').to_datetime()
278
+ utc_num = [date2num(dt) for dt in utc_dt]
279
+
280
+ # Plot dynamic spectrum with x-axis in UTC.
281
+ if saveplot or showplot:
282
+ from matplotlib.dates import DateFormatter
283
+ plt.figure(figsize=(10, 6))
284
+ extent = [utc_num[0], utc_num[-1], min(subband_freqs), max(subband_freqs)]
285
+ plt.imshow(dynamic_spectra.T, aspect='auto', origin='lower', extent=extent, cmap='viridis')
286
+ plt.xlabel("Time (UTC)")
287
+ plt.ylabel("Frequency (MHz)")
288
+ plt.title("Dynamic Spectrum")
289
+ plt.colorbar(label="Amplitude")
290
+ ax = plt.gca()
291
+ date_formatter = DateFormatter('%Y-%m-%d\n%H:%M:%S')
292
+ ax.xaxis.set_major_formatter(date_formatter)
293
+ plt.gcf().autofmt_xdate()
294
+
295
+ if saveplot:
296
+ # Use the provided plot_filename or default to "dynamic_spectrum.png"
297
+ save_path = plot_filename if plot_filename else "dynamic_spectrum.png"
298
+ plt.savefig(save_path, dpi=150, bbox_inches='tight')
299
+ if logger:
300
+ logger.info(f"Dynamic spectrum plot saved as {save_path}")
301
+ else:
302
+ print(f"Dynamic spectrum plot saved as {save_path}")
303
+
304
+ if showplot:
305
+ plt.show()
306
+ else:
307
+ plt.close()
308
+
309
+ write_fits(all_times, dynamic_spectra, subband_freqs, outfits)
310
+ return outfits
311
+
312
+ def main():
313
+ parser = argparse.ArgumentParser(description="Create Dynamic Spectra from MS files in a directory.")
314
+ parser.add_argument("--indir", type=str, required=True, help="Directory containing MS files")
315
+ parser.add_argument("--outfits", type=str, default="dynamic_spectra.fits", help="Output FITS file name")
316
+ parser.add_argument("--binwidth", type=float, default=1.0, help="Time bin width in seconds (default 1 s)")
317
+ parser.add_argument("--ncpu", type=int, default=10, help="Number of CPU threads to use (default 10)")
318
+ parser.add_argument("--uvmin", type=float, default=130.0, help="Minimum UV baseline (lambda)")
319
+ parser.add_argument("--uvmax", type=float, default=500.0, help="Maximum UV baseline (lambda)")
320
+ parser.add_argument("--startfreq", type=float, default=None, help="Start frequency in MHz (optional)")
321
+ parser.add_argument("--endfreq", type=float, default=None, help="End frequency in MHz (optional)")
322
+ parser.add_argument("--saveplot", action="store_true", help="Save plot to file")
323
+ parser.add_argument("--showplot", action="store_true", help="Show plot in window")
324
+ parser.add_argument("--plotfile", type=str, default=None, help="Filename to save the plot")
325
+ args = parser.parse_args()
326
+
327
+ # Setup a logger
328
+ logger = logging.getLogger("create_dynamic_spectra")
329
+ logger.setLevel(logging.INFO)
330
+ handler = logging.StreamHandler()
331
+ handler.setFormatter(logging.Formatter('%(asctime)s - %(levelname)s - %(message)s'))
332
+ logger.addHandler(handler)
333
+
334
+ run_dynamic_spectra(
335
+ args.indir,
336
+ outfits=args.outfits,
337
+ binwidth=args.binwidth,
338
+ ncpu=args.ncpu,
339
+ uvmin=args.uvmin,
340
+ uvmax=args.uvmax,
341
+ startfreq=args.startfreq,
342
+ endfreq=args.endfreq,
343
+ saveplot=args.saveplot,
344
+ showplot=args.showplot,
345
+ logger=logger,
346
+ plot_filename=args.plotfile
347
+ )
348
+
349
+ if __name__ == "__main__":
350
+ from matplotlib.dates import date2num # imported here for plotting conversion
351
+ main()