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=( "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"{s.storm_hover_severity}: {storm.get('lightning_severity', s.unknown)}
" f"{s.storm_hover_effective}: {format_datetime_for_display(storm.get('effective_time', 'N/A'))}
" f"{s.storm_hover_expire}: {format_datetime_for_display(storm.get('expire_time', 'N/A'))}
" f"{s.storm_hover_direction}: {storm.get('direction', 'N/A')}°
" f"{s.storm_hover_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=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