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
swpc_kit/models/grids.py
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"""Models for the gridded products: OVATION aurora and D-RAP.
|
|
2
|
+
|
|
3
|
+
Both products are values on a regular latitude/longitude grid. The values are
|
|
4
|
+
given at **cell centres**; a cell spans half a step on each side of its
|
|
5
|
+
centre. :class:`Grid` holds the axes, the values, the quantity and its units;
|
|
6
|
+
the product models add the times the content declares and its own text
|
|
7
|
+
fields. :class:`GridProduct` is what the exports in :mod:`swpc_kit.export`
|
|
8
|
+
accept.
|
|
9
|
+
|
|
10
|
+
A grid keeps the longitudes as the source delivers them (OVATION uses 0 to
|
|
11
|
+
359, D-RAP -178 to 178); :meth:`Grid.wrapped` turns them into -180 to 180 for
|
|
12
|
+
use on a map.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
from collections.abc import Iterator, Mapping
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from itertools import pairwise
|
|
20
|
+
from typing import Protocol
|
|
21
|
+
|
|
22
|
+
from swpc_kit.models._checks import check_utc
|
|
23
|
+
|
|
24
|
+
_TOLERANCE = 1e-9
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def wrap_longitude(longitude: float) -> float:
|
|
28
|
+
"""Return ``longitude`` in degrees east, moved into [-180, 180).
|
|
29
|
+
|
|
30
|
+
For example 0 stays 0, 180 becomes -180, 359 becomes -1.
|
|
31
|
+
"""
|
|
32
|
+
return (longitude + 180.0) % 360.0 - 180.0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _check_axis(name: str, axis: tuple[float, ...], step: float) -> None:
|
|
36
|
+
if not axis:
|
|
37
|
+
raise ValueError(f"{name} must not be empty")
|
|
38
|
+
if not (math.isfinite(step) and step > 0):
|
|
39
|
+
raise ValueError(f"{name} step must be a positive number, got {step!r}")
|
|
40
|
+
first = axis[0]
|
|
41
|
+
for previous, value in pairwise(axis):
|
|
42
|
+
if not value > previous:
|
|
43
|
+
raise ValueError(f"{name} must be strictly increasing, got {axis!r}")
|
|
44
|
+
for value in axis:
|
|
45
|
+
offset = (value - first) / step
|
|
46
|
+
if abs(offset - round(offset)) > _TOLERANCE:
|
|
47
|
+
raise ValueError(
|
|
48
|
+
f"{name} value {value} is not on the grid of step {step} "
|
|
49
|
+
f"starting at {first}"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _contiguous(axis: tuple[float, ...], step: float) -> bool:
|
|
54
|
+
expected = round((axis[-1] - axis[0]) / step) + 1
|
|
55
|
+
return len(axis) == expected
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _find(axis: tuple[float, ...], value: float) -> int | None:
|
|
59
|
+
for index, item in enumerate(axis):
|
|
60
|
+
if abs(item - value) <= _TOLERANCE:
|
|
61
|
+
return index
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _holding(
|
|
66
|
+
axis: tuple[float, ...], step: float, values: tuple[float, ...]
|
|
67
|
+
) -> int | None:
|
|
68
|
+
"""Index of the cell of ``axis`` whose span holds one of ``values``.
|
|
69
|
+
|
|
70
|
+
``values`` are the same point written in different conventions. On a
|
|
71
|
+
border the cell after it wins, then the one before: so the last edge of
|
|
72
|
+
the axis still belongs to its last cell.
|
|
73
|
+
"""
|
|
74
|
+
lattice = {round((item - axis[0]) / step): i for i, item in enumerate(axis)}
|
|
75
|
+
for after in (True, False):
|
|
76
|
+
for value in values:
|
|
77
|
+
position = (value - axis[0]) / step
|
|
78
|
+
offset = math.floor(position + 0.5) if after else math.ceil(position - 0.5)
|
|
79
|
+
if offset in lattice:
|
|
80
|
+
return lattice[offset]
|
|
81
|
+
return None
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True, slots=True)
|
|
85
|
+
class Grid:
|
|
86
|
+
"""Values on a regular latitude/longitude grid, at cell centres.
|
|
87
|
+
|
|
88
|
+
The axes lie on a regular lattice of the given steps, but they may have
|
|
89
|
+
gaps: a grid read from a reduced file (see :func:`~swpc_kit.parse.grids.
|
|
90
|
+
parse_ovation_aurora`) keeps only some longitudes. :attr:`is_contiguous`
|
|
91
|
+
tells the two apart.
|
|
92
|
+
|
|
93
|
+
Attributes:
|
|
94
|
+
latitudes: Cell-centre latitudes in degrees north, strictly
|
|
95
|
+
increasing (south to north), within [-90, 90].
|
|
96
|
+
longitudes: Cell-centre longitudes in degrees east, strictly
|
|
97
|
+
increasing, within [-180, 360), as the source delivers them.
|
|
98
|
+
latitude_step: Height of a cell in degrees.
|
|
99
|
+
longitude_step: Width of a cell in degrees.
|
|
100
|
+
values: One row per latitude, in the order of ``latitudes``; each row
|
|
101
|
+
has one value per longitude, in the order of ``longitudes``.
|
|
102
|
+
quantity: What the values are, such as ``"aurora"``.
|
|
103
|
+
units: Units of the values, such as ``"MHz"``.
|
|
104
|
+
|
|
105
|
+
Raises:
|
|
106
|
+
ValueError: If the axes are empty, unordered, off the lattice or out
|
|
107
|
+
of range, or the values do not match the axes or are not finite.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
latitudes: tuple[float, ...]
|
|
111
|
+
longitudes: tuple[float, ...]
|
|
112
|
+
latitude_step: float
|
|
113
|
+
longitude_step: float
|
|
114
|
+
values: tuple[tuple[float, ...], ...]
|
|
115
|
+
quantity: str
|
|
116
|
+
units: str
|
|
117
|
+
|
|
118
|
+
def __post_init__(self) -> None:
|
|
119
|
+
_check_axis("latitudes", self.latitudes, self.latitude_step)
|
|
120
|
+
_check_axis("longitudes", self.longitudes, self.longitude_step)
|
|
121
|
+
if self.latitudes[0] < -90 or self.latitudes[-1] > 90:
|
|
122
|
+
raise ValueError("latitudes must lie within [-90, 90]")
|
|
123
|
+
if self.longitudes[0] < -180 or self.longitudes[-1] >= 360:
|
|
124
|
+
raise ValueError("longitudes must lie within [-180, 360)")
|
|
125
|
+
if self.longitudes[-1] - self.longitudes[0] >= 360:
|
|
126
|
+
raise ValueError("longitudes must span less than 360 degrees")
|
|
127
|
+
if len(self.values) != len(self.latitudes):
|
|
128
|
+
raise ValueError(
|
|
129
|
+
f"{len(self.values)} rows for {len(self.latitudes)} latitudes"
|
|
130
|
+
)
|
|
131
|
+
for row in self.values:
|
|
132
|
+
if len(row) != len(self.longitudes):
|
|
133
|
+
raise ValueError(
|
|
134
|
+
f"a row has {len(row)} values for {len(self.longitudes)} longitudes"
|
|
135
|
+
)
|
|
136
|
+
if not all(math.isfinite(v) for v in row):
|
|
137
|
+
raise ValueError("values must be finite numbers")
|
|
138
|
+
|
|
139
|
+
@property
|
|
140
|
+
def shape(self) -> tuple[int, int]:
|
|
141
|
+
"""Number of latitudes and of longitudes."""
|
|
142
|
+
return len(self.latitudes), len(self.longitudes)
|
|
143
|
+
|
|
144
|
+
@property
|
|
145
|
+
def is_contiguous(self) -> bool:
|
|
146
|
+
"""Whether both axes have no gaps between their first and last value."""
|
|
147
|
+
return _contiguous(self.latitudes, self.latitude_step) and _contiguous(
|
|
148
|
+
self.longitudes, self.longitude_step
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def value_at(self, latitude: float, longitude: float) -> float:
|
|
152
|
+
"""Return the value of the cell centred at ``latitude``, ``longitude``.
|
|
153
|
+
|
|
154
|
+
The longitude may be given in either convention (``-1`` and ``359``
|
|
155
|
+
are the same cell).
|
|
156
|
+
|
|
157
|
+
Raises:
|
|
158
|
+
KeyError: If no cell of the grid is centred there.
|
|
159
|
+
"""
|
|
160
|
+
row = _find(self.latitudes, latitude)
|
|
161
|
+
column = _find(self.longitudes, longitude)
|
|
162
|
+
if column is None:
|
|
163
|
+
column = _find(self.longitudes, longitude - 360.0)
|
|
164
|
+
if column is None:
|
|
165
|
+
column = _find(self.longitudes, longitude + 360.0)
|
|
166
|
+
if row is None or column is None:
|
|
167
|
+
raise KeyError((latitude, longitude))
|
|
168
|
+
return self.values[row][column]
|
|
169
|
+
|
|
170
|
+
def value_near(self, latitude: float, longitude: float) -> float | None:
|
|
171
|
+
"""Return the value of the cell that holds the point, or ``None``.
|
|
172
|
+
|
|
173
|
+
A cell spans half a step on each side of its centre. A point on the
|
|
174
|
+
border between two cells takes the cell to its north or east; the
|
|
175
|
+
outer edges of the grid belong to its outer cells. The longitude may
|
|
176
|
+
be given in either convention (-180 to 180 or 0 to 360).
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
The value, or ``None`` if no cell holds the point: outside the
|
|
180
|
+
grid, or in a gap of a reduced grid.
|
|
181
|
+
|
|
182
|
+
Raises:
|
|
183
|
+
ValueError: If the latitude is not within [-90, 90] or the
|
|
184
|
+
longitude not within [-180, 360].
|
|
185
|
+
"""
|
|
186
|
+
if not (math.isfinite(latitude) and -90 <= latitude <= 90):
|
|
187
|
+
raise ValueError(f"latitude must lie within [-90, 90], got {latitude!r}")
|
|
188
|
+
if not (math.isfinite(longitude) and -180 <= longitude <= 360):
|
|
189
|
+
raise ValueError(
|
|
190
|
+
f"longitude must lie within [-180, 360], got {longitude!r}"
|
|
191
|
+
)
|
|
192
|
+
row = _holding(self.latitudes, self.latitude_step, (latitude,))
|
|
193
|
+
column = _holding(
|
|
194
|
+
self.longitudes,
|
|
195
|
+
self.longitude_step,
|
|
196
|
+
(longitude, longitude - 360.0, longitude + 360.0),
|
|
197
|
+
)
|
|
198
|
+
if row is None or column is None:
|
|
199
|
+
return None
|
|
200
|
+
return self.values[row][column]
|
|
201
|
+
|
|
202
|
+
def cells(self) -> Iterator[tuple[float, float, float]]:
|
|
203
|
+
"""Yield ``(latitude, longitude, value)`` for every cell, row by row."""
|
|
204
|
+
for latitude, row in zip(self.latitudes, self.values, strict=True):
|
|
205
|
+
for longitude, value in zip(self.longitudes, row, strict=True):
|
|
206
|
+
yield latitude, longitude, value
|
|
207
|
+
|
|
208
|
+
def wrapped(self) -> "Grid":
|
|
209
|
+
"""Return the same grid with longitudes in [-180, 180).
|
|
210
|
+
|
|
211
|
+
Columns are reordered so that longitudes stay increasing. A grid
|
|
212
|
+
already in that range is returned unchanged.
|
|
213
|
+
|
|
214
|
+
Raises:
|
|
215
|
+
ValueError: If the wrapped longitudes do not fit one lattice
|
|
216
|
+
(360 is not a multiple of the step).
|
|
217
|
+
"""
|
|
218
|
+
if self.longitudes[0] >= -180 and self.longitudes[-1] < 180:
|
|
219
|
+
return self
|
|
220
|
+
order = sorted(
|
|
221
|
+
range(len(self.longitudes)),
|
|
222
|
+
key=lambda j: wrap_longitude(self.longitudes[j]),
|
|
223
|
+
)
|
|
224
|
+
return Grid(
|
|
225
|
+
latitudes=self.latitudes,
|
|
226
|
+
longitudes=tuple(wrap_longitude(self.longitudes[j]) for j in order),
|
|
227
|
+
latitude_step=self.latitude_step,
|
|
228
|
+
longitude_step=self.longitude_step,
|
|
229
|
+
values=tuple(tuple(row[j] for j in order) for row in self.values),
|
|
230
|
+
quantity=self.quantity,
|
|
231
|
+
units=self.units,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class GridProduct(Protocol):
|
|
236
|
+
"""A product made of a grid and the times its content declares.
|
|
237
|
+
|
|
238
|
+
:class:`OvationAurora` and :class:`DrapFrequencies` follow it; the
|
|
239
|
+
exports accept anything that does.
|
|
240
|
+
"""
|
|
241
|
+
|
|
242
|
+
@property
|
|
243
|
+
def grid(self) -> Grid:
|
|
244
|
+
"""The values and their axes."""
|
|
245
|
+
...
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def source_url(self) -> str:
|
|
249
|
+
"""Where the content came from."""
|
|
250
|
+
...
|
|
251
|
+
|
|
252
|
+
@property
|
|
253
|
+
def data_time(self) -> datetime:
|
|
254
|
+
"""The time the data describes, in UTC."""
|
|
255
|
+
...
|
|
256
|
+
|
|
257
|
+
@property
|
|
258
|
+
def times(self) -> Mapping[str, datetime]:
|
|
259
|
+
"""Every time the content declares, by name, in UTC."""
|
|
260
|
+
...
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
@dataclass(frozen=True, slots=True)
|
|
264
|
+
class OvationAurora:
|
|
265
|
+
"""The OVATION aurora nowcast: a global 1-degree grid.
|
|
266
|
+
|
|
267
|
+
The source does not state what the values are. They are integers, 0 to
|
|
268
|
+
a few tens; reading them as the probability of visible aurora in percent
|
|
269
|
+
is an assumption (``docs/FONTI.md``), which ``grid.units`` (``"%"``)
|
|
270
|
+
follows.
|
|
271
|
+
|
|
272
|
+
Attributes:
|
|
273
|
+
grid: The values, with longitudes 0 to 359 as delivered.
|
|
274
|
+
observation_time: ``Observation Time`` of the source.
|
|
275
|
+
forecast_time: ``Forecast Time`` of the source (about an hour after
|
|
276
|
+
the observation).
|
|
277
|
+
source_url: Where the content came from.
|
|
278
|
+
|
|
279
|
+
Raises:
|
|
280
|
+
ValueError: If a time is not an aware datetime in UTC.
|
|
281
|
+
"""
|
|
282
|
+
|
|
283
|
+
grid: Grid
|
|
284
|
+
observation_time: datetime
|
|
285
|
+
forecast_time: datetime
|
|
286
|
+
source_url: str
|
|
287
|
+
|
|
288
|
+
def __post_init__(self) -> None:
|
|
289
|
+
check_utc("observation_time", self.observation_time)
|
|
290
|
+
check_utc("forecast_time", self.forecast_time)
|
|
291
|
+
|
|
292
|
+
@property
|
|
293
|
+
def data_time(self) -> datetime:
|
|
294
|
+
"""The observation time."""
|
|
295
|
+
return self.observation_time
|
|
296
|
+
|
|
297
|
+
@property
|
|
298
|
+
def times(self) -> dict[str, datetime]:
|
|
299
|
+
"""``observation_time`` and ``forecast_time``."""
|
|
300
|
+
return {
|
|
301
|
+
"observation_time": self.observation_time,
|
|
302
|
+
"forecast_time": self.forecast_time,
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@dataclass(frozen=True, slots=True)
|
|
307
|
+
class DrapFrequencies:
|
|
308
|
+
"""D-RAP: D-region absorption, as a frequency on a global grid.
|
|
309
|
+
|
|
310
|
+
The source only says "Frequency (MHz) as a function of Latitude and
|
|
311
|
+
Longitude". Reading it as the highest frequency affected by
|
|
312
|
+
D-region absorption, as ``grid.quantity`` does, is an assumption
|
|
313
|
+
(``docs/FONTI.md``).
|
|
314
|
+
|
|
315
|
+
The text fields are copied from the comment lines of the source, as
|
|
316
|
+
data; an empty field is ``None``.
|
|
317
|
+
|
|
318
|
+
Attributes:
|
|
319
|
+
grid: Frequencies in MHz, on a 2 by 4 degree grid.
|
|
320
|
+
valid_at: ``Product Valid At`` of the source.
|
|
321
|
+
estimated_recovery_time: ``Estimated Recovery Time``, as written
|
|
322
|
+
(for example ``"No Estimate"``).
|
|
323
|
+
xray_message: ``X-RAY Message``.
|
|
324
|
+
xray_warning: ``X-RAY Warning``.
|
|
325
|
+
proton_message: ``Proton Message``.
|
|
326
|
+
proton_warning: ``Proton Warning``.
|
|
327
|
+
source_url: Where the content came from.
|
|
328
|
+
|
|
329
|
+
Raises:
|
|
330
|
+
ValueError: If ``valid_at`` is not an aware datetime in UTC.
|
|
331
|
+
"""
|
|
332
|
+
|
|
333
|
+
grid: Grid
|
|
334
|
+
valid_at: datetime
|
|
335
|
+
estimated_recovery_time: str | None
|
|
336
|
+
xray_message: str | None
|
|
337
|
+
xray_warning: str | None
|
|
338
|
+
proton_message: str | None
|
|
339
|
+
proton_warning: str | None
|
|
340
|
+
source_url: str
|
|
341
|
+
|
|
342
|
+
def __post_init__(self) -> None:
|
|
343
|
+
check_utc("valid_at", self.valid_at)
|
|
344
|
+
|
|
345
|
+
@property
|
|
346
|
+
def data_time(self) -> datetime:
|
|
347
|
+
"""The time the product is valid at."""
|
|
348
|
+
return self.valid_at
|
|
349
|
+
|
|
350
|
+
@property
|
|
351
|
+
def times(self) -> dict[str, datetime]:
|
|
352
|
+
"""``valid_at``."""
|
|
353
|
+
return {"valid_at": self.valid_at}
|
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""Models for the geomagnetic and solar indices.
|
|
2
|
+
|
|
3
|
+
Planetary K index (3-hour values, forecast and 1-minute estimates), Kyoto Dst,
|
|
4
|
+
the 10.7 cm solar radio flux (F10.7) and the NOAA space weather scales.
|
|
5
|
+
|
|
6
|
+
Times are UTC. SWPC writes most of these ``time_tag`` values without a time
|
|
7
|
+
zone; they are read as UTC.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from enum import StrEnum
|
|
13
|
+
|
|
14
|
+
from swpc_kit.models._checks import check_utc, check_values
|
|
15
|
+
from swpc_kit.models.records import SkippedRecord
|
|
16
|
+
|
|
17
|
+
# --- Planetary K index, 3-hour values ---------------------------------------
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True, slots=True)
|
|
21
|
+
class KpValue:
|
|
22
|
+
"""One 3-hour planetary K index value.
|
|
23
|
+
|
|
24
|
+
Attributes:
|
|
25
|
+
time: Start of the 3-hour interval.
|
|
26
|
+
kp: Kp in thirds, as a decimal (2.67 is 3-), from 0 to 9.
|
|
27
|
+
a_running: Running a index (``a_running`` in the source).
|
|
28
|
+
station_count: Number of magnetometer stations used.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
time: datetime
|
|
32
|
+
kp: float
|
|
33
|
+
a_running: int
|
|
34
|
+
station_count: int
|
|
35
|
+
|
|
36
|
+
def __post_init__(self) -> None:
|
|
37
|
+
check_utc("time", self.time)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class PlanetaryKIndex:
|
|
42
|
+
"""The 3-hour planetary K index of the last days.
|
|
43
|
+
|
|
44
|
+
Attributes:
|
|
45
|
+
values: The values, oldest first, one per time.
|
|
46
|
+
source_url: Where the content came from.
|
|
47
|
+
skipped: The records that could not be read, in source order.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
values: tuple[KpValue, ...]
|
|
51
|
+
source_url: str
|
|
52
|
+
skipped: tuple[SkippedRecord, ...] = ()
|
|
53
|
+
|
|
54
|
+
def __post_init__(self) -> None:
|
|
55
|
+
check_values(self.values)
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def data_time(self) -> datetime:
|
|
59
|
+
"""Start of the most recent 3-hour interval."""
|
|
60
|
+
return max(v.time for v in self.values)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# --- Planetary K index forecast ---------------------------------------------
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class KpForecastStatus(StrEnum):
|
|
67
|
+
"""Whether a forecast value is measured, estimated or predicted.
|
|
68
|
+
|
|
69
|
+
Values match the ``observed`` field of the source.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
OBSERVED = "observed"
|
|
73
|
+
ESTIMATED = "estimated"
|
|
74
|
+
PREDICTED = "predicted"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True, slots=True)
|
|
78
|
+
class KpForecastValue:
|
|
79
|
+
"""One 3-hour value of the Kp forecast.
|
|
80
|
+
|
|
81
|
+
Attributes:
|
|
82
|
+
time: Start of the 3-hour interval.
|
|
83
|
+
kp: Kp as a decimal, from 0 to 9.
|
|
84
|
+
status: Whether the value is observed, estimated or predicted.
|
|
85
|
+
noaa_scale: NOAA geomagnetic storm level reached, such as ``"G1"``,
|
|
86
|
+
or ``None``.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
time: datetime
|
|
90
|
+
kp: float
|
|
91
|
+
status: KpForecastStatus
|
|
92
|
+
noaa_scale: str | None
|
|
93
|
+
|
|
94
|
+
def __post_init__(self) -> None:
|
|
95
|
+
check_utc("time", self.time)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True, slots=True)
|
|
99
|
+
class PlanetaryKIndexForecast:
|
|
100
|
+
"""Kp of the past week followed by the forecast for the next days.
|
|
101
|
+
|
|
102
|
+
Attributes:
|
|
103
|
+
values: The values, oldest first, one per time.
|
|
104
|
+
source_url: Where the content came from.
|
|
105
|
+
skipped: The records that could not be read, in source order.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
values: tuple[KpForecastValue, ...]
|
|
109
|
+
source_url: str
|
|
110
|
+
skipped: tuple[SkippedRecord, ...] = ()
|
|
111
|
+
|
|
112
|
+
def __post_init__(self) -> None:
|
|
113
|
+
check_values(self.values)
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def data_time(self) -> datetime:
|
|
117
|
+
"""Start of the most recent observed interval.
|
|
118
|
+
|
|
119
|
+
Estimated values can lie in the future, so they do not count. When
|
|
120
|
+
there is no observed value, this is the earliest time in the content.
|
|
121
|
+
"""
|
|
122
|
+
observed = [
|
|
123
|
+
v.time for v in self.values if v.status is KpForecastStatus.OBSERVED
|
|
124
|
+
]
|
|
125
|
+
if observed:
|
|
126
|
+
return max(observed)
|
|
127
|
+
return min(v.time for v in self.values)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# --- Planetary K index, 1-minute estimates ----------------------------------
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True, slots=True)
|
|
134
|
+
class Kp1mValue:
|
|
135
|
+
"""One 1-minute estimate of the planetary K index.
|
|
136
|
+
|
|
137
|
+
Attributes:
|
|
138
|
+
time: Time of the estimate.
|
|
139
|
+
kp_index: Kp rounded to an integer, from 0 to 9.
|
|
140
|
+
estimated_kp: Kp in thirds, as a decimal.
|
|
141
|
+
kp: Kp as a label: digit plus ``Z`` (exact), ``P`` (plus a third) or
|
|
142
|
+
``M`` (minus a third), such as ``"3M"`` for 2.67.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
time: datetime
|
|
146
|
+
kp_index: int
|
|
147
|
+
estimated_kp: float
|
|
148
|
+
kp: str
|
|
149
|
+
|
|
150
|
+
def __post_init__(self) -> None:
|
|
151
|
+
check_utc("time", self.time)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass(frozen=True, slots=True)
|
|
155
|
+
class PlanetaryKIndex1m:
|
|
156
|
+
"""The 1-minute planetary K index estimates of the last hours.
|
|
157
|
+
|
|
158
|
+
Attributes:
|
|
159
|
+
values: The values, oldest first, one per time.
|
|
160
|
+
source_url: Where the content came from.
|
|
161
|
+
skipped: The records that could not be read, in source order.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
values: tuple[Kp1mValue, ...]
|
|
165
|
+
source_url: str
|
|
166
|
+
skipped: tuple[SkippedRecord, ...] = ()
|
|
167
|
+
|
|
168
|
+
def __post_init__(self) -> None:
|
|
169
|
+
check_values(self.values)
|
|
170
|
+
|
|
171
|
+
@property
|
|
172
|
+
def data_time(self) -> datetime:
|
|
173
|
+
"""Time of the most recent estimate."""
|
|
174
|
+
return max(v.time for v in self.values)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# --- Kyoto Dst ---------------------------------------------------------------
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@dataclass(frozen=True, slots=True)
|
|
181
|
+
class DstValue:
|
|
182
|
+
"""One hourly value of the Kyoto Dst index.
|
|
183
|
+
|
|
184
|
+
Attributes:
|
|
185
|
+
time: Time of the value.
|
|
186
|
+
dst: Dst in nT.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
time: datetime
|
|
190
|
+
dst: int
|
|
191
|
+
|
|
192
|
+
def __post_init__(self) -> None:
|
|
193
|
+
check_utc("time", self.time)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@dataclass(frozen=True, slots=True)
|
|
197
|
+
class KyotoDst:
|
|
198
|
+
"""Hourly Kyoto Dst of the last week.
|
|
199
|
+
|
|
200
|
+
Attributes:
|
|
201
|
+
values: The values, oldest first, one per time.
|
|
202
|
+
source_url: Where the content came from.
|
|
203
|
+
skipped: The records that could not be read, in source order.
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
values: tuple[DstValue, ...]
|
|
207
|
+
source_url: str
|
|
208
|
+
skipped: tuple[SkippedRecord, ...] = ()
|
|
209
|
+
|
|
210
|
+
def __post_init__(self) -> None:
|
|
211
|
+
check_values(self.values)
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def data_time(self) -> datetime:
|
|
215
|
+
"""Time of the most recent value."""
|
|
216
|
+
return max(v.time for v in self.values)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# --- F10.7 -------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@dataclass(frozen=True, slots=True)
|
|
223
|
+
class F107Value:
|
|
224
|
+
"""One daily value of the 10.7 cm solar radio flux.
|
|
225
|
+
|
|
226
|
+
Attributes:
|
|
227
|
+
time: Time of the measurement.
|
|
228
|
+
flux: Flux in solar flux units (10⁻²² W m⁻² Hz⁻¹).
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
time: datetime
|
|
232
|
+
flux: float
|
|
233
|
+
|
|
234
|
+
def __post_init__(self) -> None:
|
|
235
|
+
check_utc("time", self.time)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@dataclass(frozen=True, slots=True)
|
|
239
|
+
class F107Flux:
|
|
240
|
+
"""Daily 10.7 cm solar radio flux of the last 30 days.
|
|
241
|
+
|
|
242
|
+
Attributes:
|
|
243
|
+
values: The values, oldest first, one per time.
|
|
244
|
+
source_url: Where the content came from.
|
|
245
|
+
skipped: The records that could not be read, in source order.
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
values: tuple[F107Value, ...]
|
|
249
|
+
source_url: str
|
|
250
|
+
skipped: tuple[SkippedRecord, ...] = ()
|
|
251
|
+
|
|
252
|
+
def __post_init__(self) -> None:
|
|
253
|
+
check_values(self.values)
|
|
254
|
+
|
|
255
|
+
@property
|
|
256
|
+
def data_time(self) -> datetime:
|
|
257
|
+
"""Time of the most recent value."""
|
|
258
|
+
return max(v.time for v in self.values)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# --- NOAA scales -------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@dataclass(frozen=True, slots=True)
|
|
265
|
+
class RadioBlackoutScale:
|
|
266
|
+
"""Radio blackout (R) entry of a day of ``noaa-scales.json``.
|
|
267
|
+
|
|
268
|
+
Attributes:
|
|
269
|
+
scale: Level reached, 0 to 5, or ``None`` (forecast days).
|
|
270
|
+
text: Description of the level, such as ``"none"``, or ``None``.
|
|
271
|
+
minor_prob: Probability of R1-R2 in percent, or ``None``.
|
|
272
|
+
major_prob: Probability of R3 or greater in percent, or ``None``.
|
|
273
|
+
"""
|
|
274
|
+
|
|
275
|
+
scale: int | None
|
|
276
|
+
text: str | None
|
|
277
|
+
minor_prob: int | None
|
|
278
|
+
major_prob: int | None
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
@dataclass(frozen=True, slots=True)
|
|
282
|
+
class SolarRadiationScale:
|
|
283
|
+
"""Solar radiation storm (S) entry of a day of ``noaa-scales.json``.
|
|
284
|
+
|
|
285
|
+
Attributes:
|
|
286
|
+
scale: Level reached, 0 to 5, or ``None`` (forecast days).
|
|
287
|
+
text: Description of the level, or ``None``.
|
|
288
|
+
prob: Probability of S1 or greater in percent, or ``None``.
|
|
289
|
+
"""
|
|
290
|
+
|
|
291
|
+
scale: int | None
|
|
292
|
+
text: str | None
|
|
293
|
+
prob: int | None
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@dataclass(frozen=True, slots=True)
|
|
297
|
+
class GeomagneticStormScale:
|
|
298
|
+
"""Geomagnetic storm (G) entry of a day of ``noaa-scales.json``.
|
|
299
|
+
|
|
300
|
+
Attributes:
|
|
301
|
+
scale: Level reached or expected, 0 to 5, or ``None``.
|
|
302
|
+
text: Description of the level, or ``None``.
|
|
303
|
+
"""
|
|
304
|
+
|
|
305
|
+
scale: int | None
|
|
306
|
+
text: str | None
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@dataclass(frozen=True, slots=True)
|
|
310
|
+
class NoaaScalesDay:
|
|
311
|
+
"""One entry of ``noaa-scales.json``.
|
|
312
|
+
|
|
313
|
+
Attributes:
|
|
314
|
+
day: The key of the entry. From the dates it carries, -1 looks like
|
|
315
|
+
the past 24 hours, 0 the current levels and 1 to 3 the forecast
|
|
316
|
+
for today and the next two days; SWPC does not document it.
|
|
317
|
+
time: ``DateStamp`` and ``TimeStamp`` of the entry.
|
|
318
|
+
r: Radio blackouts.
|
|
319
|
+
s: Solar radiation storms.
|
|
320
|
+
g: Geomagnetic storms.
|
|
321
|
+
"""
|
|
322
|
+
|
|
323
|
+
day: int
|
|
324
|
+
time: datetime
|
|
325
|
+
r: RadioBlackoutScale
|
|
326
|
+
s: SolarRadiationScale
|
|
327
|
+
g: GeomagneticStormScale
|
|
328
|
+
|
|
329
|
+
def __post_init__(self) -> None:
|
|
330
|
+
check_utc("time", self.time)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@dataclass(frozen=True, slots=True)
|
|
334
|
+
class NoaaScales:
|
|
335
|
+
"""The NOAA space weather scales: recent, current and forecast levels.
|
|
336
|
+
|
|
337
|
+
Attributes:
|
|
338
|
+
days: The entries, sorted by ``day``.
|
|
339
|
+
source_url: Where the content came from.
|
|
340
|
+
"""
|
|
341
|
+
|
|
342
|
+
days: tuple[NoaaScalesDay, ...]
|
|
343
|
+
source_url: str
|
|
344
|
+
|
|
345
|
+
def __post_init__(self) -> None:
|
|
346
|
+
if not any(d.day <= 0 for d in self.days):
|
|
347
|
+
raise ValueError("days must include an observed entry (day <= 0)")
|
|
348
|
+
|
|
349
|
+
def day(self, day: int) -> NoaaScalesDay | None:
|
|
350
|
+
"""Return the entry with key ``day``, or ``None``."""
|
|
351
|
+
return next((d for d in self.days if d.day == day), None)
|
|
352
|
+
|
|
353
|
+
@property
|
|
354
|
+
def data_time(self) -> datetime:
|
|
355
|
+
"""Most recent time among the observed entries (``day`` <= 0)."""
|
|
356
|
+
return max(d.time for d in self.days if d.day <= 0)
|