Fork of Lightning_Report adding: - n8n_report_branch.json: workflow branch for storm-triggered report delivery - report_service/: FastAPI microservice wrapping create_docx_report() so n8n can produce byte-identical reports without fighting the Python Code sandbox Made-with: Cursor
1112 lines
41 KiB
Python
1112 lines
41 KiB
Python
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=(
|
||
"<b>Risk Score: %{z:.3f}</b><br>" +
|
||
"Distance: %{x:.2f} km<br>" +
|
||
"Current: %{y:.0f} A<br>" +
|
||
"<extra></extra>"
|
||
)
|
||
)])
|
||
|
||
# Update layout
|
||
fig.update_layout(
|
||
title={
|
||
'text': 'Risk Score Calculation Chart<br><sub>Shows how distance and current magnitude affect risk scores</sub>',
|
||
'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=(
|
||
"<b>Risk Score: %{customdata:.3f}</b><br>" +
|
||
"Current: %{x:.0f} A<br>" +
|
||
"Distance: %{y:.2f} km<br>" +
|
||
"<extra></extra>"
|
||
),
|
||
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<br><sub>Current Magnitude vs Distance (0.1-{max_distance_km:.1f}km) - Higher values (red) = Higher risk</sub>',
|
||
'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=(
|
||
"<b>%{x}</b><br>" +
|
||
"Risk Score: %{y:.3f}<br>" +
|
||
"<extra></extra>"
|
||
)
|
||
))
|
||
|
||
# Update layout
|
||
fig.update_layout(
|
||
title={
|
||
'text': 'Example Risk Scores for Different Scenarios<br><sub>Shows how distance and current affect risk calculation</sub>',
|
||
'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
|