Team Ai
Apppublic

nakas/waveVisualizer-Codex

sourceHugging Facecc-by-4.0updated 1y agoView on Hugging Face
0likes
app.py423 linesDownload Raw Back to root
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: '&copy; 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