import plotly.graph_objects as go import plotly.express as px import numpy as np from src.analysis.geospatial import create_circle_points, haversine_distance from src.config import config import pandas as pd def plot_turbine_map(turbine_row: pd.Series, lightning_df: pd.DataFrame, turbine_df: pd.DataFrame) -> go.Figure: turbine_lat = turbine_row['lat'] turbine_lon = turbine_row['lng'] fig = go.Figure() # Plot all lightnings lightning_colors = [] for _, lightning in lightning_df.iterrows(): d = haversine_distance(turbine_lat, turbine_lon, lightning['lat'], lightning['lng']) color = 'gray' for ring, ring_color in zip(config.distance_rings, config.ring_colors): if d <= ring: color = ring_color break lightning_colors.append(color) lightning_sizes = np.clip(lightning_df['current_abs'] / 1500, 3, 12) fig.add_trace(go.Scattermapbox( lat=lightning_df['lat'], lon=lightning_df['lng'], mode='markers', marker=dict(size=lightning_sizes, color=lightning_colors, opacity=0.8, symbol='circle', sizemin=3), name='Lightning Strikes', showlegend=True )) # Add distance circles for radius, color in zip(config.distance_rings, config.ring_colors): circle_lats, circle_lons = create_circle_points(turbine_lat, turbine_lon, radius) fig.add_trace(go.Scattermapbox( lat=circle_lats, lon=circle_lons, mode='lines', line=dict(color=color, width=2), opacity=0.6, name=f'{radius/1000:.1f}km Distance 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.Scattermapbox( lat=turbine_df['lat'], lon=turbine_df['lng'], mode='markers+text', marker=dict(size=16, color=turbine_colors, symbol='circle', opacity=1), text=['T'] * len(turbine_df), textfont=dict(size=10, color='black'), textposition='middle center', name='Wind Turbines', showlegend=True )) # Calculate zoom level based on the largest distance ring max_radius_m = max(config.distance_rings) # Convert meters to degrees (approximate) # 1 degree latitude ≈ 111,000 meters # 1 degree longitude ≈ 111,000 * cos(latitude) meters lat_degrees = max_radius_m / 111000 lon_degrees = max_radius_m / (111000 * np.cos(np.radians(turbine_lat))) # Calculate the required map span to show the largest circle # We want the circle to almost touch the top and bottom edges # Map aspect ratio is roughly 4:3 (width:height) # We need to account for the circle diameter (2 * radius) plus minimal padding # Calculate the required span in degrees required_lat_span = 2 * lat_degrees * 1.1 # 10% padding required_lon_span = 2 * lon_degrees * 1.1 # 10% padding # Use the larger span to ensure the circle fits required_span = max(required_lat_span, required_lon_span) # Calculate zoom level based on required span # Mapbox zoom levels: each level doubles the scale # At zoom 0: 360 degrees longitude spans the map # At zoom 1: 180 degrees longitude spans the map # At zoom 2: 90 degrees longitude spans the map # Formula: zoom = log2(360 / required_span) import math zoom_level = math.log2(360 / required_span) # Clamp zoom level to reasonable bounds zoom_level = max(8, min(15, zoom_level)) fig.update_layout( mapbox=dict( style='carto-positron', center=dict(lat=turbine_lat, lon=turbine_lon), zoom=zoom_level ), margin=dict(l=0, r=0, t=0, b=0), showlegend=True, legend=dict( x=0.02, y=0.98, bgcolor='rgba(255, 255, 255, 0.8)', bordercolor='black', borderwidth=1 ) ) return fig def plot_intercloud_map(turbine_row: pd.Series, lightning_df: pd.DataFrame, turbine_df: pd.DataFrame) -> go.Figure: """Create a map showing only intercloud lightning strikes.""" turbine_lat = turbine_row['lat'] turbine_lon = turbine_row['lng'] fig = go.Figure() # Filter for intercloud lightning only (p_type != '0') ic_mask = (lightning_df['p_type'].astype(str) != '0') ic_lightning_df = lightning_df[ic_mask] if len(ic_lightning_df) > 0: # Plot intercloud lightnings lightning_colors = [] for _, lightning in ic_lightning_df.iterrows(): d = haversine_distance(turbine_lat, turbine_lon, lightning['lat'], lightning['lng']) color = 'gray' for ring, ring_color in zip(config.distance_rings, config.ring_colors): if d <= ring: color = ring_color break lightning_colors.append(color) lightning_sizes = np.clip(ic_lightning_df['current_abs'] / 1500, 3, 12) fig.add_trace(go.Scattermapbox( lat=ic_lightning_df['lat'], lon=ic_lightning_df['lng'], mode='markers', marker=dict(size=lightning_sizes, color=lightning_colors, opacity=0.8, symbol='circle', sizemin=3), name='Intercloud Lightning', showlegend=True )) # Add distance circles for radius, color in zip(config.distance_rings, config.ring_colors): circle_lats, circle_lons = create_circle_points(turbine_lat, turbine_lon, radius) fig.add_trace(go.Scattermapbox( lat=circle_lats, lon=circle_lons, mode='lines', line=dict(color=color, width=2), opacity=0.6, name=f'{radius/1000:.1f}km Distance Ring', showlegend=True )) # Add turbines colored by risk 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()) norm_risk = (turbine_df['risk_log'] - turbine_df['risk_log'].min()) / (turbine_df['risk_log'].max() - turbine_df['risk_log'].min() + 1e-9) else: turbine_colors = ['red'] * len(turbine_df) fig.add_trace(go.Scattermapbox( lat=turbine_df['lat'], lon=turbine_df['lng'], mode='markers+text', marker=dict(size=16, color=turbine_colors, symbol='circle', opacity=1), text=['T'] * len(turbine_df), textfont=dict(size=10, color='black'), textposition='middle center', name='Wind Turbines', showlegend=True )) # Calculate zoom level based on the largest distance ring max_radius_m = max(config.distance_rings) # Convert meters to degrees (approximate) lat_degrees = max_radius_m / 111000 lon_degrees = max_radius_m / (111000 * np.cos(np.radians(turbine_lat))) # Calculate the required map span to show the largest circle required_lat_span = 2 * lat_degrees * 1.1 required_lon_span = 2 * lon_degrees * 1.1 required_span = max(required_lat_span, required_lon_span) # Calculate zoom level based on required span import math zoom_level = math.log2(360 / required_span) # Clamp zoom level to reasonable bounds zoom_level = max(8, min(15, zoom_level)) fig.update_layout( mapbox=dict( style='carto-positron', center=dict(lat=turbine_lat, lon=turbine_lon), zoom=zoom_level ), margin=dict(l=0, r=0, t=0, b=0), showlegend=True, legend=dict( x=0.02, y=0.98, bgcolor='rgba(255, 255, 255, 0.8)', bordercolor='black', borderwidth=1 ) ) return fig def plot_cloud_to_ground_map(turbine_row: pd.Series, lightning_df: pd.DataFrame, turbine_df: pd.DataFrame) -> go.Figure: """Create a map showing only cloud-to-ground lightning strikes.""" turbine_lat = turbine_row['lat'] turbine_lon = turbine_row['lng'] fig = go.Figure() # Filter for cloud-to-ground lightning only (p_type == '0') cg_mask = (lightning_df['p_type'].astype(str) == '0') cg_lightning_df = lightning_df[cg_mask] if len(cg_lightning_df) > 0: # Plot cloud-to-ground lightnings lightning_colors = [] for _, lightning in cg_lightning_df.iterrows(): d = haversine_distance(turbine_lat, turbine_lon, lightning['lat'], lightning['lng']) color = 'gray' for ring, ring_color in zip(config.distance_rings, config.ring_colors): if d <= ring: color = ring_color break lightning_colors.append(color) lightning_sizes = np.clip(cg_lightning_df['current_abs'] / 1500, 3, 12) fig.add_trace(go.Scattermapbox( lat=cg_lightning_df['lat'], lon=cg_lightning_df['lng'], mode='markers', marker=dict(size=lightning_sizes, color=lightning_colors, opacity=0.8, symbol='circle', sizemin=3), name='Cloud-to-Ground Lightning', showlegend=True )) # Add distance circles for radius, color in zip(config.distance_rings, config.ring_colors): circle_lats, circle_lons = create_circle_points(turbine_lat, turbine_lon, radius) fig.add_trace(go.Scattermapbox( lat=circle_lats, lon=circle_lons, mode='lines', line=dict(color=color, width=2), opacity=0.6, name=f'{radius/1000:.1f}km Distance Ring', showlegend=True )) # Add turbines colored by risk 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()) norm_risk = (turbine_df['risk_log'] - turbine_df['risk_log'].min()) / (turbine_df['risk_log'].max() - turbine_df['risk_log'].min() + 1e-9) else: turbine_colors = ['red'] * len(turbine_df) fig.add_trace(go.Scattermapbox( lat=turbine_df['lat'], lon=turbine_df['lng'], mode='markers+text', marker=dict(size=16, color=turbine_colors, symbol='circle', opacity=1), text=['T'] * len(turbine_df), textfont=dict(size=10, color='black'), textposition='middle center', name='Wind Turbines', showlegend=True )) # Calculate zoom level based on the largest distance ring max_radius_m = max(config.distance_rings) # Convert meters to degrees (approximate) lat_degrees = max_radius_m / 111000 lon_degrees = max_radius_m / (111000 * np.cos(np.radians(turbine_lat))) # Calculate the required map span to show the largest circle required_lat_span = 2 * lat_degrees * 1.1 required_lon_span = 2 * lon_degrees * 1.1 required_span = max(required_lat_span, required_lon_span) # Calculate zoom level based on required span import math zoom_level = math.log2(360 / required_span) # Clamp zoom level to reasonable bounds zoom_level = max(8, min(15, zoom_level)) fig.update_layout( mapbox=dict( style='carto-positron', center=dict(lat=turbine_lat, lon=turbine_lon), zoom=zoom_level ), margin=dict(l=0, r=0, t=0, b=0), showlegend=True, legend=dict( x=0.02, y=0.98, bgcolor='rgba(255, 255, 255, 0.8)', bordercolor='black', borderwidth=1 ) ) return fig def plot_coordinate_plane(turbine_row: pd.Series, lightning_df: pd.DataFrame, turbine_df: pd.DataFrame, storm_data: list = None) -> go.Figure: """ Create a coordinate plane visualization where: - X-axis = Longitude values - Y-axis = Latitude values - Turbines, lightnings, and storms are plotted as points """ turbine_lat = turbine_row['lat'] turbine_lon = turbine_row['lng'] fig = go.Figure() # Add distance rings as circles on the coordinate plane (background layer) for radius, color in zip(config.distance_rings, config.ring_colors): # Convert radius from meters to degrees (approximate) radius_deg = radius / 111000 # 1 degree ≈ 111,000 meters # Create circle points in lat/lon space circle_lats, circle_lons = create_circle_points(turbine_lat, turbine_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:.1f}km Distance Ring', showlegend=True )) # Add turbines (middle layer) 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()) norm_risk = (turbine_df['risk_log'] - turbine_df['risk_log'].min()) / (turbine_df['risk_log'].max() - turbine_df['risk_log'].min() + 1e-9) 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=1, line=dict(color='black', width=1) ), text=turbine_df['name'].tolist(), textfont=dict(size=18, color='black'), # turbine name label font size textposition='middle center', name='Wind Turbines', showlegend=True )) # Add storms if available (middle layer) if storm_data is not None and len(storm_data) > 0: # Convert storm data to DataFrame for easier handling storm_df = pd.DataFrame(storm_data) if 'lat' in storm_df.columns and 'lng' in storm_df.columns: fig.add_trace(go.Scatter( x=storm_df['lng'], # X-axis = Longitude y=storm_df['lat'], # Y-axis = Latitude mode='markers+text', marker=dict( size=20, color='purple', symbol='star', opacity=0.8, line=dict(color='black', width=1) ), text=['S'] * len(storm_df), textfont=dict(size=18, color='white'), # storm "S" label font size textposition='middle center', name='Storm Cells', showlegend=True )) # Plot lightning strikes (foreground layer - always on top) lightning_colors = [] for _, lightning in lightning_df.iterrows(): d = haversine_distance(turbine_lat, turbine_lon, lightning['lat'], lightning['lng']) color = 'gray' for ring, ring_color in zip(config.distance_rings, config.ring_colors): if d <= ring: color = ring_color break lightning_colors.append(color) lightning_sizes = np.clip(lightning_df['current_abs'] / 1500, 3, 12) fig.add_trace(go.Scatter( x=lightning_df['lng'], # X-axis = Longitude y=lightning_df['lat'], # Y-axis = Latitude mode='markers', marker=dict( size=lightning_sizes, color=lightning_colors, opacity=0.8, symbol='circle', sizemin=3 ), name='Lightning Strikes', showlegend=True )) # Calculate axis limits based on data and distance rings max_radius_deg = max(config.distance_rings) / 111000 lat_min = turbine_lat - max_radius_deg * 1.5 lat_max = turbine_lat + max_radius_deg * 1.5 lon_min = turbine_lon - max_radius_deg * 1.5 lon_max = turbine_lon + max_radius_deg * 1.5 fig.update_layout( title=f'Coordinate Plane View - Central Turbine: {turbine_row["name"]}', xaxis=dict( title=dict(text='Longitude', font=dict(size=18)), tickfont=dict(size=16), range=[lon_min, lon_max], showgrid=True, gridwidth=1, gridcolor='lightgray', zeroline=False ), yaxis=dict( title=dict(text='Latitude', font=dict(size=18)), tickfont=dict(size=16), range=[lat_min, lat_max], showgrid=True, gridwidth=1, gridcolor='lightgray', zeroline=False ), plot_bgcolor='white', paper_bgcolor='white', showlegend=True, legend=dict( title=dict(text='Legend', font=dict(size=20)), font=dict(size=17), orientation='h', x=0.5, xanchor='center', y=-0.15, yanchor='top', bgcolor='rgba(255, 255, 255, 0.8)', bordercolor='black', borderwidth=1, itemsizing='constant', itemwidth=30, ), width=800, height=900 ) return fig def plot_intercloud_coordinate_plane(turbine_row: pd.Series, lightning_df: pd.DataFrame, turbine_df: pd.DataFrame, storm_data: list = None, precomputed: dict | None = None) -> go.Figure: """Create a coordinate plane showing only intercloud lightning strikes.""" turbine_lat = turbine_row['lat'] turbine_lon = turbine_row['lng'] fig = go.Figure() # Add distance rings (background layer) for radius, color in zip(config.distance_rings, config.ring_colors): circle_lats, circle_lons = create_circle_points(turbine_lat, turbine_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:.1f}km Distance Ring', showlegend=True )) # Add turbines (middle layer) 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()) norm_risk = (turbine_df['risk_log'] - turbine_df['risk_log'].min()) / (turbine_df['risk_log'].max() - turbine_df['risk_log'].min() + 1e-9) 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=1, line=dict(color='black', width=1) ), text=turbine_df['name'].tolist(), textfont=dict(size=24, color='black'), textposition='middle center', name='Wind Turbines', showlegend=True )) # Add storms if available (middle layer) if storm_data is not None and len(storm_data) > 0: # Convert storm data to DataFrame for easier handling storm_df = pd.DataFrame(storm_data) if 'lat' in storm_df.columns and 'lng' in storm_df.columns: fig.add_trace(go.Scatter( x=storm_df['lng'], # X-axis = Longitude y=storm_df['lat'], # Y-axis = Latitude mode='markers+text', marker=dict( size=20, color='purple', symbol='star', opacity=0.8, line=dict(color='black', width=1) ), text=['S'] * len(storm_df), textfont=dict(size=24, color='white'), textposition='middle center', name='Storm Cells', showlegend=True )) # Filter for intercloud lightning only (p_type != '0') ic_mask = (lightning_df['p_type'].astype(str) != '0') ic_lightning_df = lightning_df[ic_mask] if len(ic_lightning_df) > 0: # Plot intercloud lightnings (foreground layer - always on top) lightning_colors = [] if precomputed is not None and 'ring_idx' in precomputed and len(precomputed['ring_idx']) == len(lightning_df): mask_ic = (lightning_df['p_type'].astype(str) != '0').values ring_idx = precomputed['ring_idx'][mask_ic] for ri in ring_idx: if 0 <= int(ri) < len(config.ring_colors): lightning_colors.append(config.ring_colors[int(ri)]) else: lightning_colors.append('gray') else: for _, lightning in ic_lightning_df.iterrows(): d = haversine_distance(turbine_lat, turbine_lon, lightning['lat'], lightning['lng']) color = 'gray' for ring, ring_color in zip(config.distance_rings, config.ring_colors): if d <= ring: color = ring_color break lightning_colors.append(color) lightning_sizes = np.clip(ic_lightning_df['current_abs'] / 1500, 3, 12) fig.add_trace(go.Scatter( x=ic_lightning_df['lng'], # X-axis = Longitude y=ic_lightning_df['lat'], # Y-axis = Latitude mode='markers', marker=dict( size=lightning_sizes, color=lightning_colors, opacity=0.8, symbol='circle', sizemin=3 ), name='Intercloud Lightning', showlegend=True )) # Calculate axis limits max_radius_deg = max(config.distance_rings) / 111000 lat_min = turbine_lat - max_radius_deg * 1.5 lat_max = turbine_lat + max_radius_deg * 1.5 lon_min = turbine_lon - max_radius_deg * 1.5 lon_max = turbine_lon + max_radius_deg * 1.5 fig.update_layout( title=f'Intercloud Lightning - Coordinate Plane View - Central Turbine: {turbine_row["name"]}', xaxis=dict( title=dict(text='Longitude', font=dict(size=28)), # x-axis title font size tickfont=dict(size=22), # x-axis tick label font size range=[lon_min, lon_max], showgrid=True, gridwidth=1, gridcolor='lightgray', zeroline=False ), yaxis=dict( title=dict(text='Latitude', font=dict(size=28)), # y-axis title font size tickfont=dict(size=22), # y-axis tick label font size range=[lat_min, lat_max], showgrid=True, gridwidth=1, gridcolor='lightgray', zeroline=False ), plot_bgcolor='white', paper_bgcolor='white', showlegend=True, legend=dict( title=dict(text='Legend', font=dict(size=24)), # legend title font size font=dict(size=20), # legend item font size orientation='h', x=0.5, xanchor='center', y=-0.22, yanchor='top', bgcolor='rgba(255, 255, 255, 0.8)', bordercolor='black', borderwidth=1, itemsizing='constant', itemwidth=30, ), width=950, height=950, margin=dict(l=40, r=40, t=80, b=220), font=dict(size=18), # global chart font size ) return fig def plot_cloud_to_ground_coordinate_plane(turbine_row: pd.Series, lightning_df: pd.DataFrame, turbine_df: pd.DataFrame, storm_data: list = None, precomputed: dict | None = None) -> go.Figure: """Create a coordinate plane showing only cloud-to-ground lightning strikes.""" turbine_lat = turbine_row['lat'] turbine_lon = turbine_row['lng'] fig = go.Figure() # Add distance rings (background layer) for radius, color in zip(config.distance_rings, config.ring_colors): circle_lats, circle_lons = create_circle_points(turbine_lat, turbine_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:.1f}km Distance Ring', showlegend=True )) # Add turbines (middle layer) 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()) norm_risk = (turbine_df['risk_log'] - turbine_df['risk_log'].min()) / (turbine_df['risk_log'].max() - turbine_df['risk_log'].min() + 1e-9) 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=1, line=dict(color='black', width=1) ), text=turbine_df['name'].tolist(), textfont=dict(size=18, color='black'), # turbine name label font size textposition='middle center', name='Wind Turbines', showlegend=True )) # Add storms if available (middle layer) if storm_data is not None and len(storm_data) > 0: # Convert storm data to DataFrame for easier handling storm_df = pd.DataFrame(storm_data) if 'lat' in storm_df.columns and 'lng' in storm_df.columns: fig.add_trace(go.Scatter( x=storm_df['lng'], # X-axis = Longitude y=storm_df['lat'], # Y-axis = Latitude mode='markers+text', marker=dict( size=20, color='purple', symbol='star', opacity=0.8, line=dict(color='black', width=1) ), text=['S'] * len(storm_df), textfont=dict(size=18, color='white'), # storm "S" label font size textposition='middle center', name='Storm Cells', showlegend=True )) # Filter for cloud-to-ground lightning only (p_type == '0') cg_mask = (lightning_df['p_type'].astype(str) == '0') cg_lightning_df = lightning_df[cg_mask] if len(cg_lightning_df) > 0: # Plot cloud-to-ground lightnings (foreground layer - always on top) lightning_colors = [] if precomputed is not None and 'ring_idx' in precomputed and len(precomputed['ring_idx']) == len(lightning_df): mask_cg = (lightning_df['p_type'].astype(str) == '0').values ring_idx = precomputed['ring_idx'][mask_cg] for ri in ring_idx: if 0 <= int(ri) < len(config.ring_colors): lightning_colors.append(config.ring_colors[int(ri)]) else: lightning_colors.append('gray') else: for _, lightning in cg_lightning_df.iterrows(): d = haversine_distance(turbine_lat, turbine_lon, lightning['lat'], lightning['lng']) color = 'gray' for ring, ring_color in zip(config.distance_rings, config.ring_colors): if d <= ring: color = ring_color break lightning_colors.append(color) lightning_sizes = np.clip(cg_lightning_df['current_abs'] / 1500, 3, 12) fig.add_trace(go.Scatter( x=cg_lightning_df['lng'], # X-axis = Longitude y=cg_lightning_df['lat'], # Y-axis = Latitude mode='markers', marker=dict( size=lightning_sizes, color=lightning_colors, opacity=0.8, symbol='circle', sizemin=3 ), name='Cloud-to-Ground Lightning', showlegend=True )) # Calculate axis limits max_radius_deg = max(config.distance_rings) / 111000 lat_min = turbine_lat - max_radius_deg * 1.5 lat_max = turbine_lat + max_radius_deg * 1.5 lon_min = turbine_lon - max_radius_deg * 1.5 lon_max = turbine_lon + max_radius_deg * 1.5 fig.update_layout( title=f'Cloud-to-Ground Lightning - Coordinate Plane View - Central Turbine: {turbine_row["name"]}', xaxis=dict( title=dict(text='Longitude', font=dict(size=28)), # x-axis title font size tickfont=dict(size=22), # x-axis tick label font size range=[lon_min, lon_max], showgrid=True, gridwidth=1, gridcolor='lightgray', zeroline=False ), yaxis=dict( title=dict(text='Latitude', font=dict(size=28)), # y-axis title font size tickfont=dict(size=22), # y-axis tick label font size range=[lat_min, lat_max], showgrid=True, gridwidth=1, gridcolor='lightgray', zeroline=False ), plot_bgcolor='white', paper_bgcolor='white', showlegend=True, legend=dict( title=dict(text='Legend', font=dict(size=24)), # legend title font size font=dict(size=20), # legend item font size orientation='h', x=0.5, xanchor='center', y=-0.22, yanchor='top', bgcolor='rgba(255, 255, 255, 0.8)', bordercolor='black', borderwidth=1, itemsizing='constant', itemwidth=30, ), width=950, height=950, margin=dict(l=40, r=40, t=80, b=220), font=dict(size=18), # global chart font size ) return fig def save_map_image(fig, filename): fig.write_image(filename, format='png', scale=2, engine='kaleido') def create_risk_score_chart() -> go.Figure: """ Create a risk score chart showing the relationship between distance, current magnitude, and risk scores. This helps readers understand how risk scores are calculated and what the min/max values could be. """ import numpy as np from src.config import config # Get risk parameters P_0 = config.risk_params['P_0'] alpha = config.risk_params['alpha'] current_weight = config.risk_params['current_weight'] # Create distance and current ranges distances_km = np.linspace(0.1, 5.0, 50) # 0.1 to 5 km currents_amp = np.linspace(1000, 50000, 50) # 1kA to 50kA # Create meshgrid for 3D plotting D, C = np.meshgrid(distances_km, currents_amp) # Calculate risk scores using the same formula as in risk calculation current_factor = 1 + current_weight * C / 10000 distance_factor = np.exp(-alpha * D) risk_scores = P_0 * current_factor * distance_factor # Create the 3D surface plot fig = go.Figure(data=[go.Surface( x=D, y=C, z=risk_scores, colorscale='Viridis', colorbar=dict( title="Risk Score", tickformat=".3f" ), hovertemplate=( "Risk Score: %{z:.3f}
" + "Distance: %{x:.2f} km
" + "Current: %{y:.0f} A
" + "" ) )]) # Update layout fig.update_layout( title={ 'text': 'Risk Score Calculation Chart
Shows how distance and current magnitude affect risk scores', 'x': 0.5, 'xanchor': 'center', 'font': {'size': 16} }, scene=dict( xaxis_title='Distance (km)', yaxis_title='Current Magnitude (A)', zaxis_title='Risk Score', xaxis=dict( range=[0, 5], tickmode='linear', tick0=0, dtick=1, gridcolor='lightgray', zerolinecolor='lightgray' ), yaxis=dict( range=[0, 50000], tickmode='linear', tick0=0, dtick=10000, gridcolor='lightgray', zerolinecolor='lightgray' ), zaxis=dict( gridcolor='lightgray', zerolinecolor='lightgray' ), camera=dict( eye=dict(x=1.5, y=1.5, z=1.2) ) ), width=900, height=700, margin=dict(l=0, r=0, t=80, b=0) ) return fig def create_risk_score_heatmap() -> go.Figure: """ Create a 2D heatmap showing risk scores for different distance and current combinations. This provides a clearer view of the risk calculation relationship. """ import numpy as np from src.config import config # Get risk parameters P_0 = config.risk_params['P_0'] alpha = config.risk_params['alpha'] current_weight = config.risk_params['current_weight'] # Create distance and current ranges # Use distance rings from config, convert from meters to kilometers max_distance_km = max(config.distance_rings) / 1000 distances_km = np.linspace(0.1, max_distance_km, 50) currents_amp = np.linspace(1000, 300000, 50) # Extended to 300k amps # Create meshgrid (swapped: current on x-axis, distance on y-axis) C, D = np.meshgrid(currents_amp, distances_km) # Calculate risk scores current_factor = 1 + current_weight * C / 10000 distance_factor = np.exp(-alpha * D) risk_scores = P_0 * current_factor * distance_factor # Create custom colorscale using the new color palette (normalized to 0-1) custom_colorscale = [ [0.0, '#577590'], # Blue - Very Low Risk [0.067, '#43AA8B'], # Teal - Low Risk (0.1/1.5) [0.133, '#90BE6D'], # Green - Med-Low Risk (0.2/1.5) [0.267, '#F9C74F'], # Yellow - Medium Risk (0.4/1.5) [0.4, '#F8961E'], # Orange - Med-High Risk (0.6/1.5) [0.533, '#F3722C'], # Dark Orange - High Risk (0.8/1.5) [0.667, '#F94144'], # Red - Very High Risk (1.0/1.5) [0.8, '#D32F2F'], # Darker Red - Critical Risk (1.2/1.5) [0.933, '#B71C1C'], # Deepest Red - Maximum Risk (1.4/1.5) [1.0, '#8B0000'] # Dark Red - Maximum Risk (1.5/1.5) ] # Normalize risk scores to 0-1 range for colorscale (matching our fixed intervals) normalized_risk = np.clip(risk_scores, 0, 1.5) / 1.5 # Create heatmap (swapped axes: current on x-axis, distance on y-axis) fig = go.Figure(data=go.Heatmap( z=normalized_risk, x=currents_amp, y=distances_km, colorscale=custom_colorscale, colorbar=dict( title="Risk Level", tickmode='array', tickvals=[0.01, 0.13, 0.26, 0.4, 0.53, 0.66, 0.8, 0.93], ticktext=['Very Low: <0.1', 'Low: 0.1-0.2', 'Medium-Low: 0.2-0.4', 'Medium: 0.4-0.6', 'Medium-High: 0.6-0.8', 'High: 0.8-1.0', 'Very High: 1.0-1.2', 'Critical: >1.2'], len=1.0, y=0.5, yanchor='middle', thickness=22, tickfont=dict(size=18), # colorbar tick label font size xpad=8 ), hovertemplate=( "Risk Score: %{customdata:.3f}
" + "Current: %{x:.0f} A
" + "Distance: %{y:.2f} km
" + "" ), customdata=risk_scores )) # Overlay risk level contour curves at fixed risk score levels (over normalized z) risk_levels = [0.1, 0.2, 0.4, 0.6, 0.8, 1.0, 1.2, 1.4, 1.5] normalized_levels = [min(level / 1.5, 1.0) for level in risk_levels] for norm_level in normalized_levels: fig.add_trace(go.Contour( z=normalized_risk, x=currents_amp, y=distances_km, contours=dict( start=norm_level, end=norm_level, size=0, coloring='none', showlines=True ), line=dict(color='rgba(0,0,0,0.5)', width=1), showscale=False, hoverinfo='skip', name='Risk level', showlegend=False )) # Update layout fig.update_layout( title={ 'text': f'Risk Score Heatmap
Current Magnitude vs Distance (0.1-{max_distance_km:.1f}km) - Higher values (red) = Higher risk', 'x': 0.5, 'xanchor': 'center', 'font': {'size': 24} # title font size }, xaxis_title='Lightning Current Magnitude (A)', yaxis_title='Distance from Turbine (km)', xaxis=dict( tickmode='linear', tick0=0, dtick=50000, gridcolor='lightgray', zerolinecolor='lightgray', tickfont=dict(size=18), # x-axis tick label font size title_font=dict(size=20), # x-axis title font size ), yaxis=dict( tickmode='array', tickvals=[0] + [ring/1000 for ring in config.distance_rings], ticktext=['0'] + [f'{ring/1000:.1f}' for ring in config.distance_rings], gridcolor='lightgray', zerolinecolor='lightgray', tickfont=dict(size=18), # y-axis tick label font size title_font=dict(size=20), # y-axis title font size ), width=1150, height=800, plot_bgcolor='white', paper_bgcolor='white', margin=dict(l=40, r=220, t=80, b=40), font=dict(size=18), # global chart font size ) return fig def create_risk_score_examples() -> go.Figure: """ Create a chart showing example risk scores for specific distance and current combinations. This helps readers understand typical risk score ranges. """ import numpy as np from src.config import config # Get risk parameters P_0 = config.risk_params['P_0'] alpha = config.risk_params['alpha'] current_weight = config.risk_params['current_weight'] # Define example scenarios scenarios = [ {"distance": 0.5, "current": 5000, "label": "Close, Low Current"}, {"distance": 0.5, "current": 25000, "label": "Close, High Current"}, {"distance": 2.0, "current": 5000, "label": "Medium Distance, Low Current"}, {"distance": 2.0, "current": 25000, "label": "Medium Distance, High Current"}, {"distance": 4.0, "current": 5000, "label": "Far, Low Current"}, {"distance": 4.0, "current": 25000, "label": "Far, High Current"}, ] # Calculate risk scores for each scenario distances = [s["distance"] for s in scenarios] currents = [s["current"] for s in scenarios] labels = [s["label"] for s in scenarios] current_factors = 1 + current_weight * np.array(currents) / 10000 distance_factors = np.exp(-alpha * np.array(distances)) risk_scores = P_0 * current_factors * distance_factors # Create bar chart fig = go.Figure(data=go.Bar( x=labels, y=risk_scores, text=[f'{score:.3f}' for score in risk_scores], textposition='auto', marker_color='lightcoral', hovertemplate=( "%{x}
" + "Risk Score: %{y:.3f}
" + "" ) )) # Update layout fig.update_layout( title={ 'text': 'Example Risk Scores for Different Scenarios
Shows how distance and current affect risk calculation', 'x': 0.5, 'xanchor': 'center', 'font': {'size': 16} }, xaxis_title='Scenario (Distance, Current)', yaxis_title='Risk Score', xaxis=dict( tickangle=45, gridcolor='lightgray', zerolinecolor='lightgray' ), yaxis=dict( gridcolor='lightgray', zerolinecolor='lightgray' ), width=900, height=600, plot_bgcolor='white', paper_bgcolor='white', showlegend=False ) # Add annotation with formula fig.add_annotation( x=0.5, y=1.02, xref='paper', yref='paper', text=f'Formula: Risk = {P_0} × (1 + {current_weight}×Current/10000) × e^(-{alpha}×Distance)', showarrow=False, font=dict(size=12), bgcolor='lightblue', bordercolor='black', borderwidth=1 ) return fig