Files
AidsMonitoring/backend/services/alert_engine.py
T
alro65 cfd94f905a security: CORS hardening, path traversal fix, WebSocket auth + cleanup
- Restrict CORS to localhost origins (was allow_origins=[*])
- Require valid JWT on WebSocket /ws (anonymous no longer gets admin view)
- Fix path traversal in delete_cell(): resolve() + parent check
- Validate cell_id format in /charts/download-noaa/{cell_id}
- Exclude charts/ and Cartas/ from git (keep US1GC09M world overview)
- Add NOAA ENC Portal external link in charts catalog tab
- Untrack __pycache__/, .db, .claude/ session files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-03 12:45:43 -04:00

167 lines
6.7 KiB
Python

"""
Motor de alertas: detecta proximidad, movimiento continuo y proyección de trayectoria.
"""
import math
import uuid
from datetime import datetime
from collections import defaultdict, deque
def _alert_id() -> str:
return str(uuid.uuid4())
# Pre-buffer por barco (últimas 60 posiciones = ~5 min a 5seg/update)
vessel_buffers = defaultdict(lambda: deque(maxlen=60))
active_recordings = {} # mmsi -> {aid_id, inicio, min_dist}
aid_position_history = defaultdict(lambda: deque(maxlen=10))
aid_alert_state = {} # aid_id -> 'RED' | 'YELLOW' | None (evita re-emitir)
def haversine(lat1, lon1, lat2, lon2):
R = 6371000
phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi = math.radians(lat2 - lat1)
dlambda = math.radians(lon2 - lon1)
a = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dlambda/2)**2
return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1-a))
def project_position(lat, lon, cog_deg, sog_knots, minutes):
speed_ms = sog_knots * 0.514444
distance_m = speed_ms * minutes * 60
cog_rad = math.radians(cog_deg)
dlat = (distance_m * math.cos(cog_rad)) / 111320
dlon = (distance_m * math.sin(cog_rad)) / (111320 * math.cos(math.radians(lat)))
return lat + dlat, lon + dlon
def detect_continuous_movement(aid_id, lat, lon):
history = aid_position_history[aid_id]
history.append((lat, lon))
if len(history) < 5:
return False
positions = list(history)
# Verifica si cada posición es más lejana de la nominal que la anterior
distances = []
for i in range(1, len(positions)):
d = haversine(positions[i-1][0], positions[i-1][1], positions[i][0], positions[i][1])
distances.append(d)
# Movimiento continuo: al menos 4 de 5 pasos se aleja progresivamente
moving = sum(1 for d in distances if d > 0.5) >= 4
return moving
def evaluate_vessel(vessel, aids, config):
mmsi = vessel["mmsi"]
lat, lon = vessel["lat"], vessel["lon"]
sog = vessel.get("sog", 0)
cog = vessel.get("cog", 0)
vessel_buffers[mmsi].append({
"lat": lat, "lon": lon, "sog": sog, "cog": cog,
"timestamp": datetime.utcnow().isoformat()
})
alerts = []
proximity_m = config.get("proximity_alert_meters", 500)
proj_minutes = config.get("projection_minutes", 10)
proj_radius = config.get("projection_radius_meters", 800)
record_trig_m = config.get("auto_record_trigger_m", proximity_m)
for aid in aids:
aid_lat = aid.get("lat_actual") or aid["lat_nominal"]
aid_lon = aid.get("lon_actual") or aid["lon_nominal"]
dist = haversine(lat, lon, aid_lat, aid_lon)
recording_key = f"{mmsi}_{aid['id']}"
# Proyección
if sog > 0.5:
proj_lat, proj_lon = project_position(lat, lon, cog, sog, proj_minutes)
proj_dist = haversine(proj_lat, proj_lon, aid_lat, aid_lon)
else:
proj_dist = 9999
# Auto-record uses its own (usually tighter) trigger distance,
# while proximity alerts (yellow/red on the map) use proximity_m.
triggered = dist <= record_trig_m or proj_dist <= proj_radius
if triggered and recording_key not in active_recordings:
active_recordings[recording_key] = {
"mmsi": mmsi,
"aid_id": aid["id"],
"aid_nombre": aid["nombre"],
"inicio": datetime.utcnow().isoformat(),
"trigger": "PROXIMIDAD" if dist <= record_trig_m else "PROYECCION",
"min_dist": dist,
"pre_buffer": list(vessel_buffers[mmsi])
}
alerts.append({
"id": _alert_id(),
"tipo": "GRABACION_INICIADA",
"mmsi": mmsi,
"aid_id": aid["id"],
"aid_nombre": aid["nombre"],
"distancia_m": round(dist, 1),
"trigger": active_recordings[recording_key]["trigger"],
"timestamp": datetime.utcnow().isoformat()
})
elif recording_key in active_recordings:
rec = active_recordings[recording_key]
rec["min_dist"] = min(rec["min_dist"], dist)
if dist > record_trig_m * 2 and proj_dist > proj_radius * 2:
alerts.append({
"id": _alert_id(),
"tipo": "GRABACION_FINALIZADA",
"mmsi": mmsi,
"aid_id": aid["id"],
"aid_nombre": aid["nombre"],
"distancia_min_m": round(rec["min_dist"], 1),
"timestamp": datetime.utcnow().isoformat()
})
del active_recordings[recording_key]
return alerts
def evaluate_aid_movement(aid_id, lat_actual, lon_actual, lat_nominal, lon_nominal,
config=None, warn_m=None, alarm_m=None):
"""
warn_m / alarm_m: per-aid override from Aid.displacement_warn_m / alarm_m.
If None, falls back to global config values.
"""
config = config or {}
warn_m = warn_m if warn_m is not None else config.get("displacement_warn_m", 10.0)
alarm_m = alarm_m if alarm_m is not None else config.get("displacement_alarm_m", 15.0)
desplazamiento = haversine(lat_actual, lon_actual, lat_nominal, lon_nominal)
en_movimiento = detect_continuous_movement(aid_id, lat_actual, lon_actual)
alerts = []
prev = aid_alert_state.get(aid_id)
if en_movimiento or desplazamiento > alarm_m:
if prev != 'RED': # solo emitir al entrar en estado rojo
aid_alert_state[aid_id] = 'RED'
alerts.append({
"id": _alert_id(),
"tipo": "ALERTA_ROJA",
"subtipo": "AYUDA_EN_MOVIMIENTO" if en_movimiento else "AYUDA_FUERA_POSICION",
"aid_id": aid_id,
"desplazamiento_m": round(desplazamiento, 1),
"umbral_m": alarm_m,
"mensaje": "Movimiento continuo detectado" if en_movimiento
else f"Desplazamiento {desplazamiento:.1f} m supera alarma {alarm_m} m",
"timestamp": datetime.utcnow().isoformat()
})
elif desplazamiento > warn_m:
if prev != 'YELLOW': # solo emitir al entrar en estado amarillo
aid_alert_state[aid_id] = 'YELLOW'
alerts.append({
"id": _alert_id(),
"tipo": "ALERTA_AMARILLA",
"subtipo": "AYUDA_FUERA_POSICION",
"aid_id": aid_id,
"desplazamiento_m": round(desplazamiento, 1),
"umbral_m": warn_m,
"timestamp": datetime.utcnow().isoformat()
})
else:
aid_alert_state[aid_id] = None # condición resuelta
return alerts