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:
22
.gitignore
vendored
22
.gitignore
vendored
@@ -1,3 +1,16 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
|
||||
# Virtual environment
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
|
||||
# Windows
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
@@ -9,3 +22,12 @@ Desktop.ini
|
||||
# Local editor folders
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Generated outputs and processed data
|
||||
outputs/
|
||||
data/
|
||||
|
||||
# Large binary / proprietary files
|
||||
raw/*.pdf
|
||||
raw/*.xlsx
|
||||
raw/*.xls
|
||||
|
||||
103
CHANGELOG.md
Normal file
103
CHANGELOG.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# Changelog
|
||||
|
||||
Todas as alterações relevantes do projeto são registradas neste arquivo.
|
||||
Formato baseado em [Keep a Changelog](https://keepachangelog.com/pt-BR/1.0.0/).
|
||||
|
||||
---
|
||||
|
||||
## [Não lançado]
|
||||
|
||||
### Adicionado
|
||||
- `README.md` com documentação do projeto, estrutura de pastas, instruções de instalação e conceitos-chave
|
||||
- `CONTEXTO.md` com contexto operacional, motivação e decisões de projeto
|
||||
- `CHANGELOG.md` (este arquivo)
|
||||
|
||||
---
|
||||
|
||||
## [0.5.0] — 2026-06-17
|
||||
|
||||
### Adicionado
|
||||
- **Mapa interativo de rotas** (aba `🗺 Mapa`): rotas por aeronave exibidas com `folium` + `streamlit-folium`; controle de camadas para alternar entre **CartoDB positron** e **satélite Esri World Imagery**
|
||||
- **Destaque de bases de manutenção no mapa**: marcador laranja (raio duplo) com tooltip `🔧 Base de manutenção` nos aeroportos com `IS_MAINTENANCE_BASE = 1`
|
||||
- `folium>=0.14.0` e `streamlit-folium>=0.22.0` adicionados ao `requirements.txt`
|
||||
|
||||
### Corrigido
|
||||
- **TTM disponível zerava após check**: a lógica anterior usava apenas o primeiro evento de manutenção por aeronave e subtraía todo `fh_apos` do TTM do segundo ciclo. Agora todos os eventos são acumulados (`all_maint`) e `fh_after_last = total_fh − Σ(accum_fh_at_check)` calcula corretamente as FH voadas após a **última** manutenção
|
||||
- Linha "Média" na tabela Frota não tinha mais cor de fundo (removido `background-color:#e8f4f8`); mantém apenas negrito
|
||||
|
||||
---
|
||||
|
||||
## [0.4.0] — 2026-06-16
|
||||
|
||||
### Adicionado
|
||||
- **Aba `🗺 Mapa`** no Output: representação geográfica das rotas por aeronave usando `plotly.graph_objects.Scattergeo` (substituído na v0.5.0)
|
||||
- Biblioteca `airportsdata` para lookup de coordenadas por código ICAO, sem necessidade de API key
|
||||
- `airportsdata>=20240101` adicionado ao `requirements.txt`
|
||||
- Coordenadas de DEP/ARR lidas diretamente da `Escala com Atribuição` (campo compartilhado `raw_sched_atrib`)
|
||||
|
||||
### Alterado
|
||||
- Gráfico de FH por aeronave e métricas de horas disponíveis transferidos da aba `Escala com Atribuição` para a aba `Manutenção e Aeronaves` (renomeada)
|
||||
- Cor da coluna "FH de TTM perdidas por aeronave / ciclo" alterada para vermelho
|
||||
|
||||
---
|
||||
|
||||
## [0.3.0] — 2026-06-15
|
||||
|
||||
### Adicionado
|
||||
- **Gráfico de FH por aeronave** na aba `Manutenção e Aeronaves` com quatro segmentos empilhados:
|
||||
- FH Iniciais (cinza `#94a3b8`)
|
||||
- FH Planejadas antes da manutenção (azul `#3b82f6`)
|
||||
- FH Planejadas após a manutenção (âmbar `#f59e0b`)
|
||||
- FH Disponíveis até a próxima manutenção (verde `#22c55e`, opacidade 0.55)
|
||||
- Marcador diamante vermelho indicando o evento de check
|
||||
- **Métricas de horas disponíveis** por aeronave (metric cards) abaixo do gráfico
|
||||
- Campo para **adicionar N linhas de uma vez** na Escala de Voo (Input), com botão "Limpar tudo"
|
||||
- Exclusão de linhas habilitada na tabela de Escala de Voo (`num_rows="dynamic"`)
|
||||
|
||||
### Alterado
|
||||
- Abas `Resumo` e `Gantt` unificadas em `📊 Resumo`
|
||||
- Altura do gráfico de FH dobrada (800 px)
|
||||
|
||||
### Corrigido
|
||||
- Variável `_c4` renomeada para `_` (sem uso)
|
||||
- `tat` acessado via `st.session_state.get("tat", 60)` para evitar erro quando o botão de execução é pressionado antes de o sub-tab Aeronaves ser visitado
|
||||
|
||||
---
|
||||
|
||||
## [0.2.0] — 2026-06-14
|
||||
|
||||
### Adicionado
|
||||
- **Aba `🔧 Manutenção`**: tabela com eventos de manutenção (aeronave, OFRAG anterior, threshold, FH acumuladas, perda de TTM, início/fim calendário) e gráficos de FH perdidas e FH verificadas
|
||||
- **Aba `✈ Frota`**: tabela de utilização com coluna `Média` e gráficos de utilização TTM e FH por aeronave
|
||||
- Campo **Tempo do Solver (s)** e botão **Executar Otimização** movidos para a aba Input (acima das sub-abas)
|
||||
- Mensagem de conclusão com **tempo decorrido** da otimização (`⏱ Otimização concluída em Xm Ys.`)
|
||||
- Cor de fundo das células da Escala com Atribuição alterada para transparente
|
||||
|
||||
### Removido
|
||||
- Aba `Parâmetros` do sidebar (TAT mínimo movido para sub-aba Aeronaves; ano de planejamento removido — inferido de `pd.Timestamp.now().year`)
|
||||
|
||||
### Alterado
|
||||
- Campo **TAT mínimo** movido para a sub-aba `Aeronaves` do Input
|
||||
- Largura do campo Tempo do Solver reduzida com `st.columns([1, 1, 4])`
|
||||
|
||||
---
|
||||
|
||||
## [0.1.0] — 2026-06-13
|
||||
|
||||
### Adicionado
|
||||
- **Reescrita completa do dashboard** (`app/dashboard.py`) com estrutura em duas abas principais: `📥 Input` e `📤 Output`
|
||||
- Sub-abas de Input: `Escala de Voo`, `Aeronaves`, `Checks`, `Bases de Manutenção` com `st.data_editor` editável
|
||||
- Suporte a **DataFrames em memória** no pipeline: `load_from_dfs()` em `ingest.py` aceita dados do editor sem gravar em disco
|
||||
- Parâmetro `raw_dfs` em `RoutingPipeline.run()` para passar dados in-memory
|
||||
- Parsing de datas no formato `DD/MM/AAAA` em `_parse_br_date()` (além do legado `DD/mon`)
|
||||
- Backup do dashboard original em `app/dashboard_backup.py`
|
||||
|
||||
### Alterado
|
||||
- `src/routing_engine/pipeline.py`: `run()` recebe `raw_dfs` opcional; chama `load_from_dfs` se fornecido
|
||||
- `src/routing_engine/ingest.py`: adicionada função `load_from_dfs()` com normalização de sinônimos de colunas
|
||||
|
||||
---
|
||||
|
||||
## [0.0.x] — antes de 2026-06-13
|
||||
|
||||
Commits iniciais do repositório: estrutura de pastas, configuração de CI, scripts de inspeção de arquivos, engine de otimização (config, ingest, network_generator, optimizer, maintenance_monitor, metrics, pipeline, quality).
|
||||
90
CONTEXTO.md
90
CONTEXTO.md
@@ -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.
|
||||
- `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.
|
||||
## Contexto operacional
|
||||
|
||||
## Arquivos de Documentação
|
||||
### Aeronaves
|
||||
|
||||
- `README.md`: guia inicial para colaboradores e uso básico do repositório.
|
||||
- `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.
|
||||
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.
|
||||
|
||||
## Colaboradores
|
||||
### Checks de manutenção
|
||||
|
||||
- `VTO`: Vitor Cesa.
|
||||
- `GNR`: Generoso.
|
||||
- `JOM`: João Marcos.
|
||||
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).
|
||||
|
||||
## Orientações de Trabalho
|
||||
### OFRAGs
|
||||
|
||||
- Manter documentos originais sempre em `raw/`.
|
||||
- Não alterar arquivos dentro de `raw/`; quando necessário, gerar cópias ou saídas em `pre_process/`.
|
||||
- Registrar no `LOG.md` toda entrada, alteração, processamento ou decisão relevante.
|
||||
- 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.
|
||||
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.
|
||||
|
||||
## Formato Recomendado do Log
|
||||
### Escala de voo
|
||||
|
||||
```text
|
||||
| Data | Hora | Autor | Ação | Arquivos | Observações |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 2026-06-15 | 15:41 | VTO | Pré-processou PDF de inspeções | raw/documento.pdf; pre_process/saida.csv | 18 inspeções extraídas |
|
||||
```
|
||||
A escala é fornecida no formato da planilha operacional da unidade, com etapas identificadas por data, aeroportos de partida/chegada, horários e número de OFRAG. O sistema aceita datas no formato `DD/MM/AAAA` ou no formato legado `DD/mon` (ex: `15/jan`).
|
||||
|
||||
## Problema de otimização
|
||||
|
||||
O problema é modelado como **Set Partitioning** sobre o conjunto de OFRAGs:
|
||||
|
||||
- Cada OFRAG deve ser coberto por exatamente uma aeronave
|
||||
- Cada aeronave executa no máximo uma rota (sequência de OFRAGs)
|
||||
- A rota deve ser TTM-viável: a qualquer ponto, as FH acumuladas desde o último check não excedem o TTM do ciclo atual
|
||||
- A função objetivo minimiza a **perda total de TTM** (horas de TTM não utilizadas quando a manutenção é antecipada)
|
||||
|
||||
A solução é obtida por **Column Generation** (geração de colunas) combinada com **Branch & Bound** (PuLP/CBC), permitindo explorar um espaço de rotas potencialmente exponencial de forma eficiente.
|
||||
|
||||
## Decisões de projeto
|
||||
|
||||
| Decisão | Justificativa |
|
||||
|---------|---------------|
|
||||
| Set Partitioning (ao invés de Set Covering) | Garante que cada missão seja atribuída a exatamente uma aeronave, sem ambiguidade |
|
||||
| Column Generation | O número de rotas viáveis é exponencial; CG constrói apenas colunas lucrativas |
|
||||
| Pricing por DP (label-setting) | Subproblema é um shortest-path com restrições de recursos (TTM), adequado para DP em DAG |
|
||||
| TAT mínimo (padrão 60 min) | Garante tempo mínimo de preparação entre OFRAGs consecutivos na mesma aeronave |
|
||||
| Penalidade Big-M por OFRAG descoberto | Permite solução mesmo quando a cobertura total é inviável (ex: mais OFRAGs do que aeronaves disponíveis) |
|
||||
|
||||
## Limitações conhecidas
|
||||
|
||||
- O modelo considera apenas um evento de manutenção por ciclo de check; múltiplos checks consecutivos são tratados como ciclos independentes
|
||||
- A duração da manutenção é fixa por tipo de check; variações logísticas (falta de peças, disponibilidade de hangar) não são modeladas
|
||||
- OFRAGs que não partem e chegam à base de manutenção são excluídos do problema (não podem ser alocados)
|
||||
- O solver CBC tem desempenho limitado para instâncias muito grandes; o parâmetro `mip_time_limit_seconds` controla o tempo máximo
|
||||
|
||||
## Arquivos de referência
|
||||
|
||||
| Arquivo | Conteúdo |
|
||||
|---------|----------|
|
||||
| `raw/AERONAVES.csv` | Frota com FH acumuladas |
|
||||
| `raw/CHECKS.csv` | Thresholds e durações dos checks |
|
||||
| `raw/AIRPORTS.csv` | Aeroportos e bases de manutenção |
|
||||
| `raw/ESCALA DE VOO MODELO 1.csv` | Escala de missões |
|
||||
| `raw/ICA 66-31 2023.pdf` | Instrução regulatória de aeronavegabilidade (referência) |
|
||||
| `raw/Airline Operations and Scheduling*.pdf` | Referência bibliográfica principal (Bazargan, 2010) |
|
||||
|
||||
23
LOG.md
23
LOG.md
@@ -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
229
README.md
@@ -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 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.
|
||||
O subproblema de precificação (pricing) é resolvido por **programação dinâmica (label-setting DP)** sobre o DAG de OFRAGs ordenados por horário de partida.
|
||||
|
||||
## Estrutura do projeto
|
||||
|
||||
```text
|
||||
```
|
||||
arara_oarmp/
|
||||
raw/
|
||||
pre_process/
|
||||
processed/
|
||||
docs/
|
||||
CONTEXTO.md
|
||||
LOG.md
|
||||
README.md
|
||||
├── app/
|
||||
│ └── dashboard.py # Interface Streamlit (entrada de dados + resultados)
|
||||
├── raw/ # Dados de entrada (CSV)
|
||||
│ ├── AERONAVES.csv
|
||||
│ ├── CHECKS.csv
|
||||
│ ├── AIRPORTS.csv
|
||||
│ └── ESCALA DE VOO MODELO 1.csv
|
||||
├── src/
|
||||
│ └── routing_engine/
|
||||
│ ├── config.py # Parâmetros e caminhos
|
||||
│ ├── ingest.py # Leitura e normalização dos dados
|
||||
│ ├── inspect_files.py # Detecção automática de colunas (sinônimos)
|
||||
│ ├── network_generator.py # Grafo de adjacência entre OFRAGs
|
||||
│ ├── optimizer.py # CG + B&B (PuLP/CBC)
|
||||
│ ├── maintenance_monitor.py # Validação TTM e eventos de manutenção
|
||||
│ ├── metrics.py # Tabelas e sumários de saída
|
||||
│ ├── pipeline.py # Orquestração end-to-end
|
||||
│ └── quality.py # Verificações de qualidade dos dados
|
||||
├── scripts/ # Scripts auxiliares de inspeção e execução
|
||||
├── outputs/ # Resultados gerados pelo pipeline
|
||||
│ ├── schedules/
|
||||
│ └── exports/
|
||||
├── requirements.txt
|
||||
└── run_pipeline.bat
|
||||
```
|
||||
|
||||
- `raw/`: documentos originais. Não editar esses arquivos diretamente.
|
||||
- `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.
|
||||
## Instalação
|
||||
|
||||
## 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:
|
||||
|
||||
- `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
|
||||
# Instalar dependências
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
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
|
||||
git clone https://git.ppgao.ita.br/vitorcesavc/arara_oarmp.git
|
||||
cd arara_oarmp
|
||||
## Execução
|
||||
|
||||
### 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
|
||||
git pull
|
||||
```bash
|
||||
python scripts/03_run_optimization_pipeline.py
|
||||
# ou
|
||||
run_pipeline.bat
|
||||
```
|
||||
|
||||
Depois de alterar arquivos, veja o que mudou:
|
||||
## Dados de entrada
|
||||
|
||||
```powershell
|
||||
git status
|
||||
```
|
||||
Todos os arquivos ficam em `raw/` com separador `;`:
|
||||
|
||||
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
|
||||
git add .
|
||||
```
|
||||
### CHECKS.csv
|
||||
| Campo | Descrição |
|
||||
|-------|-----------|
|
||||
| CHECKS | Nome do check |
|
||||
| FH | Threshold cumulativo de FH para o check |
|
||||
| TEMPO DE EXECUCAO (DIAS) | Duração do check em dias |
|
||||
| LOCAL DE EXECUCAO | Código ICAO da base de manutenção |
|
||||
|
||||
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
|
||||
git commit -m "Descreva a alteração feita"
|
||||
```
|
||||
### ESCALA DE VOO MODELO 1.csv
|
||||
Escala de missões com colunas: `DATA`, `ETAPA`, `DEP`, `ARR`, `HORA_DEP`, `HORA_ARR`, `TEMPO_VOO`, `SEGMTO`, `MISSAO`, `OFRAG`.
|
||||
Formato de data aceito: `DD/MM/AAAA` ou `DD/mon` (ex: `15/jan`).
|
||||
|
||||
Envie para o Gitea:
|
||||
## Dashboard — abas
|
||||
|
||||
```powershell
|
||||
git push
|
||||
```
|
||||
### Entrada (📥 Input)
|
||||
- **Escala de Voo**: tabela editável com adição/remoção de linhas
|
||||
- **Aeronaves**: frota com FH totais e TAT mínimo
|
||||
- **Checks**: thresholds e durações de manutenção
|
||||
- **Bases de Manutenção**: aeroportos habilitados para check
|
||||
|
||||
## 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.
|
||||
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`.
|
||||
## Conceitos-chave
|
||||
|
||||
## 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/`.
|
||||
- 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.
|
||||
**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.
|
||||
|
||||
## 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`.
|
||||
|
||||
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.
|
||||
**TAT (Turnaround Time)** — Tempo mínimo entre o pouso de um OFRAG e a decolagem do seguinte na mesma aeronave.
|
||||
|
||||
755
app/dashboard.py
Normal file
755
app/dashboard.py
Normal file
@@ -0,0 +1,755 @@
|
||||
"""
|
||||
Aircraft Routing Dashboard – Streamlit application (v2).
|
||||
|
||||
Run with: streamlit run app/dashboard.py (from project root)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
import pandas as pd
|
||||
import plotly.express as px
|
||||
import plotly.graph_objects as go
|
||||
import streamlit as st
|
||||
|
||||
from src.routing_engine import DEFAULT_CONFIG, RoutingPipeline
|
||||
|
||||
# ── Page config ───────────────────────────────────────────────────────────────
|
||||
st.set_page_config(
|
||||
page_title="OARMP – Aircraft Routing",
|
||||
page_icon="✈",
|
||||
layout="wide",
|
||||
)
|
||||
st.title("✈ Aircraft Routing & Maintenance Planning (OARMP)")
|
||||
st.caption("Set Partitioning + Column Generation + Branch & Bound")
|
||||
|
||||
# ── Constants ─────────────────────────────────────────────────────────────────
|
||||
_RAW = Path(__file__).resolve().parents[1] / "raw"
|
||||
_SCHED_COLS = ["DATA", "ETAPA", "DEP", "ARR", "HORA_DEP", "HORA_ARR", "TEMPO_VOO", "SEGMTO", "MISSAO", "OFRAG"]
|
||||
|
||||
|
||||
def _read_csv(filename: str, skiprows: int = 0, names=None) -> pd.DataFrame:
|
||||
path = _RAW / filename
|
||||
if not path.exists():
|
||||
return pd.DataFrame(columns=names or [])
|
||||
for enc in ("utf-8-sig", "utf-8", "latin-1", "cp1252"):
|
||||
try:
|
||||
return pd.read_csv(path, sep=";", encoding=enc, skiprows=skiprows, names=names, dtype=str)
|
||||
except Exception:
|
||||
continue
|
||||
return pd.DataFrame(columns=names or [])
|
||||
|
||||
|
||||
# ── Session-state defaults ────────────────────────────────────────────────────
|
||||
def _init_state():
|
||||
if "df_aircraft" not in st.session_state:
|
||||
st.session_state["df_aircraft"] = _read_csv("AERONAVES.csv")
|
||||
|
||||
if "df_checks" not in st.session_state:
|
||||
st.session_state["df_checks"] = _read_csv("CHECKS.csv")
|
||||
|
||||
if "df_airports" not in st.session_state:
|
||||
st.session_state["df_airports"] = _read_csv("AIRPORTS.csv")
|
||||
|
||||
if "df_schedule" not in st.session_state:
|
||||
_BR_MON = {"jan":"01","fev":"02","mar":"03","abr":"04","mai":"05","jun":"06",
|
||||
"jul":"07","ago":"08","set":"09","out":"10","nov":"11","dez":"12"}
|
||||
_DEFAULT_YEAR = 2026
|
||||
|
||||
def _to_dmy(val: str) -> str:
|
||||
parts = str(val).strip().split("/")
|
||||
if len(parts) == 3:
|
||||
return val # already DD/MM/AAAA
|
||||
if len(parts) == 2:
|
||||
mon = _BR_MON.get(parts[1].lower())
|
||||
if mon:
|
||||
return f"{int(parts[0]):02d}/{mon}/{_DEFAULT_YEAR}"
|
||||
return val
|
||||
|
||||
df = _read_csv("ESCALA DE VOO MODELO 1.csv", skiprows=2, names=_SCHED_COLS)
|
||||
df = df[df["DATA"].notna() & (df["DATA"].str.strip() != "")]
|
||||
df = df[df["OFRAG"].notna() & (df["OFRAG"].str.strip() != "")]
|
||||
df["DATA"] = df["DATA"].apply(_to_dmy)
|
||||
st.session_state["df_schedule"] = df.reset_index(drop=True)
|
||||
|
||||
if "result" not in st.session_state:
|
||||
st.session_state["result"] = None
|
||||
|
||||
|
||||
_init_state()
|
||||
|
||||
# ── Main tabs ─────────────────────────────────────────────────────────────────
|
||||
tab_input, tab_output = st.tabs(["📥 Input", "📤 Output"])
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# INPUT TAB
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
with tab_input:
|
||||
_col_solver, _col_btn, _ = st.columns([1, 1, 4])
|
||||
with _col_solver:
|
||||
time_limit = st.number_input(
|
||||
"Tempo solver (s)",
|
||||
min_value=30, max_value=600, value=st.session_state.get("time_limit", 120),
|
||||
step=10, key="time_limit",
|
||||
)
|
||||
with _col_btn:
|
||||
st.write("") # alinhamento vertical
|
||||
run_btn = st.button("▶ Executar Otimização", type="primary", use_container_width=True)
|
||||
|
||||
st.divider()
|
||||
|
||||
sub_sched, sub_aircraft, sub_checks, sub_bases = st.tabs(
|
||||
["📋 Escala de Voo", "✈ Aeronaves", "🔧 Checks", "🏭 Bases de Manutenção"]
|
||||
)
|
||||
|
||||
with sub_aircraft:
|
||||
st.subheader("Aeronaves")
|
||||
st.caption("Matrícula, modelo e horas de voo totais acumuladas de cada aeronave.")
|
||||
tat = st.number_input(
|
||||
"TAT mínimo (min)",
|
||||
min_value=0, max_value=240,
|
||||
value=st.session_state.get("tat", 60),
|
||||
step=10,
|
||||
key="tat",
|
||||
)
|
||||
edited = st.data_editor(
|
||||
st.session_state["df_aircraft"],
|
||||
num_rows="dynamic",
|
||||
use_container_width=True,
|
||||
key="editor_aircraft",
|
||||
column_config={
|
||||
"MATRICULA": st.column_config.TextColumn("Matrícula"),
|
||||
"MODELO": st.column_config.TextColumn("Modelo"),
|
||||
"FH TOTAIS": st.column_config.NumberColumn("FH Totais", min_value=0, format="%.0f"),
|
||||
},
|
||||
)
|
||||
st.session_state["df_aircraft"] = edited
|
||||
|
||||
with sub_checks:
|
||||
st.subheader("Checks de Manutenção")
|
||||
st.caption("Tipos de check, limiar de FH, duração em dias e local de execução.")
|
||||
edited = st.data_editor(
|
||||
st.session_state["df_checks"],
|
||||
num_rows="dynamic",
|
||||
use_container_width=True,
|
||||
key="editor_checks",
|
||||
column_config={
|
||||
"CHECKS": st.column_config.TextColumn("Denominação do Check"),
|
||||
"FH": st.column_config.NumberColumn("FH Limite", min_value=0, format="%.0f"),
|
||||
"TEMPO DE EXECUCAO (DIAS)": st.column_config.NumberColumn("Duração (dias)", min_value=0),
|
||||
"LOCAL DE EXECUCAO": st.column_config.TextColumn("Local (ICAO)"),
|
||||
},
|
||||
)
|
||||
st.session_state["df_checks"] = edited
|
||||
|
||||
with sub_bases:
|
||||
st.subheader("Aeroportos e Bases de Manutenção")
|
||||
st.caption(
|
||||
"Lista de aeroportos utilizados. Defina IS_MAINTENANCE_BASE = 1 "
|
||||
"para os aeroportos que são base de manutenção."
|
||||
)
|
||||
edited = st.data_editor(
|
||||
st.session_state["df_airports"],
|
||||
num_rows="dynamic",
|
||||
use_container_width=True,
|
||||
key="editor_airports",
|
||||
column_config={
|
||||
"AIRPORT_CODE": st.column_config.TextColumn("Código ICAO"),
|
||||
"AIRPORT_NAME": st.column_config.TextColumn("Nome"),
|
||||
"LATITUDE": st.column_config.NumberColumn("Latitude", format="%.4f"),
|
||||
"LONGITUDE": st.column_config.NumberColumn("Longitude", format="%.4f"),
|
||||
"IS_MAINTENANCE_BASE": st.column_config.SelectboxColumn(
|
||||
"Base de Manutenção?", options=["0", "1"]
|
||||
),
|
||||
"COUNTRY": st.column_config.TextColumn("País"),
|
||||
},
|
||||
)
|
||||
st.session_state["df_airports"] = edited
|
||||
|
||||
with sub_sched:
|
||||
st.subheader("Escala de Voo")
|
||||
st.caption(
|
||||
"Etapas agrupadas em OFRAGs. "
|
||||
"Cada OFRAG deve iniciar e terminar na base de manutenção para ser elegível."
|
||||
)
|
||||
|
||||
edited = st.data_editor(
|
||||
st.session_state["df_schedule"],
|
||||
num_rows="dynamic",
|
||||
use_container_width=True,
|
||||
key="editor_schedule",
|
||||
column_config={
|
||||
"DATA": st.column_config.TextColumn("Data (DD/MM/AAAA)"),
|
||||
"ETAPA": st.column_config.NumberColumn("Etapa", min_value=1),
|
||||
"DEP": st.column_config.TextColumn("Partida (ICAO)"),
|
||||
"ARR": st.column_config.TextColumn("Destino (ICAO)"),
|
||||
"HORA_DEP": st.column_config.TextColumn("Hora Dep (Z)"),
|
||||
"HORA_ARR": st.column_config.TextColumn("Hora Arr (Z)"),
|
||||
"TEMPO_VOO": st.column_config.TextColumn("Tempo Voo"),
|
||||
"SEGMTO": st.column_config.NumberColumn("Segmento"),
|
||||
"MISSAO": st.column_config.TextColumn("Missão"),
|
||||
"OFRAG": st.column_config.NumberColumn("OFRAG", min_value=1),
|
||||
},
|
||||
)
|
||||
st.session_state["df_schedule"] = edited
|
||||
|
||||
# Controles de adição em lote e limpeza
|
||||
_c1, _c2, _c3, _ = st.columns([1, 1, 1, 3])
|
||||
with _c1:
|
||||
n_add = st.number_input(
|
||||
"Qtd. de linhas", min_value=1, max_value=100, value=1, step=1,
|
||||
key="n_add_sched",
|
||||
)
|
||||
with _c2:
|
||||
st.write("")
|
||||
if st.button("➕ Adicionar", key="btn_add_sched", use_container_width=True):
|
||||
empty = pd.DataFrame([{col: None for col in _SCHED_COLS}] * int(n_add))
|
||||
st.session_state["df_schedule"] = pd.concat(
|
||||
[st.session_state["df_schedule"], empty], ignore_index=True
|
||||
)
|
||||
st.rerun()
|
||||
with _c3:
|
||||
st.write("")
|
||||
if st.button("🗑 Limpar tudo", key="btn_clear_sched", use_container_width=True):
|
||||
st.session_state["df_schedule"] = pd.DataFrame(columns=_SCHED_COLS)
|
||||
st.rerun()
|
||||
|
||||
# ── Run pipeline (after input tabs so session state is current) ───────────────
|
||||
if run_btn:
|
||||
cfg = DEFAULT_CONFIG
|
||||
cfg.tat_minutes = int(st.session_state.get("tat", 60))
|
||||
cfg.planning_year = pd.Timestamp.now().year
|
||||
cfg.mip_time_limit_seconds = int(time_limit)
|
||||
|
||||
raw_dfs = {
|
||||
"aircraft": st.session_state["df_aircraft"],
|
||||
"checks": st.session_state["df_checks"],
|
||||
"airports": st.session_state["df_airports"],
|
||||
"schedule": st.session_state["df_schedule"],
|
||||
}
|
||||
|
||||
with st.spinner("Executando otimização…"):
|
||||
try:
|
||||
import time as _time
|
||||
_t0 = _time.perf_counter()
|
||||
pipe = RoutingPipeline(cfg)
|
||||
st.session_state["result"] = pipe.run(save_outputs=True, raw_dfs=raw_dfs)
|
||||
_elapsed = _time.perf_counter() - _t0
|
||||
_mins, _secs = divmod(int(_elapsed), 60)
|
||||
_dur = f"{_mins}m {_secs}s" if _mins else f"{_secs}s"
|
||||
st.success(f"Otimização concluída em {_dur}.")
|
||||
except Exception as exc:
|
||||
st.error(f"Erro: {exc}")
|
||||
st.exception(exc)
|
||||
|
||||
result = st.session_state.get("result")
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
# OUTPUT TAB
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
with tab_output:
|
||||
if result is None:
|
||||
st.info("Configure o cenário na aba Input e execute a otimização.")
|
||||
else:
|
||||
(
|
||||
sub_resumo,
|
||||
sub_escala,
|
||||
sub_maint,
|
||||
sub_frota,
|
||||
sub_mapa,
|
||||
) = st.tabs(
|
||||
[
|
||||
"📊 Resumo",
|
||||
"📋 Escala com Atribuição",
|
||||
"🔧 Manutenção e Aeronaves",
|
||||
"✈ Frota",
|
||||
"🗺 Mapa",
|
||||
]
|
||||
)
|
||||
|
||||
# ── Escala com atribuição (construída uma vez, reutilizada em sub_escala e sub_mapa) ──
|
||||
_sched_result = result["schedule"]
|
||||
_raw = st.session_state["df_schedule"].copy()
|
||||
_ofrag_map: dict[int, str] = {}
|
||||
for _, _r in _sched_result.iterrows():
|
||||
try:
|
||||
_ofrag_map[int(_r["ofrag_id"].replace("OFRAG", ""))] = _r["aircraft"]
|
||||
except Exception:
|
||||
pass
|
||||
_raw.insert(
|
||||
0, "AERONAVE",
|
||||
_raw["OFRAG"].apply(
|
||||
lambda x: _ofrag_map.get(int(str(x).strip()), "—")
|
||||
if str(x).strip().isdigit() else "—"
|
||||
),
|
||||
)
|
||||
raw_sched_atrib = _raw # DataFrame completo com coluna AERONAVE
|
||||
|
||||
# ── Resumo + Gantt ────────────────────────────────────────────────────
|
||||
with sub_resumo:
|
||||
s = result["summary"]
|
||||
c1, c2, c3, c4 = st.columns(4)
|
||||
c1.metric("Status", s["status"])
|
||||
c2.metric("FH Perdidas (objetivo)", f"{s['total_ttm_loss_hours']:.2f} h")
|
||||
c3.metric("OFRAGs cobertas", f"{s['covered_ofrags']} / {s['total_ofrags']}")
|
||||
c4.metric("Eventos de manutenção", s["n_maintenance_events"])
|
||||
|
||||
if s["uncovered_ofrags"]:
|
||||
st.warning(f"OFRAGs NÃO cobertas: {s['uncovered_ofrags']}")
|
||||
|
||||
qc = result.get("quality", {})
|
||||
if qc.get("issues"):
|
||||
with st.expander("Avisos de qualidade de dados"):
|
||||
for issue in qc["issues"]:
|
||||
st.warning(issue)
|
||||
|
||||
st.caption(f"Colunas geradas no CG: {s['columns_generated']}")
|
||||
|
||||
st.divider()
|
||||
|
||||
sched = result["schedule"]
|
||||
maint = result["maintenance"]
|
||||
gantt_rows = []
|
||||
|
||||
for _, row in sched.iterrows():
|
||||
if pd.isna(row.get("departure")) or pd.isna(row.get("arrival")):
|
||||
continue
|
||||
gantt_rows.append(
|
||||
dict(
|
||||
Task=row["aircraft"],
|
||||
Start=row["departure"],
|
||||
Finish=row["arrival"],
|
||||
Type="OFRAG",
|
||||
Label=row["ofrag_id"],
|
||||
)
|
||||
)
|
||||
|
||||
for _, row in maint.iterrows():
|
||||
if pd.isna(row.get("maint_start")) or pd.isna(row.get("maint_end")):
|
||||
continue
|
||||
gantt_rows.append(
|
||||
dict(
|
||||
Task=row["aircraft"],
|
||||
Start=row["maint_start"],
|
||||
Finish=row["maint_end"],
|
||||
Type="Manutenção",
|
||||
Label=f"CHECK {row['fh_threshold']:.0f}h (perde {row['ttm_loss_hours']:.1f}h)",
|
||||
)
|
||||
)
|
||||
|
||||
if gantt_rows:
|
||||
df_g = pd.DataFrame(gantt_rows)
|
||||
fig = px.timeline(
|
||||
df_g,
|
||||
x_start="Start",
|
||||
x_end="Finish",
|
||||
y="Task",
|
||||
color="Type",
|
||||
text="Label",
|
||||
title="Escala de Voo e Manutenção por Aeronave",
|
||||
color_discrete_map={"OFRAG": "#00CC96", "Manutenção": "#EF553B"},
|
||||
)
|
||||
fig.update_yaxes(categoryorder="category ascending")
|
||||
fig.update_traces(textposition="inside")
|
||||
n_ac = df_g["Task"].nunique()
|
||||
fig.update_layout(height=420 + n_ac * 40)
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
try:
|
||||
fig.write_html(str(DEFAULT_CONFIG.figures_dir / "gantt.html"))
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
st.info("Nenhum dado de Gantt disponível.")
|
||||
|
||||
# ── Escala com Atribuição ─────────────────────────────────────────────
|
||||
with sub_escala:
|
||||
st.subheader("Escala de Voo com Atribuição de Aeronaves")
|
||||
if _sched_result.empty:
|
||||
st.info("Sem escala otimizada disponível.")
|
||||
else:
|
||||
st.dataframe(raw_sched_atrib, use_container_width=True)
|
||||
|
||||
# ── Manutenção ────────────────────────────────────────────────────────
|
||||
with sub_maint:
|
||||
st.subheader("Eventos de Manutenção Planejados")
|
||||
maint = result["maintenance"]
|
||||
|
||||
if maint.empty:
|
||||
st.success("Nenhum evento de manutenção forçado no período planejado.")
|
||||
else:
|
||||
# Enrich with check name and location from input data
|
||||
checks_input = st.session_state["df_checks"].copy()
|
||||
# Identify relevant columns by position / name patterns
|
||||
_fh_col = next(
|
||||
(c for c in checks_input.columns if "FH" in c.upper() and "TEMPO" not in c.upper()),
|
||||
None,
|
||||
)
|
||||
_loc_col = next((c for c in checks_input.columns if "LOCAL" in c.upper()), None)
|
||||
_name_col = checks_input.columns[0] if len(checks_input.columns) > 0 else None
|
||||
|
||||
maint_disp = maint.copy()
|
||||
if _fh_col:
|
||||
checks_input[_fh_col] = pd.to_numeric(checks_input[_fh_col], errors="coerce")
|
||||
if _loc_col:
|
||||
fh_to_loc = checks_input.dropna(subset=[_fh_col]).set_index(_fh_col)[_loc_col].to_dict()
|
||||
maint_disp["local_execucao"] = maint_disp["fh_threshold"].map(fh_to_loc)
|
||||
if _name_col:
|
||||
fh_to_name = checks_input.dropna(subset=[_fh_col]).set_index(_fh_col)[_name_col].to_dict()
|
||||
maint_disp["check_nome"] = maint_disp["fh_threshold"].map(fh_to_name)
|
||||
|
||||
ordered_cols = [
|
||||
"aircraft",
|
||||
"check_nome",
|
||||
"local_execucao",
|
||||
"fh_threshold",
|
||||
"accum_fh_at_check",
|
||||
"maint_start",
|
||||
"maint_end",
|
||||
"ttm_loss_hours",
|
||||
]
|
||||
show_cols = [c for c in ordered_cols if c in maint_disp.columns]
|
||||
rename_map = {
|
||||
"aircraft": "Aeronave",
|
||||
"check_nome": "Check",
|
||||
"local_execucao": "Local",
|
||||
"fh_threshold": "FH Limite",
|
||||
"accum_fh_at_check": "FH na Manutenção",
|
||||
"maint_start": "Início",
|
||||
"maint_end": "Término",
|
||||
"ttm_loss_hours": "FH Perdidas",
|
||||
}
|
||||
st.dataframe(
|
||||
maint_disp[show_cols].rename(columns=rename_map),
|
||||
use_container_width=True,
|
||||
)
|
||||
|
||||
col_a, col_b = st.columns(2)
|
||||
with col_a:
|
||||
fig_loss = px.bar(
|
||||
maint_disp,
|
||||
x="aircraft",
|
||||
y="ttm_loss_hours",
|
||||
title="FH de TTM perdidas por aeronave / ciclo",
|
||||
labels={"ttm_loss_hours": "TTM perdido (h)", "aircraft": "Aeronave"},
|
||||
text_auto=".2f",
|
||||
color_discrete_sequence=["#ef4444"],
|
||||
)
|
||||
st.plotly_chart(fig_loss, use_container_width=True)
|
||||
|
||||
with col_b:
|
||||
fig_fh_chk = px.bar(
|
||||
maint_disp,
|
||||
x="aircraft",
|
||||
y="accum_fh_at_check",
|
||||
color="check_cycle_index",
|
||||
title="FH acumuladas na manutenção por aeronave",
|
||||
labels={"accum_fh_at_check": "FH acumuladas", "aircraft": "Aeronave"},
|
||||
text_auto=".1f",
|
||||
)
|
||||
st.plotly_chart(fig_fh_chk, use_container_width=True)
|
||||
|
||||
# ── Gráfico FH por aeronave + horas disponíveis ───────────────────
|
||||
st.divider()
|
||||
st.subheader("Horas de Voo por Aeronave")
|
||||
|
||||
fleet_df = result["fleet"]
|
||||
maint_df = result["maintenance"]
|
||||
ac_input = st.session_state["df_aircraft"].copy()
|
||||
|
||||
_mat_col = next(
|
||||
(c for c in ac_input.columns if any(k in c.upper() for k in ("MATRICULA", "TAIL", "AC"))),
|
||||
ac_input.columns[0],
|
||||
)
|
||||
_fh_col_ac = next((c for c in ac_input.columns if "FH" in c.upper()), None)
|
||||
fh_lookup: dict = {}
|
||||
if _fh_col_ac:
|
||||
ac_input["_fh"] = pd.to_numeric(ac_input[_fh_col_ac], errors="coerce").fillna(0)
|
||||
fh_lookup = ac_input.set_index(_mat_col)["_fh"].to_dict()
|
||||
|
||||
checks_inp = st.session_state["df_checks"].copy()
|
||||
_ck_fh_col = next(
|
||||
(c for c in checks_inp.columns if "FH" in c.upper() and "TEMPO" not in c.upper()), None
|
||||
)
|
||||
_thresholds = sorted(
|
||||
pd.to_numeric(checks_inp[_ck_fh_col], errors="coerce").dropna().tolist()
|
||||
) if _ck_fh_col else []
|
||||
|
||||
def _next_ttm(done_threshold: float) -> float:
|
||||
for i, t in enumerate(_thresholds):
|
||||
if abs(t - done_threshold) < 0.01:
|
||||
if i + 1 < len(_thresholds):
|
||||
return _thresholds[i + 1] - t
|
||||
return t - (_thresholds[i - 1] if i > 0 else 0)
|
||||
return 0.0
|
||||
|
||||
# all_maint: all events per aircraft sorted by time
|
||||
all_maint: dict = {}
|
||||
first_maint: dict = {}
|
||||
if not maint_df.empty:
|
||||
for ac, grp in maint_df.sort_values("maint_start").groupby("aircraft"):
|
||||
events = grp[["accum_fh_at_check", "fh_threshold"]].to_dict("records")
|
||||
all_maint[ac] = events
|
||||
first_maint[ac] = {
|
||||
"fh_antes": events[0]["accum_fh_at_check"],
|
||||
"threshold": events[0]["fh_threshold"],
|
||||
}
|
||||
|
||||
fleet_plot = fleet_df[["aircraft", "flight_hours_scheduled", "initial_ttm_h"]].copy()
|
||||
fleet_plot["fh_inicial"] = fleet_plot["aircraft"].map(fh_lookup).fillna(0)
|
||||
fleet_plot["fh_antes"] = fleet_plot.apply(
|
||||
lambda r: first_maint[r["aircraft"]]["fh_antes"]
|
||||
if r["aircraft"] in first_maint else r["flight_hours_scheduled"], axis=1,
|
||||
)
|
||||
fleet_plot["fh_apos"] = fleet_plot.apply(
|
||||
lambda r: max(0.0, r["flight_hours_scheduled"] - first_maint[r["aircraft"]]["fh_antes"])
|
||||
if r["aircraft"] in first_maint else 0.0, axis=1,
|
||||
)
|
||||
# fh_disp: remaining TTM after the LAST maintenance event
|
||||
# fh_after_last = total FH - sum of all accum_fh_at_check (each event resets the counter)
|
||||
fleet_plot["fh_disp"] = fleet_plot.apply(
|
||||
lambda r: max(0.0,
|
||||
_next_ttm(all_maint[r["aircraft"]][-1]["fh_threshold"])
|
||||
- max(0.0, r["flight_hours_scheduled"]
|
||||
- sum(e["accum_fh_at_check"] for e in all_maint[r["aircraft"]]))
|
||||
) if r["aircraft"] in all_maint
|
||||
else max(0.0, r["initial_ttm_h"] - r["flight_hours_scheduled"]),
|
||||
axis=1,
|
||||
)
|
||||
|
||||
fig_fh = go.Figure()
|
||||
fig_fh.add_trace(go.Bar(
|
||||
name="FH Iniciais", x=fleet_plot["aircraft"], y=fleet_plot["fh_inicial"],
|
||||
marker_color="#94a3b8",
|
||||
text=fleet_plot["fh_inicial"].apply(lambda v: f"{v:.0f}h"), textposition="inside",
|
||||
))
|
||||
fig_fh.add_trace(go.Bar(
|
||||
name="FH Planejadas (antes manut.)", x=fleet_plot["aircraft"], y=fleet_plot["fh_antes"],
|
||||
marker_color="#3b82f6",
|
||||
text=fleet_plot["fh_antes"].apply(lambda v: f"{v:.1f}h" if v > 0 else ""), textposition="inside",
|
||||
))
|
||||
fig_fh.add_trace(go.Bar(
|
||||
name="FH Planejadas (após manut.)", x=fleet_plot["aircraft"], y=fleet_plot["fh_apos"],
|
||||
marker_color="#f59e0b",
|
||||
text=fleet_plot["fh_apos"].apply(lambda v: f"{v:.1f}h" if v > 0 else ""), textposition="inside",
|
||||
))
|
||||
fig_fh.add_trace(go.Bar(
|
||||
name="FH Disponíveis (próx. manut.)", x=fleet_plot["aircraft"], y=fleet_plot["fh_disp"],
|
||||
marker_color="#22c55e", opacity=0.55,
|
||||
text=fleet_plot["fh_disp"].apply(lambda v: f"{v:.1f}h" if v > 0 else ""), textposition="inside",
|
||||
))
|
||||
_maint_legend_added = False
|
||||
for ac, info in first_maint.items():
|
||||
y_mark = fh_lookup.get(ac, 0) + info["fh_antes"]
|
||||
fig_fh.add_trace(go.Scatter(
|
||||
x=[ac], y=[y_mark], mode="markers+text",
|
||||
marker=dict(symbol="diamond", size=14, color="#ef4444",
|
||||
line=dict(color="white", width=1.5)),
|
||||
text=[f"CHECK {info['threshold']:.0f}h"], textposition="top center",
|
||||
name="Manutenção", showlegend=not _maint_legend_added, legendgroup="maint",
|
||||
))
|
||||
_maint_legend_added = True
|
||||
|
||||
fig_fh.update_layout(
|
||||
barmode="stack",
|
||||
title="Horas de Voo por Aeronave — Histórico + Planejadas + Disponíveis",
|
||||
xaxis_title="Aeronave", yaxis_title="Horas de Voo (FH)", height=800,
|
||||
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
|
||||
)
|
||||
st.plotly_chart(fig_fh, use_container_width=True)
|
||||
|
||||
st.subheader("Horas disponíveis até a próxima manutenção")
|
||||
disp_cols = st.columns(len(fleet_plot))
|
||||
for col, (_, row) in zip(disp_cols, fleet_plot.iterrows()):
|
||||
col.metric(
|
||||
label=row["aircraft"],
|
||||
value=f"{row['fh_disp']:.1f} h",
|
||||
delta=f"limiar: {first_maint[row['aircraft']]['threshold']:.0f}h"
|
||||
if row["aircraft"] in first_maint else None,
|
||||
)
|
||||
|
||||
# ── Frota ─────────────────────────────────────────────────────────────
|
||||
with sub_frota:
|
||||
st.subheader("Frota – Utilização das Aeronaves")
|
||||
fleet_df = result["fleet"].copy()
|
||||
|
||||
# ── Métricas gerais ───────────────────────────────────────────────
|
||||
avg_util = fleet_df["ttm_utilisation_pct"].mean()
|
||||
total_fh = fleet_df["flight_hours_scheduled"].sum()
|
||||
n_active = int((~fleet_df["idle"]).sum())
|
||||
|
||||
m1, m2, m3, m4 = st.columns(4)
|
||||
m1.metric("Utilização Média TTM", f"{avg_util:.1f}%")
|
||||
m2.metric("FH Totais Planejadas", f"{total_fh:.1f} h")
|
||||
m3.metric("Aeronaves Ativas", f"{n_active} / {len(fleet_df)}")
|
||||
m4.metric("FH Médias por Aeronave", f"{total_fh / len(fleet_df):.1f} h" if len(fleet_df) else "—")
|
||||
|
||||
# ── Tabela com linha "Média" ───────────────────────────────────────
|
||||
numeric_cols = ["initial_ttm_h", "flight_hours_scheduled", "ttm_utilisation_pct",
|
||||
"n_ofrags_assigned", "n_maintenance_events", "total_ttm_loss_h"]
|
||||
avg_row = {col: round(fleet_df[col].mean(), 2) for col in numeric_cols if col in fleet_df.columns}
|
||||
avg_row["aircraft"] = "Média"
|
||||
avg_row["model"] = "—"
|
||||
avg_row["idle"] = False
|
||||
|
||||
fleet_display = pd.concat(
|
||||
[fleet_df, pd.DataFrame([avg_row])],
|
||||
ignore_index=True,
|
||||
)
|
||||
|
||||
rename_fleet = {
|
||||
"aircraft": "Aeronave",
|
||||
"model": "Modelo",
|
||||
"initial_ttm_h": "TTM Inicial (h)",
|
||||
"flight_hours_scheduled": "FH Planejadas",
|
||||
"ttm_utilisation_pct": "Utilização TTM (%)",
|
||||
"n_ofrags_assigned": "OFRAGs Atribuídas",
|
||||
"n_maintenance_events": "Eventos Manut.",
|
||||
"total_ttm_loss_h": "FH Perdidas",
|
||||
"idle": "Ociosa",
|
||||
}
|
||||
|
||||
def _style_frota(row):
|
||||
if row.get("Aeronave") == "Média":
|
||||
return ["font-weight:bold"] * len(row)
|
||||
return [""] * len(row)
|
||||
|
||||
st.dataframe(
|
||||
fleet_display.rename(columns=rename_fleet)
|
||||
.style.apply(_style_frota, axis=1),
|
||||
use_container_width=True,
|
||||
)
|
||||
|
||||
# ── Gráfico: utilização TTM ───────────────────────────────────────
|
||||
fig_util = px.bar(
|
||||
fleet_df,
|
||||
x="aircraft",
|
||||
y="ttm_utilisation_pct",
|
||||
title="Utilização do TTM por aeronave (%)",
|
||||
labels={"ttm_utilisation_pct": "Utilização TTM (%)", "aircraft": "Aeronave"},
|
||||
text_auto=".1f",
|
||||
color="ttm_utilisation_pct",
|
||||
color_continuous_scale="RdYlGn",
|
||||
range_color=[0, 100],
|
||||
)
|
||||
fig_util.add_hline(
|
||||
y=avg_util,
|
||||
line_dash="dash",
|
||||
line_color="navy",
|
||||
annotation_text=f"Média: {avg_util:.1f}%",
|
||||
annotation_position="top right",
|
||||
)
|
||||
st.plotly_chart(fig_util, use_container_width=True)
|
||||
|
||||
# ── Gráfico: FH detalhado ─────────────────────────────────────────
|
||||
fig_fh = px.bar(
|
||||
fleet_df,
|
||||
x="aircraft",
|
||||
y=["initial_ttm_h", "flight_hours_scheduled", "total_ttm_loss_h"],
|
||||
barmode="group",
|
||||
title="TTM inicial vs FH planejadas vs FH perdidas",
|
||||
labels={"value": "Horas de voo", "aircraft": "Aeronave", "variable": ""},
|
||||
)
|
||||
fig_fh.update_layout(
|
||||
legend=dict(
|
||||
orientation="h",
|
||||
yanchor="bottom",
|
||||
y=1.02,
|
||||
xanchor="right",
|
||||
x=1,
|
||||
)
|
||||
)
|
||||
st.plotly_chart(fig_fh, use_container_width=True)
|
||||
|
||||
# ── Mapa ──────────────────────────────────────────────────────────────
|
||||
with sub_mapa:
|
||||
st.subheader("Rotas por Aeronave")
|
||||
|
||||
if _sched_result.empty:
|
||||
st.info("Sem escala otimizada disponível.")
|
||||
else:
|
||||
import airportsdata as _apdata
|
||||
_icao_db = _apdata.load("ICAO")
|
||||
|
||||
def _coord(icao: str):
|
||||
ap = _icao_db.get(icao.strip().upper())
|
||||
return {"_lat": ap["lat"], "_lon": ap["lon"]} if ap else None
|
||||
|
||||
# Usa exatamente os campos DEP/ARR da Escala com Atribuição
|
||||
legs_atrib = raw_sched_atrib[raw_sched_atrib["AERONAVE"] != "—"].copy()
|
||||
|
||||
import folium
|
||||
from streamlit_folium import st_folium
|
||||
|
||||
_palette_hex = [
|
||||
"#e41a1c", "#377eb8", "#4daf4a", "#984ea3",
|
||||
"#ff7f00", "#a65628", "#f781bf", "#999999",
|
||||
]
|
||||
|
||||
m = folium.Map(location=[-8, -60], zoom_start=4, tiles=None)
|
||||
|
||||
folium.TileLayer(
|
||||
tiles="CartoDB positron",
|
||||
name="Mapa base",
|
||||
control=True,
|
||||
).add_to(m)
|
||||
folium.TileLayer(
|
||||
tiles="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
|
||||
attr="Esri World Imagery",
|
||||
name="Satélite",
|
||||
control=True,
|
||||
).add_to(m)
|
||||
folium.LayerControl(position="topright").add_to(m)
|
||||
|
||||
# Linhas de rota por aeronave
|
||||
for i, (ac, legs) in enumerate(legs_atrib.groupby("AERONAVE")):
|
||||
color = _palette_hex[i % len(_palette_hex)]
|
||||
for _, leg in legs.sort_values(["OFRAG", "ETAPA"]).iterrows():
|
||||
dep = str(leg["DEP"]).strip().upper()
|
||||
arr = str(leg["ARR"]).strip().upper()
|
||||
c_dep = _coord(dep)
|
||||
c_arr = _coord(arr)
|
||||
if c_dep and c_arr:
|
||||
folium.PolyLine(
|
||||
[(c_dep["_lat"], c_dep["_lon"]), (c_arr["_lat"], c_arr["_lon"])],
|
||||
color=color,
|
||||
weight=2.5,
|
||||
opacity=0.85,
|
||||
tooltip=f"{ac}: {dep} → {arr}",
|
||||
).add_to(m)
|
||||
|
||||
# Bases de manutenção a partir da tabela de aeroportos
|
||||
_ap_df = st.session_state["df_airports"].copy()
|
||||
_ap_df.columns = [c.strip().upper() for c in _ap_df.columns]
|
||||
_base_col = next((c for c in _ap_df.columns if "MAINTENANCE" in c or "BASE" in c), None)
|
||||
_code_col = next((c for c in _ap_df.columns if "CODE" in c or "ICAO" in c), None)
|
||||
_maint_bases: set = set()
|
||||
if _base_col and _code_col:
|
||||
_mask = _ap_df[_base_col].astype(str).str.strip().isin(["1", "True", "true"])
|
||||
_maint_bases = set(_ap_df.loc[_mask, _code_col].str.strip().str.upper())
|
||||
|
||||
# Marcadores dos aeroportos usados
|
||||
used_codes = set(
|
||||
legs_atrib["DEP"].str.strip().str.upper().tolist()
|
||||
+ legs_atrib["ARR"].str.strip().str.upper().tolist()
|
||||
)
|
||||
for code in used_codes:
|
||||
c = _coord(code)
|
||||
if c:
|
||||
is_base = code in _maint_bases
|
||||
folium.CircleMarker(
|
||||
location=[c["_lat"], c["_lon"]],
|
||||
radius=10 if is_base else 5,
|
||||
color="#f59e0b" if is_base else "#1e293b",
|
||||
weight=2.5 if is_base else 1.5,
|
||||
fill=True,
|
||||
fill_color="#f59e0b" if is_base else "#1e293b",
|
||||
fill_opacity=0.95,
|
||||
tooltip=f"🔧 Base de manutenção: {code}" if is_base else code,
|
||||
popup=f"<b>{code}</b><br>Base de manutenção" if is_base else code,
|
||||
).add_to(m)
|
||||
|
||||
st_folium(m, height=700, use_container_width=True)
|
||||
232
app/dashboard_backup.py
Normal file
232
app/dashboard_backup.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
Aircraft Routing Dashboard – Streamlit application.
|
||||
|
||||
Run with: streamlit run app/dashboard.py (from project root)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Allow imports from src/ when running from project root
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
import streamlit as st
|
||||
import pandas as pd
|
||||
import plotly.express as px
|
||||
import plotly.graph_objects as go
|
||||
from datetime import timedelta
|
||||
|
||||
from src.routing_engine import RoutingPipeline, DEFAULT_CONFIG
|
||||
|
||||
# ── Page config ───────────────────────────────────────────────────────────────
|
||||
st.set_page_config(
|
||||
page_title="OARMP – Aircraft Routing",
|
||||
page_icon="✈",
|
||||
layout="wide",
|
||||
)
|
||||
st.title("✈ Aircraft Routing & Maintenance Planning (OARMP)")
|
||||
st.caption("Set Partitioning + Column Generation + Branch & Bound")
|
||||
|
||||
# ── Sidebar – parameters ──────────────────────────────────────────────────────
|
||||
with st.sidebar:
|
||||
st.header("⚙ Parameters")
|
||||
tat = st.number_input("TAT mínimo (min)", min_value=0, max_value=240, value=60, step=10)
|
||||
year = st.number_input("Ano do planejamento", min_value=2024, max_value=2030, value=2026)
|
||||
time_limit = st.number_input("Limite tempo solver (s)", min_value=30, max_value=600, value=120)
|
||||
run_btn = st.button("▶ Executar Otimização", type="primary")
|
||||
|
||||
# ── Session state ─────────────────────────────────────────────────────────────
|
||||
if "result" not in st.session_state:
|
||||
st.session_state["result"] = None
|
||||
|
||||
# ── Run pipeline ──────────────────────────────────────────────────────────────
|
||||
if run_btn:
|
||||
cfg = DEFAULT_CONFIG
|
||||
cfg.tat_minutes = tat
|
||||
cfg.planning_year = int(year)
|
||||
cfg.mip_time_limit_seconds = int(time_limit)
|
||||
|
||||
with st.spinner("Executando otimização…"):
|
||||
try:
|
||||
pipe = RoutingPipeline(cfg)
|
||||
st.session_state["result"] = pipe.run(save_outputs=True)
|
||||
st.success("Otimização concluída!")
|
||||
except Exception as exc:
|
||||
st.error(f"Erro: {exc}")
|
||||
st.exception(exc)
|
||||
|
||||
result = st.session_state.get("result")
|
||||
|
||||
# ── Tabs ──────────────────────────────────────────────────────────────────────
|
||||
tab_summary, tab_gantt, tab_maint, tab_fleet, tab_ofrags = st.tabs(
|
||||
["📊 Resumo", "📅 Gantt", "🔧 Manutenção", "✈ Frota", "📋 OFRAGs"]
|
||||
)
|
||||
|
||||
# ── Tab: Summary ──────────────────────────────────────────────────────────────
|
||||
with tab_summary:
|
||||
if result is None:
|
||||
st.info("Execute a otimização para ver os resultados.")
|
||||
else:
|
||||
s = result["summary"]
|
||||
c1, c2, c3, c4 = st.columns(4)
|
||||
c1.metric("Status", s["status"])
|
||||
c2.metric("Objetivo (FH perdidas)", f"{s['total_ttm_loss_hours']:.2f} h")
|
||||
c3.metric("OFRAGs cobertas", f"{s['covered_ofrags']} / {s['total_ofrags']}")
|
||||
c4.metric("Eventos de manutenção", s["n_maintenance_events"])
|
||||
|
||||
if s["uncovered_ofrags"]:
|
||||
st.warning(f"OFRAGs NÃO cobertas: {s['uncovered_ofrags']}")
|
||||
|
||||
st.subheader("Escala de voo otimizada")
|
||||
sched = result["schedule"]
|
||||
if not sched.empty:
|
||||
st.dataframe(
|
||||
sched.style.apply(
|
||||
lambda r: ["background-color:#fff3cd" if r.get("maintenance_before") else "" for _ in r],
|
||||
axis=1,
|
||||
),
|
||||
use_container_width=True,
|
||||
)
|
||||
|
||||
st.caption(f"Colunas geradas no CG: {s['columns_generated']}")
|
||||
|
||||
# ── Tab: Gantt ────────────────────────────────────────────────────────────────
|
||||
with tab_gantt:
|
||||
if result is None:
|
||||
st.info("Execute a otimização para ver o diagrama de Gantt.")
|
||||
else:
|
||||
sched = result["schedule"]
|
||||
maint = result["maintenance"]
|
||||
|
||||
gantt_rows = []
|
||||
|
||||
if not sched.empty:
|
||||
for _, row in sched.iterrows():
|
||||
if pd.isna(row.get("departure")) or pd.isna(row.get("arrival")):
|
||||
continue
|
||||
gantt_rows.append(
|
||||
dict(
|
||||
Task=row["aircraft"],
|
||||
Start=row["departure"],
|
||||
Finish=row["arrival"],
|
||||
Resource=row["ofrag_id"],
|
||||
Type="OFRAG",
|
||||
Label=row["ofrag_id"],
|
||||
)
|
||||
)
|
||||
|
||||
if not maint.empty:
|
||||
for _, row in maint.iterrows():
|
||||
if pd.isna(row.get("maint_start")) or pd.isna(row.get("maint_end")):
|
||||
continue
|
||||
gantt_rows.append(
|
||||
dict(
|
||||
Task=row["aircraft"],
|
||||
Start=row["maint_start"],
|
||||
Finish=row["maint_end"],
|
||||
Resource="Manutenção",
|
||||
Type="Maintenance",
|
||||
Label=f"CHECK (perde {row['ttm_loss_hours']:.1f}h)",
|
||||
)
|
||||
)
|
||||
|
||||
if gantt_rows:
|
||||
df_gantt = pd.DataFrame(gantt_rows)
|
||||
color_map = {"Manutenção": "#636EFA"}
|
||||
fig = px.timeline(
|
||||
df_gantt,
|
||||
x_start="Start",
|
||||
x_end="Finish",
|
||||
y="Task",
|
||||
color="Type",
|
||||
text="Label",
|
||||
title="Escala de Voo e Manutenção por Aeronave",
|
||||
color_discrete_map={"OFRAG": "#00CC96", "Maintenance": "#EF553B"},
|
||||
)
|
||||
fig.update_yaxes(categoryorder="category ascending")
|
||||
fig.update_traces(textposition="inside")
|
||||
fig.update_layout(height=400 + len(sched["aircraft"].unique()) * 40)
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
|
||||
cfg_obj = DEFAULT_CONFIG
|
||||
try:
|
||||
fig.write_html(str(cfg_obj.figures_dir / "gantt.html"))
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
st.info("Nenhum dado de Gantt disponível.")
|
||||
|
||||
# ── Tab: Maintenance ──────────────────────────────────────────────────────────
|
||||
with tab_maint:
|
||||
if result is None:
|
||||
st.info("Execute a otimização para ver eventos de manutenção.")
|
||||
else:
|
||||
maint = result["maintenance"]
|
||||
if maint.empty:
|
||||
st.success("Nenhum evento de manutenção forçado.")
|
||||
else:
|
||||
st.dataframe(maint, use_container_width=True)
|
||||
|
||||
fig_loss = px.bar(
|
||||
maint,
|
||||
x="aircraft",
|
||||
y="ttm_loss_hours",
|
||||
color="check_cycle_index",
|
||||
title="Horas de TTM perdidas por aeronave / ciclo de check",
|
||||
labels={"ttm_loss_hours": "TTM perdido (h)", "aircraft": "Aeronave"},
|
||||
text_auto=".2f",
|
||||
)
|
||||
st.plotly_chart(fig_loss, use_container_width=True)
|
||||
|
||||
# ── Tab: Fleet ────────────────────────────────────────────────────────────────
|
||||
with tab_fleet:
|
||||
if result is None:
|
||||
st.info("Execute a otimização.")
|
||||
else:
|
||||
fleet_df = result["fleet"]
|
||||
st.dataframe(fleet_df, use_container_width=True)
|
||||
|
||||
fig_util = px.bar(
|
||||
fleet_df,
|
||||
x="aircraft",
|
||||
y="ttm_utilisation_pct",
|
||||
title="Utilização do TTM por aeronave (%)",
|
||||
labels={"ttm_utilisation_pct": "Utilização TTM (%)", "aircraft": "Aeronave"},
|
||||
text_auto=".1f",
|
||||
color="ttm_utilisation_pct",
|
||||
color_continuous_scale="RdYlGn",
|
||||
range_color=[0, 100],
|
||||
)
|
||||
st.plotly_chart(fig_util, use_container_width=True)
|
||||
|
||||
fig_fh = px.bar(
|
||||
fleet_df,
|
||||
x="aircraft",
|
||||
y=["initial_ttm_h", "flight_hours_scheduled", "total_ttm_loss_h"],
|
||||
barmode="group",
|
||||
title="TTM inicial vs FH planejadas vs FH perdidas",
|
||||
labels={"value": "Horas de voo", "aircraft": "Aeronave"},
|
||||
)
|
||||
st.plotly_chart(fig_fh, use_container_width=True)
|
||||
|
||||
# ── Tab: OFRAGs ───────────────────────────────────────────────────────────────
|
||||
with tab_ofrags:
|
||||
if result is None:
|
||||
st.info("Execute a otimização.")
|
||||
else:
|
||||
ofrags = result["ofrags"]
|
||||
st.write(f"**{len(ofrags)} OFRAGs** elegíveis (iniciam e terminam na base de manutenção).")
|
||||
cols_show = [c for c in ["ofrag_id", "departure", "arrival", "flight_hours", "n_legs", "missions", "origin", "destination"] if c in ofrags.columns]
|
||||
st.dataframe(ofrags[cols_show], use_container_width=True)
|
||||
|
||||
fig_fh = px.bar(
|
||||
ofrags,
|
||||
x="ofrag_id",
|
||||
y="flight_hours",
|
||||
title="Horas de voo por OFRAG",
|
||||
labels={"flight_hours": "FH total", "ofrag_id": "OFRAG"},
|
||||
text_auto=".2f",
|
||||
)
|
||||
st.plotly_chart(fig_fh, use_container_width=True)
|
||||
129
docs/about.md
129
docs/about.md
@@ -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
|
||||
```
|
||||
@@ -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`.
|
||||
@@ -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`.
|
||||
@@ -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,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`
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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,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)"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
5
raw/AERONAVES.csv
Normal file
@@ -0,0 +1,5 @@
|
||||
MATRICULA;MODELO;FH TOTAIS
|
||||
FAB2800;C105;295
|
||||
FAB2803;C105;275
|
||||
FAB2809;C105;265
|
||||
FAB2811;C105;255
|
||||
|
31
raw/AIRPORTS.csv
Normal file
31
raw/AIRPORTS.csv
Normal file
@@ -0,0 +1,31 @@
|
||||
AIRPORT_CODE;AIRPORT_NAME;LATITUDE;LONGITUDE;IS_MAINTENANCE_BASE;COUNTRY
|
||||
SBMN;Manaus - Eduardo Gomes;-3.0267;-60.0496;1;Brazil
|
||||
SBBR;Brasília - Presidente Juscelino Kubitschek;-15.7942;-47.8822;0;Brazil
|
||||
SBGO;Goiânia - Santa Genoveva;-16.6197;-49.2200;0;Brazil
|
||||
SBCT;Curitiba - Afonso Pena;-25.4844;-49.3220;0;Brazil
|
||||
SBPA;Porto Alegre - Salgado Filho;-29.9911;-51.4314;0;Brazil
|
||||
SBRF;Recife - Guararapes;-8.2645;-35.2426;0;Brazil
|
||||
SBBV;Boa Vista - Atlas Brasil Cantá;2.8420;-60.6942;0;Brazil
|
||||
SBSN;São Gabriel da Cachoeira;-0.1333;-67.0833;0;Brazil
|
||||
SBTS;Tabatinga;-4.2505;-69.9342;0;Brazil
|
||||
SBMQ;Maués;-3.3900;-61.2942;0;Brazil
|
||||
SBBE;Benjamin Constant;-4.3797;-70.0336;0;Brazil
|
||||
SBOI;Iquitos - Coronel FAP Vasquez;-3.7458;-73.3075;0;Peru
|
||||
SBPV;Porto Velho - Governador Jorge Teixeira;-8.7608;-63.9047;0;Brazil
|
||||
SBVH;Vilhena;-12.7408;-60.1447;0;Brazil
|
||||
SBGL;Rio de Janeiro - Galeão;-22.8097;-43.2506;0;Brazil
|
||||
SBCY;Cruzeiro do Sul - Aeroporto;-7.6258;-72.6647;0;Brazil
|
||||
SBCO;Comodoro - Aeroporto;-20.4753;-58.6753;0;Brazil
|
||||
SBLO;Londrina - Governador Roberto Silveira;-23.3263;-51.1267;0;Brazil
|
||||
SBTF;Teresina - Senador Petrônio Portela;-5.0589;-42.8229;0;Brazil
|
||||
SBTT;Tatooine - Regional;-7.2100;-74.5700;0;Brazil
|
||||
SBUA;Uaupes;0.8333;-70.5667;0;Brazil
|
||||
SBUY;Uruçuí;-7.1833;-44.3833;0;Brazil
|
||||
SBYS;Yacuanã;0.7000;-67.0667;0;Brazil
|
||||
SWBC;Barcelos;0.9806;-62.9461;0;Brazil
|
||||
SWCA;Carauari;-4.8808;-66.8858;0;Brazil
|
||||
SWEI;Envira;-7.5636;-70.0258;0;Brazil
|
||||
SWKO;Kopena;-5.0500;-67.5000;0;Brazil
|
||||
SBSM;Santa Maria;-29.7108;-53.6922;0;Brazil
|
||||
SBCC;Guarantã do Norte (CPBV);-9.3333;-54.9647;0;Brazil
|
||||
SBAN;Anápolis (Campo Marechal Márcio);-16.2386;-48.9722;0;Brazil
|
||||
|
5
raw/CHECKS.csv
Normal file
5
raw/CHECKS.csv
Normal file
@@ -0,0 +1,5 @@
|
||||
CHECKS;FH;TEMPO DE EXECUCAO (DIAS);LOCAL DE EXECUCAO
|
||||
CHECK 1A_OPERACIONAL 300FH;300;3;SBMN
|
||||
OPEREACIONAL 400FH;400;1;SBMN
|
||||
CHECK 2A_OPERACIONAL 600FH;600;4;SBMN
|
||||
CHECK 3A;900;5;SBMN
|
||||
|
Binary file not shown.
Binary file not shown.
64
raw/ESCALA DE VOO MODELO 1.csv
Normal file
64
raw/ESCALA DE VOO MODELO 1.csv
Normal file
@@ -0,0 +1,64 @@
|
||||
DATA;ETAPA;LOCALIDADE;;HOR<4F>RIO (Z);;TEMPO DE VOO;SEGMTO;MISS<53>O;OFRAG
|
||||
;;DEP;ARR;DEP;ARR;;;;
|
||||
18/mar;1;SBMN;SBSN;14:15;15:50;01:35;1;69TV01;1
|
||||
18/mar;2;SBSN;SBTS;16:50;18:20;01:30;2;69TV02;1
|
||||
18/mar;3;SBTS;SBMQ;20:10;21:45;01:35;3;69TV03;1
|
||||
18/mar;4;SBMQ;SBBE;23:30;00:30:00;01:00;4;69TV04;1
|
||||
19/mar;6;SBBE;SBTS;12:00;14:10;02:10;2;69TV06;1
|
||||
19/mar;7;SBTS;SBOI;16:50;18:40;01:50;3;69TV07;1
|
||||
19/mar;8;SBOI;SBBE;21:10;23:20;02:10;4;69TV08;1
|
||||
20/mar;9;SBBE;SBOI;12:10;14:00;01:50;1;69TV09;1
|
||||
20/mar;10;SBOI;SBBE;15:25;17:10;01:45;2;69TV10;1
|
||||
20/mar;11;SBBE;SBOI;18:25;20:10;01:45;3;69TV11;1
|
||||
20/mar;12;SBOI;SBBE;21:30;23:15;01:45;4;69TV12;1
|
||||
20/mar;1;SBMN;SBPV;12:20;14:30;02:10;1;69TV01;2
|
||||
20/mar;2;SBPV;SBVH;15:35;17:15;01:40;2;69TV02;2
|
||||
20/mar;3;SBVH;SBBR;17:50;21:30;03:40;3;69TV03;2
|
||||
20/mar;4;SBBR;SBGL;22:45;01:15:00;02:30;4;69TV04;2
|
||||
21/mar;13;SBBE;SBOI;12:00;13:45;01:45;1;69TV13;1
|
||||
21/mar;14;SBOI;SBBE;16:00;17:55;01:55;2;69TV14;1
|
||||
21/mar;6;SBGL;SBBR;15:45;18:30;02:45;2;69TV06;2
|
||||
21/mar;7;SBBR;SBCY;20:10;22:35;02:25;3;69TV07;2
|
||||
21/mar;8;SBCY;SBPV;23:15;02:15:00;03:00;4;69TV08;2
|
||||
22/mar;15;SBBE;SBOI;12:10;13:55;01:45;1;69TV15;1
|
||||
22/mar;16;SBOI;SBBE;15:15;17:10;01:55;2;69TV16;1
|
||||
22/mar;17;SBBE;SBOI;18:30;20:15;01:45;3;69TV17;1
|
||||
22/mar;18;SBOI;SBBE;21:30;23:20;01:50;4;69TV18;1
|
||||
22/mar;10;SBPV;SBBV;15:30;19:10;03:40;2;69TV10;2
|
||||
22/mar;11;SBBV;SBMN;20:55;22:45;01:50;3;69TV11;2
|
||||
23/mar;19;SBBE;SBOI;12:05;13:55;01:50;1;69TV19;1
|
||||
23/mar;20;SBOI;SBBE;15:40;17:35;01:55;2;69TV20;1
|
||||
23/mar;21;SBBE;SBOI;18:20;20:10;01:50;3;69TV21;1
|
||||
23/mar;22;SBOI;SBBE;21:35;23:25;01:50;4;69TV22;1
|
||||
23/mar;1;SBMN;SBCC;12:40;15:15;02:35;1;69TV01;3
|
||||
23/mar;2;SBCC;SBBR;16:05;19:00;02:55;2;69TV02;3
|
||||
23/mar;3;SBBR;SBYS;20:15;22:05;01:50;3;69TV03;3
|
||||
24/mar;23;SBBE;SBOI;11:55;13:50;01:55;1;69TV23;1
|
||||
24/mar;24;SBOI;SBBE;15:40;17:25;01:45;2;69TV24;1
|
||||
24/mar;25;SBBE;SBMN;20:35;23:30;02:55;3;69TV25;1
|
||||
24/mar;4;SBYS;SBCO;14:45;17:20;02:35;1;69TV04;3
|
||||
24/mar;5;SBCO;SBSM;18:25;19:20;00:55;2;69TV05;3
|
||||
25/mar;6;SBSM;SBLO;12:00;13:55;01:55;1;69TV06;3
|
||||
25/mar;7;SBLO;SBAN;14:45;17:15;02:30;2;69TV07;3
|
||||
25/mar;8;SBAN;SBBE;18:40;22:55;02:55;3;69TV08;3
|
||||
26/mar;9;SBBE;SBBV;12:00;15:50;03:50;1;69TV09;3
|
||||
26/mar;10;SBBV;SBMN;17:00;18:50;01:50;2;69TV10;3
|
||||
26/mar;1;SBMN;SBMN;15:00;16:00;01:00;1;50TT01;4
|
||||
26/mar;2;SBMN;SBMN;16:55;17:45;00:50;2;50TT02;5
|
||||
26/mar;3;SBMN;SBMN;20:20;22:20;02:00;3;50TT03;6
|
||||
30/mar;1;SBMN;SBMN;16:20;17:10;00:50;1;05TF01;7
|
||||
30/mar;1;SBMN;SBMN;18:20;19:15;00:55;1;05TF01;8
|
||||
30/mar;1;SBMN;SBUA;15:00;17:25;02:25;1;69TV01;9
|
||||
30/mar;2;SBUA;SBTT;19:00;20:45;01:45;2;69TV02;9
|
||||
31/mar;3;SBTT;SWEI;12:10;13:10;01:00;1;69TV03;9
|
||||
31/mar;4;SWEI;SWCA;14:55;16:05;01:10;2;69TV04;9
|
||||
31/mar;5;SWCA;SWKO;17:15;18:25;01:10;3;69TV05;9
|
||||
31/mar;6;SWKO;SBMN;18:55;20:00;01:05;4;69TV06;9
|
||||
31/mar;1;SBMN;SWBC;12:00;13:10;01:10;1;69TV01;10
|
||||
31/mar;2;SWBC;SBMN;14:45;16:00;01:15;2;69TV02;10
|
||||
31/mar;3;SBMN;SBBV;18:45;20:35;01:50;3;69TV03;10
|
||||
31/mar;4;SBBV;SBMN;22:00;23:45;01:45;4;69TV04;10
|
||||
01/abr;1;SBMN;SBTF;12:25;14:40;02:15;1;69TV01;11
|
||||
01/abr;2;SBTF;SWEI;15:55;17:35;01:40;2;69TV02;11
|
||||
01/abr;3;SWEI;SBUY;19:50;21:20;01:30;3;69TV03;11
|
||||
01/abr;4;SBUY;SBMN;22:05;23:50;01:45;4;69TV04;11
|
||||
|
Binary file not shown.
@@ -1,483 +0,0 @@
|
||||
%PDF-1.4
|
||||
1 0 obj
|
||||
<<
|
||||
/Creator (Oracle11gR1 AS Reports Services)
|
||||
/CreationDate (D:20260615132342)
|
||||
/ModDate (D:20260615132342)
|
||||
/Producer (Oracle PDF driver)
|
||||
/Title ()
|
||||
/Author (Oracle Reports)
|
||||
>>
|
||||
endobj
|
||||
5 0 obj
|
||||
<</Length 6 0 R
|
||||
/Filter [/ASCII85Decode /FlateDecode]
|
||||
>>
|
||||
stream
|
||||
Gb!Sn962&m&\d:,9_h)SN7GRbQa\9NPRs#YKnYoHE=Q1uUk/ss9ScSq?Rc:\%R/RVjHdq3j](J/
|
||||
UjH_IJ\g[cP%hnWJL,//:J^`:O6IMjM^Tm$)Gp!/q"o7(LHcDDD?_TM#uQ:CM'T@U&XPoWLaHZE
|
||||
+N<8V^iU2\8sH38cpUbH`LJW:Z[h([jE>Jt*sbM90N1V"592jHUP)KLE;jlsFHRA!ao4?E1NJqJ
|
||||
MenQi-pB$MD[6)cn/Sf["b0<FN`IkaT].40gra%W3Qt/88qs>O6a\Ib`Gt^7=4+;3;?B9`a)lF0
|
||||
Y]Gr.acOV8-YD;D]J&X\St_oG*jO)?l3Z!:39Al`1@bd;QSd]PNDNKpLnZ:2('#c`c-+uQmYHP"
|
||||
"K\AY^,+_o9qEofN1=e,F$s3Y.+M_<NYEV,J2k,u1a%K#E@MCNJ5R"F],2@Ur$!BGaZP&6b>+r@
|
||||
qYBdJpVhMBEZUV2ZZ1.`Er7EXg"#lR1uOM.JX.c`S1&(_:3`!KKQ0tmEIPm%N5GJ5M.8/u71p6Z
|
||||
FGfK_;FV(I910*B$SF'5\Ib8d@.#R1cfRI9`l?6MG][j&1S,;Iq7FE'"TeMlk:aiAfgS)m:\V8)
|
||||
*GGOOJ8#UnA518aKH9i*"^.N1#5bqX1"[N4egM0XJK.Olj7%o2+LLNXjA-p'Wc?D$IH*:raqX\Q
|
||||
.>B#)8,9L9f#=_V(gfX\XFX\ZCnpfb1<F1iD$4f,)rrDJ`%@s8l(qmB2[H6nVd\VF<,T4iU`iO\
|
||||
I@*L@URDlXP!NF)U9_;'l6%qo;Nj"=+g+@14fZbS7YAoY"sl&?<13!DI4VH@9>]aH8EMa&k$bQo
|
||||
6MPG[RQ/5P+UM+)pkX,mdkN\UOu@b?(3`W[#]m<Y80-=Hf()#e'k(5V,W!I=1#@kG^3O&hboJsj
|
||||
7pclp_\h'(feLX(7WLCM%n6Qf)RaW1qIc;6,cn?VC"-Xo5Lom-p;s-&lrj':#:.U+LB5B.@FdZE
|
||||
WhiGR\KJ/^V\E=m`EpLq%_h&*<Cq(#E\WseK+PI0-rE,nR(rOAPZ[t[KO]q@-SSb04X"RNZL`Ha
|
||||
qWF:$8NHSrcXoUAE)&(X=Z.0o-a;FD"OZJM+M6u_\BtQ`PjhW].J)kt4HttWUdP`PU^)j):?Ag$
|
||||
G*&0].H#/O\ZS#"6@-]o4\fBXC_d%`LjIJMZ/`6n]\=151/4'Wq96.K4r,M8eS:M"K/A1o];kO#
|
||||
m(jKE_Gr/'KKEJP^>:LBp!6?>d`GU[-9n#CiQMR?<LEM+BQEeh<J*lK7m]2$_Ei7k@#B-uAckc3
|
||||
icpjA/4!+Y(\cZ>N+=ECS*N?1j?[m8I1J\cq>o*0?u#R<g;A7TP^=:tf;J0\#p4$'5ud1G,cT*l
|
||||
i;"'+Eo6I'9@39opMUkKT]bh^"D>=VceIT!-ud"GHV&kb33pl4g-X4)s4M0n>@!=DgU5eL9,a%Z
|
||||
4m=tknC5.\aX^HuXUV%I<Gj%%n^G`<S0SFE@rc]W78AJmb<<Ge>]oNpFG-];hB:ba>g\?+"LbX%
|
||||
lQi`*V`XOEn/G074/5rib6CEtN33E(T!Dej`.?J[9Xg,c*MBRi5p>cu_iGKkWiGLSl-6Yibpe#h
|
||||
`/M64@7'4`li-uoUSDce3:<.nXQ[%oFDHmsQ+,ncPMWM^W83jG-b2U4$q>alf<Q;J1pVL6AM`Dm
|
||||
p<B[lL<m/qmL@P!!H#O+*0p1L1sPPgp>@C3!!UDecUr=V[p#.0%VoSarNL1?[hmc*H]5APZmSf&
|
||||
,;tidgB?&,DnjcLe6FVL`:E]sI^+<*Xod!eeCqZQ8o8=].6;iNZr!Xfg@+;r8AmuiE#(R*cE[N+
|
||||
frH3=2Tj_[(O;,ZA2Kk$^Rr0+(;V^$R6p+)6M/)\H?a26FY.\p_r^MZd/c[TLd!Z]kG\@D4nWMS
|
||||
rWO-4R[SV;RWkdZ/3co\Sm\.M2RiG8c"_63RT@k:1l\=cf5"*L<Xi!HTp.7e;2`>/PVks'nS$D2
|
||||
W;=f.KA,.b`M)<4))>'\*pt:U0sEK#K3A[tCVh!M[OlYBG#pWQGf%+6FD$&SB[3@Za$.e`s.Ol(
|
||||
dFmO@@f,WKlVM@6L*,O)(:ZKgjrSrK8lgk)X(;5+]*5:G,2XB*j;Qf\DTU.4<Kd%7ot"rC?]60`
|
||||
p4d+lr,+ks^\POuIT?.%O"!nSW'J<D=^[kVkp'F1Mokknl@-8ramp5.<Xkm44WVl$nIVJ5qj5Ll
|
||||
10>n7B)bjUrp(*Ko`%bEs(5#o%9@0!\fK6,J"$%&I*E9fr@ahVY8=\B#<0Mb]R"5NjqmIA&heqm
|
||||
=)3Y5hV)-)lc$:Xo:g+:UnG421a9aIk3j5=]&?q)rmQ&(]JUM')7K7^br0f;J!c9=aENHQVEapq
|
||||
I!)-0R#aG.]E@hRCk)B7E.0XXK\K-;iSN]:9nI?[<.5s.UVKAulO`QboMFD;g^g[W&ZdsJ3g;J?
|
||||
ra)D;h:lD@8ZGsX9s2QNaJaHeOi2K*TWYjI;9!'lM_1aC?RtX:`,/+5.nXML'AesmSsh`FKs.$q
|
||||
iA#d8o)boAVPbjY7(gSUFZHSs)4d<=moSs>GKJkJZNQ>[^0mVNIafP8EY8qAPusHpB%4JIn\LpD
|
||||
,GMf$K_8/[Hj`C5715dQX!!h;4H6k:%Mp):'K8>L"Qrero>?d=k1PqG]]Lq%idB]IEBa"?F*p_1
|
||||
/%$,2B\mf%Ck#IR:qOBt^;#-'._?\<C[lq'5]PtSYk0.#(]Ygn``VPGI%e";Dui&+me$9b"0KB3
|
||||
Wk<hf%;W*h=Rd8s<B[3&eMT5Qp,[:;/[&4pP23l%=5iA+p_8YT\?$EO:#;:kCJl\+B@BFqQ%)**
|
||||
+sUa\he-;X&cS,p$@46ZO(9'[f>(Cr(((D6`7TW!RFcjZX/SVql$T)^m[FOQ?DuL<H\V2UkrDS0
|
||||
?LG/t1K%Z.ELoo_[QrIOR<NC4e*>GdloVTS!3#pF4TGH^~>
|
||||
endstream
|
||||
endobj
|
||||
6 0 obj
|
||||
2935
|
||||
endobj
|
||||
7 0 obj
|
||||
<</Type /Font
|
||||
/Name /F0
|
||||
/Subtype /Type1
|
||||
/Encoding /WinAnsiEncoding
|
||||
/BaseFont /#43#6F#75#72#69#65#72#2D#42#6F#6C#64
|
||||
>>
|
||||
endobj
|
||||
8 0 obj
|
||||
<</Type /Font
|
||||
/Name /F1
|
||||
/Subtype /Type1
|
||||
/Encoding /WinAnsiEncoding
|
||||
/BaseFont /#43#6F#75#72#69#65#72
|
||||
>>
|
||||
endobj
|
||||
9 0 obj
|
||||
<</Type /Font
|
||||
/Name /F2
|
||||
/Subtype /Type1
|
||||
/Encoding /WinAnsiEncoding
|
||||
/BaseFont /#69#74#63#61#76#61#6E#74#67#61#72#64#65#67#6F#74#68#69#63
|
||||
/FirstChar 24
|
||||
/LastChar 255
|
||||
/Widths 10 0 R
|
||||
/FontDescriptor 11 0 R
|
||||
>>
|
||||
endobj
|
||||
10 0 obj
|
||||
[1000 1000 1000 1000 1000 1000 1000 1000 250 250
|
||||
250 500 500 750 750 375 375 375 375 625
|
||||
250 625 250 125 500 500 500 500 500 500
|
||||
500 500 500 500 250 250 625 625 625 625
|
||||
875 750 625 875 750 500 500 875 625 250
|
||||
500 625 500 875 750 875 625 875 625 500
|
||||
375 625 750 1000 625 625 500 375 625 375
|
||||
625 500 375 625 625 625 625 625 375 625
|
||||
625 250 250 500 250 1000 625 625 625 625
|
||||
250 375 375 625 500 875 500 500 375 375
|
||||
625 375 625 1000 1000 1000 1000 1000 1000 1000
|
||||
1000 1000 1000 1000 1000 1000 1000 1000 1000 1000
|
||||
250 375 1000 500 500 1000 500 250 1000 1000
|
||||
375 1000 1000 500 250 500 1000 250 500 500
|
||||
500 500 625 625 375 750 375 375 625 250
|
||||
750 500 375 625 375 375 375 625 625 625
|
||||
375 375 375 375 875 875 875 625 750 750
|
||||
750 750 750 750 1000 875 500 500 500 500
|
||||
250 250 250 250 750 750 875 875 875 875
|
||||
875 625 875 625 625 625 625 625 625 500
|
||||
625 625 625 625 625 625 1125 625 625 625
|
||||
625 625 250 250 250 250 625 625 625 625
|
||||
625 625 625 625 625 625 625 625 625 500
|
||||
625 500 ]
|
||||
endobj
|
||||
11 0 obj
|
||||
<<
|
||||
/Type /FontDescriptor
|
||||
/FontName /#69#74#63#61#76#61#6E#74#67#61#72#64#65#67#6F#74#68#69#63
|
||||
/Ascent 1000
|
||||
/CapHeight 1000
|
||||
/Descent -250
|
||||
/Flags 32
|
||||
/FontBBox [0 0 0 0]
|
||||
/ItalicAngle 0
|
||||
/StemV 0
|
||||
/AvgWidth 625
|
||||
/MaxWidth 1250
|
||||
>>
|
||||
endobj
|
||||
12 0 obj
|
||||
<<
|
||||
/Type /XObject
|
||||
/Subtype /Image
|
||||
/Name /im1
|
||||
/Width 105
|
||||
/Height 129
|
||||
/BitsPerComponent 8
|
||||
/ColorSpace /DeviceRGB
|
||||
/Filter [/ASCII85Decode /DCTDecode]
|
||||
/Length 13 0 R
|
||||
>>
|
||||
stream
|
||||
s4IA0!"_al8O`[\!<<*#!!*'"s4[N@!!**$!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%
|
||||
!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!WUmS6NI2g!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%
|
||||
!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<E3%!<N59!"fJ:JH0Bd!?qLF&HMtG!WU(<
|
||||
*rl9A"T\W)!<E3$z!!!!"!WrQ/"pYD?$4HmP!4<@<!W`B*!X&T/"U"r.!!.KK!WrE*&Hrdj0gQ!W
|
||||
;.0\RE>10ZOeE%*6F"?A;UOtZ1LbBV#mqFa(`=5<-7:2j.Ps"@2`NfY6UX@47n?3D;cHat='/U/
|
||||
@q9._B4u!oF*)PJGBeCZK7nr5LPUeEP*;,qQC!u,R\HRQV5C/hWN*81['d?O\@K2f_o0O6a2lBF
|
||||
daQ^rf%8R-g>V&OjQ5OekiqC&o(2MHp@n@XqZ"J6*ru?D!<E3%!<E3%!<<*"!!!!"!WrQ/"pYD?
|
||||
$4HmP!4<C=!W`?*"9Sc3"U"r.!<RHF!<N?8"9fr'"qj4!#@VTc+u4]T'LIqUZ,$_k1K*]W@WKj'
|
||||
(*k`q-1Mcg)&ahL-n-W'2E*TU3^Z;(7Rp!@8lJ\h<``C+>%;)SAnPdkC3+K>G'A1VH@gd&KnbA=
|
||||
M2II[Pa.Q$R$jD;USO``Vl6SpZEppG[^WcW]#)A'`Q#s>ai`&\eCE.%f\,!<j5f=akNM0qo(2MH
|
||||
p@n@XqZ#7L$j-M1!YGMH!'^JUJ(^Uj<;hgCrr?LMfPAHpa6[%-,'_/I5!8e/IKoS*`L\CGO6l9'
|
||||
ZX3/+^#BFWKRXO*/Q5s*op`,oIq\,Y`j;W/\$u4JVl<qWhWbo`8Tf#7cRc@nl+c`5?a4hR(bb57
|
||||
rr@kin>$!`RG-l>B@$B-+8@3brL*,iJ,N:&$`j+mrL1+,DD0Y7iV'>ArrBoB#k8.\`f#<"27JMr
|
||||
@#%7cSc!o96[!TVjD/P-8@6CKQ53]$F$*8g"G$;DnGX)?DhZ^?O*hner)_j`j8LIQpkQhRR:<F+
|
||||
?c<mX.hRQqmh3.N0>YMiE&DXBX&c'`i:%V5'+7SBLP2YSO#:6l.Xj1nH,kcs7H<hYn37BWRucKD
|
||||
K8/qL$HM)/fKL>!1TGGdA\pP]M8`AH$IpgfU8qcY\?'76Y7k5[XPGj5C3PI)1&h4^jnfjDd=VA8
|
||||
J,"s%!"(sdSYQ0%bP!Rg!2B_rLAq8D(]G+uo/g=,?gYn0fRLdd=mB<SphQWpnMe_^]uTiUJ@&P8
|
||||
rr<OR+'b.6did7TT>[tf^aq<Z9#aMp"tOOkk'Lmg?N1"#JchrW=kRO=pfjW[\(\is@AN.2ZeoM$
|
||||
V<,.XVqG9DdbJ-bhFs7/[a7Em]TZ,RM&^3%ZTA-f-GQ@(2t0o4q8^-(N8cBiKm6oF=aiE?',cP,
|
||||
`Va/&V1s[D[kH!3C:MJW5:_[l0Y)m%X=muGd&BdA-!F3#73B=X<UgaCD[grl"2bt*(%Z;fSn"&D
|
||||
ZRdDK=Un;qib'fK2]7.#&m9TrAl=V<Dh4+K'o8+?;l`Msc456"PCLJ1I`:4:@)&r'rZ_@B_N4\/
|
||||
B`A'eErCp2keVaW^\gP#!/2NmcI__2n?Y[\<Vnmmrr</9B&=3:U#1+X`a0_<YO8SHX_]UPm=$1h
|
||||
VPGTOSgAKeeMS;::IsujI?Ip>P.%TM\jfd!5Q.P[%pf)Ept+OJ"e.HYd&+L+&9p*<3J-_:>1D$I
|
||||
V&O\*N\`B<SM%9"[gGuT/LL0:Gu?@JT0)$+LAq6D:-7Gt`up_%ZuUY-7h^d%Yr0&'N@Rd`_dRNl
|
||||
Psm/>S5*?^L+-#aPd'p&AoC9YS-FCnQ7(EtGil`4`4Yj/!TDW<AR%e(?H6]"WJSK'ge!%6A@W-c
|
||||
_c`im/u+K&L9$-)naj]HI,'VnM8J@kd_o]6Ps^`p_Spn2rr<Jhq\Ob0K)WZ;pl#.P$:"?WU]).S
|
||||
p+H@FUBUUNrrDR*rY0kR4(#Cfn4N($9[ru9nVCp`FbZP4d!R$G(Y4oH@O!4nY&?%Oj-.Y>YrD'i
|
||||
mAlcE)#(f!oAM$SqaUK:n2H6=)H^0/iZAR+=Hh_+UB[!#Vahc+f">E-_p*Te-kWQA&#T,jb='(R
|
||||
P$+MM[#(]n\4*a6>O,WPM"i4YkJFlYNo<+`_?putCqCL9_d^-9Nu.?C0"!ZJZ>?9mQ6G>!<E_!l
|
||||
HC;GrFf?qFiZ810Nm0"NW=1*u%hDTnZ%#:S/F^[da-eT>H&a+0a'eL$Ae0QmODsjtLlPK+j)UU\
|
||||
l/sqo<83Z$jmp@,:F]>0._ZQKWDXAI;oHlTf`(r+ErCp2keVaW^\gP#!/2NmcI)Xl%-@@&J'ilI
|
||||
_ghH>rr<Bjq44_TgV>j__i6lXeNOUZ)E*G$^Bm"SnG`JZ^UUf&X=(c7I8`egNJ3CgI_cF^n7Z!3
|
||||
OfLU+%k:-CLr.1<5oBT)[F@aT]j`tK@u`]R:e9V8#`+=]19)Os$EC*-Wi,q`!!!1K^(ts#i"q5P
|
||||
]ZUf_*5Z75^sg6ESOi:k(Zoii<P[`s1;%KH&P?'ofO=&RnPl"mkZ>dOXQ*1,Wp+jk[Ci+K\<k5H
|
||||
$*[PJ0E*1kA],5,/(^IqS2bF6b:2C]7h(3k_Gk;ljtanBU8o]M<fRNh04u>P>k=piO,ne.3I\s)
|
||||
oO,G<hu4i"!5afrB5%;FL3!6N^Z`L`@DD_/rr@`pr6EpeLTG_Fi@g%:#P]D"rrA'1]KN(EHr0=I
|
||||
L3sT.>r@V4S\t5olbZP8\i?q*7CO_G_'XdeTr#cXVqX':4*r7e962'cr9<],i:Z9Mhq:ZrL%%Tl
|
||||
ZqVTK'DPbsishfqK%WS6*TS=3JSFkI<iMtQ?.qYhp,D\$g>D/c9,5,(%.T0^Jp!5%r7_I1qE3bX
|
||||
/.Q;W")nr14N>*:/1+-i8`JF7R:\Z/c*Nl6n#\Q9YI2/qA^eLr_lNs6<i/'6WFO\\MPj,?$tfqB
|
||||
rr@f"B9uQegGHi[1bu/]'oL;3_3mDl)`>gDfiAlK;-*n*Z[u>6%)q6\fXHl@<BLa@2)$I?2(O/@
|
||||
4otVHIpsK7Gc+<`YQ"T1YLo-K>K"3fQI#86pdUIB>?KZd-IXo-kDp%u"^kgfCE*mNYOacXoe?.u
|
||||
rr=>Y6N7%gHFNmAn*BnXlV8tgIM_MBa"B4-SdijhP@RJ,577"Cm+C@i/9M6\GlAoWEs[fF60<F9
|
||||
D-D(%3,GoSUK`nW@lWEqhD^/7A&+*\idO#ZUCWr9DJ`H9c)G^?(1hUXfSVXl23n=RO`3K3%u,R5
|
||||
Tne<#d]\\Me:Wus,qAN\XO:J?D>QMHaajBk_)f?tc[Gur!:#mF?!jgC$@clAftU2YRX@!^#NFWm
|
||||
IO06-daq1'YjjTRM.NMLV.p#*Wq4/P@ae;cn_],pUe%:s)'+iUSY_'3=@mC7c2<%hT)L/;cef1]
|
||||
?m#.k?i2Qa'\mqKqePmgp^dB)#e,qlGdgZLDt\CkDh[*'S%mntd<1Kpm,)a02Z:8SrbnBWbNF.p
|
||||
*`lAA!"DHu!!N9#!2?<>rr@_=`P;C]lf>\_nL#ZEJUAQV3QH^N!(m/BNt1\=MKNTP08?&1B7*:f
|
||||
R>]<P:u`O<=QPtM)-"H70uBs[^uN+n36MVs@558@9:gi1Y7`rLnL]")`k66+r*/+a?\@@NO+_QM
|
||||
fj>Y;,Ud7:_VfZ'LN"!%N6:77R\B-7i+c5eV,jC-i2]bT.&C"25H%p)^MOL4kOs,L5A0urJA2Qk
|
||||
A"dS)V4H2&&b1;!&__JQ_Y_7Zi[mWH7iiuHG-A&S?es&-,]R1+<Y4ND>t&^qh-4)9R/[.CI`Pk2
|
||||
^(Yl-pmWqTIu5.?M`&,+)bIQKn9(@WoXu>D(:qM_YA&?a?T,$ik_g,.Bhre_@iQ]]b`bqniJkL9
|
||||
<p_Q_TCC?[n5%s<.TcWMpdoj;rrD16O8*HSpa>P/'eB:ar$9!.rrDX,?ajHk>N*dQ:S6_?^5]m%
|
||||
QFNB\)Hk?s56:PFH*/cc(;%L`_bUuu,d?m29NnP*P\2Jma5^T+D,so6RH(ptnXhPq4rWEc:EBBU
|
||||
ID6%a=FM=7..LjU)(o23e!2smUR0EPTVGD;<%;>3C8fLN%ghU_(f6/5l;rjmi2N=`7u6iSi4fKo
|
||||
4`:Uc;Y0l"[dcP8i^NALdCf5QcT9_,]R"\IWVrH%gA\s<bNEg75YM3prn1YcpVBYZ=2Nf&]^Y*=
|
||||
r,nZ[8+<Y1Lt<QZQ/TQRgut37\j!Bp9<-Y>K'-+Fq[TH>Blg#p/t!,?NN)?Q[$+CL`VJQo!"DHu
|
||||
!!N9#!2?<>rr@_=`P;C]kl1X;U]1=GIfBDq;SW53_FrnGrr=crQ64(0'#<Q&Iko3]N.4PQ[iNB:
|
||||
.Fk,J_>"DVn>hVL#goP1k#86/Z`<O4G[`A:V1t9K31MU0O.fQG'k""aAf_]^q4)#$8!<%A-hnLJ
|
||||
AXlCfd_t*#%PseVQ3&P<9LUW$V,&\>ok@k)&tt-!9!?ed)Yb9e8A8gokPLbB,5nB$"oC5o57hrJ
|
||||
*GKsReD]puR@m\sbMcY7-N7,DddFM'mMbV<?#rD)j(,fY6=G4qAe#h+!D@bUm,@hdGW4S6^[\2U
|
||||
`VM6G[3#PHlf(]r4sB\]1`\59Rp4>/]L/Z(^8#VK6[PtMYV<]<@aPO@I7j#\DX;9$Lg3Aok=,2b
|
||||
rr<Kurr<3#rrAdiJ,]M%Qc6`=@d*glL84bKJq!_"d8IaZ!5_Cghu<[2FD"o/n?.,,B6hKYkgB%Q
|
||||
=R>m/$FmXnn/I3m$8ll%2ID'4;[Z>mUgWIqo'0>'b-/:,NEYqO)HEV/XXN/c$%-gQI'\R5S>X;4
|
||||
!GUK%=8Q97d&Y0+W@H9?Yo)7s16stN;2`@kN[Gc,oFt:Q:8KL)guD,KJ#NAIZ^:(@i@b6,X5e\h
|
||||
4tUJT1t;2jMV]r5r'Os1Kl$CP>2)WQe"G5C/[T_k>O`Je3a6;V(HR#5V5c,fqA#/ENuK2Nq2;7Y
|
||||
7sh6#2mETiY4&O]UKIYT(-@G2,s^Ele2UM1K"EFIJBU#1^Yu-k!8sKaoMi$o!:Z0H\,QHP>p&Rc
|
||||
J+-7kqC5SH!;K-5>Q4]c0"h`/nWS]P0,okiA3WO*VIg_*k^?\#eN<omHFR3;#?j"@/2TBI35sC+
|
||||
?0]t@,aG+PDZ2R2p_2[P*9ESh=2]P5p`5<kPi=Eb\i?)I]fK9>7OK3(4AC37AX9jWSOp@bh5<tR
|
||||
:"ecLdXTt4p<E(c`;]ghrr<D=&b,A;a1;M8rZ1d?^qM3kDcO$hFSd"mY6ao->LGskPC'2%O&Wjj
|
||||
Cc]!&Q/.'c@;`eY1.rmSb)$OupT4Ap_+Qs.pqPbT^YsYSpd<)f*^&s?J2R?,%dG.h3d6AJ4>.bj
|
||||
Wp"Z<>2<B>?$Msa;dCkWRH(kiP=]Pj3+RAY>NUKL\GTY4!/@9K!.o\"!7=]ZrrBo/insb90m%o_
|
||||
;?$X45C_mr?iL,b-1PpR!1:@nGk&6E_^!:o#^mRSRh3EsQtlCI*GG3[`/>L3#@9B7]IkhO/[7.2
|
||||
rEW&)l;RDdBi`2FNCZ0<,MIRI3$7Ao%-p"$(PuH!4ALB!#7Q&&>[7YD<D3g@&>P*!d!->qfSMs/
|
||||
;Ab8>W1f'$0^?:uokZ^dl2L_f^Z;,&?hM`GrrCM2rI:,]J#SdNYE%MG(QJD;kaidh;rc'7DoXfA
|
||||
C9hP7Cq^tcKjbtk>%oq)>*Chp^Yt_F9973D@_8!6milCX_EoN9OuE=?/DBlhl]6PT,5C9?=mu3N
|
||||
i\pl7%h1WLb<N7Z:Z2/#"Ruq>X77!aplFe^5#I]iFmETrrjV5Jf_EYC<lJKeZm5%uVLF6X-\hH+
|
||||
L#*[)pj5$Gph)Y,@^t$Io6=a>=#X:5>8e?KRt@l+Ti2`*@FR#nWI_*2Zoso>V,E!BT=)!(l+T1X
|
||||
!6Kck!5TsGm*C%]rrB''p3j-!C[Q,$J+?u3n4?FQ1Z&34!:#mKT53DSrn.&KrrC1kpjN/BAJ^:i
|
||||
qYL+Y3kSSp!0Ze.rrB8E)ufoh?R?,h@s@Vc\8K%*Bm?nL&mF0X/hLrbQ8XV6Ak]"X,`1>Tin@U`
|
||||
b?52.=P[0;6NtD./,-OP#m(&+"KAg-WNTCsLAde;Mtm8Opj5nS?O6F!;feiq`Z9NJ8RoJ[mk*\2
|
||||
^5'Lq*kI2FodT66-h.\L-./iuA%0oEW8IGuN4>W-i=(>XS8e^NB7Z!fLQ)RTM9"5M$*&4i,sH!'
|
||||
R#:-'es%COS8)-+;O&/#.RW06(d(PT'i'Jr4OmZu;Kks8(pr+&P-gH^(MgIMgPfU<.7Pu\e&)+b
|
||||
$haN"NB6"F^'=?G`uZc$C""_XSPS5-LU::C'j]JEkrMhX`iH(`HM^M87]38#PA1]eRk5RXe"P@b
|
||||
\F9>WiO`r.a1B4@>/]=H8,O%prWmZ9_Umb,RIU%FnrHgeNF0S!ig@FdC>,Na!5lJ`iTGu^%7^#u
|
||||
O,Eo[drgX/`+N3k37Bb<]KWofKC/RIjPT3N22>DLHegVHrN5Tka5;[P!4*]gHm8>cq]^L;?7aO;
|
||||
0kXo-l(>;cm+ZSqnFK>6>6^PEcUmXR\Ne"#!p``SCBQ?H^P0(4C'tuK@Bn="/[&(3#X9=oi2D,I
|
||||
5A2.n$fT-ZrXSiSrK=qRTRICGHR77JXF+$rY(('PLcS=f4A4Zs4`o`B%3"34(q&6.pkuq6-D360
|
||||
?c78<^*1"oeTLXL+ff:]b9hjWX$urON:C\BGe.!P(s-N&\5O]U3YTHoYfo>?ppUKnE`L%VG`5?G
|
||||
@'S5G=2V*r0Goa@UR5Pk;mn>Ca3,3(Z1dh\9EkZ#``MkYlqA(3_n'2U^s9:IL[OE@H?C.)Wk+=i
|
||||
j[L]/e&,&V8dk'VNB@+FB;q<u(WdKk'?);Z:H%cK>0ZPP9>*%G=H%3H9sU)1mS.7\^c0rZ>2&78
|
||||
Y09<-dMT_QrrCuQrrDOU4TiJ@$LpX,T@h.J&C%-JAd-aJ>PgYBggBiJl44D/_(!qmc\?lle1PY:
|
||||
8#XV+`=*ZMkCWV#2S#i&<faB>k^FfS?'^Q0rCca?>5adO0])fS)E5Q<+8@58m3cK8?Uq\_`A_*b
|
||||
L\?&mU1U^Ce90#*dnK99Q(tJ;RCQ%<*t.=>'=khoAcps?YAASn#(raT?B5V4)UIeHr"ET6T_RjM
|
||||
1&Mj&pabm$p,DrbHr6[&](?e'#N`5c"n=[n4t"f9/QU+0;XdJ>F[$^Gpc$%C*g`PcG/:g7IA1Hl
|
||||
*ZpS7eYJO9e%@2NVqjpkiFP%,'l^9;<t]K?1QL\LauI!naKmK?j!sk21BYR@2HO)iYe3neQ<Tkk
|
||||
&>bTRQs<m3@4PEQ:E>f!4N]XB<YhQ<2i?t?iG]XS9+-V[<9&eXYh6Nh]XerK2/lR\`bqXT\(5M_
|
||||
)oQ/t*a4Yaj[QF2-VcO!?et_^.sh3qJ8Y>qlGlNl_;Lq$Btcd?LI9T8pg<%<&,;d5!m9*VpJRI=
|
||||
4FD9eIh-dp,.l=rr@RC.NsGJ4WbGcfP5"qhmgEp&)S".:0."-<P3Pt&Wrfhl)"DN]D.aX:+cZ[L
|
||||
$V6qD8BDdWdYRhUM2[c;%kY`*b5VJ*S)LQ/?OB#64)IdP(WIQM/*$AY/2]L>a^;rX[+F[#WnVb_
|
||||
:ptrZP'`+*)sO?^?\^[3VZ_R;9hX@[kh[JGp=1d;3+dZE>tb*jJ&,=g!8s5G+0en+mCjur,-o`V
|
||||
0A2u<g5B/!K'I]b:Aa!GMf)_di-5c7e8FE[^XTV0]/U8OK02`)cRtu8$oclM]/@m7V-E8W-N2-*
|
||||
0,O3)--p=;=oKKVQ1628:,Og'[<"qXN^o#%UWfrF*2l2a)[.._&ueV<N\8%RkbquI>](nuKVoo[
|
||||
c13p*9JWf*SN>Ca1?GO27W`V]PBMpZ8t2'FaXP4^8\u2;S#8T;-Th;_;+25u&:CbW0DlKAr_Dq@
|
||||
^\retGQ.ZA-]"hu>-I@\rX$RD!%3![hu:IK49#<1'?!oK/Q_atoS*;FPb-n7!;ePN%Y*eOj+7)7
|
||||
%"HA,Q%.I[`8CEpiD]WNpuhYW-3O8J"I/odZT[dgri'hhp:_`VJfC3&95C$o9eP;^jp3?ao`9<g
|
||||
)K[aWOir6#\p^FRqc[-t29!YAYe@\'`o>_#k&$3pgV4a.!9CqFi0'`+B>+EH!7`[u51dcb*j9YD
|
||||
rG,!VCuEc[Y4QR'a&KLUehkPZDb7I1_WEqYT[`U^;@BTc<Nqc[PqM_+qe>Ec+DEFq+d+8p:[VRU
|
||||
"c.XrXpA,g'oR@hN9^i5dXoD%)>m"u%W04"79/GiDA,TJnWM;tkDJ&qO)m6*gjZ0=4NVL$riBY>
|
||||
93&#o[]@q#XoAA5^YkY=r%.?pKt\l2,s"!PBCPcprXheq*lHrR9f0U)pj"]e5>B`,A#K5ee3olP
|
||||
n87g31UFU)dO_0`:W&52Gb;h(g5gs[qbIK54sQV0^AIZB5O`ZjL\qV?bmV@Z'E*Qjrr<K&A&=[0
|
||||
TAeZ<G]uaXTVL!.bn!:km:*<K[&XQOIKCcqQBi;J7;W4>,)E"R<uD4VaO36oC#9a)+5pRa3\Q5(
|
||||
r#`%c^VX/mM.H-(^n6FkqF/>Pn<$o,laiCMXUQn0rr<@M]&!a^c#8dB_^T#9cq_tYIiZb^&!FXN
|
||||
rlO";puc].Z`Q[n4+(-KX@[b_(Q\3\(]MYY!#p$^67WoFS#nbOpqbo+oc"oM;XhTWbGEne?X<Fm
|
||||
2hptgm3Als_g61FT-WIpfC6]BeOL4i=l0L5.C_;qj%:1IDi'KY`E6A7D349o<D',3@"5J%S3Me4
|
||||
P%5P*Ku#;dWFhPTZ@tb)nJJ:UoA"4tN6U\fS!j%k22&m0*#Y/QYS+l_Bhl)PXVs'9<UCN$<i(_E
|
||||
0fY[N[$>>uf])"LCYD^]Wo<2>V+*@mi[*LYZEO2Yn,EA!oRFeP^\)/Fq6AiV+m\*:M*LK&c\-`d
|
||||
e\!W6hgA](TC=%C(L8YGiTngISd=iC`D;6Bmu2,f]Tt`'e%RM`1=uF^ejAaVTn^ka`;]fGHoa+m
|
||||
a'1WM')ggpQfHEH/,fkHnTX[X[.&OXj.?qY'?-OUna5`CTAc>Z_HNf4hT9o<>![@R(8p-iDb\[Q
|
||||
.o2=2!o-oX!,C0+d&LDY>"s9N("`9iiP-F^$+s?a1<bN+F"/$0+O/]m#:%H.G_;5jlUh_2)MGF8
|
||||
oIs8ZUWT8k34cK^<NAV#$+r]AFJ\f)D3t$OP2Y]d1%_:N'cBIs`RBiK>)REaMG\Jj.ZLDdFi^#1
|
||||
APo9u3M3^/nIk<h=]\kgr\?R<0q\97rN\6DOm>O87(`I!<`T/"RiVM/iZ>f=DhS)o+7MrLnN1u.
|
||||
9KK5!3;D#WQJ)kWYDU*RBJtNk7/)h)[Z*rW,W:H(RW:Wtb3<>!ZV4E[jR:aMrX&,bDt_VbnV4g:
|
||||
NtQ[V_B'<Y*VG/Xi]VM)\qZsR96mJj4k(&-\e^."l?\HDX=ArtMm)LE7X9Z1f,$2.HFB>K$-"&V
|
||||
DCOQ%(AP4lpelJC_ql"#WPkAEk4tm"`8!_:_rJ!>%88<%k1!mkbpq;>:E$Jj;SbCoWaR,EV+K:<
|
||||
8i*GB>g!;ipafJWi39`QR,YW2L[sjU_U5'0_GZX[=bLP43-dR7UD_@@V'P3!UJ\GAPBK0*J)Vd3
|
||||
qQd=&f^Jh?&G=%j6iR.=Kfg+?K>E!ZphB73p&WThCZ+(dfrgaFG\:7Uq/*1sqN;5R0!*5uh>@8_
|
||||
L$pR8)ga_<9:SE%(0[Q2Sb8aJF_qPDni+H.pa-NfmhP-%-e%^kN#r]35!O^=kO@)ZDuLFF0>g!^
|
||||
^):T*2q-j#9io5YT>j*PIN\//]59FlDQNL]Y75k8nH\boUBJ/Ng]p`7I6K<oINEs\4s0P9EH/!l
|
||||
?gu@u#`bNq-0;Kt!7LdVqb2BSeo)VVl#:@JGK%lCrr?V5C%qTQRe#3,]^WXjn:iS)h]&h7-\f#Z
|
||||
]BCV,M0P6VY5/t(Ae,[P)hmm5&U93C):&J?_rb3>_RUo0;hV/JqX/d'f7kX\7\rXJSLXCsRkj-*
|
||||
:0b&$FQG`[b'5SX*6+_558f7!W+kXIXXjZ,'VOj<m-pBWE`FD&2(;";W@5a_nr$CCr8[QBrr<2V
|
||||
e35AHSgX;unV7!,AT*>Xg1mke$H'g6)J686rr<Vd?c1kTCW#^f_L;D&dB,9S#2&1/0!2!aILGXf
|
||||
rr@t[1_!)Mdej^9D\0(NGe]DcO@*l1F_tuFBrYClgs6*ASUe@W%7V22GiHKD>t3:=e/$L&'lGUW
|
||||
onA[hL-uh9QIG;V5I<"8iU3'%J&2+5pmpCT%GCNK;t,9ioe.$(iie?U4&'U+mCY)fWGD3ng*MI>
|
||||
et*WGX:[q2k>/S8r%?PY_4_;BT,]Q0?cG"1Wa'>.^8\5?_l$^ECANqXVfQdPBmP5bV*R9;ba26q
|
||||
VJ+[X]q1Rdl1%=q-(^U(>$G+]gT5SLO@'.J`kS8Ic_%!LMSD].Sh9Ik*Og^\r4<ia3.;U`E7n6-
|
||||
Y9i/m=NKZ*B9m)Wbc"<#c!q_N$pAueBpe%NN>f4*d@l)84>dCUR=C4UD[^1S0E$ZYbASq#=u`MA
|
||||
CXe\7,7umr&QNVhSE#=!Y2p"8H8oY>+JCj(j\g5ckOtPG'8uibn);%[K=lF.!2q9$X8#l%m[*nZ
|
||||
`W#ocGOXMh_;79R!7V\#<W"FMMtD`'!0j)7rrDpOrXOGD?fQ_'E6%m<\QA`o]M8D+Wr<_t^:tQ`
|
||||
0(CU5ce''5#uMLo2*Zk"CM<_JZ.Hlr0$hHHN#r@T'1Xh8Hl2VeY`1,NMOl`qA6S;m[?TN<U->7t
|
||||
Sulh\lYO%rAq-45iK_,)J'cU?2u`m;A:DULr;:?frZUKl*[^i?rrCu?4r^iXoF3#9>lBY0%q01!
|
||||
iY/5t]%hePQMmDX@uA:_n>4t/WB%CA>$:'Q>ADV;bDWEL4`q'4j<':$#J0Co]4E#;Be^WC`@aTF
|
||||
Rs,!p%J>^`T+8Cbfcl$l>?Tcs2a^EG==c#)TsW;lZ@^ZWg:aRP@It/lr!b:_HoUWd)9,s9cS;u&
|
||||
N-UEg"c:BC$q0C.VrJSLR$SVIN^!c%SSFCfAej>uPB3Qf#2N;(;@WQmMccLPO!!C9FfWjc9,3_2
|
||||
r[Np;c8;A%%7c1/Wc]i=k+bl/_OIH;\@HWO<;)p0pI_pe&cVh6fXgT"L%)u[p`nd8'B,jL]M7>;
|
||||
26RX;@/(u?SG5PC6L8qE=hHNUCDV^()>LPt,PZ<6_gWl\j1:EIo$Yh@<P.d,^Y-NL@skV2hr/j'
|
||||
g#:I<>Fd;&:PWJcfC);X4B&FZiQi?hRod;5eEqBqCdIb@](>S&I9OM>;hd;%ZZ"gU[5cUS,],T_
|
||||
q!@cqnH["i$G:B;YD?Kp]1XdhpfC?f7o#@LGqGlt3GksAqrhOQ)(3mU>s@9:9[rIXBNjRp]X`T)
|
||||
N6/b_DYua!!!Q<rA)\M=_8BUKa8@'(hBIj#9DF:D61De.hC%g\JGQq0?e[N.FP$B6a,-tkI5^rQ
|
||||
*C;mCh+_sc/D>3W/D?r/RB,'A!#b_nIY+.;A%Yph4AYIEKdlP`8]Qt5BDbDfA9^ch)]=cX:*02s
|
||||
dh=:I32rB;1;W.@W79++HPohe4Pq*G@%i7M9je]cdel/r,:'%GdE-Ib@`u"o>bd%hi\>Jl<Mt\i
|
||||
=.Q*^g0pZc@dpY1(;67uqZp)iWm/\\O5D#u4;Zu"T8&ppQW^ZE%[Z-M(3fr@^:rG>nMBruiEhH$
|
||||
m1[Fl'V6H!jI=)6V:N&Opk%6enG`KEmX1D^[!>Qu:;>.-/gn1!(!ZL7&,[3TidZo7=o2.]h[fB^
|
||||
oh@ZeIgj%3G^ePdp+reskk`9UM.F6Kr))J`1+&GlDC*!HET'RVf[_f)n7VHAWKtP<B)R/I:Q@UP
|
||||
r*N%0,PX(6rXejYDrJceYqKERnEuX9>@0gs2RP0)4:U5`,5t71(<XpL%7A&m\rMDWVo>T(Z>)FC
|
||||
hh^7)nB]f!i#fe=g,HoU)Xrg-W'2AL2oHdGoK1;:_&jL4hnms92Xp0iD7hl=W`eO-'eSkHCS(t"
|
||||
Ih"'<98gE#%Ss0NY=85;VWq?,\k"dVY-[-6&pT3-Vs&EcVG6],nWt]Nr*@FNCm,<Z_f6/^Rc*_+
|
||||
5E<PAgcpY>15lAF/oM.&3h?Ph3clf^Z,9Y<=4q#YKu`5Y=Blj;8pGYaO0.HtNtK?Y3DtT!M4om-
|
||||
.bJQ3)O,5=X[<akMfY1)e:akQ3FY.Q.Q&-9ee!O,W\U1oaXik[!.n3^RT>E^';Cj&:pm9c5E*d*
|
||||
*:2NZQgI?6-HBhDWhKYn_+f2^r"2$M:Q0ZQqc<"]!n&=DocFG:9_`Pc)c%8af"Z!e8CMJS3B`,%
|
||||
iI;YYE8/VO:g@`2U:5i`m7(@&*544m::=]*;iTDr=oJ_PEc9V[5A-,K-><<44?[WA^\CpWi>WYP
|
||||
^<[>0W7^G!_2[Ig?n<0qMR`2enrU!%:1oGDa)1;Vi*X=hfpBU*-1F.8gHUM.0C^.&rA)hknb:"X
|
||||
[d^*l2=V33%YJu)C,uU;)gJc]/&Jk._pNmN)L.g[Nu@7*'"&\76>[Xk/N&M$_Ytb+^KS^mP9(?J
|
||||
*XI0Hp`:<<9_XA@@I_CV/FS4q1iePtQ6)Y/kP1R%Q<hI3]"$NJ&Q<f3$J"Y!J$Z8:=PP5EL383n
|
||||
o))1dEU^5\)oO_+rrBsLEht<F)^+)<*l6u:Fl=c>&V*uO@^&[..TPV0p55D4WBq)bV`HW(itVGV
|
||||
i1G[E.)$RD.J/F6iNhulMEW>6:[llJ,M&d^\&+\G_tr8Kr)Igs^Ct7tR^?8\X5[jk2r\`a7k(r(
|
||||
ft7i<W,!;pRm6fqPM1@:MAod.[aXl<>pJ97M']mfXCZeb_HQ'IM*,13\i&gjpNj9/*3ih;,`bd3
|
||||
d.I3p<^i!)>Ak*X?bBJ+1?l9>FlP+pHmJJQ)Z3>"LPpO#nFeLNgVKW<8n%57V5)@W2"oNQ1fN>9
|
||||
)o11H^$LALe8Fpe]`S36:XH7\mk#cM9fGeQnD0t6q;L=opaVoR0+I5r1/AGUANYHfiRPTiINE7=
|
||||
n>s8GiP,e:EbVi8Iq7AB:MHq8pn$51]Y:tCnon2EQ5phee#g!@WEe^,eaN&>"8s>>C@SJ^cLID.
|
||||
iQcqk3IVn]kD%<ZQ;bs^\Q$W32;qT;1VD`U)E=1-WabK.VAgo'g6NdBeM+5FdJ_t[k$1j&3=KTL
|
||||
bqj_W'!/dBB8#uDCLUc\G3+XUKUkOGe0@^EX.u/.R['DVS,NaXIGu8_iD#r[rr<C>?OXRVidVkO
|
||||
kaLCc:QI_SO+_ulY(,C4dBAl-9R&Z_nLMX%%V4-,?h%HaW>46tQ`4-PLVk%D]u5*#h.\GOiMZT/
|
||||
\&@b3)F%j6!;oNOfan)_T8e+<`*\<A./9_#IhI,pnX]1!HqWIi6+%.oi?Jn!KjoqjcH=Gg9.dTE
|
||||
VH@'Vl+1`3ZRr;5V7!gF&HZS>D;ep`0,SE7.goLR:j"kc.J5*2frqd+4.AdDmHio!dRbgT9=ntY
|
||||
[f6?A;*OYd[eYj]!(>ZDGWg`V.!Q>XiS)u!8JdWMEI:j-q5#(''ieic7>et@emS!Qrn,%<9ft!9
|
||||
-3k^:,k0GYfZY[LJXpNinQ"kPr/\kQ=\"<)SARP17]d(F3c\2JXsO^7(G<S)PnTXP)R>9dlJein
|
||||
<N>([[7J/TJq?3UIpk!\IQ?mVm5Ns!'5Le?_->_V_SaAB4h$(D!,4[NfA[m.,WD;*f.<UR?5)f5
|
||||
`BP#\X&;oci]"Gs4%?eS)b$4por[RtXtCpu!'^hiqEJG=$>Q.?rNj3UkaKMdiGCpWXSsM\P-9Vl
|
||||
9nDXI;=J4SA;7uu["?<8;n)=*N[ZMRe[<,V4;,$4*L<:OqT9o<?GDUb:?#MC(jPYEE@)22LW3`.
|
||||
:BgYBWJ%I/_IG9!j\$-)5!.]a(IK3'@T>tM_c9X"UBn?*36DRcCAs(-PJPc,Cn@e:>$.:8D#XK"
|
||||
YD:%fEiSc4jZFhm'qlE\[7YG-PrBtYi8Z_jc-%4$'`Ff>+TDEN5@ra*VT6SddoOrt!+I%/IieUi
|
||||
@=)_d6QB9pc]"4aBD@8B+5*D]pl2:fE*5J:UGn7@rrC`W]Q%qa/]Gr*&%qG(SYKo1hhYW4O2RAl
|
||||
$@H=XLMQE(:C:l<g(p;l/33nl3i2W"mhYoO?A;U#[9j?qCY+kqZ"dH#T,mp%W%?YGj*N,LD#1`^
|
||||
HXd5]YOp0@!!NK'T9"gm-%=M(ZbDnUNGUVn%3rYQ[?&/+-VX"nKAi@/_`-F#M7bq157Q<2G#m=F
|
||||
^9ut8)#SIM47O!D?c:8->m^3!Ac8CPo2i]V:852ir2T"i(Xk[Kl&aV6J(g)k%5;M-D4Tb!^8H'@
|
||||
[kVSUZ/'.5Ar1)p>3[#;]uI$qW_FK0>cS((jP3qkh6im)I1.:7b:hlA*e-4RG8:&t4AOoD(cj/`
|
||||
?eNlslbsimA]FWuO2n8bh/#e(=PbIcG[Y?r?G(8[$rh3]B8<NWXY1p;mN8-`i[u20fhtT\RW+Ic
|
||||
L!=@6VhKdYfkqFo=.\@8b&`:Td!0@B)RS'=d&2VC\Df48,<&qh,FP:0C-#t'Ug6-DXf#N?GHh-Y
|
||||
Zdc)E/8jJWpIpn);8k9p""IFR@E?Ni2?'3+YrI,C*el<SLnfjEib;>pL;`&Ne$"PF:ob<l<N&rp
|
||||
.9!9>Ua2j5V)_-]Mc,IIVLD4-P)Q-aN2%7[8_luo8l:gVdDf$u;/5?mRVajWn@spp>Mu&A/R-(5
|
||||
-.b78W5E[hG*dM%Vg#%2f,B&:!,8a%DmIP)g^@if/4hnE)RDBD6a/eZ5Ht.fLqd\Sg1&+^mG4]-
|
||||
6`]ul>giOsi@c.>%i"dt_k&UPc\VKnWPRS`bsM/)P?@>cLMT(;D02'Jb>>RTd^>Pr<X[n.`mRL!
|
||||
H-bKpFabA$kDhYJ"l6A8r+4m&$eo%Dg>:^kpijY[^53$Tjbt#,[PrDLl*lu+gg&OeC3l/QXRdsc
|
||||
D[)n`$nd[ucE_mJj-PDJ4B(1nj"&4?r"#$3VD_RLbP8],\-IdKiYW">/Di)"j!`I$1Y`HiL,KmM
|
||||
((,:YFR/NrUK2Ie\4c,WM=_cNS]`A(E4DA-bU[)"\ur1L".A0>ajbC.;A!ld@4FU?2]@j=8TM6+
|
||||
'O2r/ae.,nT>?SH2ZEa_P3-,0*tMrQlKOVchsd+!QlEmIa0GF%IhbPV80[R5[A:B3ldA47"mD6[
|
||||
;&W#Fp'iRTUT`dgmDZ:.eK;+S56u21rr?d3:W5B_X5f6Wps\TD(@Ysl4<NPN%X=1/O2?3tQZJtK
|
||||
U\KV_G!7%R[2PjleeV=J<f(=^7q`$OIGEHKQPJb6.JT%b%6OOqDrD4_13el!qJX=nCVp`tOSH(C
|
||||
9r2NLk-^r&nUaOF8M"r?hJ<*AnI&K6[Pl^'=#q%a!Lh&*CR`M7Y,o#:,5aOm(QQ.HiqR@B]NaDL
|
||||
'mQJ0_WL:W_@I6'LKE'cZFeU/YdV<X[#7hp\P$_-gMJT;\%A4H8T%qbX<O((4$.SGr7LiYI2KJG
|
||||
>]&IJ'QG0E_X,I"3'5/V'6m$'3,9,bD[khgh#@?fQ=TL.@p^P-o,8J$B+*f8nX5[b'h^"!-\oOg
|
||||
UIqs5Xqh>`1&Yb'DD&fP0$:DC8pSZ$AmrTF/isN#rnYSo=DARdb+k_i_UG#(jqtYVV,Ek#?6r?E
|
||||
Pj)/&/>7)+'DTVeIPoWPei[=!4`A9MSG*hD;I4-bQ/88iT)R]OY8SX./:n&8\9=0/i"<r<N$&*$
|
||||
c-\t0Z1pEs%kb`H_.EC82?sJBLU4nW0Q\aPE3pd4/kS*L&;;T1k&'\+&Q`i'm@T'WF/^T`Unp)n
|
||||
[jfTt;NlI<8[dB%>A](?Qbi\%(KpMT`\@1K:ss0`Chts,9\jH(BfC?uVkU<<PkGlPVb^f6HS)$S
|
||||
`iF<]BKC^V=L<W#UBU@b1iK8F1ARcSXjugW<gHe<(RJ(1NXK&nA@T.@6hb)F#Z]uOgTi"Z*l-At
|
||||
W+jtGO/5RJCDq)gVs4h!_)(>em?XM[5+MB)BaoE`RpAMD8@X:LZd\R1FdLWqXYkH0fPU87ZB"6C
|
||||
eNV7JI!pb.Xs;+;^cD*LPr\AO38rh_d)FJ/S(>GQ`F5cJ`o(N%\N"!_rE7[h</\ZAF,rG''dZKX
|
||||
M5]>\AlsGu#E!MGCADIM7h\P]&]<,#L9H<1LTaICPBE@c3mr`Gd]/Pp#hDtRc?6,cOFP)2ogh2d
|
||||
qaYnkj/7nogCF[:Hf8VSIh;C-;V/#*=Emob(Gi]bHu7k[G31<"/m\B(F5qW9\*C/229Zsj`U4nU
|
||||
dJhI1r2fnu_ioT.Ttg4hD>sV*](gXP!6o))B&BiEm+M)nJUSU6H`t>.-]#NUhh_7;peuIsL%,cE
|
||||
VRpj;)X:%+Iu6:RKWa-@]!q3YNVbf8a"N>h/to2NgI\=-SfbLtgO5m7J*hQsa]u_?b4pn7rr<sY
|
||||
%M,U7n371f3&e[F'qOXLB[qlmGud,<&lccuQ6Q"$Xt\k\+JSCjjZ494kfXn_9"f%7V>Kro.Y^7*
|
||||
>SiV&?8u-Z_;fU*QrlL*pjCA3>Wu4Yf9E#)P\$4"-rb)c'\$kUd_FrE!;mOhiF\pbr&jJ(m4$uj
|
||||
$1Ie-q_)=BIN\.jT4tk(r(m2NGf_dX:YHW&0LYXDO)s:9+2aZaq":Cbl'bYLikH6'Vr1@+IN/'1
|
||||
XWm;lXOI1L,JUVKJSdV`O,c`iDhR6'0"qL:BXNW8)A]7/S(fMr9Tr;E89Og#0&>A4oNagZ\Xlsl
|
||||
1j`nS(cR(08MlkgUFkl&M#RJ%$GW)!g?\H%n;"ktIfBE+5EGG\eGfMZ^Q)L:`S^%Vi[\db5=gG#
|
||||
Ie\ZF_iNLCC<5ibOX&[eC0i@fRe<K*f1XUU0`ETt'<UNMl/80=-cOu+i]PXq(NF9(<M!'TBD`(+
|
||||
nFudCj*qL!'2(1&E+C]Qo*h+IZ:mCB*&W0ji#L-;1;_3GV!I-K`8IOp%>8t`o_W9b?o[>)1#*5+
|
||||
b(M9S.Sgesp$l;1%ifFiDO)q+`)VJc<gFOZU8%lAD>Pkh.9GT0;D<d=R\,Z4pg;#Z5D4J6G]MtS
|
||||
]GgDskt]^lmgbOmD/P],OBQ"Jj]d]L?M!6iJM]g)P1o:S2-\.uK\@;d+.'%cIqTqWho;:p=2\P+
|
||||
T,Qt&g9lR?f7"D'6<i85U3OjH9:-W-D_s8jG@PTgD/K2>U.9[!qGk^*a`Tb;`f[=ZGuRi<qY7`^
|
||||
[WcMe%G+Y7IZL:0lFN"XHZ<m9h8^P%Pnf%AVJ)9^<gPSq6)WtnIqEh=>s2TGm@\7.48i[Y@hllV
|
||||
+,*r[NoA8<V8jW!ic)EOk9K"\1hao7%%Aul<Q%_/>*UT1nM0m"C:K]mPtJE^:G'LO[2a?+!jL=g
|
||||
GVA6M2[e3qapWtZ'epPQIS'3k,d<]LThcN(dB/8/:),KicAiBa<kulh7YOnnCE$$0[%a(j@GtR[
|
||||
W?R4&Rq+6F/)r]87Zjs;1UR6CaTFNB80,Zn*\I/8YBXsb!)[N;-cF_oL+PeHBB$$\_O@rhD6K[s
|
||||
da$ueP>0QC^XJ1;hSj7dh5I]Dl">UrmcJ:HkcI!\i31.+/&8_^nD:bQLY6Q&)5hiVX>HZ6g0?f-
|
||||
he&1&,0N[1=t<`gKL7qu+,VoX'"eTl%C0-QAaee',Lj\OPqFKSf-XfK_QG=rl;5YYo^J'[do:']
|
||||
WF$BoX\)`TlVa1F:$%UY?XB)M.FjY3:f==+S5i729Y]mR()I.D@d5Eu_;R*dXUM(e1,W65jNOkc
|
||||
l<l#8P*0r4NN7c2#,_g9WD4(`l9%B08Gm5Q5A+70p2-/5qMpQo%4SC<]YCc"FmBA,r<=9MCYDL;
|
||||
Ve4:.R3X"aHV<>!_\,VA>et1pb9YPA[GT(B(AAT.8SrSQf%1J<8RT&biL2,7<_0#dFCF!.5ou<c
|
||||
DR8kG^m_[4_SbEKNI3='5+u[?c_C2<ep/gRfHBbQD/A1_qubpd5ke2\8UCeN2PGP;IB2+urZo)'
|
||||
/K[ZlnfNei!#Y@c([1Jc=&+%KQn7n9(=QU'ZF9c59='@4XsL`fP3U'-;:F]spq=G+*hQq(fg6V>
|
||||
l:C@kYYW6N,gZ7t,L3VhY:]5ZY7,!T>K&Si/)kro#t$SkeQhi3XbU':&7[2&nYq;`ihZu]k<(%A
|
||||
:)2'?*]3AkK2A<7YF+jG2Ps:KYEe$0X%Nm1^3/PV=`-#-P@1WBp695X!J$r(qUHB`&NEThCg5tb
|
||||
/%X-)9A*=Eo2QOUpi!ut,k3bBL\?1PY2!_M(Y8;hqVIj=hs.Y]?*or!p=D7O6Y.4lhMX;&M<;4p
|
||||
dc0L9068sh=Bf+gg=b"u9fp7:\$05WXBr]RKuK=a3j+=@GW/[:dc&\gq`_Wg8#\jtQBGM,=MgD*
|
||||
qSeH8ZqY+A[C+L2Ks_:!kkE+;0.d,'&]2%_f+3r%>V-fEYEE:hX5dnmEeIYsmi42&;cP&?Zn#T7
|
||||
cAa0N<D\@CjkE69XbiVai\:">d_eQm=uE=Y\l`[g7\*-"MOeAE\O"]B$?N=I'e.[e2bJ(Zo`"pE
|
||||
k>0I"nJ87e0+cq(qL+]^Cf:*m:'b#D#2\ZDK2AjE24[bs7Dq-K^pc6@7Z@SaZ>7QnW^j^uP?D8h
|
||||
hh<o1hqK[A=+S(+GX;7@^($]3)Y]/de8=T[PjnF$lrq]\>1P7sk\YGZFLSa-eD'2R3qCH?c\jn\
|
||||
\l>q<g0NE#h^E_^_EK=AVrN1E_]B0S^\69<Hqe^oG*/N_G*,&=grg%!Il(V;A$XN>o!WT))U61E
|
||||
VtbU-Za[KYP\F;JS->PWOo$a0adS!>DnJ+--2\%9ks$.MH/N?uC&:AKd(3MBO,@fR"S`ksQUkI!
|
||||
8@m'ZXdhHHN-mu7M***]/pK%f)E`1L1"L_"j8T+8)R"q/=lKA\K>/3RO75Zfdu?%F'_->c4NR'#
|
||||
poEW[i\jXbPJU'"K>L,3nG\,(V<.N.Lg3ML2="^$A@t-`rm]/tlpJWgViK&-n<e#TFb7VCZMsp1
|
||||
i%GNpmNUXor*u3r`;]gGnFdcd',ce2h(0i@nANq]ZHTW`TTs?F20J0#X\/9AYaAUO.M82Tm]Yk6
|
||||
r"FkE28>#t-2<P][ZZa+HkQ3)i@RRn@JucS]g#GBbpQ`c5*)i!l/*SqE;#LLhcnrAGRIC<5D4Ie
|
||||
Hp?6Mot+QCkaj!fkNt:&CO1-KO5^TV;S2msXtSbS_nQBQ,k)V'4rF2Ycu$EMi,8YkIn]@HKle8:
|
||||
Y:ekaFDW5Q4%hPE^XTE)F\PX$UQ<^8e[92Ym9`o.P2fHjDm+:&X.-)89)`tk?4t-E576KL1PN#:
|
||||
)_A`7.:]\89UtWu[VLf%4C2fm@k(3qV+ap"Qrh48lHZ3j9ff9aE`#O/:H$F3B%_nd+&W]ImA?-u
|
||||
'[fsbfQqOs/2eE2;G&dMoH?th32D^r(7KqsIh@,MGTYr&Jc&73gYu"`FMA[f)oapDqDX6n2lsf-
|
||||
Ygfa%.c:r!*Q>/:Xi,0cBtBRgp=bgrWEWP@H=bp0TkS@b(MQ<dA>:#3;3Y%T,i^;p-Q,@R36=7Z
|
||||
RVan3RaI^QD)B^UVOD5PVR9,UWbLhGk0s%GrK,Le=+Ku$"b+kn)Mc7,fhU?SY.Q"V:gW#2g\B7U
|
||||
rhFP9h.\o>O7B[;ijb1?^!Lluh&\-mp5"aSj1ntTZ]i=2B?$JuiVoNhV;?T*qucnl5@P.^J$[Fq
|
||||
F5lWl^8'_,L>q2sr@>kd(5h,G<7,fT-A(g>594H=p1Dl;EiQ*aE$<a/,HP(ebGbkoYGBR0r=Eiu
|
||||
mI'B)[CUr/Rp27k\%/rWM'f^A\!\-pTUm#9UQud-Ar]uh<_3mk*D2J*jp/h3pa1KFoS`LW7Je<S
|
||||
o`!,_.3NEpJog^10,ac5>2>u-W(h8ITmSbYrr>=94pl%KB(/6#J+-gaSe^`PNRFA=`8:m8+CBkX
|
||||
DhI!2i_P*Wr8/=K[="H*i#X&DN5b="J@oSoXEZBWYE,Lu=cP"co-i)+<8e83+%@<sV$UT#=UloZ
|
||||
KJBu=i:-Mh4YLk3Q:&YOC05@+Vd>BrL8>e*/h,!.;lbq4(u8-.k1EhHe'0S.k*'iJN[cbY0U/J9
|
||||
XQTLTlQ<JtM62"s]FEl8KmKYSoK>iA[^4P&mJKUkrLk7M(G\X#F/DO/hV)>5p6#&S.oXjgq/qDC
|
||||
XgRqXlrPlHl.4S%kC@7"IK';'V0KB'4ftlGQ9-q(h*XkrnIk]CV(3"nQS;V.L=8u.^=20Bc$?I@
|
||||
cC/XMH"EmDWf((Wl7_jrrJ+pp\(%R1B`A(bZc@B@Z#d#AB#sm_rK)+q?hT,WnL'1k;epHRhT;VH
|
||||
Hm\?5rLe9R5<t-%\^LCait%+kZTj.+)5ghH>MJB<m\OoOU?WO:R/[0U_LJlSNP6n;_r5ON^Y<D;
|
||||
Y^$0J^sd&gGdH>jYl5%#:W>$hc/EM7a2:(iO2cVbe,3q<Y7bMiG^m^(SI\k/=2B2EBNi[2[tcgu
|
||||
O'&lQh#>RWT@2W4h[aGa(&4,N]NP(kk.'Zn&Y.sQ9u:&bM1-LI`*egT+d?X;+dV9#luj#1a4-po
|
||||
<@XCM]GrX869&Z.p_`cXMnqn"7tYHlq=YIU`CCfhJ^6%f$sLYG0r8(enpLH;1K\J%Xs/lI&]EY4
|
||||
$5e)G@AO<OqRe%Go)`8)Gd7_)VTn(\&4`\*`=c:eNMru6a`+?Tka(Acc$#Wih>jKJaK3eW:eas@
|
||||
F<+T'VDZrkV6QHVX<9DJ><-h&QR5;,jF&^fXj*VD=BjQRSQKTlF(u.qUp]m/e_t1+9o&WZHfIQ-
|
||||
=#O*O&Uk5gL/jPp/'m3WSa@J,1IP)f,<+;=iRMJL[Xr[[8'8p%%'rIa?hc"h_4Q?$*RO^Mn`TW#
|
||||
peC78_Z&Q<5VdSqe&+?bd3eBWRUsIkn@+0qmhYgpcj$n)&o%+/VclNag?Q80T;PIGB\$`@L>2Dq
|
||||
?dn3%jSb)[[uS*S&$8fQE:MkX%.OR\Zg;1$iDT;eFEh"D_rT@FrM3o-p^Gl/:,,aK7,2a:KNrbj
|
||||
S)M"JC!r",b;%#u)0H>C,sQ6J1kk2ZSP""bl>fIpe;TW(r\jOsnb^5\#4T3KB0uAfXQZj0c`([U
|
||||
Xl"pk9A?Mn&mo@Z*CEi:\r,M<j&>NV.t&p=Ynq@Fp-/mgg0<2sAHmdbG^e\&i:lTqn\b'qf<2"?
|
||||
L64<=$s*X+Y8);RpA37T3Yf<1D5+DGe0g.Eo2W;S/CrZkFK97_=Lsq+e9.XSmX*2J?e`>GhlpJ*
|
||||
br\a_rY1F[QgqiaL"<?n4>3d%nK6c2LFp.LnE/UsnYV=(?Ocds5k1Jd=?[II2#*9n><P104[SU9
|
||||
p`JJp!/j#gd-e'=)Y[#gQ+`D_^THB(^YkZT&q">7UMCsYho4>U#K+!N"SY-ep[$X]Y%YNfT'"3n
|
||||
[?_7``a?)"Z)@A0ieNRO)/]$0k;GL)n42j6)1tu>4>5\BHtB*XIBulOBO=<3`Yk(dM>B+)-_BGq
|
||||
`d[6rYDo,<iZ;<"!jT/!,8l?Z\-iT]g!44=HtQ?k',+9H;+@E-fY#N<Lr",YI]ci47gik;S5s@B
|
||||
=hY5fb^M%=0/1On?PSr@(sKjn=ZV\CMU+(PAg7bOiTSNU$O;6B8ee6MkX%/1-2CL:*WF'bBH#3Q
|
||||
^)I!m<jqu_pm6)cBR^f4CEJ?r*1pF047I4R\,'>jVh0;U$Wtd5`EkWWSo54[n-?dY.Yks+?/"MO
|
||||
58q6Yr&X?7rrBs-DWgjprLUc:YD9m!:W(]O%tISZe7[9R>E9MDqo@PF]!U":,5bU=KrEX<\o,"I
|
||||
+6$&1AZ+cnR`C;.9+;h1dHp7BXXcg/O+P"dT_MUIYJY8dpab<QJp\<kY7CDIK=i)[rr@h'(XW'j
|
||||
nG`L*Sf7)WK)@@$_5)<k_B'36[(d%E?c1StYUaRfBRV&ND/?SM-Vq3L6l&f=QM/]-6uaV4dauT[
|
||||
f*9dJo5*TK31l!_:-A%kMEQmWSj)-U@e4E]1a>a4I(oc=Tbu`D4GO.K<,#`\9XYZ)R!M^cn<^QZ
|
||||
]a%?uZQ.M)W+!!u%MFe!Ol/6c?^'F'Sf\KegRW*0-2Ynm196Y9>8LSfUua&5bKhIYMC>[;4Fj/`
|
||||
-ig!!-ig!!-j0AJlhuopi7q]86?hc*rr?Ti`r?&8\c*mP70%ekd7"`L;+20!;+20"s4IAP~>
|
||||
endstream
|
||||
endobj
|
||||
13 0 obj
|
||||
21809
|
||||
endobj
|
||||
4 0 obj
|
||||
<<
|
||||
/Type /Page
|
||||
/MediaBox [0 0 763 595]
|
||||
/Parent 3 0 R
|
||||
/Resources <</ProcSet [ /PDF /Text /ImageC ] /Font << /F0 7 0 R /F1 8 0 R /F2 9 0 R >> /XObject << /im1 12 0 R >> >>
|
||||
/Contents 5 0 R
|
||||
/CropBox [0 0 763 595]
|
||||
>>
|
||||
endobj
|
||||
2 0 obj
|
||||
<<
|
||||
/Type /Catalog
|
||||
/Pages 3 0 R
|
||||
>>
|
||||
endobj
|
||||
3 0 obj
|
||||
<<
|
||||
/Type /Pages
|
||||
/Kids [
|
||||
4 0 R ]
|
||||
/Count 1
|
||||
>>
|
||||
endobj
|
||||
xref
|
||||
0 14
|
||||
0000000000 65535 f
|
||||
0000000009 00000 n
|
||||
0000027218 00000 n
|
||||
0000027267 00000 n
|
||||
0000026991 00000 n
|
||||
0000000199 00000 n
|
||||
0000003224 00000 n
|
||||
0000003244 00000 n
|
||||
0000003377 00000 n
|
||||
0000003495 00000 n
|
||||
0000003715 00000 n
|
||||
0000004721 00000 n
|
||||
0000004960 00000 n
|
||||
0000026969 00000 n
|
||||
trailer
|
||||
<<
|
||||
/Size 14
|
||||
/Root 2 0 R
|
||||
/Info 1 0 R
|
||||
>>
|
||||
startxref
|
||||
27327
|
||||
%%EOF
|
||||
11
requirements.txt
Normal file
11
requirements.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
pulp>=2.7.0
|
||||
pandas>=2.0.0
|
||||
numpy>=1.24.0
|
||||
streamlit>=1.28.0
|
||||
plotly>=5.17.0
|
||||
openpyxl>=3.1.0
|
||||
networkx>=3.2.0
|
||||
scipy>=1.11.0
|
||||
airportsdata>=20240101
|
||||
folium>=0.14.0
|
||||
streamlit-folium>=0.22.0
|
||||
37
run_pipeline.bat
Normal file
37
run_pipeline.bat
Normal file
@@ -0,0 +1,37 @@
|
||||
@echo off
|
||||
echo ============================================================
|
||||
echo OARMP – Aircraft Routing and Maintenance Planning Pipeline
|
||||
echo ============================================================
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo.
|
||||
echo [00] Setup...
|
||||
.venv\Scripts\python.exe scripts\00_setup_project.py
|
||||
if %ERRORLEVEL% neq 0 ( echo ERRO no setup & pause & exit /b 1 )
|
||||
|
||||
echo.
|
||||
echo [01] Inspecionando arquivos...
|
||||
.venv\Scripts\python.exe scripts\01_inspect_inputs.py
|
||||
if %ERRORLEVEL% neq 0 ( echo ERRO na inspecao & pause & exit /b 1 )
|
||||
|
||||
echo.
|
||||
echo [02] Construindo referencia de frota...
|
||||
.venv\Scripts\python.exe scripts\02_build_fleet_reference.py
|
||||
if %ERRORLEVEL% neq 0 ( echo ERRO na frota & pause & exit /b 1 )
|
||||
|
||||
echo.
|
||||
echo [03] Executando otimizacao...
|
||||
.venv\Scripts\python.exe scripts\03_run_optimization_pipeline.py
|
||||
if %ERRORLEVEL% neq 0 ( echo ERRO na otimizacao & pause & exit /b 1 )
|
||||
|
||||
echo.
|
||||
echo [04] Gerando flight strings...
|
||||
.venv\Scripts\python.exe scripts\04_generate_flight_strings.py
|
||||
if %ERRORLEVEL% neq 0 ( echo ERRO na geracao & pause & exit /b 1 )
|
||||
|
||||
echo.
|
||||
echo ============================================================
|
||||
echo Pipeline concluido com sucesso!
|
||||
echo Para visualizar o dashboard: scripts\05_run_dashboard.bat
|
||||
echo ============================================================
|
||||
pause
|
||||
50
scripts/00_setup_project.py
Normal file
50
scripts/00_setup_project.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
Script 00 – Project setup.
|
||||
|
||||
Creates all required directories and validates that the raw input files exist.
|
||||
Run once after cloning the repository.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.routing_engine.config import DEFAULT_CONFIG
|
||||
|
||||
cfg = DEFAULT_CONFIG
|
||||
|
||||
DIRS = [
|
||||
cfg.raw_index_dir,
|
||||
cfg.processed_dir,
|
||||
cfg.reference_dir,
|
||||
cfg.quality_dir,
|
||||
cfg.schedules_dir,
|
||||
cfg.figures_dir,
|
||||
cfg.exports_dir,
|
||||
ROOT / "outputs" / "dashboards",
|
||||
]
|
||||
|
||||
print("Creating project directories…")
|
||||
for d in DIRS:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
print(f" ok {d.relative_to(ROOT)}")
|
||||
|
||||
print("\nChecking raw input files…")
|
||||
REQUIRED = [cfg.flight_schedule_file, cfg.aircraft_file, cfg.checks_file, cfg.airports_file]
|
||||
all_ok = True
|
||||
for fname in REQUIRED:
|
||||
p = cfg.raw_dir / fname
|
||||
if p.exists():
|
||||
size = p.stat().st_size
|
||||
print(f" ok {fname} ({size:,} bytes)")
|
||||
else:
|
||||
print(f" ✗ {fname} NOT FOUND")
|
||||
all_ok = False
|
||||
|
||||
if all_ok:
|
||||
print("\n[OK] Setup complete. Run scripts in order: 01 -> 02 -> 03 -> 04.")
|
||||
else:
|
||||
print("\n[!] Some raw files are missing. Place them in the 'raw/' folder and re-run.")
|
||||
sys.exit(1)
|
||||
41
scripts/01_inspect_inputs.py
Normal file
41
scripts/01_inspect_inputs.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
Script 01 – Inspect raw input files.
|
||||
|
||||
Prints metadata (encoding, separator, column names, sample rows) for every CSV
|
||||
in the raw/ folder and saves a JSON report to data/raw_index/.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.routing_engine.config import DEFAULT_CONFIG
|
||||
from src.routing_engine.inspect_files import inspect_all
|
||||
|
||||
cfg = DEFAULT_CONFIG
|
||||
|
||||
print(f"Inspecting files in: {cfg.raw_dir}\n")
|
||||
meta = inspect_all(cfg.raw_dir)
|
||||
|
||||
for name, info in meta.items():
|
||||
print(f"-- {name} -------------------------------------------")
|
||||
if "error" in info:
|
||||
print(f" ERROR: {info['error']}")
|
||||
continue
|
||||
print(f" Encoding : {info['encoding']}")
|
||||
print(f" Separator : {repr(info['separator'])}")
|
||||
print(f" Columns : {info['columns']}")
|
||||
if info["sample_rows"]:
|
||||
print(f" First row : {info['sample_rows'][0]}")
|
||||
print()
|
||||
|
||||
# Save report
|
||||
out = cfg.raw_index_dir / "file_inventory.json"
|
||||
cfg.raw_index_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(out, "w", encoding="utf-8") as f:
|
||||
json.dump(meta, f, ensure_ascii=False, indent=2, default=str)
|
||||
|
||||
print(f"Report saved -> {out}")
|
||||
64
scripts/02_build_fleet_reference.py
Normal file
64
scripts/02_build_fleet_reference.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
Script 02 – Build fleet reference table.
|
||||
|
||||
Reads AERONAVES.csv + CHECKS.csv, computes TTM and the full check-cycle
|
||||
sequence for each aircraft, and saves the reference to data/reference/.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import pandas as pd
|
||||
from datetime import datetime
|
||||
|
||||
from src.routing_engine.config import DEFAULT_CONFIG
|
||||
from src.routing_engine.inspect_files import read_aircraft, read_checks
|
||||
from src.routing_engine.ingest import build_fleet, _check_cycles
|
||||
|
||||
cfg = DEFAULT_CONFIG
|
||||
|
||||
aircraft_df = read_aircraft(cfg.raw_dir / cfg.aircraft_file)
|
||||
checks_df = read_checks(cfg.raw_dir / cfg.checks_file)
|
||||
|
||||
print("Aircraft loaded:")
|
||||
print(aircraft_df.to_string(index=False))
|
||||
print()
|
||||
|
||||
print("Checks loaded:")
|
||||
print(checks_df.to_string(index=False))
|
||||
print()
|
||||
|
||||
# Build fleet
|
||||
planning_start = datetime(cfg.planning_year, 3, 18) # first OFRAG departure in the sample data
|
||||
fleet = build_fleet(aircraft_df, checks_df, planning_start)
|
||||
|
||||
print("Fleet reference:")
|
||||
for _, row in fleet.iterrows():
|
||||
print(f"\n {row['tail_number']} ({row['model']}) FH total = {row['fh_total']:.0f}")
|
||||
print(f" TTM before first check: {row['ttm_hours']:.1f} FH")
|
||||
for i, c in enumerate(row["checks"]):
|
||||
print(f" Cycle {i}: threshold={c['fh_threshold']:.0f} FH, "
|
||||
f"TTM={c['ttm']:.0f} FH, duration={c['duration_hours']:.0f} h")
|
||||
|
||||
# Save to reference
|
||||
cfg.reference_dir.mkdir(parents=True, exist_ok=True)
|
||||
out_path = cfg.reference_dir / "fleet_reference.csv"
|
||||
|
||||
flat_rows = []
|
||||
for _, row in fleet.iterrows():
|
||||
flat_rows.append(
|
||||
{
|
||||
"tail_number": row["tail_number"],
|
||||
"model": row["model"],
|
||||
"fh_total": row["fh_total"],
|
||||
"ttm_hours_cycle0": row["ttm_hours"],
|
||||
"n_check_cycles": len(row["checks"]),
|
||||
"cycles_json": str(row["checks"]),
|
||||
}
|
||||
)
|
||||
|
||||
pd.DataFrame(flat_rows).to_csv(out_path, index=False)
|
||||
print(f"\nFleet reference saved -> {out_path}")
|
||||
44
scripts/03_run_optimization_pipeline.py
Normal file
44
scripts/03_run_optimization_pipeline.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
Script 03 – Run the full optimisation pipeline.
|
||||
|
||||
Loads data, builds the network, runs Column Generation + B&B, and prints
|
||||
the solution. All outputs are saved to outputs/ automatically.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.routing_engine import RoutingPipeline, DEFAULT_CONFIG
|
||||
|
||||
cfg = DEFAULT_CONFIG
|
||||
# Adjust parameters here if needed:
|
||||
# cfg.tat_minutes = 60
|
||||
# cfg.mip_time_limit_seconds = 120
|
||||
|
||||
pipe = RoutingPipeline(cfg)
|
||||
result = pipe.run(save_outputs=True)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("SOLUTION SUMMARY")
|
||||
print("=" * 60)
|
||||
s = result["summary"]
|
||||
for k, v in s.items():
|
||||
print(f" {k:<35} {v}")
|
||||
|
||||
print("\n-- Schedule ----------------------------------------------")
|
||||
sched = result["schedule"]
|
||||
if not sched.empty:
|
||||
print(sched[["aircraft", "ofrag_id", "departure", "arrival", "flight_hours", "maintenance_before"]].to_string(index=False))
|
||||
|
||||
print("\n-- Maintenance events -------------------------------------")
|
||||
maint = result["maintenance"]
|
||||
if maint.empty:
|
||||
print(" No forced maintenance events.")
|
||||
else:
|
||||
print(maint.to_string(index=False))
|
||||
|
||||
print("\n-- Fleet utilisation --------------------------------------")
|
||||
print(result["fleet"].to_string(index=False))
|
||||
75
scripts/04_generate_flight_strings.py
Normal file
75
scripts/04_generate_flight_strings.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""
|
||||
Script 04 – Generate and export flight strings.
|
||||
|
||||
Reads the saved solution CSVs and generates a per-aircraft "flight string"
|
||||
report in human-readable format (console + Excel export).
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from src.routing_engine.config import DEFAULT_CONFIG
|
||||
|
||||
cfg = DEFAULT_CONFIG
|
||||
|
||||
sched_path = cfg.schedules_dir / "flight_strings.csv"
|
||||
maint_path = cfg.schedules_dir / "maintenance_events.csv"
|
||||
|
||||
if not sched_path.exists():
|
||||
print("No schedule found. Run script 03 first.")
|
||||
sys.exit(1)
|
||||
|
||||
sched = pd.read_csv(sched_path, parse_dates=["departure", "arrival"])
|
||||
maint = pd.read_csv(maint_path, parse_dates=["maint_start", "maint_end"]) if maint_path.exists() else pd.DataFrame()
|
||||
|
||||
print("=" * 70)
|
||||
print("FLIGHT STRINGS PER AIRCRAFT")
|
||||
print("=" * 70)
|
||||
|
||||
for aircraft, group in sched.groupby("aircraft"):
|
||||
print(f"\n{'-'*70}")
|
||||
print(f" Aeronave: {aircraft}")
|
||||
print(f"{'-'*70}")
|
||||
|
||||
total_fh = 0.0
|
||||
for _, row in group.sort_values("departure").iterrows():
|
||||
maint_flag = " <- MANUTENCAO ANTES" if row.get("maintenance_before") else ""
|
||||
dep = pd.to_datetime(row["departure"])
|
||||
arr = pd.to_datetime(row["arrival"])
|
||||
fh = float(row.get("flight_hours", 0))
|
||||
total_fh += fh
|
||||
print(
|
||||
f" {row['ofrag_id']:12s} "
|
||||
f"{dep.strftime('%d/%m %H:%M')} -> {arr.strftime('%d/%m %H:%M')} "
|
||||
f"({fh:.2f} FH){maint_flag}"
|
||||
)
|
||||
|
||||
print(f" {'TOTAL':12s} {total_fh:.2f} FH")
|
||||
|
||||
if not maint.empty:
|
||||
ac_maint = maint[maint["aircraft"] == aircraft]
|
||||
if not ac_maint.empty:
|
||||
print(f"\n Eventos de manutencao:")
|
||||
for _, ev in ac_maint.iterrows():
|
||||
print(
|
||||
f" CHECK (ciclo {int(ev['check_cycle_index'])}) "
|
||||
f"{pd.to_datetime(ev['maint_start']).strftime('%d/%m %H:%M')} -> "
|
||||
f"{pd.to_datetime(ev['maint_end']).strftime('%d/%m %H:%M')} "
|
||||
f"TTM perdido: {ev['ttm_loss_hours']:.2f} FH"
|
||||
)
|
||||
|
||||
# Export to Excel
|
||||
out_xlsx = cfg.exports_dir / "flight_strings.xlsx"
|
||||
try:
|
||||
with pd.ExcelWriter(out_xlsx, engine="openpyxl") as writer:
|
||||
sched.to_excel(writer, sheet_name="Escala", index=False)
|
||||
if not maint.empty:
|
||||
maint.to_excel(writer, sheet_name="Manutencao", index=False)
|
||||
print(f"\n\nExportado para Excel -> {out_xlsx}")
|
||||
except ImportError:
|
||||
print("\n(openpyxl not installed – Excel export skipped)")
|
||||
5
scripts/05_run_dashboard.bat
Normal file
5
scripts/05_run_dashboard.bat
Normal file
@@ -0,0 +1,5 @@
|
||||
@echo off
|
||||
echo Iniciando dashboard OARMP...
|
||||
cd /d "%~dp0.."
|
||||
.venv\Scripts\streamlit.exe run app\dashboard.py --server.port 8501
|
||||
pause
|
||||
6
src/routing_engine/__init__.py
Normal file
6
src/routing_engine/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Aircraft Routing Engine – Set Partitioning / Column Generation / Branch & Bound."""
|
||||
|
||||
from .config import RoutingConfig, DEFAULT_CONFIG
|
||||
from .pipeline import RoutingPipeline
|
||||
|
||||
__all__ = ["RoutingConfig", "DEFAULT_CONFIG", "RoutingPipeline"]
|
||||
74
src/routing_engine/config.py
Normal file
74
src/routing_engine/config.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Centralised configuration for the routing engine."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
@dataclass
|
||||
class RoutingConfig:
|
||||
# ── Paths ──────────────────────────────────────────────────────────────────
|
||||
project_root: Path = field(default_factory=lambda: PROJECT_ROOT)
|
||||
|
||||
@property
|
||||
def raw_dir(self) -> Path:
|
||||
return self.project_root / "raw"
|
||||
|
||||
@property
|
||||
def raw_index_dir(self) -> Path:
|
||||
return self.project_root / "data" / "raw_index"
|
||||
|
||||
@property
|
||||
def processed_dir(self) -> Path:
|
||||
return self.project_root / "data" / "processed"
|
||||
|
||||
@property
|
||||
def reference_dir(self) -> Path:
|
||||
return self.project_root / "data" / "reference"
|
||||
|
||||
@property
|
||||
def quality_dir(self) -> Path:
|
||||
return self.project_root / "data" / "quality"
|
||||
|
||||
@property
|
||||
def schedules_dir(self) -> Path:
|
||||
return self.project_root / "outputs" / "schedules"
|
||||
|
||||
@property
|
||||
def figures_dir(self) -> Path:
|
||||
return self.project_root / "outputs" / "figures"
|
||||
|
||||
@property
|
||||
def exports_dir(self) -> Path:
|
||||
return self.project_root / "outputs" / "exports"
|
||||
|
||||
# ── File names (auto-detected if left as empty string) ─────────────────────
|
||||
flight_schedule_file: str = "ESCALA DE VOO MODELO 1.csv"
|
||||
aircraft_file: str = "AERONAVES.csv"
|
||||
checks_file: str = "CHECKS.csv"
|
||||
airports_file: str = "AIRPORTS.csv"
|
||||
|
||||
# ── Calendar / planning ────────────────────────────────────────────────────
|
||||
planning_year: int = field(default_factory=lambda: datetime.now().year)
|
||||
maintenance_base_code: str = "SBMN"
|
||||
|
||||
# ── Operational constraints ────────────────────────────────────────────────
|
||||
tat_minutes: int = 60 # minimum turnaround time between OFRAGs
|
||||
aircraft_availability_offset_hours: int = 0 # hours before first OFRAG aircraft is available
|
||||
|
||||
# ── Optimiser parameters ───────────────────────────────────────────────────
|
||||
big_m: float = 1e6 # penalty for uncovered OFRAGs (artificial variable)
|
||||
cg_tolerance: float = 1e-6 # column-generation stopping threshold on reduced cost
|
||||
max_cg_iterations: int = 200
|
||||
mip_time_limit_seconds: int = 300
|
||||
mip_gap: float = 0.0 # optimality gap (0 = exact)
|
||||
|
||||
# ── Logging ────────────────────────────────────────────────────────────────
|
||||
log_level: str = "INFO"
|
||||
|
||||
|
||||
DEFAULT_CONFIG = RoutingConfig()
|
||||
322
src/routing_engine/ingest.py
Normal file
322
src/routing_engine/ingest.py
Normal file
@@ -0,0 +1,322 @@
|
||||
"""
|
||||
Data ingestion layer.
|
||||
|
||||
Reads raw files, parses dates/times, aggregates legs into OFRAG profiles,
|
||||
and computes TTM (Time to Maintenance) per aircraft.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from .config import RoutingConfig
|
||||
from .inspect_files import read_aircraft, read_checks, read_airports, read_schedule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BR_MONTHS = {
|
||||
"jan": 1, "fev": 2, "mar": 3, "abr": 4,
|
||||
"mai": 5, "jun": 6, "jul": 7, "ago": 8,
|
||||
"set": 9, "out": 10, "nov": 11, "dez": 12,
|
||||
}
|
||||
|
||||
|
||||
# ── Low-level parsers ─────────────────────────────────────────────────────────
|
||||
|
||||
def _parse_br_date(date_str: str, year: int) -> Optional[datetime]:
|
||||
"""Parse 'DD/MM/AAAA' or legacy 'DD/mon' → datetime."""
|
||||
try:
|
||||
parts = date_str.strip().split("/")
|
||||
day = int(parts[0])
|
||||
if len(parts) == 3: # DD/MM/AAAA
|
||||
return datetime(int(parts[2]), int(parts[1]), day)
|
||||
# Legacy: DD/mon (uses fallback year from config)
|
||||
mon = _BR_MONTHS.get(parts[1].lower(), 0)
|
||||
return datetime(year, mon, day) if mon else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_time(time_str: str) -> Optional[Tuple[int, int]]:
|
||||
"""Parse 'HH:MM' or 'HH:MM:SS' → (hour, minute). Returns None on failure."""
|
||||
try:
|
||||
parts = time_str.strip().split(":")
|
||||
return int(parts[0]), int(parts[1])
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _parse_flight_hours(tempo_str: str) -> float:
|
||||
"""Convert 'HH:MM' flight-time string → decimal hours."""
|
||||
try:
|
||||
parts = tempo_str.strip().split(":")
|
||||
return int(parts[0]) + int(parts[1]) / 60.0
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
# ── Schedule parsing ──────────────────────────────────────────────────────────
|
||||
|
||||
def _parse_schedule_datetimes(df: pd.DataFrame, year: int) -> pd.DataFrame:
|
||||
"""
|
||||
Add DATETIME_DEP and DATETIME_ARR columns to the schedule DataFrame.
|
||||
|
||||
The DATA column holds the departure date; arrival is the same date unless
|
||||
the arrival time string contains a second ':' colon (e.g. '00:30:00'),
|
||||
which signals midnight crossing → arrival is departure date + 1 day.
|
||||
"""
|
||||
dep_dts, arr_dts, fh_vals = [], [], []
|
||||
|
||||
for _, row in df.iterrows():
|
||||
base_date = _parse_br_date(str(row["DATA"]), year)
|
||||
if base_date is None:
|
||||
dep_dts.append(pd.NaT)
|
||||
arr_dts.append(pd.NaT)
|
||||
fh_vals.append(0.0)
|
||||
continue
|
||||
|
||||
dep_t = _parse_time(str(row["HORA_DEP"]))
|
||||
arr_str = str(row["HORA_ARR"])
|
||||
arr_t = _parse_time(arr_str)
|
||||
|
||||
if dep_t is None or arr_t is None:
|
||||
dep_dts.append(pd.NaT)
|
||||
arr_dts.append(pd.NaT)
|
||||
fh_vals.append(0.0)
|
||||
continue
|
||||
|
||||
dep_dt = base_date.replace(hour=dep_t[0], minute=dep_t[1])
|
||||
arr_dt = base_date.replace(hour=arr_t[0], minute=arr_t[1])
|
||||
|
||||
# Midnight crossing: arrival string has 3 colon-separated parts OR arr <= dep
|
||||
midnight_flag = arr_str.count(":") >= 2 or arr_dt <= dep_dt
|
||||
if midnight_flag:
|
||||
arr_dt += timedelta(days=1)
|
||||
|
||||
dep_dts.append(dep_dt)
|
||||
arr_dts.append(arr_dt)
|
||||
fh_vals.append(_parse_flight_hours(str(row["TEMPO_VOO"])))
|
||||
|
||||
df = df.copy()
|
||||
df["DATETIME_DEP"] = dep_dts
|
||||
df["DATETIME_ARR"] = arr_dts
|
||||
df["FH_LEG"] = fh_vals
|
||||
return df.dropna(subset=["DATETIME_DEP", "DATETIME_ARR"]).reset_index(drop=True)
|
||||
|
||||
|
||||
# ── OFRAG aggregation ─────────────────────────────────────────────────────────
|
||||
|
||||
def build_ofrags(schedule_df: pd.DataFrame, base_codes: List[str]) -> pd.DataFrame:
|
||||
"""
|
||||
Group schedule legs by OFRAG number and produce one row per OFRAG with:
|
||||
ofrag_id, departure (first leg dep at base), arrival (last leg arr at base),
|
||||
flight_hours (sum), origin, destination, starts_at_base, ends_at_base.
|
||||
"""
|
||||
records = []
|
||||
for ofrag_num, group in schedule_df.groupby("OFRAG", sort=False):
|
||||
group = group.sort_values("DATETIME_DEP").reset_index(drop=True)
|
||||
|
||||
first = group.iloc[0]
|
||||
last = group.iloc[-1]
|
||||
|
||||
total_fh = group["FH_LEG"].sum()
|
||||
origin = str(first["DEP"]).strip().upper()
|
||||
destination = str(last["ARR"]).strip().upper()
|
||||
|
||||
starts_base = origin in base_codes
|
||||
ends_base = destination in base_codes
|
||||
|
||||
records.append(
|
||||
{
|
||||
"ofrag_id": f"OFRAG{int(ofrag_num):03d}",
|
||||
"ofrag_num": int(ofrag_num),
|
||||
"departure": first["DATETIME_DEP"],
|
||||
"arrival": last["DATETIME_ARR"],
|
||||
"flight_hours": round(total_fh, 3),
|
||||
"origin": origin,
|
||||
"destination": destination,
|
||||
"starts_at_base": starts_base,
|
||||
"ends_at_base": ends_base,
|
||||
"n_legs": len(group),
|
||||
"missions": ",".join(group["MISSAO"].dropna().unique()),
|
||||
}
|
||||
)
|
||||
|
||||
df = pd.DataFrame(records).sort_values("departure").reset_index(drop=True)
|
||||
logger.info(
|
||||
"Built %d OFRAGs (%d start+end at base)",
|
||||
len(df),
|
||||
df["starts_at_base"].sum() & df["ends_at_base"].sum(),
|
||||
)
|
||||
return df
|
||||
|
||||
|
||||
# ── TTM computation ────────────────────────────────────────────────────────────
|
||||
|
||||
def _check_cycles(current_fh: float, thresholds: List[float], durations_days: List[float]) -> List[Dict]:
|
||||
"""
|
||||
Return the ordered sequence of upcoming maintenance checks for an aircraft
|
||||
currently at *current_fh* total flight hours.
|
||||
|
||||
Each entry: {'fh_threshold': …, 'ttm': …, 'duration_hours': …}
|
||||
The first entry is the immediately upcoming check; subsequent entries follow.
|
||||
"""
|
||||
# Sort checks by threshold
|
||||
paired = sorted(zip(thresholds, durations_days), key=lambda x: x[0])
|
||||
cycles = []
|
||||
prev_threshold = current_fh
|
||||
for threshold, days in paired:
|
||||
if threshold > current_fh:
|
||||
ttm = threshold - prev_threshold if cycles else threshold - current_fh
|
||||
cycles.append(
|
||||
{
|
||||
"fh_threshold": threshold,
|
||||
"ttm": ttm,
|
||||
"duration_hours": days * 24.0,
|
||||
}
|
||||
)
|
||||
prev_threshold = threshold
|
||||
|
||||
# Add a synthetic final cycle using the last interval (extrapolation)
|
||||
if paired:
|
||||
last_t, last_d = paired[-1]
|
||||
second_last_t = paired[-2][0] if len(paired) >= 2 else 0.0
|
||||
extra_ttm = last_t - second_last_t
|
||||
cycles.append(
|
||||
{
|
||||
"fh_threshold": last_t + extra_ttm,
|
||||
"ttm": extra_ttm,
|
||||
"duration_hours": last_d * 24.0,
|
||||
}
|
||||
)
|
||||
|
||||
return cycles
|
||||
|
||||
|
||||
def build_fleet(
|
||||
aircraft_df: pd.DataFrame,
|
||||
checks_df: pd.DataFrame,
|
||||
planning_start: datetime,
|
||||
) -> pd.DataFrame:
|
||||
"""
|
||||
Combine aircraft and checks tables to produce the fleet reference DataFrame.
|
||||
|
||||
Columns: tail_number, model, fh_total, checks (list of cycle dicts),
|
||||
ttm_hours (first upcoming TTM), available_from.
|
||||
"""
|
||||
thresholds = checks_df["fh_threshold"].astype(float).tolist()
|
||||
durations = checks_df["duration_days"].astype(float).tolist()
|
||||
|
||||
records = []
|
||||
for _, row in aircraft_df.iterrows():
|
||||
fh = float(row["fh_total"])
|
||||
cycles = _check_cycles(fh, thresholds, durations)
|
||||
records.append(
|
||||
{
|
||||
"tail_number": str(row["tail_number"]).strip(),
|
||||
"model": str(row.get("model", "")).strip(),
|
||||
"fh_total": fh,
|
||||
"checks": cycles,
|
||||
"ttm_hours": cycles[0]["ttm"] if cycles else 0.0,
|
||||
"available_from": planning_start,
|
||||
}
|
||||
)
|
||||
|
||||
return pd.DataFrame(records)
|
||||
|
||||
|
||||
# ── Public entry points ───────────────────────────────────────────────────────
|
||||
|
||||
def load_from_dfs(dfs: Dict[str, pd.DataFrame], cfg: RoutingConfig) -> Dict[str, pd.DataFrame]:
|
||||
"""
|
||||
Same processing as load_all but accepts pre-loaded DataFrames.
|
||||
|
||||
dfs keys: 'aircraft', 'checks', 'airports', 'schedule'
|
||||
The DataFrames may use the original CSV column names (synonym mapping is applied).
|
||||
'schedule' must already have columns: DATA, ETAPA, DEP, ARR, HORA_DEP,
|
||||
HORA_ARR, TEMPO_VOO, SEGMTO, MISSAO, OFRAG.
|
||||
"""
|
||||
from .inspect_files import _map_columns, _AIRCRAFT_SYNONYMS, _CHECK_SYNONYMS, _AIRPORT_SYNONYMS
|
||||
|
||||
def _norm(df: pd.DataFrame, synonyms: Dict) -> pd.DataFrame:
|
||||
df = df.copy().dropna(how="all")
|
||||
mapping = _map_columns(list(df.columns), synonyms)
|
||||
return df.rename(columns={v: k for k, v in mapping.items()})
|
||||
|
||||
aircraft_df = _norm(dfs["aircraft"], _AIRCRAFT_SYNONYMS)
|
||||
checks_df = _norm(dfs["checks"], _CHECK_SYNONYMS)
|
||||
airports_df = _norm(dfs["airports"], _AIRPORT_SYNONYMS)
|
||||
schedule_raw = dfs["schedule"].copy()
|
||||
|
||||
# Drop obviously empty rows
|
||||
for col in ("tail_number",):
|
||||
if col in aircraft_df.columns:
|
||||
aircraft_df = aircraft_df.dropna(subset=[col])
|
||||
for col in ("fh_threshold",):
|
||||
if col in checks_df.columns:
|
||||
checks_df = checks_df.dropna(subset=[col])
|
||||
|
||||
# Maintenance bases – accept "1", "True", "true", 1
|
||||
base_col = airports_df.get("is_maintenance_base", pd.Series(dtype=object))
|
||||
base_mask = base_col.astype(str).str.strip().isin(["1", "True", "true"])
|
||||
base_codes = airports_df.loc[base_mask, "airport_code"].str.upper().tolist() if "airport_code" in airports_df.columns else []
|
||||
if not base_codes:
|
||||
base_codes = [cfg.maintenance_base_code]
|
||||
logger.info("Maintenance base(s): %s", base_codes)
|
||||
|
||||
schedule_df = _parse_schedule_datetimes(schedule_raw, cfg.planning_year)
|
||||
ofrags_all = build_ofrags(schedule_df, base_codes)
|
||||
ofrags = ofrags_all[ofrags_all["starts_at_base"] & ofrags_all["ends_at_base"]].copy().reset_index(drop=True)
|
||||
logger.info("OFRAGs after base filter: %d / %d", len(ofrags), len(ofrags_all))
|
||||
|
||||
first_dep = ofrags["departure"].min() if not ofrags.empty else datetime.now()
|
||||
planning_start = first_dep.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
fleet = build_fleet(aircraft_df, checks_df, planning_start)
|
||||
|
||||
return {"ofrags": ofrags, "fleet": fleet, "airports": airports_df, "schedule": schedule_df}
|
||||
|
||||
|
||||
def load_all(cfg: RoutingConfig) -> Dict[str, pd.DataFrame]:
|
||||
"""
|
||||
Read all raw files and return a dict with keys:
|
||||
'ofrags' – OFRAG profiles (only those that start and end at the maintenance base)
|
||||
'fleet' – fleet reference with TTM cycles
|
||||
'airports'– airport table
|
||||
"""
|
||||
raw = cfg.raw_dir
|
||||
|
||||
aircraft_df = read_aircraft(raw / cfg.aircraft_file)
|
||||
checks_df = read_checks(raw / cfg.checks_file)
|
||||
airports_df = read_airports(raw / cfg.airports_file)
|
||||
schedule_raw = read_schedule(raw / cfg.flight_schedule_file)
|
||||
|
||||
# Maintenance-base ICAO codes
|
||||
base_mask = airports_df.get("is_maintenance_base", pd.Series(dtype=int)).astype(int) == 1
|
||||
base_codes = airports_df.loc[base_mask, "airport_code"].str.upper().tolist()
|
||||
if not base_codes:
|
||||
base_codes = [cfg.maintenance_base_code]
|
||||
logger.info("Maintenance base(s): %s", base_codes)
|
||||
|
||||
# Parse schedule
|
||||
schedule_df = _parse_schedule_datetimes(schedule_raw, cfg.planning_year)
|
||||
|
||||
# Build OFRAG table
|
||||
ofrags_all = build_ofrags(schedule_df, base_codes)
|
||||
ofrags = ofrags_all[ofrags_all["starts_at_base"] & ofrags_all["ends_at_base"]].copy()
|
||||
ofrags = ofrags.reset_index(drop=True)
|
||||
logger.info("OFRAGs after base filter: %d / %d", len(ofrags), len(ofrags_all))
|
||||
|
||||
# Planning horizon start = midnight of the earliest OFRAG departure day
|
||||
# (aircraft are available from start-of-day, not the exact departure time)
|
||||
first_dep = ofrags["departure"].min() if not ofrags.empty else datetime.now()
|
||||
planning_start = first_dep.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
# Build fleet
|
||||
fleet = build_fleet(aircraft_df, checks_df, planning_start)
|
||||
|
||||
return {"ofrags": ofrags, "fleet": fleet, "airports": airports_df, "schedule": schedule_df}
|
||||
160
src/routing_engine/inspect_files.py
Normal file
160
src/routing_engine/inspect_files.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""Auto-inspection of raw input files: detect encodings, separators, and column mappings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ── Column-name synonym dictionaries ─────────────────────────────────────────
|
||||
|
||||
_AIRCRAFT_SYNONYMS: Dict[str, List[str]] = {
|
||||
"tail_number": ["matricula", "tail", "registration", "aeronave", "ac"],
|
||||
"model": ["modelo", "model", "type", "tipo"],
|
||||
"fh_total": ["fh totais", "fh_total", "total fh", "horas totais", "flight hours"],
|
||||
}
|
||||
|
||||
_CHECK_SYNONYMS: Dict[str, List[str]] = {
|
||||
"check_name": ["checks", "check", "nome", "name", "descricao"],
|
||||
"fh_threshold": ["fh", "threshold", "limite fh", "horas", "hours"],
|
||||
"duration_days": ["tempo de execucao", "duracao", "duration", "dias", "days"],
|
||||
"location": ["local de execucao", "local", "location", "base"],
|
||||
}
|
||||
|
||||
_AIRPORT_SYNONYMS: Dict[str, List[str]] = {
|
||||
"airport_code": ["airport_code", "icao", "code", "codigo"],
|
||||
"airport_name": ["airport_name", "name", "nome"],
|
||||
"is_maintenance_base": ["is_maintenance_base", "base", "manutencao"],
|
||||
}
|
||||
|
||||
_SCHEDULE_COL_NAMES = [
|
||||
"DATA", "ETAPA", "DEP", "ARR",
|
||||
"HORA_DEP", "HORA_ARR", "TEMPO_VOO",
|
||||
"SEGMTO", "MISSAO", "OFRAG",
|
||||
]
|
||||
|
||||
|
||||
def _normalise(text: str) -> str:
|
||||
"""Lowercase + strip accents (ASCII fold)."""
|
||||
import unicodedata
|
||||
nfkd = unicodedata.normalize("NFKD", str(text))
|
||||
return "".join(c for c in nfkd if not unicodedata.combining(c)).lower().strip()
|
||||
|
||||
|
||||
def _detect_encoding(filepath: Path) -> str:
|
||||
for enc in ("utf-8-sig", "utf-8", "latin-1", "cp1252"):
|
||||
try:
|
||||
with open(filepath, encoding=enc) as f:
|
||||
f.read(2048)
|
||||
return enc
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return "latin-1"
|
||||
|
||||
|
||||
def _detect_separator(filepath: Path, encoding: str) -> str:
|
||||
with open(filepath, encoding=encoding) as f:
|
||||
sample = f.read(512)
|
||||
counts = {sep: sample.count(sep) for sep in (";", ",", "\t", "|")}
|
||||
return max(counts, key=counts.get)
|
||||
|
||||
|
||||
def _map_columns(df_columns: List[str], synonyms: Dict[str, List[str]]) -> Dict[str, str]:
|
||||
"""Return {canonical_name: actual_column_name} for columns that can be matched."""
|
||||
mapping: Dict[str, str] = {}
|
||||
norm_cols = {_normalise(c): c for c in df_columns}
|
||||
|
||||
for canonical, candidates in synonyms.items():
|
||||
for cand in candidates:
|
||||
if _normalise(cand) in norm_cols:
|
||||
mapping[canonical] = norm_cols[_normalise(cand)]
|
||||
break
|
||||
if canonical not in mapping:
|
||||
# Partial match fallback
|
||||
for norm_col, orig_col in norm_cols.items():
|
||||
if any(_normalise(cand) in norm_col for cand in candidates):
|
||||
mapping[canonical] = orig_col
|
||||
break
|
||||
|
||||
return mapping
|
||||
|
||||
|
||||
def inspect_file(filepath: Path) -> Dict:
|
||||
"""Return metadata dict for a single raw file."""
|
||||
enc = _detect_encoding(filepath)
|
||||
sep = _detect_separator(filepath, enc)
|
||||
try:
|
||||
df = pd.read_csv(filepath, sep=sep, encoding=enc, nrows=5)
|
||||
return {
|
||||
"path": str(filepath),
|
||||
"encoding": enc,
|
||||
"separator": sep,
|
||||
"columns": list(df.columns),
|
||||
"n_cols": len(df.columns),
|
||||
"sample_rows": df.to_dict(orient="records"),
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.warning("Could not read %s: %s", filepath, exc)
|
||||
return {"path": str(filepath), "error": str(exc)}
|
||||
|
||||
|
||||
def inspect_all(raw_dir: Path) -> Dict[str, Dict]:
|
||||
"""Inspect every CSV in raw_dir and return a metadata dict keyed by stem."""
|
||||
results: Dict[str, Dict] = {}
|
||||
for p in sorted(raw_dir.glob("*.csv")):
|
||||
results[p.stem] = inspect_file(p)
|
||||
logger.info("Inspected %s (%d cols)", p.name, results[p.stem].get("n_cols", 0))
|
||||
return results
|
||||
|
||||
|
||||
def read_aircraft(filepath: Path) -> pd.DataFrame:
|
||||
enc = _detect_encoding(filepath)
|
||||
sep = _detect_separator(filepath, enc)
|
||||
df = pd.read_csv(filepath, sep=sep, encoding=enc)
|
||||
mapping = _map_columns(list(df.columns), _AIRCRAFT_SYNONYMS)
|
||||
logger.info("Aircraft column map: %s", mapping)
|
||||
df = df.rename(columns={v: k for k, v in mapping.items()})
|
||||
return df
|
||||
|
||||
|
||||
def read_checks(filepath: Path) -> pd.DataFrame:
|
||||
enc = _detect_encoding(filepath)
|
||||
sep = _detect_separator(filepath, enc)
|
||||
df = pd.read_csv(filepath, sep=sep, encoding=enc)
|
||||
mapping = _map_columns(list(df.columns), _CHECK_SYNONYMS)
|
||||
logger.info("Checks column map: %s", mapping)
|
||||
df = df.rename(columns={v: k for k, v in mapping.items()})
|
||||
return df
|
||||
|
||||
|
||||
def read_airports(filepath: Path) -> pd.DataFrame:
|
||||
enc = _detect_encoding(filepath)
|
||||
sep = _detect_separator(filepath, enc)
|
||||
df = pd.read_csv(filepath, sep=sep, encoding=enc)
|
||||
mapping = _map_columns(list(df.columns), _AIRPORT_SYNONYMS)
|
||||
logger.info("Airports column map: %s", mapping)
|
||||
df = df.rename(columns={v: k for k, v in mapping.items()})
|
||||
return df
|
||||
|
||||
|
||||
def read_schedule(filepath: Path) -> pd.DataFrame:
|
||||
"""Read the two-row merged-header flight schedule."""
|
||||
enc = _detect_encoding(filepath)
|
||||
sep = _detect_separator(filepath, enc)
|
||||
df = pd.read_csv(
|
||||
filepath,
|
||||
sep=sep,
|
||||
encoding=enc,
|
||||
header=None,
|
||||
skiprows=2,
|
||||
names=_SCHEDULE_COL_NAMES,
|
||||
dtype=str,
|
||||
)
|
||||
df = df.dropna(subset=["DATA", "OFRAG"])
|
||||
df = df[df["DATA"].str.strip() != ""]
|
||||
df = df[df["OFRAG"].str.strip() != ""]
|
||||
return df.reset_index(drop=True)
|
||||
173
src/routing_engine/maintenance_monitor.py
Normal file
173
src/routing_engine/maintenance_monitor.py
Normal file
@@ -0,0 +1,173 @@
|
||||
"""
|
||||
Maintenance monitor: validate route TTM compliance and compute maintenance events.
|
||||
|
||||
A Route is TTM-compliant if at every point in the route the accumulated flight
|
||||
hours since the last maintenance check do not exceed the current check cycle's TTM.
|
||||
Maintenance is forced when adding the next OFRAG would exceed that TTM.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from .config import RoutingConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MaintenanceEvent:
|
||||
after_ofrag_id: Optional[str] # None = before first OFRAG (proactive)
|
||||
check_cycle_index: int # which check cycle is being performed
|
||||
fh_threshold: float
|
||||
accum_fh_at_check: float # accumulated FH at the moment of the check
|
||||
ttm_loss: float # = cycle_ttm - accum_fh_at_check
|
||||
calendar_start: datetime
|
||||
calendar_end: datetime
|
||||
|
||||
|
||||
@dataclass
|
||||
class RouteValidationResult:
|
||||
feasible: bool
|
||||
total_flight_hours: float
|
||||
total_ttm_loss: float
|
||||
maintenance_events: List[MaintenanceEvent] = field(default_factory=list)
|
||||
infeasibility_reason: str = ""
|
||||
|
||||
|
||||
def validate_route(
|
||||
ofrag_sequence: List[str],
|
||||
ofrags_df: pd.DataFrame,
|
||||
aircraft_checks: List[Dict], # ordered list of {'fh_threshold','ttm','duration_hours'}
|
||||
aircraft_available_from: datetime,
|
||||
cfg: RoutingConfig,
|
||||
) -> RouteValidationResult:
|
||||
"""
|
||||
Simulate flying a route and return whether it is TTM-feasible.
|
||||
|
||||
aircraft_checks is the list produced by ingest._check_cycles(); it encodes
|
||||
the sequence of upcoming check cycles for this specific aircraft.
|
||||
"""
|
||||
ofrag_index = {row["ofrag_id"]: row for _, row in ofrags_df.iterrows()}
|
||||
tat = timedelta(minutes=cfg.tat_minutes)
|
||||
|
||||
check_idx = 0 # which cycle we are currently in
|
||||
accum_fh = 0.0 # FH accumulated in the current cycle
|
||||
current_time = aircraft_available_from
|
||||
total_fh = 0.0
|
||||
total_loss = 0.0
|
||||
events: List[MaintenanceEvent] = []
|
||||
|
||||
for ofrag_id in ofrag_sequence:
|
||||
if ofrag_id not in ofrag_index:
|
||||
return RouteValidationResult(
|
||||
feasible=False, total_flight_hours=total_fh, total_ttm_loss=total_loss,
|
||||
infeasibility_reason=f"OFRAG {ofrag_id} not found",
|
||||
)
|
||||
|
||||
ofrag = ofrag_index[ofrag_id]
|
||||
h = float(ofrag["flight_hours"])
|
||||
dep = ofrag["departure"]
|
||||
arr = ofrag["arrival"]
|
||||
|
||||
if check_idx >= len(aircraft_checks):
|
||||
return RouteValidationResult(
|
||||
feasible=False, total_flight_hours=total_fh, total_ttm_loss=total_loss,
|
||||
infeasibility_reason="No remaining check cycles – route exceeds maintenance plan",
|
||||
)
|
||||
|
||||
cycle = aircraft_checks[check_idx]
|
||||
|
||||
# Can the current cycle accommodate this OFRAG?
|
||||
if accum_fh + h > cycle["ttm"]:
|
||||
# Maintenance required before this OFRAG
|
||||
dur = timedelta(hours=cycle["duration_hours"])
|
||||
maint_end = current_time + dur
|
||||
|
||||
if maint_end + tat > dep:
|
||||
return RouteValidationResult(
|
||||
feasible=False, total_flight_hours=total_fh, total_ttm_loss=total_loss,
|
||||
infeasibility_reason=(
|
||||
f"No time for maintenance before {ofrag_id}: "
|
||||
f"maint ends {maint_end}, OFRAG departs {dep}"
|
||||
),
|
||||
)
|
||||
|
||||
loss = cycle["ttm"] - accum_fh
|
||||
ofrag_pos = ofrag_sequence.index(ofrag_id)
|
||||
event = MaintenanceEvent(
|
||||
after_ofrag_id=None if ofrag_pos == 0 else ofrag_sequence[ofrag_pos - 1],
|
||||
check_cycle_index=check_idx,
|
||||
fh_threshold=cycle["fh_threshold"],
|
||||
accum_fh_at_check=accum_fh,
|
||||
ttm_loss=loss,
|
||||
calendar_start=current_time,
|
||||
calendar_end=maint_end,
|
||||
)
|
||||
events.append(event)
|
||||
total_loss += loss
|
||||
accum_fh = 0.0
|
||||
check_idx += 1
|
||||
current_time = maint_end
|
||||
|
||||
# Verify new cycle can accommodate the OFRAG
|
||||
if check_idx >= len(aircraft_checks):
|
||||
return RouteValidationResult(
|
||||
feasible=False, total_flight_hours=total_fh, total_ttm_loss=total_loss,
|
||||
infeasibility_reason="No remaining check cycles after maintenance",
|
||||
)
|
||||
cycle = aircraft_checks[check_idx]
|
||||
if h > cycle["ttm"]:
|
||||
return RouteValidationResult(
|
||||
feasible=False, total_flight_hours=total_fh, total_ttm_loss=total_loss,
|
||||
infeasibility_reason=f"OFRAG {ofrag_id} ({h:.1f} FH) exceeds cycle TTM ({cycle['ttm']:.1f} FH)",
|
||||
)
|
||||
|
||||
# Time feasibility: aircraft must be available before OFRAG departs
|
||||
if current_time + tat > dep:
|
||||
return RouteValidationResult(
|
||||
feasible=False, total_flight_hours=total_fh, total_ttm_loss=total_loss,
|
||||
infeasibility_reason=(
|
||||
f"Time conflict: aircraft ready at {current_time + tat}, "
|
||||
f"but {ofrag_id} departs at {dep}"
|
||||
),
|
||||
)
|
||||
|
||||
accum_fh += h
|
||||
total_fh += h
|
||||
current_time = arr
|
||||
|
||||
return RouteValidationResult(
|
||||
feasible=True,
|
||||
total_flight_hours=total_fh,
|
||||
total_ttm_loss=total_loss,
|
||||
maintenance_events=events,
|
||||
)
|
||||
|
||||
|
||||
def summarise_fleet_maintenance(solution_routes: List[Dict], ofrags_df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""
|
||||
Build a human-readable maintenance summary for the complete solution.
|
||||
Each row = one maintenance event.
|
||||
"""
|
||||
rows = []
|
||||
for r in solution_routes:
|
||||
for evt in r.get("maintenance_events", []):
|
||||
rows.append(
|
||||
{
|
||||
"tail_number": r["aircraft_id"],
|
||||
"after_ofrag": evt.after_ofrag_id,
|
||||
"check_cycle_index": evt.check_cycle_index,
|
||||
"fh_threshold": evt.fh_threshold,
|
||||
"accum_fh_at_check": round(evt.accum_fh_at_check, 2),
|
||||
"ttm_loss_hours": round(evt.ttm_loss, 2),
|
||||
"maint_start": evt.calendar_start,
|
||||
"maint_end": evt.calendar_end,
|
||||
}
|
||||
)
|
||||
return pd.DataFrame(rows)
|
||||
117
src/routing_engine/metrics.py
Normal file
117
src/routing_engine/metrics.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""Compute and format output metrics from the optimiser solution."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List
|
||||
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def build_schedule_table(routes: List[Dict], ofrags_df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""One row per OFRAG in the solution, showing which aircraft serves it."""
|
||||
ofrag_lookup = ofrags_df.set_index("ofrag_id")
|
||||
rows = []
|
||||
for r in routes:
|
||||
prev_arr = r.get("aircraft_available_from", None)
|
||||
for pos, oid in enumerate(r["ofrag_ids"]):
|
||||
maint_before = pos in r["maint_before_index"]
|
||||
ofrag_row = ofrag_lookup.loc[oid] if oid in ofrag_lookup.index else {}
|
||||
rows.append(
|
||||
{
|
||||
"aircraft": r["aircraft_id"],
|
||||
"position_in_route": pos + 1,
|
||||
"maintenance_before": maint_before,
|
||||
"ofrag_id": oid,
|
||||
"missions": ofrag_row.get("missions", ""),
|
||||
"departure": ofrag_row.get("departure", pd.NaT),
|
||||
"arrival": ofrag_row.get("arrival", pd.NaT),
|
||||
"flight_hours": ofrag_row.get("flight_hours", 0.0),
|
||||
"n_legs": ofrag_row.get("n_legs", 0),
|
||||
}
|
||||
)
|
||||
return pd.DataFrame(rows).sort_values(["aircraft", "departure"]).reset_index(drop=True)
|
||||
|
||||
|
||||
def build_maintenance_table(routes: List[Dict]) -> pd.DataFrame:
|
||||
"""One row per maintenance event in the solution."""
|
||||
rows = []
|
||||
for r in routes:
|
||||
for evt in r.get("maintenance_events", []):
|
||||
rows.append(
|
||||
{
|
||||
"aircraft": r["aircraft_id"],
|
||||
"after_ofrag": evt.after_ofrag_id,
|
||||
"check_cycle_index": evt.check_cycle_index,
|
||||
"fh_threshold": evt.fh_threshold,
|
||||
"accum_fh_at_check": round(evt.accum_fh_at_check, 2),
|
||||
"ttm_loss_hours": round(evt.ttm_loss, 2),
|
||||
"maint_start": evt.calendar_start,
|
||||
"maint_end": evt.calendar_end,
|
||||
}
|
||||
)
|
||||
return pd.DataFrame(rows)
|
||||
|
||||
|
||||
def build_fleet_summary(routes: List[Dict], fleet_df: pd.DataFrame) -> pd.DataFrame:
|
||||
"""One row per aircraft with utilisation statistics."""
|
||||
utilisation: Dict[str, Dict] = {
|
||||
aid: {
|
||||
"flight_hours": 0.0,
|
||||
"n_ofrags": 0,
|
||||
"n_maint_events": 0,
|
||||
"total_ttm_loss": 0.0,
|
||||
}
|
||||
for aid in fleet_df["tail_number"].tolist()
|
||||
}
|
||||
|
||||
for r in routes:
|
||||
aid = r["aircraft_id"]
|
||||
if aid in utilisation:
|
||||
utilisation[aid]["flight_hours"] += r["flight_hours"]
|
||||
utilisation[aid]["n_ofrags"] += len(r["ofrag_ids"])
|
||||
utilisation[aid]["n_maint_events"] += len(r.get("maintenance_events", []))
|
||||
utilisation[aid]["total_ttm_loss"] += r["ttm_loss"]
|
||||
|
||||
rows = []
|
||||
for _, ac in fleet_df.iterrows():
|
||||
aid = ac["tail_number"]
|
||||
u = utilisation.get(aid, {})
|
||||
ttm0 = ac["ttm_hours"]
|
||||
fh_done = u.get("flight_hours", 0.0)
|
||||
rows.append(
|
||||
{
|
||||
"aircraft": aid,
|
||||
"model": ac.get("model", ""),
|
||||
"initial_ttm_h": round(ttm0, 2),
|
||||
"flight_hours_scheduled": round(fh_done, 2),
|
||||
"ttm_utilisation_pct": round(fh_done / ttm0 * 100, 1) if ttm0 > 0 else 0.0,
|
||||
"n_ofrags_assigned": u.get("n_ofrags", 0),
|
||||
"n_maintenance_events": u.get("n_maint_events", 0),
|
||||
"total_ttm_loss_h": round(u.get("total_ttm_loss", 0.0), 2),
|
||||
}
|
||||
)
|
||||
|
||||
df = pd.DataFrame(rows)
|
||||
df["idle"] = df["n_ofrags_assigned"] == 0
|
||||
return df
|
||||
|
||||
|
||||
def solution_summary(result: Dict, ofrags_df: pd.DataFrame, fleet_df: pd.DataFrame) -> Dict:
|
||||
"""Return a compact summary dict of the optimisation result."""
|
||||
routes = result.get("routes", [])
|
||||
total_ofrags = len(ofrags_df)
|
||||
covered = sum(len(r["ofrag_ids"]) for r in routes)
|
||||
uncovered = result.get("uncovered_ofrags", [])
|
||||
total_ttm_loss = sum(r["ttm_loss"] for r in routes)
|
||||
n_maint = sum(len(r.get("maintenance_events", [])) for r in routes)
|
||||
|
||||
return {
|
||||
"status": result.get("status", "?"),
|
||||
"objective": round(result.get("objective", 0.0), 4),
|
||||
"total_ofrags": total_ofrags,
|
||||
"covered_ofrags": covered,
|
||||
"uncovered_ofrags": uncovered,
|
||||
"total_ttm_loss_hours": round(total_ttm_loss, 2),
|
||||
"n_maintenance_events": n_maint,
|
||||
"columns_generated": result.get("total_columns_generated", 0),
|
||||
}
|
||||
84
src/routing_engine/network_generator.py
Normal file
84
src/routing_engine/network_generator.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
Time-space network for the OFRAG routing problem.
|
||||
|
||||
Builds the precedence / adjacency structure used by the pricing subproblem:
|
||||
- can_follow[i][j] : OFRAG j can start directly after OFRAG i (no maintenance)
|
||||
- can_follow_with_check[k][i][j]: OFRAG j can start after OFRAG i + check-cycle k maintenance
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from typing import Dict, List
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from .config import RoutingConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def build_adjacency(
|
||||
ofrags: pd.DataFrame,
|
||||
checks_unique_durations: List[float],
|
||||
cfg: RoutingConfig,
|
||||
) -> Dict:
|
||||
"""
|
||||
Pre-compute adjacency matrices.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ofrags : sorted by departure, indexed 0..n-1
|
||||
checks_unique_durations : list of check-cycle durations in hours (one per cycle)
|
||||
cfg : RoutingConfig
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict with keys:
|
||||
'can_follow' : List[List[bool]] (n × n)
|
||||
'can_follow_with_check' : List[List[List[bool]]] (n_checks × n × n)
|
||||
'n_ofrags' : int
|
||||
'n_checks' : int
|
||||
"""
|
||||
n = len(ofrags)
|
||||
tat = timedelta(minutes=cfg.tat_minutes)
|
||||
|
||||
arrivals = ofrags["arrival"].tolist()
|
||||
departures = ofrags["departure"].tolist()
|
||||
|
||||
# Direct adjacency (no maintenance between i and j)
|
||||
can_follow = [[False] * n for _ in range(n)]
|
||||
for i in range(n):
|
||||
for j in range(n):
|
||||
if i != j and arrivals[i] + tat <= departures[j]:
|
||||
can_follow[i][j] = True
|
||||
|
||||
# Adjacency after check cycle k (maintenance inserted between i and j)
|
||||
n_checks = len(checks_unique_durations)
|
||||
can_follow_with_check = [
|
||||
[[False] * n for _ in range(n)] for _ in range(n_checks)
|
||||
]
|
||||
for k, dur_hours in enumerate(checks_unique_durations):
|
||||
dur = timedelta(hours=dur_hours)
|
||||
for i in range(n):
|
||||
for j in range(n):
|
||||
if i != j and arrivals[i] + dur + tat <= departures[j]:
|
||||
can_follow_with_check[k][i][j] = True
|
||||
|
||||
stats = {
|
||||
"direct_edges": sum(sum(row) for row in can_follow),
|
||||
"check_edges_per_cycle": [
|
||||
sum(sum(row) for row in can_follow_with_check[k])
|
||||
for k in range(n_checks)
|
||||
],
|
||||
}
|
||||
logger.info("Network n_ofrags=%d direct_edges=%d", n, stats["direct_edges"])
|
||||
|
||||
return {
|
||||
"can_follow": can_follow,
|
||||
"can_follow_with_check": can_follow_with_check,
|
||||
"n_ofrags": n,
|
||||
"n_checks": n_checks,
|
||||
"stats": stats,
|
||||
}
|
||||
550
src/routing_engine/optimizer.py
Normal file
550
src/routing_engine/optimizer.py
Normal file
@@ -0,0 +1,550 @@
|
||||
"""
|
||||
Aircraft Routing Optimizer
|
||||
==========================
|
||||
Solves the Aircraft Routing problem via:
|
||||
|
||||
1. Column Generation (CG) – builds the LP relaxation of the Set Partitioning
|
||||
formulation iteratively by pricing new columns with a label-setting DP.
|
||||
2. Branch and Bound (B&B) – applied by PuLP/CBC on the full column pool once
|
||||
CG has converged, producing the optimal integer schedule.
|
||||
|
||||
Mathematical formulation
|
||||
------------------------
|
||||
Minimise Σ_r c_r · x_r (total TTM loss)
|
||||
s.t. Σ_{r: j∈r} x_r = 1 ∀ j ∈ OFRAGs (each OFRAG covered once)
|
||||
Σ_{r: a(r)=a} x_r ≤ 1 ∀ a ∈ Aircraft (one route per aircraft)
|
||||
x_r ∈ {0, 1}
|
||||
|
||||
Column-generation pricing subproblem
|
||||
-------------------------------------
|
||||
For each aircraft a, find the feasible route r* that minimises:
|
||||
|
||||
c_r – Σ_{j ∈ r} π_j – μ_a
|
||||
|
||||
where π_j = dual variable of coverage constraint for OFRAG j,
|
||||
μ_a = dual variable of aircraft-usage constraint for aircraft a.
|
||||
|
||||
This is solved by label-setting DP on the DAG of OFRAGs ordered by departure.
|
||||
|
||||
Label at (node j, check_cycle k): (reduced_cost, accum_fh, route_trace)
|
||||
- reduced_cost : running objective (negative → route is profitable to add)
|
||||
- accum_fh : flight hours accumulated since last maintenance
|
||||
- route_trace : list of (ofrag_idx, maint_before_flag)
|
||||
|
||||
Dominance rule (for labels at the same node and same check_cycle k)
|
||||
---------------------------------------------------------------------
|
||||
Label 1 (rc1, h1) dominates Label 2 (rc2, h2) iff:
|
||||
rc1 ≤ rc2 AND (h1 – rc1) ≥ (h2 – rc2)
|
||||
|
||||
This ensures Label 1 is never worse than Label 2 on any future extension,
|
||||
whether that extension requires maintenance or not.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import timedelta
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import pandas as pd
|
||||
import pulp
|
||||
|
||||
from .config import RoutingConfig
|
||||
from .maintenance_monitor import validate_route, MaintenanceEvent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_EPS = 1e-9 # float comparison tolerance
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Data structures
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@dataclass
|
||||
class Route:
|
||||
"""A feasible schedule for one aircraft (= one column in the master problem)."""
|
||||
route_id: int
|
||||
aircraft_id: str
|
||||
ofrag_ids: List[str] # OFRAGs served, in order
|
||||
maint_before_index: List[int] # positions in ofrag_ids where maint precedes
|
||||
flight_hours: float
|
||||
ttm_loss: float # objective cost
|
||||
coverage: frozenset # frozenset of ofrag_ids covered
|
||||
|
||||
@property
|
||||
def cost(self) -> float:
|
||||
return self.ttm_loss
|
||||
|
||||
def reduced_cost(self, dual_ofrags: Dict[str, float], dual_aircraft: float) -> float:
|
||||
return self.ttm_loss - sum(dual_ofrags.get(j, 0.0) for j in self.ofrag_ids) - dual_aircraft
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _prune_labels(
|
||||
labels: List[Tuple[float, float, float, List]],
|
||||
) -> List[Tuple[float, float, float, List]]:
|
||||
"""
|
||||
Remove dominated labels. Each label is (rc, h, cost, trace).
|
||||
rc = running reduced cost (includes dual subtractions)
|
||||
h = accumulated FH since last maintenance event
|
||||
cost = TTM loss accumulated so far (no dual subtractions)
|
||||
trace = list of (ofrag_idx, maint_before_flag)
|
||||
|
||||
Dominance: Label 1 dominates Label 2 iff rc1 ≤ rc2 AND (h1 - rc1) ≥ (h2 - rc2).
|
||||
Pareto front: sorted by rc asc, keep only those with strictly increasing (h - rc).
|
||||
"""
|
||||
if len(labels) <= 1:
|
||||
return labels
|
||||
labels_sorted = sorted(labels, key=lambda t: t[0])
|
||||
pareto: List[Tuple[float, float, float, List]] = []
|
||||
best_value = -float("inf")
|
||||
for rc, h, cost, trace in labels_sorted:
|
||||
v = h - rc
|
||||
if v > best_value - _EPS:
|
||||
pareto.append((rc, h, cost, trace))
|
||||
best_value = max(best_value, v)
|
||||
return pareto
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Main optimizer
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class AircraftRoutingOptimizer:
|
||||
"""
|
||||
Orchestrates Column Generation + Branch and Bound.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
ofrags_df : DataFrame with columns [ofrag_id, departure, arrival, flight_hours]
|
||||
fleet_df : DataFrame with columns [tail_number, fh_total, checks, ttm_hours, available_from]
|
||||
where checks = list of {'fh_threshold','ttm','duration_hours'}
|
||||
adjacency : dict returned by network_generator.build_adjacency()
|
||||
cfg : RoutingConfig
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ofrags_df: pd.DataFrame,
|
||||
fleet_df: pd.DataFrame,
|
||||
adjacency: Dict,
|
||||
cfg: RoutingConfig,
|
||||
):
|
||||
self.ofrags = ofrags_df.sort_values("departure").reset_index(drop=True)
|
||||
self.fleet = fleet_df.reset_index(drop=True)
|
||||
self.adj = adjacency
|
||||
self.cfg = cfg
|
||||
|
||||
self._ofrag_ids: List[str] = self.ofrags["ofrag_id"].tolist()
|
||||
self._ofrag_fh: Dict[str, float] = dict(
|
||||
zip(self.ofrags["ofrag_id"], self.ofrags["flight_hours"])
|
||||
)
|
||||
self._ofrag_idx: Dict[str, int] = {oid: i for i, oid in enumerate(self._ofrag_ids)}
|
||||
self._aircraft_ids: List[str] = self.fleet["tail_number"].tolist()
|
||||
|
||||
self._columns: List[Route] = []
|
||||
self._route_counter = 0
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────
|
||||
|
||||
def solve(self) -> Dict:
|
||||
"""Run Column Generation → B&B and return the solution dict."""
|
||||
logger.info("=== Aircraft Routing Optimizer ===")
|
||||
logger.info("OFRAGs: %d Aircraft: %d", len(self._ofrag_ids), len(self._aircraft_ids))
|
||||
|
||||
self._initialise_columns()
|
||||
logger.info("Initial column pool: %d", len(self._columns))
|
||||
|
||||
self._column_generation()
|
||||
logger.info("After CG: %d columns", len(self._columns))
|
||||
|
||||
result = self._solve_mip()
|
||||
|
||||
# Post-process: attach maintenance events to selected routes
|
||||
result["routes"] = self._attach_maintenance_events(result["selected_routes"])
|
||||
return result
|
||||
|
||||
# ── Initialisation ────────────────────────────────────────────────────────
|
||||
|
||||
def _next_id(self) -> int:
|
||||
self._route_counter += 1
|
||||
return self._route_counter
|
||||
|
||||
def _make_route(
|
||||
self,
|
||||
aircraft_id: str,
|
||||
ofrag_ids: List[str],
|
||||
maint_before_index: List[int],
|
||||
aircraft_checks: List[Dict],
|
||||
starting_check_idx: int = 0,
|
||||
) -> Route:
|
||||
"""Build a Route object from its components and compute its cost."""
|
||||
fh_total = sum(self._ofrag_fh[o] for o in ofrag_ids)
|
||||
|
||||
# Compute TTM loss: for each maintenance event, loss = cycle.ttm - accumulated_at_that_point
|
||||
ttm_loss = 0.0
|
||||
check_idx = starting_check_idx
|
||||
accum = 0.0
|
||||
for pos, oid in enumerate(ofrag_ids):
|
||||
if pos in maint_before_index and check_idx < len(aircraft_checks):
|
||||
ttm_loss += aircraft_checks[check_idx]["ttm"] - accum
|
||||
accum = 0.0
|
||||
check_idx += 1
|
||||
accum += self._ofrag_fh[oid]
|
||||
|
||||
return Route(
|
||||
route_id=self._next_id(),
|
||||
aircraft_id=aircraft_id,
|
||||
ofrag_ids=ofrag_ids,
|
||||
maint_before_index=maint_before_index,
|
||||
flight_hours=fh_total,
|
||||
ttm_loss=round(ttm_loss, 6),
|
||||
coverage=frozenset(ofrag_ids),
|
||||
)
|
||||
|
||||
def _initialise_columns(self):
|
||||
"""Seed the column pool with one single-OFRAG route per aircraft × OFRAG pair."""
|
||||
tat = timedelta(minutes=self.cfg.tat_minutes)
|
||||
n = len(self._ofrag_ids)
|
||||
|
||||
for _, ac in self.fleet.iterrows():
|
||||
checks = ac["checks"]
|
||||
avail = ac["available_from"]
|
||||
ttm0 = checks[0]["ttm"] if checks else 0.0
|
||||
|
||||
added = False
|
||||
for i in range(n):
|
||||
dep = self.ofrags.iloc[i]["departure"]
|
||||
fh = self.ofrags.iloc[i]["flight_hours"]
|
||||
if avail + tat <= dep and fh <= ttm0:
|
||||
route = self._make_route(
|
||||
ac["tail_number"], [self._ofrag_ids[i]], [], checks
|
||||
)
|
||||
self._columns.append(route)
|
||||
added = True
|
||||
break # one seed per aircraft is enough
|
||||
|
||||
if not added:
|
||||
# Aircraft needs maintenance before first OFRAG; try with maint flag
|
||||
for i in range(n):
|
||||
fh = self.ofrags.iloc[i]["flight_hours"]
|
||||
if len(checks) > 1 and fh <= checks[1]["ttm"]:
|
||||
route = self._make_route(
|
||||
ac["tail_number"], [self._ofrag_ids[i]], [0], checks
|
||||
)
|
||||
self._columns.append(route)
|
||||
break
|
||||
|
||||
# ── Column Generation ─────────────────────────────────────────────────────
|
||||
|
||||
def _column_generation(self):
|
||||
for iteration in range(self.cfg.max_cg_iterations):
|
||||
lp = self._solve_rmp_lp()
|
||||
if lp is None:
|
||||
logger.warning("RMP infeasible at iteration %d – stopping CG", iteration)
|
||||
break
|
||||
|
||||
pi = lp["dual_ofrags"] # coverage duals
|
||||
mu = lp["dual_aircraft"] # aircraft-usage duals
|
||||
|
||||
added_any = False
|
||||
for _, ac in self.fleet.iterrows():
|
||||
new_routes = self._price(ac, pi, mu.get(ac["tail_number"], 0.0))
|
||||
for r in new_routes:
|
||||
self._columns.append(r)
|
||||
added_any = True
|
||||
|
||||
logger.debug(
|
||||
"CG iter %d obj=%.4f cols=%d added=%s",
|
||||
iteration, lp["objective"], len(self._columns), added_any,
|
||||
)
|
||||
|
||||
if not added_any:
|
||||
logger.info("CG converged at iteration %d", iteration)
|
||||
break
|
||||
|
||||
def _solve_rmp_lp(self) -> Optional[Dict]:
|
||||
"""Solve the LP relaxation of the Restricted Master Problem."""
|
||||
prob = pulp.LpProblem("RMP", pulp.LpMinimize)
|
||||
|
||||
x = {
|
||||
col.route_id: pulp.LpVariable(f"x{col.route_id}", lowBound=0, upBound=1)
|
||||
for col in self._columns
|
||||
}
|
||||
# Artificial slack for coverage (ensures LP feasibility)
|
||||
y = {
|
||||
oid: pulp.LpVariable(f"y_{oid}", lowBound=0)
|
||||
for oid in self._ofrag_ids
|
||||
}
|
||||
|
||||
prob += (
|
||||
pulp.lpSum(col.cost * x[col.route_id] for col in self._columns)
|
||||
+ pulp.lpSum(self.cfg.big_m * y[oid] for oid in self._ofrag_ids)
|
||||
)
|
||||
|
||||
for oid in self._ofrag_ids:
|
||||
relevant = [x[c.route_id] for c in self._columns if oid in c.coverage]
|
||||
prob += pulp.lpSum(relevant) + y[oid] == 1, f"cov_{oid}"
|
||||
|
||||
for aid in self._aircraft_ids:
|
||||
relevant = [x[c.route_id] for c in self._columns if c.aircraft_id == aid]
|
||||
prob += pulp.lpSum(relevant) <= 1, f"ac_{aid}"
|
||||
|
||||
prob.solve(pulp.PULP_CBC_CMD(msg=0))
|
||||
|
||||
if pulp.LpStatus[prob.status] not in ("Optimal",):
|
||||
return None
|
||||
|
||||
# Standard LP dual (shadow price): positive for coverage constraints when
|
||||
# covered by artificials (≈ big_M), negative/zero for aircraft-usage constraints.
|
||||
# Pricing reduced cost = c_r - Σ π_j - μ_a uses these directly.
|
||||
dual_ofrags = {}
|
||||
for oid in self._ofrag_ids:
|
||||
c = prob.constraints.get(f"cov_{oid}")
|
||||
dual_ofrags[oid] = c.pi if (c and c.pi is not None) else 0.0
|
||||
|
||||
dual_aircraft = {}
|
||||
for aid in self._aircraft_ids:
|
||||
c = prob.constraints.get(f"ac_{aid}")
|
||||
dual_aircraft[aid] = c.pi if (c and c.pi is not None) else 0.0
|
||||
|
||||
return {
|
||||
"objective": pulp.value(prob.objective),
|
||||
"dual_ofrags": dual_ofrags,
|
||||
"dual_aircraft": dual_aircraft,
|
||||
}
|
||||
|
||||
# ── Pricing subproblem (label-setting DP) ─────────────────────────────────
|
||||
|
||||
def _price(
|
||||
self,
|
||||
aircraft: pd.Series,
|
||||
dual_ofrags: Dict[str, float],
|
||||
dual_aircraft: float,
|
||||
) -> List[Route]:
|
||||
"""
|
||||
Find all routes for *aircraft* with negative reduced cost.
|
||||
|
||||
State: (ofrag_idx j, check_cycle k)
|
||||
Label: (rc, h, cost, trace)
|
||||
rc = running reduced cost (cost minus dual contributions so far)
|
||||
h = accumulated FH since last maintenance in trace
|
||||
cost = TTM loss accumulated so far (rc + duals; stored separately to
|
||||
avoid re-deriving cost at harvest time)
|
||||
trace = list of (ofrag_idx, maint_before_flag)
|
||||
"""
|
||||
checks = aircraft["checks"]
|
||||
avail = aircraft["available_from"]
|
||||
aid = aircraft["tail_number"]
|
||||
tat = timedelta(minutes=self.cfg.tat_minutes)
|
||||
n = len(self._ofrag_ids)
|
||||
n_checks = len(checks)
|
||||
|
||||
can_follow = self.adj["can_follow"]
|
||||
can_with_check = self.adj["can_follow_with_check"]
|
||||
|
||||
# labels[j][k] = list of (rc, h, cost, trace)
|
||||
labels: List[List[List]] = [[[] for _ in range(n_checks)] for _ in range(n)]
|
||||
|
||||
# --- Seed: single-OFRAG routes starting from SOURCE ---
|
||||
# For cycle k=0: aircraft starts directly.
|
||||
# For cycle k>0: aircraft must complete checks[0..k-1] before OFRAG j departs,
|
||||
# each with 0 accumulated FH (no prior OFRAGs) → full TTM loss per empty check.
|
||||
# The prior_loss IS the route cost so far; it is tracked in both rc and cost.
|
||||
for j in range(n):
|
||||
dep_j = self.ofrags.iloc[j]["departure"]
|
||||
fh_j = self.ofrags.iloc[j]["flight_hours"]
|
||||
oid_j = self._ofrag_ids[j]
|
||||
|
||||
earliest = avail # earliest calendar time to enter the current cycle
|
||||
prior_loss = 0.0 # TTM loss from empty prior checks (= route cost so far)
|
||||
|
||||
for k in range(n_checks):
|
||||
can_reach = earliest + tat <= dep_j
|
||||
fits_ttm = fh_j <= checks[k]["ttm"] + _EPS
|
||||
|
||||
if can_reach and fits_ttm:
|
||||
rc = prior_loss - dual_ofrags.get(oid_j, 0.0)
|
||||
cost = prior_loss # TTM loss from prior empty checks; no in-trace maint yet
|
||||
labels[j][k].append((rc, fh_j, cost, [(j, False)]))
|
||||
break # lowest feasible cycle with valid timing
|
||||
|
||||
if not can_reach:
|
||||
break # no point advancing to later cycles (time only grows)
|
||||
|
||||
# Advance to next cycle: empty check k before OFRAG j
|
||||
if k + 1 < n_checks:
|
||||
prior_loss += checks[k]["ttm"]
|
||||
earliest = earliest + timedelta(hours=checks[k]["duration_hours"])
|
||||
|
||||
# --- Forward expansion ---
|
||||
new_routes: List[Route] = []
|
||||
|
||||
for j in range(n):
|
||||
for k in range(n_checks):
|
||||
labels[j][k] = _prune_labels(labels[j][k])
|
||||
|
||||
for rc, h, cost, trace in labels[j][k]:
|
||||
# Harvest: if this partial route has negative reduced cost, record it
|
||||
final_rc = rc - dual_aircraft
|
||||
if final_rc < -self.cfg.cg_tolerance:
|
||||
new_routes.append(
|
||||
self._trace_to_route(aid, trace, cost, checks)
|
||||
)
|
||||
|
||||
# Extend to OFRAG m
|
||||
for m in range(j + 1, n):
|
||||
fh_m = self.ofrags.iloc[m]["flight_hours"]
|
||||
oid_m = self._ofrag_ids[m]
|
||||
gain = dual_ofrags.get(oid_m, 0.0)
|
||||
|
||||
# Option A: direct (no maintenance)
|
||||
if can_follow[j][m] and h + fh_m <= checks[k]["ttm"] + _EPS:
|
||||
new_rc = rc - gain
|
||||
new_h = h + fh_m
|
||||
new_cost = cost # no new maintenance event
|
||||
new_trace = trace + [(m, False)]
|
||||
labels[m][k].append((new_rc, new_h, new_cost, new_trace))
|
||||
|
||||
# Option B: maintenance between j and m (cycle k → k+1)
|
||||
if k + 1 < n_checks:
|
||||
can_m = can_with_check[k][j][m] if k < len(can_with_check) else False
|
||||
if can_m and fh_m <= checks[k + 1]["ttm"] + _EPS:
|
||||
loss = checks[k]["ttm"] - h
|
||||
new_rc = rc + loss - gain
|
||||
new_h = fh_m
|
||||
new_cost = cost + loss # add maintenance loss
|
||||
new_trace = trace + [(m, True)] # True = maint before m
|
||||
labels[m][k + 1].append((new_rc, new_h, new_cost, new_trace))
|
||||
|
||||
# De-duplicate by coverage set (keep cheapest)
|
||||
seen: Dict[frozenset, Route] = {}
|
||||
for r in new_routes:
|
||||
key = r.coverage
|
||||
if key not in seen or r.ttm_loss < seen[key].ttm_loss:
|
||||
seen[key] = r
|
||||
|
||||
return list(seen.values())
|
||||
|
||||
def _trace_to_route(
|
||||
self,
|
||||
aircraft_id: str,
|
||||
trace: List[Tuple[int, bool]],
|
||||
cost: float,
|
||||
checks: List[Dict],
|
||||
) -> Route:
|
||||
"""
|
||||
Convert a DP trace into a Route object using the pre-computed cost.
|
||||
|
||||
The cost stored in the label already accounts for:
|
||||
- TTM losses from any prior empty checks done before the first OFRAG
|
||||
(seed at cycle k>0)
|
||||
- TTM losses from maintenance events within the trace (Option B expansions)
|
||||
We must NOT re-derive cost from maint_before_index because prior empty checks
|
||||
are not represented in the trace entries.
|
||||
"""
|
||||
ofrag_ids = [self._ofrag_ids[idx] for idx, _ in trace]
|
||||
maint_before_index = [pos for pos, (_, mb) in enumerate(trace) if mb]
|
||||
fh_total = sum(self._ofrag_fh[oid] for oid in ofrag_ids)
|
||||
return Route(
|
||||
route_id=self._next_id(),
|
||||
aircraft_id=aircraft_id,
|
||||
ofrag_ids=ofrag_ids,
|
||||
maint_before_index=maint_before_index,
|
||||
flight_hours=fh_total,
|
||||
ttm_loss=round(cost, 6),
|
||||
coverage=frozenset(ofrag_ids),
|
||||
)
|
||||
|
||||
# ── MIP (Branch and Bound) ────────────────────────────────────────────────
|
||||
|
||||
def _solve_mip(self) -> Dict:
|
||||
"""Solve the integer Set Partitioning model using PuLP/CBC B&B."""
|
||||
prob = pulp.LpProblem("AircraftRouting_MIP", pulp.LpMinimize)
|
||||
|
||||
x = {
|
||||
col.route_id: pulp.LpVariable(f"x{col.route_id}", cat="Binary")
|
||||
for col in self._columns
|
||||
}
|
||||
y = {
|
||||
oid: pulp.LpVariable(f"y_{oid}", lowBound=0)
|
||||
for oid in self._ofrag_ids
|
||||
}
|
||||
|
||||
prob += (
|
||||
pulp.lpSum(col.cost * x[col.route_id] for col in self._columns)
|
||||
+ pulp.lpSum(self.cfg.big_m * y[oid] for oid in self._ofrag_ids)
|
||||
)
|
||||
|
||||
for oid in self._ofrag_ids:
|
||||
relevant = [x[c.route_id] for c in self._columns if oid in c.coverage]
|
||||
prob += pulp.lpSum(relevant) + y[oid] == 1, f"cov_{oid}"
|
||||
|
||||
for aid in self._aircraft_ids:
|
||||
relevant = [x[c.route_id] for c in self._columns if c.aircraft_id == aid]
|
||||
prob += pulp.lpSum(relevant) <= 1, f"ac_{aid}"
|
||||
|
||||
solver = pulp.PULP_CBC_CMD(
|
||||
msg=1,
|
||||
timeLimit=self.cfg.mip_time_limit_seconds,
|
||||
gapRel=self.cfg.mip_gap,
|
||||
)
|
||||
prob.solve(solver)
|
||||
|
||||
status = pulp.LpStatus[prob.status]
|
||||
obj = pulp.value(prob.objective) or 0.0
|
||||
|
||||
selected = [
|
||||
col for col in self._columns
|
||||
if (x[col.route_id].varValue or 0) > 0.5
|
||||
]
|
||||
uncovered = [
|
||||
oid for oid in self._ofrag_ids
|
||||
if (y[oid].varValue or 0) > 0.5
|
||||
]
|
||||
|
||||
logger.info("MIP status: %s objective: %.4f", status, obj)
|
||||
logger.info("Selected routes: %d Uncovered OFRAGs: %d", len(selected), len(uncovered))
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"objective": obj,
|
||||
"selected_routes": selected,
|
||||
"uncovered_ofrags": uncovered,
|
||||
"total_columns_generated": len(self._columns),
|
||||
}
|
||||
|
||||
# ── Post-processing ───────────────────────────────────────────────────────
|
||||
|
||||
def _attach_maintenance_events(self, selected_routes: List[Route]) -> List[Dict]:
|
||||
"""
|
||||
Run the maintenance monitor on each selected route to get full event detail.
|
||||
Returns a list of dicts (one per route) ready for output / dashboard.
|
||||
"""
|
||||
out = []
|
||||
for route in selected_routes:
|
||||
ac_row = self.fleet[self.fleet["tail_number"] == route.aircraft_id].iloc[0]
|
||||
val = validate_route(
|
||||
ofrag_sequence=route.ofrag_ids,
|
||||
ofrags_df=self.ofrags,
|
||||
aircraft_checks=ac_row["checks"],
|
||||
aircraft_available_from=ac_row["available_from"],
|
||||
cfg=self.cfg,
|
||||
)
|
||||
out.append(
|
||||
{
|
||||
"aircraft_id": route.aircraft_id,
|
||||
"ofrag_ids": route.ofrag_ids,
|
||||
"maint_before_index": route.maint_before_index,
|
||||
"flight_hours": route.flight_hours,
|
||||
"ttm_loss": route.ttm_loss,
|
||||
"feasible": val.feasible,
|
||||
"maintenance_events": val.maintenance_events,
|
||||
}
|
||||
)
|
||||
return out
|
||||
124
src/routing_engine/pipeline.py
Normal file
124
src/routing_engine/pipeline.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
End-to-end orchestration pipeline.
|
||||
|
||||
Usage
|
||||
-----
|
||||
from src.routing_engine import RoutingPipeline, DEFAULT_CONFIG
|
||||
pipe = RoutingPipeline(DEFAULT_CONFIG)
|
||||
result = pipe.run()
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional
|
||||
|
||||
import pandas as pd
|
||||
|
||||
from .config import RoutingConfig, DEFAULT_CONFIG
|
||||
from .ingest import load_all, load_from_dfs
|
||||
from .network_generator import build_adjacency
|
||||
from .optimizer import AircraftRoutingOptimizer
|
||||
from .quality import run_all as quality_check
|
||||
from .metrics import (
|
||||
build_schedule_table,
|
||||
build_maintenance_table,
|
||||
build_fleet_summary,
|
||||
solution_summary,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _setup_logging(level: str):
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, level.upper(), logging.INFO),
|
||||
format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
handlers=[logging.StreamHandler(sys.stdout)],
|
||||
)
|
||||
|
||||
|
||||
class RoutingPipeline:
|
||||
def __init__(self, cfg: RoutingConfig = DEFAULT_CONFIG):
|
||||
self.cfg = cfg
|
||||
_setup_logging(cfg.log_level)
|
||||
|
||||
def run(self, save_outputs: bool = True, raw_dfs=None) -> Dict:
|
||||
"""Execute all pipeline stages and return a results dict."""
|
||||
logger.info("-- Stage 1: Ingest --------------------------------------")
|
||||
data = load_from_dfs(raw_dfs, self.cfg) if raw_dfs is not None else load_all(self.cfg)
|
||||
ofrags = data["ofrags"]
|
||||
fleet = data["fleet"]
|
||||
|
||||
logger.info("-- Stage 2: Quality check -------------------------------")
|
||||
qc = quality_check(ofrags, fleet)
|
||||
if not qc["ok"]:
|
||||
logger.warning("Quality issues detected:\n %s", "\n ".join(qc["issues"]))
|
||||
|
||||
logger.info("-- Stage 3: Build network -------------------------------")
|
||||
# Collect unique check-cycle durations (for adjacency-with-check matrices)
|
||||
all_durations = set()
|
||||
for _, ac in fleet.iterrows():
|
||||
for c in ac["checks"]:
|
||||
all_durations.add(c["duration_hours"])
|
||||
sorted_durations = sorted(all_durations)
|
||||
|
||||
adj = build_adjacency(ofrags, sorted_durations, self.cfg)
|
||||
|
||||
logger.info("-- Stage 4: Optimise ------------------------------------")
|
||||
opt = AircraftRoutingOptimizer(ofrags, fleet, adj, self.cfg)
|
||||
result = opt.solve()
|
||||
|
||||
logger.info("-- Stage 5: Metrics -------------------------------------")
|
||||
routes = result.get("routes", [])
|
||||
schedule_df = build_schedule_table(routes, ofrags)
|
||||
maint_df = build_maintenance_table(routes)
|
||||
fleet_df = build_fleet_summary(routes, fleet)
|
||||
summary = solution_summary(result, ofrags, fleet)
|
||||
|
||||
logger.info(
|
||||
"\n%s",
|
||||
"\n".join(f" {k}: {v}" for k, v in summary.items()),
|
||||
)
|
||||
|
||||
if save_outputs:
|
||||
self._save(schedule_df, maint_df, fleet_df, summary)
|
||||
|
||||
return {
|
||||
"summary": summary,
|
||||
"schedule": schedule_df,
|
||||
"maintenance": maint_df,
|
||||
"fleet": fleet_df,
|
||||
"ofrags": ofrags,
|
||||
"raw_result": result,
|
||||
"quality": qc,
|
||||
}
|
||||
|
||||
def _save(
|
||||
self,
|
||||
schedule_df: pd.DataFrame,
|
||||
maint_df: pd.DataFrame,
|
||||
fleet_df: pd.DataFrame,
|
||||
summary: Dict,
|
||||
):
|
||||
cfg = self.cfg
|
||||
cfg.schedules_dir.mkdir(parents=True, exist_ok=True)
|
||||
cfg.exports_dir.mkdir(parents=True, exist_ok=True)
|
||||
cfg.processed_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
schedule_df.to_csv(cfg.schedules_dir / "flight_strings.csv", index=False)
|
||||
maint_df.to_csv(cfg.schedules_dir / "maintenance_events.csv", index=False)
|
||||
fleet_df.to_csv(cfg.exports_dir / "fleet_utilisation.csv", index=False)
|
||||
|
||||
with open(cfg.exports_dir / "solution_summary.json", "w", encoding="utf-8") as f:
|
||||
json.dump(
|
||||
{k: (str(v) if not isinstance(v, (int, float, str, list, dict, bool, type(None))) else v)
|
||||
for k, v in summary.items()},
|
||||
f, ensure_ascii=False, indent=2,
|
||||
)
|
||||
|
||||
logger.info("Outputs saved to %s", cfg.schedules_dir)
|
||||
105
src/routing_engine/quality.py
Normal file
105
src/routing_engine/quality.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""Data quality checks on OFRAGs and fleet tables."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Dict, List
|
||||
|
||||
import pandas as pd
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def check_ofrags(ofrags: pd.DataFrame) -> Dict:
|
||||
issues: List[str] = []
|
||||
|
||||
if ofrags.empty:
|
||||
return {"ok": False, "issues": ["OFRAG table is empty"]}
|
||||
|
||||
missing_dep = ofrags["departure"].isna().sum()
|
||||
if missing_dep:
|
||||
issues.append(f"{missing_dep} OFRAGs have missing departure datetime")
|
||||
|
||||
missing_arr = ofrags["arrival"].isna().sum()
|
||||
if missing_arr:
|
||||
issues.append(f"{missing_arr} OFRAGs have missing arrival datetime")
|
||||
|
||||
neg_fh = (ofrags["flight_hours"] <= 0).sum()
|
||||
if neg_fh:
|
||||
issues.append(f"{neg_fh} OFRAGs have non-positive flight hours")
|
||||
|
||||
inverted = (ofrags["arrival"] <= ofrags["departure"]).sum()
|
||||
if inverted:
|
||||
issues.append(f"{inverted} OFRAGs have arrival ≤ departure")
|
||||
|
||||
not_base = (~ofrags["starts_at_base"] | ~ofrags["ends_at_base"]).sum()
|
||||
if not_base:
|
||||
issues.append(
|
||||
f"{not_base} OFRAGs do not start AND end at the maintenance base "
|
||||
f"(filtered out in load_all)"
|
||||
)
|
||||
|
||||
ok = len(issues) == 0
|
||||
if ok:
|
||||
logger.info("OFRAG quality: OK (%d OFRAGs)", len(ofrags))
|
||||
else:
|
||||
for iss in issues:
|
||||
logger.warning("OFRAG quality: %s", iss)
|
||||
|
||||
return {"ok": ok, "issues": issues}
|
||||
|
||||
|
||||
def check_fleet(fleet: pd.DataFrame) -> Dict:
|
||||
issues: List[str] = []
|
||||
|
||||
if fleet.empty:
|
||||
return {"ok": False, "issues": ["Fleet table is empty"]}
|
||||
|
||||
no_checks = fleet["checks"].apply(lambda c: len(c) == 0).sum()
|
||||
if no_checks:
|
||||
issues.append(f"{no_checks} aircraft have no maintenance check cycles")
|
||||
|
||||
zero_ttm = (fleet["ttm_hours"] <= 0).sum()
|
||||
if zero_ttm:
|
||||
issues.append(f"{zero_ttm} aircraft have TTM ≤ 0 (overdue maintenance)")
|
||||
|
||||
ok = len(issues) == 0
|
||||
if ok:
|
||||
logger.info("Fleet quality: OK (%d aircraft)", len(fleet))
|
||||
else:
|
||||
for iss in issues:
|
||||
logger.warning("Fleet quality: %s", iss)
|
||||
|
||||
return {"ok": ok, "issues": issues}
|
||||
|
||||
|
||||
def check_feasibility(ofrags: pd.DataFrame, fleet: pd.DataFrame) -> Dict:
|
||||
"""High-level feasibility check before running the optimiser."""
|
||||
issues: List[str] = []
|
||||
|
||||
if fleet.empty or ofrags.empty:
|
||||
return {"ok": False, "issues": ["Empty inputs"]}
|
||||
|
||||
max_ttm = fleet["checks"].apply(
|
||||
lambda cycles: max((c["ttm"] for c in cycles), default=0)
|
||||
).max()
|
||||
|
||||
infeasible_ofrags = ofrags[ofrags["flight_hours"] > max_ttm]
|
||||
if not infeasible_ofrags.empty:
|
||||
ids = infeasible_ofrags["ofrag_id"].tolist()
|
||||
issues.append(
|
||||
f"OFRAGs {ids} have flight_hours > max achievable TTM ({max_ttm:.1f} FH) "
|
||||
f"– these cannot be served by any aircraft."
|
||||
)
|
||||
|
||||
ok = len(issues) == 0
|
||||
return {"ok": ok, "issues": issues}
|
||||
|
||||
|
||||
def run_all(ofrags: pd.DataFrame, fleet: pd.DataFrame) -> Dict:
|
||||
r1 = check_ofrags(ofrags)
|
||||
r2 = check_fleet(fleet)
|
||||
r3 = check_feasibility(ofrags, fleet)
|
||||
ok = r1["ok"] and r2["ok"] and r3["ok"]
|
||||
issues = r1["issues"] + r2["issues"] + r3["issues"]
|
||||
return {"ok": ok, "issues": issues}
|
||||
Reference in New Issue
Block a user