From 328a0668499f1c254a31a848dbaa5c0a4b75977c Mon Sep 17 00:00:00 2001 From: Cesa-V Date: Mon, 15 Jun 2026 23:29:44 -0300 Subject: [PATCH] Implementa OAMRP v3 com rede espaco-tempo e restricoes de inspecao (Fases 1-2) Reescrita do zero em software/oamrp_v3.py: abstracao No/Arco, variavel unificada y[k,arco], C1-C6 (Fase 1) e C7-C11 com relogio de horas, downtime e disjuntivas de slot unico (Fase 2). TAT corrigido para 1,5h; top-10 bases; EVAM obrigatoria; L2 = maximizar horas voadas. Co-Authored-By: Claude Sonnet 4.6 --- HANDOFF_arara_oamrp.md | 119 +++ LOG.md | 4 + db/processed/resultado_oamrp_v3.csv | 51 ++ docs/changelog.md | 35 + software/oamrp_v3.py | 1195 +++++++++++++++++++++++++++ 5 files changed, 1404 insertions(+) create mode 100644 HANDOFF_arara_oamrp.md create mode 100644 db/processed/resultado_oamrp_v3.csv create mode 100644 software/oamrp_v3.py diff --git a/HANDOFF_arara_oamrp.md b/HANDOFF_arara_oamrp.md new file mode 100644 index 0000000..71d9363 --- /dev/null +++ b/HANDOFF_arara_oamrp.md @@ -0,0 +1,119 @@ +# HANDOFF — ARARA OAMRP (Aircraft Routing C-105 / FAB) + +> Documento de transferência para o **Claude Code**. +> Projeto local: `arara_oamrp/`. Cole/abra este arquivo no Claude Code e siga as instruções de leitura antes de validar a formulação e gerar código. + +--- + +## 0. Enquadramento do problema + +Modelo de **Aircraft Routing** para o Esquadrão ARARA (C-105), no estilo **OAMRP** (Al-Thani et al., 2016): sequenciamento **compacto** em **rede espaço-tempo**. + +**NÃO** é Set Partitioning + Column Generation (modelo civil Bazargan). Desvios deliberados em relação ao caso civil: + +1. **Cobertura ≤ 1** (seleção de missões), não particionamento `= 1`. +2. **Objetivo de maximização** de cobertura ponderada por prioridade, não minimização de custo. +3. Possibilidade de subconjunto de missões **obrigatórias (hard)**. + +**Stack:** Python + PuLP (CBC para começar) → HiGHS como evolução. + +--- + +## 1. Calibração confirmada (respostas do Vitor) + +| Item | Decisão | +|---|---| +| Frota | **4 aeronaves**, homogêneas (mesmo LRT, mesma performance) | +| Horizonte | **1 mês** | +| Discretização | **Estilo OAMRP** — eventos de voo com horários explícitos | +| TAT (turnaround) | **01h30** | +| Base de inspeção | Apenas **SBMN** (somente níveis **Base** e **Orgânico**; **Parque desconsiderado**) | +| Bases de operação | **10 bases da FAB** (derivar/confirmar a partir do CSV) | +| Traslado (ferry) | **Permitido** e **consome horas de célula** | +| Reset de horas | Inspeção **zera apenas aquela inspeção**; inspeções **não simultâneas** na mesma aeronave (podem ser sequenciais se requisitos cumpridos) | +| Prioridade | Escala **1 a 5** | +| Missões por dia | Uma cauda pode encadear **várias missões no mesmo dia** | +| Horas iniciais (f_k) | Criar **campos editáveis**; permitir **default aleatório** | + +### Objetivo (lexicográfico) +- **L1 — Primário:** maximizar Σ (prioridade × missões cumpridas). +- **L2 — Secundário:** "entregar aeronave com menor LRT" *(ver Q3 — pendente de definição)*. +- **Restrição dura derivada do 6.1:** **no máximo 1 aeronave em inspeção ao mesmo tempo** → modelada como restrição de recurso (C11), não como objetivo. + +--- + +## 2. Formulação matemática proposta (validar antes de codar) + +### Conjuntos +- `K` — aeronaves (caudas), |K| = 4. +- `B` — bases (10). `B_insp ⊆ B = {SBMN}`. +- `M` — missões candidatas. `M_obr ⊆ M` — obrigatórias *(Q2)*. +- `T` — tipos de inspeção válidos *(Q1)*. + +### Parâmetros +- `f⁰_{k,i}` — horas acumuladas iniciais da cauda `k` no relógio da inspeção `i` (editável / aleatório). +- `LRT_i` — intervalo (horas de célula) da inspeção `i`. +- `CAL_i` — limite calendárico (dias) da inspeção `i`. +- `DT_i` — tempo indisponível (downtime) da inspeção `i`. +- `h_m` — horas de voo da missão `m` (do CSV, por par O-D). +- `o_m, d_m` — origem/destino de `m`. +- `[e_m, l_m]` — janela de decolagem de `m`. +- `p_m ∈ {1..5}` — prioridade de `m`. +- `hferry_{b,b'}` — horas de traslado entre bases (do CSV/distância). +- `home_k` — base inicial da cauda `k`. +- `TAT = 1.5 h`. + +### Variáveis de decisão +- `x_{k,m} ∈ {0,1}` — cauda `k` cumpre missão `m`. +- `y` — arcos de sequenciamento na rede espaço-tempo (missão→missão, ferry, inspeção) por cauda. +- `t_dep_{k,m}`, `t_arr_{k,m}` — tempos (contínuos). +- `z_{k,i}` (+ início `s_{k,i}`) — cauda `k` executa inspeção `i`. +- `F_{k,i}(·)` — estado de horas acumuladas no relógio `i` ao longo da rota (contínuo, zera na inspeção `i`). + +### Restrições +- **C1 Cobertura:** Σ_k x_{k,m} ≤ 1 ∀m; **= 1** ∀m ∈ M_obr. +- **C2 Continuidade (fluxo):** cada cauda forma um caminho único ordenado no tempo, iniciando em `home_k`; grau de entrada = grau de saída nos nós. +- **C3 Continuidade espacial:** encadear `m→m'` exige `d_m = o_{m'}`, senão exige arco de **ferry**. +- **C4 Continuidade temporal + TAT:** `t_dep(próx) ≥ t_arr(ant) + TAT` (+ tempo de ferry, se houver reposicionamento). +- **C5 Janela:** `e_m ≤ t_dep_{k,m} ≤ l_m`. +- **C6 Orçamento de horas com reset:** ao longo da rota, horas acumuladas no relógio `i` ≤ `LRT_i`; voo/ferry somam horas; inspeção `i` zera o relógio `i`. +- **C7 Limite calendárico:** dias desde o último reset de `i` ≤ `CAL_i`. +- **C8 Local de inspeção:** inspeção `i` só ocorre com a cauda em `B_insp` (SBMN); a cauda precisa rotear até lá. +- **C9 Downtime:** durante `DT_i` a cauda fica indisponível; atividades seguintes começam após. +- **C10 Não-simultaneidade na mesma cauda:** duas inspeções da mesma cauda não se sobrepõem (podem ser back-to-back). +- **C11 Slot único de inspeção (recurso):** em qualquer instante, Σ_k [cauda em inspeção] ≤ 1. + +### Função objetivo (lexicográfica) +1. **max** Σ_m p_m · (Σ_k x_{k,m}) +2. **então** otimizar L2 *(Q3)*. + +--- + +## 3. Plano incremental de construção + +1. **Modelo base** — cobertura ponderada + orçamento de horas (C1–C6). +2. **Inspeção com reset** em SBMN — C7–C11 + downtime/calendário. +3. **Realismo militar** — prioridade hard (M_obr), voos de posicionamento, janelas. +4. **Replanejamento + visualização** — re-otimização e dashboard. + +--- + +## 4. Instruções para o Claude Code (executar na pasta `arara_oamrp/`) + +1. **Ler contexto:** abrir o arquivo de CONTEXTO/LOG e tudo em `docs/` para entender o estado atual. +2. **Auditar e podar:** o ChatGPT inseriu **acréscimos desnecessários** — revisar e remover o que fugir do OAMRP compacto, mas antes pergunte. Confirmar que **não** há resquício de Set Partitioning + Column Generation. +3. **Ler inspeções:** `relatorio_ciclo_inspecoes_c105_2805_2026-06-15` (JSON) → extrair, para cada inspeção válida *(Q1: seq 2,3,4,6,7,18,19,20,22,23)*, os campos `LRT_i` (horas), `CAL_i` (limite calendárico) e `DT_i` (tempo indisponível). +4. **Ler voos:** `db/processed/registro_voo_2025_consolidado.csv` → derivar as **10 bases**, as **horas de voo por par O-D** (`h_m`) e os tempos de **ferry**. +5. **Horas iniciais:** criar campos editáveis para `f⁰_{k,i}`, com opção de **default aleatório**. +6. **Validar a formulação** da Seção 2 com o Vitor antes de gerar PuLP. Construção **incremental**, confirmando cada etapa. + +--- + +## 5. Pendências a resolver (perguntar ao Vitor no Claude Code) + +- **Q1 — Inspeções válidas:** confirmar a lista de seq **{2, 3, 4, 6, 7, 18, 19, 20, 22, 23}**. +- **Q2 — Obrigatórias:** SAR/EVAM entram como **hard** (M_obr, cobertura = 1) ou apenas como prioridade 5? *(o dataset hoje só traz prioridade 1–5)* +- **Q3 — Objetivo secundário "entregar aeronave com menor LRT":** definir o sentido exato. Duas leituras possíveis: + - **(a)** minimizar a **folga de horas ao fim do horizonte** (usar bem as horas disponíveis: não deixar cauda ociosa com muita célula sobrando que poderia ter voado); + - **(b)** **priorizar o emprego** das caudas mais próximas da inspeção primeiro (consumir antes quem está mais perto do limite). +- **Q4 — Bases:** validar as 10 bases que o Claude Code propuser a partir do CSV. diff --git a/LOG.md b/LOG.md index aa70547..f4b05c0 100644 --- a/LOG.md +++ b/LOG.md @@ -26,3 +26,7 @@ Legenda de autores: | 2026-06-15 | 19:56 | VTO | Consolidou transicoes `ROTA` do registro de voo 2025 | `db/pre_process/registro_voo_2025/preprocess_planilha_2025.py`; `db/processed/registro_voo_2025_consolidado.csv` | `ROTA` tratado como marcador de transicao; 1421 linhas originais viraram 1268 trechos consolidados; 135 trechos com `ROTA` foram convertidos para origem/destino reais. | | 2026-06-15 | 19:56 | VTO | Validou o registro de voo 2025 consolidado | `db/pre_process/registro_voo_2025/validacao_registro_voo_2025.md`; `db/processed/registro_voo_2025_consolidado.csv` | Sem duplicidades, sem divergencias de duracao e sem sobreposicoes temporais; pendentes 1 registro sem OM, 1 trecho com pousos zero e 3 quebras de continuidade espacial. | | 2026-06-15 | 19:56 | VTO | Removeu registro isolado da aeronave 2806 | `db/pre_process/registro_voo_2025/preprocess_planilha_2025.py`; `db/processed/registro_voo_2025.csv`; `db/processed/registro_voo_2025_consolidado.csv` | Linha original 793 excluida dos artefatos processados; registro final passou para 1420 linhas e consolidado para 1267 trechos; nao ha mais registro sem OM. | +| 2026-06-15 | 19:56 | VTO | Criou modelo OAMRP v2 com dados reais de 2025 | `software/oamrp_v2.py`; `db/processed/resultado_oamrp_jan_2025.csv`; `README.md`; `docs/about.md`; `docs/changelog.md` | Rodada inicial de janeiro/2025 resolvida como otima pelo CBC; prioridade uniforme 1; base de manutencao SBMN; 50 de 61 missoes cumpridas. | +| 2026-06-15 | 21:00 | VTO | Criou modelo OAMRP v3 — Fase 1: rede espaço-tempo | `software/oamrp_v3.py`; `db/processed/resultado_oamrp_v3.csv` | Reescrita do zero com abstrações No/Arco e variável unificada y[k,arco]; C1–C6; TAT=1,5h; f0_{k,i} editável; top-10 bases por frequência; missões EVAM hard; 50/50 missões cobertas em jan/2025. | +| 2026-06-15 | 22:00 | VTO | Calibrou inspeções válidas (Q1) e respondeu Q2–Q4 | `software/oamrp_v3.py` | Removidas seq 18 e 23 do catálogo; EVAM marcado como M_obr (cobertura=1); L2 definido como minimizar folga de horas ao fim do horizonte; 10 bases por frequência. | +| 2026-06-15 | 22:30 | VTO | Implementou Fase 2: inspeções na rede espaço-tempo | `software/oamrp_v3.py` | Adicionados nós INSP_{k}_{seq} e arcos missao_insp/insp_missao; variáveis H_{k,i,nid} para rastreamento de horas com reset; C9 downtime; C11 slot único via disjuntivas. | diff --git a/db/processed/resultado_oamrp_v3.csv b/db/processed/resultado_oamrp_v3.csv new file mode 100644 index 0000000..fd0dcbf --- /dev/null +++ b/db/processed/resultado_oamrp_v3.csv @@ -0,0 +1,51 @@ +status,aeronave,ordem,id,om,orig,dest,partida_utc,chegada_utc,dur_h,prioridade,periodo,aeronave_real_2025,linhas_origem +cumprida,2800,1,RV2025_OM1_E2_L5_C5,1,SBMN,SBMN,2025-01-02T14:30:00Z,2025-01-02T15:35:00Z,1.08,1,NOTURNO,2809,5 +cumprida,2800,2,RV2025_OM5_E2_L13_C13,5,SBMN,SBMN,2025-01-07T14:15:00Z,2025-01-07T16:15:00Z,2.00,1,DIURNO,2803,13 +cumprida,2803,1,RV2025_OM7_E1_L15_C15,7,SBMN,SBMN,2025-01-07T10:40:00Z,2025-01-07T12:30:00Z,1.83,1,DIURNO,2809,15 +cumprida,2803,2,RV2025_OM8_E1_L17_C17,8,SBMN,SBMN,2025-01-08T10:35:00Z,2025-01-08T12:35:00Z,2.00,1,DIURNO,2803,17 +cumprida,2803,3,RV2025_OM12_E4_L28_C29,12,SBMN,SBMN,2025-01-11T21:05:00Z,2025-01-12T00:00:00Z,2.92,1,DIURNO;NOTURNO,2809,28;29 +cumprida,2803,4,RV2025_OM15_E1_L34_C34,15,SBMN,SBMN,2025-01-12T11:50:00Z,2025-01-12T13:30:00Z,1.67,1,DIURNO,2803,34 +cumprida,2809,1,RV2025_OM1_E1_L4_C4,1,SBMN,SBMN,2025-01-02T11:55:00Z,2025-01-02T13:05:00Z,1.17,1,NOTURNO,2809,4 +cumprida,2811,1,RV2025_OM3_E1_L8_C8,3,SBMN,SBMN,2025-01-03T14:45:00Z,2025-01-03T16:10:00Z,1.42,1,DIURNO,2809,8 +cumprida,2811,2,RV2025_OM4_E2_L10_C10,4,SBMN,SBMN,2025-01-06T15:40:00Z,2025-01-06T16:45:00Z,1.08,1,DIURNO,2803,10 +cumprida,2811,3,RV2025_OM9_E1_L20_C20,9,SBMN,SBMN,2025-01-09T12:30:00Z,2025-01-09T15:55:00Z,3.42,1,DIURNO,2803,20 +cumprida,2811,4,RV2025_OM9_E3_L22_C22,9,SBMN,SBMN,2025-01-09T20:40:00Z,2025-01-09T22:15:00Z,1.58,1,DIURNO,2803,22 +cumprida,2811,5,RV2025_OM14_E1_L32_C32,14,SBMN,SBMN,2025-01-12T16:55:00Z,2025-01-12T20:25:00Z,3.50,1,DIURNO,2809,32 +cumprida,2811,6,RV2025_OM17_E1_L40_C40,17,SBMN,SBMN,2025-01-15T13:30:00Z,2025-01-15T14:25:00Z,0.92,1,DIURNO,2809,40 +cumprida,2811,7,RV2025_OM17_E3_L42_C45,17,SBMN,SBMN,2025-01-15T22:50:00Z,2025-01-15T23:50:00Z,1.00,1,NOTURNO,2809,42;43;44;45 +nao_cumprida,,,RV2025_OM2_E3_L6_C6,2,SBMN,SBMN,2025-01-02T17:00:00Z,2025-01-02T18:05:00Z,1.08,1,NOTURNO,2809,6 +nao_cumprida,,,RV2025_OM2_E4_L7_C7,2,SBMN,SBMN,2025-01-02T20:00:00Z,2025-01-02T21:10:00Z,1.17,1,NOTURNO,2809,7 +nao_cumprida,,,RV2025_OM5_E1_L11_C11,5,SBMN,SBMN,2025-01-06T19:10:00Z,2025-01-06T21:20:00Z,2.17,1,DIURNO,2803,11 +nao_cumprida,,,RV2025_OM6_E1_L12_C12,6,SBMN,SBMN,2025-01-06T19:30:00Z,2025-01-06T21:25:00Z,1.92,1,DIURNO,2811,12 +nao_cumprida,,,RV2025_OM6_E2_L14_C14,6,SBMN,SBMN,2025-01-07T11:20:00Z,2025-01-07T13:20:00Z,2.00,1,DIURNO,2811,14 +nao_cumprida,,,RV2025_OM7_E2_L16_C16,7,SBMN,SBMN,2025-01-07T14:00:00Z,2025-01-07T15:45:00Z,1.75,1,DIURNO,2809,16 +nao_cumprida,,,RV2025_OM8_E2_L18_C18,8,SBMN,SBMN,2025-01-08T14:00:00Z,2025-01-08T16:00:00Z,2.00,1,DIURNO,2803,18 +nao_cumprida,,,RV2025_OM10_E1_L19_C19,10,SBMN,SBMN,2025-01-08T22:30:00Z,2025-01-08T23:15:00Z,0.75,1,NOTURNO,2803,19 +nao_cumprida,,,RV2025_OM9_E2_L21_C21,9,SBMN,SBMN,2025-01-09T18:10:00Z,2025-01-09T20:00:00Z,1.83,1,DIURNO,2803,21 +nao_cumprida,,,RV2025_OM11_E1_L23_C23,11,SBMN,SBMN,2025-01-10T12:10:00Z,2025-01-10T13:15:00Z,1.08,1,DIURNO,2803,23 +nao_cumprida,,,RV2025_OM11_E2_L24_C24,11,SBMN,SBMN,2025-01-10T14:25:00Z,2025-01-10T15:25:00Z,1.00,1,DIURNO,2803,24 +nao_cumprida,,,RV2025_OM13_E1_L30_C30,13,SBMN,SBMN,2025-01-12T12:20:00Z,2025-01-12T13:10:00Z,0.83,1,DIURNO,2809,30 +nao_cumprida,,,RV2025_OM15_E2_L35_C35,15,SBMN,SBMN,2025-01-12T14:25:00Z,2025-01-12T16:00:00Z,1.58,1,DIURNO,2803,35 +nao_cumprida,,,RV2025_OM13_E2_L31_C31,13,SBMN,SBMN,2025-01-12T14:40:00Z,2025-01-12T15:25:00Z,0.75,1,DIURNO,2809,31 +nao_cumprida,,,RV2025_OM15_E3_L36_C36,15,SBMN,SBMN,2025-01-12T18:30:00Z,2025-01-12T20:35:00Z,2.08,1,DIURNO,2803,36 +nao_cumprida,,,RV2025_OM14_E2_L33_C37,14,SBMN,SBMN,2025-01-12T22:45:00Z,2025-01-13T02:05:00Z,3.33,1,NOTURNO,2809,33;37 +nao_cumprida,,,RV2025_OM16_E1_L38_C38,16,SBMN,SBMN,2025-01-15T12:00:00Z,2025-01-15T15:05:00Z,3.08,1,DIURNO,2803,38 +nao_cumprida,,,RV2025_OM17_E2_L41_C41,17,SBMN,SBMN,2025-01-15T14:45:00Z,2025-01-15T15:40:00Z,0.92,1,DIURNO,2809,41 +nao_cumprida,,,RV2025_OM16_E2_L39_C39,16,SBMN,SBMN,2025-01-15T17:35:00Z,2025-01-15T20:35:00Z,3.00,1,DIURNO,2803,39 +nao_cumprida,,,RV2025_OM18_E1_L46_C46,18,SBMN,SBMN,2025-01-21T12:05:00Z,2025-01-21T13:40:00Z,1.58,1,DIURNO,2800,46 +nao_cumprida,,,RV2025_OM18_E2_L47_C47,18,SBMN,SBMN,2025-01-21T14:45:00Z,2025-01-21T16:15:00Z,1.50,1,DIURNO,2800,47 +nao_cumprida,,,RV2025_OM18_E3_L48_C48,18,SBMN,SBMN,2025-01-21T18:40:00Z,2025-01-21T20:40:00Z,2.00,1,DIURNO,2800,48 +nao_cumprida,,,RV2025_OM19_E1_L49_C49,19,SBMN,SBMN,2025-01-22T14:55:00Z,2025-01-22T16:00:00Z,1.08,1,DIURNO,2800,49 +nao_cumprida,,,RV2025_OM20_E1_L51_C51,20,SBMN,SBMN,2025-01-22T14:55:00Z,2025-01-22T16:00:00Z,1.08,1,DIURNO,2803,51 +nao_cumprida,,,RV2025_OM21_E1_L53_C53,21,SBMN,SBMN,2025-01-22T14:55:00Z,2025-01-22T16:00:00Z,1.08,1,DIURNO,2809,53 +nao_cumprida,,,RV2025_OM22_E1_L55_C55,22,SBMN,SBMN,2025-01-22T14:55:00Z,2025-01-22T15:55:00Z,1.00,1,DIURNO,2811,55 +nao_cumprida,,,RV2025_OM19_E2_L50_C50,19,SBMN,SBMN,2025-01-22T20:50:00Z,2025-01-22T21:25:00Z,0.58,1,DIURNO,2800,50 +nao_cumprida,,,RV2025_OM20_E2_L52_C52,20,SBMN,SBMN,2025-01-22T20:50:00Z,2025-01-22T21:25:00Z,0.58,1,DIURNO,2803,52 +nao_cumprida,,,RV2025_OM21_E2_L54_C54,21,SBMN,SBMN,2025-01-22T20:50:00Z,2025-01-22T21:25:00Z,0.58,1,DIURNO,2809,54 +nao_cumprida,,,RV2025_OM22_E2_L56_C56,22,SBMN,SBMN,2025-01-22T20:50:00Z,2025-01-22T21:25:00Z,0.58,1,DIURNO,2811,56 +nao_cumprida,,,RV2025_OM27_E1_L68_C68,27,SBMN,SBMN,2025-01-28T12:05:00Z,2025-01-28T13:05:00Z,1.00,1,DIURNO,2803,68 +nao_cumprida,,,RV2025_OM25_E1_L64_C64,25,SBMN,SBMN,2025-01-28T13:25:00Z,2025-01-28T14:30:00Z,1.08,1,DIURNO,2809,64 +nao_cumprida,,,RV2025_OM27_E2_L69_C69,27,SBMN,SBMN,2025-01-28T14:35:00Z,2025-01-28T15:30:00Z,0.92,1,DIURNO,2803,69 +nao_cumprida,,,RV2025_OM26_E1_L65_C70,26,SBMN,SBMN,2025-01-28T23:10:00Z,2025-01-29T00:50:00Z,1.67,1,NOTURNO,2809,65;66;67;70 +nao_cumprida,,,RV2025_OM28_E1_L71_C71,28,SBMN,SBMN,2025-01-29T18:15:00Z,2025-01-29T19:25:00Z,1.17,1,DIURNO,2809,71 +nao_cumprida,,,RV2025_OM29_E1_L72_C72,29,SBMN,SBMN,2025-01-29T22:50:00Z,2025-01-30T00:00:00Z,1.17,1,NOTURNO,2809,72 diff --git a/docs/changelog.md b/docs/changelog.md index f5ef7e3..30152ef 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -86,3 +86,38 @@ Formato: ### Observacoes - A remocao foi aplicada no script de pre-processamento para manter reprodutibilidade. + +## v0.5 - VTO - 2026-06 + +### Adicionado + +- Modelo `software/oamrp_v3.py` — reescrita do zero com rede espaço-tempo (No/Arco) e variável unificada `y[k,arco]`. +- Fase 1 (C1–C6): fluxo de conservação, cobertura de missões, restrição de voo ativo, orçamento de horas por inspeção. +- Fase 2 (C7–C11): nós e arcos de inspeção na rede; variáveis `H[k,i,nid]` para relógio de horas com reset; C9 downtime; C11 slot único via variáveis disjuntivas `oo`. +- Documento `HANDOFF_arara_oamrp.md` com formulação matemática, decisões de calibração (Q1–Q5) e plano de build incremental. +- Resultado `db/processed/resultado_oamrp_v3.csv` para janeiro/2025. + +### Alterado + +- TAT corrigido para 1,5h (era 1,0h em v2). +- Top-10 bases por frequência no registro de voo 2025 (Q4). +- Missões EVAM marcadas como obrigatórias (Q2). +- Objetivo L2 definido como maximizar horas voadas (= minimizar folga ao fim do horizonte mensal) (Q3). + +### Corrigido + +- Bug de escopo de arco: arcos de início/fim agora carregam `aeronave_id` e só entram na variável `y` do aircraft correto. +- Bug `NameError tat_h` em C9b: substituído pela constante global `TAT_H`. +- Bug `AttributeError` em `escrever_resultado`: filtro por `n.tipo == "missao"` antes de acessar `n.missao`. + +## v0.4 - VTO - 2026-06 + +### Adicionado + +- Modelo `software/oamrp_v2.py` para rodar o OAMRP com o registro de voo 2025 consolidado. +- Resultado inicial `db/processed/resultado_oamrp_jan_2025.csv`. + +### Observacoes + +- Rodada padrao usa janeiro/2025, aeronaves reais `2800`, `2803`, `2809` e `2811`, base inicial provisoria `SBMN`, base de manutencao `SBMN` e prioridade uniforme 1. +- Primeira execucao retornou solucao otima com 50 de 61 missoes cumpridas. diff --git a/software/oamrp_v3.py b/software/oamrp_v3.py new file mode 100644 index 0000000..ba86571 --- /dev/null +++ b/software/oamrp_v3.py @@ -0,0 +1,1195 @@ +""" +============================================================================ + AIRCRAFT ROUTING - ESQUADRAO ARARA - V3 +============================================================================ + Modelo OAMRP compacto (Al-Thani et al., 2016) em rede espaço-tempo. + NAO usa Set Partitioning / Column Generation. + + Arquitetura: + A rede é composta por Nos (origem, missao, inspecao, sumidouro) e Arcos + tipados. A variavel de decisao y[k, arco] unifica o que era x/s/c no v2. + Para adicionar inspeções na Fase 2, basta estender construir_rede() — + o modelo de otimização e a conservação de fluxo não mudam. + + Escopo desta versão — Fase 2 (C1–C9, C11): + C1 Cobertura: cada missão é servida por no máximo 1 aeronave (EVAM = hard) + C2 Conservação de fluxo por nó e por aeronave + C3 Continuidade espacial (dest(m) == orig(m') no arco) + C4 TAT = 1,5 h entre chegada e próxima decolagem + C5 Janela de decolagem derivada do CSV + C6 Orçamento de horas por relógio de inspeção com reset (H_{k,i,n} ≤ LRT_i) + C9 Downtime: próxima missão só começa após s_{k,i} + DT_i + TAT + C11 Slot único: no máximo 1 aeronave em inspeção em qualquer instante + + Fases futuras: + Fase 3 — arcos de ferry, C7 (limite calendárico), objetivo L2 real + Fase 4 — replanejamento e visualização + + Inspeções válidas (Q1 confirmado): + seq {2, 3, 4, 6, 7, 19, 20, 22} + Removidas: seq 18 (INSP 100FH) e seq 23 (INSP 50FH) + + Horas iniciais f0_{k,i}: + --horas-iniciais + --aleatorio [--seed N] + padrão: 0,0 para todas (aeronave recém-inspecionada — conservador) +============================================================================ +""" + +from __future__ import annotations + +import argparse +import csv +import json +import random +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Literal, Optional + +import pulp + + +# --------------------------------------------------------------------------- +# Caminhos padrão +# --------------------------------------------------------------------------- + +BASE_DIR = Path(__file__).resolve().parents[1] + +CSV_REGISTRO_PADRAO = ( + BASE_DIR / "db" / "processed" / "registro_voo_2025_consolidado.csv" +) +CSV_RESULTADO_PADRAO = ( + BASE_DIR / "db" / "processed" / "resultado_oamrp_v3.csv" +) +JSON_INSPECOES_PADRAO = ( + BASE_DIR + / "db" + / "pre_process" + / "relatorio_ciclo_inspecoes_c105_2805_2026-06-15" + / "relatorio_ciclo_inspecoes_c105_2805_2026-06-15_inspecoes.json" +) + +DATA_INICIO_PADRAO = "2025-01-01" +DATA_FIM_PADRAO = "2025-02-01" + +# --------------------------------------------------------------------------- +# Constantes operacionais +# --------------------------------------------------------------------------- + +BASE_MANUTENCAO = "SBMN" +TAT_H = 1.5 + +AERONAVES_PADRAO = ["2800", "2803", "2809", "2811"] +BASE_INICIAL_PADRAO = BASE_MANUTENCAO + +SEQS_VALIDOS = {2, 3, 4, 6, 7, 19, 20, 22} # Q1 confirmado +NIVEIS_PERMITIDOS = {"Base", "Orgânico"} + +N_BASES_PADRAO = 10 # Q4: top-N bases por frequência de uso +HARD_MISSAO_PADRAO = "EVAM" # Q2: padrão para identificar missões obrigatórias + + +# --------------------------------------------------------------------------- +# Domínio +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Missao: + id: str + orig: str + dest: str + t_dep: float # horas desde o início do horizonte + t_arr: float + dur_h: float + prioridade: int + obrigatoria: bool # True = M_obr (EVAM/hard); cobertura == 1 + partida_utc: datetime + chegada_utc: datetime + periodo: str + linhas_origem: str + aeronave_real: str + om: str + codigo_missao: str # campo 'missao' do CSV (ex: "69TV01", "EVAM001") + + +@dataclass(frozen=True) +class InspecaoParam: + seq: int + sigla: str + descricao: str + nivel: str + lrt_h: float + cal_dias: Optional[float] + dt_h: float + + +@dataclass +class Aeronave: + id: str + base_inicial: str + horas_iniciais: dict[int, float] = field(default_factory=dict) + + def orcamento_h(self, catalogo: dict[int, InspecaoParam]) -> float: + """ + Orçamento de horas disponíveis (C6 — Fase 1 simplificado). + Retorna min(LRT_i − f0_{k,i}) sobre todas as inspeções ativas. + """ + if not catalogo: + return 0.0 + margens = [ + insp.lrt_h - self.horas_iniciais.get(seq, 0.0) + for seq, insp in catalogo.items() + ] + return max(0.0, min(margens)) + + def insp_mais_restritiva( + self, catalogo: dict[int, InspecaoParam] + ) -> Optional[tuple[int, float, InspecaoParam]]: + if not catalogo: + return None + seq_min = min( + catalogo, + key=lambda s: catalogo[s].lrt_h - self.horas_iniciais.get(s, 0.0), + ) + return seq_min, self.horas_iniciais.get(seq_min, 0.0), catalogo[seq_min] + + +# --------------------------------------------------------------------------- +# Rede espaço-tempo +# --------------------------------------------------------------------------- + +TipoNo = Literal["origem", "missao", "inspecao", "sumidouro"] +TipoArco = Literal[ + "inicio", # origem_k → missao + "missao_missao", # missao → missao + "missao_insp", # missao → inspecao (Fase 2) + "insp_missao", # inspecao → missao (Fase 2) + "ferry", # qualquer → qualquer com reposicionamento (Fase 3) + "fim", # missao → sumidouro_k +] + + +@dataclass(frozen=True) +class No: + """ + Nó genérico da rede espaço-tempo. + + base_in — base onde a aeronave deve estar ao *entrar* neste nó. + base_out — base onde a aeronave estará ao *sair* deste nó. + t_ini — instante de início (decolagem para missão; início downtime para inspeção). + t_fim — instante de fim (chegada para missão; fim downtime para inspeção). + carga_h — horas de célula consumidas ao atravessar este nó. + + Nós de inspeção (Fase 2) terão aeronave_id preenchido — são nós por aeronave. + Nós de missão são compartilhados entre aeronaves. + """ + + id: str + tipo: TipoNo + base_in: str + base_out: str + t_ini: float + t_fim: float + carga_h: float = 0.0 + missao: Optional[Missao] = None + insp_seq: Optional[int] = None + aeronave_id: Optional[str] = None + + +@dataclass(frozen=True) +class Arco: + """ + Arco da rede espaço-tempo. + + aeronave_id — se preenchido, o arco pertence exclusivamente a esta aeronave + (arcos de início e fim). None indica arco compartilhado + (missao_missao, ferry), utilizável por qualquer aeronave. + + Restrições de viabilidade (continuidade espacial e temporal) são + verificadas em construir_rede() — não são restrições do modelo PuLP. + """ + + id: str + orig_id: str + dest_id: str + tipo: TipoArco + aeronave_id: Optional[str] = None # None = arco compartilhado + horas_ferry: float = 0.0 # horas de célula de ferry (Fase 3) + + +# --------------------------------------------------------------------------- +# Construção da rede (Fase 1 — sem nós de inspeção) +# --------------------------------------------------------------------------- + + +def construir_rede( + missoes: list[Missao], + aeronaves: list[Aeronave], + catalogo: dict[int, InspecaoParam], + tat_h: float = TAT_H, +) -> tuple[dict[str, No], list[Arco]]: + """ + Constrói os nós e arcos da rede espaço-tempo (Fase 2). + + Tipos de nós: + - origem_k : ponto de partida de cada aeronave k + - missao_m : cada missão candidata + - inspecao_k_i : nó de inspeção por (aeronave k, tipo i) — base SBMN + - sumidouro_k : ponto de chegada de cada aeronave k + + Tipos de arcos: + - inicio : origem_k → missao_m (m.orig == base_k) + - missao_missao : missao_m → missao_m' (dest==orig e gap ≥ TAT) + - missao_insp : missao_m → inspecao_k_i (m.dest == SBMN) + - insp_missao : inspecao_k_i → missao_m' (m'.orig == SBMN, t_dep ≥ DT_i+TAT) + - fim : missao_m / inspecao_k_i → sumidouro_k + + Fase 3 adicionará arcos de ferry entre bases distintas. + """ + nos: dict[str, No] = {} + arcos: list[Arco] = [] + + # --- Nós de missão --- + for m in missoes: + no_id = f"M_{m.id}" + nos[no_id] = No( + id=no_id, + tipo="missao", + base_in=m.orig, + base_out=m.dest, + t_ini=m.t_dep, + t_fim=m.t_arr, + carga_h=m.dur_h, + missao=m, + ) + + # --- Nós de origem e sumidouro por aeronave --- + for a in aeronaves: + src_id = f"SRC_{a.id}" + snk_id = f"SNK_{a.id}" + nos[src_id] = No( + id=src_id, + tipo="origem", + base_in=a.base_inicial, + base_out=a.base_inicial, + t_ini=0.0, + t_fim=0.0, + aeronave_id=a.id, + ) + nos[snk_id] = No( + id=snk_id, + tipo="sumidouro", + base_in="", # sumidouro aceita qualquer base + base_out="", + t_ini=float("inf"), + t_fim=float("inf"), + aeronave_id=a.id, + ) + + # --- Arcos início: origem_k → missao_m --- + for a in aeronaves: + src_id = f"SRC_{a.id}" + for m in missoes: + if m.orig != a.base_inicial: + continue + arc_id = f"INI_{a.id}_{m.id}" + arcos.append( + Arco( + id=arc_id, + orig_id=src_id, + dest_id=f"M_{m.id}", + tipo="inicio", + aeronave_id=a.id, + ) + ) + + # --- Arcos missao → missao --- + for mi in missoes: + for mj in missoes: + if mi.id == mj.id: + continue + if mi.dest != mj.orig: + continue + if mj.t_dep < mi.t_arr + tat_h: + continue + arc_id = f"MM_{mi.id}_{mj.id}" + arcos.append( + Arco( + id=arc_id, + orig_id=f"M_{mi.id}", + dest_id=f"M_{mj.id}", + tipo="missao_missao", + ) + ) + + # --- Arcos fim: missao_m → sumidouro_k --- + for a in aeronaves: + snk_id = f"SNK_{a.id}" + for m in missoes: + arc_id = f"FIM_{m.id}_{a.id}" + arcos.append( + Arco( + id=arc_id, + orig_id=f"M_{m.id}", + dest_id=snk_id, + tipo="fim", + aeronave_id=a.id, + ) + ) + + # --- Fase 2: Nós e arcos de inspeção --- + for a in aeronaves: + for seq, insp in catalogo.items(): + insp_nid = f"INSP_{a.id}_{seq}" + nos[insp_nid] = No( + id=insp_nid, + tipo="inspecao", + base_in=BASE_MANUTENCAO, + base_out=BASE_MANUTENCAO, + t_ini=0.0, # início flexível: determinado por s[k,i] no modelo + t_fim=insp.dt_h, # duração do downtime (s[k,i] + dt_h = fim) + carga_h=0.0, # inspeção não acumula horas de célula + insp_seq=seq, + aeronave_id=a.id, + ) + + # SRC_k → INSP_k_i (aeronave começa o horizonte em SBMN e vai direto) + if a.base_inicial == BASE_MANUTENCAO: + arcos.append(Arco( + id=f"SRC_INSP_{a.id}_{seq}", + orig_id=f"SRC_{a.id}", + dest_id=insp_nid, + tipo="inicio", + aeronave_id=a.id, + )) + + # M_m → INSP_k_i: missões que chegam em SBMN + for m in missoes: + if m.dest != BASE_MANUTENCAO: + continue + arcos.append(Arco( + id=f"MINSP_{a.id}_{seq}_{m.id}", + orig_id=f"M_{m.id}", + dest_id=insp_nid, + tipo="missao_insp", + aeronave_id=a.id, + )) + + # INSP_k_i → M_m': missões que partem de SBMN após o downtime mínimo + for m in missoes: + if m.orig != BASE_MANUTENCAO: + continue + if m.t_dep < insp.dt_h + tat_h: # muito cedo mesmo no melhor caso + continue + arcos.append(Arco( + id=f"INSPM_{a.id}_{seq}_{m.id}", + orig_id=insp_nid, + dest_id=f"M_{m.id}", + tipo="insp_missao", + aeronave_id=a.id, + )) + + # INSP_k_i → SNK_k (aeronave encerra o horizonte após inspeção) + arcos.append(Arco( + id=f"INSP_SNK_{a.id}_{seq}", + orig_id=insp_nid, + dest_id=f"SNK_{a.id}", + tipo="fim", + aeronave_id=a.id, + )) + + return nos, arcos + + +# --------------------------------------------------------------------------- +# Modelo de otimização +# --------------------------------------------------------------------------- + + +def resolver( + nos: dict[str, No], + arcos: list[Arco], + aeronaves: list[Aeronave], + catalogo: dict[int, InspecaoParam], +) -> dict: + ks = [a.id for a in aeronaves] + acft = {a.id: a for a in aeronaves} + missoes_nos = {nid: n for nid, n in nos.items() if n.tipo == "missao"} + + # Índices de adjacência para conservação de fluxo + arcos_saindo: dict[str, list[Arco]] = {nid: [] for nid in nos} + arcos_entrando: dict[str, list[Arco]] = {nid: [] for nid in nos} + for a in arcos: + arcos_saindo[a.orig_id].append(a) + arcos_entrando[a.dest_id].append(a) + + # --- Variáveis --- + # y[k, arco_id] = 1 se aeronave k usa este arco. + # Arcos exclusivos de outra aeronave (INI/FIM) não geram variável para k. + def arco_valido_para(k: str, a: Arco) -> bool: + return a.aeronave_id is None or a.aeronave_id == k + + y: dict[tuple[str, str], pulp.LpVariable] = { + (k, a.id): pulp.LpVariable(f"y_{k}_{a.id}", cat="Binary") + for k in ks + for a in arcos + if arco_valido_para(k, a) + } + + # z[missao_no_id] = 1 se a missão é coberta (por qualquer aeronave) + z: dict[str, pulp.LpVariable] = { + nid: pulp.LpVariable(f"z_{nid}", cat="Binary") + for nid in missoes_nos + } + + mdl = pulp.LpProblem("ARARA_OAMRP_v3", pulp.LpMaximize) + + # L1: max Σ_m p_m * z_m + mdl += ( + pulp.lpSum(n.missao.prioridade * z[nid] for nid, n in missoes_nos.items()), + "obj_L1", + ) + + def y_sum(k: str, arcs: list[Arco]) -> pulp.LpAffineExpression: + """Soma y[(k, a.id)] apenas para arcos válidos para a aeronave k.""" + return pulp.lpSum( + y[(k, a.id)] for a in arcs if (k, a.id) in y + ) + + # --- C1: Cobertura e definição de z --- + # Missões normais: Σ_k y_entrada ≤ 1 (cobertura opcional) + # Missões obrigatórias (M_obr / EVAM): Σ_k y_entrada == 1 + for nid, n in missoes_nos.items(): + fluxo_entrada = pulp.lpSum( + y[(k, a.id)] + for k in ks + for a in arcos_entrando[nid] + if (k, a.id) in y + ) + mdl += fluxo_entrada == z[nid], f"def_z_{nid}" + if n.missao.obrigatoria: + mdl += z[nid] == 1, f"hard_{nid}" + else: + mdl += z[nid] <= 1, f"cob_{nid}" + + # --- C2: Conservação de fluxo por (aeronave, nó) --- + for k in ks: + src_id = f"SRC_{k}" + snk_id = f"SNK_{k}" + for nid, n in nos.items(): + if n.tipo in ("origem", "sumidouro") and n.aeronave_id != k: + continue # nó de outra aeronave — irrelevante + + entrada_k = y_sum(k, arcos_entrando[nid]) + saida_k = y_sum(k, arcos_saindo[nid]) + + if nid == src_id: + # Origem: no máximo 1 arco de saída (aeronave pode não voar) + mdl += saida_k <= 1, f"src_{k}" + elif nid == snk_id: + # Sumidouro: entrada = saída da origem (fluxo fechado) + src_saida = y_sum(k, arcos_saindo[src_id]) + mdl += entrada_k == src_saida, f"snk_{k}" + elif n.tipo == "missao": + # Missão: fluxo de entrada = fluxo de saída para esta aeronave + mdl += entrada_k == saida_k, f"fluxo_{k}_{nid}" + + # --- Fase 2: variáveis e restrições C6-reset, C9, C11 --- + + insp_nos = {nid: n for nid, n in nos.items() if n.tipo == "inspecao"} + + # Big-M: usa LRT máximo (para horas) e horizonte temporal (para tempo) + M_h = max((insp.lrt_h for insp in catalogo.values()), default=1.0) + 100.0 + M_t = ( + max((n.t_fim for n in missoes_nos.values()), default=0.0) + 200.0 + ) + + # s[k,i]: instante de início da inspeção i pela aeronave k + s_insp: dict[tuple[str, int], pulp.LpVariable] = { + (k, seq): pulp.LpVariable(f"s_{k}_{seq}", lowBound=0, upBound=M_t) + for k in ks + for seq in catalogo + } + + # H[k, i, nid]: horas acumuladas no relógio i da aeronave k ao sair do nó nid. + # Definido para todos os nós de missão e de inspeção (exceto o nó de reset + # de clock i, onde H = 0 por definição). + def _h_valido(k: str, seq: int, nid: str) -> bool: + n = nos[nid] + if n.tipo in ("origem", "sumidouro"): + return False + # Nó de reset do próprio clock i: H = 0, não precisa de variável + if n.tipo == "inspecao" and n.insp_seq == seq and n.aeronave_id == k: + return False + # Nó de inspeção de outra aeronave: irrelevante + if n.tipo == "inspecao" and n.aeronave_id != k: + return False + return True + + H: dict[tuple[str, int, str], pulp.LpVariable] = { + (k, seq, nid): pulp.LpVariable( + f"H_{k}_{seq}_{nid}", + lowBound=0, + upBound=catalogo[seq].lrt_h, + ) + for k in ks + for seq in catalogo + for nid in nos + if _h_valido(k, seq, nid) + } + + def _H_prev(k: str, seq: int, u_nid: str) -> object: + """Retorna o valor/variável de H_{k,seq} ao sair do nó u.""" + n = nos[u_nid] + if n.tipo == "origem": + return acft[k].horas_iniciais.get(seq, 0.0) + # Nó de reset para este clock: saída é 0 + if n.tipo == "inspecao" and n.insp_seq == seq and n.aeronave_id == k: + return 0.0 + return H.get((k, seq, u_nid)) + + # Propagação de H ao longo dos arcos (big-M) + for k in ks: + for seq in catalogo: + reset_nid = f"INSP_{k}_{seq}" + for a in arcos: + if (k, a.id) not in y: + continue + v_nid = a.dest_id + if v_nid == reset_nid: + continue # reset: H = 0, sem propagação + H_v = H.get((k, seq, v_nid)) + if H_v is None: + continue + h_prev = _H_prev(k, seq, a.orig_id) + if h_prev is None: + continue + carga = nos[v_nid].carga_h # dur_h para missões, 0 para inspeções + y_var = y[(k, a.id)] + mdl += H_v >= h_prev + carga - M_h * (1 - y_var), f"Hlo_{k}_{seq}_{a.id}" + mdl += H_v <= h_prev + carga + M_h * (1 - y_var), f"Hhi_{k}_{seq}_{a.id}" + + # --- C6 (reset): H[k,i,M_m] ≤ LRT_i quando aeronave k visita M_m --- + # (já aplicado como upBound na variável; confirma aqui explicitamente) + # Nota: se a missão não for visitada, H fica ≤ LRT por bound. + + # Horizonte temporal em horas (última chegada registrada) + horizonte_h = max((n.t_fim for n in missoes_nos.values()), default=744.0) + + # Inspeção só pode começar dentro do horizonte de planejamento + for k in ks: + for seq in catalogo: + insp_nid = f"INSP_{k}_{seq}" + if insp_nid not in nos: + continue + w_ki = pulp.lpSum( + y[(k, a.id)] for a in arcos_entrando[insp_nid] if (k, a.id) in y + ) + mdl += ( + s_insp[(k, seq)] <= horizonte_h * w_ki, + f"s_horizonte_{k}_{seq}", + ) + + # --- C9: downtime — próxima missão só parte após s[k,i] + DT_i + TAT --- + for k in ks: + for seq, insp in catalogo.items(): + insp_nid = f"INSP_{k}_{seq}" + # s[k,i] ≥ t_arr(m_antes) para cada arco M_m → INSP + for a in arcos_entrando[insp_nid]: + if (k, a.id) not in y: + continue + u = nos[a.orig_id] + if u.tipo == "missao": + mdl += ( + s_insp[(k, seq)] + >= u.t_fim - M_t * (1 - y[(k, a.id)]), + f"c9a_{k}_{seq}_{a.id}", + ) + # t_dep(m') ≥ s[k,i] + DT_i + TAT para cada arco INSP → M_m' + for a in arcos_saindo[insp_nid]: + if (k, a.id) not in y: + continue + v = nos[a.dest_id] + if v.tipo == "missao": + mdl += ( + v.t_ini + M_t * (1 - y[(k, a.id)]) + >= s_insp[(k, seq)] + insp.dt_h + TAT_H, + f"c9b_{k}_{seq}_{a.id}", + ) + + # --- C11: slot único — no máximo 1 aeronave em inspeção em qualquer instante --- + # Para cada par distinto de inspeções de aeronaves diferentes, + # variável oo = 1 se (k1,i1) termina antes de (k2,i2) começar. + insp_events = [ + (k, seq) + for k in ks + for seq in catalogo + if f"INSP_{k}_{seq}" in nos + ] + for idx1, (k1, seq1) in enumerate(insp_events): + for k2, seq2 in insp_events[idx1 + 1:]: + if k1 == k2: + continue # mesma aeronave: C10 já garantido pelo fluxo + insp1, insp2 = catalogo[seq1], catalogo[seq2] + nid1, nid2 = f"INSP_{k1}_{seq1}", f"INSP_{k2}_{seq2}" + w1 = pulp.lpSum(y[(k1, a.id)] for a in arcos_entrando[nid1] if (k1, a.id) in y) + w2 = pulp.lpSum(y[(k2, a.id)] for a in arcos_entrando[nid2] if (k2, a.id) in y) + oo = pulp.LpVariable(f"oo_{k1}_{seq1}_{k2}_{seq2}", cat="Binary") + # (k1,i1) termina antes de (k2,i2): s2 ≥ s1 + DT1 + mdl += ( + s_insp[(k2, seq2)] + >= s_insp[(k1, seq1)] + insp1.dt_h + - M_t * (1 - oo) - M_t * (1 - w1) - M_t * (1 - w2), + f"c11a_{k1}_{seq1}_{k2}_{seq2}", + ) + # (k2,i2) termina antes de (k1,i1): s1 ≥ s2 + DT2 + mdl += ( + s_insp[(k1, seq1)] + >= s_insp[(k2, seq2)] + insp2.dt_h + - M_t * oo - M_t * (1 - w1) - M_t * (1 - w2), + f"c11b_{k1}_{seq1}_{k2}_{seq2}", + ) + + # --- C6 (Fase 1 — mantido como redundância explícita para verificação) --- + for k in ks: + orcamento = acft[k].orcamento_h(catalogo) + mdl += ( + pulp.lpSum( + n.carga_h * y[(k, a.id)] + for nid, n in missoes_nos.items() + for a in arcos_entrando[nid] + if (k, a.id) in y + ) + <= orcamento, + f"orcamento_{k}", + ) + + solver = pulp.PULP_CBC_CMD(msg=False) + mdl.solve(solver) + prio_otima = int(round(pulp.value(mdl.objective) or 0)) + + # L2: com prioridade fixada, minimiza folga de horas ao fim do horizonte. + # folga_k = orcamento_k − horas_voadas_k + # Minimizar Σ_k folga_k ⟺ maximizar Σ_k horas_voadas_k (orcamento é constante). + mdl += ( + pulp.lpSum(n.missao.prioridade * z[nid] for nid, n in missoes_nos.items()) + == prio_otima, + "fixa_L1", + ) + horas_voadas = pulp.lpSum( + n.carga_h * y[(k, a.id)] + for nid, n in missoes_nos.items() + for k in ks + for a in arcos_entrando[nid] + if (k, a.id) in y + ) + mdl.sense = pulp.LpMaximize + mdl.setObjective(horas_voadas) + mdl.solve(solver) + + return { + "modelo": mdl, + "nos": nos, + "arcos": arcos, + "arcos_entrando": arcos_entrando, + "arcos_saindo": arcos_saindo, + "missoes_nos": missoes_nos, + "insp_nos": insp_nos, + "acft": acft, + "catalogo": catalogo, + "ks": ks, + "y": y, + "z": z, + "s_insp": s_insp, + "H": H, + "prio_otima": prio_otima, + } + + +# --------------------------------------------------------------------------- +# Extração de rota +# --------------------------------------------------------------------------- + + +def rota_da_aeronave(sol: dict, k: str) -> list[No]: + """ + Reconstrói a sequência de nós (missão e inspeção) percorrida pela aeronave k. + """ + nos = sol["nos"] + arcos_saindo = sol["arcos_saindo"] + y = sol["y"] + + src_id = f"SRC_{k}" + seq: list[No] = [] + atual_id = src_id + + while True: + prox = [ + a + for a in arcos_saindo[atual_id] + if y.get((k, a.id)) is not None + and y[(k, a.id)].value() is not None + and y[(k, a.id)].value() > 0.5 + ] + if not prox: + break + arco = prox[0] + no_dest = nos[arco.dest_id] + if no_dest.tipo == "sumidouro": + break + seq.append(no_dest) + atual_id = arco.dest_id + + return seq + + +def inspecoes_da_aeronave(sol: dict, k: str) -> list[tuple[int, float, InspecaoParam]]: + """Retorna lista de (seq, s_inicio, InspecaoParam) para inspeções executadas por k.""" + s_insp = sol["s_insp"] + catalogo = sol["catalogo"] + nos = sol["nos"] + arcos_entrando = sol["arcos_entrando"] + y = sol["y"] + resultado = [] + for seq, insp in catalogo.items(): + insp_nid = f"INSP_{k}_{seq}" + if insp_nid not in nos: + continue + if any( + y.get((k, a.id)) is not None and y[(k, a.id)].value() is not None + and y[(k, a.id)].value() > 0.5 + for a in arcos_entrando[insp_nid] + ): + s_val = s_insp[(k, seq)].value() or 0.0 + resultado.append((seq, s_val, insp)) + return sorted(resultado, key=lambda t: t[1]) + + +# --------------------------------------------------------------------------- +# Saída +# --------------------------------------------------------------------------- + + +def escrever_resultado(sol: dict, caminho: Path) -> None: + caminho.parent.mkdir(parents=True, exist_ok=True) + z = sol["z"] + missoes_nos = sol["missoes_nos"] + linhas = [] + + cobertas_ids: set[str] = set() + for k in sol["ks"]: + missoes_rota = [n for n in rota_da_aeronave(sol, k) if n.tipo == "missao"] + for ordem, no in enumerate(missoes_rota, start=1): + m = no.missao + cobertas_ids.add(no.id) + linhas.append({ + "status": "cumprida", + "aeronave": k, + "ordem": ordem, + "id": m.id, + "om": m.om, + "orig": m.orig, + "dest": m.dest, + "partida_utc": m.partida_utc.isoformat().replace("+00:00", "Z"), + "chegada_utc": m.chegada_utc.isoformat().replace("+00:00", "Z"), + "dur_h": f"{m.dur_h:.2f}", + "prioridade": m.prioridade, + "periodo": m.periodo, + "aeronave_real_2025": m.aeronave_real, + "linhas_origem": m.linhas_origem, + }) + + for nid, n in missoes_nos.items(): + if nid in cobertas_ids: + continue + m = n.missao + linhas.append({ + "status": "nao_cumprida", + "aeronave": "", + "ordem": "", + "id": m.id, + "om": m.om, + "orig": m.orig, + "dest": m.dest, + "partida_utc": m.partida_utc.isoformat().replace("+00:00", "Z"), + "chegada_utc": m.chegada_utc.isoformat().replace("+00:00", "Z"), + "dur_h": f"{m.dur_h:.2f}", + "prioridade": m.prioridade, + "periodo": m.periodo, + "aeronave_real_2025": m.aeronave_real, + "linhas_origem": m.linhas_origem, + }) + + colunas = [ + "status", "aeronave", "ordem", "id", "om", + "orig", "dest", "partida_utc", "chegada_utc", + "dur_h", "prioridade", "periodo", "aeronave_real_2025", "linhas_origem", + ] + with caminho.open("w", newline="", encoding="utf-8-sig") as f: + w = csv.DictWriter(f, fieldnames=colunas) + w.writeheader() + w.writerows(linhas) + + +def imprimir_relatorio( + sol: dict, bases: list[str], resultado_csv: Path +) -> None: + z = sol["z"] + missoes_nos = sol["missoes_nos"] + catalogo: dict[int, InspecaoParam] = sol["catalogo"] + acft: dict[str, Aeronave] = sol["acft"] + + cobertas = [nid for nid, n in missoes_nos.items() if z[nid].value() and z[nid].value() > 0.5] + prio_total = sum(n.missao.prioridade for n in missoes_nos.values()) + prio_obtida = sum(missoes_nos[nid].missao.prioridade for nid in cobertas) + total = len(missoes_nos) + + print("=" * 72) + print(" OAMRP V3 — Fase 1 (C1–C6) | Esquadrao Arara C-105") + print("=" * 72) + print(f" Status solver : {pulp.LpStatus[sol['modelo'].status]}") + print(f" Missoes : {len(cobertas)}/{total} cumpridas") + print(f" Prioridade : {prio_obtida}/{prio_total}") + print(f" Resultado CSV : {resultado_csv}") + + print(f"\n Bases observadas ({len(bases)}): {', '.join(bases)}") + + print("\n Catalogo de inspecoes (Fase 1 — orcamento por aeronave):") + for seq in sorted(catalogo): + insp = catalogo[seq] + cal = f"{insp.cal_dias:.0f}d" if insp.cal_dias else "—" + print( + f" seq {seq:2d} {insp.sigla:<18} LRT={insp.lrt_h:>6.0f}h " + f"CAL={cal:>5} DT={insp.dt_h:.1f}h [{insp.nivel}]" + ) + + print("\n Orcamento por aeronave:") + for k in sol["ks"]: + a = acft[k] + oc = a.orcamento_h(catalogo) + info = a.insp_mais_restritiva(catalogo) + if info: + seq_min, f0, insp = info + print( + f" {k}: {oc:.1f}h " + f"(limitado por seq {seq_min} {insp.sigla}, " + f"f0={f0:.1f}h, LRT={insp.lrt_h:.0f}h)" + ) + else: + print(f" {k}: {oc:.1f}h (sem restricao de inspecao)") + + print() + for k in sol["ks"]: + rota = rota_da_aeronave(sol, k) + insps = inspecoes_da_aeronave(sol, k) + missoes_rota = [n for n in rota if n.tipo == "missao"] + horas = sum(n.carga_h for n in missoes_rota) + orcamento = acft[k].orcamento_h(catalogo) + folga = orcamento - horas + print( + f" Aeronave {k}: {len(missoes_rota)} missoes | {horas:.1f}h voadas | " + f"folga {folga:.1f}h | {len(insps)} inspecao(oes)" + ) + # Linha do tempo unificada: missões e inspeções ordenadas por t_ini + eventos: list[tuple[float, str]] = [] + for no in rota: + if no.tipo == "missao": + m = no.missao + eventos.append(( + no.t_ini, + f" {m.partida_utc:%d/%m %H:%M}Z " + f"{m.orig}->{m.dest} OM {m.om} prio {m.prioridade} ({m.id})" + )) + elif no.tipo == "inspecao": + seq = no.insp_seq + s_val = sol["s_insp"][(k, seq)].value() or 0.0 + insp = catalogo[seq] + eventos.append(( + s_val, + f" [INSP seq {seq} {insp.sigla}] " + f"s={s_val:.1f}h DT={insp.dt_h:.1f}h fim={s_val + insp.dt_h:.1f}h" + )) + for _, linha in sorted(eventos)[:15]: + print(linha) + if len(eventos) > 15: + print(f" ... mais {len(eventos) - 15} eventos") + print() + + nao = [nid for nid in missoes_nos if nid not in cobertas] + if nao: + print(f" Missoes nao cumpridas ({len(nao)}, primeiras 20):") + for nid in nao[:20]: + m = missoes_nos[nid].missao + print( + f" {m.partida_utc:%d/%m %H:%M}Z " + f"{m.orig}->{m.dest} OM {m.om} ({m.id})" + ) + if len(nao) > 20: + print(f" ... mais {len(nao) - 20}") + + +# --------------------------------------------------------------------------- +# Carregamento de dados +# --------------------------------------------------------------------------- + + +def _parse_utc(valor: str) -> datetime: + texto = str(valor).strip() + if texto.endswith("Z"): + texto = texto[:-1] + "+00:00" + dt = datetime.fromisoformat(texto) + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + +def carregar_missoes( + caminho: Path, + inicio: datetime, + fim: datetime, + bases_validas: Optional[set[str]] = None, + hard_pattern: str = HARD_MISSAO_PADRAO, + prioridade_padrao: int = 1, +) -> tuple[list[Missao], datetime]: + """ + Lê o CSV consolidado e retorna missões na janela [inicio, fim). + + bases_validas — se fornecido, descarta missões fora deste conjunto de bases (Q4). + hard_pattern — substring no campo 'missao' que marca M_obr (Q2, ex: 'EVAM'). + """ + import re + hard_re = re.compile(hard_pattern, re.IGNORECASE) + + brutos: list[tuple[datetime, datetime, dict]] = [] + with caminho.open(newline="", encoding="utf-8-sig") as f: + for linha in csv.DictReader(f): + partida = _parse_utc(linha["partida_utc"]) + chegada = _parse_utc(linha["chegada_utc"]) + if not (inicio <= partida < fim): + continue + if chegada <= partida: + raise ValueError( + f"Missao {linha['id_registro']}: chegada antes da partida" + ) + orig = linha["localidade_dep"] + dest = linha["localidade_arr"] + if bases_validas and (orig not in bases_validas or dest not in bases_validas): + continue + brutos.append((partida, chegada, linha)) + + if not brutos: + raise ValueError("Nenhuma missao encontrada na janela/bases informadas.") + + brutos.sort(key=lambda t: (t[0], t[2]["id_registro"])) + horizonte = brutos[0][0] + + missoes = [] + for partida, chegada, linha in brutos: + t_dep = (partida - horizonte).total_seconds() / 3600 + t_arr = (chegada - horizonte).total_seconds() / 3600 + dur_h = t_arr - t_dep + prio = int(linha.get("prioridade", prioridade_padrao) or prioridade_padrao) + cod_missao = linha.get("missao", "") + obrigatoria = bool(hard_re.search(cod_missao)) + missoes.append( + Missao( + id=linha["id_registro"], + orig=orig, + dest=dest, + t_dep=t_dep, + t_arr=t_arr, + dur_h=dur_h, + prioridade=prio, + obrigatoria=obrigatoria, + partida_utc=partida, + chegada_utc=chegada, + periodo=linha.get("periodo", ""), + linhas_origem=linha.get("linhas_origem", ""), + aeronave_real=linha.get("aeronave", ""), + om=linha.get("om", ""), + codigo_missao=cod_missao, + ) + ) + return missoes, horizonte + + +def carregar_inspecoes(caminho: Path) -> dict[int, InspecaoParam]: + with caminho.open(encoding="utf-8") as f: + dados = json.load(f) + + catalogo: dict[int, InspecaoParam] = {} + for item in dados["inspecoes"]: + seq = item["seq"] + if seq not in SEQS_VALIDOS: + continue + nivel_desc = item.get("nivel_descricao", "") + if nivel_desc not in NIVEIS_PERMITIDOS: + continue + lrt = item.get("intervalo_horas_voo_valor") + if not lrt: + continue # sem intervalo em horas — só calendárica; entra na Fase 2 via CAL + + cal_meses = item.get("intervalo_meses_continuos_valor") + cal_dias = float(cal_meses) * 30.0 if cal_meses else None + + dur_valor = item.get("duracao_valor", 0) + dur_unidade = item.get("duracao_unidade", "horas") + dt_h = float(dur_valor) * 24.0 if dur_unidade == "dias" else float(dur_valor) + + catalogo[seq] = InspecaoParam( + seq=seq, + sigla=item["sigla_mnt"], + descricao=item["descricao_mnt"], + nivel=nivel_desc, + lrt_h=float(lrt), + cal_dias=cal_dias, + dt_h=dt_h, + ) + return catalogo + + +def derivar_bases( + caminho: Path, + inicio: datetime, + fim: datetime, + top_n: Optional[int] = None, +) -> list[str]: + """ + Deriva bases ICAO observadas na janela. + top_n — se fornecido, retorna apenas as N bases mais frequentes (Q4). + A base de manutenção SBMN é sempre incluída. + """ + from collections import Counter + contagem: Counter = Counter() + with caminho.open(newline="", encoding="utf-8-sig") as f: + for linha in csv.DictReader(f): + try: + partida = _parse_utc(linha["partida_utc"]) + except Exception: + continue + if not (inicio <= partida < fim): + continue + contagem[linha["localidade_dep"]] += 1 + contagem[linha["localidade_arr"]] += 1 + + if top_n is None: + return sorted(contagem) + + # Garante que SBMN entra independente do ranking + top = {b for b, _ in contagem.most_common(top_n)} + top.add(BASE_MANUTENCAO) + if len(top) > top_n: + # SBMN foi adicionada extra; remove a menos frequente que não seja SBMN + extras = sorted(top - {BASE_MANUTENCAO}, key=lambda b: contagem[b]) + top.discard(extras[0]) + return sorted(top) + + +# --------------------------------------------------------------------------- +# Horas iniciais +# --------------------------------------------------------------------------- + + +def horas_iniciais_aleatorias( + aeronaves: list[str], + catalogo: dict[int, InspecaoParam], + seed: Optional[int] = None, +) -> dict[str, dict[int, float]]: + rng = random.Random(seed) + return { + k: { + seq: round(rng.uniform(0.1 * insp.lrt_h, 0.9 * insp.lrt_h), 1) + for seq, insp in catalogo.items() + } + for k in aeronaves + } + + +def carregar_horas_iniciais( + fonte: Optional[str], + aleatorio: bool, + aeronaves: list[str], + catalogo: dict[int, InspecaoParam], + seed: Optional[int] = None, +) -> dict[str, dict[int, float]]: + if fonte: + p = Path(fonte) + raw = json.loads(p.read_text(encoding="utf-8")) if p.exists() else json.loads(fonte) + return {k: {int(s): float(v) for s, v in d.items()} for k, d in raw.items()} + if aleatorio: + return horas_iniciais_aleatorias(aeronaves, catalogo, seed=seed) + return {k: {seq: 0.0 for seq in catalogo} for k in aeronaves} + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def main() -> None: + parser = argparse.ArgumentParser( + description="OAMRP v3 — Fase 1: rede espaço-tempo + cobertura + orçamento." + ) + parser.add_argument("--csv", type=Path, default=CSV_REGISTRO_PADRAO) + parser.add_argument("--inspecoes", type=Path, default=JSON_INSPECOES_PADRAO) + parser.add_argument("--inicio", default=DATA_INICIO_PADRAO) + parser.add_argument("--fim", default=DATA_FIM_PADRAO) + parser.add_argument("--resultado", type=Path, default=CSV_RESULTADO_PADRAO) + parser.add_argument( + "--horas-iniciais", metavar="JSON", + help='f0_{k,i}: arquivo ou string JSON. Ex: {"2800": {"23": 35.0}}', + ) + parser.add_argument( + "--aleatorio", action="store_true", + help="Gera f0_{k,i} aleatório (10%–90% do LRT).", + ) + parser.add_argument("--seed", type=int, default=None) + parser.add_argument( + "--aeronaves", nargs="+", default=AERONAVES_PADRAO, metavar="CAUDA" + ) + parser.add_argument("--base-inicial", default=BASE_INICIAL_PADRAO) + parser.add_argument( + "--n-bases", type=int, default=N_BASES_PADRAO, + help="Top-N bases por frequência de uso (Q4). 0 = todas.", + ) + parser.add_argument( + "--hard-pattern", default=HARD_MISSAO_PADRAO, + help="Regex no campo 'missao' do CSV que marca missões obrigatórias (Q2).", + ) + args = parser.parse_args() + + inicio = datetime.fromisoformat(args.inicio).replace(tzinfo=timezone.utc) + fim = datetime.fromisoformat(args.fim).replace(tzinfo=timezone.utc) + + catalogo = carregar_inspecoes(args.inspecoes) + top_n = args.n_bases if args.n_bases > 0 else None + bases = derivar_bases(args.csv, inicio, fim, top_n=top_n) + bases_validas = set(bases) if top_n else None + missoes, _ = carregar_missoes( + args.csv, inicio, fim, + bases_validas=bases_validas, + hard_pattern=args.hard_pattern, + ) + + f0 = carregar_horas_iniciais( + fonte=args.horas_iniciais, + aleatorio=args.aleatorio, + aeronaves=args.aeronaves, + catalogo=catalogo, + seed=args.seed, + ) + aeronaves = [ + Aeronave(id=k, base_inicial=args.base_inicial, horas_iniciais=f0.get(k, {})) + for k in args.aeronaves + ] + + nos, arcos = construir_rede(missoes, aeronaves, catalogo) + sol = resolver(nos, arcos, aeronaves, catalogo) + escrever_resultado(sol, args.resultado) + imprimir_relatorio(sol, bases, args.resultado) + + +if __name__ == "__main__": + main()