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,1210 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Solar Data Viewer GUI - PyQt5-based interface for downloading solar observatory data.
4
+
5
+ This module provides a graphical interface for downloading data from various solar
6
+ observatories using the solar_data_downloader module. It can be used as a standalone
7
+ application or integrated into other PyQt applications.
8
+ """
9
+
10
+ import sys
11
+ import os
12
+ import datetime
13
+ from pathlib import Path
14
+ from typing import Optional, Dict, List
15
+
16
+ try:
17
+ from PyQt5.QtWidgets import (
18
+ QApplication,
19
+ QMainWindow,
20
+ QWidget,
21
+ QVBoxLayout,
22
+ QHBoxLayout,
23
+ QLabel,
24
+ QComboBox,
25
+ QPushButton,
26
+ QLineEdit,
27
+ QDateTimeEdit,
28
+ QFileDialog,
29
+ QProgressBar,
30
+ QMessageBox,
31
+ QGroupBox,
32
+ QRadioButton,
33
+ QButtonGroup,
34
+ QCheckBox,
35
+ QScrollArea,
36
+ )
37
+ from PyQt5.QtCore import Qt, QDateTime, pyqtSignal, QThread
38
+ except ImportError:
39
+ print("Error: PyQt5 is required. Please install it with:")
40
+ print(" pip install PyQt5")
41
+ sys.exit(1)
42
+
43
+ # Try to import the solar_data_downloader module
44
+ try:
45
+ # First try relative import (when used as part of package)
46
+ from . import solar_data_downloader as sdd
47
+ except ImportError:
48
+ try:
49
+ # Then try importing from the same directory (when run as script)
50
+ script_dir = os.path.dirname(os.path.abspath(__file__))
51
+ if script_dir not in sys.path:
52
+ sys.path.append(script_dir)
53
+ import solar_data_downloader as sdd
54
+ except ImportError:
55
+ print("Error: Could not import solar_data_downloader module.")
56
+ print(
57
+ "Make sure solar_data_downloader.py is in the same directory as this script."
58
+ )
59
+ sys.exit(1)
60
+
61
+ # Check if required packages are installed
62
+ try:
63
+ import sunpy
64
+ import drms
65
+ import astropy
66
+ except ImportError as e:
67
+ print(f"Error: Missing required package: {e.name}")
68
+ print("Please install the required packages with:")
69
+ print(" pip install sunpy drms astropy")
70
+ print("For AIA Level 1.5 calibration, also install:")
71
+ print(" pip install aiapy")
72
+ sys.exit(1)
73
+
74
+
75
+ # Instruments that only support Fido (no DRMS option)
76
+ FIDO_ONLY_INSTRUMENTS = ["IRIS", "SOHO", "GOES SUVI", "STEREO", "GONG"]
77
+
78
+
79
+ class DownloadWorker(QThread):
80
+ """Worker thread for handling downloads using subprocess with real-time progress updates."""
81
+
82
+ progress = pyqtSignal(str) # Signal to update progress text
83
+ finished = pyqtSignal(list) # Signal emitted with list of downloaded files
84
+ error = pyqtSignal(str) # Signal emitted when an error occurs
85
+
86
+ def __init__(self, download_params: dict):
87
+ super().__init__()
88
+ self.params = download_params
89
+ self._cancelled = False
90
+
91
+ def cancel(self):
92
+ """Cancel the download."""
93
+ self._cancelled = True
94
+
95
+ def run(self):
96
+ """Execute the download operation in a subprocess with real-time progress streaming."""
97
+ import subprocess
98
+ import json
99
+ import re
100
+ import time
101
+ import select
102
+ import os
103
+
104
+ try:
105
+ # Create a temporary Python script to run the download
106
+ script = self._generate_download_script()
107
+
108
+ # Run the download in a subprocess with real-time output
109
+ self.progress.emit("Starting download...")
110
+
111
+ process = subprocess.Popen(
112
+ [sys.executable, "-u", "-c", script], # -u for unbuffered output
113
+ stdout=subprocess.PIPE,
114
+ stderr=subprocess.PIPE,
115
+ bufsize=0, # Unbuffered
116
+ )
117
+
118
+ # Set non-blocking mode on stderr for progress reading
119
+ import fcntl
120
+ fl = fcntl.fcntl(process.stderr, fcntl.F_GETFL)
121
+ fcntl.fcntl(process.stderr, fcntl.F_SETFL, fl | os.O_NONBLOCK)
122
+ fl = fcntl.fcntl(process.stdout, fcntl.F_GETFL)
123
+ fcntl.fcntl(process.stdout, fcntl.F_SETFL, fl | os.O_NONBLOCK)
124
+
125
+ files = []
126
+ stdout_buffer = ""
127
+ stderr_buffer = ""
128
+ total_files = 0
129
+ last_progress_update = time.time()
130
+
131
+ # Read output in real-time
132
+ while True:
133
+ if self._cancelled:
134
+ process.terminate()
135
+ self.error.emit("Download cancelled by user")
136
+ return
137
+
138
+ # Use select to check for readable data
139
+ readable, _, _ = select.select([process.stdout, process.stderr], [], [], 0.1)
140
+
141
+ # Read from stdout (main status messages)
142
+ if process.stdout in readable:
143
+ try:
144
+ chunk = process.stdout.read(4096)
145
+ if chunk:
146
+ stdout_buffer += chunk.decode('utf-8', errors='replace')
147
+
148
+ # Process complete lines
149
+ while '\n' in stdout_buffer:
150
+ line, stdout_buffer = stdout_buffer.split('\n', 1)
151
+ line = line.strip()
152
+
153
+ if line.startswith("DOWNLOADED_FILES:"):
154
+ try:
155
+ files_json = line.replace("DOWNLOADED_FILES:", "").strip()
156
+ files = json.loads(files_json)
157
+ except json.JSONDecodeError:
158
+ pass
159
+ elif line.startswith("Searching for"):
160
+ self.progress.emit(line)
161
+ elif line.startswith("Found"):
162
+ self.progress.emit(line)
163
+ match = re.search(r'Found (\d+) files', line)
164
+ if match:
165
+ total_files = int(match.group(1))
166
+ elif "Successfully" in line:
167
+ self.progress.emit(line)
168
+ elif "Processing" in line:
169
+ self.progress.emit(line)
170
+ except (BlockingIOError, IOError):
171
+ pass
172
+
173
+ # Read from stderr (parfive progress bars)
174
+ if process.stderr in readable:
175
+ try:
176
+ chunk = process.stderr.read(4096)
177
+ if chunk:
178
+ stderr_buffer += chunk.decode('utf-8', errors='replace')
179
+
180
+ # Process carriage return separated updates
181
+ while '\r' in stderr_buffer or '\n' in stderr_buffer:
182
+ # Split on either \r or \n
183
+ sep = '\r' if '\r' in stderr_buffer else '\n'
184
+ line, stderr_buffer = stderr_buffer.split(sep, 1)
185
+
186
+ # Clean ANSI escape codes
187
+ clean_line = re.sub(r'\x1b\[[0-9;]*m', '', line).strip()
188
+
189
+ current_time = time.time()
190
+
191
+ # Parse "Files Downloaded:" progress
192
+ if "Files Downloaded:" in clean_line:
193
+ match = re.search(r'Files Downloaded:\s*(\d+)%.*?(\d+)/(\d+)', clean_line)
194
+ if match:
195
+ pct = match.group(1)
196
+ completed = match.group(2)
197
+ total = match.group(3)
198
+ if current_time - last_progress_update > 0.5:
199
+ self.progress.emit(f"Downloading: {completed}/{total} files ({pct}%)")
200
+ last_progress_update = current_time
201
+ else:
202
+ match = re.search(r'(\d+)%', clean_line)
203
+ if match and current_time - last_progress_update > 0.5:
204
+ pct = match.group(1)
205
+ self.progress.emit(f"Downloading... {pct}%")
206
+ last_progress_update = current_time
207
+ except (BlockingIOError, IOError):
208
+ pass
209
+
210
+ # Check if process has finished
211
+ if process.poll() is not None:
212
+ # Read any remaining buffered output
213
+ try:
214
+ remaining_stdout = process.stdout.read()
215
+ if remaining_stdout:
216
+ stdout_buffer += remaining_stdout.decode('utf-8', errors='replace')
217
+ except:
218
+ pass
219
+
220
+ # Process remaining stdout
221
+ for line in stdout_buffer.split('\n'):
222
+ line = line.strip()
223
+ if line.startswith("DOWNLOADED_FILES:"):
224
+ try:
225
+ files_json = line.replace("DOWNLOADED_FILES:", "").strip()
226
+ files = json.loads(files_json)
227
+ except json.JSONDecodeError:
228
+ pass
229
+ break
230
+
231
+ if process.returncode != 0:
232
+ self.error.emit("Download failed. Check console for details.")
233
+ return
234
+
235
+ self.progress.emit(f"Download complete! {len(files)} files")
236
+ self.finished.emit(files)
237
+
238
+
239
+
240
+ except Exception as e:
241
+ self.error.emit(str(e))
242
+
243
+ def _generate_download_script(self):
244
+ """Generate a Python script to run the download."""
245
+ import json
246
+
247
+ params = self.params
248
+ instrument = params.get("instrument")
249
+
250
+ script_lines = [
251
+ "import sys",
252
+ "import json",
253
+ f"sys.path.insert(0, '{os.path.dirname(os.path.dirname(os.path.abspath(__file__)))}' )",
254
+ "from solar_radio_image_viewer.solar_data_downloader import solar_data_downloader as sdd",
255
+ "",
256
+ "try:",
257
+ ]
258
+
259
+ if instrument == "AIA":
260
+ skip_cal = params.get("skip_calibration", False)
261
+ apply_psf = params.get("apply_psf", False)
262
+ apply_deg = params.get("apply_degradation", True)
263
+ apply_exp = params.get("apply_exposure_norm", True)
264
+
265
+ if params.get("use_fido", True):
266
+ script_lines.append(f''' files = sdd.download_aia_with_fido(
267
+ wavelength="{params['wavelength']}",
268
+ start_time="{params['start_time']}",
269
+ end_time="{params['end_time']}",
270
+ output_dir="{params['output_dir']}",
271
+ skip_calibration={skip_cal},
272
+ apply_psf={apply_psf},
273
+ apply_degradation={apply_deg},
274
+ apply_exposure_norm={apply_exp},
275
+ )''')
276
+ else:
277
+ email = params.get('email') or 'None'
278
+ script_lines.append(f''' files = sdd.download_aia(
279
+ wavelength="{params['wavelength']}",
280
+ cadence="{params['cadence']}",
281
+ start_time="{params['start_time']}",
282
+ end_time="{params['end_time']}",
283
+ output_dir="{params['output_dir']}",
284
+ email={email!r},
285
+ skip_calibration={skip_cal},
286
+ )''')
287
+
288
+ elif instrument == "HMI":
289
+ skip_cal = params.get("skip_calibration", False)
290
+ if params.get("use_fido", True):
291
+ script_lines.append(f''' files = sdd.download_hmi_with_fido(
292
+ series="{params['series']}",
293
+ start_time="{params['start_time']}",
294
+ end_time="{params['end_time']}",
295
+ output_dir="{params['output_dir']}",
296
+ skip_calibration={skip_cal},
297
+ )''')
298
+ else:
299
+ email = params.get('email') or 'None'
300
+ script_lines.append(f''' files = sdd.download_hmi(
301
+ series="{params['series']}",
302
+ start_time="{params['start_time']}",
303
+ end_time="{params['end_time']}",
304
+ output_dir="{params['output_dir']}",
305
+ email={email!r},
306
+ skip_calibration={skip_cal},
307
+ )''')
308
+
309
+ elif instrument == "IRIS":
310
+ wavelength = params.get('wavelength')
311
+ script_lines.append(f''' files = sdd.download_iris(
312
+ start_time="{params['start_time']}",
313
+ end_time="{params['end_time']}",
314
+ output_dir="{params['output_dir']}",
315
+ obs_type="{params['obs_type']}",
316
+ wavelength={wavelength!r},
317
+ )''')
318
+
319
+ elif instrument == "SOHO":
320
+ wavelength = params.get('wavelength')
321
+ detector = params.get('detector')
322
+ script_lines.append(f''' files = sdd.download_soho(
323
+ instrument="{params['soho_instrument']}",
324
+ start_time="{params['start_time']}",
325
+ end_time="{params['end_time']}",
326
+ output_dir="{params['output_dir']}",
327
+ wavelength={wavelength!r},
328
+ detector={detector!r},
329
+ )''')
330
+
331
+ elif instrument == "GOES SUVI":
332
+ wavelength = params.get('wavelength')
333
+ level = params.get('level', '2')
334
+ script_lines.append(f''' files = sdd.download_goes_suvi(
335
+ start_time="{params['start_time']}",
336
+ end_time="{params['end_time']}",
337
+ output_dir="{params['output_dir']}",
338
+ wavelength={wavelength!r},
339
+ level="{level}",
340
+ )''')
341
+
342
+ elif instrument == "STEREO":
343
+ spacecraft = params.get('spacecraft', 'A')
344
+ stereo_inst = params.get('stereo_instrument', 'EUVI')
345
+ wavelength = params.get('wavelength')
346
+ script_lines.append(f''' files = sdd.download_stereo(
347
+ start_time="{params['start_time']}",
348
+ end_time="{params['end_time']}",
349
+ output_dir="{params['output_dir']}",
350
+ spacecraft="{spacecraft}",
351
+ instrument="{stereo_inst}",
352
+ wavelength={wavelength!r},
353
+ )''')
354
+
355
+ elif instrument == "GONG":
356
+ script_lines.append(f''' files = sdd.download_gong(
357
+ start_time="{params['start_time']}",
358
+ end_time="{params['end_time']}",
359
+ output_dir="{params['output_dir']}",
360
+ )''')
361
+
362
+ # Add output section - flush to ensure real-time output
363
+ script_lines.extend([
364
+ " sys.stdout.flush()",
365
+ " if files:",
366
+ " print('DOWNLOADED_FILES:' + json.dumps(files))",
367
+ " sys.stdout.flush()",
368
+ " else:",
369
+ " print('DOWNLOADED_FILES:[]')",
370
+ " sys.stdout.flush()",
371
+ "except Exception as e:",
372
+ " import traceback",
373
+ " traceback.print_exc()",
374
+ " sys.exit(1)",
375
+ ])
376
+
377
+ return "\n".join(script_lines)
378
+
379
+
380
+ class SolarDataViewerGUI(QMainWindow):
381
+ """Main window for the Solar Data Viewer GUI application."""
382
+
383
+ def __init__(self, parent=None, initial_datetime=None):
384
+ super().__init__(parent)
385
+ self.setWindowTitle("Solar Data Downloader")
386
+ self.setMinimumWidth(600)
387
+ self.setMinimumHeight(800)
388
+
389
+ # Store initial datetime for time selection
390
+ self.initial_datetime = initial_datetime
391
+
392
+ # Initialize the main widget and layout
393
+ self.main_widget = QWidget()
394
+ self.setCentralWidget(self.main_widget)
395
+ self.layout = QVBoxLayout(self.main_widget)
396
+
397
+ # Create the UI components
398
+ self.create_instrument_selection()
399
+ self.create_parameter_widgets()
400
+ self.create_time_selection()
401
+ self.create_output_selection()
402
+ self.create_calibration_options()
403
+ self.create_download_section()
404
+
405
+ # Initialize the download worker
406
+ self.download_worker = None
407
+
408
+ # Initial update for method visibility
409
+ self.on_instrument_changed(0)
410
+
411
+ def create_instrument_selection(self):
412
+ """Create the instrument selection section."""
413
+ group = QGroupBox("Select Instrument")
414
+ layout = QVBoxLayout()
415
+
416
+ self.instrument_combo = QComboBox()
417
+ self.instrument_combo.addItems(
418
+ [
419
+ "SDO/AIA (Atmospheric Imaging Assembly)",
420
+ "SDO/HMI (Helioseismic and Magnetic Imager)",
421
+ "IRIS (Interface Region Imaging Spectrograph)",
422
+ "SOHO (Solar and Heliospheric Observatory)",
423
+ "GOES/SUVI (Solar Ultraviolet Imager)",
424
+ "STEREO/SECCHI (Sun Earth Connection)",
425
+ # "GONG (Global Oscillation Network Group)",
426
+ ]
427
+ )
428
+ self.instrument_combo.currentIndexChanged.connect(self.on_instrument_changed)
429
+
430
+ layout.addWidget(self.instrument_combo)
431
+ group.setLayout(layout)
432
+ self.layout.addWidget(group)
433
+
434
+ def create_parameter_widgets(self):
435
+ """Create the parameter selection widgets for each instrument."""
436
+ self.param_group = QGroupBox("Instrument Parameters")
437
+ self.param_layout = QVBoxLayout()
438
+
439
+ # AIA parameters
440
+ self.aia_params = QWidget()
441
+ aia_layout = QVBoxLayout()
442
+
443
+ # Wavelength selection
444
+ wavelength_layout = QHBoxLayout()
445
+ wavelength_layout.addWidget(QLabel("Wavelength:"))
446
+ self.wavelength_combo = QComboBox()
447
+ self.wavelength_combo.addItems(
448
+ [
449
+ "94 Å (Fe XVIII, hot)",
450
+ "131 Å (Fe VIII/XXI)",
451
+ "171 Å (Fe IX, corona)",
452
+ "193 Å (Fe XII/XXIV)",
453
+ "211 Å (Fe XIV)",
454
+ "304 Å (He II)",
455
+ "335 Å (Fe XVI)",
456
+ "1600 Å (C IV, UV)",
457
+ "1700 Å (UV continuum)",
458
+ "4500 Å (Visible)",
459
+ ]
460
+ )
461
+ self.wavelength_combo.currentIndexChanged.connect(self.on_aia_wavelength_changed)
462
+ wavelength_layout.addWidget(self.wavelength_combo)
463
+ aia_layout.addLayout(wavelength_layout)
464
+
465
+ # Cadence selection (for DRMS only)
466
+ self.cadence_widget = QWidget()
467
+ cadence_layout = QHBoxLayout()
468
+ cadence_layout.setContentsMargins(0, 0, 0, 0)
469
+ cadence_layout.addWidget(QLabel("Cadence:"))
470
+ self.cadence_combo = QComboBox()
471
+ self.cadence_combo.addItems(["12s", "24s", "1h"])
472
+ cadence_layout.addWidget(self.cadence_combo)
473
+ self.cadence_widget.setLayout(cadence_layout)
474
+ aia_layout.addWidget(self.cadence_widget)
475
+
476
+ self.aia_params.setLayout(aia_layout)
477
+
478
+ # HMI parameters
479
+ self.hmi_params = QWidget()
480
+ hmi_layout = QVBoxLayout()
481
+
482
+ series_layout = QHBoxLayout()
483
+ series_layout.addWidget(QLabel("Series:"))
484
+ self.series_combo = QComboBox()
485
+ self.series_combo.addItems(
486
+ [
487
+ "45s (LOS magnetogram)",
488
+ "720s (LOS magnetogram, 12 min)",
489
+ "B_45s (LOS magnetogram)",
490
+ "B_720s (LOS magnetogram, 12 min)",
491
+ "Ic_45s (Continuum intensity)",
492
+ "Ic_720s (Continuum intensity, 12 min)",
493
+ "V_45s (LOS velocity)",
494
+ "V_720s (LOS velocity, 12 min)",
495
+ ]
496
+ )
497
+ series_layout.addWidget(self.series_combo)
498
+ hmi_layout.addLayout(series_layout)
499
+
500
+ self.hmi_params.setLayout(hmi_layout)
501
+
502
+ # IRIS parameters
503
+ self.iris_params = QWidget()
504
+ iris_layout = QVBoxLayout()
505
+
506
+ obs_type_layout = QHBoxLayout()
507
+ obs_type_layout.addWidget(QLabel("Observation Type:"))
508
+ self.obs_type_combo = QComboBox()
509
+ self.obs_type_combo.addItems(["SJI (Slit-Jaw)", "Raster (Spectrograph)"])
510
+ obs_type_layout.addWidget(self.obs_type_combo)
511
+ iris_layout.addLayout(obs_type_layout)
512
+
513
+ iris_wavelength_layout = QHBoxLayout()
514
+ iris_wavelength_layout.addWidget(QLabel("Wavelength:"))
515
+ self.iris_wavelength_combo = QComboBox()
516
+ self.iris_wavelength_combo.addItems(
517
+ [
518
+ "1330 Å (C II)",
519
+ "1400 Å (Si IV)",
520
+ "2796 Å (Mg II k)",
521
+ "2832 Å (Photosphere)",
522
+ ]
523
+ )
524
+ iris_wavelength_layout.addWidget(self.iris_wavelength_combo)
525
+ iris_layout.addLayout(iris_wavelength_layout)
526
+
527
+ self.iris_params.setLayout(iris_layout)
528
+
529
+ # SOHO parameters
530
+ self.soho_params = QWidget()
531
+ soho_layout = QVBoxLayout()
532
+
533
+ soho_instrument_layout = QHBoxLayout()
534
+ soho_instrument_layout.addWidget(QLabel("SOHO Instrument:"))
535
+ self.soho_instrument_combo = QComboBox()
536
+ self.soho_instrument_combo.addItems(["EIT", "LASCO", "MDI"])
537
+ self.soho_instrument_combo.currentIndexChanged.connect(
538
+ self.on_soho_instrument_changed
539
+ )
540
+ soho_instrument_layout.addWidget(self.soho_instrument_combo)
541
+ soho_layout.addLayout(soho_instrument_layout)
542
+
543
+ # SOHO EIT wavelength
544
+ self.soho_eit_params = QWidget()
545
+ eit_layout = QHBoxLayout()
546
+ eit_layout.setContentsMargins(0, 0, 0, 0)
547
+ eit_layout.addWidget(QLabel("Wavelength:"))
548
+ self.eit_wavelength_combo = QComboBox()
549
+ self.eit_wavelength_combo.addItems(
550
+ ["171 Å (Fe IX/X)", "195 Å (Fe XII)", "284 Å (Fe XV)", "304 Å (He II)"]
551
+ )
552
+ eit_layout.addWidget(self.eit_wavelength_combo)
553
+ self.soho_eit_params.setLayout(eit_layout)
554
+
555
+ # SOHO LASCO detector
556
+ self.soho_lasco_params = QWidget()
557
+ lasco_layout = QHBoxLayout()
558
+ lasco_layout.setContentsMargins(0, 0, 0, 0)
559
+ lasco_layout.addWidget(QLabel("Detector:"))
560
+ self.lasco_detector_combo = QComboBox()
561
+ self.lasco_detector_combo.addItems(["C2 (2-6 Rs)", "C3 (3.7-30 Rs)"])
562
+ lasco_layout.addWidget(self.lasco_detector_combo)
563
+ self.soho_lasco_params.setLayout(lasco_layout)
564
+
565
+ soho_layout.addWidget(self.soho_eit_params)
566
+ soho_layout.addWidget(self.soho_lasco_params)
567
+ self.soho_params.setLayout(soho_layout)
568
+
569
+ # GOES SUVI parameters
570
+ self.suvi_params = QWidget()
571
+ suvi_layout = QVBoxLayout()
572
+
573
+ suvi_wavelength_layout = QHBoxLayout()
574
+ suvi_wavelength_layout.addWidget(QLabel("Wavelength:"))
575
+ self.suvi_wavelength_combo = QComboBox()
576
+ self.suvi_wavelength_combo.addItems(
577
+ [
578
+ "94 Å (Fe XVIII)",
579
+ "131 Å (Fe VIII/XXI)",
580
+ "171 Å (Fe IX)",
581
+ "195 Å (Fe XII)",
582
+ "284 Å (Fe XV)",
583
+ "304 Å (He II)",
584
+ ]
585
+ )
586
+ suvi_wavelength_layout.addWidget(self.suvi_wavelength_combo)
587
+ suvi_layout.addLayout(suvi_wavelength_layout)
588
+
589
+ suvi_level_layout = QHBoxLayout()
590
+ suvi_level_layout.addWidget(QLabel("Data Level:"))
591
+ self.suvi_level_combo = QComboBox()
592
+ self.suvi_level_combo.addItems(["Level 2 (calibrated)", "Level 1b (raw)"])
593
+ suvi_level_layout.addWidget(self.suvi_level_combo)
594
+ suvi_layout.addLayout(suvi_level_layout)
595
+
596
+ self.suvi_params.setLayout(suvi_layout)
597
+
598
+ # STEREO parameters
599
+ self.stereo_params = QWidget()
600
+ stereo_layout = QVBoxLayout()
601
+
602
+ stereo_sc_layout = QHBoxLayout()
603
+ stereo_sc_layout.addWidget(QLabel("Spacecraft:"))
604
+ self.stereo_sc_combo = QComboBox()
605
+ self.stereo_sc_combo.addItems(["STEREO-A", "STEREO-B (pre-2014 only)"])
606
+ stereo_sc_layout.addWidget(self.stereo_sc_combo)
607
+ stereo_layout.addLayout(stereo_sc_layout)
608
+
609
+ stereo_inst_layout = QHBoxLayout()
610
+ stereo_inst_layout.addWidget(QLabel("Instrument:"))
611
+ self.stereo_inst_combo = QComboBox()
612
+ self.stereo_inst_combo.addItems(
613
+ ["EUVI (EUV Imager)", "COR1 (Inner coronagraph)", "COR2 (Outer coronagraph)"]
614
+ )
615
+ self.stereo_inst_combo.currentIndexChanged.connect(self.on_stereo_instrument_changed)
616
+ stereo_inst_layout.addWidget(self.stereo_inst_combo)
617
+ stereo_layout.addLayout(stereo_inst_layout)
618
+
619
+ # STEREO EUVI wavelength
620
+ self.stereo_euvi_params = QWidget()
621
+ euvi_layout = QHBoxLayout()
622
+ euvi_layout.setContentsMargins(0, 0, 0, 0)
623
+ euvi_layout.addWidget(QLabel("Wavelength:"))
624
+ self.stereo_wavelength_combo = QComboBox()
625
+ self.stereo_wavelength_combo.addItems(
626
+ ["171 Å", "195 Å", "284 Å", "304 Å"]
627
+ )
628
+ euvi_layout.addWidget(self.stereo_wavelength_combo)
629
+ self.stereo_euvi_params.setLayout(euvi_layout)
630
+ stereo_layout.addWidget(self.stereo_euvi_params)
631
+
632
+ self.stereo_params.setLayout(stereo_layout)
633
+
634
+ # GONG parameters (minimal - just magnetograms)
635
+ self.gong_params = QWidget()
636
+ gong_layout = QVBoxLayout()
637
+ gong_label = QLabel("GONG provides magnetogram data.\nNo additional parameters needed.")
638
+ gong_label.setStyleSheet("color: gray; font-style: italic;")
639
+ gong_layout.addWidget(gong_label)
640
+ self.gong_params.setLayout(gong_layout)
641
+
642
+ # Add all parameter widgets to the group
643
+ self.param_layout.addWidget(self.aia_params)
644
+ self.param_layout.addWidget(self.hmi_params)
645
+ self.param_layout.addWidget(self.iris_params)
646
+ self.param_layout.addWidget(self.soho_params)
647
+ self.param_layout.addWidget(self.suvi_params)
648
+ self.param_layout.addWidget(self.stereo_params)
649
+ self.param_layout.addWidget(self.gong_params)
650
+
651
+ self.param_group.setLayout(self.param_layout)
652
+ self.layout.addWidget(self.param_group)
653
+
654
+ # Hide all except AIA initially
655
+ self.hmi_params.hide()
656
+ self.iris_params.hide()
657
+ self.soho_params.hide()
658
+ self.soho_eit_params.hide()
659
+ self.soho_lasco_params.hide()
660
+ self.suvi_params.hide()
661
+ self.stereo_params.hide()
662
+ self.gong_params.hide()
663
+
664
+ def create_time_selection(self):
665
+ """Create the time range selection section."""
666
+ group = QGroupBox("Time Range")
667
+ layout = QVBoxLayout()
668
+
669
+ # Start time
670
+ start_layout = QHBoxLayout()
671
+ start_layout.addWidget(QLabel("Start:"))
672
+ self.start_datetime = QDateTimeEdit()
673
+ self.start_datetime.setCalendarPopup(True)
674
+ self.start_datetime.setDisplayFormat("yyyy.MM.dd HH:mm:ss")
675
+
676
+ # Use initial_datetime if provided, otherwise current time
677
+ if self.initial_datetime:
678
+ from PyQt5.QtCore import QDateTime
679
+ self.start_datetime.setDateTime(QDateTime(self.initial_datetime))
680
+ else:
681
+ self.start_datetime.setDateTime(QDateTime.currentDateTime())
682
+
683
+ # Connect to sync end time when start time changes
684
+ self.start_datetime.dateTimeChanged.connect(self.on_start_datetime_changed)
685
+ start_layout.addWidget(self.start_datetime)
686
+ layout.addLayout(start_layout)
687
+
688
+ # End time
689
+ end_layout = QHBoxLayout()
690
+ end_layout.addWidget(QLabel("End:"))
691
+ self.end_datetime = QDateTimeEdit()
692
+ self.end_datetime.setCalendarPopup(True)
693
+ self.end_datetime.setDisplayFormat("yyyy.MM.dd HH:mm:ss")
694
+
695
+ # Use initial_datetime + 1 hour if provided, otherwise current time + 1 hour
696
+ if self.initial_datetime:
697
+ from datetime import timedelta
698
+ from PyQt5.QtCore import QDateTime
699
+ end_dt = self.initial_datetime + timedelta(hours=1)
700
+ self.end_datetime.setDateTime(QDateTime(end_dt))
701
+ else:
702
+ self.end_datetime.setDateTime(
703
+ QDateTime.currentDateTime().addSecs(3600) # Default to 1 hour later
704
+ )
705
+ end_layout.addWidget(self.end_datetime)
706
+ layout.addLayout(end_layout)
707
+
708
+ # Cadence info label
709
+ self.cadence_label = QLabel()
710
+ self.cadence_label.setStyleSheet("color: #888; font-style: italic;")
711
+ self.cadence_label.setWordWrap(True)
712
+ self.update_cadence_info() # Set initial value
713
+ layout.addWidget(self.cadence_label)
714
+
715
+ group.setLayout(layout)
716
+ self.layout.addWidget(group)
717
+
718
+ def on_start_datetime_changed(self, new_datetime):
719
+ """Sync end time when start time is changed (keep 1 hour difference)."""
720
+ # Set end time to start time + 1 hour
721
+ end_dt = new_datetime.addSecs(3600)
722
+ self.end_datetime.setDateTime(end_dt)
723
+
724
+ def create_output_selection(self):
725
+ """Create the output directory selection section."""
726
+ group = QGroupBox("Output Settings")
727
+ layout = QVBoxLayout()
728
+
729
+ # Output directory
730
+ dir_layout = QHBoxLayout()
731
+ dir_layout.addWidget(QLabel("Output Directory:"))
732
+ self.output_dir = QLineEdit()
733
+ self.output_dir.setText(os.path.join(os.getcwd(), "solar_data"))
734
+ dir_layout.addWidget(self.output_dir)
735
+
736
+ browse_button = QPushButton("Browse...")
737
+ browse_button.clicked.connect(self.browse_output_dir)
738
+ dir_layout.addWidget(browse_button)
739
+ layout.addLayout(dir_layout)
740
+
741
+ # Download method section
742
+ self.method_widget = QWidget()
743
+ method_layout = QHBoxLayout()
744
+ method_layout.setContentsMargins(0, 0, 0, 0)
745
+ method_layout.addWidget(QLabel("Download Method:"))
746
+ self.method_group = QButtonGroup()
747
+
748
+ self.fido_radio = QRadioButton("Fido (recommended)")
749
+ self.fido_radio.setChecked(True) # Fido is now default
750
+ self.method_group.addButton(self.fido_radio, 1)
751
+ method_layout.addWidget(self.fido_radio)
752
+
753
+ self.drms_radio = QRadioButton("DRMS")
754
+ self.method_group.addButton(self.drms_radio, 0)
755
+ method_layout.addWidget(self.drms_radio)
756
+
757
+ self.method_group.buttonClicked.connect(self.on_method_changed)
758
+ self.method_widget.setLayout(method_layout)
759
+ layout.addWidget(self.method_widget)
760
+
761
+ # Email for DRMS (hidden by default since Fido is default)
762
+ self.email_widget = QWidget()
763
+ email_layout = QHBoxLayout()
764
+ email_layout.setContentsMargins(0, 0, 0, 0)
765
+ email_layout.addWidget(QLabel("Email (for DRMS):"))
766
+ self.email_input = QLineEdit()
767
+ self.email_input.setPlaceholderText("Required for DRMS downloads")
768
+ email_layout.addWidget(self.email_input)
769
+ self.email_widget.setLayout(email_layout)
770
+ self.email_widget.hide() # Hidden by default
771
+ layout.addWidget(self.email_widget)
772
+
773
+ group.setLayout(layout)
774
+ self.layout.addWidget(group)
775
+
776
+ def create_calibration_options(self):
777
+ """Create the calibration options section."""
778
+ group = QGroupBox("Calibration Options")
779
+ layout = QVBoxLayout()
780
+
781
+ # Main calibration checkbox
782
+ self.calibrate_checkbox = QCheckBox("Apply Level 1.5 Calibration")
783
+ self.calibrate_checkbox.setChecked(True)
784
+ self.calibrate_checkbox.setToolTip(
785
+ "Apply standard calibration (recommended for scientific use)\n"
786
+ "Includes: pointing correction, rotation, scaling, centering"
787
+ )
788
+ self.calibrate_checkbox.stateChanged.connect(self.on_calibration_changed)
789
+ layout.addWidget(self.calibrate_checkbox)
790
+
791
+ # Advanced options container
792
+ self.calib_options_widget = QWidget()
793
+ calib_layout = QVBoxLayout()
794
+ calib_layout.setContentsMargins(20, 0, 0, 0) # Indent
795
+
796
+ # AIA-specific options
797
+ self.aia_calib_label = QLabel("AIA Advanced Options:")
798
+ self.aia_calib_label.setStyleSheet("font-weight: bold;")
799
+ calib_layout.addWidget(self.aia_calib_label)
800
+
801
+ self.psf_checkbox = QCheckBox("PSF Deconvolution")
802
+ self.psf_checkbox.setChecked(False) # Off by default (slow)
803
+ self.psf_checkbox.setToolTip(
804
+ "Apply Point Spread Function deconvolution to sharpen images.\n"
805
+ "⚠️ WARNING: This is VERY SLOW (~30-60 seconds per image)\n"
806
+ "Uses Richardson-Lucy algorithm with 25 iterations.\n"
807
+ "Downloads PSF data from JSOC on first use."
808
+ )
809
+ calib_layout.addWidget(self.psf_checkbox)
810
+
811
+ # Warning label for PSF
812
+ self.psf_warning = QLabel("⚠️ PSF deconvolution might take a while")
813
+ self.psf_warning.setStyleSheet("color: orange; font-size: 11px;")
814
+ self.psf_warning.hide()
815
+ self.psf_checkbox.stateChanged.connect(
816
+ lambda state: self.psf_warning.setVisible(state == 2)
817
+ )
818
+ calib_layout.addWidget(self.psf_warning)
819
+
820
+ self.degradation_checkbox = QCheckBox("Degradation Correction")
821
+ self.degradation_checkbox.setChecked(True)
822
+ self.degradation_checkbox.setToolTip(
823
+ "Apply time-dependent degradation correction.\n"
824
+ "Compensates for instrument sensitivity changes over time."
825
+ )
826
+ calib_layout.addWidget(self.degradation_checkbox)
827
+
828
+ self.exposure_norm_checkbox = QCheckBox("Exposure Time Normalization")
829
+ self.exposure_norm_checkbox.setChecked(True)
830
+ self.exposure_norm_checkbox.setToolTip(
831
+ "Normalize data by exposure time.\n"
832
+ "Converts DN to DN/s for consistent comparison."
833
+ )
834
+ calib_layout.addWidget(self.exposure_norm_checkbox)
835
+
836
+ self.calib_options_widget.setLayout(calib_layout)
837
+ layout.addWidget(self.calib_options_widget)
838
+
839
+ group.setLayout(layout)
840
+ self.layout.addWidget(group)
841
+
842
+ def on_calibration_changed(self, state):
843
+ """Handle calibration checkbox state changes."""
844
+ self.calib_options_widget.setVisible(state == 2)
845
+
846
+ def create_download_section(self):
847
+ """Create the download button and progress section."""
848
+ # Progress bar
849
+ self.progress_bar = QProgressBar()
850
+ self.progress_bar.setTextVisible(True)
851
+ self.progress_bar.hide()
852
+ self.layout.addWidget(self.progress_bar)
853
+
854
+ # Status label
855
+ self.status_label = QLabel()
856
+ self.status_label.setWordWrap(True)
857
+ self.layout.addWidget(self.status_label)
858
+
859
+ # Download button
860
+ self.download_button = QPushButton("Download")
861
+ self.download_button.clicked.connect(self.start_download)
862
+ self.layout.addWidget(self.download_button)
863
+
864
+ def browse_output_dir(self):
865
+ """Open a directory selection dialog."""
866
+ dir_path = QFileDialog.getExistingDirectory(
867
+ self,
868
+ "Select Output Directory",
869
+ self.output_dir.text(),
870
+ QFileDialog.ShowDirsOnly,
871
+ )
872
+ if dir_path:
873
+ self.output_dir.setText(dir_path)
874
+
875
+ def get_instrument_name(self, index):
876
+ """Get short instrument name from index."""
877
+ names = ["AIA", "HMI", "IRIS", "SOHO", "GOES SUVI", "STEREO", "GONG"]
878
+ return names[index] if index < len(names) else "Unknown"
879
+
880
+ def on_instrument_changed(self, index):
881
+ """Handle instrument selection changes."""
882
+ # Hide all parameter widgets
883
+ self.aia_params.hide()
884
+ self.hmi_params.hide()
885
+ self.iris_params.hide()
886
+ self.soho_params.hide()
887
+ self.suvi_params.hide()
888
+ self.stereo_params.hide()
889
+ self.gong_params.hide()
890
+
891
+ # Show the selected instrument's parameters
892
+ if index == 0: # AIA
893
+ self.aia_params.show()
894
+ elif index == 1: # HMI
895
+ self.hmi_params.show()
896
+ elif index == 2: # IRIS
897
+ self.iris_params.show()
898
+ elif index == 3: # SOHO
899
+ self.soho_params.show()
900
+ self.on_soho_instrument_changed(self.soho_instrument_combo.currentIndex())
901
+ elif index == 4: # GOES SUVI
902
+ self.suvi_params.show()
903
+ elif index == 5: # STEREO
904
+ self.stereo_params.show()
905
+ self.on_stereo_instrument_changed(self.stereo_inst_combo.currentIndex())
906
+ elif index == 6: # GONG
907
+ self.gong_params.show()
908
+
909
+ # Handle Fido-only instruments
910
+ instrument_name = self.get_instrument_name(index)
911
+ if instrument_name in FIDO_ONLY_INSTRUMENTS:
912
+ # Force Fido and hide method selection
913
+ self.fido_radio.setChecked(True)
914
+ self.method_widget.hide()
915
+ self.email_widget.hide()
916
+ self.cadence_widget.hide()
917
+ else:
918
+ # Show method selection for AIA/HMI
919
+ self.method_widget.show()
920
+ self.on_method_changed()
921
+
922
+ # Show AIA-specific calibration options only for AIA
923
+ if index == 0: # AIA
924
+ self.aia_calib_label.show()
925
+ self.psf_checkbox.show()
926
+ self.psf_warning.setVisible(self.psf_checkbox.isChecked())
927
+ self.degradation_checkbox.show()
928
+ self.exposure_norm_checkbox.show()
929
+ else:
930
+ self.aia_calib_label.hide()
931
+ self.psf_checkbox.hide()
932
+ self.psf_warning.hide()
933
+ self.degradation_checkbox.hide()
934
+ self.exposure_norm_checkbox.hide()
935
+
936
+ # Update cadence info for the selected instrument
937
+ self.update_cadence_info()
938
+
939
+ def on_method_changed(self, button=None):
940
+ """Handle download method changes."""
941
+ use_fido = self.method_group.checkedId() == 1
942
+
943
+ if use_fido:
944
+ self.email_widget.hide()
945
+ self.cadence_widget.hide() # Fido doesn't use cadence
946
+ else:
947
+ self.email_widget.show()
948
+ # Show cadence only for AIA with DRMS
949
+ if self.instrument_combo.currentIndex() == 0:
950
+ self.cadence_widget.show()
951
+ self.on_aia_wavelength_changed() # Update cadence based on wavelength
952
+
953
+ def on_aia_wavelength_changed(self, index=None):
954
+ """Auto-select cadence based on AIA wavelength."""
955
+ wavelength_text = self.wavelength_combo.currentText()
956
+
957
+ if "1600" in wavelength_text or "1700" in wavelength_text:
958
+ self.cadence_combo.setCurrentIndex(1) # 24s
959
+ elif "4500" in wavelength_text:
960
+ self.cadence_combo.setCurrentIndex(2) # 1h
961
+ else:
962
+ self.cadence_combo.setCurrentIndex(0) # 12s
963
+
964
+ self.update_cadence_info()
965
+
966
+ def on_soho_instrument_changed(self, index):
967
+ """Handle SOHO instrument selection changes."""
968
+ self.soho_eit_params.hide()
969
+ self.soho_lasco_params.hide()
970
+
971
+ if index == 0: # EIT
972
+ self.soho_eit_params.show()
973
+ elif index == 1: # LASCO
974
+ self.soho_lasco_params.show()
975
+
976
+ self.update_cadence_info()
977
+
978
+ def on_stereo_instrument_changed(self, index):
979
+ """Handle STEREO instrument selection changes."""
980
+ if index == 0: # EUVI
981
+ self.stereo_euvi_params.show()
982
+ else: # COR1/COR2
983
+ self.stereo_euvi_params.hide()
984
+ self.update_cadence_info()
985
+
986
+ def update_cadence_info(self):
987
+ """Update the cadence info label based on selected instrument and parameters."""
988
+ index = self.instrument_combo.currentIndex()
989
+
990
+ # Cadence info for each instrument
991
+ cadence_info = {
992
+ 0: { # AIA
993
+ "default": "12s (EUV), 24s (UV), 1h (4500Å)",
994
+ "wavelengths": {
995
+ "94": "12s", "131": "12s", "171": "12s", "193": "12s",
996
+ "211": "12s", "304": "12s", "335": "12s",
997
+ "1600": "24s", "1700": "24s", "4500": "1 hour"
998
+ }
999
+ },
1000
+ 1: { # HMI
1001
+ "default": "45s (magnetogram), 45s (continuum), 45s (Doppler)"
1002
+ },
1003
+ 2: { # IRIS
1004
+ "default": "Variable (depends on observing program, typically minutes)"
1005
+ },
1006
+ 3: { # SOHO
1007
+ "EIT": "~12 min (synoptic), 1-6 min (campaign)",
1008
+ "LASCO": "~12-30 min (C2/C3)",
1009
+ "MDI": "1 min (discontinued 2011)"
1010
+ },
1011
+ 4: { # GOES SUVI
1012
+ "default": "4 min (Level 2 composites)"
1013
+ },
1014
+ 5: { # STEREO
1015
+ "EUVI": "2.5-10 min (wavelength dependent)",
1016
+ "COR1": "5 min",
1017
+ "COR2": "15-30 min"
1018
+ },
1019
+ 6: { # GONG
1020
+ "default": "1 min (magnetograms)"
1021
+ }
1022
+ }
1023
+
1024
+ if index == 0: # AIA - show wavelength-specific cadence
1025
+ wl = self.wavelength_combo.currentText().split()[0]
1026
+ wl_cadences = cadence_info[0]["wavelengths"]
1027
+ cadence = wl_cadences.get(wl, "12s")
1028
+ self.cadence_label.setText(f"ℹ️ Typical cadence: {cadence}")
1029
+ elif index == 3: # SOHO - depends on sub-instrument
1030
+ soho_inst = self.soho_instrument_combo.currentText()
1031
+ cadence = cadence_info[3].get(soho_inst, "Variable")
1032
+ self.cadence_label.setText(f"ℹ️ Typical cadence: {cadence}")
1033
+ elif index == 5: # STEREO - depends on sub-instrument
1034
+ stereo_inst = self.stereo_inst_combo.currentText()
1035
+ if "EUVI" in stereo_inst:
1036
+ cadence = cadence_info[5]["EUVI"]
1037
+ elif "COR1" in stereo_inst:
1038
+ cadence = cadence_info[5]["COR1"]
1039
+ else:
1040
+ cadence = cadence_info[5]["COR2"]
1041
+ self.cadence_label.setText(f"ℹ️ Typical cadence: {cadence}")
1042
+ else:
1043
+ cadence = cadence_info.get(index, {}).get("default", "Variable")
1044
+ self.cadence_label.setText(f"ℹ️ Typical cadence: {cadence}")
1045
+
1046
+
1047
+ def get_download_parameters(self) -> dict:
1048
+ """Gather all parameters needed for the download."""
1049
+ instrument_index = self.instrument_combo.currentIndex()
1050
+ start_time = self.start_datetime.dateTime().toString("yyyy.MM.dd HH:mm:ss")
1051
+ end_time = self.end_datetime.dateTime().toString("yyyy.MM.dd HH:mm:ss")
1052
+ output_dir = self.output_dir.text()
1053
+ use_fido = self.method_group.checkedId() == 1
1054
+ email = self.email_input.text() if not use_fido else None
1055
+
1056
+ params = {
1057
+ "start_time": start_time,
1058
+ "end_time": end_time,
1059
+ "output_dir": output_dir,
1060
+ "use_fido": use_fido,
1061
+ "email": email,
1062
+ # Calibration options
1063
+ "skip_calibration": not self.calibrate_checkbox.isChecked(),
1064
+ "apply_psf": self.psf_checkbox.isChecked(),
1065
+ "apply_degradation": self.degradation_checkbox.isChecked(),
1066
+ "apply_exposure_norm": self.exposure_norm_checkbox.isChecked(),
1067
+ }
1068
+
1069
+ if instrument_index == 0: # AIA
1070
+ params.update(
1071
+ {
1072
+ "instrument": "AIA",
1073
+ "wavelength": self.wavelength_combo.currentText().split()[0],
1074
+ "cadence": self.cadence_combo.currentText(),
1075
+ }
1076
+ )
1077
+ elif instrument_index == 1: # HMI
1078
+ params.update(
1079
+ {
1080
+ "instrument": "HMI",
1081
+ "series": self.series_combo.currentText().split()[0],
1082
+ }
1083
+ )
1084
+ elif instrument_index == 2: # IRIS
1085
+ obs_type = "SJI" if "SJI" in self.obs_type_combo.currentText() else "raster"
1086
+ params.update(
1087
+ {
1088
+ "instrument": "IRIS",
1089
+ "obs_type": obs_type,
1090
+ "wavelength": self.iris_wavelength_combo.currentText().split()[0],
1091
+ }
1092
+ )
1093
+ elif instrument_index == 3: # SOHO
1094
+ soho_instrument = self.soho_instrument_combo.currentText()
1095
+ params.update({"instrument": "SOHO", "soho_instrument": soho_instrument})
1096
+
1097
+ if soho_instrument == "EIT":
1098
+ params["wavelength"] = self.eit_wavelength_combo.currentText().split()[0]
1099
+ elif soho_instrument == "LASCO":
1100
+ params["detector"] = self.lasco_detector_combo.currentText().split()[0]
1101
+
1102
+ elif instrument_index == 4: # GOES SUVI
1103
+ params.update(
1104
+ {
1105
+ "instrument": "GOES SUVI",
1106
+ "wavelength": self.suvi_wavelength_combo.currentText().split()[0],
1107
+ "level": "2" if "Level 2" in self.suvi_level_combo.currentText() else "1b",
1108
+ }
1109
+ )
1110
+ elif instrument_index == 5: # STEREO
1111
+ spacecraft = "A" if "STEREO-A" in self.stereo_sc_combo.currentText() else "B"
1112
+ stereo_inst_text = self.stereo_inst_combo.currentText()
1113
+ if "EUVI" in stereo_inst_text:
1114
+ stereo_inst = "EUVI"
1115
+ elif "COR1" in stereo_inst_text:
1116
+ stereo_inst = "COR1"
1117
+ else:
1118
+ stereo_inst = "COR2"
1119
+
1120
+ params.update(
1121
+ {
1122
+ "instrument": "STEREO",
1123
+ "spacecraft": spacecraft,
1124
+ "stereo_instrument": stereo_inst,
1125
+ }
1126
+ )
1127
+ if stereo_inst == "EUVI":
1128
+ params["wavelength"] = self.stereo_wavelength_combo.currentText().split()[0]
1129
+
1130
+ elif instrument_index == 6: # GONG
1131
+ params.update({"instrument": "GONG"})
1132
+
1133
+ return params
1134
+
1135
+ def start_download(self):
1136
+ """Start the download process."""
1137
+ try:
1138
+ # Create output directory if it doesn't exist
1139
+ Path(self.output_dir.text()).mkdir(parents=True, exist_ok=True)
1140
+
1141
+ # Disable the download button and show progress
1142
+ self.download_button.setEnabled(False)
1143
+ self.progress_bar.setMaximum(0) # Indeterminate progress
1144
+ self.progress_bar.show()
1145
+ self.status_label.setText("Preparing download...")
1146
+
1147
+ # Create and start the download worker
1148
+ params = self.get_download_parameters()
1149
+ self.download_worker = DownloadWorker(params)
1150
+ self.download_worker.progress.connect(self.update_progress)
1151
+ self.download_worker.finished.connect(self.download_finished)
1152
+ self.download_worker.error.connect(self.download_error)
1153
+ self.download_worker.start()
1154
+
1155
+ except Exception as e:
1156
+ QMessageBox.critical(self, "Error", f"Failed to start download: {str(e)}")
1157
+ self.download_button.setEnabled(True)
1158
+ self.progress_bar.hide()
1159
+
1160
+ def update_progress(self, message):
1161
+ """Update the progress display."""
1162
+ self.status_label.setText(message)
1163
+
1164
+ def download_finished(self, files):
1165
+ """Handle download completion."""
1166
+ self.download_button.setEnabled(True)
1167
+ self.progress_bar.hide()
1168
+
1169
+ if files:
1170
+ message = f"Download complete! Downloaded {len(files)} files to {self.output_dir.text()}"
1171
+ self.status_label.setText(message)
1172
+ QMessageBox.information(self, "Success", message)
1173
+ else:
1174
+ QMessageBox.warning(self, "Warning", "No files were downloaded.")
1175
+
1176
+ def download_error(self, error_message):
1177
+ """Handle download errors."""
1178
+ self.download_button.setEnabled(True)
1179
+ self.progress_bar.hide()
1180
+ self.status_label.setText(f"Error: {error_message}")
1181
+ QMessageBox.critical(self, "Error", f"Download failed: {error_message}")
1182
+
1183
+
1184
+ def launch_gui(parent=None, initial_datetime=None) -> SolarDataViewerGUI:
1185
+ """
1186
+ Launch the Solar Data Viewer GUI.
1187
+
1188
+ Args:
1189
+ parent: Optional parent widget for integration with other PyQt applications
1190
+ initial_datetime: Optional datetime to initialize the time selectors
1191
+
1192
+ Returns:
1193
+ SolarDataViewerGUI: The main window instance
1194
+ """
1195
+ if not QApplication.instance():
1196
+ app = QApplication(sys.argv)
1197
+ else:
1198
+ app = QApplication.instance()
1199
+
1200
+ window = SolarDataViewerGUI(parent, initial_datetime=initial_datetime)
1201
+ window.show()
1202
+
1203
+ if parent is None:
1204
+ sys.exit(app.exec_())
1205
+
1206
+ return window
1207
+
1208
+
1209
+ if __name__ == "__main__":
1210
+ launch_gui()