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,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")