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,1514 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Helioviewer Browser - Time-series viewer for solar images from Helioviewer API.
4
+ """
5
+
6
+ from PyQt5.QtWidgets import (
7
+ QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QSplitter,
8
+ QLabel, QPushButton, QDateTimeEdit, QSpinBox, QListWidget,
9
+ QScrollArea, QFrame, QProgressBar, QComboBox, QFileDialog,
10
+ QMessageBox, QDialog, QListWidgetItem, QApplication, QGroupBox
11
+ )
12
+ from PyQt5.QtCore import Qt, QDateTime, QThread, pyqtSignal, QTimer, QSize
13
+ from PyQt5.QtGui import QPixmap, QImage, QIcon
14
+ from datetime import datetime, timedelta
15
+ from collections import OrderedDict
16
+ import requests
17
+ from typing import List, Dict, Optional, Tuple
18
+ import os
19
+
20
+ # Global list to keep threads alive if window is closed while they are running
21
+ # This prevents "QThread: Destroyed while thread is still running"
22
+ _active_threads = []
23
+
24
+ # Known instrument cadences (in seconds) - Based on official mission documentation
25
+ INSTRUMENT_CADENCES = {
26
+ # SDO/AIA - 12s for EUV, 24s for UV, 3600s for visible
27
+ 'AIA 94': 12,
28
+ 'AIA 131': 12,
29
+ 'AIA 171': 12,
30
+ 'AIA 193': 12,
31
+ 'AIA 211': 12,
32
+ 'AIA 304': 12,
33
+ 'AIA 335': 12,
34
+ 'AIA 1600': 24,
35
+ 'AIA 1700': 24,
36
+ 'AIA 4500': 3600,
37
+
38
+ # SDO/HMI - 45s for both magnetogram and continuum
39
+ 'HMI magnetogram': 45,
40
+ 'HMI continuum': 45,
41
+
42
+ # SOHO/EIT - 12 minutes (720s) for all wavelengths
43
+ 'EIT 171': 720,
44
+ 'EIT 195': 720,
45
+ 'EIT 284': 720,
46
+ 'EIT 304': 720,
47
+
48
+ # SOHO/MDI - 96 minutes
49
+ 'MDI magnetogram': 96,
50
+ 'MDI continuum': 96,
51
+
52
+ # SOHO/LASCO - 30 minutes for both C2 and C3
53
+ 'LASCO': 1800,
54
+ 'LASCO C2': 1800,
55
+ 'LASCO C3': 1800,
56
+
57
+ # STEREO/SECCHI EUVI - 2.5 minutes (150s)
58
+ 'EUVI': 150,
59
+ 'EUVI 171': 150,
60
+ 'EUVI 195': 150,
61
+ 'EUVI 284': 150,
62
+ 'EUVI 304': 150,
63
+
64
+ # STEREO/SECCHI Coronagraphs
65
+ 'COR1': 300, # 5 minutes
66
+ 'COR2': 900, # 15 minutes
67
+
68
+ # GOES/SUVI - 10s for all channels
69
+ 'SUVI': 10,
70
+ 'SUVI 94': 10,
71
+ 'SUVI 131': 10,
72
+ 'SUVI 171': 10,
73
+ 'SUVI 195': 10,
74
+ 'SUVI 284': 10,
75
+ 'SUVI 304': 10,
76
+
77
+ # Solar Orbiter/EUI
78
+ 'EUI': 600,
79
+ 'FSI': 600, # Full Sun Imager - 10 minutes
80
+ 'HRI': 300, # High Resolution Imager - 5 minutes
81
+
82
+ # Legacy/Other
83
+ 'SXT': 60, # Yohkoh SXT
84
+ }
85
+
86
+ # Whitelist: Only instruments with verified cadence AND FOV parameters
87
+ # Using patterns that match API-returned names
88
+ VERIFIED_INSTRUMENTS = [
89
+ # SDO - 100% verified
90
+ 'AIA 94', 'AIA 131', 'AIA 171', 'AIA 193', 'AIA 211', 'AIA 304', 'AIA 335',
91
+ 'AIA 1600', 'AIA 1700', 'AIA 4500',
92
+ 'HMI magnetogram', 'HMI continuum',
93
+
94
+ # SOHO - 100% verified
95
+ 'EIT 171', 'EIT 195', 'EIT 284', 'EIT 304',
96
+ 'LASCO/C2 white-light', 'LASCO/C3 white-light', # API format
97
+
98
+ # STEREO - 100% verified (both A and B)
99
+ 'SECCHI/EUVI 171', 'SECCHI/EUVI 195', 'SECCHI/EUVI 284', 'SECCHI/EUVI 304',
100
+ 'SECCHI/COR1 white-light', 'SECCHI/COR2 white-light',
101
+
102
+ # GOES/SUVI - 100% verified
103
+ 'SUVI 94', 'SUVI 131', 'SUVI 171', 'SUVI 195', 'SUVI 284', 'SUVI 304',
104
+ ]
105
+
106
+
107
+ def fetch_all_instruments() -> List[Tuple[str, str, str, str]]:
108
+ """Fetch all available instruments from Helioviewer API."""
109
+ try:
110
+ url = 'https://api.helioviewer.org/v2/getDataSources/'
111
+ response = requests.get(url, timeout=10)
112
+ response.raise_for_status()
113
+ data = response.json()
114
+
115
+ instruments = []
116
+
117
+ for observatory, obs_data in sorted(data.items()):
118
+ if not isinstance(obs_data, dict):
119
+ continue
120
+
121
+ for instrument, inst_data in sorted(obs_data.items()):
122
+ if not isinstance(inst_data, dict):
123
+ continue
124
+
125
+ # At this level, keys are either measurements (dict with metadata) or detectors (dict of measurements)
126
+ for key, value in sorted(inst_data.items()):
127
+ if not isinstance(value, dict):
128
+ continue
129
+
130
+ # Check if this is a measurement (has 'sourceId') or a detector level
131
+ if 'sourceId' in value:
132
+ # This is a measurement directly under instrument
133
+ measurement = key
134
+ source_id = value['sourceId']
135
+ # Layer format is [sourceId,visible,opacity]
136
+ layer = f'[{source_id},1,100]'
137
+ name = f'{instrument} {measurement}'
138
+
139
+ # Determine cadence - try exact match first, then instrument family
140
+ cadence = INSTRUMENT_CADENCES.get(
141
+ name, # Try exact: "AIA 171"
142
+ INSTRUMENT_CADENCES.get(
143
+ instrument, # Try instrument: "AIA"
144
+ 60 # Default: 60s
145
+ )
146
+ )
147
+
148
+ description = f'{observatory} - {name}'
149
+ instruments.append((name, layer, observatory, cadence))
150
+ else:
151
+ # This is a detector level, iterate measurements
152
+ detector = key
153
+ for measurement, meas_data in sorted(value.items()):
154
+ if not isinstance(meas_data, dict):
155
+ continue
156
+ if 'sourceId' not in meas_data:
157
+ continue
158
+
159
+ source_id = meas_data['sourceId']
160
+ # Layer format is [sourceId,visible,opacity]
161
+ layer = f'[{source_id},1,100]'
162
+ name = f'{instrument}/{detector} {measurement}'
163
+
164
+ # Determine cadence - try exact match first, then detector, then instrument
165
+ cadence = INSTRUMENT_CADENCES.get(
166
+ name, # Try exact: "SECCHI/EUVI 171"
167
+ INSTRUMENT_CADENCES.get(
168
+ f'{detector} {measurement}', # Try: "EUVI 171"
169
+ INSTRUMENT_CADENCES.get(
170
+ detector, # Try: "EUVI"
171
+ INSTRUMENT_CADENCES.get(instrument, 60) # Default: 60s
172
+ )
173
+ )
174
+ )
175
+
176
+ description = f'{observatory} - {name}'
177
+ instruments.append((name, layer, observatory, cadence))
178
+
179
+ print(f"Loaded {len(instruments)} instruments from Helioviewer")
180
+
181
+ # Filter to only verified instruments
182
+ verified = []
183
+ for name, layer, obs, cad in instruments:
184
+ # Check if instrument name is in verified list
185
+ # Also check without observatory prefix for STEREO instruments
186
+ if name in VERIFIED_INSTRUMENTS:
187
+ verified.append((name, layer, obs, cad))
188
+ continue
189
+
190
+ # For STEREO, also accept if base name matches (e.g., "EUVI 171" from "SECCHI/EUVI 171")
191
+ if '/' in name:
192
+ base_name = name.split('/')[-1] # Get "EUVI 171" from "SECCHI/EUVI 171"
193
+ if base_name in ['EUVI 171', 'EUVI 195', 'EUVI 284', 'EUVI 304', 'COR1 white-light', 'COR2 white-light']:
194
+ verified.append((name, layer, obs, cad))
195
+
196
+ print(f"Filtered to {len(verified)} verified instruments (with known cadence & FOV)")
197
+ return verified
198
+ except Exception as e:
199
+ print(f"Error fetching instruments: {e}")
200
+ import traceback
201
+ traceback.print_exc()
202
+ # Fallback to essential instruments
203
+ from .solar_context.context_images import ESSENTIAL_INSTRUMENTS
204
+ return ESSENTIAL_INSTRUMENTS
205
+
206
+
207
+ class ImageDownloader(QThread):
208
+ """Thread to download a single image."""
209
+ finished = pyqtSignal(str, QPixmap) # instrument, pixmap
210
+ error = pyqtSignal(str, str) # instrument, error_msg
211
+
212
+ def __init__(self, instrument_name, layer_path, timestamp, width=1000, height=1000):
213
+ super().__init__()
214
+ self.instrument_name = instrument_name
215
+ self.layer_path = layer_path
216
+ self.timestamp = timestamp
217
+ self.width = width
218
+ self.height = height
219
+ self.running = True
220
+
221
+ def stop(self):
222
+ """Signal the thread to stop."""
223
+ self.running = False
224
+
225
+ def run(self):
226
+ try:
227
+ # Determine imageScale based on instrument
228
+ layer_lower = self.layer_path.lower()
229
+
230
+ # Parse instrument from layer (format: [sourceId,1,100])
231
+ # Need to determine instrument type from name passed in
232
+ inst_lower = self.instrument_name.lower()
233
+
234
+ # Base imageScale for 1000x1000 images
235
+ # Solar disk instruments - tight fit (2.0-4.0)
236
+ if any(x in inst_lower for x in ['aia', 'hmi', 'eit', 'euvi', 'suvi']):
237
+ if 'hmi continuum' in inst_lower:
238
+ base_image_scale = 3.0 # HMI continuum - slightly wider
239
+ elif 'hmi' in inst_lower:
240
+ base_image_scale = 2.5 # HMI magnetogram
241
+ else:
242
+ base_image_scale = 2.5 # AIA, EIT, EUVI, SUVI - fits solar disk in 1000px
243
+
244
+ # Coronagraphs - wide field
245
+ elif 'lasco' in inst_lower and 'c2' in inst_lower:
246
+ base_image_scale = 12.0 # LASCO C2: fits up to 6 Rsun in 1000px
247
+ elif 'lasco' in inst_lower and 'c3' in inst_lower:
248
+ base_image_scale = 58.0 # LASCO C3: fits up to 30 Rsun in 1000px
249
+ elif any(x in inst_lower for x in ['cor1', 'cor2']):
250
+ if 'cor1' in inst_lower:
251
+ base_image_scale = 6.0 # COR1: ~1.4-4 Rsun
252
+ else:
253
+ base_image_scale = 15.0 # COR2: ~2.5-15 Rsun
254
+
255
+ # Other instruments
256
+ else:
257
+ base_image_scale = 4.0 # Default moderate zoom
258
+
259
+ # Adjust imageScale to maintain FOV for different image sizes
260
+ # Formula: new_scale = base_scale * (1000 / actual_size)
261
+ size_factor = 1000.0 / self.width
262
+ image_scale = str(base_image_scale * size_factor)
263
+
264
+ # Build Helioviewer URL
265
+ from urllib.parse import urlencode
266
+ base_url = "https://api.helioviewer.org/v2/takeScreenshot/"
267
+ params = {
268
+ 'date': self.timestamp.toString("yyyy-MM-ddTHH:mm:ss") + "Z",
269
+ 'imageScale': image_scale,
270
+ 'layers': self.layer_path,
271
+ 'x0': '0',
272
+ 'y0': '0',
273
+ 'width': str(self.width),
274
+ 'height': str(self.height),
275
+ 'display': 'true',
276
+ 'watermark': 'false'
277
+ }
278
+ url = f"{base_url}?{urlencode(params)}"
279
+
280
+ if not self.running:
281
+ return
282
+
283
+ # Download image
284
+ response = requests.get(url, timeout=60)
285
+ response.raise_for_status()
286
+
287
+ if not self.running:
288
+ return
289
+
290
+ # Convert to QPixmap
291
+ qimage = QImage()
292
+ if qimage.loadFromData(response.content):
293
+ pixmap = QPixmap.fromImage(qimage)
294
+ if self.running:
295
+ self.finished.emit(self.instrument_name, pixmap)
296
+ else:
297
+ if self.running:
298
+ self.error.emit(self.instrument_name, "Failed to load image data")
299
+
300
+ except Exception as e:
301
+ if self.running:
302
+ self.error.emit(self.instrument_name, str(e))
303
+
304
+
305
+ class FrameLoader(QThread):
306
+ """Thread to load all instruments for multiple frames in background."""
307
+ frame_loaded = pyqtSignal(int, dict) # frame_index, {instrument: pixmap}
308
+ progress = pyqtSignal(int, int) # current, total
309
+
310
+ def __init__(self, timestamps, instruments_data, width=1000, height=1000):
311
+ super().__init__()
312
+ self.timestamps = timestamps
313
+ self.instruments_data = instruments_data # List of (name, layer_path, observatory, desc)
314
+ self.width = width
315
+ self.height = height
316
+ self.running = True
317
+
318
+ def run(self):
319
+ total_frames = len(self.timestamps)
320
+ for frame_idx, timestamp in enumerate(self.timestamps):
321
+ if not self.running:
322
+ break
323
+
324
+ frame_data = {}
325
+ for instrument_name, layer_path, observatory, description in self.instruments_data:
326
+ if not self.running:
327
+ break
328
+
329
+ try:
330
+ # Determine base imageScale for 1000x1000
331
+ inst_lower = instrument_name.lower()
332
+
333
+ # Solar disk instruments - tight fit
334
+ if any(x in inst_lower for x in ['aia', 'hmi', 'eit', 'euvi', 'suvi']):
335
+ if 'hmi continuum' in inst_lower:
336
+ base_image_scale = 3.0
337
+ elif 'hmi' in inst_lower:
338
+ base_image_scale = 2.5
339
+ else:
340
+ base_image_scale = 2.5
341
+
342
+ # Coronagraphs
343
+ elif 'lasco' in inst_lower and 'c2' in inst_lower:
344
+ base_image_scale = 12.0
345
+ elif 'lasco' in inst_lower and 'c3' in inst_lower:
346
+ base_image_scale = 58.0
347
+ elif any(x in inst_lower for x in ['cor1', 'cor2']):
348
+ base_image_scale = 6.0 if 'cor1' in inst_lower else 15.0
349
+ else:
350
+ base_image_scale = 4.0
351
+
352
+ # Adjust imageScale to maintain FOV
353
+ size_factor = 1000.0 / self.width
354
+ image_scale = str(base_image_scale * size_factor)
355
+
356
+ # Build URL
357
+ from urllib.parse import urlencode
358
+ base_url = "https://api.helioviewer.org/v2/takeScreenshot/"
359
+ params = {
360
+ 'date': timestamp.toString("yyyy-MM-ddTHH:mm:ss") + "Z",
361
+ 'imageScale': image_scale,
362
+ 'layers': layer_path,
363
+ 'x0': '0',
364
+ 'y0': '0',
365
+ 'width': str(self.width),
366
+ 'height': str(self.height),
367
+ 'display': 'true',
368
+ 'watermark': 'false'
369
+ }
370
+ url = f"{base_url}?{urlencode(params)}"
371
+
372
+ # Download
373
+ response = requests.get(url, timeout=60)
374
+ response.raise_for_status()
375
+
376
+ # Convert to pixmap
377
+ qimage = QImage()
378
+ if qimage.loadFromData(response.content):
379
+ frame_data[instrument_name] = QPixmap.fromImage(qimage)
380
+
381
+ except Exception as e:
382
+ print(f"Error loading {instrument_name} for frame {frame_idx}: {e}")
383
+
384
+ self.frame_loaded.emit(frame_idx, frame_data)
385
+ self.progress.emit(frame_idx + 1, total_frames)
386
+
387
+ def stop(self):
388
+ self.running = False
389
+
390
+
391
+ class FullImageDialog(QDialog):
392
+ """Dialog to show full resolution image."""
393
+ def __init__(self, parent, pixmap, title):
394
+ super().__init__(parent)
395
+ self.setWindowTitle(title)
396
+ self.resize(1200, 1200)
397
+
398
+ layout = QVBoxLayout(self)
399
+
400
+ # Scroll area for large image
401
+ scroll = QScrollArea()
402
+ scroll.setWidgetResizable(True)
403
+
404
+ label = QLabel()
405
+ label.setPixmap(pixmap)
406
+ label.setAlignment(Qt.AlignCenter)
407
+
408
+ scroll.setWidget(label)
409
+ layout.addWidget(scroll)
410
+
411
+ # Close button
412
+ close_btn = QPushButton("Close")
413
+ close_btn.clicked.connect(self.accept)
414
+ layout.addWidget(close_btn)
415
+
416
+
417
+ class HelioviewerBrowser(QMainWindow):
418
+ """Main browser window for Helioviewer time-series."""
419
+
420
+ def __init__(self, parent=None, initial_start=None, initial_end=None):
421
+ super().__init__(parent)
422
+ self.setWindowTitle("🌐 Helioviewer Browser")
423
+ self.resize(1600, 1000)
424
+
425
+ # Data storage
426
+ self.frames = OrderedDict() # {frame_index: {instrument: pixmap}}
427
+ self.timestamps = []
428
+ self.current_frame = 0
429
+ self.playing = False
430
+ self.frame_loaders = []
431
+
432
+ # Download queue management for parallel loading
433
+ self.download_queue = [] # Queue of (frame_idx, instrument_data)
434
+ self.active_downloads = 0
435
+ self.max_concurrent_downloads = 4
436
+
437
+ # Store initial times
438
+ self.initial_start = initial_start
439
+ self.initial_end = initial_end
440
+
441
+ # Animation timer
442
+ self.animation_timer = QTimer()
443
+ self.animation_timer.timeout.connect(self.next_frame)
444
+
445
+ # Flag to prevent updates during close
446
+ self._closing = False
447
+
448
+ self.init_ui()
449
+
450
+ def init_ui(self):
451
+ """Initialize the user interface."""
452
+ central = QWidget()
453
+ self.setCentralWidget(central)
454
+ main_layout = QVBoxLayout(central)
455
+ main_layout.setContentsMargins(10, 10, 10, 10)
456
+
457
+ # Top panel - Time range controls
458
+ self.create_time_controls(main_layout)
459
+
460
+ # Progress bar
461
+ self.progress_bar = QProgressBar()
462
+ self.progress_bar.setVisible(False)
463
+ main_layout.addWidget(self.progress_bar)
464
+
465
+ # Splitter for left/right panels
466
+ splitter = QSplitter(Qt.Horizontal)
467
+
468
+ # Left panel - Instrument filter
469
+ self.create_instrument_panel(splitter)
470
+
471
+ # Right panel - Image grid
472
+ self.create_image_panel(splitter)
473
+
474
+ splitter.setSizes([300, 1300])
475
+ main_layout.addWidget(splitter, 1)
476
+
477
+ # Bottom panel - Animation controls
478
+ self.create_animation_controls(main_layout)
479
+
480
+ def create_time_controls(self, parent_layout):
481
+ """Create time range input controls."""
482
+ group = QGroupBox("Time Range")
483
+ layout = QHBoxLayout(group)
484
+
485
+ # Start time
486
+ layout.addWidget(QLabel("Start:"))
487
+ self.start_datetime = QDateTimeEdit()
488
+ self.start_datetime.setCalendarPopup(True)
489
+ self.start_datetime.setDisplayFormat("yyyy-MM-dd HH:mm:ss")
490
+ # Default to yesterday 10:00 UTC or initial_start if provided
491
+ if hasattr(self, 'initial_start') and self.initial_start:
492
+ self.start_datetime.setDateTime(self.initial_start)
493
+ else:
494
+ default_start = QDateTime.currentDateTimeUtc().addDays(-1)
495
+ default_start.setTime(default_start.time().fromString("10:00:00", "HH:mm:ss"))
496
+ self.start_datetime.setDateTime(default_start)
497
+ layout.addWidget(self.start_datetime)
498
+
499
+ # End time
500
+ layout.addWidget(QLabel("End:"))
501
+ self.end_datetime = QDateTimeEdit()
502
+ self.end_datetime.setCalendarPopup(True)
503
+ self.end_datetime.setDisplayFormat("yyyy-MM-dd HH:mm:ss")
504
+ if hasattr(self, 'initial_end') and self.initial_end:
505
+ self.end_datetime.setDateTime(self.initial_end)
506
+ else:
507
+ default_end = self.start_datetime.dateTime().addSecs(3600) # 1 hour later
508
+ self.end_datetime.setDateTime(default_end)
509
+ layout.addWidget(self.end_datetime)
510
+
511
+ # Interval
512
+ layout.addWidget(QLabel("Interval (seconds):"))
513
+ self.interval_spin = QSpinBox()
514
+ self.interval_spin.setRange(10, 3600)
515
+ self.interval_spin.setValue(30) # Default 30 seconds
516
+ self.interval_spin.setSuffix(" s")
517
+ layout.addWidget(self.interval_spin)
518
+
519
+ layout.addStretch()
520
+
521
+ # Load button
522
+ # Add load emoji
523
+ self.load_button = QPushButton("📥 Load")
524
+ self.load_button.clicked.connect(self.load_time_series)
525
+ self.load_button.setStyleSheet("""
526
+ QPushButton {
527
+ background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
528
+ stop:0 #2196F3, stop:1 #1976D2);
529
+ color: white;
530
+ padding: 8px 16px;
531
+ border: none;
532
+ border-radius: 4px;
533
+ font-weight: bold;
534
+ }
535
+ QPushButton:hover {
536
+ background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
537
+ stop:0 #42A5F5, stop:1 #2196F3);
538
+ }
539
+ """)
540
+ layout.addWidget(self.load_button)
541
+
542
+ parent_layout.addWidget(group)
543
+
544
+ def create_instrument_panel(self, parent_splitter):
545
+ """Create left panel with instrument filter (single selection)."""
546
+ panel = QWidget()
547
+ layout = QVBoxLayout(panel)
548
+
549
+ label = QLabel("<b>Select Instrument</b>")
550
+ layout.addWidget(label)
551
+
552
+ # Load all instruments button
553
+ refresh_btn = QPushButton("🔄 Refresh Instruments")
554
+ refresh_btn.clicked.connect(self.load_all_instruments)
555
+ layout.addWidget(refresh_btn)
556
+
557
+ # Instrument list - single selection
558
+ self.instrument_list = QListWidget()
559
+ self.instrument_list.setSelectionMode(QListWidget.SingleSelection)
560
+
561
+ # Load instruments
562
+ self.load_all_instruments()
563
+
564
+ # Connect selection change
565
+ self.instrument_list.itemSelectionChanged.connect(self.on_instrument_selected)
566
+ layout.addWidget(self.instrument_list)
567
+
568
+ # Image size control
569
+ layout.addSpacing(10)
570
+ size_label = QLabel("<b>Image Size (px)</b>")
571
+ layout.addWidget(size_label)
572
+
573
+ size_layout = QHBoxLayout()
574
+ size_layout.addWidget(QLabel("Size:"))
575
+ self.image_size_spin = QSpinBox()
576
+ self.image_size_spin.setRange(100, 4000)
577
+ self.image_size_spin.setValue(800)
578
+ self.image_size_spin.setSuffix(" px")
579
+ self.image_size_spin.setToolTip("Size for both width and height (100-4000 pixels)")
580
+ size_layout.addWidget(self.image_size_spin)
581
+ layout.addLayout(size_layout)
582
+
583
+ #size_info = QLabel("Note: Higher values take longer to download")
584
+ #size_info.setStyleSheet("color: #888;")
585
+ #size_info.setWordWrap(True)
586
+ #layout.addWidget(size_info)
587
+
588
+ parent_splitter.addWidget(panel)
589
+
590
+ def load_all_instruments(self):
591
+ """Load all available instruments from Helioviewer."""
592
+ self.instrument_list.clear()
593
+ self.all_instruments = fetch_all_instruments()
594
+
595
+ for nickname, layer_path, observatory, cadence in self.all_instruments:
596
+ # Format cadence for display
597
+ if cadence < 60:
598
+ cad_str = f"{cadence}s"
599
+ elif cadence < 3600:
600
+ cad_str = f"{cadence//60}min"
601
+ else:
602
+ cad_str = f"{cadence//3600}hr"
603
+
604
+ item = QListWidgetItem(f"{nickname} ({observatory}) [{cad_str}]")
605
+ item.setData(Qt.UserRole, (nickname, layer_path, observatory, cadence))
606
+ self.instrument_list.addItem(item)
607
+
608
+ # Select first item by default
609
+ #if self.instrument_list.count() > 0:
610
+ # self.instrument_list.setCurrentRow(0)
611
+ # self.update_interval_for_instrument()
612
+
613
+ def set_all_instruments(self, checked):
614
+ """Not used in single-selection mode."""
615
+ pass
616
+
617
+ def on_instrument_selected(self):
618
+ """Handle instrument selection change."""
619
+ self.update_interval_for_instrument()
620
+ if self.frames:
621
+ self.display_current_frame()
622
+
623
+ def update_interval_for_instrument(self):
624
+ """Update interval spinner based on selected instrument's cadence."""
625
+ selected = self.get_selected_instruments()
626
+ if selected:
627
+ nickname, layer_path, observatory, cadence = selected[0]
628
+ # Set interval to instrument cadence
629
+ self.interval_spin.setValue(int(cadence))
630
+
631
+ def create_image_panel(self, parent_splitter):
632
+ """Create right panel with image grid."""
633
+ # Scroll area
634
+ scroll = QScrollArea()
635
+ scroll.setWidgetResizable(True)
636
+ scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded)
637
+ scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
638
+
639
+ # Container widget
640
+ self.image_container = QWidget()
641
+ self.image_grid = QVBoxLayout(self.image_container)
642
+ self.image_grid.setAlignment(Qt.AlignTop | Qt.AlignHCenter) # Center horizontally
643
+
644
+ # Placeholder
645
+ placeholder = QLabel("Load a time series to display images")
646
+ placeholder.setStyleSheet("color: #888; padding: 50px;")
647
+ placeholder.setAlignment(Qt.AlignCenter)
648
+ self.image_grid.addWidget(placeholder)
649
+
650
+ scroll.setWidget(self.image_container)
651
+ parent_splitter.addWidget(scroll)
652
+
653
+ def create_animation_controls(self, parent_layout):
654
+ """Create bottom panel with animation controls."""
655
+ group = QGroupBox("Animation Controls")
656
+ layout = QHBoxLayout(group)
657
+
658
+ # First button
659
+ self.first_btn = QPushButton("|◄")
660
+ self.first_btn.setToolTip("First Frame")
661
+ self.first_btn.clicked.connect(self.first_frame)
662
+ self.first_btn.setEnabled(False)
663
+ layout.addWidget(self.first_btn)
664
+
665
+ # Previous button
666
+ self.prev_btn = QPushButton("◄")
667
+ self.prev_btn.setToolTip("Previous Frame")
668
+ self.prev_btn.clicked.connect(self.prev_frame)
669
+ self.prev_btn.setEnabled(False)
670
+ layout.addWidget(self.prev_btn)
671
+
672
+ # Play/Pause button
673
+ self.play_btn = QPushButton("▶")
674
+ self.play_btn.setToolTip("Play")
675
+ self.play_btn.clicked.connect(self.toggle_play)
676
+ self.play_btn.setEnabled(False)
677
+ layout.addWidget(self.play_btn)
678
+
679
+ # Next button
680
+ self.next_btn = QPushButton("►")
681
+ self.next_btn.setToolTip("Next Frame")
682
+ self.next_btn.clicked.connect(self.next_frame)
683
+ self.next_btn.setEnabled(False)
684
+ layout.addWidget(self.next_btn)
685
+
686
+ # Last button
687
+ self.last_btn = QPushButton("►|")
688
+ self.last_btn.setToolTip("Last Frame")
689
+ self.last_btn.clicked.connect(self.last_frame)
690
+ self.last_btn.setEnabled(False)
691
+ layout.addWidget(self.last_btn)
692
+
693
+ layout.addSpacing(20)
694
+
695
+ # Frame indicator
696
+ self.frame_label = QLabel("Frame: 0/0")
697
+ layout.addWidget(self.frame_label)
698
+
699
+ layout.addSpacing(20)
700
+
701
+ # Speed control
702
+ layout.addWidget(QLabel("Speed:"))
703
+ self.speed_combo = QComboBox()
704
+ self.speed_combo.addItems(["0.25x", "0.5x", "1x", "2x", "4x", "8x", "16x"])
705
+ self.speed_combo.setCurrentText("1x")
706
+ self.speed_combo.currentIndexChanged.connect(self.on_speed_changed)
707
+ layout.addWidget(self.speed_combo)
708
+
709
+ layout.addStretch()
710
+
711
+ # Save current frame
712
+ save_frame_btn = QPushButton("💾 Save Frame")
713
+ save_frame_btn.clicked.connect(self.save_current_frame)
714
+ layout.addWidget(save_frame_btn)
715
+
716
+ # Export animation
717
+ export_btn = QPushButton("🎬 Export Animation")
718
+ export_btn.clicked.connect(self.export_animation)
719
+ layout.addWidget(export_btn)
720
+
721
+ # Batch download
722
+ batch_btn = QPushButton("⬇️ Batch Download")
723
+ batch_btn.clicked.connect(self.batch_download)
724
+ layout.addWidget(batch_btn)
725
+
726
+ parent_layout.addWidget(group)
727
+
728
+ def on_speed_changed(self):
729
+ """Handle speed change - update animation timer if playing."""
730
+ if self.playing:
731
+ # Recalculate interval and restart timer
732
+ speed_text = self.speed_combo.currentText()
733
+ speed = float(speed_text.replace('x', ''))
734
+ interval_ms = int(0.5 * 1000 / speed) # Base: 2 fps
735
+ self.animation_timer.setInterval(interval_ms)
736
+
737
+ def on_instrument_filter_changed(self):
738
+ """Handle instrument filter change."""
739
+ if self.frames:
740
+ self.display_current_frame()
741
+
742
+ def load_time_series(self):
743
+ """Load time series based on user inputs."""
744
+ start = self.start_datetime.dateTime()
745
+ end = self.end_datetime.dateTime()
746
+ interval_sec = self.interval_spin.value()
747
+
748
+ # Validate
749
+ if start >= end:
750
+ QMessageBox.warning(self, "Invalid Range", "Start time must be before end time.")
751
+ return
752
+
753
+ # Generate timestamps
754
+ self.timestamps = []
755
+ current = start
756
+ while current <= end:
757
+ self.timestamps.append(current)
758
+ current = current.addSecs(interval_sec)
759
+
760
+ # Limit to 4000 frames
761
+ if len(self.timestamps) > 4000:
762
+ QMessageBox.warning(
763
+ self, "Too Many Frames",
764
+ f"Time range generates {len(self.timestamps)} frames. Limiting to first 4000."
765
+ )
766
+ self.timestamps = self.timestamps[:4000]
767
+
768
+ # Clear old data
769
+ self.frames.clear()
770
+ self.current_frame = 0
771
+
772
+ # Get selected instruments
773
+ selected_instruments = self.get_selected_instruments()
774
+ if not selected_instruments:
775
+ QMessageBox.warning(self, "No Instruments", "Please select an instrument to proceed.")
776
+ return
777
+
778
+ # Show progress
779
+ self.progress_bar.setVisible(True)
780
+ self.progress_bar.setRange(0, len(self.timestamps))
781
+ self.progress_bar.setValue(0)
782
+ self.load_button.setEnabled(False)
783
+
784
+ # Load first frame immediately (synchronously)
785
+ self.load_frame_sync(0, selected_instruments)
786
+
787
+ # Queue remaining frames for parallel download
788
+ if len(self.timestamps) > 1:
789
+ for frame_idx in range(1, len(self.timestamps)):
790
+ for inst_data in selected_instruments:
791
+ self.download_queue.append((frame_idx, inst_data))
792
+
793
+ # Start initial batch of downloads
794
+ self._process_frame_download_queue()
795
+ else:
796
+ self.on_loading_finished()
797
+
798
+ def get_selected_instruments(self):
799
+ """Get currently selected instrument (single selection)."""
800
+ selected_items = self.instrument_list.selectedItems()
801
+ if selected_items:
802
+ return [selected_items[0].data(Qt.UserRole)]
803
+ return []
804
+
805
+ def load_frame_sync(self, frame_idx, instruments):
806
+ """Load a frame synchronously (for first frame)."""
807
+ frame_data = {}
808
+ timestamp = self.timestamps[frame_idx]
809
+
810
+ for nickname, layer_path, observatory, description in instruments:
811
+ downloader = ImageDownloader(nickname, layer_path, timestamp)
812
+ downloader.run() # Run synchronously
813
+ # Note: This won't emit signals, need to handle differently
814
+
815
+ # For now, start async download for first frame too
816
+ self.frames[frame_idx] = {}
817
+ downloaders = []
818
+
819
+ for nickname, layer_path, observatory, description in instruments:
820
+ downloader = ImageDownloader(nickname, layer_path, timestamp,
821
+ width=self.image_size_spin.value(),
822
+ height=self.image_size_spin.value())
823
+ downloader.finished.connect(
824
+ lambda inst, pix, idx=frame_idx: self.on_image_downloaded(idx, inst, pix)
825
+ )
826
+ downloader.error.connect(
827
+ lambda inst, err, idx=frame_idx: self.on_parallel_download_error(idx, inst, err)
828
+ )
829
+ downloaders.append(downloader)
830
+ self.frame_loaders.append(downloader) # Track for cleanup on close
831
+ downloader.start()
832
+
833
+ # Enable controls
834
+ self.enable_controls()
835
+
836
+ def _process_frame_download_queue(self):
837
+ """Start next frame downloads if under concurrent limit."""
838
+ while self.active_downloads < self.max_concurrent_downloads and self.download_queue:
839
+ frame_idx, inst_data = self.download_queue.pop(0)
840
+ nickname, layer_path, observatory, description = inst_data
841
+ timestamp = self.timestamps[frame_idx]
842
+
843
+ self.active_downloads += 1
844
+ downloader = ImageDownloader(
845
+ nickname, layer_path, timestamp,
846
+ width=self.image_size_spin.value(),
847
+ height=self.image_size_spin.value()
848
+ )
849
+ downloader.finished.connect(
850
+ lambda inst, pix, idx=frame_idx: self.on_parallel_image_downloaded(idx, inst, pix)
851
+ )
852
+ downloader.error.connect(
853
+ lambda inst, err, idx=frame_idx: self.on_parallel_download_error(idx, inst, err)
854
+ )
855
+ downloader.finished.connect(self._on_frame_download_finished)
856
+ self.frame_loaders.append(downloader)
857
+ downloader.start()
858
+
859
+ def on_parallel_image_downloaded(self, frame_idx, instrument, pixmap):
860
+ """Handle parallel image download completion."""
861
+ if self._closing:
862
+ return
863
+
864
+ if frame_idx not in self.frames:
865
+ self.frames[frame_idx] = {}
866
+
867
+ self.frames[frame_idx][instrument] = pixmap
868
+
869
+ # Update progress (count completed frames)
870
+ completed_frames = len(self.frames)
871
+ self.progress_bar.setValue(completed_frames)
872
+
873
+ # If this is the current frame, update display
874
+ if frame_idx == self.current_frame:
875
+ self.display_current_frame()
876
+
877
+ def on_parallel_download_error(self, frame_idx, instrument, error):
878
+ """Handle parallel download error."""
879
+ if self._closing:
880
+ return
881
+ print(f"Error downloading {instrument} for frame {frame_idx}: {error}")
882
+
883
+ def _on_frame_download_finished(self):
884
+ """Handle download thread finish and start next in queue."""
885
+ if self._closing:
886
+ return
887
+
888
+ self.active_downloads -= 1
889
+ if self.active_downloads < 0:
890
+ self.active_downloads = 0
891
+
892
+ # Process next in queue
893
+ self._process_frame_download_queue()
894
+
895
+ # Check if all downloads are complete
896
+ if self.active_downloads == 0 and len(self.download_queue) == 0:
897
+ self.on_loading_finished()
898
+
899
+ def on_image_downloaded(self, frame_idx, instrument, pixmap):
900
+ """Handle single image download completion."""
901
+ if self._closing:
902
+ return
903
+
904
+ if frame_idx not in self.frames:
905
+ self.frames[frame_idx] = {}
906
+
907
+ self.frames[frame_idx][instrument] = pixmap
908
+
909
+ # If this is the current frame, update display
910
+ if frame_idx == self.current_frame:
911
+ self.display_current_frame()
912
+
913
+ def on_background_frame_loaded(self, frame_idx, frame_data):
914
+ """Handle background frame loading."""
915
+ # Adjust index (background loader starts from index 1)
916
+ actual_idx = frame_idx + 1
917
+ self.frames[actual_idx] = frame_data
918
+
919
+ def on_loading_progress(self, current, total):
920
+ """Update progress bar."""
921
+ self.progress_bar.setValue(current + 1) # +1 for the first frame loaded synchronously
922
+
923
+ def on_loading_finished(self):
924
+ """Handle loading completion."""
925
+ self.progress_bar.setVisible(False)
926
+ self.load_button.setEnabled(True)
927
+ QMessageBox.information(
928
+ self, "Loading Complete",
929
+ f"Loaded {len(self.frames)} frames with selected instruments."
930
+ )
931
+
932
+ def enable_controls(self):
933
+ """Enable animation controls."""
934
+ has_frames = len(self.timestamps) > 0
935
+ self.first_btn.setEnabled(has_frames)
936
+ self.prev_btn.setEnabled(has_frames)
937
+ self.play_btn.setEnabled(has_frames)
938
+ self.next_btn.setEnabled(has_frames)
939
+ self.last_btn.setEnabled(has_frames)
940
+
941
+ if has_frames:
942
+ self.display_current_frame()
943
+
944
+ def display_current_frame(self):
945
+ """Display the current frame's image (single instrument, fullscreen)."""
946
+ # Clear grid
947
+ while self.image_grid.count():
948
+ child = self.image_grid.takeAt(0)
949
+ if child.widget():
950
+ child.widget().deleteLater()
951
+
952
+ if self.current_frame >= len(self.timestamps):
953
+ return
954
+
955
+ timestamp = self.timestamps[self.current_frame]
956
+ frame_data = self.frames.get(self.current_frame, {})
957
+
958
+ # Get selected instrument (single selection)
959
+ selected = self.get_selected_instruments()
960
+ if not selected:
961
+ placeholder = QLabel("Please select an instrument from the list")
962
+ placeholder.setStyleSheet("color: #888; padding: 50px;")
963
+ placeholder.setAlignment(Qt.AlignCenter)
964
+ self.image_grid.addWidget(placeholder)
965
+ return
966
+
967
+ nickname, layer_path, observatory, cadence = selected[0]
968
+
969
+ # Title with timestamp and instrument
970
+ title = QLabel(f"<h2>{nickname} ({observatory})</h2>")
971
+ title.setAlignment(Qt.AlignCenter)
972
+ title.setStyleSheet("color: #2196F3; padding: 5px;")
973
+ self.image_grid.addWidget(title)
974
+
975
+ time_label = QLabel(f"<h3>{timestamp.toString('yyyy-MM-dd HH:mm:ss')} UTC</h3>")
976
+ time_label.setAlignment(Qt.AlignCenter)
977
+ time_label.setStyleSheet("color: #666; padding: 5px;")
978
+ self.image_grid.addWidget(time_label)
979
+
980
+ # Fullscreen image
981
+ pixmap = frame_data.get(nickname)
982
+
983
+ img_label = QLabel()
984
+ img_label.setAlignment(Qt.AlignCenter)
985
+
986
+ if pixmap:
987
+ # Display at original size (1000x1000)
988
+ img_label.setPixmap(pixmap)
989
+ img_label.setCursor(Qt.PointingHandCursor)
990
+ img_label.mousePressEvent = lambda e, p=pixmap, n=nickname: self.show_full_image(p, n)
991
+ else:
992
+ img_label.setText("Loading...")
993
+ img_label.setStyleSheet("color: #666; padding: 100px;")
994
+
995
+ # Add with stretch to center vertically
996
+ self.image_grid.addWidget(img_label, 1)
997
+
998
+ # Update frame label
999
+ self.frame_label.setText(f"Frame: {self.current_frame + 1}/{len(self.timestamps)}")
1000
+
1001
+ def create_image_card(self, instrument_name, pixmap, timestamp):
1002
+ """Create a card widget for an image."""
1003
+ card = QFrame()
1004
+ card.setFrameStyle(QFrame.Box | QFrame.Raised)
1005
+ card.setLineWidth(1)
1006
+ card.setStyleSheet("background: #2b2b2b; border-radius: 8px;")
1007
+
1008
+ layout = QVBoxLayout(card)
1009
+ layout.setContentsMargins(8, 8, 8, 8)
1010
+
1011
+ # Instrument name
1012
+ name_label = QLabel(f"<b>{instrument_name}</b>")
1013
+ name_label.setStyleSheet("color: #2196F3;")
1014
+ name_label.setAlignment(Qt.AlignCenter)
1015
+ layout.addWidget(name_label)
1016
+
1017
+ # Time
1018
+ time_label = QLabel(timestamp.toString("HH:mm:ss") + " UTC")
1019
+ time_label.setStyleSheet("color: #888;")
1020
+ time_label.setAlignment(Qt.AlignCenter)
1021
+ layout.addWidget(time_label)
1022
+
1023
+ # Image
1024
+ img_label = QLabel()
1025
+ img_label.setFixedSize(250, 250)
1026
+ img_label.setAlignment(Qt.AlignCenter)
1027
+ img_label.setStyleSheet("background: #000; border: 1px solid #555;")
1028
+
1029
+ if pixmap:
1030
+ scaled = pixmap.scaled(250, 250, Qt.KeepAspectRatio, Qt.SmoothTransformation)
1031
+ img_label.setPixmap(scaled)
1032
+ img_label.setCursor(Qt.PointingHandCursor)
1033
+ img_label.mousePressEvent = lambda e, p=pixmap, n=instrument_name: self.show_full_image(p, n)
1034
+ else:
1035
+ img_label.setText("Loading...")
1036
+ img_label.setStyleSheet("background: #000; color: #666;")
1037
+
1038
+ layout.addWidget(img_label)
1039
+
1040
+ return card
1041
+
1042
+ def show_full_image(self, pixmap, title):
1043
+ """Show full resolution image in dialog."""
1044
+ dialog = FullImageDialog(self, pixmap, title)
1045
+ dialog.exec_()
1046
+
1047
+ # Animation controls
1048
+ def first_frame(self):
1049
+ """Jump to first frame."""
1050
+ self.current_frame = 0
1051
+ self.display_current_frame()
1052
+
1053
+ def prev_frame(self):
1054
+ """Go to previous frame."""
1055
+ if self.current_frame > 0:
1056
+ self.current_frame -= 1
1057
+ self.display_current_frame()
1058
+
1059
+ def next_frame(self):
1060
+ """Go to next frame."""
1061
+ if self.current_frame < len(self.timestamps) - 1:
1062
+ self.current_frame += 1
1063
+ self.display_current_frame()
1064
+ else:
1065
+ # Loop back to start
1066
+ self.current_frame = 0
1067
+ self.display_current_frame()
1068
+
1069
+ def last_frame(self):
1070
+ """Jump to last frame."""
1071
+ self.current_frame = len(self.timestamps) - 1
1072
+ self.display_current_frame()
1073
+
1074
+ def toggle_play(self):
1075
+ """Toggle animation playback."""
1076
+ if not self.playing:
1077
+ self.playing = True
1078
+ self.play_btn.setText("❚❚")
1079
+ self.play_btn.setToolTip("Pause")
1080
+
1081
+ # Get speed
1082
+ speed_text = self.speed_combo.currentText()
1083
+ speed = float(speed_text.replace('x', ''))
1084
+ interval_ms = int(0.5 * 1000 / speed) # Base: 2 fps
1085
+
1086
+ self.animation_timer.start(interval_ms)
1087
+ else:
1088
+ self.pause_animation()
1089
+
1090
+ def pause_animation(self):
1091
+ """Pause animation."""
1092
+ self.playing = False
1093
+ self.play_btn.setText("▶")
1094
+ self.play_btn.setToolTip("Play")
1095
+ self.animation_timer.stop()
1096
+
1097
+ # Export/Save functions
1098
+ def save_current_frame(self):
1099
+ """Save current frame as PNG."""
1100
+ if not self.frames or self.current_frame >= len(self.timestamps):
1101
+ QMessageBox.warning(self, "No Frame", "No frame to save.")
1102
+ return
1103
+
1104
+ # Get selected instrument
1105
+ selected = self.get_selected_instruments()
1106
+ if not selected:
1107
+ QMessageBox.warning(self, "No Instrument", "Please select an instrument.")
1108
+ return
1109
+
1110
+ nickname, _, _, _ = selected[0]
1111
+ timestamp = self.timestamps[self.current_frame]
1112
+
1113
+ # Get pixmap for current frame
1114
+ frame_data = self.frames.get(self.current_frame, {})
1115
+ pixmap = frame_data.get(nickname)
1116
+
1117
+ if not pixmap:
1118
+ QMessageBox.warning(self, "No Image", "Current frame image not loaded yet.")
1119
+ return
1120
+
1121
+ # Create default filename
1122
+ safe_name = nickname.replace('/', '_').replace(' ', '_')
1123
+ default_name = f"helioviewer_{safe_name}_{timestamp.toString('yyyyMMdd_HHmmss')}.png"
1124
+
1125
+ file_path, _ = QFileDialog.getSaveFileName(
1126
+ self, "Save Frame", default_name, "PNG Image (*.png)"
1127
+ )
1128
+
1129
+ if file_path:
1130
+ try:
1131
+ if pixmap.save(file_path, "PNG"):
1132
+ QMessageBox.information(
1133
+ self, "Save Successful",
1134
+ f"Frame saved to:\n{file_path}"
1135
+ )
1136
+ else:
1137
+ QMessageBox.warning(
1138
+ self, "Save Failed",
1139
+ "Failed to save image. Please check file path and permissions."
1140
+ )
1141
+ except Exception as e:
1142
+ QMessageBox.critical(
1143
+ self, "Error",
1144
+ f"Error saving frame:\n{str(e)}"
1145
+ )
1146
+
1147
+ def export_animation(self):
1148
+ """Export animation as GIF or MP4."""
1149
+ if len(self.frames) < 2:
1150
+ QMessageBox.warning(self, "Insufficient Frames", "Need at least 2 frames for animation.")
1151
+ return
1152
+
1153
+ # Get selected instrument
1154
+ selected = self.get_selected_instruments()
1155
+ if not selected:
1156
+ QMessageBox.warning(self, "No Instrument", "Please select an instrument.")
1157
+ return
1158
+
1159
+ nickname, _, _, _ = selected[0]
1160
+
1161
+ # Ask for format
1162
+ from PyQt5.QtWidgets import QInputDialog, QProgressDialog, QCheckBox
1163
+ formats = ["GIF", "MP4"]
1164
+ format_choice, ok = QInputDialog.getItem(
1165
+ self, "Export Format", "Choose export format:", formats, 0, False
1166
+ )
1167
+
1168
+ if not ok:
1169
+ return
1170
+
1171
+ # Ask about timestamp overlay
1172
+ timestamp_dialog = QMessageBox(self)
1173
+ timestamp_dialog.setWindowTitle("Timestamp Overlay")
1174
+ timestamp_dialog.setText("Include timestamp on each frame?")
1175
+ timestamp_dialog.setStandardButtons(QMessageBox.Yes | QMessageBox.No)
1176
+ timestamp_dialog.setDefaultButton(QMessageBox.Yes)
1177
+ include_timestamp = (timestamp_dialog.exec_() == QMessageBox.Yes)
1178
+
1179
+ # Get save path
1180
+ safe_name = nickname.replace('/', '_').replace(' ', '_')
1181
+ start_time = self.timestamps[0].toString('yyyyMMdd_HHmmss')
1182
+ ext = "gif" if format_choice == "GIF" else "mp4"
1183
+ default_name = f"helioviewer_{safe_name}_{start_time}.{ext}"
1184
+
1185
+ file_filter = "GIF Image (*.gif)" if format_choice == "GIF" else "MP4 Video (*.mp4)"
1186
+ file_path, _ = QFileDialog.getSaveFileName(
1187
+ self, "Export Animation", default_name, file_filter
1188
+ )
1189
+
1190
+ if not file_path:
1191
+ return
1192
+
1193
+ # Collect frames
1194
+ frames_to_export = []
1195
+ for idx in sorted(self.frames.keys()):
1196
+ frame_data = self.frames[idx]
1197
+ pixmap = frame_data.get(nickname)
1198
+ if pixmap:
1199
+ frames_to_export.append(pixmap)
1200
+
1201
+ if len(frames_to_export) < 2:
1202
+ QMessageBox.warning(
1203
+ self, "Insufficient Frames",
1204
+ f"Only {len(frames_to_export)} frame(s) loaded for {nickname}. Need at least 2."
1205
+ )
1206
+ return
1207
+
1208
+ # Show progress dialog
1209
+ progress = QProgressDialog("Exporting animation...", "Cancel", 0, len(frames_to_export), self)
1210
+ progress.setWindowModality(Qt.WindowModal)
1211
+ progress.setMinimumDuration(0)
1212
+
1213
+ # Get animation speed from UI
1214
+ speed_text = self.speed_combo.currentText()
1215
+ speed = float(speed_text.replace('x', ''))
1216
+ # Calculate frame duration in ms (base: 2 fps at 1x speed = 500ms)
1217
+ frame_duration_ms = int(500 / speed)
1218
+
1219
+ try:
1220
+ if format_choice == "GIF":
1221
+ self._export_gif(frames_to_export, file_path, progress, frame_duration_ms, include_timestamp)
1222
+ else:
1223
+ # For MP4, convert to FPS
1224
+ fps = 1000.0 / frame_duration_ms
1225
+ self._export_mp4(frames_to_export, file_path, progress, fps, include_timestamp)
1226
+
1227
+ if not progress.wasCanceled():
1228
+ QMessageBox.information(
1229
+ self, "Export Successful",
1230
+ f"Animation exported to:\n{file_path}\n\n"
1231
+ f"Frames: {len(frames_to_export)}"
1232
+ )
1233
+ except Exception as e:
1234
+ progress.close()
1235
+ QMessageBox.critical(
1236
+ self, "Export Failed",
1237
+ f"Error exporting animation:\n{str(e)}"
1238
+ )
1239
+ finally:
1240
+ progress.close()
1241
+
1242
+ def _add_timestamp_to_pixmap(self, pixmap, timestamp_text):
1243
+ """Add timestamp overlay to a pixmap."""
1244
+ from PyQt5.QtGui import QPainter, QFont, QColor, QPen
1245
+ from PyQt5.QtCore import Qt, QRect
1246
+
1247
+ # Create a copy to avoid modifying original
1248
+ result = QPixmap(pixmap)
1249
+ painter = QPainter(result)
1250
+
1251
+ # Set up font
1252
+ font = QFont("Arial", 12, QFont.Bold)
1253
+ painter.setFont(font)
1254
+
1255
+ # Draw background rectangle for text
1256
+ text_rect = painter.fontMetrics().boundingRect(timestamp_text)
1257
+ padding = 15
1258
+ bg_rect = QRect(
1259
+ 10,
1260
+ result.height() - text_rect.height() - padding * 2 - 10,
1261
+ text_rect.width() + padding * 2,
1262
+ text_rect.height() + padding * 2
1263
+ )
1264
+
1265
+ # Semi-transparent black background
1266
+ painter.fillRect(bg_rect, QColor(0, 0, 0, 180))
1267
+
1268
+ # Draw white text
1269
+ painter.setPen(QPen(QColor(255, 255, 255)))
1270
+ painter.drawText(
1271
+ bg_rect.adjusted(padding, padding, -padding, -padding),
1272
+ Qt.AlignLeft | Qt.AlignVCenter,
1273
+ timestamp_text
1274
+ )
1275
+
1276
+ painter.end()
1277
+ return result
1278
+
1279
+ def _export_gif(self, frames, file_path, progress, duration_ms, include_timestamp=False):
1280
+ """Export frames as animated GIF."""
1281
+ try:
1282
+ from PIL import Image
1283
+ except ImportError:
1284
+ raise Exception("PIL/Pillow is required for GIF export. Install with: pip install Pillow")
1285
+
1286
+ # Convert QPixmaps to PIL Images
1287
+ pil_images = []
1288
+ for i, pixmap in enumerate(frames):
1289
+ if progress.wasCanceled():
1290
+ return
1291
+ progress.setValue(i)
1292
+
1293
+ # Add timestamp if requested
1294
+ if include_timestamp and i < len(self.timestamps):
1295
+ timestamp_text = self.timestamps[i].toString('yyyy-MM-dd HH:mm:ss') + ' UTC'
1296
+ pixmap = self._add_timestamp_to_pixmap(pixmap, timestamp_text)
1297
+
1298
+ # Convert QPixmap to PIL Image via QImage
1299
+ qimage = pixmap.toImage()
1300
+ qimage = qimage.convertToFormat(QImage.Format_RGB888)
1301
+
1302
+ width = qimage.width()
1303
+ height = qimage.height()
1304
+ ptr = qimage.bits()
1305
+ ptr.setsize(height * width * 3)
1306
+
1307
+ import numpy as np
1308
+ arr = np.frombuffer(ptr, np.uint8).reshape((height, width, 3))
1309
+ pil_img = Image.fromarray(arr, 'RGB')
1310
+ pil_images.append(pil_img)
1311
+
1312
+ # Save as animated GIF with user-selected speed
1313
+ progress.setLabelText("Saving GIF...")
1314
+ pil_images[0].save(
1315
+ file_path,
1316
+ save_all=True,
1317
+ append_images=pil_images[1:],
1318
+ duration=duration_ms,
1319
+ loop=0
1320
+ )
1321
+ progress.setValue(len(frames))
1322
+
1323
+ def _export_mp4(self, frames, file_path, progress, fps, include_timestamp=False):
1324
+ """Export frames as MP4 video."""
1325
+ try:
1326
+ import cv2
1327
+ import numpy as np
1328
+ except ImportError:
1329
+ raise Exception("OpenCV is required for MP4 export. Install with: pip install opencv-python")
1330
+
1331
+ # Get frame dimensions from first frame
1332
+ first_pixmap = frames[0]
1333
+ width = first_pixmap.width()
1334
+ height = first_pixmap.height()
1335
+
1336
+ # Create video writer with user-selected FPS
1337
+ fourcc = cv2.VideoWriter_fourcc(*'mp4v')
1338
+ out = cv2.VideoWriter(file_path, fourcc, fps, (width, height))
1339
+
1340
+ try:
1341
+ for i, pixmap in enumerate(frames):
1342
+ if progress.wasCanceled():
1343
+ return
1344
+ progress.setValue(i)
1345
+
1346
+ # Add timestamp if requested
1347
+ if include_timestamp and i < len(self.timestamps):
1348
+ timestamp_text = self.timestamps[i].toString('yyyy-MM-dd HH:mm:ss') + ' UTC'
1349
+ pixmap = self._add_timestamp_to_pixmap(pixmap, timestamp_text)
1350
+
1351
+ # Convert QPixmap to numpy array
1352
+ qimage = pixmap.toImage()
1353
+ qimage = qimage.convertToFormat(QImage.Format_RGB888)
1354
+
1355
+ width = qimage.width()
1356
+ height = qimage.height()
1357
+ ptr = qimage.bits()
1358
+ ptr.setsize(height * width * 3)
1359
+ arr = np.frombuffer(ptr, np.uint8).reshape((height, width, 3))
1360
+
1361
+ # OpenCV uses BGR, Qt uses RGB
1362
+ arr = cv2.cvtColor(arr, cv2.COLOR_RGB2BGR)
1363
+ out.write(arr)
1364
+
1365
+ progress.setValue(len(frames))
1366
+ finally:
1367
+ out.release()
1368
+
1369
+ def batch_download(self):
1370
+ """Batch download all frames."""
1371
+ if not self.frames:
1372
+ QMessageBox.warning(self, "No Data", "No frames to download.")
1373
+ return
1374
+
1375
+ # Get selected instrument
1376
+ selected = self.get_selected_instruments()
1377
+ if not selected:
1378
+ QMessageBox.warning(self, "No Instrument", "Please select an instrument.")
1379
+ return
1380
+
1381
+ nickname, _, _, _ = selected[0]
1382
+
1383
+ dir_path = QFileDialog.getExistingDirectory(self, "Select Download Directory")
1384
+ if not dir_path:
1385
+ return
1386
+
1387
+ # Create subdirectory with timestamp
1388
+ from datetime import datetime
1389
+ safe_name = nickname.replace('/', '_').replace(' ', '_')
1390
+ timestamp_str = datetime.now().strftime('%Y%m%d_%H%M%S')
1391
+ batch_dir = os.path.join(dir_path, f"helioviewer_{safe_name}_{timestamp_str}")
1392
+
1393
+ try:
1394
+ os.makedirs(batch_dir, exist_ok=True)
1395
+ except Exception as e:
1396
+ QMessageBox.critical(
1397
+ self, "Error",
1398
+ f"Failed to create directory:\n{str(e)}"
1399
+ )
1400
+ return
1401
+
1402
+ # Show progress
1403
+ from PyQt5.QtWidgets import QProgressDialog
1404
+ progress = QProgressDialog(
1405
+ "Downloading frames...", "Cancel", 0, len(self.frames), self
1406
+ )
1407
+ progress.setWindowModality(Qt.WindowModal)
1408
+ progress.setMinimumDuration(0)
1409
+
1410
+ saved_count = 0
1411
+ metadata_lines = []
1412
+ metadata_lines.append(f"Helioviewer Batch Download")
1413
+ metadata_lines.append(f"Instrument: {nickname}")
1414
+ metadata_lines.append(f"Download Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
1415
+ metadata_lines.append(f"Total Frames: {len(self.frames)}")
1416
+ metadata_lines.append("")
1417
+ metadata_lines.append("Frame Index | Timestamp | Filename | Status")
1418
+ metadata_lines.append("-" * 80)
1419
+
1420
+ try:
1421
+ for idx in sorted(self.frames.keys()):
1422
+ if progress.wasCanceled():
1423
+ break
1424
+
1425
+ progress.setValue(saved_count)
1426
+
1427
+ frame_data = self.frames[idx]
1428
+ pixmap = frame_data.get(nickname)
1429
+
1430
+ if pixmap:
1431
+ timestamp = self.timestamps[idx]
1432
+ filename = f"frame_{idx:04d}_{timestamp.toString('yyyyMMdd_HHmmss')}.png"
1433
+ file_path = os.path.join(batch_dir, filename)
1434
+
1435
+ if pixmap.save(file_path, "PNG"):
1436
+ saved_count += 1
1437
+ status = "OK"
1438
+ else:
1439
+ status = "FAILED"
1440
+ else:
1441
+ timestamp = self.timestamps[idx] if idx < len(self.timestamps) else "Unknown"
1442
+ filename = f"frame_{idx:04d}_missing.png"
1443
+ status = "MISSING"
1444
+
1445
+ metadata_lines.append(
1446
+ f"{idx:4d} | {timestamp.toString('yyyy-MM-dd HH:mm:ss') if hasattr(timestamp, 'toString') else timestamp} | {filename} | {status}"
1447
+ )
1448
+
1449
+ # Save metadata file
1450
+ metadata_path = os.path.join(batch_dir, "_metadata.txt")
1451
+ with open(metadata_path, 'w') as f:
1452
+ f.write('\n'.join(metadata_lines))
1453
+
1454
+ progress.setValue(len(self.frames))
1455
+
1456
+ if not progress.wasCanceled():
1457
+ QMessageBox.information(
1458
+ self, "Batch Download Complete",
1459
+ f"Successfully saved {saved_count}/{len(self.frames)} frames to:\n{batch_dir}\n\n"
1460
+ f"Metadata saved to: _metadata.txt"
1461
+ )
1462
+ except Exception as e:
1463
+ progress.close()
1464
+ QMessageBox.critical(
1465
+ self, "Error",
1466
+ f"Error during batch download:\n{str(e)}"
1467
+ )
1468
+ finally:
1469
+ progress.close()
1470
+
1471
+ def closeEvent(self, event):
1472
+ """Handle window close."""
1473
+ # Set closing flag first to prevent signal handlers from updating UI
1474
+ self._closing = True
1475
+
1476
+ # Clear download queue first to prevent new downloads from starting
1477
+ self.download_queue.clear()
1478
+
1479
+ # Stop all loaders (both ImageDownloader and FrameLoader)
1480
+ for loader in self.frame_loaders:
1481
+ if hasattr(loader, 'stop'):
1482
+ loader.stop()
1483
+
1484
+ # Wait for all threads to finish with a timeout
1485
+ remaining_threads = []
1486
+ for loader in self.frame_loaders:
1487
+ if loader.isRunning():
1488
+ # Wait up to 1 second per thread (fast cleanup)
1489
+ if not loader.wait(1000):
1490
+ print(f"[WARNING] Thread {loader} did not stop in time, moving to background")
1491
+ remaining_threads.append(loader)
1492
+
1493
+ # Move stuck threads to global list to prevent GC crash
1494
+ if remaining_threads:
1495
+ global _active_threads
1496
+ _active_threads.extend(remaining_threads)
1497
+
1498
+ # Clean up global list of finished threads
1499
+ _active_threads = [t for t in _active_threads if t.isRunning()]
1500
+
1501
+ self.frame_loaders.clear()
1502
+ self.pause_animation()
1503
+ event.accept()
1504
+
1505
+
1506
+ def main():
1507
+ import sys
1508
+ app = QApplication(sys.argv)
1509
+ window = HelioviewerBrowser()
1510
+ window.show()
1511
+ sys.exit(app.exec_())
1512
+
1513
+ if __name__ == "__main__":
1514
+ main()