Fleet assignment problem#

Problem description#

Given a set of flights to be flown, an airline company needs to determine the specific route flown by each airplane in the most cost-effective way. Clearly, the airline company should try to use as fewer airplanes as possible, but the same airplane can operate two subsequent flights only if the time interval between the arrival of the first flight and the departure of the next flight is longer than or equal to one hour.

The task of the airline operations team is to determine the minimum number of airplanes needed to operate the given list of flights. This problem is known as the fleet assignment problem or aircraft rotation problem. We are going to consider the simplest version of the problem where all the of the \(M\) available airplanes are assumed to be identical.

# install dependencies and select solver
%pip install -q amplpy numpy pandas matplotlib networkx

SOLVER = "highs"  # highs, scip, cbc, mosek, gurobi, cplex, xpress, knitro

from amplpy import AMPL, ampl_notebook

ampl = ampl_notebook(
    modules=["coin", "highs"],  # modules to install
    license_uuid="default",  # license to use
)  # instantiate AMPL object and register notebook magics

Generate Flight Data#

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from numpy.random import RandomState


# python generator creates departure and arrival times for a single airport
def generate_flights(
    N_flights=30, min_duration=1, max_duration=4, max_departure=24, seed=0
):
    rs = RandomState(seed)
    for flight in range(N_flights):
        end_flight = max_departure + 1
        while end_flight > max_departure:
            start_flight = np.floor(max_departure * rs.rand())
            end_flight = start_flight + 3 * np.ceil(
                (max_duration + 1 - min_duration) * rs.rand()
            )
        yield flight + 1, int(start_flight), int(end_flight)


# generate flight data
FlightData = pd.DataFrame(
    [[flight, departure, arrival] for flight, departure, arrival in generate_flights()]
)
FlightData.columns = ["Flight", "Departure", "Arrival"]
FlightData.set_index("Flight", inplace=True)
FlightData.head()
Departure Arrival
Flight
1 13 22
2 14 23
3 10 19
4 10 22
5 1 4

Visualize Flight Data#

# visualize flight data
def draw_flights(FlightData):
    bar_style = {"alpha": 1.0, "lw": 10, "solid_capstyle": "butt"}
    text_style = {
        "fontsize": 7,
        "color": "white",
        "weight": "bold",
        "ha": "center",
        "va": "center",
    }
    fig = plt.figure(figsize=(12, 7))
    ax = fig.add_subplot(111, xlabel="Departure/Arrival Time", title="Flight schedule")
    ax.get_yaxis().set_visible(False)
    for flight, row in FlightData.iterrows():
        departure, arrival = row
        ax.plot([departure, arrival], [flight] * 2, "gray", **bar_style)
        ax.text((departure + arrival) / 2, flight, f"Flight {flight}", **text_style)
    for hr in range(25):
        ax.axvline(hr, alpha=0.1)
    ax.set_xlim(-1, 25)
    ax.set_xticks([4 * i for i in range(7)])
    return ax


ax = draw_flights(FlightData)
../../_images/63820ea87b10a70e8805d537566c6970dc17725627da9f19d8b117a5903e3229.png

AMPL Model#

The fleet assignment problem can be formulated and solved as an MILP. The idea of the MILP formulation is to construct feasible paths in a directed graph where the flights are nodes with indices \(\mathcal{F} = \left\{ 1, \ldots, F \right\}\). The set of arcs \(\mathcal{A} \subseteq \mathcal{F} \times \mathcal{F}\) that can be used by each aircraft is then:

\[ \mathcal{A} = \{ (f_1, f_2) \ : \ f_1 \text{ arrives at least 1h before the departure of } f_2 \}. \]

The following cell finds the set of arcs that can be used. These arcs are displayed in a graph of the flight data. Arcs corresponding to the minimum time between arrival and departure are highlighted in red.

min_time = 1


def feasible_flight_pairs(FlightData, min_time=1):
    # return a dictionary of turnaound times index by flight pairs
    turnaround = (
        lambda pair: FlightData.loc[pair[1], "Departure"]
        - FlightData.loc[pair[0], "Arrival"]
    )
    pairs = filter(
        lambda pair: turnaround(pair) >= min_time,
        [(i, j) for i in FlightData.index for j in FlightData.index if i != j],
    )
    return {pair: turnaround(pair) for pair in pairs}


flight_pairs = feasible_flight_pairs(FlightData).keys()

The following cell presents a visualization of the graph of feasible paths in which an aircraft can be reassigned from one flight to subsequent flight. Each node on the graph corresponds to a flight. An edge from flight \(f_1\) to flight \(f_2\) is included only if there is at least min_time hours available to turn around the aircraft. Edges that allow exactly min_time hours between flights are colored red because these will be the most affected by unexpected flight delays.

import networkx as nx

dg = nx.DiGraph()

for flight in FlightData.index:
    dg.add_node(flight)

for pair, dur in feasible_flight_pairs(FlightData).items():
    dg.add_edge(pair[0], pair[1], color="r" if dur <= min_time else "g")

size = int(1.5 * np.sqrt(len(FlightData)))
fig = plt.figure(figsize=(size, size))
pos = nx.circular_layout(dg)
nx.draw_networkx_nodes(dg, pos=pos, node_size=500, alpha=0.8)
nx.draw_networkx_labels(dg, pos=pos, font_color="w")
nx.draw_networkx_edges(
    dg, pos=pos, width=0.5, edge_color=[dg.edges[u, v]["color"] for u, v in dg.edges]
);
../../_images/45793a66727e11e9f309968abee169290deabf9c5e412ed754f7f918eae30fa6.png

Naive model formulation#

An idea of an MILP formulation of the problem is to construct a feasible path for each aircraft in a graph where the flights are nodes with indices \(1, \ldots, F\), and there is one source node \(0\) and a sink node \(F + 1\). The set of arcs \(\mathcal{A}'\) that can be used by each aircraft is then:

\[ \mathcal{A'} = \bigcup_{f_1,f_2 \in \mathcal{F}} \{ (f_1, f_2) ~:~ f_1 \text{ arrives at least 1h before departure of } f_2 \} \cup \bigcup_{f \in \mathcal{F}} \{ (0, f), \ (f, F+1)\} \]

where \(0\) and \(F + 1\) play the role of dummy flights before the first/after the last flight of a given aircraft’s assignment.

Denote the set of aircraft indices as \(\mathcal{M} = \{ 1, \ldots, M\}\). The decision variables then are:

  • \(x_{f_1, f_2, i} \in \{0, 1\}\), \(a \in \mathcal{A}'\), \(i \in \mathcal{M}\) indicating whether the connection \((f_1, f_2) = a\) is operated by aircraft \(i\)

  • \(y_i \in \{0, 1\}\), for \(i \in \mathcal{M}\) indicating whether aircraft \(i\) is used or not

The corresponding MILP is then:

\[\begin{split} \begin{align} \min \quad & \sum\limits_{i \in \mathcal{M}} y_i \label{eq:71a}\tag{a}\\ \text{s.t.} \quad & \sum\limits_{f_1 \in \mathcal{F} \cup \{ 0, F+1\} ~:~ (f_1, f) \in \mathcal{A}'} x_{f_1, f, i} = \sum\limits_{f_2 \in \mathcal{F} \cup \{ 0, F+1\} ~:~ (f, f_2) \in \mathcal{A}'} x_{f, f_2, i} & \forall \, f \in \mathcal{F}, \ \forall \, i \in \mathcal{M} \label{eq:71b}\tag{b}\\ & \sum\limits_{i \in \mathcal{M}} \sum\limits_{f' \in \mathcal{F} \cup \{ 0, F+1\} ~:~ (f', f) \in \mathcal{A}'} x_{f', f, i} = 1 & \forall \, f \in \mathcal{F} \label{eq:71c}\tag{c}\\ & \sum\limits_{(0, f) \in \mathcal{A}'} x_{0, f, i} \leq 1 & \forall \, i \in \mathcal{M} \label{eq:71d}\tag{d}\\ & x_{f, 0, i} \leq y_i & \forall \, f \in \mathcal{F}, \ \forall \, i \in \mathcal{M} \label{eq:71e}\tag{e}\\ & x_{a, i} \in \{0,1\} & \forall \, a \in \mathcal{A}', i \in \mathcal{M}\\ & y_i \in \{0,1\} & \forall \, i \in \mathcal{M} \end{align} \end{split}\]

where

  • the objective function \eqref{eq:71a} aims to minimize the number of airplanes used;

  • constraint \eqref{eq:71b} enforces that for a given real flight the number of used incoming arcs with airplane \(i\) must be equal to the number of outgoing arcs with this airplane;

  • constraint \eqref{eq:71c} ensures that each flight is operated by exactly one aircraft;

  • constraint \eqref{eq:71d} enforces that each airplane serves at most one sequence of flights;

  • constraint \eqref{eq:71e} ensures that if at least one arc \((f, 0)\) is used using a given airplane, then this airplane is used

This formulation is very explicit and each decision there clearly corresponds to a real-life decision. However, it has a drawback – it is blind to the aircraft being all identical. Consider a situation where aircraft \(1\) is assigned to operate flights \((1, 2, 3)\) and aircraft \(2\) is assigned to operate flights \((4, 5)\). Then, one can simply swap the flight assignment of the two aircraft between them and arrive at another solution is essentially the same if the aircraft are all identical. In terms of mathematical optimization, this means that the problem has a lot of symmetry which produces a huge solution space that the solving algorithm has to explore.

A normal practice is to eliminate symmetry from the problem, for example, by adding so-called symmetry-breaking constraints that do not change the problem but allow only one out of many equivalent solutions. Here, one such constraint would be

\[ \begin{align*} y_i \leq y_{i - 1} & \qquad \forall \, i \in \mathcal{M}: \ i > 0, \end{align*} \]

which would ensure, at least, that we utilize airplanes in the order they appear on the list, i.e., aircraft \(2\) cannot be used if aircraft \(1\) was not used. However, an even better way of removing symmetry from the problem is to remove the airplanes from the model altogether.

Symmetry-free and totally-unimodular model formulation#

In the next model we only create flight combinations to be operated by a single aircraft and assign the actual aircraft in a post-processing step. First, for each node \(f\in\mathcal{F}\) in the DiGraph we define a set of input nodes \(\mathcal{I}_f = \{f_1: (f_1, f)\in\mathcal{A}\}\) and a set of output nodes \(\mathcal{O}_f = \{f_1: (f, f_1)\in\mathcal{A} \}\). For this application, we use the Python set object to scan the feasible flight pairs and find the inputs and outputs nodes for each flight node.

in_nodes = {flight: set() for flight in FlightData.index}
out_nodes = {flight: set() for flight in FlightData.index}

for flight1, flight2 in flight_pairs:
    in_nodes[flight2].add(flight1)
    out_nodes[flight1].add(flight2)

The decision variables are:

  • \(x_{f_1, f_2}\in\{0,1\}\) for all \((f_1, f_2) \in\mathcal{A}\) where \(x_{f_1, f_2} = 1\) indicates that an aircraft used for arriving flight \(f_1\) will be used for departing flight \(f_2\).

  • \(p_f\in\{0,1\}\) for all \(f\in\mathcal{F}\) where \(p_f = 1\) indicates a previously unassigned aircraft will be used for departing flight \(f\).

  • \(q_f\in\{0,1\}\) for all \(f\in\mathcal{F}\) where \(q_f = 1\) indicates an aircraft will be unassigned after arrival of flight \(f\).

The binary variables \(p_f\) and \(q_f\) correspond to arcs that link nodes to the sources and sinks of aircraft needed to complete all of the flights \(f\in\mathcal{F}\). The objective is to minimize the number of required aircraft subject to the constraints that exactly one aircraft is assigned to and released from each node.

\[\begin{split}\begin{align} \min \quad & \sum_{f\in\mathcal{F}} p_f \\ \text{s.t.}\quad & p_f + \sum_{f_1\in\mathcal{I}_f} x_{f_1, f} = 1 & \forall f\in\mathcal{F} \\ & q_f + \sum_{f_1\in\mathcal{O}_f} x_{f, f_1} = 1 & \forall f\in\mathcal{F} \\ & x_{f_1, f_2}, p_f, q_f \in \{ 0, 1\} & \forall f, f_1, f_2 \in \mathcal{F} \end{align}\end{split}\]

This model will give solutions where decisions \(x_{f_1, f_2}\) will yield paths corresponding to sequences of flights that can be operated with a single aircraft. To such paths, we can assign aircraft in an arbitrary fashion after the optimization is finished.

As it turns out, for this model formulation it is also easy to verify using the tools of Chapter 4 that the corresponding matrix formulation of the model is totally unimodular. That means, it is possible to eliminate the integrality restriction on the decision variables (keeping the \([0, 1]\) interval bounds) to arrive at an LP-formulated model that yields integral solutions without explicitly requiring it.

For that reason, we are going to use this model formulation in our further analysis.

%%writefile fleet_assign.mod

set FLIGHTS;
set PAIRS in {FLIGHTS, FLIGHTS};

var x{PAIRS} binary;
var p{FLIGHTS} binary;
var q{FLIGHTS} binary;

s.t. Sources{f in FLIGHTS}:
    p[f] + sum {(f1, f) in PAIRS} x[f1, f] == 1;
    
s.t. Sinks{f in FLIGHTS}:
    q[f] + sum {(f, f2) in PAIRS} x[f, f2] == 1;
    
minimize Airplanes:
    sum {f in FLIGHTS} p[f];
Writing fleet_assign.mod
ampl = AMPL()
ampl.read("fleet_assign.mod")

ampl.set["FLIGHTS"] = list(FlightData.index)
ampl.set["PAIRS"] = list(flight_pairs)

ampl.option["solver"] = SOLVER
ampl.solve()

print(f"Minimum airplanes required = {ampl.get_value('Airplanes')}")

x = ampl.get_variable("x").to_dict()
p = ampl.get_variable("p").to_dict()
q = ampl.get_variable("q").to_dict()
HiGHS 1.5.1: HiGHS 1.5.1: optimal solution; objective 14
89 simplex iterations
1 branching nodes
Minimum airplanes required = 14.0

We visualize the solution by redrawing the graph of possible path and highlighting the edges that have been selected for aircraft reassignment.

import networkx as nx

dg_soln = nx.DiGraph()

for flight in FlightData.index:
    dg_soln.add_node(flight)

for pair, dur in feasible_flight_pairs(FlightData).items():
    if x[pair[0], pair[1]] > 0.5:
        dg_soln.add_edge(pair[0], pair[1], color="r" if dur <= min_time else "g")

size = int(1.5 * np.sqrt(len(FlightData)))
fig = plt.figure(figsize=(size, size))

nx.draw_networkx_nodes(dg_soln, pos=pos, node_size=500, alpha=0.8)
nx.draw_networkx_labels(dg_soln, pos=pos, font_color="w")
nx.draw_networkx_edges(
    dg_soln,
    pos=pos,
    width=3,
    edge_color=[dg_soln.edges[u, v]["color"] for u, v in dg_soln.edges],
)
nx.draw_networkx_edges(
    dg, pos=pos, width=0.5, edge_color=[dg.edges[u, v]["color"] for u, v in dg.edges]
)
nx.draw_networkx_edges(
    dg_soln,
    pos=pos,
    width=3,
    edge_color=[dg_soln.edges[u, v]["color"] for u, v in dg_soln.edges],
);
../../_images/2e5ca4c3be7982be310cdd41c054fa58e2da2e97e5785b33839978604f3c6833.png

We visualize the solution by drawing arcs where \(x_{f_1, f_2} = 1\) and where \(p_f = 1\) and \(q_f = 1\). These arcs draw feasible paths through the graph corresponding to the assignment of one aircraft to service one or more flights. The arcs are green if the time between the two flights is strictly larger the minimum layover time (1h) and red if it is equal.

ax = draw_flights(FlightData)
for flight1, flight2 in flight_pairs:
    if x[flight1, flight2] > 0.5:
        arr = FlightData.loc[flight1, "Arrival"]
        dep = FlightData.loc[flight2, "Departure"]
        c = "r" if dep - arr <= min_time else "g"
        ax.plot([arr, dep], [flight1, flight2], color=c, lw=4, alpha=0.4)

for flight in FlightData.index:
    if p[flight] > 0.5:
        dep = FlightData.loc[flight, "Departure"]
        ax.plot([-1, dep], [flight] * 2, "g", lw=4, alpha=0.4)
    if q[flight] > 0.5:
        arr = FlightData.loc[flight, "Arrival"]
        ax.plot([arr, 25], [flight] * 2, "g", lw=4, alpha=0.4)
../../_images/0bea206a0b3c5d171e8c72f9e5a8289a0bdc09c268f655bedcca1e0495efa2d6.png

Create Flight and Aircraft Schedules#

  • Flight Schedule: A table index by flight numbers showing the assigned aircraft, departure, and arrival times.

  • Aircraft Schedule: A table indexed by aircraft and flights showing departure and arrival times.

FlightSchedule = FlightData.copy()

# create a generator of aircraft ids
aircraft = (f"A{n+1:02d}" for n in range(len(FlightData.index)))

# traverse each path through the graph
for f in FlightData.index:
    if p[f] > 0.5:
        id = next(aircraft)
        FlightSchedule.loc[f, "Aircraft"] = id
        while q[f] < 0.5:
            f = next(filter(lambda _: x[f, _] > 0.5, out_nodes[f]))
            FlightSchedule.loc[f, "Aircraft"] = id

FlightSchedule = FlightSchedule[["Aircraft", "Departure", "Arrival"]]
display(FlightSchedule)
Aircraft Departure Arrival
Flight
1 A02 13 22
2 A13 14 23
3 A10 10 19
4 A05 10 22
5 A01 1 4
6 A02 0 12
7 A14 11 23
8 A03 2 11
9 A04 3 15
10 A03 12 18
11 A01 6 18
12 A08 10 19
13 A05 0 9
14 A06 14 23
15 A07 8 14
16 A04 16 19
17 A08 5 8
18 A09 7 13
19 A12 13 19
20 A10 5 8
21 A11 15 21
22 A11 11 14
23 A12 3 6
24 A07 15 18
25 A13 4 10
26 A07 19 22
27 A03 20 23
28 A09 17 20
29 A14 6 9
30 A12 7 10
AircraftSchedule = FlightSchedule.copy()
AircraftSchedule["Flight"] = AircraftSchedule.index
AircraftSchedule = AircraftSchedule.sort_values(["Aircraft", "Departure"])
AircraftSchedule.index = pd.MultiIndex.from_frame(
    AircraftSchedule[["Aircraft", "Flight"]]
)
AircraftSchedule = AircraftSchedule[["Departure", "Arrival"]]
display(AircraftSchedule)
Departure Arrival
Aircraft Flight
A01 5 1 4
11 6 18
A02 6 0 12
1 13 22
A03 8 2 11
10 12 18
27 20 23
A04 9 3 15
16 16 19
A05 13 0 9
4 10 22
A06 14 14 23
A07 15 8 14
24 15 18
26 19 22
A08 17 5 8
12 10 19
A09 18 7 13
28 17 20
A10 20 5 8
3 10 19
A11 22 11 14
21 15 21
A12 23 3 6
30 7 10
19 13 19
A13 25 4 10
2 14 23
A14 29 6 9
7 11 23

Reducing riskiness of the schedule#

We will now keep the maximum number of flights at the optimal level, but try to minimize their riskiness. To do so, we define a slightly different MILP that takes the minimum number of planes nplanes in input and has the total number of risky pairs as objective function.

%%writefile fleet_assign_minrisk.mod

set FLIGHTS;
set PAIRS in {FLIGHTS, FLIGHTS};

param N_planes;
param weight{PAIRS};

var x{PAIRS} binary;
var p{FLIGHTS} binary;
var q{FLIGHTS} binary;

s.t. Sources{f in FLIGHTS}:
    p[f] + sum {(f1, f) in PAIRS} x[f1, f] == 1;
    
s.t. Sinks{f in FLIGHTS}:
    q[f] + sum {(f, f2) in PAIRS} x[f, f2] == 1;
    
s.t. MinAirplanes:
    sum {f in FLIGHTS} p[f] <= N_planes;
    
minimize Risk:
    sum {(f1, f2) in PAIRS} weight[f1, f2] * x[f1, f2];
Overwriting fleet_assign_minrisk.mod
def schedule(FlightData, N_planes, min_time=1):
    pairs = feasible_flight_pairs(FlightData, min_time)

    weights = {pair: int(pairs[pair] <= min_time) for pair in pairs}

    ampl = AMPL()
    ampl.read("fleet_assign_minrisk.mod")

    ampl.set["FLIGHTS"] = list(FlightData.index)
    ampl.set["PAIRS"] = list(pairs.keys())
    ampl.param["N_planes"] = N_planes
    ampl.param["weight"] = weights

    ampl.option["solver"] = SOLVER
    ampl.solve()

    x = ampl.get_variable("x").to_dict()
    p = ampl.get_variable("p").to_dict()
    q = ampl.get_variable("q").to_dict()

    ax = draw_flights(FlightData)
    for flight1, flight2 in pairs.keys():
        if x[flight1, flight2] > 0.5:
            arr = FlightData.loc[flight1, "Arrival"]
            dep = FlightData.loc[flight2, "Departure"]
            c = "r" if dep - arr <= min_time else "g"
            ax.plot([arr, dep], [flight1, flight2], color=c, lw=4, alpha=0.4)

    for flight in FlightData.index:
        if p[flight] > 0.5:
            dep = FlightData.loc[flight, "Departure"]
            ax.plot([-1, dep], [flight] * 2, "g", lw=4, alpha=0.4)
        if q[flight] > 0.5:
            arr = FlightData.loc[flight, "Arrival"]
            ax.plot([arr, 25], [flight] * 2, "g", lw=4, alpha=0.4)

    return ampl
m = schedule(FlightData, N_planes=14, min_time=1)
HiGHS 1.5.1: HiGHS 1.5.1: optimal solution; objective 2
48 simplex iterations
1 branching nodes
../../_images/5ac5ba1871dad969a463f4eba5436feaa8a7aa0d4f4a4bb64ad3402667298948.png
m = schedule(FlightData, N_planes=15, min_time=1)
HiGHS 1.5.1: HiGHS 1.5.1: optimal solution; objective 0
45 simplex iterations
1 branching nodes
../../_images/9a1e799e24c5fd52148d968f7ab33627f676b2649348aa4b942860f9320a489f.png
m = schedule(FlightData, N_planes=15, min_time=2)
HiGHS 1.5.1: HiGHS 1.5.1: optimal solution; objective 3
52 simplex iterations
1 branching nodes
../../_images/eb937f80b4b8674397f5b3fc822239b687da353b8ca3c9351dcb2e5272df5991.png