Saltar al contenido

F1 Sustainable Logistics — 2025 Calendar Optimization

$9.17M saved (-20.9%) · 17,686 t CO2e avoided · certified exact optimum

Python 3.13 OR-Tools CP-SAT Genetic Algorithm pandas NumPy Plotly geopy Streamlit pytest

# The Problem

Formula 1 moves hundreds of tons of cargo between 24 Grands Prix across 4 continents every season. The order of the calendar determines how many kilometres that cargo travels, how much it costs to move and how much CO2e it emits. This project reorders the 2025 calendar to minimize cost and emissions while respecting real sporting, commercial and climate constraints.

Formally it is a TSP with time windows: an NP-hard problem where finding the shortest route is not enough, because each venue only admits certain dates and some rules cannot be broken. With 24 circuits the number of possible orderings is astronomical, yet the problem still falls within what an exact solver can handle.

The challenge: minimize cost and carbon footprint without violating a single constraint — and be able to prove it, not just claim it.

The approach: solve it with two independent methods and compare both against the official calendar using exactly the same model and the same audit.

# Results

Three calendars evaluated with the same logistics model and the same constraint verifier. The official 2025 calendar is the baseline:

💰
Logistics savings
$9.17M
-20.9% from $43.86M
📉
Distance
-28,216 km
-23.2% · from 121,376 to 93,161 km
🌿
CO2e avoided
17,686 t
-22.5% · from 78,693 to 61,006 t
🎯
Genetic gap
0.00%
Reached the certified optimum
Calendar Method Distance Cost CO2e Violations
Official 2025 baseline 121,376 km $43.86M 78,693 t 0
OR-Tools CP-SAT (exact optimum) 93,161 km $34.69M 61,006 t 0
Genetic Metaheuristic + 2-opt 93,161 km $34.69M 61,006 t 0

The savings do not come from cutting races but from reordering them: the optimum eliminates the 8 most expensive transfers of the official calendar and replaces them with short regional legs. 16 of the 24 Grands Prix change round. No figure is hand-written: they are all regenerated by running the pipeline.

# Why two algorithms

Solving the problem twice, with methods of a different nature, is the methodological core of the project — not redundancy:

OR-Tools CP-SAT Genetic algorithm
Type Exact solver (constraint programming) Evolutionary metaheuristic
Guarantee Certified optimum Good solution, no theoretical guarantee
Time < 1 second ~ seconds
Scales to Problems expressible as linear/logical constraints Any cost function, even non-linear or black-box
  • CP-SAT gives the business answer: the certified exact optimum. With 24 circuits the problem is still tractable for an exact solver, and giving up that guarantee would be unjustifiable.
  • The genetic algorithm validates its own implementation against that optimum: it reached exactly the same solution, with a 0.00% gap. Without a known optimum you never know whether a metaheuristic delivers a good result or merely a plausible one. Here you do.
  • The genetic algorithm is the approach that survives when the problem grows: if the model incorporated non-linear objectives, uncertainty or many more venues, the exact solver would stop scaling and the metaheuristic — already validated — would be the production tool.

# Constraints & audit

The model's seven constraints are hard: there are no negotiable penalties and no margin for non-compliance. Every run audits all three solutions and reports any violation.

First race: Melbourne

F1 tradition

Last race: Abu Dhabi

F1 tradition

Races on Sunday

Real GP dates

Minimum logistics interval

7 days (<2,500 km) or 14 days on long legs, with waiting weeks allowed

Climate window per circuit

Each GP within its viable date range

No two consecutive races in the same country

Commercial constraint

Season from Mar 1 to Dec 15

2025 season limits

A result is only reported as valid if it complies 100%. This audit is what separates a real saving from an apparent one: it is easy to make a calendar cheaper if you ignore that the cargo has to arrive on time and within the right climate window.

# Multimodal logistics model

Each leg is computed according to its transport mode: air freight (~600 t) on intercontinental jumps, road (~1,300 t in trucks) within Europe, and 5 sets of sea kits (~2,400 t) as a fixed annual overhead.

Mode Cost (official → optimum) CO2e Distance Emission factor
Air $37.73M → $29.04M 73,737 → 56,766 t 114,320 → 88,009 km ~0.60 kgCO2e/t·km
Road $1.81M → $1.32M 2,652 → 1,936 t 7,056 → 5,152 km ~0.105 kgCO2e/t·km
Sea (kits) $4.32M (fixed) 2,304 t 60,000 km ~0.016 kgCO2e/t·km

Emission factors of DEFRA 2024 order and IATA/market 2024 rates, with the source cited parameter by parameter in the repository. Scope note: this is an estimation model to compare calendars against each other, not a logistics quotation. The comparison is valid because all three calendars are evaluated with exactly the same model.

# Inside the solver

The heart of the project is 55 lines: the complete CP-SAT model. A Hamiltonian circuit with boolean arcs, dates restricted to the set of valid Sundays for each venue, temporal propagation per arc, and consecutive countries forbidden by construction. The objective minimizes logistics cost in whole dollars.

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)

Code exactly as it is in the repository — verifiable line by line.

  • data/ — circuits with coordinates and climate windows, parameters with cited sources and championship constraints.
  • model.py — multimodal model, date scheduling and constraint auditor.
  • ortools_optimizer.py — exact CP-SAT solver.
  • ga_optimizer.py — genetic algorithm: tournament selection with elitism, OX crossover, swap/inversion mutation and a final 2-opt local search.
  • reports.py — comparative dashboard and 3D globe.
  • tests/ — 19 tests, including specific regressions for the v1 errors.

Want to audit the full repo?

View on GitHub

# Lessons from version 1

The first version of this project reported a 26.5% reduction in distance. That number was false. Its calendar violated 8 of the 24 climate windows without reporting it — the date model did not allow waiting, which made it impossible to reach Abu Dhabi in November — the air emission factor was inflated by roughly 1000×, and constraints were not audited after optimizing.

This version fixes the model, audits everything and accepts a smaller (-20.9% cost) but defensible saving. The tests include specific regressions for each of those errors so they cannot come back. I publish this because a result that cannot be audited is worth nothing, and because finding your own mistakes is part of the job — not a footnote.