forcingkit 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.
- forcingkit/__init__.py +0 -0
- forcingkit/dispatcher.py +448 -0
- forcingkit/fetchers/dbofs.py +442 -0
- forcingkit/fetchers/erddap.py +142 -0
- forcingkit/fetchers/hrrr.py +72 -0
- forcingkit/fetchers/hrrr_atmosphere.py +289 -0
- forcingkit/fetchers/hycom.py +159 -0
- forcingkit/fetchers/hydrography.py +117 -0
- forcingkit/fetchers/ndbc.py +231 -0
- forcingkit/fetchers/necofs.py +369 -0
- forcingkit/fetchers/noaa.py +87 -0
- forcingkit/fetchers/nyofs.py +458 -0
- forcingkit/settings.py +72 -0
- forcingkit/zarr_stream.py +146 -0
- forcingkit-0.1.0.dist-info/METADATA +329 -0
- forcingkit-0.1.0.dist-info/RECORD +24 -0
- forcingkit-0.1.0.dist-info/WHEEL +4 -0
- forcingkit-0.1.0.dist-info/licenses/LICENSE +201 -0
- forcingkit_serve/__init__.py +0 -0
- forcingkit_serve/main.py +544 -0
- forcingkit_serve/routers/bathymetry.py +241 -0
- forcingkit_serve/routers/plotly_api.py +150 -0
- forcingkit_serve/routers/removed.py +86 -0
- forcingkit_serve/routers/viewer.py +1199 -0
forcingkit_serve/main.py
ADDED
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import time
|
|
5
|
+
import shutil
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Dict, Any, Callable, Awaitable, List
|
|
8
|
+
|
|
9
|
+
from dotenv import load_dotenv
|
|
10
|
+
|
|
11
|
+
load_dotenv()
|
|
12
|
+
|
|
13
|
+
from fastapi import FastAPI, HTTPException, Request, Response # noqa: E402
|
|
14
|
+
from fastapi.responses import FileResponse, RedirectResponse # noqa: E402
|
|
15
|
+
from fastapi.staticfiles import StaticFiles # noqa: E402
|
|
16
|
+
from pydantic import BaseModel # noqa: E402
|
|
17
|
+
|
|
18
|
+
from forcingkit.dispatcher import ( # noqa: E402
|
|
19
|
+
dispatch_station_profiles_request,
|
|
20
|
+
dispatch_bounding_box_profiles_request,
|
|
21
|
+
)
|
|
22
|
+
from forcingkit_serve.routers import viewer, bathymetry, plotly_api, removed # noqa: E402
|
|
23
|
+
from forcingkit.fetchers.noaa import fetch_noaa_tide_data # noqa: E402
|
|
24
|
+
|
|
25
|
+
# Configure Logging
|
|
26
|
+
log_dir = Path("logs")
|
|
27
|
+
log_dir.mkdir(exist_ok=True)
|
|
28
|
+
|
|
29
|
+
log_level = logging.INFO
|
|
30
|
+
root_logger = logging.getLogger()
|
|
31
|
+
logging.getLogger("cfgrib").setLevel(logging.ERROR)
|
|
32
|
+
logging.getLogger("cfgrib.messages").setLevel(logging.ERROR)
|
|
33
|
+
root_logger.setLevel(log_level)
|
|
34
|
+
|
|
35
|
+
formatter = logging.Formatter(
|
|
36
|
+
"%(asctime)s - %(name)s - %(levelname)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# File Handler
|
|
40
|
+
file_handler = logging.FileHandler(log_dir / "service.log")
|
|
41
|
+
file_handler.setFormatter(formatter)
|
|
42
|
+
root_logger.addHandler(file_handler)
|
|
43
|
+
|
|
44
|
+
# Stream Handler
|
|
45
|
+
has_console = False
|
|
46
|
+
for h in root_logger.handlers:
|
|
47
|
+
if isinstance(h, logging.StreamHandler) and h.stream == sys.stdout:
|
|
48
|
+
h.setFormatter(formatter)
|
|
49
|
+
h.setLevel(log_level)
|
|
50
|
+
has_console = True
|
|
51
|
+
break
|
|
52
|
+
|
|
53
|
+
if not has_console:
|
|
54
|
+
stream_handler = logging.StreamHandler(sys.stdout)
|
|
55
|
+
stream_handler.setFormatter(formatter)
|
|
56
|
+
stream_handler.setLevel(log_level)
|
|
57
|
+
root_logger.addHandler(stream_handler)
|
|
58
|
+
|
|
59
|
+
logging.getLogger("forcingkit_serve").setLevel(log_level)
|
|
60
|
+
logger = logging.getLogger("forcingkit_serve")
|
|
61
|
+
|
|
62
|
+
from fastapi.middleware.cors import CORSMiddleware # noqa: E402
|
|
63
|
+
from forcingkit import settings # noqa: E402
|
|
64
|
+
|
|
65
|
+
# Initialize FastAPI application
|
|
66
|
+
app = FastAPI(
|
|
67
|
+
title="forcingkit",
|
|
68
|
+
description="Spatiotemporal forcing for computational Earth-system models: selects, regrids and serves model-ready time series with provenance.",
|
|
69
|
+
version="1.0.0",
|
|
70
|
+
openapi_tags=[
|
|
71
|
+
{
|
|
72
|
+
"name": "Cache Viewer",
|
|
73
|
+
"description": "Endpoints to navigate and preview static cached data.",
|
|
74
|
+
}
|
|
75
|
+
],
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
app.add_middleware(
|
|
79
|
+
CORSMiddleware,
|
|
80
|
+
allow_origins=["*"],
|
|
81
|
+
allow_credentials=True,
|
|
82
|
+
allow_methods=["*"],
|
|
83
|
+
allow_headers=["*"],
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
app.include_router(viewer.router)
|
|
87
|
+
app.include_router(bathymetry.router)
|
|
88
|
+
app.include_router(plotly_api.router)
|
|
89
|
+
# 410 Gone stubs for the routes removed on 2026-10-05; drop at the next release.
|
|
90
|
+
app.include_router(removed.router)
|
|
91
|
+
|
|
92
|
+
# Ensure static UI directory exists
|
|
93
|
+
ui_dir = Path("static")
|
|
94
|
+
ui_dir.mkdir(exist_ok=True)
|
|
95
|
+
app.mount("/ui", StaticFiles(directory="static", html=True), name="static_ui")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@app.get("/", include_in_schema=False)
|
|
99
|
+
def ui_root() -> RedirectResponse:
|
|
100
|
+
return RedirectResponse("/ui/")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@app.middleware("http")
|
|
104
|
+
async def log_requests(
|
|
105
|
+
request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
|
106
|
+
) -> Response:
|
|
107
|
+
start_time = time.time()
|
|
108
|
+
response = await call_next(request)
|
|
109
|
+
process_time = time.time() - start_time
|
|
110
|
+
logger.info(
|
|
111
|
+
f"Request: {request.method} {request.url} | "
|
|
112
|
+
f"Status: {response.status_code} | "
|
|
113
|
+
f"Latency: {process_time:.4f}s"
|
|
114
|
+
)
|
|
115
|
+
return response
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class BoundingBox(BaseModel):
|
|
119
|
+
min_lon: float
|
|
120
|
+
min_lat: float
|
|
121
|
+
max_lon: float
|
|
122
|
+
max_lat: float
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class OBCRequest(BaseModel):
|
|
126
|
+
bbox: BoundingBox
|
|
127
|
+
start_date: str # ISO8601 string
|
|
128
|
+
duration_hours: int
|
|
129
|
+
cache_bust: bool = False
|
|
130
|
+
allow_donor_fallback: bool = True
|
|
131
|
+
sponge_cells: int = 0
|
|
132
|
+
include_tides: bool = True
|
|
133
|
+
tidal_model: str = "GOT4.10c"
|
|
134
|
+
# Parent-ocean options (schema z-v2): donor cells of padding beyond the bbox, so the parent
|
|
135
|
+
# brackets the child, and the spacing of the fixed z levels.
|
|
136
|
+
pad_cells: int = 3
|
|
137
|
+
vertical_spacing_m: float = 2.0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class TideRequest(BaseModel):
|
|
141
|
+
station_id: str
|
|
142
|
+
start_time: str # ISO8601 string
|
|
143
|
+
end_time: str # ISO8601 string
|
|
144
|
+
cache_bust: bool = False
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class TelemetryRequest(BaseModel):
|
|
148
|
+
station_id: str
|
|
149
|
+
start_time: str # ISO8601 string
|
|
150
|
+
end_time: str # ISO8601 string
|
|
151
|
+
cache_bust: bool = False
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class HoTRequest(BaseModel):
|
|
155
|
+
lat: float
|
|
156
|
+
|
|
157
|
+
lon: float
|
|
158
|
+
|
|
159
|
+
radius_km: float = 20.0
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@app.post("/api/v1/hot_discovery")
|
|
163
|
+
async def hot_discovery(req: HoTRequest):
|
|
164
|
+
"""Discover head of tide limits using OSM API."""
|
|
165
|
+
|
|
166
|
+
from forcingkit.fetchers.hydrography import find_head_of_tide
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
results = find_head_of_tide(req.lat, req.lon, req.radius_km)
|
|
170
|
+
|
|
171
|
+
return {"status": "success", "results": results}
|
|
172
|
+
|
|
173
|
+
except Exception as e:
|
|
174
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@app.get("/health")
|
|
178
|
+
async def health_check() -> Dict[str, str]:
|
|
179
|
+
"""Basic health check endpoint."""
|
|
180
|
+
return {"status": "healthy", "service": "forcingkit"}
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@app.api_route("/api/v1/cache/purge", methods=["GET", "POST"])
|
|
184
|
+
async def purge_cache() -> Dict[str, str]:
|
|
185
|
+
"""Purges the forcing and IC data cache. Supports both GET (manual) and POST (UI)."""
|
|
186
|
+
cache_dir = Path(settings.cache_dir()).expanduser()
|
|
187
|
+
if os.path.exists(cache_dir):
|
|
188
|
+
logger.info(f"Purging cache directory: {cache_dir}")
|
|
189
|
+
try:
|
|
190
|
+
shutil.rmtree(cache_dir)
|
|
191
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
192
|
+
return {"status": "success", "message": "Cache purged successfully."}
|
|
193
|
+
except Exception as e:
|
|
194
|
+
logger.error(f"Failed to purge cache: {e}")
|
|
195
|
+
raise HTTPException(status_code=500, detail=f"Failed to purge cache: {e}")
|
|
196
|
+
else:
|
|
197
|
+
return {"status": "success", "message": "Cache directory does not exist."}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@app.post("/api/v1/tide")
|
|
201
|
+
async def get_tide_data(request: TideRequest) -> Dict[str, Any]:
|
|
202
|
+
"""Fetch and cache NOAA tide data for a given station and time window."""
|
|
203
|
+
try:
|
|
204
|
+
data = fetch_noaa_tide_data(
|
|
205
|
+
station_id=request.station_id,
|
|
206
|
+
start_time=request.start_time,
|
|
207
|
+
end_time=request.end_time,
|
|
208
|
+
cache_bust=request.cache_bust,
|
|
209
|
+
)
|
|
210
|
+
return {"status": "success", "data": data}
|
|
211
|
+
except Exception as e:
|
|
212
|
+
logger.error(f"Tide fetch failed: {e}")
|
|
213
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class TelemetryBBoxRequest(BaseModel):
|
|
217
|
+
bbox: BoundingBox
|
|
218
|
+
start_time: str # ISO8601 string
|
|
219
|
+
end_time: str # ISO8601 string
|
|
220
|
+
cache_bust: bool = False
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@app.post("/api/v1/telemetry/station")
|
|
224
|
+
async def get_station_telemetry(request: TelemetryRequest) -> Dict[str, Any]:
|
|
225
|
+
"""Fetch 3-depth telemetry profile data for structural nudging."""
|
|
226
|
+
logger.info(
|
|
227
|
+
f"Received telemetry request for station {request.station_id} from {request.start_time} to {request.end_time}"
|
|
228
|
+
)
|
|
229
|
+
try:
|
|
230
|
+
data = dispatch_station_profiles_request(
|
|
231
|
+
station_id=request.station_id,
|
|
232
|
+
start_time=request.start_time,
|
|
233
|
+
end_time=request.end_time,
|
|
234
|
+
cache_bust=request.cache_bust,
|
|
235
|
+
)
|
|
236
|
+
return {"status": "success", "data": data}
|
|
237
|
+
except Exception as e:
|
|
238
|
+
logger.error(f"Telemetry fetch failed: {e}")
|
|
239
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@app.post("/api/v1/telemetry/bbox")
|
|
243
|
+
async def get_bbox_telemetry(request: TelemetryBBoxRequest) -> Dict[str, Any]:
|
|
244
|
+
"""Fetch structured nudge telemetry for all stations in bounding box."""
|
|
245
|
+
logger.info(
|
|
246
|
+
f"Received bbox telemetry request for {request.bbox} from {request.start_time} to {request.end_time}"
|
|
247
|
+
)
|
|
248
|
+
try:
|
|
249
|
+
# Convert Request bbox to list [min_lon, min_lat, max_lon, max_lat]
|
|
250
|
+
bbox_list = [
|
|
251
|
+
request.bbox.min_lon,
|
|
252
|
+
request.bbox.min_lat,
|
|
253
|
+
request.bbox.max_lon,
|
|
254
|
+
request.bbox.max_lat,
|
|
255
|
+
]
|
|
256
|
+
data = dispatch_bounding_box_profiles_request(
|
|
257
|
+
bbox=bbox_list,
|
|
258
|
+
start_time=request.start_time,
|
|
259
|
+
end_time=request.end_time,
|
|
260
|
+
cache_bust=request.cache_bust,
|
|
261
|
+
)
|
|
262
|
+
return {"status": "success", "data": data}
|
|
263
|
+
except Exception as e:
|
|
264
|
+
logger.error(f"BBox telemetry fetch failed: {e}")
|
|
265
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _obc_hash_source(request: "OBCRequest", bbox_list: list, donor_id: str) -> str:
|
|
269
|
+
"""The string an OBC store id is hashed from. Shared by /obc/cache and /obc so the two can
|
|
270
|
+
never disagree; it includes the store schema, so a store built under an older schema is never
|
|
271
|
+
served for a newer request."""
|
|
272
|
+
from forcingkit.fetchers.necofs import OBC_SCHEMA
|
|
273
|
+
|
|
274
|
+
return (
|
|
275
|
+
f"{bbox_list}_{request.start_date}_{request.duration_hours}_{donor_id}_"
|
|
276
|
+
f"{request.sponge_cells}_{request.include_tides}_{request.tidal_model}_obc_"
|
|
277
|
+
f"{OBC_SCHEMA}_p{request.pad_cells}_dz{request.vertical_spacing_m}"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _zip_is_stale(zip_path: str, store_path: str) -> bool:
|
|
282
|
+
"""True when the zip is missing or older than the newest file in the store."""
|
|
283
|
+
if not os.path.exists(zip_path):
|
|
284
|
+
return True
|
|
285
|
+
newest = max(
|
|
286
|
+
(
|
|
287
|
+
os.path.getmtime(os.path.join(d, f))
|
|
288
|
+
for d, _, files in os.walk(store_path)
|
|
289
|
+
for f in files
|
|
290
|
+
),
|
|
291
|
+
default=os.path.getmtime(store_path),
|
|
292
|
+
)
|
|
293
|
+
return os.path.getmtime(zip_path) < newest
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
@app.post("/api/v1/obc/cache")
|
|
297
|
+
async def cache_obc(request: OBCRequest) -> Dict[str, Any]:
|
|
298
|
+
import hashlib
|
|
299
|
+
|
|
300
|
+
from forcingkit.dispatcher import predict_obc_donor
|
|
301
|
+
|
|
302
|
+
bbox_list = [
|
|
303
|
+
request.bbox.min_lon,
|
|
304
|
+
request.bbox.min_lat,
|
|
305
|
+
request.bbox.max_lon,
|
|
306
|
+
request.bbox.max_lat,
|
|
307
|
+
]
|
|
308
|
+
donor_meta = predict_obc_donor(bbox_list)
|
|
309
|
+
donor_id = donor_meta.get("id", "unknown")
|
|
310
|
+
hash_str = _obc_hash_source(request, bbox_list, donor_id)
|
|
311
|
+
raw_id = hashlib.md5(hash_str.encode()).hexdigest()[:12]
|
|
312
|
+
return {"status": "success", "zarr_id": f"obc_{raw_id}"}
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@app.post("/api/v1/obc/predict-donor")
|
|
316
|
+
async def predict_obc_donor_endpoint(request: OBCRequest) -> Dict[str, Any]:
|
|
317
|
+
try:
|
|
318
|
+
from forcingkit.dispatcher import predict_obc_donor
|
|
319
|
+
|
|
320
|
+
bbox_list = [
|
|
321
|
+
request.bbox.min_lon,
|
|
322
|
+
request.bbox.min_lat,
|
|
323
|
+
request.bbox.max_lon,
|
|
324
|
+
request.bbox.max_lat,
|
|
325
|
+
]
|
|
326
|
+
donor_meta = predict_obc_donor(bbox_list)
|
|
327
|
+
if not donor_meta:
|
|
328
|
+
return {
|
|
329
|
+
"status": "error",
|
|
330
|
+
"message": "No donor found for OBC",
|
|
331
|
+
"donor": None,
|
|
332
|
+
}
|
|
333
|
+
return {"status": "success", "donor": donor_meta}
|
|
334
|
+
except Exception as e:
|
|
335
|
+
logger.error(f"Failed to predict OBC donor: {e}")
|
|
336
|
+
return {"status": "error", "message": str(e), "donor": None}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# Plain `def` for the long handlers: FastAPI runs them in its thread pool, so an hour-long parent
|
|
340
|
+
# fetch no longer blocks every other request on the event loop.
|
|
341
|
+
@app.post("/api/v1/obc")
|
|
342
|
+
def generate_obc(request: OBCRequest) -> Dict[str, Any]:
|
|
343
|
+
bbox_list = [
|
|
344
|
+
request.bbox.min_lon,
|
|
345
|
+
request.bbox.min_lat,
|
|
346
|
+
request.bbox.max_lon,
|
|
347
|
+
request.bbox.max_lat,
|
|
348
|
+
]
|
|
349
|
+
from forcingkit.dispatcher import predict_obc_donor, dispatch_obc_request
|
|
350
|
+
|
|
351
|
+
meta = predict_obc_donor(bbox_list)
|
|
352
|
+
donor_id = meta.get("id", "unknown")
|
|
353
|
+
|
|
354
|
+
import hashlib
|
|
355
|
+
|
|
356
|
+
# Hash unique configuration plus donor
|
|
357
|
+
hash_str = _obc_hash_source(request, bbox_list, donor_id)
|
|
358
|
+
raw_id = hashlib.md5(hash_str.encode()).hexdigest()[:12]
|
|
359
|
+
zarr_id = f"obc_{raw_id}"
|
|
360
|
+
zarr_name = f"{zarr_id}.zarr"
|
|
361
|
+
cache_dir = settings.cache_dir()
|
|
362
|
+
zarr_path = os.path.join(cache_dir, zarr_name)
|
|
363
|
+
|
|
364
|
+
from forcingkit.fetchers.necofs import OBC_SCHEMA
|
|
365
|
+
from forcingkit.zarr_stream import store_is_complete
|
|
366
|
+
|
|
367
|
+
if not request.cache_bust and store_is_complete(zarr_path, (OBC_SCHEMA,)):
|
|
368
|
+
return {
|
|
369
|
+
"status": "cached",
|
|
370
|
+
"zarr_id": zarr_id,
|
|
371
|
+
"zarr_path": zarr_path,
|
|
372
|
+
"download_url": f"/api/v1/obc/download/{zarr_id}",
|
|
373
|
+
"donor": donor_id,
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
try:
|
|
377
|
+
final_path = dispatch_obc_request(
|
|
378
|
+
start_date=request.start_date,
|
|
379
|
+
duration_hours=request.duration_hours,
|
|
380
|
+
bbox=bbox_list,
|
|
381
|
+
cache_bust=request.cache_bust,
|
|
382
|
+
zarr_path=zarr_path,
|
|
383
|
+
allow_donor_fallback=request.allow_donor_fallback,
|
|
384
|
+
include_tides=request.include_tides,
|
|
385
|
+
tidal_model=request.tidal_model,
|
|
386
|
+
sponge_cells=request.sponge_cells,
|
|
387
|
+
pad_cells=request.pad_cells,
|
|
388
|
+
vertical_spacing_m=request.vertical_spacing_m,
|
|
389
|
+
)
|
|
390
|
+
return {
|
|
391
|
+
"status": "success",
|
|
392
|
+
"zarr_id": zarr_id,
|
|
393
|
+
"zarr_path": final_path,
|
|
394
|
+
"download_url": f"/api/v1/obc/download/{zarr_id}",
|
|
395
|
+
"donor": donor_id,
|
|
396
|
+
}
|
|
397
|
+
except Exception as e:
|
|
398
|
+
logger.error(f"OBC generation failed: {e}")
|
|
399
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
@app.get("/api/v1/obc/download/{zarr_id}")
|
|
403
|
+
def download_obc(zarr_id: str):
|
|
404
|
+
cache_dir = Path(settings.cache_dir())
|
|
405
|
+
|
|
406
|
+
# Handle both raw hash and obc_ prefixed hashes gracefully
|
|
407
|
+
search_id = zarr_id if zarr_id.startswith("obc_") else f"obc_{zarr_id}"
|
|
408
|
+
|
|
409
|
+
matches = list(cache_dir.rglob(f"{search_id}.zarr"))
|
|
410
|
+
if not matches:
|
|
411
|
+
raise HTTPException(status_code=404, detail="OBC Zarr archive not found.")
|
|
412
|
+
zarr_path = str(matches[0])
|
|
413
|
+
|
|
414
|
+
# Compress the folder on the fly, and rebuild a zip older than its store: after a cache_bust
|
|
415
|
+
# the store is rewritten under the same id, and a stale zip would otherwise be served.
|
|
416
|
+
zip_path = os.path.join(cache_dir, f"{search_id}.zip")
|
|
417
|
+
if _zip_is_stale(zip_path, zarr_path):
|
|
418
|
+
shutil.make_archive(zip_path.replace(".zip", ""), "zip", zarr_path)
|
|
419
|
+
|
|
420
|
+
return FileResponse(
|
|
421
|
+
zip_path,
|
|
422
|
+
media_type="application/zip",
|
|
423
|
+
filename=f"{search_id}.zip",
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
class NDBCRequest(BaseModel):
|
|
428
|
+
station_id: str
|
|
429
|
+
start_time: str # ISO 8601, UTC
|
|
430
|
+
end_time: str
|
|
431
|
+
products: List[str] = ["stdmet", "adcp"]
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
@app.post("/api/v1/ndbc")
|
|
435
|
+
def get_ndbc(request: NDBCRequest) -> Dict[str, Any]:
|
|
436
|
+
"""Observations at an NDBC buoy over a window, for validation: stdmet (sea and air
|
|
437
|
+
temperature, pressure, wind, waves) and ADCP near-surface current as east and north
|
|
438
|
+
components. Missing values are null."""
|
|
439
|
+
from forcingkit.fetchers.ndbc import fetch_ndbc
|
|
440
|
+
|
|
441
|
+
try:
|
|
442
|
+
data = fetch_ndbc(
|
|
443
|
+
request.station_id,
|
|
444
|
+
request.start_time,
|
|
445
|
+
request.end_time,
|
|
446
|
+
tuple(request.products),
|
|
447
|
+
cache_dir=_atmosphere_cache_dir(),
|
|
448
|
+
)
|
|
449
|
+
except ValueError as e:
|
|
450
|
+
raise HTTPException(status_code=400, detail=str(e))
|
|
451
|
+
except Exception as e:
|
|
452
|
+
logger.error(f"NDBC fetch failed: {e}")
|
|
453
|
+
raise HTTPException(status_code=502, detail=str(e))
|
|
454
|
+
return {"status": "success", "data": data}
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
class AtmosphereRequest(BaseModel):
|
|
458
|
+
bbox: BoundingBox
|
|
459
|
+
start_time: str # ISO 8601, UTC
|
|
460
|
+
hours: int
|
|
461
|
+
source: str = "hrrr"
|
|
462
|
+
resolution_deg: float = 0.03
|
|
463
|
+
margin_deg: float = 0.25
|
|
464
|
+
cache_bust: bool = False
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _atmosphere_cache_dir() -> str:
|
|
468
|
+
return settings.cache_dir()
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
@app.post("/api/v1/atmosphere")
|
|
472
|
+
def generate_atmosphere(request: AtmosphereRequest) -> Dict[str, Any]:
|
|
473
|
+
"""A prescribed atmosphere for an ocean model: HRRR hourly fields on a regular lon/lat grid,
|
|
474
|
+
from one hour before `start_time` to one hour after the end, streamed to a Zarr store."""
|
|
475
|
+
from forcingkit.dispatcher import atmosphere_key, dispatch_atmosphere_request
|
|
476
|
+
|
|
477
|
+
if request.source != "hrrr":
|
|
478
|
+
raise HTTPException(
|
|
479
|
+
status_code=400, detail=f"unsupported source {request.source}"
|
|
480
|
+
)
|
|
481
|
+
if request.hours < 1:
|
|
482
|
+
raise HTTPException(status_code=400, detail="hours must be at least 1")
|
|
483
|
+
bbox_list = [
|
|
484
|
+
request.bbox.min_lon,
|
|
485
|
+
request.bbox.min_lat,
|
|
486
|
+
request.bbox.max_lon,
|
|
487
|
+
request.bbox.max_lat,
|
|
488
|
+
]
|
|
489
|
+
zarr_id = atmosphere_key(
|
|
490
|
+
bbox_list,
|
|
491
|
+
request.start_time,
|
|
492
|
+
request.hours,
|
|
493
|
+
request.resolution_deg,
|
|
494
|
+
request.margin_deg,
|
|
495
|
+
request.source,
|
|
496
|
+
)
|
|
497
|
+
zarr_path = os.path.join(_atmosphere_cache_dir(), f"{zarr_id}.zarr")
|
|
498
|
+
try:
|
|
499
|
+
final_path = dispatch_atmosphere_request(
|
|
500
|
+
bbox_list,
|
|
501
|
+
request.start_time,
|
|
502
|
+
request.hours,
|
|
503
|
+
zarr_path,
|
|
504
|
+
resolution_deg=request.resolution_deg,
|
|
505
|
+
margin_deg=request.margin_deg,
|
|
506
|
+
cache_bust=request.cache_bust,
|
|
507
|
+
)
|
|
508
|
+
except Exception as e:
|
|
509
|
+
logger.error(f"Atmosphere generation failed: {e}")
|
|
510
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
511
|
+
return {
|
|
512
|
+
"status": "success",
|
|
513
|
+
"zarr_id": zarr_id,
|
|
514
|
+
"zarr_path": final_path,
|
|
515
|
+
"download_url": f"/api/v1/atmosphere/download/{zarr_id}",
|
|
516
|
+
"source": request.source,
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
@app.get("/api/v1/atmosphere/download/{zarr_id}")
|
|
521
|
+
def download_atmosphere(zarr_id: str):
|
|
522
|
+
cache_dir = _atmosphere_cache_dir()
|
|
523
|
+
search_id = zarr_id if zarr_id.startswith("atm_") else f"atm_{zarr_id}"
|
|
524
|
+
zarr_path = os.path.join(cache_dir, f"{search_id}.zarr")
|
|
525
|
+
if not os.path.isdir(zarr_path):
|
|
526
|
+
raise HTTPException(status_code=404, detail="Atmosphere Zarr store not found.")
|
|
527
|
+
zip_path = os.path.join(cache_dir, f"{search_id}.zip")
|
|
528
|
+
if _zip_is_stale(zip_path, zarr_path):
|
|
529
|
+
shutil.make_archive(zip_path.replace(".zip", ""), "zip", zarr_path)
|
|
530
|
+
return FileResponse(
|
|
531
|
+
zip_path, media_type="application/zip", filename=f"{search_id}.zip"
|
|
532
|
+
)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
if __name__ == "__main__":
|
|
536
|
+
import uvicorn
|
|
537
|
+
import argparse
|
|
538
|
+
|
|
539
|
+
parser = argparse.ArgumentParser()
|
|
540
|
+
parser.add_argument("--host", default="0.0.0.0")
|
|
541
|
+
parser.add_argument("--port", type=int, default=9598)
|
|
542
|
+
args = parser.parse_args()
|
|
543
|
+
|
|
544
|
+
uvicorn.run(app, host=args.host, port=args.port)
|