Files
fleet-assignment/create_nb.py

241 lines
10 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
# 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 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!")