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.
@@ -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
@@ -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