Compare commits
10 Commits
1cbcfea5a2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3607965c88 | ||
|
|
32ad7c9f23 | ||
|
|
d2bf2883d7 | ||
|
|
33c452b264 | ||
|
|
c09ce95e62 | ||
|
|
cc984114d2 | ||
|
|
6289a7e1ad | ||
|
|
e7489c9c2a | ||
|
|
e62d7cd640 | ||
|
|
1fd88b62fe |
22
.gitignore
vendored
22
.gitignore
vendored
@@ -1,3 +1,16 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.pyo
|
||||||
|
|
||||||
|
# Virtual environment
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
|
||||||
# Windows
|
# Windows
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
Desktop.ini
|
Desktop.ini
|
||||||
@@ -9,3 +22,12 @@ Desktop.ini
|
|||||||
# Local editor folders
|
# Local editor folders
|
||||||
.vscode/
|
.vscode/
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
|
# Generated outputs and processed data
|
||||||
|
outputs/
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Large binary / proprietary files
|
||||||
|
raw/*.pdf
|
||||||
|
raw/*.xlsx
|
||||||
|
raw/*.xls
|
||||||
|
|||||||
103
CHANGELOG.md
Normal file
103
CHANGELOG.md
Normal 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).
|
||||||
82
CONTEXTO.md
82
CONTEXTO.md
@@ -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.
|
## Contexto operacional
|
||||||
- `pre_process/`: scripts e saídas intermediárias de pré-processamento.
|
|
||||||
- `processed/`: dados finais limpos, consolidados ou prontos para análise.
|
|
||||||
|
|
||||||
## Colaboradores
|
### Aeronaves
|
||||||
|
|
||||||
- `VTO`: Vitor Cesa.
|
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.
|
||||||
- `GNR`: Generoso.
|
|
||||||
- `JOM`: João Marcos.
|
|
||||||
|
|
||||||
## Orientações de Trabalho
|
### Checks de manutenção
|
||||||
|
|
||||||
- Manter documentos originais sempre em `raw/`.
|
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).
|
||||||
- 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/`.
|
|
||||||
|
|
||||||
## Formato Recomendado do Log
|
### OFRAGs
|
||||||
|
|
||||||
```text
|
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.
|
||||||
| Data | Hora | Autor | Ação | Arquivos | Observações |
|
|
||||||
| --- | --- | --- | --- | --- | --- |
|
### Escala de voo
|
||||||
| 2026-06-15 | 15:41 | VTO | Pré-processou PDF de inspeções | raw/documento.pdf; pre_process/saida.csv | 18 inspeções extraídas |
|
|
||||||
```
|
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
14
LOG.md
@@ -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
145
README.md
Normal 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
755
app/dashboard.py
Normal 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
232
app/dashboard_backup.py
Normal 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)
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -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,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)"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
@@ -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
5
raw/AERONAVES.csv
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
MATRICULA;MODELO;FH TOTAIS
|
||||||
|
FAB2800;C105;295
|
||||||
|
FAB2803;C105;275
|
||||||
|
FAB2809;C105;265
|
||||||
|
FAB2811;C105;255
|
||||||
|
31
raw/AIRPORTS.csv
Normal file
31
raw/AIRPORTS.csv
Normal 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
|
||||||
|
5
raw/CHECKS.csv
Normal file
5
raw/CHECKS.csv
Normal 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
|
||||||
|
Binary file not shown.
Binary file not shown.
64
raw/ESCALA DE VOO MODELO 1.csv
Normal file
64
raw/ESCALA DE VOO MODELO 1.csv
Normal 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,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
11
requirements.txt
Normal 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
37
run_pipeline.bat
Normal 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
|
||||||
50
scripts/00_setup_project.py
Normal file
50
scripts/00_setup_project.py
Normal 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)
|
||||||
41
scripts/01_inspect_inputs.py
Normal file
41
scripts/01_inspect_inputs.py
Normal 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}")
|
||||||
64
scripts/02_build_fleet_reference.py
Normal file
64
scripts/02_build_fleet_reference.py
Normal 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}")
|
||||||
44
scripts/03_run_optimization_pipeline.py
Normal file
44
scripts/03_run_optimization_pipeline.py
Normal 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))
|
||||||
75
scripts/04_generate_flight_strings.py
Normal file
75
scripts/04_generate_flight_strings.py
Normal 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)")
|
||||||
5
scripts/05_run_dashboard.bat
Normal file
5
scripts/05_run_dashboard.bat
Normal 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
|
||||||
6
src/routing_engine/__init__.py
Normal file
6
src/routing_engine/__init__.py
Normal 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"]
|
||||||
74
src/routing_engine/config.py
Normal file
74
src/routing_engine/config.py
Normal 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()
|
||||||
322
src/routing_engine/ingest.py
Normal file
322
src/routing_engine/ingest.py
Normal 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}
|
||||||
160
src/routing_engine/inspect_files.py
Normal file
160
src/routing_engine/inspect_files.py
Normal 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)
|
||||||
173
src/routing_engine/maintenance_monitor.py
Normal file
173
src/routing_engine/maintenance_monitor.py
Normal 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)
|
||||||
117
src/routing_engine/metrics.py
Normal file
117
src/routing_engine/metrics.py
Normal 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),
|
||||||
|
}
|
||||||
84
src/routing_engine/network_generator.py
Normal file
84
src/routing_engine/network_generator.py
Normal 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,
|
||||||
|
}
|
||||||
550
src/routing_engine/optimizer.py
Normal file
550
src/routing_engine/optimizer.py
Normal 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
|
||||||
124
src/routing_engine/pipeline.py
Normal file
124
src/routing_engine/pipeline.py
Normal 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)
|
||||||
105
src/routing_engine/quality.py
Normal file
105
src/routing_engine/quality.py
Normal 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}
|
||||||
Reference in New Issue
Block a user