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/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"]
|
swpc_kit/export/_tiff.py
ADDED
|
@@ -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)
|
swpc_kit/export/data.py
ADDED
|
@@ -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]
|