938 lines
32 KiB
Python
938 lines
32 KiB
Python
import plotly.graph_objects as go
|
||
import plotly.express as px
|
||
import numpy as np
|
||
import pandas as pd
|
||
from datetime import datetime, timezone
|
||
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.reporting.strings import ReportLanguage, get_report_language, get_strings
|
||
from src.utils import get_storm_monitoring_radius_km, parse_period_string_to_datetime
|
||
from src.visualization.basemap import add_satellite_basemap
|
||
from src.visualization.maps import (
|
||
COORDINATE_PLANE_MARGIN_B,
|
||
COORDINATE_PLANE_MARGIN_L,
|
||
COORDINATE_PLANE_MARGIN_R,
|
||
COORDINATE_PLANE_MARGIN_T,
|
||
COORDINATE_PLANE_TURBINE_NAME_FONT_SIZE,
|
||
COORDINATE_PLANE_TURBINE_TEXT_POSITION,
|
||
_compute_figure_dimensions,
|
||
_geographic_yaxis_scaleratio,
|
||
)
|
||
|
||
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 or POLYGON format to extract coordinates.
|
||
|
||
Args:
|
||
wkt_string: WKT string in format "LINESTRING(lon1 lat1, ...)" or "POLYGON((...))"
|
||
|
||
Returns:
|
||
List of (longitude, latitude) tuples
|
||
"""
|
||
if not wkt_string or not isinstance(wkt_string, str):
|
||
return []
|
||
wkt = wkt_string.strip()
|
||
upper = wkt.upper()
|
||
try:
|
||
if upper.startswith("POLYGON"):
|
||
inner = wkt[wkt.index("(") + 1 : wkt.rindex(")")]
|
||
if inner.startswith("("):
|
||
inner = inner[1 : inner.rindex(")")]
|
||
coords_str = inner
|
||
elif upper.startswith("LINESTRING"):
|
||
coords_str = wkt[wkt.index("(") + 1 : wkt.rindex(")")]
|
||
else:
|
||
return []
|
||
|
||
coordinates = []
|
||
for pair in coords_str.split(","):
|
||
parts = pair.strip().split()
|
||
if len(parts) >= 2:
|
||
coordinates.append((float(parts[0]), float(parts[1])))
|
||
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, lang: ReportLanguage | None = None) -> go.Figure:
|
||
s = get_strings(lang or get_report_language())
|
||
"""
|
||
Create a coordinate plane visualization showing storm cell polygons.
|
||
|
||
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 cell polygons 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()
|
||
|
||
if turbine_df is not None and not turbine_df.empty:
|
||
# 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=COORDINATE_PLANE_TURBINE_NAME_FONT_SIZE, color='black'),
|
||
textposition=COORDINATE_PLANE_TURBINE_TEXT_POSITION,
|
||
name=s.map_wind_turbines,
|
||
showlegend=True,
|
||
hovertemplate=(
|
||
"<b>Wind Turbine</b><br>"
|
||
"Name: %{text}<br>"
|
||
"Lat: %{y:.5f}<br>"
|
||
"Lng: %{x:.5f}<br>"
|
||
"<extra></extra>"
|
||
)
|
||
))
|
||
|
||
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"<b>Storm Cell {i+1}</b><br>"
|
||
f"{s.storm_hover_severity}: {storm.get('lightning_severity', s.unknown)}<br>"
|
||
f"{s.storm_hover_effective}: {format_datetime_for_display(storm.get('effective_time', 'N/A'))}<br>"
|
||
f"{s.storm_hover_expire}: {format_datetime_for_display(storm.get('expire_time', 'N/A'))}<br>"
|
||
f"{s.storm_hover_direction}: {storm.get('direction', 'N/A')}°<br>"
|
||
f"{s.storm_hover_speed}: {storm.get('speed', 'N/A')} km/h<br>"
|
||
f"<extra></extra>"
|
||
)
|
||
))
|
||
|
||
# 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=s.map_severity_legend.format(
|
||
severity=s.storm_severity_names.get(severity, severity.title())
|
||
),
|
||
showlegend=True,
|
||
hoverinfo='skip' # No hover for legend entries
|
||
))
|
||
|
||
bounds_lats: list[float] = []
|
||
bounds_lons: list[float] = []
|
||
for storm in storm_data:
|
||
coords = parse_wkt_linestring(storm.get('cell_polygon_wkt', ''))
|
||
if coords:
|
||
lons, lats = zip(*coords)
|
||
bounds_lats.extend(lats)
|
||
bounds_lons.extend(lons)
|
||
if turbine_df is not None and not turbine_df.empty:
|
||
bounds_lats.extend(turbine_df['lat'].tolist())
|
||
bounds_lons.extend(turbine_df['lng'].tolist())
|
||
|
||
if bounds_lats and bounds_lons:
|
||
lat_min = min(bounds_lats)
|
||
lat_max = max(bounds_lats)
|
||
lon_min = min(bounds_lons)
|
||
lon_max = max(bounds_lons)
|
||
map_center_lat = (lat_min + lat_max) / 2.0
|
||
cos_lat = max(float(np.cos(np.radians(map_center_lat))), 1e-6)
|
||
lat_pad = max((lat_max - lat_min) * 0.15, 0.01)
|
||
lon_pad = max((lon_max - lon_min) * 0.15, 0.01 / cos_lat)
|
||
lat_min -= lat_pad
|
||
lat_max += lat_pad
|
||
lon_min -= lon_pad
|
||
lon_max += lon_pad
|
||
else:
|
||
pad = 0.05
|
||
lat_min = center_lat - pad
|
||
lat_max = center_lat + pad
|
||
lon_min = center_lon - pad
|
||
lon_max = center_lon + pad
|
||
map_center_lat = center_lat
|
||
|
||
add_satellite_basemap(fig, lon_min, lon_max, lat_min, lat_max)
|
||
figure_width, figure_height = _compute_figure_dimensions(
|
||
map_center_lat,
|
||
lon_min,
|
||
lon_max,
|
||
lat_min,
|
||
lat_max,
|
||
)
|
||
|
||
fig.update_layout(
|
||
font=dict(size=18),
|
||
title=dict(text=s.storm_cells, font=dict(size=28)),
|
||
xaxis=dict(
|
||
title=dict(text=s.map_longitude, font=dict(size=28)),
|
||
tickfont=dict(size=22),
|
||
range=[lon_min, lon_max],
|
||
showgrid=False,
|
||
zeroline=False,
|
||
),
|
||
yaxis=dict(
|
||
title=dict(text=s.map_latitude, font=dict(size=28)),
|
||
tickfont=dict(size=22),
|
||
range=[lat_min, lat_max],
|
||
scaleanchor='x',
|
||
scaleratio=_geographic_yaxis_scaleratio(map_center_lat),
|
||
showgrid=False,
|
||
zeroline=False,
|
||
),
|
||
plot_bgcolor='white',
|
||
paper_bgcolor='white',
|
||
showlegend=True,
|
||
legend=dict(
|
||
title=dict(text=s.map_legend, font=dict(size=22)),
|
||
font=dict(size=18),
|
||
orientation='h',
|
||
x=0.5,
|
||
xanchor='center',
|
||
y=-0.08,
|
||
yanchor='top',
|
||
bgcolor='rgba(255, 255, 255, 0.8)',
|
||
bordercolor='black',
|
||
borderwidth=1,
|
||
),
|
||
width=figure_width,
|
||
height=figure_height,
|
||
margin=dict(
|
||
l=COORDINATE_PLANE_MARGIN_L,
|
||
r=COORDINATE_PLANE_MARGIN_R,
|
||
t=COORDINATE_PLANE_MARGIN_T,
|
||
b=COORDINATE_PLANE_MARGIN_B,
|
||
),
|
||
)
|
||
|
||
return fig
|
||
|
||
def create_storm_cells_map(storm_data: List[Dict], turbine_df: pd.DataFrame = None, center_lat: float = None, center_lon: float = None, lang: ReportLanguage | None = None) -> go.Figure:
|
||
"""
|
||
Create a map showing storm cell polygons from the fırtına data.
|
||
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 cell polygons in coordinate plane view
|
||
"""
|
||
return create_storm_cells_coordinate_plane(storm_data, turbine_df, center_lat, center_lon, lang=lang)
|
||
|
||
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=get_strings(get_report_language()).storm_cells_day_title.format(day=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 not time_field:
|
||
filtered_data.append(storm)
|
||
continue
|
||
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:
|
||
filtered_data.append(storm)
|
||
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:
|
||
max_distance_km = get_storm_monitoring_radius_km()
|
||
|
||
print(f"🌩️ Filtering storm cells within {max_distance_km} km of farm/turbine locations...")
|
||
|
||
filtered_storms = []
|
||
centroid_lat = getattr(config, "centroid_lat", None)
|
||
centroid_lon = getattr(config, "centroid_lon", None)
|
||
|
||
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
|
||
|
||
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 not storm_within_range and centroid_lat is not None and centroid_lon is not None:
|
||
cell_centroid = calculate_storm_cell_centroid(wkt_string)
|
||
if cell_centroid is not None:
|
||
c_lat, c_lon = cell_centroid
|
||
if haversine_distance(centroid_lat, centroid_lon, c_lat, c_lon) / 1000 <= max_distance_km:
|
||
storm_within_range = True
|
||
|
||
if not storm_within_range:
|
||
for storm_lon, storm_lat in coords:
|
||
if haversine_distance(centroid_lat, centroid_lon, storm_lat, storm_lon) / 1000 <= max_distance_km:
|
||
storm_within_range = True
|
||
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
|
||
}
|
||
|
||
|
||
_STORM_ENVELOPE_KEYS = ("thunderstorms", "cells", "storms", "data", "items")
|
||
|
||
|
||
def _storm_epoch_ms_to_iso_utc(epoch_ms: Any) -> str | None:
|
||
if epoch_ms in (None, "", 0):
|
||
return None
|
||
try:
|
||
return (
|
||
datetime.fromtimestamp(int(epoch_ms) / 1000, tz=timezone.utc)
|
||
.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||
)
|
||
except (TypeError, ValueError, OSError):
|
||
return None
|
||
|
||
|
||
def _storm_point_to_lng_lat(point: Any) -> tuple[float | None, float | None]:
|
||
if isinstance(point, dict):
|
||
lng = point.get("lng") if "lng" in point else point.get("longitude") or point.get("lon")
|
||
lat = point.get("lat") if "lat" in point else point.get("latitude")
|
||
elif isinstance(point, (list, tuple)) and len(point) >= 2:
|
||
lng, lat = point[0], point[1]
|
||
else:
|
||
return None, None
|
||
try:
|
||
return float(lng), float(lat)
|
||
except (TypeError, ValueError):
|
||
return None, None
|
||
|
||
|
||
def _storm_exterior_to_wkt(exterior: Any) -> str | None:
|
||
if not isinstance(exterior, list) or not exterior:
|
||
return None
|
||
pairs: list[str] = []
|
||
for point in exterior:
|
||
lng, lat = _storm_point_to_lng_lat(point)
|
||
if lng is None or lat is None:
|
||
return None
|
||
pairs.append(f"{lng} {lat}")
|
||
if len(pairs) < 2:
|
||
return None
|
||
return f"LINESTRING({', '.join(pairs)})"
|
||
|
||
|
||
def _storm_normalize_wkt_string(wkt: Any) -> str | None:
|
||
if not wkt or not isinstance(wkt, str):
|
||
return None
|
||
wkt = wkt.strip()
|
||
if not wkt:
|
||
return None
|
||
upper = wkt.upper()
|
||
if upper.startswith("POLYGON"):
|
||
inner = wkt[wkt.index("(") + 1 : wkt.rindex(")")]
|
||
if inner.startswith("("):
|
||
inner = inner[1 : inner.rindex(")")]
|
||
return f"LINESTRING({inner})" if inner else None
|
||
if upper.startswith("LINESTRING"):
|
||
return wkt
|
||
return None
|
||
|
||
|
||
def _storm_exterior_from_polygon_obj(polygon: Any) -> Any:
|
||
if not isinstance(polygon, dict):
|
||
return None
|
||
for key in ("exterior", "exteriorRing", "exterior_ring", "coordinates", "coords"):
|
||
value = polygon.get(key)
|
||
if value:
|
||
return value
|
||
return None
|
||
|
||
|
||
def _storm_extract_wkt(record: dict[str, Any]) -> str | None:
|
||
for key in ("cell_polygon_wkt", "cellPolygonWkt", "polygon_wkt", "polygonWkt"):
|
||
existing = _storm_normalize_wkt_string(record.get(key))
|
||
if existing:
|
||
return existing
|
||
|
||
cell = record.get("cell") if isinstance(record.get("cell"), dict) else {}
|
||
polygon_sources = [
|
||
cell.get("polygon"),
|
||
cell.get("threatPolygon"),
|
||
record.get("threatPolygon"),
|
||
record.get("polygon"),
|
||
]
|
||
for polygon in polygon_sources:
|
||
if isinstance(polygon, list):
|
||
wkt = _storm_exterior_to_wkt(polygon)
|
||
if wkt:
|
||
return wkt
|
||
if isinstance(polygon, dict):
|
||
wkt = _storm_exterior_to_wkt(_storm_exterior_from_polygon_obj(polygon))
|
||
if wkt:
|
||
return wkt
|
||
return None
|
||
|
||
|
||
def _storm_first_iso_timestamp(record: dict[str, Any], keys: tuple[str, ...]) -> str | None:
|
||
for key in keys:
|
||
value = record.get(key)
|
||
if value in (None, ""):
|
||
continue
|
||
if isinstance(value, (int, float)) or (isinstance(value, str) and value.isdigit()):
|
||
iso = _storm_epoch_ms_to_iso_utc(value)
|
||
if iso:
|
||
return iso
|
||
if isinstance(value, str):
|
||
parsed = pd.to_datetime(value, utc=True, errors="coerce")
|
||
if pd.notna(parsed):
|
||
return parsed.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||
return None
|
||
|
||
|
||
def _unwrap_storm_envelope(raw: Any) -> list[dict[str, Any]]:
|
||
if raw is None:
|
||
return []
|
||
|
||
if isinstance(raw, list):
|
||
if raw and all(
|
||
isinstance(item, dict) and any(k in item for k in _STORM_ENVELOPE_KEYS)
|
||
for item in raw
|
||
):
|
||
unwrapped: list[dict[str, Any]] = []
|
||
for item in raw:
|
||
for key in _STORM_ENVELOPE_KEYS:
|
||
if key in item and isinstance(item[key], list):
|
||
unwrapped.extend(s for s in item[key] if isinstance(s, dict))
|
||
break
|
||
return unwrapped
|
||
return [s for s in raw if isinstance(s, dict)]
|
||
|
||
if isinstance(raw, dict):
|
||
for key in _STORM_ENVELOPE_KEYS:
|
||
if key in raw and isinstance(raw[key], list):
|
||
return [s for s in raw[key] if isinstance(s, dict)]
|
||
return [v for v in raw.values() if isinstance(v, dict)]
|
||
|
||
return []
|
||
|
||
|
||
def normalize_storm_records(raw: Any) -> list[dict[str, Any]]:
|
||
"""
|
||
Translate iklim.co `/v1/thunderstorms/within` records into the shape that
|
||
reporting and visualization code expect.
|
||
"""
|
||
records = _unwrap_storm_envelope(raw)
|
||
if not records:
|
||
return []
|
||
|
||
out: list[dict[str, Any]] = []
|
||
skipped = 0
|
||
for record in records:
|
||
wkt = _storm_extract_wkt(record)
|
||
if not wkt:
|
||
skipped += 1
|
||
continue
|
||
|
||
effective = _storm_first_iso_timestamp(
|
||
record,
|
||
(
|
||
"effective_time",
|
||
"eventStartUtcEpoch",
|
||
"eventStartEpoch",
|
||
"startTimeEpoch",
|
||
"eventStartUtc",
|
||
"effectiveTime",
|
||
"startTime",
|
||
),
|
||
)
|
||
expire = _storm_first_iso_timestamp(
|
||
record,
|
||
(
|
||
"expire_time",
|
||
"eventEndUtcEpoch",
|
||
"eventEndEpoch",
|
||
"endTimeEpoch",
|
||
"eventEndUtc",
|
||
"expireTime",
|
||
"endTime",
|
||
),
|
||
)
|
||
created = _storm_first_iso_timestamp(
|
||
record,
|
||
(
|
||
"creation_time",
|
||
"insertedAtEpoch",
|
||
"insertedEpoch",
|
||
"createdAtEpoch",
|
||
"insertedAtUtc",
|
||
"creationTime",
|
||
"createdAt",
|
||
),
|
||
)
|
||
|
||
normalized = dict(record)
|
||
normalized["cell_polygon_wkt"] = wkt
|
||
if effective:
|
||
normalized["effective_time"] = effective
|
||
if expire:
|
||
normalized["expire_time"] = expire
|
||
if created:
|
||
normalized["creation_time"] = created
|
||
|
||
severity = record.get("severity") or record.get("lightning_severity")
|
||
if severity is not None:
|
||
normalized["lightning_severity"] = str(severity).strip().lower()
|
||
|
||
out.append(normalized)
|
||
|
||
if records and not out:
|
||
print(f"Warning: received {len(records)} storm record(s) but none had a usable polygon")
|
||
elif skipped:
|
||
print(f"Warning: skipped {skipped}/{len(records)} storm record(s) without polygon data")
|
||
|
||
return out |