nakas/waveVisualizer-Codex
0
1import os2import json3from datetime import datetime4import math5from functools import lru_cache6from typing import Dict, Any, List7 8import numpy as np9from fastapi import FastAPI, Query10from fastapi.responses import JSONResponse, HTMLResponse11import gradio as gr12 13# Local import: vendored from working project14from backend.grib_wave_puller import GRIBWavePuller15 16 17app = FastAPI(title="Wave Visualizer API")18 19 20def _compute_uv_from_wave(height: np.ndarray, direction_deg: np.ndarray, scale: float = 0.1):21 """Compute U/V components from wave height and meteorological 'from' direction.22 23 - height: significant wave height array (m)24 - direction_deg: wave direction (deg, meteorological, coming from)25 - scale: visualization scaling factor26 """27 dir_rad = np.deg2rad(direction_deg)28 mag = np.clip(height, 0, np.nanmax(height)) * scale29 # Eastward (u) and northward (v) components; negative on v because 'from'30 u = mag * np.sin(dir_rad)31 v = -mag * np.cos(dir_rad)32 return u, v33 34 35def _build_velocity_grib_json(lats: np.ndarray, lons: np.ndarray, u: np.ndarray, v: np.ndarray, ref_time: str) -> List[Dict[str, Any]]:36 """Build leaflet-velocity compatible JSON (Wind/Earth GRIB-like format).37 38 Data must be provided on a regular lat-lon grid. Arrays are 2D with shape (ny, nx)39 where ny=len(lats), nx=len(lons). Latitude should be provided in descending order40 (north to south) to match common GRIB conventions; reorder if needed.41 """42 # Ensure 1D coordinate arrays43 lats_1d = lats if lats.ndim == 1 else lats[:, 0]44 lons_1d = lons if lons.ndim == 1 else lons[0, :]45 46 ny = int(len(lats_1d))47 nx = int(len(lons_1d))48 49 # If latitude increases northward, reverse to north->south50 if ny > 1 and lats_1d[0] < lats_1d[-1]:51 lats_1d = lats_1d[::-1]52 u = np.flipud(u)53 v = np.flipud(v)54 55 # Normalize longitudes to [-180, 180) to avoid 0..360 grids causing clipping56 # Then ensure they ascend west->east and reorder u/v columns accordingly.57 if nx > 1:58 lons_wrapped = ((np.asarray(lons_1d, dtype=float) + 180.0) % 360.0) - 180.059 order = np.argsort(lons_wrapped)60 lons_1d = lons_wrapped[order]61 if u.ndim == 2 and v.ndim == 2 and u.shape[1] == nx and v.shape[1] == nx:62 u = u[:, order]63 v = v[:, order]64 65 la1 = float(lats_1d[0])66 la2 = float(lats_1d[-1])67 lo1 = float(lons_1d[0])68 lo2 = float(lons_1d[-1])69 70 # Grid spacing (approx)71 dy = float(abs(lats_1d[1] - lats_1d[0])) if ny > 1 else 0.072 dx = float(abs(lons_1d[1] - lons_1d[0])) if nx > 1 else 0.073 74 # Sanitize arrays: replace NaN/Inf with zeros for JSON compliance75 u = np.nan_to_num(np.asarray(u, dtype=float), nan=0.0, posinf=0.0, neginf=0.0)76 v = np.nan_to_num(np.asarray(v, dtype=float), nan=0.0, posinf=0.0, neginf=0.0)77 78 # Optional clamp to reasonable range (avoid absurd values)79 # Here, clamp to [-20, 20] m/s just for safety in visualization80 u = np.clip(u, -20.0, 20.0)81 v = np.clip(v, -20.0, 20.0)82 83 # Flatten row-major (lat-major first, then lon) matching header84 u_data = u.flatten().tolist()85 v_data = v.flatten().tolist()86 87 header_common = {88 "lo1": lo1,89 "la1": la1,90 "lo2": lo2,91 "la2": la2,92 "nx": nx,93 "ny": ny,94 "dx": dx,95 "dy": dy,96 "refTime": ref_time,97 }98 99 u_record = {100 "header": {101 **header_common,102 "parameterCategory": 2,103 "parameterNumber": 2, # U component104 "parameterUnit": "m/s",105 },106 "data": u_data,107 }108 109 v_record = {110 "header": {111 **header_common,112 "parameterCategory": 2,113 "parameterNumber": 3, # V component114 "parameterUnit": "m/s",115 },116 "data": v_data,117 }118 119 return [u_record, v_record]120 121 122@lru_cache(maxsize=16)123def get_puller() -> GRIBWavePuller:124 return GRIBWavePuller()125 126 127@app.get("/data/points")128def data_points(hour: int = Query(0, ge=0, le=240)):129 puller = get_puller()130 result = puller.fetch_global_wave_data(hour)131 if not result:132 return JSONResponse(status_code=503, content={"error": "No data available"})133 134 def _sanitize(obj):135 if isinstance(obj, dict):136 return {k: _sanitize(v) for k, v in obj.items()}137 if isinstance(obj, list):138 return [_sanitize(v) for v in obj]139 if isinstance(obj, (np.floating,)):140 v = float(obj)141 return None if not math.isfinite(v) else v142 if isinstance(obj, (np.integer,)):143 return int(obj)144 if isinstance(obj, float):145 return None if not math.isfinite(obj) else obj146 return obj147 148 payload = {149 "type": "points",150 "refTime": result.get("timestamp"),151 "points": result.get("sample_points", []),152 }153 return JSONResponse(content=_sanitize(payload))154 155 156@app.get("/data/velocity")157def data_velocity(hour: int = Query(0, ge=0, le=240), scale: float = Query(0.1)):158 puller = get_puller()159 result = puller.fetch_global_wave_data(hour)160 if not result:161 return JSONResponse(status_code=503, content={"error": "No data available"})162 163 # If we have a downsampled UV grid, return leaflet-velocity JSON164 grid_uv = result.get("grid_uv")165 if grid_uv:166 lats = np.array(grid_uv['lats'])167 lons = np.array(grid_uv['lons'])168 u = np.array(grid_uv['u'])169 v = np.array(grid_uv['v'])170 # Validate grid content; if empty or trivial, fall back to points171 if (172 u.size < 16 or v.size < 16 or173 not np.isfinite(u).any() or not np.isfinite(v).any() or174 (np.nanmax(np.abs(u)) < 1e-6 and np.nanmax(np.abs(v)) < 1e-6)175 ):176 sample_points = result.get("sample_points", [])177 return JSONResponse(content={"type": "points", "refTime": result.get("timestamp"), "points": sample_points})178 payload = _build_velocity_grib_json(lats, lons, u, v, ref_time=result.get("timestamp", datetime.utcnow().isoformat()))179 return JSONResponse(content=payload)180 181 # Fallback to points if no grid is present182 sample_points = result.get("sample_points", [])183 payload = {"type": "points", "refTime": result.get("timestamp"), "points": sample_points}184 # Sanitize for JSON compliance185 def _san(obj):186 if isinstance(obj, dict):187 return {k: _san(v) for k, v in obj.items()}188 if isinstance(obj, list):189 return [_san(v) for v in obj]190 if isinstance(obj, (np.floating,)):191 v = float(obj)192 return None if not math.isfinite(v) else v193 if isinstance(obj, (np.integer,)):194 return int(obj)195 if isinstance(obj, float):196 return None if not math.isfinite(obj) else obj197 return obj198 return JSONResponse(content=_san(payload))199 200 201def leaflet_html() -> str:202 return """203<!doctype html>204<html>205 <head>206 <meta charset=\"utf-8\" />207 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\" />208 <link rel=\"stylesheet\" href=\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.css\" />209 <style>210 html, body, #map { height: 100%; margin: 0; }211 .leaflet-control-container .leaflet-top.leaflet-left { z-index: 1000; }212 .control { position:absolute; top:10px; left:10px; z-index:1000; background:#fff; padding:8px; border-radius:4px; box-shadow:0 1px 3px rgba(0,0,0,0.3); pointer-events:auto; }213 /* Animated flow for arrow polylines */214 @keyframes arrow-dash {215 0% { stroke-dashoffset: 0; }216 100% { stroke-dashoffset: -20; }217 }218 /* Leaflet renders polylines as SVG paths */219 .leaflet-overlay-pane path.arrow-line {220 vector-effect: non-scaling-stroke;221 fill: none;222 stroke-linecap: butt;223 stroke-dasharray: 6 10;224 animation: arrow-dash 1.2s linear infinite;225 }226 </style>227 </head>228 <body>229 <div id=\"map\"></div>230 <div class=\"control\">231 <label>Forecast hour: <input type=\"number\" id=\"hour\" min=\"0\" max=\"240\" step=\"6\" value=\"0\" /></label>232 <button id=\"load\">Load Waves</button>233 <span id=\"status\" style=\"margin-left:8px; font-size:12px; color:#333\"></span>234 </div>235 <script src=\"https://unpkg.com/leaflet@1.9.4/dist/leaflet.js\"></script>236 <!-- Use a known-good Leaflet-Velocity build -->237 <script src=\"https://cdn.jsdelivr.net/npm/leaflet-velocity@1.8.0/dist/leaflet-velocity.min.js\"></script>238 <script>239 const map = L.map('map').setView([20, 0], 2);240 L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {241 maxZoom: 6,242 attribution: '© OpenStreetMap contributors'243 }).addTo(map);244 245 let velocityLayer = null;246 let pointLayer = null;247 248 // Create a short dash polyline with attached metadata (wind-like particle)249 function mkArrow(p, opts = {}) {250 const dir = p.wave_direction ?? 0;251 const height = p.wave_height ?? 0.5;252 const period = p.wave_period ?? null;253 // Keep segments short to resemble particles rather than long arrows254 const len = Math.max(15000, Math.min(60000, height * 35000)); // meters255 const rad = dir * Math.PI/180.0;256 const dx = Math.sin(rad) * len;257 const dy = -Math.cos(rad) * len;258 const poly = L.polyline(259 [[p.lat, p.lon], [p.lat + dy/1e6, p.lon + dx/1e6]],260 { color: '#e5242a', weight: 1.0, opacity: 0.9, className: 'arrow-line', ...opts }261 );262 // Attach metadata so we can tune styles after added to map263 poly._waveMeta = { height, period, direction: dir };264 return poly;265 }266 267 // Tune per-feature animation speed/appearance using period and height268 function tuneArrowStyles(group) {269 if (!group) return;270 const tune = (layer) => {271 const el = (layer.getElement && layer.getElement()) || layer._path;272 if (!el || !layer._waveMeta) return;273 const { height, period } = layer._waveMeta;274 // Stroke weight by height (clamped)275 const w = Math.max(0.6, Math.min(1.6, 0.8 + (height || 0) * 0.25));276 layer.setStyle && layer.setStyle({ weight: w });277 // Particle-like short dashes278 const dashLen = Math.max(2, Math.min(10, 3 + (height || 0) * 1.2));279 const gapLen = Math.round(dashLen * 1.4);280 el.style.strokeDasharray = `${dashLen} ${gapLen}`;281 // Animation speed by period: longer period => faster flow (shorter duration)282 let dur;283 if (period && isFinite(period)) {284 // Map 2s..20s -> 1.2s..0.5s duration for livelier particles285 const p = Math.max(2, Math.min(20, period));286 dur = 1.2 - (p - 2) * ((1.2 - 0.5) / (20 - 2));287 } else {288 dur = 0.9; // default289 }290 el.style.animationDuration = `${dur.toFixed(2)}s`;291 };292 // Defer slightly to ensure SVG paths exist293 setTimeout(() => {294 group.eachLayer(tune);295 }, 0);296 }297 298 async function load(hour) {299 if (velocityLayer) { map.removeLayer(velocityLayer); velocityLayer = null; }300 if (pointLayer) { map.removeLayer(pointLayer); pointLayer = null; }301 const status = document.getElementById('status');302 status.textContent = 'Loading...';303 304 const res = await fetch(`/data/velocity?hour=${hour}`);305 if (!res.ok) { alert('Failed to fetch data'); return; }306 const payload = await res.json();307 console.log('velocity payload', payload);308 309 if (payload && payload.type === 'points') {310 // Fallback: draw particle-like markers with direction311 const features = payload.points.map(p => mkArrow(p));312 pointLayer = L.layerGroup(features).addTo(map);313 tuneArrowStyles(pointLayer);314 status.textContent = `Rendered ${features.length} wave arrows`;315 } else {316 try {317 // Expected: array of two GRIB-like records (u and v)318 if (Array.isArray(payload) && payload.length >= 2 && payload[0].data && payload[0].data.length) {319 // Quick sanity check: some non-zero magnitudes320 const sample = payload[0].data.slice(0, 200);321 const nz = sample.reduce((acc, v) => acc + Math.abs(v), 0);322 if (nz < 1e-3) {323 throw new Error('Velocity grid near-zero; fallback to points');324 }325 velocityLayer = L.velocityLayer({326 data: payload,327 displayValues: true,328 displayOptions: {329 velocityType: 'Wave',330 position: 'bottomleft',331 emptyString: 'No wave data',332 speedUnit: 'm/s',333 angleConvention: 'bearingCW',334 showCardinal: true335 },336 // Settings aligned with the working wind demo337 velocityScale: 0.01,338 opacity: 0.9,339 maxVelocity: 20,340 particleMultiplier: 0.002,341 lineWidth: 1.2,342 frameRate: 15,343 particleAge: 40,344 fadeOpacity: 0,345 animationDuration: 0,346 // Remove strict bounds/wrap to support 0..360 or -180..180 grids347 // Red gradient color scale348 colorScale: [349 "#4c0000", "#660000", "#800000", "#990000", "#b30000",350 "#cc0000", "#e60000", "#ff0000", "#ff3333", "#ff6666", "#ff9999"351 ],352 });353 velocityLayer.addTo(map);354 status.textContent = 'Velocity layer active';355 // Also overlay a sparse set of arrows for immediate visual feedback356 try {357 const resPts = await fetch(`/data/points?hour=${hour}`);358 if (resPts.ok) {359 const pld = await resPts.json();360 const pts = (pld.points || []).slice(0, 300);361 const arrs = pts.map(p => mkArrow(p, { color: '#e5242a', opacity: 0.85 }));362 pointLayer = L.layerGroup(arrs).addTo(map);363 tuneArrowStyles(pointLayer);364 }365 } catch (e2) { console.warn('arrow overlay failed', e2); }366 } else {367 // Final fallback: fetch points explicitly368 const res2 = await fetch(`/data/points?hour=${hour}`);369 if (res2.ok) {370 const payload2 = await res2.json();371 console.log('points payload', payload2);372 const features = (payload2.points || []).map(p => mkArrow(p));373 if (features.length) {374 pointLayer = L.layerGroup(features).addTo(map);375 tuneArrowStyles(pointLayer);376 status.textContent = `Rendered ${features.length} wave arrows`;377 } else {378 status.textContent = 'No wave data available';379 }380 } else {381 status.textContent = 'Failed to fetch data';382 }383 }384 } catch (e) {385 console.warn('Velocity layer failed, falling back to points:', e);386 const res2 = await fetch(`/data/points?hour=${hour}`);387 const payload2 = await res2.json();388 const features = payload2.points.map(p => mkArrow(p));389 pointLayer = L.layerGroup(features).addTo(map);390 tuneArrowStyles(pointLayer);391 status.textContent = `Rendered ${features.length} wave arrows`;392 }393 }394 }395 396 document.getElementById('load').onclick = () => {397 const h = parseInt(document.getElementById('hour').value || '0', 10);398 load(h);399 };400 </script>401 </body>402 </html>403 """404 405 406@app.get("/map", response_class=HTMLResponse)407def map_page():408 return leaflet_html()409 410 411@app.get("/", response_class=HTMLResponse)412def root_page():413 return leaflet_html()414 415 416# Optional Gradio UI under /ui417with gr.Blocks(title="Wave Visualizer UI") as demo:418 gr.Markdown("# Wave Visualizer\nUse the link below to open the map page.")419 gr.HTML('<p><a href="/map" target="_blank">Open Map</a></p>')420 421from gradio.routes import mount_gradio_app422app = mount_gradio_app(app, demo, path="/ui")423 