feat: AR-House initial commit
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
"""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",
|
||||
}
|
||||
Reference in New Issue
Block a user