Saltar al contenido

F1 Sustainable Logistics — Optimización de Calendario 2025

$9.17M menos (-20.9%) · 17,686 t CO2e evitadas · óptimo exacto certificado

Python 3.13 OR-Tools CP-SAT Algoritmo Genético pandas NumPy Plotly geopy Streamlit pytest

# 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:

💰
Ahorro logístico
$9.17M
-20.9% sobre $43.86M
📉
Distancia
-28,216 km
-23.2% · de 121,376 a 93,161 km
🌿
CO2e evitado
17,686 t
-22.5% · de 78,693 a 61,006 t
🎯
Gap del genético
0.00%
Alcanzó el óptimo certificado
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.

# 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.

ortools_optimizer.py
# 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.