import plotly.graph_objects as go import plotly.express as px import numpy as np import pandas as pd from datetime import datetime import json from typing import List, Dict, Tuple, Any import re from collections import defaultdict from zoneinfo import ZoneInfo from src.analysis.geospatial import haversine_distance from src.config import config from src.utils import parse_period_string_to_datetime def format_datetime_for_display(datetime_str: str) -> str: """ Format datetime string from 'YYYY-MM-DD HH:MM:SS' to 'DD-MM-YYYY HH:MM:SS'. Args: datetime_str: Datetime string in format 'YYYY-MM-DD HH:MM:SS' Returns: Formatted datetime string in 'DD-MM-YYYY HH:MM:SS' format """ try: if datetime_str and datetime_str != 'N/A': dt = datetime.strptime(datetime_str, '%Y-%m-%d %H:%M:%S') return dt.strftime('%d-%m-%Y %H:%M:%S') return datetime_str except: return datetime_str def parse_wkt_linestring(wkt_string: str) -> List[Tuple[float, float]]: """ Parse WKT LINESTRING format to extract coordinates. Args: wkt_string: WKT string in format "LINESTRING(lon1 lat1, lon2 lat2, ...)" Returns: List of (longitude, latitude) tuples """ try: # Extract coordinates from LINESTRING format # Remove "LINESTRING(" and ")" and split by commas coords_str = wkt_string.replace("LINESTRING(", "").replace(")", "") coord_pairs = coords_str.split(",") coordinates = [] for pair in coord_pairs: lon, lat = pair.strip().split() coordinates.append((float(lon), float(lat))) return coordinates except Exception as e: print(f"Error parsing WKT: {e}") return [] def group_storm_data_by_day(storm_data: List[Dict]) -> Dict[str, List[Dict]]: """ Group storm data by day. Args: storm_data: List of storm cell dictionaries Returns: Dictionary with date as key and list of storms as value """ daily_storms = defaultdict(list) for storm in storm_data: # Try different time fields that might exist in storm data time_field = storm.get('effective_time') or storm.get('creation_time') or storm.get('expire_time', '') if time_field: try: # Parse time field (format: '2025-08-29T15:08:45.002Z' or '2024-06-22 11:13:00') if 'T' in time_field: # ISO format with T storm_date = datetime.strptime(time_field[:10], '%Y-%m-%d').strftime('%d-%m-%Y') else: # Standard format storm_date = datetime.strptime(time_field[:10], '%Y-%m-%d').strftime('%d-%m-%Y') daily_storms[storm_date].append(storm) except: continue return dict(daily_storms) def group_storm_data_by_month(storm_data: List[Dict]) -> Dict[str, List[Dict]]: """ Group storm data by month. Args: storm_data: List of storm cell dictionaries Returns: Dictionary with month as key and list of storms as value """ monthly_storms = defaultdict(list) for storm in storm_data: # Try different time fields that might exist in storm data time_field = storm.get('effective_time') or storm.get('creation_time') or storm.get('expire_time', '') if time_field: try: # Parse time field (format: '2025-08-29T15:08:45.002Z' or '2024-06-22 11:13:00') if 'T' in time_field: # ISO format with T storm_month = datetime.strptime(time_field[:10], '%Y-%m-%d').strftime('%Y-%m') else: # Standard format storm_month = datetime.strptime(time_field[:10], '%Y-%m-%d').strftime('%Y-%m') monthly_storms[storm_month].append(storm) except: continue return dict(monthly_storms) def create_storm_cells_coordinate_plane(storm_data: List[Dict], turbine_df: pd.DataFrame = None, center_lat: float = None, center_lon: float = None) -> go.Figure: """ Create a coordinate plane visualization showing storm cells with turbines and distance rings. Args: storm_data: List of storm cell dictionaries with WKT data turbine_df: DataFrame containing turbine locations with 'lat' and 'lng' columns center_lat: Center latitude for map (optional) center_lon: Center longitude for map (optional) Returns: Plotly figure with storm cells, turbines, and distance rings in coordinate plane view """ # Calculate center if not provided if center_lat is None or center_lon is None: if turbine_df is not None and not turbine_df.empty: # Use turbine centroid as center center_lat = turbine_df['lat'].mean() center_lon = turbine_df['lng'].mean() else: # Calculate center from storm data all_lats = [] all_lons = [] for storm in storm_data: coords = parse_wkt_linestring(storm.get('cell_polygon_wkt', '')) if coords: lons, lats = zip(*coords) all_lats.extend(lats) all_lons.extend(lons) if all_lats and all_lons: center_lat = np.mean(all_lats) center_lon = np.mean(all_lons) else: center_lat, center_lon = 36.8, 33.8 # Default to Turkey fig = go.Figure() # Add distance rings if turbine data is provided if turbine_df is not None and not turbine_df.empty: from src.analysis.geospatial import create_circle_points # Add distance rings from turbine centroid for radius, color in zip(config.distance_rings, config.ring_colors): circle_lats, circle_lons = create_circle_points(center_lat, center_lon, radius) fig.add_trace(go.Scatter( x=circle_lons, # X-axis = Longitude y=circle_lats, # Y-axis = Latitude mode='lines', line=dict(color=color, width=2), opacity=0.6, name=f'{radius/1000:.0f}km Ring', showlegend=True )) # Add turbines colored by risk using fixed intervals if 'risk_log' in turbine_df.columns: from src.utils import get_turbine_colors_by_fixed_intervals turbine_colors = get_turbine_colors_by_fixed_intervals(turbine_df['risk_log'].tolist()) else: turbine_colors = ['red'] * len(turbine_df) fig.add_trace(go.Scatter( x=turbine_df['lng'], # X-axis = Longitude y=turbine_df['lat'], # Y-axis = Latitude mode='markers+text', marker=dict( size=30, color=turbine_colors, symbol='triangle-down', opacity=0.8, line=dict(color='black', width=1) ), text=turbine_df['name'].tolist(), textfont=dict(size=12, color='black'), textposition='middle center', name='Wind Turbines', showlegend=True, hovertemplate=( "Wind Turbine
" "Name: %{text}
" "Lat: %{y:.5f}
" "Lng: %{x:.5f}
" "" ) )) severity_colors = {} seen_polygon_key_to_count: Dict[Tuple, int] = {} offset_deg_lon = 0.003 offset_deg_lat = 0.002 for i, storm in enumerate(storm_data): wkt_string = storm.get('cell_polygon_wkt', '') if not wkt_string: continue coords = parse_wkt_linestring(wkt_string) if not coords: continue polygon_key = tuple((round(lon, 6), round(lat, 6)) for lon, lat in coords) duplicate_index = 0 if polygon_key in seen_polygon_key_to_count: seen_polygon_key_to_count[polygon_key] += 1 duplicate_index = seen_polygon_key_to_count[polygon_key] - 1 else: seen_polygon_key_to_count[polygon_key] = 1 if duplicate_index > 0: off_lon = offset_deg_lon * duplicate_index off_lat = offset_deg_lat * duplicate_index coords = [(lon + off_lon, lat + off_lat) for lon, lat in coords] lons, lats = zip(*coords) severity = storm.get('lightning_severity', 'Unknown').lower() if severity == 'high': color = 'purple' elif severity == 'medium': color = 'orange' elif severity == 'low': color = 'green' else: color = 'gray' # Track severity colors for legend severity_colors[severity] = color # Add storm cell boundary fig.add_trace(go.Scatter( x=lons, # X-axis = Longitude y=lats, # Y-axis = Latitude mode='lines', line=dict(color=color, width=3), opacity=1, showlegend=False, # Don't show individual storms in legend hovertemplate=( f"Storm Cell {i+1}
" f"Severity: {storm.get('lightning_severity', 'Unknown')}
" f"Effective: {format_datetime_for_display(storm.get('effective_time', 'N/A'))}
" f"Expire: {format_datetime_for_display(storm.get('expire_time', 'N/A'))}
" f"Direction: {storm.get('direction', 'N/A')}°
" f"Speed: {storm.get('speed', 'N/A')} km/h
" f"" ) )) # Add static legend entries for severity levels for severity, color in severity_colors.items(): fig.add_trace(go.Scatter( x=[None], # Invisible trace for legend only y=[None], mode='lines', line=dict(color=color, width=3), name=f"{severity.title()} Severity", showlegend=True, hoverinfo='skip' # No hover for legend entries )) # Calculate axis limits based on data and distance rings max_radius_deg = max(config.distance_rings) / 111000 lat_min = center_lat - max_radius_deg * 1.5 lat_max = center_lat + max_radius_deg * 1.5 lon_min = center_lon - max_radius_deg * 1.5 lon_max = center_lon + max_radius_deg * 1.5 fig.update_layout( font=dict(size=18), title=dict(text='Storm Cells - Coordinate Plane View', font=dict(size=28)), xaxis_title='Longitude', yaxis_title='Latitude', xaxis=dict( range=[lon_min, lon_max], showgrid=True, gridwidth=1, gridcolor='lightgray', zeroline=False, tickfont=dict(size=22), title_font=dict(size=28), ), yaxis=dict( range=[lat_min, lat_max], showgrid=True, gridwidth=1, gridcolor='lightgray', zeroline=False, tickfont=dict(size=22), title_font=dict(size=28), ), plot_bgcolor='white', paper_bgcolor='white', showlegend=True, legend=dict( title=dict(text='Legend', font=dict(size=24)), font=dict(size=20), orientation='h', x=0.5, xanchor='center', y=-0.18, yanchor='top', bgcolor='rgba(255, 255, 255, 0.8)', bordercolor='black', borderwidth=1, ), width=800, height=900, margin=dict(l=70, r=40, t=50, b=130), ) return fig def create_storm_cells_map(storm_data: List[Dict], turbine_df: pd.DataFrame = None, center_lat: float = None, center_lon: float = None) -> go.Figure: """ Create a map showing storm cells from the fırtına data with turbines and distance rings. Now uses coordinate plane view instead of mapbox. Args: storm_data: List of storm cell dictionaries with WKT data turbine_df: DataFrame containing turbine locations with 'lat' and 'lng' columns center_lat: Center latitude for map (optional) center_lon: Center longitude for map (optional) Returns: Plotly figure with storm cells, turbines, and distance rings in coordinate plane view """ return create_storm_cells_coordinate_plane(storm_data, turbine_df, center_lat, center_lon) def create_daily_storm_maps(storm_data: List[Dict], max_maps_per_page: int = 2) -> List[go.Figure]: """ Create separate maps for each day with storms. Args: storm_data: List of storm cell dictionaries max_maps_per_page: Maximum number of maps to show per page Returns: List of Plotly figures, each containing maps for one or more days """ daily_storms = group_storm_data_by_day(storm_data) if not daily_storms: return [] # Sort days sorted_days = sorted(daily_storms.keys()) figures = [] current_fig = None maps_in_current_fig = 0 for day in sorted_days: day_storms = daily_storms[day] if current_fig is None or maps_in_current_fig >= max_maps_per_page: # Create new figure current_fig = go.Figure() maps_in_current_fig = 0 # Set up subplot layout if max_maps_per_page == 1: # Single map layout current_fig.update_layout( mapbox=dict( style='carto-positron', center=dict(lat=36.8, lon=33.8), zoom=8 ), margin=dict(l=0, r=0, t=0, b=0), showlegend=True ) else: # Multiple maps layout - will be handled in PDF generation pass # Create map for this day day_fig = create_storm_cells_map(day_storms) # Add day title day_fig.update_layout( title=f"Storm Cells - {day}", title_x=0.5, title_font_size=16 ) # If this is the first map in the figure, use it as base if maps_in_current_fig == 0: current_fig = day_fig else: # For multiple maps, we'll handle layout in PDF generation pass maps_in_current_fig += 1 # If we've reached the limit, add to figures list if maps_in_current_fig >= max_maps_per_page: figures.append(current_fig) current_fig = None maps_in_current_fig = 0 # Add remaining figure if any if current_fig is not None: figures.append(current_fig) return figures def create_monthly_storm_maps(storm_data: List[Dict]) -> Dict[str, go.Figure]: """ Create separate maps for each month with storms. Args: storm_data: List of storm cell dictionaries Returns: Dictionary with month as key and Plotly figure as value """ monthly_storms = group_storm_data_by_month(storm_data) monthly_figures = {} for month, month_storms in monthly_storms.items(): if month_storms: fig = create_storm_cells_map(month_storms) fig.update_layout( title=f"Storm Cells - {month}", title_x=0.5, title_font_size=16 ) monthly_figures[month] = fig return monthly_figures def load_storm_data_from_json(json_file_path: str) -> List[Dict]: """ Load storm data from JSON file. Args: json_file_path: Path to the JSON file Returns: List of storm cell dictionaries """ try: with open(json_file_path, 'r', encoding='utf-8') as f: data = json.load(f) # Handle different JSON structures if isinstance(data, dict): # Handle structure with "success" and "data" keys if "data" in data and isinstance(data["data"], dict): # Convert dictionary of storm cells to list storm_list = [] for storm_id, storm_records in data["data"].items(): if isinstance(storm_records, list): storm_list.extend(storm_records) else: storm_list.append(storm_records) return storm_list # Handle structure where data is directly a list elif "data" in data and isinstance(data["data"], list): return data["data"] # Handle structure where data is directly the list else: return data # If data is already a list elif isinstance(data, list): return data return [] except Exception as e: print(f"Error loading storm data: {e}") return [] def filter_storm_data_by_date_range(storm_data: List[Dict], start_date: str, end_date: str) -> List[Dict]: """ Filter storm data by date range. start_date/end_date can be 'DD-MM-YYYY', 'DD-MM-YYYY HH:MM', or ISO (e.g. 2026-01-22T07:00:00Z). Storm timestamps are converted to the farm's local timezone (config.timezone) for comparison. """ try: start_dt = parse_period_string_to_datetime(start_date) end_dt = parse_period_string_to_datetime(end_date) if start_dt is None or end_dt is None: return storm_data if re.fullmatch(r"\d{2}-\d{2}-\d{4}", str(end_date).strip()): end_dt = end_dt.replace(hour=23, minute=59, second=59) tz_name = getattr(config, 'timezone', None) tz = ZoneInfo(tz_name) if tz_name else None filtered_data = [] for storm in storm_data: time_field = storm.get('effective_time') or storm.get('creation_time') or storm.get('expire_time', '') if time_field: try: storm_ts = pd.to_datetime(time_field, utc=True) if tz is not None: storm_ts = storm_ts.tz_convert(tz).tz_localize(None) storm_dt = storm_ts.to_pydatetime() else: storm_dt = storm_ts.to_pydatetime().replace(tzinfo=None) if start_dt <= storm_dt <= end_dt: filtered_data.append(storm) except Exception: continue return filtered_data except Exception as e: print(f"Error filtering storm data: {e}") return storm_data def filter_storm_data_by_turbine_proximity(storm_data: List[Dict], turbine_df: pd.DataFrame, max_distance_km: float = None) -> List[Dict]: """ Filter storm data to only include storms within the specified distance from turbines. Args: storm_data: List of storm cell dictionaries turbine_df: DataFrame containing turbine locations with 'lat' and 'lng' columns max_distance_km: Maximum distance in kilometers. If None, uses the farthest distance ring from config. Returns: Filtered list of storm cell dictionaries """ if max_distance_km is None: # Use the farthest distance ring from config (convert meters to km) max_distance_km = max(config.distance_rings) / 1000 print(f"🌩️ Filtering storm cells within {max_distance_km} km of turbine locations...") filtered_storms = [] for storm in storm_data: wkt_string = storm.get('cell_polygon_wkt', '') if not wkt_string: continue coords = parse_wkt_linestring(wkt_string) if not coords: continue # Check if any point in the storm cell is within the distance threshold storm_within_range = False for storm_lon, storm_lat in coords: for _, turbine in turbine_df.iterrows(): turbine_lat = turbine['lat'] turbine_lon = turbine['lng'] distance_km = haversine_distance(turbine_lat, turbine_lon, storm_lat, storm_lon) / 1000 if distance_km <= max_distance_km: storm_within_range = True break if storm_within_range: break if storm_within_range: filtered_storms.append(storm) print(f"🌩️ Filtered from {len(storm_data)} to {len(filtered_storms)} storm cells within {max_distance_km} km") return filtered_storms def calculate_storm_cell_centroid(wkt_string: str) -> Tuple[float, float]: """ Calculate the centroid of a storm cell from WKT coordinates. Args: wkt_string: WKT string representing the storm cell boundary Returns: Tuple of (latitude, longitude) for the centroid """ coords = parse_wkt_linestring(wkt_string) if not coords: return None # Calculate centroid (simple average of all points) lons, lats = zip(*coords) centroid_lat = np.mean(lats) centroid_lon = np.mean(lons) return centroid_lat, centroid_lon def create_storm_cells_summary(storm_data: List[Dict]) -> Dict[str, Any]: """ Create a summary of storm cells data. Args: storm_data: List of storm cell dictionaries Returns: Dictionary with summary statistics """ if not storm_data: return {} # Count by severity severity_counts = {} total_cells = len(storm_data) for storm in storm_data: severity = storm.get('lightning_severity', 'Unknown') severity_counts[severity] = severity_counts.get(severity, 0) + 1 # Calculate average direction and speed directions = [storm.get('direction', 0) for storm in storm_data if storm.get('direction') is not None] speeds = [storm.get('speed', 0) for storm in storm_data if storm.get('speed') is not None] avg_direction = np.mean(directions) if directions else 0 avg_speed = np.mean(speeds) if speeds else 0 # Get date range time_fields = [] for storm in storm_data: time_field = storm.get('effective_time') or storm.get('creation_time') or storm.get('expire_time', '') if time_field: time_fields.append(time_field) if time_fields: try: dates = [] for time_field in time_fields: if 'T' in time_field: # ISO format with T dates.append(datetime.strptime(time_field[:10], '%Y-%m-%d')) else: # Standard format dates.append(datetime.strptime(time_field[:10], '%Y-%m-%d')) start_date = min(dates).strftime('%d-%m-%Y') end_date = max(dates).strftime('%d-%m-%Y') except: start_date = end_date = "Unknown" else: start_date = end_date = "Unknown" # Get daily breakdown daily_storms = group_storm_data_by_day(storm_data) daily_summary = {day: len(storms) for day, storms in daily_storms.items()} return { 'total_cells': total_cells, 'severity_counts': severity_counts, 'avg_direction': avg_direction, 'avg_speed': avg_speed, 'date_range': {'start': start_date, 'end': end_date}, 'daily_breakdown': daily_summary }