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
665 lines
23 KiB
Python
665 lines
23 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
|
||
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=(
|
||
"<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"Severity: {storm.get('lightning_severity', 'Unknown')}<br>"
|
||
f"Effective: {format_datetime_for_display(storm.get('effective_time', 'N/A'))}<br>"
|
||
f"Expire: {format_datetime_for_display(storm.get('expire_time', 'N/A'))}<br>"
|
||
f"Direction: {storm.get('direction', 'N/A')}°<br>"
|
||
f"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=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
|
||
} |