swpc-kit 0.3.0__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.
- swpc_kit/__init__.py +32 -0
- swpc_kit/client/__init__.py +482 -0
- swpc_kit/client/_core.py +384 -0
- swpc_kit/client/_httpx.py +93 -0
- swpc_kit/client/_products.py +146 -0
- swpc_kit/codes.py +249 -0
- swpc_kit/errors.py +135 -0
- swpc_kit/export/__init__.py +12 -0
- swpc_kit/export/_tiff.py +64 -0
- swpc_kit/export/data.py +167 -0
- swpc_kit/export/geojson.py +111 -0
- swpc_kit/export/geotiff.py +134 -0
- swpc_kit/models/__init__.py +116 -0
- swpc_kit/models/_checks.py +16 -0
- swpc_kit/models/alerts.py +205 -0
- swpc_kit/models/bulletins.py +239 -0
- swpc_kit/models/goes.py +252 -0
- swpc_kit/models/grids.py +353 -0
- swpc_kit/models/indices.py +356 -0
- swpc_kit/models/records.py +29 -0
- swpc_kit/models/solar_wind.py +274 -0
- swpc_kit/parse/__init__.py +53 -0
- swpc_kit/parse/_common.py +353 -0
- swpc_kit/parse/_text.py +334 -0
- swpc_kit/parse/alerts.py +252 -0
- swpc_kit/parse/bulletins.py +532 -0
- swpc_kit/parse/goes.py +111 -0
- swpc_kit/parse/grids.py +379 -0
- swpc_kit/parse/indices.py +344 -0
- swpc_kit/parse/solar_wind.py +154 -0
- swpc_kit/py.typed +0 -0
- swpc_kit/skill/__init__.py +213 -0
- swpc_kit/skill/__main__.py +7 -0
- swpc_kit/skill/swpc-kit/SKILL.md +261 -0
- swpc_kit/skill/swpc-kit/references/api.md +1052 -0
- swpc_kit-0.3.0.dist-info/METADATA +375 -0
- swpc_kit-0.3.0.dist-info/RECORD +39 -0
- swpc_kit-0.3.0.dist-info/WHEEL +4 -0
- swpc_kit-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Models for the SWPC text bulletins.
|
|
2
|
+
|
|
3
|
+
The 3-day forecast (``3-day-forecast.txt``), the geomagnetic forecast
|
|
4
|
+
(``3-day-geomag-forecast.txt``) and the 27-day outlook (``27-day-outlook.txt``).
|
|
5
|
+
Each one carries the ``:Issued:`` time of its header, which is also its
|
|
6
|
+
``data_time``.
|
|
7
|
+
|
|
8
|
+
The bulletins write their days as month and day without a year; the parsers
|
|
9
|
+
place them in the year that puts them closest to the issue time, so a
|
|
10
|
+
forecast issued on 31 December runs into January of the next year.
|
|
11
|
+
|
|
12
|
+
Free text (summaries and rationales) is kept as written, with the line
|
|
13
|
+
wrapping undone: wrapped lines are joined with a space and paragraphs are
|
|
14
|
+
separated by a blank line. It is data to read, never instructions.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from datetime import date, datetime, timedelta
|
|
20
|
+
from itertools import pairwise
|
|
21
|
+
|
|
22
|
+
from swpc_kit.models._checks import check_utc
|
|
23
|
+
|
|
24
|
+
KP_PERIOD = timedelta(hours=3)
|
|
25
|
+
"""Length of one interval of a Kp table."""
|
|
26
|
+
|
|
27
|
+
KP_PERIODS_PER_DAY = 8
|
|
28
|
+
"""Rows of a Kp table: ``00-03UT`` to ``21-00UT``."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _check_days(days: Sequence[date]) -> None:
|
|
32
|
+
if not days:
|
|
33
|
+
raise ValueError("days must not be empty")
|
|
34
|
+
for before, after in pairwise(days):
|
|
35
|
+
if after - before != timedelta(days=1):
|
|
36
|
+
raise ValueError(f"days must follow each other, got {before} then {after}")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _check_aligned(name: str, values: Sequence[object], days: Sequence[date]) -> None:
|
|
40
|
+
if len(values) != len(days):
|
|
41
|
+
raise ValueError(f"{name} has {len(values)} values for {len(days)} days")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _check_kp(kp: Sequence["KpPeriod"], days: Sequence[date]) -> None:
|
|
45
|
+
if len(kp) != KP_PERIODS_PER_DAY * len(days):
|
|
46
|
+
raise ValueError(
|
|
47
|
+
f"kp has {len(kp)} periods, expected {KP_PERIODS_PER_DAY} per day "
|
|
48
|
+
f"for {len(days)} days"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True, slots=True)
|
|
53
|
+
class KpPeriod:
|
|
54
|
+
"""One 3-hour interval of a Kp forecast table.
|
|
55
|
+
|
|
56
|
+
Attributes:
|
|
57
|
+
start: Start of the interval.
|
|
58
|
+
kp: Kp in thirds, as a decimal (2.67 is 3-), from 0 to 9.
|
|
59
|
+
noaa_scale: NOAA geomagnetic storm level written next to the value,
|
|
60
|
+
such as ``"G1"`` for ``5.33 (G1)``, or ``None``.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
start: datetime
|
|
64
|
+
kp: float
|
|
65
|
+
noaa_scale: str | None
|
|
66
|
+
|
|
67
|
+
def __post_init__(self) -> None:
|
|
68
|
+
check_utc("start", self.start)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def end(self) -> datetime:
|
|
72
|
+
"""End of the interval, three hours after ``start``."""
|
|
73
|
+
return self.start + KP_PERIOD
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True, slots=True)
|
|
77
|
+
class ThreeDayForecast:
|
|
78
|
+
"""The 3-day forecast: geomagnetic activity, radiation storms, blackouts.
|
|
79
|
+
|
|
80
|
+
Every per-day tuple is aligned with ``days``.
|
|
81
|
+
|
|
82
|
+
Attributes:
|
|
83
|
+
issued: The ``:Issued:`` time.
|
|
84
|
+
days: The three forecast days.
|
|
85
|
+
greatest_observed_kp: Greatest 3-hour Kp observed over the 24 hours
|
|
86
|
+
before the issue time.
|
|
87
|
+
greatest_observed_scale: NOAA scale level of that value, such as
|
|
88
|
+
``"G1"``, or ``None`` when below the scale levels.
|
|
89
|
+
greatest_expected_kp: Greatest 3-hour Kp expected over ``days``.
|
|
90
|
+
greatest_expected_scale: NOAA scale level of that value, or ``None``.
|
|
91
|
+
geomagnetic_summary: The sentences on the greatest Kp, as text.
|
|
92
|
+
kp: Kp forecast for every 3-hour interval of ``days``, in time order.
|
|
93
|
+
geomagnetic_rationale: Rationale of section A.
|
|
94
|
+
solar_radiation_summary: Observation paragraph of section B.
|
|
95
|
+
s1_or_greater: Probability of an S1 or greater radiation storm, in
|
|
96
|
+
percent, per day.
|
|
97
|
+
solar_radiation_rationale: Rationale of section B.
|
|
98
|
+
radio_blackout_summary: Observation paragraph of section C.
|
|
99
|
+
r1_r2: Probability of an R1-R2 radio blackout, in percent, per day.
|
|
100
|
+
r3_or_greater: Probability of an R3 or greater radio blackout, in
|
|
101
|
+
percent, per day.
|
|
102
|
+
radio_blackout_rationale: Rationale of section C.
|
|
103
|
+
source_url: Where the content came from.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
issued: datetime
|
|
107
|
+
days: tuple[date, ...]
|
|
108
|
+
greatest_observed_kp: float
|
|
109
|
+
greatest_observed_scale: str | None
|
|
110
|
+
greatest_expected_kp: float
|
|
111
|
+
greatest_expected_scale: str | None
|
|
112
|
+
geomagnetic_summary: str
|
|
113
|
+
kp: tuple[KpPeriod, ...]
|
|
114
|
+
geomagnetic_rationale: str
|
|
115
|
+
solar_radiation_summary: str
|
|
116
|
+
s1_or_greater: tuple[int, ...]
|
|
117
|
+
solar_radiation_rationale: str
|
|
118
|
+
radio_blackout_summary: str
|
|
119
|
+
r1_r2: tuple[int, ...]
|
|
120
|
+
r3_or_greater: tuple[int, ...]
|
|
121
|
+
radio_blackout_rationale: str
|
|
122
|
+
source_url: str
|
|
123
|
+
|
|
124
|
+
def __post_init__(self) -> None:
|
|
125
|
+
check_utc("issued", self.issued)
|
|
126
|
+
_check_days(self.days)
|
|
127
|
+
_check_kp(self.kp, self.days)
|
|
128
|
+
_check_aligned("s1_or_greater", self.s1_or_greater, self.days)
|
|
129
|
+
_check_aligned("r1_r2", self.r1_r2, self.days)
|
|
130
|
+
_check_aligned("r3_or_greater", self.r3_or_greater, self.days)
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def data_time(self) -> datetime:
|
|
134
|
+
"""The ``:Issued:`` time."""
|
|
135
|
+
return self.issued
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@dataclass(frozen=True, slots=True)
|
|
139
|
+
class DailyAp:
|
|
140
|
+
"""The planetary A index of one day.
|
|
141
|
+
|
|
142
|
+
Attributes:
|
|
143
|
+
day: The UTC day.
|
|
144
|
+
ap: The Ap index.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
day: date
|
|
148
|
+
ap: int
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(frozen=True, slots=True)
|
|
152
|
+
class GeomagneticForecast:
|
|
153
|
+
"""The geomagnetic forecast: Ap, storm probabilities and Kp.
|
|
154
|
+
|
|
155
|
+
Every per-day tuple is aligned with ``days``.
|
|
156
|
+
|
|
157
|
+
Attributes:
|
|
158
|
+
issued: The ``:Issued:`` time.
|
|
159
|
+
observed_ap: Ap observed on the day before ``estimated_ap``.
|
|
160
|
+
estimated_ap: Ap estimated for the day of issue.
|
|
161
|
+
days: The three forecast days.
|
|
162
|
+
predicted_ap: Ap predicted for each of ``days``.
|
|
163
|
+
active: Probability of active conditions, in percent, per day.
|
|
164
|
+
minor_storm: Probability of a minor storm, in percent, per day.
|
|
165
|
+
moderate_storm: Probability of a moderate storm, in percent, per day.
|
|
166
|
+
strong_extreme_storm: Probability of a strong to extreme storm, in
|
|
167
|
+
percent, per day.
|
|
168
|
+
kp: Kp forecast for every 3-hour interval of ``days``, in time order.
|
|
169
|
+
source_url: Where the content came from.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
issued: datetime
|
|
173
|
+
observed_ap: DailyAp
|
|
174
|
+
estimated_ap: DailyAp
|
|
175
|
+
days: tuple[date, ...]
|
|
176
|
+
predicted_ap: tuple[DailyAp, ...]
|
|
177
|
+
active: tuple[int, ...]
|
|
178
|
+
minor_storm: tuple[int, ...]
|
|
179
|
+
moderate_storm: tuple[int, ...]
|
|
180
|
+
strong_extreme_storm: tuple[int, ...]
|
|
181
|
+
kp: tuple[KpPeriod, ...]
|
|
182
|
+
source_url: str
|
|
183
|
+
|
|
184
|
+
def __post_init__(self) -> None:
|
|
185
|
+
check_utc("issued", self.issued)
|
|
186
|
+
_check_days(self.days)
|
|
187
|
+
if tuple(a.day for a in self.predicted_ap) != self.days:
|
|
188
|
+
raise ValueError("predicted_ap must give one value for each of days")
|
|
189
|
+
_check_aligned("active", self.active, self.days)
|
|
190
|
+
_check_aligned("minor_storm", self.minor_storm, self.days)
|
|
191
|
+
_check_aligned("moderate_storm", self.moderate_storm, self.days)
|
|
192
|
+
_check_aligned("strong_extreme_storm", self.strong_extreme_storm, self.days)
|
|
193
|
+
_check_kp(self.kp, self.days)
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def data_time(self) -> datetime:
|
|
197
|
+
"""The ``:Issued:`` time."""
|
|
198
|
+
return self.issued
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass(frozen=True, slots=True)
|
|
202
|
+
class OutlookDay:
|
|
203
|
+
"""One day of the 27-day outlook.
|
|
204
|
+
|
|
205
|
+
Attributes:
|
|
206
|
+
day: The UTC day.
|
|
207
|
+
radio_flux: Predicted 10.7 cm radio flux, in solar flux units.
|
|
208
|
+
ap: Predicted planetary A index.
|
|
209
|
+
largest_kp: Predicted largest Kp of the day, from 0 to 9.
|
|
210
|
+
"""
|
|
211
|
+
|
|
212
|
+
day: date
|
|
213
|
+
radio_flux: float
|
|
214
|
+
ap: int
|
|
215
|
+
largest_kp: int
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@dataclass(frozen=True, slots=True)
|
|
219
|
+
class TwentySevenDayOutlook:
|
|
220
|
+
"""The 27-day space weather outlook.
|
|
221
|
+
|
|
222
|
+
Attributes:
|
|
223
|
+
issued: The ``:Issued:`` time.
|
|
224
|
+
days: One entry per day, in date order, on consecutive days.
|
|
225
|
+
source_url: Where the content came from.
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
issued: datetime
|
|
229
|
+
days: tuple[OutlookDay, ...]
|
|
230
|
+
source_url: str
|
|
231
|
+
|
|
232
|
+
def __post_init__(self) -> None:
|
|
233
|
+
check_utc("issued", self.issued)
|
|
234
|
+
_check_days([d.day for d in self.days])
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def data_time(self) -> datetime:
|
|
238
|
+
"""The ``:Issued:`` time."""
|
|
239
|
+
return self.issued
|
swpc_kit/models/goes.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""Models for the GOES X-ray flux and integral proton flux.
|
|
2
|
+
|
|
3
|
+
The GOES products of SWPC hold one record per time and per band: two X-ray
|
|
4
|
+
bands every minute, eight integral proton energy thresholds every five
|
|
5
|
+
minutes. The models keep the records as they come and give access by band
|
|
6
|
+
or threshold. The flare class (A, B, C, M, X) is computed from the flux of
|
|
7
|
+
the 0.1-0.8 nm band.
|
|
8
|
+
|
|
9
|
+
Times are UTC (the source writes them with ``Z``).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from decimal import ROUND_FLOOR, Decimal
|
|
16
|
+
from enum import StrEnum
|
|
17
|
+
from itertools import pairwise
|
|
18
|
+
|
|
19
|
+
from swpc_kit.models._checks import check_utc, check_values
|
|
20
|
+
from swpc_kit.models.records import SkippedRecord
|
|
21
|
+
|
|
22
|
+
# --- Flare class -------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
_LETTERS = ("A", "B", "C", "M", "X")
|
|
25
|
+
_BASE = {letter: Decimal(f"1e-{8 - i}") for i, letter in enumerate(_LETTERS)}
|
|
26
|
+
_TENTH = Decimal("0.1")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True, order=True)
|
|
30
|
+
class FlareClass:
|
|
31
|
+
"""A solar flare class, such as ``M2.5`` (a flux of 2.5e-5 W/m²).
|
|
32
|
+
|
|
33
|
+
The letter gives the decade of the 0.1-0.8 nm flux in W/m² (A from
|
|
34
|
+
10⁻⁸, B from 10⁻⁷, C from 10⁻⁶, M from 10⁻⁵, X from 10⁻⁴) and the
|
|
35
|
+
multiplier the flux within it. X has no upper bound (X12 is
|
|
36
|
+
1.2e-3 W/m²). Classes compare by flux.
|
|
37
|
+
|
|
38
|
+
Attributes:
|
|
39
|
+
letter: ``"A"``, ``"B"``, ``"C"``, ``"M"`` or ``"X"``.
|
|
40
|
+
multiplier: Flux divided by the lower bound of the letter's decade,
|
|
41
|
+
0 to 9.9 for A to M, 0 or more for X.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
ValueError: If the letter is unknown or the multiplier is out of
|
|
45
|
+
range.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
letter: str
|
|
49
|
+
multiplier: float
|
|
50
|
+
|
|
51
|
+
def __post_init__(self) -> None:
|
|
52
|
+
if self.letter not in _LETTERS:
|
|
53
|
+
raise ValueError(f"unknown flare class letter {self.letter!r}")
|
|
54
|
+
upper = math.inf if self.letter == "X" else 10.0
|
|
55
|
+
if not (0 <= self.multiplier < upper):
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"flare class multiplier {self.multiplier} out of range for "
|
|
58
|
+
f"{self.letter}"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def from_flux(cls, flux: float) -> "FlareClass":
|
|
63
|
+
"""Return the class of a 0.1-0.8 nm flux in W/m².
|
|
64
|
+
|
|
65
|
+
The multiplier is truncated to one decimal (3.96e-6 is C3.9, and
|
|
66
|
+
9.99e-6 is C9.9, not C10.0). The flux is read through its
|
|
67
|
+
shortest decimal form, so 2.3e-5 gives M2.3 despite binary rounding.
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
ValueError: If the flux is not a positive finite number.
|
|
71
|
+
"""
|
|
72
|
+
if not (math.isfinite(flux) and flux > 0):
|
|
73
|
+
raise ValueError(f"flux must be positive and finite, got {flux!r}")
|
|
74
|
+
exact = Decimal(repr(flux))
|
|
75
|
+
letter = next(
|
|
76
|
+
(letter for letter, upper in pairwise(_LETTERS) if exact < _BASE[upper]),
|
|
77
|
+
"X",
|
|
78
|
+
)
|
|
79
|
+
multiplier = (exact / _BASE[letter]).quantize(_TENTH, rounding=ROUND_FLOOR)
|
|
80
|
+
return cls(letter, float(multiplier))
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def flux(self) -> float:
|
|
84
|
+
"""The flux the class stands for, in W/m² (M2.5 is 2.5e-5)."""
|
|
85
|
+
return float(Decimal(repr(self.multiplier)) * _BASE[self.letter])
|
|
86
|
+
|
|
87
|
+
def __str__(self) -> str:
|
|
88
|
+
return f"{self.letter}{self.multiplier:.1f}"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# --- X-rays ------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class XrayBand(StrEnum):
|
|
95
|
+
"""The two GOES X-ray bands, as written in the ``energy`` field.
|
|
96
|
+
|
|
97
|
+
Samples keep ``energy`` as a plain string, so a band SWPC adds later
|
|
98
|
+
does not break parsing; these members compare equal to those strings.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
SHORT = "0.05-0.4nm"
|
|
102
|
+
"""Short band, 0.05-0.4 nm."""
|
|
103
|
+
LONG = "0.1-0.8nm"
|
|
104
|
+
"""Long band, 0.1-0.8 nm: the one flare classes are defined on."""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True, slots=True)
|
|
108
|
+
class XraySample:
|
|
109
|
+
"""One GOES X-ray flux value of one band.
|
|
110
|
+
|
|
111
|
+
Attributes:
|
|
112
|
+
time: Time of the value.
|
|
113
|
+
satellite: GOES satellite number, such as 18.
|
|
114
|
+
energy: Band, such as ``"0.1-0.8nm"`` (see :class:`XrayBand`).
|
|
115
|
+
flux: Flux in W/m², after the electron correction, or ``None``.
|
|
116
|
+
observed_flux: Flux in W/m² before the correction, or ``None``.
|
|
117
|
+
electron_correction: Correction subtracted, in W/m², or ``None``.
|
|
118
|
+
electron_contamination: Whether the value is flagged as contaminated
|
|
119
|
+
by electrons (``electron_contaminaton``, so spelled, in the
|
|
120
|
+
source), or ``None``.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
time: datetime
|
|
124
|
+
satellite: int
|
|
125
|
+
energy: str
|
|
126
|
+
flux: float | None
|
|
127
|
+
observed_flux: float | None
|
|
128
|
+
electron_correction: float | None
|
|
129
|
+
electron_contamination: bool | None
|
|
130
|
+
|
|
131
|
+
def __post_init__(self) -> None:
|
|
132
|
+
check_utc("time", self.time)
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def flare_class(self) -> FlareClass | None:
|
|
136
|
+
"""Flare class of the flux, for the 0.1-0.8 nm band only.
|
|
137
|
+
|
|
138
|
+
``None`` for the other bands and when the flux is missing, zero or
|
|
139
|
+
negative.
|
|
140
|
+
"""
|
|
141
|
+
if self.energy != XrayBand.LONG or self.flux is None or self.flux <= 0:
|
|
142
|
+
return None
|
|
143
|
+
return FlareClass.from_flux(self.flux)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@dataclass(frozen=True, slots=True)
|
|
147
|
+
class GoesXrays:
|
|
148
|
+
"""GOES X-ray flux in both bands.
|
|
149
|
+
|
|
150
|
+
Attributes:
|
|
151
|
+
values: The values of every band, oldest first; values with the same
|
|
152
|
+
time keep the order of the source file.
|
|
153
|
+
source_url: Where the content came from.
|
|
154
|
+
skipped: The records that could not be read, in source order.
|
|
155
|
+
"""
|
|
156
|
+
|
|
157
|
+
values: tuple[XraySample, ...]
|
|
158
|
+
source_url: str
|
|
159
|
+
skipped: tuple[SkippedRecord, ...] = ()
|
|
160
|
+
|
|
161
|
+
def __post_init__(self) -> None:
|
|
162
|
+
check_values(self.values)
|
|
163
|
+
|
|
164
|
+
@property
|
|
165
|
+
def data_time(self) -> datetime:
|
|
166
|
+
"""Time of the most recent value, of any band."""
|
|
167
|
+
return max(v.time for v in self.values)
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def energies(self) -> tuple[str, ...]:
|
|
171
|
+
"""The bands in the content, sorted."""
|
|
172
|
+
return tuple(sorted({v.energy for v in self.values}))
|
|
173
|
+
|
|
174
|
+
def band(self, energy: str) -> tuple[XraySample, ...]:
|
|
175
|
+
"""Return the values of one band, oldest first (empty if none)."""
|
|
176
|
+
return tuple(v for v in self.values if v.energy == energy)
|
|
177
|
+
|
|
178
|
+
def latest(self, energy: str = XrayBand.LONG) -> XraySample | None:
|
|
179
|
+
"""Return the most recent value of a band, or ``None``."""
|
|
180
|
+
band = self.band(energy)
|
|
181
|
+
return max(band, key=lambda v: v.time) if band else None
|
|
182
|
+
|
|
183
|
+
def peak(self, energy: str = XrayBand.LONG) -> XraySample | None:
|
|
184
|
+
"""Return the value of a band with the highest flux, or ``None``.
|
|
185
|
+
|
|
186
|
+
Values without a flux are ignored; on a tie the earliest one wins.
|
|
187
|
+
"""
|
|
188
|
+
band = [v for v in self.band(energy) if v.flux is not None]
|
|
189
|
+
return max(band, key=lambda v: v.flux or 0.0) if band else None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# --- Integral protons --------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@dataclass(frozen=True, slots=True)
|
|
196
|
+
class ProtonSample:
|
|
197
|
+
"""One GOES integral proton flux value above one energy threshold.
|
|
198
|
+
|
|
199
|
+
Attributes:
|
|
200
|
+
time: Time of the value.
|
|
201
|
+
satellite: GOES satellite number, such as 18.
|
|
202
|
+
energy: Threshold as written in the source, such as ``">=10 MeV"``.
|
|
203
|
+
threshold_mev: The threshold in MeV, such as 10.0.
|
|
204
|
+
flux: Integral flux in pfu (particles cm⁻² s⁻¹ sr⁻¹), or ``None``.
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
time: datetime
|
|
208
|
+
satellite: int
|
|
209
|
+
energy: str
|
|
210
|
+
threshold_mev: float
|
|
211
|
+
flux: float | None
|
|
212
|
+
|
|
213
|
+
def __post_init__(self) -> None:
|
|
214
|
+
check_utc("time", self.time)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass(frozen=True, slots=True)
|
|
218
|
+
class GoesIntegralProtons:
|
|
219
|
+
"""GOES integral proton flux above each energy threshold.
|
|
220
|
+
|
|
221
|
+
Attributes:
|
|
222
|
+
values: The values of every threshold, oldest first; values with the
|
|
223
|
+
same time keep the order of the source file.
|
|
224
|
+
source_url: Where the content came from.
|
|
225
|
+
skipped: The records that could not be read, in source order.
|
|
226
|
+
"""
|
|
227
|
+
|
|
228
|
+
values: tuple[ProtonSample, ...]
|
|
229
|
+
source_url: str
|
|
230
|
+
skipped: tuple[SkippedRecord, ...] = ()
|
|
231
|
+
|
|
232
|
+
def __post_init__(self) -> None:
|
|
233
|
+
check_values(self.values)
|
|
234
|
+
|
|
235
|
+
@property
|
|
236
|
+
def data_time(self) -> datetime:
|
|
237
|
+
"""Time of the most recent value, of any threshold."""
|
|
238
|
+
return max(v.time for v in self.values)
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def thresholds(self) -> tuple[float, ...]:
|
|
242
|
+
"""The energy thresholds in the content, in MeV, ascending."""
|
|
243
|
+
return tuple(sorted({v.threshold_mev for v in self.values}))
|
|
244
|
+
|
|
245
|
+
def series(self, threshold_mev: float) -> tuple[ProtonSample, ...]:
|
|
246
|
+
"""Return the values above ``threshold_mev``, oldest first."""
|
|
247
|
+
return tuple(v for v in self.values if v.threshold_mev == threshold_mev)
|
|
248
|
+
|
|
249
|
+
def latest(self, threshold_mev: float) -> ProtonSample | None:
|
|
250
|
+
"""Return the most recent value above ``threshold_mev``, or ``None``."""
|
|
251
|
+
series = self.series(threshold_mev)
|
|
252
|
+
return max(series, key=lambda v: v.time) if series else None
|