""" Servicio para gestión de geocercas. Proporciona funcionalidades para verificar si un punto está dentro de una geocerca y calcular distancias. """ import json import math from typing import List, Optional, Tuple from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.models.geocerca import Geocerca class GeocercaService: """Servicio para operaciones con geocercas.""" def __init__(self, db: AsyncSession): """ Inicializa el servicio. Args: db: Sesión de base de datos async. """ self.db = db async def verificar_punto_en_geocerca( self, lat: float, lng: float, geocerca_id: int, ) -> Tuple[bool, Optional[float]]: """ Verifica si un punto está dentro de una geocerca. Args: lat: Latitud del punto. lng: Longitud del punto. geocerca_id: ID de la geocerca. Returns: Tupla (está_dentro, distancia_al_borde_metros). distancia es None si está dentro, o la distancia al borde si está fuera. """ result = await self.db.execute( select(Geocerca).where(Geocerca.id == geocerca_id) ) geocerca = result.scalar_one_or_none() if not geocerca: return False, None if geocerca.tipo == "circular": return self._punto_en_circulo( lat, lng, geocerca.centro_lat, geocerca.centro_lng, geocerca.radio_metros ) else: coordenadas = json.loads(geocerca.coordenadas_json) if geocerca.coordenadas_json else [] return self._punto_en_poligono(lat, lng, coordenadas) async def obtener_geocercas_activas_para_vehiculo( self, vehiculo_id: int, ) -> List[Geocerca]: """ Obtiene las geocercas activas aplicables a un vehículo. Args: vehiculo_id: ID del vehículo. Returns: Lista de geocercas aplicables. """ # Geocercas sin vehículos asignados (aplican a todos) # o con este vehículo asignado result = await self.db.execute( select(Geocerca) .where(Geocerca.activa == True) ) todas_geocercas = result.scalars().all() geocercas_aplicables = [] for g in todas_geocercas: # Si no tiene vehículos asignados, aplica a todos if not g.vehiculos_asignados: geocercas_aplicables.append(g) # Si tiene vehículos asignados, verificar si incluye este elif any(v.id == vehiculo_id for v in g.vehiculos_asignados): geocercas_aplicables.append(g) return geocercas_aplicables async def verificar_todas_geocercas( self, lat: float, lng: float, vehiculo_id: int, ) -> List[dict]: """ Verifica un punto contra todas las geocercas aplicables. Args: lat: Latitud del punto. lng: Longitud del punto. vehiculo_id: ID del vehículo. Returns: Lista de geocercas con información de si está dentro o fuera. """ geocercas = await self.obtener_geocercas_activas_para_vehiculo(vehiculo_id) resultados = [] for g in geocercas: if g.tipo == "circular": dentro, distancia = self._punto_en_circulo( lat, lng, g.centro_lat, g.centro_lng, g.radio_metros ) else: coordenadas = json.loads(g.coordenadas_json) if g.coordenadas_json else [] dentro, distancia = self._punto_en_poligono(lat, lng, coordenadas) resultados.append({ "geocerca_id": g.id, "geocerca_nombre": g.nombre, "dentro": dentro, "distancia_metros": distancia, "alerta_entrada": g.alerta_entrada, "alerta_salida": g.alerta_salida, "velocidad_maxima": g.velocidad_maxima, }) return resultados def _punto_en_circulo( self, lat: float, lng: float, centro_lat: float, centro_lng: float, radio_metros: float, ) -> Tuple[bool, Optional[float]]: """ Verifica si un punto está dentro de un círculo. Args: lat, lng: Coordenadas del punto. centro_lat, centro_lng: Centro del círculo. radio_metros: Radio del círculo. Returns: (está_dentro, distancia_al_borde). """ distancia = self._distancia_haversine(lat, lng, centro_lat, centro_lng) distancia_metros = distancia * 1000 # km a metros dentro = distancia_metros <= radio_metros if dentro: return True, None else: return False, distancia_metros - radio_metros def _punto_en_poligono( self, lat: float, lng: float, coordenadas: List[List[float]], ) -> Tuple[bool, Optional[float]]: """ Verifica si un punto está dentro de un polígono. Usa el algoritmo ray casting. Args: lat, lng: Coordenadas del punto. coordenadas: Lista de coordenadas [[lat, lng], ...]. Returns: (está_dentro, distancia_al_borde). """ if not coordenadas or len(coordenadas) < 3: return False, None n = len(coordenadas) dentro = False j = n - 1 for i in range(n): yi, xi = coordenadas[i][0], coordenadas[i][1] yj, xj = coordenadas[j][0], coordenadas[j][1] if ((yi > lat) != (yj > lat)) and ( lng < (xj - xi) * (lat - yi) / (yj - yi) + xi ): dentro = not dentro j = i if dentro: return True, None else: # Calcular distancia al borde más cercano distancia_min = float('inf') for i in range(n): j = (i + 1) % n d = self._distancia_punto_segmento( lat, lng, coordenadas[i][0], coordenadas[i][1], coordenadas[j][0], coordenadas[j][1] ) if d < distancia_min: distancia_min = d return False, distancia_min * 1000 # km a metros def _distancia_haversine( self, lat1: float, lng1: float, lat2: float, lng2: float, ) -> float: """ Calcula la distancia entre dos puntos usando Haversine. Args: lat1, lng1: Primer punto. lat2, lng2: Segundo punto. Returns: Distancia en kilómetros. """ R = 6371 # Radio de la Tierra en km lat1_rad = math.radians(lat1) lat2_rad = math.radians(lat2) dlat = math.radians(lat2 - lat1) dlng = math.radians(lng2 - lng1) a = ( math.sin(dlat / 2) ** 2 + math.cos(lat1_rad) * math.cos(lat2_rad) * math.sin(dlng / 2) ** 2 ) c = 2 * math.asin(math.sqrt(a)) return R * c def _distancia_punto_segmento( self, px: float, py: float, x1: float, y1: float, x2: float, y2: float, ) -> float: """ Calcula la distancia de un punto a un segmento de línea. Args: px, py: Punto. x1, y1, x2, y2: Extremos del segmento. Returns: Distancia en kilómetros. """ # Longitud del segmento al cuadrado l2 = (x2 - x1) ** 2 + (y2 - y1) ** 2 if l2 == 0: # El segmento es un punto return self._distancia_haversine(px, py, x1, y1) # Proyección del punto sobre la línea t = max(0, min(1, ((px - x1) * (x2 - x1) + (py - y1) * (y2 - y1)) / l2)) # Punto más cercano en el segmento proj_x = x1 + t * (x2 - x1) proj_y = y1 + t * (y2 - y1) return self._distancia_haversine(px, py, proj_x, proj_y) @staticmethod def calcular_area_poligono(coordenadas: List[List[float]]) -> float: """ Calcula el área de un polígono en metros cuadrados. Args: coordenadas: Lista de coordenadas [[lat, lng], ...]. Returns: Área en metros cuadrados. """ if len(coordenadas) < 3: return 0.0 # Usar la fórmula del topógrafo (Shoelace) con conversión a metros n = len(coordenadas) area = 0.0 # Factor de conversión aproximado para grados a metros # (varía según la latitud) lat_media = sum(c[0] for c in coordenadas) / n m_per_deg_lat = 111132.92 - 559.82 * math.cos(2 * math.radians(lat_media)) m_per_deg_lng = 111412.84 * math.cos(math.radians(lat_media)) for i in range(n): j = (i + 1) % n xi = coordenadas[i][1] * m_per_deg_lng yi = coordenadas[i][0] * m_per_deg_lat xj = coordenadas[j][1] * m_per_deg_lng yj = coordenadas[j][0] * m_per_deg_lat area += xi * yj - xj * yi return abs(area) / 2 @staticmethod def calcular_perimetro_poligono(coordenadas: List[List[float]]) -> float: """ Calcula el perímetro de un polígono en metros. Args: coordenadas: Lista de coordenadas [[lat, lng], ...]. Returns: Perímetro en metros. """ if len(coordenadas) < 2: return 0.0 servicio = GeocercaService(None) # Solo para usar método estático perimetro = 0.0 n = len(coordenadas) for i in range(n): j = (i + 1) % n d = servicio._distancia_haversine( coordenadas[i][0], coordenadas[i][1], coordenadas[j][0], coordenadas[j][1] ) perimetro += d * 1000 # km a metros return perimetro