219 lines
6.8 KiB
Python
219 lines
6.8 KiB
Python
"""NOAA HURDAT2 - historial de huracanes Atlantico.
|
|
|
|
Dataset: https://www.nhc.noaa.gov/data/#hurdat
|
|
Format spec: https://www.nhc.noaa.gov/data/hurdat/hurdat2-format.pdf
|
|
|
|
Descarga el archivo en data/hurdat2.txt en el primer uso (lazy).
|
|
Re-descarga si tiene mas de 365 dias.
|
|
|
|
Para una direccion dada (lat/lng), devuelve huracanes que pasaron a menos
|
|
de N millas (default 150) en los ultimos K anos (default 20).
|
|
|
|
Returns:
|
|
{
|
|
"lookback_years": 20,
|
|
"max_distance_mi": 150,
|
|
"total_hurricanes_nearby": <int>,
|
|
"hurricanes": [
|
|
{"name": "Ian", "year": 2022, "category": 4,
|
|
"max_wind_mph": 155, "closest_pass_miles": 12},
|
|
...
|
|
]
|
|
}
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import math
|
|
import os
|
|
import time
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
import requests
|
|
|
|
from .base import FetcherError, USER_AGENT, DEFAULT_TIMEOUT
|
|
|
|
|
|
# URLs candidatas (NOAA renombra el archivo cada ano).
|
|
# Si todas fallan, FetcherError.
|
|
HURDAT2_URL_CANDIDATES = [
|
|
"https://www.nhc.noaa.gov/data/hurdat/hurdat2-1851-2024-040425.txt",
|
|
"https://www.nhc.noaa.gov/data/hurdat/hurdat2-1851-2023-051124.txt",
|
|
"https://www.nhc.noaa.gov/data/hurdat/hurdat2-1851-2022-050423.txt",
|
|
]
|
|
|
|
HURDAT2_MAX_AGE_DAYS = 365
|
|
|
|
|
|
def _saffir_simpson(max_wind_mph: float) -> int:
|
|
"""Categoria Saffir-Simpson basada en max sustained wind (mph). 0 = TS (no huracan)."""
|
|
if max_wind_mph >= 157:
|
|
return 5
|
|
if max_wind_mph >= 130:
|
|
return 4
|
|
if max_wind_mph >= 111:
|
|
return 3
|
|
if max_wind_mph >= 96:
|
|
return 2
|
|
if max_wind_mph >= 74:
|
|
return 1
|
|
return 0 # tropical storm or less
|
|
|
|
|
|
def _haversine_mi(lat1: float, lng1: float, lat2: float, lng2: float) -> float:
|
|
"""Distancia great-circle entre dos puntos en millas."""
|
|
R_MI = 3958.8
|
|
p1 = math.radians(lat1)
|
|
p2 = math.radians(lat2)
|
|
dp = math.radians(lat2 - lat1)
|
|
dl = math.radians(lng2 - lng1)
|
|
a = math.sin(dp/2)**2 + math.cos(p1) * math.cos(p2) * math.sin(dl/2)**2
|
|
return 2 * R_MI * math.asin(math.sqrt(a))
|
|
|
|
|
|
def _parse_coord(s: str) -> float | None:
|
|
"""Parse '26.5N' o '80.3W' a float (West/South negativos)."""
|
|
s = s.strip()
|
|
if not s or len(s) < 2:
|
|
return None
|
|
try:
|
|
val = float(s[:-1])
|
|
d = s[-1].upper()
|
|
if d in ("S", "W"):
|
|
val = -val
|
|
return val
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _download_hurdat2(dest_path: Path) -> None:
|
|
"""Intenta descargar HURDAT2 desde varias URLs candidatas."""
|
|
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
last_err = None
|
|
for url in HURDAT2_URL_CANDIDATES:
|
|
try:
|
|
r = requests.get(url, headers={"User-Agent": USER_AGENT}, timeout=30)
|
|
if r.status_code == 200 and len(r.text) > 10000:
|
|
dest_path.write_text(r.text, encoding="utf-8")
|
|
return
|
|
last_err = f"HTTP {r.status_code} from {url}"
|
|
except requests.RequestException as e:
|
|
last_err = f"{url}: {e}"
|
|
continue
|
|
raise FetcherError(f"No pude descargar HURDAT2 desde ninguna URL. Ultimo error: {last_err}")
|
|
|
|
|
|
def _ensure_hurdat2_local(local_path: str | Path) -> Path:
|
|
"""Garantiza que el archivo local exista y este fresco. Descarga si hace falta."""
|
|
p = Path(local_path)
|
|
needs_download = (
|
|
not p.exists()
|
|
or (time.time() - p.stat().st_mtime) / 86400 > HURDAT2_MAX_AGE_DAYS
|
|
)
|
|
if needs_download:
|
|
_download_hurdat2(p)
|
|
return p
|
|
|
|
|
|
def fetch_hurricanes(
|
|
lat: float,
|
|
lng: float,
|
|
years_back: int = 20,
|
|
max_distance_mi: float = 150.0,
|
|
hurdat2_path: str | Path = "data/hurdat2.txt",
|
|
) -> dict:
|
|
"""Busca huracanes que pasaron cerca de (lat, lng) en los ultimos N anos.
|
|
|
|
'Cerca' = al menos un track-point del huracan estuvo a <= max_distance_mi.
|
|
Solo cuenta sistemas que alcanzaron category 1+ (max_wind >= 74 mph) en algun
|
|
momento de su track.
|
|
"""
|
|
if lat is None or lng is None:
|
|
raise FetcherError("lat/lng requeridos")
|
|
|
|
p = _ensure_hurdat2_local(hurdat2_path)
|
|
current_year = datetime.now().year
|
|
min_year = current_year - years_back
|
|
|
|
text = p.read_text(encoding="utf-8", errors="replace")
|
|
lines = text.splitlines()
|
|
|
|
hurricanes_nearby = []
|
|
i = 0
|
|
n = len(lines)
|
|
while i < n:
|
|
line = lines[i].strip()
|
|
if not line:
|
|
i += 1
|
|
continue
|
|
|
|
parts = [x.strip() for x in line.split(",")]
|
|
# Header: AL112022, IAN, 65,
|
|
if len(parts) >= 3 and parts[0].startswith("AL") and len(parts[0]) >= 8:
|
|
atcf_id = parts[0]
|
|
name = parts[1]
|
|
try:
|
|
num_records = int(parts[2])
|
|
year = int(atcf_id[4:8])
|
|
except (ValueError, IndexError):
|
|
i += 1
|
|
continue
|
|
|
|
if year < min_year:
|
|
# Skip todos los track lines de este huracan
|
|
i += 1 + num_records
|
|
continue
|
|
|
|
max_wind_kt = 0
|
|
min_dist_mi = float("inf")
|
|
for j in range(num_records):
|
|
tl_idx = i + 1 + j
|
|
if tl_idx >= n:
|
|
break
|
|
track_parts = [x.strip() for x in lines[tl_idx].split(",")]
|
|
if len(track_parts) < 7:
|
|
continue
|
|
# 0:date 1:time 2:record_id 3:status 4:lat 5:lng 6:wind
|
|
tlat = _parse_coord(track_parts[4])
|
|
tlng = _parse_coord(track_parts[5])
|
|
try:
|
|
wind = int(track_parts[6])
|
|
except ValueError:
|
|
wind = 0
|
|
if tlat is None or tlng is None:
|
|
continue
|
|
dist = _haversine_mi(lat, lng, tlat, tlng)
|
|
if dist < min_dist_mi:
|
|
min_dist_mi = dist
|
|
if wind > max_wind_kt:
|
|
max_wind_kt = wind
|
|
|
|
max_wind_mph = max_wind_kt * 1.15078 # kt -> mph
|
|
category = _saffir_simpson(max_wind_mph)
|
|
|
|
# Solo contamos huracanes (cat 1+) que pasaron cerca
|
|
if category >= 1 and min_dist_mi <= max_distance_mi:
|
|
hurricanes_nearby.append({
|
|
"name": name if name else "UNNAMED",
|
|
"year": year,
|
|
"category": category,
|
|
"max_wind_mph": int(round(max_wind_mph)),
|
|
"closest_pass_miles": int(round(min_dist_mi)),
|
|
})
|
|
|
|
i += 1 + num_records
|
|
else:
|
|
i += 1
|
|
|
|
# Ordenar: mas reciente y mas fuerte primero
|
|
hurricanes_nearby.sort(key=lambda h: (-h["year"], -h["category"]))
|
|
|
|
return {
|
|
"lookback_years": years_back,
|
|
"max_distance_mi": max_distance_mi,
|
|
"total_hurricanes_nearby": len(hurricanes_nearby),
|
|
"hurricanes": hurricanes_nearby,
|
|
"source": "NOAA HURDAT2",
|
|
}
|