Add OARMP routing engine, dashboard and documentation

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Eduardo Carlos
2026-06-17 11:52:34 -03:00
parent 32ad7c9f23
commit 3607965c88
43 changed files with 3419 additions and 1936 deletions

22
.gitignore vendored
View File

@@ -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
View File

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

View File

@@ -1,50 +1,64 @@
# Contexto do Projeto # Contexto do Projeto — OARMP
## Objetivo ## Origem e motivação
Organizar, preservar e processar documentos relacionados ao projeto `arara_oarmp`, 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.
- `docs/`: documentação técnica, autoria e histórico de versões.
## Arquivos de Documentação ### Aeronaves
- `README.md`: guia inicial para colaboradores e uso básico do repositório. 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.
- `CONTEXTO.md`: orientações permanentes e combinados de trabalho.
- `LOG.md`: diário operacional detalhado, com data, hora, autor, ação, arquivos e observações.
- `docs/about.md`: documentação técnica do projeto, fluxo de dados, limitações e oportunidades de melhoria.
- `docs/authors.md`: autoria, colaboradores e regras de contribuição.
- `docs/changelog.md`: histórico resumido de versões significativas.
## Colaboradores ### Checks de manutenção
- `VTO`: Vitor Cesa. 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).
- `GNR`: Generoso.
- `JOM`: João Marcos.
## Orientações de Trabalho ### OFRAGs
- Manter documentos originais sempre em `raw/`. 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.
- 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.
- Registrar em `docs/changelog.md` mudanças significativas de versão, como novo parser, novo conjunto de dados, mudança estrutural ou nova funcionalidade.
- 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/`.
- Atualizar `docs/about.md` quando o fluxo técnico, campos extraídos ou limitações conhecidas mudarem.
## Formato Recomendado do Log ### Escala de voo
```text 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`).
| Data | Hora | Autor | Ação | Arquivos | Observações |
| --- | --- | --- | --- | --- | --- | ## Problema de otimização
| 2026-06-15 | 15:41 | VTO | Pré-processou PDF de inspeções | raw/documento.pdf; pre_process/saida.csv | 18 inspeções extraídas |
``` 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) |

23
LOG.md
View File

@@ -1,23 +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. |
| 2026-06-15 | 15:47 | VTO | Renomeou PDF e artefatos derivados com nome descritivo | `raw/relatorio_ciclo_inspecoes_c105_2805_2026-06-15.pdf`; `pre_process/relatorio_ciclo_inspecoes_c105_2805_2026-06-15_texto.txt`; `pre_process/relatorio_ciclo_inspecoes_c105_2805_2026-06-15_inspecoes.json`; `pre_process/relatorio_ciclo_inspecoes_c105_2805_2026-06-15_inspecoes.csv`; `pre_process/preprocess_pdf.py` | Nome antigo removia contexto técnico; novo padrão usa tipo do documento, modelo, matrícula e data do relatório. |
| 2026-06-15 | 15:54 | VTO | Separou campos de controle e duração no pré-processamento | `pre_process/preprocess_pdf.py`; `pre_process/relatorio_ciclo_inspecoes_c105_2805_2026-06-15_inspecoes.json`; `pre_process/relatorio_ciclo_inspecoes_c105_2805_2026-06-15_inspecoes.csv` | Criadas colunas `tso`, `letra`, `nivel`, `var_media`, `duracao`, `duracao_valor` e `duracao_unidade`; `D` tratado como dias e `H` como horas. |
| 2026-06-15 | 16:00 | VTO | Criou guia inicial do repositório | `README.md` | Explica o objetivo do projeto OAMRP, a estrutura de pastas, o uso básico do GitHub/Git e os cuidados de rastreabilidade. |
| 2026-06-15 | 16:03 | VTO | Corrigiu interpretação da coluna `Zera TSO` | `pre_process/preprocess_pdf.py`; `pre_process/relatorio_ciclo_inspecoes_c105_2805_2026-06-15_inspecoes.json`; `pre_process/relatorio_ciclo_inspecoes_c105_2805_2026-06-15_inspecoes.csv` | `Zera TSO` passou a ser coluna única `zera_tso`; níveis `B`, `P` e `O` mapeados para Base, Parque e Orgânico; `controle_original` mantido para rastreabilidade. |
| 2026-06-15 | 16:08 | VTO | Separou valores numéricos dos intervalos de inspeção | `pre_process/preprocess_pdf.py`; `pre_process/relatorio_ciclo_inspecoes_c105_2805_2026-06-15_inspecoes.json`; `pre_process/relatorio_ciclo_inspecoes_c105_2805_2026-06-15_inspecoes.csv` | Criadas colunas discretas para horas de voo, meses contínuos e pousos, mantendo os campos textuais originais. |
| 2026-06-15 | 16:43 | VTO | Renomeou o projeto e trocou o repositório remoto | `README.md`; `CONTEXTO.md`; `.git/config` | Projeto passou a se chamar `arara_oarmp`; remoto atualizado para `https://git.ppgao.ita.br/vitorcesavc/arara_oarmp.git`. |
| 2026-06-15 | 17:57 | VTO | Criou documentação técnica no padrão `docs/` | `docs/about.md`; `docs/authors.md`; `docs/changelog.md`; `README.md`; `CONTEXTO.md`; `LOG.md` | Estrutura inspirada no módulo `meteorologia_aeroportos`: documentação técnica, autoria e changelog separados do log operacional. |
| 2026-06-15 | 18:11 | VTO | Agrupou artefatos do relatório de ciclo em subpasta própria | `pre_process/relatorio_ciclo_inspecoes_c105_2805_2026-06-15/`; `README.md`; `docs/about.md` | Removido `.gitkeep` de `pre_process/` porque a pasta deixou de estar vazia; `.gitkeep` de `processed/` foi mantido. |
| 2026-06-15 | 18:33 | VTO | Comparou ICA 66-31/2023 com relatório de ciclo C-105 2805 | `raw/ICA 66-31 2023.pdf`; `pre_process/comparacao_ica_66_31_c105_2023/` | A comparação usou o Anexo D textual do PDF completo da ICA. |

229
README.md
View File

@@ -1,158 +1,145 @@
# Arara OARMP # OARMP — Aircraft Routing & Maintenance Planning
Este repositório organiza os arquivos e processamentos do projeto Arara OARMP do Esquadrão Arara, em Manaus. 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).
A ideia é construir, em conjunto, uma base confiável para encaixar missões, aeronaves e manutenções. O repositório serve como lugar único para guardar documentos originais, registrar decisões, transformar relatórios em dados utilizáveis e manter um histórico claro do que foi feito por cada colaborador. ## Visão geral
## O que é um repositório 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.
Um repositório é uma pasta controlada pelo Git. Ele guarda os arquivos do projeto e também o histórico de alterações. ### Formulação matemática
O Gitea do PPGAO/ITA é o site onde esse repositório fica hospedado de forma privada, para que os colaboradores possam acessar, baixar, atualizar e enviar contribuições. **Set Partitioning com Column Generation + Branch & Bound**
Na prática: ```
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 Git mostra o que mudou; 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.
- o Gitea permite compartilhar o projeto;
- os commits são registros das alterações feitas;
- o `LOG.md` explica o motivo e o contexto das mudanças importantes.
## Estrutura do projeto ## Estrutura do projeto
```text ```
arara_oarmp/ arara_oarmp/
raw/ ├── app/
pre_process/ └── dashboard.py # Interface Streamlit (entrada de dados + resultados)
processed/ ├── raw/ # Dados de entrada (CSV)
docs/ ├── AERONAVES.csv
CONTEXTO.md │ ├── CHECKS.csv
LOG.md ├── AIRPORTS.csv
README.md └── 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
``` ```
- `raw/`: documentos originais. Não editar esses arquivos diretamente. ## Instalação
- `pre_process/`: scripts e arquivos intermediários gerados a partir dos documentos originais.
- `processed/`: arquivos finais, limpos e prontos para análise ou uso.
- `docs/`: documentação técnica, autoria e histórico de versões.
- `CONTEXTO.md`: regras, combinados e orientações permanentes do projeto.
- `LOG.md`: histórico rastreável das ações feitas no projeto.
- `README.md`: este guia inicial.
## Colaboradores ```bash
# Criar ambiente virtual
python -m venv .venv
.venv\Scripts\activate # Windows
# source .venv/bin/activate # Linux/Mac
Usamos tags curtas para identificar quem fez cada alteração relevante: # Instalar dependências
pip install -r requirements.txt
- `VTO`: Vitor Cesa.
- `GNR`: Generoso.
- `JOM`: João Marcos.
Sempre que alguém fizer uma mudança importante, deve registrar no `LOG.md` com data, hora, tag do autor, ação, arquivos e observações.
## Como usar pelo site do Gitea
Este caminho é bom para quem só quer ver arquivos, baixar documentos ou conferir o histórico.
1. Abra o repositório no Gitea do PPGAO/ITA.
2. Entre nas pastas para ver os arquivos.
3. Use `raw/` para consultar documentos originais.
4. Use `pre_process/` para consultar arquivos intermediários.
5. Use `processed/` para consultar dados finais quando existirem.
6. Abra o `LOG.md` para entender o que já foi feito.
7. Abra o `CONTEXTO.md` para ver os combinados do projeto.
Para baixar um arquivo pelo site:
1. Clique no arquivo.
2. Clique em `Download raw file` ou no botão de download.
3. Salve no computador.
## Documentação
- `docs/about.md`: descrição técnica, fluxo de dados, limitações e oportunidades de melhoria.
- `docs/authors.md`: autoria, colaboradores e regras de contribuição.
- `docs/changelog.md`: histórico resumido de versões significativas.
- `LOG.md`: diário operacional detalhado.
## Como usar no computador
Este caminho é para quem vai mexer nos arquivos e devolver alterações ao Gitea.
Primeiro, instale o Git:
```powershell
git --version
``` ```
Se o comando não funcionar, o Git ainda não está instalado. ### Dependências principais
Depois, clone o repositório: | 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 |
```powershell ## Execução
git clone https://git.ppgao.ita.br/vitorcesavc/arara_oarmp.git
cd arara_oarmp ### Via dashboard (recomendado)
```bash
streamlit run app/dashboard.py
``` ```
Antes de começar a trabalhar, atualize sua cópia: ### Via script de linha de comando
```powershell ```bash
git pull python scripts/03_run_optimization_pipeline.py
# ou
run_pipeline.bat
``` ```
Depois de alterar arquivos, veja o que mudou: ## Dados de entrada
```powershell Todos os arquivos ficam em `raw/` com separador `;`:
git status
```
Adicione os arquivos modificados: ### AERONAVES.csv
| Campo | Descrição |
|-------|-----------|
| MATRICULA | Indicativo da aeronave |
| MODELO | Modelo (ex: C105) |
| FH TOTAIS | Horas de voo totais acumuladas |
```powershell ### CHECKS.csv
git add . | 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 |
Crie um commit com uma mensagem curta: ### AIRPORTS.csv
| Campo | Descrição |
|-------|-----------|
| AIRPORT_CODE | Código ICAO |
| AIRPORT_NAME | Nome do aeroporto |
| IS_MAINTENANCE_BASE | 1 = base de manutenção |
```powershell ### ESCALA DE VOO MODELO 1.csv
git commit -m "Descreva a alteração feita" 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`).
Envie para o Gitea: ## Dashboard — abas
```powershell ### Entrada (📥 Input)
git push - **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
## Fluxo recomendado de trabalho ### 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)
1. Rode `git pull` antes de começar. ## Conceitos-chave
2. Coloque documentos novos em `raw/`.
3. Gere arquivos intermediários em `pre_process/`.
4. Coloque resultados finais em `processed/`.
5. Atualize o `LOG.md`.
6. Faça commit.
7. Rode `git push`.
## Cuidados importantes **OFRAG** — Fragmento de voo: conjunto de etapas que sai e retorna à base de manutenção. Unidade de alocação do problema.
- Não editar arquivos originais dentro de `raw/`. **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.
- Não apagar arquivos de outros colaboradores sem combinar.
- Não subir arquivos temporários do Office, como arquivos começando com `~$`.
- Não colocar senhas, tokens ou informações pessoais desnecessárias no repositório.
- Preferir nomes de arquivo descritivos, com tipo do documento, aeronave, matrícula e data quando possível.
- Registrar no `LOG.md` qualquer decisão que afete os dados.
## Estado atual **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).
Já existe um pré-processamento do relatório de ciclo de inspeções da aeronave C-105 matrícula 2805, com data de relatório `2026-06-15`. **TAT (Turnaround Time)** — Tempo mínimo entre o pouso de um OFRAG e a decolagem do seguinte na mesma aeronave.
Arquivos principais:
```text
raw/relatorio_ciclo_inspecoes_c105_2805_2026-06-15.pdf
pre_process/relatorio_ciclo_inspecoes_c105_2805_2026-06-15/preprocess_pdf.py
pre_process/relatorio_ciclo_inspecoes_c105_2805_2026-06-15/relatorio_ciclo_inspecoes_c105_2805_2026-06-15_texto.txt
pre_process/relatorio_ciclo_inspecoes_c105_2805_2026-06-15/relatorio_ciclo_inspecoes_c105_2805_2026-06-15_inspecoes.json
pre_process/relatorio_ciclo_inspecoes_c105_2805_2026-06-15/relatorio_ciclo_inspecoes_c105_2805_2026-06-15_inspecoes.csv
```
Esse processamento extraiu 18 inspeções e separou campos como inspeção, referência, vencimento, TSO, letra, nível, duração, intervalo por horas de voo, meses contínuos e pousos.

755
app/dashboard.py Normal file
View File

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

232
app/dashboard_backup.py Normal file
View File

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

View File

@@ -1,129 +0,0 @@
# Documentação Técnica — Arara OARMP
## O que é
O Arara OARMP é um projeto para organizar dados e documentos usados no planejamento e acompanhamento operacional do Esquadrão Arara, em Manaus.
O objetivo é construir uma base rastreável para encaixar missões, aeronaves e manutenções, começando pela leitura e estruturação de documentos operacionais e de manutenção.
Nesta fase inicial, o projeto contém:
- documentos originais em `raw/`;
- scripts e saídas intermediárias em `pre_process/`;
- área reservada para dados finais em `processed/`;
- documentação de contexto, autoria, mudanças e rastreabilidade.
## Fontes de dados
As fontes atuais são documentos operacionais e de manutenção adicionados manualmente ao repositório.
Fonte inicial processada:
| Arquivo | Conteúdo | Observação |
| --- | --- | --- |
| `raw/relatorio_ciclo_inspecoes_c105_2805_2026-06-15.pdf` | Relatório de ciclo de inspeções do equipamento | Aeronave C-105, matrícula 2805, relatório de 2026-06-15 |
Arquivos originais devem permanecer em `raw/` sem edição direta.
## Como funciona
O fluxo atual é:
1. Guardar documentos originais em `raw/`.
2. Executar scripts de extração e padronização em `pre_process/`.
3. Gerar saídas intermediárias em texto, JSON e CSV.
4. Manter campos originais para rastreabilidade.
5. Separar campos discretos úteis para cálculo, comparação e planejamento.
6. Registrar mudanças relevantes em `LOG.md`.
7. Consolidar dados finais em `processed/` quando os critérios de qualidade estiverem definidos.
## Pré-processamento atual
O script `pre_process/relatorio_ciclo_inspecoes_c105_2805_2026-06-15/preprocess_pdf.py` lê o relatório de ciclo de inspeções em PDF e gera:
```text
pre_process/relatorio_ciclo_inspecoes_c105_2805_2026-06-15/relatorio_ciclo_inspecoes_c105_2805_2026-06-15_texto.txt
pre_process/relatorio_ciclo_inspecoes_c105_2805_2026-06-15/relatorio_ciclo_inspecoes_c105_2805_2026-06-15_inspecoes.json
pre_process/relatorio_ciclo_inspecoes_c105_2805_2026-06-15/relatorio_ciclo_inspecoes_c105_2805_2026-06-15_inspecoes.csv
```
Campos extraídos e estruturados:
| Campo | Conteúdo |
| --- | --- |
| `seq` | Sequência da inspeção no relatório |
| `sigla_mnt` | Sigla da manutenção |
| `descricao_mnt` | Descrição da manutenção |
| `referencia` | Referência textual da inspeção |
| `tipo_vencimento` | Tipo de vencimento |
| `zera_tso` | Coluna original `Zera TSO` |
| `letra` | Letra da inspeção, quando preenchida |
| `nivel` | Código do nível |
| `nivel_descricao` | Descrição do nível |
| `var_media` | Variação média informada no relatório |
| `duracao` | Duração original, por exemplo `3 D` ou `6 H` |
| `duracao_valor` | Valor numérico da duração |
| `duracao_unidade` | Unidade normalizada: `dias` ou `horas` |
| `controle_original` | Trecho original usado para rastreabilidade |
| `intervalo_horas_voo` | Intervalo textual em horas de voo |
| `intervalo_horas_voo_valor` | Valor numérico de horas de voo |
| `intervalo_meses_continuos` | Intervalo textual em meses contínuos |
| `intervalo_meses_continuos_valor` | Valor numérico de meses contínuos |
| `intervalo_pousos` | Intervalo textual em pousos |
| `intervalo_pousos_valor` | Valor numérico de pousos |
| `linha_original` | Linha reconstruída a partir do texto extraído |
## Convenções operacionais
Níveis de manutenção:
| Código | Descrição |
| --- | --- |
| `B` | Base |
| `P` | Parque |
| `O` | Orgânico |
Unidades de duração:
| Código | Descrição |
| --- | --- |
| `D` | Dias |
| `H` | Horas |
## Limitações conhecidas
1. A extração depende da qualidade do PDF e do texto recuperado por `pdfplumber`.
2. O parser foi calibrado para o layout atual do relatório de ciclo de inspeções.
3. Mudanças no formato do relatório podem exigir ajuste no script.
4. A grafia original é preservada, inclusive erros existentes no documento fonte.
5. Ainda não há validação cruzada com outros sistemas ou fontes oficiais.
6. Ainda não há modelo final em `processed/` para integração completa entre missões, aeronaves e manutenções.
## Oportunidades de melhoria
| Prioridade | Melhoria |
| --- | --- |
| Alta | Definir o modelo final de dados em `processed/` |
| Alta | Criar uma tabela mestre de aeronaves |
| Alta | Criar uma tabela mestre de missões |
| Alta | Definir regras para cálculo de vencimento por horas, meses e pousos |
| Média | Criar testes automatizados para o parser de PDF |
| Média | Padronizar nomes de colunas entre todos os documentos |
| Média | Criar relatórios de inconsistências e campos ausentes |
| Baixa | Criar interface simples para consulta e atualização |
## Solução de problemas
Se o pré-processamento falhar, verifique:
- se o PDF existe em `raw/`;
- se o nome do arquivo no script corresponde ao arquivo real;
- se as bibliotecas Python necessárias estão instaladas;
- se o layout do PDF mudou;
- se o texto extraído em `_texto.txt` contém as linhas esperadas.
Para reexecutar o pré-processamento:
```powershell
python pre_process/relatorio_ciclo_inspecoes_c105_2805_2026-06-15/preprocess_pdf.py
```

View File

@@ -1,44 +0,0 @@
# Autoria e Contribuições
## Colaboradores
| Tag | Nome | Papel inicial |
| --- | --- | --- |
| `VTO` | Vitor Cesa | Organização do repositório, estrutura inicial, pré-processamento e documentação |
| `GNR` | Generoso | Colaborador |
| `JOM` | João Marcos | Colaborador e origem inicial do relatório de inspeções |
## Instituição e contexto
Projeto desenvolvido para apoiar o OARMP do Esquadrão Arara, em Manaus, com organização colaborativa em repositório Git.
## Como contribuir
Ao modificar ou estender este projeto:
1. Atualize sua cópia com `git pull` antes de começar.
2. Preserve documentos originais em `raw/`.
3. Coloque scripts e saídas intermediárias em `pre_process/`.
4. Coloque resultados finais e validados em `processed/`.
5. Registre mudanças operacionais em `LOG.md`.
6. Registre mudanças de versão em `docs/changelog.md` quando houver alteração significativa.
7. Identifique o autor usando a tag do colaborador: `VTO`, `GNR` ou `JOM`.
8. Use mensagens de commit curtas e objetivas.
## Formato sugerido para mudanças de versão
```text
## v<versão> — <Autor ou tag> — <YYYY-MM>
### Adicionado / Alterado / Corrigido / Removido
- Descrição objetiva da mudança.
```
## Histórico de contribuições
| Versão | Autor | Data | Descrição resumida |
| --- | --- | --- | --- |
| v0.1 | VTO | 2026-06 | Estrutura inicial, documentação, pré-processamento do primeiro relatório de inspeções e migração para o repositório `arara_oarmp` |
Contribuidores futuros devem adicionar uma linha nesta tabela e criar a entrada correspondente em `docs/changelog.md`.

View File

@@ -1,45 +0,0 @@
# Changelog
Todas as versões significativas do Arara OARMP devem ser registradas aqui. O `LOG.md` guarda o diário operacional detalhado; este arquivo resume mudanças por versão.
Formato:
```text
## v<versão> — <Autor ou tag> — <YYYY-MM>
### Adicionado / Alterado / Corrigido / Removido
- Descrição objetiva.
```
---
## v0.1 — VTO — 2026-06
### Adicionado
- Repositório Git inicial.
- Estrutura de dados com `raw/`, `pre_process/` e `processed/`.
- Arquivo `README.md` na raiz com guia de uso do repositório.
- Arquivo `CONTEXTO.md` com orientações permanentes do projeto.
- Arquivo `LOG.md` com rastreabilidade operacional por data, hora e tag de autor.
- Pré-processamento inicial do relatório de ciclo de inspeções do C-105 matrícula 2805.
- Saídas intermediárias em texto, JSON e CSV.
- Campos discretos para `zera_tso`, letra, nível, duração, horas de voo, meses contínuos e pousos.
- Documentação em `docs/about.md`, `docs/authors.md` e `docs/changelog.md`.
### Alterado
- Projeto renomeado para `arara_oarmp`.
- Repositório remoto migrado para `https://git.ppgao.ita.br/vitorcesavc/arara_oarmp.git`.
- Documentação reorganizada para separar guia inicial, contexto, autoria, histórico de versão e log operacional.
### Corrigido
- Interpretação da coluna `Zera TSO`, que passou a ser tratada como coluna única.
- Mapeamento dos níveis `B`, `P` e `O` para Base, Parque e Orgânico.
### Observações
- O parser atual foi calibrado para o primeiro PDF processado.
- A grafia original do documento fonte foi preservada, inclusive ocorrências de `INPEÇÃO`.

View File

@@ -1,24 +0,0 @@
chave;status;tipo_ica;tipo_relatorio;nivel_ica;nivel_relatorio;intervalo_horas_ica;intervalo_horas_relatorio;intervalo_meses_ica;intervalo_meses_relatorio;duracao_ica;duracao_relatorio;hxh_ica;seq_relatorio;observacoes
PRE VOO;somente_ica;PRÉ VOO;;Base;;;;;;1 H;;1;;Não encontrada no relatório de ciclo
TRANSITO;somente_ica;TRÂNSITO;;Base;;;;;;1 H;;1;;Não encontrada no relatório de ciclo
DIARIA;somente_ica;DIÁRIA;;Base;;;;;;1 H;;1;;Não encontrada no relatório de ciclo
SERV 72H;somente_ica;SERV 72H;;Base;;72;;;;4 H;;16;;Não encontrada no relatório de ciclo
OPERACIONAL 300FH;divergente;OPERACIONAL 300FH;CHECK OPERACIONAL DE 300FH;Base;Orgânico;300;300;;;1 D;6 H;14;6;"nível ICA=Base relatório=Orgânico; duração ICA=1 D relatório=6 H"
OPERACIONAL 400FH;divergente;OPERACIONAL 400FH;CHECK OPERACIONAL DE 400FH;Base;Orgânico;400;400;;;1 D;6 H;14;7;"nível ICA=Base relatório=Orgânico; duração ICA=1 D relatório=6 H"
OPERACIONAL 600FH;somente_ica;OPERACIONAL 600FH;;Base;;600;;;;1 D;;14;;Não encontrada no relatório de ciclo
CHECK 1A;compatível;CHECK 1A;INSPEÇÃO CHECK 1A;Base;Base;300;300;8;8;3 D;3 D;16;2;
CHECK 2A;compatível;CHECK 2A;INSPEÇÃO CHECK 2A;Base;Base;600;600;16;16;4 D;4 D;28;3;
CHECK 3A;compatível;CHECK 3A;INSPEÇÃO CHECK 3A;Base;Base;900;900;24;24;5 D;5 D;76;4;
CHECK 1C;divergente;CHECK 1C;INSPEÇÃO CHECK 1C;Base;Parque;2400;2400;48;48;65 D;65 D;526;15;nível ICA=Base relatório=Parque
CHECK 2C;divergente;CHECK 2C;INSPEÇÃO GERAL CHECK 2C;Base;Parque;4800;4800;96;96;5 D;10 D;17;16;"nível ICA=Base relatório=Parque; duração ICA=5 D relatório=10 D"
CALENDARICA 2Y;compatível;CALENDÁRICA 2Y;INSPEÇÃO CALENDÁRICA DE 2 YEARS;Base;Base;;;24;24;5 D;5 D;30;5;
CALENDARICA 4Y;compatível;CALENDÁRICA 4Y;INSPEÇÃO CALENDÁRICA DE 4 YEARS;Parque;Parque;;;48;48;10 D;10 D;123;17;
CALENDARICA 8Y;compatível;CALENDÁRICA 8Y;INSPEÇÃO CALENDÁRICA DE 8 YEARS;Parque;Parque;;;96;96;20 D;20 D;336;14;
;somente_relatorio;;INSPEÇÃO AOL 295-019;;Base;;;;;;1 D;;8;Não encontrada na tabela C/SC-105 da ICA 66-31/2023 usada nesta comparação
;somente_relatorio;;INSPEÇÃO AOL 295-019;;Base;;;;;;1 D;;9;Não encontrada na tabela C/SC-105 da ICA 66-31/2023 usada nesta comparação
;somente_relatorio;;INSPEÇÃO AOL 295-019;;Base;;;;;;1 D;;10;Não encontrada na tabela C/SC-105 da ICA 66-31/2023 usada nesta comparação
;somente_relatorio;;INSPEÇÃO DE 100 FH;;Orgânico;;100;;;;1 H;;18;Não encontrada na tabela C/SC-105 da ICA 66-31/2023 usada nesta comparação
;somente_relatorio;;INPEÇÃO 2400FH;;Orgânico;;2400;;;;3 H;;19;Não encontrada na tabela C/SC-105 da ICA 66-31/2023 usada nesta comparação
;somente_relatorio;;INSPEÇÃO 3000FH;;Base;;3000;;;;1 D;;20;Não encontrada na tabela C/SC-105 da ICA 66-31/2023 usada nesta comparação
;somente_relatorio;;INPEÇÃO 1000FH;;Base;;1000;;;;1 D;;22;Não encontrada na tabela C/SC-105 da ICA 66-31/2023 usada nesta comparação
;somente_relatorio;;INSPEÇÃO 50 FH;;Base;;50;;;;1 D;;23;Não encontrada na tabela C/SC-105 da ICA 66-31/2023 usada nesta comparação
1 chave status tipo_ica tipo_relatorio nivel_ica nivel_relatorio intervalo_horas_ica intervalo_horas_relatorio intervalo_meses_ica intervalo_meses_relatorio duracao_ica duracao_relatorio hxh_ica seq_relatorio observacoes
2 PRE VOO somente_ica PRÉ VOO Base 1 H 1 Não encontrada no relatório de ciclo
3 TRANSITO somente_ica TRÂNSITO Base 1 H 1 Não encontrada no relatório de ciclo
4 DIARIA somente_ica DIÁRIA Base 1 H 1 Não encontrada no relatório de ciclo
5 SERV 72H somente_ica SERV 72H Base 72 4 H 16 Não encontrada no relatório de ciclo
6 OPERACIONAL 300FH divergente OPERACIONAL 300FH CHECK OPERACIONAL DE 300FH Base Orgânico 300 300 1 D 6 H 14 6 nível ICA=Base relatório=Orgânico; duração ICA=1 D relatório=6 H
7 OPERACIONAL 400FH divergente OPERACIONAL 400FH CHECK OPERACIONAL DE 400FH Base Orgânico 400 400 1 D 6 H 14 7 nível ICA=Base relatório=Orgânico; duração ICA=1 D relatório=6 H
8 OPERACIONAL 600FH somente_ica OPERACIONAL 600FH Base 600 1 D 14 Não encontrada no relatório de ciclo
9 CHECK 1A compatível CHECK 1A INSPEÇÃO CHECK 1A Base Base 300 300 8 8 3 D 3 D 16 2
10 CHECK 2A compatível CHECK 2A INSPEÇÃO CHECK 2A Base Base 600 600 16 16 4 D 4 D 28 3
11 CHECK 3A compatível CHECK 3A INSPEÇÃO CHECK 3A Base Base 900 900 24 24 5 D 5 D 76 4
12 CHECK 1C divergente CHECK 1C INSPEÇÃO CHECK 1C Base Parque 2400 2400 48 48 65 D 65 D 526 15 nível ICA=Base relatório=Parque
13 CHECK 2C divergente CHECK 2C INSPEÇÃO GERAL CHECK 2C Base Parque 4800 4800 96 96 5 D 10 D 17 16 nível ICA=Base relatório=Parque; duração ICA=5 D relatório=10 D
14 CALENDARICA 2Y compatível CALENDÁRICA 2Y INSPEÇÃO CALENDÁRICA DE 2 YEARS Base Base 24 24 5 D 5 D 30 5
15 CALENDARICA 4Y compatível CALENDÁRICA 4Y INSPEÇÃO CALENDÁRICA DE 4 YEARS Parque Parque 48 48 10 D 10 D 123 17
16 CALENDARICA 8Y compatível CALENDÁRICA 8Y INSPEÇÃO CALENDÁRICA DE 8 YEARS Parque Parque 96 96 20 D 20 D 336 14
17 somente_relatorio INSPEÇÃO AOL 295-019 Base 1 D 8 Não encontrada na tabela C/SC-105 da ICA 66-31/2023 usada nesta comparação
18 somente_relatorio INSPEÇÃO AOL 295-019 Base 1 D 9 Não encontrada na tabela C/SC-105 da ICA 66-31/2023 usada nesta comparação
19 somente_relatorio INSPEÇÃO AOL 295-019 Base 1 D 10 Não encontrada na tabela C/SC-105 da ICA 66-31/2023 usada nesta comparação
20 somente_relatorio INSPEÇÃO DE 100 FH Orgânico 100 1 H 18 Não encontrada na tabela C/SC-105 da ICA 66-31/2023 usada nesta comparação
21 somente_relatorio INPEÇÃO 2400FH Orgânico 2400 3 H 19 Não encontrada na tabela C/SC-105 da ICA 66-31/2023 usada nesta comparação
22 somente_relatorio INSPEÇÃO 3000FH Base 3000 1 D 20 Não encontrada na tabela C/SC-105 da ICA 66-31/2023 usada nesta comparação
23 somente_relatorio INPEÇÃO 1000FH Base 1000 1 D 22 Não encontrada na tabela C/SC-105 da ICA 66-31/2023 usada nesta comparação
24 somente_relatorio INSPEÇÃO 50 FH Base 50 1 D 23 Não encontrada na tabela C/SC-105 da ICA 66-31/2023 usada nesta comparação

View File

@@ -1,26 +0,0 @@
# Comparação ICA 66-31/2023 x Relatório de Ciclo C-105 2805
Fonte ICA: `raw/ICA 66-31 2023.pdf`, Anexo D - Projeto C/SC-105.
Observação: a comparação foi feita com o Anexo D textual do PDF completo da ICA.
## Resumo
- `compatível`: 6
- `divergente`: 4
- `somente_ica`: 5
- `somente_relatorio`: 8
## Principais Achados
- `CHECK 1A`, `CHECK 2A`, `CHECK 3A`, `CALENDÁRICA 2Y`, `CALENDÁRICA 4Y` e `CALENDÁRICA 8Y` estão compatíveis nos intervalos e durações.
- `CHECK 1C` bate em intervalo e duração, mas diverge no nível: ICA indica Base na tabela D.2, enquanto o relatório de ciclo indica Parque.
- `CHECK 2C` diverge em nível e duração: ICA indica Base e 5 D; relatório de ciclo indica Parque e 10 D.
- `OPERACIONAL 300FH` e `OPERACIONAL 400FH` batem no intervalo de horas, mas divergem em nível e duração: ICA indica Base e 1 D; relatório de ciclo indica Orgânico e 6 H.
- `OPERACIONAL 600FH`, `PRÉ VOO`, `TRÂNSITO`, `DIÁRIA` e `SERV 72H` aparecem na ICA, mas não apareceram no relatório de ciclo processado.
- Inspeções como AOL, 50FH, 100FH, 1000FH, 2400FH e 3000FH aparecem no relatório de ciclo, mas não na tabela C/SC-105 da ICA usada na comparação.
## Arquivo Detalhado
- `pre_process\comparacao_ica_66_31_c105_2023\comparacao_ica_66_31_c105_2023_vs_relatorio_ciclo.csv`

View File

@@ -1,182 +0,0 @@
import csv
import json
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
RELATORIO_JSON = (
ROOT
/ "pre_process"
/ "relatorio_ciclo_inspecoes_c105_2805_2026-06-15"
/ "relatorio_ciclo_inspecoes_c105_2805_2026-06-15_inspecoes.json"
)
OUTPUT_DIR = Path(__file__).resolve().parent
CSV_PATH = OUTPUT_DIR / "comparacao_ica_66_31_c105_2023_vs_relatorio_ciclo.csv"
MD_PATH = OUTPUT_DIR / "comparacao_ica_66_31_c105_2023_vs_relatorio_ciclo.md"
ICA_C105 = [
{"chave": "PRE VOO", "tipo_ica": "PRÉ VOO", "nivel_ica": "Base", "intervalo_horas_ica": "", "intervalo_meses_ica": "", "duracao_ica": "1 H", "hxh_ica": 1},
{"chave": "TRANSITO", "tipo_ica": "TRÂNSITO", "nivel_ica": "Base", "intervalo_horas_ica": "", "intervalo_meses_ica": "", "duracao_ica": "1 H", "hxh_ica": 1},
{"chave": "DIARIA", "tipo_ica": "DIÁRIA", "nivel_ica": "Base", "intervalo_horas_ica": "", "intervalo_meses_ica": "", "duracao_ica": "1 H", "hxh_ica": 1},
{"chave": "SERV 72H", "tipo_ica": "SERV 72H", "nivel_ica": "Base", "intervalo_horas_ica": 72, "intervalo_meses_ica": "", "duracao_ica": "4 H", "hxh_ica": 16},
{"chave": "OPERACIONAL 300FH", "tipo_ica": "OPERACIONAL 300FH", "nivel_ica": "Base", "intervalo_horas_ica": 300, "intervalo_meses_ica": "", "duracao_ica": "1 D", "hxh_ica": 14},
{"chave": "OPERACIONAL 400FH", "tipo_ica": "OPERACIONAL 400FH", "nivel_ica": "Base", "intervalo_horas_ica": 400, "intervalo_meses_ica": "", "duracao_ica": "1 D", "hxh_ica": 14},
{"chave": "OPERACIONAL 600FH", "tipo_ica": "OPERACIONAL 600FH", "nivel_ica": "Base", "intervalo_horas_ica": 600, "intervalo_meses_ica": "", "duracao_ica": "1 D", "hxh_ica": 14},
{"chave": "CHECK 1A", "tipo_ica": "CHECK 1A", "nivel_ica": "Base", "intervalo_horas_ica": 300, "intervalo_meses_ica": 8, "duracao_ica": "3 D", "hxh_ica": 16},
{"chave": "CHECK 2A", "tipo_ica": "CHECK 2A", "nivel_ica": "Base", "intervalo_horas_ica": 600, "intervalo_meses_ica": 16, "duracao_ica": "4 D", "hxh_ica": 28},
{"chave": "CHECK 3A", "tipo_ica": "CHECK 3A", "nivel_ica": "Base", "intervalo_horas_ica": 900, "intervalo_meses_ica": 24, "duracao_ica": "5 D", "hxh_ica": 76},
{"chave": "CHECK 1C", "tipo_ica": "CHECK 1C", "nivel_ica": "Base", "intervalo_horas_ica": 2400, "intervalo_meses_ica": 48, "duracao_ica": "65 D", "hxh_ica": 526},
{"chave": "CHECK 2C", "tipo_ica": "CHECK 2C", "nivel_ica": "Base", "intervalo_horas_ica": 4800, "intervalo_meses_ica": 96, "duracao_ica": "5 D", "hxh_ica": 17},
{"chave": "CALENDARICA 2Y", "tipo_ica": "CALENDÁRICA 2Y", "nivel_ica": "Base", "intervalo_horas_ica": "", "intervalo_meses_ica": 24, "duracao_ica": "5 D", "hxh_ica": 30},
{"chave": "CALENDARICA 4Y", "tipo_ica": "CALENDÁRICA 4Y", "nivel_ica": "Parque", "intervalo_horas_ica": "", "intervalo_meses_ica": 48, "duracao_ica": "10 D", "hxh_ica": 123},
{"chave": "CALENDARICA 8Y", "tipo_ica": "CALENDÁRICA 8Y", "nivel_ica": "Parque", "intervalo_horas_ica": "", "intervalo_meses_ica": 96, "duracao_ica": "20 D", "hxh_ica": 336},
]
RELATORIO_KEYS = {
"CHECK 1A": "CHECK 1A",
"CHECK 2A": "CHECK 2A",
"CHECK 3A": "CHECK 3A",
"CHECK 1C": "CHECK 1C",
"CHECK 2C": "CHECK 2C",
"CALENDÁRICA DE 2 YEARS": "CALENDARICA 2Y",
"CALENDÁRICA DE 4 YEARS": "CALENDARICA 4Y",
"CALENDÁRICA DE 8 YEARS": "CALENDARICA 8Y",
"OPERACIONAL DE 300FH": "OPERACIONAL 300FH",
"OPERACIONAL DE 400FH": "OPERACIONAL 400FH",
}
def key_from_relatorio(row):
descricao = row["descricao_mnt"].upper()
for needle, key in RELATORIO_KEYS.items():
if needle in descricao:
return key
return ""
def equal_or_blank(left, right):
return left == "" or right == "" or left == right
def compare_row(ica, rel):
if rel is None:
return "somente_ica", ["Não encontrada no relatório de ciclo"]
issues = []
if not equal_or_blank(ica["nivel_ica"], rel["nivel_descricao"]):
issues.append(f"nível ICA={ica['nivel_ica']} relatório={rel['nivel_descricao']}")
if not equal_or_blank(ica["intervalo_horas_ica"], rel["intervalo_horas_voo_valor"]):
issues.append(f"horas ICA={ica['intervalo_horas_ica']} relatório={rel['intervalo_horas_voo_valor']}")
if not equal_or_blank(ica["intervalo_meses_ica"], rel["intervalo_meses_continuos_valor"]):
issues.append(f"meses ICA={ica['intervalo_meses_ica']} relatório={rel['intervalo_meses_continuos_valor']}")
if not equal_or_blank(ica["duracao_ica"], rel["duracao"]):
issues.append(f"duração ICA={ica['duracao_ica']} relatório={rel['duracao']}")
return ("divergente" if issues else "compatível"), issues
def main():
payload = json.loads(RELATORIO_JSON.read_text(encoding="utf-8"))
relatorio_by_key = {}
relatorio_unmatched = []
for row in payload["inspecoes"]:
key = key_from_relatorio(row)
if key:
relatorio_by_key[key] = row
else:
relatorio_unmatched.append(row)
rows = []
for ica in ICA_C105:
rel = relatorio_by_key.get(ica["chave"])
status, issues = compare_row(ica, rel)
rows.append(
{
"chave": ica["chave"],
"status": status,
"tipo_ica": ica["tipo_ica"],
"tipo_relatorio": rel["descricao_mnt"] if rel else "",
"nivel_ica": ica["nivel_ica"],
"nivel_relatorio": rel["nivel_descricao"] if rel else "",
"intervalo_horas_ica": ica["intervalo_horas_ica"],
"intervalo_horas_relatorio": rel["intervalo_horas_voo_valor"] if rel else "",
"intervalo_meses_ica": ica["intervalo_meses_ica"],
"intervalo_meses_relatorio": rel["intervalo_meses_continuos_valor"] if rel else "",
"duracao_ica": ica["duracao_ica"],
"duracao_relatorio": rel["duracao"] if rel else "",
"hxh_ica": ica["hxh_ica"],
"seq_relatorio": rel["seq"] if rel else "",
"observacoes": "; ".join(issues),
}
)
for rel in relatorio_unmatched:
rows.append(
{
"chave": "",
"status": "somente_relatorio",
"tipo_ica": "",
"tipo_relatorio": rel["descricao_mnt"],
"nivel_ica": "",
"nivel_relatorio": rel["nivel_descricao"],
"intervalo_horas_ica": "",
"intervalo_horas_relatorio": rel["intervalo_horas_voo_valor"],
"intervalo_meses_ica": "",
"intervalo_meses_relatorio": rel["intervalo_meses_continuos_valor"],
"duracao_ica": "",
"duracao_relatorio": rel["duracao"],
"hxh_ica": "",
"seq_relatorio": rel["seq"],
"observacoes": "Não encontrada na tabela C/SC-105 da ICA 66-31/2023 usada nesta comparação",
}
)
fields = list(rows[0].keys())
with CSV_PATH.open("w", newline="", encoding="utf-8-sig") as csv_file:
writer = csv.DictWriter(csv_file, fieldnames=fields, delimiter=";")
writer.writeheader()
writer.writerows(rows)
counts = {status: sum(1 for row in rows if row["status"] == status) for status in sorted({row["status"] for row in rows})}
lines = [
"# Comparação ICA 66-31/2023 x Relatório de Ciclo C-105 2805",
"",
"Fonte ICA: `raw/ICA 66-31 2023.pdf`, Anexo D - Projeto C/SC-105.",
"",
"Observação: a comparação foi feita com o Anexo D textual do PDF completo da ICA.",
"",
"## Resumo",
"",
]
for status, count in counts.items():
lines.append(f"- `{status}`: {count}")
lines.extend(
[
"",
"## Principais Achados",
"",
"- `CHECK 1A`, `CHECK 2A`, `CHECK 3A`, `CALENDÁRICA 2Y`, `CALENDÁRICA 4Y` e `CALENDÁRICA 8Y` estão compatíveis nos intervalos e durações.",
"- `CHECK 1C` bate em intervalo e duração, mas diverge no nível: ICA indica Base na tabela D.2, enquanto o relatório de ciclo indica Parque.",
"- `CHECK 2C` diverge em nível e duração: ICA indica Base e 5 D; relatório de ciclo indica Parque e 10 D.",
"- `OPERACIONAL 300FH` e `OPERACIONAL 400FH` batem no intervalo de horas, mas divergem em nível e duração: ICA indica Base e 1 D; relatório de ciclo indica Orgânico e 6 H.",
"- `OPERACIONAL 600FH`, `PRÉ VOO`, `TRÂNSITO`, `DIÁRIA` e `SERV 72H` aparecem na ICA, mas não apareceram no relatório de ciclo processado.",
"- Inspeções como AOL, 50FH, 100FH, 1000FH, 2400FH e 3000FH aparecem no relatório de ciclo, mas não na tabela C/SC-105 da ICA usada na comparação.",
"",
"## Arquivo Detalhado",
"",
f"- `{CSV_PATH.relative_to(ROOT)}`",
"",
]
)
MD_PATH.write_text("\n".join(lines) + "\n", encoding="utf-8")
print(f"Gerado: {CSV_PATH.relative_to(ROOT)}")
print(f"Gerado: {MD_PATH.relative_to(ROOT)}")
print(counts)
if __name__ == "__main__":
main()

View File

@@ -1,267 +0,0 @@
import csv
import json
import re
from pathlib import Path
import pdfplumber
ROOT = Path(__file__).resolve().parents[2]
BASE_NAME = "relatorio_ciclo_inspecoes_c105_2805_2026-06-15"
OUTPUT_DIR = Path(__file__).resolve().parent
PDF_PATH = ROOT / "raw" / f"{BASE_NAME}.pdf"
TXT_PATH = OUTPUT_DIR / f"{BASE_NAME}_texto.txt"
JSON_PATH = OUTPUT_DIR / f"{BASE_NAME}_inspecoes.json"
CSV_PATH = OUTPUT_DIR / f"{BASE_NAME}_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 interval_number(intervalo):
if not intervalo:
return ""
first_token = intervalo.split()[0]
if ":" in first_token:
return int(first_token.split(":", 1)[0])
if first_token.isdigit():
return int(first_token)
return ""
def parse_control_fields(tokens):
nivel_descricao = {
"B": "Base",
"P": "Parque",
"O": "Orgânico",
}
fields = {
"zera_tso": "",
"letra": "",
"nivel": "",
"nivel_descricao": "",
"var_media": "",
"duracao": "",
"duracao_valor": "",
"duracao_unidade": "",
}
if tokens:
fields["zera_tso"] = tokens[0]
tokens = tokens[1:]
if len(tokens) >= 2 and tokens[-1] in {"D", "H"} and tokens[-2].isdigit():
duracao_valor = tokens[-2]
duracao_codigo = tokens[-1]
tokens = tokens[:-2]
fields["duracao"] = f"{duracao_valor} {duracao_codigo}"
fields["duracao_valor"] = int(duracao_valor)
fields["duracao_unidade"] = "dias" if duracao_codigo == "D" else "horas"
if len(tokens) == 1:
if tokens[0] in nivel_descricao:
fields["nivel"] = tokens[0]
else:
fields["letra"] = tokens[0]
elif len(tokens) >= 2:
if tokens[1] in nivel_descricao:
fields["letra"] = tokens[0]
fields["nivel"] = tokens[1]
fields["var_media"] = " ".join(tokens[2:])
elif tokens[0] in nivel_descricao:
fields["nivel"] = tokens[0]
fields["var_media"] = " ".join(tokens[1:])
else:
fields["letra"] = tokens[0]
fields["nivel"] = tokens[1]
fields["var_media"] = " ".join(tokens[2:])
fields["nivel_descricao"] = nivel_descricao.get(fields["nivel"], "")
return fields
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()
controle_tokens = tokens
controle = " ".join(controle_tokens)
control_fields = parse_control_fields(controle_tokens)
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_tso": control_fields["zera_tso"],
"letra": control_fields["letra"],
"nivel": control_fields["nivel"],
"nivel_descricao": control_fields["nivel_descricao"],
"var_media": control_fields["var_media"],
"duracao": control_fields["duracao"],
"duracao_valor": control_fields["duracao_valor"],
"duracao_unidade": control_fields["duracao_unidade"],
"controle_original": controle,
"intervalo_horas_voo": intervalo_horas_voo,
"intervalo_horas_voo_valor": interval_number(intervalo_horas_voo),
"intervalo_meses_continuos": intervalo_meses_continuos,
"intervalo_meses_continuos_valor": interval_number(intervalo_meses_continuos),
"intervalo_pousos": intervalo_pousos,
"intervalo_pousos_valor": interval_number(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_tso",
"letra",
"nivel",
"nivel_descricao",
"var_media",
"duracao",
"duracao_valor",
"duracao_unidade",
"controle_original",
"intervalo_horas_voo",
"intervalo_horas_voo_valor",
"intervalo_meses_continuos",
"intervalo_meses_continuos_valor",
"intervalo_pousos",
"intervalo_pousos_valor",
"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()

View File

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

View File

@@ -1,489 +0,0 @@
{
"fonte": "raw\\relatorio_ciclo_inspecoes_c105_2805_2026-06-15.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_tso": "N",
"letra": "A",
"nivel": "B",
"nivel_descricao": "Base",
"var_media": "3",
"duracao": "3 D",
"duracao_valor": 3,
"duracao_unidade": "dias",
"controle_original": "N A B 3 3 D",
"intervalo_horas_voo": "300:00 HORAS DE VOO",
"intervalo_horas_voo_valor": 300,
"intervalo_meses_continuos": "8 MESES CONTÍNUOS",
"intervalo_meses_continuos_valor": 8,
"intervalo_pousos": "",
"intervalo_pousos_valor": "",
"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_tso": "N",
"letra": "A",
"nivel": "B",
"nivel_descricao": "Base",
"var_media": "10",
"duracao": "4 D",
"duracao_valor": 4,
"duracao_unidade": "dias",
"controle_original": "N A B 10 4 D",
"intervalo_horas_voo": "600:00 HORAS DE VOO",
"intervalo_horas_voo_valor": 600,
"intervalo_meses_continuos": "16 MESES CONTÍNUOS",
"intervalo_meses_continuos_valor": 16,
"intervalo_pousos": "",
"intervalo_pousos_valor": "",
"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_tso": "N",
"letra": "A",
"nivel": "B",
"nivel_descricao": "Base",
"var_media": "10",
"duracao": "5 D",
"duracao_valor": 5,
"duracao_unidade": "dias",
"controle_original": "N A B 10 5 D",
"intervalo_horas_voo": "900:00 HORAS DE VOO",
"intervalo_horas_voo_valor": 900,
"intervalo_meses_continuos": "24 MESES CONTÍNUOS",
"intervalo_meses_continuos_valor": 24,
"intervalo_pousos": "",
"intervalo_pousos_valor": "",
"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_tso": "N",
"letra": "B",
"nivel": "B",
"nivel_descricao": "Base",
"var_media": "5",
"duracao": "5 D",
"duracao_valor": 5,
"duracao_unidade": "dias",
"controle_original": "N B B 5 5 D",
"intervalo_horas_voo": "",
"intervalo_horas_voo_valor": "",
"intervalo_meses_continuos": "24 MESES CONTÍNUOS",
"intervalo_meses_continuos_valor": 24,
"intervalo_pousos": "",
"intervalo_pousos_valor": "",
"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_tso": "N",
"letra": "A",
"nivel": "O",
"nivel_descricao": "Orgânico",
"var_media": "",
"duracao": "6 H",
"duracao_valor": 6,
"duracao_unidade": "horas",
"controle_original": "N A O 6 H",
"intervalo_horas_voo": "300:00 HORAS DE VOO",
"intervalo_horas_voo_valor": 300,
"intervalo_meses_continuos": "",
"intervalo_meses_continuos_valor": "",
"intervalo_pousos": "",
"intervalo_pousos_valor": "",
"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_tso": "N",
"letra": "A",
"nivel": "O",
"nivel_descricao": "Orgânico",
"var_media": "",
"duracao": "6 H",
"duracao_valor": 6,
"duracao_unidade": "horas",
"controle_original": "N A O 6 H",
"intervalo_horas_voo": "400:00 HORAS DE VOO",
"intervalo_horas_voo_valor": 400,
"intervalo_meses_continuos": "",
"intervalo_meses_continuos_valor": "",
"intervalo_pousos": "",
"intervalo_pousos_valor": "",
"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_tso": "N",
"letra": "",
"nivel": "B",
"nivel_descricao": "Base",
"var_media": "",
"duracao": "1 D",
"duracao_valor": 1,
"duracao_unidade": "dias",
"controle_original": "N B 1 D",
"intervalo_horas_voo": "",
"intervalo_horas_voo_valor": "",
"intervalo_meses_continuos": "",
"intervalo_meses_continuos_valor": "",
"intervalo_pousos": "3800 POUSOS",
"intervalo_pousos_valor": 3800,
"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_tso": "N",
"letra": "",
"nivel": "B",
"nivel_descricao": "Base",
"var_media": "",
"duracao": "1 D",
"duracao_valor": 1,
"duracao_unidade": "dias",
"controle_original": "N B 1 D",
"intervalo_horas_voo": "",
"intervalo_horas_voo_valor": "",
"intervalo_meses_continuos": "",
"intervalo_meses_continuos_valor": "",
"intervalo_pousos": "2000 POUSOS",
"intervalo_pousos_valor": 2000,
"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_tso": "N",
"letra": "",
"nivel": "B",
"nivel_descricao": "Base",
"var_media": "",
"duracao": "1 D",
"duracao_valor": 1,
"duracao_unidade": "dias",
"controle_original": "N B 1 D",
"intervalo_horas_voo": "",
"intervalo_horas_voo_valor": "",
"intervalo_meses_continuos": "",
"intervalo_meses_continuos_valor": "",
"intervalo_pousos": "2000 POUSOS",
"intervalo_pousos_valor": 2000,
"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_tso": "N",
"letra": "B",
"nivel": "P",
"nivel_descricao": "Parque",
"var_media": "15",
"duracao": "20 D",
"duracao_valor": 20,
"duracao_unidade": "dias",
"controle_original": "N B P 15 20 D",
"intervalo_horas_voo": "",
"intervalo_horas_voo_valor": "",
"intervalo_meses_continuos": "96 MESES CONTÍNUOS",
"intervalo_meses_continuos_valor": 96,
"intervalo_pousos": "",
"intervalo_pousos_valor": "",
"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_tso": "N",
"letra": "C",
"nivel": "P",
"nivel_descricao": "Parque",
"var_media": "10",
"duracao": "65 D",
"duracao_valor": 65,
"duracao_unidade": "dias",
"controle_original": "N C P 10 65 D",
"intervalo_horas_voo": "2400:00 HORAS DE VOO",
"intervalo_horas_voo_valor": 2400,
"intervalo_meses_continuos": "48 MESES CONTÍNUOS",
"intervalo_meses_continuos_valor": 48,
"intervalo_pousos": "",
"intervalo_pousos_valor": "",
"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_tso": "N",
"letra": "C",
"nivel": "P",
"nivel_descricao": "Parque",
"var_media": "60",
"duracao": "10 D",
"duracao_valor": 10,
"duracao_unidade": "dias",
"controle_original": "N C P 60 10 D",
"intervalo_horas_voo": "4800:00 HORAS DE VOO",
"intervalo_horas_voo_valor": 4800,
"intervalo_meses_continuos": "96 MESES CONTÍNUOS",
"intervalo_meses_continuos_valor": 96,
"intervalo_pousos": "",
"intervalo_pousos_valor": "",
"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_tso": "N",
"letra": "B",
"nivel": "P",
"nivel_descricao": "Parque",
"var_media": "10",
"duracao": "10 D",
"duracao_valor": 10,
"duracao_unidade": "dias",
"controle_original": "N B P 10 10 D",
"intervalo_horas_voo": "",
"intervalo_horas_voo_valor": "",
"intervalo_meses_continuos": "48 MESES CONTÍNUOS",
"intervalo_meses_continuos_valor": 48,
"intervalo_pousos": "",
"intervalo_pousos_valor": "",
"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_tso": "N",
"letra": "",
"nivel": "O",
"nivel_descricao": "Orgânico",
"var_media": "",
"duracao": "1 H",
"duracao_valor": 1,
"duracao_unidade": "horas",
"controle_original": "N O 1 H",
"intervalo_horas_voo": "100:00 HORAS DE VOO",
"intervalo_horas_voo_valor": 100,
"intervalo_meses_continuos": "",
"intervalo_meses_continuos_valor": "",
"intervalo_pousos": "",
"intervalo_pousos_valor": "",
"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_tso": "N",
"letra": "",
"nivel": "O",
"nivel_descricao": "Orgânico",
"var_media": "",
"duracao": "3 H",
"duracao_valor": 3,
"duracao_unidade": "horas",
"controle_original": "N O 3 H",
"intervalo_horas_voo": "2400:00 HORAS DE VOO",
"intervalo_horas_voo_valor": 2400,
"intervalo_meses_continuos": "",
"intervalo_meses_continuos_valor": "",
"intervalo_pousos": "",
"intervalo_pousos_valor": "",
"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_tso": "N",
"letra": "",
"nivel": "B",
"nivel_descricao": "Base",
"var_media": "",
"duracao": "1 D",
"duracao_valor": 1,
"duracao_unidade": "dias",
"controle_original": "N B 1 D",
"intervalo_horas_voo": "3000:00 HORAS DE VOO",
"intervalo_horas_voo_valor": 3000,
"intervalo_meses_continuos": "",
"intervalo_meses_continuos_valor": "",
"intervalo_pousos": "",
"intervalo_pousos_valor": "",
"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_tso": "N",
"letra": "",
"nivel": "B",
"nivel_descricao": "Base",
"var_media": "",
"duracao": "1 D",
"duracao_valor": 1,
"duracao_unidade": "dias",
"controle_original": "N B 1 D",
"intervalo_horas_voo": "1000:00 HORAS DE VOO",
"intervalo_horas_voo_valor": 1000,
"intervalo_meses_continuos": "",
"intervalo_meses_continuos_valor": "",
"intervalo_pousos": "",
"intervalo_pousos_valor": "",
"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_tso": "N",
"letra": "",
"nivel": "B",
"nivel_descricao": "Base",
"var_media": "",
"duracao": "1 D",
"duracao_valor": 1,
"duracao_unidade": "dias",
"controle_original": "N B 1 D",
"intervalo_horas_voo": "50:00 HORAS DE VOO",
"intervalo_horas_voo_valor": 50,
"intervalo_meses_continuos": "",
"intervalo_meses_continuos_valor": "",
"intervalo_pousos": "",
"intervalo_pousos_valor": "",
"intervalos": [
"50:00 HORAS DE VOO"
],
"linha_original": "23 INSP 50FH INSPEÇÃO 50 FH Especial (a contar dela mesma Término da Anterior N B 1 D 50:00 HORAS DE VÔO - Tipo D)"
}
]
}

View File

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

5
raw/AERONAVES.csv Normal file
View File

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

31
raw/AIRPORTS.csv Normal file
View File

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

5
raw/CHECKS.csv Normal file
View File

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

Binary file not shown.

View File

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

Binary file not shown.

View File

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

11
requirements.txt Normal file
View File

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

37
run_pipeline.bat Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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