""" 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