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.
- solar_radio_image_viewer/__init__.py +12 -0
- solar_radio_image_viewer/assets/add_tab_default.png +0 -0
- solar_radio_image_viewer/assets/add_tab_default_light.png +0 -0
- solar_radio_image_viewer/assets/add_tab_hover.png +0 -0
- solar_radio_image_viewer/assets/add_tab_hover_light.png +0 -0
- solar_radio_image_viewer/assets/browse.png +0 -0
- solar_radio_image_viewer/assets/browse_light.png +0 -0
- solar_radio_image_viewer/assets/close_tab_default.png +0 -0
- solar_radio_image_viewer/assets/close_tab_default_light.png +0 -0
- solar_radio_image_viewer/assets/close_tab_hover.png +0 -0
- solar_radio_image_viewer/assets/close_tab_hover_light.png +0 -0
- solar_radio_image_viewer/assets/ellipse_selection.png +0 -0
- solar_radio_image_viewer/assets/ellipse_selection_light.png +0 -0
- solar_radio_image_viewer/assets/icons8-ellipse-90.png +0 -0
- solar_radio_image_viewer/assets/icons8-ellipse-90_light.png +0 -0
- solar_radio_image_viewer/assets/icons8-info-90.png +0 -0
- solar_radio_image_viewer/assets/icons8-info-90_light.png +0 -0
- solar_radio_image_viewer/assets/profile.png +0 -0
- solar_radio_image_viewer/assets/profile_light.png +0 -0
- solar_radio_image_viewer/assets/rectangle_selection.png +0 -0
- solar_radio_image_viewer/assets/rectangle_selection_light.png +0 -0
- solar_radio_image_viewer/assets/reset.png +0 -0
- solar_radio_image_viewer/assets/reset_light.png +0 -0
- solar_radio_image_viewer/assets/ruler.png +0 -0
- solar_radio_image_viewer/assets/ruler_light.png +0 -0
- solar_radio_image_viewer/assets/search.png +0 -0
- solar_radio_image_viewer/assets/search_light.png +0 -0
- solar_radio_image_viewer/assets/settings.png +0 -0
- solar_radio_image_viewer/assets/settings_light.png +0 -0
- solar_radio_image_viewer/assets/splash.fits +0 -0
- solar_radio_image_viewer/assets/zoom_60arcmin.png +0 -0
- solar_radio_image_viewer/assets/zoom_60arcmin_light.png +0 -0
- solar_radio_image_viewer/assets/zoom_in.png +0 -0
- solar_radio_image_viewer/assets/zoom_in_light.png +0 -0
- solar_radio_image_viewer/assets/zoom_out.png +0 -0
- solar_radio_image_viewer/assets/zoom_out_light.png +0 -0
- solar_radio_image_viewer/create_video.py +1345 -0
- solar_radio_image_viewer/dialogs.py +2665 -0
- solar_radio_image_viewer/from_simpl/__init__.py +184 -0
- solar_radio_image_viewer/from_simpl/caltable_visualizer.py +1001 -0
- solar_radio_image_viewer/from_simpl/dynamic_spectra_dialog.py +332 -0
- solar_radio_image_viewer/from_simpl/make_dynamic_spectra.py +351 -0
- solar_radio_image_viewer/from_simpl/pipeline_logger_gui.py +1232 -0
- solar_radio_image_viewer/from_simpl/simpl_theme.py +352 -0
- solar_radio_image_viewer/from_simpl/utils.py +984 -0
- solar_radio_image_viewer/from_simpl/view_dynamic_spectra_GUI.py +1975 -0
- solar_radio_image_viewer/helioprojective.py +1916 -0
- solar_radio_image_viewer/helioprojective_viewer.py +817 -0
- solar_radio_image_viewer/helioviewer_browser.py +1514 -0
- solar_radio_image_viewer/main.py +148 -0
- solar_radio_image_viewer/move_phasecenter.py +1269 -0
- solar_radio_image_viewer/napari_viewer.py +368 -0
- solar_radio_image_viewer/noaa_events/__init__.py +32 -0
- solar_radio_image_viewer/noaa_events/noaa_events.py +430 -0
- solar_radio_image_viewer/noaa_events/noaa_events_gui.py +1922 -0
- solar_radio_image_viewer/norms.py +293 -0
- solar_radio_image_viewer/radio_data_downloader/__init__.py +25 -0
- solar_radio_image_viewer/radio_data_downloader/radio_data_downloader.py +756 -0
- solar_radio_image_viewer/radio_data_downloader/radio_data_downloader_gui.py +528 -0
- solar_radio_image_viewer/searchable_combobox.py +220 -0
- solar_radio_image_viewer/solar_context/__init__.py +41 -0
- solar_radio_image_viewer/solar_context/active_regions.py +371 -0
- solar_radio_image_viewer/solar_context/cme_alerts.py +234 -0
- solar_radio_image_viewer/solar_context/context_images.py +297 -0
- solar_radio_image_viewer/solar_context/realtime_data.py +528 -0
- solar_radio_image_viewer/solar_data_downloader/__init__.py +35 -0
- solar_radio_image_viewer/solar_data_downloader/solar_data_downloader.py +1667 -0
- solar_radio_image_viewer/solar_data_downloader/solar_data_downloader_cli.py +901 -0
- solar_radio_image_viewer/solar_data_downloader/solar_data_downloader_gui.py +1210 -0
- solar_radio_image_viewer/styles.py +643 -0
- solar_radio_image_viewer/utils/__init__.py +32 -0
- solar_radio_image_viewer/utils/rate_limiter.py +255 -0
- solar_radio_image_viewer/utils.py +952 -0
- solar_radio_image_viewer/video_dialog.py +2629 -0
- solar_radio_image_viewer/video_utils.py +656 -0
- solar_radio_image_viewer/viewer.py +11174 -0
- solarviewer-1.0.2.dist-info/METADATA +343 -0
- solarviewer-1.0.2.dist-info/RECORD +82 -0
- solarviewer-1.0.2.dist-info/WHEEL +5 -0
- solarviewer-1.0.2.dist-info/entry_points.txt +8 -0
- solarviewer-1.0.2.dist-info/licenses/LICENSE +21 -0
- 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()
|