Files
fleet-assignment/create_nb.py

287 lines
12 KiB
Python

import nbformat as nbf
nb = nbf.v4.new_notebook()
cells = []
# Cell 1: Markdown Introduction
cells.append(nbf.v4.new_markdown_cell("""# Alocação de Frotas (Fleet Scheduling) - Aeronaves C-97
Este projeto visa realizar a alocação de frota (Fleet Assignment) baseada nos princípios do livro referenciado *Airline Operations and Scheduling*.
Diferentemente do modelo anterior que utilizava aeronaves de carga, este foca exclusivamente na aeronave de passageiros **C-97**.
## Objetivos:
1. Remover cálculos de aeronaves de carga.
2. Analisar as demandas diárias para a aeronave C-97.
3. Identificar os esquadrões detentores dessas aeronaves e a quantidade de aeronaves (matrículas únicas).
4. Calcular os 5 trechos mais relevantes do ano de 2025 utilizando estatística e gráficos.
5. Realizar o Fleet Assignment para os 5 trechos, visando minimizar o custo total da operação (Modelo 1 proposto).
"""))
# Cell 2: Imports
cells.append(nbf.v4.new_code_cell("""# Importação de bibliotecas
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
import pulp
import pymap3d as pm
import math
import networkx as nx
# Configuração de estilo para os gráficos
sns.set_theme(style="whitegrid")
# Constantes globais
CSV_FILEPATH = "SISCO_AEREA_01-01-2025_A_31-12-2025_1911251714Z.csv"
JSON_AEROPORTOS = "airports.json"
CAPACIDADE_C97 = 30 # Capacidade média de passageiros do C-97 (Brasília)
"""))
# Cell 3: Markdown Load Data
cells.append(nbf.v4.new_markdown_cell("""## 1. Leitura e Limpeza dos Dados
Vamos carregar os dados, filtrar apenas para o modelo `C-97` e tratar os campos de localidades e datas.
"""))
# Cell 4: Code Load Data
cells.append(nbf.v4.new_code_cell("""# Carregamento do banco de dados
df = pd.read_csv(CSV_FILEPATH, low_memory=False)
df_aeroportos = pd.read_json(JSON_AEROPORTOS, orient='index')
# Tratamento básico de valores nulos expressos como 'NIL'
df.replace('NIL', 0, inplace=True)
# Filtro para a aeronave C-97 (Passageiros) e remoção de registros de carga
df_c97 = df[df['Modelo'] == 'C-97'].copy()
# Filtrando decolagens e pousos conhecidos
df_c97 = df_c97[df_c97['Localidade Decolagem'] != 0]
df_c97 = df_c97[df_c97['Localidade Pouso'] != 0]
# Criação da coluna Trecho
df_c97['Trecho'] = df_c97['Localidade Decolagem'] + '-' + df_c97['Localidade Pouso']
# Conversão da coluna PAX para numérico
df_c97['PAX'] = pd.to_numeric(df_c97['PAX']).fillna(0)
# Tratamento de datas
df_c97['Dep TimeStamp'] = pd.to_datetime(df_c97['Data de Decolagem'] + ' ' + df_c97['Hora de Decolagem'], format='mixed', dayfirst=True)
df_c97['Data Apenas'] = df_c97['Dep TimeStamp'].dt.date
"""))
# Cell 5: Markdown Squadrons
cells.append(nbf.v4.new_markdown_cell("""## 2. Análise dos Esquadrões e Matrículas (Aeronaves)
Identificaremos quais esquadrões operam o C-97 e qual a disponibilidade de aeronaves (número de matrículas únicas), pois isso definirá as restrições de frota.
"""))
# Cell 6: Code Squadrons
cells.append(nbf.v4.new_code_cell("""# Obtendo esquadrões e matrículas únicas
esquadroes = df_c97['Emissor'].unique()
matriculas_unicas = df_c97['Matrícula'].unique()
total_aeronaves = len(matriculas_unicas)
print(f"Esquadrões que operam o C-97: {', '.join(esquadroes)}")
print(f"Total de matrículas únicas (aeronaves disponíveis): {total_aeronaves}")
# Aeronaves por esquadrão
aeronaves_por_esquadrao = df_c97.groupby('Emissor')['Matrícula'].nunique().to_dict()
print("\\nAeronaves por esquadrão:")
for esq, qtd in aeronaves_por_esquadrao.items():
print(f" - {esq}: {qtd} aeronaves")
# Identificar a base principal de cada esquadrão (aeroporto com mais decolagens)
bases_esquadroes = {}
for esq in aeronaves_por_esquadrao.keys():
base = df_c97[df_c97['Emissor'] == esq]['Localidade Decolagem'].mode()[0]
bases_esquadroes[esq] = base
print("\\nBases inferidas por esquadrão (pela moda de decolagens):")
for esq, base in bases_esquadroes.items():
print(f" - {esq}: {base}")
"""))
# Cell 7: Markdown Stats
cells.append(nbf.v4.new_markdown_cell("""## 3. Análise Estatística das Demandas Diárias
Para definir os trechos mais relevantes do ano, calcularemos a demanda de passageiros (PAX) agregada diariamente. Em seguida, extraímos a **média de passageiros diários** e o **desvio padrão** para cada trecho. A relevância será determinada pela maior média de demanda diária de PAX.
Começaremos focando em 5 trechos (alvo variável).
"""))
# Cell 8: Code Stats
cells.append(nbf.v4.new_code_cell("""# Demanda diária por trecho
demanda_diaria = df_c97.groupby(['Trecho', 'Data Apenas'])['PAX'].sum().reset_index()
# Criar um range de datas para todo o ano de 2025 para contabilizar os dias sem voo
import itertools
datas_2025 = pd.date_range(start='2025-01-01', end='2025-12-31').date
todos_trechos = demanda_diaria['Trecho'].unique()
idx = pd.MultiIndex.from_product([todos_trechos, datas_2025], names=['Trecho', 'Data Apenas'])
demanda_diaria_completa = pd.DataFrame(index=idx).reset_index()
# Mesclar com os dados reais e preencher NaN com 0
demanda_diaria_completa = pd.merge(demanda_diaria_completa, demanda_diaria, on=['Trecho', 'Data Apenas'], how='left').fillna({'PAX': 0})
# Estatísticas (Média e Desvio Padrão) por trecho
estatisticas_trechos = demanda_diaria_completa.groupby('Trecho').agg(
Media_PAX_Diario=('PAX', 'mean'),
Desvio_Padrao_PAX=('PAX', 'std'),
Total_PAX=('PAX', 'sum'),
Dias_Operados=('PAX', lambda x: (x > 0).sum())
).reset_index()
# Média PAX por dias operados (usado para o Fleet Assignment Model)
estatisticas_trechos['Media_PAX_Dias_Operados'] = estatisticas_trechos.apply(
lambda row: row['Total_PAX'] / row['Dias_Operados'] if row['Dias_Operados'] > 0 else 0, axis=1
)
# Nova métrica solicitada: PAX * Dias_Operados
estatisticas_trechos['Score_Relevancia'] = estatisticas_trechos['Total_PAX'] * estatisticas_trechos['Dias_Operados']
# Ordenar pelas rotas com maior métrica (PAX * Dias_Operados)
estatisticas_trechos = estatisticas_trechos.sort_values(by='Score_Relevancia', ascending=False)
# Definir número de trechos alvo
NUM_TRECHOS = 5
top_trechos = estatisticas_trechos.head(NUM_TRECHOS)
display(top_trechos)
# Gráfico
plt.figure(figsize=(10, 6))
sns.barplot(data=top_trechos, x='Trecho', y='Score_Relevancia', hue='Trecho', palette='viridis', legend=False)
plt.title(f'Top {NUM_TRECHOS} Trechos Mais Relevantes (PAX * Dias Operados)')
plt.ylabel('Score de Relevância (PAX * Dias Operados)')
plt.xlabel('Trecho (Origem - Destino)')
plt.xticks(rotation=45)
plt.tight_layout()
plt.show()
"""))
# Cell: Markdown Diagrama de Rede
cells.append(nbf.v4.new_markdown_cell("""## Diagrama de Rede dos Trechos
Para melhor visualização dos trechos mais relevantes, abaixo temos um diagrama de rede conectando as origens aos destinos.
"""))
# Cell: Code Diagrama de Rede
cells.append(nbf.v4.new_code_cell("""# Criação do grafo direcionado
G = nx.DiGraph()
# Adiciona arestas com peso baseado no Score de Relevância
for _, row in top_trechos.iterrows():
origem, destino = row['Trecho'].split('-')
peso = row['Score_Relevancia']
G.add_edge(origem, destino, weight=peso)
# Configuração do layout do grafo
pos = nx.spring_layout(G, seed=42)
plt.figure(figsize=(8, 6))
# Define o tamanho do nó
tamanho_no = 1500
# Desenha os nós
nx.draw_networkx_nodes(G, pos, node_size=tamanho_no, node_color='skyblue', edgecolors='black')
# Desenha as arestas, informando o node_size para que a seta não fique escondida sob o nó
nx.draw_networkx_edges(
G, pos,
edgelist=G.edges(),
arrows=True,
arrowstyle='-|>',
arrowsize=25,
edge_color='gray',
width=2,
node_size=tamanho_no,
connectionstyle='arc3,rad=0.15'
)
# Desenha os rótulos (nomes dos aeroportos)
nx.draw_networkx_labels(G, pos, font_size=12, font_family='sans-serif', font_weight='bold')
plt.title('Diagrama de Rede - Top Trechos Relevantes', fontsize=14)
plt.axis('off')
plt.tight_layout()
plt.show()
"""))
# Cell 9: Markdown FAM
cells.append(nbf.v4.new_markdown_cell("""## 4. Fleet Assignment Model (FAM)
Conforme o livro base *Airline Operations and Scheduling*, o problema de alocação de frotas busca atribuir os tipos de aeronaves às pernas de voo visando minimizar o custo e as perdas de receita (spill). Como temos apenas um tipo (C-97), nosso problema adapta-se a uma **alocação de esquadrões (e suas respectivas bases)** aos voos requeridos para minimizar o custo total de operação, que é proporcional à distância voada.
**Modelo Matemático:**
- **Variáveis de decisão:** $X_{s, r}$ (Quantidade de voos operados pelo esquadrão $s$ no trecho $r$)
- **Função Objetivo:** Minimizar a distância total (Distância de posicionamento da base + Distância do trecho + Retorno para a base).
- **Restrições:**
1. O número de voos alocados para o trecho deve suprir a demanda média (Média PAX / Capacidade do C-97).
2. O total de voos atribuídos a um esquadrão não pode exceder o número de aeronaves disponíveis naquele esquadrão no dia.
"""))
# Cell 10: Code FAM
cells.append(nbf.v4.new_code_cell("""# Preparação dos dados para o modelo
rotas_lista = top_trechos['Trecho'].tolist()
# Dicionário de distâncias
def calc_distancia(icao1, icao2):
try:
lat1, lon1, alt1 = df_aeroportos.loc[icao1, ['lat', 'lon', 'elevation']]
lat2, lon2, alt2 = df_aeroportos.loc[icao2, ['lat', 'lon', 'elevation']]
# Retorna a distância oblíqua em km
return pm.geodetic2aer(lat1, lon1, alt1, lat2, lon2, alt2)[2] / 1000.0
except KeyError:
return 9999.0 # Penalidade caso aeroporto não exista no json
distancias_voo = {}
for r in rotas_lista:
origem, destino = r.split('-')
dist_trecho = calc_distancia(origem, destino)
for esq, base in bases_esquadroes.items():
dist_ida = calc_distancia(base, origem) if base != origem else 0
dist_volta = calc_distancia(destino, base) if base != destino else 0
distancias_voo[(esq, r)] = dist_ida + dist_trecho + dist_volta
# Demanda de voos por rota (Média diária)
voos_requeridos = {}
for _, row in top_trechos.iterrows():
# Arredondamento para cima da (demanda / capacidade)
voos = math.ceil(row['Media_PAX_Dias_Operados'] / CAPACIDADE_C97)
# Pelo menos 1 voo para suprir a demanda da rota selecionada
voos_requeridos[row['Trecho']] = max(1, voos)
# MODELAGEM COM PULP
prob = pulp.LpProblem("Fleet_Assignment_C97", pulp.LpMinimize)
# Variáveis
X = pulp.LpVariable.dicts("X", [(esq, r) for esq in aeronaves_por_esquadrao.keys() for r in rotas_lista], lowBound=0, cat='Integer')
# Função Objetivo
prob += pulp.lpSum([distancias_voo[(esq, r)] * X[(esq, r)] for esq in aeronaves_por_esquadrao.keys() for r in rotas_lista])
# Restrição 1: Suprir a demanda da rota
for r in rotas_lista:
prob += pulp.lpSum([X[(esq, r)] for esq in aeronaves_por_esquadrao.keys()]) >= voos_requeridos[r]
# Restrição 2: Limite de aeronaves por esquadrão
for esq in aeronaves_por_esquadrao.keys():
prob += pulp.lpSum([X[(esq, r)] for r in rotas_lista]) <= aeronaves_por_esquadrao[esq]
# Solução
prob.solve(pulp.PULP_CBC_CMD(msg=False))
# Exibição dos resultados
print("== RESULTADO DO FLEET ASSIGNMENT ==")
print(f"Status: {pulp.LpStatus[prob.status]}")
print(f"Distância Total Minimizada: {pulp.value(prob.objective):.2f} km\\n")
print("Alocações (Voos Diários):")
for r in rotas_lista:
print(f"\\nTrecho: {r} (Voos necessários: {voos_requeridos[r]})")
for esq in aeronaves_por_esquadrao.keys():
qtd = int(X[(esq, r)].varValue)
if qtd > 0:
print(f" -> {esq} (Base {bases_esquadroes[esq]}): {qtd} voo(s)")
"""))
nb.cells = cells
with open('modelos.ipynb', 'w', encoding='utf-8') as f:
nbf.write(nb, f)
print("modelos.ipynb gerado com sucesso!")