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