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,1922 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Solar Activity Viewer GUI - Comprehensive solar context data display.
4
+ """
5
+
6
+ import sys
7
+ import os
8
+ import re
9
+ from datetime import datetime, date
10
+ from typing import Optional, List
11
+
12
+ # Qt imports
13
+ try:
14
+ from PyQt5.QtWidgets import (
15
+ QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
16
+ QLabel, QPushButton, QDateEdit, QTableWidget, QTableWidgetItem,
17
+ QHeaderView, QGroupBox, QSplitter, QFrame, QScrollArea,
18
+ QSizePolicy, QMessageBox, QProgressBar, QDialog, QTextBrowser,
19
+ QTabWidget
20
+ )
21
+ from PyQt5.QtCore import Qt, QDate, QThread, pyqtSignal, QUrl, QSize
22
+ from PyQt5.QtGui import QFont, QColor, QPalette, QIcon, QPixmap
23
+ from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkRequest, QNetworkReply
24
+ except ImportError:
25
+ print("PyQt5 is required. Install with: pip install PyQt5")
26
+ sys.exit(1)
27
+
28
+ try:
29
+ # Try relative imports (when run as module)
30
+ from . import noaa_events as ne
31
+ from ..styles import theme_manager
32
+ except ImportError:
33
+ # Fallback for standalone execution
34
+ # Add project root to path to allow absolute imports
35
+ import sys
36
+ import os
37
+ current_dir = os.path.dirname(os.path.abspath(__file__))
38
+ project_root = os.path.abspath(os.path.join(current_dir, "../../"))
39
+ if project_root not in sys.path:
40
+ sys.path.insert(0, project_root)
41
+
42
+ from solar_radio_image_viewer.noaa_events import noaa_events as ne
43
+ from solar_radio_image_viewer.styles import theme_manager
44
+ import requests
45
+
46
+ class ClickableLabel(QLabel):
47
+ """QLabel that emits a clicked signal."""
48
+ clicked = pyqtSignal()
49
+ def mouseReleaseEvent(self, event):
50
+ self.clicked.emit()
51
+
52
+ class FullImageViewer(QDialog):
53
+ """Dialog to view high-resolution image."""
54
+ def __init__(self, parent, title, page_url):
55
+ super().__init__(parent)
56
+ self.setWindowTitle(f"{title} - High Resolution")
57
+ self.setWindowFlags(Qt.Window | Qt.WindowMinMaxButtonsHint | Qt.WindowCloseButtonHint)
58
+ self.resize(1920, 1080)
59
+ self.page_url = page_url
60
+
61
+ layout = QVBoxLayout(self)
62
+
63
+ self.scroll = QScrollArea()
64
+ self.scroll.setWidgetResizable(True) # Start resizable, maybe set False when huge image loads?
65
+ self.scroll.setStyleSheet("background-color: #222;")
66
+
67
+ self.img_label = QLabel("Resolving high-resolution image URL...")
68
+ self.img_label.setAlignment(Qt.AlignCenter)
69
+ self.img_label.setStyleSheet("color: #ccc; font-weight: bold;")
70
+
71
+ self.scroll.setWidget(self.img_label)
72
+ layout.addWidget(self.scroll)
73
+
74
+ # Close btn
75
+ btn_layout = QHBoxLayout()
76
+ btn_layout.addStretch()
77
+ close = QPushButton("Close")
78
+ close.clicked.connect(self.accept)
79
+ btn_layout.addWidget(close)
80
+ layout.addLayout(btn_layout)
81
+
82
+ # Start Resolve
83
+ self.resolve_url()
84
+
85
+ def resolve_url(self):
86
+ self.resolver = ImageUrlResolver(self.page_url)
87
+ self.resolver.found.connect(self.on_url_found)
88
+ self.resolver.start()
89
+
90
+ def on_url_found(self, full_url):
91
+ try:
92
+ if not self.isVisible() and not self.parent(): return
93
+
94
+ if not full_url:
95
+ self.img_label.setText("Failed to resolve high-res image.")
96
+ return
97
+
98
+ self.img_label.setText("Loading... Please wait")
99
+
100
+ # Download
101
+ self.downloader = ImageLoader(full_url)
102
+ self.downloader.loaded.connect(self.on_image_loaded)
103
+ self.downloader.error.connect(lambda e: self.img_label.setText(f"Error: {e}") if self.isVisible() else None)
104
+ self.downloader.start()
105
+ except RuntimeError:
106
+ pass
107
+
108
+ def on_image_loaded(self, data):
109
+ try:
110
+ if not self.isVisible(): return
111
+
112
+ pixmap = QPixmap()
113
+ if pixmap.loadFromData(data):
114
+ self.img_label.setPixmap(pixmap)
115
+ self.img_label.adjustSize()
116
+ # If huge, maybe enable scrollbars
117
+ self.scroll.setWidgetResizable(True) # If true, it shrinks image to fit? No, QLabel usually expands.
118
+ # To scroll, widgetResizable is complicated.
119
+ # If we want scroll, setWidgetResizable(False) implies widget dictates size.
120
+ if pixmap.width() > self.scroll.width() or pixmap.height() > self.scroll.height():
121
+ self.scroll.setWidgetResizable(False) # Let label be big
122
+ else:
123
+ self.scroll.setWidgetResizable(True) # Center it
124
+ else:
125
+ self.img_label.setText("Failed to load image data.")
126
+ except RuntimeError:
127
+ pass
128
+
129
+ class ImageUrlResolver(QThread):
130
+ found = pyqtSignal(str)
131
+ def __init__(self, page_url):
132
+ super().__init__()
133
+ self.url = page_url
134
+ def run(self):
135
+ from ..solar_context import context_images as ci
136
+ url = ci.resolve_full_image_url(self.url)
137
+ self.found.emit(url)
138
+
139
+
140
+ class ImageLoader(QThread):
141
+ """
142
+ Thread to download image data.
143
+ If page_url is provided, it tries to resolve the High-Res image first.
144
+ Otherwise (or if resolve fails), it falls back to the direct url (thumbnail).
145
+ """
146
+ loaded = pyqtSignal(bytes)
147
+ error = pyqtSignal(str)
148
+
149
+ def __init__(self, url, page_url=None):
150
+ super().__init__()
151
+ self.url = url
152
+ self.page_url = page_url
153
+
154
+ def run(self):
155
+ try:
156
+ target_url = self.url
157
+ # Try to resolve high-res if page_url available
158
+ # BUT: Skip for Helioviewer URLs (they're already direct image URLs)
159
+ if self.page_url and 'helioviewer.org' not in self.page_url:
160
+ try:
161
+ from ..solar_context import context_images as ci
162
+ resolved = ci.resolve_full_image_url(self.page_url)
163
+ if resolved:
164
+ target_url = resolved
165
+ except Exception as e:
166
+ print(f"Failed to resolve high-res: {e}")
167
+
168
+ import requests
169
+ response = requests.get(target_url, timeout=60, headers={"User-Agent": "Mozilla/5.0"})
170
+ if response.status_code == 200:
171
+ self.loaded.emit(response.content)
172
+ else:
173
+ self.error.emit(f"HTTP {response.status_code}")
174
+ except Exception as e:
175
+ self.error.emit(str(e))
176
+
177
+
178
+ class FetchWorker(QThread):
179
+ """Worker thread for fetching events, active regions, conditions, CMEs, and images."""
180
+ finished = pyqtSignal(object, object, object, object, object) # (events, active_regions, conditions, cmes, images) tuple
181
+ error = pyqtSignal(str)
182
+
183
+ def __init__(self, event_date: date):
184
+ super().__init__()
185
+ self.event_date = event_date
186
+
187
+
188
+ def run(self):
189
+ try:
190
+ # Fetch solar events
191
+ events = ne.fetch_and_parse_events(self.event_date)
192
+
193
+ # Fetch active regions
194
+ active_regions = None
195
+ try:
196
+ from ..solar_context import active_regions as ar
197
+ active_regions = ar.fetch_and_parse_active_regions(self.event_date)
198
+ except Exception as ar_err:
199
+ print(f"Active regions fetch failed: {ar_err}")
200
+
201
+ # Fetch solar conditions for the selected date
202
+ conditions = None
203
+ try:
204
+ from ..solar_context import realtime_data as rt
205
+ conditions = rt.fetch_conditions_for_date(self.event_date)
206
+ except Exception as cond_err:
207
+ print(f"Solar conditions fetch failed: {cond_err}")
208
+
209
+ # Fetch CME alerts
210
+ cmes = None
211
+ try:
212
+ from ..solar_context import cme_alerts as cme
213
+ cmes = cme.fetch_and_parse_cme_events(self.event_date)
214
+ except Exception as cme_err:
215
+ print(f"CME fetch failed: {cme_err}")
216
+
217
+ # Fetch Context Images URLs
218
+ images = []
219
+ try:
220
+ from ..solar_context import context_images as ci
221
+ images = ci.fetch_context_images(self.event_date)
222
+ except Exception as img_err:
223
+ print(f"Context images fetch failed: {img_err}")
224
+
225
+ self.finished.emit(events, active_regions, conditions, cmes, images)
226
+ except Exception as e:
227
+ self.error.emit(str(e))
228
+
229
+
230
+ class GOESPlotWorker(QThread):
231
+ """Worker thread for fetching and plotting GOES X-ray flux."""
232
+ finished = pyqtSignal(object)
233
+ error = pyqtSignal(str)
234
+
235
+ def __init__(self, event_date: date):
236
+ super().__init__()
237
+ self.event_date = event_date
238
+
239
+ def run(self):
240
+ try:
241
+ from sunpy.net import Fido, attrs as a
242
+ from sunpy.timeseries import TimeSeries
243
+ import matplotlib.pyplot as plt
244
+
245
+ # Define time range for the full day
246
+ t_start = datetime.combine(self.event_date, datetime.min.time())
247
+ t_end = datetime.combine(self.event_date, datetime.max.time())
248
+
249
+ # Search for GOES XRS data
250
+ # Use a.Resolution.flx1s (1-second data) if possible, or avg1m (1-minute)
251
+ # print(f"Searching for GOES data for {self.event_date}...")
252
+ res = Fido.search(a.Time(t_start, t_end), a.Instrument('GOES'))
253
+
254
+ if len(res) == 0:
255
+ raise Exception("No GOES X-ray data found for this date.")
256
+
257
+ # Filter results to get the "best" single file
258
+ # 1. Prefer High Cadence (flx1s) over Average (avg1m)
259
+
260
+ # Simple conversion to astropy table to sort/filter
261
+ # tbl = res[0]
262
+
263
+ # Searching for 'flx1s' first
264
+ res_high = Fido.search(a.Time(t_start, t_end), a.Instrument('GOES'), a.Resolution('flx1s'))
265
+
266
+ if len(res_high) > 0:
267
+ res = res_high
268
+ else:
269
+ pass # Fallback to whatever we found (likely 1m)
270
+
271
+ # If we still have multiple satellites (e.g. 16 and 18), pick one.
272
+ # Converting to list of rows and picking the first one is safest to avoid downloading 4 files.
273
+
274
+ # Slice the UnifiedResponse to keep only the first row of the first provider results
275
+ best_result = res[0, 0]
276
+
277
+ # print(f"Downloading the first available match: {best_result}")
278
+ files = Fido.fetch(best_result)
279
+
280
+ if not files:
281
+ raise Exception("Failed to download GOES data file.")
282
+
283
+ # Load TimeSeries
284
+ ts = TimeSeries(files)
285
+
286
+ # Concatenate if multiple files (though usually one per day/search)
287
+ if isinstance(ts, list):
288
+ if len(ts) > 1:
289
+ # TODO: Concatenate the TimeSeries objects
290
+ pass
291
+
292
+ self.finished.emit(ts)
293
+
294
+ except Exception as e:
295
+ self.error.emit(str(e))
296
+
297
+
298
+ class CollapsibleSection(QWidget):
299
+ """A collapsible section widget with header and content."""
300
+ toggled = pyqtSignal(bool)
301
+
302
+ def __init__(self, title: str, icon: str = "", count: int = 0, parent=None):
303
+ super().__init__(parent)
304
+ self.is_collapsed = False
305
+
306
+ # Allow expanding vertically
307
+ self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding)
308
+
309
+ layout = QVBoxLayout(self)
310
+ layout.setContentsMargins(0, 0, 0, 0)
311
+ layout.setSpacing(0)
312
+
313
+ # Header
314
+ self.header = QPushButton()
315
+
316
+ # Theme-aware styling
317
+ palette = theme_manager.palette
318
+ is_dark = theme_manager.is_dark
319
+
320
+ if is_dark:
321
+ bg_normal = "rgba(128, 128, 128, 0.12)"
322
+ bg_hover = "rgba(128, 128, 128, 0.18)"
323
+ bg_pressed = "rgba(128, 128, 128, 0.1)"
324
+ border = "none"
325
+ text_color = palette['text']
326
+ else:
327
+ # Light theme: Use distinct solid colors
328
+ bg_normal = palette['button'] # Distinct from window background
329
+ bg_hover = palette['button_hover']
330
+ bg_pressed = palette['button_pressed']
331
+ border = f"1px solid {palette['border']}"
332
+ text_color = palette['text']
333
+
334
+ self.header.setStyleSheet(f"""
335
+ QPushButton {{
336
+ text-align: left;
337
+ padding: 12px 16px;
338
+ font-weight: 600;
339
+ border: {border};
340
+ border-radius: 8px;
341
+ background-color: {bg_normal};
342
+ color: {text_color};
343
+ }}
344
+ QPushButton:hover {{
345
+ background-color: {bg_hover};
346
+ border-color: {palette['highlight']};
347
+ }}
348
+ QPushButton:pressed {{
349
+ background-color: {bg_pressed};
350
+ }}
351
+ """)
352
+ self.update_header(title, icon, count)
353
+ self.header.clicked.connect(self.toggle)
354
+ layout.addWidget(self.header)
355
+
356
+ # Content container
357
+ self.content = QWidget()
358
+ self.content_layout = QVBoxLayout(self.content)
359
+ self.content_layout.setContentsMargins(0, 5, 0, 5)
360
+ layout.addWidget(self.content)
361
+
362
+ self.title = title
363
+ self.icon = icon
364
+
365
+ def update_header(self, title: str, icon: str = "", count: int = 0):
366
+ arrow = "▼" if not self.is_collapsed else "▶"
367
+ count_str = f" [{count}]" if count > 0 else ""
368
+ self.header.setText(f"{arrow} {icon} {title} {count_str}")
369
+
370
+ def toggle(self):
371
+ self.is_collapsed = not self.is_collapsed
372
+ self.content.setVisible(not self.is_collapsed)
373
+ self.update_header(self.title, self.icon,
374
+ getattr(self, '_count', 0))
375
+
376
+ # Update size policy based on state
377
+ if self.is_collapsed:
378
+ self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
379
+ else:
380
+ self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding)
381
+
382
+ self.toggled.emit(self.is_collapsed)
383
+
384
+ def set_count(self, count: int):
385
+ self._count = count
386
+ self.update_header(self.title, self.icon, count)
387
+
388
+ def add_widget(self, widget):
389
+ self.content_layout.addWidget(widget)
390
+
391
+
392
+ class EventTable(QTableWidget):
393
+ """Custom table widget for displaying events."""
394
+
395
+ def __init__(self, columns: list, parent=None):
396
+ super().__init__(parent)
397
+ self.setColumnCount(len(columns))
398
+ self.setHorizontalHeaderLabels(columns)
399
+ self.horizontalHeader().setStretchLastSection(True)
400
+ self.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeToContents)
401
+ self.verticalHeader().setVisible(False)
402
+ self.setAlternatingRowColors(True)
403
+ self.setSelectionBehavior(QTableWidget.SelectRows)
404
+ self.setEditTriggers(QTableWidget.NoEditTriggers)
405
+ self.setSortingEnabled(True)
406
+ self.setShowGrid(False)
407
+
408
+ # Allow table to grow
409
+ self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
410
+
411
+ # Better row height
412
+ self.verticalHeader().setDefaultSectionSize(32)
413
+
414
+ # Modern table styling handled by global stylesheet
415
+ pass
416
+
417
+ def add_event_row(self, values: list, colors: dict = None):
418
+ """Add a row with optional cell coloring."""
419
+ # Temporarily disable sorting to prevent row movement during insertion
420
+ sorting_enabled = self.isSortingEnabled()
421
+ self.setSortingEnabled(False)
422
+
423
+ row = self.rowCount()
424
+ self.insertRow(row)
425
+ for col, value in enumerate(values):
426
+ item = QTableWidgetItem(str(value))
427
+ item.setTextAlignment(Qt.AlignCenter)
428
+ if colors and col in colors:
429
+ item.setForeground(QColor(colors[col]))
430
+ self.setItem(row, col, item)
431
+
432
+ # Re-enable sorting
433
+ self.setSortingEnabled(sorting_enabled)
434
+
435
+
436
+ class NOAAEventsViewer(QMainWindow):
437
+ """Main Solar Activity Viewer window - displays events, active regions, conditions, and CMEs."""
438
+
439
+ def __init__(self, parent=None, initial_date: Optional[date] = None):
440
+ super().__init__(parent)
441
+ self.setWindowTitle("☀️ Solar Activity Viewer")
442
+ self.resize(1000, 800)
443
+
444
+ # Network Manager for image downloading
445
+ self.nam = QNetworkAccessManager(self)
446
+ self.image_downloads = {} # Keep references to replies
447
+ self.image_viewers = [] # Keep references to open image windows
448
+
449
+ self.worker = None
450
+ self.goes_worker = None
451
+ self.events = []
452
+
453
+ # Initial load state to manage cursor
454
+ self._initial_load = False
455
+ if initial_date:
456
+ self._initial_load = True
457
+ from PyQt5.QtWidgets import QApplication
458
+ from PyQt5.QtCore import Qt
459
+ QApplication.setOverrideCursor(Qt.WaitCursor)
460
+
461
+ # Detect theme (dark vs light)
462
+ self.is_dark_theme = theme_manager.is_dark
463
+ self.setStyleSheet(theme_manager.stylesheet)
464
+
465
+ # Improve Light Theme: Override window background to use 'base' (lighter) instead of 'window' (muddy)
466
+ if not self.is_dark_theme:
467
+ palette = theme_manager.palette
468
+ # Use base color for main window and dialogs to reduce the heavy beige look
469
+ # Use 'window' color (darker beige) for panels/containers to create hierarchy
470
+ light_overrides = f"""
471
+ QMainWindow, QDialog {{
472
+ background-color: {palette['base']};
473
+ }}
474
+ QTabWidget::pane {{
475
+ background-color: {palette['plot_bg']};
476
+ border: 1px solid {palette['border']};
477
+ }}
478
+ """
479
+ self.setStyleSheet(theme_manager.stylesheet + light_overrides)
480
+
481
+ self.init_ui()
482
+
483
+ # Set initial date
484
+ if initial_date:
485
+ self.date_edit.setDate(QDate(initial_date.year, initial_date.month, initial_date.day))
486
+ else:
487
+ # Default to yesterday
488
+ yesterday = QDate.currentDate().addDays(-1)
489
+ self.date_edit.setDate(yesterday)
490
+
491
+ def init_ui(self):
492
+ """Initialize the user interface."""
493
+ central = QWidget()
494
+ self.setCentralWidget(central)
495
+ layout = QVBoxLayout(central)
496
+ layout.setSpacing(16)
497
+ layout.setContentsMargins(20, 16, 20, 16)
498
+
499
+ # Modern button styles from theme_manager
500
+
501
+ # Top bar: date selection
502
+ top_bar = QHBoxLayout()
503
+ top_bar.setSpacing(12)
504
+
505
+ date_label = QLabel("Date:")
506
+ date_label.setStyleSheet("font-weight: bold;")
507
+ top_bar.addWidget(date_label)
508
+
509
+ self.date_edit = QDateEdit()
510
+ self.date_edit.setCalendarPopup(True)
511
+ self.date_edit.setDisplayFormat("yyyy.MM.dd")
512
+ self.date_edit.setMaximumDate(QDate.currentDate())
513
+ # Styles handled by global stylesheet
514
+ top_bar.addWidget(self.date_edit)
515
+
516
+ # Get date from current tab button
517
+ self.get_date_btn = QPushButton("📅 From Tab")
518
+ self.get_date_btn.setToolTip("Get date from currently open image/FITS file")
519
+ self.get_date_btn.clicked.connect(self.get_date_from_parent_tab)
520
+ if not self.parent():
521
+ self.get_date_btn.setEnabled(False)
522
+ self.get_date_btn.setToolTip("Not available in independent mode")
523
+ top_bar.addWidget(self.get_date_btn)
524
+
525
+ top_bar.addStretch()
526
+
527
+ self.progress = QProgressBar()
528
+ self.progress.setMaximumWidth(150)
529
+ self.progress.setMaximum(0) # Indeterminate
530
+ self.progress.hide()
531
+ top_bar.addWidget(self.progress)
532
+
533
+ self.fetch_btn = QPushButton("🔍 Fetch")
534
+ self.fetch_btn.setObjectName("PrimaryButton")
535
+ self.fetch_btn.clicked.connect(self.fetch_data)
536
+ top_bar.addWidget(self.fetch_btn)
537
+
538
+
539
+ layout.addLayout(top_bar)
540
+
541
+ # Modern summary bar
542
+ self.summary_frame = QFrame()
543
+
544
+ # Use simple gradient based on palette
545
+ palette = theme_manager.palette
546
+ highlight = palette['highlight']
547
+
548
+ # Convert hex to rgba for transparent gradient
549
+ self.summary_frame.setStyleSheet(f"""
550
+ QFrame {{
551
+ background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
552
+ stop:0 {highlight}1A,
553
+ stop:1 {highlight}0D);
554
+ border-radius: 10px;
555
+ border: 1px solid {highlight}33;
556
+ }}
557
+ """)
558
+ summary_layout = QHBoxLayout(self.summary_frame)
559
+ summary_layout.setContentsMargins(20, 16, 20, 16)
560
+
561
+ self.summary_label = QLabel("Select a date and click 'Fetch Events' to view solar activity.")
562
+ self.summary_label.setStyleSheet("font-weight: 500;")
563
+ self.summary_label.setWordWrap(True)
564
+ summary_layout.addWidget(self.summary_label)
565
+
566
+ layout.addWidget(self.summary_frame)
567
+
568
+ # Modern tab styling handled by global stylesheet
569
+ self.tabs = QTabWidget()
570
+
571
+ # Tab 1: Solar Events (existing content)
572
+ events_tab = QWidget()
573
+ events_layout = QVBoxLayout(events_tab)
574
+ events_layout.setContentsMargins(24, 24, 24, 24)
575
+ events_layout.setSpacing(24)
576
+
577
+ # X-ray Flares section
578
+ self.xray_section = CollapsibleSection("X-ray Flares", "☀️")
579
+ self.xray_table = EventTable(["Time (UT)", "Class", "Peak Flux", "Region", "Duration", "Observatory"])
580
+ self.xray_section.add_widget(self.xray_table)
581
+ events_layout.addWidget(self.xray_section)
582
+
583
+ # Optical Flares section
584
+ self.optical_section = CollapsibleSection("Optical Flares (H-alpha)", "🔥")
585
+ self.optical_table = EventTable(["Time (UT)", "Class", "Location", "Region", "Notes", "Observatory"])
586
+ self.optical_section.add_widget(self.optical_table)
587
+ events_layout.addWidget(self.optical_section)
588
+
589
+ # Radio Events section
590
+ self.radio_section = CollapsibleSection("Radio Events", "📻")
591
+ self.radio_table = EventTable(["Type", "Time (UT)", "Frequency", "Particulars", "Region", "Observatory"])
592
+ self.radio_section.add_widget(self.radio_table)
593
+ events_layout.addWidget(self.radio_section)
594
+
595
+ # Connect signals for dynamic layout
596
+ self.xray_section.toggled.connect(self.update_events_layout_logic)
597
+ self.optical_section.toggled.connect(self.update_events_layout_logic)
598
+ self.radio_section.toggled.connect(self.update_events_layout_logic)
599
+
600
+ # Dynamic spacer - stays hidden unless all sections are collapsed
601
+ self.events_bottom_spacer = QWidget()
602
+ self.events_bottom_spacer.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding)
603
+ events_layout.addWidget(self.events_bottom_spacer)
604
+
605
+ # Initial logic check
606
+ self.update_events_layout_logic()
607
+
608
+ # Make events tab scrollable
609
+ events_scroll = QScrollArea()
610
+ events_scroll.setWidgetResizable(True)
611
+ events_scroll.setFrameShape(QFrame.NoFrame)
612
+ events_scroll.setWidget(events_tab)
613
+ self.tabs.addTab(events_scroll, "☀️ Solar Events")
614
+
615
+ # Tab 2: Active Regions
616
+ ar_tab = QWidget()
617
+ ar_layout = QVBoxLayout(ar_tab)
618
+ ar_layout.setContentsMargins(24, 24, 24, 24)
619
+ ar_layout.setSpacing(24)
620
+
621
+ # Active regions table
622
+ self.ar_table = EventTable([
623
+ "AR#", "Location", "Area", "McIntosh", "Mag Type",
624
+ "C%", "M%", "X%", "Risk Level"
625
+ ])
626
+ ar_layout.addWidget(self.ar_table)
627
+
628
+ # AR info label
629
+ self.ar_info_label = QLabel("Fetch data to view active sunspot regions and flare probabilities.")
630
+ self.ar_info_label.setWordWrap(True)
631
+ self.ar_info_label.setStyleSheet(f"color: {theme_manager.palette['text']}; font-style: italic; padding: 10px; font-weight: light; opacity: 0.4;")
632
+ ar_layout.addWidget(self.ar_info_label)
633
+
634
+
635
+ #ar_layout.addStretch()
636
+
637
+ # Make AR tab scrollable
638
+ ar_scroll = QScrollArea()
639
+ ar_scroll.setWidgetResizable(True)
640
+ ar_scroll.setFrameShape(QFrame.NoFrame)
641
+ ar_scroll.setWidget(ar_tab)
642
+ self.tabs.addTab(ar_scroll, "🌡️ Active Regions")
643
+
644
+ # Tab 3: Solar Conditions (Real-time data)
645
+ conditions_tab = QWidget()
646
+ conditions_layout = QVBoxLayout(conditions_tab)
647
+ conditions_layout.setContentsMargins(24, 24, 24, 24)
648
+ conditions_layout.setSpacing(24)
649
+
650
+ # Geomagnetic Activity Card - modern styling
651
+ geo_card = QFrame()
652
+ if self.is_dark_theme:
653
+ geo_card.setStyleSheet("""
654
+ QFrame {
655
+ background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
656
+ stop:0 rgba(99, 102, 241, 0.12),
657
+ stop:1 rgba(99, 102, 241, 0.06));
658
+ border-radius: 12px;
659
+ border: 1px solid rgba(99, 102, 241, 0.25);
660
+ }
661
+ """)
662
+ else:
663
+ palette = theme_manager.palette
664
+ geo_card.setStyleSheet(f"""
665
+ QFrame {{
666
+ background-color: {palette['surface']};
667
+ border-radius: 12px;
668
+ border: 1px solid {palette['border']};
669
+ }}
670
+ """)
671
+ geo_layout = QVBoxLayout(geo_card)
672
+ geo_layout.setContentsMargins(20, 20, 20, 20)
673
+
674
+ geo_title = QLabel("🧭 Geomagnetic Activity (Daily)")
675
+ geo_title.setStyleSheet("font-weight: bold;")
676
+ geo_layout.addWidget(geo_title)
677
+
678
+ self.geo_ap_label = QLabel("Ap Index: —")
679
+ self.geo_kp_max_label = QLabel("Kp max: —")
680
+ self.geo_kp_avg_label = QLabel("Kp avg: —")
681
+ self.geo_kp_vals_label = QLabel("3-hour Kp values: —")
682
+ self.geo_storm_label = QLabel("Storm Level: —")
683
+
684
+ for lbl in [self.geo_ap_label, self.geo_kp_max_label, self.geo_kp_avg_label, self.geo_storm_label, self.geo_kp_vals_label]:
685
+ lbl.setStyleSheet("padding-left: 10px;")
686
+ geo_layout.addWidget(lbl)
687
+
688
+ conditions_layout.addWidget(geo_card)
689
+
690
+ # Solar Wind Card - modern styling
691
+ self.wind_card = QFrame()
692
+ if self.is_dark_theme:
693
+ self.wind_card.setStyleSheet("""
694
+ QFrame {
695
+ background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
696
+ stop:0 rgba(16, 185, 129, 0.12),
697
+ stop:1 rgba(16, 185, 129, 0.06));
698
+ border-radius: 12px;
699
+ border: 1px solid rgba(16, 185, 129, 0.25);
700
+ }
701
+ """)
702
+ else:
703
+ palette = theme_manager.palette
704
+ self.wind_card.setStyleSheet(f"""
705
+ QFrame {{
706
+ background-color: {palette['surface']};
707
+ border-radius: 12px;
708
+ border: 1px solid {palette['border']};
709
+ }}
710
+ """)
711
+ wind_layout = QVBoxLayout(self.wind_card)
712
+ wind_layout.setContentsMargins(20, 20, 20, 20)
713
+
714
+ wind_title = QLabel("💨 Solar Wind (Real-time)")
715
+ wind_title.setStyleSheet("font-weight: bold;")
716
+ wind_layout.addWidget(wind_title)
717
+
718
+ self.sw_speed_label = QLabel("Speed: — km/s")
719
+ self.sw_density_label = QLabel("Density: — p/cm³")
720
+ self.sw_temp_label = QLabel("Temperature: — K")
721
+ self.sw_status_label = QLabel("Status: —")
722
+
723
+ for lbl in [self.sw_speed_label, self.sw_density_label, self.sw_temp_label, self.sw_status_label]:
724
+ lbl.setStyleSheet("padding-left: 10px;")
725
+ wind_layout.addWidget(lbl)
726
+
727
+ conditions_layout.addWidget(self.wind_card)
728
+ self.wind_card.hide() # Only show when available
729
+
730
+ # F10.7 Flux card - modern styling
731
+ f107_card = QFrame()
732
+ if self.is_dark_theme:
733
+ f107_card.setStyleSheet("""
734
+ QFrame {
735
+ background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
736
+ stop:0 rgba(251, 146, 60, 0.12),
737
+ stop:1 rgba(251, 146, 60, 0.06));
738
+ border-radius: 12px;
739
+ border: 1px solid rgba(251, 146, 60, 0.25);
740
+ }
741
+ """)
742
+ else:
743
+ palette = theme_manager.palette
744
+ f107_card.setStyleSheet(f"""
745
+ QFrame {{
746
+ background-color: {palette['surface']};
747
+ border-radius: 12px;
748
+ border: 1px solid {palette['border']};
749
+ }}
750
+ """)
751
+ f107_layout = QVBoxLayout(f107_card)
752
+ f107_layout.setContentsMargins(20, 20, 20, 20)
753
+
754
+ f107_title = QLabel("☀️ Solar Indices (Daily)")
755
+ f107_title.setStyleSheet("font-weight: bold;")
756
+ f107_layout.addWidget(f107_title)
757
+
758
+ self.f107_value_label = QLabel("Flux: — sfu")
759
+ self.sunspot_area_label = QLabel("Sunspot Area: —")
760
+ #self.xray_bg_label = QLabel("X-Ray Background: —")
761
+ self.f107_activity_label = QLabel("Activity Level: —")
762
+
763
+ #for lbl in [self.f107_value_label, self.sunspot_area_label, self.xray_bg_label, self.f107_activity_label]:
764
+ for lbl in [self.f107_value_label, self.sunspot_area_label, self.f107_activity_label]:
765
+ lbl.setStyleSheet("padding-left: 10px;")
766
+ f107_layout.addWidget(lbl)
767
+
768
+ # Add GOES Plot Button
769
+ self.plot_goes_btn = QPushButton("📈 Plot GOES X-ray Flux")
770
+ self.plot_goes_btn.setToolTip("Plot the GOES X-ray light curve for this date")
771
+
772
+ self.plot_goes_btn.clicked.connect(self.plot_goes_xray)
773
+ f107_layout.addWidget(self.plot_goes_btn)
774
+ self.plot_goes_btn.setEnabled(True)
775
+
776
+ conditions_layout.addWidget(f107_card)
777
+
778
+ # Conditions info label - theme-aware
779
+ self.conditions_info_label = QLabel("⚡ Real-time solar conditions from NOAA SWPC")
780
+ self.conditions_info_label.setWordWrap(True)
781
+ self.conditions_info_label.setStyleSheet(f"color: {theme_manager.palette['text']}; font-style: italic; padding: 10px; font-weight: light; opacity: 0.4;")
782
+ conditions_layout.addWidget(self.conditions_info_label)
783
+
784
+ conditions_layout.addStretch()
785
+
786
+ # Make conditions tab scrollable
787
+ conditions_scroll = QScrollArea()
788
+ conditions_scroll.setWidgetResizable(True)
789
+ conditions_scroll.setFrameShape(QFrame.NoFrame)
790
+ conditions_scroll.setWidget(conditions_tab)
791
+ self.tabs.addTab(conditions_scroll, "⚡ Solar Conditions")
792
+
793
+ # Tab 4: CME Alerts
794
+ cme_tab = QWidget()
795
+ cme_layout = QVBoxLayout(cme_tab)
796
+ cme_layout.setContentsMargins(24, 24, 24, 24)
797
+ cme_layout.setSpacing(24)
798
+
799
+ # CME table
800
+ self.cme_table = EventTable([
801
+ "Time (UT)", "Speed (km/s)", "Source", "Width", "Earth Dir.", "Est. Arrival"
802
+ ])
803
+ cme_layout.addWidget(self.cme_table)
804
+
805
+ # CME info label - theme-aware
806
+ self.cme_info_label = QLabel("🚀 CME data from NASA DONKI (±3 days from selected date)")
807
+ self.cme_info_label.setWordWrap(True)
808
+ self.cme_info_label.setStyleSheet(f"color: {theme_manager.palette['text']}; font-style: italic; padding: 10px; font-weight: light; opacity: 0.4;")
809
+ cme_layout.addWidget(self.cme_info_label)
810
+
811
+ # cme_layout.addStretch()
812
+
813
+ # Make CME tab scrollable
814
+ cme_scroll = QScrollArea()
815
+ cme_scroll.setWidgetResizable(True)
816
+ cme_scroll.setFrameShape(QFrame.NoFrame)
817
+ cme_scroll.setWidget(cme_tab)
818
+ self.tabs.addTab(cme_scroll, "🚀 CME Alerts")
819
+
820
+ # Tab 5: Context Images
821
+ images_tab = QWidget()
822
+ images_layout = QVBoxLayout(images_tab)
823
+
824
+ images_scroll = QScrollArea()
825
+ images_scroll.setWidgetResizable(True)
826
+ images_scroll_content = QWidget()
827
+ self.images_grid = QVBoxLayout(images_scroll_content) # Use VBox for list of cards or Grid
828
+ self.images_grid.setSpacing(16)
829
+ self.images_grid.setContentsMargins(24, 24, 24, 24)
830
+
831
+ images_scroll.setWidget(images_scroll_content)
832
+ images_layout.addWidget(images_scroll)
833
+
834
+ self.tabs.addTab(images_tab, "📷 Context Images")
835
+
836
+ layout.addWidget(self.tabs)
837
+
838
+ def update_events_layout_logic(self, *args):
839
+ """Show/hide bottom spacer based on whether any section is open."""
840
+ any_open = not (self.xray_section.is_collapsed and
841
+ self.optical_section.is_collapsed and
842
+ self.radio_section.is_collapsed)
843
+
844
+ # If any section is open, hide spacer so the open section can expand
845
+ # If all are closed, show spacer to push headers to the top
846
+ if hasattr(self, 'events_bottom_spacer'):
847
+ self.events_bottom_spacer.setVisible(not any_open)
848
+
849
+ def fetch_data(self):
850
+ """Start fetching data for the selected date."""
851
+ # Ensure imports are available for whole scope
852
+ from PyQt5.QtWidgets import QApplication
853
+ from PyQt5.QtCore import Qt
854
+
855
+ if self.worker and self.worker.isRunning():
856
+ return
857
+
858
+ # Show busy cursor immediately
859
+ # If this is the initial load, cursor was already set in __init__
860
+ if getattr(self, '_initial_load', False):
861
+ self._initial_load = False
862
+ else:
863
+ QApplication.setOverrideCursor(Qt.WaitCursor)
864
+
865
+ qdate = self.date_edit.date()
866
+ selected_date = date(qdate.year(), qdate.month(), qdate.day())
867
+
868
+ self.date_edit.setEnabled(False)
869
+ self.fetch_btn.setEnabled(False)
870
+ self.summary_label.setText(f"Fetching data for {selected_date}...")
871
+ self.progress.show()
872
+
873
+ QApplication.processEvents() # Force UI update immediately
874
+
875
+ # Clean up old worker if exists
876
+ if self.worker is not None:
877
+ self.worker.finished.disconnect()
878
+ self.worker.error.disconnect()
879
+ self.worker.deleteLater()
880
+
881
+ self.worker = FetchWorker(selected_date)
882
+ self.worker.finished.connect(self.on_fetch_finished)
883
+ self.worker.error.connect(self.on_fetch_error)
884
+ self.worker.start()
885
+
886
+ def on_fetch_finished(self, events, active_regions, conditions, cmes, images):
887
+ """Handle fetched data."""
888
+ try:
889
+ # Check for validity
890
+ if not self.isVisible() and not self.parent():
891
+ return # Window closed
892
+
893
+ # Restore cursor
894
+ from PyQt5.QtWidgets import QApplication
895
+ QApplication.restoreOverrideCursor()
896
+
897
+ self.date_edit.setEnabled(True)
898
+ self.fetch_btn.setEnabled(True)
899
+ self.fetch_btn.setText("🔍 Fetch")
900
+ self.progress.hide()
901
+ self.events = events
902
+
903
+ # Display events
904
+ self.display_events(events)
905
+
906
+ # Display active regions
907
+ self.display_active_regions(active_regions)
908
+
909
+ # Display conditions
910
+ self.display_solar_conditions(conditions)
911
+
912
+ # Display CMEs
913
+ self.display_cme_events(cmes)
914
+
915
+ # Display images
916
+ self.display_context_images(images)
917
+
918
+ # Update comprehensive summary
919
+ self._update_comprehensive_summary(events, active_regions, conditions, cmes)
920
+
921
+ except RuntimeError:
922
+ # Widget deleted during update
923
+ pass
924
+
925
+ def _update_comprehensive_summary(self, events, active_regions, conditions, cmes):
926
+ """Update the main summary label with a comprehensive overview of all data."""
927
+ summary_parts = []
928
+
929
+ # 1. Active Regions
930
+ ar_count = len(active_regions) if active_regions else 0
931
+ if ar_count > 0:
932
+ summary_parts.append(f"Regions: {ar_count}")
933
+ elif active_regions is not None:
934
+ summary_parts.append("Regions: 0")
935
+
936
+ # 2. Sunspots & Flux (from conditions)
937
+ if conditions and conditions.f107_flux:
938
+ ssn = conditions.f107_flux.sunspot_number
939
+ flux = conditions.f107_flux.flux_value
940
+ summary_parts.append(f"Sunspots: {ssn}")
941
+ summary_parts.append(f"Flux: {flux:.0f} sfu")
942
+
943
+ # 3. Solar Flares (from events)
944
+ if events:
945
+ categories = ne.categorize_events(events)
946
+ xray = categories.get("xray", [])
947
+ stats = ne.get_event_statistics(events)
948
+ max_class = stats.get("max_xray_class", None)
949
+
950
+ flare_part = f"Flares: {len(xray)}"
951
+ if max_class:
952
+ flare_part += f" (Max: {max_class})"
953
+ summary_parts.append(flare_part)
954
+ else:
955
+ summary_parts.append("Flares: 0")
956
+
957
+ # 4. CMEs
958
+ if cmes:
959
+ cme_count = len(cmes)
960
+ earth_directed = sum(1 for cme in cmes if cme.is_earth_directed)
961
+ cme_text = f"CMEs: {cme_count}"
962
+ if earth_directed > 0:
963
+ cme_text += f" (🌍 {earth_directed})"
964
+ summary_parts.append(cme_text)
965
+ elif cmes is not None:
966
+ summary_parts.append("CMEs: 0")
967
+
968
+ if not summary_parts:
969
+ self.summary_label.setText("No data available for this date.")
970
+ else:
971
+ self.summary_label.setText(" | ".join(summary_parts))
972
+
973
+
974
+ def on_fetch_error(self, error_msg):
975
+ """Handle fetch error."""
976
+ try:
977
+ # Check validity
978
+ if not self.isVisible() and not self.parent(): return
979
+
980
+ from PyQt5.QtWidgets import QApplication
981
+ from PyQt5.QtWidgets import QMessageBox
982
+ QApplication.restoreOverrideCursor()
983
+
984
+ self.date_edit.setEnabled(True)
985
+ self.fetch_btn.setEnabled(True)
986
+ self.fetch_btn.setText("🔍 Fetch")
987
+ self.progress.hide()
988
+
989
+ self.summary_label.setText(f"Error fetching data: {error_msg}")
990
+ QMessageBox.critical(self, "Fetch Error", f"Failed to fetch data")
991
+ except RuntimeError:
992
+ pass
993
+
994
+ def clear_tables(self):
995
+ """Clear all event tables."""
996
+ self.xray_table.setRowCount(0)
997
+ self.optical_table.setRowCount(0)
998
+ self.radio_table.setRowCount(0)
999
+ self.ar_table.setRowCount(0)
1000
+ self.cme_table.setRowCount(0)
1001
+ self.xray_section.set_count(0)
1002
+ self.optical_section.set_count(0)
1003
+ self.radio_section.set_count(0)
1004
+
1005
+ def display_events(self, events):
1006
+ """Display events in categorized tables."""
1007
+ self.clear_tables()
1008
+
1009
+ if events is None:
1010
+ self.summary_label.setText("No data could be fetched.")
1011
+ return
1012
+
1013
+ categories = ne.categorize_events(events)
1014
+ stats = ne.get_event_statistics(events)
1015
+
1016
+ # Update summary - MOVED to _update_comprehensive_summary
1017
+ xray_count = len(categories["xray"])
1018
+ optical_count = len(categories["optical"])
1019
+ radio_count = len(categories["radio"])
1020
+ # max_class = stats.get("max_xray_class", "—")
1021
+
1022
+ # summary_parts = []
1023
+ # if xray_count > 0:
1024
+ # max_note = f" (max: {max_class})" if max_class else ""
1025
+ # summary_parts.append(f"📊 {xray_count} X-ray flare{'s' if xray_count > 1 else ''}{max_note}")
1026
+ # if optical_count > 0:
1027
+ # summary_parts.append(f"{optical_count} Optical")
1028
+ # if radio_count > 0:
1029
+ # summary_parts.append(f"{radio_count} Radio")
1030
+
1031
+ # if summary_parts:
1032
+ # self.summary_label.setText(" | ".join(summary_parts))
1033
+ # else:
1034
+ # self.summary_label.setText("No significant events recorded for this date.")
1035
+
1036
+ # Populate X-ray table
1037
+ self.xray_section.set_count(xray_count)
1038
+ for event in sorted(categories["xray"], key=lambda e: e.begin_time or "9999"):
1039
+ duration = f"{event.duration_minutes} min" if event.duration_minutes else "—"
1040
+ flare_class = event.flare_class or "—"
1041
+ peak_flux = event.particulars.split()[1] if len(event.particulars.split()) > 1 else "—"
1042
+
1043
+ color_col = {}
1044
+ if event.flare_class_letter in ["M", "X"]:
1045
+ color_col[1] = event.flare_class_color
1046
+
1047
+ self.xray_table.add_event_row([
1048
+ event.time_range,
1049
+ flare_class,
1050
+ peak_flux,
1051
+ event.active_region or "—",
1052
+ duration,
1053
+ event.observatory_name,
1054
+ ], color_col)
1055
+
1056
+ # Populate Optical table
1057
+ self.optical_section.set_count(optical_count)
1058
+ for event in sorted(categories["optical"], key=lambda e: e.begin_time or "9999"):
1059
+ optical_class = event.optical_class or "—"
1060
+ notes_parts = event.particulars.split()[1:] if event.particulars else []
1061
+ notes = " ".join(notes_parts) if notes_parts else "—"
1062
+
1063
+ self.optical_table.add_event_row([
1064
+ event.time_range,
1065
+ optical_class,
1066
+ event.location_or_freq,
1067
+ event.active_region or "—",
1068
+ notes,
1069
+ event.observatory_name,
1070
+ ])
1071
+
1072
+ # Populate Radio table
1073
+ self.radio_section.set_count(radio_count)
1074
+ for event in sorted(categories["radio"], key=lambda e: e.begin_time or "9999"):
1075
+ type_name = ne.EVENT_TYPES.get(event.event_type, {}).get("name", event.event_type)
1076
+
1077
+ self.radio_table.add_event_row([
1078
+ event.event_type,
1079
+ event.time_range,
1080
+ event.location_or_freq,
1081
+ event.particulars,
1082
+ event.active_region or "—",
1083
+ event.observatory_name,
1084
+ ])
1085
+
1086
+ # Resize columns to fit contents and scroll to top
1087
+ self.xray_table.resizeColumnsToContents()
1088
+ self.optical_table.resizeColumnsToContents()
1089
+ self.radio_table.resizeColumnsToContents()
1090
+ self.xray_table.scrollToTop()
1091
+ self.optical_table.scrollToTop()
1092
+ self.radio_table.scrollToTop()
1093
+
1094
+ def display_active_regions(self, regions):
1095
+ """Display active regions in the AR table."""
1096
+ self.ar_table.setRowCount(0)
1097
+
1098
+ if regions is None or len(regions) == 0:
1099
+ self.ar_info_label.setText("No active regions data available for this date.")
1100
+ self.ar_info_label.show()
1101
+ return
1102
+
1103
+ # self.ar_info_label.hide()
1104
+ self.ar_info_label.setText(f"Found {len(regions)} active regions.")
1105
+
1106
+ # Color coding for risk levels
1107
+ risk_colors = {
1108
+ "Very High": "#F44336", # Red
1109
+ "High": "#FF9800", # Orange
1110
+ "Moderate": "#FFC107", # Amber
1111
+ "Low": "#4CAF50", # Green
1112
+ "Quiet": "#9E9E9E", # Grey
1113
+ }
1114
+
1115
+ for region in sorted(regions, key=lambda r: r.area, reverse=True):
1116
+ # Format probabilities
1117
+ c_prob = f"{region.prob_c}%" if region.prob_c is not None else "—"
1118
+ m_prob = f"{region.prob_m}%" if region.prob_m is not None else "—"
1119
+ x_prob = f"{region.prob_x}%" if region.prob_x is not None else "—"
1120
+
1121
+ risk = region.flare_risk_level
1122
+ risk_color = risk_colors.get(risk, "#9E9E9E")
1123
+
1124
+ # Add row with color for risk level column
1125
+ color_col = {8: risk_color}
1126
+
1127
+ # Also color M% and X% if they're significant
1128
+ if region.prob_m and region.prob_m >= 20:
1129
+ color_col[6] = "#FF9800"
1130
+ if region.prob_x and region.prob_x >= 5:
1131
+ color_col[7] = "#F44336"
1132
+
1133
+ self.ar_table.add_event_row([
1134
+ f"AR{region.noaa_number}",
1135
+ region.location,
1136
+ str(region.area),
1137
+ region.mcintosh_class,
1138
+ region.mag_type,
1139
+ c_prob,
1140
+ m_prob,
1141
+ x_prob,
1142
+ risk,
1143
+ ], color_col)
1144
+
1145
+ self.ar_table.resizeColumnsToContents()
1146
+ self.ar_table.scrollToTop()
1147
+
1148
+ def display_solar_conditions(self, conditions):
1149
+ """Display solar conditions for the selected date."""
1150
+ if conditions is None:
1151
+ self.conditions_info_label.setText("⚠️ Unable to fetch solar conditions data")
1152
+ self.geo_ap_label.setText("Ap Index: —")
1153
+ self.geo_kp_max_label.setText("Kp max: —")
1154
+ self.geo_kp_avg_label.setText("Kp avg: —")
1155
+ self.geo_kp_vals_label.setText("3-hour Kp/Ap values: —")
1156
+ self.geo_storm_label.setText("Storm Level: —")
1157
+ self.wind_card.hide()
1158
+ return
1159
+
1160
+ # Update title label to show data source
1161
+ self.conditions_info_label.setText(f"📊 {conditions.data_source}")
1162
+
1163
+ # 1. Geomagnetic Data (Kp)
1164
+ if conditions.kp_index:
1165
+ kp = conditions.kp_index
1166
+ self.geo_ap_label.setText(f"Ap Index: {kp.ap_value}")
1167
+ self.geo_kp_max_label.setText(f"Kp max: {kp.kp_max:.0f}")
1168
+ self.geo_kp_avg_label.setText(f"Kp avg: {kp.kp_avg:.1f}")
1169
+ self.geo_kp_vals_label.setText(f"8 Kp values: {', '.join([f'{v:.0f}' for v in kp.kp_values])}")
1170
+ self.geo_kp_vals_label.setStyleSheet("padding-left: 10px; color: #888;")
1171
+
1172
+ self.geo_storm_label.setText(f"Storm Level: {kp.storm_level}")
1173
+ self.geo_storm_label.setStyleSheet(f"padding-left: 10px; color: {kp.color_code}; font-weight: bold;")
1174
+ else:
1175
+ self.geo_ap_label.setText("Ap Index: —")
1176
+ self.geo_kp_max_label.setText("Kp max: —")
1177
+ self.geo_kp_avg_label.setText("Kp avg: —")
1178
+ self.geo_kp_vals_label.setText("No geomagnetic data for this date")
1179
+ self.geo_storm_label.setText("Storm Level: Data unavailable")
1180
+
1181
+ # 2. Solar Wind Data (Real-time only)
1182
+ if hasattr(conditions, 'solar_wind') and conditions.solar_wind:
1183
+ sw = conditions.solar_wind
1184
+ self.wind_card.show()
1185
+ self.sw_speed_label.setText(f"Speed: {sw.speed:.0f} km/s")
1186
+ self.sw_density_label.setText(f"Density: {sw.density:.1f} p/cm³")
1187
+ self.sw_temp_label.setText(f"Temperature: {sw.temperature:.0f} K")
1188
+
1189
+ status_color = "#888"
1190
+ status_text = sw.speed_category
1191
+ if status_text == "High": status_color = "#F44336"
1192
+ elif status_text == "Elevated": status_color = "#FF9800"
1193
+ elif status_text == "Normal": status_color = "#4CAF50"
1194
+
1195
+ self.sw_status_label.setText(f"Status: {status_text} Speed")
1196
+ self.sw_status_label.setStyleSheet(f"padding-left: 10px; color: {status_color}; font-weight: bold;")
1197
+ else:
1198
+ self.wind_card.hide()
1199
+
1200
+ # F10.7 Flux (historical daily data)
1201
+ if conditions.f107_flux:
1202
+ f107 = conditions.f107_flux
1203
+ self.f107_value_label.setText(f"10.7cm Flux: {f107.flux_value:.1f} sfu (Sunspot #: {f107.sunspot_number})")
1204
+
1205
+ area = getattr(f107, 'sunspot_area', '—')
1206
+ area_str = f"{area} (10⁻⁶ Hemis.)" if area != '—' else "—"
1207
+ self.sunspot_area_label.setText(f"Sunspot Area: {area_str}")
1208
+
1209
+ '''bg = getattr(f107, 'xray_background', '—')
1210
+ bg_text = bg
1211
+ bg_color = "#888" # Default gray
1212
+
1213
+ if bg == '*':
1214
+ bg_text = "N/A"
1215
+ bg_color = "#4CAF50" # Green
1216
+ elif bg and bg[0] in ['A', 'B']:
1217
+ bg_color = "#4CAF50" # Green for A/B
1218
+ elif bg and bg.startswith('C'):
1219
+ bg_color = "#FF9800" # Orange for C
1220
+ elif bg and bg.startswith('M'):
1221
+ bg_color = "#F44336" # Red for M
1222
+ elif bg and bg.startswith('X'):
1223
+ bg_color = "#9C27B0" # Purple for X
1224
+
1225
+ self.xray_bg_label.setText(f"X-Ray Background: {bg_text}")
1226
+ self.xray_bg_label.setStyleSheet(f"padding-left: 10px; color: {bg_color}; font-weight: bold;")'''
1227
+
1228
+ # Color-code activity level
1229
+ activity_colors = {
1230
+ "Very Low": "#2196F3",
1231
+ "Low": "#4CAF50",
1232
+ "Moderate": "#FFC107",
1233
+ "Elevated": "#FF9800",
1234
+ "High": "#F44336",
1235
+ "Very High": "#9C27B0",
1236
+ }
1237
+ color = activity_colors.get(f107.activity_level, "#9E9E9E")
1238
+ self.f107_activity_label.setText(f"Activity Level: {f107.activity_level}")
1239
+ self.f107_activity_label.setStyleSheet(f"padding-left: 10px; color: {color}; font-weight: bold;")
1240
+ else:
1241
+ self.f107_value_label.setText("10.7cm Flux: — sfu")
1242
+ self.sunspot_area_label.setText("Sunspot Area: —")
1243
+ #self.xray_bg_label.setText("X-Ray Background: —")
1244
+ self.f107_activity_label.setText("Activity Level: Data unavailable")
1245
+
1246
+ def display_cme_events(self, cmes):
1247
+ """Display CME events in the CME table."""
1248
+ self.cme_table.setRowCount(0)
1249
+
1250
+ if cmes is None or len(cmes) == 0:
1251
+ self.cme_info_label.setText("🚀 No CME activity detected in the ±3 day range for this date.")
1252
+ self.cme_info_label.show()
1253
+ return
1254
+
1255
+ self.cme_info_label.setText(f"🚀 Found {len(cmes)} CME events (±3 days from selected date)")
1256
+
1257
+ for cme in cmes:
1258
+ # Format columns
1259
+ time_str = cme.start_time.strftime("%Y-%m-%d %H:%M")
1260
+ speed_str = f"{cme.speed:.0f}"
1261
+ width_str = f"{cme.half_angle:.0f}°" if cme.half_angle else "—"
1262
+ earth_str = "🌍 Yes" if cme.is_earth_directed else "No"
1263
+ arrival_str = cme.arrival_str
1264
+
1265
+ # Color coding
1266
+ color_col = {}
1267
+
1268
+ # Color Earth-directed column
1269
+ if cme.is_earth_directed:
1270
+ color_col[4] = "#FF9800" # Orange for Earth-directed
1271
+ if cme.speed >= 1000:
1272
+ color_col[4] = "#F44336" # Red for fast Earth-directed
1273
+
1274
+ # Color speed column based on category
1275
+ speed_colors = {
1276
+ "Slow": "#4CAF50",
1277
+ "Moderate": "#FFC107",
1278
+ "Fast": "#FF9800",
1279
+ "Extreme": "#F44336",
1280
+ }
1281
+ color_col[1] = speed_colors.get(cme.speed_category, "#9E9E9E")
1282
+
1283
+ self.cme_table.add_event_row([
1284
+ time_str,
1285
+ speed_str,
1286
+ cme.source_location,
1287
+ width_str,
1288
+ earth_str,
1289
+ arrival_str,
1290
+ ], color_col)
1291
+
1292
+ self.cme_table.resizeColumnsToContents()
1293
+ self.cme_table.scrollToTop()
1294
+
1295
+ def get_date_from_parent_tab(self):
1296
+ """Extract date from the currently open tab in the parent viewer.
1297
+
1298
+ Uses the same logic as the viewer's figure title date extraction.
1299
+ """
1300
+ try:
1301
+ # Get parent main window
1302
+ parent = self.parent()
1303
+ if parent is None:
1304
+ QMessageBox.information(self, "Info", "No parent viewer found. Please open an image first.")
1305
+ return
1306
+
1307
+ # Try to get current tab
1308
+ current_tab = None
1309
+ if hasattr(parent, 'tab_widget'):
1310
+ current_tab = parent.tab_widget.currentWidget()
1311
+
1312
+ if current_tab is None:
1313
+ QMessageBox.information(self, "Info", "No image is currently open.")
1314
+ return
1315
+
1316
+ extracted_date = None
1317
+ image_time = None
1318
+ imagename = getattr(current_tab, 'imagename', None)
1319
+
1320
+ # Method 1: Try FITS header from tab attribute first
1321
+ header = None
1322
+ if hasattr(current_tab, 'header') and current_tab.header:
1323
+ header = current_tab.header
1324
+
1325
+ # Method 1b: If no header attribute, read FITS/FTS file directly
1326
+ if header is None and imagename:
1327
+ lower_name = imagename.lower()
1328
+ if lower_name.endswith('.fits') or lower_name.endswith('.fts') or lower_name.endswith('.fit'):
1329
+ try:
1330
+ from astropy.io import fits
1331
+ header = fits.getheader(imagename)
1332
+ except Exception as fits_err:
1333
+ print(f"FITS header read failed: {fits_err}")
1334
+
1335
+ # Extract date from header
1336
+ if header is not None:
1337
+ # Check DATE-OBS (standard), DATE_OBS (IRIS), and STARTOBS
1338
+ image_time = header.get("DATE-OBS") or header.get("DATE_OBS") or header.get("STARTOBS")
1339
+
1340
+ # Special handling for SOHO (DATE-OBS + TIME-OBS)
1341
+ if header.get("TELESCOP") == "SOHO" and header.get("TIME-OBS") and image_time:
1342
+ image_time = f"{image_time}T{header['TIME-OBS']}"
1343
+
1344
+ if image_time:
1345
+ extracted_date = self._parse_date_string(str(image_time))
1346
+
1347
+ # Method 2: CASA image - read csys_record directly from file (like viewer.py)
1348
+ if extracted_date is None and imagename:
1349
+ # Check if it's a CASA image (directory, not .fits/.fts)
1350
+ lower_name = imagename.lower()
1351
+ is_casa_image = os.path.isdir(imagename) or (
1352
+ not lower_name.endswith('.fits') and
1353
+ not lower_name.endswith('.fts') and
1354
+ not lower_name.endswith('.fit')
1355
+ )
1356
+
1357
+ if is_casa_image:
1358
+ try:
1359
+ from casatools import image as IA
1360
+ ia_tool = IA()
1361
+ ia_tool.open(imagename)
1362
+ csys_record = ia_tool.coordsys().torecord()
1363
+ ia_tool.close()
1364
+
1365
+ if "obsdate" in csys_record:
1366
+ obsdate = csys_record["obsdate"]
1367
+ m0 = obsdate.get("m0", {})
1368
+ time_value = m0.get("value", None)
1369
+ time_unit = m0.get("unit", None)
1370
+ refer = obsdate.get("refer", None)
1371
+
1372
+ if (refer == "UTC" or time_unit == "d") and time_value:
1373
+ from astropy.time import Time
1374
+ t = Time(time_value, format="mjd")
1375
+ extracted_date = t.to_datetime().date()
1376
+ except Exception as casa_err:
1377
+ print(f"CASA date extraction failed: {casa_err}")
1378
+
1379
+ # Method 3: Try filename parsing (e.g., 20231002_image.fits)
1380
+ if extracted_date is None and imagename:
1381
+ filename = imagename
1382
+ # Try various date patterns in filename
1383
+ patterns = [
1384
+ r'(\d{4})(\d{2})(\d{2})', # YYYYMMDD
1385
+ r'(\d{4})-(\d{2})-(\d{2})', # YYYY-MM-DD
1386
+ r'(\d{4})\.(\d{2})\.(\d{2})', # YYYY.MM.DD
1387
+ ]
1388
+ for pattern in patterns:
1389
+ match = re.search(pattern, filename)
1390
+ if match:
1391
+ try:
1392
+ y, m, d = int(match.group(1)), int(match.group(2)), int(match.group(3))
1393
+ if 1990 < y < 2100 and 1 <= m <= 12 and 1 <= d <= 31:
1394
+ extracted_date = date(y, m, d)
1395
+ break
1396
+ except (ValueError, IndexError):
1397
+ continue
1398
+
1399
+ if extracted_date:
1400
+ self.date_edit.setDate(QDate(extracted_date.year, extracted_date.month, extracted_date.day))
1401
+ self.summary_label.setText(f"Date set to {extracted_date} from current image.")
1402
+ else:
1403
+ QMessageBox.information(self, "Info",
1404
+ "Could not extract date from the current image.\n\n"
1405
+ "Supported formats:\n"
1406
+ "• FITS files with DATE-OBS header\n"
1407
+ "• CASA images with observation date\n"
1408
+ "• Files with date in filename (YYYYMMDD)")
1409
+
1410
+ except Exception as e:
1411
+ QMessageBox.warning(self, "Error", f"Error extracting date: {str(e)}")
1412
+
1413
+ def plot_goes_xray(self):
1414
+ """Fetch and plot GOES X-ray flux for the selected date."""
1415
+ if hasattr(self, 'goes_worker') and self.goes_worker and self.goes_worker.isRunning():
1416
+ return
1417
+
1418
+ qdate = self.date_edit.date()
1419
+ selected_date = date(qdate.year(), qdate.month(), qdate.day())
1420
+
1421
+ # Save current summary to restore later
1422
+ self.previous_summary = self.summary_label.text()
1423
+ self.summary_label.setText(f"Fetching GOES data for {selected_date}...")
1424
+ self.progress.show()
1425
+ self.plot_goes_btn.setEnabled(False)
1426
+
1427
+ from PyQt5.QtWidgets import QApplication
1428
+ QApplication.setOverrideCursor(Qt.WaitCursor)
1429
+
1430
+ self.goes_worker = GOESPlotWorker(selected_date)
1431
+ self.goes_worker.finished.connect(self.on_goes_plot_ready)
1432
+ self.goes_worker.error.connect(self.on_goes_plot_error)
1433
+ self.goes_worker.start()
1434
+
1435
+ def on_goes_plot_ready(self, ts):
1436
+ """Handle ready GOES data."""
1437
+ try:
1438
+ # Check validity
1439
+ if not self.isVisible() and not self.parent(): return
1440
+
1441
+ from PyQt5.QtWidgets import QApplication
1442
+ QApplication.restoreOverrideCursor()
1443
+ self.progress.hide()
1444
+ self.plot_goes_btn.setEnabled(True)
1445
+
1446
+ # Restore previous summary
1447
+ if hasattr(self, 'previous_summary'):
1448
+ self.summary_label.setText(self.previous_summary)
1449
+ else:
1450
+ self.summary_label.setText(f"GOES data loaded for {self.date_edit.date().toString('yyyy-MM-dd')}")
1451
+
1452
+ ts_list = ts if isinstance(ts, list) else [ts]
1453
+ if not ts_list: return
1454
+
1455
+ import matplotlib.pyplot as plt
1456
+ import numpy as np
1457
+ from pandas.plotting import register_matplotlib_converters
1458
+ register_matplotlib_converters()
1459
+
1460
+ # Create figure
1461
+ fig, ax = plt.subplots(figsize=(12, 6))
1462
+
1463
+ # Try native SunPy TimeSeries.plot() first, fallback to manual plotting if it fails
1464
+ # (e.g., due to xarray multi-dimensional indexing deprecation in newer versions)
1465
+ try:
1466
+ for t in ts_list:
1467
+ t.plot(axes=ax)
1468
+ except (IndexError, TypeError, ValueError) as plot_err:
1469
+ # Fallback: manual plotting with proper GOES styling
1470
+ plt.close(fig) # Close the failed figure
1471
+ fig, ax = plt.subplots(figsize=(12, 6))
1472
+
1473
+ # Color scheme for GOES channels
1474
+ colors = {'xrsa': '#1f77b4', 'xrsb': '#d62728'} # Blue for short, Red for long
1475
+ labels = {'xrsa': 'GOES 0.5-4 Å', 'xrsb': 'GOES 1-8 Å'}
1476
+
1477
+ for t in ts_list:
1478
+ # Convert to DataFrame to avoid xarray multi-dimensional indexing deprecation
1479
+ df = t.to_dataframe()
1480
+
1481
+ # Only plot the actual flux columns (xrsa and xrsb), not quality flags
1482
+ for col in ['xrsa', 'xrsb']:
1483
+ if col in df.columns:
1484
+ data = df[col].values
1485
+ # Filter out invalid values (zeros, negatives, NaN)
1486
+ valid_mask = (data > 0) & np.isfinite(data)
1487
+ times = df.index[valid_mask]
1488
+ values = data[valid_mask]
1489
+ ax.plot(times, values,
1490
+ color=colors.get(col, 'gray'),
1491
+ label=labels.get(col, col),
1492
+ linewidth=1.0)
1493
+
1494
+ # Set logarithmic scale for Y-axis (essential for GOES plots)
1495
+ ax.set_yscale('log')
1496
+
1497
+ # Set Y-axis limits and flare classification levels
1498
+ ax.set_ylim(1e-9, 1e-3)
1499
+
1500
+ # Add flare classification horizontal lines and labels
1501
+ flare_levels = {
1502
+ 'A': 1e-8,
1503
+ 'B': 1e-7,
1504
+ 'C': 1e-6,
1505
+ 'M': 1e-5,
1506
+ 'X': 1e-4,
1507
+ }
1508
+ for flare_class, level in flare_levels.items():
1509
+ ax.axhline(y=level, color='gray', linestyle='--', alpha=0.5, linewidth=0.8)
1510
+ ax.text(ax.get_xlim()[1], level, f' {flare_class}',
1511
+ va='center', ha='left', fontsize=10, color='gray')
1512
+
1513
+ # Labels and formatting
1514
+ ax.set_xlabel('Time (UTC)')
1515
+ ax.set_ylabel('Flux (W/m²)')
1516
+ ax.legend(loc='upper right')
1517
+ ax.grid(True, alpha=0.3, which='both')
1518
+
1519
+ ax.set_title(f"GOES X-ray Flux - {self.date_edit.date().toString('yyyy-MM-dd')}")
1520
+
1521
+ plt.tight_layout()
1522
+ plt.show(block=False)
1523
+
1524
+ except RuntimeError:
1525
+ pass
1526
+ except Exception as e:
1527
+ QMessageBox.warning(self, "Plot Error", f"Failed to plot GOES data:\n{str(e)}")
1528
+
1529
+ def on_goes_plot_error(self, error_msg):
1530
+ """Handle GOES fetch error."""
1531
+ from PyQt5.QtWidgets import QApplication
1532
+ QApplication.restoreOverrideCursor()
1533
+ self.progress.hide()
1534
+ self.plot_goes_btn.setEnabled(True)
1535
+ # Restore previous summary
1536
+ if hasattr(self, 'previous_summary'):
1537
+ self.summary_label.setText(self.previous_summary)
1538
+ else:
1539
+ self.summary_label.setText(f"Error fetching GOES data")
1540
+ QMessageBox.warning(self, "GOES Error", f"Failed to fetch GOES data:\n{error_msg}")
1541
+
1542
+ def _parse_date_string(self, date_str: str) -> Optional[date]:
1543
+ """Parse various date string formats."""
1544
+ if not date_str:
1545
+ return None
1546
+
1547
+ date_str = str(date_str).strip()
1548
+
1549
+ try:
1550
+ # ISO format with time (2023-10-02T12:30:00)
1551
+ if 'T' in date_str:
1552
+ # Clean up the date string for parsing
1553
+ clean_str = date_str.replace('Z', '').split('+')[0].split('.')[0]
1554
+ # Handle potential timezone info
1555
+ if '-' in clean_str[11:]: # Timezone like -05:00 after time
1556
+ clean_str = clean_str[:19]
1557
+ try:
1558
+ dt = datetime.fromisoformat(clean_str)
1559
+ return dt.date()
1560
+ except ValueError:
1561
+ # Fallback: just extract date part
1562
+ date_part = clean_str.split('T')[0]
1563
+ if len(date_part) >= 10:
1564
+ return datetime.strptime(date_part[:10], '%Y-%m-%d').date()
1565
+
1566
+ # YYYY-MM-DD
1567
+ if '-' in date_str and len(date_str) >= 10:
1568
+ return datetime.strptime(date_str[:10], '%Y-%m-%d').date()
1569
+
1570
+ # YYYY/MM/DD
1571
+ if '/' in date_str and len(date_str) >= 10:
1572
+ return datetime.strptime(date_str[:10], '%Y/%m/%d').date()
1573
+
1574
+ # YYYYMMDD (8 digits)
1575
+ if date_str.isdigit() and len(date_str) >= 8:
1576
+ return datetime.strptime(date_str[:8], '%Y%m%d').date()
1577
+
1578
+ # MJD (Modified Julian Date)
1579
+ if date_str.replace('.', '').isdigit():
1580
+ mjd = float(date_str)
1581
+ if 40000 < mjd < 100000: # Valid MJD range
1582
+ from astropy.time import Time
1583
+ t = Time(mjd, format='mjd')
1584
+ return t.to_datetime().date()
1585
+ except (ValueError, TypeError, ImportError):
1586
+ pass
1587
+
1588
+ return None
1589
+
1590
+ def display_context_images(self, images):
1591
+ """Display context images."""
1592
+ # Clear existing content from the grid layout
1593
+ while self.images_grid.count():
1594
+ item = self.images_grid.takeAt(0)
1595
+ if item.widget():
1596
+ item.widget().deleteLater()
1597
+ elif item.layout():
1598
+ pass
1599
+
1600
+ # Cancel any pending downloads and reset queue
1601
+ self.image_downloads = {} # Active ones
1602
+ self.download_queue = [] # Waiting ones
1603
+ self.active_downloads = 0
1604
+
1605
+ if not images:
1606
+ no_data = QLabel("Failed to retrieve context images for this date.")
1607
+ no_data.setAlignment(Qt.AlignCenter)
1608
+ self.images_grid.addWidget(no_data)
1609
+ return
1610
+
1611
+ palette = theme_manager.palette
1612
+ header = QLabel("Solar Context Imagery (Helioviewer.org / SolarMonitor.org / NASA SDO / SOHO)")
1613
+ header.setStyleSheet(f"color: {palette['text']}; padding: 10px; opacity: 0.4;")
1614
+ self.images_grid.addWidget(header)
1615
+
1616
+ # Create a card for each image
1617
+ for img in images:
1618
+ card = QFrame()
1619
+ # Theme-aware card styling
1620
+ bg = palette['surface'] if not theme_manager.is_dark else "qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 rgba(80, 80, 80, 0.1), stop:1 rgba(80, 80, 80, 0.2))"
1621
+ border = f"1px solid {palette['border']}" if not theme_manager.is_dark else "1px solid rgba(128, 128, 128, 0.3)"
1622
+
1623
+ card.setStyleSheet(f"""
1624
+ QFrame {{
1625
+ background: {bg};
1626
+ border-radius: 8px;
1627
+ border: {border};
1628
+ }}
1629
+ QLabel {{ color: {palette['text']}; }}
1630
+ """)
1631
+ card_layout = QHBoxLayout(card)
1632
+ card_layout.setContentsMargins(10, 10, 10, 10)
1633
+
1634
+ # Image container - LARGER thumbnails
1635
+ img_container = QFrame()
1636
+ img_container.setFixedSize(320, 320) # Increased from 222
1637
+ img_container.setStyleSheet("background: #000; border: 1px solid #555; border-radius: 4px;")
1638
+ img_container_layout = QVBoxLayout(img_container)
1639
+ img_container_layout.setContentsMargins(0,0,0,0)
1640
+
1641
+ # Use ClickableLabel
1642
+ img_label = ClickableLabel("Queued...")
1643
+ img_label.setAlignment(Qt.AlignCenter)
1644
+ img_label.setStyleSheet("color: #aaa; border: none; background: transparent;")
1645
+ img_label.setToolTip("Click to view High Resolution Image")
1646
+ img_label.setCursor(Qt.PointingHandCursor)
1647
+
1648
+ # Connect click to viewer
1649
+ img_label.clicked.connect(lambda i=img: self.show_high_res_image(i))
1650
+
1651
+ img_container_layout.addWidget(img_label)
1652
+
1653
+ card_layout.addWidget(img_container)
1654
+
1655
+ # Info container
1656
+ info_layout = QVBoxLayout()
1657
+ title = ClickableLabel(img.title)
1658
+ title.clicked.connect(lambda i=img: self.show_high_res_image(i))
1659
+ title.setCursor(Qt.PointingHandCursor)
1660
+ title.setStyleSheet("font-weight: bold; color: #2196F3;")
1661
+
1662
+ instrument_lbl = QLabel(f"Instrument: {img.instrument}")
1663
+ instrument_lbl.setStyleSheet("font-weight: bold; color: #555;")
1664
+
1665
+ desc = QLabel(img.description)
1666
+ desc.setWordWrap(True)
1667
+ desc.setStyleSheet("color: #666;")
1668
+
1669
+ # Credits label instead of View Source Page link
1670
+ credits_lbl = QLabel(f"Credits: {img.credits}")
1671
+ credits_lbl.setStyleSheet("color: #666; font-style: italic;")
1672
+
1673
+ info_layout.addWidget(title)
1674
+ info_layout.addWidget(instrument_lbl)
1675
+ info_layout.addWidget(desc)
1676
+ info_layout.addWidget(credits_lbl)
1677
+
1678
+ # Add save button for high-res image
1679
+ save_btn = QPushButton("💾 Save High-Res")
1680
+ save_btn.setStyleSheet("""
1681
+ QPushButton {
1682
+ padding: 4px 12px;
1683
+ background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
1684
+ stop:0 rgba(33, 150, 243, 0.9),
1685
+ stop:1 rgba(25, 118, 210, 0.9));
1686
+ border: none;
1687
+ border-radius: 4px;
1688
+ color: white;
1689
+ font-weight: bold;
1690
+ }
1691
+ QPushButton:hover {
1692
+ background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
1693
+ stop:0 rgba(33, 150, 243, 1.0),
1694
+ stop:1 rgba(25, 118, 210, 1.0));
1695
+ }
1696
+ """)
1697
+ save_btn.clicked.connect(lambda checked, i=img: self.save_high_res_image(i))
1698
+ info_layout.addWidget(save_btn)
1699
+ info_layout.addStretch()
1700
+
1701
+ card_layout.addLayout(info_layout)
1702
+ self.images_grid.addWidget(card)
1703
+
1704
+ # Add to download queue instead of starting immediately
1705
+ self.download_queue.append((img.thumb_url, img_label, img.page_url))
1706
+
1707
+ self.images_grid.addStretch()
1708
+
1709
+ # Start processing queue
1710
+ self._process_download_queue()
1711
+
1712
+ def show_high_res_image(self, img_obj):
1713
+ """Open dialog to show full resolution image."""
1714
+ # Use None as parent to make it an independent window
1715
+ viewer = FullImageViewer(None, img_obj.title, img_obj.page_url)
1716
+ viewer.setAttribute(Qt.WA_DeleteOnClose) # Cleanup on close
1717
+
1718
+ # Keep reference to prevent GC
1719
+ self.image_viewers.append(viewer)
1720
+ viewer.finished.connect(lambda result, v=viewer: self._cleanup_viewer(v))
1721
+
1722
+ viewer.show() # Non-blocking
1723
+
1724
+ def _cleanup_viewer(self, viewer):
1725
+ """Safely remove viewer reference."""
1726
+ try:
1727
+ if viewer in self.image_viewers:
1728
+ self.image_viewers.remove(viewer)
1729
+ except RuntimeError:
1730
+ pass
1731
+
1732
+ def save_high_res_image(self, img_obj):
1733
+ """Save high resolution image as PNG."""
1734
+ from PyQt5.QtWidgets import QFileDialog, QMessageBox, QProgressDialog
1735
+ from PyQt5.QtCore import Qt
1736
+ import requests
1737
+
1738
+ # Ask user for save location (PNG only)
1739
+ default_name = f"{img_obj.title.replace(' ', '_')}_{self.date_edit.date().toString('yyyyMMdd')}.png"
1740
+
1741
+ file_path, _ = QFileDialog.getSaveFileName(
1742
+ self,
1743
+ "Save High Resolution Image",
1744
+ default_name,
1745
+ "PNG Image (*.png)"
1746
+ )
1747
+
1748
+ if not file_path:
1749
+ return # User cancelled
1750
+
1751
+ # Ensure .png extension
1752
+ if not file_path.endswith('.png'):
1753
+ file_path += '.png'
1754
+
1755
+ # Show progress dialog
1756
+ progress = QProgressDialog("Downloading high resolution image...", "Cancel", 0, 0, self)
1757
+ progress.setWindowModality(Qt.WindowModal)
1758
+ progress.setMinimumDuration(0)
1759
+ progress.setValue(0)
1760
+ progress.show()
1761
+ QApplication.processEvents()
1762
+
1763
+ try:
1764
+ # Download high-res image
1765
+ response = requests.get(img_obj.page_url, timeout=60)
1766
+ response.raise_for_status()
1767
+
1768
+ progress.setLabelText("Saving image...")
1769
+ QApplication.processEvents()
1770
+
1771
+ # Save as PNG
1772
+ with open(file_path, 'wb') as f:
1773
+ f.write(response.content)
1774
+
1775
+ progress.close()
1776
+ QMessageBox.information(self, "Success", f"Image saved to:\n{file_path}")
1777
+
1778
+ except Exception as e:
1779
+ progress.close()
1780
+ QMessageBox.critical(self, "Error", f"Failed to save image:\n{str(e)}")
1781
+
1782
+ def _process_download_queue(self):
1783
+ """Start next downloads if under limit."""
1784
+ MAX_CONCURRENT = 4
1785
+
1786
+ while self.active_downloads < MAX_CONCURRENT and self.download_queue:
1787
+ url, label, page_url = self.download_queue.pop(0)
1788
+ self.active_downloads += 1
1789
+ label.setText("Loading...")
1790
+ self._start_download(url, label, page_url)
1791
+
1792
+ def _start_download(self, url, label, page_url):
1793
+ loader = ImageLoader(url, page_url)
1794
+ loader.loaded.connect(lambda data, l=label: self._on_image_loaded(data, l))
1795
+ loader.error.connect(lambda err, l=label: self._on_image_error(err, l))
1796
+
1797
+ # Cleanup and process next on finish
1798
+ loader.finished.connect(self._on_download_finished)
1799
+
1800
+ # Keep reference
1801
+ self.image_downloads[id(loader)] = loader
1802
+ loader.start()
1803
+
1804
+ def _on_download_finished(self):
1805
+ """Handle download thread finish (cleanup and next)."""
1806
+ try:
1807
+ # Check validity
1808
+ if not self.isVisible() and not self.parent(): return
1809
+
1810
+ sender = self.sender()
1811
+ if sender:
1812
+ self.image_downloads.pop(id(sender), None)
1813
+
1814
+ self.active_downloads -= 1
1815
+ if self.active_downloads < 0: self.active_downloads = 0
1816
+
1817
+ self._process_download_queue()
1818
+ except RuntimeError:
1819
+ pass
1820
+
1821
+ def _on_image_loaded(self, data, label):
1822
+ """Handle image download completion."""
1823
+ try:
1824
+ # Check if label is still valid (not deleted c++ object)
1825
+ if not label: return
1826
+
1827
+ pixmap = QPixmap()
1828
+ if pixmap.loadFromData(data):
1829
+ label.setPixmap(pixmap.scaled(QSize(320, 320), Qt.KeepAspectRatio, Qt.SmoothTransformation))
1830
+ label.setText("")
1831
+ else:
1832
+ label.setText("Format Error")
1833
+ except RuntimeError:
1834
+ # Widget deleted, ignore
1835
+ pass
1836
+
1837
+ def _on_image_error(self, error_msg, label):
1838
+ """Handle download error."""
1839
+ try:
1840
+ if not label: return
1841
+
1842
+ # shorten error message
1843
+ short = "Connection Error" if "101" in error_msg or "Unreachable" in str(error_msg) else "Error"
1844
+ label.setText(f"{short}\nRetrying..." if "101" in error_msg else f"{short}")
1845
+ if "101" in error_msg:
1846
+ # Maybe retry? For now just show error.
1847
+ pass
1848
+ except RuntimeError:
1849
+ pass
1850
+
1851
+
1852
+ def set_date_from_fits(self, fits_date: Optional[date]):
1853
+ """Set the date from a FITS file's DATE-OBS."""
1854
+ if fits_date:
1855
+ self.date_edit.setDate(QDate(fits_date.year, fits_date.month, fits_date.day))
1856
+
1857
+
1858
+ def show_noaa_events_viewer(parent=None, initial_date: Optional[date] = None):
1859
+ """
1860
+ Show the NOAA Events Viewer dialog.
1861
+
1862
+ Args:
1863
+ parent: Parent widget
1864
+ initial_date: Optional initial date (e.g., from FITS header)
1865
+
1866
+ Returns:
1867
+ The viewer window instance
1868
+ """
1869
+ viewer = NOAAEventsViewer(parent, initial_date)
1870
+ viewer.show()
1871
+
1872
+ # If initial date provided, auto-fetch
1873
+ if initial_date:
1874
+ viewer.fetch_data()
1875
+
1876
+ return viewer
1877
+
1878
+
1879
+ def main():
1880
+ import argparse
1881
+
1882
+ # Parse command line arguments
1883
+ parser = argparse.ArgumentParser(description="NOAA Solar Events Viewer")
1884
+ parser.add_argument("--theme", choices=["light", "dark"], default="dark",
1885
+ help="Set application theme (light or dark)")
1886
+ args = parser.parse_args()
1887
+
1888
+ # Setup application
1889
+ app = QApplication(sys.argv)
1890
+ app.setStyle("Fusion")
1891
+
1892
+ # Apply theme
1893
+ if args.theme == "light":
1894
+ theme_manager.set_theme(theme_manager.LIGHT)
1895
+ else:
1896
+ theme_manager.set_theme(theme_manager.DARK)
1897
+
1898
+ # Apply detailed palette to application (replicates main.py logic)
1899
+ palette = theme_manager.palette
1900
+ qt_palette = QPalette()
1901
+ qt_palette.setColor(QPalette.Window, QColor(palette["window"]))
1902
+ qt_palette.setColor(QPalette.WindowText, QColor(palette["text"]))
1903
+ qt_palette.setColor(QPalette.Base, QColor(palette["base"]))
1904
+ qt_palette.setColor(QPalette.AlternateBase, QColor(palette["surface"]))
1905
+ qt_palette.setColor(QPalette.Text, QColor(palette["text"]))
1906
+ qt_palette.setColor(QPalette.Button, QColor(palette["button"]))
1907
+ qt_palette.setColor(QPalette.ButtonText, QColor(palette["text"]))
1908
+ qt_palette.setColor(QPalette.Highlight, QColor(palette["highlight"]))
1909
+ qt_palette.setColor(QPalette.HighlightedText, Qt.white)
1910
+ qt_palette.setColor(QPalette.Link, QColor(palette["highlight"]))
1911
+ qt_palette.setColor(QPalette.Disabled, QPalette.Text, QColor(palette["disabled"]))
1912
+ qt_palette.setColor(QPalette.Disabled, QPalette.ButtonText, QColor(palette["disabled"]))
1913
+
1914
+ app.setPalette(qt_palette)
1915
+ app.setStyleSheet(theme_manager.stylesheet)
1916
+
1917
+ viewer = NOAAEventsViewer()
1918
+ viewer.show()
1919
+ sys.exit(app.exec_())
1920
+
1921
+ if __name__ == "__main__":
1922
+ main()