feat: AR-House initial commit

This commit is contained in:
2026-07-03 12:24:58 -04:00
commit 047c05287a
216 changed files with 127552 additions and 0 deletions
+218
View File
@@ -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",
}