| docs | ||
| .gitignore | ||
| config.py | ||
| ortools_adapter.py | ||
| preprocess.py | ||
| README.md | ||
| requirements.txt | ||
| stage2_cvrptw.py | ||
| test_preprocess.py | ||
| test_stage2_cvrptw.py | ||
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:
load_manifest— lee el Excel y renombra al esquema interno por posición. La fuente trae el encabezadoLunesduplicado en las columnas E y F, y un typoLOGporLON, así que resolver por nombre sería frágil.normalize_records— coacciona tipos, tolera coma decimal y separador de miles, colapsa espacios en texto, preserva identificadores mixtos (CA353junto a11038952) sin convertirlos en11038952.0, y descarta registros nulos o malformados devolviendo los rechazos con su motivo.assign_service_time— asigna minutos por banda de peso, de forma vectorial.haversine_km/flag_domiciliario— distancia ortodrómica desde el nodo satélite y bandera de elegibilidad.convert_time_windows— traduce las ventanas horarias a minutos.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.