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,817 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
import numpy as np
|
|
7
|
+
import matplotlib.pyplot as plt
|
|
8
|
+
from matplotlib.figure import Figure
|
|
9
|
+
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
|
|
10
|
+
from matplotlib.backends.backend_qt5agg import NavigationToolbar2QT as NavigationToolbar
|
|
11
|
+
from matplotlib import rcParams
|
|
12
|
+
from matplotlib.colors import Normalize, LogNorm, PowerNorm
|
|
13
|
+
import matplotlib.patches as patches
|
|
14
|
+
|
|
15
|
+
from PyQt5.QtWidgets import (
|
|
16
|
+
QMainWindow,
|
|
17
|
+
QWidget,
|
|
18
|
+
QVBoxLayout,
|
|
19
|
+
QHBoxLayout,
|
|
20
|
+
QLabel,
|
|
21
|
+
QLineEdit,
|
|
22
|
+
QPushButton,
|
|
23
|
+
QComboBox,
|
|
24
|
+
QSlider,
|
|
25
|
+
QCheckBox,
|
|
26
|
+
QGroupBox,
|
|
27
|
+
QFormLayout,
|
|
28
|
+
QMessageBox,
|
|
29
|
+
QSizePolicy,
|
|
30
|
+
QStatusBar,
|
|
31
|
+
QToolBar,
|
|
32
|
+
QAction,
|
|
33
|
+
QToolButton,
|
|
34
|
+
QSpinBox,
|
|
35
|
+
QDoubleSpinBox,
|
|
36
|
+
)
|
|
37
|
+
from PyQt5.QtCore import Qt, QTimer
|
|
38
|
+
from PyQt5.QtGui import QIcon
|
|
39
|
+
|
|
40
|
+
# Try to import sunpy
|
|
41
|
+
try:
|
|
42
|
+
import sunpy
|
|
43
|
+
import sunpy.map
|
|
44
|
+
from sunpy.coordinates import frames
|
|
45
|
+
import astropy.units as u
|
|
46
|
+
from astropy.coordinates import SkyCoord
|
|
47
|
+
|
|
48
|
+
SUNPY_AVAILABLE = True
|
|
49
|
+
except ImportError:
|
|
50
|
+
SUNPY_AVAILABLE = False
|
|
51
|
+
print(
|
|
52
|
+
"Warning: sunpy is not available. Please install sunpy for helioprojective coordinates."
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Try to import CASA tools
|
|
56
|
+
try:
|
|
57
|
+
from casatools import image as IA
|
|
58
|
+
from casatasks import exportfits
|
|
59
|
+
|
|
60
|
+
CASA_AVAILABLE = True
|
|
61
|
+
except ImportError:
|
|
62
|
+
CASA_AVAILABLE = False
|
|
63
|
+
print(
|
|
64
|
+
"Warning: CASA tools are not available. Please ensure CASA is properly installed."
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def convert_casaimage_to_fits(
|
|
69
|
+
imagename=None, fitsname=None, dropdeg=False, overwrite=True
|
|
70
|
+
):
|
|
71
|
+
"""Convert a CASA image to a FITS file."""
|
|
72
|
+
if not CASA_AVAILABLE:
|
|
73
|
+
print("Error: CASA tools are not available")
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
if imagename is None:
|
|
77
|
+
print("Error: No input image specified")
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
if fitsname is None:
|
|
82
|
+
fitsname = "temp_" + os.path.basename(imagename) + ".fits"
|
|
83
|
+
|
|
84
|
+
# Use exportfits task
|
|
85
|
+
exportfits(
|
|
86
|
+
imagename=imagename,
|
|
87
|
+
fitsimage=fitsname,
|
|
88
|
+
dropdeg=dropdeg,
|
|
89
|
+
overwrite=overwrite,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if os.path.exists(fitsname):
|
|
93
|
+
return fitsname
|
|
94
|
+
else:
|
|
95
|
+
print(f"Error: Failed to create FITS file {fitsname}")
|
|
96
|
+
return None
|
|
97
|
+
except Exception as e:
|
|
98
|
+
print(f"Error in convert_casaimage_to_fits: {str(e)}")
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# Import the helioprojective conversion functions
|
|
103
|
+
try:
|
|
104
|
+
from .helioprojective import convert_to_hpc
|
|
105
|
+
from .styles import theme_manager, get_stylesheet
|
|
106
|
+
except ImportError:
|
|
107
|
+
try:
|
|
108
|
+
# For direct script execution
|
|
109
|
+
from helioprojective import convert_to_hpc
|
|
110
|
+
from styles import theme_manager, get_stylesheet
|
|
111
|
+
except ImportError:
|
|
112
|
+
print("Error: Could not import helioprojective conversion functions")
|
|
113
|
+
sys.exit(1)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def update_hpc_matplotlib_theme():
|
|
117
|
+
"""Update matplotlib rcParams based on current theme."""
|
|
118
|
+
rcParams.update(theme_manager.matplotlib_params)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
rcParams["axes.linewidth"] = 1.4
|
|
122
|
+
rcParams["font.size"] = 12
|
|
123
|
+
update_hpc_matplotlib_theme()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class HelioProjectiveViewer(QMainWindow):
|
|
127
|
+
"""
|
|
128
|
+
A separate window for displaying images in helioprojective coordinates.
|
|
129
|
+
"""
|
|
130
|
+
|
|
131
|
+
def __init__(
|
|
132
|
+
self,
|
|
133
|
+
imagename=None,
|
|
134
|
+
stokes="I",
|
|
135
|
+
threshold=10,
|
|
136
|
+
rms_box=(0, 200, 0, 130),
|
|
137
|
+
parent=None,
|
|
138
|
+
):
|
|
139
|
+
super().__init__(parent)
|
|
140
|
+
self.setWindowTitle("Helioprojective Viewer")
|
|
141
|
+
self.resize(1280, 720)
|
|
142
|
+
|
|
143
|
+
# Store parameters
|
|
144
|
+
self.imagename = imagename
|
|
145
|
+
self.stokes = stokes
|
|
146
|
+
self.threshold = threshold
|
|
147
|
+
self.rms_box = rms_box
|
|
148
|
+
self.parent = parent
|
|
149
|
+
|
|
150
|
+
# Initialize variables
|
|
151
|
+
self.helioprojective_map = None
|
|
152
|
+
self.psf = None
|
|
153
|
+
self.temp_fits_file = None
|
|
154
|
+
self.colormap = "viridis"
|
|
155
|
+
self.stretch = "linear"
|
|
156
|
+
self.gamma = 1.0
|
|
157
|
+
self.vmin = None
|
|
158
|
+
self.vmax = None
|
|
159
|
+
self.show_grid = True
|
|
160
|
+
self.show_limb = True
|
|
161
|
+
self.show_beam = True
|
|
162
|
+
self.show_colorbar = True
|
|
163
|
+
|
|
164
|
+
# Apply current theme stylesheet
|
|
165
|
+
self.setStyleSheet(get_stylesheet(theme_manager.palette, theme_manager.is_dark))
|
|
166
|
+
|
|
167
|
+
# Register for theme changes
|
|
168
|
+
theme_manager.register_callback(self._on_theme_changed)
|
|
169
|
+
|
|
170
|
+
# Set up the UI
|
|
171
|
+
self.setup_ui()
|
|
172
|
+
|
|
173
|
+
# Load and display the image if provided
|
|
174
|
+
if imagename:
|
|
175
|
+
# Use QTimer to load image after UI is set up
|
|
176
|
+
QTimer.singleShot(100, self.load_image)
|
|
177
|
+
|
|
178
|
+
def setup_ui(self):
|
|
179
|
+
"""Set up the user interface"""
|
|
180
|
+
# Create central widget and layout
|
|
181
|
+
self.central_widget = QWidget()
|
|
182
|
+
self.setCentralWidget(self.central_widget)
|
|
183
|
+
self.main_layout = QHBoxLayout(self.central_widget)
|
|
184
|
+
|
|
185
|
+
# Create left panel for controls
|
|
186
|
+
self.left_panel = QWidget()
|
|
187
|
+
self.left_layout = QVBoxLayout(self.left_panel)
|
|
188
|
+
self.left_panel.setMaximumWidth(400)
|
|
189
|
+
|
|
190
|
+
# Create right panel for figure
|
|
191
|
+
self.right_panel = QWidget()
|
|
192
|
+
self.right_layout = QVBoxLayout(self.right_panel)
|
|
193
|
+
|
|
194
|
+
# Add panels to main layout
|
|
195
|
+
self.main_layout.addWidget(self.left_panel)
|
|
196
|
+
self.main_layout.addWidget(self.right_panel, 1) # Right panel should expand
|
|
197
|
+
|
|
198
|
+
# Create controls
|
|
199
|
+
self.create_display_controls()
|
|
200
|
+
self.create_overlay_controls()
|
|
201
|
+
|
|
202
|
+
# Add a spacer to push controls to the top
|
|
203
|
+
self.left_layout.addStretch(1)
|
|
204
|
+
|
|
205
|
+
# Create figure and canvas
|
|
206
|
+
self.figure = Figure(figsize=(8, 6), dpi=100)
|
|
207
|
+
self.canvas = FigureCanvas(self.figure)
|
|
208
|
+
self.canvas.setMinimumHeight(400)
|
|
209
|
+
self.canvas.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
|
210
|
+
|
|
211
|
+
# Create navigation toolbar
|
|
212
|
+
self.toolbar = NavigationToolbar(self.canvas, self)
|
|
213
|
+
|
|
214
|
+
# Add figure and toolbar to right panel
|
|
215
|
+
self.right_layout.addWidget(self.toolbar)
|
|
216
|
+
self.right_layout.addWidget(self.canvas, 1) # Canvas should expand vertically
|
|
217
|
+
|
|
218
|
+
# Create status bar
|
|
219
|
+
self.statusbar = QStatusBar(self)
|
|
220
|
+
self.setStatusBar(self.statusbar)
|
|
221
|
+
self.statusbar.showMessage("Ready")
|
|
222
|
+
|
|
223
|
+
def create_display_controls(self):
|
|
224
|
+
"""Create controls for display settings"""
|
|
225
|
+
# Create group box for display controls
|
|
226
|
+
display_group = QGroupBox("Display Settings")
|
|
227
|
+
display_layout = QFormLayout(display_group)
|
|
228
|
+
|
|
229
|
+
# Colormap selection
|
|
230
|
+
self.cmap_combo = QComboBox()
|
|
231
|
+
for cmap in sorted(plt.colormaps()):
|
|
232
|
+
self.cmap_combo.addItem(cmap)
|
|
233
|
+
self.cmap_combo.setCurrentText(self.colormap)
|
|
234
|
+
self.cmap_combo.currentTextChanged.connect(self.on_display_changed)
|
|
235
|
+
display_layout.addRow("Colormap:", self.cmap_combo)
|
|
236
|
+
|
|
237
|
+
# Stretch function
|
|
238
|
+
self.stretch_combo = QComboBox()
|
|
239
|
+
self.stretch_combo.addItems(["linear", "sqrt", "log", "power"])
|
|
240
|
+
self.stretch_combo.setCurrentText(self.stretch)
|
|
241
|
+
self.stretch_combo.currentTextChanged.connect(self.on_display_changed)
|
|
242
|
+
display_layout.addRow("Stretch:", self.stretch_combo)
|
|
243
|
+
|
|
244
|
+
# Gamma (for power stretch)
|
|
245
|
+
gamma_layout = QHBoxLayout()
|
|
246
|
+
self.gamma_slider = QSlider(Qt.Horizontal)
|
|
247
|
+
self.gamma_slider.setRange(1, 100)
|
|
248
|
+
self.gamma_slider.setValue(int(self.gamma * 10))
|
|
249
|
+
self.gamma_slider.valueChanged.connect(self.on_gamma_changed)
|
|
250
|
+
|
|
251
|
+
self.gamma_entry = QLineEdit(str(self.gamma))
|
|
252
|
+
self.gamma_entry.setMaximumWidth(50)
|
|
253
|
+
self.gamma_entry.returnPressed.connect(self.on_gamma_entry_changed)
|
|
254
|
+
|
|
255
|
+
gamma_layout.addWidget(self.gamma_slider)
|
|
256
|
+
gamma_layout.addWidget(self.gamma_entry)
|
|
257
|
+
display_layout.addRow("Gamma:", gamma_layout)
|
|
258
|
+
|
|
259
|
+
# Min/Max values
|
|
260
|
+
range_layout = QHBoxLayout()
|
|
261
|
+
self.vmin_entry = QLineEdit()
|
|
262
|
+
self.vmin_entry.setMaximumWidth(80)
|
|
263
|
+
self.vmax_entry = QLineEdit()
|
|
264
|
+
self.vmax_entry.setMaximumWidth(80)
|
|
265
|
+
|
|
266
|
+
range_layout.addWidget(QLabel("Min:"))
|
|
267
|
+
range_layout.addWidget(self.vmin_entry)
|
|
268
|
+
range_layout.addWidget(QLabel("Max:"))
|
|
269
|
+
range_layout.addWidget(self.vmax_entry)
|
|
270
|
+
display_layout.addRow("Range:", range_layout)
|
|
271
|
+
|
|
272
|
+
# Auto-scale buttons
|
|
273
|
+
scale_layout = QHBoxLayout()
|
|
274
|
+
self.auto_minmax_btn = QPushButton("Min/Max")
|
|
275
|
+
self.auto_minmax_btn.clicked.connect(self.auto_minmax)
|
|
276
|
+
self.auto_percentile_btn = QPushButton("99.5%")
|
|
277
|
+
self.auto_percentile_btn.clicked.connect(self.auto_percentile)
|
|
278
|
+
self.auto_median_btn = QPushButton("Median±5σ")
|
|
279
|
+
self.auto_median_btn.clicked.connect(self.auto_median_rms)
|
|
280
|
+
|
|
281
|
+
scale_layout.addWidget(self.auto_minmax_btn)
|
|
282
|
+
scale_layout.addWidget(self.auto_percentile_btn)
|
|
283
|
+
scale_layout.addWidget(self.auto_median_btn)
|
|
284
|
+
display_layout.addRow("Auto-scale:", scale_layout)
|
|
285
|
+
|
|
286
|
+
# Add the group to the left panel
|
|
287
|
+
self.left_layout.addWidget(display_group)
|
|
288
|
+
|
|
289
|
+
def create_overlay_controls(self):
|
|
290
|
+
"""Create controls for overlay settings"""
|
|
291
|
+
# Create group box for overlay controls
|
|
292
|
+
overlay_group = QGroupBox("Overlay Settings")
|
|
293
|
+
overlay_layout = QFormLayout(overlay_group)
|
|
294
|
+
|
|
295
|
+
# Grid lines
|
|
296
|
+
self.show_grid_checkbox = QCheckBox("Show Grid")
|
|
297
|
+
self.show_grid_checkbox.setChecked(self.show_grid)
|
|
298
|
+
self.show_grid_checkbox.stateChanged.connect(self.on_overlay_changed)
|
|
299
|
+
overlay_layout.addRow(self.show_grid_checkbox)
|
|
300
|
+
|
|
301
|
+
# Solar limb
|
|
302
|
+
self.show_limb_checkbox = QCheckBox("Show Solar Limb")
|
|
303
|
+
self.show_limb_checkbox.setChecked(self.show_limb)
|
|
304
|
+
self.show_limb_checkbox.stateChanged.connect(self.on_overlay_changed)
|
|
305
|
+
overlay_layout.addRow(self.show_limb_checkbox)
|
|
306
|
+
|
|
307
|
+
# Beam
|
|
308
|
+
self.show_beam_checkbox = QCheckBox("Show Beam")
|
|
309
|
+
self.show_beam_checkbox.setChecked(self.show_beam)
|
|
310
|
+
self.show_beam_checkbox.stateChanged.connect(self.on_overlay_changed)
|
|
311
|
+
overlay_layout.addRow(self.show_beam_checkbox)
|
|
312
|
+
|
|
313
|
+
# Colorbar
|
|
314
|
+
self.show_colorbar_checkbox = QCheckBox("Show Colorbar")
|
|
315
|
+
self.show_colorbar_checkbox.setChecked(self.show_colorbar)
|
|
316
|
+
self.show_colorbar_checkbox.stateChanged.connect(self.on_overlay_changed)
|
|
317
|
+
overlay_layout.addRow(self.show_colorbar_checkbox)
|
|
318
|
+
|
|
319
|
+
# Add the group to the left panel
|
|
320
|
+
self.left_layout.addWidget(overlay_group)
|
|
321
|
+
|
|
322
|
+
def load_image(self):
|
|
323
|
+
"""Load and convert the image to helioprojective coordinates"""
|
|
324
|
+
if not SUNPY_AVAILABLE:
|
|
325
|
+
self.show_status_message("Sunpy is not available. Please install sunpy.")
|
|
326
|
+
return
|
|
327
|
+
|
|
328
|
+
if not self.imagename:
|
|
329
|
+
self.show_status_message("No image specified.")
|
|
330
|
+
return
|
|
331
|
+
|
|
332
|
+
try:
|
|
333
|
+
self.show_status_message(f"Loading image: {self.imagename}")
|
|
334
|
+
|
|
335
|
+
# If it's a CASA image, convert it to FITS first
|
|
336
|
+
if os.path.isdir(self.imagename):
|
|
337
|
+
try:
|
|
338
|
+
self.show_status_message(
|
|
339
|
+
f"Converting CASA image to FITS: {self.imagename}"
|
|
340
|
+
)
|
|
341
|
+
temp_fits = convert_casaimage_to_fits(imagename=self.imagename)
|
|
342
|
+
if temp_fits is None:
|
|
343
|
+
raise RuntimeError("Failed to convert CASA image to FITS")
|
|
344
|
+
self.temp_fits_file = temp_fits
|
|
345
|
+
fits_file = temp_fits
|
|
346
|
+
self.show_status_message(
|
|
347
|
+
f"CASA image converted to FITS: {temp_fits}"
|
|
348
|
+
)
|
|
349
|
+
except Exception as e:
|
|
350
|
+
self.show_status_message(
|
|
351
|
+
f"Error converting CASA image to FITS: {str(e)}"
|
|
352
|
+
)
|
|
353
|
+
print(f"Error converting CASA image to FITS: {str(e)}")
|
|
354
|
+
return
|
|
355
|
+
else:
|
|
356
|
+
fits_file = self.imagename
|
|
357
|
+
|
|
358
|
+
# Convert to helioprojective coordinates
|
|
359
|
+
self.show_status_message(f"Converting to helioprojective coordinates...")
|
|
360
|
+
|
|
361
|
+
# Make sure the rms_box is a tuple of integers
|
|
362
|
+
if isinstance(self.rms_box, list):
|
|
363
|
+
self.rms_box = tuple(self.rms_box)
|
|
364
|
+
|
|
365
|
+
# Convert to helioprojective coordinates
|
|
366
|
+
self.helioprojective_map, csys, self.psf = convert_to_hpc(
|
|
367
|
+
fits_file=fits_file,
|
|
368
|
+
Stokes=self.stokes,
|
|
369
|
+
thres=self.threshold,
|
|
370
|
+
rms_box=self.rms_box,
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
if self.helioprojective_map is None:
|
|
374
|
+
self.show_status_message(
|
|
375
|
+
"Failed to convert to helioprojective coordinates."
|
|
376
|
+
)
|
|
377
|
+
return
|
|
378
|
+
|
|
379
|
+
# Ensure required metadata is present and valid
|
|
380
|
+
"""if not hasattr(self.helioprojective_map, "meta"):
|
|
381
|
+
self.helioprojective_map.meta = {}
|
|
382
|
+
|
|
383
|
+
# Fix any 'None' string values in metadata
|
|
384
|
+
for key in ["telescop", "instrume", "detector"]:
|
|
385
|
+
if self.helioprojective_map.meta.get(key) in ["None", "none", None]:
|
|
386
|
+
self.helioprojective_map.meta[key] = "Unknown"
|
|
387
|
+
|
|
388
|
+
# Auto-scale the image
|
|
389
|
+
self.auto_percentile()"""
|
|
390
|
+
|
|
391
|
+
# Plot the image
|
|
392
|
+
self.plot_image()
|
|
393
|
+
|
|
394
|
+
self.show_status_message(
|
|
395
|
+
"Image loaded and converted to helioprojective coordinates."
|
|
396
|
+
)
|
|
397
|
+
except Exception as e:
|
|
398
|
+
import traceback
|
|
399
|
+
|
|
400
|
+
traceback.print_exc()
|
|
401
|
+
error_msg = f"Error loading image: {str(e)}"
|
|
402
|
+
self.show_status_message(error_msg)
|
|
403
|
+
QMessageBox.critical(self, "Error", error_msg)
|
|
404
|
+
|
|
405
|
+
def plot_image(self):
|
|
406
|
+
"""Plot the helioprojective map"""
|
|
407
|
+
if self.helioprojective_map is None:
|
|
408
|
+
self.show_status_message("No helioprojective map available to plot.")
|
|
409
|
+
return
|
|
410
|
+
|
|
411
|
+
try:
|
|
412
|
+
# Clear the figure
|
|
413
|
+
self.figure.clear()
|
|
414
|
+
|
|
415
|
+
# Create a subplot with the helioprojective map projection
|
|
416
|
+
try:
|
|
417
|
+
ax = self.figure.add_subplot(111, projection=self.helioprojective_map)
|
|
418
|
+
except Exception as e:
|
|
419
|
+
print(f"Error creating subplot with projection: {str(e)}")
|
|
420
|
+
ax = self.figure.add_subplot(111)
|
|
421
|
+
|
|
422
|
+
# Apply stretch function
|
|
423
|
+
data = self.helioprojective_map.data.copy()
|
|
424
|
+
|
|
425
|
+
# Handle NaN values
|
|
426
|
+
if np.isnan(data).any():
|
|
427
|
+
data = np.nan_to_num(data, nan=0.0)
|
|
428
|
+
|
|
429
|
+
# Apply stretch
|
|
430
|
+
if self.stretch == "sqrt":
|
|
431
|
+
norm = PowerNorm(0.5, vmin=self.vmin, vmax=self.vmax)
|
|
432
|
+
elif self.stretch == "log":
|
|
433
|
+
norm = LogNorm(
|
|
434
|
+
vmin=max(1e-10, self.vmin) if self.vmin else 1e-10, vmax=self.vmax
|
|
435
|
+
)
|
|
436
|
+
elif self.stretch == "power":
|
|
437
|
+
norm = PowerNorm(self.gamma, vmin=self.vmin, vmax=self.vmax)
|
|
438
|
+
else: # linear
|
|
439
|
+
norm = Normalize(vmin=self.vmin, vmax=self.vmax)
|
|
440
|
+
|
|
441
|
+
# Plot the map
|
|
442
|
+
try:
|
|
443
|
+
im = self.helioprojective_map.plot(
|
|
444
|
+
axes=ax,
|
|
445
|
+
cmap=self.colormap,
|
|
446
|
+
norm=norm,
|
|
447
|
+
title=False,
|
|
448
|
+
)
|
|
449
|
+
except Exception as e:
|
|
450
|
+
print(f"Error using sunpy plot: {str(e)}, falling back to imshow")
|
|
451
|
+
im = ax.imshow(
|
|
452
|
+
data,
|
|
453
|
+
cmap=self.colormap,
|
|
454
|
+
norm=norm,
|
|
455
|
+
origin="lower",
|
|
456
|
+
aspect="equal",
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
# Set axis labels
|
|
460
|
+
ax.set_xlabel("Helioprojective Longitude (arcsec)")
|
|
461
|
+
ax.set_ylabel("Helioprojective Latitude (arcsec)")
|
|
462
|
+
|
|
463
|
+
# Set title with observation information
|
|
464
|
+
try:
|
|
465
|
+
if hasattr(self.helioprojective_map, "wavelength") and hasattr(
|
|
466
|
+
self.helioprojective_map, "date"
|
|
467
|
+
):
|
|
468
|
+
wavelength_str = f"{self.helioprojective_map.wavelength.value:.2f} {self.helioprojective_map.wavelength.unit}"
|
|
469
|
+
title = f"Helioprojective Coordinate Map\n{wavelength_str} - {self.helioprojective_map.date.strftime('%Y-%m-%d %H:%M:%S')}"
|
|
470
|
+
ax.set_title(title, fontsize=12)
|
|
471
|
+
else:
|
|
472
|
+
ax.set_title("Helioprojective Coordinate Map", fontsize=12)
|
|
473
|
+
except Exception as e:
|
|
474
|
+
print(f"Error setting title: {str(e)}")
|
|
475
|
+
ax.set_title("Helioprojective Coordinate Map", fontsize=12)
|
|
476
|
+
|
|
477
|
+
# Draw grid if enabled
|
|
478
|
+
if self.show_grid:
|
|
479
|
+
ax.grid(True, color="white", linestyle="--", alpha=0.5)
|
|
480
|
+
|
|
481
|
+
# Draw solar limb if enabled
|
|
482
|
+
if self.show_limb:
|
|
483
|
+
try:
|
|
484
|
+
self.helioprojective_map.draw_limb(
|
|
485
|
+
axes=ax, color="white", alpha=0.5, linewidth=1
|
|
486
|
+
)
|
|
487
|
+
except Exception as e:
|
|
488
|
+
print(f"Error drawing limb: {str(e)}")
|
|
489
|
+
|
|
490
|
+
# Draw PSF beam if enabled
|
|
491
|
+
if self.show_beam and self.psf:
|
|
492
|
+
try:
|
|
493
|
+
self._draw_beam(ax)
|
|
494
|
+
except Exception as e:
|
|
495
|
+
print(f"Error drawing beam: {str(e)}")
|
|
496
|
+
|
|
497
|
+
# Draw colorbar if enabled
|
|
498
|
+
if self.show_colorbar:
|
|
499
|
+
try:
|
|
500
|
+
self.figure.colorbar(im, ax=ax, label="Intensity")
|
|
501
|
+
except Exception as e:
|
|
502
|
+
print(f"Error drawing colorbar: {str(e)}")
|
|
503
|
+
|
|
504
|
+
# Update the canvas
|
|
505
|
+
self.canvas.draw()
|
|
506
|
+
|
|
507
|
+
self.show_status_message("Helioprojective map plotted successfully.")
|
|
508
|
+
except Exception as e:
|
|
509
|
+
import traceback
|
|
510
|
+
|
|
511
|
+
traceback.print_exc()
|
|
512
|
+
self.show_status_message(f"Error plotting image: {str(e)}")
|
|
513
|
+
|
|
514
|
+
def _draw_beam(self, ax):
|
|
515
|
+
"""Draw the PSF beam on the plot"""
|
|
516
|
+
if not self.psf:
|
|
517
|
+
return
|
|
518
|
+
|
|
519
|
+
try:
|
|
520
|
+
# Get beam properties
|
|
521
|
+
if isinstance(self.psf["major"]["value"], list):
|
|
522
|
+
major_deg = float(self.psf["major"]["value"][0])
|
|
523
|
+
else:
|
|
524
|
+
major_deg = float(self.psf["major"]["value"])
|
|
525
|
+
|
|
526
|
+
if isinstance(self.psf["minor"]["value"], list):
|
|
527
|
+
minor_deg = float(self.psf["minor"]["value"][0])
|
|
528
|
+
else:
|
|
529
|
+
minor_deg = float(self.psf["minor"]["value"])
|
|
530
|
+
|
|
531
|
+
if isinstance(self.psf["positionangle"]["value"], list):
|
|
532
|
+
pa_deg = float(self.psf["positionangle"]["value"][0]) - 90
|
|
533
|
+
else:
|
|
534
|
+
pa_deg = float(self.psf["positionangle"]["value"]) - 90
|
|
535
|
+
|
|
536
|
+
# Convert beam size to arcseconds
|
|
537
|
+
major_arcsec = major_deg * 3600
|
|
538
|
+
minor_arcsec = minor_deg * 3600
|
|
539
|
+
|
|
540
|
+
# Get the current axis limits in arcseconds
|
|
541
|
+
xlim = ax.get_xlim()
|
|
542
|
+
ylim = ax.get_ylim()
|
|
543
|
+
view_width = xlim[1] - xlim[0]
|
|
544
|
+
view_height = ylim[1] - ylim[0]
|
|
545
|
+
margin_x = view_width * 0.05
|
|
546
|
+
margin_y = view_height * 0.05
|
|
547
|
+
|
|
548
|
+
# Position the beam in the bottom-left corner
|
|
549
|
+
beam_x = xlim[0] + margin_x + major_arcsec / 2
|
|
550
|
+
beam_y = ylim[0] + margin_y + minor_arcsec / 2
|
|
551
|
+
|
|
552
|
+
# Create the beam ellipse
|
|
553
|
+
beam = patches.Ellipse(
|
|
554
|
+
(beam_x, beam_y),
|
|
555
|
+
major_arcsec,
|
|
556
|
+
minor_arcsec,
|
|
557
|
+
angle=pa_deg,
|
|
558
|
+
fc="white",
|
|
559
|
+
ec="black",
|
|
560
|
+
alpha=0.7,
|
|
561
|
+
)
|
|
562
|
+
ax.add_patch(beam)
|
|
563
|
+
|
|
564
|
+
# Add text with beam size
|
|
565
|
+
ax.text(
|
|
566
|
+
beam_x,
|
|
567
|
+
beam_y + minor_arcsec,
|
|
568
|
+
f"{major_arcsec:.1f}″×{minor_arcsec:.1f}″",
|
|
569
|
+
ha="center",
|
|
570
|
+
va="bottom",
|
|
571
|
+
color="white",
|
|
572
|
+
fontsize=8,
|
|
573
|
+
)
|
|
574
|
+
except Exception as e:
|
|
575
|
+
print(f"Error drawing beam: {str(e)}")
|
|
576
|
+
|
|
577
|
+
def on_display_changed(self):
|
|
578
|
+
"""Handle changes to display settings"""
|
|
579
|
+
self.colormap = self.cmap_combo.currentText()
|
|
580
|
+
self.stretch = self.stretch_combo.currentText()
|
|
581
|
+
|
|
582
|
+
# Update gamma controls visibility
|
|
583
|
+
self.gamma_slider.setEnabled(self.stretch == "power")
|
|
584
|
+
self.gamma_entry.setEnabled(self.stretch == "power")
|
|
585
|
+
|
|
586
|
+
# Update the plot
|
|
587
|
+
self.plot_image()
|
|
588
|
+
|
|
589
|
+
def on_overlay_changed(self):
|
|
590
|
+
"""Handle changes to overlay settings"""
|
|
591
|
+
self.show_grid = self.show_grid_checkbox.isChecked()
|
|
592
|
+
self.show_limb = self.show_limb_checkbox.isChecked()
|
|
593
|
+
self.show_beam = self.show_beam_checkbox.isChecked()
|
|
594
|
+
self.show_colorbar = self.show_colorbar_checkbox.isChecked()
|
|
595
|
+
|
|
596
|
+
# Update the plot
|
|
597
|
+
self.plot_image()
|
|
598
|
+
|
|
599
|
+
def on_gamma_changed(self):
|
|
600
|
+
"""Handle changes to the gamma slider"""
|
|
601
|
+
self.gamma = self.gamma_slider.value() / 10.0
|
|
602
|
+
self.gamma_entry.setText(f"{self.gamma:.1f}")
|
|
603
|
+
|
|
604
|
+
if self.stretch == "power":
|
|
605
|
+
self.plot_image()
|
|
606
|
+
|
|
607
|
+
def on_gamma_entry_changed(self):
|
|
608
|
+
"""Handle changes to the gamma entry field"""
|
|
609
|
+
try:
|
|
610
|
+
gamma = float(self.gamma_entry.text())
|
|
611
|
+
if gamma > 0:
|
|
612
|
+
self.gamma = gamma
|
|
613
|
+
self.gamma_slider.setValue(int(gamma * 10))
|
|
614
|
+
|
|
615
|
+
if self.stretch == "power":
|
|
616
|
+
self.plot_image()
|
|
617
|
+
except ValueError:
|
|
618
|
+
# Restore the previous value
|
|
619
|
+
self.gamma_entry.setText(f"{self.gamma:.1f}")
|
|
620
|
+
|
|
621
|
+
def auto_minmax(self):
|
|
622
|
+
"""Auto-scale using min/max values"""
|
|
623
|
+
if self.helioprojective_map is None:
|
|
624
|
+
return
|
|
625
|
+
|
|
626
|
+
data = self.helioprojective_map.data
|
|
627
|
+
self.vmin = float(np.nanmin(data))
|
|
628
|
+
self.vmax = float(np.nanmax(data))
|
|
629
|
+
|
|
630
|
+
self.vmin_entry.setText(f"{self.vmin:.6g}")
|
|
631
|
+
self.vmax_entry.setText(f"{self.vmax:.6g}")
|
|
632
|
+
|
|
633
|
+
self.plot_image()
|
|
634
|
+
self.show_status_message(
|
|
635
|
+
f"Auto-scaled to min/max: {self.vmin:.6g} - {self.vmax:.6g}"
|
|
636
|
+
)
|
|
637
|
+
|
|
638
|
+
def auto_percentile(self):
|
|
639
|
+
"""Auto-scale using percentile values"""
|
|
640
|
+
if self.helioprojective_map is None:
|
|
641
|
+
return
|
|
642
|
+
|
|
643
|
+
data = self.helioprojective_map.data
|
|
644
|
+
self.vmin = float(np.nanpercentile(data, 0.5))
|
|
645
|
+
self.vmax = float(np.nanpercentile(data, 99.5))
|
|
646
|
+
|
|
647
|
+
self.vmin_entry.setText(f"{self.vmin:.6g}")
|
|
648
|
+
self.vmax_entry.setText(f"{self.vmax:.6g}")
|
|
649
|
+
|
|
650
|
+
self.plot_image()
|
|
651
|
+
self.show_status_message(
|
|
652
|
+
f"Auto-scaled to 0.5-99.5 percentile: {self.vmin:.6g} - {self.vmax:.6g}"
|
|
653
|
+
)
|
|
654
|
+
|
|
655
|
+
def auto_median_rms(self):
|
|
656
|
+
"""Auto-scale using median ± 5σ"""
|
|
657
|
+
if self.helioprojective_map is None:
|
|
658
|
+
return
|
|
659
|
+
|
|
660
|
+
data = self.helioprojective_map.data
|
|
661
|
+
median = float(np.nanmedian(data))
|
|
662
|
+
rms = float(np.nanstd(data))
|
|
663
|
+
|
|
664
|
+
self.vmin = median - 5 * rms
|
|
665
|
+
self.vmax = median + 5 * rms
|
|
666
|
+
|
|
667
|
+
self.vmin_entry.setText(f"{self.vmin:.6g}")
|
|
668
|
+
self.vmax_entry.setText(f"{self.vmax:.6g}")
|
|
669
|
+
|
|
670
|
+
self.plot_image()
|
|
671
|
+
self.show_status_message(
|
|
672
|
+
f"Auto-scaled to median±5σ: {self.vmin:.6g} - {self.vmax:.6g}"
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
def show_status_message(self, message):
|
|
676
|
+
"""Show a message in the status bar"""
|
|
677
|
+
self.statusbar.showMessage(message)
|
|
678
|
+
print(message)
|
|
679
|
+
|
|
680
|
+
def _on_theme_changed(self, new_theme):
|
|
681
|
+
"""Handle theme change events."""
|
|
682
|
+
# Update matplotlib rcParams
|
|
683
|
+
update_hpc_matplotlib_theme()
|
|
684
|
+
|
|
685
|
+
# Update window stylesheet
|
|
686
|
+
self.setStyleSheet(get_stylesheet(theme_manager.palette, theme_manager.is_dark))
|
|
687
|
+
|
|
688
|
+
# Refresh the plot with new theme colors
|
|
689
|
+
if hasattr(self, 'figure') and self.figure and self.helioprojective_map:
|
|
690
|
+
palette = theme_manager.palette
|
|
691
|
+
is_dark = theme_manager.is_dark
|
|
692
|
+
|
|
693
|
+
# Use plot-specific colors for light mode
|
|
694
|
+
if is_dark:
|
|
695
|
+
fig_bg = palette["window"]
|
|
696
|
+
axes_bg = palette["base"]
|
|
697
|
+
text_color = palette["text"]
|
|
698
|
+
else:
|
|
699
|
+
fig_bg = palette.get("plot_bg", "#ffffff")
|
|
700
|
+
axes_bg = palette.get("plot_bg", "#ffffff")
|
|
701
|
+
text_color = palette.get("plot_text", "#1a1a1a")
|
|
702
|
+
|
|
703
|
+
self.figure.set_facecolor(fig_bg)
|
|
704
|
+
for ax in self.figure.get_axes():
|
|
705
|
+
ax.set_facecolor(axes_bg)
|
|
706
|
+
ax.tick_params(colors=text_color)
|
|
707
|
+
ax.xaxis.label.set_color(text_color)
|
|
708
|
+
ax.yaxis.label.set_color(text_color)
|
|
709
|
+
ax.title.set_color(text_color)
|
|
710
|
+
for spine in ax.spines.values():
|
|
711
|
+
spine.set_color(text_color)
|
|
712
|
+
self.canvas.draw_idle()
|
|
713
|
+
|
|
714
|
+
def closeEvent(self, event):
|
|
715
|
+
"""Handle window close event"""
|
|
716
|
+
# Unregister theme callback
|
|
717
|
+
theme_manager.unregister_callback(self._on_theme_changed)
|
|
718
|
+
|
|
719
|
+
# Clean up temporary files
|
|
720
|
+
if self.temp_fits_file and os.path.exists(self.temp_fits_file):
|
|
721
|
+
try:
|
|
722
|
+
os.remove(self.temp_fits_file)
|
|
723
|
+
print(f"Removed temporary file: {self.temp_fits_file}")
|
|
724
|
+
except Exception as e:
|
|
725
|
+
print(f"Error removing temporary file: {str(e)}")
|
|
726
|
+
|
|
727
|
+
# Accept the close event
|
|
728
|
+
event.accept()
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def main():
|
|
732
|
+
"""Main function for standalone execution"""
|
|
733
|
+
import argparse
|
|
734
|
+
from PyQt5.QtWidgets import QApplication
|
|
735
|
+
|
|
736
|
+
# Create argument parser with detailed help
|
|
737
|
+
parser = argparse.ArgumentParser(
|
|
738
|
+
description="""
|
|
739
|
+
Solar Radio Image Helioprojective Viewer
|
|
740
|
+
|
|
741
|
+
A standalone viewer for displaying solar radio images in helioprojective coordinates.
|
|
742
|
+
This tool can handle both FITS files and CASA images, and provides interactive
|
|
743
|
+
visualization with various display options.
|
|
744
|
+
|
|
745
|
+
Examples:
|
|
746
|
+
heliosv myimage.fits
|
|
747
|
+
heliosv myimage.image --stokes I
|
|
748
|
+
heliosv myimage.fits --threshold 5 --rms-box 0 200 0 130
|
|
749
|
+
""",
|
|
750
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
parser.add_argument(
|
|
754
|
+
"imagename", help="Path to the input image (FITS file or CASA image directory)"
|
|
755
|
+
)
|
|
756
|
+
|
|
757
|
+
parser.add_argument(
|
|
758
|
+
"--stokes",
|
|
759
|
+
default="I",
|
|
760
|
+
choices=[
|
|
761
|
+
"I",
|
|
762
|
+
"Q",
|
|
763
|
+
"U",
|
|
764
|
+
"V",
|
|
765
|
+
"L",
|
|
766
|
+
"Lfrac",
|
|
767
|
+
"Vfrac",
|
|
768
|
+
"Q/I",
|
|
769
|
+
"U/I",
|
|
770
|
+
"U/V",
|
|
771
|
+
"PANG",
|
|
772
|
+
],
|
|
773
|
+
help="Stokes parameter to display (default: I)",
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
parser.add_argument(
|
|
777
|
+
"--threshold",
|
|
778
|
+
type=float,
|
|
779
|
+
default=10,
|
|
780
|
+
help="Threshold value for polarization calculations (default: 10)",
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
parser.add_argument(
|
|
784
|
+
"--rms-box",
|
|
785
|
+
type=int,
|
|
786
|
+
nargs=4,
|
|
787
|
+
default=[0, 200, 0, 130],
|
|
788
|
+
metavar=("X1", "X2", "Y1", "Y2"),
|
|
789
|
+
help="RMS box coordinates as X1 X2 Y1 Y2 (default: 0 200 0 130)",
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
# Parse arguments
|
|
793
|
+
args = parser.parse_args()
|
|
794
|
+
|
|
795
|
+
# Check if image exists
|
|
796
|
+
if not os.path.exists(args.imagename):
|
|
797
|
+
print(f"Error: Image not found: {args.imagename}")
|
|
798
|
+
sys.exit(1)
|
|
799
|
+
|
|
800
|
+
# Create Qt application
|
|
801
|
+
app = QApplication(sys.argv)
|
|
802
|
+
|
|
803
|
+
# Create and show the viewer
|
|
804
|
+
viewer = HelioProjectiveViewer(
|
|
805
|
+
imagename=args.imagename,
|
|
806
|
+
stokes=args.stokes,
|
|
807
|
+
threshold=args.threshold,
|
|
808
|
+
rms_box=args.rms_box,
|
|
809
|
+
)
|
|
810
|
+
viewer.show()
|
|
811
|
+
|
|
812
|
+
# Start the event loop
|
|
813
|
+
sys.exit(app.exec_())
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
if __name__ == "__main__":
|
|
817
|
+
main()
|