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