cfd94f905a
- 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>
167 lines
6.7 KiB
Python
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
|