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,1001 @@
1
+ import sys
2
+ import numpy as np
3
+ import seaborn as sns # Import seaborn
4
+ from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
5
+ QPushButton, QLabel, QFileDialog, QTabWidget, QComboBox,
6
+ QGridLayout, QStyleFactory, QProgressBar, QShortcut)
7
+ from PyQt5.QtCore import Qt, QThread, pyqtSignal, QTimer
8
+ from PyQt5.QtGui import QIcon, QKeySequence, QCursor
9
+ from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
10
+ from matplotlib.figure import Figure
11
+ # NOTE: casacore is loaded via subprocess to completely isolate it from Qt
12
+ from pathlib import Path
13
+ from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar
14
+ import subprocess
15
+ import tempfile
16
+ import pickle
17
+
18
+
19
+ def read_caltable_safe(table_path, read_spectral_window=False):
20
+ """
21
+ Read a caltable using a completely separate subprocess to avoid casacore/Qt conflicts.
22
+ Uses subprocess.run + pickle for complete isolation - no shared Python state.
23
+ """
24
+ # Create a script that reads the caltable and outputs pickled data
25
+ script = f'''
26
+ import pickle
27
+ import sys
28
+ from casacore.tables import table
29
+
30
+ result = {{}}
31
+
32
+ # Read main table
33
+ tb = table("{table_path}", readonly=True)
34
+ result['solutions'] = tb.getcol("CPARAM")
35
+ result['flag'] = tb.getcol("FLAG")
36
+ tb.close()
37
+
38
+ # Read spectral window if requested
39
+ if {read_spectral_window}:
40
+ tb = table("{table_path}/SPECTRAL_WINDOW", readonly=True)
41
+ result['chan_freq'] = tb.getcol("CHAN_FREQ")
42
+ tb.close()
43
+
44
+ # Write result to stdout as pickle
45
+ sys.stdout.buffer.write(pickle.dumps(result))
46
+ '''
47
+
48
+ # Run in completely separate process
49
+ result = subprocess.run(
50
+ [sys.executable, "-c", script],
51
+ capture_output=True,
52
+ check=True
53
+ )
54
+
55
+ # Unpickle the result
56
+ data = pickle.loads(result.stdout)
57
+ return data
58
+
59
+ class WorkerThread(QThread):
60
+ finished = pyqtSignal()
61
+
62
+ def __init__(self, func, *args, **kwargs):
63
+ super().__init__()
64
+ self.func = func
65
+ self.args = args
66
+ self.kwargs = kwargs
67
+
68
+ def run(self):
69
+ self.func(*self.args, **self.kwargs)
70
+ self.finished.emit()
71
+
72
+ class VisualizationApp(QMainWindow):
73
+ def __init__(self):
74
+ super().__init__()
75
+ self.setWindowTitle("Bandpass and Crossphase Visualizer")
76
+ self.setGeometry(100, 100, 860, 810)
77
+
78
+ # Variables for bandpass
79
+ self.bandpass_solutions = None
80
+ self.bandpass_freqs = None
81
+ self.crossphase_freq = None
82
+ self.crossphase_data = None
83
+ self.num_antennas = 0
84
+ self.current_page = 0
85
+ self.plot_rows = 3 # Default rows for plots
86
+ self.plot_cols = 3 # Default columns for plots
87
+ self.antennas_per_page = self.plot_rows * self.plot_cols
88
+ self.bandpass_directory = None
89
+ self.crossphase_file = None
90
+ self.plot_mode = "Amplitude" # Default mode for bandpass
91
+
92
+ # Variables for crossphase
93
+ self.crossphase_file = None
94
+ self.crossphase_freq = None
95
+ self.crossphase = None
96
+ self.cross_freq_filtered = None
97
+ self.crossphase_filtered = None
98
+ self.cross_fit_func = None
99
+ self.cross_r_squared = None
100
+ self.cross_std_residuals = None
101
+
102
+ # Variables for selfcal
103
+ self.selfcal_directory = None
104
+ # self.plot_mode is reused for selfcal as well
105
+
106
+ # Create GUI elements
107
+ self.create_widgets()
108
+ self.setup_shortcuts()
109
+
110
+ def setup_shortcuts(self):
111
+ """Setup keyboard shortcuts for navigation and actions."""
112
+ # Navigation shortcuts (similar to solarviewer)
113
+ QShortcut(QKeySequence("]"), self, self.next_bandpass_page) # Next page
114
+ QShortcut(QKeySequence("["), self, self.prev_bandpass_page) # Previous page
115
+ QShortcut(QKeySequence("}"), self, self.last_bandpass_page) # Last page (Shift+])
116
+ QShortcut(QKeySequence("{"), self, self.first_bandpass_page) # First page (Shift+[)
117
+
118
+ # Mode toggle
119
+ QShortcut(QKeySequence("T"), self, self.toggle_current_mode) # Toggle amplitude/phase
120
+
121
+ # File operations
122
+ QShortcut(QKeySequence("Ctrl+O"), self, self.open_current_tab) # Open directory/file
123
+ QShortcut(QKeySequence("Ctrl+Q"), self, self.close) # Quit
124
+
125
+ # Tab switching
126
+ QShortcut(QKeySequence("1"), self, lambda: self.tab_control.setCurrentIndex(0)) # Bandpass tab
127
+ QShortcut(QKeySequence("2"), self, lambda: self.tab_control.setCurrentIndex(1)) # Crossphase tab
128
+ QShortcut(QKeySequence("3"), self, lambda: self.tab_control.setCurrentIndex(2)) # Selfcal tab
129
+
130
+ def _set_busy_cursor(self):
131
+ """Set busy (wait) cursor during long operations."""
132
+ QApplication.setOverrideCursor(QCursor(Qt.WaitCursor))
133
+ QApplication.processEvents()
134
+
135
+ def _restore_cursor(self):
136
+ """Restore normal cursor after operation completes."""
137
+ QApplication.restoreOverrideCursor()
138
+
139
+ def toggle_current_mode(self):
140
+ """Toggle mode based on current tab."""
141
+ current_tab = self.tab_control.currentIndex()
142
+ if current_tab == 0: # Bandpass
143
+ self.toggle_bandpass_mode()
144
+ elif current_tab == 2: # Selfcal
145
+ self.toggle_selfcal_mode()
146
+
147
+ def open_current_tab(self):
148
+ """Open directory/file based on current tab."""
149
+ current_tab = self.tab_control.currentIndex()
150
+ if current_tab == 0: # Bandpass
151
+ self.select_bandpass_directory()
152
+ elif current_tab == 1: # Crossphase
153
+ self.select_crossphase_file()
154
+ elif current_tab == 2: # Selfcal
155
+ self.select_selfcal_directory()
156
+
157
+ def show_shortcuts_dialog(self):
158
+ """Show dialog with keyboard shortcuts and usage documentation."""
159
+ from PyQt5.QtWidgets import QDialog, QTextEdit
160
+
161
+ dialog = QDialog(self)
162
+ dialog.setWindowTitle("Help - Caltable Visualizer")
163
+ dialog.resize(500, 500)
164
+
165
+ layout = QVBoxLayout(dialog)
166
+
167
+ text = QTextEdit()
168
+ text.setReadOnly(True)
169
+ text.setHtml("""
170
+ <h2>Caltable Visualizer</h2>
171
+ <p>A tool for visualizing LOFAR calibration tables.</p>
172
+
173
+ <h3>Features</h3>
174
+ <ul>
175
+ <li><b>Bandpass:</b> View bandpass calibration solutions (amplitude/phase vs frequency)</li>
176
+ <li><b>Crossphase:</b> View crossphase calibration with polynomial fit</li>
177
+ <li><b>Selfcal:</b> View self-calibration solutions (amplitude/phase vs antenna)</li>
178
+ </ul>
179
+
180
+ <hr>
181
+ <h2>Keyboard Shortcuts</h2>
182
+
183
+ <h3>Navigation</h3>
184
+ <table>
185
+ <tr><td width="100"><b>]</b></td><td>Next page</td></tr>
186
+ <tr><td><b>[</b></td><td>Previous page</td></tr>
187
+ <tr><td><b>}</b> (Shift+])</td><td>Last page</td></tr>
188
+ <tr><td><b>{</b> (Shift+[)</td><td>First page</td></tr>
189
+ </table>
190
+
191
+ <h3>Mode & File</h3>
192
+ <table>
193
+ <tr><td width="100"><b>T</b></td><td>Toggle Amplitude/Phase mode</td></tr>
194
+ <tr><td><b>Ctrl+O</b></td><td>Open directory/file (current tab)</td></tr>
195
+ <tr><td><b>Ctrl+Q</b></td><td>Quit application</td></tr>
196
+ </table>
197
+
198
+ <h3>Tab Switching</h3>
199
+ <table>
200
+ <tr><td width="100"><b>1</b></td><td>Bandpass tab</td></tr>
201
+ <tr><td><b>2</b></td><td>Crossphase tab</td></tr>
202
+ <tr><td><b>3</b></td><td>Selfcal tab</td></tr>
203
+ </table>
204
+
205
+ <h3>Help</h3>
206
+ <table>
207
+ <tr><td width="100"><b>F1</b></td><td>Show this dialog</td></tr>
208
+ </table>
209
+
210
+ <hr>
211
+ <p><i>Part of Solar Radio Image Viewer LOFAR Tools</i></p>
212
+ """)
213
+ layout.addWidget(text)
214
+
215
+ dialog.exec_()
216
+
217
+ def create_widgets(self):
218
+ # Central widget and layout
219
+ central_widget = QWidget(self)
220
+ self.setCentralWidget(central_widget)
221
+ main_layout = QVBoxLayout(central_widget)
222
+
223
+ # Tabs
224
+ self.tab_control = QTabWidget(self)
225
+ main_layout.addWidget(self.tab_control)
226
+
227
+ #self.help_button = QPushButton("?")
228
+ #self.help_button.setToolTip("Help & Shortcuts (F1)")
229
+ #self.help_button.setFixedSize(10, 10)
230
+ #self.help_button.clicked.connect(self.show_shortcuts_dialog)
231
+ #self.tab_control.setCornerWidget(self.help_button, Qt.TopRightCorner)
232
+
233
+ # Bandpass tab
234
+ self.bandpass_tab = QWidget()
235
+ self.tab_control.addTab(self.bandpass_tab, "Bandpass")
236
+ self.create_bandpass_widgets()
237
+
238
+ # Crossphase tab
239
+ self.crossphase_tab = QWidget()
240
+ self.tab_control.addTab(self.crossphase_tab, "Crossphase")
241
+ self.create_crossphase_widgets()
242
+
243
+ # Selfcal tab
244
+ self.selfcal_tab = QWidget()
245
+ self.tab_control.addTab(self.selfcal_tab, "Selfcal")
246
+ self.create_selfcal_widgets()
247
+
248
+ def create_bandpass_widgets(self):
249
+ # Instead of icons, use text labels
250
+ layout = QVBoxLayout(self.bandpass_tab)
251
+ # Progress bar
252
+ self.progress = QProgressBar()
253
+ self.progress.setVisible(False)
254
+ layout.addWidget(self.progress)
255
+
256
+ # Bandpass directory selection
257
+ dir_frame = QHBoxLayout()
258
+ self.dir_label = QLabel("No caltable selected")
259
+ self.dir_label.setAlignment(Qt.AlignCenter)
260
+ dir_frame.addWidget(self.dir_label)
261
+
262
+ dir_frame.addStretch()
263
+
264
+ select_button = QPushButton("🗁 Load")
265
+ # select_button.setFixedSize(100, 30)
266
+ select_button.clicked.connect(self.select_bandpass_directory)
267
+ dir_frame.addWidget(select_button)
268
+ layout.addLayout(dir_frame)
269
+
270
+ # Bandpass canvas
271
+ self.bandpass_canvas_frame = QWidget()
272
+ layout.addWidget(self.bandpass_canvas_frame)
273
+ self.create_bandpass_canvas()
274
+
275
+ # Add navigation toolbar
276
+ self.toolbar_bandpass = NavigationToolbar(self.bandpass_canvas, self)
277
+ layout.addWidget(self.toolbar_bandpass)
278
+
279
+ # Bandpass navigation and plot mode
280
+ control_frame = QHBoxLayout()
281
+ self.plot_button = QPushButton("Phase")
282
+ self.plot_button.clicked.connect(self.toggle_bandpass_mode)
283
+ control_frame.addWidget(self.plot_button)
284
+
285
+ #self.first_button = QPushButton(QIcon(str(icon_path) + "/first.svg"), "") # Replace "icons/first.png" with your icon path
286
+ self.first_button = QPushButton("|◄")
287
+ self.first_button.setToolTip("First page")
288
+ self.first_button.clicked.connect(self.first_bandpass_page)
289
+ self.first_button.setEnabled(False)
290
+ control_frame.addWidget(self.first_button)
291
+
292
+ #self.prev_button = QPushButton(QIcon(str(icon_path) + "/prev.svg"), "") # Replace "icons/prev.png" with your icon path
293
+ self.prev_button = QPushButton("◄")
294
+ self.prev_button.setToolTip("Previous page")
295
+ self.prev_button.clicked.connect(self.prev_bandpass_page)
296
+ self.prev_button.setEnabled(False)
297
+ control_frame.addWidget(self.prev_button)
298
+
299
+ #self.next_button = QPushButton(QIcon(str(icon_path) + "/next.svg"), "") # Replace "icons/next.png" with your icon path
300
+ self.next_button = QPushButton("►")
301
+ self.next_button.setToolTip("Next page")
302
+ self.next_button.clicked.connect(self.next_bandpass_page)
303
+ self.next_button.setEnabled(False)
304
+ control_frame.addWidget(self.next_button)
305
+
306
+ #self.last_button = QPushButton(QIcon(str(icon_path) + "/last.svg"), "") # Replace "icons/last.png" with your icon path
307
+ self.last_button = QPushButton("►|")
308
+ self.last_button.setToolTip("Last page")
309
+ self.last_button.clicked.connect(self.last_bandpass_page)
310
+ self.last_button.setEnabled(False)
311
+ control_frame.addWidget(self.last_button)
312
+
313
+ control_frame.addStretch()
314
+
315
+ # Plot settings
316
+ control_frame.addWidget(QLabel("Plots per page:"))
317
+ self.plot_size_menu = QComboBox()
318
+ self.plot_size_menu.addItems(["2x2", "3x3", "4x4", "5x5"])
319
+ self.plot_size_menu.setCurrentText("3x3")
320
+ self.plot_size_menu.currentIndexChanged.connect(self.update_plot_grid)
321
+ control_frame.addWidget(self.plot_size_menu)
322
+
323
+ # Plot type selector
324
+ control_frame.addWidget(QLabel("View:"))
325
+ self.plot_type_combo = QComboBox()
326
+ self.plot_type_combo.addItems([
327
+ "Per-Antenna", # Current paginated view
328
+ "Waterfall", # Heatmap (antenna vs freq)
329
+ "Median Diff", # Deviation from median
330
+ "Phase Unwrapped", # Continuous phase
331
+ "RMS per Antenna", # Bar chart
332
+ "SNR Heatmap", # SNR per antenna/channel
333
+ "Closure Phases" # Triangle closure
334
+ ])
335
+ self.plot_type_combo.currentIndexChanged.connect(self._on_plot_type_changed)
336
+ control_frame.addWidget(self.plot_type_combo)
337
+
338
+ layout.addLayout(control_frame)
339
+
340
+ def create_bandpass_canvas(self):
341
+ self.bandpass_figure = Figure(figsize=(8, 6), tight_layout=True)
342
+ self.bandpass_axes = self.bandpass_figure.subplots(self.plot_rows, self.plot_cols)
343
+ self.bandpass_canvas = FigureCanvas(self.bandpass_figure)
344
+ layout = QGridLayout(self.bandpass_canvas_frame)
345
+ layout.addWidget(self.bandpass_canvas)
346
+
347
+ def create_crossphase_widgets(self):
348
+ layout = QVBoxLayout(self.crossphase_tab)
349
+
350
+ # Crossphase file selection
351
+ file_frame = QHBoxLayout()
352
+ self.file_label = QLabel("No caltable selected")
353
+ self.file_label.setAlignment(Qt.AlignCenter)
354
+ file_frame.addWidget(self.file_label)
355
+
356
+ file_frame.addStretch()
357
+
358
+ select_button = QPushButton("🗁 Load")
359
+ select_button.clicked.connect(self.select_crossphase_file)
360
+ file_frame.addWidget(select_button)
361
+ layout.addLayout(file_frame)
362
+
363
+ # Crossphase canvas
364
+ self.crossphase_figure = Figure(figsize=(8, 4), tight_layout=True)
365
+ self.crossphase_ax = self.crossphase_figure.add_subplot(111)
366
+ self.crossphase_canvas = FigureCanvas(self.crossphase_figure)
367
+ layout.addWidget(self.crossphase_canvas)
368
+ # Add navigation toolbar
369
+ self.toolbar_crossphase = NavigationToolbar(self.crossphase_canvas, self)
370
+ layout.addWidget(self.toolbar_crossphase)
371
+
372
+ def create_selfcal_widgets(self):
373
+ layout = QVBoxLayout(self.selfcal_tab)
374
+ # Progress bar (can be reused or a new one created if needed)
375
+ # self.selfcal_progress = QProgressBar()
376
+ # self.selfcal_progress.setVisible(False)
377
+ # layout.addWidget(self.selfcal_progress)
378
+
379
+ # Selfcal directory selection
380
+ dir_frame = QHBoxLayout()
381
+ self.selfcal_dir_label = QLabel("No caltable selected")
382
+ self.selfcal_dir_label.setAlignment(Qt.AlignCenter)
383
+ dir_frame.addWidget(self.selfcal_dir_label)
384
+
385
+ dir_frame.addStretch()
386
+
387
+ select_button = QPushButton("🗁 Load")
388
+ select_button.clicked.connect(self.select_selfcal_directory)
389
+ dir_frame.addWidget(select_button)
390
+ layout.addLayout(dir_frame)
391
+
392
+ # Selfcal canvas
393
+ self.selfcal_canvas_frame = QWidget()
394
+ layout.addWidget(self.selfcal_canvas_frame)
395
+ self.create_selfcal_canvas()
396
+
397
+ # Add navigation toolbar for selfcal
398
+ self.toolbar_selfcal = NavigationToolbar(self.selfcal_canvas, self)
399
+ layout.addWidget(self.toolbar_selfcal)
400
+
401
+ # Selfcal plot mode
402
+ control_frame = QHBoxLayout()
403
+ self.selfcal_plot_button = QPushButton("Plot Phase vs Antenna") # Initial text assuming default is Amplitude
404
+ self.selfcal_plot_button.clicked.connect(self.toggle_selfcal_mode)
405
+ control_frame.addWidget(self.selfcal_plot_button)
406
+
407
+ # Add other controls if needed, e.g., if pagination was desired for selfcal
408
+ # For now, selfcal plots all antennas at once.
409
+
410
+ layout.addLayout(control_frame)
411
+
412
+ def create_selfcal_canvas(self):
413
+ self.selfcal_figure = Figure(figsize=(8, 6), tight_layout=True)
414
+ self.selfcal_ax = self.selfcal_figure.add_subplot(111)
415
+ self.selfcal_canvas = FigureCanvas(self.selfcal_figure)
416
+ layout = QGridLayout(self.selfcal_canvas_frame) # Use QGridLayout for consistency
417
+ layout.addWidget(self.selfcal_canvas)
418
+
419
+ def select_bandpass_directory(self):
420
+ directory = QFileDialog.getExistingDirectory(self, "Select Bandpass Table")
421
+ if directory:
422
+ self.dir_label.setText(directory)
423
+ self.bandpass_directory = directory
424
+ self.load_bandpass_table(directory)
425
+
426
+ def update_plot_grid(self):
427
+ size_str = self.plot_size_menu.currentText()
428
+ self.plot_rows, self.plot_cols = map(int, size_str.split("x"))
429
+ self.antennas_per_page = self.plot_rows * self.plot_cols
430
+
431
+ # Remove old layout properly
432
+ old_layout = self.bandpass_canvas_frame.layout()
433
+ if old_layout is not None:
434
+ while old_layout.count():
435
+ item = old_layout.takeAt(0)
436
+ widget = item.widget()
437
+ if widget is not None:
438
+ widget.deleteLater()
439
+ QWidget().setLayout(old_layout)
440
+
441
+ # Re-create bandpass canvas
442
+ self.create_bandpass_canvas()
443
+ self.plot_bandpass_page()
444
+
445
+ def load_bandpass_table(self, bandpass_table):
446
+ # Set busy cursor
447
+ self._set_busy_cursor()
448
+
449
+ # Show progress bar and initialize value
450
+ self.progress.setVisible(True)
451
+ self.progress.setValue(0)
452
+
453
+ # Simulate progress updates
454
+ QTimer.singleShot(500, lambda: self.progress.setValue(50)) # 50% after 500ms
455
+ QTimer.singleShot(1000, lambda: self.progress.setValue(100)) # 100% after 1000ms
456
+ QTimer.singleShot(1500, lambda: self.progress.setVisible(False)) # Hide after 1500ms
457
+
458
+ # Load bandpass table using subprocess to avoid Qt/casacore conflicts
459
+ try:
460
+ data = read_caltable_safe(bandpass_table, read_spectral_window=True)
461
+ solutions = data['solutions']
462
+ flag = data['flag']
463
+ self.bandpass_freqs = data['chan_freq'][0, :] / 1e6
464
+ except Exception as e:
465
+ self.progress.setVisible(False)
466
+ self._restore_cursor()
467
+ from PyQt5.QtWidgets import QMessageBox
468
+ QMessageBox.critical(self, "Error", f"Failed to load caltable:\n{str(e)}")
469
+ return
470
+
471
+ solutions[flag] = np.nan
472
+ self.xx_sols = solutions[:, :, 0]
473
+ self.yy_sols = solutions[:, :, 1]
474
+ self.num_antennas = self.xx_sols.shape[0]
475
+ self.current_page = 0
476
+
477
+ # Enable/disable navigation buttons based on data
478
+ self.update_navigation_buttons()
479
+ self.plot_bandpass_page()
480
+
481
+ # Restore cursor
482
+ self._restore_cursor()
483
+
484
+ def toggle_bandpass_mode(self):
485
+ self._set_busy_cursor()
486
+ if self.plot_mode == "Amplitude":
487
+ self.plot_mode = "Phase"
488
+ self.plot_button.setText("Amplitude")
489
+ else:
490
+ self.plot_mode = "Amplitude"
491
+ self.plot_button.setText("Phase")
492
+ self._on_plot_type_changed() # Re-plot with current view type
493
+ self._restore_cursor()
494
+
495
+ def _on_plot_type_changed(self):
496
+ """Handle plot type ComboBox change - route to appropriate plot method."""
497
+ if self.xx_sols is None:
498
+ return # No data loaded
499
+
500
+ self._set_busy_cursor()
501
+ plot_type = self.plot_type_combo.currentText()
502
+
503
+ # Enable/disable navigation buttons based on plot type
504
+ is_per_antenna = (plot_type == "Per-Antenna")
505
+ self.first_button.setEnabled(is_per_antenna and self.current_page > 0)
506
+ self.prev_button.setEnabled(is_per_antenna and self.current_page > 0)
507
+ self.next_button.setEnabled(is_per_antenna and self.current_page < int(np.ceil(self.num_antennas / self.antennas_per_page)) - 1)
508
+ self.last_button.setEnabled(is_per_antenna and self.current_page < int(np.ceil(self.num_antennas / self.antennas_per_page)) - 1)
509
+ self.plot_size_menu.setEnabled(is_per_antenna)
510
+
511
+ # Enable/disable amplitude/phase toggle based on applicable views
512
+ # Phase Unwrapped, RMS, SNR, and Closure don't use amplitude/phase mode
513
+ toggle_applicable = plot_type in ["Per-Antenna", "Waterfall", "Median Diff"]
514
+ self.plot_button.setEnabled(toggle_applicable)
515
+
516
+ # Check if we WERE in single-axis mode before changing the flag
517
+ was_single_axis = getattr(self, '_single_axis_mode', False)
518
+
519
+ # Track whether we're in single-axis mode (for next switch)
520
+ self._single_axis_mode = not is_per_antenna
521
+
522
+ # If switching TO Per-Antenna from single-axis, need to restore grid
523
+ if plot_type == "Per-Antenna" and was_single_axis:
524
+ self._restore_grid_axes()
525
+
526
+ if plot_type == "Per-Antenna":
527
+ self.plot_bandpass_page()
528
+ elif plot_type == "Waterfall":
529
+ self.plot_bandpass_waterfall()
530
+ elif plot_type == "Median Diff":
531
+ self.plot_bandpass_median_diff()
532
+ elif plot_type == "Phase Unwrapped":
533
+ self.plot_bandpass_unwrapped_phase()
534
+ elif plot_type == "RMS per Antenna":
535
+ self.plot_rms_per_antenna()
536
+ elif plot_type == "SNR Heatmap":
537
+ self.plot_snr_heatmap()
538
+ elif plot_type == "Closure Phases":
539
+ self.plot_closure_phases()
540
+
541
+ self._restore_cursor()
542
+
543
+ def plot_bandpass_page(self):
544
+ # Fallback: ensure grid axes exist (normally handled by _on_plot_type_changed)
545
+ if not hasattr(self, 'bandpass_axes') or not hasattr(self.bandpass_axes, 'flat'):
546
+ self._restore_grid_axes()
547
+
548
+ start_ant = self.current_page * self.antennas_per_page
549
+ end_ant = min(start_ant + self.antennas_per_page, self.num_antennas)
550
+
551
+ # Determine if a legend is needed (at least one plot has data)
552
+ legend_needed_on_page = False
553
+ for idx, ax in enumerate(self.bandpass_axes.flat):
554
+ ax.clear()
555
+ ant_idx = start_ant + idx
556
+ if ant_idx >= self.num_antennas:
557
+ ax.axis("off")
558
+ else:
559
+ legend_needed_on_page = True # Mark that at least one plot will have data
560
+ if self.plot_mode == "Amplitude":
561
+ ax.plot(self.bandpass_freqs, np.abs(self.xx_sols[ant_idx, :]), "+", color='#1f77b4', label="X", markersize=6)
562
+ ax.plot(self.bandpass_freqs, np.abs(self.yy_sols[ant_idx, :]), "+", color='#ff7f0e', label="Y", markersize=6)
563
+ ax.set_ylabel("Amplitude")
564
+ else:
565
+ ax.plot(self.bandpass_freqs, np.angle(self.xx_sols[ant_idx, :], deg=True), "+", color='#1f77b4', label="X", markersize=6)
566
+ ax.plot(self.bandpass_freqs, np.angle(self.yy_sols[ant_idx, :], deg=True), "+", color='#ff7f0e', label="Y", markersize=6)
567
+ ax.set_ylabel("Phase (\u00b0)")
568
+
569
+ ax.set_title(f"Antenna {ant_idx}")
570
+ ax.set_xlim(self.bandpass_freqs[0], self.bandpass_freqs[-1])
571
+ ax.set_xlabel("Frequency (MHz)")
572
+ ax.grid(linestyle="--", linewidth=0.5, color="grey", alpha=0.5)
573
+ # ax.legend() # Add legend to each subplot
574
+
575
+ # Add a single legend to the figure if any plots were made, or handle it per subplot as above.
576
+ # If using per-subplot legends, a figure-level legend might be redundant or require careful placement.
577
+ # For now, per-subplot legends are enabled.
578
+
579
+ self.bandpass_figure.suptitle(
580
+ f"{self.plot_mode} vs Frequency - Page {self.current_page + 1} of {int(np.ceil(self.num_antennas / self.antennas_per_page))}\n X: Blue, Y: Orange")
581
+ self.bandpass_canvas.draw()
582
+ self.preload_bandpass_page()
583
+
584
+ def _setup_single_axes(self):
585
+ """Reconfigure figure for a single axes plot."""
586
+ self.bandpass_figure.clear()
587
+ self.bandpass_ax_single = self.bandpass_figure.add_subplot(111)
588
+ return self.bandpass_ax_single
589
+
590
+ def _restore_grid_axes(self):
591
+ """Restore the grid axes configuration."""
592
+ self.bandpass_figure.clear()
593
+ self.bandpass_axes = self.bandpass_figure.subplots(self.plot_rows, self.plot_cols)
594
+
595
+ def plot_bandpass_waterfall(self):
596
+ """Plot waterfall heatmap of amplitude/phase (antenna vs frequency)."""
597
+ ax = self._setup_single_axes()
598
+
599
+ if self.plot_mode == "Amplitude":
600
+ # Average X and Y polarizations
601
+ data = (np.abs(self.xx_sols) + np.abs(self.yy_sols)) / 2
602
+ label = "Amplitude"
603
+ else:
604
+ # Use X polarization phase
605
+ data = np.angle(self.xx_sols, deg=True)
606
+ label = "Phase (°)"
607
+
608
+ im = ax.imshow(data, aspect='auto', origin='lower',
609
+ extent=[self.bandpass_freqs[0], self.bandpass_freqs[-1], 0, self.num_antennas],
610
+ cmap='viridis' if self.plot_mode == "Amplitude" else 'twilight')
611
+
612
+ ax.set_xlabel("Frequency (MHz)")
613
+ ax.set_ylabel("Antenna")
614
+ self.bandpass_figure.colorbar(im, ax=ax, label=label)
615
+ self.bandpass_figure.suptitle(f"Waterfall - {label}")
616
+ self.bandpass_canvas.draw()
617
+
618
+ def plot_bandpass_median_diff(self):
619
+ """Plot deviation from median bandpass per antenna."""
620
+ ax = self._setup_single_axes()
621
+
622
+ if self.plot_mode == "Amplitude":
623
+ data = np.abs(self.xx_sols)
624
+ label = "Amplitude"
625
+ else:
626
+ data = np.angle(self.xx_sols, deg=True)
627
+ label = "Phase (°)"
628
+
629
+ # Compute median across antennas
630
+ median_per_freq = np.nanmedian(data, axis=0)
631
+ diff_from_median = data - median_per_freq
632
+
633
+ im = ax.imshow(diff_from_median, aspect='auto', origin='lower',
634
+ extent=[self.bandpass_freqs[0], self.bandpass_freqs[-1], 0, self.num_antennas],
635
+ cmap='RdBu_r', vmin=-np.nanstd(diff_from_median)*3, vmax=np.nanstd(diff_from_median)*3)
636
+
637
+ ax.set_xlabel("Frequency (MHz)")
638
+ ax.set_ylabel("Antenna")
639
+ self.bandpass_figure.colorbar(im, ax=ax, label=f"Δ {label} from median")
640
+ self.bandpass_figure.suptitle(f"Deviation from Median - {label}")
641
+ self.bandpass_canvas.draw()
642
+
643
+ def plot_bandpass_unwrapped_phase(self):
644
+ """Plot unwrapped phase (continuous, no 360° jumps)."""
645
+ ax = self._setup_single_axes()
646
+
647
+ # Unwrap phase for each antenna
648
+ phase_xx = np.angle(self.xx_sols) # radians
649
+
650
+ # Plot a subset of antennas to avoid clutter
651
+ step = max(1, self.num_antennas // 10)
652
+ for ant_idx in range(0, self.num_antennas, step):
653
+ unwrapped = np.unwrap(phase_xx[ant_idx, :])
654
+ ax.plot(self.bandpass_freqs, np.degrees(unwrapped), label=f"Ant {ant_idx}", alpha=0.7)
655
+
656
+ ax.set_xlabel("Frequency (MHz)")
657
+ ax.set_ylabel("Unwrapped Phase (°)")
658
+ ax.legend(loc='upper right', fontsize=8)
659
+ ax.grid(True, alpha=0.3)
660
+ self.bandpass_figure.suptitle("Unwrapped Phase vs Frequency")
661
+ self.bandpass_canvas.draw()
662
+
663
+ def plot_rms_per_antenna(self):
664
+ """Bar chart of RMS amplitude variation per antenna."""
665
+ ax = self._setup_single_axes()
666
+
667
+ # Compute RMS of amplitude across frequency
668
+ amp_xx = np.abs(self.xx_sols)
669
+ amp_yy = np.abs(self.yy_sols)
670
+
671
+ rms_xx = np.nanstd(amp_xx, axis=1)
672
+ rms_yy = np.nanstd(amp_yy, axis=1)
673
+
674
+ antennas = np.arange(self.num_antennas)
675
+ width = 0.35
676
+
677
+ ax.bar(antennas - width/2, rms_xx, width, label='X', color='#1f77b4', alpha=0.8)
678
+ ax.bar(antennas + width/2, rms_yy, width, label='Y', color='#ff7f0e', alpha=0.8)
679
+
680
+ ax.set_xlabel("Antenna")
681
+ ax.set_ylabel("RMS Amplitude")
682
+ ax.legend()
683
+ ax.grid(True, alpha=0.3, axis='y')
684
+
685
+ # Highlight outliers (> 2 sigma from median)
686
+ median_rms = np.nanmedian(np.concatenate([rms_xx, rms_yy]))
687
+ std_rms = np.nanstd(np.concatenate([rms_xx, rms_yy]))
688
+ ax.axhline(median_rms + 2*std_rms, color='red', linestyle='--', label='2σ threshold')
689
+
690
+ self.bandpass_figure.suptitle("RMS Amplitude per Antenna")
691
+ self.bandpass_canvas.draw()
692
+
693
+ def plot_snr_heatmap(self):
694
+ """Heatmap of SNR estimate per antenna/channel."""
695
+ ax = self._setup_single_axes()
696
+
697
+ # Estimate SNR as amplitude / std(amplitude)
698
+ amp = (np.abs(self.xx_sols) + np.abs(self.yy_sols)) / 2
699
+
700
+ # Simple SNR estimate: mean / std across small windows
701
+ window_size = max(1, amp.shape[1] // 20)
702
+ snr = np.zeros_like(amp)
703
+
704
+ for i in range(amp.shape[1]):
705
+ start = max(0, i - window_size)
706
+ end = min(amp.shape[1], i + window_size)
707
+ local_mean = np.nanmean(amp[:, start:end], axis=1)
708
+ local_std = np.nanstd(amp[:, start:end], axis=1)
709
+ snr[:, i] = local_mean / (local_std + 1e-10)
710
+
711
+ im = ax.imshow(snr, aspect='auto', origin='lower',
712
+ extent=[self.bandpass_freqs[0], self.bandpass_freqs[-1], 0, self.num_antennas],
713
+ cmap='plasma', vmin=0, vmax=np.nanpercentile(snr, 95))
714
+
715
+ ax.set_xlabel("Frequency (MHz)")
716
+ ax.set_ylabel("Antenna")
717
+ self.bandpass_figure.colorbar(im, ax=ax, label="SNR estimate")
718
+ self.bandpass_figure.suptitle("SNR Heatmap (Antenna vs Frequency)")
719
+ self.bandpass_canvas.draw()
720
+
721
+ def plot_closure_phases(self):
722
+ """Plot closure phases for antenna triplets."""
723
+ ax = self._setup_single_axes()
724
+
725
+ if self.num_antennas < 3:
726
+ ax.text(0.5, 0.5, "Need at least 3 antennas for closure phases",
727
+ ha='center', va='center', transform=ax.transAxes)
728
+ self.bandpass_canvas.draw()
729
+ return
730
+
731
+ # Get phases for X polarization
732
+ phase = np.angle(self.xx_sols)
733
+
734
+ # Compute closure phases for triplets (0-1-2, 1-2-3, etc.)
735
+ closure_phases = []
736
+ triplet_labels = []
737
+
738
+ num_triplets = min(20, self.num_antennas - 2) # Limit to 20 triplets
739
+ for i in range(num_triplets):
740
+ # Closure phase = phi_01 + phi_12 - phi_02
741
+ phi_01 = phase[i, :]
742
+ phi_12 = phase[i+1, :]
743
+ phi_02 = phase[i+2, :]
744
+
745
+ closure = np.degrees(phi_01 + phi_12 - phi_02)
746
+ # Wrap to -180 to 180
747
+ closure = (closure + 180) % 360 - 180
748
+
749
+ closure_phases.append(np.nanmean(closure))
750
+ triplet_labels.append(f"{i}-{i+1}-{i+2}")
751
+
752
+ ax.bar(range(len(closure_phases)), closure_phases, color='#2ca02c', alpha=0.8)
753
+ ax.set_xticks(range(len(triplet_labels)))
754
+ ax.set_xticklabels(triplet_labels, rotation=45, ha='right', fontsize=8)
755
+ ax.set_xlabel("Antenna Triplet")
756
+ ax.set_ylabel("Closure Phase (°)")
757
+ ax.axhline(0, color='gray', linestyle='--', alpha=0.5)
758
+ ax.grid(True, alpha=0.3, axis='y')
759
+
760
+ self.bandpass_figure.suptitle("Closure Phases (should be ~0 for good calibration)")
761
+ self.bandpass_figure.tight_layout()
762
+ self.bandpass_canvas.draw()
763
+
764
+
765
+ def next_bandpass_page(self):
766
+ if self.current_page < int(np.ceil(self.num_antennas / self.antennas_per_page)):
767
+ self._set_busy_cursor()
768
+ self.current_page += 1
769
+ self.plot_bandpass_page()
770
+ self._restore_cursor()
771
+ self.update_navigation_buttons()
772
+
773
+ def prev_bandpass_page(self):
774
+ if self.current_page > 0:
775
+ self._set_busy_cursor()
776
+ self.current_page -= 1
777
+ self.plot_bandpass_page()
778
+ self._restore_cursor()
779
+ self.update_navigation_buttons()
780
+
781
+ def first_bandpass_page(self):
782
+ if self.current_page != 0:
783
+ self._set_busy_cursor()
784
+ self.current_page = 0
785
+ self.plot_bandpass_page()
786
+ self._restore_cursor()
787
+ self.update_navigation_buttons()
788
+
789
+ def last_bandpass_page(self):
790
+ last_page = int(np.ceil(self.num_antennas / self.antennas_per_page)) - 1
791
+ if self.current_page != last_page:
792
+ self._set_busy_cursor()
793
+ self.current_page = last_page
794
+ self.plot_bandpass_page()
795
+ self._restore_cursor()
796
+ self.update_navigation_buttons()
797
+
798
+ def preload_bandpass_page(self):
799
+ self.thread = WorkerThread(self._preload_bandpass_data)
800
+ self.thread.finished.connect(self.thread.quit)
801
+ self.thread.start()
802
+
803
+ def _preload_bandpass_data(self):
804
+ total_pages = int(np.ceil(self.num_antennas / self.antennas_per_page))
805
+ for page in range(total_pages):
806
+ start_ant = page * self.antennas_per_page
807
+ end_ant = min(start_ant + self.antennas_per_page, self.num_antennas)
808
+ for ant_idx in range(start_ant, end_ant):
809
+ if ant_idx >= self.num_antennas:
810
+ break
811
+ if self.plot_mode == "Amplitude":
812
+ _ = np.abs(self.xx_sols[ant_idx, :])
813
+ _ = np.abs(self.yy_sols[ant_idx, :])
814
+ else:
815
+ _ = np.angle(self.xx_sols[ant_idx, :], deg=True)
816
+ _ = np.angle(self.yy_sols[ant_idx, :], deg=True)
817
+
818
+ def update_navigation_buttons(self):
819
+ total_pages = int(np.ceil(self.num_antennas / self.antennas_per_page))
820
+ self.first_button.setEnabled(self.current_page > 0)
821
+ self.prev_button.setEnabled(self.current_page > 0)
822
+ self.next_button.setEnabled(self.current_page + 1 < total_pages)
823
+ self.last_button.setEnabled(self.current_page + 1 < total_pages)
824
+
825
+ def select_selfcal_directory(self):
826
+ directory = QFileDialog.getExistingDirectory(self, "Select Self-calibration Table")
827
+ if directory:
828
+ self.selfcal_dir_label.setText(directory) # Update selfcal specific label
829
+ self.selfcal_directory = directory
830
+ self.load_selfcal_table(directory)
831
+
832
+ def load_selfcal_table(self, caltable):
833
+ # Set busy cursor
834
+ self._set_busy_cursor()
835
+
836
+ # Show progress bar and initialize value
837
+ self.progress.setVisible(True)
838
+ self.progress.setValue(0)
839
+
840
+ # Simulate progress updates
841
+ QTimer.singleShot(500, lambda: self.progress.setValue(50)) # 50% after 500ms
842
+ QTimer.singleShot(1000, lambda: self.progress.setValue(100)) # 100% after 1000ms
843
+ QTimer.singleShot(1500, lambda: self.progress.setVisible(False)) # Hide after 1500ms
844
+
845
+ # Load selfcal table using subprocess to avoid Qt/casacore conflicts
846
+ try:
847
+ data = read_caltable_safe(caltable, read_spectral_window=False)
848
+ solutions = data['solutions']
849
+ flag = data['flag']
850
+ except Exception as e:
851
+ self.progress.setVisible(False)
852
+ self._restore_cursor()
853
+ from PyQt5.QtWidgets import QMessageBox
854
+ QMessageBox.critical(self, "Error", f"Failed to load caltable:\n{str(e)}")
855
+ return
856
+
857
+ solutions[flag] = np.nan
858
+ self.xx_sols = solutions[:, :, 0]
859
+ self.yy_sols = solutions[:, :, 1]
860
+ self.num_antennas = self.xx_sols.shape[0]
861
+
862
+ self.plot_selfcal_page()
863
+
864
+ # Restore cursor
865
+ self._restore_cursor()
866
+
867
+ def toggle_selfcal_mode(self):
868
+ self._set_busy_cursor()
869
+ if self.plot_mode == "Amplitude":
870
+ self.plot_mode = "Phase"
871
+ self.selfcal_plot_button.setText("Plot Amplitude vs Antenna") # Update selfcal button
872
+ else:
873
+ self.plot_mode = "Amplitude"
874
+ self.selfcal_plot_button.setText("Plot Phase vs Antenna") # Update selfcal button
875
+ if self.selfcal_directory: # Only plot if data is loaded
876
+ self.plot_selfcal_page()
877
+ self._restore_cursor()
878
+
879
+ def plot_selfcal_page(self):
880
+ if not self.selfcal_directory or self.xx_sols is None: # Check if data is loaded
881
+ # Optionally, display a message on the canvas or clear it
882
+ self.selfcal_ax.clear()
883
+ self.selfcal_ax.text(0.5, 0.5, "No data loaded. Select a selfcal directory.",
884
+ horizontalalignment='center', verticalalignment='center',
885
+ transform=self.selfcal_ax.transAxes)
886
+ self.selfcal_canvas.draw()
887
+ return
888
+
889
+ self.selfcal_ax.clear()
890
+ # Plot Gains vs Antenna
891
+ antennas = np.arange(self.num_antennas)
892
+ if self.plot_mode == "Amplitude":
893
+ self.selfcal_ax.plot(antennas, np.abs(self.xx_sols[:,0]), "+", color='#1f77b4', label="X", markersize=6)
894
+ self.selfcal_ax.plot(antennas, np.abs(self.yy_sols[:,0]), "+", color='#ff7f0e', label="Y", markersize=6)
895
+ self.selfcal_ax.set_ylabel("Amplitude")
896
+ else:
897
+ self.selfcal_ax.plot(antennas, np.angle(self.xx_sols[:,0], deg=True), "+", color='#1f77b4', label="X", markersize=6)
898
+ self.selfcal_ax.plot(antennas, np.angle(self.yy_sols[:,0], deg=True), "+", color='#ff7f0e', label="Y", markersize=6)
899
+ self.selfcal_ax.set_ylabel("Phase (\u00b0)")
900
+
901
+ self.selfcal_ax.set_title(f"{self.plot_mode} vs Antenna")
902
+ self.selfcal_ax.set_xlabel("Antenna Number")
903
+ self.selfcal_ax.grid(linestyle="--", linewidth=0.5, color="grey", alpha=0.5)
904
+ self.selfcal_ax.legend()
905
+
906
+ self.selfcal_figure.suptitle(f"Selfcal Gains - {self.plot_mode} vs Antenna", fontsize=14)
907
+ self.selfcal_canvas.draw()
908
+
909
+
910
+ def select_crossphase_file(self):
911
+ file, _ = QFileDialog.getOpenFileName(
912
+ self, "Select Crossphase Caltable", "",
913
+ "kcross Files (*.kcross);;Numpy Files (*.npy);;All Files (*)"
914
+ )
915
+ if file:
916
+ self.crossphase_file = file
917
+ self.load_crossphase_file(file)
918
+
919
+ def load_crossphase_file(self, caltable):
920
+ # Set busy cursor
921
+ self._set_busy_cursor()
922
+
923
+ loaded_caltable = np.load(caltable, allow_pickle=True)
924
+ self.cross_freq = loaded_caltable[0, :] / 1e6
925
+ self.crossphase = loaded_caltable[1, :]
926
+ self.cross_flags = loaded_caltable[2, :]
927
+
928
+ self.cross_freq = np.array(self.cross_freq, dtype=float)
929
+ self.crossphase = np.array(self.crossphase, dtype=float)
930
+
931
+ median_crossphase = np.median(self.crossphase)
932
+ absolute_deviation = np.abs(self.crossphase - median_crossphase)
933
+ mad = np.median(absolute_deviation)
934
+
935
+ threshold = 10 * mad
936
+ mask = absolute_deviation <= threshold
937
+
938
+ self.cross_freq_filtered = self.cross_freq[mask]
939
+ self.crossphase_filtered = self.crossphase[mask]
940
+
941
+ self.cross_fit_coeffs = np.polyfit(self.cross_freq_filtered, self.crossphase_filtered, 2)
942
+ self.cross_fit_func = np.poly1d(self.cross_fit_coeffs)
943
+ self.crossphase_fit = self.cross_fit_func(self.cross_freq_filtered)
944
+
945
+ residuals = self.crossphase_filtered - self.crossphase_fit
946
+ sse = np.sum(residuals**2)
947
+ sst = np.sum((self.crossphase_filtered - np.mean(self.crossphase))**2)
948
+ self.cross_r_squared = 1 - (sse / sst)
949
+
950
+ self.cross_std_residuals = np.std(residuals)
951
+
952
+ self.plot_crossphase()
953
+
954
+ # Restore cursor
955
+ self._restore_cursor()
956
+
957
+ def plot_crossphase(self):
958
+ self.crossphase_ax.clear()
959
+
960
+ self.crossphase_ax.plot(self.cross_freq, self.crossphase, '+', label='Data')
961
+ self.crossphase_ax.plot(self.cross_freq_filtered, self.crossphase_fit, '-', label=f'Fit ($R^2 = {self.cross_r_squared:.3f}$)')
962
+ self.crossphase_ax.fill_between(
963
+ self.cross_freq_filtered,
964
+ self.crossphase_fit - 3 * self.cross_std_residuals,
965
+ self.crossphase_fit + 3 * self.cross_std_residuals,
966
+ color='gray', alpha=0.3, label=r'Fit Error ($3\sigma$)'
967
+ )
968
+
969
+ self.crossphase_ax.set_xlabel('Frequency (MHz)')
970
+ self.crossphase_ax.set_ylabel('Crossphase (deg)')
971
+ self.crossphase_ax.grid(linestyle='--', linewidth=0.5, color='grey', alpha=0.5)
972
+ self.crossphase_ax.legend()
973
+
974
+ self.crossphase_figure.suptitle("Crossphase vs Frequency", fontsize=14)
975
+ self.crossphase_canvas.draw()
976
+
977
+ def main():
978
+ """Entry point for viewcaltable command."""
979
+ app = QApplication(sys.argv)
980
+ app.setStyle(QStyleFactory.create("Fusion"))
981
+
982
+ # Apply dark theme from solarviewer
983
+ from solar_radio_image_viewer.from_simpl.simpl_theme import apply_theme
984
+ apply_theme(app, "dark")
985
+
986
+ # Set seaborn theme for plots
987
+ sns.set_theme(style="darkgrid")
988
+
989
+ # Configure matplotlib using theme_manager (same as solarviewer)
990
+ from matplotlib import rcParams
991
+ from solar_radio_image_viewer.styles import theme_manager
992
+ rcParams.update(theme_manager.matplotlib_params)
993
+ rcParams["axes.linewidth"] = 1.4
994
+ rcParams["font.size"] = 12
995
+
996
+ window = VisualizationApp()
997
+ window.show()
998
+ sys.exit(app.exec_())
999
+
1000
+ if __name__ == "__main__":
1001
+ main()