Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions data/pedidos_ejemplo.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
id_pedido,cliente,direccion,lat,lon,prioridad,peso_kg,franja_inicio,franja_fin,observaciones
PED-001,Panadería Altabix,Calle Bernabé del Campo Latorre 12 Elche,38.2725,-0.6782,2,15.5,09:00,12:00,Entregar antes del mediodía
PED-002,Cafetería UMH,Avenida de la Universidad s/n Elche,38.2750,-0.6870,3,40.0,09:00,11:00,Carga prioritaria de café
PED-003,Farmacia Carrús,Calle Diagonal 45 Elche,38.2810,-0.6990,1,10.0,09:00,18:00,Medicamentos no urgentes
PED-004,Restaurante Matola,Carretera de la Hoya 14 Elche,38.2420,-0.7250,2,85.0,14:00,17:00,Producto fresco refrigerado
PED-005,Supermercado Sector V,Calle Capitán Antonio Mena 89 Elche,38.2580,-0.7020,3,120.0,09:00,13:00,Requiere transpaleta
PED-006,Tienda Centro Elche,Calle Corredora 22 Elche,38.2680,-0.6980,2,25.0,09:00,14:00,Zona peatonal accesible
PED-007,Ferretería El Toscar,Calle Emilio Hernández Selva 102 Elche,38.2770,-0.7080,1,60.0,09:00,18:00,Material de obra pesado
PED-008,Clínica Raval,Calle Boix y Rosario 5 Elche,38.2630,-0.6950,2,8.0,14:00,18:00,Entregar en recepción
PED-009,Papelería Palmeral,Paseo de la Estación 15 Elche,38.2690,-0.6890,1,12.0,09:00,18:00,Cajas de folios
PED-010,Frutería La Hoya,Avenida de la Hoya 3 Elche,38.2450,-0.6550,2,95.0,09:00,12:00,Fruta de temporada
PED-011,Café Carolinas,Calle San Mateo 42 Alicante,38.3580,-0.4850,2,18.0,09:00,13:00,Café molido
PED-012,Gimnasio Babel,Calle México 18 Alicante,38.3310,-0.5050,3,75.0,09:00,12:00,Discos y mancuernas
PED-013,Hotel Alicante Centro,Calle Gerona 5 Alicante,38.3450,-0.4880,3,110.0,09:00,11:00,Ropa de cama limpia urgencia
PED-014,Boutique Maisonnave,Avenida de Maisonnave 33 Alicante,38.3430,-0.4920,1,14.0,09:00,18:00,Moda de temporada
PED-015,Restaurante El Puerto,Muelle de Levante s/n Alicante,38.3410,-0.4810,2,90.0,14:00,18:00,Pescado fresco del día
PED-016,Librería San Blas,Calle Pintor Gisbert 8 Alicante,38.3470,-0.5010,1,22.0,09:00,18:00,Libros escolares
PED-017,Clínica Vistahermosa,Avenida de Denia 103 Alicante,38.3680,-0.4680,2,30.0,10:00,14:00,Material quirúrgico
PED-018,Taller La Florida,Calle Asturias 12 Alicante,38.3370,-0.5110,1,55.0,09:00,18:00,Repuestos de motor
PED-019,Hogar Albufereta,Calle Sol Naciente 2 Alicante,38.3650,-0.4510,2,45.0,14:00,18:00,Mobiliario pequeño
PED-020,Supermercado Miriam Blasco,Avenida de la Condomina 45 Alicante,38.3690,-0.4350,3,130.0,09:00,13:00,Palets de leche
PED-021,Carnicería Altabix,Calle Diagonal 10 Elche,38.2740,-0.6810,2,35.0,09:00,13:00,Carne fresca
PED-022,Floristería Centro,Calle Mayor de la Vila 8 Elche,38.2675,-0.6975,1,5.0,09:00,18:00,Flores delicadas
PED-023,Restaurante Carrús,Calle de la Reina Victoria 120 Elche,38.2710,-0.7040,2,50.0,14:00,17:00,Bebidas y barriles
PED-024,Óptica San José,Calle Menéndez y Pelayo 34 Elche,38.2620,-0.7050,1,3.0,09:00,18:00,Gafas graduadas
PED-025,Pescadería Elche,Avenida de Novelda 55 Elche,38.2830,-0.7010,3,70.0,09:00,11:00,Pescado para restaurantes
PED-026,Juguetería Alicante,Calle San Vicente 60 Alicante,38.3490,-0.4870,1,28.0,09:00,18:00,Juguetes varios
PED-027,Panadería Florida,Calle Orihuela 78 Alicante,38.3350,-0.5180,2,20.0,09:00,12:00,Pan de molde artesano
PED-028,Farmacia San Vicente,Calle Ancha de Castelar 10 San Vicente del Raspeig,38.3950,-0.5210,1,12.0,09:00,18:00,Medicamentos generales
PED-029,Restaurante UMH Elche,Avenida de la Universidad s/n Edificio Rectorado Elche,38.2760,-0.6860,3,65.0,11:00,14:00,Suministros comedor universitario
PED-030,Residencia San Lucas,Calle Doctor Waksman 2 Elche,38.2650,-0.6820,3,80.0,09:00,13:00,Suministros médicos prioritarios
35 changes: 35 additions & 0 deletions data/vehiculos_config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
[
{
"id_vehiculo": "VAN-01",
"nombre": "Furgoneta Eléctrica (Elche/Alicante)",
"capacidad_kg": 400,
"coste_por_km": 0.15,
"hora_inicio": "08:00",
"hora_fin": "16:00",
"deposito_lat": 38.2743,
"deposito_lon": -0.6865,
"zona_preferente": "Elche"
},
{
"id_vehiculo": "VAN-02",
"nombre": "Furgoneta Diésel (Alicante Este)",
"capacidad_kg": 500,
"coste_por_km": 0.35,
"hora_inicio": "08:30",
"hora_fin": "17:30",
"deposito_lat": 38.2743,
"deposito_lon": -0.6865,
"zona_preferente": "Alicante"
},
{
"id_vehiculo": "VAN-03",
"nombre": "Furgoneta de Apoyo (Cercanías Elche)",
"capacidad_kg": 250,
"coste_por_km": 0.22,
"hora_inicio": "09:00",
"hora_fin": "15:00",
"deposito_lat": 38.2743,
"deposito_lon": -0.6865,
"zona_preferente": "Elche"
}
]
193 changes: 193 additions & 0 deletions docs/BACKEND_INTEGRATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
# Guía de integración del backend OpenRoute

Este documento describe cómo consumir el **motor logístico de OpenRoute**
(`src/optimizer.py`, `src/data_processor.py`, `src/metrics.py`,
`src/ai_assistant.py`) desde otra aplicación: panel Streamlit del gestor de
flota, microservicio FastAPI para el frontend conversacional, o cualquier
otro componente Python.

> Diseño y autoría del motor: **Samuel Parra**. Guía dirigida en su origen
> a **Giulian Peterlecean** (Streamlit) y posteriormente extendida para la
> integración con el frontend Next.js.

---

## 📁 Arquitectura de archivos core

- `data/pedidos_ejemplo.csv` — Dataset estático de 30 entregas simuladas
distribuidas en Elche y Alicante.
- `data/vehiculos_config.json` — Configuración de capacidades de flota y
tipos de vehículos (Diésel/Eléctrico/Apoyo).
- `src/data_processor.py` — Carga y limpieza de datos, y cálculo de
matrices de distancia/tiempo (Haversine ajustada por factor 1.3× para
reflejar el callejero urbano).
- `src/optimizer.py` — Resolvedores duales con patrón Strategy:
- **Heurística Propia**: clustering geográfico + Vecino Más Cercano
Ponderado por prioridad.
- **Google OR-Tools**: solver industrial CVRPTW (Capacitated VRP +
Time Windows). Cae a la heurística si OR-Tools no encuentra
solución factible.
- `src/metrics.py` — Simulación de reparto manual (baseline) con tres
heurísticas reales del conductor humano, y cálculo de métricas
financieras (€) y ecológicas (CO2).
- `src/ai_assistant.py` — Generador de informes y explicaciones en
lenguaje natural mediante **Ollama local** (LLM open source) o motor
de plantillas heurísticas locales como fallback.

---

## 🔌 Integración en 3 pasos

### 1. Cargar datos y generar matriz geográfica

```python
from src.data_processor import DataProcessor

processor = DataProcessor()

# Cargar archivos estándar
orders_df = processor.load_orders("data/pedidos_ejemplo.csv")
vehicles_df = processor.load_vehicles("data/vehiculos_config.json")

# Agregar parada manual ingresada desde una interfaz
orders_df = processor.add_manual_order(
orders_df,
id_pedido="PED-MAN",
cliente="Clínica Elche",
direccion="Avenida Libertad 10",
lat=38.2650,
lon=-0.7020,
prioridad=3,
peso_kg=45.0,
franja_inicio="10:00",
franja_fin="14:00",
)

# Generar matrices a partir del depósito de flota (índice 0)
depot_lat = vehicles_df.loc[0, 'deposito_lat']
depot_lon = vehicles_df.loc[0, 'deposito_lon']
dist_matrix, time_matrix = processor.build_distance_matrix(
depot_lat, depot_lon, orders_df
)
```

### 2. Ejecutar la optimización y el plan manual (baseline)

```python
from src.optimizer import RouteOptimizerFactory
from src.metrics import MetricsEngine

# Simular plan manual (baseline de comparación)
metrics = MetricsEngine()
manual_res = metrics.simulate_manual_baseline(
orders_df, vehicles_df, dist_matrix, time_matrix
)

# Resolver con el motor seleccionado: "ortools" o "heuristic"
optimizer = RouteOptimizerFactory.get_optimizer(mode="heuristic")
optimized_res = optimizer.optimize(
orders_df, vehicles_df, dist_matrix, time_matrix
)

# Obtener el cuadro de ahorros comparativos
savings = metrics.compare_plans(manual_res, optimized_res)
```

### 3. Generar el informe explicativo en lenguaje natural

El asistente IA usa **Ollama local** (`llama3.1:8b`) por defecto. Si Ollama
no está disponible, cae automáticamente al motor de plantillas
heurísticas locales sin necesidad de intervención del cliente.

```python
from src.ai_assistant import AIAssistant

# Configuración por defecto: localhost:11434 + llama3.1:8b
ai = AIAssistant()

# Personalización opcional vía argumentos o variables de entorno
# (OLLAMA_BASE_URL, OLLAMA_MODEL):
# ai = AIAssistant(base_url="http://otra-maquina:11434", model="qwen2.5:7b")

report_markdown = ai.generate_explanation(optimized_res, savings)
```

**Requisito**: tener Ollama instalado y el modelo descargado.

```bash
ollama pull llama3.1:8b
ollama serve # arranca el daemon (en Windows ya arranca como servicio)
```

---

## 🧪 Pruebas y validación

```bash
# Suite de pruebas unitarias matemáticas
python src/test_optimizer.py

# Test end-to-end con reporte comparativo en pantalla
python src/test_run.py
```

---

## 📦 Esquema de salida unificado

Ambos resolvedores (`heuristic` y `ortools`) devuelven el mismo formato
JSON para que el consumidor no necesite saber qué motor lo generó:

```python
{
'tipo_planificacion': str, # "Heurística Propia" | "Google OR-Tools"
'vehiculos_activos': int,
'distancia_total_km': float,
'tiempo_total_horas': float,
'coste_total_euros': float,
'co2_total_kg': float,
'pedidos_retrasados': int,
'incidentes_sobrecarga': int,
'rutas': [
{
'id_vehiculo': str,
'nombre_vehiculo': str,
'distancia_km': float,
'coste_euros': float,
'co2_emissions_kg': float,
'carga_total_kg': float,
'detalle_paradas': [
{
'id_pedido': str,
'cliente': str,
'prioridad': int,
'peso_kg': float,
'hora_llegada': str, # "HH:MM"
'ventana': str, # "HH:MM-HH:MM"
'retrasado': bool,
},
# ...
],
},
# ...
],
}
```

Cumple el contrato verificado por `test_heuristic_optimizer_stability` en
`src/test_optimizer.py`.

---

## 🔮 Próximos pasos de integración (roadmap)

- **Microservicio FastAPI**: envolver `RouteOptimizerFactory` y `AIAssistant`
en una API HTTP que el frontend Next.js pueda consumir como tool del
chatbot (`optimize_with_ortools`).
- **Endpoint streaming**: para optimizaciones largas, emitir progreso en
tiempo real al frontend.
- **Persistencia de resultados**: guardar `optimized_res` en la base del
frontend para que aparezcan en la pantalla de rutas sin tener que
re-ejecutar.

Ver [`ROADMAP.md`](ROADMAP.md) para más detalle.
15 changes: 14 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,17 @@
streamlit>=1.35.0
# OpenRoute - backend de optimización
# Python 3.9+

# Datos
numpy>=1.26
pandas>=2.2.0

# Optimización VRP
ortools>=9.10.4067

# UI Streamlit (gestor de flota)
streamlit>=1.35.0
folium>=0.17.0
streamlit-folium>=0.20.0

# Llamada a Ollama (LLM local, open source)
requests>=2.31.0
26 changes: 26 additions & 0 deletions skills/1_data_cleaning/clean_orders.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import os
import sys

# Agregar carpeta src al PATH para importar
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../src")))

from data_processor import DataProcessor

def run_cleaning():
workspace_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))
input_path = os.path.join(workspace_dir, "data/pedidos_ejemplo.csv")

print(f"[*] Iniciando limpieza académica de datos desde: {input_path}")

processor = DataProcessor()
try:
cleaned_df = processor.load_orders(input_path)
print("\n[✓] Limpieza completada con éxito.")
print(f" Total registros válidos: {len(cleaned_df)}")
print("\nPrimeros 5 registros procesados:")
print(cleaned_df[['id_pedido', 'cliente', 'lat', 'lon', 'peso_kg', 'minutos_inicio', 'minutos_fin']].head())
except Exception as e:
print(f"[X] Error durante la limpieza de datos: {e}")

if __name__ == "__main__":
run_cleaning()
75 changes: 75 additions & 0 deletions skills/2_eda/eda_report.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import os
import sys
import pandas as pd
import numpy as np

# Agregar carpeta src al PATH para importar
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "../../src")))

from data_processor import DataProcessor

def run_eda():
workspace_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))
input_path = os.path.join(workspace_dir, "data/pedidos_ejemplo.csv")

print(f"[*] Iniciando Análisis Exploratorio de Datos (EDA) sobre: {input_path}\n")

processor = DataProcessor()
try:
df = processor.load_orders(input_path)

# 1. Estadísticas Descriptivas Básicas de Peso
total_orders = len(df)
total_weight = df['peso_kg'].sum()
avg_weight = df['peso_kg'].mean()
max_weight = df['peso_kg'].max()
min_weight = df['peso_kg'].min()

print("=" * 60)
print("ESTADÍSTICAS GENERALES DE CARGA")
print("=" * 60)
print(f" Total de Pedidos: {total_orders} entregas")
print(f" Peso Total Solicitado: {total_weight:.2f} kg")
print(f" Peso Promedio por Pedido: {avg_weight:.2f} kg")
print(f" Peso Máximo en un Pedido: {max_weight:.2f} kg")
print(f" Peso Mínimo en un Pedido: {min_weight:.2f} kg")
print("-" * 60)

# 2. Distribución de Prioridades
priority_counts = df['prioridad'].value_counts().sort_index()
priority_mapping = {1: "Baja (1)", 2: "Media (2)", 3: "Alta (3)"}

print("\nDISTRIBUCIÓN DE PRIORIDADES")
print("=" * 60)
for p, count in priority_counts.items():
pct = (count / total_orders) * 100
bar = "█" * int(pct // 5)
print(f" Nivel {priority_mapping.get(p, p)}: {count:2d} ({pct:5.1f}%) {bar}")
print("-" * 60)

# 3. Análisis Geográfico: Identificar los Centros de Gravedad (Elche vs Alicante)
# Clasificamos geográficamente por longitud (Elche está al oeste de -0.6, Alicante al este)
elche_mask = df['lon'] < -0.58
alicante_mask = ~elche_mask

elche_df = df[elche_mask]
alicante_df = df[alicante_mask]

print("\nANÁLISIS DE CLUSTERING GEOGRÁFICO NATURAL")
print("=" * 60)
print(f" Pedidos en Núcleo Elche: {len(elche_df):2d} entregas")
if len(elche_df) > 0:
print(f" Centroide (Lat, Lon): ({elche_df['lat'].mean():.4f}, {elche_df['lon'].mean():.4f})")
print(f" Peso en este núcleo: {elche_df['peso_kg'].sum():.2f} kg")

print(f"\n Pedidos en Núcleo Alicante: {len(alicante_df):2d} entregas")
if len(alicante_df) > 0:
print(f" Centroide (Lat, Lon): ({alicante_df['lat'].mean():.4f}, {alicante_df['lon'].mean():.4f})")
print(f" Peso en este núcleo: {alicante_df['peso_kg'].sum():.2f} kg")
print("=" * 60)

except Exception as e:
print(f"[X] Error durante el análisis EDA: {e}")

if __name__ == "__main__":
run_eda()
Loading