Spaces:
Sleeping
Sleeping
Add Leaflet velocity wave visualizer: FastAPI+Gradio app, NOAA WW3 puller, endpoints, Dockerfile, requirements
Browse files- .DS_Store +0 -0
- Dockerfile +24 -0
- app.py +236 -0
- backend/__init__.py +2 -0
- backend/grib_wave_puller.py +270 -0
- requirements.txt +9 -0
.DS_Store
ADDED
|
Binary file (6.15 kB). View file
|
|
|
Dockerfile
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
# System deps for cfgrib/eccodes
|
| 4 |
+
RUN apt-get update && \
|
| 5 |
+
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
| 6 |
+
libeccodes0 libeccodes-data libeccodes-dev \
|
| 7 |
+
libopenjp2-7 libnetcdf19 ca-certificates && \
|
| 8 |
+
rm -rf /var/lib/apt/lists/*
|
| 9 |
+
|
| 10 |
+
ENV PIP_NO_CACHE_DIR=1 \
|
| 11 |
+
PYTHONUNBUFFERED=1 \
|
| 12 |
+
GRADIO_SERVER_NAME=0.0.0.0 \
|
| 13 |
+
GRADIO_SERVER_PORT=7860
|
| 14 |
+
|
| 15 |
+
WORKDIR /app
|
| 16 |
+
COPY requirements.txt ./
|
| 17 |
+
RUN pip install -r requirements.txt
|
| 18 |
+
|
| 19 |
+
COPY . .
|
| 20 |
+
|
| 21 |
+
EXPOSE 7860
|
| 22 |
+
|
| 23 |
+
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
| 24 |
+
|
app.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from functools import lru_cache
|
| 5 |
+
from typing import Dict, Any, List
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
from fastapi import FastAPI, Query
|
| 9 |
+
from fastapi.responses import JSONResponse, HTMLResponse
|
| 10 |
+
import gradio as gr
|
| 11 |
+
|
| 12 |
+
# Local import: vendored from working project
|
| 13 |
+
from backend.grib_wave_puller import GRIBWavePuller
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
app = FastAPI(title="Wave Visualizer API")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def _compute_uv_from_wave(height: np.ndarray, direction_deg: np.ndarray, scale: float = 0.1):
|
| 20 |
+
"""Compute U/V components from wave height and meteorological 'from' direction.
|
| 21 |
+
|
| 22 |
+
- height: significant wave height array (m)
|
| 23 |
+
- direction_deg: wave direction (deg, meteorological, coming from)
|
| 24 |
+
- scale: visualization scaling factor
|
| 25 |
+
"""
|
| 26 |
+
dir_rad = np.deg2rad(direction_deg)
|
| 27 |
+
mag = np.clip(height, 0, np.nanmax(height)) * scale
|
| 28 |
+
# Eastward (u) and northward (v) components; negative on v because 'from'
|
| 29 |
+
u = mag * np.sin(dir_rad)
|
| 30 |
+
v = -mag * np.cos(dir_rad)
|
| 31 |
+
return u, v
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _build_velocity_grib_json(lats: np.ndarray, lons: np.ndarray, u: np.ndarray, v: np.ndarray, ref_time: str) -> List[Dict[str, Any]]:
|
| 35 |
+
"""Build leaflet-velocity compatible JSON (Wind/Earth GRIB-like format).
|
| 36 |
+
|
| 37 |
+
Data must be provided on a regular lat-lon grid. Arrays are 2D with shape (ny, nx)
|
| 38 |
+
where ny=len(lats), nx=len(lons). Latitude should be provided in descending order
|
| 39 |
+
(north to south) to match common GRIB conventions; reorder if needed.
|
| 40 |
+
"""
|
| 41 |
+
# Ensure 1D coordinate arrays
|
| 42 |
+
lats_1d = lats if lats.ndim == 1 else lats[:, 0]
|
| 43 |
+
lons_1d = lons if lons.ndim == 1 else lons[0, :]
|
| 44 |
+
|
| 45 |
+
ny = int(len(lats_1d))
|
| 46 |
+
nx = int(len(lons_1d))
|
| 47 |
+
|
| 48 |
+
# If latitude increases northward, reverse to north->south
|
| 49 |
+
if ny > 1 and lats_1d[0] < lats_1d[-1]:
|
| 50 |
+
lats_1d = lats_1d[::-1]
|
| 51 |
+
u = np.flipud(u)
|
| 52 |
+
v = np.flipud(v)
|
| 53 |
+
|
| 54 |
+
la1 = float(lats_1d[0])
|
| 55 |
+
la2 = float(lats_1d[-1])
|
| 56 |
+
lo1 = float(lons_1d[0])
|
| 57 |
+
lo2 = float(lons_1d[-1])
|
| 58 |
+
|
| 59 |
+
# Grid spacing (approx)
|
| 60 |
+
dy = float(abs(lats_1d[1] - lats_1d[0])) if ny > 1 else 0
|
| 61 |
+
dx = float(abs(lons_1d[1] - lons_1d[0])) if nx > 1 else 0
|
| 62 |
+
|
| 63 |
+
# Flatten row-major (lat-major first, then lon) matching header
|
| 64 |
+
u_data = np.asarray(u, dtype=float).flatten().tolist()
|
| 65 |
+
v_data = np.asarray(v, dtype=float).flatten().tolist()
|
| 66 |
+
|
| 67 |
+
header_common = {
|
| 68 |
+
"lo1": lo1,
|
| 69 |
+
"la1": la1,
|
| 70 |
+
"lo2": lo2,
|
| 71 |
+
"la2": la2,
|
| 72 |
+
"nx": nx,
|
| 73 |
+
"ny": ny,
|
| 74 |
+
"dx": dx,
|
| 75 |
+
"dy": dy,
|
| 76 |
+
"refTime": ref_time,
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
u_record = {
|
| 80 |
+
"header": {
|
| 81 |
+
**header_common,
|
| 82 |
+
"parameterCategory": 2,
|
| 83 |
+
"parameterNumber": 2, # U component
|
| 84 |
+
"parameterUnit": "m/s",
|
| 85 |
+
},
|
| 86 |
+
"data": u_data,
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
v_record = {
|
| 90 |
+
"header": {
|
| 91 |
+
**header_common,
|
| 92 |
+
"parameterCategory": 2,
|
| 93 |
+
"parameterNumber": 3, # V component
|
| 94 |
+
"parameterUnit": "m/s",
|
| 95 |
+
},
|
| 96 |
+
"data": v_data,
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
return [u_record, v_record]
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@lru_cache(maxsize=16)
|
| 103 |
+
def get_puller() -> GRIBWavePuller:
|
| 104 |
+
return GRIBWavePuller()
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@app.get("/data/points")
|
| 108 |
+
def data_points(hour: int = Query(0, ge=0, le=240)):
|
| 109 |
+
puller = get_puller()
|
| 110 |
+
data = puller.fetch_global_wave_data(hour)
|
| 111 |
+
if not data:
|
| 112 |
+
return JSONResponse(status_code=503, content={"error": "No data available"})
|
| 113 |
+
return JSONResponse(content=data)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@app.get("/data/velocity")
|
| 117 |
+
def data_velocity(hour: int = Query(0, ge=0, le=240), scale: float = Query(0.1)):
|
| 118 |
+
puller = get_puller()
|
| 119 |
+
result = puller.fetch_global_wave_data(hour)
|
| 120 |
+
if not result:
|
| 121 |
+
return JSONResponse(status_code=503, content={"error": "No data available"})
|
| 122 |
+
|
| 123 |
+
# If we have a downsampled UV grid, return leaflet-velocity JSON
|
| 124 |
+
grid_uv = result.get("grid_uv")
|
| 125 |
+
if grid_uv:
|
| 126 |
+
lats = np.array(grid_uv['lats'])
|
| 127 |
+
lons = np.array(grid_uv['lons'])
|
| 128 |
+
u = np.array(grid_uv['u'])
|
| 129 |
+
v = np.array(grid_uv['v'])
|
| 130 |
+
payload = _build_velocity_grib_json(lats, lons, u, v, ref_time=result.get("timestamp", datetime.utcnow().isoformat()))
|
| 131 |
+
return JSONResponse(content=payload)
|
| 132 |
+
|
| 133 |
+
# Fallback to points if no grid is present
|
| 134 |
+
sample_points = result.get("sample_points", [])
|
| 135 |
+
return JSONResponse(content={"type": "points", "refTime": result.get("timestamp"), "points": sample_points})
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def leaflet_html() -> str:
|
| 139 |
+
return f"""
|
| 140 |
+
<!doctype html>
|
| 141 |
+
<html>
|
| 142 |
+
<head>
|
| 143 |
+
<meta charset=\"utf-8\" />
|
| 144 |
+
<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />
|
| 145 |
+
<link rel=\"stylesheet\" href=\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.css\" />
|
| 146 |
+
<style>
|
| 147 |
+
html, body, #map {{ height: 100%; margin: 0; }}
|
| 148 |
+
.leaflet-control-container .leaflet-top.leaflet-left {{ z-index: 1000; }}
|
| 149 |
+
.control {{ position:absolute; top:10px; left:10px; background:#fff; padding:8px; border-radius:4px; box-shadow:0 1px 3px rgba(0,0,0,0.3); }}
|
| 150 |
+
</style>
|
| 151 |
+
</head>
|
| 152 |
+
<body>
|
| 153 |
+
<div id=\"map\"></div>
|
| 154 |
+
<div class=\"control\">
|
| 155 |
+
<label>Forecast hour: <input type=\"number\" id=\"hour\" min=\"0\" max=\"240\" step=\"6\" value=\"0\" /></label>
|
| 156 |
+
<button id=\"load\">Load Waves</button>
|
| 157 |
+
</div>
|
| 158 |
+
<script src=\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.js\"></script>
|
| 159 |
+
<script src=\"https://unpkg.com/leaflet-velocity/dist/leaflet-velocity.min.js\"></script>
|
| 160 |
+
<script>
|
| 161 |
+
const map = L.map('map').setView([20, 0], 2);
|
| 162 |
+
L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{
|
| 163 |
+
maxZoom: 6,
|
| 164 |
+
attribution: '© OpenStreetMap contributors'
|
| 165 |
+
}}).addTo(map);
|
| 166 |
+
|
| 167 |
+
let velocityLayer = null;
|
| 168 |
+
let pointLayer = null;
|
| 169 |
+
|
| 170 |
+
async function load(hour) {{
|
| 171 |
+
if (velocityLayer) {{ map.removeLayer(velocityLayer); velocityLayer = null; }}
|
| 172 |
+
if (pointLayer) {{ map.removeLayer(pointLayer); pointLayer = null; }}
|
| 173 |
+
|
| 174 |
+
const res = await fetch(`/data/velocity?hour=${{hour}}`);
|
| 175 |
+
if (!res.ok) {{ alert('Failed to fetch data'); return; }}
|
| 176 |
+
const payload = await res.json();
|
| 177 |
+
|
| 178 |
+
if (payload.type === 'points') {{
|
| 179 |
+
// Fallback: draw particle-like markers with direction
|
| 180 |
+
const features = payload.points.map(p => {{
|
| 181 |
+
const dir = p.wave_direction ?? 0;
|
| 182 |
+
const len = (p.wave_height ?? 0.5) * 50000; // scale for arrow length
|
| 183 |
+
const rad = dir * Math.PI/180.0;
|
| 184 |
+
const dx = Math.sin(rad) * len;
|
| 185 |
+
const dy = -Math.cos(rad) * len;
|
| 186 |
+
return L.polyline([[p.lat, p.lon], [p.lat + dy/1e6, p.lon + dx/1e6]], {{ color: '#0aa', weight: 1, opacity: 0.8 }});
|
| 187 |
+
});
|
| 188 |
+
pointLayer = L.layerGroup(features).addTo(map);
|
| 189 |
+
}} else {{
|
| 190 |
+
try {{
|
| 191 |
+
// Expected: array of two GRIB-like records (u and v)
|
| 192 |
+
velocityLayer = L.velocityLayer({{
|
| 193 |
+
displayValues: true,
|
| 194 |
+
displayOptions: {{ velocityType: 'Wave', position: 'bottomleft', emptyString: 'No wave data' }},
|
| 195 |
+
data: payload,
|
| 196 |
+
maxVelocity: 5,
|
| 197 |
+
velocityScale: 0.02,
|
| 198 |
+
particleAge: 60,
|
| 199 |
+
particleMultiplier: 0.01
|
| 200 |
+
}});
|
| 201 |
+
velocityLayer.addTo(map);
|
| 202 |
+
}} catch (e) {{
|
| 203 |
+
console.warn('Velocity layer failed, falling back to points:', e);
|
| 204 |
+
const res2 = await fetch(`/data/points?hour=${{hour}}`);
|
| 205 |
+
const payload2 = await res2.json();
|
| 206 |
+
const features = payload2.points.map(p => {{
|
| 207 |
+
const dir = p.wave_direction ?? 0;
|
| 208 |
+
const len = (p.wave_height ?? 0.5) * 50000;
|
| 209 |
+
const rad = dir * Math.PI/180.0;
|
| 210 |
+
const dx = Math.sin(rad) * len;
|
| 211 |
+
const dy = -Math.cos(rad) * len;
|
| 212 |
+
return L.polyline([[p.lat, p.lon], [p.lat + dy/1e6, p.lon + dx/1e6]], {{ color: '#0aa', weight: 1, opacity: 0.8 }});
|
| 213 |
+
}});
|
| 214 |
+
pointLayer = L.layerGroup(features).addTo(map);
|
| 215 |
+
}}
|
| 216 |
+
}}
|
| 217 |
+
}}
|
| 218 |
+
|
| 219 |
+
document.getElementById('load').onclick = () => {{
|
| 220 |
+
const h = parseInt(document.getElementById('hour').value || '0', 10);
|
| 221 |
+
load(h);
|
| 222 |
+
}};
|
| 223 |
+
</script>
|
| 224 |
+
</body>
|
| 225 |
+
</html>
|
| 226 |
+
"""
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
with gr.Blocks(title="Wave Visualizer") as demo:
|
| 230 |
+
gr.HTML(leaflet_html())
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
# Mount Gradio UI on root path
|
| 234 |
+
from gradio.routes import mount_gradio_app
|
| 235 |
+
|
| 236 |
+
app = mount_gradio_app(app, demo, path="/")
|
backend/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Backend package for wave visualizer."""
|
| 2 |
+
|
backend/grib_wave_puller.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Vendored GRIBWavePuller from NWPS_SWAN project (trimmed only for runtime import here).
|
| 3 |
+
If Arctic-specific helpers are missing, the puller will log and continue with fallbacks.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
import sys
|
| 8 |
+
import tempfile
|
| 9 |
+
import logging
|
| 10 |
+
import subprocess
|
| 11 |
+
from datetime import datetime, timedelta
|
| 12 |
+
import numpy as np
|
| 13 |
+
import xarray as xr
|
| 14 |
+
from ecmwf.opendata import Client
|
| 15 |
+
import requests
|
| 16 |
+
|
| 17 |
+
logging.basicConfig(level=logging.INFO)
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
# Optional Arctic handler
|
| 21 |
+
try:
|
| 22 |
+
from arctic_grib_handler import ArcticGRIBHandler # type: ignore
|
| 23 |
+
ARCTIC_HANDLER_AVAILABLE = True
|
| 24 |
+
logger.info("✅ Arctic GRIB Handler loaded (Docker production version)")
|
| 25 |
+
except Exception as e:
|
| 26 |
+
logger.warning(f"Arctic GRIB handler not available: {e}")
|
| 27 |
+
ARCTIC_HANDLER_AVAILABLE = False
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class GRIBWavePuller:
|
| 31 |
+
def __init__(self):
|
| 32 |
+
self.client = Client("ecmwf")
|
| 33 |
+
self.output_dir = os.getenv('OUTPUT_DIR', '/tmp/wave_data')
|
| 34 |
+
os.makedirs(self.output_dir, exist_ok=True)
|
| 35 |
+
self._setup_eccodes_environment()
|
| 36 |
+
|
| 37 |
+
def _setup_eccodes_environment(self):
|
| 38 |
+
try:
|
| 39 |
+
os.environ['ECCODES_GRIB_STRICT_PARSING'] = '0'
|
| 40 |
+
os.environ['ECCODES_GRIB_IGNORE_GRID_DEFINITION'] = '1'
|
| 41 |
+
except Exception:
|
| 42 |
+
pass
|
| 43 |
+
|
| 44 |
+
def fetch_ecmwf_wave_grib(self, forecast_time=0):
|
| 45 |
+
try:
|
| 46 |
+
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.grib2')
|
| 47 |
+
try:
|
| 48 |
+
self.client.retrieve(
|
| 49 |
+
type="fc",
|
| 50 |
+
param=["swh"],
|
| 51 |
+
time=0,
|
| 52 |
+
step=forecast_time,
|
| 53 |
+
target=temp_file.name,
|
| 54 |
+
)
|
| 55 |
+
return temp_file.name
|
| 56 |
+
except Exception:
|
| 57 |
+
if os.path.exists(temp_file.name):
|
| 58 |
+
os.unlink(temp_file.name)
|
| 59 |
+
return None
|
| 60 |
+
except Exception:
|
| 61 |
+
return None
|
| 62 |
+
|
| 63 |
+
def fetch_noaa_wave_grib(self, forecast_hour=0):
|
| 64 |
+
"""Fetch NOAA WW3 files (regional + global attempt). Returns list of (path, region, run, fh)."""
|
| 65 |
+
try:
|
| 66 |
+
base_url = "https://nomads.ncep.noaa.gov/pub/data/nccf/com/gfs/prod"
|
| 67 |
+
now = datetime.utcnow()
|
| 68 |
+
dates_to_try = [
|
| 69 |
+
now.strftime("%Y%m%d"),
|
| 70 |
+
(now - timedelta(days=1)).strftime("%Y%m%d"),
|
| 71 |
+
(now - timedelta(days=2)).strftime("%Y%m%d"),
|
| 72 |
+
]
|
| 73 |
+
if now.hour >= 18:
|
| 74 |
+
preferred_runs = ["18", "12", "06", "00"]
|
| 75 |
+
elif now.hour >= 12:
|
| 76 |
+
preferred_runs = ["12", "06", "00", "18"]
|
| 77 |
+
elif now.hour >= 6:
|
| 78 |
+
preferred_runs = ["06", "00", "18", "12"]
|
| 79 |
+
else:
|
| 80 |
+
preferred_runs = ["00", "18", "12", "06"]
|
| 81 |
+
|
| 82 |
+
for date_str in dates_to_try:
|
| 83 |
+
for hour in preferred_runs:
|
| 84 |
+
successful = []
|
| 85 |
+
regional_files = [
|
| 86 |
+
(f"gfswave.t{hour}z.atlocn.0p16.f{forecast_hour:03d}.grib2", "Atlantic"),
|
| 87 |
+
(f"gfswave.t{hour}z.epacif.0p16.f{forecast_hour:03d}.grib2", "East_Pacific"),
|
| 88 |
+
(f"gfswave.t{hour}z.arctic.9km.f{forecast_hour:03d}.grib2", "Arctic"),
|
| 89 |
+
(f"gfswave.t{hour}z.global.0p16.f{forecast_hour:03d}.grib2", "Global"),
|
| 90 |
+
]
|
| 91 |
+
for filename, region_name in regional_files:
|
| 92 |
+
url = f"{base_url}/gfs.{date_str}/{hour}/wave/gridded/{filename}"
|
| 93 |
+
try:
|
| 94 |
+
tf = tempfile.NamedTemporaryFile(delete=False, suffix='.grib2')
|
| 95 |
+
r = requests.get(url, timeout=300)
|
| 96 |
+
if r.status_code == 200:
|
| 97 |
+
tf.write(r.content)
|
| 98 |
+
tf.close()
|
| 99 |
+
successful.append((tf.name, region_name, hour, forecast_hour))
|
| 100 |
+
else:
|
| 101 |
+
tf.close(); os.unlink(tf.name)
|
| 102 |
+
except Exception:
|
| 103 |
+
try:
|
| 104 |
+
tf.close(); os.unlink(tf.name)
|
| 105 |
+
except Exception:
|
| 106 |
+
pass
|
| 107 |
+
continue
|
| 108 |
+
if successful:
|
| 109 |
+
return successful
|
| 110 |
+
return None
|
| 111 |
+
except Exception:
|
| 112 |
+
return None
|
| 113 |
+
|
| 114 |
+
def process_grib_file(self, grib_file_path, region_name=None):
|
| 115 |
+
try:
|
| 116 |
+
ds = xr.open_dataset(grib_file_path, engine='cfgrib', decode_timedelta=True)
|
| 117 |
+
except Exception:
|
| 118 |
+
return None, None
|
| 119 |
+
|
| 120 |
+
# Identify variables
|
| 121 |
+
vars_map = {name: ds[name] for name in ds.variables}
|
| 122 |
+
wave_var = None
|
| 123 |
+
for cand in ['swh', 'HTSGW', 'htsgw']:
|
| 124 |
+
if cand in vars_map:
|
| 125 |
+
wave_var = cand; break
|
| 126 |
+
if wave_var is None:
|
| 127 |
+
# fallback heuristic
|
| 128 |
+
for n in vars_map:
|
| 129 |
+
if 'wave' in n.lower() and 'height' in n.lower():
|
| 130 |
+
wave_var = n; break
|
| 131 |
+
if wave_var is None:
|
| 132 |
+
ds.close()
|
| 133 |
+
return None, None
|
| 134 |
+
|
| 135 |
+
wave_heights = vars_map[wave_var].values
|
| 136 |
+
|
| 137 |
+
wave_dir = None
|
| 138 |
+
for cand in ['dirpw', 'DIRPW', 'dp', 'wvdir', 'WVDIR', 'dir']:
|
| 139 |
+
if cand in vars_map:
|
| 140 |
+
wave_dir = vars_map[cand].values; break
|
| 141 |
+
|
| 142 |
+
wave_per = None
|
| 143 |
+
for cand in ['perpw', 'PERPW', 'tp', 'wvper', 'WVPER', 'per']:
|
| 144 |
+
if cand in vars_map:
|
| 145 |
+
wave_per = vars_map[cand].values; break
|
| 146 |
+
|
| 147 |
+
lats = ds.latitude.values if 'latitude' in ds else ds.lat.values
|
| 148 |
+
lons = ds.longitude.values if 'longitude' in ds else ds.lon.values
|
| 149 |
+
|
| 150 |
+
# Sample points (downsample for visualization)
|
| 151 |
+
lon_grid, lat_grid = np.meshgrid(lons, lats)
|
| 152 |
+
flat_lats = lat_grid.flatten()
|
| 153 |
+
flat_lons = lon_grid.flatten()
|
| 154 |
+
flat_waves = wave_heights.flatten()
|
| 155 |
+
mask = ~np.isnan(flat_waves)
|
| 156 |
+
if wave_dir is not None:
|
| 157 |
+
mask &= ~np.isnan(wave_dir.flatten())
|
| 158 |
+
idx = np.random.choice(np.where(mask)[0], size=min(1000, mask.sum()), replace=False) if mask.any() else np.array([])
|
| 159 |
+
|
| 160 |
+
points = []
|
| 161 |
+
for i in idx:
|
| 162 |
+
point = {
|
| 163 |
+
'lat': float(flat_lats[i]),
|
| 164 |
+
'lon': float(flat_lons[i]),
|
| 165 |
+
'wave_height': float(flat_waves[i]),
|
| 166 |
+
}
|
| 167 |
+
if wave_dir is not None:
|
| 168 |
+
d = float(wave_dir.flatten()[i])
|
| 169 |
+
point['wave_direction'] = d
|
| 170 |
+
mag = point['wave_height'] * 0.1
|
| 171 |
+
rad = np.deg2rad(d)
|
| 172 |
+
point['u_component'] = float(mag * np.sin(rad))
|
| 173 |
+
point['v_component'] = float(-mag * np.cos(rad))
|
| 174 |
+
if wave_per is not None:
|
| 175 |
+
point['wave_period'] = float(wave_per.flatten()[i])
|
| 176 |
+
points.append(point)
|
| 177 |
+
|
| 178 |
+
data = {
|
| 179 |
+
'timestamp': datetime.utcnow().isoformat(),
|
| 180 |
+
'data_source': 'NOAA_GRIB' if 'gfswave' in os.path.basename(grib_file_path) else 'GRIB',
|
| 181 |
+
'parameters_found': {
|
| 182 |
+
'wave_height': wave_var,
|
| 183 |
+
'wave_direction': 'present' if wave_dir is not None else None,
|
| 184 |
+
'wave_period': 'present' if wave_per is not None else None,
|
| 185 |
+
'has_velocity_components': wave_dir is not None,
|
| 186 |
+
},
|
| 187 |
+
'grid_info': {
|
| 188 |
+
'lat_min': float(np.nanmin(lats)),
|
| 189 |
+
'lat_max': float(np.nanmax(lats)),
|
| 190 |
+
'lon_min': float(np.nanmin(lons)),
|
| 191 |
+
'lon_max': float(np.nanmax(lons)),
|
| 192 |
+
'grid_shape': list(wave_heights.shape),
|
| 193 |
+
},
|
| 194 |
+
'sample_points': points,
|
| 195 |
+
}
|
| 196 |
+
# Optional: include a downsampled U/V grid for velocity layers
|
| 197 |
+
try:
|
| 198 |
+
if wave_dir is not None:
|
| 199 |
+
# Compute U/V on the native grid
|
| 200 |
+
# Determine reasonable downsample strides to keep <= ~360x180
|
| 201 |
+
ny, nx = wave_heights.shape
|
| 202 |
+
sy = max(1, ny // 180)
|
| 203 |
+
sx = max(1, nx // 360)
|
| 204 |
+
lats_ds = lats[::sy]
|
| 205 |
+
lons_ds = lons[::sx]
|
| 206 |
+
# Align 2D arrays for downsample
|
| 207 |
+
wh_ds = wave_heights[::sy, ::sx]
|
| 208 |
+
wd_ds = wave_dir[::sy, ::sx]
|
| 209 |
+
# Compute U/V (scale ~0.1 for visualization)
|
| 210 |
+
dir_rad = np.deg2rad(wd_ds)
|
| 211 |
+
mag = np.clip(wh_ds, 0, np.nanmax(wh_ds)) * 0.1
|
| 212 |
+
u_ds = mag * np.sin(dir_rad)
|
| 213 |
+
v_ds = -mag * np.cos(dir_rad)
|
| 214 |
+
data['grid_uv'] = {
|
| 215 |
+
'lats': lats_ds.tolist() if hasattr(lats_ds, 'tolist') else list(map(float, lats_ds)),
|
| 216 |
+
'lons': lons_ds.tolist() if hasattr(lons_ds, 'tolist') else list(map(float, lons_ds)),
|
| 217 |
+
'u': u_ds.tolist(),
|
| 218 |
+
'v': v_ds.tolist(),
|
| 219 |
+
}
|
| 220 |
+
except Exception:
|
| 221 |
+
# If any step fails, just skip embedding grid_uv
|
| 222 |
+
pass
|
| 223 |
+
ds.close()
|
| 224 |
+
return data, grib_file_path
|
| 225 |
+
|
| 226 |
+
def process_multiple_regional_files(self, regional_files):
|
| 227 |
+
combined = []
|
| 228 |
+
for path, region_name, *_ in regional_files:
|
| 229 |
+
try:
|
| 230 |
+
res, _ = self.process_grib_file(path, region_name=region_name)
|
| 231 |
+
if res and 'sample_points' in res:
|
| 232 |
+
combined.extend(res['sample_points'])
|
| 233 |
+
finally:
|
| 234 |
+
try:
|
| 235 |
+
if os.path.exists(path):
|
| 236 |
+
os.unlink(path)
|
| 237 |
+
except Exception:
|
| 238 |
+
pass
|
| 239 |
+
if not combined:
|
| 240 |
+
return None
|
| 241 |
+
return {
|
| 242 |
+
'timestamp': datetime.utcnow().isoformat(),
|
| 243 |
+
'data_source': 'NOAA_MULTI_REGIONAL_GRIB',
|
| 244 |
+
'parameters_found': {'has_velocity_components': True},
|
| 245 |
+
'grid_info': {},
|
| 246 |
+
'sample_points': combined,
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
def fetch_global_wave_data(self, forecast_hour=0):
|
| 250 |
+
result = self.fetch_noaa_wave_grib(forecast_hour)
|
| 251 |
+
if isinstance(result, list) and result:
|
| 252 |
+
if any(region == 'Global' for _, region, *_ in result):
|
| 253 |
+
# Prefer the global grid if present
|
| 254 |
+
global_entry = next((t for t in result if t[1] == 'Global'), None)
|
| 255 |
+
if global_entry:
|
| 256 |
+
data, _ = self.process_grib_file(global_entry[0], region_name='Global')
|
| 257 |
+
return data
|
| 258 |
+
# Otherwise combine sample points from regions
|
| 259 |
+
return self.process_multiple_regional_files(result)
|
| 260 |
+
|
| 261 |
+
# Fallback ECMWF (may not include waves)
|
| 262 |
+
grib_file = self.fetch_ecmwf_wave_grib(forecast_hour)
|
| 263 |
+
if grib_file:
|
| 264 |
+
data, _ = self.process_grib_file(grib_file)
|
| 265 |
+
try:
|
| 266 |
+
os.unlink(grib_file)
|
| 267 |
+
except Exception:
|
| 268 |
+
pass
|
| 269 |
+
return data
|
| 270 |
+
return None
|
requirements.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.111.0
|
| 2 |
+
gradio==4.44.0
|
| 3 |
+
uvicorn[standard]==0.30.6
|
| 4 |
+
numpy==1.26.4
|
| 5 |
+
xarray==2024.2.0
|
| 6 |
+
cfgrib==0.9.14.1
|
| 7 |
+
eccodes==1.6.1
|
| 8 |
+
requests==2.32.3
|
| 9 |
+
ecmwf-opendata==0.3.3
|