nakas Claude commited on
Commit
276ecd7
Β·
1 Parent(s): 2ccd08d

Implement complete ECMWF wind particle visualization with Gradio interface

Browse files

- Add real ECMWF 10m wind data fetching (U/V components)
- Implement windy-style particle animation using Leaflet-Velocity
- Create TimestampedGeoJson alternative visualization method
- Add RK4 integration for accurate particle advection
- Include bilinear interpolation for smooth wind field sampling
- Support multiple regions (global, North America, Europe)
- Add interactive Folium maps with multiple tile layers
- Configure Docker deployment for Hugging Face Spaces
- Include comprehensive documentation and setup instructions

πŸ€– Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>

Files changed (4) hide show
  1. Dockerfile +35 -0
  2. README.md +104 -0
  3. app.py +946 -0
  4. requirements.txt +13 -0
Dockerfile ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # Set working directory
4
+ WORKDIR /app
5
+
6
+ # Install system dependencies for GRIB processing
7
+ RUN apt-get update && apt-get install -y \
8
+ libeccodes-dev \
9
+ libeccodes-tools \
10
+ wget \
11
+ curl \
12
+ && rm -rf /var/lib/apt/lists/*
13
+
14
+ # Copy requirements file
15
+ COPY requirements.txt .
16
+
17
+ # Install Python dependencies
18
+ RUN pip install --no-cache-dir -r requirements.txt
19
+
20
+ # Copy application code
21
+ COPY app.py .
22
+
23
+ # Create temp directory for GRIB files
24
+ RUN mkdir -p /tmp/ecmwf_data
25
+
26
+ # Set environment variables
27
+ ENV PYTHONUNBUFFERED=1
28
+ ENV GRADIO_SERVER_NAME=0.0.0.0
29
+ ENV GRADIO_SERVER_PORT=7860
30
+
31
+ # Expose the port
32
+ EXPOSE 7860
33
+
34
+ # Run the application
35
+ CMD ["python", "app.py"]
README.md ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # πŸŒͺ️ ECMWF Wind Particle Visualization
2
+
3
+ A Gradio application that downloads real ECMWF 10m wind data and creates windy-style particle animations using Folium maps with Leaflet-Velocity plugin.
4
+
5
+ ## Features
6
+
7
+ - 🌍 **Real ECMWF Data**: Downloads operational 10m wind forecasts (U/V components)
8
+ - 🎨 **Windy-style Animation**: Canvas-based particle system using Leaflet-Velocity
9
+ - ⏱️ **Time Controls**: TimestampedGeoJson animation with interactive time slider
10
+ - πŸ—ΊοΈ **Interactive Maps**: Multiple tile layers and regional views
11
+ - 🎯 **Particle Physics**: RK4 integration for accurate wind field advection
12
+
13
+ ## Quick Start
14
+
15
+ ### Local Development
16
+
17
+ 1. Install dependencies:
18
+ ```bash
19
+ pip install -r requirements.txt
20
+ ```
21
+
22
+ 2. Run the application:
23
+ ```bash
24
+ python app.py
25
+ ```
26
+
27
+ 3. Open your browser to `http://localhost:7860`
28
+
29
+ ### Docker Deployment
30
+
31
+ 1. Build the Docker image:
32
+ ```bash
33
+ docker build -t ecmwf-wind-viz .
34
+ ```
35
+
36
+ 2. Run the container:
37
+ ```bash
38
+ docker run -p 7860:7860 ecmwf-wind-viz
39
+ ```
40
+
41
+ ### Hugging Face Spaces
42
+
43
+ This app is designed for deployment on Hugging Face Spaces:
44
+
45
+ 1. Create a new Space on Hugging Face
46
+ 2. Choose "Docker" as the SDK
47
+ 3. Upload the files: `app.py`, `requirements.txt`, `Dockerfile`, `README.md`
48
+ 4. The Space will automatically build and deploy
49
+
50
+ ## How It Works
51
+
52
+ ### Data Processing
53
+ 1. Downloads ECMWF 10m wind data (U/V components) in GRIB2 format
54
+ 2. Converts GRIB to grib2json-compatible velocity JSON format
55
+ 3. Implements bilinear interpolation for smooth wind field sampling
56
+
57
+ ### Particle Animation
58
+ 1. **Canvas Rendering**: Uses Leaflet-Velocity for real-time particle advection
59
+ 2. **TimestampedGeoJSON**: Pre-computed particle trajectories with time controls
60
+ 3. **RK4 Integration**: Accurate particle movement through wind field
61
+ 4. **Color Coding**: Particles colored by wind speed magnitude
62
+
63
+ ### Visualization Options
64
+ - **Global**: Worldwide wind patterns
65
+ - **North America**: Regional focus on North American winds
66
+ - **Europe**: European wind patterns
67
+ - **Particle Count**: Adjustable from 200-2000 particles
68
+
69
+ ## Technical Details
70
+
71
+ ### Data Source
72
+ - **ECMWF Open Data**: Free operational forecasts updated every 6 hours
73
+ - **Parameters**: 10m U-wind and V-wind components
74
+ - **Resolution**: 0.25Β° (~25km spacing)
75
+ - **Format**: GRIB2 converted to velocity JSON
76
+
77
+ ### Algorithms
78
+ - **Bilinear Interpolation**: Smooth wind field sampling between grid points
79
+ - **RK4 Integration**: 4th-order Runge-Kutta for particle advection
80
+ - **Coordinate Conversion**: Proper handling of geographic projections
81
+
82
+ ### Libraries
83
+ - **Gradio**: Web interface framework
84
+ - **Folium**: Python-Leaflet bridge for interactive maps
85
+ - **Leaflet-Velocity**: Canvas-based particle rendering
86
+ - **xarray/cfgrib**: GRIB data processing
87
+ - **ecmwf-opendata**: ECMWF data access
88
+
89
+ ## Inspired By
90
+
91
+ This implementation follows the same principles used by professional weather visualization platforms like:
92
+ - [Windy.com](https://www.windy.com/) - Visual style and particle behavior
93
+ - [earth.nullschool.net](https://earth.nullschool.net/) - Original particle flow visualization
94
+ - [Ventusky](https://www.ventusky.com/) - Weather animation techniques
95
+
96
+ ## License
97
+
98
+ This project is open source. ECMWF data is provided under CC BY 4.0 license.
99
+
100
+ ## Attribution
101
+
102
+ Weather data provided by ECMWF under their Open Data initiative:
103
+ - Data source: https://www.ecmwf.int/en/forecasts/datasets/open-data
104
+ - Data license: https://creativecommons.org/licenses/by/4.0/
app.py ADDED
@@ -0,0 +1,946 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ ECMWF Wind Particle Visualization
4
+ Windy-style particle animation using ECMWF 10m wind data
5
+
6
+ Features:
7
+ - Downloads real ECMWF 10m wind data (U and V components)
8
+ - Creates windy-style particle animation using Leaflet-Velocity
9
+ - TimestampedGeoJson animation as alternative visualization
10
+ - Interactive Folium map with time controls
11
+ """
12
+
13
+ import gradio as gr
14
+ import numpy as np
15
+ import pandas as pd
16
+ import xarray as xr
17
+ import requests
18
+ import tempfile
19
+ import os
20
+ import json
21
+ import math
22
+ import random
23
+ from datetime import datetime, timedelta, timezone
24
+ import warnings
25
+ import folium
26
+ from folium.plugins import TimestampedGeoJson
27
+ from branca.element import Element
28
+ warnings.filterwarnings('ignore')
29
+
30
+ try:
31
+ from ecmwf.opendata import Client as OpenDataClient
32
+ OPENDATA_AVAILABLE = True
33
+ except ImportError:
34
+ OPENDATA_AVAILABLE = False
35
+
36
+
37
+ class ECMWFWindDataFetcher:
38
+ """
39
+ Fetches ECMWF 10m wind data (U and V components) and converts to JSON format
40
+ compatible with Leaflet-Velocity for windy-style particle animation
41
+ """
42
+
43
+ def __init__(self):
44
+ self.temp_dir = tempfile.mkdtemp()
45
+ self.client = None
46
+ if OPENDATA_AVAILABLE:
47
+ try:
48
+ self.client = OpenDataClient()
49
+ except:
50
+ self.client = None
51
+
52
+ # AWS S3 direct access URLs
53
+ self.aws_base_url = "https://ecmwf-forecasts.s3.eu-central-1.amazonaws.com"
54
+
55
+ def get_latest_forecast_info(self):
56
+ """Get the latest available forecast run information"""
57
+ try:
58
+ now = datetime.utcnow()
59
+
60
+ # Find the most recent model run (data available 7-9 hours after run time)
61
+ for hours_back in range(4, 24, 6):
62
+ test_time = now - timedelta(hours=hours_back)
63
+
64
+ # Round to nearest 6-hour cycle
65
+ run_hour = (test_time.hour // 6) * 6
66
+ run_time = test_time.replace(hour=run_hour, minute=0, second=0, microsecond=0)
67
+
68
+ date_str = run_time.strftime("%Y%m%d")
69
+ time_str = f"{run_hour:02d}"
70
+
71
+ # Test if this run is available
72
+ test_url = f"{self.aws_base_url}/{date_str}/{time_str}z/0p25/oper/"
73
+ try:
74
+ response = requests.head(test_url, timeout=10)
75
+ if response.status_code in [200, 403]:
76
+ return date_str, time_str, run_time
77
+ except:
78
+ continue
79
+
80
+ # Fallback
81
+ return now.strftime("%Y%m%d"), "12", now
82
+
83
+ except Exception:
84
+ now = datetime.utcnow()
85
+ return now.strftime("%Y%m%d"), "12", now
86
+
87
+ def download_wind_component(self, parameter, step=0, max_retries=3):
88
+ """Download ECMWF wind component (10u or 10v)"""
89
+
90
+ date_str, time_str, run_time = self.get_latest_forecast_info()
91
+
92
+ # Method 1: Try ecmwf-opendata client
93
+ if OPENDATA_AVAILABLE and self.client:
94
+ try:
95
+ filename = os.path.join(self.temp_dir, f'ecmwf_{parameter}_{step}h_{datetime.now().strftime("%Y%m%d_%H%M%S")}.grib')
96
+
97
+ self.client.retrieve(
98
+ type="fc",
99
+ param=parameter,
100
+ step=step,
101
+ target=filename
102
+ )
103
+
104
+ if os.path.exists(filename) and os.path.getsize(filename) > 1000:
105
+ return filename, f"βœ… ECMWF {parameter} data downloaded successfully!"
106
+
107
+ except Exception as e:
108
+ print(f"Client method failed: {str(e)}")
109
+
110
+ # Method 2: Direct AWS S3 access
111
+ try:
112
+ step_str = f"{step:03d}"
113
+ grib_filename = f"{date_str}{time_str}0000-{step_str}h-oper-fc.grib2"
114
+ url = f"{self.aws_base_url}/{date_str}/{time_str}z/0p25/oper/{grib_filename}"
115
+
116
+ response = requests.get(url, timeout=120, stream=True)
117
+ if response.status_code == 200:
118
+ local_file = os.path.join(self.temp_dir, f'ecmwf_aws_{parameter}_{step}h.grib2')
119
+
120
+ with open(local_file, 'wb') as f:
121
+ for chunk in response.iter_content(chunk_size=8192):
122
+ f.write(chunk)
123
+
124
+ if os.path.getsize(local_file) > 1000:
125
+ return local_file, f"βœ… ECMWF {parameter} data downloaded via AWS S3!"
126
+
127
+ except Exception as e:
128
+ print(f"AWS method failed: {str(e)}")
129
+
130
+ return None, f"❌ Unable to download ECMWF {parameter} data at +{step}h"
131
+
132
+ def download_wind_data(self, forecast_steps=[0, 6, 12, 24]):
133
+ """Download both U and V wind components for multiple forecast steps"""
134
+ wind_data = {}
135
+
136
+ for step in forecast_steps:
137
+ try:
138
+ # Download U component (10u)
139
+ u_file, u_msg = self.download_wind_component("10u", step)
140
+
141
+ # Download V component (10v)
142
+ v_file, v_msg = self.download_wind_component("10v", step)
143
+
144
+ if u_file and v_file:
145
+ wind_data[step] = {
146
+ 'u_file': u_file,
147
+ 'v_file': v_file,
148
+ 'forecast_hour': step,
149
+ 'valid_time': datetime.utcnow() + timedelta(hours=step)
150
+ }
151
+ print(f"βœ… Successfully downloaded wind data for +{step}h")
152
+ else:
153
+ print(f"❌ Failed to download wind data for +{step}h")
154
+
155
+ except Exception as e:
156
+ print(f"Error downloading wind data for +{step}h: {str(e)}")
157
+ continue
158
+
159
+ return wind_data
160
+
161
+ def grib_to_velocity_json(self, u_file, v_file):
162
+ """
163
+ Convert GRIB wind files to JSON format compatible with Leaflet-Velocity
164
+ Returns grib2json-style format: [{"header": {...}, "data": [...]}, {"header": {...}, "data": [...]}]
165
+ """
166
+ try:
167
+ # Open U component GRIB file
168
+ ds_u = xr.open_dataset(u_file, engine='cfgrib', backend_kwargs={'indexpath': ''})
169
+
170
+ # Open V component GRIB file
171
+ ds_v = xr.open_dataset(v_file, engine='cfgrib', backend_kwargs={'indexpath': ''})
172
+
173
+ # Get variable names (first data variable in each file)
174
+ u_var = list(ds_u.data_vars.keys())[0]
175
+ v_var = list(ds_v.data_vars.keys())[0]
176
+
177
+ # Extract data arrays
178
+ u_data = ds_u[u_var]
179
+ v_data = ds_v[v_var]
180
+
181
+ # Handle coordinates
182
+ if 'latitude' in ds_u.coords:
183
+ lats = ds_u.latitude.values
184
+ lons = ds_u.longitude.values
185
+ elif 'lat' in ds_u.coords:
186
+ lats = ds_u.lat.values
187
+ lons = ds_u.lon.values
188
+ else:
189
+ raise ValueError("Could not find latitude/longitude coordinates")
190
+
191
+ # Select first time step if multiple
192
+ if 'time' in u_data.dims and len(u_data.time) > 1:
193
+ u_values = u_data.isel(time=0).values
194
+ v_values = v_data.isel(time=0).values
195
+ elif 'valid_time' in u_data.dims:
196
+ u_values = u_data.isel(valid_time=0).values
197
+ v_values = v_data.isel(valid_time=0).values
198
+ else:
199
+ u_values = u_data.values
200
+ v_values = v_data.values
201
+
202
+ # Handle 3D data (select first level if needed)
203
+ if u_values.ndim > 2:
204
+ u_values = u_values[0]
205
+ v_values = v_values[0]
206
+
207
+ # Ensure latitude is sorted north to south for grib2json convention
208
+ if lats[0] < lats[-1]: # south to north, need to flip
209
+ lats = lats[::-1]
210
+ u_values = u_values[::-1, :]
211
+ v_values = v_values[::-1, :]
212
+
213
+ # Create header following grib2json format
214
+ ny, nx = u_values.shape
215
+ lo1 = float(lons[0])
216
+ la1 = float(lats[0]) # northernmost lat
217
+ lo2 = float(lons[-1])
218
+ la2 = float(lats[-1]) # southernmost lat
219
+ dx = float(lons[1] - lons[0]) if len(lons) > 1 else 0.25
220
+ dy = abs(float(lats[1] - lats[0])) if len(lats) > 1 else 0.25
221
+
222
+ # Common header
223
+ header_common = {
224
+ "discipline": 0,
225
+ "parameterCategory": 2,
226
+ "nx": nx,
227
+ "ny": ny,
228
+ "lo1": lo1,
229
+ "la1": la1,
230
+ "lo2": lo2,
231
+ "la2": la2,
232
+ "dx": dx,
233
+ "dy": dy,
234
+ "refTime": datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S")
235
+ }
236
+
237
+ # U component header
238
+ header_u = header_common.copy()
239
+ header_u.update({
240
+ "parameterNumber": 2,
241
+ "parameterName": "UGRD",
242
+ "parameterNumberName": "eastward_wind",
243
+ "description": "U-component of wind"
244
+ })
245
+
246
+ # V component header
247
+ header_v = header_common.copy()
248
+ header_v.update({
249
+ "parameterNumber": 3,
250
+ "parameterName": "VGRD",
251
+ "parameterNumberName": "northward_wind",
252
+ "description": "V-component of wind"
253
+ })
254
+
255
+ # Flatten arrays (row-major order: west->east, north->south)
256
+ u_flat = u_values.flatten().tolist()
257
+ v_flat = v_values.flatten().tolist()
258
+
259
+ # Replace NaN values with None
260
+ u_flat = [None if (isinstance(x, float) and math.isnan(x)) else float(x) for x in u_flat]
261
+ v_flat = [None if (isinstance(x, float) and math.isnan(x)) else float(x) for x in v_flat]
262
+
263
+ # Create grib2json-style output
264
+ velocity_json = [
265
+ {
266
+ "header": header_u,
267
+ "data": u_flat
268
+ },
269
+ {
270
+ "header": header_v,
271
+ "data": v_flat
272
+ }
273
+ ]
274
+
275
+ # Clean up
276
+ ds_u.close()
277
+ ds_v.close()
278
+
279
+ return velocity_json, f"βœ… Converted GRIB to velocity JSON: {nx}x{ny} grid points"
280
+
281
+ except Exception as e:
282
+ return None, f"❌ Error converting GRIB to JSON: {str(e)}"
283
+
284
+
285
+ class GribJsonField:
286
+ """
287
+ Bilinear-interpolated wind field for JSON exported by grib2json (UGRD/VGRD)
288
+ Based on the reference implementation for windy-style particle systems
289
+ """
290
+
291
+ def __init__(self, records):
292
+ # Identify U (eastward) and V (northward)
293
+ rec_u = None
294
+ rec_v = None
295
+ for rec in records:
296
+ h = rec.get("header", {})
297
+ name = h.get("parameterNumberName") or h.get("parameterName") or ""
298
+ # Accept typical identifiers
299
+ if "eastward" in name or name.upper().startswith("UGRD"):
300
+ rec_u = rec
301
+ elif "northward" in name or name.upper().startswith("VGRD"):
302
+ rec_v = rec
303
+
304
+ if rec_u is None or rec_v is None:
305
+ # Fallback by parameterNumber: 2 (UGRD), 3 (VGRD)
306
+ for rec in records:
307
+ h = rec.get("header", {})
308
+ if h.get("parameterCategory") == 2:
309
+ if h.get("parameterNumber") == 2:
310
+ rec_u = rec_u or rec
311
+ if h.get("parameterNumber") == 3:
312
+ rec_v = rec_v or rec
313
+
314
+ if rec_u is None or rec_v is None:
315
+ raise ValueError("Could not find U and V components in records")
316
+
317
+ Hu = rec_u["header"]
318
+ Hv = rec_v["header"]
319
+ # Sanity: grid must match
320
+ for k in ("lo1","la1","lo2","la2","nx","ny","dx","dy"):
321
+ if Hu.get(k) != Hv.get(k):
322
+ raise ValueError(f"U/V headers differ on {k}: {Hu.get(k)} vs {Hv.get(k)}")
323
+
324
+ self.h = Hu
325
+ self.nx = int(Hu["nx"])
326
+ self.ny = int(Hu["ny"])
327
+ self.lo1 = float(Hu["lo1"])
328
+ self.la1 = float(Hu["la1"])
329
+ self.lo2 = float(Hu["lo2"])
330
+ self.la2 = float(Hu["la2"])
331
+ self.dx = float(Hu["dx"])
332
+ self.dy = float(Hu["dy"])
333
+
334
+ # Data order: i=0..nx-1 west->east, j=0..ny-1 north->south if la1>la2
335
+ self.u = rec_u["data"]
336
+ self.v = rec_v["data"]
337
+ if len(self.u) != self.nx * self.ny or len(self.v) != self.nx * self.ny:
338
+ raise ValueError("Unexpected data length for U/V")
339
+
340
+ # Pre-compute whether latitude decreases with j
341
+ self.north_to_south = self.la1 > self.la2
342
+
343
+ # Normalize longitudes to [0, 360) to simplify wrap-around if grid is global
344
+ self.global_wrap = math.isclose((self.lo2 - self.lo1 + self.dx), 360.0, rel_tol=1e-3)
345
+
346
+ def _index(self, i, j):
347
+ # row-major: for each j-row, nx x-cells
348
+ return j * self.nx + i
349
+
350
+ def _grid_coords(self, lon, lat):
351
+ """
352
+ Map lon/lat to continuous grid indices (x, y) in [0, nx-1], [0, ny-1].
353
+ Handles increasing or decreasing latitude, and optional 0..360 wrap.
354
+ """
355
+ L = lon
356
+ if self.global_wrap:
357
+ # normalize lon into [lo1, lo2)
358
+ span = 360.0
359
+ while L < self.lo1:
360
+ L += span
361
+ while L >= self.lo2:
362
+ L -= span
363
+
364
+ x = (L - self.lo1) / self.dx
365
+ if self.north_to_south:
366
+ y = (self.la1 - lat) / self.dy
367
+ else:
368
+ y = (lat - self.la1) / self.dy
369
+ return x, y
370
+
371
+ def _bilinear(self, arr, x, y):
372
+ # Bilinear interpolation over arr shaped ny*nx
373
+ i0 = math.floor(x)
374
+ j0 = math.floor(y)
375
+ i1 = i0 + 1
376
+ j1 = j0 + 1
377
+
378
+ if i0 < 0 or j0 < 0 or i1 >= self.nx or j1 >= self.ny:
379
+ return None # outside grid
380
+
381
+ tx = x - i0
382
+ ty = y - j0
383
+
384
+ def val(ii, jj):
385
+ return arr[self._index(ii, jj)]
386
+
387
+ a = val(i0, j0)
388
+ b = val(i1, j0)
389
+ c = val(i0, j1)
390
+ d = val(i1, j1)
391
+
392
+ if None in (a,b,c,d):
393
+ return None
394
+
395
+ # bilinear mix
396
+ ab = a + tx * (b - a)
397
+ cd = c + tx * (d - c)
398
+ return ab + ty * (cd - ab)
399
+
400
+ def vector(self, lon, lat):
401
+ """
402
+ Interpolated (u, v) at lon/lat in m/s. Returns None if out of grid.
403
+ """
404
+ x, y = self._grid_coords(lon, lat)
405
+ u = self._bilinear(self.u, x, y)
406
+ v = self._bilinear(self.v, x, y)
407
+ if u is None or v is None or math.isnan(u) or math.isnan(v):
408
+ return None
409
+ return (u, v)
410
+
411
+
412
+ class WindParticleAnimator:
413
+ """
414
+ Creates windy-style particle animation using RK4 advection
415
+ """
416
+
417
+ def __init__(self):
418
+ pass
419
+
420
+ def meters_per_degree_lat(self):
421
+ # ~111.32 km per degree latitude
422
+ return 111320.0
423
+
424
+ def meters_per_degree_lon(self, lat_deg):
425
+ # ~111.32 km * cos(lat)
426
+ return 111320.0 * math.cos(math.radians(lat_deg))
427
+
428
+ def rk4_step(self, field, lon, lat, dt_s):
429
+ """
430
+ Advance (lon, lat) by dt_s seconds using RK4 on the vector field (u,v) m/s.
431
+ Convert m/s to degrees per second using local meters/degree.
432
+ """
433
+ def uv_to_dlonlat(lon_, lat_, u, v):
434
+ if u is None or v is None:
435
+ return (None, None)
436
+ dlon_dt = u / self.meters_per_degree_lon(lat_)
437
+ dlat_dt = v / self.meters_per_degree_lat()
438
+ return (dlon_dt, dlat_dt)
439
+
440
+ vec = field.vector
441
+ # k1
442
+ uv1 = vec(lon, lat); k1 = uv_to_dlonlat(lon, lat, *(uv1 or (None,None)))
443
+ if None in k1: return (None, None)
444
+ # k2
445
+ lon2 = lon + 0.5 * dt_s * k1[0]; lat2 = lat + 0.5 * dt_s * k1[1]
446
+ uv2 = vec(lon2, lat2); k2 = uv_to_dlonlat(lon2, lat2, *(uv2 or (None,None)))
447
+ if None in k2: return (None, None)
448
+ # k3
449
+ lon3 = lon + 0.5 * dt_s * k2[0]; lat3 = lat + 0.5 * dt_s * k2[1]
450
+ uv3 = vec(lon3, lat3); k3 = uv_to_dlonlat(lon3, lat3, *(uv3 or (None,None)))
451
+ if None in k3: return (None, None)
452
+ # k4
453
+ lon4 = lon + dt_s * k3[0]; lat4 = lat + dt_s * k3[1]
454
+ uv4 = vec(lon4, lat4); k4 = uv_to_dlonlat(lon4, lat4, *(uv4 or (None,None)))
455
+ if None in k4: return (None, None)
456
+
457
+ dlon = (dt_s / 6.0) * (k1[0] + 2*k2[0] + 2*k3[0] + k4[0])
458
+ dlat = (dt_s / 6.0) * (k1[1] + 2*k2[1] + 2*k3[1] + k4[1])
459
+ return (lon + dlon, lat + dlat)
460
+
461
+ def simulate_particles(self, field, bounds, n_particles=800, steps=60, dt_minutes=5,
462
+ seed=None, respawn=True):
463
+ """
464
+ Create Timestamped GeoJSON 'features' by advecting particles.
465
+ Each feature is a MultiPoint with a matching 'times' array.
466
+ bounds = (minLat, minLon, maxLat, maxLon)
467
+ """
468
+ if seed is not None:
469
+ random.seed(seed)
470
+
471
+ minLat, minLon, maxLat, maxLon = bounds
472
+ dt_s = dt_minutes * 60
473
+ t0 = datetime.now(timezone.utc).replace(microsecond=0)
474
+ features = []
475
+
476
+ def rand_seed():
477
+ return (random.uniform(minLon, maxLon), random.uniform(minLat, maxLat))
478
+
479
+ for _ in range(n_particles):
480
+ coords = []
481
+ times = []
482
+ lon, lat = rand_seed()
483
+ for k in range(steps):
484
+ # record current position/time
485
+ coords.append([lon, lat])
486
+ times.append((t0 + timedelta(minutes=dt_minutes*k)).isoformat())
487
+ # advance
488
+ nxt = self.rk4_step(field, lon, lat, dt_s)
489
+ if nxt == (None, None):
490
+ if respawn:
491
+ lon, lat = rand_seed()
492
+ continue
493
+ else:
494
+ break
495
+ lon, lat = nxt
496
+ # keep within rough geographic bounds; respawn if out
497
+ if not (minLon <= lon <= maxLon and minLat <= lat <= maxLat):
498
+ if respawn:
499
+ lon, lat = rand_seed()
500
+ else:
501
+ break
502
+
503
+ if len(coords) >= 2:
504
+ # Color particles by wind speed at first position for visual appeal
505
+ initial_uv = field.vector(coords[0][0], coords[0][1])
506
+ if initial_uv:
507
+ wind_speed = math.sqrt(initial_uv[0]**2 + initial_uv[1]**2)
508
+ # Color code: blue (low) -> green -> yellow -> red (high)
509
+ if wind_speed < 5:
510
+ color = "#0066cc" # blue
511
+ elif wind_speed < 10:
512
+ color = "#00cc66" # green
513
+ elif wind_speed < 15:
514
+ color = "#cccc00" # yellow
515
+ else:
516
+ color = "#cc0000" # red
517
+ else:
518
+ color = "#666666" # gray for unknown
519
+
520
+ features.append({
521
+ "type": "Feature",
522
+ "geometry": {"type": "MultiPoint", "coordinates": coords},
523
+ "properties": {
524
+ "times": times,
525
+ "style": {
526
+ "color": color,
527
+ "fillColor": color,
528
+ "fillOpacity": 0.7,
529
+ "weight": 2,
530
+ "radius": 2
531
+ },
532
+ }
533
+ })
534
+
535
+ return {
536
+ "type": "FeatureCollection",
537
+ "features": features
538
+ }
539
+
540
+
541
+ class WindVisualizationApp:
542
+ """
543
+ Main application class that ties together ECMWF data fetching and wind visualization
544
+ """
545
+
546
+ def __init__(self):
547
+ self.wind_fetcher = ECMWFWindDataFetcher()
548
+ self.particle_animator = WindParticleAnimator()
549
+ self.current_wind_data = {}
550
+ self.current_velocity_json = None
551
+
552
+ def download_and_process_wind_data(self, forecast_steps=[0, 6, 12, 24]):
553
+ """Download ECMWF wind data and convert to velocity JSON"""
554
+ try:
555
+ # Download wind data for multiple forecast steps
556
+ status_msg = "πŸŒͺ️ Downloading ECMWF 10m wind data...\n"
557
+ wind_data = self.wind_fetcher.download_wind_data(forecast_steps)
558
+
559
+ if not wind_data:
560
+ return "❌ Failed to download any wind data", None, None
561
+
562
+ status_msg += f"βœ… Downloaded wind data for {len(wind_data)} forecast steps\n"
563
+
564
+ # Convert first available time step to velocity JSON
565
+ first_step = min(wind_data.keys())
566
+ u_file = wind_data[first_step]['u_file']
567
+ v_file = wind_data[first_step]['v_file']
568
+
569
+ status_msg += f"πŸ”„ Converting GRIB to velocity JSON format...\n"
570
+ velocity_json, conv_msg = self.wind_fetcher.grib_to_velocity_json(u_file, v_file)
571
+
572
+ if velocity_json:
573
+ self.current_velocity_json = velocity_json
574
+ self.current_wind_data = wind_data
575
+ status_msg += conv_msg + "\n"
576
+ status_msg += f"πŸ“Š Ready for particle animation visualization!"
577
+ return status_msg, velocity_json, wind_data
578
+ else:
579
+ return f"❌ Failed to convert wind data: {conv_msg}", None, None
580
+
581
+ except Exception as e:
582
+ return f"❌ Error processing wind data: {str(e)}", None, None
583
+
584
+ def create_wind_particle_map(self, velocity_json, region="global", particle_count=1000):
585
+ """Create Folium map with windy-style particle animation"""
586
+ try:
587
+ if not velocity_json:
588
+ return """
589
+ <div style="padding: 20px; background-color: #fff3cd; border: 1px solid #ffeaa7; border-radius: 8px;">
590
+ <h4>⚠️ No Wind Data Available</h4>
591
+ <p>Please download wind data first using the button above.</p>
592
+ </div>
593
+ """
594
+
595
+ # Determine map bounds based on region
596
+ if region == "global":
597
+ bounds = (-60.0, -180.0, 60.0, 180.0)
598
+ center = [20, 0]
599
+ zoom = 2
600
+ elif region == "north_america":
601
+ bounds = (25.0, -130.0, 55.0, -60.0)
602
+ center = [40, -95]
603
+ zoom = 3
604
+ elif region == "europe":
605
+ bounds = (35.0, -15.0, 65.0, 35.0)
606
+ center = [50, 10]
607
+ zoom = 4
608
+ else: # default to global
609
+ bounds = (-60.0, -180.0, 60.0, 180.0)
610
+ center = [20, 0]
611
+ zoom = 2
612
+
613
+ # Create map
614
+ m = folium.Map(
615
+ location=center,
616
+ tiles="CartoDB dark_matter",
617
+ zoom_start=zoom,
618
+ control_scale=True
619
+ )
620
+
621
+ # Add CartoDB Positron as alternative tile layer
622
+ folium.TileLayer(
623
+ tiles="CartoDB positron",
624
+ name="Light Theme",
625
+ control=True
626
+ ).add_to(m)
627
+
628
+ # Add OpenStreetMap as alternative
629
+ folium.TileLayer(
630
+ tiles="OpenStreetMap",
631
+ name="OpenStreetMap",
632
+ control=True
633
+ ).add_to(m)
634
+
635
+ # Create wind field from velocity JSON
636
+ field = GribJsonField(velocity_json)
637
+
638
+ # Generate particle animation using TimestampedGeoJson
639
+ geojson_data = self.particle_animator.simulate_particles(
640
+ field,
641
+ bounds=bounds,
642
+ n_particles=particle_count,
643
+ steps=50,
644
+ dt_minutes=6,
645
+ seed=42,
646
+ respawn=True
647
+ )
648
+
649
+ # Add TimestampedGeoJson layer for particle animation
650
+ ts = TimestampedGeoJson(
651
+ data=geojson_data,
652
+ period="PT6M", # 6 minutes between frames
653
+ duration=None,
654
+ add_last_point=False,
655
+ loop=True,
656
+ loop_button=True,
657
+ auto_play=True,
658
+ transition_time=300,
659
+ time_slider_drag_update=True
660
+ )
661
+ ts.add_to(m)
662
+
663
+ # Add Leaflet-Velocity plugin for canvas-based particle rendering
664
+ plugin_js = """
665
+ <script src="https://unpkg.com/leaflet-velocity/dist/leaflet-velocity.min.js"></script>
666
+ """
667
+ m.get_root().html.add_child(Element(plugin_js))
668
+
669
+ # Save velocity JSON to a data URL for the Leaflet-Velocity layer
670
+ velocity_json_str = json.dumps(velocity_json)
671
+
672
+ map_id = m.get_name()
673
+ js = f"""
674
+ <script>
675
+ (function() {{
676
+ var map = {map_id};
677
+ var velocityData = {velocity_json_str};
678
+
679
+ // Add Leaflet-Velocity layer for windy-style canvas particles
680
+ var velocityLayer = L.velocityLayer({{
681
+ data: velocityData,
682
+ displayValues: true,
683
+ displayOptions: {{
684
+ velocityType: "Wind",
685
+ position: "bottomright",
686
+ emptyString: "No wind data",
687
+ speedUnit: "m/s",
688
+ angleConvention: "bearingCW",
689
+ showCardinal: true
690
+ }},
691
+ velocityScale: 0.008, // tune visual speed (px per m/s)
692
+ opacity: 0.85,
693
+ maxVelocity: 25, // color scale domain upper bound (m/s)
694
+ particleMultiplier: 0.006, // particle density
695
+ lineWidth: 2,
696
+ colorScale: ["#ffffff", "#4575b4", "#74add1", "#abd9e9", "#e0f3f8", "#fee090", "#fdae61", "#f46d43", "#d73027", "#a50026"]
697
+ }}).addTo(map);
698
+
699
+ // Layer control
700
+ var overlayMaps = {{
701
+ "Wind Particles (Canvas)": velocityLayer
702
+ }};
703
+
704
+ // Add layer control if it doesn't exist
705
+ if (!map._layerControlAdded) {{
706
+ L.control.layers(null, overlayMaps, {{position: 'topright'}}).addTo(map);
707
+ map._layerControlAdded = true;
708
+ }}
709
+ }})();
710
+ </script>
711
+ """
712
+ m.get_root().html.add_child(Element(js))
713
+
714
+ # Add layer control for base maps
715
+ folium.LayerControl().add_to(m)
716
+
717
+ return m._repr_html_()
718
+
719
+ except Exception as e:
720
+ return f"""
721
+ <div style="padding: 20px; background-color: #f8d7da; border: 1px solid #f5c6cb; border-radius: 8px;">
722
+ <h4>❌ Error Creating Wind Visualization</h4>
723
+ <p>Error: {str(e)}</p>
724
+ <p>Please try downloading the wind data again or check the console for detailed error information.</p>
725
+ </div>
726
+ """
727
+
728
+ def get_wind_info(self):
729
+ """Get information about current wind data"""
730
+ if not self.current_wind_data:
731
+ return "No wind data loaded. Please download wind data first."
732
+
733
+ info = "πŸŒͺ️ **Current ECMWF 10m Wind Data:**\n\n"
734
+
735
+ for step, data in self.current_wind_data.items():
736
+ valid_time = data['valid_time'].strftime("%Y-%m-%d %H:%M UTC")
737
+ info += f"β€’ **+{step}h forecast**: Valid {valid_time}\n"
738
+
739
+ if self.current_velocity_json:
740
+ header = self.current_velocity_json[0]['header']
741
+ nx = header['nx']
742
+ ny = header['ny']
743
+ dx = header['dx']
744
+ dy = header['dy']
745
+
746
+ info += f"\n**Grid Information:**\n"
747
+ info += f"β€’ Resolution: {dx}Β° Γ— {dy}Β° (~{dx*111:.0f}km spacing)\n"
748
+ info += f"β€’ Grid size: {nx} Γ— {ny} = {nx*ny:,} points\n"
749
+ info += f"β€’ Coverage: {header['lo1']}Β° to {header['lo2']}Β°E, {header['la2']}Β° to {header['la1']}Β°N\n"
750
+ info += f"β€’ Data source: ECMWF IFS Operational\n"
751
+ info += f"β€’ Parameters: 10m U-wind (eastward) and V-wind (northward)\n"
752
+
753
+ return info
754
+
755
+
756
+ # Initialize the application
757
+ app = WindVisualizationApp()
758
+
759
+
760
+ def download_wind_data():
761
+ """Gradio function to download wind data"""
762
+ return app.download_and_process_wind_data([0, 6, 12, 24])
763
+
764
+
765
+ def create_wind_map(region, particle_count):
766
+ """Gradio function to create wind particle map"""
767
+ if not app.current_velocity_json:
768
+ status, velocity_json, wind_data = app.download_and_process_wind_data([0])
769
+ if not velocity_json:
770
+ return status, ""
771
+
772
+ map_html = app.create_wind_particle_map(app.current_velocity_json, region, particle_count)
773
+ wind_info = app.get_wind_info()
774
+ return map_html, wind_info
775
+
776
+
777
+ def create_gradio_interface():
778
+ """Create the Gradio interface"""
779
+
780
+ with gr.Blocks(title="ECMWF Wind Particle Visualization", theme=gr.themes.Soft()) as interface:
781
+
782
+ gr.Markdown("""
783
+ # πŸŒͺ️ ECMWF Wind Particle Visualization
784
+ ## Windy-style particle animation using real ECMWF 10m wind data
785
+
786
+ **Features:**
787
+ - 🌍 Real ECMWF operational forecast data (10m winds)
788
+ - 🎨 Windy-style particle animation using Leaflet-Velocity
789
+ - ⏱️ TimestampedGeoJson animation with time controls
790
+ - πŸ—ΊοΈ Interactive Folium maps with multiple tile layers
791
+ - 🎯 Regional and global visualization options
792
+ """)
793
+
794
+ with gr.Tab("πŸŒͺ️ Wind Particle Animation"):
795
+
796
+ with gr.Row():
797
+ with gr.Column(scale=1):
798
+ gr.Markdown("### βš™οΈ Controls")
799
+
800
+ download_btn = gr.Button(
801
+ "πŸ“‘ Download ECMWF Wind Data",
802
+ variant="primary",
803
+ size="lg"
804
+ )
805
+
806
+ region_choice = gr.Radio(
807
+ choices=["global", "north_america", "europe"],
808
+ value="global",
809
+ label="πŸ—ΊοΈ Region"
810
+ )
811
+
812
+ particle_count = gr.Slider(
813
+ minimum=200,
814
+ maximum=2000,
815
+ value=1000,
816
+ step=100,
817
+ label="πŸ”΅ Particle Count"
818
+ )
819
+
820
+ create_map_btn = gr.Button(
821
+ "🎨 Create Wind Visualization",
822
+ variant="secondary",
823
+ size="lg"
824
+ )
825
+
826
+ gr.Markdown("### πŸ“Š Data Information")
827
+ wind_info = gr.Markdown("No wind data loaded yet.")
828
+
829
+ with gr.Column(scale=2):
830
+ download_status = gr.Textbox(
831
+ label="πŸ“‹ Download Status",
832
+ lines=8,
833
+ max_lines=12
834
+ )
835
+
836
+ with gr.Row():
837
+ wind_map = gr.HTML(
838
+ label="πŸŒͺ️ Wind Particle Animation",
839
+ value="""
840
+ <div style="padding: 40px; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
841
+ border-radius: 12px; text-align: center; color: white;">
842
+ <h3>πŸŒͺ️ Wind Particle Visualization</h3>
843
+ <p>Download wind data and create visualization to see windy-style particle animation</p>
844
+ <p style="font-size: 14px; opacity: 0.8;">
845
+ β€’ Canvas-based particle system (Leaflet-Velocity)<br>
846
+ β€’ Time-stamped GeoJSON animation<br>
847
+ β€’ Interactive time controls<br>
848
+ β€’ Multiple map themes
849
+ </p>
850
+ </div>
851
+ """
852
+ )
853
+
854
+ # Event handlers
855
+ download_btn.click(
856
+ download_wind_data,
857
+ outputs=[download_status, gr.JSON(visible=False), gr.JSON(visible=False)]
858
+ )
859
+
860
+ create_map_btn.click(
861
+ create_wind_map,
862
+ inputs=[region_choice, particle_count],
863
+ outputs=[wind_map, wind_info]
864
+ )
865
+
866
+ with gr.Tab("πŸ“– About"):
867
+ gr.Markdown("""
868
+ # 🌍 About ECMWF Wind Particle Visualization
869
+
870
+ ## 🎯 What This App Does
871
+
872
+ This application creates **windy-style particle animations** using real ECMWF operational wind data.
873
+ It demonstrates two complementary visualization approaches:
874
+
875
+ ### 🎨 Visualization Methods
876
+
877
+ 1. **Canvas Particle System (Leaflet-Velocity)**
878
+ - Real-time particle advection on HTML5 canvas
879
+ - Thousands of particles animated by wind field
880
+ - Color-coded by wind speed
881
+ - Interactive controls and wind speed display
882
+
883
+ 2. **TimestampedGeoJSON Animation**
884
+ - Pre-computed particle trajectories
885
+ - Time slider controls
886
+ - Smooth animation transitions
887
+ - Compatible with Folium/Leaflet ecosystem
888
+
889
+ ### πŸ“Š Technical Implementation
890
+
891
+ **Data Processing:**
892
+ - Downloads ECMWF 10m U/V wind components in GRIB2 format
893
+ - Converts to grib2json-compatible velocity JSON
894
+ - Implements bilinear interpolation for smooth field sampling
895
+
896
+ **Particle Physics:**
897
+ - RK4 (Runge-Kutta 4th order) integration for accurate advection
898
+ - Proper coordinate system handling (geographic projections)
899
+ - Particle respawning and boundary management
900
+
901
+ **Visualization:**
902
+ - Leaflet-Velocity plugin for canvas rendering
903
+ - Folium TimestampedGeoJson for time-based animation
904
+ - Multiple map tile layers and interactive controls
905
+
906
+ ### πŸŒͺ️ Inspired by Windy.com
907
+
908
+ This implementation follows the same principles used by professional weather visualization platforms:
909
+ - Vector field interpolation
910
+ - Massless particle advection
911
+ - Canvas-based rendering for performance
912
+ - Color coding by wind magnitude
913
+ - Time-based animation controls
914
+
915
+ ### πŸ› οΈ Technologies Used
916
+
917
+ - **ECMWF OpenData**: Real operational forecast data
918
+ - **Leaflet-Velocity**: Canvas particle rendering
919
+ - **Folium**: Python-Leaflet bridge
920
+ - **xarray/cfgrib**: GRIB data processing
921
+ - **Gradio**: Interactive web interface
922
+
923
+ ### πŸ“š References
924
+
925
+ - [earth.nullschool.net](https://earth.nullschool.net/) - Original particle visualization inspiration
926
+ - [Leaflet-Velocity](https://github.com/onaci/leaflet-velocity) - Canvas particle system
927
+ - [ECMWF Open Data](https://www.ecmwf.int/en/forecasts/datasets/open-data) - Data source
928
+ - [Windy.com](https://www.windy.com/) - Visual style reference
929
+ """)
930
+
931
+ gr.Markdown("""
932
+ ---
933
+ **πŸŒͺ️ ECMWF Wind Particle Visualization - Bringing Windy-style Animation to Your Browser**
934
+ *Powered by ECMWF Open Data - Real operational forecasts updated every 6 hours*
935
+ """)
936
+
937
+ return interface
938
+
939
+
940
+ if __name__ == "__main__":
941
+ interface = create_gradio_interface()
942
+ interface.launch(
943
+ server_name="0.0.0.0",
944
+ server_port=7860,
945
+ share=False
946
+ )
requirements.txt ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ gradio==4.44.0
2
+ folium==0.17.0
3
+ xarray==2024.7.0
4
+ cfgrib==0.9.11.0
5
+ ecmwf-opendata==0.3.6
6
+ requests==2.32.3
7
+ numpy==1.26.4
8
+ pandas==2.2.2
9
+ matplotlib==3.9.2
10
+ plotly==5.22.0
11
+ eccodes==1.7.0
12
+ branca==0.7.2
13
+ jinja2==3.1.4