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,111 @@
|
|
|
1
|
+
"""GeoJSON export of the gridded products (standard library only).
|
|
2
|
+
|
|
3
|
+
:func:`to_geojson` turns an OVATION or D-RAP product into a GeoJSON
|
|
4
|
+
``FeatureCollection`` (RFC 7946): one feature per grid cell, as the cell's
|
|
5
|
+
polygon or its centre point, optionally only for cells at or above a
|
|
6
|
+
threshold. Longitudes are moved into -180 to 180; a cell that straddles the
|
|
7
|
+
antimeridian becomes a ``MultiPolygon`` of its two halves, and cells at the
|
|
8
|
+
poles stop at ±90.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
from datetime import UTC, datetime
|
|
13
|
+
from typing import Any, Literal
|
|
14
|
+
|
|
15
|
+
from swpc_kit.models.grids import GridProduct
|
|
16
|
+
|
|
17
|
+
Geometry = Literal["polygon", "point"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _iso(value: datetime) -> str:
|
|
21
|
+
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _ring(west: float, south: float, east: float, north: float) -> list[list[float]]:
|
|
25
|
+
# Counter-clockwise, as RFC 7946 asks for exterior rings.
|
|
26
|
+
return [[west, south], [east, south], [east, north], [west, north], [west, south]]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _cell(
|
|
30
|
+
latitude: float, longitude: float, half_height: float, half_width: float
|
|
31
|
+
) -> dict[str, Any]:
|
|
32
|
+
south = max(latitude - half_height, -90.0)
|
|
33
|
+
north = min(latitude + half_height, 90.0)
|
|
34
|
+
west, east = longitude - half_width, longitude + half_width
|
|
35
|
+
if west < -180:
|
|
36
|
+
parts = [(west + 360, 180.0), (-180.0, east)]
|
|
37
|
+
elif east > 180:
|
|
38
|
+
parts = [(west, 180.0), (-180.0, east - 360)]
|
|
39
|
+
else:
|
|
40
|
+
return {"type": "Polygon", "coordinates": [_ring(west, south, east, north)]}
|
|
41
|
+
return {
|
|
42
|
+
"type": "MultiPolygon",
|
|
43
|
+
"coordinates": [[_ring(w, south, e, north)] for w, e in parts],
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def to_geojson(
|
|
48
|
+
product: GridProduct,
|
|
49
|
+
*,
|
|
50
|
+
threshold: float | None = None,
|
|
51
|
+
geometry: Geometry = "polygon",
|
|
52
|
+
) -> dict[str, Any]:
|
|
53
|
+
"""Return the cells of a gridded product as a GeoJSON FeatureCollection.
|
|
54
|
+
|
|
55
|
+
Each feature has the properties ``latitude`` and ``longitude`` (the cell
|
|
56
|
+
centre, longitude in [-180, 180)) and ``value``. Features come row by
|
|
57
|
+
row, from south to north and from west to east. The collection carries a
|
|
58
|
+
``metadata`` member (a foreign member in RFC 7946 terms) with
|
|
59
|
+
``quantity``, ``units``, ``source_url``, ``data_time``, each time of
|
|
60
|
+
``product.times`` as ISO 8601 text, and ``threshold``.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
product: An OVATION or D-RAP model, or any :class:`GridProduct`.
|
|
64
|
+
threshold: Keep only cells whose value is at least this; ``None``
|
|
65
|
+
keeps every cell.
|
|
66
|
+
geometry: ``"polygon"`` for the cell outline, ``"point"`` for its
|
|
67
|
+
centre.
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
A dict ready for :func:`json.dumps`.
|
|
71
|
+
|
|
72
|
+
Raises:
|
|
73
|
+
ValueError: If ``geometry`` is unknown or ``threshold`` is not a
|
|
74
|
+
finite number.
|
|
75
|
+
"""
|
|
76
|
+
if geometry not in ("polygon", "point"):
|
|
77
|
+
raise ValueError(f"geometry must be 'polygon' or 'point', got {geometry!r}")
|
|
78
|
+
if threshold is not None and not math.isfinite(threshold):
|
|
79
|
+
raise ValueError(f"threshold must be a finite number, got {threshold!r}")
|
|
80
|
+
grid = product.grid.wrapped()
|
|
81
|
+
half_height = grid.latitude_step / 2
|
|
82
|
+
half_width = grid.longitude_step / 2
|
|
83
|
+
features = []
|
|
84
|
+
for latitude, longitude, value in grid.cells():
|
|
85
|
+
if threshold is not None and value < threshold:
|
|
86
|
+
continue
|
|
87
|
+
shape = (
|
|
88
|
+
_cell(latitude, longitude, half_height, half_width)
|
|
89
|
+
if geometry == "polygon"
|
|
90
|
+
else {"type": "Point", "coordinates": [longitude, latitude]}
|
|
91
|
+
)
|
|
92
|
+
features.append(
|
|
93
|
+
{
|
|
94
|
+
"type": "Feature",
|
|
95
|
+
"geometry": shape,
|
|
96
|
+
"properties": {
|
|
97
|
+
"latitude": latitude,
|
|
98
|
+
"longitude": longitude,
|
|
99
|
+
"value": value,
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
)
|
|
103
|
+
metadata: dict[str, Any] = {
|
|
104
|
+
"quantity": grid.quantity,
|
|
105
|
+
"units": grid.units,
|
|
106
|
+
"source_url": product.source_url,
|
|
107
|
+
"data_time": _iso(product.data_time),
|
|
108
|
+
}
|
|
109
|
+
metadata.update({name: _iso(time) for name, time in product.times.items()})
|
|
110
|
+
metadata["threshold"] = threshold
|
|
111
|
+
return {"type": "FeatureCollection", "metadata": metadata, "features": features}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""GeoTIFF export of the gridded products, with the standard library only.
|
|
2
|
+
|
|
3
|
+
:func:`write_geotiff` writes an OVATION or D-RAP product as a one-band
|
|
4
|
+
``float32`` GeoTIFF in EPSG:4326 (WGS 84 latitude/longitude), compressed
|
|
5
|
+
with deflate. The raster is north-up, with longitudes moved into -180 to
|
|
6
|
+
180, and each pixel is a grid cell: the transform puts pixel edges half a
|
|
7
|
+
step around the cell centres, so OVATION, whose centres reach the poles and
|
|
8
|
+
-180, spans -180.5 to 179.5 and -90.5 to 90.5.
|
|
9
|
+
|
|
10
|
+
The times the content declares go into the dataset tags as ISO 8601 text
|
|
11
|
+
(``DATA_TIME``, then one tag per time, such as ``OBSERVATION_TIME``,
|
|
12
|
+
``FORECAST_TIME`` or ``VALID_AT``), with ``SOURCE_URL``, ``QUANTITY`` and
|
|
13
|
+
``UNITS``; ``TIFFTAG_DATETIME`` holds the data time in the TIFF format.
|
|
14
|
+
They are written as GDAL writes them, so GDAL, rasterio and QGIS read them
|
|
15
|
+
back as dataset metadata.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import math
|
|
19
|
+
import os
|
|
20
|
+
import struct
|
|
21
|
+
import zlib
|
|
22
|
+
from datetime import UTC, datetime
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from xml.sax.saxutils import escape
|
|
25
|
+
|
|
26
|
+
from swpc_kit.export._tiff import ASCII, DOUBLE, LONG, SHORT, Entry, encode
|
|
27
|
+
from swpc_kit.models.grids import GridProduct
|
|
28
|
+
|
|
29
|
+
__all__ = ["write_geotiff"]
|
|
30
|
+
|
|
31
|
+
_STRIP_BYTES = 8192
|
|
32
|
+
"""About how much raw data goes in one strip, as GDAL chooses."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _iso(value: datetime) -> str:
|
|
36
|
+
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _float32(value: float) -> float:
|
|
40
|
+
return float(struct.unpack("<f", struct.pack("<f", value))[0])
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def write_geotiff(
|
|
44
|
+
product: GridProduct,
|
|
45
|
+
path: str | os.PathLike[str],
|
|
46
|
+
*,
|
|
47
|
+
nodata: float = math.nan,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Write a gridded product to a GeoTIFF file.
|
|
50
|
+
|
|
51
|
+
Cells the grid does not have (the missing longitudes of a reduced
|
|
52
|
+
OVATION file) are set to ``nodata``.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
product: An OVATION or D-RAP model, or any :class:`GridProduct`.
|
|
56
|
+
path: Where to write the file; an existing file is replaced.
|
|
57
|
+
nodata: Value of the cells without data, declared as the nodata
|
|
58
|
+
value of the band. It must not be one of the grid's values.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
ValueError: If ``nodata`` is one of the grid's values, or does not
|
|
62
|
+
fit in a ``float32``.
|
|
63
|
+
"""
|
|
64
|
+
grid = product.grid.wrapped()
|
|
65
|
+
try:
|
|
66
|
+
nodata_32 = _float32(nodata)
|
|
67
|
+
except OverflowError:
|
|
68
|
+
raise ValueError(f"nodata {nodata} does not fit in float32") from None
|
|
69
|
+
if not math.isnan(nodata) and any(
|
|
70
|
+
_float32(value) == nodata_32 for row in grid.values for value in row
|
|
71
|
+
):
|
|
72
|
+
raise ValueError(f"nodata {nodata} is also a value of the grid")
|
|
73
|
+
dy, dx = grid.latitude_step, grid.longitude_step
|
|
74
|
+
south, north = grid.latitudes[0], grid.latitudes[-1]
|
|
75
|
+
west, east = grid.longitudes[0], grid.longitudes[-1]
|
|
76
|
+
height = round((north - south) / dy) + 1
|
|
77
|
+
width = round((east - west) / dx) + 1
|
|
78
|
+
raster = [[nodata] * width for _ in range(height)]
|
|
79
|
+
cols = [round((lon - west) / dx) for lon in grid.longitudes]
|
|
80
|
+
for lat, values in zip(grid.latitudes, grid.values, strict=True):
|
|
81
|
+
# Row 0 is the northernmost latitude; the grid is stored south first.
|
|
82
|
+
row = raster[height - 1 - round((lat - south) / dy)]
|
|
83
|
+
for col, value in zip(cols, values, strict=True):
|
|
84
|
+
row[col] = value
|
|
85
|
+
rows_per_strip = max(1, _STRIP_BYTES // (4 * width))
|
|
86
|
+
strips = [
|
|
87
|
+
zlib.compress(
|
|
88
|
+
b"".join(
|
|
89
|
+
struct.pack(f"<{width}f", *row)
|
|
90
|
+
for row in raster[top : top + rows_per_strip]
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
for top in range(0, height, rows_per_strip)
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
data_time = product.data_time
|
|
97
|
+
tags = {
|
|
98
|
+
"SOURCE_URL": product.source_url,
|
|
99
|
+
"QUANTITY": grid.quantity,
|
|
100
|
+
"UNITS": grid.units,
|
|
101
|
+
"DATA_TIME": _iso(data_time),
|
|
102
|
+
}
|
|
103
|
+
tags.update({name.upper(): _iso(time) for name, time in product.times.items()})
|
|
104
|
+
items = [f'<Item name="{key}">{escape(text)}</Item>' for key, text in tags.items()]
|
|
105
|
+
items.append(
|
|
106
|
+
f'<Item name="DESCRIPTION" sample="0" role="description">'
|
|
107
|
+
f"{escape(grid.quantity)}</Item>"
|
|
108
|
+
)
|
|
109
|
+
items.append(
|
|
110
|
+
f'<Item name="UNITTYPE" sample="0" role="unittype">{escape(grid.units)}</Item>'
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
entries: list[Entry] = [
|
|
114
|
+
(256, LONG, [width]), # ImageWidth
|
|
115
|
+
(257, LONG, [height]), # ImageLength
|
|
116
|
+
(258, SHORT, [32]), # BitsPerSample
|
|
117
|
+
(259, SHORT, [8]), # Compression: deflate
|
|
118
|
+
(262, SHORT, [1]), # PhotometricInterpretation: min is black
|
|
119
|
+
(277, SHORT, [1]), # SamplesPerPixel
|
|
120
|
+
(278, LONG, [rows_per_strip]), # RowsPerStrip
|
|
121
|
+
(279, LONG, [len(strip) for strip in strips]), # StripByteCounts
|
|
122
|
+
(284, SHORT, [1]), # PlanarConfiguration: contiguous
|
|
123
|
+
(306, ASCII, data_time.strftime("%Y:%m:%d %H:%M:%S")), # DateTime
|
|
124
|
+
(339, SHORT, [3]), # SampleFormat: IEEE floating point
|
|
125
|
+
(33550, DOUBLE, [dx, dy, 0.0]), # ModelPixelScale
|
|
126
|
+
# ModelTiepoint: pixel (0, 0) is the north-west corner of the grid.
|
|
127
|
+
(33922, DOUBLE, [0.0, 0.0, 0.0, west - dx / 2, north + dy / 2, 0.0]),
|
|
128
|
+
# GeoKeyDirectory, version 1.1.0 with three keys: a geographic model
|
|
129
|
+
# (1024 = 2), pixels that are areas (1025 = 1), and EPSG:4326 (2048).
|
|
130
|
+
(34735, SHORT, [1, 1, 0, 3, 1024, 0, 1, 2, 1025, 0, 1, 1, 2048, 0, 1, 4326]),
|
|
131
|
+
(42112, ASCII, f"<GDALMetadata>{''.join(items)}</GDALMetadata>"),
|
|
132
|
+
(42113, ASCII, repr(nodata)), # GDAL_NODATA
|
|
133
|
+
]
|
|
134
|
+
Path(path).write_bytes(encode(entries, strips))
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Models: frozen, typed dataclasses for SWPC products.
|
|
2
|
+
|
|
3
|
+
Every time is a ``datetime`` in UTC. Each top-level model exposes
|
|
4
|
+
``data_time`` (the most recent time declared by the content), ``source_url``,
|
|
5
|
+
and ``issued``, ``observed``, ``valid_from`` or ``valid_to`` where the source
|
|
6
|
+
provides them. The models are re-exported here from their modules:
|
|
7
|
+
``indices``, ``solar_wind``, ``goes``, ``alerts``, ``bulletins``, ``grids``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from swpc_kit.models.alerts import (
|
|
11
|
+
AlertMessage,
|
|
12
|
+
Alerts,
|
|
13
|
+
MessageField,
|
|
14
|
+
MessageKind,
|
|
15
|
+
NoaaScale,
|
|
16
|
+
ScaleCategory,
|
|
17
|
+
field_key,
|
|
18
|
+
)
|
|
19
|
+
from swpc_kit.models.bulletins import (
|
|
20
|
+
DailyAp,
|
|
21
|
+
GeomagneticForecast,
|
|
22
|
+
KpPeriod,
|
|
23
|
+
OutlookDay,
|
|
24
|
+
ThreeDayForecast,
|
|
25
|
+
TwentySevenDayOutlook,
|
|
26
|
+
)
|
|
27
|
+
from swpc_kit.models.goes import (
|
|
28
|
+
FlareClass,
|
|
29
|
+
GoesIntegralProtons,
|
|
30
|
+
GoesXrays,
|
|
31
|
+
ProtonSample,
|
|
32
|
+
XrayBand,
|
|
33
|
+
XraySample,
|
|
34
|
+
)
|
|
35
|
+
from swpc_kit.models.grids import (
|
|
36
|
+
DrapFrequencies,
|
|
37
|
+
Grid,
|
|
38
|
+
GridProduct,
|
|
39
|
+
OvationAurora,
|
|
40
|
+
wrap_longitude,
|
|
41
|
+
)
|
|
42
|
+
from swpc_kit.models.indices import (
|
|
43
|
+
DstValue,
|
|
44
|
+
F107Flux,
|
|
45
|
+
F107Value,
|
|
46
|
+
GeomagneticStormScale,
|
|
47
|
+
Kp1mValue,
|
|
48
|
+
KpForecastStatus,
|
|
49
|
+
KpForecastValue,
|
|
50
|
+
KpValue,
|
|
51
|
+
KyotoDst,
|
|
52
|
+
NoaaScales,
|
|
53
|
+
NoaaScalesDay,
|
|
54
|
+
PlanetaryKIndex,
|
|
55
|
+
PlanetaryKIndex1m,
|
|
56
|
+
PlanetaryKIndexForecast,
|
|
57
|
+
RadioBlackoutScale,
|
|
58
|
+
SolarRadiationScale,
|
|
59
|
+
)
|
|
60
|
+
from swpc_kit.models.records import SkippedRecord
|
|
61
|
+
from swpc_kit.models.solar_wind import (
|
|
62
|
+
RtswMag1m,
|
|
63
|
+
RtswMagSample,
|
|
64
|
+
RtswSource,
|
|
65
|
+
RtswWind1m,
|
|
66
|
+
RtswWindSample,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
__all__ = [
|
|
70
|
+
"AlertMessage",
|
|
71
|
+
"Alerts",
|
|
72
|
+
"DailyAp",
|
|
73
|
+
"DrapFrequencies",
|
|
74
|
+
"DstValue",
|
|
75
|
+
"F107Flux",
|
|
76
|
+
"F107Value",
|
|
77
|
+
"FlareClass",
|
|
78
|
+
"GeomagneticForecast",
|
|
79
|
+
"GeomagneticStormScale",
|
|
80
|
+
"GoesIntegralProtons",
|
|
81
|
+
"GoesXrays",
|
|
82
|
+
"Grid",
|
|
83
|
+
"GridProduct",
|
|
84
|
+
"Kp1mValue",
|
|
85
|
+
"KpForecastStatus",
|
|
86
|
+
"KpForecastValue",
|
|
87
|
+
"KpPeriod",
|
|
88
|
+
"KpValue",
|
|
89
|
+
"KyotoDst",
|
|
90
|
+
"MessageField",
|
|
91
|
+
"MessageKind",
|
|
92
|
+
"NoaaScale",
|
|
93
|
+
"NoaaScales",
|
|
94
|
+
"NoaaScalesDay",
|
|
95
|
+
"OutlookDay",
|
|
96
|
+
"OvationAurora",
|
|
97
|
+
"PlanetaryKIndex",
|
|
98
|
+
"PlanetaryKIndex1m",
|
|
99
|
+
"PlanetaryKIndexForecast",
|
|
100
|
+
"ProtonSample",
|
|
101
|
+
"RadioBlackoutScale",
|
|
102
|
+
"RtswMag1m",
|
|
103
|
+
"RtswMagSample",
|
|
104
|
+
"RtswSource",
|
|
105
|
+
"RtswWind1m",
|
|
106
|
+
"RtswWindSample",
|
|
107
|
+
"ScaleCategory",
|
|
108
|
+
"SkippedRecord",
|
|
109
|
+
"SolarRadiationScale",
|
|
110
|
+
"ThreeDayForecast",
|
|
111
|
+
"TwentySevenDayOutlook",
|
|
112
|
+
"XrayBand",
|
|
113
|
+
"XraySample",
|
|
114
|
+
"field_key",
|
|
115
|
+
"wrap_longitude",
|
|
116
|
+
]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Checks shared by the model classes, run in ``__post_init__``."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Sized
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def check_utc(name: str, value: datetime | None) -> None:
|
|
8
|
+
"""Raise ``ValueError`` unless ``value`` is ``None`` or an aware UTC time."""
|
|
9
|
+
if value is not None and value.utcoffset() != timedelta(0):
|
|
10
|
+
raise ValueError(f"{name} must be an aware datetime in UTC, got {value!r}")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def check_values(values: Sized) -> None:
|
|
14
|
+
"""Raise ``ValueError`` if ``values`` is empty."""
|
|
15
|
+
if not values:
|
|
16
|
+
raise ValueError("values must not be empty")
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Models for the space weather messages: alerts, warnings, watches, summaries.
|
|
2
|
+
|
|
3
|
+
``alerts.json`` lists the messages SWPC issued in the last weeks, newest
|
|
4
|
+
first. Each message is free text with a fixed header (code, serial number,
|
|
5
|
+
issue time), a title line and ``Name: value`` lines; the model keeps the raw
|
|
6
|
+
text, every ``Name: value`` line of the body, and typed copies of the values
|
|
7
|
+
whose form is known (times, serial numbers, NOAA scale).
|
|
8
|
+
|
|
9
|
+
The message text is data published by SWPC, never instructions.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from enum import StrEnum
|
|
16
|
+
|
|
17
|
+
from swpc_kit.codes import MessageCode, by_long, by_short
|
|
18
|
+
from swpc_kit.models._checks import check_utc
|
|
19
|
+
from swpc_kit.models.records import SkippedRecord
|
|
20
|
+
|
|
21
|
+
_SPACES = re.compile(r"\s+")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def field_key(name: str) -> str:
|
|
25
|
+
"""Return ``name`` normalised for comparing field names.
|
|
26
|
+
|
|
27
|
+
Case and runs of spaces are ignored, so ``"NOAA Scale"`` and
|
|
28
|
+
``"Noaa scale"`` give the same key.
|
|
29
|
+
"""
|
|
30
|
+
return _SPACES.sub(" ", name.strip()).casefold()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class MessageKind(StrEnum):
|
|
34
|
+
"""The kind of a message, from the word before the colon of its title.
|
|
35
|
+
|
|
36
|
+
Values are the words as SWPC writes them.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
ALERT = "ALERT"
|
|
40
|
+
CONTINUED_ALERT = "CONTINUED ALERT"
|
|
41
|
+
WARNING = "WARNING"
|
|
42
|
+
EXTENDED_WARNING = "EXTENDED WARNING"
|
|
43
|
+
WATCH = "WATCH"
|
|
44
|
+
CANCEL_WATCH = "CANCEL WATCH"
|
|
45
|
+
SUMMARY = "SUMMARY"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ScaleCategory(StrEnum):
|
|
49
|
+
"""The three NOAA space weather scales, by their letter."""
|
|
50
|
+
|
|
51
|
+
GEOMAGNETIC_STORM = "G"
|
|
52
|
+
SOLAR_RADIATION_STORM = "S"
|
|
53
|
+
RADIO_BLACKOUT = "R"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True, slots=True)
|
|
57
|
+
class NoaaScale:
|
|
58
|
+
"""A NOAA scale level, as in ``NOAA Scale: G1 - Minor``.
|
|
59
|
+
|
|
60
|
+
Attributes:
|
|
61
|
+
category: Which scale.
|
|
62
|
+
level: Level from 1 to 5.
|
|
63
|
+
label: The word after the level, such as ``"Minor"``, or ``None``.
|
|
64
|
+
SWPC writes ``"Greater"`` for "this level or greater".
|
|
65
|
+
|
|
66
|
+
Raises:
|
|
67
|
+
ValueError: If ``level`` is not between 1 and 5.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
category: ScaleCategory
|
|
71
|
+
level: int
|
|
72
|
+
label: str | None
|
|
73
|
+
|
|
74
|
+
def __post_init__(self) -> None:
|
|
75
|
+
if not 1 <= self.level <= 5:
|
|
76
|
+
raise ValueError(f"level must be between 1 and 5, got {self.level}")
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def code(self) -> str:
|
|
80
|
+
"""The level as letter and number, such as ``"G1"``."""
|
|
81
|
+
return f"{self.category.value}{self.level}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True, slots=True)
|
|
85
|
+
class MessageField:
|
|
86
|
+
"""One ``Name: value`` line of the body of a message.
|
|
87
|
+
|
|
88
|
+
Attributes:
|
|
89
|
+
name: The name as written, such as ``"Valid From"``.
|
|
90
|
+
value: The rest of the line, without surrounding spaces; may be empty.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
name: str
|
|
94
|
+
value: str
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True, slots=True)
|
|
98
|
+
class AlertMessage:
|
|
99
|
+
"""One space weather message (alert, warning, watch or summary).
|
|
100
|
+
|
|
101
|
+
Attributes:
|
|
102
|
+
product_id: Short code of the message, such as ``"K04W"``.
|
|
103
|
+
code: Long code from the ``Space Weather Message Code:`` line, such
|
|
104
|
+
as ``"WARK04"``.
|
|
105
|
+
serial_number: From the ``Serial Number:`` line. Not unique: a
|
|
106
|
+
continued alert can repeat the serial number of an earlier one.
|
|
107
|
+
issued: ``issue_datetime`` of the record, to the millisecond.
|
|
108
|
+
issue_time: From the ``Issue Time:`` line, to the minute.
|
|
109
|
+
kind: Kind of message from its title, or ``None`` when unknown.
|
|
110
|
+
title: The title line (``"ALERT: Geomagnetic K-index of 5"``), or an
|
|
111
|
+
empty string when the message has none.
|
|
112
|
+
valid_from: ``Valid From:``, or ``None``.
|
|
113
|
+
valid_to: ``Now Valid Until:`` (extended warnings) or ``Valid To:``,
|
|
114
|
+
or ``None``.
|
|
115
|
+
threshold_reached: ``Threshold Reached:``, or ``None``.
|
|
116
|
+
begin_time: ``Begin Time:``, or ``None``.
|
|
117
|
+
maximum_time: ``Maximum Time:`` or ``Max time:``, or ``None``.
|
|
118
|
+
end_time: ``End Time:``, or ``None``.
|
|
119
|
+
observed: ``Observed:`` (sudden impulse summaries), or ``None``.
|
|
120
|
+
extension_to_serial: ``Extension to Serial Number:``, or ``None``.
|
|
121
|
+
continuation_of_serial: ``Continuation of Serial Number:``, or
|
|
122
|
+
``None``.
|
|
123
|
+
cancel_serial: ``Cancel Serial Number:``, or ``None``.
|
|
124
|
+
noaa_scale: First valid ``NOAA Scale:`` or ``Noaa Scale:``, or
|
|
125
|
+
``None``. Watches give their level in the title, not here.
|
|
126
|
+
fields: Every ``Name: value`` line after the title, in order,
|
|
127
|
+
repeats included. A typed attribute is ``None`` when its value
|
|
128
|
+
has an unknown form; the raw value stays here.
|
|
129
|
+
text: The message as served.
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
ValueError: If a time is not an aware datetime in UTC.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
product_id: str
|
|
136
|
+
code: str
|
|
137
|
+
serial_number: int
|
|
138
|
+
issued: datetime
|
|
139
|
+
issue_time: datetime
|
|
140
|
+
kind: MessageKind | None = None
|
|
141
|
+
title: str = ""
|
|
142
|
+
valid_from: datetime | None = None
|
|
143
|
+
valid_to: datetime | None = None
|
|
144
|
+
threshold_reached: datetime | None = None
|
|
145
|
+
begin_time: datetime | None = None
|
|
146
|
+
maximum_time: datetime | None = None
|
|
147
|
+
end_time: datetime | None = None
|
|
148
|
+
observed: datetime | None = None
|
|
149
|
+
extension_to_serial: int | None = None
|
|
150
|
+
continuation_of_serial: int | None = None
|
|
151
|
+
cancel_serial: int | None = None
|
|
152
|
+
noaa_scale: NoaaScale | None = None
|
|
153
|
+
fields: tuple[MessageField, ...] = ()
|
|
154
|
+
text: str = ""
|
|
155
|
+
|
|
156
|
+
def __post_init__(self) -> None:
|
|
157
|
+
for name in (
|
|
158
|
+
"issued",
|
|
159
|
+
"issue_time",
|
|
160
|
+
"valid_from",
|
|
161
|
+
"valid_to",
|
|
162
|
+
"threshold_reached",
|
|
163
|
+
"begin_time",
|
|
164
|
+
"maximum_time",
|
|
165
|
+
"end_time",
|
|
166
|
+
"observed",
|
|
167
|
+
):
|
|
168
|
+
check_utc(name, getattr(self, name))
|
|
169
|
+
|
|
170
|
+
def field(self, name: str) -> str | None:
|
|
171
|
+
"""Return the value of the first field called ``name``, or ``None``.
|
|
172
|
+
|
|
173
|
+
Case and runs of spaces in ``name`` are ignored.
|
|
174
|
+
"""
|
|
175
|
+
key = field_key(name)
|
|
176
|
+
return next((f.value for f in self.fields if field_key(f.name) == key), None)
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def info(self) -> MessageCode | None:
|
|
180
|
+
"""Catalogue entry of the message, by short code then long code.
|
|
181
|
+
|
|
182
|
+
``None`` when neither code is in :mod:`swpc_kit.codes`.
|
|
183
|
+
"""
|
|
184
|
+
return by_short(self.product_id) or by_long(self.code)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass(frozen=True, slots=True)
|
|
188
|
+
class Alerts:
|
|
189
|
+
"""The space weather messages of the last weeks (``alerts.json``).
|
|
190
|
+
|
|
191
|
+
Attributes:
|
|
192
|
+
messages: The messages, in source order (newest first). May be
|
|
193
|
+
empty.
|
|
194
|
+
source_url: Where the content came from.
|
|
195
|
+
skipped: The records that could not be read, in source order.
|
|
196
|
+
"""
|
|
197
|
+
|
|
198
|
+
messages: tuple[AlertMessage, ...]
|
|
199
|
+
source_url: str
|
|
200
|
+
skipped: tuple[SkippedRecord, ...] = ()
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def data_time(self) -> datetime | None:
|
|
204
|
+
"""Issue time of the most recent message, or ``None`` when empty."""
|
|
205
|
+
return max((m.issued for m in self.messages), default=None)
|