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/codes.py ADDED
@@ -0,0 +1,249 @@
1
+ """Catalogue of the SWPC space weather message codes.
2
+
3
+ Each message of ``alerts.json`` carries two codes: a short ``product_id``
4
+ such as ``K04W``, and a long code in its first line
5
+ (``Space Weather Message Code: WARK04``), the one SWPC uses in its table of
6
+ products (https://www.spaceweather.gov/content/subscription-services).
7
+
8
+ Every entry says how it is known (see ``docs/FONTI.md``, section "Alerts"):
9
+
10
+ - :attr:`Verification.VERIFIED`: everything in the entry is confirmed,
11
+ either by the text of the messages of the recorded fixture (the pair of
12
+ codes, meaning and scale) or by the official SWPC table of products (long
13
+ code, meaning and scale; the short code is then ``None`` if no message
14
+ showed it);
15
+ - :attr:`Verification.DEDUCED`: long code, meaning and scale come from the
16
+ official table, but the short code was not seen in a message: it follows
17
+ the pattern of the verified ones and may be wrong.
18
+
19
+ An unknown code is not an error: lookups return ``None`` and the message
20
+ keeps its raw text.
21
+ """
22
+
23
+ from dataclasses import dataclass
24
+ from enum import StrEnum
25
+
26
+ # CATALOGUE stays out: its repr would fill the API reference; use lookup().
27
+ __all__ = ["MessageCode", "Verification", "by_long", "by_short", "lookup"]
28
+
29
+
30
+ class Verification(StrEnum):
31
+ """How a catalogue entry is known."""
32
+
33
+ VERIFIED = "verified"
34
+ DEDUCED = "deduced"
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class MessageCode:
39
+ """One kind of space weather message.
40
+
41
+ Attributes:
42
+ long: Long code, as in ``Space Weather Message Code:``, e.g. ``WARK04``.
43
+ short: Short code, as in the ``product_id`` of ``alerts.json``, e.g.
44
+ ``K04W``; ``None`` when not known.
45
+ meaning: What the message announces, e.g.
46
+ ``"Warning: Geomagnetic K-index of 4 expected"``.
47
+ scale: NOAA scale level the message is about, e.g. ``"G1"``, or
48
+ ``None`` when the message has no scale level.
49
+ verification: Whether the entry was verified or deduced.
50
+ """
51
+
52
+ long: str
53
+ short: str | None
54
+ meaning: str
55
+ scale: str | None
56
+ verification: Verification
57
+
58
+
59
+ _V = Verification.VERIFIED
60
+ _D = Verification.DEDUCED
61
+
62
+ CATALOGUE: tuple[MessageCode, ...] = (
63
+ # Geomagnetic K-index warnings and alerts.
64
+ MessageCode(
65
+ "WARK04", "K04W", "Warning: Geomagnetic K-index of 4 expected", None, _V
66
+ ),
67
+ MessageCode(
68
+ "WARK05", "K05W", "Warning: Geomagnetic K-index of 5 expected", "G1", _V
69
+ ),
70
+ MessageCode(
71
+ "WARK06", "K06W", "Warning: Geomagnetic K-index of 6 expected", "G2", _V
72
+ ),
73
+ MessageCode(
74
+ "WARK07",
75
+ "K07W",
76
+ "Warning: Geomagnetic K-index of 7 or greater expected",
77
+ "G3",
78
+ _V,
79
+ ),
80
+ MessageCode("ALTK04", "K04A", "Alert: Geomagnetic K-index of 4", None, _V),
81
+ MessageCode("ALTK05", "K05A", "Alert: Geomagnetic K-index of 5", "G1", _V),
82
+ MessageCode("ALTK06", "K06A", "Alert: Geomagnetic K-index of 6", "G2", _V),
83
+ MessageCode("ALTK07", "K07A", "Alert: Geomagnetic K-index of 7", "G3", _V),
84
+ MessageCode("ALTK08", "K08A", "Alert: Geomagnetic K-index of 8", "G4", _D),
85
+ MessageCode("ALTK09", "K09A", "Alert: Geomagnetic K-index of 9", "G5", _D),
86
+ # Geomagnetic storm watches.
87
+ MessageCode(
88
+ "WATA20", "A20F", "Watch: Geomagnetic Storm Category G1 Predicted", "G1", _V
89
+ ),
90
+ MessageCode(
91
+ "WATA30", "A30F", "Watch: Geomagnetic Storm Category G2 Predicted", "G2", _V
92
+ ),
93
+ MessageCode(
94
+ "WATA50", "A50F", "Watch: Geomagnetic Storm Category G3 Predicted", "G3", _D
95
+ ),
96
+ MessageCode(
97
+ "WATA99",
98
+ "A99F",
99
+ "Watch: Geomagnetic Storm Category G4 or Greater Predicted",
100
+ "G4",
101
+ _D,
102
+ ),
103
+ # Geomagnetic sudden impulse.
104
+ MessageCode(
105
+ "WARSUD", "SGIW", "Warning: Geomagnetic Sudden Impulse expected", None, _V
106
+ ),
107
+ MessageCode("SUMSUD", "MSIS", "Summary: Geomagnetic Sudden Impulse", None, _V),
108
+ # Electrons.
109
+ MessageCode(
110
+ "ALTEF3",
111
+ "EF3A",
112
+ "Alert: Electron 2MeV Integral Flux exceeded 1,000pfu",
113
+ None,
114
+ _V,
115
+ ),
116
+ # Protons (official table; short codes not seen in a message).
117
+ MessageCode(
118
+ "WARPX1",
119
+ None,
120
+ "Warning: Proton 10MeV Integral Flux above 10pfu expected",
121
+ "S1",
122
+ _V,
123
+ ),
124
+ MessageCode(
125
+ "WARPC0",
126
+ None,
127
+ "Warning: Proton 100MeV Integral Flux above 1pfu expected",
128
+ None,
129
+ _V,
130
+ ),
131
+ MessageCode(
132
+ "ALTPX1",
133
+ None,
134
+ "Alert: Proton Event 10MeV Integral Flux exceeded 10pfu",
135
+ "S1",
136
+ _V,
137
+ ),
138
+ MessageCode(
139
+ "SUMPX1",
140
+ None,
141
+ "Summary: Proton Event 10MeV Integral Flux exceeded 10pfu",
142
+ "S1",
143
+ _V,
144
+ ),
145
+ MessageCode(
146
+ "ALTPX2",
147
+ None,
148
+ "Alert: Proton Event 10MeV Integral Flux exceeded 100pfu",
149
+ "S2",
150
+ _V,
151
+ ),
152
+ MessageCode(
153
+ "SUMPX2",
154
+ None,
155
+ "Summary: Proton Event 10MeV Integral Flux exceeded 100pfu",
156
+ "S2",
157
+ _V,
158
+ ),
159
+ MessageCode(
160
+ "ALTPX3",
161
+ None,
162
+ "Alert: Proton Event 10MeV Integral Flux exceeded 1,000pfu",
163
+ "S3",
164
+ _V,
165
+ ),
166
+ MessageCode(
167
+ "SUMPX3",
168
+ None,
169
+ "Summary: Proton Event 10MeV Integral Flux exceeded 1,000pfu",
170
+ "S3",
171
+ _V,
172
+ ),
173
+ MessageCode(
174
+ "ALTPX4",
175
+ None,
176
+ "Alert: Proton Event 10MeV Integral Flux exceeded 10,000pfu",
177
+ "S4",
178
+ _V,
179
+ ),
180
+ MessageCode(
181
+ "SUMPX4",
182
+ None,
183
+ "Summary: Proton Event 10MeV Integral Flux exceeded 10,000pfu",
184
+ "S4",
185
+ _V,
186
+ ),
187
+ MessageCode(
188
+ "ALTPX5",
189
+ None,
190
+ "Alert: Proton Event 10MeV Integral Flux exceeded 100,000pfu",
191
+ "S5",
192
+ _V,
193
+ ),
194
+ MessageCode(
195
+ "SUMPX5",
196
+ None,
197
+ "Summary: Proton Event 10MeV Integral Flux exceeded 100,000pfu",
198
+ "S5",
199
+ _V,
200
+ ),
201
+ MessageCode(
202
+ "ALTPC0",
203
+ None,
204
+ "Alert: Proton Event 100MeV Integral Flux exceeded 1pfu",
205
+ None,
206
+ _V,
207
+ ),
208
+ MessageCode(
209
+ "SUMPC0",
210
+ None,
211
+ "Summary: Proton Event 100MeV Integral Flux exceeded 1pfu",
212
+ None,
213
+ _V,
214
+ ),
215
+ # X-rays.
216
+ MessageCode("ALTXMF", "XM5A", "Alert: X-Ray Flux exceeded M5", "R2", _V),
217
+ MessageCode("SUMXM5", "XM5S", "Summary: X-ray Event exceeded M5", "R2", _V),
218
+ MessageCode("SUMX01", None, "Summary: X-ray Event exceeded X1", "R3", _V),
219
+ MessageCode("SUMX10", None, "Summary: X-ray Event exceeded X10", "R4", _V),
220
+ MessageCode("SUMX20", None, "Summary: X-ray Event exceeded X20", "R5", _V),
221
+ # Radio.
222
+ MessageCode("ALTTP2", "TIIA", "Alert: Type II Radio Emission", None, _V),
223
+ MessageCode("ALTTP4", "TIVA", "Alert: Type IV Radio Emission", None, _V),
224
+ MessageCode("SUM10R", "BHIS", "Summary: 10cm Radio Burst", None, _V),
225
+ )
226
+
227
+ _BY_LONG = {c.long: c for c in CATALOGUE}
228
+ _BY_SHORT = {c.short: c for c in CATALOGUE if c.short is not None}
229
+
230
+
231
+ def by_long(code: str) -> MessageCode | None:
232
+ """Return the entry with long code ``code`` (e.g. ``WARK04``), or ``None``.
233
+
234
+ Case and surrounding spaces are ignored.
235
+ """
236
+ return _BY_LONG.get(code.strip().upper())
237
+
238
+
239
+ def by_short(code: str) -> MessageCode | None:
240
+ """Return the entry with short code ``code`` (e.g. ``K04W``), or ``None``.
241
+
242
+ Case and surrounding spaces are ignored.
243
+ """
244
+ return _BY_SHORT.get(code.strip().upper())
245
+
246
+
247
+ def lookup(code: str) -> MessageCode | None:
248
+ """Return the entry with ``code`` as its short or long code, or ``None``."""
249
+ return by_short(code) or by_long(code)
swpc_kit/errors.py ADDED
@@ -0,0 +1,135 @@
1
+ """Typed errors raised by swpc-kit.
2
+
3
+ Every error derives from :class:`SwpcError`, so a caller can catch them all
4
+ at once, or tell apart a transient state (:class:`SourceNotReady`) from a
5
+ source that answered with something else (:class:`UnexpectedContent`) and
6
+ from a known format with an inconsistent value (:class:`ParseError`).
7
+ """
8
+
9
+ from datetime import datetime, timedelta
10
+
11
+
12
+ class SwpcError(Exception):
13
+ """Base class for every error raised by swpc-kit.
14
+
15
+ Attributes:
16
+ product: Name of the product being fetched, such as
17
+ ``"planetary_k_index"``, when the error went through the client;
18
+ ``None`` from a parser called directly.
19
+ url: The URL being fetched, under the same condition.
20
+ """
21
+
22
+ product: str | None = None
23
+ url: str | None = None
24
+
25
+ def __str__(self) -> str:
26
+ text = super().__str__()
27
+ if self.product is None or self.url is None:
28
+ return text
29
+ if self.url in text:
30
+ return f"{text} [{self.product}]"
31
+ return f"{text} [{self.product} at {self.url}]"
32
+
33
+
34
+ class SourceNotReady(SwpcError):
35
+ """The data is truncated or empty.
36
+
37
+ SWPC sometimes serves a file while rewriting it, so a JSON array may not
38
+ be closed or a text product may stop halfway. This is a transient state,
39
+ not a bug: the caller should try again later.
40
+ """
41
+
42
+
43
+ class UnexpectedContent(SwpcError):
44
+ """The source answered with something other than the expected product.
45
+
46
+ For example an HTML page, a redirect to an announcement page, or a format
47
+ that no parser knows.
48
+
49
+ Args:
50
+ message: What came instead of the product.
51
+ status: The HTTP status, when the client got one other than ``200``.
52
+ location: The ``Location`` header of a redirect: where the product
53
+ may have moved.
54
+ """
55
+
56
+ def __init__(
57
+ self, message: str, *, status: int | None = None, location: str | None = None
58
+ ) -> None:
59
+ self.status = status
60
+ self.location = location
61
+ super().__init__(message)
62
+
63
+
64
+ class ParseError(SwpcError):
65
+ """The format is the expected one, but a value is inconsistent.
66
+
67
+ The message names the offending field and where it is, when known.
68
+
69
+ The arguments are also attributes; ``reason`` is ``message``, without
70
+ the location.
71
+
72
+ Args:
73
+ message: What is wrong with the value.
74
+ field: Name of the offending field or column.
75
+ line: 1-based line number in a text product.
76
+ index: 0-based position of the offending record in a JSON array.
77
+
78
+ Raises:
79
+ ValueError: If ``line`` is not positive or ``index`` is negative.
80
+ """
81
+
82
+ def __init__(
83
+ self,
84
+ message: str,
85
+ *,
86
+ field: str | None = None,
87
+ line: int | None = None,
88
+ index: int | None = None,
89
+ ) -> None:
90
+ if line is not None and line < 1:
91
+ raise ValueError(f"line is 1-based, got {line}")
92
+ if index is not None and index < 0:
93
+ raise ValueError(f"index must not be negative, got {index}")
94
+ self.reason = message
95
+ self.field = field
96
+ self.line = line
97
+ self.index = index
98
+ where = []
99
+ if field is not None:
100
+ where.append(f"field {field!r}")
101
+ if line is not None:
102
+ where.append(f"line {line}")
103
+ if index is not None:
104
+ where.append(f"record {index}")
105
+ super().__init__(f"{message} ({', '.join(where)})" if where else message)
106
+
107
+
108
+ class StaleData(SwpcError):
109
+ """The data is older than the maximum age the caller accepts.
110
+
111
+ Raised only by the client, and only when the caller passes ``max_age``.
112
+ The age is measured on the time the content declares, never on HTTP
113
+ metadata.
114
+
115
+ Args:
116
+ data_time: Most recent time declared by the content, in UTC.
117
+ max_age: Maximum age the caller accepts.
118
+ age: Age of the data when it was checked.
119
+
120
+ Raises:
121
+ ValueError: If ``data_time`` is not an aware datetime in UTC.
122
+ """
123
+
124
+ def __init__(
125
+ self, *, data_time: datetime, max_age: timedelta, age: timedelta
126
+ ) -> None:
127
+ if data_time.utcoffset() != timedelta(0):
128
+ raise ValueError(f"data_time must be in UTC, got {data_time!r}")
129
+ self.data_time = data_time
130
+ self.max_age = max_age
131
+ self.age = age
132
+ super().__init__(
133
+ f"data from {data_time.isoformat()} is {age} old, "
134
+ f"more than the allowed {max_age}"
135
+ )
@@ -0,0 +1,12 @@
1
+ """Exports of the models: plain data, and the gridded products as maps.
2
+
3
+ :func:`to_dict` and :func:`to_records` (plain data for JSON and for tables)
4
+ and :func:`to_geojson` (OVATION, D-RAP) are re-exported here. GeoTIFF
5
+ export lives in :mod:`swpc_kit.export.geotiff` (``write_geotiff``). All of
6
+ them use only the standard library.
7
+ """
8
+
9
+ from swpc_kit.export.data import to_dict, to_records
10
+ from swpc_kit.export.geojson import to_geojson
11
+
12
+ __all__ = ["to_dict", "to_geojson", "to_records"]
@@ -0,0 +1,64 @@
1
+ """The TIFF container behind :mod:`swpc_kit.export.geotiff`.
2
+
3
+ :func:`encode` lays out a little-endian classic TIFF with one image: the
4
+ header, one directory of tags, the tag values that do not fit in their
5
+ entry, then the strips of pixel data. It knows nothing about GeoTIFF or
6
+ GDAL: the caller chooses the tags and compresses the strips.
7
+ """
8
+
9
+ import struct
10
+ from collections.abc import Sequence
11
+
12
+ __all__ = ["ASCII", "DOUBLE", "LONG", "SHORT", "STRIP_OFFSETS", "encode"]
13
+
14
+ ASCII = 2
15
+ SHORT = 3
16
+ LONG = 4
17
+ DOUBLE = 12
18
+
19
+ STRIP_OFFSETS = 273
20
+ """The tag whose values :func:`encode` fills in: where each strip starts."""
21
+
22
+ _FORMATS = {SHORT: "H", LONG: "I", DOUBLE: "d"}
23
+
24
+ type Entry = tuple[int, int, Sequence[int] | Sequence[float] | str]
25
+ """A tag: its number, its TIFF type and its values (text for ``ASCII``)."""
26
+
27
+
28
+ def _pack(kind: int, values: Sequence[int] | Sequence[float] | str) -> bytes:
29
+ if isinstance(values, str):
30
+ return values.encode("ascii") + b"\0"
31
+ return struct.pack(f"<{len(values)}{_FORMATS[kind]}", *values)
32
+
33
+
34
+ def encode(entries: Sequence[Entry], strips: Sequence[bytes]) -> bytes:
35
+ """Return the bytes of a TIFF file with these tags and strips.
36
+
37
+ Args:
38
+ entries: The tags of the image, in any order. They must not include
39
+ :data:`STRIP_OFFSETS`, which is computed here.
40
+ strips: The pixel data, one compressed strip per item, top first.
41
+ """
42
+ placeholder: Entry = (STRIP_OFFSETS, LONG, [0] * len(strips))
43
+ tags = sorted([*entries, placeholder], key=lambda entry: entry[0])
44
+ directory_size = 2 + 12 * len(tags) + 4
45
+ # Tag values longer than four bytes go after the directory, each on an
46
+ # even offset; the strips follow them.
47
+ raws = [_pack(kind, values) for _, kind, values in tags]
48
+ spill = sum(len(raw) + len(raw) % 2 for raw in raws if len(raw) > 4)
49
+ start = 8 + directory_size + spill
50
+ offsets = [start + sum(map(len, strips[:i])) for i in range(len(strips))]
51
+
52
+ directory = bytearray(struct.pack("<H", len(tags)))
53
+ extra = bytearray()
54
+ for tag, kind, values in tags:
55
+ raw = _pack(kind, offsets if tag == STRIP_OFFSETS else values)
56
+ count = len(raw) if kind == ASCII else len(values)
57
+ if len(raw) <= 4:
58
+ directory += struct.pack("<HHI", tag, kind, count) + raw.ljust(4, b"\0")
59
+ else:
60
+ at = 8 + directory_size + len(extra)
61
+ directory += struct.pack("<HHII", tag, kind, count, at)
62
+ extra += raw + b"\0" * (len(raw) % 2)
63
+ directory += struct.pack("<I", 0) # no next image
64
+ return b"II*\0" + struct.pack("<I", 8) + directory + extra + b"".join(strips)
@@ -0,0 +1,167 @@
1
+ """Plain data from the models (standard library only).
2
+
3
+ :func:`to_dict` turns any model into dicts, lists and plain values, ready
4
+ for :func:`json.dumps`. :func:`to_records` turns a product into a list of
5
+ flat rows, one per record, ready for :class:`csv.DictWriter` or for
6
+ ``pandas.DataFrame(...)``, without depending on pandas.
7
+
8
+ Both give the fields of the models, nothing computed: ``data_time``,
9
+ ``flare_class`` and the other properties stay on the models.
10
+ """
11
+
12
+ import types
13
+ from collections.abc import Iterator, Mapping, Sequence
14
+ from dataclasses import fields, is_dataclass
15
+ from datetime import UTC, date, datetime
16
+ from enum import Enum
17
+ from functools import cache
18
+ from typing import Any, Union, get_args, get_origin, get_type_hints
19
+
20
+ from swpc_kit.models.alerts import Alerts
21
+ from swpc_kit.models.bulletins import (
22
+ GeomagneticForecast,
23
+ ThreeDayForecast,
24
+ TwentySevenDayOutlook,
25
+ )
26
+ from swpc_kit.models.grids import Grid
27
+ from swpc_kit.models.indices import NoaaScales
28
+
29
+ __all__ = ["to_dict", "to_records"]
30
+
31
+ # The products whose rows are not in ``values``.
32
+ _ROWS: dict[type, str] = {
33
+ Alerts: "messages",
34
+ NoaaScales: "days",
35
+ TwentySevenDayOutlook: "days",
36
+ ThreeDayForecast: "kp",
37
+ GeomagneticForecast: "kp",
38
+ }
39
+
40
+
41
+ def _is_model(value: object) -> bool:
42
+ return is_dataclass(value) and not isinstance(value, type)
43
+
44
+
45
+ def _iso(value: datetime) -> str:
46
+ return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
47
+
48
+
49
+ def _plain(value: Any) -> Any:
50
+ if _is_model(value):
51
+ return {f.name: _plain(getattr(value, f.name)) for f in fields(value)}
52
+ if isinstance(value, datetime):
53
+ return _iso(value)
54
+ if isinstance(value, date):
55
+ return value.isoformat()
56
+ if isinstance(value, Enum):
57
+ return value.value
58
+ if isinstance(value, Mapping):
59
+ return {str(key): _plain(item) for key, item in value.items()}
60
+ if isinstance(value, (tuple, list)):
61
+ return [_plain(item) for item in value]
62
+ return value
63
+
64
+
65
+ def to_dict(model: object) -> dict[str, Any]:
66
+ """Return a model as plain data, ready for :func:`json.dumps`.
67
+
68
+ Every field of the model becomes a key, in the order of the fields;
69
+ nested models become dicts and tuples become lists. Times become ISO
70
+ 8601 text in UTC with ``Z`` (``"2026-10-15T12:00:00Z"``), dates
71
+ ``"2026-10-15"``, and enum members their value.
72
+
73
+ Args:
74
+ model: Any model of :mod:`swpc_kit.models`: a product, a sample, a
75
+ grid, a ``SkippedRecord``.
76
+
77
+ Returns:
78
+ A dict of str, int, float, bool, ``None``, lists and dicts.
79
+
80
+ Raises:
81
+ TypeError: If ``model`` is not a model.
82
+ """
83
+ if not _is_model(model):
84
+ raise TypeError(f"to_dict takes a model, got {type(model).__name__}")
85
+ data: dict[str, Any] = _plain(model)
86
+ return data
87
+
88
+
89
+ @cache
90
+ def _nested(kind: type) -> dict[str, type]:
91
+ """The fields of ``kind`` that hold a model (or ``None``), by name."""
92
+ nested = {}
93
+ for name, hint in get_type_hints(kind).items():
94
+ if get_origin(hint) in (Union, types.UnionType):
95
+ options = [a for a in get_args(hint) if a is not type(None)]
96
+ hint = options[0] if len(options) == 1 else None
97
+ if isinstance(hint, type) and is_dataclass(hint):
98
+ nested[name] = hint
99
+ return nested
100
+
101
+
102
+ def _flat(kind: type, value: object, prefix: str) -> Iterator[tuple[str, Any]]:
103
+ nested = _nested(kind)
104
+ for f in fields(kind):
105
+ item = None if value is None else getattr(value, f.name)
106
+ if f.name in nested:
107
+ yield from _flat(nested[f.name], item, f"{prefix}{f.name}_")
108
+ elif isinstance(item, Enum):
109
+ yield prefix + f.name, item.value
110
+ elif isinstance(item, (tuple, list)):
111
+ yield prefix + f.name, [_plain(element) for element in item]
112
+ else:
113
+ yield prefix + f.name, item
114
+
115
+
116
+ def _rows(data: object) -> Sequence[Any]:
117
+ if _is_model(data):
118
+ rows = getattr(data, _ROWS.get(type(data), "values"), None)
119
+ if isinstance(rows, tuple):
120
+ return rows
121
+ elif isinstance(data, (tuple, list)) and all(_is_model(row) for row in data):
122
+ return data
123
+ raise TypeError(
124
+ "to_records takes a product, a grid or a sequence of records,"
125
+ f" got {type(data).__name__}"
126
+ )
127
+
128
+
129
+ def to_records(data: object) -> list[dict[str, Any]]:
130
+ """Return the records of a product as flat rows.
131
+
132
+ One dict per record, with the same keys in the same order for every
133
+ row. Times stay aware UTC ``datetime`` objects (pandas reads them as
134
+ ``datetime64[ns, UTC]``), dates stay ``date``, enum members become
135
+ their value. A nested model is flattened with its field name as prefix
136
+ (``g_scale``, ``noaa_scale_level``), with ``None`` in each column when
137
+ it is absent. A field that holds a tuple, such as ``fields`` of an
138
+ alert, becomes a list.
139
+
140
+ The records of a product are its ``values``; for the alerts its
141
+ ``messages``; for the NOAA scales and the 27-day outlook its ``days``;
142
+ for the 3-day and geomagnetic forecasts the Kp periods (``kp``); for a
143
+ grid or a gridded product its cells, ``latitude``, ``longitude`` and
144
+ ``value``, with the longitudes as delivered.
145
+
146
+ Args:
147
+ data: A product, a grid, or a sequence of records of one kind (such
148
+ as ``wind.active_series()`` or ``kp.skipped``).
149
+
150
+ Returns:
151
+ A list of dicts, empty if there are no records.
152
+
153
+ Raises:
154
+ TypeError: If ``data`` is none of these, or mixes kinds of record.
155
+ """
156
+ grid = data if isinstance(data, Grid) else getattr(data, "grid", None)
157
+ if _is_model(data) and isinstance(grid, Grid):
158
+ return [
159
+ {"latitude": latitude, "longitude": longitude, "value": value}
160
+ for latitude, longitude, value in grid.cells()
161
+ ]
162
+ rows = _rows(data)
163
+ kinds = {type(row) for row in rows}
164
+ if len(kinds) > 1:
165
+ names = ", ".join(sorted(kind.__name__ for kind in kinds))
166
+ raise TypeError(f"to_records wants records of one kind, got {names}")
167
+ return [dict(_flat(type(row), row, "")) for row in rows]