Puedes analizar mi codigo?

import pandas as pd
import numpy as np
import requests
import pickle
import os
import logging
import sys
import signal
from typing import List, Tuple, Optional, Set
from ortools.constraint_solver import pywrapcp, routing_enums_pb2
import time
import hashlib
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type
import concurrent.futures
import json
from datetime import datetime
from pathlib import Path
from tqdm import tqdm

# Configuración de logging
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(levelname)s - %(message)s',
    handlers=[logging.FileHandler("routing_log.log"), logging.StreamHandler()]
)
logger = logging.getLogger(__name__)

# Configuración por defecto
CONFIG = {
    'SERVICE_TIME_MIN': 30,  # Tiempo mínimo de servicio en minutos
    'MAX_TIME_MIN': 720,  # Tiempo máximo de servicio en minutos
    'PENALTY_FACTOR': 5000,  # Factor de penalización por omisión
    'MAX_VEHICLES': 18,  # Máximo de vehículos a considerar
    'SEARCH_TIME_LIMIT_SEC': 900,  # Límite de tiempo para la búsqueda en segundos
    'OSRM_URL': 'http://localhost:5000',  # URL del servidor OSRM
    'CHUNK_SIZE': 50,
    'VEHICLE_PENALTY': 5000,
    'MAX_RETRIES': 3, 
    'TIME_WINDOWS': True,
    'MAX_WORKERS': 5,
    'MAX_CACHE_SIZE': 10,
    'MAX_TABLE_SIZE': 100,
    'TIME_ADJUSTMENT_FACTOR': 1.8,
    'DEPOT_START_TIME': 8 * 3600,  # 8:00 AM in seconds
    'DEPOT_END_TIME': 20 * 3600,   # 8:00 PM in seconds
    'SLACK_ARRIVAL_MIN': 10,  # Minutos de holgura antes de la ventana de inicio
    'PENALTY_PER_MINUTE_EARLY': 10,
    'PENALTY_PER_MINUTE_LATE': 20,
    'DEPOT_EARLY_PENALTY_PER_MIN': 50,
    'DEPOT_LATE_PENALTY_PER_MIN': 100
}

current_date = datetime.now().strftime("%Y%m%d")
CACHE_DIR = Path("osrm_cache")
CACHE_DIR.mkdir(exist_ok=True)

# Funciones auxiliares
def clean_old_cache(max_files: int = CONFIG['MAX_CACHE_SIZE']):
    """Elimina archivos de caché antiguos para mantener un límite."""
    cache_files = list(CACHE_DIR.glob("osrm_cache_*.pkl"))
    if not cache_files:
        return
    cache_files.sort(key=os.path.getmtime, reverse=True)
    for file in cache_files[max_files:]:
        try:
            file.unlink()
        except Exception as e:
            logger.warning(f"No se pudo eliminar {file.name}: {e}")

def wait_for_osrm(timeout: int = 30, interval: int = 2) -> bool:
    """Verifica si el servidor OSRM está disponible."""
    test_url = f"{CONFIG['OSRM_URL']}/nearest/v1/driving/-98.2,19.05"
    start_time = time.time()
    while time.time() - start_time < timeout:
        try:
            r = requests.get(test_url, timeout=1)
            if r.status_code == 200:
                logger.info("OSRM server is responding correctamente.")
                return True
        except requests.exceptions.RequestException:
            pass
        time.sleep(interval)
    logger.error(f"El servidor OSRM no respondió dentro de {timeout} segundos.")
    return False

def check_and_start_osrm() -> bool:
    """Comprueba si OSRM está activo; retorna False si no se puede conectar."""
    test_url = f"{CONFIG['OSRM_URL']}/nearest/v1/driving/-98.2,19.05"
    try:
        r = requests.get(test_url, timeout=2)
        if r.status_code == 200:
            logger.info("Servidor OSRM está activo y responde correctamente.")
            return True
    except requests.exceptions.RequestException as e:
        logger.critical(f"No se pudo conectar con OSRM: {e}")
    return False

def get_osrm_matrix(df: pd.DataFrame) -> Tuple[np.ndarray, np.ndarray]:
    """Obtiene matrices de tiempo y distancia usando OSRM, con caché."""
    df_sorted = df[['lat', 'lon']].sort_values(by=['lat', 'lon']).reset_index(drop=True)
    data_hash = hashlib.md5(pd.util.hash_pandas_object(df_sorted).values.tobytes()).hexdigest()
    cache_file = CACHE_DIR / f"osrm_cache_{current_date}_{data_hash}.pkl"
    if cache_file.exists():
        try:
            with open(cache_file, 'rb') as f:
                logger.info("Usando matriz de caché existente")
                return pickle.load(f)
        except Exception as e:
            logger.warning(f"Error al cargar caché: {e}")
            cache_file.unlink()

    n = len(df)
    coords = [(row['lon'], row['lat']) for _, row in df.iterrows()]
    time_matrix = np.zeros((n, n))
    dist_matrix = np.zeros((n, n))
    if n <= CONFIG['MAX_TABLE_SIZE']:
        try:
            logger.info(f"Intentando obtener matriz completa para {n} puntos")
            coords_str = ';'.join([f"{lon},{lat}" for lon, lat in coords])
            url = f"{CONFIG['OSRM_URL']}/table/v1/driving/{coords_str}"
            params = {'annotations': 'duration,distance'}
            r = requests.get(url, params=params, timeout=60)
            r.raise_for_status()
            data = r.json()
            if data.get('code') == 'Ok':
                time_matrix = np.array(data['durations']) / 60
                dist_matrix = np.array(data['distances']) / 1000
                time_matrix = np.nan_to_num(time_matrix, nan=1e9, posinf=1e9)
                dist_matrix = np.nan_to_num(dist_matrix, nan=1e9, posinf=1e9)
                time_matrix *= CONFIG['TIME_ADJUSTMENT_FACTOR']
                with open(cache_file, 'wb') as f:
                    pickle.dump((time_matrix, dist_matrix), f)
                clean_old_cache()
                return time_matrix, dist_matrix
        except Exception as e:
            logger.warning(f"Table service falló: {e}, usando método por pares")

    logger.info(f"Calculando matriz para {n}x{n} puntos (pares)")
    pair_cache_file = CACHE_DIR / f"osrm_pair_cache_{current_date}_{data_hash}.pkl"
    pair_cache = {}
    if pair_cache_file.exists():
        try:
            with open(pair_cache_file, 'rb') as f:
                pair_cache = pickle.load(f)
        except Exception as e:
            logger.warning(f"Error al cargar caché de pares: {e}")

    @retry(stop=stop_after_attempt(CONFIG['MAX_RETRIES']),
           wait=wait_exponential(multiplier=1, min=2, max=10),
           retry=retry_if_exception_type(requests.RequestException))
    def get_route_info(o: Tuple[float, float], d: Tuple[float, float]) -> Tuple[float, float]:
        key = (o, d)
        if key in pair_cache:
            return pair_cache[key]
        urls = [
            f"{CONFIG['OSRM_URL']}/route/v1/driving/{o[0]},{o[1]};{d[0]},{d[1]}?overview=false&exclude=toll",
            f"{CONFIG['OSRM_URL']}/route/v1/driving/{o[0]},{o[1]};{d[0]},{d[1]}?overview=false"
        ]
        for url in urls:
            try:
                r = requests.get(url, timeout=5)
                if r.status_code == 200:
                    data = r.json()
                    if data.get('code') == 'Ok' and data.get('routes'):
                        dur = data['routes'][0]['duration'] / 60
                        dist = data['routes'][0]['distance'] / 1000
                        pair_cache[key] = (dur, dist)
                        return dur, dist
            except Exception:
                continue
        return np.inf, np.inf

    total_pairs = n * n - n
    if total_pairs <= 0: total_pairs = 1  # Evitar división por cero
    with tqdm(total=total_pairs, desc="Calculando matriz de distancias") as pbar:
        with concurrent.futures.ThreadPoolExecutor(max_workers=CONFIG['MAX_WORKERS']) as executor:
            futures = {}
            for i in range(n):
                for j in range(n):
                    if i == j:
                        continue
                    key = (coords[i], coords[j])
                    if key in pair_cache:
                        time_matrix[i][j], dist_matrix[i][j] = pair_cache[key]
                        pbar.update(1)
                    else:
                        futures[executor.submit(get_route_info, coords[i], coords[j])] = (i, j)
            for fut in concurrent.futures.as_completed(futures):
                i, j = futures[fut]
                try:
                    dur, dist = fut.result()
                    time_matrix[i][j], dist_matrix[i][j] = dur, dist
                except Exception as e:
                    time_matrix[i][j], dist_matrix[i][j] = np.inf, np.inf
                finally:
                    pbar.update(1)

    time_matrix[time_matrix == np.inf] = 1e9
    dist_matrix[dist_matrix == np.inf] = 1e9
    time_matrix *= CONFIG['TIME_ADJUSTMENT_FACTOR']
    with open(pair_cache_file, 'wb') as f:
        pickle.dump(pair_cache, f)
    with open(cache_file, 'wb') as f:
        pickle.dump((time_matrix, dist_matrix), f)
    clean_old_cache()
    return time_matrix, dist_matrix

def validate_data(df_points: pd.DataFrame, df_vehicles: pd.DataFrame) -> None:
    """Valida los datos de entrada para puntos y vehículos."""
    req_p = ['id', 'lat', 'lon', 'demanda_peso', 'demanda_espacio', 'tipo_tarea']
    if 'nombre' in df_points.columns:
        req_p.append('nombre')
    if CONFIG['TIME_WINDOWS']:
        req_p.extend(['ventana_inicio', 'ventana_fin'])
    req_v = ['id', 'capacidad_peso', 'capacidad_espacio', 'costo_km']
    missing = [c for c in req_p if c not in df_points.columns] + \
              [c for c in req_v if c not in df_vehicles.columns]
    if missing:
        raise ValueError(f"Columnas faltantes: {missing}")
    if df_points['id'].duplicated().any() or df_vehicles['id'].duplicated().any():
        raise ValueError("IDs duplicados encontrados")
    depot = df_points[df_points['id'] == 0]
    if depot.empty or depot.iloc[0]['tipo_tarea'] != 'depósito':
        raise ValueError("Debe existir depósito con id=0 tipo_tarea='depósito'")
    for col in ['demanda_peso', 'demanda_espacio']:
        if (df_points[col] < 0).any():
            raise ValueError(f"Valores negativos en {col}")
    for col in ['capacidad_peso', 'capacidad_espacio', 'costo_km']:
        if (df_vehicles[col] < 0).any():
            raise ValueError(f"Valores negativos en {col}")

    # Validación de ventanas de tiempo (en horas)
    depot_start = CONFIG['DEPOT_START_TIME'] / 3600
    depot_end = CONFIG['DEPOT_END_TIME'] / 3600
    max_horizon = 24
    for i in range(len(df_points)):
        node_id = df_points.iloc[i]['id']
        if pd.notna(df_points.iloc[i]['ventana_inicio']) and pd.notna(df_points.iloc[i]['ventana_fin']):
            start_hour = float(df_points.iloc[i]['ventana_inicio'])
            end_hour = float(df_points.iloc[i]['ventana_fin'])
            if not isinstance(start_hour, (int, float)) or not isinstance(end_hour, (int, float)):
                raise ValueError(f"Nodo {node_id}: ventana_inicio o ventana_fin no son numéricos")
            if start_hour < 0 or end_hour < 0:
                raise ValueError(f"Nodo {node_id}: ventana_inicio o ventana_fin son negativos")
            if start_hour > end_hour:
                raise ValueError(f"Nodo {node_id}: ventana_inicio ({start_hour}h) > ventana_fin ({end_hour}h)")
            if start_hour > max_horizon or end_hour > max_horizon:
                raise ValueError(f"Nodo {node_id}: ventana de tiempo excede el horizonte de 24 horas")
            if node_id != 0:
                if end_hour < depot_start or start_hour > depot_end:
                    logger.error(f"Nodo {node_id}: ventana ({start_hour}h, {end_hour}h) incompatible con el depósito ({depot_start}h, {depot_end}h)")
                    raise ValueError(f"Nodo {node_id}: ventana incompatible con el depósito ({depot_start}h, {depot_end}h)")
        elif node_id != 0:
            df_points.at[i, 'ventana_inicio'] = depot_start
            df_points.at[i, 'ventana_fin'] = depot_end
            logger.warning(f"Nodo {node_id}: ventana_inicio o ventana_fin eran NaN. Asignado ventana por defecto ({depot_start}h, {depot_end}h).")

def create_vrp_model(pts: pd.DataFrame, vehicles: pd.DataFrame,
                     time_matrix: np.ndarray, dist_matrix: np.ndarray,
                     initial_routes: Optional[List[List[int]]] = None) -> Tuple:
    """Crea el modelo VRP con OR-Tools."""
    n = len(pts)
    num_vehicles = min(len(vehicles), CONFIG['MAX_VEHICLES'])
    manager = pywrapcp.RoutingIndexManager(n, num_vehicles, 0)
    routing = pywrapcp.RoutingModel(manager)

    if initial_routes:
        try:
            assignment = routing.ReadAssignmentFromRoutes(initial_routes, True)
            if assignment:
                logger.info("Solución inicial cargada desde rutas históricas")
        except Exception as e:
            logger.warning(f"No se pudo cargar solución inicial: {e}")

    penalty_value = CONFIG['PENALTY_FACTOR'] * 1000
    for node in range(1, n):
        routing.AddDisjunction([manager.NodeToIndex(node)], penalty_value)

    max_fixed_cost = 15000
    min_fixed_cost = 1000
    for v_idx in range(num_vehicles):
        fixed_cost = max_fixed_cost - ((max_fixed_cost - min_fixed_cost) * v_idx // max(1, num_vehicles - 1))
        routing.SetFixedCostOfVehicle(fixed_cost, v_idx)

    for v_idx in range(num_vehicles):
        costo_km = vehicles.iloc[v_idx]['costo_km']
        cb = routing.RegisterTransitCallback(
            lambda i, j, c=costo_km: int(dist_matrix[manager.IndexToNode(i)][manager.IndexToNode(j)] * c * 1000)
        )
        routing.SetArcCostEvaluatorOfVehicle(cb, v_idx)

    # Dimensiones de entrega
    demands_delivery_weight = pts['demanda_peso'].where(pts['tipo_tarea'] == 'entrega', 0).astype(float).values
    demands_delivery_space = pts['demanda_espacio'].where(pts['tipo_tarea'] == 'entrega', 0).astype(float).values

    def delivery_weight_cb(i):
        node = manager.IndexToNode(i)
        return -int(demands_delivery_weight[node] * 1000) if pts.iloc[node]['tipo_tarea'] == 'entrega' else 0

    def delivery_space_cb(i):
        node = manager.IndexToNode(i)
        return -int(demands_delivery_space[node] * 1000) if pts.iloc[node]['tipo_tarea'] == 'entrega' else 0

    delivery_weight_idx = routing.RegisterUnaryTransitCallback(delivery_weight_cb)
    delivery_space_idx = routing.RegisterUnaryTransitCallback(delivery_space_cb)

    max_delivery_weight = int(vehicles['capacidad_peso'].max() * 1000)
    max_delivery_space = int(vehicles['capacidad_espacio'].max() * 1000)

    routing.AddDimension(delivery_weight_idx, 0, max_delivery_weight, False, 'DeliveryWeight')
    routing.AddDimension(delivery_space_idx, 0, max_delivery_space, False, 'DeliverySpace')
    delivery_weight_dim = routing.GetDimensionOrDie('DeliveryWeight')
    delivery_space_dim = routing.GetDimensionOrDie('DeliverySpace')

    for v in range(num_vehicles):
        start_idx = routing.Start(v)
        delivery_weight_dim.CumulVar(start_idx).SetRange(0, max_delivery_weight)
        delivery_space_dim.CumulVar(start_idx).SetRange(0, max_delivery_space)

    # Dimensiones de recolección
    demands_pickup_weight = pts['demanda_peso'].where(pts['tipo_tarea'] == 'recolección', 0).astype(float).values
    demands_pickup_space = pts['demanda_espacio'].where(pts['tipo_tarea'] == 'recolección', 0).astype(float).values

    def pickup_weight_cb(i):
        node = manager.IndexToNode(i)
        return int(demands_pickup_weight[node] * 1000) if pts.iloc[node]['tipo_tarea'] == 'recolección' else 0

    def pickup_space_cb(i):
        node = manager.IndexToNode(i)
        return int(demands_pickup_space[node] * 1000) if pts.iloc[node]['tipo_tarea'] == 'recolección' else 0

    pickup_weight_idx = routing.RegisterUnaryTransitCallback(pickup_weight_cb)
    pickup_space_idx = routing.RegisterUnaryTransitCallback(pickup_space_cb)

    capacities_weight = [int(cap * 1000) for cap in vehicles['capacidad_peso'].iloc[:num_vehicles]]
    capacities_space = [int(cap * 1000) for cap in vehicles['capacidad_espacio'].iloc[:num_vehicles]]

    routing.AddDimensionWithVehicleCapacity(pickup_weight_idx, 0, capacities_weight, True, 'PickupWeight')
    routing.AddDimensionWithVehicleCapacity(pickup_space_idx, 0, capacities_space, True, 'PickupSpace')
    pickup_weight_dim = routing.GetDimensionOrDie('PickupWeight')
    pickup_space_dim = routing.GetDimensionOrDie('PickupSpace')

    # Dimensión de tiempo
    service_times = pts['tiempo_servicio'].values if 'tiempo_servicio' in pts.columns else np.full(n, CONFIG['SERVICE_TIME_MIN'])

    def time_cb(i, j):
        ni = manager.IndexToNode(i)
        nj = manager.IndexToNode(j)
        travel = time_matrix[ni][nj]
        service = service_times[ni] if ni != 0 else 0
        return int((travel + service) * 60)

    t_idx = routing.RegisterTransitCallback(time_cb)
    max_time_horizon = 24 * 60 * 60
    routing.AddDimension(t_idx, max_time_horizon, max_time_horizon, False, 'Time')
    time_dim = routing.GetDimensionOrDie('Time')

    # Restricciones duras para el horario del depósito
    depot_start_sec = CONFIG['DEPOT_START_TIME']
    depot_end_sec = CONFIG['DEPOT_END_TIME']
    for v in range(num_vehicles):
        start_idx = routing.Start(v)
        end_idx = routing.End(v)
        time_dim.CumulVar(start_idx).SetMin(depot_start_sec)
        time_dim.CumulVar(end_idx).SetMax(depot_end_sec)

    # Penalizaciones para ventanas de tiempo en nodos
    if CONFIG['TIME_WINDOWS']:
        for i in range(1, n):
            node_id = pts.iloc[i]['id']
            if pd.notna(pts.iloc[i]['ventana_inicio']) and pd.notna(pts.iloc[i]['ventana_fin']):
                start_hour = pts.iloc[i]['ventana_inicio']
                end_hour = pts.iloc[i]['ventana_fin']
                start_sec = int(start_hour * 3600)
                end_sec = int(end_hour * 3600)
                effective_start = max(0, start_sec - (CONFIG['SLACK_ARRIVAL_MIN'] * 60))
                logger.info(f"Nodo {node_id}: ventana_inicio={start_hour}h, ventana_fin={end_hour}h, effective_start={effective_start/3600:.2f}h, end_sec={end_sec/3600:.2f}h")
                index = manager.NodeToIndex(i)
                if effective_start <= end_sec:
                    try:
                        time_dim.CumulVar(index).SetRange(int(effective_start), int(end_sec))
                    except Exception as e:
                        logger.error(f"Error al establecer rango para nodo {node_id}: {e}. Usando solo penalización suave.")
                else:
                    logger.warning(f"Nodo {node_id}: Rango inválido effective_start={effective_start/3600:.2f}h > end_sec={end_sec/3600:.2f}h. Usando penalización suave.")
                    arrival_time = time_dim.CumulVar(index)
                    zero_const = routing.solver().IntConst(0)
                    early_diff = start_sec - arrival_time
                    early_violation = routing.solver().Max(early_diff, zero_const)
                    routing.AddVariableMinimizedByFinalizer(
                        early_violation * (CONFIG['PENALTY_PER_MINUTE_EARLY'] * 60)
                    )
                    late_diff = arrival_time - end_sec
                    late_violation = routing.solver().Max(late_diff, zero_const)
                    routing.AddVariableMinimizedByFinalizer(
                        late_violation * (CONFIG['PENALTY_PER_MINUTE_LATE'] * 60)
                    )
            else:
                logger.info(f"Nodo {node_id} sin ventana definida. Solo penalizaciones suaves.")

    # Precedencia de entregas antes de recolecciones
    delivery_nodes_indices = [manager.NodeToIndex(i) for i in range(1, n) if pts.iloc[i]['tipo_tarea'] == 'entrega']
    pickup_nodes_indices = [manager.NodeToIndex(i) for i in range(1, n) if pts.iloc[i]['tipo_tarea'] == 'recolección']
    for vehicle_id in range(num_vehicles):
        deliveries = [time_dim.CumulVar(node) for node in delivery_nodes_indices if routing.VehicleVar(node).Contains(vehicle_id)]
        pickups = [time_dim.CumulVar(node) for node in pickup_nodes_indices if routing.VehicleVar(node).Contains(vehicle_id)]
        if deliveries and pickups:
            max_delivery_time = routing.solver().Max(deliveries)
            min_pickup_time = routing.solver().Min(pickups)
            routing.solver().Add(max_delivery_time < min_pickup_time)

    return routing, manager, time_dim

def extract_single_route(v: int, solution, routing, manager, time_dim, pts, vehicles, dist_matrix) -> Optional[pd.DataFrame]:
    """Extrae una ruta individual."""
    index = routing.Start(v)
    if routing.IsEnd(solution.Value(routing.NextVar(index))):
        return None

    delivery_weight_dim = routing.GetDimensionOrDie('DeliveryWeight')
    delivery_space_dim = routing.GetDimensionOrDie('DeliverySpace')
    pickup_weight_dim = routing.GetDimensionOrDie('PickupWeight')
    pickup_space_dim = routing.GetDimensionOrDie('PickupSpace')

    route_data = []
    cum_dist = 0.0
    prev_node_idx = None
    order = 0

    def format_time(seconds_from_depot_start):
        total_seconds = CONFIG['DEPOT_START_TIME'] + seconds_from_depot_start
        hours = (total_seconds // 3600) % 24
        minutes = (total_seconds % 3600) // 60
        return f"{int(hours):02d}:{int(minutes):02d}"

    while not routing.IsEnd(index):
        node_idx = manager.IndexToNode(index)
        if prev_node_idx is not None:
            cum_dist += dist_matrix[prev_node_idx][node_idx]

        time_at_node_seconds = solution.Value(time_dim.CumulVar(index))
        time_from_depot_start = time_at_node_seconds - CONFIG['DEPOT_START_TIME']
        formatted_time = format_time(time_from_depot_start)
        
        logger.debug(f"Node {node_idx} (id {pts.iloc[node_idx]['id']}): "
                     f"Time cumulative: {time_at_node_seconds}s, "
                     f"From depot start: {time_from_depot_start}s, "
                     f"Formatted: {formatted_time}")

        route_data.append({
            'id': int(pts.iloc[node_idx]['id']),
            'tipo_tarea': pts.iloc[node_idx]['tipo_tarea'],
            'orden': order,
            'hora': formatted_time,
            'cum_dist_km': round(cum_dist, 2),
            'carga_peso_entrega': round(solution.Value(delivery_weight_dim.CumulVar(index)) / 1000.0, 2),
            'carga_espacio_entrega': round(solution.Value(delivery_space_dim.CumulVar(index)) / 1000.0, 2),
            'carga_peso_recoleccion': round(solution.Value(pickup_weight_dim.CumulVar(index)) / 1000.0, 2),
            'carga_espacio_recoleccion': round(solution.Value(pickup_space_dim.CumulVar(index)) / 1000.0, 2)
        })

        prev_node_idx = node_idx
        index = solution.Value(routing.NextVar(index))
        order += 1

    node_idx = manager.IndexToNode(index)
    if prev_node_idx is not None:
        cum_dist += dist_matrix[prev_node_idx][node_idx]

    time_at_node_seconds = solution.Value(time_dim.CumulVar(index))
    time_from_depot_start = time_at_node_seconds - CONFIG['DEPOT_START_TIME']
    formatted_time = format_time(time_from_depot_start)

    route_data.append({
        'id': int(pts.iloc[node_idx]['id']),
        'tipo_tarea': 'depósito',
        'orden': order,
        'hora': formatted_time,
        'cum_dist_km': round(cum_dist, 2),
        'carga_peso_entrega': round(solution.Value(delivery_weight_dim.CumulVar(index)) / 1000.0, 2),
        'carga_espacio_entrega': round(solution.Value(delivery_space_dim.CumulVar(index)) / 1000.0, 2),
        'carga_peso_recoleccion': round(solution.Value(pickup_weight_dim.CumulVar(index)) / 1000.0, 2),
        'carga_espacio_recoleccion': round(solution.Value(pickup_space_dim.CumulVar(index)) / 1000.0, 2)
    })

    df_route = pd.DataFrame(route_data)
    df_route['vehiculo_id'] = int(vehicles.iloc[v]['id'])
    return df_route

def extract_routes(solution, routing, manager, time_dim, pts, vehicles, dist_matrix) -> Tuple[List[pd.DataFrame], pd.DataFrame, Set[int]]:
    """Extrae todas las rutas y nodos omitidos."""
    n = len(pts)
    num_vehicles = min(len(vehicles), CONFIG['MAX_VEHICLES'])
    visited_nodes = set()
    used_vehicle_ids = set()
    for v in range(num_vehicles):
        index = routing.Start(v)
        while not routing.IsEnd(index):
            node_idx = manager.IndexToNode(index)
            visited_nodes.add(node_idx)
            index = solution.Value(routing.NextVar(index))
        visited_nodes.add(manager.IndexToNode(index))
        used_vehicle_ids.add(int(vehicles.iloc[v]['id']))

    omitted = [{'id': int(pts.iloc[i]['id']), 'tipo_tarea': pts.iloc[i]['tipo_tarea'], 'penalizacion': CONFIG['PENALTY_FACTOR']}
               for i in range(1, n) if i not in visited_nodes]
    df_omitted = pd.DataFrame(omitted)

    routes = []
    with concurrent.futures.ThreadPoolExecutor() as ex:
        futures = [ex.submit(extract_single_route, v, solution, routing, manager, time_dim, pts, vehicles, dist_matrix) for v in range(num_vehicles)]
        for fut in concurrent.futures.as_completed(futures):
            df_r = fut.result()
            if df_r is not None:
                routes.append(df_r)
    return routes, df_omitted, used_vehicle_ids

def export_results(routes: List[pd.DataFrame], df_omitted: pd.DataFrame, vehicles: pd.DataFrame) -> None:
    """Exporta rutas y resumen a Excel."""
    if not routes and df_omitted.empty:
        logger.warning("No hay rutas ni nodos omitidos para exportar.")
        return
    with pd.ExcelWriter("resultados_rutas.xlsx") as writer:
        summary = []
        total_dist_km = 0.0
        total_route_cost_variable = 0.0
        total_omission_penalty = 0.0
        used_vehicle_ids = set()
        for i, r in enumerate(routes):
            name = f"Ruta_{i+1}"
            r.to_excel(writer, sheet_name=name, index=False)
            dist = r['cum_dist_km'].iloc[-1]
            vehicle_id = r['vehiculo_id'].iloc[0]
            used_vehicle_ids.add(vehicle_id)
            vehicle_cost_per_km = vehicles.loc[vehicles['id'] == vehicle_id, 'costo_km'].item()
            cost_for_route_variable = dist * vehicle_cost_per_km
            summary.append({
                'ruta': name,
                'vehiculo': vehicle_id,
                'dist_km': round(dist, 2),
                'costo_variable_ruta': round(cost_for_route_variable, 2),
                'costo_km': round(vehicle_cost_per_km, 2),
                'penalizacion_omision': 0
            })
            total_dist_km += dist
            total_route_cost_variable += cost_for_route_variable
        if not df_omitted.empty:
            total_omission_penalty = df_omitted['penalizacion'].sum()
            df_omitted.to_excel(writer, sheet_name='Nodos_Omitidos', index=False)
            summary.append({
                'ruta': 'TOTAL_PENALIZACION_OMISION',
                'vehiculo': '--',
                'dist_km': 0,
                'costo_variable_ruta': 0,
                'costo_km': 0,
                'penalizacion_omision': total_omission_penalty
            })
        summary.append({
            'ruta': 'TOTAL GENERAL',
            'vehiculo': '',
            'dist_km': round(total_dist_km, 2),
            'costo_variable_ruta': round(total_route_cost_variable, 2),
            'costo_km': '--',
            'penalizacion_omision': round(total_omission_penalty, 2)
        })
        pd.DataFrame(summary).to_excel(writer, sheet_name='Resumen', index=False)
        if used_vehicle_ids:
            df_used_vehicles = vehicles[vehicles['id'].isin(list(used_vehicle_ids))].copy()
            df_used_vehicles.to_excel(writer, sheet_name='Vehiculos_Utilizados', index=False)

def validate_solution(routes: List[pd.DataFrame], df_points: pd.DataFrame, df_vehicles: pd.DataFrame) -> None:
    """Valida la factibilidad de las rutas generadas."""
    for route in routes:
        vehicle_id = route['vehiculo_id'].iloc[0]
        vehicle = df_vehicles[df_vehicles['id'] == vehicle_id].iloc[0]
        
        max_delivery_weight = route['carga_peso_entrega'].max()
        max_delivery_space = route['carga_espacio_entrega'].max()
        if max_delivery_weight > vehicle['capacidad_peso']:
            logger.warning(f"Ruta {vehicle_id} excede capacidad de peso para entregas: {max_delivery_weight} > {vehicle['capacidad_peso']}")
        if max_delivery_space > vehicle['capacidad_espacio']:
            logger.warning(f"Ruta {vehicle_id} excede capacidad de espacio para entregas: {max_delivery_space} > {vehicle['capacidad_espacio']}")
        
        max_pickup_weight = route['carga_peso_recoleccion'].max()
        max_pickup_space = route['carga_espacio_recoleccion'].max()
        if max_pickup_weight > vehicle['capacidad_peso']:
            logger.warning(f"Ruta {vehicle_id} excede capacidad de peso para recolecciones: {max_pickup_weight} > {vehicle['capacidad_peso']}")
        if max_pickup_space > vehicle['capacidad_espacio']:
            logger.warning(f"Ruta {vehicle_id} excede capacidad de espacio para recolecciones: {max_pickup_space} > {vehicle['capacidad_espacio']}")
        
        try:
            times = pd.to_datetime(route['hora'], format='%H:%M')
        except ValueError as e:
            logger.error(f"Error al convertir 'hora' en ruta {vehicle_id}: {e}")
            continue
        
        if (times.diff().dt.total_seconds() < 0).any():
            logger.warning(f"Ruta {vehicle_id} tiene tiempos inconsistentes")
        
        delivery_times = times[route['tipo_tarea'] == 'entrega']
        pickup_times = times[route['tipo_tarea'] == 'recolección']
        if not delivery_times.empty and not pickup_times.empty:
            if delivery_times.max() >= pickup_times.min():
                logger.warning(f"Ruta {vehicle_id} no cumple precedencia de entregas antes de recolecciones")

def solve_vrp(df_p: pd.DataFrame, df_v: pd.DataFrame, 
              time_limit_sec: int = CONFIG['SEARCH_TIME_LIMIT_SEC'],
              initial_routes: Optional[List[List[int]]] = None) -> Tuple[List[pd.DataFrame], pd.DataFrame, Set[int]]:
    """Resuelve el problema VRP con OR-Tools."""
    time_matrix, dist_matrix = get_osrm_matrix(df_p)
    df_v = df_v.sort_values('costo_km').reset_index(drop=True)
    routing, manager, time_dim = create_vrp_model(df_p, df_v, time_matrix, dist_matrix, initial_routes)

    search_params = pywrapcp.DefaultRoutingSearchParameters()
    search_params.time_limit.seconds = time_limit_sec
    search_params.first_solution_strategy = routing_enums_pb2.FirstSolutionStrategy.PARALLEL_CHEAPEST_INSERTION
    search_params.local_search_metaheuristic = routing_enums_pb2.LocalSearchMetaheuristic.GUIDED_LOCAL_SEARCH
    search_params.log_search = True

    logger.info(f"Iniciando búsqueda VRP con límite de {time_limit_sec} segundos.")
    solution = routing.SolveWithParameters(search_params)
    if solution:
        logger.info("Solución encontrada.")
        routes, df_omitted, used_vehicles = extract_routes(solution, routing, manager, time_dim, df_p, df_v, dist_matrix)
        return routes, df_omitted, used_vehicles
    else:
        logger.error("No se encontró solución. Aumente el tiempo límite o revise las restricciones.")
        return [], pd.DataFrame(), set()

def main():
    """Función principal."""
    logger.info("Iniciando optimización de rutas.")
    signal.signal(signal.SIGINT, lambda signum, frame: sys.exit(1))
    try:
        df_p = pd.read_excel("datos_ruteo.xlsx", sheet_name="puntos")
        df_v = pd.read_excel("datos_ruteo.xlsx", sheet_name="vehiculos")
        validate_data(df_p, df_v)

        if not check_and_start_osrm():
            logger.error("No se pudo iniciar OSRM.")
            return

        routes, df_omitted, used_vehicles = solve_vrp(
            df_p, df_v, time_limit_sec=CONFIG['SEARCH_TIME_LIMIT_SEC']
        )

        if routes:
            validate_solution(routes, df_p, df_v)
            export_results(routes, df_omitted, df_v)
        logger.info("Proceso completado.")
    except Exception as e:
        logger.error(f"Error: {e}", exc_info=True)

if __name__ == "__main__":
    main()