qpkit 0.1.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.
- qpkit/__init__.py +54 -0
- qpkit/__main__.py +4 -0
- qpkit/_version.py +24 -0
- qpkit/api.py +310 -0
- qpkit/cli.py +218 -0
- qpkit/downloader.py +89 -0
- qpkit/exceptions.py +13 -0
- qpkit/http.py +58 -0
- qpkit/logging_utils.py +42 -0
- qpkit/models.py +417 -0
- qpkit/services/__init__.py +14 -0
- qpkit/services/dss/__init__.py +6 -0
- qpkit/services/dss/_helpers.py +26 -0
- qpkit/services/dss/base.py +28 -0
- qpkit/services/dss/hrrr.py +543 -0
- qpkit/services/dss/qpe.py +263 -0
- qpkit/services/dss/qpf.py +257 -0
- qpkit/services/hrrr.py +113 -0
- qpkit/services/qpe.py +364 -0
- qpkit/services/qpf.py +44 -0
- qpkit/utils.py +80 -0
- qpkit-0.1.0.dist-info/METADATA +379 -0
- qpkit-0.1.0.dist-info/RECORD +29 -0
- qpkit-0.1.0.dist-info/WHEEL +5 -0
- qpkit-0.1.0.dist-info/entry_points.txt +2 -0
- qpkit-0.1.0.dist-info/licenses/LICENSE +185 -0
- qpkit-0.1.0.dist-info/licenses/NOTICE +6 -0
- qpkit-0.1.0.dist-info/scm_version.json +8 -0
- qpkit-0.1.0.dist-info/top_level.txt +1 -0
qpkit/__init__.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .api import QPKit, Request
|
|
4
|
+
from .exceptions import DownloadError, PlanBuildError, QPKitError
|
|
5
|
+
from .http import HttpClient, HttpClientProtocol
|
|
6
|
+
from .models import (
|
|
7
|
+
BoundingBox,
|
|
8
|
+
DownloadDSSResult,
|
|
9
|
+
DownloadItem,
|
|
10
|
+
DownloadPlan,
|
|
11
|
+
DownloadResult,
|
|
12
|
+
DSSWriteResult,
|
|
13
|
+
GridOptions,
|
|
14
|
+
HRRRGridOptions,
|
|
15
|
+
HRRRRequest,
|
|
16
|
+
QPEGridOptions,
|
|
17
|
+
QPERequest,
|
|
18
|
+
QPFGridOptions,
|
|
19
|
+
QPFRequest,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
from ._version import version as __version__
|
|
24
|
+
except ImportError:
|
|
25
|
+
try:
|
|
26
|
+
from importlib.metadata import version
|
|
27
|
+
|
|
28
|
+
__version__ = version("qpkit")
|
|
29
|
+
except Exception:
|
|
30
|
+
__version__ = "0.0.0"
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"BoundingBox",
|
|
34
|
+
"DSSWriteResult",
|
|
35
|
+
"DownloadDSSResult",
|
|
36
|
+
"DownloadError",
|
|
37
|
+
"DownloadItem",
|
|
38
|
+
"DownloadPlan",
|
|
39
|
+
"DownloadResult",
|
|
40
|
+
"GridOptions",
|
|
41
|
+
"HRRRGridOptions",
|
|
42
|
+
"HRRRRequest",
|
|
43
|
+
"HttpClient",
|
|
44
|
+
"QPEGridOptions",
|
|
45
|
+
"HttpClientProtocol",
|
|
46
|
+
"PlanBuildError",
|
|
47
|
+
"QPERequest",
|
|
48
|
+
"QPFGridOptions",
|
|
49
|
+
"QPFRequest",
|
|
50
|
+
"QPKit",
|
|
51
|
+
"QPKitError",
|
|
52
|
+
"Request",
|
|
53
|
+
"__version__",
|
|
54
|
+
]
|
qpkit/__main__.py
ADDED
qpkit/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
qpkit/api.py
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import warnings
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
|
|
10
|
+
from .downloader import Downloader
|
|
11
|
+
from .http import HttpClient, HttpClientProtocol
|
|
12
|
+
from .models import (
|
|
13
|
+
DownloadDSSResult,
|
|
14
|
+
DownloadPlan,
|
|
15
|
+
DownloadResult,
|
|
16
|
+
GridOptions,
|
|
17
|
+
HRRRGridOptions,
|
|
18
|
+
HRRRRequest,
|
|
19
|
+
QPEGridOptions,
|
|
20
|
+
QPERequest,
|
|
21
|
+
QPFGridOptions,
|
|
22
|
+
QPFRequest,
|
|
23
|
+
)
|
|
24
|
+
from .services import (
|
|
25
|
+
HRRRDSSWriter,
|
|
26
|
+
HRRRService,
|
|
27
|
+
QPEDSSWriter,
|
|
28
|
+
QPEService,
|
|
29
|
+
QPFDSSWriter,
|
|
30
|
+
QPFService,
|
|
31
|
+
)
|
|
32
|
+
from .utils import list_local_files
|
|
33
|
+
|
|
34
|
+
Request = QPERequest | QPFRequest | HRRRRequest
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class QPKit:
|
|
38
|
+
"""High-level API for building plans and downloading precipitation products."""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
*,
|
|
43
|
+
http_client: HttpClientProtocol | None = None,
|
|
44
|
+
logger: logging.Logger | None = None,
|
|
45
|
+
max_workers: int = 4,
|
|
46
|
+
) -> None:
|
|
47
|
+
self._owns_http = http_client is None
|
|
48
|
+
self.http: HttpClientProtocol = http_client or HttpClient()
|
|
49
|
+
self.logger = logger or logging.getLogger("qpkit")
|
|
50
|
+
self.qpe = QPEService(self.http)
|
|
51
|
+
self.qpf = QPFService(self.http)
|
|
52
|
+
self.hrrr = HRRRService(self.http)
|
|
53
|
+
self.downloader = Downloader(self.http, self.logger, max_workers=max_workers)
|
|
54
|
+
self.hrrr_dss = HRRRDSSWriter()
|
|
55
|
+
self.qpe_dss = QPEDSSWriter()
|
|
56
|
+
self.qpf_dss = QPFDSSWriter()
|
|
57
|
+
|
|
58
|
+
def close(self) -> None:
|
|
59
|
+
if self._owns_http:
|
|
60
|
+
self.http.close()
|
|
61
|
+
|
|
62
|
+
def __enter__(self) -> QPKit:
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
def __exit__(
|
|
66
|
+
self,
|
|
67
|
+
exc_type: type[BaseException] | None,
|
|
68
|
+
exc: BaseException | None,
|
|
69
|
+
tb: TracebackType | None,
|
|
70
|
+
) -> None:
|
|
71
|
+
self.close()
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def _resolve(output_dir: str | os.PathLike[str]) -> Path:
|
|
75
|
+
return Path(os.fspath(output_dir))
|
|
76
|
+
|
|
77
|
+
def build_plan_qpe(
|
|
78
|
+
self,
|
|
79
|
+
request: QPERequest,
|
|
80
|
+
output_dir: str | os.PathLike[str],
|
|
81
|
+
*,
|
|
82
|
+
force: bool = False,
|
|
83
|
+
) -> DownloadPlan:
|
|
84
|
+
path = self._resolve(output_dir)
|
|
85
|
+
return self.qpe.build_plan(request, path, list_local_files(path), force=force)
|
|
86
|
+
|
|
87
|
+
def build_plan_qpf(
|
|
88
|
+
self,
|
|
89
|
+
request: QPFRequest,
|
|
90
|
+
output_dir: str | os.PathLike[str],
|
|
91
|
+
*,
|
|
92
|
+
force: bool = False,
|
|
93
|
+
) -> DownloadPlan:
|
|
94
|
+
path = self._resolve(output_dir)
|
|
95
|
+
return self.qpf.build_plan(request, path, list_local_files(path), force=force)
|
|
96
|
+
|
|
97
|
+
def build_plan_hrrr(
|
|
98
|
+
self,
|
|
99
|
+
request: HRRRRequest,
|
|
100
|
+
output_dir: str | os.PathLike[str],
|
|
101
|
+
*,
|
|
102
|
+
force: bool = False,
|
|
103
|
+
) -> DownloadPlan:
|
|
104
|
+
path = self._resolve(output_dir)
|
|
105
|
+
return self.hrrr.build_plan(request, path, list_local_files(path), force=force)
|
|
106
|
+
|
|
107
|
+
def download_qpe(
|
|
108
|
+
self,
|
|
109
|
+
request: QPERequest,
|
|
110
|
+
output_dir: str | os.PathLike[str],
|
|
111
|
+
*,
|
|
112
|
+
force: bool = False,
|
|
113
|
+
) -> DownloadResult:
|
|
114
|
+
plan = self.build_plan_qpe(request, output_dir, force=force)
|
|
115
|
+
return self.downloader.download(plan)
|
|
116
|
+
|
|
117
|
+
def download_qpf(
|
|
118
|
+
self,
|
|
119
|
+
request: QPFRequest,
|
|
120
|
+
output_dir: str | os.PathLike[str],
|
|
121
|
+
*,
|
|
122
|
+
force: bool = False,
|
|
123
|
+
) -> DownloadResult:
|
|
124
|
+
plan = self.build_plan_qpf(request, output_dir, force=force)
|
|
125
|
+
return self.downloader.download(plan)
|
|
126
|
+
|
|
127
|
+
def download_hrrr(
|
|
128
|
+
self,
|
|
129
|
+
request: HRRRRequest,
|
|
130
|
+
output_dir: str | os.PathLike[str],
|
|
131
|
+
*,
|
|
132
|
+
force: bool = False,
|
|
133
|
+
cycle_hour_latest: bool | None = None,
|
|
134
|
+
) -> DownloadResult:
|
|
135
|
+
if cycle_hour_latest:
|
|
136
|
+
today = datetime.now(timezone.utc).date()
|
|
137
|
+
if request.cycle_date is not None and request.cycle_date != today:
|
|
138
|
+
warnings.warn(
|
|
139
|
+
"cycle_hour_latest=True requires cycle_date to be unset or today's "
|
|
140
|
+
f"UTC date; got {request.cycle_date}. Using the latest available "
|
|
141
|
+
"cycle instead.",
|
|
142
|
+
stacklevel=2,
|
|
143
|
+
)
|
|
144
|
+
latest_date, latest_hour = self.hrrr.find_latest_cycle()
|
|
145
|
+
request = request.model_copy(
|
|
146
|
+
update={"cycle_date": latest_date, "cycle_hour": latest_hour}
|
|
147
|
+
)
|
|
148
|
+
plan = self.build_plan_hrrr(request, output_dir, force=force)
|
|
149
|
+
return self.downloader.download(plan)
|
|
150
|
+
|
|
151
|
+
def build_plan(
|
|
152
|
+
self,
|
|
153
|
+
request: Request,
|
|
154
|
+
output_dir: str | os.PathLike[str],
|
|
155
|
+
*,
|
|
156
|
+
force: bool = False,
|
|
157
|
+
) -> DownloadPlan:
|
|
158
|
+
match request:
|
|
159
|
+
case QPERequest():
|
|
160
|
+
return self.build_plan_qpe(request, output_dir, force=force)
|
|
161
|
+
case QPFRequest():
|
|
162
|
+
return self.build_plan_qpf(request, output_dir, force=force)
|
|
163
|
+
case HRRRRequest():
|
|
164
|
+
return self.build_plan_hrrr(request, output_dir, force=force)
|
|
165
|
+
|
|
166
|
+
def download(
|
|
167
|
+
self,
|
|
168
|
+
request: Request,
|
|
169
|
+
output_dir: str | os.PathLike[str],
|
|
170
|
+
*,
|
|
171
|
+
force: bool = False,
|
|
172
|
+
) -> DownloadResult:
|
|
173
|
+
plan = self.build_plan(request, output_dir, force=force)
|
|
174
|
+
return self.downloader.download(plan)
|
|
175
|
+
|
|
176
|
+
def download_to_dss(
|
|
177
|
+
self,
|
|
178
|
+
request: Request,
|
|
179
|
+
output_dir: str | os.PathLike[str],
|
|
180
|
+
dss_file: str | os.PathLike[str],
|
|
181
|
+
*,
|
|
182
|
+
grid_options: GridOptions | None = None,
|
|
183
|
+
force: bool = False,
|
|
184
|
+
cycle_hour_latest: bool | None = None,
|
|
185
|
+
dry_run: bool = False,
|
|
186
|
+
dss_version: int | None = None,
|
|
187
|
+
interval: int | None = None,
|
|
188
|
+
) -> DownloadDSSResult:
|
|
189
|
+
"""Download a precipitation product and write the resulting grids to DSS.
|
|
190
|
+
|
|
191
|
+
Single end-to-end entry point for every supported product. The request
|
|
192
|
+
type drives the download dispatch; the ``grid_options`` type drives the
|
|
193
|
+
DSS-writer dispatch. When ``grid_options`` is None, sensible defaults
|
|
194
|
+
are chosen based on the product.
|
|
195
|
+
|
|
196
|
+
``interval`` (HRRR APCP only) — compute per-interval accumulations
|
|
197
|
+
instead of storing the raw running total. Provide a single forecast hour
|
|
198
|
+
in ``request.forecast_hours`` (the end hour); the required intermediate
|
|
199
|
+
files are derived and downloaded automatically. Not applicable to QPE
|
|
200
|
+
(its accumulation window is set via ``QPERequest.interval``).
|
|
201
|
+
|
|
202
|
+
Two-stage workflows (writing GRIBs that came from elsewhere, or
|
|
203
|
+
re-writing the same set with different conventions) go through the
|
|
204
|
+
public writer-service attribute, e.g. ``kit.hrrr_dss.write(...)``.
|
|
205
|
+
"""
|
|
206
|
+
if dss_version not in (None, 6, 7):
|
|
207
|
+
raise ValueError(f"dss_version must be 6, 7, or None; got {dss_version!r}")
|
|
208
|
+
|
|
209
|
+
# Argument coherence — surface the most specific user mistake first.
|
|
210
|
+
if cycle_hour_latest and not isinstance(request, HRRRRequest):
|
|
211
|
+
raise ValueError(
|
|
212
|
+
"cycle_hour_latest is HRRR-only; "
|
|
213
|
+
f"got request of type {type(request).__name__}"
|
|
214
|
+
)
|
|
215
|
+
if interval is not None and isinstance(request, QPERequest):
|
|
216
|
+
raise ValueError(
|
|
217
|
+
"interval is not applicable to QPE; the accumulation window is "
|
|
218
|
+
"set via QPERequest.interval"
|
|
219
|
+
)
|
|
220
|
+
if grid_options is not None and not _grid_options_matches(request, grid_options):
|
|
221
|
+
raise ValueError(
|
|
222
|
+
f"grid_options type {type(grid_options).__name__} "
|
|
223
|
+
f"does not match request type {type(request).__name__}"
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
dss_path = Path(os.fspath(dss_file))
|
|
227
|
+
|
|
228
|
+
match request:
|
|
229
|
+
case HRRRRequest():
|
|
230
|
+
if interval is not None:
|
|
231
|
+
if request.precip_type != "APCP":
|
|
232
|
+
raise ValueError(
|
|
233
|
+
f"interval is only valid for APCP; "
|
|
234
|
+
f"got precip_type={request.precip_type!r}"
|
|
235
|
+
)
|
|
236
|
+
if len(request.forecast_hours) != 1:
|
|
237
|
+
raise ValueError(
|
|
238
|
+
"interval mode requires exactly one forecast hour "
|
|
239
|
+
f"(the end hour); got {len(request.forecast_hours)}"
|
|
240
|
+
)
|
|
241
|
+
end_hour = request.forecast_hours[0]
|
|
242
|
+
if end_hour % interval != 0:
|
|
243
|
+
warnings.warn(
|
|
244
|
+
f"end_hour={end_hour} is not divisible by "
|
|
245
|
+
f"interval={interval}; the last incomplete interval "
|
|
246
|
+
"will be omitted.",
|
|
247
|
+
stacklevel=2,
|
|
248
|
+
)
|
|
249
|
+
download_hours = tuple(range(0, end_hour + 1, interval))
|
|
250
|
+
request = request.model_copy(
|
|
251
|
+
update={"forecast_hours": download_hours}
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
if grid_options is None:
|
|
255
|
+
if request.precip_type == "APCP":
|
|
256
|
+
grid_options = HRRRGridOptions.for_apcp()
|
|
257
|
+
else:
|
|
258
|
+
grid_options = HRRRGridOptions.for_prate(as_depth=False)
|
|
259
|
+
|
|
260
|
+
download = self.download_hrrr(
|
|
261
|
+
request, output_dir, force=force,
|
|
262
|
+
cycle_hour_latest=cycle_hour_latest,
|
|
263
|
+
)
|
|
264
|
+
dss = self.hrrr_dss.write(
|
|
265
|
+
download.all_files,
|
|
266
|
+
dss_path,
|
|
267
|
+
grid_options=grid_options,
|
|
268
|
+
dry_run=dry_run,
|
|
269
|
+
dss_version=dss_version,
|
|
270
|
+
interval=interval,
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
case QPERequest():
|
|
274
|
+
if grid_options is None:
|
|
275
|
+
grid_options = QPEGridOptions()
|
|
276
|
+
|
|
277
|
+
download = self.download_qpe(request, output_dir, force=force)
|
|
278
|
+
dss = self.qpe_dss.write(
|
|
279
|
+
download.all_files,
|
|
280
|
+
dss_path,
|
|
281
|
+
grid_options=grid_options,
|
|
282
|
+
dry_run=dry_run,
|
|
283
|
+
dss_version=dss_version,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
case QPFRequest():
|
|
287
|
+
if grid_options is None:
|
|
288
|
+
grid_options = QPFGridOptions()
|
|
289
|
+
|
|
290
|
+
download = self.download_qpf(request, output_dir, force=force)
|
|
291
|
+
dss = self.qpf_dss.write(
|
|
292
|
+
download.all_files,
|
|
293
|
+
dss_path,
|
|
294
|
+
grid_options=grid_options,
|
|
295
|
+
dry_run=dry_run,
|
|
296
|
+
dss_version=dss_version,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
return DownloadDSSResult(download=download, dss=dss)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _grid_options_matches(request: Request, grid_options: GridOptions) -> bool:
|
|
303
|
+
"""Return True if the grid_options type is consistent with the request type."""
|
|
304
|
+
match request:
|
|
305
|
+
case HRRRRequest():
|
|
306
|
+
return isinstance(grid_options, HRRRGridOptions)
|
|
307
|
+
case QPERequest():
|
|
308
|
+
return isinstance(grid_options, QPEGridOptions)
|
|
309
|
+
case QPFRequest():
|
|
310
|
+
return isinstance(grid_options, QPFGridOptions)
|
qpkit/cli.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import datetime as dt
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pydantic import ValidationError
|
|
13
|
+
|
|
14
|
+
from .api import QPKit, Request
|
|
15
|
+
from .exceptions import QPKitError
|
|
16
|
+
from .logging_utils import build_logger
|
|
17
|
+
from .models import (
|
|
18
|
+
BoundingBox,
|
|
19
|
+
DownloadResult,
|
|
20
|
+
HRRRRequest,
|
|
21
|
+
QPERequest,
|
|
22
|
+
QPFRequest,
|
|
23
|
+
)
|
|
24
|
+
from .utils import parse_forecast_hours
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _qpkit_version() -> str:
|
|
28
|
+
try:
|
|
29
|
+
return version("qpkit")
|
|
30
|
+
except PackageNotFoundError:
|
|
31
|
+
return "0.0.0+unknown"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PathExpandAction(argparse.Action):
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
option_strings: Sequence[str],
|
|
38
|
+
dest: str,
|
|
39
|
+
nargs: int | str | None = None,
|
|
40
|
+
**kwargs: Any,
|
|
41
|
+
) -> None:
|
|
42
|
+
if nargs is not None:
|
|
43
|
+
raise ValueError('"nargs" is not supported for PathExpandAction')
|
|
44
|
+
super().__init__(option_strings, dest, **kwargs)
|
|
45
|
+
|
|
46
|
+
def __call__(
|
|
47
|
+
self,
|
|
48
|
+
parser: argparse.ArgumentParser,
|
|
49
|
+
namespace: argparse.Namespace,
|
|
50
|
+
values: str | Sequence[Any] | None,
|
|
51
|
+
option_string: str | None = None,
|
|
52
|
+
) -> None:
|
|
53
|
+
if values == "-":
|
|
54
|
+
setattr(namespace, self.dest, None)
|
|
55
|
+
return
|
|
56
|
+
if not isinstance(values, str):
|
|
57
|
+
raise ValueError("PathExpandAction expects a string value")
|
|
58
|
+
expanded = os.path.expandvars(values)
|
|
59
|
+
setattr(namespace, self.dest, str(Path(expanded).expanduser()))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _build_request(args: argparse.Namespace) -> Request:
|
|
63
|
+
match args.subcommand:
|
|
64
|
+
case "qpe":
|
|
65
|
+
return QPERequest(product=args.product, interval=args.interval)
|
|
66
|
+
case "qpf":
|
|
67
|
+
return QPFRequest(interval=args.interval, cycle_hour=args.cycle)
|
|
68
|
+
case "hrrr":
|
|
69
|
+
return HRRRRequest(
|
|
70
|
+
cycle_hour=args.cycle,
|
|
71
|
+
forecast_hours=tuple(args.fct_hour),
|
|
72
|
+
bbox=BoundingBox(
|
|
73
|
+
left_lon=args.llon,
|
|
74
|
+
right_lon=args.rlon,
|
|
75
|
+
top_lat=args.tlat,
|
|
76
|
+
bottom_lat=args.blat,
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
case _:
|
|
80
|
+
raise ValueError(f"unknown subcommand: {args.subcommand}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _summary(
|
|
84
|
+
result: DownloadResult,
|
|
85
|
+
*,
|
|
86
|
+
dry_run: bool,
|
|
87
|
+
subcommand: str,
|
|
88
|
+
output_dir: Path,
|
|
89
|
+
) -> dict[str, Any]:
|
|
90
|
+
return {
|
|
91
|
+
"subcommand": subcommand,
|
|
92
|
+
"dry_run": dry_run,
|
|
93
|
+
"planned": len(result.plan.items),
|
|
94
|
+
"downloaded": len(result.succeeded),
|
|
95
|
+
"skipped": len(result.skipped),
|
|
96
|
+
"failed": len(result.failed),
|
|
97
|
+
"output_dir": str(output_dir),
|
|
98
|
+
"files": [item.filename for item in result.plan.items],
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
103
|
+
parser = argparse.ArgumentParser(description="qpkit: NOAA precipitation GRIB2 downloader")
|
|
104
|
+
parser.add_argument("--version", action="version", version=f"qpkit {_qpkit_version()}")
|
|
105
|
+
|
|
106
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
107
|
+
common.add_argument(
|
|
108
|
+
"-o", "--output-dir", required=True, action=PathExpandAction, dest="output_dir"
|
|
109
|
+
)
|
|
110
|
+
common.add_argument("-w", "--working-dir", action=PathExpandAction, dest="working_dir")
|
|
111
|
+
common.add_argument("--force", action="store_true")
|
|
112
|
+
common.add_argument("--dry-run", action="store_true", dest="dry_run")
|
|
113
|
+
common.add_argument("--json", action="store_true", dest="json_output")
|
|
114
|
+
common.add_argument("-l", "--log-file", action=PathExpandAction, dest="log_file")
|
|
115
|
+
common.add_argument(
|
|
116
|
+
"-n", "--log-level", type=int, choices=range(6), default=2, dest="log_level"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
sub = parser.add_subparsers(dest="subcommand")
|
|
120
|
+
|
|
121
|
+
qpe = sub.add_parser("qpe", parents=[common])
|
|
122
|
+
qpe.add_argument(
|
|
123
|
+
"-p",
|
|
124
|
+
"--product",
|
|
125
|
+
choices=["MultiSensor_Pass1", "MultiSensor_Pass2", "RadarOnly"],
|
|
126
|
+
default="MultiSensor_Pass1",
|
|
127
|
+
)
|
|
128
|
+
qpe.add_argument("-i", "--interval", type=int, choices=[1, 3, 6, 12, 24, 48, 72], default=1)
|
|
129
|
+
|
|
130
|
+
utc_hour = dt.datetime.now(dt.timezone.utc).hour
|
|
131
|
+
|
|
132
|
+
qpf = sub.add_parser("qpf", parents=[common])
|
|
133
|
+
qpf.add_argument("-i", "--interval", type=int, choices=[6, 24, 48, 120], default=6)
|
|
134
|
+
qpf.add_argument("-c", "--cycle", type=int, default=utc_hour)
|
|
135
|
+
|
|
136
|
+
hrrr = sub.add_parser("hrrr", parents=[common])
|
|
137
|
+
hrrr.add_argument("-c", "--cycle", type=int, default=utc_hour)
|
|
138
|
+
hrrr.add_argument(
|
|
139
|
+
"-f",
|
|
140
|
+
"--fct-hour",
|
|
141
|
+
type=parse_forecast_hours,
|
|
142
|
+
default=parse_forecast_hours("0-18"),
|
|
143
|
+
dest="fct_hour",
|
|
144
|
+
)
|
|
145
|
+
hrrr.add_argument("--left-lon", type=float, default=0.0, dest="llon")
|
|
146
|
+
hrrr.add_argument("--right-lon", type=float, default=360.0, dest="rlon")
|
|
147
|
+
hrrr.add_argument("--top-lat", type=float, default=90.0, dest="tlat")
|
|
148
|
+
hrrr.add_argument("--bottom-lat", type=float, default=-90.0, dest="blat")
|
|
149
|
+
|
|
150
|
+
return parser
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def main(argv: list[str] | None = None) -> int:
|
|
154
|
+
parser = build_parser()
|
|
155
|
+
args = parser.parse_args(argv)
|
|
156
|
+
|
|
157
|
+
if args.subcommand not in {"qpe", "qpf", "hrrr"}:
|
|
158
|
+
parser.print_help()
|
|
159
|
+
return 2
|
|
160
|
+
|
|
161
|
+
logger = build_logger(log_file=args.log_file, log_level=args.log_level)
|
|
162
|
+
|
|
163
|
+
if args.working_dir:
|
|
164
|
+
os.chdir(args.working_dir)
|
|
165
|
+
|
|
166
|
+
output_dir = Path(args.output_dir).expanduser().resolve()
|
|
167
|
+
if not output_dir.is_dir():
|
|
168
|
+
logger.error("Output directory does not exist: %s", output_dir)
|
|
169
|
+
return 1
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
request = _build_request(args)
|
|
173
|
+
except ValidationError as exc:
|
|
174
|
+
if args.json_output:
|
|
175
|
+
print(json.dumps({"error": str(exc)}))
|
|
176
|
+
else:
|
|
177
|
+
logger.error("Invalid request: %s", exc)
|
|
178
|
+
return 1
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
with QPKit(logger=logger) as kit:
|
|
182
|
+
plan = kit.build_plan(request, output_dir, force=args.force)
|
|
183
|
+
if args.dry_run:
|
|
184
|
+
result = DownloadResult(plan=plan, skipped=plan.skipped)
|
|
185
|
+
else:
|
|
186
|
+
result = kit.downloader.download(plan)
|
|
187
|
+
|
|
188
|
+
summary = _summary(
|
|
189
|
+
result,
|
|
190
|
+
dry_run=args.dry_run,
|
|
191
|
+
subcommand=args.subcommand,
|
|
192
|
+
output_dir=output_dir,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
if args.json_output:
|
|
196
|
+
print(json.dumps(summary, sort_keys=True))
|
|
197
|
+
else:
|
|
198
|
+
logger.info(
|
|
199
|
+
"Summary: planned=%s downloaded=%s skipped=%s failed=%s",
|
|
200
|
+
summary["planned"],
|
|
201
|
+
summary["downloaded"],
|
|
202
|
+
summary["skipped"],
|
|
203
|
+
summary["failed"],
|
|
204
|
+
)
|
|
205
|
+
if args.dry_run:
|
|
206
|
+
logger.info("Dry run: no files were downloaded.")
|
|
207
|
+
|
|
208
|
+
return 1 if summary["failed"] > 0 else 0
|
|
209
|
+
except QPKitError as exc:
|
|
210
|
+
if args.json_output:
|
|
211
|
+
print(json.dumps({"error": str(exc)}))
|
|
212
|
+
else:
|
|
213
|
+
logger.error(str(exc))
|
|
214
|
+
return 1
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
if __name__ == "__main__":
|
|
218
|
+
raise SystemExit(main())
|
qpkit/downloader.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .http import HttpClientProtocol
|
|
11
|
+
from .models import DownloadItem, DownloadPlan, DownloadResult
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Downloader:
|
|
15
|
+
def __init__(
|
|
16
|
+
self,
|
|
17
|
+
http: HttpClientProtocol,
|
|
18
|
+
logger: logging.Logger,
|
|
19
|
+
*,
|
|
20
|
+
max_workers: int = 4,
|
|
21
|
+
max_retries: int = 3,
|
|
22
|
+
backoff_seconds: float = 1.0,
|
|
23
|
+
) -> None:
|
|
24
|
+
self.http = http
|
|
25
|
+
self.logger = logger
|
|
26
|
+
self.max_workers = max_workers
|
|
27
|
+
self.max_retries = max_retries
|
|
28
|
+
self.backoff_seconds = backoff_seconds
|
|
29
|
+
|
|
30
|
+
def _download_one(self, item: DownloadItem, output_path: Path) -> Path | None:
|
|
31
|
+
temp_path = output_path.with_name(f"{output_path.name}.part")
|
|
32
|
+
temp_path.unlink(missing_ok=True)
|
|
33
|
+
|
|
34
|
+
for attempt in range(self.max_retries + 1):
|
|
35
|
+
try:
|
|
36
|
+
self.http.download(item.url, temp_path)
|
|
37
|
+
temp_path.replace(output_path)
|
|
38
|
+
self.logger.info("Download saved to: %s", output_path)
|
|
39
|
+
return output_path
|
|
40
|
+
except (httpx.HTTPError, OSError) as exc:
|
|
41
|
+
temp_path.unlink(missing_ok=True)
|
|
42
|
+
if attempt < self.max_retries:
|
|
43
|
+
delay = self.backoff_seconds * (2**attempt)
|
|
44
|
+
self.logger.warning(
|
|
45
|
+
"Download retry %s/%s for %s in %.1fs: %s",
|
|
46
|
+
attempt + 1,
|
|
47
|
+
self.max_retries,
|
|
48
|
+
output_path.name,
|
|
49
|
+
delay,
|
|
50
|
+
exc,
|
|
51
|
+
)
|
|
52
|
+
time.sleep(delay)
|
|
53
|
+
continue
|
|
54
|
+
|
|
55
|
+
self.logger.warning(
|
|
56
|
+
"Failed download %s after retries: %s", output_path.name, exc
|
|
57
|
+
)
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
def download(self, plan: DownloadPlan) -> DownloadResult:
|
|
63
|
+
if not plan.items:
|
|
64
|
+
return DownloadResult(plan=plan, skipped=plan.skipped)
|
|
65
|
+
|
|
66
|
+
output_dir = plan.output_dir
|
|
67
|
+
workers = min(self.max_workers, len(plan.items))
|
|
68
|
+
succeeded: list[Path] = []
|
|
69
|
+
failed: list[str] = []
|
|
70
|
+
|
|
71
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
72
|
+
future_to_item = {
|
|
73
|
+
pool.submit(self._download_one, item, output_dir / item.filename): item
|
|
74
|
+
for item in plan.items
|
|
75
|
+
}
|
|
76
|
+
for future in as_completed(future_to_item):
|
|
77
|
+
item = future_to_item[future]
|
|
78
|
+
result = future.result()
|
|
79
|
+
if result is None:
|
|
80
|
+
failed.append(item.url)
|
|
81
|
+
else:
|
|
82
|
+
succeeded.append(result)
|
|
83
|
+
|
|
84
|
+
return DownloadResult(
|
|
85
|
+
succeeded=tuple(succeeded),
|
|
86
|
+
failed=tuple(failed),
|
|
87
|
+
skipped=plan.skipped,
|
|
88
|
+
plan=plan,
|
|
89
|
+
)
|