Spaces:
Runtime error
Runtime error
| import gradio as gr | |
| import json | |
| import os | |
| import logging | |
| import numpy as np | |
| from datetime import datetime | |
| import tempfile | |
| import sys | |
| import subprocess | |
| import shutil | |
| from datetime import datetime, timedelta | |
| import xarray as xr | |
| from ecmwf.opendata import Client | |
| import requests | |
| logging.basicConfig(level=logging.INFO) | |
| logger = logging.getLogger(__name__) | |
| # Global wave data storage | |
| wave_particle_cache = {} | |
| class WorkingGRIBWaveFetcher: | |
| """ | |
| Working GRIB wave fetcher - exactly like NWPS_SWAN but output particle format | |
| """ | |
| def __init__(self): | |
| logger.info("π Initializing WorkingGRIBWaveFetcher with Arctic support") | |
| self.client = Client("ecmwf") | |
| self.output_dir = os.getenv('OUTPUT_DIR', '/tmp/wave_data') | |
| os.makedirs(self.output_dir, exist_ok=True) | |
| # Set ECCODES environment variables to handle polar stereographic issues | |
| self._setup_eccodes_environment() | |
| def _setup_eccodes_environment(self): | |
| """Setup ECCODES environment variables to handle projection issues""" | |
| try: | |
| # Set environment variables that might help with polar stereographic processing | |
| os.environ['ECCODES_GRIB_STRICT_PARSING'] = '0' # Relaxed parsing | |
| os.environ['ECCODES_GRIB_IGNORE_GRID_DEFINITION'] = '1' # Ignore grid definition errors | |
| logger.info("Set ECCODES environment variables for relaxed parsing") | |
| except Exception as e: | |
| logger.warning(f"Could not set ECCODES environment variables: {e}") | |
| def fetch_noaa_wave_grib(self, forecast_hour=0): | |
| """Fetch global wave data from NOAA WW3 model - exactly like NWPS_SWAN""" | |
| try: | |
| logger.info(f"Fetching NOAA WW3 global wave GRIB data for forecast hour {forecast_hour}...") | |
| # NOAA GFS/WW3 wave data URL pattern | |
| base_url = "https://nomads.ncep.noaa.gov/pub/data/nccf/com/gfs/prod" | |
| # Try current date and previous days (in case of delayed updates) | |
| now = datetime.utcnow() | |
| dates_to_try = [ | |
| now.strftime("%Y%m%d"), | |
| (now - timedelta(days=1)).strftime("%Y%m%d"), | |
| (now - timedelta(days=2)).strftime("%Y%m%d") | |
| ] | |
| # Try different model runs (00, 06, 12, 18 UTC) to find available data | |
| current_hour = now.hour | |
| # Start with the most recent available run | |
| if current_hour >= 18: | |
| preferred_runs = ["18", "12", "06", "00"] | |
| elif current_hour >= 12: | |
| preferred_runs = ["12", "06", "00", "18"] | |
| elif current_hour >= 6: | |
| preferred_runs = ["06", "00", "18", "12"] | |
| else: | |
| preferred_runs = ["00", "18", "12", "06"] | |
| # Try different dates and model runs | |
| for date_str in dates_to_try: | |
| logger.info(f"Trying date: {date_str}") | |
| for hour in preferred_runs: | |
| try: | |
| # Format forecast hour with leading zeros (f000, f001, f002, etc.) | |
| forecast_str = f"f{forecast_hour:03d}" | |
| # Download multiple regional files for global coverage | |
| successful_downloads = [] | |
| # Try different regional GRIB files available on NOAA including Arctic | |
| regional_files = [ | |
| (f"gfswave.t{hour}z.atlocn.0p16.{forecast_str}.grib2", "Atlantic"), | |
| (f"gfswave.t{hour}z.epacif.0p16.{forecast_str}.grib2", "East_Pacific"), | |
| (f"gfswave.t{hour}z.wcoast.0p16.{forecast_str}.grib2", "West_Coast"), | |
| (f"gfswave.t{hour}z.arctic.9km.{forecast_str}.grib2", "Arctic"), | |
| (f"gfswave.t{hour}z.global.0p16.{forecast_str}.grib2", "Global"), | |
| ] | |
| # Try to download each regional file | |
| for filename, region_name in regional_files: | |
| try: | |
| url = f"{base_url}/gfs.{date_str}/{hour}/wave/gridded/{filename}" | |
| logger.info(f"Attempting to download {region_name} region: {filename}") | |
| temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.grib2') | |
| response = requests.get(url, timeout=300) | |
| if response.status_code == 200: | |
| temp_file.write(response.content) | |
| temp_file.close() | |
| successful_downloads.append((temp_file.name, region_name, hour, forecast_hour)) | |
| logger.info(f"{region_name} GRIB file downloaded: {temp_file.name}") | |
| else: | |
| logger.debug(f"HTTP {response.status_code} for {region_name}") | |
| os.unlink(temp_file.name) | |
| continue | |
| except Exception as file_error: | |
| logger.debug(f"Error downloading {region_name}: {file_error}") | |
| continue | |
| # If we got at least one regional file, return the list | |
| if successful_downloads: | |
| logger.info(f"Successfully downloaded {len(successful_downloads)} regional files") | |
| return successful_downloads | |
| except Exception as run_error: | |
| logger.warning(f"Error trying {hour}Z run on {date_str}: {run_error}") | |
| continue | |
| logger.error("Failed to download NOAA data from any model run") | |
| return None | |
| except Exception as e: | |
| logger.error(f"Error in fetch_noaa_wave_grib: {e}") | |
| return None | |
| def process_grib_file(self, grib_file_path, region_name=None): | |
| """Process GRIB file and extract wave data - exactly like NWPS_SWAN but for particles""" | |
| try: | |
| logger.info(f"Processing GRIB file: {grib_file_path}") | |
| # Use xarray + cfgrib for all regions including Arctic | |
| logger.info(f"Using xarray + cfgrib for {region_name} data processing") | |
| # Use xarray + cfgrib - simple approach like in girbplayground | |
| try: | |
| # Open the GRIB file with xarray + cfgrib engine | |
| ds = xr.open_dataset(grib_file_path, engine='cfgrib') | |
| all_vars = ds.variables | |
| logger.info(f"Available variables: {list(all_vars.keys())}") | |
| except Exception as e: | |
| error_msg = str(e) | |
| logger.error(f"Error opening GRIB file with xarray + cfgrib: {error_msg}") | |
| return None | |
| # Extract wave height data | |
| wave_height_var = None | |
| wave_heights = None | |
| for var_name in ['swh', 'HTSGW', 'htsgw']: | |
| if var_name in all_vars: | |
| wave_height_var = var_name | |
| wave_heights = all_vars[var_name].values | |
| logger.info(f"Using wave height variable: {wave_height_var}") | |
| break | |
| if wave_heights is None: | |
| # Try broader search | |
| for var_name in all_vars: | |
| if any(keyword in var_name.lower() for keyword in ['wave', 'height', 'swh']): | |
| wave_height_var = var_name | |
| wave_heights = all_vars[var_name].values | |
| logger.info(f"Found wave height variable: {wave_height_var}") | |
| break | |
| if wave_heights is None: | |
| logger.error("No wave height variables found in GRIB file") | |
| for ds in datasets: | |
| ds.close() | |
| return None | |
| # Extract wave direction data | |
| wave_directions = None | |
| wave_dir_var = None | |
| for var_name in ['dirpw', 'DIRPW', 'dp', 'wvdir', 'WVDIR', 'dir']: | |
| if var_name in all_vars: | |
| wave_dir_var = var_name | |
| wave_directions = all_vars[var_name].values | |
| logger.info(f"Found wave direction variable: {wave_dir_var}") | |
| break | |
| # Extract wave period data | |
| wave_periods = None | |
| wave_period_var = None | |
| for var_name in ['perpw', 'PERPW', 'tp', 'wvper', 'WVPER', 'per']: | |
| if var_name in all_vars: | |
| wave_period_var = var_name | |
| wave_periods = all_vars[var_name].values | |
| logger.info(f"Found wave period variable: {wave_period_var}") | |
| break | |
| # Get coordinates from the dataset | |
| lats = ds.latitude.values if 'latitude' in ds else ds.lat.values | |
| lons = ds.longitude.values if 'longitude' in ds else ds.lon.values | |
| # Log what we found | |
| if wave_directions is not None: | |
| logger.info(f"Wave directions shape: {wave_directions.shape}, range: {np.nanmin(wave_directions):.1f}-{np.nanmax(wave_directions):.1f} degrees") | |
| if wave_periods is not None: | |
| logger.info(f"Wave periods shape: {wave_periods.shape}, range: {np.nanmin(wave_periods):.1f}-{np.nanmax(wave_periods):.1f} seconds") | |
| # Extract particle data for visualization | |
| particle_points = self._extract_particle_points(lats, lons, wave_heights, wave_directions, wave_periods, region_name) | |
| # Close the dataset | |
| ds.close() | |
| return particle_points | |
| except Exception as e: | |
| logger.error(f"Error processing GRIB file: {e}") | |
| return None | |
| def _extract_particle_points(self, lats, lons, wave_heights, wave_directions=None, wave_periods=None, region_name="Global", max_particles=1500): | |
| """Extract particle points with velocity vectors for wave animation""" | |
| try: | |
| # Create meshgrid for coordinates | |
| lon_grid, lat_grid = np.meshgrid(lons, lats) | |
| # Flatten arrays | |
| flat_lats = lat_grid.flatten() | |
| flat_lons = lon_grid.flatten() | |
| flat_waves = wave_heights.flatten() | |
| flat_dirs = None | |
| flat_periods = None | |
| if wave_directions is not None: | |
| flat_dirs = wave_directions.flatten() | |
| if wave_periods is not None: | |
| flat_periods = wave_periods.flatten() | |
| # Remove NaN values and invalid data | |
| valid_mask = (~np.isnan(flat_waves)) & (flat_waves > 0) & (flat_waves < 30) | |
| if flat_dirs is not None: | |
| valid_mask = valid_mask & ~np.isnan(flat_dirs) | |
| valid_lats = flat_lats[valid_mask] | |
| valid_lons = flat_lons[valid_mask] | |
| valid_waves = flat_waves[valid_mask] | |
| if flat_dirs is not None: | |
| valid_dirs = flat_dirs[valid_mask] | |
| else: | |
| # Generate synthetic wave directions based on location patterns | |
| valid_dirs = self._generate_synthetic_directions(valid_lats, valid_lons) | |
| if flat_periods is not None: | |
| valid_periods = flat_periods[valid_mask] | |
| else: | |
| # Generate synthetic periods based on wave height | |
| valid_periods = np.clip(4 + valid_waves * 2, 3, 15) | |
| if len(valid_waves) == 0: | |
| return [] | |
| # Sample points for particle visualization | |
| sample_size = min(max_particles, len(valid_waves)) | |
| if sample_size < len(valid_waves): | |
| sample_indices = np.random.choice(len(valid_waves), size=sample_size, replace=False) | |
| else: | |
| sample_indices = np.arange(len(valid_waves)) | |
| particle_points = [] | |
| for idx in sample_indices: | |
| lat = float(valid_lats[idx]) | |
| lon = float(valid_lons[idx]) | |
| height = float(valid_waves[idx]) | |
| direction = float(valid_dirs[idx]) | |
| period = float(valid_periods[idx]) | |
| # Calculate velocity components for particle movement | |
| # Wave direction is "coming from" in meteorological convention | |
| # Convert to mathematical convention (direction of travel) | |
| travel_direction = (direction + 180) % 360 | |
| dir_rad = np.radians(travel_direction) | |
| # Velocity magnitude based on wave height and period | |
| # Wave celerity approximation: c = g*T/(2*pi) for deep water | |
| wave_speed = 9.81 * period / (2 * np.pi) # m/s | |
| # Scale for visualization (convert to degrees per animation frame) | |
| velocity_scale = 0.001 # Adjust this for particle speed | |
| u_velocity = wave_speed * np.cos(dir_rad) * velocity_scale | |
| v_velocity = wave_speed * np.sin(dir_rad) * velocity_scale | |
| particle_points.append({ | |
| 'lat': lat, | |
| 'lon': lon, | |
| 'wave_height': height, | |
| 'wave_direction': direction, | |
| 'wave_period': period, | |
| 'u_velocity': u_velocity, # eastward component (degrees/frame) | |
| 'v_velocity': v_velocity, # northward component (degrees/frame) | |
| 'particle_size': max(1, min(8, height * 2)), # Size based on wave height | |
| 'color_intensity': min(1.0, height / 8.0), # Color intensity based on height | |
| 'region': region_name | |
| }) | |
| logger.info(f"Generated {len(particle_points)} particle points for {region_name}") | |
| return particle_points | |
| except Exception as e: | |
| logger.error(f"Error extracting particle points: {e}") | |
| return [] | |
| def _generate_synthetic_directions(self, lats, lons): | |
| """Generate realistic wave directions based on geographic patterns""" | |
| try: | |
| directions = np.zeros_like(lats) | |
| for i, (lat, lon) in enumerate(zip(lats, lons)): | |
| # Simplified wind/wave pattern generation | |
| if abs(lat) < 30: # Trade wind regions | |
| if lon < 0: # Atlantic/Americas | |
| directions[i] = np.random.normal(90, 30) # Generally eastward | |
| else: # Pacific/Asia | |
| directions[i] = np.random.normal(270, 30) # Generally westward | |
| elif abs(lat) > 60: # Polar regions | |
| directions[i] = np.random.uniform(0, 360) # More variable | |
| else: # Mid-latitudes | |
| if lat > 0: # Northern hemisphere | |
| directions[i] = np.random.normal(225, 45) # SW generally | |
| else: # Southern hemisphere | |
| directions[i] = np.random.normal(315, 45) # NW generally | |
| # Ensure direction is in [0, 360) range | |
| directions[i] = directions[i] % 360 | |
| return directions | |
| except Exception as e: | |
| logger.error(f"Error generating synthetic directions: {e}") | |
| return np.random.uniform(0, 360, len(lats)) | |
| def process_multiple_regional_files(self, regional_files): | |
| """Process multiple regional GRIB files and combine particle data""" | |
| try: | |
| logger.info(f"Processing {len(regional_files)} regional GRIB files for global particle coverage...") | |
| all_particles = [] | |
| regions_processed = [] | |
| for grib_file_path, region_name, model_run, forecast_hour in regional_files: | |
| try: | |
| logger.info(f"Processing {region_name} region: {grib_file_path}") | |
| # Process this regional file | |
| particles = self.process_grib_file(grib_file_path, region_name=region_name) | |
| if particles: | |
| # Add region info to each particle | |
| for particle in particles: | |
| particle['region'] = region_name | |
| particle['model_run'] = model_run | |
| all_particles.extend(particles) | |
| regions_processed.append(region_name) | |
| logger.info(f"Successfully processed {region_name}: {len(particles)} particles") | |
| else: | |
| logger.warning(f"Failed to process {region_name} region") | |
| # Clean up temp file | |
| if os.path.exists(grib_file_path): | |
| os.unlink(grib_file_path) | |
| except Exception as e: | |
| logger.error(f"Error processing {region_name} region: {e}") | |
| # Clean up temp file on error | |
| if os.path.exists(grib_file_path): | |
| os.unlink(grib_file_path) | |
| continue | |
| if not all_particles: | |
| logger.error("No valid particle data found in any regional file") | |
| return None | |
| logger.info(f"Combined particle data from {len(regions_processed)} regions: {regions_processed}") | |
| logger.info(f"Total particles: {len(all_particles)}") | |
| # Calculate global statistics | |
| wave_heights = [p['wave_height'] for p in all_particles if p.get('wave_height')] | |
| return { | |
| 'timestamp': datetime.utcnow().isoformat(), | |
| 'data_source': f'NOAA_MULTI_REGIONAL_GRIB ({"_".join(regions_processed)})', | |
| 'total_particles': len(all_particles), | |
| 'regions_processed': regions_processed, | |
| 'wave_statistics': { | |
| 'max_wave_height': float(max(wave_heights)) if wave_heights else None, | |
| 'min_wave_height': float(min(wave_heights)) if wave_heights else None, | |
| 'mean_wave_height': float(np.mean(wave_heights)) if wave_heights else None, | |
| 'std_wave_height': float(np.std(wave_heights)) if wave_heights else None | |
| }, | |
| 'forecast_info': { | |
| 'forecast_hour': 0, | |
| 'model_run': regions_processed[0] if regions_processed else 'DEMO', | |
| 'forecast_valid_time': datetime.utcnow().isoformat(), | |
| 'is_current': True | |
| }, | |
| 'particles': all_particles | |
| } | |
| except Exception as e: | |
| logger.error(f"Error processing multiple regional files: {e}") | |
| # Clean up any remaining temp files | |
| for grib_file_path, region_name, _, _ in regional_files: | |
| if os.path.exists(grib_file_path): | |
| os.unlink(grib_file_path) | |
| return None | |
| def fetch_global_wave_particles(self, forecast_hour=0): | |
| """Main method to fetch global wave data formatted for particle animation""" | |
| try: | |
| logger.info("Fetching wave data from NOAA WW3 model for particle animation...") | |
| # Try NOAA for wave data | |
| result = self.fetch_noaa_wave_grib(forecast_hour) | |
| if result and isinstance(result, list): | |
| # Multiple regional files downloaded | |
| regional_files = result | |
| # Process multiple regional files and combine | |
| particle_data = self.process_multiple_regional_files(regional_files) | |
| return particle_data | |
| else: | |
| logger.error("NOAA failed - generating fallback demo data for particle animation") | |
| return self._generate_demo_particle_data(forecast_hour) | |
| except Exception as e: | |
| logger.error(f"Error in fetch_global_wave_particles: {e}") | |
| return self._generate_demo_particle_data(forecast_hour) | |
| def _generate_demo_particle_data(self, forecast_hour=0): | |
| """Generate demo wave particle data for visualization when GRIB data is unavailable""" | |
| logger.info(f"Generating demo wave particle data for +{forecast_hour}h forecast...") | |
| # Create realistic demo particles | |
| particles = [] | |
| # Atlantic Ocean patterns | |
| for lat in range(-40, 61, 8): | |
| for lon in range(-80, 21, 10): | |
| if lat > 60 or lat < -60: | |
| continue | |
| base_height = np.random.uniform(1.0, 3.5) | |
| if abs(lat) > 40: | |
| base_height += np.random.uniform(0.5, 2.0) | |
| if abs(lat) < 30: | |
| wave_dir = np.random.normal(90, 20) | |
| else: | |
| wave_dir = np.random.normal(225, 45) | |
| wave_dir = wave_dir % 360 | |
| period = np.clip(4 + base_height * 1.5, 4, 14) | |
| travel_direction = (wave_dir + 180) % 360 | |
| dir_rad = np.radians(travel_direction) | |
| wave_speed = 9.81 * period / (2 * np.pi) | |
| velocity_scale = 0.001 | |
| u_velocity = wave_speed * np.cos(dir_rad) * velocity_scale | |
| v_velocity = wave_speed * np.sin(dir_rad) * velocity_scale | |
| particles.append({ | |
| 'lat': float(lat + np.random.uniform(-2, 2)), | |
| 'lon': float(lon + np.random.uniform(-3, 3)), | |
| 'wave_height': round(float(base_height), 2), | |
| 'wave_direction': round(float(wave_dir), 1), | |
| 'wave_period': round(float(period), 1), | |
| 'u_velocity': u_velocity, | |
| 'v_velocity': v_velocity, | |
| 'particle_size': max(1, min(6, base_height * 1.5)), | |
| 'color_intensity': min(1.0, base_height / 6.0), | |
| 'region': 'Atlantic_Demo' | |
| }) | |
| # Pacific Ocean patterns | |
| for lat in range(-50, 61, 8): | |
| for lon in range(120, 241, 12): | |
| if lat > 60 or lat < -60: | |
| continue | |
| base_height = np.random.uniform(1.2, 4.0) | |
| if abs(lat) > 35: | |
| base_height += np.random.uniform(0.8, 2.5) | |
| if abs(lat) < 25: | |
| wave_dir = np.random.normal(270, 25) | |
| elif lat > 25: | |
| wave_dir = np.random.normal(315, 40) | |
| else: | |
| wave_dir = np.random.normal(225, 40) | |
| wave_dir = wave_dir % 360 | |
| period = np.clip(5 + base_height * 1.3, 5, 16) | |
| travel_direction = (wave_dir + 180) % 360 | |
| dir_rad = np.radians(travel_direction) | |
| wave_speed = 9.81 * period / (2 * np.pi) | |
| velocity_scale = 0.001 | |
| u_velocity = wave_speed * np.cos(dir_rad) * velocity_scale | |
| v_velocity = wave_speed * np.sin(dir_rad) * velocity_scale | |
| particles.append({ | |
| 'lat': float(lat + np.random.uniform(-2, 2)), | |
| 'lon': float(lon + np.random.uniform(-4, 4)), | |
| 'wave_height': round(float(base_height), 2), | |
| 'wave_direction': round(float(wave_dir), 1), | |
| 'wave_period': round(float(period), 1), | |
| 'u_velocity': u_velocity, | |
| 'v_velocity': v_velocity, | |
| 'particle_size': max(1, min(6, base_height * 1.5)), | |
| 'color_intensity': min(1.0, base_height / 6.0), | |
| 'region': 'Pacific_Demo' | |
| }) | |
| wave_heights = [p['wave_height'] for p in particles] | |
| return { | |
| 'timestamp': datetime.utcnow().isoformat(), | |
| 'data_source': 'DEMO_WAVE_PARTICLES', | |
| 'total_particles': len(particles), | |
| 'regions_processed': ['Atlantic_Demo', 'Pacific_Demo'], | |
| 'wave_statistics': { | |
| 'max_wave_height': float(max(wave_heights)), | |
| 'min_wave_height': float(min(wave_heights)), | |
| 'mean_wave_height': float(np.mean(wave_heights)), | |
| 'std_wave_height': float(np.std(wave_heights)) | |
| }, | |
| 'forecast_info': { | |
| 'forecast_hour': forecast_hour, | |
| 'model_run': 'DEMO', | |
| 'forecast_valid_time': (datetime.utcnow() + timedelta(hours=forecast_hour)).isoformat(), | |
| 'is_current': forecast_hour == 0 | |
| }, | |
| 'particles': particles | |
| } | |
| def fetch_wave_particles(): | |
| """Fetch global wave data optimized for particle animation""" | |
| try: | |
| logger.info("π Starting NEW Wave Particle Fetcher (using working GRIB logic)") | |
| fetcher = WorkingGRIBWaveFetcher() | |
| # Fetch wave data formatted for particles | |
| data = fetcher.fetch_global_wave_particles(forecast_hour=0) | |
| if data and data.get('particles'): | |
| # Cache the data | |
| global wave_particle_cache | |
| wave_particle_cache = data | |
| # Save to file (with error handling) | |
| try: | |
| os.makedirs('/tmp/wave_data', exist_ok=True) | |
| filename = f"/tmp/wave_data/wave_particles_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json" | |
| with open(filename, 'w') as f: | |
| json.dump(data, f, indent=2) | |
| logger.info(f"Saved particle data to {filename}") | |
| except Exception as save_error: | |
| logger.warning(f"Could not save to /tmp/wave_data: {save_error}") | |
| filename = "particle_data_in_memory" | |
| # Create particle visualization | |
| particle_map = create_particle_wave_map(data) | |
| # Format data summary | |
| summary = format_particle_summary(data) | |
| return summary, particle_map, f"β Particle data saved to: {filename}" | |
| else: | |
| return "β No wave particle data available", None, "Failed to fetch particle data" | |
| except Exception as e: | |
| logger.error(f"Error fetching wave particle data: {e}") | |
| return f"β Error: {str(e)}", None, "Failed to fetch particle data" | |
| def create_particle_wave_map(data): | |
| """Create Folium map with wave layer toggles exactly like the wind app""" | |
| try: | |
| import folium | |
| import numpy as np | |
| particles = data.get('particles', []) | |
| if not particles: | |
| return "<div>No wave particle data available for visualization</div>" | |
| # Get data bounds | |
| lats = [p['lat'] for p in particles if p.get('lat')] | |
| lons = [p['lon'] for p in particles if p.get('lon')] | |
| if not lats or not lons: | |
| return "<div>No valid wave coordinates found</div>" | |
| # Create center point and bounds | |
| center_lat = (min(lats) + max(lats)) / 2 | |
| center_lon = (min(lons) + max(lons)) / 2 | |
| # Create Folium map exactly like wind app | |
| m = folium.Map( | |
| location=[center_lat, center_lon], | |
| zoom_start=4, | |
| tiles=None # We'll add tiles manually | |
| ) | |
| # Add base tile layers (like wind app) | |
| light_tiles = folium.TileLayer( | |
| 'https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', | |
| name='OpenStreetMap', | |
| attr='Β© OpenStreetMap contributors' | |
| ) | |
| light_tiles.add_to(m) | |
| dark_tiles = folium.TileLayer( | |
| 'https://{{s}}.basemaps.cartocdn.com/dark_all/{{z}}/{{x}}/{{y}}{{r}}.png', | |
| name='Dark', | |
| attr='Β© CARTO Β© OpenStreetMap contributors' | |
| ) | |
| dark_tiles.add_to(m) | |
| # Sample particles for performance | |
| import random | |
| if len(particles) > 500: | |
| particles = random.sample(particles, 500) | |
| # Create wave height layer (like 10m wind in wind app) | |
| wave_points = [] | |
| for particle in particles[::2]: # Every 2nd particle for performance | |
| if particle.get('lat') and particle.get('lon') and particle.get('wave_height'): | |
| wave_points.append([ | |
| particle['lat'], | |
| particle['lon'], | |
| particle['wave_height'] | |
| ]) | |
| # Add wave markers as a feature group (like wind layers) | |
| wave_layer = folium.FeatureGroup(name="Wave Heights", show=True) | |
| for i, particle in enumerate(particles[::5]): # Every 5th particle | |
| if particle.get('lat') and particle.get('lon') and particle.get('wave_height'): | |
| # Color based on wave height | |
| if particle['wave_height'] < 2: | |
| color = '#0066ff' | |
| elif particle['wave_height'] < 4: | |
| color = '#00aaff' | |
| elif particle['wave_height'] < 6: | |
| color = '#ffaa00' | |
| else: | |
| color = '#ff0000' | |
| popup_text = f""" | |
| <div style="font-family: Arial; font-size: 12px;"> | |
| <b>π Wave Data</b><br> | |
| <b>Height:</b> {particle['wave_height']:.2f}m<br> | |
| <b>Direction:</b> {particle.get('wave_direction', 0):.1f}Β°<br> | |
| <b>Period:</b> {particle.get('wave_period', 0):.1f}s<br> | |
| <b>Region:</b> {particle.get('region', 'Unknown')} | |
| </div> | |
| """ | |
| folium.CircleMarker( | |
| location=[particle['lat'], particle['lon']], | |
| radius=max(2, min(6, particle['wave_height'] * 1.5)), | |
| popup=folium.Popup(popup_text, max_width=200), | |
| color=color, | |
| fillColor=color, | |
| fillOpacity=0.7, | |
| weight=1, | |
| opacity=0.8 | |
| ).add_to(wave_layer) | |
| wave_layer.add_to(m) | |
| # Add particle flow layer (simulated particle effect) | |
| particle_layer = folium.FeatureGroup(name="Wave Particles", show=True) | |
| # Add direction arrows as particles | |
| for i, particle in enumerate(particles[::8]): # Every 8th particle | |
| if all(key in particle for key in ['lat', 'lon', 'wave_direction', 'wave_height']): | |
| # Create arrow marker pointing in wave direction | |
| if particle['wave_height'] > 1: # Only show for significant waves | |
| direction = particle['wave_direction'] | |
| # Create custom arrow icon | |
| arrow_html = f''' | |
| <div style="transform: rotate({direction}deg); color: rgba(100, 200, 255, 0.8); font-size: 16px;"> | |
| β | |
| </div> | |
| ''' | |
| folium.Marker( | |
| location=[particle['lat'], particle['lon']], | |
| icon=folium.DivIcon( | |
| html=arrow_html, | |
| icon_size=(20, 20), | |
| icon_anchor=(10, 10) | |
| ) | |
| ).add_to(particle_layer) | |
| particle_layer.add_to(m) | |
| # Add layer control (like wind app) | |
| folium.LayerControl(collapsed=False).add_to(m) | |
| # Add custom controls HTML (exactly like wind app structure) | |
| controls_html = f''' | |
| <div style="position: fixed; top: 10px; left: 10px; z-index: 9999; | |
| background: rgba(255,255,255,0.95); padding: 15px; border-radius: 8px; | |
| box-shadow: 0 4px 8px rgba(0,0,0,0.2); font-family: Arial, sans-serif; font-size: 13px;"> | |
| <div style="margin-bottom: 10px;"> | |
| <strong>π Wave Visualization</strong> | |
| </div> | |
| <div style="margin: 8px 0;"> | |
| <label style="display: flex; align-items: center; margin-bottom: 5px;"> | |
| <input type="checkbox" id="waveHeightToggle" checked onchange="toggleWaveHeight()" | |
| style="margin-right: 8px;"> | |
| Wave Heights | |
| </label> | |
| <label style="display: flex; align-items: center;"> | |
| <input type="checkbox" id="waveParticleToggle" checked onchange="toggleWaveParticles()" | |
| style="margin-right: 8px;"> | |
| Wave Particles | |
| </label> | |
| </div> | |
| <div style="margin-top: 12px; padding-top: 10px; border-top: 1px solid #ddd;"> | |
| <label style="display: flex; align-items: center;"> | |
| <input type="checkbox" id="darkModeToggle" onchange="toggleDarkMode()" | |
| style="margin-right: 8px;"> | |
| Dark Mode | |
| </label> | |
| </div> | |
| <div style="margin-top: 8px; font-size: 11px; color: #666;"> | |
| Particles: {len(particles)} | Regions: {len(data.get('regions_processed', []))} | |
| </div> | |
| </div> | |
| <script> | |
| // Wait for map to be fully loaded | |
| setTimeout(function() {{ | |
| // Get the map instance | |
| var mapElement = document.querySelector('.folium-map'); | |
| if (mapElement && mapElement._leaflet_id) {{ | |
| var map = window[mapElement._leaflet_id]; | |
| window.waveMap = map; | |
| // Get layer references | |
| window.waveLayers = {{}}; | |
| map.eachLayer(function(layer) {{ | |
| if (layer.options && layer.options.name) {{ | |
| window.waveLayers[layer.options.name] = layer; | |
| }} | |
| }}); | |
| console.log('Wave map initialized with layers:', Object.keys(window.waveLayers)); | |
| }} | |
| }}, 1000); | |
| function toggleWaveHeight() {{ | |
| var checkbox = document.getElementById('waveHeightToggle'); | |
| var map = window.waveMap; | |
| if (map && window.waveLayers['Wave Heights']) {{ | |
| if (checkbox.checked) {{ | |
| map.addLayer(window.waveLayers['Wave Heights']); | |
| }} else {{ | |
| map.removeLayer(window.waveLayers['Wave Heights']); | |
| }} | |
| }} | |
| }} | |
| function toggleWaveParticles() {{ | |
| var checkbox = document.getElementById('waveParticleToggle'); | |
| var map = window.waveMap; | |
| if (map && window.waveLayers['Wave Particles']) {{ | |
| if (checkbox.checked) {{ | |
| map.addLayer(window.waveLayers['Wave Particles']); | |
| }} else {{ | |
| map.removeLayer(window.waveLayers['Wave Particles']); | |
| }} | |
| }} | |
| }} | |
| function toggleDarkMode() {{ | |
| var checkbox = document.getElementById('darkModeToggle'); | |
| var map = window.waveMap; | |
| if (map && window.waveLayers) {{ | |
| if (checkbox.checked) {{ | |
| // Switch to dark | |
| if (window.waveLayers['OpenStreetMap']) {{ | |
| map.removeLayer(window.waveLayers['OpenStreetMap']); | |
| }} | |
| if (window.waveLayers['Dark']) {{ | |
| map.addLayer(window.waveLayers['Dark']); | |
| }} | |
| }} else {{ | |
| // Switch to light | |
| if (window.waveLayers['Dark']) {{ | |
| map.removeLayer(window.waveLayers['Dark']); | |
| }} | |
| if (window.waveLayers['OpenStreetMap']) {{ | |
| map.addLayer(window.waveLayers['OpenStreetMap']); | |
| }} | |
| }} | |
| }} | |
| }} | |
| </script> | |
| ''' | |
| m.get_root().html.add_child(folium.Element(controls_html)) | |
| # Return HTML representation | |
| return m._repr_html_() | |
| except Exception as e: | |
| logger.error(f"Error creating wave map: {e}") | |
| return f"<div>Error creating wave visualization: {e}</div>" | |
| def format_particle_summary(data): | |
| """Format wave particle data summary for display""" | |
| try: | |
| particles = data.get('particles', []) | |
| if not particles: | |
| return "No wave particle data available" | |
| wave_heights = [p.get('wave_height', 0) for p in particles if p.get('wave_height') is not None] | |
| wave_speeds = [] | |
| for p in particles: | |
| if p.get('u_velocity') is not None and p.get('v_velocity') is not None: | |
| speed = np.sqrt(p['u_velocity']**2 + p['v_velocity']**2) | |
| wave_speeds.append(speed) | |
| if wave_heights: | |
| avg_height = np.mean(wave_heights) | |
| max_height = np.max(wave_heights) | |
| min_height = np.min(wave_heights) | |
| else: | |
| avg_height = max_height = min_height = 0 | |
| if wave_speeds: | |
| avg_speed = np.mean(wave_speeds) | |
| max_speed = np.max(wave_speeds) | |
| else: | |
| avg_speed = max_speed = 0 | |
| regions = data.get('regions_processed', ['Global']) | |
| summary = f""" | |
| ## π Wave Particle Animation Data | |
| **Data Source:** {data.get('data_source', 'NOAA_GRIB')} | |
| **Total Particles:** {len(particles):,} | |
| **Timestamp:** {data.get('timestamp', 'Unknown')} | |
| **Regions:** {', '.join(regions)} | |
| ### Wave Statistics: | |
| - **Average Height:** {avg_height:.2f}m | |
| - **Maximum Height:** {max_height:.2f}m | |
| - **Minimum Height:** {min_height:.2f}m | |
| - **Average Speed:** {avg_speed*1000:.2f} mm/s | |
| - **Maximum Speed:** {max_speed*1000:.2f} mm/s | |
| ### Particle Features: | |
| - β Real-time wave direction vectors | |
| - β Velocity-based particle movement | |
| - β Wave height color coding | |
| - β Interactive controls (play/pause, speed, colors) | |
| - β Particle trails showing wave paths | |
| ### Animation Controls: | |
| - **Play/Pause:** Control animation | |
| - **Speed:** Adjust particle movement speed | |
| - **Colors:** Cycle through color schemes | |
| - **Particles:** Adjust number of active particles | |
| *Use the controls on the map to customize the visualization!* | |
| """ | |
| return summary | |
| except Exception as e: | |
| logger.error(f"Error formatting particle summary: {e}") | |
| return f"Error formatting particle summary: {e}" | |
| # Create Gradio interface using WORKING GRIB fetching logic | |
| with gr.Blocks(title="π Wave Particle Visualizer", theme=gr.themes.Soft()) as demo: | |
| gr.Markdown("# π Wave Particle Animation Visualizer") | |
| gr.Markdown("Real-time animated wave particles using the SAME GRIB fetching logic as the working NWPS_SWAN space") | |
| with gr.Tabs(): | |
| # Particle Animation Tab | |
| with gr.TabItem("π― Wave Particle Animation"): | |
| gr.Markdown(""" | |
| **Using Working NWPS_SWAN GRIB Logic:** | |
| - π Same GRIB processing as your working NWPS_SWAN space | |
| - π― Animated particles showing wave movement | |
| - π¨ Multiple color schemes (Ocean, Heat Map, Plasma) | |
| - β‘ Adjustable animation speed and particle count | |
| - π Atlantic, Pacific, West Coast coverage (no Arctic errors) | |
| """) | |
| with gr.Row(): | |
| fetch_particles_btn = gr.Button("π― Fetch Wave Particles", variant="primary", size="lg") | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| particle_summary = gr.Markdown("Click 'Fetch Wave Particles' to start the animation") | |
| particle_status = gr.Textbox(label="Status", value="Ready to fetch particle data") | |
| with gr.Column(scale=2): | |
| particle_map = gr.HTML(label="Wave Particle Animation", value="") | |
| fetch_particles_btn.click( | |
| fn=fetch_wave_particles, | |
| outputs=[particle_summary, particle_map, particle_status] | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch( | |
| server_name="0.0.0.0", | |
| server_port=7860, | |
| show_error=True, | |
| share=False | |
| ) |