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,220 @@
|
|
|
1
|
+
from PyQt5.QtWidgets import (
|
|
2
|
+
QComboBox,
|
|
3
|
+
QPushButton,
|
|
4
|
+
QLineEdit,
|
|
5
|
+
QListWidget,
|
|
6
|
+
QVBoxLayout,
|
|
7
|
+
QHBoxLayout,
|
|
8
|
+
QWidget,
|
|
9
|
+
QFrame,
|
|
10
|
+
QListWidgetItem,
|
|
11
|
+
QStyle,
|
|
12
|
+
QApplication,
|
|
13
|
+
QDialog,
|
|
14
|
+
QDialogButtonBox,
|
|
15
|
+
)
|
|
16
|
+
from PyQt5.QtCore import Qt, QSize, pyqtSignal
|
|
17
|
+
from PyQt5.QtGui import QIcon
|
|
18
|
+
import pkg_resources
|
|
19
|
+
from .styles import get_icon_path, theme_manager
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SearchDialog(QDialog):
|
|
23
|
+
"""A dialog for searching and selecting colormaps"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, parent=None, all_items=None):
|
|
26
|
+
super().__init__(parent)
|
|
27
|
+
self.setWindowTitle("Search Colormaps")
|
|
28
|
+
self.setMinimumWidth(350)
|
|
29
|
+
self.setMinimumHeight(400)
|
|
30
|
+
|
|
31
|
+
self.all_items = all_items or []
|
|
32
|
+
self.selected_item = None
|
|
33
|
+
|
|
34
|
+
layout = QVBoxLayout(self)
|
|
35
|
+
|
|
36
|
+
self.search_edit = QLineEdit()
|
|
37
|
+
self.search_edit.setPlaceholderText("Type to search colormaps...")
|
|
38
|
+
self.search_edit.textChanged.connect(self.filter_items)
|
|
39
|
+
layout.addWidget(self.search_edit)
|
|
40
|
+
|
|
41
|
+
self.list_widget = QListWidget()
|
|
42
|
+
self.list_widget.itemClicked.connect(self.on_item_clicked)
|
|
43
|
+
self.list_widget.itemDoubleClicked.connect(self.on_item_double_clicked)
|
|
44
|
+
layout.addWidget(self.list_widget)
|
|
45
|
+
|
|
46
|
+
button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
|
47
|
+
button_box.accepted.connect(self.accept)
|
|
48
|
+
button_box.rejected.connect(self.reject)
|
|
49
|
+
layout.addWidget(button_box)
|
|
50
|
+
|
|
51
|
+
self.populate_list_widget(self.all_items)
|
|
52
|
+
self.search_edit.setFocus()
|
|
53
|
+
self.search_edit.installEventFilter(self)
|
|
54
|
+
self.list_widget.installEventFilter(self)
|
|
55
|
+
|
|
56
|
+
def filter_items(self, text):
|
|
57
|
+
if not text:
|
|
58
|
+
self.populate_list_widget(self.all_items)
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
filtered_items = []
|
|
62
|
+
for item in self.all_items:
|
|
63
|
+
if text.lower() in item.lower():
|
|
64
|
+
filtered_items.append(item)
|
|
65
|
+
|
|
66
|
+
self.populate_list_widget(filtered_items)
|
|
67
|
+
|
|
68
|
+
if filtered_items and self.list_widget.count() > 0:
|
|
69
|
+
self.list_widget.setCurrentRow(0)
|
|
70
|
+
|
|
71
|
+
def populate_list_widget(self, items):
|
|
72
|
+
self.list_widget.clear()
|
|
73
|
+
for item in items:
|
|
74
|
+
list_item = QListWidgetItem(item)
|
|
75
|
+
list_item.setSizeHint(QSize(300, 25))
|
|
76
|
+
self.list_widget.addItem(list_item)
|
|
77
|
+
|
|
78
|
+
def on_item_clicked(self, item):
|
|
79
|
+
self.selected_item = item.text()
|
|
80
|
+
|
|
81
|
+
def on_item_double_clicked(self, item):
|
|
82
|
+
self.selected_item = item.text()
|
|
83
|
+
self.accept()
|
|
84
|
+
|
|
85
|
+
def accept(self):
|
|
86
|
+
current_item = self.list_widget.currentItem()
|
|
87
|
+
if current_item:
|
|
88
|
+
self.selected_item = current_item.text()
|
|
89
|
+
super().accept()
|
|
90
|
+
|
|
91
|
+
def eventFilter(self, obj, event):
|
|
92
|
+
if event.type() == event.KeyPress:
|
|
93
|
+
if obj == self.search_edit:
|
|
94
|
+
if event.key() == Qt.Key_Up or event.key() == Qt.Key_Down:
|
|
95
|
+
self.list_widget.setFocus()
|
|
96
|
+
if (
|
|
97
|
+
self.list_widget.count() > 0
|
|
98
|
+
and not self.list_widget.currentItem()
|
|
99
|
+
):
|
|
100
|
+
self.list_widget.setCurrentRow(0)
|
|
101
|
+
return True
|
|
102
|
+
elif event.key() == Qt.Key_Return or event.key() == Qt.Key_Enter:
|
|
103
|
+
if self.list_widget.count() > 0:
|
|
104
|
+
if not self.list_widget.currentItem():
|
|
105
|
+
self.list_widget.setCurrentRow(0)
|
|
106
|
+
current_item = self.list_widget.currentItem()
|
|
107
|
+
if current_item:
|
|
108
|
+
self.selected_item = current_item.text()
|
|
109
|
+
self.accept()
|
|
110
|
+
return True
|
|
111
|
+
elif obj == self.list_widget:
|
|
112
|
+
if event.key() == Qt.Key_Return or event.key() == Qt.Key_Enter:
|
|
113
|
+
current_item = self.list_widget.currentItem()
|
|
114
|
+
if current_item:
|
|
115
|
+
self.selected_item = current_item.text()
|
|
116
|
+
self.accept()
|
|
117
|
+
return True
|
|
118
|
+
return super().eventFilter(obj, event)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class ColormapSelector(QWidget):
|
|
122
|
+
"""
|
|
123
|
+
A widget that combines a simple dropdown for preferred colormaps
|
|
124
|
+
and a search button that expands to show a search area for all colormaps.
|
|
125
|
+
"""
|
|
126
|
+
|
|
127
|
+
colormapSelected = pyqtSignal(str)
|
|
128
|
+
|
|
129
|
+
def __init__(self, parent=None, preferred_items=None, all_items=None):
|
|
130
|
+
super().__init__(parent)
|
|
131
|
+
|
|
132
|
+
self.preferred_items = preferred_items or []
|
|
133
|
+
self.all_items = all_items or []
|
|
134
|
+
self.current_colormap = "viridis"
|
|
135
|
+
|
|
136
|
+
self.main_layout = QHBoxLayout(self)
|
|
137
|
+
self.main_layout.setContentsMargins(0, 0, 0, 0)
|
|
138
|
+
self.main_layout.setSpacing(5)
|
|
139
|
+
|
|
140
|
+
self.combo = QComboBox()
|
|
141
|
+
self.combo.addItems(self.preferred_items)
|
|
142
|
+
self.combo.currentTextChanged.connect(self.on_combo_changed)
|
|
143
|
+
self.main_layout.addWidget(self.combo, 1)
|
|
144
|
+
|
|
145
|
+
self.search_button = QPushButton()
|
|
146
|
+
self.search_button.setObjectName("IconOnlyNBGButton")
|
|
147
|
+
self.search_button.setToolTip("Search all colormaps")
|
|
148
|
+
self.search_button.setMaximumWidth(32)
|
|
149
|
+
self.search_button.setFixedSize(32, 32)
|
|
150
|
+
self._update_search_icon() # Use theme-aware icon
|
|
151
|
+
self.search_button.setIconSize(QSize(24, 24))
|
|
152
|
+
self.search_button.clicked.connect(self.show_search_dialog)
|
|
153
|
+
self.main_layout.addWidget(self.search_button)
|
|
154
|
+
|
|
155
|
+
# Register callback for theme changes
|
|
156
|
+
theme_manager.register_callback(self._on_theme_changed)
|
|
157
|
+
|
|
158
|
+
if self.preferred_items:
|
|
159
|
+
self.combo.setCurrentText(self.preferred_items[0])
|
|
160
|
+
self.current_colormap = self.preferred_items[0]
|
|
161
|
+
|
|
162
|
+
def _update_search_icon(self):
|
|
163
|
+
"""Update the search icon based on the current theme."""
|
|
164
|
+
icon_filename = get_icon_path("search.png")
|
|
165
|
+
self.search_button.setIcon(
|
|
166
|
+
QIcon(
|
|
167
|
+
pkg_resources.resource_filename(
|
|
168
|
+
"solar_radio_image_viewer", f"assets/{icon_filename}"
|
|
169
|
+
)
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def _on_theme_changed(self, theme):
|
|
174
|
+
"""Handle theme change by updating the search icon."""
|
|
175
|
+
self._update_search_icon()
|
|
176
|
+
|
|
177
|
+
def on_combo_changed(self, text):
|
|
178
|
+
if text in self.all_items:
|
|
179
|
+
self.current_colormap = text
|
|
180
|
+
self.colormapSelected.emit(text)
|
|
181
|
+
|
|
182
|
+
def show_search_dialog(self):
|
|
183
|
+
dialog = SearchDialog(self, self.all_items)
|
|
184
|
+
if dialog.exec_() == QDialog.Accepted and dialog.selected_item:
|
|
185
|
+
self.current_colormap = dialog.selected_item
|
|
186
|
+
if dialog.selected_item in self.preferred_items:
|
|
187
|
+
self.combo.blockSignals(True)
|
|
188
|
+
self.combo.setCurrentText(dialog.selected_item)
|
|
189
|
+
self.combo.blockSignals(False)
|
|
190
|
+
else:
|
|
191
|
+
self.combo.blockSignals(True)
|
|
192
|
+
found = False
|
|
193
|
+
for i in range(self.combo.count()):
|
|
194
|
+
if self.combo.itemText(i) == dialog.selected_item:
|
|
195
|
+
self.combo.setCurrentIndex(i)
|
|
196
|
+
found = True
|
|
197
|
+
break
|
|
198
|
+
if not found:
|
|
199
|
+
self.combo.addItem(dialog.selected_item)
|
|
200
|
+
self.combo.setCurrentText(dialog.selected_item)
|
|
201
|
+
self.combo.blockSignals(False)
|
|
202
|
+
self.colormapSelected.emit(dialog.selected_item)
|
|
203
|
+
|
|
204
|
+
def currentText(self):
|
|
205
|
+
return self.current_colormap
|
|
206
|
+
|
|
207
|
+
def setCurrentText(self, text):
|
|
208
|
+
self.current_colormap = text
|
|
209
|
+
if text in self.preferred_items:
|
|
210
|
+
self.combo.setCurrentText(text)
|
|
211
|
+
else:
|
|
212
|
+
found = False
|
|
213
|
+
for i in range(self.combo.count()):
|
|
214
|
+
if self.combo.itemText(i) == text:
|
|
215
|
+
self.combo.setCurrentIndex(i)
|
|
216
|
+
found = True
|
|
217
|
+
break
|
|
218
|
+
if not found:
|
|
219
|
+
self.combo.addItem(text)
|
|
220
|
+
self.combo.setCurrentText(text)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Solar Context Module - Provides solar activity context data.
|
|
4
|
+
|
|
5
|
+
Submodules:
|
|
6
|
+
- active_regions: NOAA Active Region data
|
|
7
|
+
- realtime_data: Real-time solar wind, Kp index, F10.7 flux
|
|
8
|
+
- cme_alerts: CME data from NASA DONKI
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from .active_regions import (
|
|
12
|
+
ActiveRegion,
|
|
13
|
+
fetch_and_parse_active_regions,
|
|
14
|
+
get_ar_statistics,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from .realtime_data import (
|
|
18
|
+
KpIndexData,
|
|
19
|
+
F107FluxData,
|
|
20
|
+
SolarConditions,
|
|
21
|
+
fetch_conditions_for_date,
|
|
22
|
+
fetch_current_conditions,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
from .cme_alerts import (
|
|
26
|
+
CMEEvent,
|
|
27
|
+
fetch_and_parse_cme_events,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"ActiveRegion",
|
|
32
|
+
"fetch_and_parse_active_regions",
|
|
33
|
+
"get_ar_statistics",
|
|
34
|
+
"KpIndexData",
|
|
35
|
+
"F107FluxData",
|
|
36
|
+
"SolarConditions",
|
|
37
|
+
"fetch_conditions_for_date",
|
|
38
|
+
"fetch_current_conditions",
|
|
39
|
+
"CMEEvent",
|
|
40
|
+
"fetch_and_parse_cme_events",
|
|
41
|
+
]
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Active Regions Parser - Fetches and parses NOAA active region data from solarmonitor.org.
|
|
4
|
+
|
|
5
|
+
Data sources:
|
|
6
|
+
- SRS file: Solar Region Summary (AR#, Location, Area, McIntosh class, Mag Type)
|
|
7
|
+
- arm_forecast: Flare probability predictions (C/M/X class percentages)
|
|
8
|
+
- arm_ar_summary: Recent flare activity per region
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
import urllib.request
|
|
13
|
+
import urllib.error
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from datetime import datetime, date
|
|
16
|
+
from typing import List, Optional, Dict, Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class ActiveRegion:
|
|
21
|
+
"""Represents a NOAA Active Region."""
|
|
22
|
+
noaa_number: str
|
|
23
|
+
location: str # e.g., "S17W72"
|
|
24
|
+
longitude: int # Carrington longitude
|
|
25
|
+
area: int # Millionths of solar hemisphere
|
|
26
|
+
mcintosh_class: str # e.g., "Ekc", "Dao"
|
|
27
|
+
num_spots: int
|
|
28
|
+
spot_count: int # NN field
|
|
29
|
+
mag_type: str # Magnetic classification (Alpha, Beta, Beta-Gamma, etc.)
|
|
30
|
+
|
|
31
|
+
# Flare probabilities (optional, from arm_forecast)
|
|
32
|
+
prob_c: Optional[int] = None # C-class probability %
|
|
33
|
+
prob_m: Optional[int] = None # M-class probability %
|
|
34
|
+
prob_x: Optional[int] = None # X-class probability %
|
|
35
|
+
|
|
36
|
+
# Recent flares (optional, from arm_ar_summary)
|
|
37
|
+
recent_flares: List[str] = field(default_factory=list)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def location_formatted(self) -> str:
|
|
41
|
+
"""Format location with hemisphere indicators."""
|
|
42
|
+
return self.location
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def is_complex(self) -> bool:
|
|
46
|
+
"""Check if region has complex magnetic configuration."""
|
|
47
|
+
return "Gamma" in self.mag_type or "Delta" in self.mag_type
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def flare_risk_level(self) -> str:
|
|
51
|
+
"""Assess overall flare risk based on probabilities."""
|
|
52
|
+
if self.prob_x and self.prob_x >= 10:
|
|
53
|
+
return "Very High"
|
|
54
|
+
if self.prob_m and self.prob_m >= 50:
|
|
55
|
+
return "High"
|
|
56
|
+
if self.prob_m and self.prob_m >= 20:
|
|
57
|
+
return "Moderate"
|
|
58
|
+
if self.prob_c and self.prob_c >= 50:
|
|
59
|
+
return "Low"
|
|
60
|
+
return "Quiet"
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def mcintosh_description(self) -> str:
|
|
64
|
+
"""Get human-readable McIntosh class description."""
|
|
65
|
+
if len(self.mcintosh_class) < 3:
|
|
66
|
+
return "Unknown"
|
|
67
|
+
|
|
68
|
+
# Modified Zurich class (first letter)
|
|
69
|
+
zurich = {
|
|
70
|
+
'A': "Unipolar",
|
|
71
|
+
'B': "Bipolar, no penumbra",
|
|
72
|
+
'C': "Bipolar, penumbra one end",
|
|
73
|
+
'D': "Bipolar, penumbra both ends",
|
|
74
|
+
'E': "Large bipolar",
|
|
75
|
+
'F': "Very large bipolar",
|
|
76
|
+
'H': "Unipolar with penumbra",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# Penumbra of largest spot (second letter)
|
|
80
|
+
penumbra = {
|
|
81
|
+
'x': "undefined",
|
|
82
|
+
'r': "rudimentary",
|
|
83
|
+
's': "small symmetric",
|
|
84
|
+
'a': "small asymmetric",
|
|
85
|
+
'h': "large symmetric",
|
|
86
|
+
'k': "large asymmetric",
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
# Spot distribution (third letter)
|
|
90
|
+
dist = {
|
|
91
|
+
'x': "undefined",
|
|
92
|
+
'o': "open",
|
|
93
|
+
'i': "intermediate",
|
|
94
|
+
'c': "compact",
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
z = self.mcintosh_class[0].upper()
|
|
98
|
+
p = self.mcintosh_class[1].lower()
|
|
99
|
+
d = self.mcintosh_class[2].lower()
|
|
100
|
+
|
|
101
|
+
z_desc = zurich.get(z, z)
|
|
102
|
+
p_desc = penumbra.get(p, p)
|
|
103
|
+
d_desc = dist.get(d, d)
|
|
104
|
+
|
|
105
|
+
return f"{z_desc}, {p_desc} penumbra, {d_desc} distribution"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def fetch_srs_data(event_date: date) -> Optional[str]:
|
|
109
|
+
"""
|
|
110
|
+
Fetch Solar Region Summary data from solarmonitor.org.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
event_date: The date to fetch data for
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Raw text content or None if fetch failed
|
|
117
|
+
"""
|
|
118
|
+
year = event_date.strftime("%Y")
|
|
119
|
+
month = event_date.strftime("%m")
|
|
120
|
+
day = event_date.strftime("%d")
|
|
121
|
+
|
|
122
|
+
# SRS filename format: MMDDSRS.txt (e.g., 0609SRS.txt)
|
|
123
|
+
srs_filename = f"{month}{day}SRS.txt"
|
|
124
|
+
|
|
125
|
+
url = f"https://solarmonitor.org/data/{year}/{month}/{day}/meta/{srs_filename}"
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
from ..utils import get_global_session
|
|
129
|
+
session = get_global_session()
|
|
130
|
+
response = session.get(url)
|
|
131
|
+
return response.text
|
|
132
|
+
except Exception as e:
|
|
133
|
+
if hasattr(e, 'response') and e.response is not None:
|
|
134
|
+
if e.response.status_code == 404:
|
|
135
|
+
return None
|
|
136
|
+
print(f"Error fetching SRS data: {e}")
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def fetch_forecast_data(event_date: date) -> Optional[str]:
|
|
141
|
+
"""
|
|
142
|
+
Fetch ARM flare forecast data from solarmonitor.org.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
event_date: The date to fetch data for
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
Raw text content or None if fetch failed
|
|
149
|
+
"""
|
|
150
|
+
year = event_date.strftime("%Y")
|
|
151
|
+
month = event_date.strftime("%m")
|
|
152
|
+
day = event_date.strftime("%d")
|
|
153
|
+
date_str = event_date.strftime("%Y%m%d")
|
|
154
|
+
|
|
155
|
+
url = f"https://solarmonitor.org/data/{year}/{month}/{day}/meta/arm_forecast_{date_str}.txt"
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
from ..utils import get_global_session
|
|
159
|
+
session = get_global_session()
|
|
160
|
+
response = session.get(url)
|
|
161
|
+
return response.text
|
|
162
|
+
except Exception as e:
|
|
163
|
+
print(f"Error fetching ARM forecast: {e}")
|
|
164
|
+
return None
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def parse_srs_data(raw_text: str) -> List[ActiveRegion]:
|
|
168
|
+
"""
|
|
169
|
+
Parse Solar Region Summary text into ActiveRegion objects.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
raw_text: Raw SRS text file content
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
List of ActiveRegion objects
|
|
176
|
+
"""
|
|
177
|
+
regions = []
|
|
178
|
+
in_sunspot_section = False
|
|
179
|
+
|
|
180
|
+
for line in raw_text.split("\n"):
|
|
181
|
+
line = line.strip()
|
|
182
|
+
|
|
183
|
+
# Detect section start
|
|
184
|
+
if "Regions with Sunspots" in line:
|
|
185
|
+
in_sunspot_section = True
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
# Detect section end
|
|
189
|
+
if line.startswith("IA.") or line.startswith("II."):
|
|
190
|
+
in_sunspot_section = False
|
|
191
|
+
continue
|
|
192
|
+
|
|
193
|
+
if not in_sunspot_section:
|
|
194
|
+
continue
|
|
195
|
+
|
|
196
|
+
# Skip header line
|
|
197
|
+
if line.startswith("Nmbr") or not line:
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
# Parse region line
|
|
201
|
+
# Format: Nmbr Location Lo Area Z LL NN Mag Type
|
|
202
|
+
# Example: 3697 S17W72 349 0360 Ekc 15 19 Beta-Gamma-Delta
|
|
203
|
+
|
|
204
|
+
try:
|
|
205
|
+
# Use regex for flexible parsing
|
|
206
|
+
pattern = r"(\d{4})\s+([NS]\d{2}[EW]\d{2})\s+(\d+)\s+(\d+)\s+(\w{3})\s+(\d+)\s+(\d+)\s+(.*)"
|
|
207
|
+
match = re.match(pattern, line)
|
|
208
|
+
|
|
209
|
+
if match:
|
|
210
|
+
noaa_num = match.group(1)
|
|
211
|
+
location = match.group(2)
|
|
212
|
+
longitude = int(match.group(3))
|
|
213
|
+
area = int(match.group(4))
|
|
214
|
+
mcintosh = match.group(5)
|
|
215
|
+
ll = int(match.group(6))
|
|
216
|
+
nn = int(match.group(7))
|
|
217
|
+
mag_type = match.group(8).strip()
|
|
218
|
+
|
|
219
|
+
regions.append(ActiveRegion(
|
|
220
|
+
noaa_number=noaa_num,
|
|
221
|
+
location=location,
|
|
222
|
+
longitude=longitude,
|
|
223
|
+
area=area,
|
|
224
|
+
mcintosh_class=mcintosh,
|
|
225
|
+
num_spots=ll,
|
|
226
|
+
spot_count=nn,
|
|
227
|
+
mag_type=mag_type,
|
|
228
|
+
))
|
|
229
|
+
except Exception:
|
|
230
|
+
continue
|
|
231
|
+
|
|
232
|
+
return regions
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def parse_forecast_data(raw_text: str) -> Dict[str, Dict[str, int]]:
|
|
236
|
+
"""
|
|
237
|
+
Parse ARM forecast data to get flare probabilities.
|
|
238
|
+
|
|
239
|
+
Format: AR# McIntosh C%(method1)(method2)(method3) M%(...) X%(...)
|
|
240
|
+
Example: 13697 Ekc 89(93)(90) 77(82)(60) 15(20)(20)
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
Dict mapping AR# to {'c': %, 'm': %, 'x': %}
|
|
244
|
+
"""
|
|
245
|
+
forecasts = {}
|
|
246
|
+
|
|
247
|
+
for line in raw_text.split("\n"):
|
|
248
|
+
line = line.strip()
|
|
249
|
+
if not line:
|
|
250
|
+
continue
|
|
251
|
+
|
|
252
|
+
try:
|
|
253
|
+
# Pattern: AR# McIntosh C(...)(...) M(...)(...) X(...)
|
|
254
|
+
parts = line.split()
|
|
255
|
+
if len(parts) >= 5:
|
|
256
|
+
ar_num = parts[0]
|
|
257
|
+
|
|
258
|
+
# Extract first number from each probability field
|
|
259
|
+
# Format like: 89(93)(90)
|
|
260
|
+
c_prob = int(parts[2].split("(")[0])
|
|
261
|
+
m_prob = int(parts[3].split("(")[0])
|
|
262
|
+
x_prob = int(parts[4].split("(")[0])
|
|
263
|
+
|
|
264
|
+
forecasts[ar_num] = {
|
|
265
|
+
'c': c_prob,
|
|
266
|
+
'm': m_prob,
|
|
267
|
+
'x': x_prob,
|
|
268
|
+
}
|
|
269
|
+
except (ValueError, IndexError):
|
|
270
|
+
continue
|
|
271
|
+
|
|
272
|
+
return forecasts
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def fetch_and_parse_active_regions(event_date: date) -> Optional[List[ActiveRegion]]:
|
|
276
|
+
"""
|
|
277
|
+
Fetch and parse all active region data for a date.
|
|
278
|
+
|
|
279
|
+
Args:
|
|
280
|
+
event_date: The date to fetch data for
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
List of ActiveRegion objects with probabilities, or None if fetch failed
|
|
284
|
+
"""
|
|
285
|
+
# Fetch SRS data
|
|
286
|
+
srs_text = fetch_srs_data(event_date)
|
|
287
|
+
if srs_text is None:
|
|
288
|
+
return None
|
|
289
|
+
|
|
290
|
+
# Parse regions
|
|
291
|
+
regions = parse_srs_data(srs_text)
|
|
292
|
+
if not regions:
|
|
293
|
+
return []
|
|
294
|
+
|
|
295
|
+
# Fetch and merge forecast data
|
|
296
|
+
forecast_text = fetch_forecast_data(event_date)
|
|
297
|
+
if forecast_text:
|
|
298
|
+
forecasts = parse_forecast_data(forecast_text)
|
|
299
|
+
|
|
300
|
+
for region in regions:
|
|
301
|
+
# Try different AR number formats (SRS uses 4-digit, forecast often uses 5-digit with "1" prefix)
|
|
302
|
+
ar_keys_to_try = [
|
|
303
|
+
region.noaa_number,
|
|
304
|
+
"1" + region.noaa_number, # 3697 -> 13697
|
|
305
|
+
region.noaa_number.lstrip("1") if region.noaa_number.startswith("1") else None, # 13697 -> 3697
|
|
306
|
+
]
|
|
307
|
+
|
|
308
|
+
for ar_key in ar_keys_to_try:
|
|
309
|
+
if ar_key and ar_key in forecasts:
|
|
310
|
+
probs = forecasts[ar_key]
|
|
311
|
+
region.prob_c = probs.get('c')
|
|
312
|
+
region.prob_m = probs.get('m')
|
|
313
|
+
region.prob_x = probs.get('x')
|
|
314
|
+
break
|
|
315
|
+
|
|
316
|
+
return regions
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def get_ar_statistics(regions: List[ActiveRegion]) -> Dict[str, Any]:
|
|
320
|
+
"""
|
|
321
|
+
Calculate statistics for a list of active regions.
|
|
322
|
+
|
|
323
|
+
Returns:
|
|
324
|
+
Dict with statistics like counts, max complexity, etc.
|
|
325
|
+
"""
|
|
326
|
+
stats = {
|
|
327
|
+
"total": len(regions),
|
|
328
|
+
"complex_count": sum(1 for r in regions if r.is_complex),
|
|
329
|
+
"total_area": sum(r.area for r in regions),
|
|
330
|
+
"max_area_region": None,
|
|
331
|
+
"highest_risk_region": None,
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if regions:
|
|
335
|
+
stats["max_area_region"] = max(regions, key=lambda r: r.area)
|
|
336
|
+
|
|
337
|
+
# Find highest risk region
|
|
338
|
+
risk_order = {"Very High": 4, "High": 3, "Moderate": 2, "Low": 1, "Quiet": 0}
|
|
339
|
+
stats["highest_risk_region"] = max(
|
|
340
|
+
regions,
|
|
341
|
+
key=lambda r: risk_order.get(r.flare_risk_level, 0)
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
return stats
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
if __name__ == "__main__":
|
|
348
|
+
# Test with sample date
|
|
349
|
+
from datetime import date
|
|
350
|
+
|
|
351
|
+
test_date = date(2024, 6, 9)
|
|
352
|
+
print(f"Fetching active regions for {test_date}...")
|
|
353
|
+
|
|
354
|
+
regions = fetch_and_parse_active_regions(test_date)
|
|
355
|
+
if regions:
|
|
356
|
+
print(f"Found {len(regions)} active regions:")
|
|
357
|
+
for r in regions:
|
|
358
|
+
prob_str = ""
|
|
359
|
+
if r.prob_c is not None:
|
|
360
|
+
prob_str = f" | C:{r.prob_c}% M:{r.prob_m}% X:{r.prob_x}%"
|
|
361
|
+
print(f" AR{r.noaa_number} | {r.location} | {r.mcintosh_class} | {r.mag_type}{prob_str}")
|
|
362
|
+
|
|
363
|
+
stats = get_ar_statistics(regions)
|
|
364
|
+
print(f"\nStatistics:")
|
|
365
|
+
print(f" Total area: {stats['total_area']} millionths")
|
|
366
|
+
print(f" Complex regions: {stats['complex_count']}")
|
|
367
|
+
if stats['highest_risk_region']:
|
|
368
|
+
hr = stats['highest_risk_region']
|
|
369
|
+
print(f" Highest risk: AR{hr.noaa_number} ({hr.flare_risk_level})")
|
|
370
|
+
else:
|
|
371
|
+
print("No active regions found or fetch failed")
|