solarviewer 1.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- solar_radio_image_viewer/__init__.py +12 -0
- solar_radio_image_viewer/assets/add_tab_default.png +0 -0
- solar_radio_image_viewer/assets/add_tab_default_light.png +0 -0
- solar_radio_image_viewer/assets/add_tab_hover.png +0 -0
- solar_radio_image_viewer/assets/add_tab_hover_light.png +0 -0
- solar_radio_image_viewer/assets/browse.png +0 -0
- solar_radio_image_viewer/assets/browse_light.png +0 -0
- solar_radio_image_viewer/assets/close_tab_default.png +0 -0
- solar_radio_image_viewer/assets/close_tab_default_light.png +0 -0
- solar_radio_image_viewer/assets/close_tab_hover.png +0 -0
- solar_radio_image_viewer/assets/close_tab_hover_light.png +0 -0
- solar_radio_image_viewer/assets/ellipse_selection.png +0 -0
- solar_radio_image_viewer/assets/ellipse_selection_light.png +0 -0
- solar_radio_image_viewer/assets/icons8-ellipse-90.png +0 -0
- solar_radio_image_viewer/assets/icons8-ellipse-90_light.png +0 -0
- solar_radio_image_viewer/assets/icons8-info-90.png +0 -0
- solar_radio_image_viewer/assets/icons8-info-90_light.png +0 -0
- solar_radio_image_viewer/assets/profile.png +0 -0
- solar_radio_image_viewer/assets/profile_light.png +0 -0
- solar_radio_image_viewer/assets/rectangle_selection.png +0 -0
- solar_radio_image_viewer/assets/rectangle_selection_light.png +0 -0
- solar_radio_image_viewer/assets/reset.png +0 -0
- solar_radio_image_viewer/assets/reset_light.png +0 -0
- solar_radio_image_viewer/assets/ruler.png +0 -0
- solar_radio_image_viewer/assets/ruler_light.png +0 -0
- solar_radio_image_viewer/assets/search.png +0 -0
- solar_radio_image_viewer/assets/search_light.png +0 -0
- solar_radio_image_viewer/assets/settings.png +0 -0
- solar_radio_image_viewer/assets/settings_light.png +0 -0
- solar_radio_image_viewer/assets/splash.fits +0 -0
- solar_radio_image_viewer/assets/zoom_60arcmin.png +0 -0
- solar_radio_image_viewer/assets/zoom_60arcmin_light.png +0 -0
- solar_radio_image_viewer/assets/zoom_in.png +0 -0
- solar_radio_image_viewer/assets/zoom_in_light.png +0 -0
- solar_radio_image_viewer/assets/zoom_out.png +0 -0
- solar_radio_image_viewer/assets/zoom_out_light.png +0 -0
- solar_radio_image_viewer/create_video.py +1345 -0
- solar_radio_image_viewer/dialogs.py +2665 -0
- solar_radio_image_viewer/from_simpl/__init__.py +184 -0
- solar_radio_image_viewer/from_simpl/caltable_visualizer.py +1001 -0
- solar_radio_image_viewer/from_simpl/dynamic_spectra_dialog.py +332 -0
- solar_radio_image_viewer/from_simpl/make_dynamic_spectra.py +351 -0
- solar_radio_image_viewer/from_simpl/pipeline_logger_gui.py +1232 -0
- solar_radio_image_viewer/from_simpl/simpl_theme.py +352 -0
- solar_radio_image_viewer/from_simpl/utils.py +984 -0
- solar_radio_image_viewer/from_simpl/view_dynamic_spectra_GUI.py +1975 -0
- solar_radio_image_viewer/helioprojective.py +1916 -0
- solar_radio_image_viewer/helioprojective_viewer.py +817 -0
- solar_radio_image_viewer/helioviewer_browser.py +1514 -0
- solar_radio_image_viewer/main.py +148 -0
- solar_radio_image_viewer/move_phasecenter.py +1269 -0
- solar_radio_image_viewer/napari_viewer.py +368 -0
- solar_radio_image_viewer/noaa_events/__init__.py +32 -0
- solar_radio_image_viewer/noaa_events/noaa_events.py +430 -0
- solar_radio_image_viewer/noaa_events/noaa_events_gui.py +1922 -0
- solar_radio_image_viewer/norms.py +293 -0
- solar_radio_image_viewer/radio_data_downloader/__init__.py +25 -0
- solar_radio_image_viewer/radio_data_downloader/radio_data_downloader.py +756 -0
- solar_radio_image_viewer/radio_data_downloader/radio_data_downloader_gui.py +528 -0
- solar_radio_image_viewer/searchable_combobox.py +220 -0
- solar_radio_image_viewer/solar_context/__init__.py +41 -0
- solar_radio_image_viewer/solar_context/active_regions.py +371 -0
- solar_radio_image_viewer/solar_context/cme_alerts.py +234 -0
- solar_radio_image_viewer/solar_context/context_images.py +297 -0
- solar_radio_image_viewer/solar_context/realtime_data.py +528 -0
- solar_radio_image_viewer/solar_data_downloader/__init__.py +35 -0
- solar_radio_image_viewer/solar_data_downloader/solar_data_downloader.py +1667 -0
- solar_radio_image_viewer/solar_data_downloader/solar_data_downloader_cli.py +901 -0
- solar_radio_image_viewer/solar_data_downloader/solar_data_downloader_gui.py +1210 -0
- solar_radio_image_viewer/styles.py +643 -0
- solar_radio_image_viewer/utils/__init__.py +32 -0
- solar_radio_image_viewer/utils/rate_limiter.py +255 -0
- solar_radio_image_viewer/utils.py +952 -0
- solar_radio_image_viewer/video_dialog.py +2629 -0
- solar_radio_image_viewer/video_utils.py +656 -0
- solar_radio_image_viewer/viewer.py +11174 -0
- solarviewer-1.0.2.dist-info/METADATA +343 -0
- solarviewer-1.0.2.dist-info/RECORD +82 -0
- solarviewer-1.0.2.dist-info/WHEEL +5 -0
- solarviewer-1.0.2.dist-info/entry_points.txt +8 -0
- solarviewer-1.0.2.dist-info/licenses/LICENSE +21 -0
- solarviewer-1.0.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,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()
|