diff --git a/.gitignore b/.gitignore index ff9c438..452ed43 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..275bdb4 --- /dev/null +++ b/CHANGELOG.md @@ -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). diff --git a/CONTEXTO.md b/CONTEXTO.md index d348dca..8321f7e 100644 --- a/CONTEXTO.md +++ b/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) | diff --git a/LOG.md b/LOG.md deleted file mode 100644 index 37bf86a..0000000 --- a/LOG.md +++ /dev/null @@ -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. | diff --git a/README.md b/README.md index 4aebda6..6fa64db 100644 --- a/README.md +++ b/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. diff --git a/app/dashboard.py b/app/dashboard.py new file mode 100644 index 0000000..ab98e23 --- /dev/null +++ b/app/dashboard.py @@ -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"{code}
Base de manutenção" if is_base else code, + ).add_to(m) + + st_folium(m, height=700, use_container_width=True) diff --git a/app/dashboard_backup.py b/app/dashboard_backup.py new file mode 100644 index 0000000..5c91f62 --- /dev/null +++ b/app/dashboard_backup.py @@ -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) diff --git a/docs/about.md b/docs/about.md deleted file mode 100644 index e2701ca..0000000 --- a/docs/about.md +++ /dev/null @@ -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 -``` diff --git a/docs/authors.md b/docs/authors.md deleted file mode 100644 index 86f2cc3..0000000 --- a/docs/authors.md +++ /dev/null @@ -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 - -### 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`. diff --git a/docs/changelog.md b/docs/changelog.md deleted file mode 100644 index 1c22fca..0000000 --- a/docs/changelog.md +++ /dev/null @@ -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 - -### 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`. diff --git a/pre_process/comparacao_ica_66_31_c105_2023/comparacao_ica_66_31_c105_2023_vs_relatorio_ciclo.csv b/pre_process/comparacao_ica_66_31_c105_2023/comparacao_ica_66_31_c105_2023_vs_relatorio_ciclo.csv deleted file mode 100644 index bd04abb..0000000 --- a/pre_process/comparacao_ica_66_31_c105_2023/comparacao_ica_66_31_c105_2023_vs_relatorio_ciclo.csv +++ /dev/null @@ -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 diff --git a/pre_process/comparacao_ica_66_31_c105_2023/comparacao_ica_66_31_c105_2023_vs_relatorio_ciclo.md b/pre_process/comparacao_ica_66_31_c105_2023/comparacao_ica_66_31_c105_2023_vs_relatorio_ciclo.md deleted file mode 100644 index b720a25..0000000 --- a/pre_process/comparacao_ica_66_31_c105_2023/comparacao_ica_66_31_c105_2023_vs_relatorio_ciclo.md +++ /dev/null @@ -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` - diff --git a/pre_process/comparacao_ica_66_31_c105_2023/comparar_ica_relatorio_ciclo.py b/pre_process/comparacao_ica_66_31_c105_2023/comparar_ica_relatorio_ciclo.py deleted file mode 100644 index 0e55312..0000000 --- a/pre_process/comparacao_ica_66_31_c105_2023/comparar_ica_relatorio_ciclo.py +++ /dev/null @@ -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() diff --git a/pre_process/relatorio_ciclo_inspecoes_c105_2805_2026-06-15/preprocess_pdf.py b/pre_process/relatorio_ciclo_inspecoes_c105_2805_2026-06-15/preprocess_pdf.py deleted file mode 100644 index f21d448..0000000 --- a/pre_process/relatorio_ciclo_inspecoes_c105_2805_2026-06-15/preprocess_pdf.py +++ /dev/null @@ -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\d+)\s+") -INTERVAL_RE = re.compile( - r"(?P\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() diff --git a/pre_process/relatorio_ciclo_inspecoes_c105_2805_2026-06-15/relatorio_ciclo_inspecoes_c105_2805_2026-06-15_inspecoes.csv b/pre_process/relatorio_ciclo_inspecoes_c105_2805_2026-06-15/relatorio_ciclo_inspecoes_c105_2805_2026-06-15_inspecoes.csv deleted file mode 100644 index 78cf3f0..0000000 --- a/pre_process/relatorio_ciclo_inspecoes_c105_2805_2026-06-15/relatorio_ciclo_inspecoes_c105_2805_2026-06-15_inspecoes.csv +++ /dev/null @@ -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 diff --git a/pre_process/relatorio_ciclo_inspecoes_c105_2805_2026-06-15/relatorio_ciclo_inspecoes_c105_2805_2026-06-15_inspecoes.json b/pre_process/relatorio_ciclo_inspecoes_c105_2805_2026-06-15/relatorio_ciclo_inspecoes_c105_2805_2026-06-15_inspecoes.json deleted file mode 100644 index b30b8be..0000000 --- a/pre_process/relatorio_ciclo_inspecoes_c105_2805_2026-06-15/relatorio_ciclo_inspecoes_c105_2805_2026-06-15_inspecoes.json +++ /dev/null @@ -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)" - } - ] -} diff --git a/pre_process/relatorio_ciclo_inspecoes_c105_2805_2026-06-15/relatorio_ciclo_inspecoes_c105_2805_2026-06-15_texto.txt b/pre_process/relatorio_ciclo_inspecoes_c105_2805_2026-06-15/relatorio_ciclo_inspecoes_c105_2805_2026-06-15_texto.txt deleted file mode 100644 index 8b0e489..0000000 --- a/pre_process/relatorio_ciclo_inspecoes_c105_2805_2026-06-15/relatorio_ciclo_inspecoes_c105_2805_2026-06-15_texto.txt +++ /dev/null @@ -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) diff --git a/raw/AERONAVES.csv b/raw/AERONAVES.csv new file mode 100644 index 0000000..50917f8 --- /dev/null +++ b/raw/AERONAVES.csv @@ -0,0 +1,5 @@ +MATRICULA;MODELO;FH TOTAIS +FAB2800;C105;295 +FAB2803;C105;275 +FAB2809;C105;265 +FAB2811;C105;255 diff --git a/raw/AIRPORTS.csv b/raw/AIRPORTS.csv new file mode 100644 index 0000000..1f97094 --- /dev/null +++ b/raw/AIRPORTS.csv @@ -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 diff --git a/raw/CHECKS.csv b/raw/CHECKS.csv new file mode 100644 index 0000000..5bea8c1 --- /dev/null +++ b/raw/CHECKS.csv @@ -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 diff --git a/raw/Cópia de ESCALA DE MISSÕES 2025 - ATÉ 20 de outubro, 1812.xlsx b/raw/Cópia de ESCALA DE MISSÕES 2025 - ATÉ 20 de outubro, 1812.xlsx deleted file mode 100644 index 2a1a385..0000000 Binary files a/raw/Cópia de ESCALA DE MISSÕES 2025 - ATÉ 20 de outubro, 1812.xlsx and /dev/null differ diff --git a/raw/ESCALA DE MISSÕES 2024.xlsx b/raw/ESCALA DE MISSÕES 2024.xlsx deleted file mode 100644 index 2511d59..0000000 Binary files a/raw/ESCALA DE MISSÕES 2024.xlsx and /dev/null differ diff --git a/raw/ESCALA DE VOO MODELO 1.csv b/raw/ESCALA DE VOO MODELO 1.csv new file mode 100644 index 0000000..d42169a --- /dev/null +++ b/raw/ESCALA DE VOO MODELO 1.csv @@ -0,0 +1,64 @@ +DATA;ETAPA;LOCALIDADE;;HORRIO (Z);;TEMPO DE VOO;SEGMTO;MISSO;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 diff --git a/raw/ICA 66-31 2023.pdf b/raw/ICA 66-31 2023.pdf deleted file mode 100644 index 6358c9a..0000000 Binary files a/raw/ICA 66-31 2023.pdf and /dev/null differ diff --git a/raw/relatorio_ciclo_inspecoes_c105_2805_2026-06-15.pdf b/raw/relatorio_ciclo_inspecoes_c105_2805_2026-06-15.pdf deleted file mode 100644 index 9d53cba..0000000 --- a/raw/relatorio_ciclo_inspecoes_c105_2805_2026-06-15.pdf +++ /dev/null @@ -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 -<> -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["b0O6a\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_+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]aH8EMa&k$bQo -6MPG[RQ/5P+UM+)pkX,mdkN\UOu@b?(3`W[#]m:LBp!6?>d`GU[-9n#CiQMR?N+=ECS*N?1j?[m8I1J\cq>o*0?u#R=VceIT!-ud"GHV&kb33pl4g-X4)s4M0n>@!=DgU5eL9,a%Z -4m=tknC5.\aX^HuXUV%I]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@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/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.4n7B)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^;#-'._?\(Cr(((D6`7TW!RFcjZX/SVql$T)^m[FOQ?DuLGdloVTS!3#pF4TGH^~> -endstream -endobj -6 0 obj -2935 -endobj -7 0 obj -<> -endobj -8 0 obj -<> -endobj -9 0 obj -<> -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@!!**$!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!%;)SAnPdkC3+K>G'A1VH@gd&KnbA= -M2II[Pa.Q$R$jD;USO``Vl6SpZEppG[^WcW]#)A'`Q#s>ai`&\eCE.%f\,!$!`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:YMiE&DXBX&c'`i:%V5'+7SBLP2YSO#:6l.Xj1nH,kcs7H!1TGGdA\pP]M8`AH$IpgfU8qcY\?'76Y7k5[XPGj5C3PI)1&h4^jnfjDd=VA8 -J,"s%!"(sdSYQ0%bP!Rg!2B_rLAq8D(]G+uo/g=,?gYn0fRLdd=mB[tf^aqP.%TM\jfd!5Q.P[%pf)Ept+OJ"e.HYd&+L+&9p*<3J-_:>1D$I -V&O\*N\`BS5*?^L+-#aPd'p&AoC9YS-FCnQ7(EtGil`4`4Yj/!TDWYrD'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>!H&a+0a'eL$Ae0QmODsjtLlPK+j)UU\ -l/sqo<83Z$jmp@,:F]>0._ZQKWDXAI;oHlTf`(r+ErCp2keVaW^\gP#!/2NmcI)Xl%-@@&J'ilI -_ghH>rrj__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(ZoiidOXQ*1,Wp+jk[Ci+K\P>k=piO,ne.3I\s) -oO,Gr@V4S\t5olbZP8\i?q*7CO_G_'XdeTr#cXVqX':4*r7e962'cr9<],i:Z9Mhq:ZrL%%Tl -ZqVTK'DPbsishfqK%WS6*TS=3JSFkID/c9,5,(%.T0^Jp!5%r7_I1qE3bX -/.Q;W")nr14N>*:/1+-i8`JF7R:\Z/c*Nl6n#\Q9YI2/qA^eLr_lNs6gDfiAlK;-*n*Z[u>6%)q6\fXHl@K"3fQI#86pdUIB>?KZd-IXo-kDp%u"^kgfCE*mNYOacXoe?.u -rr=>Y6N7%gHFNmAn*BnXlV8tgIM_MBa"B4-SdijhP@RJ,577"Cm+C@i/9M6\GlAoWEs[fF60QMHaajBk_)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>]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+t&^qh-4)9R/[.CI`Pk2 -^(Yl-pmWqTIu5.?M`&,+)bIQKn9(@WoXu>D(:qM_YA&?a?T,$ik_g,.Bhre_@iQ]]b`bqniJkL9 -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\sK'-+Fq[TH>Blg#p/t!,?NN)?Q[$+CL`VJQo!"DHu -!!N9#!2?<>rr@_=`P;C]kl1X;U]1=GIfBDq;SW53_FrnGrr=crQ64(0'#"DVn>hVL#goP1k#86/Z`ok@k)&tt-!9!?ed)Yb9e8A8gokPLbB,5nB$"oC5o57hrJ -*GKsReD]puR@m\sbMcY7-N7,DddFM'mMbV/]L/Z(^8#VK6[PtMYV<]<@aPO@I7j#\DX;9$Lg3Aok=,2b -rrm/$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^?\#eN7OK3(4AC37AX9jWSOp@bh5LGskPC'2%O&Wjj -Cc]!&Q/.'c@;`eY1.rmSb)$OupT4Ap_+Qs.pqPbT^YsYSpd<)f*^&s?J2R?,%dG.h3d6AJ4>.bj -Wp"Z<>2?$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&&>[7YDP*!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%h1WLbX77!aplFe^5#I]iFmETrrjV5Jf_EYC=#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;q0ZPP9>*%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&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;bTRQsf!4N]XBqlGlNl_;Lq$Btcd?LI9T8pg<%<&,;d5!m9*VpJRI= -4FD9eIh-dp,.l=rr@RC.NsGJ4WbGcfP5"qhmgEp&)S".:0."-a^;rX[+F[#WnVb_ -:ptrZP'`+*)sO?^?\^[3VZ_R;9hX@[kh[JGp=1d;3+dZE>tb*jJ&,=g!8s5G+0en+mCjur,-o`V -0A2u](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_#k&$3pgV4a.!9CqFi0'`+B>+EH!7`[u51dcb*j9YD -rG,!VCuEc[Y4QR'a&KLUehkPZDb7I1_WEqYT[`U^;@BTcEc+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,)E"RPn<$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>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)REaMG\Jj.ZLDdFi^#1 -APo9u3M3^/nIkO87(`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'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_RUo0;hV/JqX/d'f7kX\7\rXJSLXCsRkj-* -:0b&$FQG`[b'5SX*6+_558f7!W+kXIXXjZ,'VOjXg1mke$H'g6)J686rrt3:=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^\r4f4*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\#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@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!!!Q3W/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\>JlnMBruiEhH$ -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@0gs2RP0)4:U5`,5t71(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,15lAF/oM.&3h?Ph3clf^Z,9Y<=4q#YKu`5Y=Blj;8pGYaO0.HtNtK?Y3DtT!M4om- -.bJQ3)O,5=X[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&V*uO@^&[..TPV0p55D4WBq)bV`HW(itVGV -i1G[E.)$RD.J/F6iNhulMEW>6:[llJ,M&d^\&+\G_tr8Kr)Igs^Ct7tR^?8\X5[jk2r\`a7k(r( -ft7ipJ97M']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%?OXRVidVkO -kaLCc:QI_SO+_ulY(,C4dBAl-9R&Z_nLMX%%V4-,?h%HaW>46tQ`4-PLVk%D]u5*#h.\GOiMZT/ -\&@b3)F%j6!;oNOfan)_T8e+<`*\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(G9dlJein -([[7J/TJq?3UIpk!\IQ?mVm5Ns!'5Le?_->_V_SaAB4h$(D!,4[NfA[m.,WD;*f.Q.?rNj3UkaKMdiGCpWXSsM\P-9Vl -9nDXI;=J4SA;7uu["?<8;n)=*N[ZMRe[<,V4;,$4*L<:OqT9otM_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:lm^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]B8pL;`&Ne$"PF:obUa2j5V)_-]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:^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]&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"A](?Qbi\%(KpMT`\@1K:ss0`Chts,9\jH(BfC?uVkU<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\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_?b4pn7rrKro.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@fRe8t`o_W9b?o[>)1#*5+ -b(M9S.Sgesp$l;1%ifFiDO)q+`)VJcPkh.9GT0;DU.9[!qGk^*a`Tb;`f[=ZGuRis2TGm@\7.48i[Y@hllV -+,*r[NoA8*UT1nM0m"C:K]mPtJE^:G'LO[2a?+!jL=g -GVA6M2[e3qapWtZ'epPQIS'3k,d<]LThcN(dB/8/:),KicAiBa0QC^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!_\,VA>et1pb9YPA[GT(B(AAT.8SrSQf%1J<8RT&biL2,7<_0#dFCF!.5ou -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 -cAa0Nd_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 -hh1P7sk\YGZFLSa-eD'2R3qCH?c\jn\ -\l>qo!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#t-2/:Xi,0cBtBRgp=bgrWEWP@H=bp0TkS@b(MQ:#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$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+%@BrL8>e*/h,!.;lbq4(u8-.k1EhHe'0S.k*'iJN[cbY0U/J9 -XQTLTlQiA[^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\?5rLe9R5MJBjYl5%#:W>$hc/EM7a2:(iO2cVbe,3qRWT@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@AOjKJaK3eW: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,MNV.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"3d%nK6c2LFp.LnE/UsnYV=(?Ocds5k1Jd=?[II2#*9n>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,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_MUIYJY8dpaba4I(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 <> /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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c508a99 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/run_pipeline.bat b/run_pipeline.bat new file mode 100644 index 0000000..056b0d3 --- /dev/null +++ b/run_pipeline.bat @@ -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 diff --git a/scripts/00_setup_project.py b/scripts/00_setup_project.py new file mode 100644 index 0000000..a16c81c --- /dev/null +++ b/scripts/00_setup_project.py @@ -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) diff --git a/scripts/01_inspect_inputs.py b/scripts/01_inspect_inputs.py new file mode 100644 index 0000000..1608714 --- /dev/null +++ b/scripts/01_inspect_inputs.py @@ -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}") diff --git a/scripts/02_build_fleet_reference.py b/scripts/02_build_fleet_reference.py new file mode 100644 index 0000000..0c90599 --- /dev/null +++ b/scripts/02_build_fleet_reference.py @@ -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}") diff --git a/scripts/03_run_optimization_pipeline.py b/scripts/03_run_optimization_pipeline.py new file mode 100644 index 0000000..3e60513 --- /dev/null +++ b/scripts/03_run_optimization_pipeline.py @@ -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)) diff --git a/scripts/04_generate_flight_strings.py b/scripts/04_generate_flight_strings.py new file mode 100644 index 0000000..2f3e8e8 --- /dev/null +++ b/scripts/04_generate_flight_strings.py @@ -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)") diff --git a/scripts/05_run_dashboard.bat b/scripts/05_run_dashboard.bat new file mode 100644 index 0000000..1847c41 --- /dev/null +++ b/scripts/05_run_dashboard.bat @@ -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 diff --git a/src/routing_engine/__init__.py b/src/routing_engine/__init__.py new file mode 100644 index 0000000..97ae239 --- /dev/null +++ b/src/routing_engine/__init__.py @@ -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"] diff --git a/src/routing_engine/config.py b/src/routing_engine/config.py new file mode 100644 index 0000000..cf5979c --- /dev/null +++ b/src/routing_engine/config.py @@ -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() diff --git a/src/routing_engine/ingest.py b/src/routing_engine/ingest.py new file mode 100644 index 0000000..d086dc3 --- /dev/null +++ b/src/routing_engine/ingest.py @@ -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} diff --git a/src/routing_engine/inspect_files.py b/src/routing_engine/inspect_files.py new file mode 100644 index 0000000..29e4384 --- /dev/null +++ b/src/routing_engine/inspect_files.py @@ -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) diff --git a/src/routing_engine/maintenance_monitor.py b/src/routing_engine/maintenance_monitor.py new file mode 100644 index 0000000..485f3d3 --- /dev/null +++ b/src/routing_engine/maintenance_monitor.py @@ -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) diff --git a/src/routing_engine/metrics.py b/src/routing_engine/metrics.py new file mode 100644 index 0000000..07edce8 --- /dev/null +++ b/src/routing_engine/metrics.py @@ -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), + } diff --git a/src/routing_engine/network_generator.py b/src/routing_engine/network_generator.py new file mode 100644 index 0000000..3215bde --- /dev/null +++ b/src/routing_engine/network_generator.py @@ -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, + } diff --git a/src/routing_engine/optimizer.py b/src/routing_engine/optimizer.py new file mode 100644 index 0000000..875f40f --- /dev/null +++ b/src/routing_engine/optimizer.py @@ -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 diff --git a/src/routing_engine/pipeline.py b/src/routing_engine/pipeline.py new file mode 100644 index 0000000..56152d9 --- /dev/null +++ b/src/routing_engine/pipeline.py @@ -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) diff --git a/src/routing_engine/quality.py b/src/routing_engine/quality.py new file mode 100644 index 0000000..74a0f69 --- /dev/null +++ b/src/routing_engine/quality.py @@ -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}