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.
@@ -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)