Compare commits

...

10 Commits

Author SHA1 Message Date
Eduardo Carlos
3607965c88 Add OARMP routing engine, dashboard and documentation
- Complete routing engine: ingest, optimizer (CG+B&B), maintenance
  monitor, metrics, pipeline, quality checks
- Streamlit dashboard with Input/Output tab structure, editable data
  editors, interactive Folium map with satellite layer and maintenance
  base highlights, FH stacked bar chart with TTM availability
- CSV data files: AERONAVES, CHECKS, AIRPORTS, ESCALA DE VOO
- README, CONTEXTO and CHANGELOG added
- Remove legacy pre_process scripts and raw binary files (PDFs/xlsx)
- Update .gitignore to exclude outputs/, data/, raw/*.pdf, raw/*.xlsx

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 11:52:34 -03:00
Cesa-V
32ad7c9f23 Compare ICA 66-31 with cycle report 2026-06-15 18:34:45 -03:00
Cesa-V
d2bf2883d7 Group cycle report preprocessing artifacts 2026-06-15 18:12:13 -03:00
Cesa-V
33c452b264 Add technical docs structure 2026-06-15 17:58:54 -03:00
Cesa-V
c09ce95e62 Rename project and update remote docs 2026-06-15 16:43:51 -03:00
Cesa-V
cc984114d2 Add numeric interval fields 2026-06-15 16:08:28 -03:00
Cesa-V
6289a7e1ad Correct zera TSO parsing 2026-06-15 16:03:33 -03:00
Cesa-V
e7489c9c2a Add repository guide 2026-06-15 16:00:13 -03:00
Cesa-V
e62d7cd640 Split inspection control fields 2026-06-15 15:55:30 -03:00
Cesa-V
1fd88b62fe Rename inspection report artifacts 2026-06-15 15:47:55 -03:00
37 changed files with 3458 additions and 1081 deletions

22
.gitignore vendored
View File

@@ -1,3 +1,16 @@
# Python
__pycache__/
*.py[cod]
*.pyo
# Virtual environment
.venv/
venv/
env/
# Logs
logs/
# Windows
Thumbs.db
Desktop.ini
@@ -9,3 +22,12 @@ Desktop.ini
# Local editor folders
.vscode/
.idea/
# Generated outputs and processed data
outputs/
data/
# Large binary / proprietary files
raw/*.pdf
raw/*.xlsx
raw/*.xls

103
CHANGELOG.md Normal file
View File

@@ -0,0 +1,103 @@
# Changelog
Todas as alterações relevantes do projeto são registradas neste arquivo.
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.0.0/).
---
## [Não lançado]
### Adicionado
- `README.md` com documentação do projeto, estrutura de pastas, instruções de instalação e conceitos-chave
- `CONTEXTO.md` com contexto operacional, motivação e decisões de projeto
- `CHANGELOG.md` (este arquivo)
---
## [0.5.0] — 2026-06-17
### Adicionado
- **Mapa interativo de rotas** (aba `🗺 Mapa`): rotas por aeronave exibidas com `folium` + `streamlit-folium`; controle de camadas para alternar entre **CartoDB positron** e **satélite Esri World Imagery**
- **Destaque de bases de manutenção no mapa**: marcador laranja (raio duplo) com tooltip `🔧 Base de manutenção` nos aeroportos com `IS_MAINTENANCE_BASE = 1`
- `folium>=0.14.0` e `streamlit-folium>=0.22.0` adicionados ao `requirements.txt`
### Corrigido
- **TTM disponível zerava após check**: a lógica anterior usava apenas o primeiro evento de manutenção por aeronave e subtraía todo `fh_apos` do TTM do segundo ciclo. Agora todos os eventos são acumulados (`all_maint`) e `fh_after_last = total_fh Σ(accum_fh_at_check)` calcula corretamente as FH voadas após a **última** manutenção
- Linha "Média" na tabela Frota não tinha mais cor de fundo (removido `background-color:#e8f4f8`); mantém apenas negrito
---
## [0.4.0] — 2026-06-16
### Adicionado
- **Aba `🗺 Mapa`** no Output: representação geográfica das rotas por aeronave usando `plotly.graph_objects.Scattergeo` (substituído na v0.5.0)
- Biblioteca `airportsdata` para lookup de coordenadas por código ICAO, sem necessidade de API key
- `airportsdata>=20240101` adicionado ao `requirements.txt`
- Coordenadas de DEP/ARR lidas diretamente da `Escala com Atribuição` (campo compartilhado `raw_sched_atrib`)
### Alterado
- Gráfico de FH por aeronave e métricas de horas disponíveis transferidos da aba `Escala com Atribuição` para a aba `Manutenção e Aeronaves` (renomeada)
- Cor da coluna "FH de TTM perdidas por aeronave / ciclo" alterada para vermelho
---
## [0.3.0] — 2026-06-15
### Adicionado
- **Gráfico de FH por aeronave** na aba `Manutenção e Aeronaves` com quatro segmentos empilhados:
- FH Iniciais (cinza `#94a3b8`)
- FH Planejadas antes da manutenção (azul `#3b82f6`)
- FH Planejadas após a manutenção (âmbar `#f59e0b`)
- FH Disponíveis até a próxima manutenção (verde `#22c55e`, opacidade 0.55)
- Marcador diamante vermelho indicando o evento de check
- **Métricas de horas disponíveis** por aeronave (metric cards) abaixo do gráfico
- Campo para **adicionar N linhas de uma vez** na Escala de Voo (Input), com botão "Limpar tudo"
- Exclusão de linhas habilitada na tabela de Escala de Voo (`num_rows="dynamic"`)
### Alterado
- Abas `Resumo` e `Gantt` unificadas em `📊 Resumo`
- Altura do gráfico de FH dobrada (800 px)
### Corrigido
- Variável `_c4` renomeada para `_` (sem uso)
- `tat` acessado via `st.session_state.get("tat", 60)` para evitar erro quando o botão de execução é pressionado antes de o sub-tab Aeronaves ser visitado
---
## [0.2.0] — 2026-06-14
### Adicionado
- **Aba `🔧 Manutenção`**: tabela com eventos de manutenção (aeronave, OFRAG anterior, threshold, FH acumuladas, perda de TTM, início/fim calendário) e gráficos de FH perdidas e FH verificadas
- **Aba `✈ Frota`**: tabela de utilização com coluna `Média` e gráficos de utilização TTM e FH por aeronave
- Campo **Tempo do Solver (s)** e botão **Executar Otimização** movidos para a aba Input (acima das sub-abas)
- Mensagem de conclusão com **tempo decorrido** da otimização (`⏱ Otimização concluída em Xm Ys.`)
- Cor de fundo das células da Escala com Atribuição alterada para transparente
### Removido
- Aba `Parâmetros` do sidebar (TAT mínimo movido para sub-aba Aeronaves; ano de planejamento removido — inferido de `pd.Timestamp.now().year`)
### Alterado
- Campo **TAT mínimo** movido para a sub-aba `Aeronaves` do Input
- Largura do campo Tempo do Solver reduzida com `st.columns([1, 1, 4])`
---
## [0.1.0] — 2026-06-13
### Adicionado
- **Reescrita completa do dashboard** (`app/dashboard.py`) com estrutura em duas abas principais: `📥 Input` e `📤 Output`
- Sub-abas de Input: `Escala de Voo`, `Aeronaves`, `Checks`, `Bases de Manutenção` com `st.data_editor` editável
- Suporte a **DataFrames em memória** no pipeline: `load_from_dfs()` em `ingest.py` aceita dados do editor sem gravar em disco
- Parâmetro `raw_dfs` em `RoutingPipeline.run()` para passar dados in-memory
- Parsing de datas no formato `DD/MM/AAAA` em `_parse_br_date()` (além do legado `DD/mon`)
- Backup do dashboard original em `app/dashboard_backup.py`
### Alterado
- `src/routing_engine/pipeline.py`: `run()` recebe `raw_dfs` opcional; chama `load_from_dfs` se fornecido
- `src/routing_engine/ingest.py`: adicionada função `load_from_dfs()` com normalização de sinônimos de colunas
---
## [0.0.x] — antes de 2026-06-13
Commits iniciais do repositório: estrutura de pastas, configuração de CI, scripts de inspeção de arquivos, engine de otimização (config, ingest, network_generator, optimizer, maintenance_monitor, metrics, pipeline, quality).

View File

@@ -1,38 +1,64 @@
# Contexto do Projeto
# Contexto do Projeto — OARMP
## Objetivo
## Origem e motivação
Organizar, preservar e processar documentos relacionados ao projeto `fab_oamrp`, mantendo rastreabilidade das alterações e separando arquivos originais, intermediários e processados.
O OARMP nasceu da necessidade de apoiar o planejamento de emprego da frota de aeronaves C-105 Amazonas no âmbito do CEAO 809. A gestão manual da escala de voo — realizada em planilhas — não garante o respeito sistemático aos intervalos de manutenção (TTM), o que pode resultar em aeronaves com check vencido, indisponibilidade não planejada ou perda desnecessária de horas de TTM.
## Estrutura de Pastas
O sistema automatiza a atribuição de aeronaves a missões, respeitando os checks programados e minimizando o desperdício de horas de vida útil de manutenção.
- `raw/`: arquivos originais, sem alteração.
- `pre_process/`: scripts e saídas intermediárias de pré-processamento.
- `processed/`: dados finais limpos, consolidados ou prontos para análise.
## Contexto operacional
## Colaboradores
### Aeronaves
- `VTO`: Vitor Cesa.
- `GNR`: Generoso.
- `JOM`: João Marcos.
A frota considerada é composta por aeronaves **C-105 Amazonas** (Embraer KC-390 regional), operadas no transporte logístico e de pessoal na região amazônica. Cada aeronave possui um histórico acumulado de horas de voo (FH Totais) que determina a proximidade ao próximo check.
## Orientações de Trabalho
### Checks de manutenção
- Manter documentos originais sempre em `raw/`.
- Não alterar arquivos dentro de `raw/`; quando necessário, gerar cópias ou saídas em `pre_process/`.
- Registrar no `LOG.md` toda entrada, alteração, processamento ou decisão relevante.
- Usar o formato de log com data, hora, tag do colaborador e descrição objetiva da ação.
- Registrar a origem de cada documento, quando conhecida.
- Informar scripts executados e parâmetros importantes.
- Registrar quantidade de registros extraídos, descartados, corrigidos ou validados.
- Registrar problemas encontrados nos arquivos originais, como erros de digitação, páginas ilegíveis, campos ausentes ou tabelas quebradas.
- Registrar decisões de padronização, por exemplo nomes de colunas, unidades, formatos de data e tratamento de acentos.
- Explicitar os critérios usados para mover dados de `pre_process/` para `processed/`.
Os checks são definidos por **thresholds cumulativos de FH** (ex: 300 h, 400 h, 600 h, 900 h). Quando uma aeronave atinge o threshold de seu ciclo atual, ela deve ser submetida ao check correspondente antes de continuar voando. A manutenção é realizada exclusivamente nas **bases habilitadas** (campo `IS_MAINTENANCE_BASE = 1` em AIRPORTS.csv).
## Formato Recomendado do Log
### OFRAGs
```text
| Data | Hora | Autor | Ação | Arquivos | Observações |
| --- | --- | --- | --- | --- | --- |
| 2026-06-15 | 15:41 | VTO | Pré-processou PDF de inspeções | raw/documento.pdf; pre_process/saida.csv | 18 inspeções extraídas |
```
Um OFRAG (fragmento de voo) é um conjunto de etapas que **parte da base de manutenção e retorna a ela**. Essa restrição é fundamental: apenas OFRAGs com início e fim na base podem ser alocados no problema de otimização, pois garantem que a aeronave pode ser submetida a check entre missões.
### Escala de voo
A escala é fornecida no formato da planilha operacional da unidade, com etapas identificadas por data, aeroportos de partida/chegada, horários e número de OFRAG. O sistema aceita datas no formato `DD/MM/AAAA` ou no formato legado `DD/mon` (ex: `15/jan`).
## Problema de otimização
O problema é modelado como **Set Partitioning** sobre o conjunto de OFRAGs:
- Cada OFRAG deve ser coberto por exatamente uma aeronave
- Cada aeronave executa no máximo uma rota (sequência de OFRAGs)
- A rota deve ser TTM-viável: a qualquer ponto, as FH acumuladas desde o último check não excedem o TTM do ciclo atual
- A função objetivo minimiza a **perda total de TTM** (horas de TTM não utilizadas quando a manutenção é antecipada)
A solução é obtida por **Column Generation** (geração de colunas) combinada com **Branch & Bound** (PuLP/CBC), permitindo explorar um espaço de rotas potencialmente exponencial de forma eficiente.
## Decisões de projeto
| Decisão | Justificativa |
|---------|---------------|
| Set Partitioning (ao invés de Set Covering) | Garante que cada missão seja atribuída a exatamente uma aeronave, sem ambiguidade |
| Column Generation | O número de rotas viáveis é exponencial; CG constrói apenas colunas lucrativas |
| Pricing por DP (label-setting) | Subproblema é um shortest-path com restrições de recursos (TTM), adequado para DP em DAG |
| TAT mínimo (padrão 60 min) | Garante tempo mínimo de preparação entre OFRAGs consecutivos na mesma aeronave |
| Penalidade Big-M por OFRAG descoberto | Permite solução mesmo quando a cobertura total é inviável (ex: mais OFRAGs do que aeronaves disponíveis) |
## Limitações conhecidas
- O modelo considera apenas um evento de manutenção por ciclo de check; múltiplos checks consecutivos são tratados como ciclos independentes
- A duração da manutenção é fixa por tipo de check; variações logísticas (falta de peças, disponibilidade de hangar) não são modeladas
- OFRAGs que não partem e chegam à base de manutenção são excluídos do problema (não podem ser alocados)
- O solver CBC tem desempenho limitado para instâncias muito grandes; o parâmetro `mip_time_limit_seconds` controla o tempo máximo
## Arquivos de referência
| Arquivo | Conteúdo |
|---------|----------|
| `raw/AERONAVES.csv` | Frota com FH acumuladas |
| `raw/CHECKS.csv` | Thresholds e durações dos checks |
| `raw/AIRPORTS.csv` | Aeroportos e bases de manutenção |
| `raw/ESCALA DE VOO MODELO 1.csv` | Escala de missões |
| `raw/ICA 66-31 2023.pdf` | Instrução regulatória de aeronavegabilidade (referência) |
| `raw/Airline Operations and Scheduling*.pdf` | Referência bibliográfica principal (Bazargan, 2010) |

14
LOG.md
View File

@@ -1,14 +0,0 @@
# Log do Projeto
Legenda de autores:
- `VTO`: Vitor Cesa.
- `GNR`: Generoso.
- `JOM`: João Marcos.
| Data | Hora | Autor | Ação | Arquivos | Observações |
| --- | --- | --- | --- | --- | --- |
| 2026-06-15 | 15:41 | VTO | Criou repositório Git do projeto | `.gitignore`; `.gitattributes` | Projeto versionado na branch `main`. |
| 2026-06-15 | 15:41 | VTO | Organizou estrutura inicial de dados | `raw/`; `pre_process/`; `processed/` | Documentos originais movidos para `raw/`; pastas vazias preservadas com `.gitkeep`. |
| 2026-06-15 | 15:41 | VTO | Pré-processou PDF de inspeções | `raw/documento joaomarcos.pdf`; `pre_process/documento_joaomarcos_texto.txt`; `pre_process/documento_joaomarcos_inspecoes.json`; `pre_process/documento_joaomarcos_inspecoes.csv`; `pre_process/preprocess_pdf.py` | 18 inspeções extraídas; grafias originais preservadas, inclusive `INPEÇÃO`. |
| 2026-06-15 | 15:41 | VTO | Separou orientações permanentes do histórico | `CONTEXTO.md`; `LOG.md` | Orientações movidas para `CONTEXTO.md`; `LOG.md` passou a usar data, hora e tag de autor. |

145
README.md Normal file
View File

@@ -0,0 +1,145 @@
# OARMP — Aircraft Routing & Maintenance Planning
Sistema de otimização de roteamento de aeronaves com planejamento integrado de manutenção, desenvolvido para o Curso de Especialização em Administração de Organizações (CEAO 809).
## Visão geral
O OARMP resolve o problema de atribuição de aeronaves a fragmentos de voo (OFRAGs) respeitando as restrições de **Time to Maintenance (TTM)** de cada aeronave, minimizando as horas perdidas de TTM ao longo do planejamento.
### Formulação matemática
**Set Partitioning com Column Generation + Branch & Bound**
```
Minimizar Σ_r c_r · x_r (perda total de TTM)
s.a. Σ_{r: j∈r} x_r = 1 ∀ j ∈ OFRAGs (cada OFRAG coberto exatamente uma vez)
Σ_{r: a(r)=a} x_r ≤ 1 ∀ a ∈ Aeronaves (uma rota por aeronave)
x_r ∈ {0, 1}
```
O subproblema de precificação (pricing) é resolvido por **programação dinâmica (label-setting DP)** sobre o DAG de OFRAGs ordenados por horário de partida.
## Estrutura do projeto
```
arara_oarmp/
├── app/
│ └── dashboard.py # Interface Streamlit (entrada de dados + resultados)
├── raw/ # Dados de entrada (CSV)
│ ├── AERONAVES.csv
│ ├── CHECKS.csv
│ ├── AIRPORTS.csv
│ └── ESCALA DE VOO MODELO 1.csv
├── src/
│ └── routing_engine/
│ ├── config.py # Parâmetros e caminhos
│ ├── ingest.py # Leitura e normalização dos dados
│ ├── inspect_files.py # Detecção automática de colunas (sinônimos)
│ ├── network_generator.py # Grafo de adjacência entre OFRAGs
│ ├── optimizer.py # CG + B&B (PuLP/CBC)
│ ├── maintenance_monitor.py # Validação TTM e eventos de manutenção
│ ├── metrics.py # Tabelas e sumários de saída
│ ├── pipeline.py # Orquestração end-to-end
│ └── quality.py # Verificações de qualidade dos dados
├── scripts/ # Scripts auxiliares de inspeção e execução
├── outputs/ # Resultados gerados pelo pipeline
│ ├── schedules/
│ └── exports/
├── requirements.txt
└── run_pipeline.bat
```
## Instalação
```bash
# Criar ambiente virtual
python -m venv .venv
.venv\Scripts\activate # Windows
# source .venv/bin/activate # Linux/Mac
# Instalar dependências
pip install -r requirements.txt
```
### Dependências principais
| Pacote | Uso |
|--------|-----|
| `pulp` | Formulação e resolução do MIP (solver CBC) |
| `pandas` / `numpy` | Manipulação de dados |
| `streamlit` | Dashboard interativo |
| `plotly` | Gráficos (Gantt, barras) |
| `folium` + `streamlit-folium` | Mapa interativo de rotas |
| `airportsdata` | Coordenadas geográficas de aeroportos por ICAO |
| `networkx` | Grafo de adjacência entre OFRAGs |
## Execução
### Via dashboard (recomendado)
```bash
streamlit run app/dashboard.py
```
### Via script de linha de comando
```bash
python scripts/03_run_optimization_pipeline.py
# ou
run_pipeline.bat
```
## Dados de entrada
Todos os arquivos ficam em `raw/` com separador `;`:
### AERONAVES.csv
| Campo | Descrição |
|-------|-----------|
| MATRICULA | Indicativo da aeronave |
| MODELO | Modelo (ex: C105) |
| FH TOTAIS | Horas de voo totais acumuladas |
### CHECKS.csv
| Campo | Descrição |
|-------|-----------|
| CHECKS | Nome do check |
| FH | Threshold cumulativo de FH para o check |
| TEMPO DE EXECUCAO (DIAS) | Duração do check em dias |
| LOCAL DE EXECUCAO | Código ICAO da base de manutenção |
### AIRPORTS.csv
| Campo | Descrição |
|-------|-----------|
| AIRPORT_CODE | Código ICAO |
| AIRPORT_NAME | Nome do aeroporto |
| IS_MAINTENANCE_BASE | 1 = base de manutenção |
### ESCALA DE VOO MODELO 1.csv
Escala de missões com colunas: `DATA`, `ETAPA`, `DEP`, `ARR`, `HORA_DEP`, `HORA_ARR`, `TEMPO_VOO`, `SEGMTO`, `MISSAO`, `OFRAG`.
Formato de data aceito: `DD/MM/AAAA` ou `DD/mon` (ex: `15/jan`).
## Dashboard — abas
### Entrada (📥 Input)
- **Escala de Voo**: tabela editável com adição/remoção de linhas
- **Aeronaves**: frota com FH totais e TAT mínimo
- **Checks**: thresholds e durações de manutenção
- **Bases de Manutenção**: aeroportos habilitados para check
### Saída (📤 Output)
- **Resumo**: métricas gerais + diagrama de Gantt
- **Escala com Atribuição**: escala original com aeronave atribuída por OFRAG
- **Manutenção e Aeronaves**: eventos de manutenção, gráfico de FH por aeronave (FH iniciais + planejadas antes/após manutenção + TTM disponível)
- **Frota**: utilização percentual do TTM por aeronave
- **Mapa**: rotas por aeronave em mapa interativo (satélite Esri / CartoDB)
## Conceitos-chave
**OFRAG** — Fragmento de voo: conjunto de etapas que sai e retorna à base de manutenção. Unidade de alocação do problema.
**TTM (Time to Maintenance)** — Horas de voo disponíveis até o próximo check obrigatório. Cada ciclo de check tem seu próprio TTM.
**TTM Loss** — Horas de TTM não utilizadas quando a manutenção é realizada antes de esgotar o ciclo (inevitável quando o próximo OFRAG excederia o limite).
**TAT (Turnaround Time)** — Tempo mínimo entre o pouso de um OFRAG e a decolagem do seguinte na mesma aeronave.

755
app/dashboard.py Normal file
View File

@@ -0,0 +1,755 @@
"""
Aircraft Routing Dashboard Streamlit application (v2).
Run with: streamlit run app/dashboard.py (from project root)
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import streamlit as st
from src.routing_engine import DEFAULT_CONFIG, RoutingPipeline
# ── Page config ───────────────────────────────────────────────────────────────
st.set_page_config(
page_title="OARMP Aircraft Routing",
page_icon="",
layout="wide",
)
st.title("✈ Aircraft Routing & Maintenance Planning (OARMP)")
st.caption("Set Partitioning + Column Generation + Branch & Bound")
# ── Constants ─────────────────────────────────────────────────────────────────
_RAW = Path(__file__).resolve().parents[1] / "raw"
_SCHED_COLS = ["DATA", "ETAPA", "DEP", "ARR", "HORA_DEP", "HORA_ARR", "TEMPO_VOO", "SEGMTO", "MISSAO", "OFRAG"]
def _read_csv(filename: str, skiprows: int = 0, names=None) -> pd.DataFrame:
path = _RAW / filename
if not path.exists():
return pd.DataFrame(columns=names or [])
for enc in ("utf-8-sig", "utf-8", "latin-1", "cp1252"):
try:
return pd.read_csv(path, sep=";", encoding=enc, skiprows=skiprows, names=names, dtype=str)
except Exception:
continue
return pd.DataFrame(columns=names or [])
# ── Session-state defaults ────────────────────────────────────────────────────
def _init_state():
if "df_aircraft" not in st.session_state:
st.session_state["df_aircraft"] = _read_csv("AERONAVES.csv")
if "df_checks" not in st.session_state:
st.session_state["df_checks"] = _read_csv("CHECKS.csv")
if "df_airports" not in st.session_state:
st.session_state["df_airports"] = _read_csv("AIRPORTS.csv")
if "df_schedule" not in st.session_state:
_BR_MON = {"jan":"01","fev":"02","mar":"03","abr":"04","mai":"05","jun":"06",
"jul":"07","ago":"08","set":"09","out":"10","nov":"11","dez":"12"}
_DEFAULT_YEAR = 2026
def _to_dmy(val: str) -> str:
parts = str(val).strip().split("/")
if len(parts) == 3:
return val # already DD/MM/AAAA
if len(parts) == 2:
mon = _BR_MON.get(parts[1].lower())
if mon:
return f"{int(parts[0]):02d}/{mon}/{_DEFAULT_YEAR}"
return val
df = _read_csv("ESCALA DE VOO MODELO 1.csv", skiprows=2, names=_SCHED_COLS)
df = df[df["DATA"].notna() & (df["DATA"].str.strip() != "")]
df = df[df["OFRAG"].notna() & (df["OFRAG"].str.strip() != "")]
df["DATA"] = df["DATA"].apply(_to_dmy)
st.session_state["df_schedule"] = df.reset_index(drop=True)
if "result" not in st.session_state:
st.session_state["result"] = None
_init_state()
# ── Main tabs ─────────────────────────────────────────────────────────────────
tab_input, tab_output = st.tabs(["📥 Input", "📤 Output"])
# ══════════════════════════════════════════════════════════════════════════════
# INPUT TAB
# ══════════════════════════════════════════════════════════════════════════════
with tab_input:
_col_solver, _col_btn, _ = st.columns([1, 1, 4])
with _col_solver:
time_limit = st.number_input(
"Tempo solver (s)",
min_value=30, max_value=600, value=st.session_state.get("time_limit", 120),
step=10, key="time_limit",
)
with _col_btn:
st.write("") # alinhamento vertical
run_btn = st.button("▶ Executar Otimização", type="primary", use_container_width=True)
st.divider()
sub_sched, sub_aircraft, sub_checks, sub_bases = st.tabs(
["📋 Escala de Voo", "✈ Aeronaves", "🔧 Checks", "🏭 Bases de Manutenção"]
)
with sub_aircraft:
st.subheader("Aeronaves")
st.caption("Matrícula, modelo e horas de voo totais acumuladas de cada aeronave.")
tat = st.number_input(
"TAT mínimo (min)",
min_value=0, max_value=240,
value=st.session_state.get("tat", 60),
step=10,
key="tat",
)
edited = st.data_editor(
st.session_state["df_aircraft"],
num_rows="dynamic",
use_container_width=True,
key="editor_aircraft",
column_config={
"MATRICULA": st.column_config.TextColumn("Matrícula"),
"MODELO": st.column_config.TextColumn("Modelo"),
"FH TOTAIS": st.column_config.NumberColumn("FH Totais", min_value=0, format="%.0f"),
},
)
st.session_state["df_aircraft"] = edited
with sub_checks:
st.subheader("Checks de Manutenção")
st.caption("Tipos de check, limiar de FH, duração em dias e local de execução.")
edited = st.data_editor(
st.session_state["df_checks"],
num_rows="dynamic",
use_container_width=True,
key="editor_checks",
column_config={
"CHECKS": st.column_config.TextColumn("Denominação do Check"),
"FH": st.column_config.NumberColumn("FH Limite", min_value=0, format="%.0f"),
"TEMPO DE EXECUCAO (DIAS)": st.column_config.NumberColumn("Duração (dias)", min_value=0),
"LOCAL DE EXECUCAO": st.column_config.TextColumn("Local (ICAO)"),
},
)
st.session_state["df_checks"] = edited
with sub_bases:
st.subheader("Aeroportos e Bases de Manutenção")
st.caption(
"Lista de aeroportos utilizados. Defina IS_MAINTENANCE_BASE = 1 "
"para os aeroportos que são base de manutenção."
)
edited = st.data_editor(
st.session_state["df_airports"],
num_rows="dynamic",
use_container_width=True,
key="editor_airports",
column_config={
"AIRPORT_CODE": st.column_config.TextColumn("Código ICAO"),
"AIRPORT_NAME": st.column_config.TextColumn("Nome"),
"LATITUDE": st.column_config.NumberColumn("Latitude", format="%.4f"),
"LONGITUDE": st.column_config.NumberColumn("Longitude", format="%.4f"),
"IS_MAINTENANCE_BASE": st.column_config.SelectboxColumn(
"Base de Manutenção?", options=["0", "1"]
),
"COUNTRY": st.column_config.TextColumn("País"),
},
)
st.session_state["df_airports"] = edited
with sub_sched:
st.subheader("Escala de Voo")
st.caption(
"Etapas agrupadas em OFRAGs. "
"Cada OFRAG deve iniciar e terminar na base de manutenção para ser elegível."
)
edited = st.data_editor(
st.session_state["df_schedule"],
num_rows="dynamic",
use_container_width=True,
key="editor_schedule",
column_config={
"DATA": st.column_config.TextColumn("Data (DD/MM/AAAA)"),
"ETAPA": st.column_config.NumberColumn("Etapa", min_value=1),
"DEP": st.column_config.TextColumn("Partida (ICAO)"),
"ARR": st.column_config.TextColumn("Destino (ICAO)"),
"HORA_DEP": st.column_config.TextColumn("Hora Dep (Z)"),
"HORA_ARR": st.column_config.TextColumn("Hora Arr (Z)"),
"TEMPO_VOO": st.column_config.TextColumn("Tempo Voo"),
"SEGMTO": st.column_config.NumberColumn("Segmento"),
"MISSAO": st.column_config.TextColumn("Missão"),
"OFRAG": st.column_config.NumberColumn("OFRAG", min_value=1),
},
)
st.session_state["df_schedule"] = edited
# Controles de adição em lote e limpeza
_c1, _c2, _c3, _ = st.columns([1, 1, 1, 3])
with _c1:
n_add = st.number_input(
"Qtd. de linhas", min_value=1, max_value=100, value=1, step=1,
key="n_add_sched",
)
with _c2:
st.write("")
if st.button(" Adicionar", key="btn_add_sched", use_container_width=True):
empty = pd.DataFrame([{col: None for col in _SCHED_COLS}] * int(n_add))
st.session_state["df_schedule"] = pd.concat(
[st.session_state["df_schedule"], empty], ignore_index=True
)
st.rerun()
with _c3:
st.write("")
if st.button("🗑 Limpar tudo", key="btn_clear_sched", use_container_width=True):
st.session_state["df_schedule"] = pd.DataFrame(columns=_SCHED_COLS)
st.rerun()
# ── Run pipeline (after input tabs so session state is current) ───────────────
if run_btn:
cfg = DEFAULT_CONFIG
cfg.tat_minutes = int(st.session_state.get("tat", 60))
cfg.planning_year = pd.Timestamp.now().year
cfg.mip_time_limit_seconds = int(time_limit)
raw_dfs = {
"aircraft": st.session_state["df_aircraft"],
"checks": st.session_state["df_checks"],
"airports": st.session_state["df_airports"],
"schedule": st.session_state["df_schedule"],
}
with st.spinner("Executando otimização…"):
try:
import time as _time
_t0 = _time.perf_counter()
pipe = RoutingPipeline(cfg)
st.session_state["result"] = pipe.run(save_outputs=True, raw_dfs=raw_dfs)
_elapsed = _time.perf_counter() - _t0
_mins, _secs = divmod(int(_elapsed), 60)
_dur = f"{_mins}m {_secs}s" if _mins else f"{_secs}s"
st.success(f"Otimização concluída em {_dur}.")
except Exception as exc:
st.error(f"Erro: {exc}")
st.exception(exc)
result = st.session_state.get("result")
# ══════════════════════════════════════════════════════════════════════════════
# OUTPUT TAB
# ══════════════════════════════════════════════════════════════════════════════
with tab_output:
if result is None:
st.info("Configure o cenário na aba Input e execute a otimização.")
else:
(
sub_resumo,
sub_escala,
sub_maint,
sub_frota,
sub_mapa,
) = st.tabs(
[
"📊 Resumo",
"📋 Escala com Atribuição",
"🔧 Manutenção e Aeronaves",
"✈ Frota",
"🗺 Mapa",
]
)
# ── Escala com atribuição (construída uma vez, reutilizada em sub_escala e sub_mapa) ──
_sched_result = result["schedule"]
_raw = st.session_state["df_schedule"].copy()
_ofrag_map: dict[int, str] = {}
for _, _r in _sched_result.iterrows():
try:
_ofrag_map[int(_r["ofrag_id"].replace("OFRAG", ""))] = _r["aircraft"]
except Exception:
pass
_raw.insert(
0, "AERONAVE",
_raw["OFRAG"].apply(
lambda x: _ofrag_map.get(int(str(x).strip()), "")
if str(x).strip().isdigit() else ""
),
)
raw_sched_atrib = _raw # DataFrame completo com coluna AERONAVE
# ── Resumo + Gantt ────────────────────────────────────────────────────
with sub_resumo:
s = result["summary"]
c1, c2, c3, c4 = st.columns(4)
c1.metric("Status", s["status"])
c2.metric("FH Perdidas (objetivo)", f"{s['total_ttm_loss_hours']:.2f} h")
c3.metric("OFRAGs cobertas", f"{s['covered_ofrags']} / {s['total_ofrags']}")
c4.metric("Eventos de manutenção", s["n_maintenance_events"])
if s["uncovered_ofrags"]:
st.warning(f"OFRAGs NÃO cobertas: {s['uncovered_ofrags']}")
qc = result.get("quality", {})
if qc.get("issues"):
with st.expander("Avisos de qualidade de dados"):
for issue in qc["issues"]:
st.warning(issue)
st.caption(f"Colunas geradas no CG: {s['columns_generated']}")
st.divider()
sched = result["schedule"]
maint = result["maintenance"]
gantt_rows = []
for _, row in sched.iterrows():
if pd.isna(row.get("departure")) or pd.isna(row.get("arrival")):
continue
gantt_rows.append(
dict(
Task=row["aircraft"],
Start=row["departure"],
Finish=row["arrival"],
Type="OFRAG",
Label=row["ofrag_id"],
)
)
for _, row in maint.iterrows():
if pd.isna(row.get("maint_start")) or pd.isna(row.get("maint_end")):
continue
gantt_rows.append(
dict(
Task=row["aircraft"],
Start=row["maint_start"],
Finish=row["maint_end"],
Type="Manutenção",
Label=f"CHECK {row['fh_threshold']:.0f}h (perde {row['ttm_loss_hours']:.1f}h)",
)
)
if gantt_rows:
df_g = pd.DataFrame(gantt_rows)
fig = px.timeline(
df_g,
x_start="Start",
x_end="Finish",
y="Task",
color="Type",
text="Label",
title="Escala de Voo e Manutenção por Aeronave",
color_discrete_map={"OFRAG": "#00CC96", "Manutenção": "#EF553B"},
)
fig.update_yaxes(categoryorder="category ascending")
fig.update_traces(textposition="inside")
n_ac = df_g["Task"].nunique()
fig.update_layout(height=420 + n_ac * 40)
st.plotly_chart(fig, use_container_width=True)
try:
fig.write_html(str(DEFAULT_CONFIG.figures_dir / "gantt.html"))
except Exception:
pass
else:
st.info("Nenhum dado de Gantt disponível.")
# ── Escala com Atribuição ─────────────────────────────────────────────
with sub_escala:
st.subheader("Escala de Voo com Atribuição de Aeronaves")
if _sched_result.empty:
st.info("Sem escala otimizada disponível.")
else:
st.dataframe(raw_sched_atrib, use_container_width=True)
# ── Manutenção ────────────────────────────────────────────────────────
with sub_maint:
st.subheader("Eventos de Manutenção Planejados")
maint = result["maintenance"]
if maint.empty:
st.success("Nenhum evento de manutenção forçado no período planejado.")
else:
# Enrich with check name and location from input data
checks_input = st.session_state["df_checks"].copy()
# Identify relevant columns by position / name patterns
_fh_col = next(
(c for c in checks_input.columns if "FH" in c.upper() and "TEMPO" not in c.upper()),
None,
)
_loc_col = next((c for c in checks_input.columns if "LOCAL" in c.upper()), None)
_name_col = checks_input.columns[0] if len(checks_input.columns) > 0 else None
maint_disp = maint.copy()
if _fh_col:
checks_input[_fh_col] = pd.to_numeric(checks_input[_fh_col], errors="coerce")
if _loc_col:
fh_to_loc = checks_input.dropna(subset=[_fh_col]).set_index(_fh_col)[_loc_col].to_dict()
maint_disp["local_execucao"] = maint_disp["fh_threshold"].map(fh_to_loc)
if _name_col:
fh_to_name = checks_input.dropna(subset=[_fh_col]).set_index(_fh_col)[_name_col].to_dict()
maint_disp["check_nome"] = maint_disp["fh_threshold"].map(fh_to_name)
ordered_cols = [
"aircraft",
"check_nome",
"local_execucao",
"fh_threshold",
"accum_fh_at_check",
"maint_start",
"maint_end",
"ttm_loss_hours",
]
show_cols = [c for c in ordered_cols if c in maint_disp.columns]
rename_map = {
"aircraft": "Aeronave",
"check_nome": "Check",
"local_execucao": "Local",
"fh_threshold": "FH Limite",
"accum_fh_at_check": "FH na Manutenção",
"maint_start": "Início",
"maint_end": "Término",
"ttm_loss_hours": "FH Perdidas",
}
st.dataframe(
maint_disp[show_cols].rename(columns=rename_map),
use_container_width=True,
)
col_a, col_b = st.columns(2)
with col_a:
fig_loss = px.bar(
maint_disp,
x="aircraft",
y="ttm_loss_hours",
title="FH de TTM perdidas por aeronave / ciclo",
labels={"ttm_loss_hours": "TTM perdido (h)", "aircraft": "Aeronave"},
text_auto=".2f",
color_discrete_sequence=["#ef4444"],
)
st.plotly_chart(fig_loss, use_container_width=True)
with col_b:
fig_fh_chk = px.bar(
maint_disp,
x="aircraft",
y="accum_fh_at_check",
color="check_cycle_index",
title="FH acumuladas na manutenção por aeronave",
labels={"accum_fh_at_check": "FH acumuladas", "aircraft": "Aeronave"},
text_auto=".1f",
)
st.plotly_chart(fig_fh_chk, use_container_width=True)
# ── Gráfico FH por aeronave + horas disponíveis ───────────────────
st.divider()
st.subheader("Horas de Voo por Aeronave")
fleet_df = result["fleet"]
maint_df = result["maintenance"]
ac_input = st.session_state["df_aircraft"].copy()
_mat_col = next(
(c for c in ac_input.columns if any(k in c.upper() for k in ("MATRICULA", "TAIL", "AC"))),
ac_input.columns[0],
)
_fh_col_ac = next((c for c in ac_input.columns if "FH" in c.upper()), None)
fh_lookup: dict = {}
if _fh_col_ac:
ac_input["_fh"] = pd.to_numeric(ac_input[_fh_col_ac], errors="coerce").fillna(0)
fh_lookup = ac_input.set_index(_mat_col)["_fh"].to_dict()
checks_inp = st.session_state["df_checks"].copy()
_ck_fh_col = next(
(c for c in checks_inp.columns if "FH" in c.upper() and "TEMPO" not in c.upper()), None
)
_thresholds = sorted(
pd.to_numeric(checks_inp[_ck_fh_col], errors="coerce").dropna().tolist()
) if _ck_fh_col else []
def _next_ttm(done_threshold: float) -> float:
for i, t in enumerate(_thresholds):
if abs(t - done_threshold) < 0.01:
if i + 1 < len(_thresholds):
return _thresholds[i + 1] - t
return t - (_thresholds[i - 1] if i > 0 else 0)
return 0.0
# all_maint: all events per aircraft sorted by time
all_maint: dict = {}
first_maint: dict = {}
if not maint_df.empty:
for ac, grp in maint_df.sort_values("maint_start").groupby("aircraft"):
events = grp[["accum_fh_at_check", "fh_threshold"]].to_dict("records")
all_maint[ac] = events
first_maint[ac] = {
"fh_antes": events[0]["accum_fh_at_check"],
"threshold": events[0]["fh_threshold"],
}
fleet_plot = fleet_df[["aircraft", "flight_hours_scheduled", "initial_ttm_h"]].copy()
fleet_plot["fh_inicial"] = fleet_plot["aircraft"].map(fh_lookup).fillna(0)
fleet_plot["fh_antes"] = fleet_plot.apply(
lambda r: first_maint[r["aircraft"]]["fh_antes"]
if r["aircraft"] in first_maint else r["flight_hours_scheduled"], axis=1,
)
fleet_plot["fh_apos"] = fleet_plot.apply(
lambda r: max(0.0, r["flight_hours_scheduled"] - first_maint[r["aircraft"]]["fh_antes"])
if r["aircraft"] in first_maint else 0.0, axis=1,
)
# fh_disp: remaining TTM after the LAST maintenance event
# fh_after_last = total FH - sum of all accum_fh_at_check (each event resets the counter)
fleet_plot["fh_disp"] = fleet_plot.apply(
lambda r: max(0.0,
_next_ttm(all_maint[r["aircraft"]][-1]["fh_threshold"])
- max(0.0, r["flight_hours_scheduled"]
- sum(e["accum_fh_at_check"] for e in all_maint[r["aircraft"]]))
) if r["aircraft"] in all_maint
else max(0.0, r["initial_ttm_h"] - r["flight_hours_scheduled"]),
axis=1,
)
fig_fh = go.Figure()
fig_fh.add_trace(go.Bar(
name="FH Iniciais", x=fleet_plot["aircraft"], y=fleet_plot["fh_inicial"],
marker_color="#94a3b8",
text=fleet_plot["fh_inicial"].apply(lambda v: f"{v:.0f}h"), textposition="inside",
))
fig_fh.add_trace(go.Bar(
name="FH Planejadas (antes manut.)", x=fleet_plot["aircraft"], y=fleet_plot["fh_antes"],
marker_color="#3b82f6",
text=fleet_plot["fh_antes"].apply(lambda v: f"{v:.1f}h" if v > 0 else ""), textposition="inside",
))
fig_fh.add_trace(go.Bar(
name="FH Planejadas (após manut.)", x=fleet_plot["aircraft"], y=fleet_plot["fh_apos"],
marker_color="#f59e0b",
text=fleet_plot["fh_apos"].apply(lambda v: f"{v:.1f}h" if v > 0 else ""), textposition="inside",
))
fig_fh.add_trace(go.Bar(
name="FH Disponíveis (próx. manut.)", x=fleet_plot["aircraft"], y=fleet_plot["fh_disp"],
marker_color="#22c55e", opacity=0.55,
text=fleet_plot["fh_disp"].apply(lambda v: f"{v:.1f}h" if v > 0 else ""), textposition="inside",
))
_maint_legend_added = False
for ac, info in first_maint.items():
y_mark = fh_lookup.get(ac, 0) + info["fh_antes"]
fig_fh.add_trace(go.Scatter(
x=[ac], y=[y_mark], mode="markers+text",
marker=dict(symbol="diamond", size=14, color="#ef4444",
line=dict(color="white", width=1.5)),
text=[f"CHECK {info['threshold']:.0f}h"], textposition="top center",
name="Manutenção", showlegend=not _maint_legend_added, legendgroup="maint",
))
_maint_legend_added = True
fig_fh.update_layout(
barmode="stack",
title="Horas de Voo por Aeronave — Histórico + Planejadas + Disponíveis",
xaxis_title="Aeronave", yaxis_title="Horas de Voo (FH)", height=800,
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
)
st.plotly_chart(fig_fh, use_container_width=True)
st.subheader("Horas disponíveis até a próxima manutenção")
disp_cols = st.columns(len(fleet_plot))
for col, (_, row) in zip(disp_cols, fleet_plot.iterrows()):
col.metric(
label=row["aircraft"],
value=f"{row['fh_disp']:.1f} h",
delta=f"limiar: {first_maint[row['aircraft']]['threshold']:.0f}h"
if row["aircraft"] in first_maint else None,
)
# ── Frota ─────────────────────────────────────────────────────────────
with sub_frota:
st.subheader("Frota Utilização das Aeronaves")
fleet_df = result["fleet"].copy()
# ── Métricas gerais ───────────────────────────────────────────────
avg_util = fleet_df["ttm_utilisation_pct"].mean()
total_fh = fleet_df["flight_hours_scheduled"].sum()
n_active = int((~fleet_df["idle"]).sum())
m1, m2, m3, m4 = st.columns(4)
m1.metric("Utilização Média TTM", f"{avg_util:.1f}%")
m2.metric("FH Totais Planejadas", f"{total_fh:.1f} h")
m3.metric("Aeronaves Ativas", f"{n_active} / {len(fleet_df)}")
m4.metric("FH Médias por Aeronave", f"{total_fh / len(fleet_df):.1f} h" if len(fleet_df) else "")
# ── Tabela com linha "Média" ───────────────────────────────────────
numeric_cols = ["initial_ttm_h", "flight_hours_scheduled", "ttm_utilisation_pct",
"n_ofrags_assigned", "n_maintenance_events", "total_ttm_loss_h"]
avg_row = {col: round(fleet_df[col].mean(), 2) for col in numeric_cols if col in fleet_df.columns}
avg_row["aircraft"] = "Média"
avg_row["model"] = ""
avg_row["idle"] = False
fleet_display = pd.concat(
[fleet_df, pd.DataFrame([avg_row])],
ignore_index=True,
)
rename_fleet = {
"aircraft": "Aeronave",
"model": "Modelo",
"initial_ttm_h": "TTM Inicial (h)",
"flight_hours_scheduled": "FH Planejadas",
"ttm_utilisation_pct": "Utilização TTM (%)",
"n_ofrags_assigned": "OFRAGs Atribuídas",
"n_maintenance_events": "Eventos Manut.",
"total_ttm_loss_h": "FH Perdidas",
"idle": "Ociosa",
}
def _style_frota(row):
if row.get("Aeronave") == "Média":
return ["font-weight:bold"] * len(row)
return [""] * len(row)
st.dataframe(
fleet_display.rename(columns=rename_fleet)
.style.apply(_style_frota, axis=1),
use_container_width=True,
)
# ── Gráfico: utilização TTM ───────────────────────────────────────
fig_util = px.bar(
fleet_df,
x="aircraft",
y="ttm_utilisation_pct",
title="Utilização do TTM por aeronave (%)",
labels={"ttm_utilisation_pct": "Utilização TTM (%)", "aircraft": "Aeronave"},
text_auto=".1f",
color="ttm_utilisation_pct",
color_continuous_scale="RdYlGn",
range_color=[0, 100],
)
fig_util.add_hline(
y=avg_util,
line_dash="dash",
line_color="navy",
annotation_text=f"Média: {avg_util:.1f}%",
annotation_position="top right",
)
st.plotly_chart(fig_util, use_container_width=True)
# ── Gráfico: FH detalhado ─────────────────────────────────────────
fig_fh = px.bar(
fleet_df,
x="aircraft",
y=["initial_ttm_h", "flight_hours_scheduled", "total_ttm_loss_h"],
barmode="group",
title="TTM inicial vs FH planejadas vs FH perdidas",
labels={"value": "Horas de voo", "aircraft": "Aeronave", "variable": ""},
)
fig_fh.update_layout(
legend=dict(
orientation="h",
yanchor="bottom",
y=1.02,
xanchor="right",
x=1,
)
)
st.plotly_chart(fig_fh, use_container_width=True)
# ── Mapa ──────────────────────────────────────────────────────────────
with sub_mapa:
st.subheader("Rotas por Aeronave")
if _sched_result.empty:
st.info("Sem escala otimizada disponível.")
else:
import airportsdata as _apdata
_icao_db = _apdata.load("ICAO")
def _coord(icao: str):
ap = _icao_db.get(icao.strip().upper())
return {"_lat": ap["lat"], "_lon": ap["lon"]} if ap else None
# Usa exatamente os campos DEP/ARR da Escala com Atribuição
legs_atrib = raw_sched_atrib[raw_sched_atrib["AERONAVE"] != ""].copy()
import folium
from streamlit_folium import st_folium
_palette_hex = [
"#e41a1c", "#377eb8", "#4daf4a", "#984ea3",
"#ff7f00", "#a65628", "#f781bf", "#999999",
]
m = folium.Map(location=[-8, -60], zoom_start=4, tiles=None)
folium.TileLayer(
tiles="CartoDB positron",
name="Mapa base",
control=True,
).add_to(m)
folium.TileLayer(
tiles="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
attr="Esri World Imagery",
name="Satélite",
control=True,
).add_to(m)
folium.LayerControl(position="topright").add_to(m)
# Linhas de rota por aeronave
for i, (ac, legs) in enumerate(legs_atrib.groupby("AERONAVE")):
color = _palette_hex[i % len(_palette_hex)]
for _, leg in legs.sort_values(["OFRAG", "ETAPA"]).iterrows():
dep = str(leg["DEP"]).strip().upper()
arr = str(leg["ARR"]).strip().upper()
c_dep = _coord(dep)
c_arr = _coord(arr)
if c_dep and c_arr:
folium.PolyLine(
[(c_dep["_lat"], c_dep["_lon"]), (c_arr["_lat"], c_arr["_lon"])],
color=color,
weight=2.5,
opacity=0.85,
tooltip=f"{ac}: {dep}{arr}",
).add_to(m)
# Bases de manutenção a partir da tabela de aeroportos
_ap_df = st.session_state["df_airports"].copy()
_ap_df.columns = [c.strip().upper() for c in _ap_df.columns]
_base_col = next((c for c in _ap_df.columns if "MAINTENANCE" in c or "BASE" in c), None)
_code_col = next((c for c in _ap_df.columns if "CODE" in c or "ICAO" in c), None)
_maint_bases: set = set()
if _base_col and _code_col:
_mask = _ap_df[_base_col].astype(str).str.strip().isin(["1", "True", "true"])
_maint_bases = set(_ap_df.loc[_mask, _code_col].str.strip().str.upper())
# Marcadores dos aeroportos usados
used_codes = set(
legs_atrib["DEP"].str.strip().str.upper().tolist()
+ legs_atrib["ARR"].str.strip().str.upper().tolist()
)
for code in used_codes:
c = _coord(code)
if c:
is_base = code in _maint_bases
folium.CircleMarker(
location=[c["_lat"], c["_lon"]],
radius=10 if is_base else 5,
color="#f59e0b" if is_base else "#1e293b",
weight=2.5 if is_base else 1.5,
fill=True,
fill_color="#f59e0b" if is_base else "#1e293b",
fill_opacity=0.95,
tooltip=f"🔧 Base de manutenção: {code}" if is_base else code,
popup=f"<b>{code}</b><br>Base de manutenção" if is_base else code,
).add_to(m)
st_folium(m, height=700, use_container_width=True)

232
app/dashboard_backup.py Normal file
View File

@@ -0,0 +1,232 @@
"""
Aircraft Routing Dashboard Streamlit application.
Run with: streamlit run app/dashboard.py (from project root)
"""
from __future__ import annotations
import sys
from pathlib import Path
# Allow imports from src/ when running from project root
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import streamlit as st
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
from datetime import timedelta
from src.routing_engine import RoutingPipeline, DEFAULT_CONFIG
# ── Page config ───────────────────────────────────────────────────────────────
st.set_page_config(
page_title="OARMP Aircraft Routing",
page_icon="",
layout="wide",
)
st.title("✈ Aircraft Routing & Maintenance Planning (OARMP)")
st.caption("Set Partitioning + Column Generation + Branch & Bound")
# ── Sidebar parameters ──────────────────────────────────────────────────────
with st.sidebar:
st.header("⚙ Parameters")
tat = st.number_input("TAT mínimo (min)", min_value=0, max_value=240, value=60, step=10)
year = st.number_input("Ano do planejamento", min_value=2024, max_value=2030, value=2026)
time_limit = st.number_input("Limite tempo solver (s)", min_value=30, max_value=600, value=120)
run_btn = st.button("▶ Executar Otimização", type="primary")
# ── Session state ─────────────────────────────────────────────────────────────
if "result" not in st.session_state:
st.session_state["result"] = None
# ── Run pipeline ──────────────────────────────────────────────────────────────
if run_btn:
cfg = DEFAULT_CONFIG
cfg.tat_minutes = tat
cfg.planning_year = int(year)
cfg.mip_time_limit_seconds = int(time_limit)
with st.spinner("Executando otimização…"):
try:
pipe = RoutingPipeline(cfg)
st.session_state["result"] = pipe.run(save_outputs=True)
st.success("Otimização concluída!")
except Exception as exc:
st.error(f"Erro: {exc}")
st.exception(exc)
result = st.session_state.get("result")
# ── Tabs ──────────────────────────────────────────────────────────────────────
tab_summary, tab_gantt, tab_maint, tab_fleet, tab_ofrags = st.tabs(
["📊 Resumo", "📅 Gantt", "🔧 Manutenção", "✈ Frota", "📋 OFRAGs"]
)
# ── Tab: Summary ──────────────────────────────────────────────────────────────
with tab_summary:
if result is None:
st.info("Execute a otimização para ver os resultados.")
else:
s = result["summary"]
c1, c2, c3, c4 = st.columns(4)
c1.metric("Status", s["status"])
c2.metric("Objetivo (FH perdidas)", f"{s['total_ttm_loss_hours']:.2f} h")
c3.metric("OFRAGs cobertas", f"{s['covered_ofrags']} / {s['total_ofrags']}")
c4.metric("Eventos de manutenção", s["n_maintenance_events"])
if s["uncovered_ofrags"]:
st.warning(f"OFRAGs NÃO cobertas: {s['uncovered_ofrags']}")
st.subheader("Escala de voo otimizada")
sched = result["schedule"]
if not sched.empty:
st.dataframe(
sched.style.apply(
lambda r: ["background-color:#fff3cd" if r.get("maintenance_before") else "" for _ in r],
axis=1,
),
use_container_width=True,
)
st.caption(f"Colunas geradas no CG: {s['columns_generated']}")
# ── Tab: Gantt ────────────────────────────────────────────────────────────────
with tab_gantt:
if result is None:
st.info("Execute a otimização para ver o diagrama de Gantt.")
else:
sched = result["schedule"]
maint = result["maintenance"]
gantt_rows = []
if not sched.empty:
for _, row in sched.iterrows():
if pd.isna(row.get("departure")) or pd.isna(row.get("arrival")):
continue
gantt_rows.append(
dict(
Task=row["aircraft"],
Start=row["departure"],
Finish=row["arrival"],
Resource=row["ofrag_id"],
Type="OFRAG",
Label=row["ofrag_id"],
)
)
if not maint.empty:
for _, row in maint.iterrows():
if pd.isna(row.get("maint_start")) or pd.isna(row.get("maint_end")):
continue
gantt_rows.append(
dict(
Task=row["aircraft"],
Start=row["maint_start"],
Finish=row["maint_end"],
Resource="Manutenção",
Type="Maintenance",
Label=f"CHECK (perde {row['ttm_loss_hours']:.1f}h)",
)
)
if gantt_rows:
df_gantt = pd.DataFrame(gantt_rows)
color_map = {"Manutenção": "#636EFA"}
fig = px.timeline(
df_gantt,
x_start="Start",
x_end="Finish",
y="Task",
color="Type",
text="Label",
title="Escala de Voo e Manutenção por Aeronave",
color_discrete_map={"OFRAG": "#00CC96", "Maintenance": "#EF553B"},
)
fig.update_yaxes(categoryorder="category ascending")
fig.update_traces(textposition="inside")
fig.update_layout(height=400 + len(sched["aircraft"].unique()) * 40)
st.plotly_chart(fig, use_container_width=True)
cfg_obj = DEFAULT_CONFIG
try:
fig.write_html(str(cfg_obj.figures_dir / "gantt.html"))
except Exception:
pass
else:
st.info("Nenhum dado de Gantt disponível.")
# ── Tab: Maintenance ──────────────────────────────────────────────────────────
with tab_maint:
if result is None:
st.info("Execute a otimização para ver eventos de manutenção.")
else:
maint = result["maintenance"]
if maint.empty:
st.success("Nenhum evento de manutenção forçado.")
else:
st.dataframe(maint, use_container_width=True)
fig_loss = px.bar(
maint,
x="aircraft",
y="ttm_loss_hours",
color="check_cycle_index",
title="Horas de TTM perdidas por aeronave / ciclo de check",
labels={"ttm_loss_hours": "TTM perdido (h)", "aircraft": "Aeronave"},
text_auto=".2f",
)
st.plotly_chart(fig_loss, use_container_width=True)
# ── Tab: Fleet ────────────────────────────────────────────────────────────────
with tab_fleet:
if result is None:
st.info("Execute a otimização.")
else:
fleet_df = result["fleet"]
st.dataframe(fleet_df, use_container_width=True)
fig_util = px.bar(
fleet_df,
x="aircraft",
y="ttm_utilisation_pct",
title="Utilização do TTM por aeronave (%)",
labels={"ttm_utilisation_pct": "Utilização TTM (%)", "aircraft": "Aeronave"},
text_auto=".1f",
color="ttm_utilisation_pct",
color_continuous_scale="RdYlGn",
range_color=[0, 100],
)
st.plotly_chart(fig_util, use_container_width=True)
fig_fh = px.bar(
fleet_df,
x="aircraft",
y=["initial_ttm_h", "flight_hours_scheduled", "total_ttm_loss_h"],
barmode="group",
title="TTM inicial vs FH planejadas vs FH perdidas",
labels={"value": "Horas de voo", "aircraft": "Aeronave"},
)
st.plotly_chart(fig_fh, use_container_width=True)
# ── Tab: OFRAGs ───────────────────────────────────────────────────────────────
with tab_ofrags:
if result is None:
st.info("Execute a otimização.")
else:
ofrags = result["ofrags"]
st.write(f"**{len(ofrags)} OFRAGs** elegíveis (iniciam e terminam na base de manutenção).")
cols_show = [c for c in ["ofrag_id", "departure", "arrival", "flight_hours", "n_legs", "missions", "origin", "destination"] if c in ofrags.columns]
st.dataframe(ofrags[cols_show], use_container_width=True)
fig_fh = px.bar(
ofrags,
x="ofrag_id",
y="flight_hours",
title="Horas de voo por OFRAG",
labels={"flight_hours": "FH total", "ofrag_id": "OFRAG"},
text_auto=".2f",
)
st.plotly_chart(fig_fh, use_container_width=True)

View File

@@ -1 +0,0 @@

View File

@@ -1,19 +0,0 @@
seq;sigla_mnt;descricao_mnt;referencia;tipo_vencimento;zera;controle_original;intervalo_horas_voo;intervalo_meses_continuos;intervalo_pousos;intervalos
2;INSP 1A;INSPEÇÃO CHECK 1A;Especial (a contar dela mesma - Tipo D);Término da Anterior;N;A B 3 3 D;300:00 HORAS DE VOO;8 MESES CONTÍNUOS;;300:00 HORAS DE VOO | 8 MESES CONTÍNUOS
3;INSP 2A;INSPEÇÃO CHECK 2A;Especial (a contar dela mesma - Tipo D);Término da Anterior;N;A B 10 4 D;600:00 HORAS DE VOO;16 MESES CONTÍNUOS;;600:00 HORAS DE VOO | 16 MESES CONTÍNUOS
4;INSP 3A;INSPEÇÃO CHECK 3A;Especial (a contar dela mesma - Tipo D);Término da Anterior;N;A B 10 5 D;900:00 HORAS DE VOO;24 MESES CONTÍNUOS;;900:00 HORAS DE VOO | 24 MESES CONTÍNUOS
5;INSP 2Y;INSPEÇÃO CALENDÁRICA DE 2 YEARS;Especial (a contar dela mesma - Tipo D);Término da Anterior;N;B B 5 5 D;;24 MESES CONTÍNUOS;;24 MESES CONTÍNUOS
6;CHECK 300;CHECK OPERACIONAL DE 300FH;Especial (a contar dela mesma - Tipo D);Término da Anterior;N;A O 6 H;300:00 HORAS DE VOO;;;300:00 HORAS DE VOO
7;CHECK 400;CHECK OPERACIONAL DE 400FH;Especial (a contar dela mesma - Tipo D);Término da Anterior;N;A O 6 H;400:00 HORAS DE VOO;;;400:00 HORAS DE VOO
8;1-INSP AOL;INSPEÇÃO AOL 295-019;Última inspeção;Término da Anterior;N;B 1 D;;;3800 POUSOS;3800 POUSOS
9;2-INSP AOL;INSPEÇÃO AOL 295-019;Última Inspeção Específica 8 1-INSP AOL;Término da Anterior;N;B 1 D;;;2000 POUSOS;2000 POUSOS
10;3-INSP AOL;INSPEÇÃO AOL 295-019;Última Inspeção Específica 9 2-INSP AOL;Término da Anterior;N;B 1 D;;;2000 POUSOS;2000 POUSOS
14;INSP 8Y;INSPEÇÃO CALENDÁRICA DE 8 YEARS;Especial (a contar dela mesma - Tipo D);Término da Anterior;N;B P 15 20 D;;96 MESES CONTÍNUOS;;96 MESES CONTÍNUOS
15;INSP 1C;INSPEÇÃO CHECK 1C;Especial (a contar dela mesma - Tipo D);Término da Anterior;N;C P 10 65 D;2400:00 HORAS DE VOO;48 MESES CONTÍNUOS;;2400:00 HORAS DE VOO | 48 MESES CONTÍNUOS
16;INSP 2C;INSPEÇÃO GERAL CHECK 2C;Especial (a contar dela mesma - Tipo D);Término da Anterior;N;C P 60 10 D;4800:00 HORAS DE VOO;96 MESES CONTÍNUOS;;4800:00 HORAS DE VOO | 96 MESES CONTÍNUOS
17;INSP 4Y;INSPEÇÃO CALENDÁRICA DE 4 YEARS;Especial (a contar dela mesma - Tipo D);Término da Anterior;N;B P 10 10 D;;48 MESES CONTÍNUOS;;48 MESES CONTÍNUOS
18;INSP 100FH BR01;INSPEÇÃO DE 100 FH;Especial (a contar dela mesma - Tipo D);Término da Anterior;N;O 1 H;100:00 HORAS DE VOO;;;100:00 HORAS DE VOO
19;INSP 2400F BR01;INPEÇÃO 2400FH;Especial (a contar dela mesma - Tipo D);Término da Anterior;N;O 3 H;2400:00 HORAS DE VOO;;;2400:00 HORAS DE VOO
20;INSP 3000F BR01;INSPEÇÃO 3000FH;Especial (a contar dela mesma - Tipo D);Término da Anterior;N;B 1 D;3000:00 HORAS DE VOO;;;3000:00 HORAS DE VOO
22;INSP 1000F;INPEÇÃO 1000FH;Especial (a contar dela mesma - Tipo D);Término da Anterior;N;B 1 D;1000:00 HORAS DE VOO;;;1000:00 HORAS DE VOO
23;INSP 50FH;INSPEÇÃO 50 FH;Especial (a contar dela mesma - Tipo D);Término da Anterior;N;B 1 D;50:00 HORAS DE VOO;;;50:00 HORAS DE VOO
1 seq sigla_mnt descricao_mnt referencia tipo_vencimento zera controle_original intervalo_horas_voo intervalo_meses_continuos intervalo_pousos intervalos
2 2 INSP 1A INSPEÇÃO CHECK 1A Especial (a contar dela mesma - Tipo D) Término da Anterior N A B 3 3 D 300:00 HORAS DE VOO 8 MESES CONTÍNUOS 300:00 HORAS DE VOO | 8 MESES CONTÍNUOS
3 3 INSP 2A INSPEÇÃO CHECK 2A Especial (a contar dela mesma - Tipo D) Término da Anterior N A B 10 4 D 600:00 HORAS DE VOO 16 MESES CONTÍNUOS 600:00 HORAS DE VOO | 16 MESES CONTÍNUOS
4 4 INSP 3A INSPEÇÃO CHECK 3A Especial (a contar dela mesma - Tipo D) Término da Anterior N A B 10 5 D 900:00 HORAS DE VOO 24 MESES CONTÍNUOS 900:00 HORAS DE VOO | 24 MESES CONTÍNUOS
5 5 INSP 2Y INSPEÇÃO CALENDÁRICA DE 2 YEARS Especial (a contar dela mesma - Tipo D) Término da Anterior N B B 5 5 D 24 MESES CONTÍNUOS 24 MESES CONTÍNUOS
6 6 CHECK 300 CHECK OPERACIONAL DE 300FH Especial (a contar dela mesma - Tipo D) Término da Anterior N A O 6 H 300:00 HORAS DE VOO 300:00 HORAS DE VOO
7 7 CHECK 400 CHECK OPERACIONAL DE 400FH Especial (a contar dela mesma - Tipo D) Término da Anterior N A O 6 H 400:00 HORAS DE VOO 400:00 HORAS DE VOO
8 8 1-INSP AOL INSPEÇÃO AOL 295-019 Última inspeção Término da Anterior N B 1 D 3800 POUSOS 3800 POUSOS
9 9 2-INSP AOL INSPEÇÃO AOL 295-019 Última Inspeção Específica 8 1-INSP AOL Término da Anterior N B 1 D 2000 POUSOS 2000 POUSOS
10 10 3-INSP AOL INSPEÇÃO AOL 295-019 Última Inspeção Específica 9 2-INSP AOL Término da Anterior N B 1 D 2000 POUSOS 2000 POUSOS
11 14 INSP 8Y INSPEÇÃO CALENDÁRICA DE 8 YEARS Especial (a contar dela mesma - Tipo D) Término da Anterior N B P 15 20 D 96 MESES CONTÍNUOS 96 MESES CONTÍNUOS
12 15 INSP 1C INSPEÇÃO CHECK 1C Especial (a contar dela mesma - Tipo D) Término da Anterior N C P 10 65 D 2400:00 HORAS DE VOO 48 MESES CONTÍNUOS 2400:00 HORAS DE VOO | 48 MESES CONTÍNUOS
13 16 INSP 2C INSPEÇÃO GERAL CHECK 2C Especial (a contar dela mesma - Tipo D) Término da Anterior N C P 60 10 D 4800:00 HORAS DE VOO 96 MESES CONTÍNUOS 4800:00 HORAS DE VOO | 96 MESES CONTÍNUOS
14 17 INSP 4Y INSPEÇÃO CALENDÁRICA DE 4 YEARS Especial (a contar dela mesma - Tipo D) Término da Anterior N B P 10 10 D 48 MESES CONTÍNUOS 48 MESES CONTÍNUOS
15 18 INSP 100FH BR01 INSPEÇÃO DE 100 FH Especial (a contar dela mesma - Tipo D) Término da Anterior N O 1 H 100:00 HORAS DE VOO 100:00 HORAS DE VOO
16 19 INSP 2400F BR01 INPEÇÃO 2400FH Especial (a contar dela mesma - Tipo D) Término da Anterior N O 3 H 2400:00 HORAS DE VOO 2400:00 HORAS DE VOO
17 20 INSP 3000F BR01 INSPEÇÃO 3000FH Especial (a contar dela mesma - Tipo D) Término da Anterior N B 1 D 3000:00 HORAS DE VOO 3000:00 HORAS DE VOO
18 22 INSP 1000F INPEÇÃO 1000FH Especial (a contar dela mesma - Tipo D) Término da Anterior N B 1 D 1000:00 HORAS DE VOO 1000:00 HORAS DE VOO
19 23 INSP 50FH INSPEÇÃO 50 FH Especial (a contar dela mesma - Tipo D) Término da Anterior N B 1 D 50:00 HORAS DE VOO 50:00 HORAS DE VOO

View File

@@ -1,309 +0,0 @@
{
"fonte": "raw\\documento joaomarcos.pdf",
"metadados": {
"data_relatorio": "15/06/2026",
"hora_relatorio": "13:23:38",
"pn": "ANV C-105",
"cff": "0117B",
"nomenclatura": "AERONAVE, ASA FIXA, CASA 295 (C-105 AMAZONAS)",
"sn": "038",
"matricula": "2805",
"modelo": "C-105",
"ciclo_atual": "3650007082"
},
"inspecoes": [
{
"seq": 2,
"sigla_mnt": "INSP 1A",
"descricao_mnt": "INSPEÇÃO CHECK 1A",
"referencia": "Especial (a contar dela mesma - Tipo D)",
"tipo_vencimento": "Término da Anterior",
"zera": "N",
"controle_original": "A B 3 3 D",
"intervalo_horas_voo": "300:00 HORAS DE VOO",
"intervalo_meses_continuos": "8 MESES CONTÍNUOS",
"intervalo_pousos": "",
"intervalos": [
"300:00 HORAS DE VOO",
"8 MESES CONTÍNUOS"
],
"linha_original": "2 INSP 1A INSPEÇÃO CHECK 1A Especial (a contar dela mesma Término da Anterior N A B 3 3 D 300:00 HORAS DE VÔO - Tipo D) 8 MESES CONTÍNUOS"
},
{
"seq": 3,
"sigla_mnt": "INSP 2A",
"descricao_mnt": "INSPEÇÃO CHECK 2A",
"referencia": "Especial (a contar dela mesma - Tipo D)",
"tipo_vencimento": "Término da Anterior",
"zera": "N",
"controle_original": "A B 10 4 D",
"intervalo_horas_voo": "600:00 HORAS DE VOO",
"intervalo_meses_continuos": "16 MESES CONTÍNUOS",
"intervalo_pousos": "",
"intervalos": [
"600:00 HORAS DE VOO",
"16 MESES CONTÍNUOS"
],
"linha_original": "3 INSP 2A INSPEÇÃO CHECK 2A Especial (a contar dela mesma Término da Anterior N A B 10 4 D 600:00 HORAS DE VÔO - Tipo D) 16 MESES CONTÍNUOS"
},
{
"seq": 4,
"sigla_mnt": "INSP 3A",
"descricao_mnt": "INSPEÇÃO CHECK 3A",
"referencia": "Especial (a contar dela mesma - Tipo D)",
"tipo_vencimento": "Término da Anterior",
"zera": "N",
"controle_original": "A B 10 5 D",
"intervalo_horas_voo": "900:00 HORAS DE VOO",
"intervalo_meses_continuos": "24 MESES CONTÍNUOS",
"intervalo_pousos": "",
"intervalos": [
"900:00 HORAS DE VOO",
"24 MESES CONTÍNUOS"
],
"linha_original": "4 INSP 3A INSPEÇÃO CHECK 3A Especial (a contar dela mesma Término da Anterior N A B 10 5 D 900:00 HORAS DE VÔO - Tipo D) 24 MESES CONTÍNUOS"
},
{
"seq": 5,
"sigla_mnt": "INSP 2Y",
"descricao_mnt": "INSPEÇÃO CALENDÁRICA DE 2 YEARS",
"referencia": "Especial (a contar dela mesma - Tipo D)",
"tipo_vencimento": "Término da Anterior",
"zera": "N",
"controle_original": "B B 5 5 D",
"intervalo_horas_voo": "",
"intervalo_meses_continuos": "24 MESES CONTÍNUOS",
"intervalo_pousos": "",
"intervalos": [
"24 MESES CONTÍNUOS"
],
"linha_original": "5 INSP 2Y INSPEÇÃO CALENDÁRICA DE 2 YEARS Especial (a contar dela mesma Término da Anterior N B B 5 5 D 24 MESES CONTÍNUOS - Tipo D)"
},
{
"seq": 6,
"sigla_mnt": "CHECK 300",
"descricao_mnt": "CHECK OPERACIONAL DE 300FH",
"referencia": "Especial (a contar dela mesma - Tipo D)",
"tipo_vencimento": "Término da Anterior",
"zera": "N",
"controle_original": "A O 6 H",
"intervalo_horas_voo": "300:00 HORAS DE VOO",
"intervalo_meses_continuos": "",
"intervalo_pousos": "",
"intervalos": [
"300:00 HORAS DE VOO"
],
"linha_original": "6 CHECK 300 CHECK OPERACIONAL DE 300FH Especial (a contar dela mesma Término da Anterior N A O 6 H 300:00 HORAS DE VÔO - Tipo D)"
},
{
"seq": 7,
"sigla_mnt": "CHECK 400",
"descricao_mnt": "CHECK OPERACIONAL DE 400FH",
"referencia": "Especial (a contar dela mesma - Tipo D)",
"tipo_vencimento": "Término da Anterior",
"zera": "N",
"controle_original": "A O 6 H",
"intervalo_horas_voo": "400:00 HORAS DE VOO",
"intervalo_meses_continuos": "",
"intervalo_pousos": "",
"intervalos": [
"400:00 HORAS DE VOO"
],
"linha_original": "7 CHECK 400 CHECK OPERACIONAL DE 400FH Especial (a contar dela mesma Término da Anterior N A O 6 H 400:00 HORAS DE VÔO - Tipo D)"
},
{
"seq": 8,
"sigla_mnt": "1-INSP AOL",
"descricao_mnt": "INSPEÇÃO AOL 295-019",
"referencia": "Última inspeção",
"tipo_vencimento": "Término da Anterior",
"zera": "N",
"controle_original": "B 1 D",
"intervalo_horas_voo": "",
"intervalo_meses_continuos": "",
"intervalo_pousos": "3800 POUSOS",
"intervalos": [
"3800 POUSOS"
],
"linha_original": "8 1-INSP AOL INSPEÇÃO AOL 295-019 Última inspeção Término da Anterior N B 1 D 3800 POUSOS"
},
{
"seq": 9,
"sigla_mnt": "2-INSP AOL",
"descricao_mnt": "INSPEÇÃO AOL 295-019",
"referencia": "Última Inspeção Específica 8 1-INSP AOL",
"tipo_vencimento": "Término da Anterior",
"zera": "N",
"controle_original": "B 1 D",
"intervalo_horas_voo": "",
"intervalo_meses_continuos": "",
"intervalo_pousos": "2000 POUSOS",
"intervalos": [
"2000 POUSOS"
],
"linha_original": "9 2-INSP AOL INSPEÇÃO AOL 295-019 Última Inspeção Específica 8 1-INSP AOL Término da Anterior N B 1 D 2000 POUSOS"
},
{
"seq": 10,
"sigla_mnt": "3-INSP AOL",
"descricao_mnt": "INSPEÇÃO AOL 295-019",
"referencia": "Última Inspeção Específica 9 2-INSP AOL",
"tipo_vencimento": "Término da Anterior",
"zera": "N",
"controle_original": "B 1 D",
"intervalo_horas_voo": "",
"intervalo_meses_continuos": "",
"intervalo_pousos": "2000 POUSOS",
"intervalos": [
"2000 POUSOS"
],
"linha_original": "10 3-INSP AOL INSPEÇÃO AOL 295-019 Última Inspeção Específica 9 2-INSP AOL Término da Anterior N B 1 D 2000 POUSOS"
},
{
"seq": 14,
"sigla_mnt": "INSP 8Y",
"descricao_mnt": "INSPEÇÃO CALENDÁRICA DE 8 YEARS",
"referencia": "Especial (a contar dela mesma - Tipo D)",
"tipo_vencimento": "Término da Anterior",
"zera": "N",
"controle_original": "B P 15 20 D",
"intervalo_horas_voo": "",
"intervalo_meses_continuos": "96 MESES CONTÍNUOS",
"intervalo_pousos": "",
"intervalos": [
"96 MESES CONTÍNUOS"
],
"linha_original": "14 INSP 8Y INSPEÇÃO CALENDÁRICA DE 8 YEARS Especial (a contar dela mesma Término da Anterior N B P 15 20 D 96 MESES CONTÍNUOS - Tipo D)"
},
{
"seq": 15,
"sigla_mnt": "INSP 1C",
"descricao_mnt": "INSPEÇÃO CHECK 1C",
"referencia": "Especial (a contar dela mesma - Tipo D)",
"tipo_vencimento": "Término da Anterior",
"zera": "N",
"controle_original": "C P 10 65 D",
"intervalo_horas_voo": "2400:00 HORAS DE VOO",
"intervalo_meses_continuos": "48 MESES CONTÍNUOS",
"intervalo_pousos": "",
"intervalos": [
"2400:00 HORAS DE VOO",
"48 MESES CONTÍNUOS"
],
"linha_original": "15 INSP 1C INSPEÇÃO CHECK 1C Especial (a contar dela mesma Término da Anterior N C P 10 65 D 2400:00 HORAS DE VÔO - Tipo D) 48 MESES CONTÍNUOS"
},
{
"seq": 16,
"sigla_mnt": "INSP 2C",
"descricao_mnt": "INSPEÇÃO GERAL CHECK 2C",
"referencia": "Especial (a contar dela mesma - Tipo D)",
"tipo_vencimento": "Término da Anterior",
"zera": "N",
"controle_original": "C P 60 10 D",
"intervalo_horas_voo": "4800:00 HORAS DE VOO",
"intervalo_meses_continuos": "96 MESES CONTÍNUOS",
"intervalo_pousos": "",
"intervalos": [
"4800:00 HORAS DE VOO",
"96 MESES CONTÍNUOS"
],
"linha_original": "16 INSP 2C INSPEÇÃO GERAL CHECK 2C Especial (a contar dela mesma Término da Anterior N C P 60 10 D 4800:00 HORAS DE VÔO - Tipo D) 96 MESES CONTÍNUOS"
},
{
"seq": 17,
"sigla_mnt": "INSP 4Y",
"descricao_mnt": "INSPEÇÃO CALENDÁRICA DE 4 YEARS",
"referencia": "Especial (a contar dela mesma - Tipo D)",
"tipo_vencimento": "Término da Anterior",
"zera": "N",
"controle_original": "B P 10 10 D",
"intervalo_horas_voo": "",
"intervalo_meses_continuos": "48 MESES CONTÍNUOS",
"intervalo_pousos": "",
"intervalos": [
"48 MESES CONTÍNUOS"
],
"linha_original": "17 INSP 4Y INSPEÇÃO CALENDÁRICA DE 4 YEARS Especial (a contar dela mesma Término da Anterior N B P 10 10 D 48 MESES CONTÍNUOS - Tipo D)"
},
{
"seq": 18,
"sigla_mnt": "INSP 100FH BR01",
"descricao_mnt": "INSPEÇÃO DE 100 FH",
"referencia": "Especial (a contar dela mesma - Tipo D)",
"tipo_vencimento": "Término da Anterior",
"zera": "N",
"controle_original": "O 1 H",
"intervalo_horas_voo": "100:00 HORAS DE VOO",
"intervalo_meses_continuos": "",
"intervalo_pousos": "",
"intervalos": [
"100:00 HORAS DE VOO"
],
"linha_original": "18 INSP 100FH BR01 INSPEÇÃO DE 100 FH Especial (a contar dela mesma Término da Anterior N O 1 H 100:00 HORAS DE VÔO - Tipo D)"
},
{
"seq": 19,
"sigla_mnt": "INSP 2400F BR01",
"descricao_mnt": "INPEÇÃO 2400FH",
"referencia": "Especial (a contar dela mesma - Tipo D)",
"tipo_vencimento": "Término da Anterior",
"zera": "N",
"controle_original": "O 3 H",
"intervalo_horas_voo": "2400:00 HORAS DE VOO",
"intervalo_meses_continuos": "",
"intervalo_pousos": "",
"intervalos": [
"2400:00 HORAS DE VOO"
],
"linha_original": "19 INSP 2400F BR01 INPEÇÃO 2400FH Especial (a contar dela mesma Término da Anterior N O 3 H 2400:00 HORAS DE VÔO - Tipo D)"
},
{
"seq": 20,
"sigla_mnt": "INSP 3000F BR01",
"descricao_mnt": "INSPEÇÃO 3000FH",
"referencia": "Especial (a contar dela mesma - Tipo D)",
"tipo_vencimento": "Término da Anterior",
"zera": "N",
"controle_original": "B 1 D",
"intervalo_horas_voo": "3000:00 HORAS DE VOO",
"intervalo_meses_continuos": "",
"intervalo_pousos": "",
"intervalos": [
"3000:00 HORAS DE VOO"
],
"linha_original": "20 INSP 3000F BR01 INSPEÇÃO 3000FH Especial (a contar dela mesma Término da Anterior N B 1 D 3000:00 HORAS DE VÔO - Tipo D)"
},
{
"seq": 22,
"sigla_mnt": "INSP 1000F",
"descricao_mnt": "INPEÇÃO 1000FH",
"referencia": "Especial (a contar dela mesma - Tipo D)",
"tipo_vencimento": "Término da Anterior",
"zera": "N",
"controle_original": "B 1 D",
"intervalo_horas_voo": "1000:00 HORAS DE VOO",
"intervalo_meses_continuos": "",
"intervalo_pousos": "",
"intervalos": [
"1000:00 HORAS DE VOO"
],
"linha_original": "22 INSP 1000F INPEÇÃO 1000FH Especial (a contar dela mesma Término da Anterior N B 1 D 1000:00 HORAS DE VÔO - Tipo D)"
},
{
"seq": 23,
"sigla_mnt": "INSP 50FH",
"descricao_mnt": "INSPEÇÃO 50 FH",
"referencia": "Especial (a contar dela mesma - Tipo D)",
"tipo_vencimento": "Término da Anterior",
"zera": "N",
"controle_original": "B 1 D",
"intervalo_horas_voo": "50:00 HORAS DE VOO",
"intervalo_meses_continuos": "",
"intervalo_pousos": "",
"intervalos": [
"50:00 HORAS DE VOO"
],
"linha_original": "23 INSP 50FH INSPEÇÃO 50 FH Especial (a contar dela mesma Término da Anterior N B 1 D 50:00 HORAS DE VÔO - Tipo D)"
}
]
}

View File

@@ -1,46 +0,0 @@
SISTEMA INTEGRADO DE LOGÍSTICA DE MATERIAL E DE SERVIÇOS Pág.: 1 de 1
BASE AÉREA DE MANAUS Data: 15/06/2026
Hora: 13:23:38
RELATÓRIO DE CICLO DE INSPEÇÕES DO EQUIPAMENTO
CTR0142R v.10.5
PN: ANV C-105 CFF: 0117B Nomenclatura: AERONAVE, ASA FIXA, CASA 295 (C-105 AMAZONAS)
SN: 038 Matrícula: 2805 Modelo: C-105 Ciclo Atual: 3650007082
Seq. Sigla da MNT Descrição da MNT Referência Referência Tipo"E" Vencimento Zera TSO Letra Nível Var. Média Duração Intervalo Controle
2 INSP 1A INSPEÇÃO CHECK 1A Especial (a contar dela mesma Término da Anterior N A B 3 3 D 300:00 HORAS DE VÔO
- Tipo D)
8 MESES CONTÍNUOS
3 INSP 2A INSPEÇÃO CHECK 2A Especial (a contar dela mesma Término da Anterior N A B 10 4 D 600:00 HORAS DE VÔO
- Tipo D)
16 MESES CONTÍNUOS
4 INSP 3A INSPEÇÃO CHECK 3A Especial (a contar dela mesma Término da Anterior N A B 10 5 D 900:00 HORAS DE VÔO
- Tipo D)
24 MESES CONTÍNUOS
5 INSP 2Y INSPEÇÃO CALENDÁRICA DE 2 YEARS Especial (a contar dela mesma Término da Anterior N B B 5 5 D 24 MESES CONTÍNUOS
- Tipo D)
6 CHECK 300 CHECK OPERACIONAL DE 300FH Especial (a contar dela mesma Término da Anterior N A O 6 H 300:00 HORAS DE VÔO
- Tipo D)
7 CHECK 400 CHECK OPERACIONAL DE 400FH Especial (a contar dela mesma Término da Anterior N A O 6 H 400:00 HORAS DE VÔO
- Tipo D)
8 1-INSP AOL INSPEÇÃO AOL 295-019 Última inspeção Término da Anterior N B 1 D 3800 POUSOS
9 2-INSP AOL INSPEÇÃO AOL 295-019 Última Inspeção Específica 8 1-INSP AOL Término da Anterior N B 1 D 2000 POUSOS
10 3-INSP AOL INSPEÇÃO AOL 295-019 Última Inspeção Específica 9 2-INSP AOL Término da Anterior N B 1 D 2000 POUSOS
14 INSP 8Y INSPEÇÃO CALENDÁRICA DE 8 YEARS Especial (a contar dela mesma Término da Anterior N B P 15 20 D 96 MESES CONTÍNUOS
- Tipo D)
15 INSP 1C INSPEÇÃO CHECK 1C Especial (a contar dela mesma Término da Anterior N C P 10 65 D 2400:00 HORAS DE VÔO
- Tipo D)
48 MESES CONTÍNUOS
16 INSP 2C INSPEÇÃO GERAL CHECK 2C Especial (a contar dela mesma Término da Anterior N C P 60 10 D 4800:00 HORAS DE VÔO
- Tipo D)
96 MESES CONTÍNUOS
17 INSP 4Y INSPEÇÃO CALENDÁRICA DE 4 YEARS Especial (a contar dela mesma Término da Anterior N B P 10 10 D 48 MESES CONTÍNUOS
- Tipo D)
18 INSP 100FH BR01 INSPEÇÃO DE 100 FH Especial (a contar dela mesma Término da Anterior N O 1 H 100:00 HORAS DE VÔO
- Tipo D)
19 INSP 2400F BR01 INPEÇÃO 2400FH Especial (a contar dela mesma Término da Anterior N O 3 H 2400:00 HORAS DE VÔO
- Tipo D)
20 INSP 3000F BR01 INSPEÇÃO 3000FH Especial (a contar dela mesma Término da Anterior N B 1 D 3000:00 HORAS DE VÔO
- Tipo D)
22 INSP 1000F INPEÇÃO 1000FH Especial (a contar dela mesma Término da Anterior N B 1 D 1000:00 HORAS DE VÔO
- Tipo D)
23 INSP 50FH INSPEÇÃO 50 FH Especial (a contar dela mesma Término da Anterior N B 1 D 50:00 HORAS DE VÔO
- Tipo D)

View File

@@ -1,181 +0,0 @@
import csv
import json
import re
from pathlib import Path
import pdfplumber
ROOT = Path(__file__).resolve().parents[1]
PDF_PATH = ROOT / "raw" / "documento joaomarcos.pdf"
TXT_PATH = ROOT / "pre_process" / "documento_joaomarcos_texto.txt"
JSON_PATH = ROOT / "pre_process" / "documento_joaomarcos_inspecoes.json"
CSV_PATH = ROOT / "pre_process" / "documento_joaomarcos_inspecoes.csv"
ROW_START_RE = re.compile(r"^(?P<seq>\d+)\s+")
INTERVAL_RE = re.compile(
r"(?P<intervalo>\d+(?::\d+)?\s*(?:HORAS DE V[ÔO]O|MESES CONT[ÍI]NUOS|POUSOS))",
re.IGNORECASE,
)
def extract_text(pdf_path):
pages = []
with pdfplumber.open(pdf_path) as pdf:
for page in pdf.pages:
pages.append(page.extract_text(x_tolerance=1, y_tolerance=3) or "")
return "\n\n".join(pages).strip()
def normalize_interval(value):
return re.sub(r"\s+", " ", value.replace("VÔO", "VOO")).strip()
def split_records(text):
records = []
current = []
for raw_line in text.splitlines():
line = re.sub(r"\s+", " ", raw_line).strip()
if not line:
continue
if ROW_START_RE.match(line) and " Término da Anterior " in line:
if current:
records.append(" ".join(current))
current = [line]
elif current:
current.append(line)
if current:
records.append(" ".join(current))
return records
def parse_header(text):
header = {}
patterns = {
"data_relatorio": r"Data:\s*(\d{2}/\d{2}/\d{4})",
"hora_relatorio": r"Hora:\s*(\d{2}:\d{2}:\d{2})",
"pn": r"PN:\s*(.*?)\s+CFF:",
"cff": r"CFF:\s*(.*?)\s+Nomenclatura:",
"nomenclatura": r"Nomenclatura:\s*(.*?)\s+SN:",
"sn": r"SN:\s*(.*?)\s+Matrícula:",
"matricula": r"Matrícula:\s*(.*?)\s+Modelo:",
"modelo": r"Modelo:\s*(.*?)\s+Ciclo Atual:",
"ciclo_atual": r"Ciclo Atual:\s*(\S+)",
}
compact_text = re.sub(r"\s+", " ", text)
for key, pattern in patterns.items():
match = re.search(pattern, compact_text)
if match:
header[key] = match.group(1).strip()
return header
def parse_record(record):
record = record.replace("- Tipo D)", " - Tipo D)")
has_tipo_d = " - Tipo D)" in record
left, right = record.split(" Término da Anterior ", 1)
seq_match = ROW_START_RE.match(left)
seq = int(seq_match.group("seq"))
left_body = left[seq_match.end() :]
intervalos = [normalize_interval(m.group("intervalo")) for m in INTERVAL_RE.finditer(right)]
right_before_interval = INTERVAL_RE.split(right, maxsplit=1)[0].strip()
tokens = right_before_interval.split()
zera = tokens[0] if tokens else ""
controle = " ".join(tokens[1:])
especial_marker = " Especial (a contar dela mesma"
if especial_marker in left_body:
before_ref, referencia = left_body.split(especial_marker, 1)
referencia = "Especial (a contar dela mesma"
if has_tipo_d:
referencia += " - Tipo D)"
else:
ref_match = re.search(r"(Última .*)$", left_body)
referencia = ref_match.group(1).strip() if ref_match else ""
before_ref = left_body[: ref_match.start()].strip() if ref_match else left_body
upper_before_ref = before_ref.upper()
desc_idx = upper_before_ref.find("INSPEÇÃO")
if desc_idx == -1:
desc_idx = upper_before_ref.find("INPEÇÃO")
if desc_idx == -1:
desc_idx = upper_before_ref.find("CHECK OPERACIONAL")
if desc_idx != -1:
sigla = before_ref[:desc_idx].strip()
descricao = before_ref[desc_idx:].strip()
else:
parts = before_ref.split(maxsplit=1)
sigla = parts[0] if parts else ""
descricao = parts[1] if len(parts) > 1 else ""
intervalo_horas_voo = next((item for item in intervalos if "HORAS DE VOO" in item), "")
intervalo_meses_continuos = next((item for item in intervalos if "MESES CONTÍNUOS" in item), "")
intervalo_pousos = next((item for item in intervalos if "POUSOS" in item), "")
return {
"seq": seq,
"sigla_mnt": sigla,
"descricao_mnt": descricao,
"referencia": referencia,
"tipo_vencimento": "Término da Anterior",
"zera": zera,
"controle_original": controle,
"intervalo_horas_voo": intervalo_horas_voo,
"intervalo_meses_continuos": intervalo_meses_continuos,
"intervalo_pousos": intervalo_pousos,
"intervalos": intervalos,
"linha_original": record,
}
def main():
text = extract_text(PDF_PATH)
TXT_PATH.write_text(text + "\n", encoding="utf-8")
payload = {
"fonte": str(PDF_PATH.relative_to(ROOT)),
"metadados": parse_header(text),
"inspecoes": [parse_record(record) for record in split_records(text)],
}
JSON_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
fields = [
"seq",
"sigla_mnt",
"descricao_mnt",
"referencia",
"tipo_vencimento",
"zera",
"controle_original",
"intervalo_horas_voo",
"intervalo_meses_continuos",
"intervalo_pousos",
"intervalos",
]
with CSV_PATH.open("w", newline="", encoding="utf-8-sig") as csv_file:
writer = csv.DictWriter(csv_file, fieldnames=fields, delimiter=";")
writer.writeheader()
for row in payload["inspecoes"]:
csv_row = {key: row[key] for key in fields}
csv_row["intervalos"] = " | ".join(row["intervalos"])
writer.writerow(csv_row)
print(f"Gerado: {TXT_PATH.relative_to(ROOT)}")
print(f"Gerado: {JSON_PATH.relative_to(ROOT)}")
print(f"Gerado: {CSV_PATH.relative_to(ROOT)}")
print(f"Inspeções extraídas: {len(payload['inspecoes'])}")
if __name__ == "__main__":
main()

5
raw/AERONAVES.csv Normal file
View File

@@ -0,0 +1,5 @@
MATRICULA;MODELO;FH TOTAIS
FAB2800;C105;295
FAB2803;C105;275
FAB2809;C105;265
FAB2811;C105;255
1 MATRICULA MODELO FH TOTAIS
2 FAB2800 C105 295
3 FAB2803 C105 275
4 FAB2809 C105 265
5 FAB2811 C105 255

31
raw/AIRPORTS.csv Normal file
View File

@@ -0,0 +1,31 @@
AIRPORT_CODE;AIRPORT_NAME;LATITUDE;LONGITUDE;IS_MAINTENANCE_BASE;COUNTRY
SBMN;Manaus - Eduardo Gomes;-3.0267;-60.0496;1;Brazil
SBBR;Brasília - Presidente Juscelino Kubitschek;-15.7942;-47.8822;0;Brazil
SBGO;Goiânia - Santa Genoveva;-16.6197;-49.2200;0;Brazil
SBCT;Curitiba - Afonso Pena;-25.4844;-49.3220;0;Brazil
SBPA;Porto Alegre - Salgado Filho;-29.9911;-51.4314;0;Brazil
SBRF;Recife - Guararapes;-8.2645;-35.2426;0;Brazil
SBBV;Boa Vista - Atlas Brasil Cantá;2.8420;-60.6942;0;Brazil
SBSN;São Gabriel da Cachoeira;-0.1333;-67.0833;0;Brazil
SBTS;Tabatinga;-4.2505;-69.9342;0;Brazil
SBMQ;Maués;-3.3900;-61.2942;0;Brazil
SBBE;Benjamin Constant;-4.3797;-70.0336;0;Brazil
SBOI;Iquitos - Coronel FAP Vasquez;-3.7458;-73.3075;0;Peru
SBPV;Porto Velho - Governador Jorge Teixeira;-8.7608;-63.9047;0;Brazil
SBVH;Vilhena;-12.7408;-60.1447;0;Brazil
SBGL;Rio de Janeiro - Galeão;-22.8097;-43.2506;0;Brazil
SBCY;Cruzeiro do Sul - Aeroporto;-7.6258;-72.6647;0;Brazil
SBCO;Comodoro - Aeroporto;-20.4753;-58.6753;0;Brazil
SBLO;Londrina - Governador Roberto Silveira;-23.3263;-51.1267;0;Brazil
SBTF;Teresina - Senador Petrônio Portela;-5.0589;-42.8229;0;Brazil
SBTT;Tatooine - Regional;-7.2100;-74.5700;0;Brazil
SBUA;Uaupes;0.8333;-70.5667;0;Brazil
SBUY;Uruçuí;-7.1833;-44.3833;0;Brazil
SBYS;Yacuanã;0.7000;-67.0667;0;Brazil
SWBC;Barcelos;0.9806;-62.9461;0;Brazil
SWCA;Carauari;-4.8808;-66.8858;0;Brazil
SWEI;Envira;-7.5636;-70.0258;0;Brazil
SWKO;Kopena;-5.0500;-67.5000;0;Brazil
SBSM;Santa Maria;-29.7108;-53.6922;0;Brazil
SBCC;Guarantã do Norte (CPBV);-9.3333;-54.9647;0;Brazil
SBAN;Anápolis (Campo Marechal Márcio);-16.2386;-48.9722;0;Brazil
1 AIRPORT_CODE AIRPORT_NAME LATITUDE LONGITUDE IS_MAINTENANCE_BASE COUNTRY
2 SBMN Manaus - Eduardo Gomes -3.0267 -60.0496 1 Brazil
3 SBBR Brasília - Presidente Juscelino Kubitschek -15.7942 -47.8822 0 Brazil
4 SBGO Goiânia - Santa Genoveva -16.6197 -49.2200 0 Brazil
5 SBCT Curitiba - Afonso Pena -25.4844 -49.3220 0 Brazil
6 SBPA Porto Alegre - Salgado Filho -29.9911 -51.4314 0 Brazil
7 SBRF Recife - Guararapes -8.2645 -35.2426 0 Brazil
8 SBBV Boa Vista - Atlas Brasil Cantá 2.8420 -60.6942 0 Brazil
9 SBSN São Gabriel da Cachoeira -0.1333 -67.0833 0 Brazil
10 SBTS Tabatinga -4.2505 -69.9342 0 Brazil
11 SBMQ Maués -3.3900 -61.2942 0 Brazil
12 SBBE Benjamin Constant -4.3797 -70.0336 0 Brazil
13 SBOI Iquitos - Coronel FAP Vasquez -3.7458 -73.3075 0 Peru
14 SBPV Porto Velho - Governador Jorge Teixeira -8.7608 -63.9047 0 Brazil
15 SBVH Vilhena -12.7408 -60.1447 0 Brazil
16 SBGL Rio de Janeiro - Galeão -22.8097 -43.2506 0 Brazil
17 SBCY Cruzeiro do Sul - Aeroporto -7.6258 -72.6647 0 Brazil
18 SBCO Comodoro - Aeroporto -20.4753 -58.6753 0 Brazil
19 SBLO Londrina - Governador Roberto Silveira -23.3263 -51.1267 0 Brazil
20 SBTF Teresina - Senador Petrônio Portela -5.0589 -42.8229 0 Brazil
21 SBTT Tatooine - Regional -7.2100 -74.5700 0 Brazil
22 SBUA Uaupes 0.8333 -70.5667 0 Brazil
23 SBUY Uruçuí -7.1833 -44.3833 0 Brazil
24 SBYS Yacuanã 0.7000 -67.0667 0 Brazil
25 SWBC Barcelos 0.9806 -62.9461 0 Brazil
26 SWCA Carauari -4.8808 -66.8858 0 Brazil
27 SWEI Envira -7.5636 -70.0258 0 Brazil
28 SWKO Kopena -5.0500 -67.5000 0 Brazil
29 SBSM Santa Maria -29.7108 -53.6922 0 Brazil
30 SBCC Guarantã do Norte (CPBV) -9.3333 -54.9647 0 Brazil
31 SBAN Anápolis (Campo Marechal Márcio) -16.2386 -48.9722 0 Brazil

5
raw/CHECKS.csv Normal file
View File

@@ -0,0 +1,5 @@
CHECKS;FH;TEMPO DE EXECUCAO (DIAS);LOCAL DE EXECUCAO
CHECK 1A_OPERACIONAL 300FH;300;3;SBMN
OPEREACIONAL 400FH;400;1;SBMN
CHECK 2A_OPERACIONAL 600FH;600;4;SBMN
CHECK 3A;900;5;SBMN
1 CHECKS FH TEMPO DE EXECUCAO (DIAS) LOCAL DE EXECUCAO
2 CHECK 1A_OPERACIONAL 300FH 300 3 SBMN
3 OPEREACIONAL 400FH 400 1 SBMN
4 CHECK 2A_OPERACIONAL 600FH 600 4 SBMN
5 CHECK 3A 900 5 SBMN

Binary file not shown.

View File

@@ -0,0 +1,64 @@
DATA;ETAPA;LOCALIDADE;;HOR<4F>RIO (Z);;TEMPO DE VOO;SEGMTO;MISS<53>O;OFRAG
;;DEP;ARR;DEP;ARR;;;;
18/mar;1;SBMN;SBSN;14:15;15:50;01:35;1;69TV01;1
18/mar;2;SBSN;SBTS;16:50;18:20;01:30;2;69TV02;1
18/mar;3;SBTS;SBMQ;20:10;21:45;01:35;3;69TV03;1
18/mar;4;SBMQ;SBBE;23:30;00:30:00;01:00;4;69TV04;1
19/mar;6;SBBE;SBTS;12:00;14:10;02:10;2;69TV06;1
19/mar;7;SBTS;SBOI;16:50;18:40;01:50;3;69TV07;1
19/mar;8;SBOI;SBBE;21:10;23:20;02:10;4;69TV08;1
20/mar;9;SBBE;SBOI;12:10;14:00;01:50;1;69TV09;1
20/mar;10;SBOI;SBBE;15:25;17:10;01:45;2;69TV10;1
20/mar;11;SBBE;SBOI;18:25;20:10;01:45;3;69TV11;1
20/mar;12;SBOI;SBBE;21:30;23:15;01:45;4;69TV12;1
20/mar;1;SBMN;SBPV;12:20;14:30;02:10;1;69TV01;2
20/mar;2;SBPV;SBVH;15:35;17:15;01:40;2;69TV02;2
20/mar;3;SBVH;SBBR;17:50;21:30;03:40;3;69TV03;2
20/mar;4;SBBR;SBGL;22:45;01:15:00;02:30;4;69TV04;2
21/mar;13;SBBE;SBOI;12:00;13:45;01:45;1;69TV13;1
21/mar;14;SBOI;SBBE;16:00;17:55;01:55;2;69TV14;1
21/mar;6;SBGL;SBBR;15:45;18:30;02:45;2;69TV06;2
21/mar;7;SBBR;SBCY;20:10;22:35;02:25;3;69TV07;2
21/mar;8;SBCY;SBPV;23:15;02:15:00;03:00;4;69TV08;2
22/mar;15;SBBE;SBOI;12:10;13:55;01:45;1;69TV15;1
22/mar;16;SBOI;SBBE;15:15;17:10;01:55;2;69TV16;1
22/mar;17;SBBE;SBOI;18:30;20:15;01:45;3;69TV17;1
22/mar;18;SBOI;SBBE;21:30;23:20;01:50;4;69TV18;1
22/mar;10;SBPV;SBBV;15:30;19:10;03:40;2;69TV10;2
22/mar;11;SBBV;SBMN;20:55;22:45;01:50;3;69TV11;2
23/mar;19;SBBE;SBOI;12:05;13:55;01:50;1;69TV19;1
23/mar;20;SBOI;SBBE;15:40;17:35;01:55;2;69TV20;1
23/mar;21;SBBE;SBOI;18:20;20:10;01:50;3;69TV21;1
23/mar;22;SBOI;SBBE;21:35;23:25;01:50;4;69TV22;1
23/mar;1;SBMN;SBCC;12:40;15:15;02:35;1;69TV01;3
23/mar;2;SBCC;SBBR;16:05;19:00;02:55;2;69TV02;3
23/mar;3;SBBR;SBYS;20:15;22:05;01:50;3;69TV03;3
24/mar;23;SBBE;SBOI;11:55;13:50;01:55;1;69TV23;1
24/mar;24;SBOI;SBBE;15:40;17:25;01:45;2;69TV24;1
24/mar;25;SBBE;SBMN;20:35;23:30;02:55;3;69TV25;1
24/mar;4;SBYS;SBCO;14:45;17:20;02:35;1;69TV04;3
24/mar;5;SBCO;SBSM;18:25;19:20;00:55;2;69TV05;3
25/mar;6;SBSM;SBLO;12:00;13:55;01:55;1;69TV06;3
25/mar;7;SBLO;SBAN;14:45;17:15;02:30;2;69TV07;3
25/mar;8;SBAN;SBBE;18:40;22:55;02:55;3;69TV08;3
26/mar;9;SBBE;SBBV;12:00;15:50;03:50;1;69TV09;3
26/mar;10;SBBV;SBMN;17:00;18:50;01:50;2;69TV10;3
26/mar;1;SBMN;SBMN;15:00;16:00;01:00;1;50TT01;4
26/mar;2;SBMN;SBMN;16:55;17:45;00:50;2;50TT02;5
26/mar;3;SBMN;SBMN;20:20;22:20;02:00;3;50TT03;6
30/mar;1;SBMN;SBMN;16:20;17:10;00:50;1;05TF01;7
30/mar;1;SBMN;SBMN;18:20;19:15;00:55;1;05TF01;8
30/mar;1;SBMN;SBUA;15:00;17:25;02:25;1;69TV01;9
30/mar;2;SBUA;SBTT;19:00;20:45;01:45;2;69TV02;9
31/mar;3;SBTT;SWEI;12:10;13:10;01:00;1;69TV03;9
31/mar;4;SWEI;SWCA;14:55;16:05;01:10;2;69TV04;9
31/mar;5;SWCA;SWKO;17:15;18:25;01:10;3;69TV05;9
31/mar;6;SWKO;SBMN;18:55;20:00;01:05;4;69TV06;9
31/mar;1;SBMN;SWBC;12:00;13:10;01:10;1;69TV01;10
31/mar;2;SWBC;SBMN;14:45;16:00;01:15;2;69TV02;10
31/mar;3;SBMN;SBBV;18:45;20:35;01:50;3;69TV03;10
31/mar;4;SBBV;SBMN;22:00;23:45;01:45;4;69TV04;10
01/abr;1;SBMN;SBTF;12:25;14:40;02:15;1;69TV01;11
01/abr;2;SBTF;SWEI;15:55;17:35;01:40;2;69TV02;11
01/abr;3;SWEI;SBUY;19:50;21:20;01:30;3;69TV03;11
01/abr;4;SBUY;SBMN;22:05;23:50;01:45;4;69TV04;11
1 DATA ETAPA LOCALIDADE HORÁRIO (Z) TEMPO DE VOO SEGMTO MISSÃO OFRAG
2 DEP ARR DEP ARR
3 18/mar 1 SBMN SBSN 14:15 15:50 01:35 1 69TV01 1
4 18/mar 2 SBSN SBTS 16:50 18:20 01:30 2 69TV02 1
5 18/mar 3 SBTS SBMQ 20:10 21:45 01:35 3 69TV03 1
6 18/mar 4 SBMQ SBBE 23:30 00:30:00 01:00 4 69TV04 1
7 19/mar 6 SBBE SBTS 12:00 14:10 02:10 2 69TV06 1
8 19/mar 7 SBTS SBOI 16:50 18:40 01:50 3 69TV07 1
9 19/mar 8 SBOI SBBE 21:10 23:20 02:10 4 69TV08 1
10 20/mar 9 SBBE SBOI 12:10 14:00 01:50 1 69TV09 1
11 20/mar 10 SBOI SBBE 15:25 17:10 01:45 2 69TV10 1
12 20/mar 11 SBBE SBOI 18:25 20:10 01:45 3 69TV11 1
13 20/mar 12 SBOI SBBE 21:30 23:15 01:45 4 69TV12 1
14 20/mar 1 SBMN SBPV 12:20 14:30 02:10 1 69TV01 2
15 20/mar 2 SBPV SBVH 15:35 17:15 01:40 2 69TV02 2
16 20/mar 3 SBVH SBBR 17:50 21:30 03:40 3 69TV03 2
17 20/mar 4 SBBR SBGL 22:45 01:15:00 02:30 4 69TV04 2
18 21/mar 13 SBBE SBOI 12:00 13:45 01:45 1 69TV13 1
19 21/mar 14 SBOI SBBE 16:00 17:55 01:55 2 69TV14 1
20 21/mar 6 SBGL SBBR 15:45 18:30 02:45 2 69TV06 2
21 21/mar 7 SBBR SBCY 20:10 22:35 02:25 3 69TV07 2
22 21/mar 8 SBCY SBPV 23:15 02:15:00 03:00 4 69TV08 2
23 22/mar 15 SBBE SBOI 12:10 13:55 01:45 1 69TV15 1
24 22/mar 16 SBOI SBBE 15:15 17:10 01:55 2 69TV16 1
25 22/mar 17 SBBE SBOI 18:30 20:15 01:45 3 69TV17 1
26 22/mar 18 SBOI SBBE 21:30 23:20 01:50 4 69TV18 1
27 22/mar 10 SBPV SBBV 15:30 19:10 03:40 2 69TV10 2
28 22/mar 11 SBBV SBMN 20:55 22:45 01:50 3 69TV11 2
29 23/mar 19 SBBE SBOI 12:05 13:55 01:50 1 69TV19 1
30 23/mar 20 SBOI SBBE 15:40 17:35 01:55 2 69TV20 1
31 23/mar 21 SBBE SBOI 18:20 20:10 01:50 3 69TV21 1
32 23/mar 22 SBOI SBBE 21:35 23:25 01:50 4 69TV22 1
33 23/mar 1 SBMN SBCC 12:40 15:15 02:35 1 69TV01 3
34 23/mar 2 SBCC SBBR 16:05 19:00 02:55 2 69TV02 3
35 23/mar 3 SBBR SBYS 20:15 22:05 01:50 3 69TV03 3
36 24/mar 23 SBBE SBOI 11:55 13:50 01:55 1 69TV23 1
37 24/mar 24 SBOI SBBE 15:40 17:25 01:45 2 69TV24 1
38 24/mar 25 SBBE SBMN 20:35 23:30 02:55 3 69TV25 1
39 24/mar 4 SBYS SBCO 14:45 17:20 02:35 1 69TV04 3
40 24/mar 5 SBCO SBSM 18:25 19:20 00:55 2 69TV05 3
41 25/mar 6 SBSM SBLO 12:00 13:55 01:55 1 69TV06 3
42 25/mar 7 SBLO SBAN 14:45 17:15 02:30 2 69TV07 3
43 25/mar 8 SBAN SBBE 18:40 22:55 02:55 3 69TV08 3
44 26/mar 9 SBBE SBBV 12:00 15:50 03:50 1 69TV09 3
45 26/mar 10 SBBV SBMN 17:00 18:50 01:50 2 69TV10 3
46 26/mar 1 SBMN SBMN 15:00 16:00 01:00 1 50TT01 4
47 26/mar 2 SBMN SBMN 16:55 17:45 00:50 2 50TT02 5
48 26/mar 3 SBMN SBMN 20:20 22:20 02:00 3 50TT03 6
49 30/mar 1 SBMN SBMN 16:20 17:10 00:50 1 05TF01 7
50 30/mar 1 SBMN SBMN 18:20 19:15 00:55 1 05TF01 8
51 30/mar 1 SBMN SBUA 15:00 17:25 02:25 1 69TV01 9
52 30/mar 2 SBUA SBTT 19:00 20:45 01:45 2 69TV02 9
53 31/mar 3 SBTT SWEI 12:10 13:10 01:00 1 69TV03 9
54 31/mar 4 SWEI SWCA 14:55 16:05 01:10 2 69TV04 9
55 31/mar 5 SWCA SWKO 17:15 18:25 01:10 3 69TV05 9
56 31/mar 6 SWKO SBMN 18:55 20:00 01:05 4 69TV06 9
57 31/mar 1 SBMN SWBC 12:00 13:10 01:10 1 69TV01 10
58 31/mar 2 SWBC SBMN 14:45 16:00 01:15 2 69TV02 10
59 31/mar 3 SBMN SBBV 18:45 20:35 01:50 3 69TV03 10
60 31/mar 4 SBBV SBMN 22:00 23:45 01:45 4 69TV04 10
61 01/abr 1 SBMN SBTF 12:25 14:40 02:15 1 69TV01 11
62 01/abr 2 SBTF SWEI 15:55 17:35 01:40 2 69TV02 11
63 01/abr 3 SWEI SBUY 19:50 21:20 01:30 3 69TV03 11
64 01/abr 4 SBUY SBMN 22:05 23:50 01:45 4 69TV04 11

View File

@@ -1,483 +0,0 @@
%PDF-1.4
1 0 obj
<<
/Creator (Oracle11gR1 AS Reports Services)
/CreationDate (D:20260615132342)
/ModDate (D:20260615132342)
/Producer (Oracle PDF driver)
/Title ()
/Author (Oracle Reports)
>>
endobj
5 0 obj
<</Length 6 0 R
/Filter [/ASCII85Decode /FlateDecode]
>>
stream
Gb!Sn962&m&\d:,9_h)SN7GRbQa\9NPRs#YKnYoHE=Q1uUk/ss9ScSq?Rc:\%R/RVjHdq3j](J/
UjH_IJ\g[cP%hnWJL,//:J^`:O6IMjM^Tm$)Gp!/q"o7(LHcDDD?_TM#uQ:CM'T@U&XPoWLaHZE
+N<8V^iU2\8sH38cpUbH`LJW:Z[h([jE>Jt*sbM90N1V"592jHUP)KLE;jlsFHRA!ao4?E1NJqJ
MenQi-pB$MD[6)cn/Sf["b0<FN`IkaT].40gra%W3Qt/88qs>O6a\Ib`Gt^7=4+;3;?B9`a)lF0
Y]Gr.acOV8-YD;D]J&X\St_oG*jO)?l3Z!:39Al`1@bd;QSd]PNDNKpLnZ:2('#c`c-+uQmYHP"
"K\AY^,+_o9qEofN1=e,F$s3Y.+M_<NYEV,J2k,u1a%K#E@MCNJ5R"F],2@Ur$!BGaZP&6b>+r@
qYBdJpVhMBEZUV2ZZ1.`Er7EXg"#lR1uOM.JX.c`S1&(_:3`!KKQ0tmEIPm%N5GJ5M.8/u71p6Z
FGfK_;FV(I910*B$SF'5\Ib8d@.#R1cfRI9`l?6MG][j&1S,;Iq7FE'"TeMlk:aiAfgS)m:\V8)
*GGOOJ8#UnA518aKH9i*"^.N1#5bqX1"[N4egM0XJK.Olj7%o2+LLNXjA-p'Wc?D$IH*:raqX\Q
.>B#)8,9L9f#=_V(gfX\XFX\ZCnpfb1<F1iD$4f,)rrDJ`%@s8l(qmB2[H6nVd\VF<,T4iU`iO\
I@*L@URDlXP!NF)U9_;'l6%qo;Nj"=+g+@14fZbS7YAoY"sl&?<13!DI4VH@9>]aH8EMa&k$bQo
6MPG[RQ/5P+UM+)pkX,mdkN\UOu@b?(3`W[#]m<Y80-=Hf()#e'k(5V,W!I=1#@kG^3O&hboJsj
7pclp_\h'(feLX(7WLCM%n6Qf)RaW1qIc;6,cn?VC"-Xo5Lom-p;s-&lrj':#:.U+LB5B.@FdZE
WhiGR\KJ/^V\E=m`EpLq%_h&*<Cq(#E\WseK+PI0-rE,nR(rOAPZ[t[KO]q@-SSb04X"RNZL`Ha
qWF:$8NHSrcXoUAE)&(X=Z.0o-a;FD"OZJM+M6u_\BtQ`PjhW].J)kt4HttWUdP`PU^)j):?Ag$
G*&0].H#/O\ZS#"6@-]o4\fBXC_d%`LjIJMZ/`6n]\=151/4'Wq96.K4r,M8eS:M"K/A1o];kO#
m(jKE_Gr/'KKEJP^>:LBp!6?>d`GU[-9n#CiQMR?<LEM+BQEeh<J*lK7m]2$_Ei7k@#B-uAckc3
icpjA/4!+Y(\cZ>N+=ECS*N?1j?[m8I1J\cq>o*0?u#R<g;A7TP^=:tf;J0\#p4$'5ud1G,cT*l
i;"'+Eo6I'9@39opMUkKT]bh^"D>=VceIT!-ud"GHV&kb33pl4g-X4)s4M0n>@!=DgU5eL9,a%Z
4m=tknC5.\aX^HuXUV%I<Gj%%n^G`<S0SFE@rc]W78AJmb<<Ge>]oNpFG-];hB:ba>g\?+"LbX%
lQi`*V`XOEn/G074/5rib6CEtN33E(T!Dej`.?J[9Xg,c*MBRi5p>cu_iGKkWiGLSl-6Yibpe#h
`/M64@7'4`li-uoUSDce3:<.nXQ[%oFDHmsQ+,ncPMWM^W83jG-b2U4$q>alf<Q;J1pVL6AM`Dm
p<B[lL<m/qmL@P!!H#O+*0p1L1sPPgp>@C3!!UDecUr=V[p#.0%VoSarNL1?[hmc*H]5APZmSf&
,;tidgB?&,DnjcLe6FVL`:E]sI^+<*Xod!eeCqZQ8o8=].6;iNZr!Xfg@+;r8AmuiE#(R*cE[N+
frH3=2Tj_[(O;,ZA2Kk$^Rr0+(;V^$R6p+)6M/)\H?a26FY.\p_r^MZd/c[TLd!Z]kG\@D4nWMS
rWO-4R[SV;RWkdZ/3co\Sm\.M2RiG8c"_63RT@k:1l\=cf5"*L<Xi!HTp.7e;2`>/PVks'nS$D2
W;=f.KA,.b`M)<4))>'\*pt:U0sEK#K3A[tCVh!M[OlYBG#pWQGf%+6FD$&SB[3@Za$.e`s.Ol(
dFmO@@f,WKlVM@6L*,O)(:ZKgjrSrK8lgk)X(;5+]*5:G,2XB*j;Qf\DTU.4<Kd%7ot"rC?]60`
p4d+lr,+ks^\POuIT?.%O"!nSW'J<D=^[kVkp'F1Mokknl@-8ramp5.<Xkm44WVl$nIVJ5qj5Ll
10>n7B)bjUrp(*Ko`%bEs(5#o%9@0!\fK6,J"$%&I*E9fr@ahVY8=\B#<0Mb]R"5NjqmIA&heqm
=)3Y5hV)-)lc$:Xo:g+:UnG421a9aIk3j5=]&?q)rmQ&(]JUM')7K7^br0f;J!c9=aENHQVEapq
I!)-0R#aG.]E@hRCk)B7E.0XXK\K-;iSN]:9nI?[<.5s.UVKAulO`QboMFD;g^g[W&ZdsJ3g;J?
ra)D;h:lD@8ZGsX9s2QNaJaHeOi2K*TWYjI;9!'lM_1aC?RtX:`,/+5.nXML'AesmSsh`FKs.$q
iA#d8o)boAVPbjY7(gSUFZHSs)4d<=moSs>GKJkJZNQ>[^0mVNIafP8EY8qAPusHpB%4JIn\LpD
,GMf$K_8/[Hj`C5715dQX!!h;4H6k:%Mp):'K8>L"Qrero>?d=k1PqG]]Lq%idB]IEBa"?F*p_1
/%$,2B\mf%Ck#IR:qOBt^;#-'._?\<C[lq'5]PtSYk0.#(]Ygn``VPGI%e";Dui&+me$9b"0KB3
Wk<hf%;W*h=Rd8s<B[3&eMT5Qp,[:;/[&4pP23l%=5iA+p_8YT\?$EO:#;:kCJl\+B@BFqQ%)**
+sUa\he-;X&cS,p$@46ZO(9'[f>(Cr(((D6`7TW!RFcjZX/SVql$T)^m[FOQ?DuL<H\V2UkrDS0
?LG/t1K%Z.ELoo_[QrIOR<NC4e*>GdloVTS!3#pF4TGH^~>
endstream
endobj
6 0 obj
2935
endobj
7 0 obj
<</Type /Font
/Name /F0
/Subtype /Type1
/Encoding /WinAnsiEncoding
/BaseFont /#43#6F#75#72#69#65#72#2D#42#6F#6C#64
>>
endobj
8 0 obj
<</Type /Font
/Name /F1
/Subtype /Type1
/Encoding /WinAnsiEncoding
/BaseFont /#43#6F#75#72#69#65#72
>>
endobj
9 0 obj
<</Type /Font
/Name /F2
/Subtype /Type1
/Encoding /WinAnsiEncoding
/BaseFont /#69#74#63#61#76#61#6E#74#67#61#72#64#65#67#6F#74#68#69#63
/FirstChar 24
/LastChar 255
/Widths 10 0 R
/FontDescriptor 11 0 R
>>
endobj
10 0 obj
[1000 1000 1000 1000 1000 1000 1000 1000 250 250
250 500 500 750 750 375 375 375 375 625
250 625 250 125 500 500 500 500 500 500
500 500 500 500 250 250 625 625 625 625
875 750 625 875 750 500 500 875 625 250
500 625 500 875 750 875 625 875 625 500
375 625 750 1000 625 625 500 375 625 375
625 500 375 625 625 625 625 625 375 625
625 250 250 500 250 1000 625 625 625 625
250 375 375 625 500 875 500 500 375 375
625 375 625 1000 1000 1000 1000 1000 1000 1000
1000 1000 1000 1000 1000 1000 1000 1000 1000 1000
250 375 1000 500 500 1000 500 250 1000 1000
375 1000 1000 500 250 500 1000 250 500 500
500 500 625 625 375 750 375 375 625 250
750 500 375 625 375 375 375 625 625 625
375 375 375 375 875 875 875 625 750 750
750 750 750 750 1000 875 500 500 500 500
250 250 250 250 750 750 875 875 875 875
875 625 875 625 625 625 625 625 625 500
625 625 625 625 625 625 1125 625 625 625
625 625 250 250 250 250 625 625 625 625
625 625 625 625 625 625 625 625 625 500
625 500 ]
endobj
11 0 obj
<<
/Type /FontDescriptor
/FontName /#69#74#63#61#76#61#6E#74#67#61#72#64#65#67#6F#74#68#69#63
/Ascent 1000
/CapHeight 1000
/Descent -250
/Flags 32
/FontBBox [0 0 0 0]
/ItalicAngle 0
/StemV 0
/AvgWidth 625
/MaxWidth 1250
>>
endobj
12 0 obj
<<
/Type /XObject
/Subtype /Image
/Name /im1
/Width 105
/Height 129
/BitsPerComponent 8
/ColorSpace /DeviceRGB
/Filter [/ASCII85Decode /DCTDecode]
/Length 13 0 R
>>
stream
s4IA0!"_al8O`[\!<<*#!!*'"s4[N@!!**$!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%
!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!WUmS6NI2g!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%
!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<N59!"fJ:JH0Bd!?qLF&HMtG!WU(<
*rl9A"T\W)!<E3$z!!!!"!WrQ/"pYD?$4HmP!4<@<!W`B*!X&T/"U"r.!!.KK!WrE*&Hrdj0gQ!W
;.0\RE>10ZOeE%*6F"?A;UOtZ1LbBV#mqFa(`=5<-7:2j.Ps"@2`NfY6UX@47n?3D;cHat='/U/
@q9._B4u!oF*)PJGBeCZK7nr5LPUeEP*;,qQC!u,R\HRQV5C/hWN*81['d?O\@K2f_o0O6a2lBF
daQ^rf%8R-g>V&OjQ5OekiqC&o(2MHp@n@XqZ"J6*ru?D!<E3%!<E3%!<<*"!!!!"!WrQ/"pYD?
$4HmP!4<C=!W`?*"9Sc3"U"r.!<RHF!<N?8"9fr'"qj4!#@VTc+u4]T'LIqUZ,$_k1K*]W@WKj'
(*k`q-1Mcg)&ahL-n-W'2E*TU3^Z;(7Rp!@8lJ\h<``C+>%;)SAnPdkC3+K>G'A1VH@gd&KnbA=
M2II[Pa.Q$R$jD;USO``Vl6SpZEppG[^WcW]#)A'`Q#s>ai`&\eCE.%f\,!<j5f=akNM0qo(2MH
p@n@XqZ#7L$j-M1!YGMH!'^JUJ(^Uj<;hgCrr?LMfPAHpa6[%-,'_/I5!8e/IKoS*`L\CGO6l9'
ZX3/+^#BFWKRXO*/Q5s*op`,oIq\,Y`j;W/\$u4JVl<qWhWbo`8Tf#7cRc@nl+c`5?a4hR(bb57
rr@kin>$!`RG-l>B@$B-+8@3brL*,iJ,N:&$`j+mrL1+,DD0Y7iV'>ArrBoB#k8.\`f#<"27JMr
@#%7cSc!o96[!TVjD/P-8@6CKQ53]$F$*8g"G$;DnGX)?DhZ^?O*hner)_j`j8LIQpkQhRR:<F+
?c<mX.hRQqmh3.N0>YMiE&DXBX&c'`i:%V5'+7SBLP2YSO#:6l.Xj1nH,kcs7H<hYn37BWRucKD
K8/qL$HM)/fKL>!1TGGdA\pP]M8`AH$IpgfU8qcY\?'76Y7k5[XPGj5C3PI)1&h4^jnfjDd=VA8
J,"s%!"(sdSYQ0%bP!Rg!2B_rLAq8D(]G+uo/g=,?gYn0fRLdd=mB<SphQWpnMe_^]uTiUJ@&P8
rr<OR+'b.6did7TT>[tf^aq<Z9#aMp"tOOkk'Lmg?N1"#JchrW=kRO=pfjW[\(\is@AN.2ZeoM$
V<,.XVqG9DdbJ-bhFs7/[a7Em]TZ,RM&^3%ZTA-f-GQ@(2t0o4q8^-(N8cBiKm6oF=aiE?',cP,
`Va/&V1s[D[kH!3C:MJW5:_[l0Y)m%X=muGd&BdA-!F3#73B=X<UgaCD[grl"2bt*(%Z;fSn"&D
ZRdDK=Un;qib'fK2]7.#&m9TrAl=V<Dh4+K'o8+?;l`Msc456"PCLJ1I`:4:@)&r'rZ_@B_N4\/
B`A'eErCp2keVaW^\gP#!/2NmcI__2n?Y[\<Vnmmrr</9B&=3:U#1+X`a0_<YO8SHX_]UPm=$1h
VPGTOSgAKeeMS;::IsujI?Ip>P.%TM\jfd!5Q.P[%pf)Ept+OJ"e.HYd&+L+&9p*<3J-_:>1D$I
V&O\*N\`B<SM%9"[gGuT/LL0:Gu?@JT0)$+LAq6D:-7Gt`up_%ZuUY-7h^d%Yr0&'N@Rd`_dRNl
Psm/>S5*?^L+-#aPd'p&AoC9YS-FCnQ7(EtGil`4`4Yj/!TDW<AR%e(?H6]"WJSK'ge!%6A@W-c
_c`im/u+K&L9$-)naj]HI,'VnM8J@kd_o]6Ps^`p_Spn2rr<Jhq\Ob0K)WZ;pl#.P$:"?WU]).S
p+H@FUBUUNrrDR*rY0kR4(#Cfn4N($9[ru9nVCp`FbZP4d!R$G(Y4oH@O!4nY&?%Oj-.Y>YrD'i
mAlcE)#(f!oAM$SqaUK:n2H6=)H^0/iZAR+=Hh_+UB[!#Vahc+f">E-_p*Te-kWQA&#T,jb='(R
P$+MM[#(]n\4*a6>O,WPM"i4YkJFlYNo<+`_?putCqCL9_d^-9Nu.?C0"!ZJZ>?9mQ6G>!<E_!l
HC;GrFf?qFiZ810Nm0"NW=1*u%hDTnZ%#:S/F^[da-eT>H&a+0a'eL$Ae0QmODsjtLlPK+j)UU\
l/sqo<83Z$jmp@,:F]>0._ZQKWDXAI;oHlTf`(r+ErCp2keVaW^\gP#!/2NmcI)Xl%-@@&J'ilI
_ghH>rr<Bjq44_TgV>j__i6lXeNOUZ)E*G$^Bm"SnG`JZ^UUf&X=(c7I8`egNJ3CgI_cF^n7Z!3
OfLU+%k:-CLr.1<5oBT)[F@aT]j`tK@u`]R:e9V8#`+=]19)Os$EC*-Wi,q`!!!1K^(ts#i"q5P
]ZUf_*5Z75^sg6ESOi:k(Zoii<P[`s1;%KH&P?'ofO=&RnPl"mkZ>dOXQ*1,Wp+jk[Ci+K\<k5H
$*[PJ0E*1kA],5,/(^IqS2bF6b:2C]7h(3k_Gk;ljtanBU8o]M<fRNh04u>P>k=piO,ne.3I\s)
oO,G<hu4i"!5afrB5%;FL3!6N^Z`L`@DD_/rr@`pr6EpeLTG_Fi@g%:#P]D"rrA'1]KN(EHr0=I
L3sT.>r@V4S\t5olbZP8\i?q*7CO_G_'XdeTr#cXVqX':4*r7e962'cr9<],i:Z9Mhq:ZrL%%Tl
ZqVTK'DPbsishfqK%WS6*TS=3JSFkI<iMtQ?.qYhp,D\$g>D/c9,5,(%.T0^Jp!5%r7_I1qE3bX
/.Q;W")nr14N>*:/1+-i8`JF7R:\Z/c*Nl6n#\Q9YI2/qA^eLr_lNs6<i/'6WFO\\MPj,?$tfqB
rr@f"B9uQegGHi[1bu/]'oL;3_3mDl)`>gDfiAlK;-*n*Z[u>6%)q6\fXHl@<BLa@2)$I?2(O/@
4otVHIpsK7Gc+<`YQ"T1YLo-K>K"3fQI#86pdUIB>?KZd-IXo-kDp%u"^kgfCE*mNYOacXoe?.u
rr=>Y6N7%gHFNmAn*BnXlV8tgIM_MBa"B4-SdijhP@RJ,577"Cm+C@i/9M6\GlAoWEs[fF60<F9
D-D(%3,GoSUK`nW@lWEqhD^/7A&+*\idO#ZUCWr9DJ`H9c)G^?(1hUXfSVXl23n=RO`3K3%u,R5
Tne<#d]\\Me:Wus,qAN\XO:J?D>QMHaajBk_)f?tc[Gur!:#mF?!jgC$@clAftU2YRX@!^#NFWm
IO06-daq1'YjjTRM.NMLV.p#*Wq4/P@ae;cn_],pUe%:s)'+iUSY_'3=@mC7c2<%hT)L/;cef1]
?m#.k?i2Qa'\mqKqePmgp^dB)#e,qlGdgZLDt\CkDh[*'S%mntd<1Kpm,)a02Z:8SrbnBWbNF.p
*`lAA!"DHu!!N9#!2?<>rr@_=`P;C]lf>\_nL#ZEJUAQV3QH^N!(m/BNt1\=MKNTP08?&1B7*:f
R>]<P:u`O<=QPtM)-"H70uBs[^uN+n36MVs@558@9:gi1Y7`rLnL]")`k66+r*/+a?\@@NO+_QM
fj>Y;,Ud7:_VfZ'LN"!%N6:77R\B-7i+c5eV,jC-i2]bT.&C"25H%p)^MOL4kOs,L5A0urJA2Qk
A"dS)V4H2&&b1;!&__JQ_Y_7Zi[mWH7iiuHG-A&S?es&-,]R1+<Y4ND>t&^qh-4)9R/[.CI`Pk2
^(Yl-pmWqTIu5.?M`&,+)bIQKn9(@WoXu>D(:qM_YA&?a?T,$ik_g,.Bhre_@iQ]]b`bqniJkL9
<p_Q_TCC?[n5%s<.TcWMpdoj;rrD16O8*HSpa>P/'eB:ar$9!.rrDX,?ajHk>N*dQ:S6_?^5]m%
QFNB\)Hk?s56:PFH*/cc(;%L`_bUuu,d?m29NnP*P\2Jma5^T+D,so6RH(ptnXhPq4rWEc:EBBU
ID6%a=FM=7..LjU)(o23e!2smUR0EPTVGD;<%;>3C8fLN%ghU_(f6/5l;rjmi2N=`7u6iSi4fKo
4`:Uc;Y0l"[dcP8i^NALdCf5QcT9_,]R"\IWVrH%gA\s<bNEg75YM3prn1YcpVBYZ=2Nf&]^Y*=
r,nZ[8+<Y1Lt<QZQ/TQRgut37\j!Bp9<-Y>K'-+Fq[TH>Blg#p/t!,?NN)?Q[$+CL`VJQo!"DHu
!!N9#!2?<>rr@_=`P;C]kl1X;U]1=GIfBDq;SW53_FrnGrr=crQ64(0'#<Q&Iko3]N.4PQ[iNB:
.Fk,J_>"DVn>hVL#goP1k#86/Z`<O4G[`A:V1t9K31MU0O.fQG'k""aAf_]^q4)#$8!<%A-hnLJ
AXlCfd_t*#%PseVQ3&P<9LUW$V,&\>ok@k)&tt-!9!?ed)Yb9e8A8gokPLbB,5nB$"oC5o57hrJ
*GKsReD]puR@m\sbMcY7-N7,DddFM'mMbV<?#rD)j(,fY6=G4qAe#h+!D@bUm,@hdGW4S6^[\2U
`VM6G[3#PHlf(]r4sB\]1`\59Rp4>/]L/Z(^8#VK6[PtMYV<]<@aPO@I7j#\DX;9$Lg3Aok=,2b
rr<Kurr<3#rrAdiJ,]M%Qc6`=@d*glL84bKJq!_"d8IaZ!5_Cghu<[2FD"o/n?.,,B6hKYkgB%Q
=R>m/$FmXnn/I3m$8ll%2ID'4;[Z>mUgWIqo'0>'b-/:,NEYqO)HEV/XXN/c$%-gQI'\R5S>X;4
!GUK%=8Q97d&Y0+W@H9?Yo)7s16stN;2`@kN[Gc,oFt:Q:8KL)guD,KJ#NAIZ^:(@i@b6,X5e\h
4tUJT1t;2jMV]r5r'Os1Kl$CP>2)WQe"G5C/[T_k>O`Je3a6;V(HR#5V5c,fqA#/ENuK2Nq2;7Y
7sh6#2mETiY4&O]UKIYT(-@G2,s^Ele2UM1K"EFIJBU#1^Yu-k!8sKaoMi$o!:Z0H\,QHP>p&Rc
J+-7kqC5SH!;K-5>Q4]c0"h`/nWS]P0,okiA3WO*VIg_*k^?\#eN<omHFR3;#?j"@/2TBI35sC+
?0]t@,aG+PDZ2R2p_2[P*9ESh=2]P5p`5<kPi=Eb\i?)I]fK9>7OK3(4AC37AX9jWSOp@bh5<tR
:"ecLdXTt4p<E(c`;]ghrr<D=&b,A;a1;M8rZ1d?^qM3kDcO$hFSd"mY6ao->LGskPC'2%O&Wjj
Cc]!&Q/.'c@;`eY1.rmSb)$OupT4Ap_+Qs.pqPbT^YsYSpd<)f*^&s?J2R?,%dG.h3d6AJ4>.bj
Wp"Z<>2<B>?$Msa;dCkWRH(kiP=]Pj3+RAY>NUKL\GTY4!/@9K!.o\"!7=]ZrrBo/insb90m%o_
;?$X45C_mr?iL,b-1PpR!1:@nGk&6E_^!:o#^mRSRh3EsQtlCI*GG3[`/>L3#@9B7]IkhO/[7.2
rEW&)l;RDdBi`2FNCZ0<,MIRI3$7Ao%-p"$(PuH!4ALB!#7Q&&>[7YD<D3g@&>P*!d!->qfSMs/
;Ab8>W1f'$0^?:uokZ^dl2L_f^Z;,&?hM`GrrCM2rI:,]J#SdNYE%MG(QJD;kaidh;rc'7DoXfA
C9hP7Cq^tcKjbtk>%oq)>*Chp^Yt_F9973D@_8!6milCX_EoN9OuE=?/DBlhl]6PT,5C9?=mu3N
i\pl7%h1WLb<N7Z:Z2/#"Ruq>X77!aplFe^5#I]iFmETrrjV5Jf_EYC<lJKeZm5%uVLF6X-\hH+
L#*[)pj5$Gph)Y,@^t$Io6=a>=#X:5>8e?KRt@l+Ti2`*@FR#nWI_*2Zoso>V,E!BT=)!(l+T1X
!6Kck!5TsGm*C%]rrB''p3j-!C[Q,$J+?u3n4?FQ1Z&34!:#mKT53DSrn.&KrrC1kpjN/BAJ^:i
qYL+Y3kSSp!0Ze.rrB8E)ufoh?R?,h@s@Vc\8K%*Bm?nL&mF0X/hLrbQ8XV6Ak]"X,`1>Tin@U`
b?52.=P[0;6NtD./,-OP#m(&+"KAg-WNTCsLAde;Mtm8Opj5nS?O6F!;feiq`Z9NJ8RoJ[mk*\2
^5'Lq*kI2FodT66-h.\L-./iuA%0oEW8IGuN4>W-i=(>XS8e^NB7Z!fLQ)RTM9"5M$*&4i,sH!'
R#:-'es%COS8)-+;O&/#.RW06(d(PT'i'Jr4OmZu;Kks8(pr+&P-gH^(MgIMgPfU<.7Pu\e&)+b
$haN"NB6"F^'=?G`uZc$C""_XSPS5-LU::C'j]JEkrMhX`iH(`HM^M87]38#PA1]eRk5RXe"P@b
\F9>WiO`r.a1B4@>/]=H8,O%prWmZ9_Umb,RIU%FnrHgeNF0S!ig@FdC>,Na!5lJ`iTGu^%7^#u
O,Eo[drgX/`+N3k37Bb<]KWofKC/RIjPT3N22>DLHegVHrN5Tka5;[P!4*]gHm8>cq]^L;?7aO;
0kXo-l(>;cm+ZSqnFK>6>6^PEcUmXR\Ne"#!p``SCBQ?H^P0(4C'tuK@Bn="/[&(3#X9=oi2D,I
5A2.n$fT-ZrXSiSrK=qRTRICGHR77JXF+$rY(('PLcS=f4A4Zs4`o`B%3"34(q&6.pkuq6-D360
?c78<^*1"oeTLXL+ff:]b9hjWX$urON:C\BGe.!P(s-N&\5O]U3YTHoYfo>?ppUKnE`L%VG`5?G
@'S5G=2V*r0Goa@UR5Pk;mn>Ca3,3(Z1dh\9EkZ#``MkYlqA(3_n'2U^s9:IL[OE@H?C.)Wk+=i
j[L]/e&,&V8dk'VNB@+FB;q<u(WdKk'?);Z:H%cK>0ZPP9>*%G=H%3H9sU)1mS.7\^c0rZ>2&78
Y09<-dMT_QrrCuQrrDOU4TiJ@$LpX,T@h.J&C%-JAd-aJ>PgYBggBiJl44D/_(!qmc\?lle1PY:
8#XV+`=*ZMkCWV#2S#i&<faB>k^FfS?'^Q0rCca?>5adO0])fS)E5Q<+8@58m3cK8?Uq\_`A_*b
L\?&mU1U^Ce90#*dnK99Q(tJ;RCQ%<*t.=>'=khoAcps?YAASn#(raT?B5V4)UIeHr"ET6T_RjM
1&Mj&pabm$p,DrbHr6[&](?e'#N`5c"n=[n4t"f9/QU+0;XdJ>F[$^Gpc$%C*g`PcG/:g7IA1Hl
*ZpS7eYJO9e%@2NVqjpkiFP%,'l^9;<t]K?1QL\LauI!naKmK?j!sk21BYR@2HO)iYe3neQ<Tkk
&>bTRQs<m3@4PEQ:E>f!4N]XB<YhQ<2i?t?iG]XS9+-V[<9&eXYh6Nh]XerK2/lR\`bqXT\(5M_
)oQ/t*a4Yaj[QF2-VcO!?et_^.sh3qJ8Y>qlGlNl_;Lq$Btcd?LI9T8pg<%<&,;d5!m9*VpJRI=
4FD9eIh-dp,.l=rr@RC.NsGJ4WbGcfP5"qhmgEp&)S".:0."-<P3Pt&Wrfhl)"DN]D.aX:+cZ[L
$V6qD8BDdWdYRhUM2[c;%kY`*b5VJ*S)LQ/?OB#64)IdP(WIQM/*$AY/2]L>a^;rX[+F[#WnVb_
:ptrZP'`+*)sO?^?\^[3VZ_R;9hX@[kh[JGp=1d;3+dZE>tb*jJ&,=g!8s5G+0en+mCjur,-o`V
0A2u<g5B/!K'I]b:Aa!GMf)_di-5c7e8FE[^XTV0]/U8OK02`)cRtu8$oclM]/@m7V-E8W-N2-*
0,O3)--p=;=oKKVQ1628:,Og'[<"qXN^o#%UWfrF*2l2a)[.._&ueV<N\8%RkbquI>](nuKVoo[
c13p*9JWf*SN>Ca1?GO27W`V]PBMpZ8t2'FaXP4^8\u2;S#8T;-Th;_;+25u&:CbW0DlKAr_Dq@
^\retGQ.ZA-]"hu>-I@\rX$RD!%3![hu:IK49#<1'?!oK/Q_atoS*;FPb-n7!;ePN%Y*eOj+7)7
%"HA,Q%.I[`8CEpiD]WNpuhYW-3O8J"I/odZT[dgri'hhp:_`VJfC3&95C$o9eP;^jp3?ao`9<g
)K[aWOir6#\p^FRqc[-t29!YAYe@\'`o>_#k&$3pgV4a.!9CqFi0'`+B>+EH!7`[u51dcb*j9YD
rG,!VCuEc[Y4QR'a&KLUehkPZDb7I1_WEqYT[`U^;@BTc<Nqc[PqM_+qe>Ec+DEFq+d+8p:[VRU
"c.XrXpA,g'oR@hN9^i5dXoD%)>m"u%W04"79/GiDA,TJnWM;tkDJ&qO)m6*gjZ0=4NVL$riBY>
93&#o[]@q#XoAA5^YkY=r%.?pKt\l2,s"!PBCPcprXheq*lHrR9f0U)pj"]e5>B`,A#K5ee3olP
n87g31UFU)dO_0`:W&52Gb;h(g5gs[qbIK54sQV0^AIZB5O`ZjL\qV?bmV@Z'E*Qjrr<K&A&=[0
TAeZ<G]uaXTVL!.bn!:km:*<K[&XQOIKCcqQBi;J7;W4>,)E"R<uD4VaO36oC#9a)+5pRa3\Q5(
r#`%c^VX/mM.H-(^n6FkqF/>Pn<$o,laiCMXUQn0rr<@M]&!a^c#8dB_^T#9cq_tYIiZb^&!FXN
rlO";puc].Z`Q[n4+(-KX@[b_(Q\3\(]MYY!#p$^67WoFS#nbOpqbo+oc"oM;XhTWbGEne?X<Fm
2hptgm3Als_g61FT-WIpfC6]BeOL4i=l0L5.C_;qj%:1IDi'KY`E6A7D349o<D',3@"5J%S3Me4
P%5P*Ku#;dWFhPTZ@tb)nJJ:UoA"4tN6U\fS!j%k22&m0*#Y/QYS+l_Bhl)PXVs'9<UCN$<i(_E
0fY[N[$>>uf])"LCYD^]Wo<2>V+*@mi[*LYZEO2Yn,EA!oRFeP^\)/Fq6AiV+m\*:M*LK&c\-`d
e\!W6hgA](TC=%C(L8YGiTngISd=iC`D;6Bmu2,f]Tt`'e%RM`1=uF^ejAaVTn^ka`;]fGHoa+m
a'1WM')ggpQfHEH/,fkHnTX[X[.&OXj.?qY'?-OUna5`CTAc>Z_HNf4hT9o<>![@R(8p-iDb\[Q
.o2=2!o-oX!,C0+d&LDY>"s9N("`9iiP-F^$+s?a1<bN+F"/$0+O/]m#:%H.G_;5jlUh_2)MGF8
oIs8ZUWT8k34cK^<NAV#$+r]AFJ\f)D3t$OP2Y]d1%_:N'cBIs`RBiK>)REaMG\Jj.ZLDdFi^#1
APo9u3M3^/nIk<h=]\kgr\?R<0q\97rN\6DOm>O87(`I!<`T/"RiVM/iZ>f=DhS)o+7MrLnN1u.
9KK5!3;D#WQJ)kWYDU*RBJtNk7/)h)[Z*rW,W:H(RW:Wtb3<>!ZV4E[jR:aMrX&,bDt_VbnV4g:
NtQ[V_B'<Y*VG/Xi]VM)\qZsR96mJj4k(&-\e^."l?\HDX=ArtMm)LE7X9Z1f,$2.HFB>K$-"&V
DCOQ%(AP4lpelJC_ql"#WPkAEk4tm"`8!_:_rJ!>%88<%k1!mkbpq;>:E$Jj;SbCoWaR,EV+K:<
8i*GB>g!;ipafJWi39`QR,YW2L[sjU_U5'0_GZX[=bLP43-dR7UD_@@V'P3!UJ\GAPBK0*J)Vd3
qQd=&f^Jh?&G=%j6iR.=Kfg+?K>E!ZphB73p&WThCZ+(dfrgaFG\:7Uq/*1sqN;5R0!*5uh>@8_
L$pR8)ga_<9:SE%(0[Q2Sb8aJF_qPDni+H.pa-NfmhP-%-e%^kN#r]35!O^=kO@)ZDuLFF0>g!^
^):T*2q-j#9io5YT>j*PIN\//]59FlDQNL]Y75k8nH\boUBJ/Ng]p`7I6K<oINEs\4s0P9EH/!l
?gu@u#`bNq-0;Kt!7LdVqb2BSeo)VVl#:@JGK%lCrr?V5C%qTQRe#3,]^WXjn:iS)h]&h7-\f#Z
]BCV,M0P6VY5/t(Ae,[P)hmm5&U93C):&J?_rb3>_RUo0;hV/JqX/d'f7kX\7\rXJSLXCsRkj-*
:0b&$FQG`[b'5SX*6+_558f7!W+kXIXXjZ,'VOj<m-pBWE`FD&2(;";W@5a_nr$CCr8[QBrr<2V
e35AHSgX;unV7!,AT*>Xg1mke$H'g6)J686rr<Vd?c1kTCW#^f_L;D&dB,9S#2&1/0!2!aILGXf
rr@t[1_!)Mdej^9D\0(NGe]DcO@*l1F_tuFBrYClgs6*ASUe@W%7V22GiHKD>t3:=e/$L&'lGUW
onA[hL-uh9QIG;V5I<"8iU3'%J&2+5pmpCT%GCNK;t,9ioe.$(iie?U4&'U+mCY)fWGD3ng*MI>
et*WGX:[q2k>/S8r%?PY_4_;BT,]Q0?cG"1Wa'>.^8\5?_l$^ECANqXVfQdPBmP5bV*R9;ba26q
VJ+[X]q1Rdl1%=q-(^U(>$G+]gT5SLO@'.J`kS8Ic_%!LMSD].Sh9Ik*Og^\r4<ia3.;U`E7n6-
Y9i/m=NKZ*B9m)Wbc"<#c!q_N$pAueBpe%NN>f4*d@l)84>dCUR=C4UD[^1S0E$ZYbASq#=u`MA
CXe\7,7umr&QNVhSE#=!Y2p"8H8oY>+JCj(j\g5ckOtPG'8uibn);%[K=lF.!2q9$X8#l%m[*nZ
`W#ocGOXMh_;79R!7V\#<W"FMMtD`'!0j)7rrDpOrXOGD?fQ_'E6%m<\QA`o]M8D+Wr<_t^:tQ`
0(CU5ce''5#uMLo2*Zk"CM<_JZ.Hlr0$hHHN#r@T'1Xh8Hl2VeY`1,NMOl`qA6S;m[?TN<U->7t
Sulh\lYO%rAq-45iK_,)J'cU?2u`m;A:DULr;:?frZUKl*[^i?rrCu?4r^iXoF3#9>lBY0%q01!
iY/5t]%hePQMmDX@uA:_n>4t/WB%CA>$:'Q>ADV;bDWEL4`q'4j<':$#J0Co]4E#;Be^WC`@aTF
Rs,!p%J>^`T+8Cbfcl$l>?Tcs2a^EG==c#)TsW;lZ@^ZWg:aRP@It/lr!b:_HoUWd)9,s9cS;u&
N-UEg"c:BC$q0C.VrJSLR$SVIN^!c%SSFCfAej>uPB3Qf#2N;(;@WQmMccLPO!!C9FfWjc9,3_2
r[Np;c8;A%%7c1/Wc]i=k+bl/_OIH;\@HWO<;)p0pI_pe&cVh6fXgT"L%)u[p`nd8'B,jL]M7>;
26RX;@/(u?SG5PC6L8qE=hHNUCDV^()>LPt,PZ<6_gWl\j1:EIo$Yh@<P.d,^Y-NL@skV2hr/j'
g#:I<>Fd;&:PWJcfC);X4B&FZiQi?hRod;5eEqBqCdIb@](>S&I9OM>;hd;%ZZ"gU[5cUS,],T_
q!@cqnH["i$G:B;YD?Kp]1XdhpfC?f7o#@LGqGlt3GksAqrhOQ)(3mU>s@9:9[rIXBNjRp]X`T)
N6/b_DYua!!!Q<rA)\M=_8BUKa8@'(hBIj#9DF:D61De.hC%g\JGQq0?e[N.FP$B6a,-tkI5^rQ
*C;mCh+_sc/D>3W/D?r/RB,'A!#b_nIY+.;A%Yph4AYIEKdlP`8]Qt5BDbDfA9^ch)]=cX:*02s
dh=:I32rB;1;W.@W79++HPohe4Pq*G@%i7M9je]cdel/r,:'%GdE-Ib@`u"o>bd%hi\>Jl<Mt\i
=.Q*^g0pZc@dpY1(;67uqZp)iWm/\\O5D#u4;Zu"T8&ppQW^ZE%[Z-M(3fr@^:rG>nMBruiEhH$
m1[Fl'V6H!jI=)6V:N&Opk%6enG`KEmX1D^[!>Qu:;>.-/gn1!(!ZL7&,[3TidZo7=o2.]h[fB^
oh@ZeIgj%3G^ePdp+reskk`9UM.F6Kr))J`1+&GlDC*!HET'RVf[_f)n7VHAWKtP<B)R/I:Q@UP
r*N%0,PX(6rXejYDrJceYqKERnEuX9>@0gs2RP0)4:U5`,5t71(<XpL%7A&m\rMDWVo>T(Z>)FC
hh^7)nB]f!i#fe=g,HoU)Xrg-W'2AL2oHdGoK1;:_&jL4hnms92Xp0iD7hl=W`eO-'eSkHCS(t"
Ih"'<98gE#%Ss0NY=85;VWq?,\k"dVY-[-6&pT3-Vs&EcVG6],nWt]Nr*@FNCm,<Z_f6/^Rc*_+
5E<PAgcpY>15lAF/oM.&3h?Ph3clf^Z,9Y<=4q#YKu`5Y=Blj;8pGYaO0.HtNtK?Y3DtT!M4om-
.bJQ3)O,5=X[<akMfY1)e:akQ3FY.Q.Q&-9ee!O,W\U1oaXik[!.n3^RT>E^';Cj&:pm9c5E*d*
*:2NZQgI?6-HBhDWhKYn_+f2^r"2$M:Q0ZQqc<"]!n&=DocFG:9_`Pc)c%8af"Z!e8CMJS3B`,%
iI;YYE8/VO:g@`2U:5i`m7(@&*544m::=]*;iTDr=oJ_PEc9V[5A-,K-><<44?[WA^\CpWi>WYP
^<[>0W7^G!_2[Ig?n<0qMR`2enrU!%:1oGDa)1;Vi*X=hfpBU*-1F.8gHUM.0C^.&rA)hknb:"X
[d^*l2=V33%YJu)C,uU;)gJc]/&Jk._pNmN)L.g[Nu@7*'"&\76>[Xk/N&M$_Ytb+^KS^mP9(?J
*XI0Hp`:<<9_XA@@I_CV/FS4q1iePtQ6)Y/kP1R%Q<hI3]"$NJ&Q<f3$J"Y!J$Z8:=PP5EL383n
o))1dEU^5\)oO_+rrBsLEht<F)^+)<*l6u:Fl=c>&V*uO@^&[..TPV0p55D4WBq)bV`HW(itVGV
i1G[E.)$RD.J/F6iNhulMEW>6:[llJ,M&d^\&+\G_tr8Kr)Igs^Ct7tR^?8\X5[jk2r\`a7k(r(
ft7i<W,!;pRm6fqPM1@:MAod.[aXl<>pJ97M']mfXCZeb_HQ'IM*,13\i&gjpNj9/*3ih;,`bd3
d.I3p<^i!)>Ak*X?bBJ+1?l9>FlP+pHmJJQ)Z3>"LPpO#nFeLNgVKW<8n%57V5)@W2"oNQ1fN>9
)o11H^$LALe8Fpe]`S36:XH7\mk#cM9fGeQnD0t6q;L=opaVoR0+I5r1/AGUANYHfiRPTiINE7=
n>s8GiP,e:EbVi8Iq7AB:MHq8pn$51]Y:tCnon2EQ5phee#g!@WEe^,eaN&>"8s>>C@SJ^cLID.
iQcqk3IVn]kD%<ZQ;bs^\Q$W32;qT;1VD`U)E=1-WabK.VAgo'g6NdBeM+5FdJ_t[k$1j&3=KTL
bqj_W'!/dBB8#uDCLUc\G3+XUKUkOGe0@^EX.u/.R['DVS,NaXIGu8_iD#r[rr<C>?OXRVidVkO
kaLCc:QI_SO+_ulY(,C4dBAl-9R&Z_nLMX%%V4-,?h%HaW>46tQ`4-PLVk%D]u5*#h.\GOiMZT/
\&@b3)F%j6!;oNOfan)_T8e+<`*\<A./9_#IhI,pnX]1!HqWIi6+%.oi?Jn!KjoqjcH=Gg9.dTE
VH@'Vl+1`3ZRr;5V7!gF&HZS>D;ep`0,SE7.goLR:j"kc.J5*2frqd+4.AdDmHio!dRbgT9=ntY
[f6?A;*OYd[eYj]!(>ZDGWg`V.!Q>XiS)u!8JdWMEI:j-q5#(''ieic7>et@emS!Qrn,%<9ft!9
-3k^:,k0GYfZY[LJXpNinQ"kPr/\kQ=\"<)SARP17]d(F3c\2JXsO^7(G<S)PnTXP)R>9dlJein
<N>([[7J/TJq?3UIpk!\IQ?mVm5Ns!'5Le?_->_V_SaAB4h$(D!,4[NfA[m.,WD;*f.<UR?5)f5
`BP#\X&;oci]"Gs4%?eS)b$4por[RtXtCpu!'^hiqEJG=$>Q.?rNj3UkaKMdiGCpWXSsM\P-9Vl
9nDXI;=J4SA;7uu["?<8;n)=*N[ZMRe[<,V4;,$4*L<:OqT9o<?GDUb:?#MC(jPYEE@)22LW3`.
:BgYBWJ%I/_IG9!j\$-)5!.]a(IK3'@T>tM_c9X"UBn?*36DRcCAs(-PJPc,Cn@e:>$.:8D#XK"
YD:%fEiSc4jZFhm'qlE\[7YG-PrBtYi8Z_jc-%4$'`Ff>+TDEN5@ra*VT6SddoOrt!+I%/IieUi
@=)_d6QB9pc]"4aBD@8B+5*D]pl2:fE*5J:UGn7@rrC`W]Q%qa/]Gr*&%qG(SYKo1hhYW4O2RAl
$@H=XLMQE(:C:l<g(p;l/33nl3i2W"mhYoO?A;U#[9j?qCY+kqZ"dH#T,mp%W%?YGj*N,LD#1`^
HXd5]YOp0@!!NK'T9"gm-%=M(ZbDnUNGUVn%3rYQ[?&/+-VX"nKAi@/_`-F#M7bq157Q<2G#m=F
^9ut8)#SIM47O!D?c:8->m^3!Ac8CPo2i]V:852ir2T"i(Xk[Kl&aV6J(g)k%5;M-D4Tb!^8H'@
[kVSUZ/'.5Ar1)p>3[#;]uI$qW_FK0>cS((jP3qkh6im)I1.:7b:hlA*e-4RG8:&t4AOoD(cj/`
?eNlslbsimA]FWuO2n8bh/#e(=PbIcG[Y?r?G(8[$rh3]B8<NWXY1p;mN8-`i[u20fhtT\RW+Ic
L!=@6VhKdYfkqFo=.\@8b&`:Td!0@B)RS'=d&2VC\Df48,<&qh,FP:0C-#t'Ug6-DXf#N?GHh-Y
Zdc)E/8jJWpIpn);8k9p""IFR@E?Ni2?'3+YrI,C*el<SLnfjEib;>pL;`&Ne$"PF:ob<l<N&rp
.9!9>Ua2j5V)_-]Mc,IIVLD4-P)Q-aN2%7[8_luo8l:gVdDf$u;/5?mRVajWn@spp>Mu&A/R-(5
-.b78W5E[hG*dM%Vg#%2f,B&:!,8a%DmIP)g^@if/4hnE)RDBD6a/eZ5Ht.fLqd\Sg1&+^mG4]-
6`]ul>giOsi@c.>%i"dt_k&UPc\VKnWPRS`bsM/)P?@>cLMT(;D02'Jb>>RTd^>Pr<X[n.`mRL!
H-bKpFabA$kDhYJ"l6A8r+4m&$eo%Dg>:^kpijY[^53$Tjbt#,[PrDLl*lu+gg&OeC3l/QXRdsc
D[)n`$nd[ucE_mJj-PDJ4B(1nj"&4?r"#$3VD_RLbP8],\-IdKiYW">/Di)"j!`I$1Y`HiL,KmM
((,:YFR/NrUK2Ie\4c,WM=_cNS]`A(E4DA-bU[)"\ur1L".A0>ajbC.;A!ld@4FU?2]@j=8TM6+
'O2r/ae.,nT>?SH2ZEa_P3-,0*tMrQlKOVchsd+!QlEmIa0GF%IhbPV80[R5[A:B3ldA47"mD6[
;&W#Fp'iRTUT`dgmDZ:.eK;+S56u21rr?d3:W5B_X5f6Wps\TD(@Ysl4<NPN%X=1/O2?3tQZJtK
U\KV_G!7%R[2PjleeV=J<f(=^7q`$OIGEHKQPJb6.JT%b%6OOqDrD4_13el!qJX=nCVp`tOSH(C
9r2NLk-^r&nUaOF8M"r?hJ<*AnI&K6[Pl^'=#q%a!Lh&*CR`M7Y,o#:,5aOm(QQ.HiqR@B]NaDL
'mQJ0_WL:W_@I6'LKE'cZFeU/YdV<X[#7hp\P$_-gMJT;\%A4H8T%qbX<O((4$.SGr7LiYI2KJG
>]&IJ'QG0E_X,I"3'5/V'6m$'3,9,bD[khgh#@?fQ=TL.@p^P-o,8J$B+*f8nX5[b'h^"!-\oOg
UIqs5Xqh>`1&Yb'DD&fP0$:DC8pSZ$AmrTF/isN#rnYSo=DARdb+k_i_UG#(jqtYVV,Ek#?6r?E
Pj)/&/>7)+'DTVeIPoWPei[=!4`A9MSG*hD;I4-bQ/88iT)R]OY8SX./:n&8\9=0/i"<r<N$&*$
c-\t0Z1pEs%kb`H_.EC82?sJBLU4nW0Q\aPE3pd4/kS*L&;;T1k&'\+&Q`i'm@T'WF/^T`Unp)n
[jfTt;NlI<8[dB%>A](?Qbi\%(KpMT`\@1K:ss0`Chts,9\jH(BfC?uVkU<<PkGlPVb^f6HS)$S
`iF<]BKC^V=L<W#UBU@b1iK8F1ARcSXjugW<gHe<(RJ(1NXK&nA@T.@6hb)F#Z]uOgTi"Z*l-At
W+jtGO/5RJCDq)gVs4h!_)(>em?XM[5+MB)BaoE`RpAMD8@X:LZd\R1FdLWqXYkH0fPU87ZB"6C
eNV7JI!pb.Xs;+;^cD*LPr\AO38rh_d)FJ/S(>GQ`F5cJ`o(N%\N"!_rE7[h</\ZAF,rG''dZKX
M5]>\AlsGu#E!MGCADIM7h\P]&]<,#L9H<1LTaICPBE@c3mr`Gd]/Pp#hDtRc?6,cOFP)2ogh2d
qaYnkj/7nogCF[:Hf8VSIh;C-;V/#*=Emob(Gi]bHu7k[G31<"/m\B(F5qW9\*C/229Zsj`U4nU
dJhI1r2fnu_ioT.Ttg4hD>sV*](gXP!6o))B&BiEm+M)nJUSU6H`t>.-]#NUhh_7;peuIsL%,cE
VRpj;)X:%+Iu6:RKWa-@]!q3YNVbf8a"N>h/to2NgI\=-SfbLtgO5m7J*hQsa]u_?b4pn7rr<sY
%M,U7n371f3&e[F'qOXLB[qlmGud,<&lccuQ6Q"$Xt\k\+JSCjjZ494kfXn_9"f%7V>Kro.Y^7*
>SiV&?8u-Z_;fU*QrlL*pjCA3>Wu4Yf9E#)P\$4"-rb)c'\$kUd_FrE!;mOhiF\pbr&jJ(m4$uj
$1Ie-q_)=BIN\.jT4tk(r(m2NGf_dX:YHW&0LYXDO)s:9+2aZaq":Cbl'bYLikH6'Vr1@+IN/'1
XWm;lXOI1L,JUVKJSdV`O,c`iDhR6'0"qL:BXNW8)A]7/S(fMr9Tr;E89Og#0&>A4oNagZ\Xlsl
1j`nS(cR(08MlkgUFkl&M#RJ%$GW)!g?\H%n;"ktIfBE+5EGG\eGfMZ^Q)L:`S^%Vi[\db5=gG#
Ie\ZF_iNLCC<5ibOX&[eC0i@fRe<K*f1XUU0`ETt'<UNMl/80=-cOu+i]PXq(NF9(<M!'TBD`(+
nFudCj*qL!'2(1&E+C]Qo*h+IZ:mCB*&W0ji#L-;1;_3GV!I-K`8IOp%>8t`o_W9b?o[>)1#*5+
b(M9S.Sgesp$l;1%ifFiDO)q+`)VJc<gFOZU8%lAD>Pkh.9GT0;D<d=R\,Z4pg;#Z5D4J6G]MtS
]GgDskt]^lmgbOmD/P],OBQ"Jj]d]L?M!6iJM]g)P1o:S2-\.uK\@;d+.'%cIqTqWho;:p=2\P+
T,Qt&g9lR?f7"D'6<i85U3OjH9:-W-D_s8jG@PTgD/K2>U.9[!qGk^*a`Tb;`f[=ZGuRi<qY7`^
[WcMe%G+Y7IZL:0lFN"XHZ<m9h8^P%Pnf%AVJ)9^<gPSq6)WtnIqEh=>s2TGm@\7.48i[Y@hllV
+,*r[NoA8<V8jW!ic)EOk9K"\1hao7%%Aul<Q%_/>*UT1nM0m"C:K]mPtJE^:G'LO[2a?+!jL=g
GVA6M2[e3qapWtZ'epPQIS'3k,d<]LThcN(dB/8/:),KicAiBa<kulh7YOnnCE$$0[%a(j@GtR[
W?R4&Rq+6F/)r]87Zjs;1UR6CaTFNB80,Zn*\I/8YBXsb!)[N;-cF_oL+PeHBB$$\_O@rhD6K[s
da$ueP>0QC^XJ1;hSj7dh5I]Dl">UrmcJ:HkcI!\i31.+/&8_^nD:bQLY6Q&)5hiVX>HZ6g0?f-
he&1&,0N[1=t<`gKL7qu+,VoX'"eTl%C0-QAaee',Lj\OPqFKSf-XfK_QG=rl;5YYo^J'[do:']
WF$BoX\)`TlVa1F:$%UY?XB)M.FjY3:f==+S5i729Y]mR()I.D@d5Eu_;R*dXUM(e1,W65jNOkc
l<l#8P*0r4NN7c2#,_g9WD4(`l9%B08Gm5Q5A+70p2-/5qMpQo%4SC<]YCc"FmBA,r<=9MCYDL;
Ve4:.R3X"aHV<>!_\,VA>et1pb9YPA[GT(B(AAT.8SrSQf%1J<8RT&biL2,7<_0#dFCF!.5ou<c
DR8kG^m_[4_SbEKNI3='5+u[?c_C2<ep/gRfHBbQD/A1_qubpd5ke2\8UCeN2PGP;IB2+urZo)'
/K[ZlnfNei!#Y@c([1Jc=&+%KQn7n9(=QU'ZF9c59='@4XsL`fP3U'-;:F]spq=G+*hQq(fg6V>
l:C@kYYW6N,gZ7t,L3VhY:]5ZY7,!T>K&Si/)kro#t$SkeQhi3XbU':&7[2&nYq;`ihZu]k<(%A
:)2'?*]3AkK2A<7YF+jG2Ps:KYEe$0X%Nm1^3/PV=`-#-P@1WBp695X!J$r(qUHB`&NEThCg5tb
/%X-)9A*=Eo2QOUpi!ut,k3bBL\?1PY2!_M(Y8;hqVIj=hs.Y]?*or!p=D7O6Y.4lhMX;&M<;4p
dc0L9068sh=Bf+gg=b"u9fp7:\$05WXBr]RKuK=a3j+=@GW/[:dc&\gq`_Wg8#\jtQBGM,=MgD*
qSeH8ZqY+A[C+L2Ks_:!kkE+;0.d,'&]2%_f+3r%>V-fEYEE:hX5dnmEeIYsmi42&;cP&?Zn#T7
cAa0N<D\@CjkE69XbiVai\:">d_eQm=uE=Y\l`[g7\*-"MOeAE\O"]B$?N=I'e.[e2bJ(Zo`"pE
k>0I"nJ87e0+cq(qL+]^Cf:*m:'b#D#2\ZDK2AjE24[bs7Dq-K^pc6@7Z@SaZ>7QnW^j^uP?D8h
hh<o1hqK[A=+S(+GX;7@^($]3)Y]/de8=T[PjnF$lrq]\>1P7sk\YGZFLSa-eD'2R3qCH?c\jn\
\l>q<g0NE#h^E_^_EK=AVrN1E_]B0S^\69<Hqe^oG*/N_G*,&=grg%!Il(V;A$XN>o!WT))U61E
VtbU-Za[KYP\F;JS->PWOo$a0adS!>DnJ+--2\%9ks$.MH/N?uC&:AKd(3MBO,@fR"S`ksQUkI!
8@m'ZXdhHHN-mu7M***]/pK%f)E`1L1"L_"j8T+8)R"q/=lKA\K>/3RO75Zfdu?%F'_->c4NR'#
poEW[i\jXbPJU'"K>L,3nG\,(V<.N.Lg3ML2="^$A@t-`rm]/tlpJWgViK&-n<e#TFb7VCZMsp1
i%GNpmNUXor*u3r`;]gGnFdcd',ce2h(0i@nANq]ZHTW`TTs?F20J0#X\/9AYaAUO.M82Tm]Yk6
r"FkE28>#t-2<P][ZZa+HkQ3)i@RRn@JucS]g#GBbpQ`c5*)i!l/*SqE;#LLhcnrAGRIC<5D4Ie
Hp?6Mot+QCkaj!fkNt:&CO1-KO5^TV;S2msXtSbS_nQBQ,k)V'4rF2Ycu$EMi,8YkIn]@HKle8:
Y:ekaFDW5Q4%hPE^XTE)F\PX$UQ<^8e[92Ym9`o.P2fHjDm+:&X.-)89)`tk?4t-E576KL1PN#:
)_A`7.:]\89UtWu[VLf%4C2fm@k(3qV+ap"Qrh48lHZ3j9ff9aE`#O/:H$F3B%_nd+&W]ImA?-u
'[fsbfQqOs/2eE2;G&dMoH?th32D^r(7KqsIh@,MGTYr&Jc&73gYu"`FMA[f)oapDqDX6n2lsf-
Ygfa%.c:r!*Q>/:Xi,0cBtBRgp=bgrWEWP@H=bp0TkS@b(MQ<dA>:#3;3Y%T,i^;p-Q,@R36=7Z
RVan3RaI^QD)B^UVOD5PVR9,UWbLhGk0s%GrK,Le=+Ku$"b+kn)Mc7,fhU?SY.Q"V:gW#2g\B7U
rhFP9h.\o>O7B[;ijb1?^!Lluh&\-mp5"aSj1ntTZ]i=2B?$JuiVoNhV;?T*qucnl5@P.^J$[Fq
F5lWl^8'_,L>q2sr@>kd(5h,G<7,fT-A(g>594H=p1Dl;EiQ*aE$<a/,HP(ebGbkoYGBR0r=Eiu
mI'B)[CUr/Rp27k\%/rWM'f^A\!\-pTUm#9UQud-Ar]uh<_3mk*D2J*jp/h3pa1KFoS`LW7Je<S
o`!,_.3NEpJog^10,ac5>2>u-W(h8ITmSbYrr>=94pl%KB(/6#J+-gaSe^`PNRFA=`8:m8+CBkX
DhI!2i_P*Wr8/=K[="H*i#X&DN5b="J@oSoXEZBWYE,Lu=cP"co-i)+<8e83+%@<sV$UT#=UloZ
KJBu=i:-Mh4YLk3Q:&YOC05@+Vd>BrL8>e*/h,!.;lbq4(u8-.k1EhHe'0S.k*'iJN[cbY0U/J9
XQTLTlQ<JtM62"s]FEl8KmKYSoK>iA[^4P&mJKUkrLk7M(G\X#F/DO/hV)>5p6#&S.oXjgq/qDC
XgRqXlrPlHl.4S%kC@7"IK';'V0KB'4ftlGQ9-q(h*XkrnIk]CV(3"nQS;V.L=8u.^=20Bc$?I@
cC/XMH"EmDWf((Wl7_jrrJ+pp\(%R1B`A(bZc@B@Z#d#AB#sm_rK)+q?hT,WnL'1k;epHRhT;VH
Hm\?5rLe9R5<t-%\^LCait%+kZTj.+)5ghH>MJB<m\OoOU?WO:R/[0U_LJlSNP6n;_r5ON^Y<D;
Y^$0J^sd&gGdH>jYl5%#:W>$hc/EM7a2:(iO2cVbe,3q<Y7bMiG^m^(SI\k/=2B2EBNi[2[tcgu
O'&lQh#>RWT@2W4h[aGa(&4,N]NP(kk.'Zn&Y.sQ9u:&bM1-LI`*egT+d?X;+dV9#luj#1a4-po
<@XCM]GrX869&Z.p_`cXMnqn"7tYHlq=YIU`CCfhJ^6%f$sLYG0r8(enpLH;1K\J%Xs/lI&]EY4
$5e)G@AO<OqRe%Go)`8)Gd7_)VTn(\&4`\*`=c:eNMru6a`+?Tka(Acc$#Wih>jKJaK3eW:eas@
F<+T'VDZrkV6QHVX<9DJ><-h&QR5;,jF&^fXj*VD=BjQRSQKTlF(u.qUp]m/e_t1+9o&WZHfIQ-
=#O*O&Uk5gL/jPp/'m3WSa@J,1IP)f,<+;=iRMJL[Xr[[8'8p%%'rIa?hc"h_4Q?$*RO^Mn`TW#
peC78_Z&Q<5VdSqe&+?bd3eBWRUsIkn@+0qmhYgpcj$n)&o%+/VclNag?Q80T;PIGB\$`@L>2Dq
?dn3%jSb)[[uS*S&$8fQE:MkX%.OR\Zg;1$iDT;eFEh"D_rT@FrM3o-p^Gl/:,,aK7,2a:KNrbj
S)M"JC!r",b;%#u)0H>C,sQ6J1kk2ZSP""bl>fIpe;TW(r\jOsnb^5\#4T3KB0uAfXQZj0c`([U
Xl"pk9A?Mn&mo@Z*CEi:\r,M<j&>NV.t&p=Ynq@Fp-/mgg0<2sAHmdbG^e\&i:lTqn\b'qf<2"?
L64<=$s*X+Y8);RpA37T3Yf<1D5+DGe0g.Eo2W;S/CrZkFK97_=Lsq+e9.XSmX*2J?e`>GhlpJ*
br\a_rY1F[QgqiaL"<?n4>3d%nK6c2LFp.LnE/UsnYV=(?Ocds5k1Jd=?[II2#*9n><P104[SU9
p`JJp!/j#gd-e'=)Y[#gQ+`D_^THB(^YkZT&q">7UMCsYho4>U#K+!N"SY-ep[$X]Y%YNfT'"3n
[?_7``a?)"Z)@A0ieNRO)/]$0k;GL)n42j6)1tu>4>5\BHtB*XIBulOBO=<3`Yk(dM>B+)-_BGq
`d[6rYDo,<iZ;<"!jT/!,8l?Z\-iT]g!44=HtQ?k',+9H;+@E-fY#N<Lr",YI]ci47gik;S5s@B
=hY5fb^M%=0/1On?PSr@(sKjn=ZV\CMU+(PAg7bOiTSNU$O;6B8ee6MkX%/1-2CL:*WF'bBH#3Q
^)I!m<jqu_pm6)cBR^f4CEJ?r*1pF047I4R\,'>jVh0;U$Wtd5`EkWWSo54[n-?dY.Yks+?/"MO
58q6Yr&X?7rrBs-DWgjprLUc:YD9m!:W(]O%tISZe7[9R>E9MDqo@PF]!U":,5bU=KrEX<\o,"I
+6$&1AZ+cnR`C;.9+;h1dHp7BXXcg/O+P"dT_MUIYJY8dpab<QJp\<kY7CDIK=i)[rr@h'(XW'j
nG`L*Sf7)WK)@@$_5)<k_B'36[(d%E?c1StYUaRfBRV&ND/?SM-Vq3L6l&f=QM/]-6uaV4dauT[
f*9dJo5*TK31l!_:-A%kMEQmWSj)-U@e4E]1a>a4I(oc=Tbu`D4GO.K<,#`\9XYZ)R!M^cn<^QZ
]a%?uZQ.M)W+!!u%MFe!Ol/6c?^'F'Sf\KegRW*0-2Ynm196Y9>8LSfUua&5bKhIYMC>[;4Fj/`
-ig!!-ig!!-j0AJlhuopi7q]86?hc*rr?Ti`r?&8\c*mP70%ekd7"`L;+20!;+20"s4IAP~>
endstream
endobj
13 0 obj
21809
endobj
4 0 obj
<<
/Type /Page
/MediaBox [0 0 763 595]
/Parent 3 0 R
/Resources <</ProcSet [ /PDF /Text /ImageC ] /Font << /F0 7 0 R /F1 8 0 R /F2 9 0 R >> /XObject << /im1 12 0 R >> >>
/Contents 5 0 R
/CropBox [0 0 763 595]
>>
endobj
2 0 obj
<<
/Type /Catalog
/Pages 3 0 R
>>
endobj
3 0 obj
<<
/Type /Pages
/Kids [
4 0 R ]
/Count 1
>>
endobj
xref
0 14
0000000000 65535 f
0000000009 00000 n
0000027218 00000 n
0000027267 00000 n
0000026991 00000 n
0000000199 00000 n
0000003224 00000 n
0000003244 00000 n
0000003377 00000 n
0000003495 00000 n
0000003715 00000 n
0000004721 00000 n
0000004960 00000 n
0000026969 00000 n
trailer
<<
/Size 14
/Root 2 0 R
/Info 1 0 R
>>
startxref
27327
%%EOF

11
requirements.txt Normal file
View File

@@ -0,0 +1,11 @@
pulp>=2.7.0
pandas>=2.0.0
numpy>=1.24.0
streamlit>=1.28.0
plotly>=5.17.0
openpyxl>=3.1.0
networkx>=3.2.0
scipy>=1.11.0
airportsdata>=20240101
folium>=0.14.0
streamlit-folium>=0.22.0

37
run_pipeline.bat Normal file
View File

@@ -0,0 +1,37 @@
@echo off
echo ============================================================
echo OARMP Aircraft Routing and Maintenance Planning Pipeline
echo ============================================================
cd /d "%~dp0"
echo.
echo [00] Setup...
.venv\Scripts\python.exe scripts\00_setup_project.py
if %ERRORLEVEL% neq 0 ( echo ERRO no setup & pause & exit /b 1 )
echo.
echo [01] Inspecionando arquivos...
.venv\Scripts\python.exe scripts\01_inspect_inputs.py
if %ERRORLEVEL% neq 0 ( echo ERRO na inspecao & pause & exit /b 1 )
echo.
echo [02] Construindo referencia de frota...
.venv\Scripts\python.exe scripts\02_build_fleet_reference.py
if %ERRORLEVEL% neq 0 ( echo ERRO na frota & pause & exit /b 1 )
echo.
echo [03] Executando otimizacao...
.venv\Scripts\python.exe scripts\03_run_optimization_pipeline.py
if %ERRORLEVEL% neq 0 ( echo ERRO na otimizacao & pause & exit /b 1 )
echo.
echo [04] Gerando flight strings...
.venv\Scripts\python.exe scripts\04_generate_flight_strings.py
if %ERRORLEVEL% neq 0 ( echo ERRO na geracao & pause & exit /b 1 )
echo.
echo ============================================================
echo Pipeline concluido com sucesso!
echo Para visualizar o dashboard: scripts\05_run_dashboard.bat
echo ============================================================
pause

View File

@@ -0,0 +1,50 @@
"""
Script 00 Project setup.
Creates all required directories and validates that the raw input files exist.
Run once after cloning the repository.
"""
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from src.routing_engine.config import DEFAULT_CONFIG
cfg = DEFAULT_CONFIG
DIRS = [
cfg.raw_index_dir,
cfg.processed_dir,
cfg.reference_dir,
cfg.quality_dir,
cfg.schedules_dir,
cfg.figures_dir,
cfg.exports_dir,
ROOT / "outputs" / "dashboards",
]
print("Creating project directories…")
for d in DIRS:
d.mkdir(parents=True, exist_ok=True)
print(f" ok {d.relative_to(ROOT)}")
print("\nChecking raw input files…")
REQUIRED = [cfg.flight_schedule_file, cfg.aircraft_file, cfg.checks_file, cfg.airports_file]
all_ok = True
for fname in REQUIRED:
p = cfg.raw_dir / fname
if p.exists():
size = p.stat().st_size
print(f" ok {fname} ({size:,} bytes)")
else:
print(f"{fname} NOT FOUND")
all_ok = False
if all_ok:
print("\n[OK] Setup complete. Run scripts in order: 01 -> 02 -> 03 -> 04.")
else:
print("\n[!] Some raw files are missing. Place them in the 'raw/' folder and re-run.")
sys.exit(1)

View File

@@ -0,0 +1,41 @@
"""
Script 01 Inspect raw input files.
Prints metadata (encoding, separator, column names, sample rows) for every CSV
in the raw/ folder and saves a JSON report to data/raw_index/.
"""
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from src.routing_engine.config import DEFAULT_CONFIG
from src.routing_engine.inspect_files import inspect_all
cfg = DEFAULT_CONFIG
print(f"Inspecting files in: {cfg.raw_dir}\n")
meta = inspect_all(cfg.raw_dir)
for name, info in meta.items():
print(f"-- {name} -------------------------------------------")
if "error" in info:
print(f" ERROR: {info['error']}")
continue
print(f" Encoding : {info['encoding']}")
print(f" Separator : {repr(info['separator'])}")
print(f" Columns : {info['columns']}")
if info["sample_rows"]:
print(f" First row : {info['sample_rows'][0]}")
print()
# Save report
out = cfg.raw_index_dir / "file_inventory.json"
cfg.raw_index_dir.mkdir(parents=True, exist_ok=True)
with open(out, "w", encoding="utf-8") as f:
json.dump(meta, f, ensure_ascii=False, indent=2, default=str)
print(f"Report saved -> {out}")

View File

@@ -0,0 +1,64 @@
"""
Script 02 Build fleet reference table.
Reads AERONAVES.csv + CHECKS.csv, computes TTM and the full check-cycle
sequence for each aircraft, and saves the reference to data/reference/.
"""
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
import pandas as pd
from datetime import datetime
from src.routing_engine.config import DEFAULT_CONFIG
from src.routing_engine.inspect_files import read_aircraft, read_checks
from src.routing_engine.ingest import build_fleet, _check_cycles
cfg = DEFAULT_CONFIG
aircraft_df = read_aircraft(cfg.raw_dir / cfg.aircraft_file)
checks_df = read_checks(cfg.raw_dir / cfg.checks_file)
print("Aircraft loaded:")
print(aircraft_df.to_string(index=False))
print()
print("Checks loaded:")
print(checks_df.to_string(index=False))
print()
# Build fleet
planning_start = datetime(cfg.planning_year, 3, 18) # first OFRAG departure in the sample data
fleet = build_fleet(aircraft_df, checks_df, planning_start)
print("Fleet reference:")
for _, row in fleet.iterrows():
print(f"\n {row['tail_number']} ({row['model']}) FH total = {row['fh_total']:.0f}")
print(f" TTM before first check: {row['ttm_hours']:.1f} FH")
for i, c in enumerate(row["checks"]):
print(f" Cycle {i}: threshold={c['fh_threshold']:.0f} FH, "
f"TTM={c['ttm']:.0f} FH, duration={c['duration_hours']:.0f} h")
# Save to reference
cfg.reference_dir.mkdir(parents=True, exist_ok=True)
out_path = cfg.reference_dir / "fleet_reference.csv"
flat_rows = []
for _, row in fleet.iterrows():
flat_rows.append(
{
"tail_number": row["tail_number"],
"model": row["model"],
"fh_total": row["fh_total"],
"ttm_hours_cycle0": row["ttm_hours"],
"n_check_cycles": len(row["checks"]),
"cycles_json": str(row["checks"]),
}
)
pd.DataFrame(flat_rows).to_csv(out_path, index=False)
print(f"\nFleet reference saved -> {out_path}")

View File

@@ -0,0 +1,44 @@
"""
Script 03 Run the full optimisation pipeline.
Loads data, builds the network, runs Column Generation + B&B, and prints
the solution. All outputs are saved to outputs/ automatically.
"""
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from src.routing_engine import RoutingPipeline, DEFAULT_CONFIG
cfg = DEFAULT_CONFIG
# Adjust parameters here if needed:
# cfg.tat_minutes = 60
# cfg.mip_time_limit_seconds = 120
pipe = RoutingPipeline(cfg)
result = pipe.run(save_outputs=True)
print("\n" + "=" * 60)
print("SOLUTION SUMMARY")
print("=" * 60)
s = result["summary"]
for k, v in s.items():
print(f" {k:<35} {v}")
print("\n-- Schedule ----------------------------------------------")
sched = result["schedule"]
if not sched.empty:
print(sched[["aircraft", "ofrag_id", "departure", "arrival", "flight_hours", "maintenance_before"]].to_string(index=False))
print("\n-- Maintenance events -------------------------------------")
maint = result["maintenance"]
if maint.empty:
print(" No forced maintenance events.")
else:
print(maint.to_string(index=False))
print("\n-- Fleet utilisation --------------------------------------")
print(result["fleet"].to_string(index=False))

View File

@@ -0,0 +1,75 @@
"""
Script 04 Generate and export flight strings.
Reads the saved solution CSVs and generates a per-aircraft "flight string"
report in human-readable format (console + Excel export).
"""
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
import pandas as pd
from src.routing_engine.config import DEFAULT_CONFIG
cfg = DEFAULT_CONFIG
sched_path = cfg.schedules_dir / "flight_strings.csv"
maint_path = cfg.schedules_dir / "maintenance_events.csv"
if not sched_path.exists():
print("No schedule found. Run script 03 first.")
sys.exit(1)
sched = pd.read_csv(sched_path, parse_dates=["departure", "arrival"])
maint = pd.read_csv(maint_path, parse_dates=["maint_start", "maint_end"]) if maint_path.exists() else pd.DataFrame()
print("=" * 70)
print("FLIGHT STRINGS PER AIRCRAFT")
print("=" * 70)
for aircraft, group in sched.groupby("aircraft"):
print(f"\n{'-'*70}")
print(f" Aeronave: {aircraft}")
print(f"{'-'*70}")
total_fh = 0.0
for _, row in group.sort_values("departure").iterrows():
maint_flag = " <- MANUTENCAO ANTES" if row.get("maintenance_before") else ""
dep = pd.to_datetime(row["departure"])
arr = pd.to_datetime(row["arrival"])
fh = float(row.get("flight_hours", 0))
total_fh += fh
print(
f" {row['ofrag_id']:12s} "
f"{dep.strftime('%d/%m %H:%M')} -> {arr.strftime('%d/%m %H:%M')} "
f"({fh:.2f} FH){maint_flag}"
)
print(f" {'TOTAL':12s} {total_fh:.2f} FH")
if not maint.empty:
ac_maint = maint[maint["aircraft"] == aircraft]
if not ac_maint.empty:
print(f"\n Eventos de manutencao:")
for _, ev in ac_maint.iterrows():
print(
f" CHECK (ciclo {int(ev['check_cycle_index'])}) "
f"{pd.to_datetime(ev['maint_start']).strftime('%d/%m %H:%M')} -> "
f"{pd.to_datetime(ev['maint_end']).strftime('%d/%m %H:%M')} "
f"TTM perdido: {ev['ttm_loss_hours']:.2f} FH"
)
# Export to Excel
out_xlsx = cfg.exports_dir / "flight_strings.xlsx"
try:
with pd.ExcelWriter(out_xlsx, engine="openpyxl") as writer:
sched.to_excel(writer, sheet_name="Escala", index=False)
if not maint.empty:
maint.to_excel(writer, sheet_name="Manutencao", index=False)
print(f"\n\nExportado para Excel -> {out_xlsx}")
except ImportError:
print("\n(openpyxl not installed Excel export skipped)")

View File

@@ -0,0 +1,5 @@
@echo off
echo Iniciando dashboard OARMP...
cd /d "%~dp0.."
.venv\Scripts\streamlit.exe run app\dashboard.py --server.port 8501
pause

View File

@@ -0,0 +1,6 @@
"""Aircraft Routing Engine Set Partitioning / Column Generation / Branch & Bound."""
from .config import RoutingConfig, DEFAULT_CONFIG
from .pipeline import RoutingPipeline
__all__ = ["RoutingConfig", "DEFAULT_CONFIG", "RoutingPipeline"]

View File

@@ -0,0 +1,74 @@
"""Centralised configuration for the routing engine."""
from __future__ import annotations
from dataclasses import dataclass, field
from pathlib import Path
from datetime import datetime
PROJECT_ROOT = Path(__file__).resolve().parents[2]
@dataclass
class RoutingConfig:
# ── Paths ──────────────────────────────────────────────────────────────────
project_root: Path = field(default_factory=lambda: PROJECT_ROOT)
@property
def raw_dir(self) -> Path:
return self.project_root / "raw"
@property
def raw_index_dir(self) -> Path:
return self.project_root / "data" / "raw_index"
@property
def processed_dir(self) -> Path:
return self.project_root / "data" / "processed"
@property
def reference_dir(self) -> Path:
return self.project_root / "data" / "reference"
@property
def quality_dir(self) -> Path:
return self.project_root / "data" / "quality"
@property
def schedules_dir(self) -> Path:
return self.project_root / "outputs" / "schedules"
@property
def figures_dir(self) -> Path:
return self.project_root / "outputs" / "figures"
@property
def exports_dir(self) -> Path:
return self.project_root / "outputs" / "exports"
# ── File names (auto-detected if left as empty string) ─────────────────────
flight_schedule_file: str = "ESCALA DE VOO MODELO 1.csv"
aircraft_file: str = "AERONAVES.csv"
checks_file: str = "CHECKS.csv"
airports_file: str = "AIRPORTS.csv"
# ── Calendar / planning ────────────────────────────────────────────────────
planning_year: int = field(default_factory=lambda: datetime.now().year)
maintenance_base_code: str = "SBMN"
# ── Operational constraints ────────────────────────────────────────────────
tat_minutes: int = 60 # minimum turnaround time between OFRAGs
aircraft_availability_offset_hours: int = 0 # hours before first OFRAG aircraft is available
# ── Optimiser parameters ───────────────────────────────────────────────────
big_m: float = 1e6 # penalty for uncovered OFRAGs (artificial variable)
cg_tolerance: float = 1e-6 # column-generation stopping threshold on reduced cost
max_cg_iterations: int = 200
mip_time_limit_seconds: int = 300
mip_gap: float = 0.0 # optimality gap (0 = exact)
# ── Logging ────────────────────────────────────────────────────────────────
log_level: str = "INFO"
DEFAULT_CONFIG = RoutingConfig()

View File

@@ -0,0 +1,322 @@
"""
Data ingestion layer.
Reads raw files, parses dates/times, aggregates legs into OFRAG profiles,
and computes TTM (Time to Maintenance) per aircraft.
"""
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from pathlib import Path
from typing import Dict, List, Optional, Tuple
import pandas as pd
from .config import RoutingConfig
from .inspect_files import read_aircraft, read_checks, read_airports, read_schedule
logger = logging.getLogger(__name__)
_BR_MONTHS = {
"jan": 1, "fev": 2, "mar": 3, "abr": 4,
"mai": 5, "jun": 6, "jul": 7, "ago": 8,
"set": 9, "out": 10, "nov": 11, "dez": 12,
}
# ── Low-level parsers ─────────────────────────────────────────────────────────
def _parse_br_date(date_str: str, year: int) -> Optional[datetime]:
"""Parse 'DD/MM/AAAA' or legacy 'DD/mon' → datetime."""
try:
parts = date_str.strip().split("/")
day = int(parts[0])
if len(parts) == 3: # DD/MM/AAAA
return datetime(int(parts[2]), int(parts[1]), day)
# Legacy: DD/mon (uses fallback year from config)
mon = _BR_MONTHS.get(parts[1].lower(), 0)
return datetime(year, mon, day) if mon else None
except Exception:
return None
def _parse_time(time_str: str) -> Optional[Tuple[int, int]]:
"""Parse 'HH:MM' or 'HH:MM:SS' → (hour, minute). Returns None on failure."""
try:
parts = time_str.strip().split(":")
return int(parts[0]), int(parts[1])
except Exception:
return None
def _parse_flight_hours(tempo_str: str) -> float:
"""Convert 'HH:MM' flight-time string → decimal hours."""
try:
parts = tempo_str.strip().split(":")
return int(parts[0]) + int(parts[1]) / 60.0
except Exception:
return 0.0
# ── Schedule parsing ──────────────────────────────────────────────────────────
def _parse_schedule_datetimes(df: pd.DataFrame, year: int) -> pd.DataFrame:
"""
Add DATETIME_DEP and DATETIME_ARR columns to the schedule DataFrame.
The DATA column holds the departure date; arrival is the same date unless
the arrival time string contains a second ':' colon (e.g. '00:30:00'),
which signals midnight crossing → arrival is departure date + 1 day.
"""
dep_dts, arr_dts, fh_vals = [], [], []
for _, row in df.iterrows():
base_date = _parse_br_date(str(row["DATA"]), year)
if base_date is None:
dep_dts.append(pd.NaT)
arr_dts.append(pd.NaT)
fh_vals.append(0.0)
continue
dep_t = _parse_time(str(row["HORA_DEP"]))
arr_str = str(row["HORA_ARR"])
arr_t = _parse_time(arr_str)
if dep_t is None or arr_t is None:
dep_dts.append(pd.NaT)
arr_dts.append(pd.NaT)
fh_vals.append(0.0)
continue
dep_dt = base_date.replace(hour=dep_t[0], minute=dep_t[1])
arr_dt = base_date.replace(hour=arr_t[0], minute=arr_t[1])
# Midnight crossing: arrival string has 3 colon-separated parts OR arr <= dep
midnight_flag = arr_str.count(":") >= 2 or arr_dt <= dep_dt
if midnight_flag:
arr_dt += timedelta(days=1)
dep_dts.append(dep_dt)
arr_dts.append(arr_dt)
fh_vals.append(_parse_flight_hours(str(row["TEMPO_VOO"])))
df = df.copy()
df["DATETIME_DEP"] = dep_dts
df["DATETIME_ARR"] = arr_dts
df["FH_LEG"] = fh_vals
return df.dropna(subset=["DATETIME_DEP", "DATETIME_ARR"]).reset_index(drop=True)
# ── OFRAG aggregation ─────────────────────────────────────────────────────────
def build_ofrags(schedule_df: pd.DataFrame, base_codes: List[str]) -> pd.DataFrame:
"""
Group schedule legs by OFRAG number and produce one row per OFRAG with:
ofrag_id, departure (first leg dep at base), arrival (last leg arr at base),
flight_hours (sum), origin, destination, starts_at_base, ends_at_base.
"""
records = []
for ofrag_num, group in schedule_df.groupby("OFRAG", sort=False):
group = group.sort_values("DATETIME_DEP").reset_index(drop=True)
first = group.iloc[0]
last = group.iloc[-1]
total_fh = group["FH_LEG"].sum()
origin = str(first["DEP"]).strip().upper()
destination = str(last["ARR"]).strip().upper()
starts_base = origin in base_codes
ends_base = destination in base_codes
records.append(
{
"ofrag_id": f"OFRAG{int(ofrag_num):03d}",
"ofrag_num": int(ofrag_num),
"departure": first["DATETIME_DEP"],
"arrival": last["DATETIME_ARR"],
"flight_hours": round(total_fh, 3),
"origin": origin,
"destination": destination,
"starts_at_base": starts_base,
"ends_at_base": ends_base,
"n_legs": len(group),
"missions": ",".join(group["MISSAO"].dropna().unique()),
}
)
df = pd.DataFrame(records).sort_values("departure").reset_index(drop=True)
logger.info(
"Built %d OFRAGs (%d start+end at base)",
len(df),
df["starts_at_base"].sum() & df["ends_at_base"].sum(),
)
return df
# ── TTM computation ────────────────────────────────────────────────────────────
def _check_cycles(current_fh: float, thresholds: List[float], durations_days: List[float]) -> List[Dict]:
"""
Return the ordered sequence of upcoming maintenance checks for an aircraft
currently at *current_fh* total flight hours.
Each entry: {'fh_threshold': …, 'ttm': …, 'duration_hours': …}
The first entry is the immediately upcoming check; subsequent entries follow.
"""
# Sort checks by threshold
paired = sorted(zip(thresholds, durations_days), key=lambda x: x[0])
cycles = []
prev_threshold = current_fh
for threshold, days in paired:
if threshold > current_fh:
ttm = threshold - prev_threshold if cycles else threshold - current_fh
cycles.append(
{
"fh_threshold": threshold,
"ttm": ttm,
"duration_hours": days * 24.0,
}
)
prev_threshold = threshold
# Add a synthetic final cycle using the last interval (extrapolation)
if paired:
last_t, last_d = paired[-1]
second_last_t = paired[-2][0] if len(paired) >= 2 else 0.0
extra_ttm = last_t - second_last_t
cycles.append(
{
"fh_threshold": last_t + extra_ttm,
"ttm": extra_ttm,
"duration_hours": last_d * 24.0,
}
)
return cycles
def build_fleet(
aircraft_df: pd.DataFrame,
checks_df: pd.DataFrame,
planning_start: datetime,
) -> pd.DataFrame:
"""
Combine aircraft and checks tables to produce the fleet reference DataFrame.
Columns: tail_number, model, fh_total, checks (list of cycle dicts),
ttm_hours (first upcoming TTM), available_from.
"""
thresholds = checks_df["fh_threshold"].astype(float).tolist()
durations = checks_df["duration_days"].astype(float).tolist()
records = []
for _, row in aircraft_df.iterrows():
fh = float(row["fh_total"])
cycles = _check_cycles(fh, thresholds, durations)
records.append(
{
"tail_number": str(row["tail_number"]).strip(),
"model": str(row.get("model", "")).strip(),
"fh_total": fh,
"checks": cycles,
"ttm_hours": cycles[0]["ttm"] if cycles else 0.0,
"available_from": planning_start,
}
)
return pd.DataFrame(records)
# ── Public entry points ───────────────────────────────────────────────────────
def load_from_dfs(dfs: Dict[str, pd.DataFrame], cfg: RoutingConfig) -> Dict[str, pd.DataFrame]:
"""
Same processing as load_all but accepts pre-loaded DataFrames.
dfs keys: 'aircraft', 'checks', 'airports', 'schedule'
The DataFrames may use the original CSV column names (synonym mapping is applied).
'schedule' must already have columns: DATA, ETAPA, DEP, ARR, HORA_DEP,
HORA_ARR, TEMPO_VOO, SEGMTO, MISSAO, OFRAG.
"""
from .inspect_files import _map_columns, _AIRCRAFT_SYNONYMS, _CHECK_SYNONYMS, _AIRPORT_SYNONYMS
def _norm(df: pd.DataFrame, synonyms: Dict) -> pd.DataFrame:
df = df.copy().dropna(how="all")
mapping = _map_columns(list(df.columns), synonyms)
return df.rename(columns={v: k for k, v in mapping.items()})
aircraft_df = _norm(dfs["aircraft"], _AIRCRAFT_SYNONYMS)
checks_df = _norm(dfs["checks"], _CHECK_SYNONYMS)
airports_df = _norm(dfs["airports"], _AIRPORT_SYNONYMS)
schedule_raw = dfs["schedule"].copy()
# Drop obviously empty rows
for col in ("tail_number",):
if col in aircraft_df.columns:
aircraft_df = aircraft_df.dropna(subset=[col])
for col in ("fh_threshold",):
if col in checks_df.columns:
checks_df = checks_df.dropna(subset=[col])
# Maintenance bases accept "1", "True", "true", 1
base_col = airports_df.get("is_maintenance_base", pd.Series(dtype=object))
base_mask = base_col.astype(str).str.strip().isin(["1", "True", "true"])
base_codes = airports_df.loc[base_mask, "airport_code"].str.upper().tolist() if "airport_code" in airports_df.columns else []
if not base_codes:
base_codes = [cfg.maintenance_base_code]
logger.info("Maintenance base(s): %s", base_codes)
schedule_df = _parse_schedule_datetimes(schedule_raw, cfg.planning_year)
ofrags_all = build_ofrags(schedule_df, base_codes)
ofrags = ofrags_all[ofrags_all["starts_at_base"] & ofrags_all["ends_at_base"]].copy().reset_index(drop=True)
logger.info("OFRAGs after base filter: %d / %d", len(ofrags), len(ofrags_all))
first_dep = ofrags["departure"].min() if not ofrags.empty else datetime.now()
planning_start = first_dep.replace(hour=0, minute=0, second=0, microsecond=0)
fleet = build_fleet(aircraft_df, checks_df, planning_start)
return {"ofrags": ofrags, "fleet": fleet, "airports": airports_df, "schedule": schedule_df}
def load_all(cfg: RoutingConfig) -> Dict[str, pd.DataFrame]:
"""
Read all raw files and return a dict with keys:
'ofrags' OFRAG profiles (only those that start and end at the maintenance base)
'fleet' fleet reference with TTM cycles
'airports' airport table
"""
raw = cfg.raw_dir
aircraft_df = read_aircraft(raw / cfg.aircraft_file)
checks_df = read_checks(raw / cfg.checks_file)
airports_df = read_airports(raw / cfg.airports_file)
schedule_raw = read_schedule(raw / cfg.flight_schedule_file)
# Maintenance-base ICAO codes
base_mask = airports_df.get("is_maintenance_base", pd.Series(dtype=int)).astype(int) == 1
base_codes = airports_df.loc[base_mask, "airport_code"].str.upper().tolist()
if not base_codes:
base_codes = [cfg.maintenance_base_code]
logger.info("Maintenance base(s): %s", base_codes)
# Parse schedule
schedule_df = _parse_schedule_datetimes(schedule_raw, cfg.planning_year)
# Build OFRAG table
ofrags_all = build_ofrags(schedule_df, base_codes)
ofrags = ofrags_all[ofrags_all["starts_at_base"] & ofrags_all["ends_at_base"]].copy()
ofrags = ofrags.reset_index(drop=True)
logger.info("OFRAGs after base filter: %d / %d", len(ofrags), len(ofrags_all))
# Planning horizon start = midnight of the earliest OFRAG departure day
# (aircraft are available from start-of-day, not the exact departure time)
first_dep = ofrags["departure"].min() if not ofrags.empty else datetime.now()
planning_start = first_dep.replace(hour=0, minute=0, second=0, microsecond=0)
# Build fleet
fleet = build_fleet(aircraft_df, checks_df, planning_start)
return {"ofrags": ofrags, "fleet": fleet, "airports": airports_df, "schedule": schedule_df}

View File

@@ -0,0 +1,160 @@
"""Auto-inspection of raw input files: detect encodings, separators, and column mappings."""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Dict, List, Optional
import pandas as pd
logger = logging.getLogger(__name__)
# ── Column-name synonym dictionaries ─────────────────────────────────────────
_AIRCRAFT_SYNONYMS: Dict[str, List[str]] = {
"tail_number": ["matricula", "tail", "registration", "aeronave", "ac"],
"model": ["modelo", "model", "type", "tipo"],
"fh_total": ["fh totais", "fh_total", "total fh", "horas totais", "flight hours"],
}
_CHECK_SYNONYMS: Dict[str, List[str]] = {
"check_name": ["checks", "check", "nome", "name", "descricao"],
"fh_threshold": ["fh", "threshold", "limite fh", "horas", "hours"],
"duration_days": ["tempo de execucao", "duracao", "duration", "dias", "days"],
"location": ["local de execucao", "local", "location", "base"],
}
_AIRPORT_SYNONYMS: Dict[str, List[str]] = {
"airport_code": ["airport_code", "icao", "code", "codigo"],
"airport_name": ["airport_name", "name", "nome"],
"is_maintenance_base": ["is_maintenance_base", "base", "manutencao"],
}
_SCHEDULE_COL_NAMES = [
"DATA", "ETAPA", "DEP", "ARR",
"HORA_DEP", "HORA_ARR", "TEMPO_VOO",
"SEGMTO", "MISSAO", "OFRAG",
]
def _normalise(text: str) -> str:
"""Lowercase + strip accents (ASCII fold)."""
import unicodedata
nfkd = unicodedata.normalize("NFKD", str(text))
return "".join(c for c in nfkd if not unicodedata.combining(c)).lower().strip()
def _detect_encoding(filepath: Path) -> str:
for enc in ("utf-8-sig", "utf-8", "latin-1", "cp1252"):
try:
with open(filepath, encoding=enc) as f:
f.read(2048)
return enc
except UnicodeDecodeError:
continue
return "latin-1"
def _detect_separator(filepath: Path, encoding: str) -> str:
with open(filepath, encoding=encoding) as f:
sample = f.read(512)
counts = {sep: sample.count(sep) for sep in (";", ",", "\t", "|")}
return max(counts, key=counts.get)
def _map_columns(df_columns: List[str], synonyms: Dict[str, List[str]]) -> Dict[str, str]:
"""Return {canonical_name: actual_column_name} for columns that can be matched."""
mapping: Dict[str, str] = {}
norm_cols = {_normalise(c): c for c in df_columns}
for canonical, candidates in synonyms.items():
for cand in candidates:
if _normalise(cand) in norm_cols:
mapping[canonical] = norm_cols[_normalise(cand)]
break
if canonical not in mapping:
# Partial match fallback
for norm_col, orig_col in norm_cols.items():
if any(_normalise(cand) in norm_col for cand in candidates):
mapping[canonical] = orig_col
break
return mapping
def inspect_file(filepath: Path) -> Dict:
"""Return metadata dict for a single raw file."""
enc = _detect_encoding(filepath)
sep = _detect_separator(filepath, enc)
try:
df = pd.read_csv(filepath, sep=sep, encoding=enc, nrows=5)
return {
"path": str(filepath),
"encoding": enc,
"separator": sep,
"columns": list(df.columns),
"n_cols": len(df.columns),
"sample_rows": df.to_dict(orient="records"),
}
except Exception as exc:
logger.warning("Could not read %s: %s", filepath, exc)
return {"path": str(filepath), "error": str(exc)}
def inspect_all(raw_dir: Path) -> Dict[str, Dict]:
"""Inspect every CSV in raw_dir and return a metadata dict keyed by stem."""
results: Dict[str, Dict] = {}
for p in sorted(raw_dir.glob("*.csv")):
results[p.stem] = inspect_file(p)
logger.info("Inspected %s (%d cols)", p.name, results[p.stem].get("n_cols", 0))
return results
def read_aircraft(filepath: Path) -> pd.DataFrame:
enc = _detect_encoding(filepath)
sep = _detect_separator(filepath, enc)
df = pd.read_csv(filepath, sep=sep, encoding=enc)
mapping = _map_columns(list(df.columns), _AIRCRAFT_SYNONYMS)
logger.info("Aircraft column map: %s", mapping)
df = df.rename(columns={v: k for k, v in mapping.items()})
return df
def read_checks(filepath: Path) -> pd.DataFrame:
enc = _detect_encoding(filepath)
sep = _detect_separator(filepath, enc)
df = pd.read_csv(filepath, sep=sep, encoding=enc)
mapping = _map_columns(list(df.columns), _CHECK_SYNONYMS)
logger.info("Checks column map: %s", mapping)
df = df.rename(columns={v: k for k, v in mapping.items()})
return df
def read_airports(filepath: Path) -> pd.DataFrame:
enc = _detect_encoding(filepath)
sep = _detect_separator(filepath, enc)
df = pd.read_csv(filepath, sep=sep, encoding=enc)
mapping = _map_columns(list(df.columns), _AIRPORT_SYNONYMS)
logger.info("Airports column map: %s", mapping)
df = df.rename(columns={v: k for k, v in mapping.items()})
return df
def read_schedule(filepath: Path) -> pd.DataFrame:
"""Read the two-row merged-header flight schedule."""
enc = _detect_encoding(filepath)
sep = _detect_separator(filepath, enc)
df = pd.read_csv(
filepath,
sep=sep,
encoding=enc,
header=None,
skiprows=2,
names=_SCHEDULE_COL_NAMES,
dtype=str,
)
df = df.dropna(subset=["DATA", "OFRAG"])
df = df[df["DATA"].str.strip() != ""]
df = df[df["OFRAG"].str.strip() != ""]
return df.reset_index(drop=True)

View File

@@ -0,0 +1,173 @@
"""
Maintenance monitor: validate route TTM compliance and compute maintenance events.
A Route is TTM-compliant if at every point in the route the accumulated flight
hours since the last maintenance check do not exceed the current check cycle's TTM.
Maintenance is forced when adding the next OFRAG would exceed that TTM.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple
import pandas as pd
from .config import RoutingConfig
logger = logging.getLogger(__name__)
@dataclass
class MaintenanceEvent:
after_ofrag_id: Optional[str] # None = before first OFRAG (proactive)
check_cycle_index: int # which check cycle is being performed
fh_threshold: float
accum_fh_at_check: float # accumulated FH at the moment of the check
ttm_loss: float # = cycle_ttm - accum_fh_at_check
calendar_start: datetime
calendar_end: datetime
@dataclass
class RouteValidationResult:
feasible: bool
total_flight_hours: float
total_ttm_loss: float
maintenance_events: List[MaintenanceEvent] = field(default_factory=list)
infeasibility_reason: str = ""
def validate_route(
ofrag_sequence: List[str],
ofrags_df: pd.DataFrame,
aircraft_checks: List[Dict], # ordered list of {'fh_threshold','ttm','duration_hours'}
aircraft_available_from: datetime,
cfg: RoutingConfig,
) -> RouteValidationResult:
"""
Simulate flying a route and return whether it is TTM-feasible.
aircraft_checks is the list produced by ingest._check_cycles(); it encodes
the sequence of upcoming check cycles for this specific aircraft.
"""
ofrag_index = {row["ofrag_id"]: row for _, row in ofrags_df.iterrows()}
tat = timedelta(minutes=cfg.tat_minutes)
check_idx = 0 # which cycle we are currently in
accum_fh = 0.0 # FH accumulated in the current cycle
current_time = aircraft_available_from
total_fh = 0.0
total_loss = 0.0
events: List[MaintenanceEvent] = []
for ofrag_id in ofrag_sequence:
if ofrag_id not in ofrag_index:
return RouteValidationResult(
feasible=False, total_flight_hours=total_fh, total_ttm_loss=total_loss,
infeasibility_reason=f"OFRAG {ofrag_id} not found",
)
ofrag = ofrag_index[ofrag_id]
h = float(ofrag["flight_hours"])
dep = ofrag["departure"]
arr = ofrag["arrival"]
if check_idx >= len(aircraft_checks):
return RouteValidationResult(
feasible=False, total_flight_hours=total_fh, total_ttm_loss=total_loss,
infeasibility_reason="No remaining check cycles route exceeds maintenance plan",
)
cycle = aircraft_checks[check_idx]
# Can the current cycle accommodate this OFRAG?
if accum_fh + h > cycle["ttm"]:
# Maintenance required before this OFRAG
dur = timedelta(hours=cycle["duration_hours"])
maint_end = current_time + dur
if maint_end + tat > dep:
return RouteValidationResult(
feasible=False, total_flight_hours=total_fh, total_ttm_loss=total_loss,
infeasibility_reason=(
f"No time for maintenance before {ofrag_id}: "
f"maint ends {maint_end}, OFRAG departs {dep}"
),
)
loss = cycle["ttm"] - accum_fh
ofrag_pos = ofrag_sequence.index(ofrag_id)
event = MaintenanceEvent(
after_ofrag_id=None if ofrag_pos == 0 else ofrag_sequence[ofrag_pos - 1],
check_cycle_index=check_idx,
fh_threshold=cycle["fh_threshold"],
accum_fh_at_check=accum_fh,
ttm_loss=loss,
calendar_start=current_time,
calendar_end=maint_end,
)
events.append(event)
total_loss += loss
accum_fh = 0.0
check_idx += 1
current_time = maint_end
# Verify new cycle can accommodate the OFRAG
if check_idx >= len(aircraft_checks):
return RouteValidationResult(
feasible=False, total_flight_hours=total_fh, total_ttm_loss=total_loss,
infeasibility_reason="No remaining check cycles after maintenance",
)
cycle = aircraft_checks[check_idx]
if h > cycle["ttm"]:
return RouteValidationResult(
feasible=False, total_flight_hours=total_fh, total_ttm_loss=total_loss,
infeasibility_reason=f"OFRAG {ofrag_id} ({h:.1f} FH) exceeds cycle TTM ({cycle['ttm']:.1f} FH)",
)
# Time feasibility: aircraft must be available before OFRAG departs
if current_time + tat > dep:
return RouteValidationResult(
feasible=False, total_flight_hours=total_fh, total_ttm_loss=total_loss,
infeasibility_reason=(
f"Time conflict: aircraft ready at {current_time + tat}, "
f"but {ofrag_id} departs at {dep}"
),
)
accum_fh += h
total_fh += h
current_time = arr
return RouteValidationResult(
feasible=True,
total_flight_hours=total_fh,
total_ttm_loss=total_loss,
maintenance_events=events,
)
def summarise_fleet_maintenance(solution_routes: List[Dict], ofrags_df: pd.DataFrame) -> pd.DataFrame:
"""
Build a human-readable maintenance summary for the complete solution.
Each row = one maintenance event.
"""
rows = []
for r in solution_routes:
for evt in r.get("maintenance_events", []):
rows.append(
{
"tail_number": r["aircraft_id"],
"after_ofrag": evt.after_ofrag_id,
"check_cycle_index": evt.check_cycle_index,
"fh_threshold": evt.fh_threshold,
"accum_fh_at_check": round(evt.accum_fh_at_check, 2),
"ttm_loss_hours": round(evt.ttm_loss, 2),
"maint_start": evt.calendar_start,
"maint_end": evt.calendar_end,
}
)
return pd.DataFrame(rows)

View File

@@ -0,0 +1,117 @@
"""Compute and format output metrics from the optimiser solution."""
from __future__ import annotations
from typing import Dict, List
import pandas as pd
def build_schedule_table(routes: List[Dict], ofrags_df: pd.DataFrame) -> pd.DataFrame:
"""One row per OFRAG in the solution, showing which aircraft serves it."""
ofrag_lookup = ofrags_df.set_index("ofrag_id")
rows = []
for r in routes:
prev_arr = r.get("aircraft_available_from", None)
for pos, oid in enumerate(r["ofrag_ids"]):
maint_before = pos in r["maint_before_index"]
ofrag_row = ofrag_lookup.loc[oid] if oid in ofrag_lookup.index else {}
rows.append(
{
"aircraft": r["aircraft_id"],
"position_in_route": pos + 1,
"maintenance_before": maint_before,
"ofrag_id": oid,
"missions": ofrag_row.get("missions", ""),
"departure": ofrag_row.get("departure", pd.NaT),
"arrival": ofrag_row.get("arrival", pd.NaT),
"flight_hours": ofrag_row.get("flight_hours", 0.0),
"n_legs": ofrag_row.get("n_legs", 0),
}
)
return pd.DataFrame(rows).sort_values(["aircraft", "departure"]).reset_index(drop=True)
def build_maintenance_table(routes: List[Dict]) -> pd.DataFrame:
"""One row per maintenance event in the solution."""
rows = []
for r in routes:
for evt in r.get("maintenance_events", []):
rows.append(
{
"aircraft": r["aircraft_id"],
"after_ofrag": evt.after_ofrag_id,
"check_cycle_index": evt.check_cycle_index,
"fh_threshold": evt.fh_threshold,
"accum_fh_at_check": round(evt.accum_fh_at_check, 2),
"ttm_loss_hours": round(evt.ttm_loss, 2),
"maint_start": evt.calendar_start,
"maint_end": evt.calendar_end,
}
)
return pd.DataFrame(rows)
def build_fleet_summary(routes: List[Dict], fleet_df: pd.DataFrame) -> pd.DataFrame:
"""One row per aircraft with utilisation statistics."""
utilisation: Dict[str, Dict] = {
aid: {
"flight_hours": 0.0,
"n_ofrags": 0,
"n_maint_events": 0,
"total_ttm_loss": 0.0,
}
for aid in fleet_df["tail_number"].tolist()
}
for r in routes:
aid = r["aircraft_id"]
if aid in utilisation:
utilisation[aid]["flight_hours"] += r["flight_hours"]
utilisation[aid]["n_ofrags"] += len(r["ofrag_ids"])
utilisation[aid]["n_maint_events"] += len(r.get("maintenance_events", []))
utilisation[aid]["total_ttm_loss"] += r["ttm_loss"]
rows = []
for _, ac in fleet_df.iterrows():
aid = ac["tail_number"]
u = utilisation.get(aid, {})
ttm0 = ac["ttm_hours"]
fh_done = u.get("flight_hours", 0.0)
rows.append(
{
"aircraft": aid,
"model": ac.get("model", ""),
"initial_ttm_h": round(ttm0, 2),
"flight_hours_scheduled": round(fh_done, 2),
"ttm_utilisation_pct": round(fh_done / ttm0 * 100, 1) if ttm0 > 0 else 0.0,
"n_ofrags_assigned": u.get("n_ofrags", 0),
"n_maintenance_events": u.get("n_maint_events", 0),
"total_ttm_loss_h": round(u.get("total_ttm_loss", 0.0), 2),
}
)
df = pd.DataFrame(rows)
df["idle"] = df["n_ofrags_assigned"] == 0
return df
def solution_summary(result: Dict, ofrags_df: pd.DataFrame, fleet_df: pd.DataFrame) -> Dict:
"""Return a compact summary dict of the optimisation result."""
routes = result.get("routes", [])
total_ofrags = len(ofrags_df)
covered = sum(len(r["ofrag_ids"]) for r in routes)
uncovered = result.get("uncovered_ofrags", [])
total_ttm_loss = sum(r["ttm_loss"] for r in routes)
n_maint = sum(len(r.get("maintenance_events", [])) for r in routes)
return {
"status": result.get("status", "?"),
"objective": round(result.get("objective", 0.0), 4),
"total_ofrags": total_ofrags,
"covered_ofrags": covered,
"uncovered_ofrags": uncovered,
"total_ttm_loss_hours": round(total_ttm_loss, 2),
"n_maintenance_events": n_maint,
"columns_generated": result.get("total_columns_generated", 0),
}

View File

@@ -0,0 +1,84 @@
"""
Time-space network for the OFRAG routing problem.
Builds the precedence / adjacency structure used by the pricing subproblem:
- can_follow[i][j] : OFRAG j can start directly after OFRAG i (no maintenance)
- can_follow_with_check[k][i][j]: OFRAG j can start after OFRAG i + check-cycle k maintenance
"""
from __future__ import annotations
import logging
from datetime import timedelta
from typing import Dict, List
import pandas as pd
from .config import RoutingConfig
logger = logging.getLogger(__name__)
def build_adjacency(
ofrags: pd.DataFrame,
checks_unique_durations: List[float],
cfg: RoutingConfig,
) -> Dict:
"""
Pre-compute adjacency matrices.
Parameters
----------
ofrags : sorted by departure, indexed 0..n-1
checks_unique_durations : list of check-cycle durations in hours (one per cycle)
cfg : RoutingConfig
Returns
-------
dict with keys:
'can_follow' : List[List[bool]] (n × n)
'can_follow_with_check' : List[List[List[bool]]] (n_checks × n × n)
'n_ofrags' : int
'n_checks' : int
"""
n = len(ofrags)
tat = timedelta(minutes=cfg.tat_minutes)
arrivals = ofrags["arrival"].tolist()
departures = ofrags["departure"].tolist()
# Direct adjacency (no maintenance between i and j)
can_follow = [[False] * n for _ in range(n)]
for i in range(n):
for j in range(n):
if i != j and arrivals[i] + tat <= departures[j]:
can_follow[i][j] = True
# Adjacency after check cycle k (maintenance inserted between i and j)
n_checks = len(checks_unique_durations)
can_follow_with_check = [
[[False] * n for _ in range(n)] for _ in range(n_checks)
]
for k, dur_hours in enumerate(checks_unique_durations):
dur = timedelta(hours=dur_hours)
for i in range(n):
for j in range(n):
if i != j and arrivals[i] + dur + tat <= departures[j]:
can_follow_with_check[k][i][j] = True
stats = {
"direct_edges": sum(sum(row) for row in can_follow),
"check_edges_per_cycle": [
sum(sum(row) for row in can_follow_with_check[k])
for k in range(n_checks)
],
}
logger.info("Network n_ofrags=%d direct_edges=%d", n, stats["direct_edges"])
return {
"can_follow": can_follow,
"can_follow_with_check": can_follow_with_check,
"n_ofrags": n,
"n_checks": n_checks,
"stats": stats,
}

View File

@@ -0,0 +1,550 @@
"""
Aircraft Routing Optimizer
==========================
Solves the Aircraft Routing problem via:
1. Column Generation (CG) builds the LP relaxation of the Set Partitioning
formulation iteratively by pricing new columns with a label-setting DP.
2. Branch and Bound (B&B) applied by PuLP/CBC on the full column pool once
CG has converged, producing the optimal integer schedule.
Mathematical formulation
------------------------
Minimise Σ_r c_r · x_r (total TTM loss)
s.t. Σ_{r: j∈r} x_r = 1 ∀ j ∈ OFRAGs (each OFRAG covered once)
Σ_{r: a(r)=a} x_r ≤ 1 ∀ a ∈ Aircraft (one route per aircraft)
x_r ∈ {0, 1}
Column-generation pricing subproblem
-------------------------------------
For each aircraft a, find the feasible route r* that minimises:
c_r Σ_{j ∈ r} π_j μ_a
where π_j = dual variable of coverage constraint for OFRAG j,
μ_a = dual variable of aircraft-usage constraint for aircraft a.
This is solved by label-setting DP on the DAG of OFRAGs ordered by departure.
Label at (node j, check_cycle k): (reduced_cost, accum_fh, route_trace)
- reduced_cost : running objective (negative → route is profitable to add)
- accum_fh : flight hours accumulated since last maintenance
- route_trace : list of (ofrag_idx, maint_before_flag)
Dominance rule (for labels at the same node and same check_cycle k)
---------------------------------------------------------------------
Label 1 (rc1, h1) dominates Label 2 (rc2, h2) iff:
rc1 ≤ rc2 AND (h1 rc1) ≥ (h2 rc2)
This ensures Label 1 is never worse than Label 2 on any future extension,
whether that extension requires maintenance or not.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import timedelta
from typing import Dict, List, Optional, Tuple
import pandas as pd
import pulp
from .config import RoutingConfig
from .maintenance_monitor import validate_route, MaintenanceEvent
logger = logging.getLogger(__name__)
_EPS = 1e-9 # float comparison tolerance
# ─────────────────────────────────────────────────────────────────────────────
# Data structures
# ─────────────────────────────────────────────────────────────────────────────
@dataclass
class Route:
"""A feasible schedule for one aircraft (= one column in the master problem)."""
route_id: int
aircraft_id: str
ofrag_ids: List[str] # OFRAGs served, in order
maint_before_index: List[int] # positions in ofrag_ids where maint precedes
flight_hours: float
ttm_loss: float # objective cost
coverage: frozenset # frozenset of ofrag_ids covered
@property
def cost(self) -> float:
return self.ttm_loss
def reduced_cost(self, dual_ofrags: Dict[str, float], dual_aircraft: float) -> float:
return self.ttm_loss - sum(dual_ofrags.get(j, 0.0) for j in self.ofrag_ids) - dual_aircraft
# ─────────────────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────────────────
def _prune_labels(
labels: List[Tuple[float, float, float, List]],
) -> List[Tuple[float, float, float, List]]:
"""
Remove dominated labels. Each label is (rc, h, cost, trace).
rc = running reduced cost (includes dual subtractions)
h = accumulated FH since last maintenance event
cost = TTM loss accumulated so far (no dual subtractions)
trace = list of (ofrag_idx, maint_before_flag)
Dominance: Label 1 dominates Label 2 iff rc1 ≤ rc2 AND (h1 - rc1) ≥ (h2 - rc2).
Pareto front: sorted by rc asc, keep only those with strictly increasing (h - rc).
"""
if len(labels) <= 1:
return labels
labels_sorted = sorted(labels, key=lambda t: t[0])
pareto: List[Tuple[float, float, float, List]] = []
best_value = -float("inf")
for rc, h, cost, trace in labels_sorted:
v = h - rc
if v > best_value - _EPS:
pareto.append((rc, h, cost, trace))
best_value = max(best_value, v)
return pareto
# ─────────────────────────────────────────────────────────────────────────────
# Main optimizer
# ─────────────────────────────────────────────────────────────────────────────
class AircraftRoutingOptimizer:
"""
Orchestrates Column Generation + Branch and Bound.
Parameters
----------
ofrags_df : DataFrame with columns [ofrag_id, departure, arrival, flight_hours]
fleet_df : DataFrame with columns [tail_number, fh_total, checks, ttm_hours, available_from]
where checks = list of {'fh_threshold','ttm','duration_hours'}
adjacency : dict returned by network_generator.build_adjacency()
cfg : RoutingConfig
"""
def __init__(
self,
ofrags_df: pd.DataFrame,
fleet_df: pd.DataFrame,
adjacency: Dict,
cfg: RoutingConfig,
):
self.ofrags = ofrags_df.sort_values("departure").reset_index(drop=True)
self.fleet = fleet_df.reset_index(drop=True)
self.adj = adjacency
self.cfg = cfg
self._ofrag_ids: List[str] = self.ofrags["ofrag_id"].tolist()
self._ofrag_fh: Dict[str, float] = dict(
zip(self.ofrags["ofrag_id"], self.ofrags["flight_hours"])
)
self._ofrag_idx: Dict[str, int] = {oid: i for i, oid in enumerate(self._ofrag_ids)}
self._aircraft_ids: List[str] = self.fleet["tail_number"].tolist()
self._columns: List[Route] = []
self._route_counter = 0
# ── Public API ────────────────────────────────────────────────────────────
def solve(self) -> Dict:
"""Run Column Generation → B&B and return the solution dict."""
logger.info("=== Aircraft Routing Optimizer ===")
logger.info("OFRAGs: %d Aircraft: %d", len(self._ofrag_ids), len(self._aircraft_ids))
self._initialise_columns()
logger.info("Initial column pool: %d", len(self._columns))
self._column_generation()
logger.info("After CG: %d columns", len(self._columns))
result = self._solve_mip()
# Post-process: attach maintenance events to selected routes
result["routes"] = self._attach_maintenance_events(result["selected_routes"])
return result
# ── Initialisation ────────────────────────────────────────────────────────
def _next_id(self) -> int:
self._route_counter += 1
return self._route_counter
def _make_route(
self,
aircraft_id: str,
ofrag_ids: List[str],
maint_before_index: List[int],
aircraft_checks: List[Dict],
starting_check_idx: int = 0,
) -> Route:
"""Build a Route object from its components and compute its cost."""
fh_total = sum(self._ofrag_fh[o] for o in ofrag_ids)
# Compute TTM loss: for each maintenance event, loss = cycle.ttm - accumulated_at_that_point
ttm_loss = 0.0
check_idx = starting_check_idx
accum = 0.0
for pos, oid in enumerate(ofrag_ids):
if pos in maint_before_index and check_idx < len(aircraft_checks):
ttm_loss += aircraft_checks[check_idx]["ttm"] - accum
accum = 0.0
check_idx += 1
accum += self._ofrag_fh[oid]
return Route(
route_id=self._next_id(),
aircraft_id=aircraft_id,
ofrag_ids=ofrag_ids,
maint_before_index=maint_before_index,
flight_hours=fh_total,
ttm_loss=round(ttm_loss, 6),
coverage=frozenset(ofrag_ids),
)
def _initialise_columns(self):
"""Seed the column pool with one single-OFRAG route per aircraft × OFRAG pair."""
tat = timedelta(minutes=self.cfg.tat_minutes)
n = len(self._ofrag_ids)
for _, ac in self.fleet.iterrows():
checks = ac["checks"]
avail = ac["available_from"]
ttm0 = checks[0]["ttm"] if checks else 0.0
added = False
for i in range(n):
dep = self.ofrags.iloc[i]["departure"]
fh = self.ofrags.iloc[i]["flight_hours"]
if avail + tat <= dep and fh <= ttm0:
route = self._make_route(
ac["tail_number"], [self._ofrag_ids[i]], [], checks
)
self._columns.append(route)
added = True
break # one seed per aircraft is enough
if not added:
# Aircraft needs maintenance before first OFRAG; try with maint flag
for i in range(n):
fh = self.ofrags.iloc[i]["flight_hours"]
if len(checks) > 1 and fh <= checks[1]["ttm"]:
route = self._make_route(
ac["tail_number"], [self._ofrag_ids[i]], [0], checks
)
self._columns.append(route)
break
# ── Column Generation ─────────────────────────────────────────────────────
def _column_generation(self):
for iteration in range(self.cfg.max_cg_iterations):
lp = self._solve_rmp_lp()
if lp is None:
logger.warning("RMP infeasible at iteration %d stopping CG", iteration)
break
pi = lp["dual_ofrags"] # coverage duals
mu = lp["dual_aircraft"] # aircraft-usage duals
added_any = False
for _, ac in self.fleet.iterrows():
new_routes = self._price(ac, pi, mu.get(ac["tail_number"], 0.0))
for r in new_routes:
self._columns.append(r)
added_any = True
logger.debug(
"CG iter %d obj=%.4f cols=%d added=%s",
iteration, lp["objective"], len(self._columns), added_any,
)
if not added_any:
logger.info("CG converged at iteration %d", iteration)
break
def _solve_rmp_lp(self) -> Optional[Dict]:
"""Solve the LP relaxation of the Restricted Master Problem."""
prob = pulp.LpProblem("RMP", pulp.LpMinimize)
x = {
col.route_id: pulp.LpVariable(f"x{col.route_id}", lowBound=0, upBound=1)
for col in self._columns
}
# Artificial slack for coverage (ensures LP feasibility)
y = {
oid: pulp.LpVariable(f"y_{oid}", lowBound=0)
for oid in self._ofrag_ids
}
prob += (
pulp.lpSum(col.cost * x[col.route_id] for col in self._columns)
+ pulp.lpSum(self.cfg.big_m * y[oid] for oid in self._ofrag_ids)
)
for oid in self._ofrag_ids:
relevant = [x[c.route_id] for c in self._columns if oid in c.coverage]
prob += pulp.lpSum(relevant) + y[oid] == 1, f"cov_{oid}"
for aid in self._aircraft_ids:
relevant = [x[c.route_id] for c in self._columns if c.aircraft_id == aid]
prob += pulp.lpSum(relevant) <= 1, f"ac_{aid}"
prob.solve(pulp.PULP_CBC_CMD(msg=0))
if pulp.LpStatus[prob.status] not in ("Optimal",):
return None
# Standard LP dual (shadow price): positive for coverage constraints when
# covered by artificials (≈ big_M), negative/zero for aircraft-usage constraints.
# Pricing reduced cost = c_r - Σ π_j - μ_a uses these directly.
dual_ofrags = {}
for oid in self._ofrag_ids:
c = prob.constraints.get(f"cov_{oid}")
dual_ofrags[oid] = c.pi if (c and c.pi is not None) else 0.0
dual_aircraft = {}
for aid in self._aircraft_ids:
c = prob.constraints.get(f"ac_{aid}")
dual_aircraft[aid] = c.pi if (c and c.pi is not None) else 0.0
return {
"objective": pulp.value(prob.objective),
"dual_ofrags": dual_ofrags,
"dual_aircraft": dual_aircraft,
}
# ── Pricing subproblem (label-setting DP) ─────────────────────────────────
def _price(
self,
aircraft: pd.Series,
dual_ofrags: Dict[str, float],
dual_aircraft: float,
) -> List[Route]:
"""
Find all routes for *aircraft* with negative reduced cost.
State: (ofrag_idx j, check_cycle k)
Label: (rc, h, cost, trace)
rc = running reduced cost (cost minus dual contributions so far)
h = accumulated FH since last maintenance in trace
cost = TTM loss accumulated so far (rc + duals; stored separately to
avoid re-deriving cost at harvest time)
trace = list of (ofrag_idx, maint_before_flag)
"""
checks = aircraft["checks"]
avail = aircraft["available_from"]
aid = aircraft["tail_number"]
tat = timedelta(minutes=self.cfg.tat_minutes)
n = len(self._ofrag_ids)
n_checks = len(checks)
can_follow = self.adj["can_follow"]
can_with_check = self.adj["can_follow_with_check"]
# labels[j][k] = list of (rc, h, cost, trace)
labels: List[List[List]] = [[[] for _ in range(n_checks)] for _ in range(n)]
# --- Seed: single-OFRAG routes starting from SOURCE ---
# For cycle k=0: aircraft starts directly.
# For cycle k>0: aircraft must complete checks[0..k-1] before OFRAG j departs,
# each with 0 accumulated FH (no prior OFRAGs) → full TTM loss per empty check.
# The prior_loss IS the route cost so far; it is tracked in both rc and cost.
for j in range(n):
dep_j = self.ofrags.iloc[j]["departure"]
fh_j = self.ofrags.iloc[j]["flight_hours"]
oid_j = self._ofrag_ids[j]
earliest = avail # earliest calendar time to enter the current cycle
prior_loss = 0.0 # TTM loss from empty prior checks (= route cost so far)
for k in range(n_checks):
can_reach = earliest + tat <= dep_j
fits_ttm = fh_j <= checks[k]["ttm"] + _EPS
if can_reach and fits_ttm:
rc = prior_loss - dual_ofrags.get(oid_j, 0.0)
cost = prior_loss # TTM loss from prior empty checks; no in-trace maint yet
labels[j][k].append((rc, fh_j, cost, [(j, False)]))
break # lowest feasible cycle with valid timing
if not can_reach:
break # no point advancing to later cycles (time only grows)
# Advance to next cycle: empty check k before OFRAG j
if k + 1 < n_checks:
prior_loss += checks[k]["ttm"]
earliest = earliest + timedelta(hours=checks[k]["duration_hours"])
# --- Forward expansion ---
new_routes: List[Route] = []
for j in range(n):
for k in range(n_checks):
labels[j][k] = _prune_labels(labels[j][k])
for rc, h, cost, trace in labels[j][k]:
# Harvest: if this partial route has negative reduced cost, record it
final_rc = rc - dual_aircraft
if final_rc < -self.cfg.cg_tolerance:
new_routes.append(
self._trace_to_route(aid, trace, cost, checks)
)
# Extend to OFRAG m
for m in range(j + 1, n):
fh_m = self.ofrags.iloc[m]["flight_hours"]
oid_m = self._ofrag_ids[m]
gain = dual_ofrags.get(oid_m, 0.0)
# Option A: direct (no maintenance)
if can_follow[j][m] and h + fh_m <= checks[k]["ttm"] + _EPS:
new_rc = rc - gain
new_h = h + fh_m
new_cost = cost # no new maintenance event
new_trace = trace + [(m, False)]
labels[m][k].append((new_rc, new_h, new_cost, new_trace))
# Option B: maintenance between j and m (cycle k → k+1)
if k + 1 < n_checks:
can_m = can_with_check[k][j][m] if k < len(can_with_check) else False
if can_m and fh_m <= checks[k + 1]["ttm"] + _EPS:
loss = checks[k]["ttm"] - h
new_rc = rc + loss - gain
new_h = fh_m
new_cost = cost + loss # add maintenance loss
new_trace = trace + [(m, True)] # True = maint before m
labels[m][k + 1].append((new_rc, new_h, new_cost, new_trace))
# De-duplicate by coverage set (keep cheapest)
seen: Dict[frozenset, Route] = {}
for r in new_routes:
key = r.coverage
if key not in seen or r.ttm_loss < seen[key].ttm_loss:
seen[key] = r
return list(seen.values())
def _trace_to_route(
self,
aircraft_id: str,
trace: List[Tuple[int, bool]],
cost: float,
checks: List[Dict],
) -> Route:
"""
Convert a DP trace into a Route object using the pre-computed cost.
The cost stored in the label already accounts for:
- TTM losses from any prior empty checks done before the first OFRAG
(seed at cycle k>0)
- TTM losses from maintenance events within the trace (Option B expansions)
We must NOT re-derive cost from maint_before_index because prior empty checks
are not represented in the trace entries.
"""
ofrag_ids = [self._ofrag_ids[idx] for idx, _ in trace]
maint_before_index = [pos for pos, (_, mb) in enumerate(trace) if mb]
fh_total = sum(self._ofrag_fh[oid] for oid in ofrag_ids)
return Route(
route_id=self._next_id(),
aircraft_id=aircraft_id,
ofrag_ids=ofrag_ids,
maint_before_index=maint_before_index,
flight_hours=fh_total,
ttm_loss=round(cost, 6),
coverage=frozenset(ofrag_ids),
)
# ── MIP (Branch and Bound) ────────────────────────────────────────────────
def _solve_mip(self) -> Dict:
"""Solve the integer Set Partitioning model using PuLP/CBC B&B."""
prob = pulp.LpProblem("AircraftRouting_MIP", pulp.LpMinimize)
x = {
col.route_id: pulp.LpVariable(f"x{col.route_id}", cat="Binary")
for col in self._columns
}
y = {
oid: pulp.LpVariable(f"y_{oid}", lowBound=0)
for oid in self._ofrag_ids
}
prob += (
pulp.lpSum(col.cost * x[col.route_id] for col in self._columns)
+ pulp.lpSum(self.cfg.big_m * y[oid] for oid in self._ofrag_ids)
)
for oid in self._ofrag_ids:
relevant = [x[c.route_id] for c in self._columns if oid in c.coverage]
prob += pulp.lpSum(relevant) + y[oid] == 1, f"cov_{oid}"
for aid in self._aircraft_ids:
relevant = [x[c.route_id] for c in self._columns if c.aircraft_id == aid]
prob += pulp.lpSum(relevant) <= 1, f"ac_{aid}"
solver = pulp.PULP_CBC_CMD(
msg=1,
timeLimit=self.cfg.mip_time_limit_seconds,
gapRel=self.cfg.mip_gap,
)
prob.solve(solver)
status = pulp.LpStatus[prob.status]
obj = pulp.value(prob.objective) or 0.0
selected = [
col for col in self._columns
if (x[col.route_id].varValue or 0) > 0.5
]
uncovered = [
oid for oid in self._ofrag_ids
if (y[oid].varValue or 0) > 0.5
]
logger.info("MIP status: %s objective: %.4f", status, obj)
logger.info("Selected routes: %d Uncovered OFRAGs: %d", len(selected), len(uncovered))
return {
"status": status,
"objective": obj,
"selected_routes": selected,
"uncovered_ofrags": uncovered,
"total_columns_generated": len(self._columns),
}
# ── Post-processing ───────────────────────────────────────────────────────
def _attach_maintenance_events(self, selected_routes: List[Route]) -> List[Dict]:
"""
Run the maintenance monitor on each selected route to get full event detail.
Returns a list of dicts (one per route) ready for output / dashboard.
"""
out = []
for route in selected_routes:
ac_row = self.fleet[self.fleet["tail_number"] == route.aircraft_id].iloc[0]
val = validate_route(
ofrag_sequence=route.ofrag_ids,
ofrags_df=self.ofrags,
aircraft_checks=ac_row["checks"],
aircraft_available_from=ac_row["available_from"],
cfg=self.cfg,
)
out.append(
{
"aircraft_id": route.aircraft_id,
"ofrag_ids": route.ofrag_ids,
"maint_before_index": route.maint_before_index,
"flight_hours": route.flight_hours,
"ttm_loss": route.ttm_loss,
"feasible": val.feasible,
"maintenance_events": val.maintenance_events,
}
)
return out

View File

@@ -0,0 +1,124 @@
"""
End-to-end orchestration pipeline.
Usage
-----
from src.routing_engine import RoutingPipeline, DEFAULT_CONFIG
pipe = RoutingPipeline(DEFAULT_CONFIG)
result = pipe.run()
"""
from __future__ import annotations
import json
import logging
import sys
from pathlib import Path
from typing import Dict, Optional
import pandas as pd
from .config import RoutingConfig, DEFAULT_CONFIG
from .ingest import load_all, load_from_dfs
from .network_generator import build_adjacency
from .optimizer import AircraftRoutingOptimizer
from .quality import run_all as quality_check
from .metrics import (
build_schedule_table,
build_maintenance_table,
build_fleet_summary,
solution_summary,
)
logger = logging.getLogger(__name__)
def _setup_logging(level: str):
logging.basicConfig(
level=getattr(logging, level.upper(), logging.INFO),
format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
datefmt="%H:%M:%S",
handlers=[logging.StreamHandler(sys.stdout)],
)
class RoutingPipeline:
def __init__(self, cfg: RoutingConfig = DEFAULT_CONFIG):
self.cfg = cfg
_setup_logging(cfg.log_level)
def run(self, save_outputs: bool = True, raw_dfs=None) -> Dict:
"""Execute all pipeline stages and return a results dict."""
logger.info("-- Stage 1: Ingest --------------------------------------")
data = load_from_dfs(raw_dfs, self.cfg) if raw_dfs is not None else load_all(self.cfg)
ofrags = data["ofrags"]
fleet = data["fleet"]
logger.info("-- Stage 2: Quality check -------------------------------")
qc = quality_check(ofrags, fleet)
if not qc["ok"]:
logger.warning("Quality issues detected:\n %s", "\n ".join(qc["issues"]))
logger.info("-- Stage 3: Build network -------------------------------")
# Collect unique check-cycle durations (for adjacency-with-check matrices)
all_durations = set()
for _, ac in fleet.iterrows():
for c in ac["checks"]:
all_durations.add(c["duration_hours"])
sorted_durations = sorted(all_durations)
adj = build_adjacency(ofrags, sorted_durations, self.cfg)
logger.info("-- Stage 4: Optimise ------------------------------------")
opt = AircraftRoutingOptimizer(ofrags, fleet, adj, self.cfg)
result = opt.solve()
logger.info("-- Stage 5: Metrics -------------------------------------")
routes = result.get("routes", [])
schedule_df = build_schedule_table(routes, ofrags)
maint_df = build_maintenance_table(routes)
fleet_df = build_fleet_summary(routes, fleet)
summary = solution_summary(result, ofrags, fleet)
logger.info(
"\n%s",
"\n".join(f" {k}: {v}" for k, v in summary.items()),
)
if save_outputs:
self._save(schedule_df, maint_df, fleet_df, summary)
return {
"summary": summary,
"schedule": schedule_df,
"maintenance": maint_df,
"fleet": fleet_df,
"ofrags": ofrags,
"raw_result": result,
"quality": qc,
}
def _save(
self,
schedule_df: pd.DataFrame,
maint_df: pd.DataFrame,
fleet_df: pd.DataFrame,
summary: Dict,
):
cfg = self.cfg
cfg.schedules_dir.mkdir(parents=True, exist_ok=True)
cfg.exports_dir.mkdir(parents=True, exist_ok=True)
cfg.processed_dir.mkdir(parents=True, exist_ok=True)
schedule_df.to_csv(cfg.schedules_dir / "flight_strings.csv", index=False)
maint_df.to_csv(cfg.schedules_dir / "maintenance_events.csv", index=False)
fleet_df.to_csv(cfg.exports_dir / "fleet_utilisation.csv", index=False)
with open(cfg.exports_dir / "solution_summary.json", "w", encoding="utf-8") as f:
json.dump(
{k: (str(v) if not isinstance(v, (int, float, str, list, dict, bool, type(None))) else v)
for k, v in summary.items()},
f, ensure_ascii=False, indent=2,
)
logger.info("Outputs saved to %s", cfg.schedules_dir)

View File

@@ -0,0 +1,105 @@
"""Data quality checks on OFRAGs and fleet tables."""
from __future__ import annotations
import logging
from typing import Dict, List
import pandas as pd
logger = logging.getLogger(__name__)
def check_ofrags(ofrags: pd.DataFrame) -> Dict:
issues: List[str] = []
if ofrags.empty:
return {"ok": False, "issues": ["OFRAG table is empty"]}
missing_dep = ofrags["departure"].isna().sum()
if missing_dep:
issues.append(f"{missing_dep} OFRAGs have missing departure datetime")
missing_arr = ofrags["arrival"].isna().sum()
if missing_arr:
issues.append(f"{missing_arr} OFRAGs have missing arrival datetime")
neg_fh = (ofrags["flight_hours"] <= 0).sum()
if neg_fh:
issues.append(f"{neg_fh} OFRAGs have non-positive flight hours")
inverted = (ofrags["arrival"] <= ofrags["departure"]).sum()
if inverted:
issues.append(f"{inverted} OFRAGs have arrival ≤ departure")
not_base = (~ofrags["starts_at_base"] | ~ofrags["ends_at_base"]).sum()
if not_base:
issues.append(
f"{not_base} OFRAGs do not start AND end at the maintenance base "
f"(filtered out in load_all)"
)
ok = len(issues) == 0
if ok:
logger.info("OFRAG quality: OK (%d OFRAGs)", len(ofrags))
else:
for iss in issues:
logger.warning("OFRAG quality: %s", iss)
return {"ok": ok, "issues": issues}
def check_fleet(fleet: pd.DataFrame) -> Dict:
issues: List[str] = []
if fleet.empty:
return {"ok": False, "issues": ["Fleet table is empty"]}
no_checks = fleet["checks"].apply(lambda c: len(c) == 0).sum()
if no_checks:
issues.append(f"{no_checks} aircraft have no maintenance check cycles")
zero_ttm = (fleet["ttm_hours"] <= 0).sum()
if zero_ttm:
issues.append(f"{zero_ttm} aircraft have TTM ≤ 0 (overdue maintenance)")
ok = len(issues) == 0
if ok:
logger.info("Fleet quality: OK (%d aircraft)", len(fleet))
else:
for iss in issues:
logger.warning("Fleet quality: %s", iss)
return {"ok": ok, "issues": issues}
def check_feasibility(ofrags: pd.DataFrame, fleet: pd.DataFrame) -> Dict:
"""High-level feasibility check before running the optimiser."""
issues: List[str] = []
if fleet.empty or ofrags.empty:
return {"ok": False, "issues": ["Empty inputs"]}
max_ttm = fleet["checks"].apply(
lambda cycles: max((c["ttm"] for c in cycles), default=0)
).max()
infeasible_ofrags = ofrags[ofrags["flight_hours"] > max_ttm]
if not infeasible_ofrags.empty:
ids = infeasible_ofrags["ofrag_id"].tolist()
issues.append(
f"OFRAGs {ids} have flight_hours > max achievable TTM ({max_ttm:.1f} FH) "
f" these cannot be served by any aircraft."
)
ok = len(issues) == 0
return {"ok": ok, "issues": issues}
def run_all(ofrags: pd.DataFrame, fleet: pd.DataFrame) -> Dict:
r1 = check_ofrags(ofrags)
r2 = check_fleet(fleet)
r3 = check_feasibility(ofrags, fleet)
ok = r1["ok"] and r2["ok"] and r3["ok"]
issues = r1["issues"] + r2["issues"] + r3["issues"]
return {"ok": ok, "issues": issues}