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