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/parse/alerts.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
r"""Parser for ``alerts.json``, the space weather messages of the last weeks.
|
|
2
|
+
|
|
3
|
+
The JSON is an array of ``{product_id, issue_datetime, message}`` objects.
|
|
4
|
+
Each ``message`` is free text: lines end with ``\r\n`` or ``\n`` (both are
|
|
5
|
+
mixed in one message), the first three lines are the header
|
|
6
|
+
(``Space Weather Message Code:``, ``Serial Number:``, ``Issue Time:``), then
|
|
7
|
+
come a title such as ``ALERT: ...`` and ``Name: value`` lines.
|
|
8
|
+
|
|
9
|
+
The header is mandatory: if it is missing or broken the parser raises
|
|
10
|
+
:class:`~swpc_kit.errors.ParseError`. Everything else is optional: a value
|
|
11
|
+
in an unknown form gives ``None`` in the typed attribute and stays as text
|
|
12
|
+
in the message fields. The message text is data, never instructions.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from collections.abc import Sequence
|
|
17
|
+
from datetime import UTC, datetime
|
|
18
|
+
|
|
19
|
+
from swpc_kit.errors import UnexpectedContent
|
|
20
|
+
from swpc_kit.models.alerts import (
|
|
21
|
+
AlertMessage,
|
|
22
|
+
Alerts,
|
|
23
|
+
MessageField,
|
|
24
|
+
MessageKind,
|
|
25
|
+
NoaaScale,
|
|
26
|
+
ScaleCategory,
|
|
27
|
+
field_key,
|
|
28
|
+
)
|
|
29
|
+
from swpc_kit.models.records import SkippedRecord
|
|
30
|
+
from swpc_kit.parse._common import Record, load_json, read_records
|
|
31
|
+
|
|
32
|
+
ALERTS_URL = "https://services.swpc.noaa.gov/products/alerts.json"
|
|
33
|
+
|
|
34
|
+
_CODE_LABEL = "Space Weather Message Code"
|
|
35
|
+
_SERIAL_LABEL = "Serial Number"
|
|
36
|
+
_ISSUE_LABEL = "Issue Time"
|
|
37
|
+
|
|
38
|
+
_MONTHS = {
|
|
39
|
+
name: number
|
|
40
|
+
for number, name in enumerate(
|
|
41
|
+
[
|
|
42
|
+
"Jan",
|
|
43
|
+
"Feb",
|
|
44
|
+
"Mar",
|
|
45
|
+
"Apr",
|
|
46
|
+
"May",
|
|
47
|
+
"Jun",
|
|
48
|
+
"Jul",
|
|
49
|
+
"Aug",
|
|
50
|
+
"Sep",
|
|
51
|
+
"Oct",
|
|
52
|
+
"Nov",
|
|
53
|
+
"Dec",
|
|
54
|
+
],
|
|
55
|
+
start=1,
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
_TEXT_TIME = re.compile(r"(\d{4}) ([A-Z][a-z]{2}) (\d{1,2}) (\d{2})(\d{2}) UTC")
|
|
59
|
+
_NEWLINE = re.compile(r"\r\n|\r|\n")
|
|
60
|
+
_CODE = re.compile(r"[A-Za-z0-9]+")
|
|
61
|
+
_SERIAL = re.compile(r"\d+")
|
|
62
|
+
_TITLE = re.compile(r"([A-Z]+(?: [A-Z]+)*):")
|
|
63
|
+
_FIELD = re.compile(r"([A-Za-z][A-Za-z0-9 ()/-]*?)\s*:(.*)")
|
|
64
|
+
_DATE_KEY = re.compile(r"[A-Z][a-z]{2} \d{1,2}")
|
|
65
|
+
_SCALE = re.compile(r"([GSR])([1-5])(?:\s*-\s*(.*))?")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _text_time(text: str) -> datetime:
|
|
69
|
+
"""Parse a time written like ``2026 Oct 13 2058 UTC``.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
ValueError: If the text is not such a time.
|
|
73
|
+
"""
|
|
74
|
+
match = _TEXT_TIME.fullmatch(text)
|
|
75
|
+
if match is None or match.group(2) not in _MONTHS:
|
|
76
|
+
raise ValueError(f"expected a time like '2026 Oct 13 2058 UTC', got {text!r}")
|
|
77
|
+
year, month, day, hour, minute = match.groups()
|
|
78
|
+
try:
|
|
79
|
+
return datetime(
|
|
80
|
+
int(year), _MONTHS[month], int(day), int(hour), int(minute), tzinfo=UTC
|
|
81
|
+
)
|
|
82
|
+
except ValueError as exc:
|
|
83
|
+
raise ValueError(f"not a valid time: {text!r} ({exc})") from None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _header(record: Record, lines: Sequence[str], label: str) -> tuple[int, str]:
|
|
87
|
+
"""Return the position and value of the first line ``label: value``."""
|
|
88
|
+
prefix = label.lower() + ":"
|
|
89
|
+
for number, line in enumerate(lines):
|
|
90
|
+
if line[: len(prefix)].lower() == prefix:
|
|
91
|
+
return number, line[len(prefix) :].strip()
|
|
92
|
+
raise record.error("missing header line", f"message.{label}")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _fields(lines: Sequence[str]) -> tuple[MessageField, ...]:
|
|
96
|
+
"""Return the ``Name: value`` lines.
|
|
97
|
+
|
|
98
|
+
Day labels of watches (``Oct 12: None (Below G1)``) and lines starting
|
|
99
|
+
with a URL are not fields.
|
|
100
|
+
"""
|
|
101
|
+
fields = []
|
|
102
|
+
for line in lines:
|
|
103
|
+
match = _FIELD.fullmatch(line.strip())
|
|
104
|
+
if match is None:
|
|
105
|
+
continue
|
|
106
|
+
name, value = match.group(1).strip(), match.group(2).strip()
|
|
107
|
+
if _DATE_KEY.fullmatch(name) or value.startswith("//"):
|
|
108
|
+
continue
|
|
109
|
+
fields.append(MessageField(name, value))
|
|
110
|
+
return tuple(fields)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _values(fields: Sequence[MessageField], *names: str) -> list[str]:
|
|
114
|
+
keys = {field_key(n) for n in names}
|
|
115
|
+
return [f.value for f in fields if field_key(f.name) in keys]
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _optional_time(fields: Sequence[MessageField], *names: str) -> datetime | None:
|
|
119
|
+
"""Return the first field found, by order of ``names``, as a time.
|
|
120
|
+
|
|
121
|
+
Returns ``None`` when no such field exists or its value has another form.
|
|
122
|
+
"""
|
|
123
|
+
for name in names:
|
|
124
|
+
values = _values(fields, name)
|
|
125
|
+
if values:
|
|
126
|
+
try:
|
|
127
|
+
return _text_time(values[0])
|
|
128
|
+
except ValueError:
|
|
129
|
+
return None
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _optional_serial(fields: Sequence[MessageField], name: str) -> int | None:
|
|
134
|
+
"""Return the named field as a serial number, or ``None``."""
|
|
135
|
+
values = _values(fields, name)
|
|
136
|
+
if not values or not _SERIAL.fullmatch(values[0]):
|
|
137
|
+
return None
|
|
138
|
+
return int(values[0])
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _scale(fields: Sequence[MessageField]) -> NoaaScale | None:
|
|
142
|
+
for value in _values(fields, "NOAA Scale"):
|
|
143
|
+
match = _SCALE.fullmatch(value)
|
|
144
|
+
if match is not None:
|
|
145
|
+
label = (match.group(3) or "").strip() or None
|
|
146
|
+
return NoaaScale(ScaleCategory(match.group(1)), int(match.group(2)), label)
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _kind(title: str) -> MessageKind | None:
|
|
151
|
+
match = _TITLE.match(title)
|
|
152
|
+
if match is None:
|
|
153
|
+
return None
|
|
154
|
+
try:
|
|
155
|
+
return MessageKind(match.group(1))
|
|
156
|
+
except ValueError:
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _message(record: Record) -> AlertMessage:
|
|
161
|
+
product_id = record.get_str("product_id").strip()
|
|
162
|
+
if not product_id:
|
|
163
|
+
raise record.error("empty product_id", "product_id")
|
|
164
|
+
issued = record.get_time("issue_datetime")
|
|
165
|
+
text = record.get_str("message")
|
|
166
|
+
lines = _NEWLINE.split(text)
|
|
167
|
+
|
|
168
|
+
end, code = _header(record, lines, _CODE_LABEL)
|
|
169
|
+
if not _CODE.fullmatch(code):
|
|
170
|
+
raise record.error(f"expected a code, got {code!r}", f"message.{_CODE_LABEL}")
|
|
171
|
+
position, serial = _header(record, lines, _SERIAL_LABEL)
|
|
172
|
+
end = max(end, position)
|
|
173
|
+
if not _SERIAL.fullmatch(serial):
|
|
174
|
+
raise record.error(
|
|
175
|
+
f"expected a serial number, got {serial!r}", f"message.{_SERIAL_LABEL}"
|
|
176
|
+
)
|
|
177
|
+
position, issue = _header(record, lines, _ISSUE_LABEL)
|
|
178
|
+
end = max(end, position)
|
|
179
|
+
try:
|
|
180
|
+
issue_time = _text_time(issue)
|
|
181
|
+
except ValueError as exc:
|
|
182
|
+
raise record.error(str(exc), f"message.{_ISSUE_LABEL}") from None
|
|
183
|
+
|
|
184
|
+
body = lines[end + 1 :]
|
|
185
|
+
start = next((n for n, line in enumerate(body) if line.strip()), len(body))
|
|
186
|
+
title = body[start].strip() if start < len(body) else ""
|
|
187
|
+
fields = _fields(body[start + 1 :])
|
|
188
|
+
|
|
189
|
+
return AlertMessage(
|
|
190
|
+
product_id=product_id,
|
|
191
|
+
code=code,
|
|
192
|
+
serial_number=int(serial),
|
|
193
|
+
issued=issued,
|
|
194
|
+
issue_time=issue_time,
|
|
195
|
+
kind=_kind(title),
|
|
196
|
+
title=title,
|
|
197
|
+
valid_from=_optional_time(fields, "Valid From"),
|
|
198
|
+
valid_to=_optional_time(fields, "Now Valid Until", "Valid To"),
|
|
199
|
+
threshold_reached=_optional_time(fields, "Threshold Reached"),
|
|
200
|
+
begin_time=_optional_time(fields, "Begin Time"),
|
|
201
|
+
maximum_time=_optional_time(fields, "Maximum Time", "Max time"),
|
|
202
|
+
end_time=_optional_time(fields, "End Time"),
|
|
203
|
+
observed=_optional_time(fields, "Observed"),
|
|
204
|
+
extension_to_serial=_optional_serial(fields, "Extension to Serial Number"),
|
|
205
|
+
continuation_of_serial=_optional_serial(
|
|
206
|
+
fields, "Continuation of Serial Number"
|
|
207
|
+
),
|
|
208
|
+
cancel_serial=_optional_serial(fields, "Cancel Serial Number"),
|
|
209
|
+
noaa_scale=_scale(fields),
|
|
210
|
+
fields=fields,
|
|
211
|
+
text=text,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def parse_alerts(raw: str | bytes, *, source_url: str = ALERTS_URL) -> Alerts:
|
|
216
|
+
"""Parse ``alerts.json`` (space weather messages of the last weeks).
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
raw: The content. An empty array gives a model without messages.
|
|
220
|
+
source_url: Where the content came from.
|
|
221
|
+
|
|
222
|
+
Raises:
|
|
223
|
+
SourceNotReady: If the content is empty or truncated.
|
|
224
|
+
UnexpectedContent: If the content is not an array of objects.
|
|
225
|
+
ParseError: If a record or the header of a message is missing or
|
|
226
|
+
inconsistent; the error names the field and the record.
|
|
227
|
+
"""
|
|
228
|
+
document = load_json(raw)
|
|
229
|
+
if not isinstance(document, list):
|
|
230
|
+
raise UnexpectedContent(
|
|
231
|
+
f"expected a JSON array of messages, got {type(document).__name__}"
|
|
232
|
+
)
|
|
233
|
+
if document and not isinstance(document[0], dict):
|
|
234
|
+
raise UnexpectedContent(
|
|
235
|
+
f"expected objects in the JSON array, got {type(document[0]).__name__}"
|
|
236
|
+
)
|
|
237
|
+
not_objects = [
|
|
238
|
+
SkippedRecord(index=index, field=None, reason="expected an object")
|
|
239
|
+
for index, item in enumerate(document)
|
|
240
|
+
if not isinstance(item, dict)
|
|
241
|
+
]
|
|
242
|
+
records = [
|
|
243
|
+
Record(index, item)
|
|
244
|
+
for index, item in enumerate(document)
|
|
245
|
+
if isinstance(item, dict)
|
|
246
|
+
]
|
|
247
|
+
read, skipped = read_records(records, _message)
|
|
248
|
+
return Alerts(
|
|
249
|
+
messages=tuple(message for _, message in read),
|
|
250
|
+
source_url=source_url,
|
|
251
|
+
skipped=tuple(sorted([*skipped, *not_objects], key=lambda s: s.index)),
|
|
252
|
+
)
|