No description
Find a file
Andrés Castañeda 8157534c09 first commit
2026-09-04 11:33:49 -05:00
docs first commit 2026-09-04 11:33:49 -05:00
.gitignore first commit 2026-09-04 11:33:49 -05:00
config.py first commit 2026-09-04 11:33:49 -05:00
ortools_adapter.py first commit 2026-09-04 11:33:49 -05:00
preprocess.py first commit 2026-09-04 11:33:49 -05:00
README.md first commit 2026-09-04 11:33:49 -05:00
requirements.txt first commit 2026-09-04 11:33:49 -05:00
stage2_cvrptw.py first commit 2026-09-04 11:33:49 -05:00
test_preprocess.py first commit 2026-09-04 11:33:49 -05:00
test_stage2_cvrptw.py first commit 2026-09-04 11:33:49 -05:00

Preprocesamiento de manifiestos de última milla — Bucaramanga

Pipeline en Python/pandas que convierte el manifiesto diario de pedidos geolocalizados en una estructura JSON indexada por nodo, lista para alimentar un motor heurístico de ruteo Google OR-Tools (CVRPTW).

Cubre la etapa 1 (ingesta y preprocesamiento del manifiesto) y la etapa 2 (el motor de ruteo CVRPTW que consume ese JSON: flota heterogénea, jornada de 420 minutos, ventanas con castigo financiero y entregas opcionales). Este archivo documenta el uso del código. La documentación del proyecto vive en docs/.

Documento Contenido
docs/README.md Índice y mapa de etapas.
docs/stage-1-preprocesamiento.md La etapa 1: objetivo, alcance, reglas, resultados y verificación.
docs/stage-1-contrato-datos.md Referencia campo por campo del JSON de salida.
docs/stage-2-modelo.md La etapa 2: formulación del CVRPTW, las tres dimensiones, resultados y verificación.
docs/decisiones.md Registro de decisiones de diseño.

Instalación

python3 -m venv .venv
.venv/bin/pip install -r requirements.txt

Uso

.venv/bin/python preprocess.py \
    --input "/ruta/Taller Planeación de Cargues - Datos.xlsx" \
    --output output/pedidos_ortools.json

Opciones: --sheet (hoja de pedidos), --rejected (CSV de descartes), --depot-lat / --depot-lon (reubicar el nodo satélite), --log-level.

Pruebas:

.venv/bin/python -m unittest -v

Arquitectura

Archivo Rol
config.py Parámetros de negocio: depósito, matriz de tiempos, umbrales, esquema de columnas.
preprocess.py Funciones puras encadenables más una interfaz de línea de comandos.
ortools_adapter.py Adaptador de referencia del JSON al modelo de OR-Tools. Etapa 1; funciona sin ortools instalado.
stage2_cvrptw.py Etapa 2: el modelo CVRPTW completo, sus orquestadores, el informe y la verificación.
test_preprocess.py 24 pruebas con unittest, incluida una corrida completa sobre el manifiesto real.
test_stage2_cvrptw.py 34 pruebas de las invariantes del modelo de ruteo.

Etapas de preprocess.py:

  1. load_manifest — lee el Excel y renombra al esquema interno por posición. La fuente trae el encabezado Lunes duplicado en las columnas E y F, y un typo LOG por LON, así que resolver por nombre sería frágil.
  2. normalize_records — coacciona tipos, tolera coma decimal y separador de miles, colapsa espacios en texto, preserva identificadores mixtos (CA353 junto a 11038952) sin convertirlos en 11038952.0, y descarta registros nulos o malformados devolviendo los rechazos con su motivo.
  3. assign_service_time — asigna minutos por banda de peso, de forma vectorial.
  4. haversine_km / flag_domiciliario — distancia ortodrómica desde el nodo satélite y bandera de elegibilidad.
  5. convert_time_windows — traduce las ventanas horarias a minutos.
  6. build_ortools_payload — serializa los arreglos paralelos.

Reglas de negocio

Tiempo de servicio

La hoja de referencia declara las bandas como 0-20 / 21-50 / 51-100 / 101-500 / 501-1000 / 1001 o más, lo que deja huecos abiertos. El manifiesto real contiene un pedido de 50.22 kg, que no cae en ninguna banda literal. El pipeline usa por tanto intervalos continuos cerrados por la derecha:

Peso (kg) Servicio
(0, 20] 5 min
(20, 50] 8 min
(50, 100] 10 min
(100, 500] 15 min
(500, 1000] 45 min
(1000, ∞) 120 min

La frontera de 50 kg coincide con el umbral de domiciliario, de modo que ambas reglas clasifican igual los casos límite. La implementación es np.searchsorted(edges, weights, side="left"), sin apply fila a fila.

Elegibilidad de domiciliario

is_domiciliario_eligible es verdadera solo si la distancia ortodrómica al nodo satélite es ≤ 3 km y el peso es ≤ 50 kg. Ambas cotas son inclusivas. El nodo satélite Full Filler está en 7.113411, -73.117982.

Reglas de descarte

Se rechaza un registro si le falta el identificador de pedido, si las coordenadas son nulas o no numéricas, si la latitud sale de [-90, 90] o la longitud de [-180, 180], si el par es (0, 0), si el peso es nulo o no positivo, o si el identificador de pedido ya apareció. El duplicado se evalúa solo entre los registros que sobrevivieron a las reglas anteriores, para que una copia buena no se pierda por culpa de una primera copia malformada. Los descartes se vuelcan a output/rejected.csv con su motivo y su fila de origen.

Estructura del JSON

Índice 0 es el depósito, índices 1..N son los clientes, en el mismo orden en todos los arreglos.

{
  "metadata": { "num_nodes": 35, "records_rejected": 0, "warnings": [...] },
  "depot":    { "index": 0, "name": "Full Filler", "lat": 7.113411, "lon": -73.117982 },

  "locations":                [[lat, lon], ...],   // N+1 pares
  "demands_kg":               [0.0, 14.5, ...],
  "service_times_min":        [0, 5, ...],
  "time_windows_min":         [[0, 1439], [360, 480], ...],
  "distance_from_depot_km":   [0.0, 1.17, ...],
  "is_domiciliario_eligible": [false, true, ...],
  "node_ids":                 ["DEPOT", "202055583", ...],

  "nodes": [ /* vista por registro, para trazabilidad */ ]
}

Las ventanas horarias van en minutos desde medianoche, que es la unidad de la dimensión Time de OR-Tools. Una ventana nula o invertida se sustituye por la jornada completa y se reporta en metadata.warnings, para que un dato sucio no vuelva infactible el modelo.

Mapeo a OR-Tools

Arreglo del JSON Uso en el modelo
locations Matriz de distancias vía haversine_km, en metros enteros, para SetArcCostEvaluatorOfAllVehicles.
demands_kg RegisterUnaryTransitCallback + AddDimensionWithVehicleCapacity.
service_times_min Sumando fijo del callback de tránsito de la dimensión Time.
time_windows_min time_dimension.CumulVar(index).SetRange(open, close).
is_domiciliario_eligible Segmentación de flota: subconjunto asignable a motos.
metadata.depot_index Argumento depot de RoutingIndexManager.

ortools_adapter.py implementa ese mapeo completo y resuelve el problema:

.venv/bin/pip install ortools
.venv/bin/python ortools_adapter.py --data output/pedidos_ortools.json --vehicles 5 --capacity 3500

Sin ortools instalado el script valida solo el contrato de datos.

Etapa 2 — el modelo de ruteo

stage2_cvrptw.py es el motor de ruteo propiamente dicho. A diferencia del adaptador, asume ortools instalado y añade lo que hace operable al plan: flota heterogénea de 2000 kg y 50 kg, jornada dura de 420 minutos impuesta como span, castigo financiero por entrega fuera de ventana, fragmentación de los pedidos que no caben en ningún vehículo, y una disyunción por nodo que evita que el motor se bloquee ante una instancia sobrecargada.

# Plan nominal con flota dimensionada automáticamente y verificación.
.venv/bin/python stage2_cvrptw.py --verificar --json output/plan_stage2.json

# Las dos mecánicas de castigo por incumplimiento de ventana.
.venv/bin/python stage2_cvrptw.py --salida-min 600 --penalty-mode plano
.venv/bin/python stage2_cvrptw.py --salida-min 600 --penalty-mode lineal

# Instancia deliberadamente sobrecargada: devuelve plan, no un error.
.venv/bin/python stage2_cvrptw.py --camiones 1 --motos 0

Sobre el manifiesto de referencia rutea el 100 % de la demanda con 5 camiones, sin incumplimientos de ventana y por unos 730.000 COP. Los detalles, incluida la razón por la que el solver no usa motos si no se le obliga, están en docs/stage-2-modelo.md.

Resultado sobre el manifiesto de referencia

34 pedidos leídos, 34 válidos, 0 rechazados, 35 nodos.

Métrica Valor
Demanda total 8376.195 kg
Tiempo de servicio total 631 min
Elegibles para domiciliario 15 de 34
Distribución de servicio 5 min: 10, 8 min: 7, 10 min: 6, 15 min: 6, 45 min: 3, 120 min: 2

Dos advertencias emitidas: los pedidos 5300865669 y 5300865699 están a 25 m del nodo satélite, es decir sobre el propio Full Filler. No se eliminan, porque descartar demanda no es una decisión del preprocesamiento, pero quedan marcados en metadata.warnings para que planeación decida si son entregas reales.

Supuestos

  • Bandas de peso continuas cerradas por la derecha, para cubrir los huecos que deja la tabla original.
  • Las columnas E y F del manifiesto son la apertura y el cierre de la ventana de entrega, expresadas como fracción de día de Excel.
  • Los pedidos situados sobre el depósito se conservan y solo se advierten.
  • El índice 0 se reserva para el depósito, convención estándar de OR-Tools.