erdemerikci 45d80dfaa6 Initial import: Lightning_Report with n8n integration
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
2026-04-22 15:13:08 +03:00

1112 lines
41 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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