nakas commited on
Commit
8474a22
·
1 Parent(s): ce04957

Add Leaflet velocity wave visualizer: FastAPI+Gradio app, NOAA WW3 puller, endpoints, Dockerfile, requirements

Browse files
Files changed (6) hide show
  1. .DS_Store +0 -0
  2. Dockerfile +24 -0
  3. app.py +236 -0
  4. backend/__init__.py +2 -0
  5. backend/grib_wave_puller.py +270 -0
  6. 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: '&copy; 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