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,234 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ CME Alerts Parser - Fetches Coronal Mass Ejection data from NASA DONKI API.
4
+
5
+ Data source:
6
+ - NASA DONKI (Space Weather Database of Notifications, Knowledge, Information)
7
+ - Endpoint: kauai.ccmc.gsfc.nasa.gov/DONKI/WS/get/CME
8
+ """
9
+
10
+ import json
11
+ import urllib.request
12
+ import urllib.error
13
+ from dataclasses import dataclass, field
14
+ from datetime import datetime, date, timedelta
15
+ from typing import List, Optional, Dict, Any
16
+
17
+
18
+ @dataclass
19
+ class CMEEvent:
20
+ """Represents a Coronal Mass Ejection event."""
21
+ activity_id: str
22
+ start_time: datetime
23
+ source_location: str # e.g., "S17W72"
24
+ active_region: Optional[str]
25
+ speed: float # km/s
26
+ half_angle: float # degrees (angular width)
27
+ latitude: float
28
+ longitude: float
29
+ is_earth_directed: bool
30
+ earth_arrival_time: Optional[datetime]
31
+ note: str
32
+ analysis_link: str
33
+
34
+ @property
35
+ def speed_category(self) -> str:
36
+ """Categorize CME speed."""
37
+ if self.speed < 500:
38
+ return "Slow"
39
+ elif self.speed < 1000:
40
+ return "Moderate"
41
+ elif self.speed < 2000:
42
+ return "Fast"
43
+ else:
44
+ return "Extreme"
45
+
46
+ @property
47
+ def color_code(self) -> str:
48
+ """Get color based on speed and Earth-directed status."""
49
+ if self.is_earth_directed:
50
+ if self.speed >= 1500:
51
+ return "#F44336" # Red - fast Earth-directed
52
+ elif self.speed >= 1000:
53
+ return "#FF9800" # Orange
54
+ else:
55
+ return "#FFC107" # Amber
56
+ else:
57
+ return "#4CAF50" # Green - not Earth-directed
58
+
59
+ @property
60
+ def start_time_str(self) -> str:
61
+ """Format start time."""
62
+ return self.start_time.strftime("%Y-%m-%d %H:%M")
63
+
64
+ @property
65
+ def arrival_str(self) -> str:
66
+ """Format Earth arrival time."""
67
+ if self.earth_arrival_time:
68
+ return self.earth_arrival_time.strftime("%Y-%m-%d %H:%M")
69
+ return "—"
70
+
71
+
72
+ def fetch_cme_data(event_date: date, days_range: int = 3) -> Optional[List[Dict]]:
73
+ """
74
+ Fetch CME data from NASA DONKI API.
75
+
76
+ Args:
77
+ event_date: Center date to search around
78
+ days_range: Number of days before and after to search
79
+
80
+ Returns:
81
+ Raw JSON data or None if fetch failed
82
+ """
83
+ start_date = (event_date - timedelta(days=days_range)).strftime("%Y-%m-%d")
84
+ end_date = (event_date + timedelta(days=days_range)).strftime("%Y-%m-%d")
85
+
86
+ url = f"https://kauai.ccmc.gsfc.nasa.gov/DONKI/WS/get/CME?startDate={start_date}&endDate={end_date}"
87
+
88
+ try:
89
+ from ..utils import get_global_session
90
+ session = get_global_session()
91
+ response = session.get(url)
92
+ return json.loads(response.text)
93
+ except Exception as e:
94
+ if hasattr(e, 'response') and e.response is not None:
95
+ if e.response.status_code == 404:
96
+ return []
97
+ print(f"CME fetch error: {e}")
98
+ return None
99
+
100
+
101
+ def parse_cme_events(raw_data: List[Dict]) -> List[CMEEvent]:
102
+ """
103
+ Parse CME JSON data into CMEEvent objects.
104
+
105
+ Args:
106
+ raw_data: Raw JSON from DONKI API
107
+
108
+ Returns:
109
+ List of CMEEvent objects
110
+ """
111
+ events = []
112
+
113
+ for cme in raw_data:
114
+ try:
115
+ activity_id = cme.get("activityID", "Unknown")
116
+ start_time_str = activity_id.split("-CME-")[0] if "-CME-" in activity_id else None
117
+
118
+ if start_time_str:
119
+ try:
120
+ start_time = datetime.fromisoformat(start_time_str)
121
+ except ValueError:
122
+ start_time = datetime.now()
123
+ else:
124
+ start_time = datetime.now()
125
+
126
+ # Get the most accurate analysis
127
+ analyses = cme.get("cmeAnalyses") or []
128
+ best_analysis = None
129
+ for analysis in analyses:
130
+ if analysis.get("isMostAccurate"):
131
+ best_analysis = analysis
132
+ break
133
+ if not best_analysis and analyses:
134
+ best_analysis = analyses[0]
135
+
136
+ if not best_analysis:
137
+ continue
138
+
139
+ speed = best_analysis.get("speed", 0) or 0
140
+ half_angle = best_analysis.get("halfAngle", 0) or 0
141
+ latitude = best_analysis.get("latitude", 0) or 0
142
+ longitude = best_analysis.get("longitude", 0) or 0
143
+ note = best_analysis.get("note", "") or ""
144
+ analysis_link = best_analysis.get("link", "")
145
+
146
+ # Check for Earth impact
147
+ is_earth_directed = False
148
+ earth_arrival_time = None
149
+
150
+ enlil_list = best_analysis.get("enlilList") or []
151
+ for enlil in enlil_list:
152
+ if enlil.get("isEarthGB") or enlil.get("isEarthMinorImpact"):
153
+ is_earth_directed = True
154
+ impact_list = enlil.get("impactList") or []
155
+ for impact in impact_list:
156
+ if "Earth" in impact.get("location", ""):
157
+ is_earth_directed = True
158
+ arrival_str = impact.get("arrivalTime")
159
+ if arrival_str:
160
+ try:
161
+ earth_arrival_time = datetime.fromisoformat(arrival_str.replace("Z", ""))
162
+ except ValueError:
163
+ pass
164
+
165
+ # Determine if likely Earth-directed based on longitude
166
+ if abs(longitude) < 45: # CME centered within ±45° of Sun-Earth line
167
+ is_earth_directed = True
168
+
169
+ # Format source location
170
+ lat_hem = "N" if latitude >= 0 else "S"
171
+ lon_hem = "E" if longitude <= 0 else "W"
172
+ source_location = f"{lat_hem}{abs(int(latitude))}{lon_hem}{abs(int(longitude))}"
173
+
174
+ active_region = cme.get("activeRegionNum")
175
+ if active_region:
176
+ active_region = str(active_region)
177
+
178
+ events.append(CMEEvent(
179
+ activity_id=activity_id,
180
+ start_time=start_time,
181
+ source_location=source_location,
182
+ active_region=active_region,
183
+ speed=speed,
184
+ half_angle=half_angle,
185
+ latitude=latitude,
186
+ longitude=longitude,
187
+ is_earth_directed=is_earth_directed,
188
+ earth_arrival_time=earth_arrival_time,
189
+ note=note[:200] + "..." if len(note) > 200 else note,
190
+ analysis_link=analysis_link,
191
+ ))
192
+ except Exception as e:
193
+ print(f"Error parsing CME: {e}")
194
+ continue
195
+
196
+ # Sort by start time
197
+ events.sort(key=lambda e: e.start_time, reverse=True)
198
+ return events
199
+
200
+
201
+ def fetch_and_parse_cme_events(event_date: date, days_range: int = 3) -> Optional[List[CMEEvent]]:
202
+ """
203
+ Fetch and parse CME events for a date range.
204
+
205
+ Args:
206
+ event_date: Center date to search around
207
+ days_range: Number of days before and after to search
208
+
209
+ Returns:
210
+ List of CMEEvent objects or None if fetch failed
211
+ """
212
+ raw_data = fetch_cme_data(event_date, days_range)
213
+ if raw_data is None:
214
+ return None
215
+ if not raw_data:
216
+ return []
217
+ return parse_cme_events(raw_data)
218
+
219
+
220
+ if __name__ == "__main__":
221
+ # Test with sample date
222
+ from datetime import date
223
+
224
+ test_date = date(2024, 6, 9)
225
+ print(f"Fetching CME events around {test_date}...")
226
+
227
+ events = fetch_and_parse_cme_events(test_date)
228
+ if events:
229
+ print(f"Found {len(events)} CME events:")
230
+ for e in events[:5]:
231
+ earth_str = "🌍 Earth-directed" if e.is_earth_directed else ""
232
+ print(f" {e.start_time_str} | {e.speed:.0f} km/s ({e.speed_category}) | {e.source_location} {earth_str}")
233
+ else:
234
+ print("No CME events found or fetch failed")
@@ -0,0 +1,297 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Context Images Fetcher - Get solar context imagery from Helioviewer API or SolarMonitor.org.
4
+ """
5
+
6
+ from dataclasses import dataclass
7
+ from datetime import date, datetime
8
+ from typing import List, Optional, Dict, Tuple
9
+ import requests
10
+ import re
11
+ from urllib.parse import urljoin
12
+ import json
13
+
14
+ @dataclass
15
+ class ContextImage:
16
+ """Represents a context image."""
17
+ title: str
18
+ thumb_url: str
19
+ page_url: str # URL to the full disk page or full-res image
20
+ instrument: str
21
+ description: str
22
+ credits: str = "Helioviewer" # Credit source
23
+ source_id: Optional[int] = None # Helioviewer sourceId if applicable
24
+
25
+ # Essential instruments for context viewing
26
+ # Format: (nickname, layer_path, description)
27
+ # layer_path format: [observatory,instrument,detector,measurement,visible,opacity]
28
+ ESSENTIAL_INSTRUMENTS = [
29
+ ("AIA 304", "[SDO,AIA,AIA,304,1,100]", "SDO", "304Å Chromosphere & Transition Region"),
30
+ ("AIA 193", "[SDO,AIA,AIA,193,1,100]", "SDO", "193Å Corona & Hot Flare Plasma"),
31
+ ("AIA 171", "[SDO,AIA,AIA,171,1,100]", "SDO", "171Å Quiet Corona"),
32
+ ("AIA 335", "[SDO,AIA,AIA,335,1,100]", "SDO", "335Å Active Region Corona"),
33
+ ("HMI Mag", "[SDO,HMI,HMI,magnetogram,1,100]", "SDO", "Magnetogram (Magnetic Field)"),
34
+ ("HMI Int", "[SDO,HMI,HMI,continuum,1,100]", "SDO", "Continuum (Photosphere)"),
35
+ ("LASCO C2", "[SOHO,LASCO,C2,white-light,1,100]", "SOHO", "Inner Coronagraph (2-6 Rs)"),
36
+ ("LASCO C3", "[SOHO,LASCO,C3,white-light,1,100]", "SOHO", "Outer Coronagraph (4-30 Rs)"),
37
+ ("SUVI 171", "[GOES,SUVI,SUVI,171,1,100]", "GOES", "171Å Corona"),
38
+ ("SUVI 304", "[GOES,SUVI,SUVI,304,1,100]", "GOES", "304Å Chromosphere"),
39
+ ("EUVI-A 171", "[STEREO_A,SECCHI,EUVI,171,1,100]", "STEREO_A", "171Å from STEREO-A"),
40
+ ("EUVI-A 304", "[STEREO_A,SECCHI,EUVI,304,1,100]", "STEREO_A", "304Å from STEREO-A"),
41
+ ]
42
+
43
+ def fetch_from_helioviewer(event_date: date) -> List[ContextImage]:
44
+ """
45
+ Fetch essential context images from Helioviewer API as PNG screenshots.
46
+
47
+ Args:
48
+ event_date: Date to fetch images for
49
+
50
+ Returns:
51
+ List of ContextImage objects with PNG image URLs
52
+ """
53
+ # Format date for Helioviewer API (ISO 8601 UTC)
54
+ date_str = f"{event_date.strftime('%Y-%m-%d')}T12:00:00Z"
55
+
56
+ images = []
57
+
58
+ # Fetch only essential instruments with PNG screenshots
59
+ for nickname, layer_path, observatory, description in ESSENTIAL_INSTRUMENTS:
60
+ # Determine image type for appropriate field of view
61
+ is_lasco_c2 = 'LASCO,C2' in layer_path
62
+ is_lasco_c3 = 'LASCO,C3' in layer_path
63
+
64
+ # Thumbnail parameters - optimized for each image type
65
+ base_url = "https://api.helioviewer.org/v2/takeScreenshot/"
66
+
67
+ if is_lasco_c2:
68
+ # LASCO C2: Inner coronagraph - needs MORE FoV than before
69
+ thumb_params = {
70
+ 'date': date_str,
71
+ 'imageScale': '75',
72
+ 'layers': layer_path,
73
+ 'x0': '0',
74
+ 'y0': '0',
75
+ 'width': '160',
76
+ 'height': '160',
77
+ 'display': 'true',
78
+ 'watermark': 'false'
79
+ }
80
+ full_params = {
81
+ 'date': date_str,
82
+ 'imageScale': '6',
83
+ 'layers': layer_path,
84
+ 'x0': '0',
85
+ 'y0': '0',
86
+ 'width': '2048',
87
+ 'height': '2048',
88
+ 'display': 'true',
89
+ 'watermark': 'false'
90
+ }
91
+ elif is_lasco_c3:
92
+ # LASCO C3: Outer coronagraph - needs A LOT MORE FoV
93
+ thumb_params = {
94
+ 'date': date_str,
95
+ 'imageScale': '240',
96
+ 'layers': layer_path,
97
+ 'x0': '0',
98
+ 'y0': '0',
99
+ 'width': '160',
100
+ 'height': '160',
101
+ 'display': 'true',
102
+ 'watermark': 'false'
103
+ }
104
+ full_params = {
105
+ 'date': date_str,
106
+ 'imageScale': '28.0',
107
+ 'layers': layer_path,
108
+ 'x0': '0',
109
+ 'y0': '0',
110
+ 'width': '2048',
111
+ 'height': '2048',
112
+ 'display': 'true',
113
+ 'watermark': 'false'
114
+ }
115
+ else:
116
+ # Solar disk images - need LESS FoV (less black space)
117
+ thumb_params = {
118
+ 'date': date_str,
119
+ 'imageScale': '15.0',
120
+ 'layers': layer_path,
121
+ 'x0': '0',
122
+ 'y0': '0',
123
+ 'width': '160',
124
+ 'height': '160',
125
+ 'display': 'true',
126
+ 'watermark': 'false'
127
+ }
128
+ full_params = {
129
+ 'date': date_str,
130
+ 'imageScale': '1.6',
131
+ 'layers': layer_path,
132
+ 'x0': '0',
133
+ 'y0': '0',
134
+ 'width': '1600',
135
+ 'height': '1600',
136
+ 'display': 'true',
137
+ 'watermark': 'false'
138
+ }
139
+
140
+ # Construct URLs with parameters
141
+ from urllib.parse import urlencode
142
+ thumb_url = f"{base_url}?{urlencode(thumb_params)}"
143
+ full_url = f"{base_url}?{urlencode(full_params)}"
144
+
145
+ images.append(ContextImage(
146
+ title=nickname,
147
+ thumb_url=thumb_url,
148
+ page_url=full_url,
149
+ instrument=observatory,
150
+ description=description,
151
+ credits="Helioviewer",
152
+ source_id=None
153
+ ))
154
+
155
+ return images
156
+
157
+ def fetch_from_solarmonitor(event_date: date) -> List[ContextImage]:
158
+ """
159
+ Scrape SolarMonitor index page to find all available context images for the date.
160
+ Returns list of ContextImage objects with thumbnail and page URLs.
161
+
162
+ This is the legacy/fallback method.
163
+ """
164
+ date_str = event_date.strftime("%Y%m%d")
165
+ base_url = "https://solarmonitor.org/"
166
+ index_url = f"{base_url}index.php?date={date_str}"
167
+
168
+ images = []
169
+
170
+ try:
171
+ from ..utils import get_global_session
172
+ except ImportError:
173
+ from solar_radio_image_viewer.utils import get_global_session
174
+
175
+ session = get_global_session()
176
+
177
+ try:
178
+ response = session.get(index_url)
179
+
180
+ if response.status_code != 200:
181
+ print(f"SolarMonitor index fetch failed: {response.status_code}")
182
+ return []
183
+
184
+ html = response.text
185
+
186
+ # Regex to find links to full_disk.php wrapping a thumbnail
187
+ pattern = re.compile(r'href=["\']?(full_disk\.php[^"\'>]+)["\']?[^>]*>[\s\S]*?<img[^>]+src=["\']?([^"\'> ]+thumb\.png)["\']?', re.IGNORECASE)
188
+
189
+ matches = pattern.findall(html)
190
+
191
+ seen_types = set()
192
+
193
+ for page_link, thumb_path in matches:
194
+ # Extract 'type' from page_link to use as title/ID
195
+ type_match = re.search(r'type=([a-zA-Z0-9_]+)', page_link)
196
+ img_type = type_match.group(1) if type_match else "unknown"
197
+
198
+ if img_type in seen_types:
199
+ continue
200
+ seen_types.add(img_type)
201
+
202
+ # Construct absolute URLs
203
+ full_page_url = urljoin(base_url, page_link)
204
+ full_thumb_url = urljoin(base_url, thumb_path)
205
+
206
+ # Metadata lookup
207
+ instrument, desc = _identify_instrument(img_type)
208
+
209
+ images.append(ContextImage(
210
+ title=img_type.replace('_', ' ').upper(),
211
+ thumb_url=full_thumb_url,
212
+ page_url=full_page_url,
213
+ instrument=instrument,
214
+ description=desc,
215
+ credits="SolarMonitor"
216
+ ))
217
+
218
+ except Exception as e:
219
+ print(f"Error scraping SolarMonitor: {e}")
220
+
221
+ return images
222
+
223
+ def fetch_context_images(event_date: date, use_helioviewer: bool = True) -> List[ContextImage]:
224
+ """
225
+ Fetch context images for a given date.
226
+
227
+ Args:
228
+ event_date: Date to fetch images for
229
+ use_helioviewer: If True, use Helioviewer API (default, returns PNG).
230
+ If False, use SolarMonitor (strict backup only).
231
+
232
+ Returns:
233
+ List of ContextImage objects
234
+ """
235
+ if use_helioviewer:
236
+ images = fetch_from_helioviewer(event_date)
237
+ if images:
238
+ return images
239
+ # Fall back to SolarMonitor if Helioviewer fails
240
+ print("Helioviewer fetch failed, falling back to SolarMonitor")
241
+
242
+ return fetch_from_solarmonitor(event_date)
243
+
244
+ def resolve_full_image_url(page_url: str) -> Optional[str]:
245
+ """
246
+ Given an image page URL, resolve to the actual full-resolution image URL.
247
+
248
+ For Helioviewer URLs, returns the URL directly.
249
+ For SolarMonitor URLs, scrapes the page to find the image.
250
+ """
251
+ # Check if it's a Helioviewer URL
252
+ if 'helioviewer.org' in page_url:
253
+ # It's already a direct image URL from Helioviewer
254
+ return page_url
255
+
256
+ # Otherwise, it's a SolarMonitor URL - scrape it
257
+ try:
258
+ from ..utils import get_global_session
259
+ except ImportError:
260
+ from solar_radio_image_viewer.utils import get_global_session
261
+ session = get_global_session()
262
+ response = session.get(page_url)
263
+ if response.status_code != 200:
264
+ return None
265
+
266
+ html = response.text
267
+
268
+ # Find all PNG images
269
+ all_imgs = re.findall(r'src=["\']?([^"\'> ]+\.png)["\']?', html, re.IGNORECASE)
270
+
271
+ for img_path in all_imgs:
272
+ if "thmb" not in img_path and "common_files" not in img_path:
273
+ return urljoin("https://solarmonitor.org/", img_path)
274
+
275
+ except Exception as e:
276
+ print(f"Error resolving full image: {e}")
277
+
278
+ return None
279
+
280
+ def _identify_instrument(img_type: str) -> Tuple[str, str]:
281
+ """Refine instrument name and description based on code (for SolarMonitor)."""
282
+ code = img_type.lower()
283
+ if 'saia' in code:
284
+ if '193' in code: return "SDO AIA", "193Å (Corona)"
285
+ if '094' in code: return "SDO AIA", "94Å (Hot Flare)"
286
+ if '335' in code: return "SDO AIA", "335Å (Active Region)"
287
+ return "SDO AIA", "EUV Image"
288
+ if 'seit' in code: return "SOHO EIT", "EUV (Historical)"
289
+ if 'shmi' in code: return "SDO HMI", "Magnetogram"
290
+ if 'smdi' in code: return "SOHO MDI", "Magnetogram (Historical)"
291
+ if 'gong' in code: return "GONG", "H-Alpha (Chromosphere)"
292
+ if 'bbso' in code: return "BBSO", "H-Alpha"
293
+ if 'swap' in code: return "Proba-2 SWAP", "174Å (Corona)"
294
+ if 'trce' in code: return "TRACE", "EUV (Historical)"
295
+ if 'sxi' in code or 'suvi' in code or 'goes' in code: return "GOES", "X-Ray Imager"
296
+ if 'lasc' in code or 'c2' in code or 'c3' in code: return "SOHO LASCO", "Coronagraph"
297
+ return "Unknown", "Solar Context"