# El Problema
La Fórmula 1 mueve cientos de toneladas de carga entre 24 Grandes Premios en 4 continentes cada temporada. El orden del calendario define cuántos kilómetros recorre esa carga, cuánto cuesta moverla y cuánto CO2e emite. Este proyecto reordena el calendario 2025 para minimizar coste y emisiones respetando restricciones deportivas, comerciales y climáticas reales.
Formalmente es un TSP con ventanas de tiempo: un problema NP-hard donde no basta con encontrar la ruta más corta, porque cada sede solo admite ciertas fechas y hay reglas que no se pueden romper. Con 24 circuitos, el número de órdenes posibles es astronómico, pero el problema todavía cae dentro de lo que un solver exacto puede resolver.
El reto: minimizar coste y huella de carbono sin violar ni una sola restricción — y poder demostrarlo, no afirmarlo.
El enfoque: resolverlo con dos métodos independientes y comparar ambos contra el calendario oficial usando exactamente el mismo modelo y la misma auditoría.
# Resultados
Tres calendarios evaluados con el mismo modelo logístico y el mismo verificador de restricciones. El calendario oficial de 2025 es la línea base:
| Calendario | Método | Distancia | Costo | CO2e | Violaciones |
|---|---|---|---|---|---|
| Oficial 2025 | línea base | 121,376 km | $43.86M | 78,693 t | 0 |
| OR-Tools | CP-SAT (óptimo exacto) | 93,161 km | $34.69M | 61,006 t | 0 |
| Genético | Metaheurística + 2-opt | 93,161 km | $34.69M | 61,006 t | 0 |
El ahorro no viene de recortar carreras, sino de reordenarlas: el óptimo elimina los 8 traslados más caros del calendario oficial y los sustituye por tramos regionales cortos. 16 de los 24 Grandes Premios cambian de ronda. Ninguna cifra está escrita a mano: todas se regeneran ejecutando el pipeline.
# Por qué dos algoritmos
Resolver el problema dos veces, con métodos de naturaleza distinta, es el núcleo metodológico del proyecto — no una redundancia:
| OR-Tools CP-SAT | Algoritmo genético | |
|---|---|---|
| Tipo | Solver exacto (constraint programming) | Metaheurística evolutiva |
| Garantía | Óptimo certificado | Buena solución, sin garantía teórica |
| Tiempo | < 1 segundo | ~ segundos |
| Escala a | Problemas formulables como restricciones lineales/lógicas | Cualquier función de costo, incluso no lineal o de caja negra |
- CP-SAT da la respuesta de negocio: el óptimo exacto certificado. Con 24 circuitos el problema todavía es tratable para un solver exacto, y renunciar a esa garantía sería injustificable.
- El genético valida su propia implementación contra ese óptimo: alcanzó exactamente la misma solución, con un gap de 0.00%. Sin un óptimo conocido, nunca se sabe si una metaheurística entrega un buen resultado o solo uno plausible. Aquí sí se sabe.
- El genético es el enfoque que sobrevive cuando el problema crece: si el modelo incorporara objetivos no lineales, incertidumbre o muchas más sedes, el solver exacto dejaría de escalar y la metaheurística —ya validada— sería la herramienta de producción.
# Restricciones y auditoría
Las siete restricciones del modelo son duras: no hay penalizaciones negociables ni margen de incumplimiento. Cada ejecución audita las tres soluciones y reporta cualquier violación.
Primera carrera: Melbourne
Tradición F1
Última carrera: Abu Dhabi
Tradición F1
Carreras en domingo
Fechas reales de GP
Intervalo logístico mínimo
7 días (<2,500 km) o 14 días en tramos largos, con semanas de espera permitidas
Ventanas climáticas por circuito
Cada GP dentro de su rango de fechas viable
Sin dos carreras seguidas del mismo país
Restricción comercial
Temporada del 1-mar al 15-dic
Límites de temporada 2025
Un resultado solo se reporta como válido si cumple el 100%. Esta auditoría es lo que separa un ahorro real de un ahorro aparente: es fácil abaratar un calendario si se ignora que la carga tiene que llegar a tiempo y en la ventana climática correcta.
# Modelo logístico multimodal
Cada tramo se calcula según su modo de transporte: carga aérea (~600 t) en los saltos intercontinentales, carretera (~1,300 t en camiones) dentro de Europa, y 5 juegos de kits marítimos (~2,400 t) como sobrecoste fijo anual.
| Modo | Costo (oficial → óptimo) | CO2e | Distancia | Factor de emisión |
|---|---|---|---|---|
| Aéreo | $37.73M → $29.04M | 73,737 → 56,766 t | 114,320 → 88,009 km | ~0.60 kgCO2e/t·km |
| Carretera | $1.81M → $1.32M | 2,652 → 1,936 t | 7,056 → 5,152 km | ~0.105 kgCO2e/t·km |
| Marítimo (kits) | $4.32M (fijo) | 2,304 t | 60,000 km | ~0.016 kgCO2e/t·km |
Factores de emisión de orden DEFRA 2024 y tarifas IATA/mercado 2024, con la fuente citada parámetro por parámetro en el repositorio. Nota de alcance: es un modelo de estimación para comparar calendarios entre sí, no una cotización logística. La comparación es válida porque los tres calendarios se evalúan con exactamente el mismo modelo.
# Visualización
El pipeline genera sus propios informes. Ambos se abren en una pestaña nueva y son los archivos que produce el proyecto, sin retoques:
Dashboard ejecutivo
Comparativa de los tres calendarios: tabla maestra con diferencias, desglose por modo de transporte, los traslados más caros y cuáles elimina el óptimo, cambios de posición por GP, detalle tramo a tramo, auditoría de restricciones y convergencia del genético hacia el óptimo.
Abrir dashboard 🌍Globo 3D interactivo
Globo terráqueo en WebGL con los arcos animados de cada calendario, selector Oficial / OR-Tools / Genético con sus KPIs, y tooltips por carrera y por tramo. Rotación y zoom libres.
Abrir globoEl globo 3D carga su librería y sus texturas desde un CDN, así que necesita conexión a internet para dibujarse.
# El solver por dentro
El corazón del proyecto son 55 líneas: el modelo CP-SAT completo. Un circuito hamiltoniano con arcos booleanos, fechas restringidas al conjunto de domingos válidos de cada sede, propagación temporal por arco y países consecutivos prohibidos por construcción. El objetivo minimiza el coste logístico en dólares enteros.
# src/f1logistics/ortools_optimizer.py
class OrToolsOptimizer:
def _sundays_in_window(self, i):
"""Domingos (días de temporada) dentro de la ventana climática del circuito i."""
first = self.model._next_sunday(int(self.dm.win_start[i]))
return list(range(first, int(self.dm.win_end[i]) + 1, 7))
def run(self):
"""Devuelve (route, days, status_name). Lanza RuntimeError si es infactible."""
dm, lm = self.dm, self.model
n = dm.n
m = cp_model.CpModel()
# Fechas: solo domingos dentro de la ventana de cada circuito
day = []
for i in range(n):
sundays = self._sundays_in_window(i)
if not sundays:
raise RuntimeError(f"El circuito {dm.df.iloc[i]['Ciudad']} no tiene "
"ningún domingo dentro de su ventana climática")
day.append(m.NewIntVarFromDomain(cp_model.Domain.FromValues(sundays), f"day_{i}"))
# Arcos del circuito hamiltoniano
arcs, lit = [], {}
for i in range(n):
for j in range(n):
if i == j:
continue
if i == dm.end_idx and j == dm.start_idx:
# Arco virtual de cierre: obligatorio, sin costo ni secuencia temporal
closing = m.NewBoolVar("closing")
m.Add(closing == 1)
arcs.append((i, j, closing))
continue
if j == dm.start_idx or i == dm.end_idx:
continue # nadie más entra al inicio ni sale del final
if dm.countries[i] == dm.countries[j]:
continue # restricción dura: sin países consecutivos
x = m.NewBoolVar(f"x_{i}_{j}")
lit[(i, j)] = x
arcs.append((i, j, x))
m.Add(day[j] >= day[i] + int(lm.leg_gap[i, j])).OnlyEnforceIf(x)
m.AddCircuit(arcs)
# Objetivo: costo logístico entero en USD
m.Minimize(sum(int(round(lm.leg_cost[i, j])) * x for (i, j), x in lit.items()))
solver = cp_model.CpSolver()
solver.parameters.max_time_in_seconds = float(self.time_limit_s)
solver.parameters.num_workers = int(self.workers)
status = solver.Solve(m)
if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
raise RuntimeError(f"CP-SAT no encontró solución factible")
# Reconstruir la ruta siguiendo los arcos activos desde Melbourne
nxt = {i: j for (i, j), x in lit.items() if solver.Value(x)}
route = [dm.start_idx]
while route[-1] != dm.end_idx:
route.append(nxt[route[-1]])
days = [solver.Value(day[i]) for i in route]
return route, days, solver.StatusName(status) # src/f1logistics/ortools_optimizer.py
class OrToolsOptimizer:
def _sundays_in_window(self, i):
"""Domingos (días de temporada) dentro de la ventana climática del circuito i."""
first = self.model._next_sunday(int(self.dm.win_start[i]))
return list(range(first, int(self.dm.win_end[i]) + 1, 7))
def run(self):
"""Devuelve (route, days, status_name). Lanza RuntimeError si es infactible."""
dm, lm = self.dm, self.model
n = dm.n
m = cp_model.CpModel()
# Fechas: solo domingos dentro de la ventana de cada circuito
day = []
for i in range(n):
sundays = self._sundays_in_window(i)
if not sundays:
raise RuntimeError(f"El circuito {dm.df.iloc[i]['Ciudad']} no tiene "
"ningún domingo dentro de su ventana climática")
day.append(m.NewIntVarFromDomain(cp_model.Domain.FromValues(sundays), f"day_{i}"))
# Arcos del circuito hamiltoniano
arcs, lit = [], {}
for i in range(n):
for j in range(n):
if i == j:
continue
if i == dm.end_idx and j == dm.start_idx:
# Arco virtual de cierre: obligatorio, sin costo ni secuencia temporal
closing = m.NewBoolVar("closing")
m.Add(closing == 1)
arcs.append((i, j, closing))
continue
if j == dm.start_idx or i == dm.end_idx:
continue # nadie más entra al inicio ni sale del final
if dm.countries[i] == dm.countries[j]:
continue # restricción dura: sin países consecutivos
x = m.NewBoolVar(f"x_{i}_{j}")
lit[(i, j)] = x
arcs.append((i, j, x))
m.Add(day[j] >= day[i] + int(lm.leg_gap[i, j])).OnlyEnforceIf(x)
m.AddCircuit(arcs)
# Objetivo: costo logístico entero en USD
m.Minimize(sum(int(round(lm.leg_cost[i, j])) * x for (i, j), x in lit.items()))
solver = cp_model.CpSolver()
solver.parameters.max_time_in_seconds = float(self.time_limit_s)
solver.parameters.num_workers = int(self.workers)
status = solver.Solve(m)
if status not in (cp_model.OPTIMAL, cp_model.FEASIBLE):
raise RuntimeError(f"CP-SAT no encontró solución factible")
# Reconstruir la ruta siguiendo los arcos activos desde Melbourne
nxt = {i: j for (i, j), x in lit.items() if solver.Value(x)}
route = [dm.start_idx]
while route[-1] != dm.end_idx:
route.append(nxt[route[-1]])
days = [solver.Value(day[i]) for i in route]
return route, days, solver.StatusName(status) Código tal cual está en el repositorio — contrastable línea por línea.
- data/ — circuitos con coordenadas y ventanas climáticas, parámetros con fuente citada y restricciones del campeonato.
- model.py — modelo multimodal, programación de fechas y auditor de restricciones.
- ortools_optimizer.py — solver exacto CP-SAT.
- ga_optimizer.py — algoritmo genético: selección por torneo con elitismo, cruce OX, mutación swap/inversión y búsqueda local 2-opt.
- reports.py — dashboard comparativo y globo 3D.
- tests/ — 19 tests, con regresiones específicas de los errores de la v1.
¿Quieres auditar el repositorio completo?
Ver en GitHub# Lecciones de la versión 1
La primera versión de este proyecto reportaba una reducción del 26.5% en distancia. Ese número era falso. Su calendario violaba 8 de las 24 ventanas climáticas sin reportarlo — el modelo de fechas no permitía esperas, lo que hacía imposible llegar a Abu Dhabi en noviembre — el factor de emisión aérea estaba inflado unas 1000 veces, y las restricciones no se auditaban después de optimizar.
Esta versión corrige el modelo, audita todo y acepta un ahorro menor (-20.9% de coste) pero defendible. Los tests incluyen regresiones específicas de cada uno de esos errores para que no puedan volver. Publico esto porque un resultado que no se puede auditar no vale nada, y porque encontrar los propios errores es parte del trabajo — no una nota al pie.