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,353 @@
1
+ """Models for the gridded products: OVATION aurora and D-RAP.
2
+
3
+ Both products are values on a regular latitude/longitude grid. The values are
4
+ given at **cell centres**; a cell spans half a step on each side of its
5
+ centre. :class:`Grid` holds the axes, the values, the quantity and its units;
6
+ the product models add the times the content declares and its own text
7
+ fields. :class:`GridProduct` is what the exports in :mod:`swpc_kit.export`
8
+ accept.
9
+
10
+ A grid keeps the longitudes as the source delivers them (OVATION uses 0 to
11
+ 359, D-RAP -178 to 178); :meth:`Grid.wrapped` turns them into -180 to 180 for
12
+ use on a map.
13
+ """
14
+
15
+ import math
16
+ from collections.abc import Iterator, Mapping
17
+ from dataclasses import dataclass
18
+ from datetime import datetime
19
+ from itertools import pairwise
20
+ from typing import Protocol
21
+
22
+ from swpc_kit.models._checks import check_utc
23
+
24
+ _TOLERANCE = 1e-9
25
+
26
+
27
+ def wrap_longitude(longitude: float) -> float:
28
+ """Return ``longitude`` in degrees east, moved into [-180, 180).
29
+
30
+ For example 0 stays 0, 180 becomes -180, 359 becomes -1.
31
+ """
32
+ return (longitude + 180.0) % 360.0 - 180.0
33
+
34
+
35
+ def _check_axis(name: str, axis: tuple[float, ...], step: float) -> None:
36
+ if not axis:
37
+ raise ValueError(f"{name} must not be empty")
38
+ if not (math.isfinite(step) and step > 0):
39
+ raise ValueError(f"{name} step must be a positive number, got {step!r}")
40
+ first = axis[0]
41
+ for previous, value in pairwise(axis):
42
+ if not value > previous:
43
+ raise ValueError(f"{name} must be strictly increasing, got {axis!r}")
44
+ for value in axis:
45
+ offset = (value - first) / step
46
+ if abs(offset - round(offset)) > _TOLERANCE:
47
+ raise ValueError(
48
+ f"{name} value {value} is not on the grid of step {step} "
49
+ f"starting at {first}"
50
+ )
51
+
52
+
53
+ def _contiguous(axis: tuple[float, ...], step: float) -> bool:
54
+ expected = round((axis[-1] - axis[0]) / step) + 1
55
+ return len(axis) == expected
56
+
57
+
58
+ def _find(axis: tuple[float, ...], value: float) -> int | None:
59
+ for index, item in enumerate(axis):
60
+ if abs(item - value) <= _TOLERANCE:
61
+ return index
62
+ return None
63
+
64
+
65
+ def _holding(
66
+ axis: tuple[float, ...], step: float, values: tuple[float, ...]
67
+ ) -> int | None:
68
+ """Index of the cell of ``axis`` whose span holds one of ``values``.
69
+
70
+ ``values`` are the same point written in different conventions. On a
71
+ border the cell after it wins, then the one before: so the last edge of
72
+ the axis still belongs to its last cell.
73
+ """
74
+ lattice = {round((item - axis[0]) / step): i for i, item in enumerate(axis)}
75
+ for after in (True, False):
76
+ for value in values:
77
+ position = (value - axis[0]) / step
78
+ offset = math.floor(position + 0.5) if after else math.ceil(position - 0.5)
79
+ if offset in lattice:
80
+ return lattice[offset]
81
+ return None
82
+
83
+
84
+ @dataclass(frozen=True, slots=True)
85
+ class Grid:
86
+ """Values on a regular latitude/longitude grid, at cell centres.
87
+
88
+ The axes lie on a regular lattice of the given steps, but they may have
89
+ gaps: a grid read from a reduced file (see :func:`~swpc_kit.parse.grids.
90
+ parse_ovation_aurora`) keeps only some longitudes. :attr:`is_contiguous`
91
+ tells the two apart.
92
+
93
+ Attributes:
94
+ latitudes: Cell-centre latitudes in degrees north, strictly
95
+ increasing (south to north), within [-90, 90].
96
+ longitudes: Cell-centre longitudes in degrees east, strictly
97
+ increasing, within [-180, 360), as the source delivers them.
98
+ latitude_step: Height of a cell in degrees.
99
+ longitude_step: Width of a cell in degrees.
100
+ values: One row per latitude, in the order of ``latitudes``; each row
101
+ has one value per longitude, in the order of ``longitudes``.
102
+ quantity: What the values are, such as ``"aurora"``.
103
+ units: Units of the values, such as ``"MHz"``.
104
+
105
+ Raises:
106
+ ValueError: If the axes are empty, unordered, off the lattice or out
107
+ of range, or the values do not match the axes or are not finite.
108
+ """
109
+
110
+ latitudes: tuple[float, ...]
111
+ longitudes: tuple[float, ...]
112
+ latitude_step: float
113
+ longitude_step: float
114
+ values: tuple[tuple[float, ...], ...]
115
+ quantity: str
116
+ units: str
117
+
118
+ def __post_init__(self) -> None:
119
+ _check_axis("latitudes", self.latitudes, self.latitude_step)
120
+ _check_axis("longitudes", self.longitudes, self.longitude_step)
121
+ if self.latitudes[0] < -90 or self.latitudes[-1] > 90:
122
+ raise ValueError("latitudes must lie within [-90, 90]")
123
+ if self.longitudes[0] < -180 or self.longitudes[-1] >= 360:
124
+ raise ValueError("longitudes must lie within [-180, 360)")
125
+ if self.longitudes[-1] - self.longitudes[0] >= 360:
126
+ raise ValueError("longitudes must span less than 360 degrees")
127
+ if len(self.values) != len(self.latitudes):
128
+ raise ValueError(
129
+ f"{len(self.values)} rows for {len(self.latitudes)} latitudes"
130
+ )
131
+ for row in self.values:
132
+ if len(row) != len(self.longitudes):
133
+ raise ValueError(
134
+ f"a row has {len(row)} values for {len(self.longitudes)} longitudes"
135
+ )
136
+ if not all(math.isfinite(v) for v in row):
137
+ raise ValueError("values must be finite numbers")
138
+
139
+ @property
140
+ def shape(self) -> tuple[int, int]:
141
+ """Number of latitudes and of longitudes."""
142
+ return len(self.latitudes), len(self.longitudes)
143
+
144
+ @property
145
+ def is_contiguous(self) -> bool:
146
+ """Whether both axes have no gaps between their first and last value."""
147
+ return _contiguous(self.latitudes, self.latitude_step) and _contiguous(
148
+ self.longitudes, self.longitude_step
149
+ )
150
+
151
+ def value_at(self, latitude: float, longitude: float) -> float:
152
+ """Return the value of the cell centred at ``latitude``, ``longitude``.
153
+
154
+ The longitude may be given in either convention (``-1`` and ``359``
155
+ are the same cell).
156
+
157
+ Raises:
158
+ KeyError: If no cell of the grid is centred there.
159
+ """
160
+ row = _find(self.latitudes, latitude)
161
+ column = _find(self.longitudes, longitude)
162
+ if column is None:
163
+ column = _find(self.longitudes, longitude - 360.0)
164
+ if column is None:
165
+ column = _find(self.longitudes, longitude + 360.0)
166
+ if row is None or column is None:
167
+ raise KeyError((latitude, longitude))
168
+ return self.values[row][column]
169
+
170
+ def value_near(self, latitude: float, longitude: float) -> float | None:
171
+ """Return the value of the cell that holds the point, or ``None``.
172
+
173
+ A cell spans half a step on each side of its centre. A point on the
174
+ border between two cells takes the cell to its north or east; the
175
+ outer edges of the grid belong to its outer cells. The longitude may
176
+ be given in either convention (-180 to 180 or 0 to 360).
177
+
178
+ Returns:
179
+ The value, or ``None`` if no cell holds the point: outside the
180
+ grid, or in a gap of a reduced grid.
181
+
182
+ Raises:
183
+ ValueError: If the latitude is not within [-90, 90] or the
184
+ longitude not within [-180, 360].
185
+ """
186
+ if not (math.isfinite(latitude) and -90 <= latitude <= 90):
187
+ raise ValueError(f"latitude must lie within [-90, 90], got {latitude!r}")
188
+ if not (math.isfinite(longitude) and -180 <= longitude <= 360):
189
+ raise ValueError(
190
+ f"longitude must lie within [-180, 360], got {longitude!r}"
191
+ )
192
+ row = _holding(self.latitudes, self.latitude_step, (latitude,))
193
+ column = _holding(
194
+ self.longitudes,
195
+ self.longitude_step,
196
+ (longitude, longitude - 360.0, longitude + 360.0),
197
+ )
198
+ if row is None or column is None:
199
+ return None
200
+ return self.values[row][column]
201
+
202
+ def cells(self) -> Iterator[tuple[float, float, float]]:
203
+ """Yield ``(latitude, longitude, value)`` for every cell, row by row."""
204
+ for latitude, row in zip(self.latitudes, self.values, strict=True):
205
+ for longitude, value in zip(self.longitudes, row, strict=True):
206
+ yield latitude, longitude, value
207
+
208
+ def wrapped(self) -> "Grid":
209
+ """Return the same grid with longitudes in [-180, 180).
210
+
211
+ Columns are reordered so that longitudes stay increasing. A grid
212
+ already in that range is returned unchanged.
213
+
214
+ Raises:
215
+ ValueError: If the wrapped longitudes do not fit one lattice
216
+ (360 is not a multiple of the step).
217
+ """
218
+ if self.longitudes[0] >= -180 and self.longitudes[-1] < 180:
219
+ return self
220
+ order = sorted(
221
+ range(len(self.longitudes)),
222
+ key=lambda j: wrap_longitude(self.longitudes[j]),
223
+ )
224
+ return Grid(
225
+ latitudes=self.latitudes,
226
+ longitudes=tuple(wrap_longitude(self.longitudes[j]) for j in order),
227
+ latitude_step=self.latitude_step,
228
+ longitude_step=self.longitude_step,
229
+ values=tuple(tuple(row[j] for j in order) for row in self.values),
230
+ quantity=self.quantity,
231
+ units=self.units,
232
+ )
233
+
234
+
235
+ class GridProduct(Protocol):
236
+ """A product made of a grid and the times its content declares.
237
+
238
+ :class:`OvationAurora` and :class:`DrapFrequencies` follow it; the
239
+ exports accept anything that does.
240
+ """
241
+
242
+ @property
243
+ def grid(self) -> Grid:
244
+ """The values and their axes."""
245
+ ...
246
+
247
+ @property
248
+ def source_url(self) -> str:
249
+ """Where the content came from."""
250
+ ...
251
+
252
+ @property
253
+ def data_time(self) -> datetime:
254
+ """The time the data describes, in UTC."""
255
+ ...
256
+
257
+ @property
258
+ def times(self) -> Mapping[str, datetime]:
259
+ """Every time the content declares, by name, in UTC."""
260
+ ...
261
+
262
+
263
+ @dataclass(frozen=True, slots=True)
264
+ class OvationAurora:
265
+ """The OVATION aurora nowcast: a global 1-degree grid.
266
+
267
+ The source does not state what the values are. They are integers, 0 to
268
+ a few tens; reading them as the probability of visible aurora in percent
269
+ is an assumption (``docs/FONTI.md``), which ``grid.units`` (``"%"``)
270
+ follows.
271
+
272
+ Attributes:
273
+ grid: The values, with longitudes 0 to 359 as delivered.
274
+ observation_time: ``Observation Time`` of the source.
275
+ forecast_time: ``Forecast Time`` of the source (about an hour after
276
+ the observation).
277
+ source_url: Where the content came from.
278
+
279
+ Raises:
280
+ ValueError: If a time is not an aware datetime in UTC.
281
+ """
282
+
283
+ grid: Grid
284
+ observation_time: datetime
285
+ forecast_time: datetime
286
+ source_url: str
287
+
288
+ def __post_init__(self) -> None:
289
+ check_utc("observation_time", self.observation_time)
290
+ check_utc("forecast_time", self.forecast_time)
291
+
292
+ @property
293
+ def data_time(self) -> datetime:
294
+ """The observation time."""
295
+ return self.observation_time
296
+
297
+ @property
298
+ def times(self) -> dict[str, datetime]:
299
+ """``observation_time`` and ``forecast_time``."""
300
+ return {
301
+ "observation_time": self.observation_time,
302
+ "forecast_time": self.forecast_time,
303
+ }
304
+
305
+
306
+ @dataclass(frozen=True, slots=True)
307
+ class DrapFrequencies:
308
+ """D-RAP: D-region absorption, as a frequency on a global grid.
309
+
310
+ The source only says "Frequency (MHz) as a function of Latitude and
311
+ Longitude". Reading it as the highest frequency affected by
312
+ D-region absorption, as ``grid.quantity`` does, is an assumption
313
+ (``docs/FONTI.md``).
314
+
315
+ The text fields are copied from the comment lines of the source, as
316
+ data; an empty field is ``None``.
317
+
318
+ Attributes:
319
+ grid: Frequencies in MHz, on a 2 by 4 degree grid.
320
+ valid_at: ``Product Valid At`` of the source.
321
+ estimated_recovery_time: ``Estimated Recovery Time``, as written
322
+ (for example ``"No Estimate"``).
323
+ xray_message: ``X-RAY Message``.
324
+ xray_warning: ``X-RAY Warning``.
325
+ proton_message: ``Proton Message``.
326
+ proton_warning: ``Proton Warning``.
327
+ source_url: Where the content came from.
328
+
329
+ Raises:
330
+ ValueError: If ``valid_at`` is not an aware datetime in UTC.
331
+ """
332
+
333
+ grid: Grid
334
+ valid_at: datetime
335
+ estimated_recovery_time: str | None
336
+ xray_message: str | None
337
+ xray_warning: str | None
338
+ proton_message: str | None
339
+ proton_warning: str | None
340
+ source_url: str
341
+
342
+ def __post_init__(self) -> None:
343
+ check_utc("valid_at", self.valid_at)
344
+
345
+ @property
346
+ def data_time(self) -> datetime:
347
+ """The time the product is valid at."""
348
+ return self.valid_at
349
+
350
+ @property
351
+ def times(self) -> dict[str, datetime]:
352
+ """``valid_at``."""
353
+ return {"valid_at": self.valid_at}
@@ -0,0 +1,356 @@
1
+ """Models for the geomagnetic and solar indices.
2
+
3
+ Planetary K index (3-hour values, forecast and 1-minute estimates), Kyoto Dst,
4
+ the 10.7 cm solar radio flux (F10.7) and the NOAA space weather scales.
5
+
6
+ Times are UTC. SWPC writes most of these ``time_tag`` values without a time
7
+ zone; they are read as UTC.
8
+ """
9
+
10
+ from dataclasses import dataclass
11
+ from datetime import datetime
12
+ from enum import StrEnum
13
+
14
+ from swpc_kit.models._checks import check_utc, check_values
15
+ from swpc_kit.models.records import SkippedRecord
16
+
17
+ # --- Planetary K index, 3-hour values ---------------------------------------
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class KpValue:
22
+ """One 3-hour planetary K index value.
23
+
24
+ Attributes:
25
+ time: Start of the 3-hour interval.
26
+ kp: Kp in thirds, as a decimal (2.67 is 3-), from 0 to 9.
27
+ a_running: Running a index (``a_running`` in the source).
28
+ station_count: Number of magnetometer stations used.
29
+ """
30
+
31
+ time: datetime
32
+ kp: float
33
+ a_running: int
34
+ station_count: int
35
+
36
+ def __post_init__(self) -> None:
37
+ check_utc("time", self.time)
38
+
39
+
40
+ @dataclass(frozen=True, slots=True)
41
+ class PlanetaryKIndex:
42
+ """The 3-hour planetary K index of the last days.
43
+
44
+ Attributes:
45
+ values: The values, oldest first, one per time.
46
+ source_url: Where the content came from.
47
+ skipped: The records that could not be read, in source order.
48
+ """
49
+
50
+ values: tuple[KpValue, ...]
51
+ source_url: str
52
+ skipped: tuple[SkippedRecord, ...] = ()
53
+
54
+ def __post_init__(self) -> None:
55
+ check_values(self.values)
56
+
57
+ @property
58
+ def data_time(self) -> datetime:
59
+ """Start of the most recent 3-hour interval."""
60
+ return max(v.time for v in self.values)
61
+
62
+
63
+ # --- Planetary K index forecast ---------------------------------------------
64
+
65
+
66
+ class KpForecastStatus(StrEnum):
67
+ """Whether a forecast value is measured, estimated or predicted.
68
+
69
+ Values match the ``observed`` field of the source.
70
+ """
71
+
72
+ OBSERVED = "observed"
73
+ ESTIMATED = "estimated"
74
+ PREDICTED = "predicted"
75
+
76
+
77
+ @dataclass(frozen=True, slots=True)
78
+ class KpForecastValue:
79
+ """One 3-hour value of the Kp forecast.
80
+
81
+ Attributes:
82
+ time: Start of the 3-hour interval.
83
+ kp: Kp as a decimal, from 0 to 9.
84
+ status: Whether the value is observed, estimated or predicted.
85
+ noaa_scale: NOAA geomagnetic storm level reached, such as ``"G1"``,
86
+ or ``None``.
87
+ """
88
+
89
+ time: datetime
90
+ kp: float
91
+ status: KpForecastStatus
92
+ noaa_scale: str | None
93
+
94
+ def __post_init__(self) -> None:
95
+ check_utc("time", self.time)
96
+
97
+
98
+ @dataclass(frozen=True, slots=True)
99
+ class PlanetaryKIndexForecast:
100
+ """Kp of the past week followed by the forecast for the next days.
101
+
102
+ Attributes:
103
+ values: The values, oldest first, one per time.
104
+ source_url: Where the content came from.
105
+ skipped: The records that could not be read, in source order.
106
+ """
107
+
108
+ values: tuple[KpForecastValue, ...]
109
+ source_url: str
110
+ skipped: tuple[SkippedRecord, ...] = ()
111
+
112
+ def __post_init__(self) -> None:
113
+ check_values(self.values)
114
+
115
+ @property
116
+ def data_time(self) -> datetime:
117
+ """Start of the most recent observed interval.
118
+
119
+ Estimated values can lie in the future, so they do not count. When
120
+ there is no observed value, this is the earliest time in the content.
121
+ """
122
+ observed = [
123
+ v.time for v in self.values if v.status is KpForecastStatus.OBSERVED
124
+ ]
125
+ if observed:
126
+ return max(observed)
127
+ return min(v.time for v in self.values)
128
+
129
+
130
+ # --- Planetary K index, 1-minute estimates ----------------------------------
131
+
132
+
133
+ @dataclass(frozen=True, slots=True)
134
+ class Kp1mValue:
135
+ """One 1-minute estimate of the planetary K index.
136
+
137
+ Attributes:
138
+ time: Time of the estimate.
139
+ kp_index: Kp rounded to an integer, from 0 to 9.
140
+ estimated_kp: Kp in thirds, as a decimal.
141
+ kp: Kp as a label: digit plus ``Z`` (exact), ``P`` (plus a third) or
142
+ ``M`` (minus a third), such as ``"3M"`` for 2.67.
143
+ """
144
+
145
+ time: datetime
146
+ kp_index: int
147
+ estimated_kp: float
148
+ kp: str
149
+
150
+ def __post_init__(self) -> None:
151
+ check_utc("time", self.time)
152
+
153
+
154
+ @dataclass(frozen=True, slots=True)
155
+ class PlanetaryKIndex1m:
156
+ """The 1-minute planetary K index estimates of the last hours.
157
+
158
+ Attributes:
159
+ values: The values, oldest first, one per time.
160
+ source_url: Where the content came from.
161
+ skipped: The records that could not be read, in source order.
162
+ """
163
+
164
+ values: tuple[Kp1mValue, ...]
165
+ source_url: str
166
+ skipped: tuple[SkippedRecord, ...] = ()
167
+
168
+ def __post_init__(self) -> None:
169
+ check_values(self.values)
170
+
171
+ @property
172
+ def data_time(self) -> datetime:
173
+ """Time of the most recent estimate."""
174
+ return max(v.time for v in self.values)
175
+
176
+
177
+ # --- Kyoto Dst ---------------------------------------------------------------
178
+
179
+
180
+ @dataclass(frozen=True, slots=True)
181
+ class DstValue:
182
+ """One hourly value of the Kyoto Dst index.
183
+
184
+ Attributes:
185
+ time: Time of the value.
186
+ dst: Dst in nT.
187
+ """
188
+
189
+ time: datetime
190
+ dst: int
191
+
192
+ def __post_init__(self) -> None:
193
+ check_utc("time", self.time)
194
+
195
+
196
+ @dataclass(frozen=True, slots=True)
197
+ class KyotoDst:
198
+ """Hourly Kyoto Dst of the last week.
199
+
200
+ Attributes:
201
+ values: The values, oldest first, one per time.
202
+ source_url: Where the content came from.
203
+ skipped: The records that could not be read, in source order.
204
+ """
205
+
206
+ values: tuple[DstValue, ...]
207
+ source_url: str
208
+ skipped: tuple[SkippedRecord, ...] = ()
209
+
210
+ def __post_init__(self) -> None:
211
+ check_values(self.values)
212
+
213
+ @property
214
+ def data_time(self) -> datetime:
215
+ """Time of the most recent value."""
216
+ return max(v.time for v in self.values)
217
+
218
+
219
+ # --- F10.7 -------------------------------------------------------------------
220
+
221
+
222
+ @dataclass(frozen=True, slots=True)
223
+ class F107Value:
224
+ """One daily value of the 10.7 cm solar radio flux.
225
+
226
+ Attributes:
227
+ time: Time of the measurement.
228
+ flux: Flux in solar flux units (10⁻²² W m⁻² Hz⁻¹).
229
+ """
230
+
231
+ time: datetime
232
+ flux: float
233
+
234
+ def __post_init__(self) -> None:
235
+ check_utc("time", self.time)
236
+
237
+
238
+ @dataclass(frozen=True, slots=True)
239
+ class F107Flux:
240
+ """Daily 10.7 cm solar radio flux of the last 30 days.
241
+
242
+ Attributes:
243
+ values: The values, oldest first, one per time.
244
+ source_url: Where the content came from.
245
+ skipped: The records that could not be read, in source order.
246
+ """
247
+
248
+ values: tuple[F107Value, ...]
249
+ source_url: str
250
+ skipped: tuple[SkippedRecord, ...] = ()
251
+
252
+ def __post_init__(self) -> None:
253
+ check_values(self.values)
254
+
255
+ @property
256
+ def data_time(self) -> datetime:
257
+ """Time of the most recent value."""
258
+ return max(v.time for v in self.values)
259
+
260
+
261
+ # --- NOAA scales -------------------------------------------------------------
262
+
263
+
264
+ @dataclass(frozen=True, slots=True)
265
+ class RadioBlackoutScale:
266
+ """Radio blackout (R) entry of a day of ``noaa-scales.json``.
267
+
268
+ Attributes:
269
+ scale: Level reached, 0 to 5, or ``None`` (forecast days).
270
+ text: Description of the level, such as ``"none"``, or ``None``.
271
+ minor_prob: Probability of R1-R2 in percent, or ``None``.
272
+ major_prob: Probability of R3 or greater in percent, or ``None``.
273
+ """
274
+
275
+ scale: int | None
276
+ text: str | None
277
+ minor_prob: int | None
278
+ major_prob: int | None
279
+
280
+
281
+ @dataclass(frozen=True, slots=True)
282
+ class SolarRadiationScale:
283
+ """Solar radiation storm (S) entry of a day of ``noaa-scales.json``.
284
+
285
+ Attributes:
286
+ scale: Level reached, 0 to 5, or ``None`` (forecast days).
287
+ text: Description of the level, or ``None``.
288
+ prob: Probability of S1 or greater in percent, or ``None``.
289
+ """
290
+
291
+ scale: int | None
292
+ text: str | None
293
+ prob: int | None
294
+
295
+
296
+ @dataclass(frozen=True, slots=True)
297
+ class GeomagneticStormScale:
298
+ """Geomagnetic storm (G) entry of a day of ``noaa-scales.json``.
299
+
300
+ Attributes:
301
+ scale: Level reached or expected, 0 to 5, or ``None``.
302
+ text: Description of the level, or ``None``.
303
+ """
304
+
305
+ scale: int | None
306
+ text: str | None
307
+
308
+
309
+ @dataclass(frozen=True, slots=True)
310
+ class NoaaScalesDay:
311
+ """One entry of ``noaa-scales.json``.
312
+
313
+ Attributes:
314
+ day: The key of the entry. From the dates it carries, -1 looks like
315
+ the past 24 hours, 0 the current levels and 1 to 3 the forecast
316
+ for today and the next two days; SWPC does not document it.
317
+ time: ``DateStamp`` and ``TimeStamp`` of the entry.
318
+ r: Radio blackouts.
319
+ s: Solar radiation storms.
320
+ g: Geomagnetic storms.
321
+ """
322
+
323
+ day: int
324
+ time: datetime
325
+ r: RadioBlackoutScale
326
+ s: SolarRadiationScale
327
+ g: GeomagneticStormScale
328
+
329
+ def __post_init__(self) -> None:
330
+ check_utc("time", self.time)
331
+
332
+
333
+ @dataclass(frozen=True, slots=True)
334
+ class NoaaScales:
335
+ """The NOAA space weather scales: recent, current and forecast levels.
336
+
337
+ Attributes:
338
+ days: The entries, sorted by ``day``.
339
+ source_url: Where the content came from.
340
+ """
341
+
342
+ days: tuple[NoaaScalesDay, ...]
343
+ source_url: str
344
+
345
+ def __post_init__(self) -> None:
346
+ if not any(d.day <= 0 for d in self.days):
347
+ raise ValueError("days must include an observed entry (day <= 0)")
348
+
349
+ def day(self, day: int) -> NoaaScalesDay | None:
350
+ """Return the entry with key ``day``, or ``None``."""
351
+ return next((d for d in self.days if d.day == day), None)
352
+
353
+ @property
354
+ def data_time(self) -> datetime:
355
+ """Most recent time among the observed entries (``day`` <= 0)."""
356
+ return max(d.time for d in self.days if d.day <= 0)