commit 6443c0e2dc93c963018d69b2cac140b9f1a88015 Author: Lucas Date: Fri Jun 26 18:07:04 2026 -0300 Initial project import diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8f89e3d --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +.venv/ +venv/ +env/ +__pycache__/ +**/__pycache__/ +*.py[cod] +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +resultados/ + +logs/ +*.log +temp/ +*.tmp +*.temp +~$* + +.env +.env.* diff --git a/.vscode/Escalante Pastor V6.code-workspace b/.vscode/Escalante Pastor V6.code-workspace new file mode 100644 index 0000000..2c10f35 --- /dev/null +++ b/.vscode/Escalante Pastor V6.code-workspace @@ -0,0 +1,7 @@ +{ + "folders": [ + { + "path": ".." + } + ] +} diff --git a/.vscode/planejador_missao_limpo_milp.code-workspace b/.vscode/planejador_missao_limpo_milp.code-workspace new file mode 100644 index 0000000..2c10f35 --- /dev/null +++ b/.vscode/planejador_missao_limpo_milp.code-workspace @@ -0,0 +1,7 @@ +{ + "folders": [ + { + "path": ".." + } + ] +} diff --git a/Abrir Planejador.vbs b/Abrir Planejador.vbs new file mode 100644 index 0000000..30f25c0 --- /dev/null +++ b/Abrir Planejador.vbs @@ -0,0 +1,18 @@ +Set shell = CreateObject("WScript.Shell") +Set fso = CreateObject("Scripting.FileSystemObject") + +baseDir = fso.GetParentFolderName(WScript.ScriptFullName) +pythonw = baseDir & "\.venv\Scripts\pythonw.exe" +python = baseDir & "\.venv\Scripts\python.exe" +app = baseDir & "\web_app.py" + +If fso.FileExists(pythonw) Then + exe = pythonw +ElseIf fso.FileExists(python) Then + exe = python +Else + exe = "pythonw" +End If + +shell.CurrentDirectory = baseDir +shell.Run """" & exe & """ """ & app & """", 0, False diff --git a/README.md b/README.md new file mode 100644 index 0000000..b28488c --- /dev/null +++ b/README.md @@ -0,0 +1,216 @@ +# Planejador Diario de Missoes e Sobreaviso + +Projeto em Python para gerar a escala diaria de missao acionada, missao local e sobreaviso usando Programacao Linear Inteira Mista (MILP). + +## 1. Estrutura do projeto + +```text +dados/ Arquivos de entrada do planejamento +resultados/ Planilhas geradas pelo planejador +src/planejador_missao/ Codigo-fonte Python do modelo +scripts/ Scripts numerados de execucao, validacao e teste +.vscode/ Arquivos de workspace do VS Code +web_app.py Interface web HTML para abrir no navegador +run_planner.py Ponto de entrada para executar o planejamento +requirements.txt Dependencias Python +Abrir Planejador.vbs Atalho para abrir a interface sem prompt +``` + +A execucao oficial do projeto e feita somente em Python. + +## 2. Preparacao do ambiente + +No PowerShell, a partir da raiz do projeto: + +```powershell +cd "C:\caminho\para\planejador_missao_limpo_milp" +python -m venv .venv +.\.venv\Scripts\activate +pip install -r requirements.txt +``` + +A pasta `.venv` nao acompanha a entrega oficial. Ela deve ser recriada pelo usuario com os comandos acima. + +## 3. Como executar pelo app no navegador + +Forma recomendada: + +1. De dois cliques em `Abrir Planejador.vbs`. +2. Aguarde o navegador abrir em `http://127.0.0.1:8050`. + +Se preferir executar manualmente pelo PowerShell, use: + +Na raiz do projeto: + +```powershell +python web_app.py +``` + +O navegador abre automaticamente em `http://127.0.0.1:8050`. Se nao abrir, copie esse endereco no Google Chrome. + +O app permite: + +- editar data e criterios de otimizacao; +- informar a missao de rota acionada; +- marcar condicao das aeronaves; +- selecionar tripulantes disponiveis; +- consultar a aba `Quadrinhos`, com proxima OI, horas do ano, SBV e falta para 50 horas; +- salvar os arquivos de entrada em `dados/`; +- gerar a escala e abrir a ultima planilha. + +A versao final usa Python e interface web local. + +## 4. Como executar pelo terminal + +Na raiz do projeto: + +```powershell +python scripts\00_main.py +``` + +Ao final, o programa informa: + +- data de planejamento usada; +- caminho da planilha Excel gerada; +- quantidade de colunas candidatas criadas; +- quantidade de escalas selecionadas pelo MILP. + +## 5. Arquivos de entrada + +Os arquivos de entrada ficam em `dados/`. + +| Arquivo | Finalidade | +| --- | --- | +| `Modelagem_C98_ETA2_local.xlsx` ou `Modelagem C98 ETA2.xlsx` | Cadastro base de tripulantes, qualificacoes, projetos, soldo e metas de horas. | +| `catalogo_ois.xlsx` | Catalogo de OIs por aeronave, subprograma, ordem e tipo de missao. | +| `indisponibilidades_2026.xlsx` | Periodos em que cada tripulante nao pode ser escalado. | +| `parametros_missao.csv` | Data de planejamento e criterios de otimizacao. | +| `aeronaves_disponiveis.csv` | Condicao diaria de cada aeronave. | +| `rotas_acionadas.csv` | Missao de rota acionada para atendimento obrigatorio. | +| `tripulantes_disponiveis.csv` | Selecao manual dos tripulantes disponiveis no dia. | +| `progresso_ois_2026.xlsx` | OIs ja concluidas manualmente. | +| `historico_horas_voadas.csv` | Historico acumulado gerado automaticamente pelo planejador. | + +## 6. Saida gerada + +O planejador cria uma planilha em: + +```text +resultados/planejamento_diario_YYYY-MM-DD.xlsx +``` + +Quando ja existe arquivo para a mesma data, o sistema acrescenta o horario ao nome para preservar a saida anterior. + +A aba `ESCALA DIARIA` contem tres blocos: + +- `MISSAO ACIONADA`; +- `VOOS LOCAIS`; +- `SOBREAVISO`. + +Apos a solucao, o arquivo `dados/historico_horas_voadas.csv` tambem e atualizado com horas voadas e sobreavisos registrados. + +## 7. Scripts e validacao com voos de 2025 + +O fluxo pelo VS Code pode ser executado com: + +```powershell +python scripts\00_main.py --modo diario +``` + +Para conferir rapidamente o ambiente: + +```powershell +python scripts\09_testar_instalacao.py +``` + +Para validar o modelo com dados historicos, coloque os voos reais de 2025 em: + +```text +dados/validacao/voos_2025.csv +``` + +O arquivo deve ter as colunas: + +```text +data,aeronave,tipo_escala,tripulante,funcao,oi,horas_voadas,sbv +``` + +Se a planilha `Quadro de Voo 2025 (2).xlsx` estiver em `dados/`, a validacao importa a aba `VOOS` automaticamente: + +```powershell +python scripts\00_main.py --validacao 2025 +``` + +Tambem e possivel chamar diretamente: + +```powershell +python scripts\07_validacao_2025.py +``` + +Os relatorios sao salvos em: + +```text +resultados/validacao/validacao_2025_resumo.xlsx +resultados/validacao/validacao_2025_detalhada.xlsx +resultados/validacao/validacao_2025_metricas.csv +resultados/validacao/validacao_2025_barras.png +``` + +Ele contem: + +- `metricas`: comparacao entre escala real 2025 e escala otimizada; +- `comparativo_trips`: horas reais, horas otimizadas e delta por tripulante; +- `voos_2025_slots`: voos historicos agregados em slots; +- `escala_otimizada`: redistribuicao proposta pelo MILP. + +Os scripts antigos de importacao/validacao foram preservados em `scripts/_arquivados/`. + +## 8. Metodo de otimizacao + +O modelo monta previamente todas as colunas candidatas viaveis. Cada coluna representa uma possivel escala de dupla para: + +- rota acionada; +- missao local; +- sobreaviso. + +Cada coluna recebe uma variavel binaria no MILP: + +```text +x[i] = 1 se a coluna candidata i for escolhida +x[i] = 0 caso contrario +``` + +A funcao objetivo maximiza o score operacional, com prioridade para: + +1. atender rotas acionadas; +2. aproveitar missoes locais para progressao operacional; +3. escolher sobreavisos conforme o criterio definido. + +Restricoes principais: + +- cada tripulante aparece no maximo uma vez na escala; +- cada aeronave livre recebe exatamente uma cobertura principal, por rota acionada ou sobreaviso; +- cada aeronave executa no maximo uma rota acionada; +- cada aeronave executa no maximo uma missao local; +- rota acionada e missao local nao podem usar a mesma aeronave no mesmo dia; +- toda rota acionada informada deve ser atendida exatamente uma vez. + +O resolvedor usado e `scipy.optimize.milp`, um resolvedor exato de programacao inteira disponivel diretamente em Python. + +## 9. Organizacao do codigo + +O codigo em `src/planejador_missao/` esta dividido por blocos funcionais: + +| Arquivo | Responsabilidade | +| --- | --- | +| `data_io.py` | Leitura das planilhas/CSVs e escrita do historico. | +| `rules.py` | Regras de disponibilidade, qualificacao e progresso operacional. | +| `candidates.py` | Geracao das colunas candidatas para o MILP. | +| `optimizer.py` | Formulacao e solucao do modelo MILP. | +| `report.py` | Registro de horas e criacao da planilha Excel. | +| `quadrinhos.py` | Resumo operacional da aba Quadrinhos. | +| `validacao.py` | Validacao retrospectiva com voos historicos. | +| `main.py` | Orquestracao do fluxo completo. | +| `utils.py` | Normalizacao de textos, datas, booleanos e horas. | + +Os comentarios no codigo indicam os blocos do fluxo e as restricoes relevantes do modelo, sem repetir conceitos elementares da linguagem. diff --git a/atalhos/Abrir Planejador.cmd b/atalhos/Abrir Planejador.cmd new file mode 100644 index 0000000..ada4d9c --- /dev/null +++ b/atalhos/Abrir Planejador.cmd @@ -0,0 +1,3 @@ +@echo off +wscript.exe "%~dp0Abrir Planejador.vbs" +exit /b diff --git a/atalhos/Abrir Planejador.vbs b/atalhos/Abrir Planejador.vbs new file mode 100644 index 0000000..8d1edeb --- /dev/null +++ b/atalhos/Abrir Planejador.vbs @@ -0,0 +1,19 @@ +Set shell = CreateObject("WScript.Shell") +Set fso = CreateObject("Scripting.FileSystemObject") + +atalhosDir = fso.GetParentFolderName(WScript.ScriptFullName) +baseDir = fso.GetParentFolderName(atalhosDir) +pythonw = baseDir & "\.venv\Scripts\pythonw.exe" +python = baseDir & "\.venv\Scripts\python.exe" +app = baseDir & "\web_app.py" + +If fso.FileExists(pythonw) Then + exe = pythonw +ElseIf fso.FileExists(python) Then + exe = python +Else + exe = "pythonw" +End If + +shell.CurrentDirectory = baseDir +shell.Run """" & exe & """ """ & app & """", 0, False diff --git a/dados/Modelagem C98 ETA2.xlsx b/dados/Modelagem C98 ETA2.xlsx new file mode 100644 index 0000000..65f7fbd Binary files /dev/null and b/dados/Modelagem C98 ETA2.xlsx differ diff --git a/dados/Modelagem_C98_ETA2_local.xlsx b/dados/Modelagem_C98_ETA2_local.xlsx new file mode 100644 index 0000000..ac29a5a Binary files /dev/null and b/dados/Modelagem_C98_ETA2_local.xlsx differ diff --git a/dados/Quadro de Voo 2025 (2).xlsx b/dados/Quadro de Voo 2025 (2).xlsx new file mode 100644 index 0000000..c4b2ce4 Binary files /dev/null and b/dados/Quadro de Voo 2025 (2).xlsx differ diff --git a/dados/aeronaves_disponiveis.csv b/dados/aeronaves_disponiveis.csv new file mode 100644 index 0000000..45682e6 --- /dev/null +++ b/dados/aeronaves_disponiveis.csv @@ -0,0 +1,4 @@ +aeronave,disponivel_sede,em_pane,em_missao_rota,hora_livre +C98,True,False,False,50:00 +C97,True,False,False,50:00 +C95,True,False,False,50:00 diff --git a/dados/assets/app_logo.png b/dados/assets/app_logo.png new file mode 100644 index 0000000..0f683ae Binary files /dev/null and b/dados/assets/app_logo.png differ diff --git a/dados/assets/favicon.ico b/dados/assets/favicon.ico new file mode 100644 index 0000000..ea7f7e1 Binary files /dev/null and b/dados/assets/favicon.ico differ diff --git a/dados/assets/planilha_logo.png b/dados/assets/planilha_logo.png new file mode 100644 index 0000000..25190bf Binary files /dev/null and b/dados/assets/planilha_logo.png differ diff --git a/dados/catalogo_ois.xlsx b/dados/catalogo_ois.xlsx new file mode 100644 index 0000000..a0868a9 Binary files /dev/null and b/dados/catalogo_ois.xlsx differ diff --git a/dados/historico_horas_voadas.csv b/dados/historico_horas_voadas.csv new file mode 100644 index 0000000..c389bea --- /dev/null +++ b/dados/historico_horas_voadas.csv @@ -0,0 +1,49 @@ +data,aeronave,tipo_escala,tripulante,funcao,oi,horas_voadas,sbv,origem_registro,registrado_em +2026-06-18,C97,MISSAO_LOCAL,AEU,TRIP2,,1.5,0,escala_diaria,2026-06-18 10:35:24 +2026-06-18,C97,MISSAO_LOCAL,LPS,TRIP1,17TL17D31,1.5,0,escala_diaria,2026-06-18 10:35:24 +2026-06-18,C95,ROTA_ACIONADA,ISA,TRIP1,69TF01DN03,6.0,0,escala_diaria,2026-06-18 10:35:24 +2026-06-18,C95,ROTA_ACIONADA,MES,TRIP2,,6.0,0,escala_diaria,2026-06-18 10:35:24 +2026-06-18,C97,SBV,MAT,TRIP1,,0.0,1,escala_diaria,2026-06-18 10:35:24 +2026-06-18,C97,SBV,STS,TRIP2,,0.0,1,escala_diaria,2026-06-18 10:35:24 +2026-06-22,C97,ROTA_ACIONADA,JVT,TRIP1,00TF86D01,6.0,0,escala_diaria,2026-06-22 08:49:59 +2026-06-22,C97,ROTA_ACIONADA,AEU,TRIP2,,6.0,0,escala_diaria,2026-06-22 08:49:59 +2026-06-22,C95,MISSAO_LOCAL,ISA,TRIP1,69TF01D04,1.0,0,escala_diaria,2026-06-22 08:49:59 +2026-06-22,C95,MISSAO_LOCAL,MES,TRIP2,,1.0,0,escala_diaria,2026-06-22 08:49:59 +2026-06-22,C95,SBV,BRJ,TRIP1,,0.0,1,escala_diaria,2026-06-22 08:49:59 +2026-06-22,C95,SBV,KVN,TRIP2,,0.0,1,escala_diaria,2026-06-22 08:49:59 +2026-06-26,C95,ROTA_ACIONADA,SOL,TRIP1,69TS01D01,6.0,0,escala_diaria,2026-06-26 09:41:10 +2026-06-26,C95,ROTA_ACIONADA,MES,TRIP2,,6.0,0,escala_diaria,2026-06-26 09:41:10 +2026-06-26,C97,MISSAO_LOCAL,MHL,TRIP1,01TF01D21,1.5,0,escala_diaria,2026-06-26 09:41:10 +2026-06-26,C97,MISSAO_LOCAL,MAT,TRIP2,,1.5,0,escala_diaria,2026-06-26 09:41:10 +2026-06-26,C97,SBV,SEI,TRIP1,,0.0,1,escala_diaria,2026-06-26 09:41:10 +2026-06-26,C97,SBV,LPS,TRIP2,,0.0,1,escala_diaria,2026-06-26 09:41:10 +2026-06-26,C95,ROTA_ACIONADA,SOL,TRIP1,69TS01D02,6.0,0,escala_diaria,2026-06-26 12:32:06 +2026-06-26,C95,ROTA_ACIONADA,MES,TRIP2,,6.0,0,escala_diaria,2026-06-26 12:32:06 +2026-06-26,C98,MISSAO_LOCAL,MCH,TRIP1,01TL01D31,1.0,0,escala_diaria,2026-06-26 12:32:06 +2026-06-26,C98,MISSAO_LOCAL,SLS,TRIP2,,1.0,0,escala_diaria,2026-06-26 12:32:06 +2026-06-26,C98,SBV,GMS,TRIP1,,0.0,1,escala_diaria,2026-06-26 12:32:06 +2026-06-26,C98,SBV,LRS,TRIP2,,0.0,1,escala_diaria,2026-06-26 12:32:06 +2026-06-26,C97,MISSAO_LOCAL,LPS,TRIP1,17TL17D32,1.5,0,escala_diaria,2026-06-26 12:32:06 +2026-06-26,C97,MISSAO_LOCAL,MAT,TRIP2,,1.5,0,escala_diaria,2026-06-26 12:32:06 +2026-06-26,C97,SBV,FIA,TRIP1,,0.0,1,escala_diaria,2026-06-26 12:32:06 +2026-06-26,C97,SBV,AEU,TRIP2,,0.0,1,escala_diaria,2026-06-26 12:32:06 +2026-06-26,C97,ROTA_ACIONADA,LPS,TRIP1,01TL01D41,6.0,0,escala_diaria,2026-06-26 13:35:48 +2026-06-26,C97,ROTA_ACIONADA,MAT,TRIP2,,6.0,0,escala_diaria,2026-06-26 13:35:48 +2026-06-26,C98,MISSAO_LOCAL,MCH,TRIP1,04TL01N32,1.0,0,escala_diaria,2026-06-26 13:35:48 +2026-06-26,C98,MISSAO_LOCAL,SLS,TRIP2,,1.0,0,escala_diaria,2026-06-26 13:35:48 +2026-06-26,C98,SBV,CFF,TRIP1,,0.0,1,escala_diaria,2026-06-26 13:35:48 +2026-06-26,C98,SBV,SUG,TRIP2,,0.0,1,escala_diaria,2026-06-26 13:35:48 +2026-06-26,C95,MISSAO_LOCAL,SOL,TRIP1,06TT36D01,1.0,0,escala_diaria,2026-06-26 13:35:48 +2026-06-26,C95,MISSAO_LOCAL,MES,TRIP2,,1.0,0,escala_diaria,2026-06-26 13:35:48 +2026-06-26,C95,SBV,ISA,TRIP1,,0.0,1,escala_diaria,2026-06-26 13:35:48 +2026-06-26,C95,SBV,ATA,TRIP2,,0.0,1,escala_diaria,2026-06-26 13:35:48 +2026-06-26,C97,ROTA_ACIONADA,HCK,TRIP1,00TF86D01,6.0,0,escala_diaria,2026-06-26 14:29:27 +2026-06-26,C97,ROTA_ACIONADA,AEU,TRIP2,,6.0,0,escala_diaria,2026-06-26 14:29:27 +2026-06-26,C98,MISSAO_LOCAL,LRS,TRIP1,01TT01D01,1.0,0,escala_diaria,2026-06-26 14:29:27 +2026-06-26,C98,MISSAO_LOCAL,CFF,TRIP2,,1.0,0,escala_diaria,2026-06-26 14:29:27 +2026-06-26,C98,SBV,SLS,TRIP1,,0.0,1,escala_diaria,2026-06-26 14:29:27 +2026-06-26,C98,SBV,MCH,TRIP2,,0.0,1,escala_diaria,2026-06-26 14:29:27 +2026-06-26,C95,MISSAO_LOCAL,SOL,TRIP1,06TT36D02,1.0,0,escala_diaria,2026-06-26 14:29:27 +2026-06-26,C95,MISSAO_LOCAL,MES,TRIP2,,1.0,0,escala_diaria,2026-06-26 14:29:27 +2026-06-26,C95,SBV,ISA,TRIP1,,0.0,1,escala_diaria,2026-06-26 14:29:27 +2026-06-26,C95,SBV,KVN,TRIP2,,0.0,1,escala_diaria,2026-06-26 14:29:27 diff --git a/dados/indisponibilidades_2026.xlsx b/dados/indisponibilidades_2026.xlsx new file mode 100644 index 0000000..58ba09f Binary files /dev/null and b/dados/indisponibilidades_2026.xlsx differ diff --git a/dados/parametros_missao.csv b/dados/parametros_missao.csv new file mode 100644 index 0000000..c8cc7e2 --- /dev/null +++ b/dados/parametros_missao.csv @@ -0,0 +1,4 @@ +parametro,valor +data_planejamento,hoje +criterio_missao,meta_50 +criterio_sbv,equalizar_quadrinhos diff --git a/dados/progresso_ois_2026.xlsx b/dados/progresso_ois_2026.xlsx new file mode 100644 index 0000000..b1dd0bb Binary files /dev/null and b/dados/progresso_ois_2026.xlsx differ diff --git a/dados/quadrinho_operacional_manual.csv b/dados/quadrinho_operacional_manual.csv new file mode 100644 index 0000000..b17f6c2 --- /dev/null +++ b/dados/quadrinho_operacional_manual.csv @@ -0,0 +1 @@ +tripulante,aeronave,qualificacao_manual,horas_voadas_manual,sbv_manual,ultima_data_voo_manual,observacao diff --git a/dados/rotas_acionadas.csv b/dados/rotas_acionadas.csv new file mode 100644 index 0000000..734dd57 --- /dev/null +++ b/dados/rotas_acionadas.csv @@ -0,0 +1,2 @@ +origem,destino,inicio,fim,tev_horas,pernoite_dias,rota +NT,NT,2026-06-14 08:00,2026-06-17 13:00,6,3,NT-RF-*FZ*-RF-NT diff --git a/dados/tripulantes_disponiveis.csv b/dados/tripulantes_disponiveis.csv new file mode 100644 index 0000000..dcc1b2a --- /dev/null +++ b/dados/tripulantes_disponiveis.csv @@ -0,0 +1,33 @@ +tripulante,disponivel +AEU,True +ATA,True +BEN,True +BMK,True +BRI,True +BRJ,True +CFF,True +DIL,True +DOG,True +FIA,True +GMR,True +GMS,True +HCK,True +ISA,True +JVT,True +KVN,True +LPS,True +LRS,True +MAT,True +MCH,True +MDO,True +MES,True +MHL,True +NSC,True +PFR,True +RCH,True +SCA,True +SEI,True +SLS,True +SOL,True +STS,True +SUG,True diff --git a/dados/validacao/voos_2025.csv b/dados/validacao/voos_2025.csv new file mode 100644 index 0000000..8b4edbd --- /dev/null +++ b/dados/validacao/voos_2025.csv @@ -0,0 +1,769 @@ +slot_id,data,aeronave,tipo_escala,tripulante,funcao,oi,horas_voadas,sbv +1,2025-01-02,C98,ROTA,SLS,PILOTO,001,6.67,0 +1,2025-01-02,C98,ROTA,DOG,COPILOTO,001,6.67,0 +2,2025-01-07,C98,ROTA,SLS,PILOTO,002,6.67,0 +2,2025-01-07,C98,ROTA,CFF,COPILOTO,002,6.67,0 +3,2025-01-09,C95,LOCAL,JVT,PILOTO,-,0.08,0 +3,2025-01-09,C95,LOCAL,GMR,COPILOTO,-,0.08,0 +4,2025-01-09,C95,LOCAL,HCK,PILOTO,-,0.08,0 +4,2025-01-09,C95,LOCAL,CAR,COPILOTO,-,0.08,0 +5,2025-01-09,C97,LOCAL,MES,PILOTO,-,0.08,0 +5,2025-01-09,C97,LOCAL,SEI,COPILOTO,-,0.08,0 +6,2025-01-09,C97,LOCAL,MAT,PILOTO,-,0.08,0 +6,2025-01-09,C97,LOCAL,STS,COPILOTO,-,0.08,0 +7,2025-01-15,C98,LOCAL,HAI,PILOTO,011,0.08,0 +7,2025-01-15,C98,LOCAL,GMS,COPILOTO,011,0.08,0 +8,2025-01-15,C95,ROTA,RAY,PILOTO,003,1.33,0 +8,2025-01-15,C95,ROTA,HCK,COPILOTO,003,1.33,0 +9,2025-01-20,C97,LOCAL,MES,PILOTO,004,1.0,0 +9,2025-01-20,C97,LOCAL,AEU,COPILOTO,004,1.0,0 +10,2025-01-20,C97,ROTA,MAT,PILOTO,005,5.67,0 +10,2025-01-20,C97,ROTA,STS,COPILOTO,005,5.67,0 +11,2025-01-21,C98,LOCAL,CNI,PILOTO,006,1.0,0 +11,2025-01-21,C98,LOCAL,DOG,COPILOTO,006,1.0,0 +12,2025-01-22,C98,ROTA,SLS,PILOTO,007,13.0,0 +12,2025-01-22,C98,ROTA,GMS,COPILOTO,007,13.0,0 +13,2025-01-24,C98,ROTA,CFF,PILOTO,009,13.67,0 +13,2025-01-24,C98,ROTA,GMS,COPILOTO,009,13.67,0 +14,2025-01-24,C98,LOCAL,FER,PILOTO,008,1.0,0 +14,2025-01-24,C98,LOCAL,MCH,COPILOTO,008,1.0,0 +15,2025-01-25,C98,ROTA,SLS,PILOTO,010,6.67,0 +15,2025-01-25,C98,ROTA,DOG,COPILOTO,010,6.67,0 +16,2025-01-29,C98,ROTA,FER,PILOTO,012,12.33,0 +16,2025-01-29,C98,ROTA,DOG,COPILOTO,012,12.33,0 +17,2025-02-03,C98,LOCAL,HAI,PILOTO,014,1.0,0 +17,2025-02-03,C98,LOCAL,MED,COPILOTO,014,1.0,0 +18,2025-02-03,C98,LOCAL,HAI,PILOTO,015,1.0,0 +18,2025-02-03,C98,LOCAL,FIA,COPILOTO,015,1.0,0 +19,2025-02-04,C98,ROTA,MED,PILOTO,016,5.92,0 +19,2025-02-04,C98,ROTA,CNI,COPILOTO,016,5.92,0 +20,2025-02-06,C98,ROTA,FER,PILOTO,018,10.5,0 +20,2025-02-06,C98,ROTA,CNI,COPILOTO,018,10.5,0 +21,2025-02-07,C98,ROTA,HAI,PILOTO,020,2.0,0 +21,2025-02-07,C98,ROTA,FIA,COPILOTO,020,2.0,0 +22,2025-02-07,C97,ROTA,AEU,PILOTO,021,7.58,0 +22,2025-02-07,C97,ROTA,SEI,COPILOTO,021,7.58,0 +23,2025-02-08,C98,ROTA,CFF,PILOTO,022,10.5,0 +23,2025-02-08,C98,ROTA,FER,COPILOTO,022,10.5,0 +24,2025-02-09,C97,ROTA,AEU,PILOTO,023,9.42,0 +24,2025-02-09,C97,ROTA,SEI,COPILOTO,023,9.42,0 +25,2025-02-11,C98,ROTA,FER,PILOTO,026,3.33,0 +25,2025-02-11,C98,ROTA,DOG,COPILOTO,026,3.33,0 +26,2025-02-12,C98,LOCAL,FIA,PILOTO,027,1.0,0 +26,2025-02-12,C98,LOCAL,GMS,COPILOTO,027,1.0,0 +27,2025-02-12,C98,ROTA,FER,PILOTO,028,1.67,0 +27,2025-02-12,C98,ROTA,DOG,COPILOTO,028,1.67,0 +28,2025-02-12,C98,ROTA,MCH,PILOTO,028,1.67,0 +28,2025-02-12,C98,ROTA,CNI,COPILOTO,028,1.67,0 +29,2025-02-12,C98,ROTA,MCH,PILOTO,026,3.33,0 +29,2025-02-12,C98,ROTA,CNI,COPILOTO,026,3.33,0 +30,2025-02-13,C98,LOCAL,BRI,PILOTO,029,1.0,0 +30,2025-02-13,C98,LOCAL,HAI,COPILOTO,029,1.0,0 +31,2025-02-13,C98,LOCAL,SLS,PILOTO,027,0.5,0 +31,2025-02-13,C98,LOCAL,DOG,COPILOTO,027,0.5,0 +32,2025-02-14,C97,ROTA,AEU,PILOTO,030,5.83,0 +32,2025-02-14,C97,ROTA,SEI,COPILOTO,030,5.83,0 +33,2025-02-14,C98,LOCAL,BRI,PILOTO,031,1.0,0 +33,2025-02-14,C98,LOCAL,FIA,COPILOTO,031,1.0,0 +34,2025-02-16,C95,ROTA,RAY,PILOTO,032,4.83,0 +34,2025-02-16,C95,ROTA,PCN,COPILOTO,032,4.83,0 +35,2025-02-16,C97,ROTA,AEU,PILOTO,033,10.67,0 +35,2025-02-16,C97,ROTA,SEI,COPILOTO,033,10.67,0 +36,2025-02-17,C98,LOCAL,CNI,PILOTO,CPT,2.0,0 +36,2025-02-17,C98,LOCAL,LOU,COPILOTO,CPT,2.0,0 +37,2025-02-17,C98,LOCAL,CNI,PILOTO,CPT,2.0,0 +37,2025-02-17,C98,LOCAL,PFR,COPILOTO,CPT,2.0,0 +38,2025-02-17,C98,LOCAL,FER,PILOTO,CPT,2.0,0 +38,2025-02-17,C98,LOCAL,BEN,COPILOTO,CPT,2.0,0 +39,2025-02-17,C98,LOCAL,FER,PILOTO,CPT,2.0,0 +39,2025-02-17,C98,LOCAL,RCH,COPILOTO,CPT,2.0,0 +40,2025-02-18,C98,LOCAL,CNI,PILOTO,CPT,1.0,0 +40,2025-02-18,C98,LOCAL,LOU,COPILOTO,CPT,1.0,0 +41,2025-02-18,C98,LOCAL,FER,PILOTO,CPT,2.0,0 +41,2025-02-18,C98,LOCAL,SUG,COPILOTO,CPT,2.0,0 +42,2025-02-18,C98,LOCAL,FER,PILOTO,CPT,2.0,0 +42,2025-02-18,C98,LOCAL,LRS,COPILOTO,CPT,2.0,0 +43,2025-02-18,C97,ROTA,AEU,PILOTO,034,7.83,0 +43,2025-02-18,C97,ROTA,SEI,COPILOTO,034,7.83,0 +44,2025-02-18,C98,LOCAL,MED,PILOTO,036,1.5,0 +44,2025-02-18,C98,LOCAL,CNI,COPILOTO,036,1.5,0 +45,2025-02-18,C98,LOCAL,FIA,PILOTO,037,1.0,0 +45,2025-02-18,C98,LOCAL,CNI,COPILOTO,037,1.0,0 +46,2025-02-20,C95,LOCAL,PCN,PILOTO,039,0.08,0 +46,2025-02-20,C95,LOCAL,JVT,COPILOTO,039,0.08,0 +47,2025-02-20,C98,LOCAL,CNI,PILOTO,040,1.5,0 +47,2025-02-20,C98,LOCAL,PFR,COPILOTO,040,1.5,0 +48,2025-02-20,C98,LOCAL,CNI,PILOTO,041,1.5,0 +48,2025-02-20,C98,LOCAL,BEN,COPILOTO,041,1.5,0 +49,2025-02-20,C95,LOCAL,MHL,PILOTO,042,1.0,0 +49,2025-02-20,C95,LOCAL,KVN,COPILOTO,042,1.0,0 +50,2025-02-21,C97,ROTA,AEU,PILOTO,048,4.0,0 +50,2025-02-21,C97,ROTA,SEI,COPILOTO,048,4.0,0 +51,2025-02-21,C95,ROTA,CAR,PILOTO,043,3.58,0 +51,2025-02-21,C95,ROTA,BRJ,COPILOTO,043,3.58,0 +52,2025-02-21,C98,LOCAL,MED,PILOTO,044,1.5,0 +52,2025-02-21,C98,LOCAL,RCH,COPILOTO,044,1.5,0 +53,2025-02-21,C98,LOCAL,FER,PILOTO,045,1.5,0 +53,2025-02-21,C98,LOCAL,LRS,COPILOTO,045,1.5,0 +54,2025-02-21,C95,LOCAL,MHL,PILOTO,046,1.0,0 +54,2025-02-21,C95,LOCAL,ISA,COPILOTO,046,1.0,0 +55,2025-02-24,C98,LOCAL,FER,PILOTO,050,1.5,0 +55,2025-02-24,C98,LOCAL,SUG,COPILOTO,050,1.5,0 +56,2025-02-24,C98,LOCAL,FIA,PILOTO,051,1.5,0 +56,2025-02-24,C98,LOCAL,BEN,COPILOTO,051,1.5,0 +57,2025-02-24,C97,LOCAL,AEU,PILOTO,047,0.08,0 +57,2025-02-24,C97,LOCAL,MAT,COPILOTO,047,0.08,0 +58,2025-02-24,C95,LOCAL,MHL,PILOTO,052,1.0,0 +58,2025-02-24,C95,LOCAL,ISA,COPILOTO,052,1.0,0 +59,2025-02-24,C95,LOCAL,MHL,PILOTO,053,1.0,0 +59,2025-02-24,C95,LOCAL,KVN,COPILOTO,053,1.0,0 +60,2025-02-25,C98,ROTA,CNI,PILOTO,054,12.42,0 +60,2025-02-25,C98,ROTA,BEN,COPILOTO,054,12.42,0 +61,2025-02-25,C97,LOCAL,MES,PILOTO,055,2.0,0 +61,2025-02-25,C97,LOCAL,AEU,COPILOTO,055,2.0,0 +62,2025-02-25,C97,LOCAL,MES,PILOTO,056,1.5,0 +62,2025-02-25,C97,LOCAL,HCK,COPILOTO,056,1.5,0 +63,2025-02-26,C97,LOCAL,MES,PILOTO,057,1.5,0 +63,2025-02-26,C97,LOCAL,HCK,COPILOTO,057,1.5,0 +64,2025-02-27,C98,LOCAL,FER,PILOTO,058,1.5,0 +64,2025-02-27,C98,LOCAL,LOU,COPILOTO,058,1.5,0 +65,2025-02-27,C98,LOCAL,MED,PILOTO,059,1.5,0 +65,2025-02-27,C98,LOCAL,PFR,COPILOTO,059,1.5,0 +66,2025-02-27,C95,LOCAL,MHL,PILOTO,060,1.0,0 +66,2025-02-27,C95,LOCAL,BRI,COPILOTO,060,1.0,0 +67,2025-02-28,C98,LOCAL,MED,PILOTO,061,0.67,0 +67,2025-02-28,C98,LOCAL,PFR,COPILOTO,061,0.67,0 +68,2025-02-28,C95,LOCAL,JVT,PILOTO,066,0.08,0 +68,2025-02-28,C95,LOCAL,HCK,COPILOTO,066,0.08,0 +69,2025-02-28,C95,ROTA,RAY,PILOTO,062,9.25,0 +69,2025-02-28,C95,ROTA,ISA,COPILOTO,062,9.25,0 +70,2025-03-01,C95,ROTA,CAR,PILOTO,063,13.58,0 +70,2025-03-01,C95,ROTA,BMK,COPILOTO,063,13.58,0 +71,2025-03-01,C95,ROTA,MHL,PILOTO,064,7.42,0 +71,2025-03-01,C95,ROTA,KVN,COPILOTO,064,7.42,0 +72,2025-03-05,C97,ROTA,MES,PILOTO,065,2.0,0 +72,2025-03-05,C97,ROTA,LPS,COPILOTO,065,2.0,0 +73,2025-03-05,C95,LOCAL,RAY,PILOTO,067,1.5,0 +73,2025-03-05,C95,LOCAL,BRI,COPILOTO,067,1.5,0 +74,2025-03-05,C98,LOCAL,FER,PILOTO,068,1.0,0 +74,2025-03-05,C98,LOCAL,PFR,COPILOTO,068,1.0,0 +75,2025-03-06,C95,ROTA,PCN,PILOTO,069,3.92,0 +75,2025-03-06,C95,ROTA,JVT,COPILOTO,069,3.92,0 +76,2025-03-06,C98,LOCAL,FIA,PILOTO,070,1.5,0 +76,2025-03-06,C98,LOCAL,LRS,COPILOTO,070,1.5,0 +77,2025-03-06,C98,LOCAL,CNI,PILOTO,071,1.5,0 +77,2025-03-06,C98,LOCAL,RCH,COPILOTO,071,1.5,0 +78,2025-03-06,C97,LOCAL,MES,PILOTO,072,2.0,0 +78,2025-03-06,C97,LOCAL,AEU,COPILOTO,072,2.0,0 +79,2025-03-06,C98,LOCAL,CFF,PILOTO,073,0.08,0 +79,2025-03-06,C98,LOCAL,GMS,COPILOTO,073,0.08,0 +80,2025-03-06,C98,LOCAL,BRI,PILOTO,074,1.0,0 +80,2025-03-06,C98,LOCAL,CNI,COPILOTO,074,1.0,0 +81,2025-03-06,C97,LOCAL,MES,PILOTO,075,1.0,0 +81,2025-03-06,C97,LOCAL,HCK,COPILOTO,075,1.0,0 +82,2025-03-08,C97,ROTA,MES,PILOTO,077,19.33,0 +82,2025-03-08,C97,ROTA,LPS,COPILOTO,077,19.33,0 +83,2025-03-10,C98,LOCAL,FER,PILOTO,078,1.5,0 +83,2025-03-10,C98,LOCAL,LRS,COPILOTO,078,1.5,0 +84,2025-03-10,C98,LOCAL,GMS,PILOTO,079,1.0,0 +84,2025-03-10,C98,LOCAL,DOG,COPILOTO,079,1.0,0 +85,2025-03-11,C97,LOCAL,AEU,PILOTO,080,1.5,0 +85,2025-03-11,C97,LOCAL,JVT,COPILOTO,080,1.5,0 +86,2025-03-11,C97,LOCAL,AEU,PILOTO,081,1.0,0 +86,2025-03-11,C97,LOCAL,MAT,COPILOTO,081,1.0,0 +87,2025-03-12,C97,LOCAL,AEU,PILOTO,082,1.5,0 +87,2025-03-12,C97,LOCAL,JVT,COPILOTO,082,1.5,0 +88,2025-03-12,C98,LOCAL,CNI,PILOTO,083,1.5,0 +88,2025-03-12,C98,LOCAL,SUG,COPILOTO,083,1.5,0 +89,2025-03-12,C98,LOCAL,HAI,PILOTO,084,1.0,0 +89,2025-03-12,C98,LOCAL,MED,COPILOTO,084,1.0,0 +90,2025-03-13,C98,LOCAL,CNI,PILOTO,085,1.0,0 +90,2025-03-13,C98,LOCAL,FER,COPILOTO,085,1.0,0 +91,2025-03-14,C95,ROTA,MHL,PILOTO,086,0.75,0 +91,2025-03-14,C95,ROTA,ISA,COPILOTO,086,0.75,0 +92,2025-03-15,C97,ROTA,MAT,PILOTO,087,9.75,0 +92,2025-03-15,C97,ROTA,LPS,COPILOTO,087,9.75,0 +93,2025-03-16,C95,ROTA,MHL,PILOTO,086,0.75,0 +93,2025-03-16,C95,ROTA,ISA,COPILOTO,086,0.75,0 +94,2025-03-17,C97,LOCAL,MES,PILOTO,089,1.0,0 +94,2025-03-17,C97,LOCAL,JVT,COPILOTO,089,1.0,0 +95,2025-03-18,C98,ROTA,CNI,PILOTO,088,6.67,0 +95,2025-03-18,C98,ROTA,PFR,COPILOTO,088,6.67,0 +96,2025-03-18,C95,ROTA,PCN,PILOTO,090,5.33,0 +96,2025-03-18,C95,ROTA,KVN,COPILOTO,090,5.33,0 +97,2025-03-18,C95,LOCAL,MES,PILOTO,091,1.5,0 +97,2025-03-18,C95,LOCAL,MHL,COPILOTO,091,1.5,0 +98,2025-03-18,C98,LOCAL,MCH,PILOTO,092,0.08,0 +98,2025-03-18,C98,LOCAL,DOG,COPILOTO,092,0.08,0 +99,2025-03-19,C98,ROTA,SLS,PILOTO,094,6.67,0 +99,2025-03-19,C98,ROTA,CFF,COPILOTO,094,6.67,0 +100,2025-03-19,C95,ROTA,MES,PILOTO,093,12.83,0 +100,2025-03-19,C95,ROTA,RAY,COPILOTO,093,12.83,0 +101,2025-03-20,C98,LOCAL,FIA,PILOTO,095,1.5,0 +101,2025-03-20,C98,LOCAL,SLS,COPILOTO,095,1.5,0 +102,2025-03-20,C98,LOCAL,FER,PILOTO,096,1.0,0 +102,2025-03-20,C98,LOCAL,LRS,COPILOTO,096,1.0,0 +103,2025-03-20,C98,LOCAL,FER,PILOTO,097,1.0,0 +103,2025-03-20,C98,LOCAL,BEN,COPILOTO,097,1.0,0 +104,2025-03-21,C95,ROTA,BRI,PILOTO,098,10.08,0 +104,2025-03-21,C95,ROTA,RAY,COPILOTO,098,10.08,0 +105,2025-03-22,C97,ROTA,MES,PILOTO,099,10.5,0 +105,2025-03-22,C97,ROTA,MAT,COPILOTO,099,10.5,0 +106,2025-03-23,C95,ROTA,JVT,PILOTO,100,1.5,0 +106,2025-03-23,C95,ROTA,ISA,COPILOTO,100,1.5,0 +107,2025-03-23,C97,ROTA,MES,PILOTO,099,4.5,0 +107,2025-03-23,C97,ROTA,SEI,COPILOTO,099,4.5,0 +108,2025-03-24,C95,ROTA,HCK,PILOTO,101,8.67,0 +108,2025-03-24,C95,ROTA,KVN,COPILOTO,101,8.67,0 +109,2025-03-24,C98,ROTA,FER,PILOTO,102,18.75,0 +109,2025-03-24,C98,ROTA,LRS,COPILOTO,102,18.75,0 +110,2025-03-27,C95,ROTA,CAR,PILOTO,104,3.67,0 +110,2025-03-27,C95,ROTA,BRJ,COPILOTO,104,3.67,0 +111,2025-03-27,C95,ROTA,PCN,PILOTO,105,8.33,0 +111,2025-03-27,C95,ROTA,GMR,COPILOTO,105,8.33,0 +112,2025-03-27,C97,ROTA,MES,PILOTO,106,9.17,0 +112,2025-03-27,C97,ROTA,JVT,COPILOTO,106,9.17,0 +113,2025-03-29,C97,ROTA,MAT,PILOTO,107,10.58,0 +113,2025-03-29,C97,ROTA,SEI,COPILOTO,107,10.58,0 +114,2025-03-29,C95,ROTA,MHL,PILOTO,108,9.5,0 +114,2025-03-29,C95,ROTA,BMK,COPILOTO,108,9.5,0 +115,2025-03-31,C95,ROTA,JVT,PILOTO,110,4.75,0 +115,2025-03-31,C95,ROTA,KVN,COPILOTO,110,4.75,0 +116,2025-03-31,C98,LOCAL,HAI,PILOTO,111,0.33,0 +116,2025-03-31,C98,LOCAL,MCH,COPILOTO,111,0.33,0 +117,2025-04-01,C95,LOCAL,BRI,PILOTO,112,1.0,0 +117,2025-04-01,C95,LOCAL,MHL,COPILOTO,112,1.0,0 +118,2025-04-02,C98,LOCAL,FIA,PILOTO,113,0.67,0 +118,2025-04-02,C98,LOCAL,LOU,COPILOTO,113,0.67,0 +119,2025-04-02,C98,LOCAL,AEU,PILOTO,CPT,2.0,0 +119,2025-04-02,C98,LOCAL,RAY,COPILOTO,CPT,2.0,0 +120,2025-04-02,C98,LOCAL,MED,PILOTO,114,1.0,0 +120,2025-04-02,C98,LOCAL,SUG,COPILOTO,114,1.0,0 +121,2025-04-02,C97,ROTA,MAT,PILOTO,109,2.75,0 +121,2025-04-02,C97,ROTA,LPS,COPILOTO,109,2.75,0 +122,2025-04-03,C97,LOCAL,AEU,PILOTO,115,1.5,0 +122,2025-04-03,C97,LOCAL,MAT,COPILOTO,115,1.5,0 +123,2025-04-03,C97,LOCAL,AEU,PILOTO,116,1.0,0 +123,2025-04-03,C97,LOCAL,SEI,COPILOTO,116,1.0,0 +124,2025-04-03,C98,LOCAL,CNI,PILOTO,117,1.0,0 +124,2025-04-03,C98,LOCAL,LOU,COPILOTO,117,1.0,0 +125,2025-04-04,C97,LOCAL,AEU,PILOTO,118,0.5,0 +125,2025-04-04,C97,LOCAL,MAT,COPILOTO,118,0.5,0 +126,2025-04-05,C95,ROTA,CAR,PILOTO,119,4.75,0 +126,2025-04-05,C95,ROTA,GMR,COPILOTO,119,4.75,0 +127,2025-04-06,C97,ROTA,MES,PILOTO,120,15.42,0 +127,2025-04-06,C97,ROTA,JVT,COPILOTO,120,15.42,0 +128,2025-04-07,C98,LOCAL,FER,PILOTO,121,1.5,0 +128,2025-04-07,C98,LOCAL,RAY,COPILOTO,121,1.5,0 +129,2025-04-08,C98,ROTA,BRI,PILOTO,122,5.33,0 +129,2025-04-08,C98,ROTA,FER,COPILOTO,122,5.33,0 +130,2025-04-11,C97,ROTA,MES,PILOTO,125,13.58,0 +130,2025-04-11,C97,ROTA,HCK,COPILOTO,125,13.58,0 +131,2025-04-11,C95,ROTA,PCN,PILOTO,123,3.92,0 +131,2025-04-11,C95,ROTA,BMK,COPILOTO,123,3.92,0 +132,2025-04-14,C98,LOCAL,MED,PILOTO,124,1.5,0 +132,2025-04-14,C98,LOCAL,AEU,COPILOTO,124,1.5,0 +133,2025-04-14,C95,LOCAL,RAY,PILOTO,126,1.0,0 +133,2025-04-14,C95,LOCAL,CAR,COPILOTO,126,1.0,0 +134,2025-04-14,C98,LOCAL,BRI,PILOTO,127,1.0,0 +134,2025-04-14,C98,LOCAL,SLS,COPILOTO,127,1.0,0 +135,2025-04-15,C95,ROTA,CAR,PILOTO,128,9.58,0 +135,2025-04-15,C95,ROTA,ISA,COPILOTO,128,9.58,0 +136,2025-04-15,C98,LOCAL,MED,PILOTO,129,1.5,0 +136,2025-04-15,C98,LOCAL,RAY,COPILOTO,129,1.5,0 +137,2025-04-15,C98,LOCAL,FER,PILOTO,130,1.5,0 +137,2025-04-15,C98,LOCAL,AEU,COPILOTO,130,1.5,0 +138,2025-04-15,C98,LOCAL,FIA,PILOTO,131,1.0,0 +138,2025-04-15,C98,LOCAL,RCH,COPILOTO,131,1.0,0 +139,2025-04-17,C97,LOCAL,MES,PILOTO,132,1.0,0 +139,2025-04-17,C97,LOCAL,MAT,COPILOTO,132,1.0,0 +140,2025-04-18,C95,ROTA,BRJ,PILOTO,133,7.5,0 +140,2025-04-18,C95,ROTA,BMK,COPILOTO,133,7.5,0 +141,2025-04-21,C98,ROTA,MED,PILOTO,134,11.92,0 +141,2025-04-21,C98,ROTA,SUG,COPILOTO,134,11.92,0 +142,2025-04-22,C95,ROTA,BRI,PILOTO,135,12.67,0 +142,2025-04-22,C95,ROTA,GMR,COPILOTO,135,12.67,0 +143,2025-04-22,C98,LOCAL,CFF,PILOTO,136,0.08,0 +143,2025-04-22,C98,LOCAL,DOG,COPILOTO,136,0.08,0 +144,2025-04-25,C98,ROTA,MED,PILOTO,137,6.58,0 +144,2025-04-25,C98,ROTA,MCH,COPILOTO,137,6.58,0 +145,2025-04-26,C98,ROTA,MCH,PILOTO,139,11.5,0 +145,2025-04-26,C98,ROTA,DOG,COPILOTO,139,11.5,0 +146,2025-04-29,C98,LOCAL,SLS,PILOTO,141,1.0,0 +146,2025-04-29,C98,LOCAL,FER,COPILOTO,141,1.0,0 +147,2025-04-30,C97,ROTA,SEI,PILOTO,140,11.5,0 +147,2025-04-30,C97,ROTA,LPS,COPILOTO,140,11.5,0 +148,2025-05-04,C97,ROTA,MAT,PILOTO,142,5.67,0 +148,2025-05-04,C97,ROTA,JVT,COPILOTO,142,5.67,0 +149,2025-05-05,C95,ROTA,BRJ,PILOTO,143,7.25,0 +149,2025-05-05,C95,ROTA,ISA,COPILOTO,143,7.25,0 +150,2025-05-05,C98,LOCAL,HAI,PILOTO,144,1.0,0 +150,2025-05-05,C98,LOCAL,AEU,COPILOTO,144,1.0,0 +151,2025-05-05,C98,LOCAL,CNI,PILOTO,145,1.0,0 +151,2025-05-05,C98,LOCAL,RAY,COPILOTO,145,1.0,0 +152,2025-05-06,C98,ROTA,MED,PILOTO,146,6.33,0 +152,2025-05-06,C98,ROTA,PFR,COPILOTO,146,6.33,0 +153,2025-05-07,C95,ROTA,PCN,PILOTO,147,3.67,0 +153,2025-05-07,C95,ROTA,GMR,COPILOTO,147,3.67,0 +154,2025-05-07,C98,LOCAL,SLS,PILOTO,148,1.0,0 +154,2025-05-07,C98,LOCAL,GMS,COPILOTO,148,1.0,0 +155,2025-05-08,C98,ROTA,FER,PILOTO,150,6.67,0 +155,2025-05-08,C98,ROTA,MCH,COPILOTO,150,6.67,0 +156,2025-05-08,C98,ROTA,HAI,PILOTO,149,6.33,0 +156,2025-05-08,C98,ROTA,LRS,COPILOTO,149,6.33,0 +157,2025-05-09,C95,ROTA,CAR,PILOTO,151,3.58,0 +157,2025-05-09,C95,ROTA,KVN,COPILOTO,151,3.58,0 +158,2025-05-09,C98,ROTA,MED,PILOTO,152,17.75,0 +158,2025-05-09,C98,ROTA,BEN,COPILOTO,152,17.75,0 +159,2025-05-09,C95,ROTA,RAY,PILOTO,153,5.33,0 +159,2025-05-09,C95,ROTA,BMK,COPILOTO,153,5.33,0 +160,2025-05-10,C95,ROTA,BRJ,PILOTO,154,11.58,0 +160,2025-05-10,C95,ROTA,ISA,COPILOTO,154,11.58,0 +161,2025-05-12,C95,ROTA,PCN,PILOTO,155,7.58,0 +161,2025-05-12,C95,ROTA,GMR,COPILOTO,155,7.58,0 +162,2025-05-13,C98,ROTA,BRI,PILOTO,156,4.83,0 +162,2025-05-13,C98,ROTA,RAY,COPILOTO,156,4.83,0 +163,2025-05-14,C95,ROTA,CAR,PILOTO,157,8.0,0 +163,2025-05-14,C95,ROTA,KVN,COPILOTO,157,8.0,0 +164,2025-05-14,C95,ROTA,CAR,PILOTO,157,8.75,0 +164,2025-05-14,C95,ROTA,KVN,COPILOTO,157,8.75,0 +165,2025-05-19,C95,ROTA,HCK,PILOTO,158,3.58,0 +165,2025-05-19,C95,ROTA,BMK,COPILOTO,158,3.58,0 +166,2025-05-20,C95,ROTA,MHL,PILOTO,159,11.17,0 +166,2025-05-20,C95,ROTA,GMR,COPILOTO,159,11.17,0 +167,2025-05-20,C97,LOCAL,MAT,PILOTO,160,1.0,0 +167,2025-05-20,C97,LOCAL,SEI,COPILOTO,160,1.0,0 +168,2025-05-21,C98,LOCAL,CNI,PILOTO,161,1.0,0 +168,2025-05-21,C98,LOCAL,MCH,COPILOTO,161,1.0,0 +169,2025-05-22,C98,ROTA,AEU,PILOTO,162,9.42,0 +169,2025-05-22,C98,ROTA,SLS,COPILOTO,162,9.42,0 +170,2025-05-22,C97,ROTA,MAT,PILOTO,163,11.08,0 +170,2025-05-22,C97,ROTA,JVT,COPILOTO,163,11.08,0 +171,2025-05-23,C98,LOCAL,FIA,PILOTO,164,1.0,0 +171,2025-05-23,C98,LOCAL,DOG,COPILOTO,164,1.0,0 +172,2025-05-25,C97,ROTA,SEI,PILOTO,165,9.67,0 +172,2025-05-25,C97,ROTA,LPS,COPILOTO,165,9.67,0 +173,2025-05-26,C98,ROTA,HAI,PILOTO,166,11.92,0 +173,2025-05-26,C98,ROTA,PFR,COPILOTO,166,11.92,0 +174,2025-05-27,C98,ROTA,BRI,PILOTO,166,3.33,0 +174,2025-05-27,C98,ROTA,LRS,COPILOTO,166,3.33,0 +175,2025-05-29,C98,ROTA,MED,PILOTO,167,4.92,0 +175,2025-05-29,C98,ROTA,RCH,COPILOTO,167,4.92,0 +176,2025-05-29,C98,LOCAL,MCH,PILOTO,168,1.0,0 +176,2025-05-29,C98,LOCAL,DOG,COPILOTO,168,1.0,0 +177,2025-05-30,C98,ROTA,SLS,PILOTO,169,14.33,0 +177,2025-05-30,C98,ROTA,PFR,COPILOTO,169,14.33,0 +178,2025-06-01,C98,ROTA,MCH,PILOTO,170,3.33,0 +178,2025-06-01,C98,ROTA,GMS,COPILOTO,170,3.33,0 +179,2025-06-01,C98,ROTA,MED,PILOTO,170,6.83,0 +179,2025-06-01,C98,ROTA,LRS,COPILOTO,170,6.83,0 +180,2025-06-02,C98,ROTA,CNI,PILOTO,171,5.67,0 +180,2025-06-02,C98,ROTA,RCH,COPILOTO,171,5.67,0 +181,2025-06-03,C98,ROTA,CNI,PILOTO,171,11.67,0 +181,2025-06-03,C98,ROTA,RCH,COPILOTO,171,11.67,0 +182,2025-06-03,C98,ROTA,BRI,PILOTO,172,2.0,0 +182,2025-06-03,C98,ROTA,LRS,COPILOTO,172,2.0,0 +183,2025-06-03,C98,ROTA,MCH,PILOTO,172,4.0,0 +183,2025-06-03,C98,ROTA,DOG,COPILOTO,172,4.0,0 +184,2025-06-04,C98,ROTA,MCH,PILOTO,173,5.0,0 +184,2025-06-04,C98,ROTA,GMS,COPILOTO,173,5.0,0 +185,2025-06-06,C98,ROTA,SLS,PILOTO,174,13.33,0 +185,2025-06-06,C98,ROTA,LRS,COPILOTO,174,13.33,0 +186,2025-06-06,C97,LOCAL,MAT,PILOTO,175,1.0,0 +186,2025-06-06,C97,LOCAL,SEI,COPILOTO,175,1.0,0 +187,2025-06-07,C97,ROTA,SEI,PILOTO,178,11.92,0 +187,2025-06-07,C97,ROTA,LPS,COPILOTO,178,11.92,0 +188,2025-06-07,C95,LOCAL,MES,PILOTO,179,1.0,0 +188,2025-06-07,C95,LOCAL,BMK,COPILOTO,179,1.0,0 +189,2025-06-08,C97,ROTA,MES,PILOTO,176,9.5,0 +189,2025-06-08,C97,ROTA,AEU,COPILOTO,176,9.5,0 +190,2025-06-08,C98,ROTA,MCH,PILOTO,177,16.33,0 +190,2025-06-08,C98,ROTA,GMS,COPILOTO,177,16.33,0 +191,2025-06-10,C95,ROTA,PCN,PILOTO,180,9.33,0 +191,2025-06-10,C95,ROTA,ISA,COPILOTO,180,9.33,0 +192,2025-06-12,C95,ROTA,GMR,PILOTO,181,11.5,0 +192,2025-06-12,C95,ROTA,BMK,COPILOTO,181,11.5,0 +193,2025-06-12,C98,ROTA,HAI,PILOTO,182,11.08,0 +193,2025-06-12,C98,ROTA,SUG,COPILOTO,182,11.08,0 +194,2025-06-12,C97,LOCAL,AEU,PILOTO,183,1.5,0 +194,2025-06-12,C97,LOCAL,MHL,COPILOTO,183,1.5,0 +195,2025-06-14,C95,ROTA,MHL,PILOTO,184,1.5,0 +195,2025-06-14,C95,ROTA,JVT,COPILOTO,184,1.5,0 +196,2025-06-15,C95,ROTA,BRJ,PILOTO,185,9.33,0 +196,2025-06-15,C95,ROTA,ISA,COPILOTO,185,9.33,0 +197,2025-06-17,C95,LOCAL,HCK,PILOTO,186,0.67,0 +197,2025-06-17,C95,LOCAL,KVN,COPILOTO,186,0.67,0 +198,2025-06-18,C95,ROTA,BMK,PILOTO,187,9.5,0 +198,2025-06-18,C95,ROTA,ISA,COPILOTO,187,9.5,0 +199,2025-06-18,C98,LOCAL,MCH,PILOTO,189,1.0,0 +199,2025-06-18,C98,LOCAL,GMS,COPILOTO,189,1.0,0 +200,2025-06-18,C95,ROTA,GMR,PILOTO,191,8.0,0 +200,2025-06-18,C95,ROTA,KVN,COPILOTO,191,8.0,0 +201,2025-06-21,C98,ROTA,SLS,PILOTO,192,2.0,0 +201,2025-06-21,C98,ROTA,AEU,COPILOTO,192,2.0,0 +202,2025-06-21,C97,ROTA,MAT,PILOTO,193,10.0,0 +202,2025-06-21,C97,ROTA,JVT,COPILOTO,193,10.0,0 +203,2025-06-23,C97,LOCAL,MAT,PILOTO,190,1.5,0 +203,2025-06-23,C97,LOCAL,MHL,COPILOTO,190,1.5,0 +204,2025-06-23,C98,LOCAL,MCH,PILOTO,194,1.0,0 +204,2025-06-23,C98,LOCAL,DOG,COPILOTO,194,1.0,0 +205,2025-06-24,C97,LOCAL,AEU,PILOTO,195,1.0,0 +205,2025-06-24,C97,LOCAL,MHL,COPILOTO,195,1.0,0 +206,2025-06-25,C97,LOCAL,AEU,PILOTO,198,1.0,0 +206,2025-06-25,C97,LOCAL,MES,COPILOTO,198,1.0,0 +207,2025-06-25,C98,LOCAL,SLS,PILOTO,199,1.0,0 +207,2025-06-25,C98,LOCAL,CFF,COPILOTO,199,1.0,0 +208,2025-06-25,C98,LOCAL,CNI,PILOTO,200,1.0,0 +208,2025-06-25,C98,LOCAL,FER,COPILOTO,200,1.0,0 +209,2025-06-26,C97,ROTA,MAT,PILOTO,202,9.92,0 +209,2025-06-26,C97,ROTA,MHL,COPILOTO,202,9.92,0 +210,2025-06-26,C98,LOCAL,MED,PILOTO,201,0.67,0 +210,2025-06-26,C98,LOCAL,CFF,COPILOTO,201,0.67,0 +211,2025-06-26,C98,LOCAL,FER,PILOTO,203,0.25,0 +211,2025-06-26,C98,LOCAL,MCH,COPILOTO,203,0.25,0 +212,2025-06-26,C98,LOCAL,FER,PILOTO,204,0.25,0 +212,2025-06-26,C98,LOCAL,DOG,COPILOTO,204,0.25,0 +213,2025-06-27,C95,LOCAL,BRI,PILOTO,205,1.0,0 +213,2025-06-27,C95,LOCAL,RAY,COPILOTO,205,1.0,0 +214,2025-06-29,C97,ROTA,MAT,PILOTO,207,10.67,0 +214,2025-06-29,C97,ROTA,MHL,COPILOTO,207,10.67,0 +215,2025-06-30,C98,ROTA,CNI,PILOTO,208,6.67,0 +215,2025-06-30,C98,ROTA,PFR,COPILOTO,208,6.67,0 +216,2025-07-01,C98,ROTA,SLS,PILOTO,211,10.92,0 +216,2025-07-01,C98,ROTA,SUG,COPILOTO,211,10.92,0 +217,2025-07-01,C95,LOCAL,RAY,PILOTO,206,1.0,0 +217,2025-07-01,C95,LOCAL,BMK,COPILOTO,206,1.0,0 +218,2025-07-01,C95,ROTA,PCN,PILOTO,209,5.92,0 +218,2025-07-01,C95,ROTA,KVN,COPILOTO,209,5.92,0 +219,2025-07-02,C98,ROTA,RAY,PILOTO,212,3.17,0 +219,2025-07-02,C98,ROTA,CNI,COPILOTO,212,3.17,0 +220,2025-07-04,C98,ROTA,AEU,PILOTO,214,5.0,0 +220,2025-07-04,C98,ROTA,SLS,COPILOTO,214,5.0,0 +221,2025-07-04,C98,ROTA,MED,PILOTO,213,6.92,0 +221,2025-07-04,C98,ROTA,RAY,COPILOTO,213,6.92,0 +222,2025-07-04,C97,LOCAL,MES,PILOTO,210,1.5,0 +222,2025-07-04,C97,LOCAL,FIA,COPILOTO,210,1.5,0 +223,2025-07-05,C97,LOCAL,MAT,PILOTO,216,1.5,0 +223,2025-07-05,C97,LOCAL,FIA,COPILOTO,216,1.5,0 +224,2025-07-07,C98,ROTA,FER,PILOTO,215,5.92,0 +224,2025-07-07,C98,ROTA,RCH,COPILOTO,215,5.92,0 +225,2025-07-07,C97,LOCAL,AEU,PILOTO,217,1.0,0 +225,2025-07-07,C97,LOCAL,FIA,COPILOTO,217,1.0,0 +226,2025-07-10,C98,ROTA,CNI,PILOTO,218,12.17,0 +226,2025-07-10,C98,ROTA,SUG,COPILOTO,218,12.17,0 +227,2025-07-11,C97,ROTA,MAT,PILOTO,219,6.83,0 +227,2025-07-11,C97,ROTA,FIA,COPILOTO,219,6.83,0 +228,2025-07-11,C98,ROTA,FER,PILOTO,220,13.67,0 +228,2025-07-11,C98,ROTA,LRS,COPILOTO,220,13.67,0 +229,2025-07-12,C97,ROTA,FIA,PILOTO,221,10.25,0 +229,2025-07-12,C97,ROTA,LPS,COPILOTO,221,10.25,0 +230,2025-07-12,C98,ROTA,CFF,PILOTO,223,8.0,0 +230,2025-07-12,C98,ROTA,MCH,COPILOTO,223,8.0,0 +231,2025-07-14,C98,ROTA,GMS,PILOTO,224,4.83,0 +231,2025-07-14,C98,ROTA,DOG,COPILOTO,224,4.83,0 +232,2025-07-14,C98,ROTA,MCH,PILOTO,222,9.75,0 +232,2025-07-14,C98,ROTA,CFF,COPILOTO,222,9.75,0 +233,2025-07-15,C98,ROTA,CNI,PILOTO,225,4.83,0 +233,2025-07-15,C98,ROTA,PFR,COPILOTO,225,4.83,0 +234,2025-07-16,C97,ROTA,FIA,PILOTO,226,6.17,0 +234,2025-07-16,C97,ROTA,LPS,COPILOTO,226,6.17,0 +235,2025-07-19,C98,ROTA,GMS,PILOTO,228,17.83,0 +235,2025-07-19,C98,ROTA,DOG,COPILOTO,228,17.83,0 +236,2025-07-21,C98,ROTA,SLS,PILOTO,228,3.33,0 +236,2025-07-21,C98,ROTA,AEU,COPILOTO,228,3.33,0 +237,2025-08-01,C98,LOCAL,CFF,PILOTO,188,1.0,0 +237,2025-08-01,C98,LOCAL,MCH,COPILOTO,188,1.0,0 +238,2025-08-04,C95,LOCAL,BMK,PILOTO,229,0.08,0 +238,2025-08-04,C95,LOCAL,KVN,COPILOTO,229,0.08,0 +239,2025-08-06,C98,ROTA,MCH,PILOTO,231,6.67,0 +239,2025-08-06,C98,ROTA,GMS,COPILOTO,231,6.67,0 +240,2025-08-12,C95,LOCAL,BRI,PILOTO,232,1.0,0 +240,2025-08-12,C95,LOCAL,GMR,COPILOTO,232,1.0,0 +241,2025-08-16,C97,LOCAL,MES,PILOTO,233,0.92,0 +241,2025-08-16,C97,LOCAL,SEI,COPILOTO,233,0.92,0 +242,2025-08-16,C97,LOCAL,FIA,PILOTO,234,0.08,0 +242,2025-08-16,C97,LOCAL,LPS,COPILOTO,234,0.08,0 +243,2025-08-18,C98,ROTA,RAY,PILOTO,235,6.67,0 +243,2025-08-18,C98,ROTA,SLS,COPILOTO,235,6.67,0 +244,2025-08-21,C95,LOCAL,BRI,PILOTO,236,1.0,0 +244,2025-08-21,C95,LOCAL,HCK,COPILOTO,236,1.0,0 +245,2025-08-25,C98,LOCAL,FIA,PILOTO,237,1.0,0 +245,2025-08-25,C98,LOCAL,SLS,COPILOTO,237,1.0,0 +246,2025-08-25,C98,LOCAL,FIA,PILOTO,238,0.08,0 +246,2025-08-25,C98,LOCAL,DOG,COPILOTO,238,0.08,0 +247,2025-08-26,C98,LOCAL,FIA,PILOTO,239,1.0,0 +247,2025-08-26,C98,LOCAL,LRS,COPILOTO,239,1.0,0 +248,2025-08-26,C97,LOCAL,MES,PILOTO,240,1.0,0 +248,2025-08-26,C97,LOCAL,AEU,COPILOTO,240,1.0,0 +249,2025-08-26,C98,LOCAL,FIA,PILOTO,241,1.0,0 +249,2025-08-26,C98,LOCAL,MED,COPILOTO,241,1.0,0 +250,2025-08-26,C95,LOCAL,BRI,PILOTO,242,1.0,0 +250,2025-08-26,C95,LOCAL,PCN,COPILOTO,242,1.0,0 +251,2025-08-27,C97,ROTA,FIA,PILOTO,243,10.5,0 +251,2025-08-27,C97,ROTA,LPS,COPILOTO,243,10.5,0 +252,2025-08-27,C98,LOCAL,MED,PILOTO,244,1.0,0 +252,2025-08-27,C98,LOCAL,RCH,COPILOTO,244,1.0,0 +253,2025-08-27,C98,LOCAL,MED,PILOTO,245,1.0,0 +253,2025-08-27,C98,LOCAL,PFR,COPILOTO,245,1.0,0 +254,2025-09-01,C97,LOCAL,AEU,PILOTO,246,1.0,0 +254,2025-09-01,C97,LOCAL,LPS,COPILOTO,246,1.0,0 +255,2025-09-02,C98,LOCAL,FIA,PILOTO,247,1.0,0 +255,2025-09-02,C98,LOCAL,SLS,COPILOTO,247,1.0,0 +256,2025-09-02,C95,LOCAL,BRI,PILOTO,248,1.0,0 +256,2025-09-02,C95,LOCAL,CAR,COPILOTO,248,1.0,0 +257,2025-09-02,C98,LOCAL,MED,PILOTO,249,0.08,0 +257,2025-09-02,C98,LOCAL,SLS,COPILOTO,249,0.08,0 +258,2025-09-03,C95,LOCAL,CAR,PILOTO,250,0.08,0 +258,2025-09-03,C95,LOCAL,KVN,COPILOTO,250,0.08,0 +259,2025-09-03,C95,LOCAL,CAR,PILOTO,251,0.08,0 +259,2025-09-03,C95,LOCAL,ISA,COPILOTO,251,0.08,0 +260,2025-09-05,C98,ROTA,MCH,PILOTO,252,4.17,0 +260,2025-09-05,C98,ROTA,LRS,COPILOTO,252,4.17,0 +261,2025-09-10,C95,ROTA,BRI,PILOTO,253,2.17,0 +261,2025-09-10,C95,ROTA,BRJ,COPILOTO,253,2.17,0 +262,2025-09-10,C98,LOCAL,GMS,PILOTO,254,0.08,0 +262,2025-09-10,C98,LOCAL,LRS,COPILOTO,254,0.08,0 +263,2025-09-11,C98,LOCAL,DOG,PILOTO,255,0.08,0 +263,2025-09-11,C98,LOCAL,PFR,COPILOTO,255,0.08,0 +264,2025-09-11,C98,LOCAL,FIA,PILOTO,256,1.0,0 +264,2025-09-11,C98,LOCAL,CFF,COPILOTO,256,1.0,0 +265,2025-09-12,C97,LOCAL,MES,PILOTO,257,0.08,0 +265,2025-09-12,C97,LOCAL,SEI,COPILOTO,257,0.08,0 +266,2025-09-15,C98,LOCAL,MED,PILOTO,258,1.0,0 +266,2025-09-15,C98,LOCAL,SUG,COPILOTO,258,1.0,0 +267,2025-09-16,C97,ROTA,FIA,PILOTO,260,8.67,0 +267,2025-09-16,C97,ROTA,SEI,COPILOTO,260,8.67,0 +268,2025-09-16,C98,ROTA,DOG,PILOTO,259,2.0,0 +268,2025-09-16,C98,ROTA,SUG,COPILOTO,259,2.0,0 +269,2025-09-17,C98,ROTA,CFF,PILOTO,261,0.67,0 +269,2025-09-17,C98,ROTA,SUG,COPILOTO,261,0.67,0 +270,2025-09-18,C97,ROTA,FIA,PILOTO,262,5.0,0 +270,2025-09-18,C97,ROTA,AEU,COPILOTO,262,5.0,0 +271,2025-09-22,C97,LOCAL,AEU,PILOTO,263,1.5,0 +271,2025-09-22,C97,LOCAL,LPS,COPILOTO,263,1.5,0 +272,2025-09-24,C95,LOCAL,HCK,PILOTO,264,0.08,0 +272,2025-09-24,C95,LOCAL,CAR,COPILOTO,264,0.08,0 +273,2025-09-25,C98,LOCAL,CFF,PILOTO,265,1.0,0 +273,2025-09-25,C98,LOCAL,MCH,COPILOTO,265,1.0,0 +274,2025-10-01,C98,LOCAL,MCH,PILOTO,268,0.08,0 +274,2025-10-01,C98,LOCAL,GMS,COPILOTO,268,0.08,0 +275,2025-09-27,C98,ROTA,CFF,PILOTO,266,20.58,0 +275,2025-09-27,C98,ROTA,DOG,COPILOTO,266,20.58,0 +276,2025-09-29,C97,ROTA,AEU,PILOTO,267,6.0,0 +276,2025-09-29,C97,ROTA,LPS,COPILOTO,267,6.0,0 +277,2025-10-02,C98,ROTA,CFF,PILOTO,269,8.5,0 +277,2025-10-02,C98,ROTA,DOG,COPILOTO,269,8.5,0 +278,2025-10-05,C95,LOCAL,BRI,PILOTO,270,3.0,0 +278,2025-10-05,C95,LOCAL,CAR,COPILOTO,270,3.0,0 +279,2025-10-05,C95,ROTA,PCN,PILOTO,271,3.5,0 +279,2025-10-05,C95,ROTA,GMR,COPILOTO,271,3.5,0 +280,2025-10-08,C95,ROTA,CAR,PILOTO,272,3.58,0 +280,2025-10-08,C95,ROTA,KVN,COPILOTO,272,3.58,0 +281,2025-10-11,C95,LOCAL,BRI,PILOTO,273,1.0,0 +281,2025-10-11,C95,LOCAL,BRJ,COPILOTO,273,1.0,0 +282,2025-10-11,C95,LOCAL,BRI,PILOTO,274,1.0,0 +282,2025-10-11,C95,LOCAL,BMK,COPILOTO,274,1.0,0 +283,2025-10-13,C97,LOCAL,MES,PILOTO,275,0.25,0 +283,2025-10-13,C97,LOCAL,SEI,COPILOTO,275,0.25,0 +284,2025-10-13,C98,LOCAL,MED,PILOTO,276,0.08,0 +284,2025-10-13,C98,LOCAL,SLS,COPILOTO,276,0.08,0 +285,2025-10-21,C98,LOCAL,FIA,PILOTO,277,0.67,0 +285,2025-10-21,C98,LOCAL,BEN,COPILOTO,277,0.67,0 +286,2025-10-21,C95,LOCAL,HCK,PILOTO,278,0.08,0 +286,2025-10-21,C95,LOCAL,ISA,COPILOTO,278,0.08,0 +287,2025-10-22,C98,LOCAL,GMS,PILOTO,279,1.0,0 +287,2025-10-22,C98,LOCAL,DOG,COPILOTO,279,1.0,0 +288,2025-10-23,C95,LOCAL,BRI,PILOTO,280,1.0,0 +288,2025-10-23,C95,LOCAL,BRJ,COPILOTO,280,1.0,0 +289,2025-10-23,C95,LOCAL,BRI,PILOTO,281,1.5,0 +289,2025-10-23,C95,LOCAL,JVT,COPILOTO,281,1.5,0 +290,2025-10-28,C95,LOCAL,BRI,PILOTO,282,1.5,0 +290,2025-10-28,C95,LOCAL,PCN,COPILOTO,282,1.5,0 +291,2025-10-29,C98,LOCAL,GMS,PILOTO,283,0.08,0 +291,2025-10-29,C98,LOCAL,PFR,COPILOTO,283,0.08,0 +292,2025-10-30,C98,LOCAL,MED,PILOTO,284,0.58,0 +292,2025-10-30,C98,LOCAL,BEN,COPILOTO,284,0.58,0 +293,2025-11-04,C98,LOCAL,MCH,PILOTO,285,0.08,0 +293,2025-11-04,C98,LOCAL,DOG,COPILOTO,285,0.08,0 +294,2025-11-08,C98,ROTA,CFF,PILOTO,286,10.83,0 +294,2025-11-08,C98,ROTA,BEN,COPILOTO,286,10.83,0 +295,2025-11-08,C95,LOCAL,BRI,PILOTO,288,2.0,0 +295,2025-11-08,C95,LOCAL,PCN,COPILOTO,288,2.0,0 +296,2025-11-11,C98,ROTA,MCH,PILOTO,287,5.92,0 +296,2025-11-11,C98,ROTA,PFR,COPILOTO,287,5.92,0 +297,2025-11-11,C95,LOCAL,PCN,PILOTO,289,0.08,0 +297,2025-11-11,C95,LOCAL,BMK,COPILOTO,289,0.08,0 +298,2025-11-11,C95,LOCAL,PCN,PILOTO,290,1.0,0 +298,2025-11-11,C95,LOCAL,GMR,COPILOTO,290,1.0,0 +299,2025-11-13,C98,ROTA,DOG,PILOTO,287,5.92,0 +299,2025-11-13,C98,ROTA,LRS,COPILOTO,287,5.92,0 +300,2025-11-24,C98,LOCAL,SLS,PILOTO,292,0.08,0 +300,2025-11-24,C98,LOCAL,RCH,COPILOTO,292,0.08,0 +301,2025-11-24,C98,ROTA,GMS,PILOTO,291,5.0,0 +301,2025-11-24,C98,ROTA,BEN,COPILOTO,291,5.0,0 +302,2025-11-24,C95,LOCAL,BRJ,PILOTO,293,0.08,0 +302,2025-11-24,C95,LOCAL,ISA,COPILOTO,293,0.08,0 +303,2025-11-27,C95,LOCAL,MHL,PILOTO,294,1.0,0 +303,2025-11-27,C95,LOCAL,BRJ,COPILOTO,294,1.0,0 +304,2025-12-01,C95,ROTA,BRI,PILOTO,295,1.5,0 +304,2025-12-01,C95,ROTA,ISA,COPILOTO,295,1.5,0 +305,2025-12-02,C95,ROTA,CAR,PILOTO,296,6.58,0 +305,2025-12-02,C95,ROTA,KVN,COPILOTO,296,6.58,0 +306,2025-12-03,C97,ROTA,MES,PILOTO,297,9.08,0 +306,2025-12-03,C97,ROTA,SEI,COPILOTO,297,9.08,0 +307,2025-12-03,C98,LOCAL,SLS,PILOTO,298,0.08,0 +307,2025-12-03,C98,LOCAL,RCH,COPILOTO,298,0.08,0 +308,2025-12-03,C95,LOCAL,JVT,PILOTO,299,0.67,0 +308,2025-12-03,C95,LOCAL,GMR,COPILOTO,299,0.67,0 +309,2025-12-03,C95,LOCAL,CAR,PILOTO,300,1.0,0 +309,2025-12-03,C95,LOCAL,BMK,COPILOTO,300,1.0,0 +310,2025-12-03,C95,LOCAL,CAR,PILOTO,300,1.0,0 +310,2025-12-03,C95,LOCAL,BMK,COPILOTO,300,1.0,0 +311,2025-12-03,C95,LOCAL,CAR,PILOTO,300,1.0,0 +311,2025-12-03,C95,LOCAL,BMK,COPILOTO,300,1.0,0 +312,2025-12-03,C95,LOCAL,JVT,PILOTO,299,0.67,0 +312,2025-12-03,C95,LOCAL,GMR,COPILOTO,299,0.67,0 +313,2025-12-03,C95,ROTA,BRJ,PILOTO,304,12.0,0 +313,2025-12-03,C95,ROTA,ISA,COPILOTO,304,12.0,0 +314,2025-12-04,C95,ROTA,GMR,PILOTO,302,2.67,0 +314,2025-12-04,C95,ROTA,KVN,COPILOTO,302,2.67,0 +315,2025-12-04,C95,ROTA,GMR,PILOTO,303,14.0,0 +315,2025-12-04,C95,ROTA,KVN,COPILOTO,303,14.0,0 +316,2025-12-05,C95,LOCAL,BRI,PILOTO,301,1.5,0 +316,2025-12-05,C95,LOCAL,CAR,COPILOTO,301,1.5,0 +317,2025-12-05,C98,LOCAL,SLS,PILOTO,305,1.0,0 +317,2025-12-05,C98,LOCAL,MCH,COPILOTO,305,1.0,0 +318,2025-12-05,C98,LOCAL,SLS,PILOTO,306,1.0,0 +318,2025-12-05,C98,LOCAL,SUG,COPILOTO,306,1.0,0 +319,2025-12-05,C98,ROTA,DOG,PILOTO,308,5.0,0 +319,2025-12-05,C98,ROTA,BEN,COPILOTO,308,5.0,0 +320,2025-12-06,C98,ROTA,GMS,PILOTO,309,3.5,0 +320,2025-12-06,C98,ROTA,LRS,COPILOTO,309,3.5,0 +321,2025-12-06,C98,ROTA,MCH,PILOTO,310,2.08,0 +321,2025-12-06,C98,ROTA,RCH,COPILOTO,310,2.08,0 +322,2025-12-06,C95,LOCAL,BRI,PILOTO,307,2.5,0 +322,2025-12-06,C95,LOCAL,CAR,COPILOTO,307,2.5,0 +323,2025-12-07,C98,ROTA,CFF,PILOTO,313,3.5,0 +323,2025-12-07,C98,ROTA,RCH,COPILOTO,313,3.5,0 +324,2025-12-07,C98,ROTA,DOG,PILOTO,314,2.08,0 +324,2025-12-07,C98,ROTA,BEN,COPILOTO,314,2.08,0 +325,2025-12-08,C95,LOCAL,CAR,PILOTO,311,0.67,0 +325,2025-12-08,C95,LOCAL,KVN,COPILOTO,311,0.67,0 +326,2025-12-08,C95,LOCAL,JVT,PILOTO,312,1.0,0 +326,2025-12-08,C95,LOCAL,GMR,COPILOTO,312,1.0,0 +327,2025-12-08,C95,LOCAL,JVT,PILOTO,312,1.0,0 +327,2025-12-08,C95,LOCAL,GMR,COPILOTO,312,1.0,0 +328,2025-12-08,C95,LOCAL,JVT,PILOTO,312,1.0,0 +328,2025-12-08,C95,LOCAL,GMR,COPILOTO,312,1.0,0 +329,2025-12-08,C95,LOCAL,CAR,PILOTO,311,0.67,0 +329,2025-12-08,C95,LOCAL,KVN,COPILOTO,311,0.67,0 +330,2025-12-08,C97,LOCAL,MES,PILOTO,315,1.0,0 +330,2025-12-08,C97,LOCAL,AEU,COPILOTO,315,1.0,0 +331,2025-12-08,C98,LOCAL,CNI,PILOTO,316,1.0,0 +331,2025-12-08,C98,LOCAL,FIA,COPILOTO,316,1.0,0 +332,2025-12-09,C95,ROTA,HCK,PILOTO,317,6.75,0 +332,2025-12-09,C95,ROTA,BRJ,COPILOTO,317,6.75,0 +333,2025-12-09,C98,LOCAL,FIA,PILOTO,318,1.5,0 +333,2025-12-09,C98,LOCAL,CFF,COPILOTO,318,1.5,0 +334,2025-12-09,C95,LOCAL,CAR,PILOTO,319,1.0,0 +334,2025-12-09,C95,LOCAL,KVN,COPILOTO,319,1.0,0 +335,2025-12-09,C98,ROTA,GMS,PILOTO,320,6.0,0 +335,2025-12-09,C98,ROTA,PFR,COPILOTO,320,6.0,0 +336,2025-12-09,C98,LOCAL,MED,PILOTO,321,0.08,0 +336,2025-12-09,C98,LOCAL,RCH,COPILOTO,321,0.08,0 +337,2025-12-10,C95,ROTA,BMK,PILOTO,322,6.75,0 +337,2025-12-10,C95,ROTA,BRJ,COPILOTO,322,6.75,0 +338,2025-12-10,C98,LOCAL,FIA,PILOTO,323,1.5,0 +338,2025-12-10,C98,LOCAL,GMS,COPILOTO,323,1.5,0 +339,2025-12-10,C98,LOCAL,MED,PILOTO,324,1.0,0 +339,2025-12-10,C98,LOCAL,CFF,COPILOTO,324,1.0,0 +340,2025-12-11,C95,ROTA,CAR,PILOTO,325,4.83,0 +340,2025-12-11,C95,ROTA,KVN,COPILOTO,325,4.83,0 +341,2025-12-12,C97,LOCAL,FIA,PILOTO,326,1.0,0 +341,2025-12-12,C97,LOCAL,AEU,COPILOTO,326,1.0,0 +342,2025-12-12,C95,LOCAL,JVT,PILOTO,327,0.67,0 +342,2025-12-12,C95,LOCAL,BMK,COPILOTO,327,0.67,0 +343,2025-12-12,C95,LOCAL,BRI,PILOTO,328,1.0,0 +343,2025-12-12,C95,LOCAL,HCK,COPILOTO,328,1.0,0 +344,2025-12-12,C95,LOCAL,BRI,PILOTO,328,1.0,0 +344,2025-12-12,C95,LOCAL,HCK,COPILOTO,328,1.0,0 +345,2025-12-12,C95,LOCAL,BRI,PILOTO,328,1.0,0 +345,2025-12-12,C95,LOCAL,HCK,COPILOTO,328,1.0,0 +346,2025-12-12,C95,LOCAL,GMR,PILOTO,327,0.67,0 +346,2025-12-12,C95,LOCAL,HCK,COPILOTO,327,0.67,0 +347,2025-12-12,C95,LOCAL,RAY,PILOTO,329,1.0,0 +347,2025-12-12,C95,LOCAL,KVN,COPILOTO,329,1.0,0 +348,2025-12-12,C95,LOCAL,MHL,PILOTO,329,1.0,0 +348,2025-12-12,C95,LOCAL,ISA,COPILOTO,329,1.0,0 +349,2025-12-12,C95,LOCAL,MHL,PILOTO,330,1.0,0 +349,2025-12-12,C95,LOCAL,ISA,COPILOTO,330,1.0,0 +350,2025-12-12,C95,LOCAL,RAY,PILOTO,330,1.0,0 +350,2025-12-12,C95,LOCAL,KVN,COPILOTO,330,1.0,0 +351,2025-12-12,C98,LOCAL,MED,PILOTO,331,1.0,0 +351,2025-12-12,C98,LOCAL,GMS,COPILOTO,331,1.0,0 +352,2025-12-13,C95,LOCAL,RAY,PILOTO,333,1.0,0 +352,2025-12-13,C95,LOCAL,CAR,COPILOTO,333,1.0,0 +353,2025-12-13,C95,LOCAL,HCK,PILOTO,334,1.0,0 +353,2025-12-13,C95,LOCAL,ISA,COPILOTO,334,1.0,0 +354,2025-12-13,C95,LOCAL,RAY,PILOTO,335,1.0,0 +354,2025-12-13,C95,LOCAL,CAR,COPILOTO,335,1.0,0 +355,2025-12-14,C95,ROTA,JVT,PILOTO,332,11.33,0 +355,2025-12-14,C95,ROTA,BMK,COPILOTO,332,11.33,0 +356,2025-12-15,C95,LOCAL,MHL,PILOTO,336,1.0,0 +356,2025-12-15,C95,LOCAL,MES,COPILOTO,336,1.0,0 +357,2025-12-15,C95,LOCAL,BRJ,PILOTO,336,1.0,0 +357,2025-12-15,C95,LOCAL,KVN,COPILOTO,336,1.0,0 +358,2025-12-15,C95,LOCAL,MES,PILOTO,337,1.0,0 +358,2025-12-15,C95,LOCAL,MHL,COPILOTO,337,1.0,0 +359,2025-12-15,C95,LOCAL,BRJ,PILOTO,337,1.0,0 +359,2025-12-15,C95,LOCAL,KVN,COPILOTO,337,1.0,0 +360,2025-12-15,C98,LOCAL,CFF,PILOTO,338,1.0,0 +360,2025-12-15,C98,LOCAL,BEN,COPILOTO,338,1.0,0 +361,2025-12-16,C95,ROTA,BRJ,PILOTO,339,5.33,0 +361,2025-12-16,C95,ROTA,KVN,COPILOTO,339,5.33,0 +362,2025-12-16,C98,ROTA,CFF,PILOTO,340,2.08,0 +362,2025-12-16,C98,ROTA,BEN,COPILOTO,340,2.08,0 +363,2025-12-17,C95,LOCAL,CAR,PILOTO,342,1.0,0 +363,2025-12-17,C95,LOCAL,MES,COPILOTO,342,1.0,0 +364,2025-12-17,C95,LOCAL,GMR,PILOTO,342,1.0,0 +364,2025-12-17,C95,LOCAL,BMK,COPILOTO,342,1.0,0 +365,2025-12-17,C95,LOCAL,MES,PILOTO,343,1.0,0 +365,2025-12-17,C95,LOCAL,CAR,COPILOTO,343,1.0,0 +366,2025-12-17,C95,LOCAL,BMK,PILOTO,343,1.0,0 +366,2025-12-17,C95,LOCAL,GMR,COPILOTO,343,1.0,0 +367,2025-12-18,C97,ROTA,AEU,PILOTO,341,10.17,0 +367,2025-12-18,C97,ROTA,MHL,COPILOTO,341,10.17,0 +368,2025-12-18,C98,LOCAL,FIA,PILOTO,344,1.0,0 +368,2025-12-18,C98,LOCAL,DOG,COPILOTO,344,1.0,0 +369,2025-12-19,C95,ROTA,BRI,PILOTO,345,5.67,0 +369,2025-12-19,C95,ROTA,HCK,COPILOTO,345,5.67,0 +370,2025-12-20,C98,ROTA,MCH,PILOTO,347,1.17,0 +370,2025-12-20,C98,ROTA,SUG,COPILOTO,347,1.17,0 +371,2025-12-23,C95,ROTA,GMR,PILOTO,346,6.42,0 +371,2025-12-23,C95,ROTA,ISA,COPILOTO,346,6.42,0 +372,2025-12-23,C97,LOCAL,MES,PILOTO,349,0.5,0 +372,2025-12-23,C97,LOCAL,MHL,COPILOTO,349,0.5,0 +373,2025-12-23,C98,ROTA,MCH,PILOTO,350,0.92,0 +373,2025-12-23,C98,ROTA,RCH,COPILOTO,350,0.92,0 +374,2025-12-28,C95,ROTA,CAR,PILOTO,352,3.92,0 +374,2025-12-28,C95,ROTA,HCK,COPILOTO,352,3.92,0 +375,2025-12-28,C95,ROTA,JVT,PILOTO,353,10.0,0 +375,2025-12-28,C95,ROTA,BRJ,COPILOTO,353,10.0,0 +376,2025-12-29,C95,ROTA,BMK,PILOTO,354,3.92,0 +376,2025-12-29,C95,ROTA,KVN,COPILOTO,354,3.92,0 +377,2025-12-29,C97,LOCAL,AEU,PILOTO,348,0.75,0 +377,2025-12-29,C97,LOCAL,LPS,COPILOTO,348,0.75,0 +378,2025-12-29,C97,LOCAL,AEU,PILOTO,351,0.75,0 +378,2025-12-29,C97,LOCAL,SEI,COPILOTO,351,0.75,0 +379,2025-12-29,C95,LOCAL,MES,PILOTO,355,1.0,0 +379,2025-12-29,C95,LOCAL,CAR,COPILOTO,355,1.0,0 +380,2025-12-29,C95,LOCAL,ISA,PILOTO,355,1.0,0 +380,2025-12-29,C95,LOCAL,MHL,COPILOTO,355,1.0,0 +381,2025-12-29,C95,LOCAL,MHL,PILOTO,356,1.0,0 +381,2025-12-29,C95,LOCAL,RAY,COPILOTO,356,1.0,0 +382,2025-12-29,C95,LOCAL,CAR,PILOTO,356,1.0,0 +382,2025-12-29,C95,LOCAL,HCK,COPILOTO,356,1.0,0 +383,2025-12-30,C95,ROTA,BRI,PILOTO,357,3.42,0 +383,2025-12-30,C95,ROTA,KVN,COPILOTO,357,3.42,0 +384,2025-12-30,C97,LOCAL,FIA,PILOTO,358,0.75,0 +384,2025-12-30,C97,LOCAL,SEI,COPILOTO,358,0.75,0 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a9ea572 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +pandas>=3.0,<4 +numpy>=2.4,<3 +scipy>=1.18,<2 +openpyxl>=3.1,<4 diff --git a/run_planner.py b/run_planner.py new file mode 100644 index 0000000..4cb535b --- /dev/null +++ b/run_planner.py @@ -0,0 +1,14 @@ +"""Ponto de entrada do planejador diario de missoes.""" + +from pathlib import Path + +from src.planejador_missao.main import executar_planejamento + + +if __name__ == "__main__": + resultado = executar_planejamento(Path(__file__).resolve().parent) + print("\n=== Planejamento concluido ===") + print(f"Data: {resultado['data_planejamento']}") + print(f"Arquivo de saida: {resultado['arquivo_saida']}") + print(f"Colunas candidatas: {resultado['total_candidatas']}") + print(f"Escalas selecionadas: {resultado['total_selecionadas']}") diff --git a/scripts/00_main.py b/scripts/00_main.py new file mode 100644 index 0000000..f874d0e --- /dev/null +++ b/scripts/00_main.py @@ -0,0 +1,103 @@ +"""Orquestrador principal do Planejador Missao. + +Entradas: + Arquivos em dados/, argumentos opcionais de linha de comando e configuracoes + centralizadas em scripts/01_config.py. + +Saidas: + Planejamento diario em resultados/, historico operacional atualizado, + relatorios de validacao quando solicitado e logs em logs/execucao.log. + +Papel no pipeline: + Executa o fluxo completo: leitura de dados, preparacao, geracao de colunas + candidatas, montagem/solucao do MILP, exportacao de resultados e validacao. + +Exemplos: + python scripts/00_main.py --data 2026-01-02 --modo diario + python scripts/00_main.py --validacao 2025 +""" + +from __future__ import annotations + +import argparse +import importlib +import time + +import pandas as pd + +config = importlib.import_module("01_config") +io_utils = importlib.import_module("02_io_utils") + + +def atualizar_data_planejamento(data: str) -> None: + """Atualiza dados/parametros_missao.csv com a data solicitada.""" + path = config.ARQUIVO_PARAMETROS + df = pd.read_csv(path) if path.exists() else pd.DataFrame(columns=["parametro", "valor"]) + if "parametro" not in df.columns or "valor" not in df.columns: + raise ValueError("Erro: parametros_missao.csv deve conter as colunas parametro,valor.") + if (df["parametro"] == "data_planejamento").any(): + df.loc[df["parametro"] == "data_planejamento", "valor"] = data + else: + df = pd.concat([df, pd.DataFrame([{"parametro": "data_planejamento", "valor": data}])], ignore_index=True) + df.to_csv(path, index=False) + + +def executar_diario(args: argparse.Namespace, logger) -> dict: + """Executa o planejamento diario oficial em Python.""" + io_utils.configurar_ambiente() + if args.data: + atualizar_data_planejamento(args.data) + logger.info("Data de planejamento ajustada para %s", args.data) + if args.aeronave: + logger.info("Filtro --aeronave recebido: %s. O modelo diario usa a disponibilidade em dados/aeronaves_disponiveis.csv.", args.aeronave) + + from src.planejador_missao.main import executar_planejamento + + inicio = time.perf_counter() + resultado = executar_planejamento(config.PROJECT_ROOT) + duracao = time.perf_counter() - inicio + logger.info("Data planejada: %s", resultado["data_planejamento"]) + logger.info("Colunas candidatas: %s", resultado["total_candidatas"]) + logger.info("Escalas selecionadas: %s", resultado["total_selecionadas"]) + logger.info("Arquivo exportado: %s", resultado["arquivo_saida"]) + logger.info("Tempo de execucao: %.1f s", duracao) + return resultado + + +def executar_validacao(ano: str, logger) -> dict: + """Executa validacao retrospectiva suportada pelo projeto.""" + if str(ano) != "2025": + raise ValueError("Erro: no momento a validacao disponivel e --validacao 2025.") + validacao = importlib.import_module("07_validacao_2025") + return validacao.executar_validacao_2025(importar=True, logger=logger) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Executa o Planejador Missao em Python.") + parser.add_argument("--data", help="Data do planejamento diario no formato AAAA-MM-DD.") + parser.add_argument("--aeronave", choices=config.AERONAVES, help="Aeronave de referencia para diagnostico.") + parser.add_argument("--modo", default="diario", choices=["diario"], help="Modo de execucao do planejamento.") + parser.add_argument("--validacao", help="Ano da validacao retrospectiva. Exemplo: --validacao 2025.") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + logger = io_utils.configurar_logger("main") + if args.validacao: + saida = executar_validacao(args.validacao, logger) + print("\n=== Validacao concluida ===") + for path in saida["arquivos"]: + print(f"- {path}") + return + + resultado = executar_diario(args, logger) + print("\n=== Planejamento diario concluido ===") + print(f"Data: {resultado['data_planejamento']}") + print(f"Arquivo: {resultado['arquivo_saida']}") + print(f"Colunas candidatas: {resultado['total_candidatas']}") + print(f"Escalas selecionadas: {resultado['total_selecionadas']}") + + +if __name__ == "__main__": + main() diff --git a/scripts/01_config.py b/scripts/01_config.py new file mode 100644 index 0000000..9316026 --- /dev/null +++ b/scripts/01_config.py @@ -0,0 +1,72 @@ +"""Configuracao central dos scripts do Planejador Missao. + +Entradas: + Estrutura padrao do projeto, com pastas dados/, src/ e resultados/. + +Saidas: + Constantes de caminho, parametros operacionais, nomes de arquivos e pesos + usados pelos scripts de execucao, validacao e teste. + +Papel no pipeline: + Evita caminhos absolutos e concentra convencoes para que o projeto rode em + qualquer computador mantendo a mesma estrutura de diretorios. +""" + +from __future__ import annotations + +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +SCRIPTS_DIR = PROJECT_ROOT / "scripts" +SRC_DIR = PROJECT_ROOT / "src" +DADOS_DIR = PROJECT_ROOT / "dados" +RESULTADOS_DIR = PROJECT_ROOT / "resultados" +VALIDACAO_DIR = RESULTADOS_DIR / "validacao" +LOGS_DIR = PROJECT_ROOT / "logs" +TEMP_DIR = PROJECT_ROOT / "temp" + +ARQUIVO_PARAMETROS = DADOS_DIR / "parametros_missao.csv" +ARQUIVO_CADASTRO_LOCAL = DADOS_DIR / "Modelagem_C98_ETA2_local.xlsx" +ARQUIVO_CADASTRO_ORIGINAL = DADOS_DIR / "Modelagem C98 ETA2.xlsx" +ARQUIVO_CATALOGO_OIS = DADOS_DIR / "catalogo_ois.xlsx" +ARQUIVO_INDISPONIBILIDADES = DADOS_DIR / "indisponibilidades_2026.xlsx" +ARQUIVO_HISTORICO = DADOS_DIR / "historico_horas_voadas.csv" +ARQUIVO_QUADRO_VOO_2025 = DADOS_DIR / "Quadro de Voo 2025 (2).xlsx" +ARQUIVO_VOOS_2025 = DADOS_DIR / "validacao" / "voos_2025.csv" + +LOG_EXECUCAO = LOGS_DIR / "execucao.log" + +AERONAVES = ["C98", "C97", "C95"] +META_HORAS_PADRAO = 50.0 +VALIDACAO_TEMPO_LIMITE_SEGUNDOS = 180.0 +VALIDACAO_GAP_RELATIVO = 0.05 + +PESOS_OBJETIVO = { + "rota_acionada": 100000, + "missao_local": 1000, + "sobreaviso": 0, + "meta_50": 100, + "meta_110": 100, + "beneficio_paop": 10, + "custo_financeiro": -0.05, +} + +ABAS = { + "cadastro": "BANCO DE DADOS 2026", + "catalogo_ois": "catalogo_ois", + "voos_2025": "VOOS", + "relatorio_diario": "ESCALA DIARIA", +} + +ARQUIVOS_ENTRADA_OBRIGATORIOS = [ + ARQUIVO_CATALOGO_OIS, + ARQUIVO_INDISPONIBILIDADES, + ARQUIVO_PARAMETROS, +] + + +def garantir_diretorios() -> None: + """Cria diretorios de saida e apoio usados pelos scripts.""" + for path in [RESULTADOS_DIR, VALIDACAO_DIR, LOGS_DIR, TEMP_DIR, ARQUIVO_VOOS_2025.parent]: + path.mkdir(parents=True, exist_ok=True) diff --git a/scripts/02_io_utils.py b/scripts/02_io_utils.py new file mode 100644 index 0000000..42d575e --- /dev/null +++ b/scripts/02_io_utils.py @@ -0,0 +1,85 @@ +"""Utilitarios de entrada, saida e log dos scripts. + +Entradas: + Caminhos definidos em 01_config.py. + +Saidas: + Validacoes de arquivos, diretorios criados, logs em logs/execucao.log e + funcoes pequenas para exportacao. + +Papel no pipeline: + Padroniza mensagens de erro e evita repeticao de codigo de filesystem. +""" + +from __future__ import annotations + +import importlib +import logging +import sys +from datetime import datetime +from pathlib import Path +from typing import Iterable + +import pandas as pd + +config = importlib.import_module("01_config") + + +def configurar_ambiente() -> None: + """Garante diretorios e deixa src/ importavel para execucoes pelo VS Code.""" + config.garantir_diretorios() + project_root = str(config.PROJECT_ROOT) + if project_root not in sys.path: + sys.path.insert(0, project_root) + + +def configurar_logger(nome: str = "planejador_missao") -> logging.Logger: + """Cria logger simples em arquivo e terminal.""" + configurar_ambiente() + logger = logging.getLogger(nome) + logger.setLevel(logging.INFO) + logger.handlers.clear() + + formato = logging.Formatter("%(asctime)s | %(levelname)s | %(message)s") + file_handler = logging.FileHandler(config.LOG_EXECUCAO, encoding="utf-8") + file_handler.setFormatter(formato) + stream_handler = logging.StreamHandler() + stream_handler.setFormatter(logging.Formatter("%(message)s")) + logger.addHandler(file_handler) + logger.addHandler(stream_handler) + logger.info("Execucao iniciada em %s", datetime.now().strftime("%Y-%m-%d %H:%M:%S")) + return logger + + +def exigir_arquivos(paths: Iterable[Path]) -> None: + """Interrompe a execucao com mensagem clara se algum arquivo faltar.""" + ausentes = [path for path in paths if not path.exists()] + if ausentes: + lista = "\n".join(f"- {path}" for path in ausentes) + raise FileNotFoundError(f"Erro: arquivos obrigatorios ausentes:\n{lista}") + + +def exigir_colunas(df: pd.DataFrame, colunas: Iterable[str], origem: str) -> None: + """Valida colunas obrigatorias de uma tabela carregada.""" + faltantes = [col for col in colunas if col not in df.columns] + if faltantes: + raise ValueError( + f"Erro: coluna obrigatoria '{faltantes[0]}' nao encontrada em {origem}. " + "Verifique o arquivo de entrada." + ) + + +def exportar_excel(path: Path, abas: dict[str, pd.DataFrame]) -> Path: + """Exporta um workbook Excel com uma aba por DataFrame.""" + path.parent.mkdir(parents=True, exist_ok=True) + with pd.ExcelWriter(path, engine="openpyxl") as writer: + for nome, df in abas.items(): + df.to_excel(writer, sheet_name=nome[:31], index=False) + return path + + +def exportar_csv(path: Path, df: pd.DataFrame) -> Path: + """Exporta CSV garantindo existencia do diretorio.""" + path.parent.mkdir(parents=True, exist_ok=True) + df.to_csv(path, index=False) + return path diff --git a/scripts/03_preparar_dados.py b/scripts/03_preparar_dados.py new file mode 100644 index 0000000..17b1798 --- /dev/null +++ b/scripts/03_preparar_dados.py @@ -0,0 +1,73 @@ +"""Preparacao das bases operacionais do Planejador Missao. + +Entradas: + Planilhas e CSVs em dados/: cadastro, catalogo de OIs, indisponibilidades, + progresso, historico, aeronaves, tripulantes disponiveis e rotas acionadas. + +Saidas: + Dicionario com DataFrames normalizados para geracao de colunas candidatas. + +Papel no pipeline: + Concentra a leitura e a preparacao antes do MILP, mantendo rastreavel quais + arquivos alimentam cada execucao. +""" + +from __future__ import annotations + +import importlib +from pathlib import Path + +config = importlib.import_module("01_config") +io_utils = importlib.import_module("02_io_utils") + + +def carregar_bases(base_dir: Path = config.PROJECT_ROOT) -> dict: + """Le e normaliza todas as bases necessarias ao planejamento diario.""" + io_utils.configurar_ambiente() + io_utils.exigir_arquivos(config.ARQUIVOS_ENTRADA_OBRIGATORIOS) + + from src.planejador_missao.data_io import ( + carregar_aeronaves, + carregar_cadastro, + carregar_catalogo_ois, + carregar_historico, + carregar_indisponibilidades, + carregar_parametros, + carregar_progresso_ois, + carregar_rotas, + carregar_tripulantes_disponiveis, + ) + from src.planejador_missao.rules import combinar_progresso_com_historico + + parametros = carregar_parametros(base_dir) + historico = carregar_historico(base_dir) + cadastro = carregar_cadastro(base_dir, historico) + catalogo = carregar_catalogo_ois(base_dir) + indisponibilidades = carregar_indisponibilidades(base_dir) + progresso = combinar_progresso_com_historico(carregar_progresso_ois(base_dir), historico, catalogo) + + return { + "parametros": parametros, + "historico": historico, + "cadastro": cadastro, + "catalogo": catalogo, + "indisponibilidades": indisponibilidades, + "progresso": progresso, + "aeronaves": carregar_aeronaves(base_dir), + "disponiveis": carregar_tripulantes_disponiveis(base_dir), + "rotas": carregar_rotas(base_dir), + } + + +def main() -> None: + """Executa uma leitura de diagnostico das bases.""" + logger = io_utils.configurar_logger("preparar_dados") + bases = carregar_bases() + logger.info("Tripulantes no cadastro: %s", len(bases["cadastro"])) + logger.info("Tripulantes disponiveis: %s", len(bases["disponiveis"])) + logger.info("Aeronaves informadas: %s", len(bases["aeronaves"])) + logger.info("Rotas acionadas: %s", len(bases["rotas"])) + + +if __name__ == "__main__": + main() diff --git a/scripts/04_gerar_colunas_candidatas.py b/scripts/04_gerar_colunas_candidatas.py new file mode 100644 index 0000000..e480e95 --- /dev/null +++ b/scripts/04_gerar_colunas_candidatas.py @@ -0,0 +1,56 @@ +"""Geracao das colunas candidatas viaveis. + +Entradas: + Bases preparadas por 03_preparar_dados.py. + +Saidas: + DataFrame em que cada linha representa uma escala possivel: rota acionada, + missao local ou sobreaviso, com dupla, aeronave, score, custo e metadados. + +Papel no pipeline: + Materializa o espaco de decisoes do MILP. A compatibilidade de aeronave, + indisponibilidade, qualificacao e regra de instrucao ja sao filtradas aqui. +""" + +from __future__ import annotations + +import importlib + +preparar = importlib.import_module("03_preparar_dados") +io_utils = importlib.import_module("02_io_utils") + + +def gerar_colunas_do_dia(bases: dict): + """Gera colunas candidatas para a data e parametros informados nas bases.""" + from src.planejador_missao.candidates import gerar_colunas + + parametros = bases["parametros"] + # Cada coluna candidata equivale a uma variavel x_j do MILP. + # As regras de dupla, qualificacao, instrucao e indisponibilidade sao + # aplicadas antes do solver para reduzir o modelo a alternativas viaveis. + return gerar_colunas( + parametros["data_planejamento"], + bases["rotas"], + bases["aeronaves"], + bases["cadastro"], + bases["indisponibilidades"], + bases["progresso"], + bases["catalogo"], + bases["disponiveis"], + parametros["criterio_missao"], + parametros["criterio_sbv"], + ) + + +def main() -> None: + """Executa diagnostico da geracao de colunas.""" + logger = io_utils.configurar_logger("gerar_colunas") + bases = preparar.carregar_bases() + colunas = gerar_colunas_do_dia(bases) + logger.info("Colunas candidatas geradas: %s", len(colunas)) + if colunas.empty: + logger.warning("Nenhuma coluna candidata viavel foi gerada.") + + +if __name__ == "__main__": + main() diff --git a/scripts/05_modelo_milp.py b/scripts/05_modelo_milp.py new file mode 100644 index 0000000..ad6d80c --- /dev/null +++ b/scripts/05_modelo_milp.py @@ -0,0 +1,58 @@ +"""Modelo MILP do Planejador Missao usando scipy.optimize.milp/HiGHS. + +Entradas: + Colunas candidatas viaveis geradas por 04_gerar_colunas_candidatas.py. + +Saidas: + Subconjunto de colunas selecionadas pelo solver, representando a escala + diaria otimizada. + +Metodologia: + Variavel de decisao: x_j em {0,1}, onde cada j representa uma coluna + candidata. Como scipy.optimize.milp minimiza, o modelo minimiza -score_milp, + equivalente a maximizar o score operacional total. + +Restricoes implementadas no motor src.planejador_missao.optimizer: + a) rota acionada obrigatoria coberta exatamente uma vez; + b) cada tripulante aparece no maximo uma vez no dia; + c) cada aeronave recebe cobertura principal por rota acionada ou SBV; + d) aeronave nao executa escalas conflitantes de rota/local no mesmo dia; + e) qualificacao, indisponibilidade e regras de instrucao entram no filtro de + colunas candidatas, logo colunas inviaveis nao chegam ao MILP; + f) regras especificas de rota, missao local e SBV sao preservadas por tipo. +""" + +from __future__ import annotations + +import importlib + +gerador = importlib.import_module("04_gerar_colunas_candidatas") +io_utils = importlib.import_module("02_io_utils") +preparar = importlib.import_module("03_preparar_dados") + + +def resolver_escala(colunas, rotas, aeronaves): + """Resolve o MILP e retorna as colunas escolhidas.""" + from src.planejador_missao.optimizer import resolver_milp + + if colunas.empty: + raise RuntimeError("Erro: nao ha colunas candidatas viaveis para montar o MILP.") + + # O resolvedor cria x_j binario para cada linha de colunas. + # A funcao objetivo usa -score_milp porque o HiGHS via SciPy resolve + # minimizacao; isso preserva a interpretacao de maximizar score. + return resolver_milp(colunas, rotas, aeronaves) + + +def main() -> None: + """Executa o MILP em modo diagnostico.""" + logger = io_utils.configurar_logger("modelo_milp") + bases = preparar.carregar_bases() + colunas = gerador.gerar_colunas_do_dia(bases) + solucao = resolver_escala(colunas, bases["rotas"], bases["aeronaves"]) + logger.info("Colunas candidatas: %s", len(colunas)) + logger.info("Escalas selecionadas: %s", len(solucao)) + + +if __name__ == "__main__": + main() diff --git a/scripts/06_exportar_resultados.py b/scripts/06_exportar_resultados.py new file mode 100644 index 0000000..02ba2f1 --- /dev/null +++ b/scripts/06_exportar_resultados.py @@ -0,0 +1,38 @@ +"""Exportacao dos resultados do planejamento diario. + +Entradas: + Solucao do MILP, catalogo de OIs e data de planejamento. + +Saidas: + Planilha em resultados/planejamento_diario_YYYY-MM-DD.xlsx e registros de + historico operacional para atualizacao do acumulado. + +Papel no pipeline: + Converte a solucao matematica em artefatos utilizaveis pelo esquadrao. +""" + +from __future__ import annotations + +import importlib + +config = importlib.import_module("01_config") +io_utils = importlib.import_module("02_io_utils") + + +def exportar_planejamento(data_planejamento, solucao, catalogo): + """Gera registros operacionais e planilha Excel da escala.""" + from src.planejador_missao.report import gerar_excel, gerar_registros + + registros = gerar_registros(solucao, catalogo) + arquivo = gerar_excel(config.PROJECT_ROOT, data_planejamento, solucao) + return {"arquivo": arquivo, "registros": registros} + + +def main() -> None: + """Mostra instrucao de uso deste modulo dentro do pipeline.""" + logger = io_utils.configurar_logger("exportar_resultados") + logger.info("Use python scripts/00_main.py para executar o fluxo completo.") + + +if __name__ == "__main__": + main() diff --git a/scripts/07_validacao_2025.py b/scripts/07_validacao_2025.py new file mode 100644 index 0000000..7d3a6ea --- /dev/null +++ b/scripts/07_validacao_2025.py @@ -0,0 +1,257 @@ +"""Validacao retrospectiva com voos reais de 2025. + +Entradas: + dados/Quadro de Voo 2025 (2).xlsx, aba VOOS, ou o CSV ja importado em + dados/validacao/voos_2025.csv. + +Saidas: + resultados/validacao/validacao_2025_resumo.xlsx + resultados/validacao/validacao_2025_detalhada.xlsx + resultados/validacao/validacao_2025_metricas.csv + resultados/validacao/validacao_2025_barras.png + +Papel no pipeline: + Mantem a demanda historica de 2025, gera candidatos compativeis para cada + slot historico e usa MILP para redistribuir tripulantes preservando horas. +""" + +from __future__ import annotations + +import importlib +import re +import struct +import zlib +from datetime import time, timedelta +from pathlib import Path + +import pandas as pd + +config = importlib.import_module("01_config") +io_utils = importlib.import_module("02_io_utils") + +COLUNAS_VALIDACAO = ["slot_id", "data", "aeronave", "tipo_escala", "tripulante", "funcao", "oi", "horas_voadas", "sbv"] + + +def _normalizar_texto(valor: object) -> str: + from src.planejador_missao.utils import normalizar_texto + + return normalizar_texto(valor) + + +def _horas(valor: object) -> float: + """Converte TEV da planilha historica para horas decimais.""" + if pd.isna(valor): + return 0.0 + if isinstance(valor, timedelta): + return round(valor.total_seconds() / 3600, 2) + if isinstance(valor, time): + return round(valor.hour + valor.minute / 60 + valor.second / 3600, 2) + if isinstance(valor, str): + texto = valor.strip() + if not texto or texto == "-": + return 0.0 + partes = texto.split(":") + if len(partes) >= 2: + return round(float(partes[0]) + float(partes[1]) / 60, 2) + return float(texto.replace(",", ".")) + if isinstance(valor, (int, float)): + return round(float(valor) * 24, 2) if 0 < float(valor) < 1 else float(valor) + return 0.0 + + +def _aeronave(valor: object) -> str: + """Traduz matricula FAB para familia operacional C95/C97/C98.""" + texto = _normalizar_texto(valor) + match = re.search(r"(\d{4})", texto) + if not match: + return "" + numero = match.group(1) + if numero.startswith("20"): + return "C97" + if numero.startswith("23"): + return "C95" + if numero.startswith("27"): + return "C98" + return "" + + +def _tripulantes(valor: object) -> list[str]: + """Extrai a dupla principal da tripulacao historica.""" + partes = re.split(r"\s*/\s*", str(valor)) + trips = [] + for parte in partes: + texto = _normalizar_texto(parte) + texto = re.sub(r"\([^)]*\)", " ", texto) + texto = re.sub(r"[^A-Z0-9]+", " ", texto).strip() + if texto and texto != "NAN": + trips.append(texto) + return trips[:2] + + +def importar_voos_2025(origem: Path = config.ARQUIVO_QUADRO_VOO_2025, destino: Path = config.ARQUIVO_VOOS_2025) -> pd.DataFrame: + """Importa voos realizados de 2025 da aba VOOS para o CSV de validacao.""" + io_utils.exigir_arquivos([origem]) + raw = pd.read_excel(origem, sheet_name=config.ABAS["voos_2025"], header=None) + linhas = [] + slot_id = 0 + for _, row in raw.iterrows(): + data = pd.to_datetime(row[2], errors="coerce") + if pd.isna(data) or data.year != 2025: + continue + if _normalizar_texto(row[41]) != "REALIZADO": + continue + + aeronave = _aeronave(row[37]) + if not aeronave: + continue + + horas_voadas = _horas(row[30]) + if horas_voadas <= 0: + continue + + trips = _tripulantes(row[8]) + if len(trips) < 2: + continue + + slot_id += 1 + tipo_escala = _normalizar_texto(row[1]) + oi = _normalizar_texto(row[35]) + if not oi or oi == "-": + oi = _normalizar_texto(row[32]) + + for idx, tripulante in enumerate(trips, start=1): + linhas.append( + { + "slot_id": slot_id, + "data": data.date().isoformat(), + "aeronave": aeronave, + "tipo_escala": tipo_escala, + "tripulante": tripulante, + "funcao": "PILOTO" if idx == 1 else "COPILOTO", + "oi": oi, + "horas_voadas": horas_voadas, + "sbv": 0, + } + ) + + df = pd.DataFrame(linhas, columns=COLUNAS_VALIDACAO) + io_utils.exportar_csv(destino, df) + return df + + +def _png_barras(path: Path, metricas: pd.DataFrame) -> Path: + """Gera PNG simples sem depender de bibliotecas graficas externas.""" + width, height = 760, 420 + canvas = bytearray([255, 255, 255] * width * height) + + def rect(x0: int, y0: int, x1: int, y1: int, color: tuple[int, int, int]) -> None: + x0, x1 = max(0, x0), min(width, x1) + y0, y1 = max(0, y0), min(height, y1) + for y in range(y0, y1): + for x in range(x0, x1): + i = (y * width + x) * 3 + canvas[i : i + 3] = bytes(color) + + rect(55, 45, 60, 365, (38, 57, 77)) + rect(55, 360, 705, 365, (38, 57, 77)) + valores = metricas.set_index("cenario")["desvio_padrao"].to_dict() + max_v = max(valores.values()) if valores else 1 + cores = {"real_2025": (154, 52, 18), "otimizado_meta_50": (38, 115, 77)} + xs = {"real_2025": 190, "otimizado_meta_50": 430} + for cenario, valor in valores.items(): + h = int((valor / max_v) * 270) + rect(xs[cenario], 360 - h, xs[cenario] + 110, 360, cores[cenario]) + + raw = b"".join(b"\x00" + canvas[y * width * 3 : (y + 1) * width * 3] for y in range(height)) + png = b"\x89PNG\r\n\x1a\n" + for chunk_type, data in [ + (b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)), + (b"IDAT", zlib.compress(raw, 9)), + (b"IEND", b""), + ]: + png += struct.pack(">I", len(data)) + chunk_type + data + struct.pack(">I", zlib.crc32(chunk_type + data) & 0xFFFFFFFF) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(png) + return path + + +def executar_validacao_2025(importar: bool = True, logger=None) -> dict: + """Executa a validacao completa e exporta os artefatos finais.""" + io_utils.configurar_ambiente() + logger = logger or io_utils.configurar_logger("validacao_2025") + inicio = pd.Timestamp.now() + + if importar or not config.ARQUIVO_VOOS_2025.exists(): + voos_importados = importar_voos_2025() + logger.info("Voos 2025 importados: %s registros", len(voos_importados)) + + from src.planejador_missao.validacao import carregar_voos_2025, otimizar_equalizacao_50 + + voos = carregar_voos_2025(config.ARQUIVO_VOOS_2025) + if voos.empty: + raise RuntimeError("Erro: base de validacao 2025 vazia. Verifique dados/validacao/voos_2025.csv.") + if (voos["horas_voadas"] < 0).any(): + raise RuntimeError("Erro: foram encontradas horas voadas negativas na validacao 2025.") + + resultado = otimizar_equalizacao_50( + config.PROJECT_ROOT, + voos, + meta_horas=config.META_HORAS_PADRAO, + tempo_limite_segundos=config.VALIDACAO_TEMPO_LIMITE_SEGUNDOS, + gap_relativo=config.VALIDACAO_GAP_RELATIVO, + ) + metricas = resultado["metricas"] + total_real = float(metricas.loc[metricas["cenario"] == "real_2025", "horas_total"].iloc[0]) + total_otim = float(metricas.loc[metricas["cenario"] == "otimizado_meta_50", "horas_total"].iloc[0]) + if abs(total_real - total_otim) > 0.01: + logger.warning("Horas nao preservadas: real=%s otimizado=%s", total_real, total_otim) + + resumo_path = config.VALIDACAO_DIR / "validacao_2025_resumo.xlsx" + detalhada_path = config.VALIDACAO_DIR / "validacao_2025_detalhada.xlsx" + metricas_path = config.VALIDACAO_DIR / "validacao_2025_metricas.csv" + grafico_path = config.VALIDACAO_DIR / "validacao_2025_barras.png" + + solver_df = pd.DataFrame([resultado.get("status_solver", {})]) + io_utils.exportar_excel(resumo_path, {"solver": solver_df, "metricas": metricas}) + io_utils.exportar_excel( + detalhada_path, + { + "solver": solver_df, + "metricas": metricas, + "comparativo_trips": resultado["comparativo"], + "voos_2025_slots": resultado["slots"], + "escala_otimizada": resultado["otimizados"], + }, + ) + io_utils.exportar_csv(metricas_path, metricas) + _png_barras(grafico_path, metricas) + + duracao = (pd.Timestamp.now() - inicio).total_seconds() + logger.info("Slots historicos avaliados: %s", len(resultado["slots"])) + logger.info("Alocacoes otimizadas: %s", len(resultado["otimizados"])) + logger.info("Tripulantes comparados: %s", len(resultado["comparativo"])) + logger.info("Status solver: %s", resultado["status_solver"]["mensagem"]) + logger.info("Tempo de validacao: %.1f s", duracao) + logger.info("Arquivos exportados: %s | %s | %s | %s", resumo_path, detalhada_path, metricas_path, grafico_path) + + return { + "resultado": resultado, + "arquivos": [resumo_path, detalhada_path, metricas_path, grafico_path], + "duracao_segundos": duracao, + } + + +def main() -> None: + """Ponto de entrada da validacao 2025.""" + logger = io_utils.configurar_logger("validacao_2025") + saida = executar_validacao_2025(importar=True, logger=logger) + metricas = saida["resultado"]["metricas"] + print("\n=== Validacao 2025 concluida ===") + print(metricas.to_string(index=False)) + print("\nArquivos gerados:") + for path in saida["arquivos"]: + print(f"- {path}") + + +if __name__ == "__main__": + main() diff --git a/scripts/08_app_utils.py b/scripts/08_app_utils.py new file mode 100644 index 0000000..c117b18 --- /dev/null +++ b/scripts/08_app_utils.py @@ -0,0 +1,44 @@ +"""Funcoes auxiliares da interface web local. + +Entradas: + Estrutura padrao do projeto e web_app.py. + +Saidas: + Inicializacao local da interface em http://127.0.0.1:8050 quando chamado + diretamente. + +Papel no pipeline: + Facilita acesso ao app Python com interface web local. +""" + +from __future__ import annotations + +import importlib +import subprocess +import sys + +config = importlib.import_module("01_config") +io_utils = importlib.import_module("02_io_utils") + + +def comando_python_app() -> list[str]: + """Retorna comando portavel para iniciar a interface web local.""" + pythonw = config.PROJECT_ROOT / ".venv" / "Scripts" / "pythonw.exe" + python = config.PROJECT_ROOT / ".venv" / "Scripts" / "python.exe" + exe = pythonw if pythonw.exists() else python if python.exists() else sys.executable + return [str(exe), str(config.PROJECT_ROOT / "web_app.py")] + + +def abrir_app() -> None: + """Inicia o app em segundo plano sem janela de console quando possivel.""" + io_utils.configurar_ambiente() + subprocess.Popen(comando_python_app(), cwd=config.PROJECT_ROOT) + + +def main() -> None: + abrir_app() + print("Interface web local iniciada em http://127.0.0.1:8050") + + +if __name__ == "__main__": + main() diff --git a/scripts/09_testar_instalacao.py b/scripts/09_testar_instalacao.py new file mode 100644 index 0000000..4dc2aed --- /dev/null +++ b/scripts/09_testar_instalacao.py @@ -0,0 +1,63 @@ +"""Teste rapido de instalacao do Planejador Missao. + +Entradas: + Ambiente Python atual, dependencias instaladas e arquivos em dados/. + +Saidas: + Diagnostico amigavel no terminal e log em logs/execucao.log. + +Papel no pipeline: + Permite verificar rapidamente se o computador esta pronto para executar o + planejador e o solver HiGHS/scipy.optimize.milp. +""" + +from __future__ import annotations + +import importlib +import sys + +import numpy as np +from scipy.optimize import Bounds, LinearConstraint, milp + +config = importlib.import_module("01_config") +io_utils = importlib.import_module("02_io_utils") + + +def testar_solver_minimo() -> None: + """Resolve um MILP minimo para comprovar que scipy.optimize.milp funciona.""" + c = np.array([-1.0, -2.0]) + integrality = np.ones(2) + bounds = Bounds([0, 0], [1, 1]) + constraints = [LinearConstraint([[1, 1]], -np.inf, 1)] + result = milp(c=c, integrality=integrality, bounds=bounds, constraints=constraints) + if not result.success: + raise RuntimeError(f"Erro: teste minimo do solver falhou: {result.message}") + + +def main() -> None: + """Executa todas as verificacoes de ambiente.""" + logger = io_utils.configurar_logger("teste_instalacao") + print(f"Python encontrado: {sys.version.split()[0]}") + + for pacote in ["pandas", "numpy", "openpyxl", "scipy"]: + importlib.import_module(pacote) + print(f"Biblioteca OK: {pacote}") + + config.garantir_diretorios() + for diretorio in [config.DADOS_DIR, config.RESULTADOS_DIR, config.SRC_DIR, config.LOGS_DIR, config.TEMP_DIR]: + if not diretorio.exists(): + raise RuntimeError(f"Erro: diretorio obrigatorio ausente: {diretorio}") + print(f"Diretorio OK: {diretorio.name}") + + cadastro = config.ARQUIVO_CADASTRO_LOCAL if config.ARQUIVO_CADASTRO_LOCAL.exists() else config.ARQUIVO_CADASTRO_ORIGINAL + io_utils.exigir_arquivos([cadastro, *config.ARQUIVOS_ENTRADA_OBRIGATORIOS]) + print("Arquivos de entrada OK") + + testar_solver_minimo() + print("Solver scipy.optimize.milp/HiGHS OK") + logger.info("Teste de instalacao concluido com sucesso.") + print("Ambiente OK para executar o Planejador Missao.") + + +if __name__ == "__main__": + main() diff --git a/scripts/_arquivados/importar_quadro_voo_2025.py b/scripts/_arquivados/importar_quadro_voo_2025.py new file mode 100644 index 0000000..0dddc99 --- /dev/null +++ b/scripts/_arquivados/importar_quadro_voo_2025.py @@ -0,0 +1,136 @@ +"""Importa a aba VOOS do Quadro de Voo 2025 para a base de validacao.""" + +from __future__ import annotations + +import argparse +import re +import sys +from datetime import time, timedelta +from pathlib import Path + +import pandas as pd + +BASE_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BASE_DIR)) + +from src.planejador_missao.utils import normalizar_texto + + +COLUNAS_VALIDACAO = ["slot_id", "data", "aeronave", "tipo_escala", "tripulante", "funcao", "oi", "horas_voadas", "sbv"] + + +def _horas(valor: object) -> float: + if pd.isna(valor): + return 0.0 + if isinstance(valor, timedelta): + return round(valor.total_seconds() / 3600, 2) + if isinstance(valor, time): + return round(valor.hour + valor.minute / 60 + valor.second / 3600, 2) + if isinstance(valor, str): + texto = valor.strip() + if not texto or texto == "-": + return 0.0 + partes = texto.split(":") + if len(partes) >= 2: + return round(float(partes[0]) + float(partes[1]) / 60, 2) + return float(texto.replace(",", ".")) + if isinstance(valor, (int, float)): + return round(float(valor) * 24, 2) if 0 < float(valor) < 1 else float(valor) + return 0.0 + + +def _aeronave(valor: object) -> str: + texto = normalizar_texto(valor) + match = re.search(r"(\d{4})", texto) + if not match: + return "" + numero = match.group(1) + if numero.startswith("20"): + return "C97" + if numero.startswith("23"): + return "C95" + if numero.startswith("27"): + return "C98" + return "" + + +def _tripulantes(valor: object) -> list[str]: + partes = re.split(r"\s*/\s*", str(valor)) + trips = [] + for parte in partes: + texto = normalizar_texto(parte) + texto = re.sub(r"\([^)]*\)", " ", texto) + texto = re.sub(r"[^A-Z0-9]+", " ", texto).strip() + if texto and texto != "NAN": + trips.append(texto) + return trips[:2] + + +def importar(origem: Path, destino: Path) -> pd.DataFrame: + raw = pd.read_excel(origem, sheet_name="VOOS", header=None) + linhas = [] + slot_id = 0 + for _, row in raw.iterrows(): + data = pd.to_datetime(row[2], errors="coerce") + if pd.isna(data) or data.year != 2025: + continue + if normalizar_texto(row[41]) != "REALIZADO": + continue + + aeronave = _aeronave(row[37]) + if not aeronave: + continue + + slot_id += 1 + tipo_escala = normalizar_texto(row[1]) + horas_voadas = _horas(row[30]) + oi = normalizar_texto(row[35]) + if not oi or oi == "-": + oi = normalizar_texto(row[32]) + + for idx, tripulante in enumerate(_tripulantes(row[8]), start=1): + linhas.append( + { + "slot_id": slot_id, + "data": data.date().isoformat(), + "aeronave": aeronave, + "tipo_escala": tipo_escala, + "tripulante": tripulante, + "funcao": "PILOTO" if idx == 1 else "COPILOTO", + "oi": oi, + "horas_voadas": horas_voadas, + "sbv": 0, + } + ) + + df = pd.DataFrame(linhas, columns=COLUNAS_VALIDACAO) + destino.parent.mkdir(parents=True, exist_ok=True) + df.to_csv(destino, index=False) + return df + + +def main() -> None: + parser = argparse.ArgumentParser(description="Importa voos realizados de 2025 para a validacao.") + parser.add_argument( + "--origem", + type=Path, + default=BASE_DIR / "dados" / "Quadro de Voo 2025 (2).xlsx", + help="Planilha com aba VOOS.", + ) + parser.add_argument( + "--destino", + type=Path, + default=BASE_DIR / "dados" / "validacao" / "voos_2025.csv", + help="CSV de validacao gerado.", + ) + args = parser.parse_args() + + df = importar(args.origem, args.destino) + print(f"Arquivo gerado: {args.destino}") + print(f"Registros exportados: {len(df)}") + print(f"Voos/slots exportados: {df['slot_id'].nunique() if not df.empty else 0}") + print(f"Tripulantes exportados: {df['tripulante'].nunique() if not df.empty else 0}") + + +if __name__ == "__main__": + main() diff --git a/scripts/_arquivados/validar_2025.py b/scripts/_arquivados/validar_2025.py new file mode 100644 index 0000000..466ff7f --- /dev/null +++ b/scripts/_arquivados/validar_2025.py @@ -0,0 +1,57 @@ +"""Executa a validacao retrospectiva com voos de 2025.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(BASE_DIR)) + +from src.planejador_missao.validacao import ( + carregar_voos_2025, + criar_modelo_voos_2025, + otimizar_equalizacao_50, + salvar_relatorio_validacao, +) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Valida o planejador com voos historicos de 2025.") + parser.add_argument("--meta", type=float, default=50.0, help="Meta de horas usada na equalizacao.") + parser.add_argument("--tempo-limite", type=float, default=180.0, help="Tempo limite do resolvedor MILP, em segundos.") + parser.add_argument("--gap", type=float, default=0.05, help="Gap relativo aceitavel para encerrar o MILP.") + args = parser.parse_args() + + entrada = BASE_DIR / "dados" / "validacao" / "voos_2025.csv" + criar_modelo_voos_2025(entrada) + + voos = carregar_voos_2025(entrada) + if voos.empty: + print("Base de validacao ainda vazia.") + print(f"Preencha o arquivo: {entrada}") + print("Colunas esperadas: data,aeronave,tipo_escala,tripulante,funcao,oi,horas_voadas,sbv") + return + + resultado = otimizar_equalizacao_50( + BASE_DIR, + voos, + meta_horas=args.meta, + tempo_limite_segundos=args.tempo_limite, + gap_relativo=args.gap, + ) + arquivo = salvar_relatorio_validacao(BASE_DIR, resultado, meta_horas=args.meta) + + print("\n=== Validacao 2025 concluida ===") + print(f"Meta de equalizacao: {args.meta:g} horas") + print(f"Voos/slots avaliados: {len(resultado['slots'])}") + print(f"Tripulantes comparados: {len(resultado['comparativo'])}") + print(f"Relatorio: {arquivo}") + print(f"Solver: {resultado['status_solver']['mensagem']}") + print("\nMetricas:") + print(resultado["metricas"].to_string(index=False)) + + +if __name__ == "__main__": + main() diff --git a/src/planejador_missao/__init__.py b/src/planejador_missao/__init__.py new file mode 100644 index 0000000..ef8516f --- /dev/null +++ b/src/planejador_missao/__init__.py @@ -0,0 +1,2 @@ +"""Planejador de missao e sobreaviso por MILP.""" + diff --git a/src/planejador_missao/candidates.py b/src/planejador_missao/candidates.py new file mode 100644 index 0000000..9c5bbb5 --- /dev/null +++ b/src/planejador_missao/candidates.py @@ -0,0 +1,216 @@ +"""Geracao de colunas candidatas para o modelo MILP.""" + +from __future__ import annotations + +from datetime import date +from itertools import combinations + +import pandas as pd + +from .data_io import AERONAVES +from .rules import eh_instrutor_adaptado, piloto_disponivel, qualificacao_operacional +from .utils import normalizar_texto, parse_horas + + +def proxima_oi(tripulante: str, aeronave: str, subprograma: str, progresso: pd.DataFrame, catalogo: pd.DataFrame) -> dict | None: + ois = catalogo[(catalogo["aeronave"] == aeronave) & (catalogo["subprograma"] == subprograma)].sort_values("ordem") + if ois.empty: + return None + concluidas = set( + progresso[ + (progresso["tripulante"] == tripulante) + & (progresso["aeronave"] == aeronave) + & (progresso["subprograma"] == subprograma) + & (progresso["concluida"] == True) + ]["oi"] + ) + pendentes = ois[~ois["oi"].isin(concluidas)] + if pendentes.empty: + return None + row = pendentes.iloc[0].to_dict() + row["tripulante"] = tripulante + return row + + +def candidatos_missao(data_missao: date, aeronave: str, tipo: str, cadastro: pd.DataFrame, indisponibilidades: pd.DataFrame, progresso: pd.DataFrame, catalogo: pd.DataFrame, disponiveis: pd.DataFrame) -> pd.DataFrame: + qt_col = f"qt_{aeronave.lower()}" + permitidos = set(disponiveis["tripulante"]) + linhas = [] + for _, trip in cadastro[cadastro[qt_col]].iterrows(): + trigrama = trip["tripulante"] + if trigrama not in permitidos or not piloto_disponivel(trigrama, data_missao, indisponibilidades): + continue + subprogramas = [sp for sp in str(trip[f"subprograma_{aeronave.lower()}"]).split() if sp.startswith("SP")] + for sp in subprogramas: + oi = proxima_oi(trigrama, aeronave, sp, progresso, catalogo) + if not oi: + continue + if tipo != "TODAS" and oi["tipo_missao"] != tipo: + continue + oi.update( + patente=trip.get("patente", ""), + qt=trip.get("qt", ""), + qualificacao=trip.get("qualificacao", ""), + horas_totais_2026=trip.get("horas_totais_2026", 0), + horas_realizadas_2026=trip.get("horas_realizadas_2026", 0), + sbv_realizados_2026=trip.get("sbv_realizados_2026", 0), + soldo=trip.get("soldo", 0), + ) + linhas.append(oi) + if not linhas: + return pd.DataFrame() + df = pd.DataFrame(linhas) + df["horas_faltantes_paop"] = df["horas_totais_2026"] - df["horas_realizadas_2026"] + df["prioridade_tipo"] = df["tipo_missao"].map({"LOCAL": 1, "ROTA": 2}).fillna(3) + return df.sort_values(["horas_faltantes_paop", "prioridade_tipo", "aeronave", "subprograma", "ordem"], ascending=[False, True, True, True, True]) + + +def aplicar_prioridades(df: pd.DataFrame) -> pd.DataFrame: + df = df.copy() + df["bloqueado"] = False + df["prioridade_paop"] = 100.0 + df.loc[(df["aeronave"] == "C98") & (df["tripulante"].isin(["DOG", "MCH"])) & (df["subprograma"] == "SPQE-3"), "prioridade_paop"] = 1 + df.loc[(df["aeronave"] == "C98") & (df["tripulante"].isin(["DOG", "MCH"])) & (df["subprograma"] != "SPQE-3"), "prioridade_paop"] = 50 + df.loc[(df["aeronave"] == "C98") & (df["tripulante"].isin(["SCA", "DIL"])) & (df["subprograma"] == "SPMO-1"), "prioridade_paop"] = 1 + df.loc[(df["aeronave"] == "C98") & (df["tripulante"].isin(["SCA", "DIL"])) & (df["subprograma"] != "SPMO-1"), "prioridade_paop"] = 60 + df.loc[(df["aeronave"] == "C98") & (df["subprograma"] == "SPFO-2") & (df["horas_realizadas_2026"] < 50), "bloqueado"] = True + return df.sort_values(["bloqueado", "prioridade_paop", "horas_faltantes_paop"], ascending=[True, True, False]) + + +def score_dupla(p1, p2, h1, h2, s1, s2, sbv1, sbv2, criterio: str) -> float: + beneficio = (0 if pd.isna(p1) else 100 / p1) + (0 if pd.isna(p2) else 100 / p2) + horas_50 = (h1 < 50) + (h2 < 50) + horas_110 = (h1 < 110) + (h2 < 110) + custo = (0 if pd.isna(s1) else s1) + (0 if pd.isna(s2) else s2) + sbv = (0 if pd.isna(sbv1) else sbv1) + (0 if pd.isna(sbv2) else sbv2) + dif_sbv = abs((0 if pd.isna(sbv1) else sbv1) - (0 if pd.isna(sbv2) else sbv2)) + if criterio == "menor_custo": + return -custo + 0.2 * beneficio + 20 * horas_50 + 10 * horas_110 + if criterio == "meta_110": + return 100 * horas_110 + 5 * beneficio - 0.05 * custo + if criterio == "equalizar_quadrinhos": + return -sbv - 0.5 * dif_sbv + 0.1 * beneficio + return 100 * horas_50 + 10 * beneficio - 0.05 * custo + + +def instrutores_disponiveis(data_missao: date, aeronave: str, cadastro: pd.DataFrame, indisponibilidades: pd.DataFrame, progresso: pd.DataFrame, catalogo: pd.DataFrame, disponiveis: pd.DataFrame) -> pd.DataFrame: + qt_col = f"qt_{aeronave.lower()}" + permitidos = set(disponiveis["tripulante"]) + rows = [] + for _, row in cadastro[cadastro[qt_col]].iterrows(): + trip = row["tripulante"] + if trip in permitidos and piloto_disponivel(trip, data_missao, indisponibilidades) and eh_instrutor_adaptado(trip, aeronave, cadastro, progresso, catalogo): + rows.append(row) + return pd.DataFrame(rows) + + +def gerar_duplas(candidatos: pd.DataFrame, criterio: str, tipo_escala: str, aeronave: str, data_planejamento: date, cadastro: pd.DataFrame, indisponibilidades: pd.DataFrame, progresso: pd.DataFrame, catalogo: pd.DataFrame, disponiveis: pd.DataFrame, rota_id=None, rota=None, observacao="") -> pd.DataFrame: + if candidatos.empty: + return pd.DataFrame() + livres = candidatos[~candidatos["bloqueado"]].drop_duplicates("tripulante") + linhas = [] + if tipo_escala in {"ROTA_ACIONADA", "MISSAO_LOCAL"}: + instrutores = instrutores_disponiveis(data_planejamento, aeronave, cadastro, indisponibilidades, progresso, catalogo, disponiveis) + for _, aluno in livres.iterrows(): + for _, inst in instrutores.iterrows(): + if aluno["tripulante"] == inst["tripulante"]: + continue + linhas.append(_linha_coluna(data_planejamento, tipo_escala, aeronave, aluno, inst, criterio, rota_id, rota, observacao)) + return pd.DataFrame(linhas) + for t1, t2 in combinations(livres["tripulante"].unique(), 2): + info1 = livres[livres["tripulante"] == t1].iloc[0] + info2 = livres[livres["tripulante"] == t2].iloc[0] + linha = _linha_coluna(data_planejamento, tipo_escala, aeronave, info1, info2, criterio, rota_id, rota, observacao) + linha["oi_trip1"] = "" + linha["oi_trip2"] = "" + linhas.append(linha) + return pd.DataFrame(linhas) + + +def _linha_coluna(data_planejamento, tipo_escala, aeronave, info1, info2, criterio, rota_id, rota, observacao): + h2 = info2.get("horas_realizadas_2026", 999) + p2 = info2.get("prioridade_paop", pd.NA) + score = score_dupla(info1.get("prioridade_paop", pd.NA), p2, info1.get("horas_realizadas_2026", 0), h2, info1.get("soldo", 0), info2.get("soldo", 0), info1.get("sbv_realizados_2026", 0), info2.get("sbv_realizados_2026", 0), criterio) + return { + "data": data_planejamento, + "tipo_escala": tipo_escala, + "rota_id": rota_id, + "aeronave": aeronave, + "origem": "" if rota is None else rota.get("origem", ""), + "destino": "" if rota is None else rota.get("destino", ""), + "inicio": pd.NaT if rota is None else rota.get("inicio", pd.NaT), + "fim": pd.NaT if rota is None else rota.get("fim", pd.NaT), + "tev_horas": pd.NA if rota is None else rota.get("tev_horas", pd.NA), + "pernoite_dias": pd.NA if rota is None else rota.get("pernoite_dias", pd.NA), + "rota": "" if rota is None else rota.get("rota", ""), + "trip1": info1["tripulante"], + "trip2": info2["tripulante"], + "oi_trip1": info1.get("oi", ""), + "oi_trip2": "", + "prioridade_paop_1": info1.get("prioridade_paop", pd.NA), + "prioridade_paop_2": p2, + "custo_financeiro": info1.get("soldo", 0) + info2.get("soldo", 0), + "score": score, + "observacao": observacao, + } + + +def gerar_colunas(data_planejamento, rotas, aeronaves, cadastro, indisponibilidades, progresso, catalogo, disponiveis, criterio_missao, criterio_sbv): + livres = aeronaves[(aeronaves["disponivel_sede"]) & (~aeronaves["em_pane"]) & (~aeronaves["em_missao_rota"])]["aeronave"].tolist() + colunas = [] + for rota_id, rota in rotas.iterrows(): + for av in livres: + horas_disp = horas_disponiveis(aeronaves, av) + horas_rota = parse_horas(rota.get("tev_horas")) + if horas_rota is None and pd.notna(rota.get("duracao_horas")): + horas_rota = float(rota.get("duracao_horas")) + if horas_rota is not None and horas_rota > horas_disp: + continue + candidatos = candidatos_missao(data_planejamento, av, "ROTA", cadastro, indisponibilidades, progresso, catalogo, disponiveis) + if len(candidatos) < 2: + candidatos = candidatos_missao(data_planejamento, av, "TODAS", cadastro, indisponibilidades, progresso, catalogo, disponiveis) + candidatos = filtrar_por_horas_aeronave(candidatos, horas_disp) + if candidatos.empty: + continue + colunas.append(gerar_duplas(aplicar_prioridades(candidatos), criterio_missao, "ROTA_ACIONADA", av, data_planejamento, cadastro, indisponibilidades, progresso, catalogo, disponiveis, rota_id=rota_id + 1, rota=rota, observacao="Missao de rota acionada")) + for av in livres: + horas_disp = horas_disponiveis(aeronaves, av) + candidatos_local = candidatos_missao(data_planejamento, av, "LOCAL", cadastro, indisponibilidades, progresso, catalogo, disponiveis) + candidatos_local = filtrar_por_horas_aeronave(candidatos_local, horas_disp) + if not candidatos_local.empty: + colunas.append(gerar_duplas(aplicar_prioridades(candidatos_local), criterio_missao, "MISSAO_LOCAL", av, data_planejamento, cadastro, indisponibilidades, progresso, catalogo, disponiveis, observacao="Missao local planejada para progressao operacional")) + candidatos_sbv = [] + for _, row in cadastro[cadastro[f"qt_{av.lower()}"]].iterrows(): + if row["tripulante"] in set(disponiveis["tripulante"]) and piloto_disponivel(row["tripulante"], data_planejamento, indisponibilidades): + if qualificacao_operacional(row["tripulante"], av, cadastro, progresso, catalogo) != "AL": + r = row.copy() + r["aeronave"] = av + r["subprograma"] = "" + r["oi"] = "" + r["prioridade_paop"] = max(1, 999 - float(r.get("horas_totais_2026", 0) - r.get("horas_realizadas_2026", 0))) + r["bloqueado"] = False + candidatos_sbv.append(r) + if len(candidatos_sbv) >= 2: + colunas.append(gerar_duplas(pd.DataFrame(candidatos_sbv), criterio_sbv, "SBV", av, data_planejamento, cadastro, indisponibilidades, progresso, catalogo, disponiveis, observacao="Sobreaviso para missao de rota")) + if not colunas: + return pd.DataFrame() + df = pd.concat(colunas, ignore_index=True) + bonus = {"ROTA_ACIONADA": 100000, "MISSAO_LOCAL": 1000, "SBV": 0} + df["coluna_id"] = range(1, len(df) + 1) + df["score_milp"] = df["score"] + df["tipo_escala"].map(bonus).fillna(0) + return df + + +def horas_disponiveis(aeronaves: pd.DataFrame, aeronave: str) -> float: + linha = aeronaves[aeronaves["aeronave"] == aeronave] + if linha.empty: + return float("inf") + horas = parse_horas(linha.iloc[0].get("hora_livre")) + return float("inf") if horas is None else horas + + +def filtrar_por_horas_aeronave(candidatos: pd.DataFrame, horas_disp: float) -> pd.DataFrame: + if candidatos.empty or horas_disp == float("inf") or "tev_horas" not in candidatos.columns: + return candidatos + horas_oi = candidatos["tev_horas"].map(parse_horas) + return candidatos[(horas_oi.isna()) | (horas_oi <= horas_disp)] diff --git a/src/planejador_missao/data_io.py b/src/planejador_missao/data_io.py new file mode 100644 index 0000000..2e1e68c --- /dev/null +++ b/src/planejador_missao/data_io.py @@ -0,0 +1,155 @@ +"""Leitura das bases de entrada e escrita do historico operacional.""" + +from __future__ import annotations + +from datetime import date, datetime +from pathlib import Path + +import pandas as pd + +from .utils import clean_columns, normalizar_texto, parse_bool, parse_data, parse_data_hora, read_csv + + +AERONAVES = ["C98", "C97", "C95"] + + +def carregar_parametros(base_dir: Path) -> dict: + default = { + "data_planejamento": "hoje", + "criterio_missao": "meta_50", + "criterio_sbv": "equalizar_quadrinhos", + } + path = base_dir / "dados" / "parametros_missao.csv" + if path.exists(): + df = pd.read_csv(path) + if {"parametro", "valor"}.issubset(df.columns): + for _, row in df.iterrows(): + chave = str(row["parametro"]).strip().lower() + if chave in default: + default[chave] = row["valor"] + default["data_planejamento"] = parse_data(default["data_planejamento"]) + if default["criterio_missao"] == "paop": + default["criterio_missao"] = "meta_50" + return default + + +def carregar_cadastro(base_dir: Path, historico: pd.DataFrame) -> pd.DataFrame: + local = base_dir / "dados" / "Modelagem_C98_ETA2_local.xlsx" + original = base_dir / "dados" / "Modelagem C98 ETA2.xlsx" + arquivo = local if local.exists() else original + df = clean_columns(pd.read_excel(arquivo, sheet_name="BANCO DE DADOS 2026")) + + resumo = pd.DataFrame(columns=["tripulante", "horas_historico", "sbv_historico"]) + if not historico.empty: + resumo = ( + historico.groupby("tripulante", as_index=False) + .agg(horas_historico=("horas_voadas", "sum"), sbv_historico=("sbv", "sum")) + ) + + cadastro = df.rename( + columns={ + "subprograma_c98": "subprograma_c98", + "subprograma_c97": "subprograma_c97", + "subprograma_c95": "subprograma_c95", + } + ).copy() + cadastro["tripulante"] = cadastro["tripulante"].map(normalizar_texto) + cadastro["qt"] = cadastro["qt"].map(normalizar_texto) + cadastro["qualificacao"] = cadastro["qualificacao"].map(normalizar_texto) + for av in AERONAVES: + cadastro[f"qt_{av.lower()}"] = cadastro["qt"].str.contains(av, na=False) + cadastro[f"subprograma_{av.lower()}"] = cadastro[f"subprograma_{av.lower()}"].map(normalizar_texto) + cadastro[f"horas_{av.lower()}"] = pd.to_numeric(cadastro[f"horas_{av.lower()}"], errors="coerce").fillna(0) + cadastro["soldo"] = pd.to_numeric(cadastro["soldo"], errors="coerce").fillna(0) + cadastro["horas_totais_2026"] = pd.to_numeric(cadastro["horas_totais_2026"], errors="coerce").fillna(0) + cadastro = cadastro.merge(resumo, how="left", on="tripulante") + cadastro["horas_realizadas_2026"] = cadastro["horas_historico"].fillna(0) + cadastro["sbv_realizados_2026"] = cadastro["sbv_historico"].fillna(0) + cadastro["disponivel"] = True + return cadastro.drop(columns=["horas_historico", "sbv_historico"], errors="ignore") + + +def carregar_catalogo_ois(base_dir: Path) -> pd.DataFrame: + df = pd.read_excel(base_dir / "dados" / "catalogo_ois.xlsx", sheet_name="catalogo_ois", dtype=str) + df = df.dropna(subset=["aeronave", "subprograma", "oi"]).copy() + for col in ["aeronave", "subprograma", "oi", "esforco", "tipo_missao"]: + df[col] = df[col].map(normalizar_texto) + df["ordem"] = pd.to_numeric(df["ordem"], errors="coerce") + df["eh_piloto"] = df.get("eh_piloto", "VERDADEIRO").fillna("VERDADEIRO").map(parse_bool) + df["conta_hora_voo"] = df["tipo_missao"].isin(["LOCAL", "ROTA"]) + return df[df["eh_piloto"]].sort_values(["aeronave", "subprograma", "ordem"]).reset_index(drop=True) + + +def carregar_indisponibilidades(base_dir: Path) -> pd.DataFrame: + path = base_dir / "dados" / "indisponibilidades_2026.xlsx" + df = pd.read_excel(path) + df["tripulante"] = df["tripulante"].map(normalizar_texto) + df["inicio"] = pd.to_datetime(df["inicio"]).dt.date + df["fim"] = pd.to_datetime(df["fim"]).dt.date + return df + + +def carregar_historico(base_dir: Path) -> pd.DataFrame: + cols = ["data", "aeronave", "tipo_escala", "tripulante", "funcao", "oi", "horas_voadas", "sbv", "origem_registro", "registrado_em"] + df = read_csv(base_dir / "dados" / "historico_horas_voadas.csv", cols) + if df.empty: + return df + df["data"] = pd.to_datetime(df["data"], errors="coerce").dt.date + for col in ["aeronave", "tipo_escala", "tripulante", "funcao", "oi"]: + df[col] = df[col].map(normalizar_texto) + df["horas_voadas"] = pd.to_numeric(df["horas_voadas"], errors="coerce").fillna(0) + df["sbv"] = pd.to_numeric(df["sbv"], errors="coerce").fillna(0) + return df + + +def carregar_progresso_ois(base_dir: Path) -> pd.DataFrame: + path = base_dir / "dados" / "progresso_ois_2026.xlsx" + cols = ["tripulante", "aeronave", "subprograma", "oi", "horas_realizadas", "concluida"] + if not path.exists(): + return pd.DataFrame(columns=cols) + df = pd.read_excel(path) + for col in ["tripulante", "aeronave", "subprograma", "oi"]: + df[col] = df[col].map(normalizar_texto) + df["horas_realizadas"] = pd.to_numeric(df["horas_realizadas"], errors="coerce").fillna(0) + df["concluida"] = df["concluida"].map(parse_bool) + return df[cols] + + +def carregar_aeronaves(base_dir: Path) -> pd.DataFrame: + cols = ["aeronave", "disponivel_sede", "em_pane", "em_missao_rota", "hora_livre"] + df = read_csv(base_dir / "dados" / "aeronaves_disponiveis.csv", cols) + if df.empty: + df = pd.DataFrame({"aeronave": AERONAVES, "disponivel_sede": True, "em_pane": False, "em_missao_rota": False, "hora_livre": "08:00"}) + df["aeronave"] = df["aeronave"].map(normalizar_texto) + for col in ["disponivel_sede", "em_pane", "em_missao_rota"]: + df[col] = df[col].map(parse_bool) + return df + + +def carregar_tripulantes_disponiveis(base_dir: Path) -> pd.DataFrame: + df = read_csv(base_dir / "dados" / "tripulantes_disponiveis.csv", ["tripulante", "disponivel"]) + df["tripulante"] = df["tripulante"].map(normalizar_texto) + df["disponivel"] = df["disponivel"].map(parse_bool) + return df[df["disponivel"]].reset_index(drop=True) + + +def carregar_rotas(base_dir: Path) -> pd.DataFrame: + cols = ["origem", "destino", "inicio", "fim", "tev_horas", "pernoite_dias", "rota"] + df = read_csv(base_dir / "dados" / "rotas_acionadas.csv", cols) + if df.empty: + return df + for col in ["origem", "destino", "rota"]: + df[col] = df[col].map(normalizar_texto) + df["inicio"] = df["inicio"].map(parse_data_hora) + df["fim"] = df["fim"].map(parse_data_hora) + df["tev_horas"] = pd.to_numeric(df["tev_horas"], errors="coerce") + df["pernoite_dias"] = pd.to_numeric(df["pernoite_dias"], errors="coerce").fillna((df["fim"] - df["inicio"]).dt.days).fillna(0) + df["duracao_horas"] = (df["fim"] - df["inicio"]).dt.total_seconds() / 3600 + return df.dropna(subset=["origem", "destino"], how="all").reset_index(drop=True) + + +def salvar_historico(base_dir: Path, registros: pd.DataFrame) -> None: + path = base_dir / "dados" / "historico_horas_voadas.csv" + historico = carregar_historico(base_dir) + atualizado = pd.concat([historico, registros], ignore_index=True) + atualizado.to_csv(path, index=False) diff --git a/src/planejador_missao/main.py b/src/planejador_missao/main.py new file mode 100644 index 0000000..6a9dc83 --- /dev/null +++ b/src/planejador_missao/main.py @@ -0,0 +1,64 @@ +"""Fluxo principal do planejador em Python.""" + +from __future__ import annotations + +from pathlib import Path + +from .candidates import gerar_colunas +from .data_io import ( + carregar_aeronaves, + carregar_cadastro, + carregar_catalogo_ois, + carregar_historico, + carregar_indisponibilidades, + carregar_parametros, + carregar_progresso_ois, + carregar_rotas, + carregar_tripulantes_disponiveis, + salvar_historico, +) +from .optimizer import resolver_milp +from .report import gerar_excel, gerar_registros +from .rules import combinar_progresso_com_historico + + +def executar_planejamento(base_dir: Path) -> dict: + # Bloco 01 - Entrada e estado operacional. + parametros = carregar_parametros(base_dir) + historico = carregar_historico(base_dir) + cadastro = carregar_cadastro(base_dir, historico) + catalogo = carregar_catalogo_ois(base_dir) + indisponibilidades = carregar_indisponibilidades(base_dir) + progresso = combinar_progresso_com_historico(carregar_progresso_ois(base_dir), historico, catalogo) + aeronaves = carregar_aeronaves(base_dir) + disponiveis = carregar_tripulantes_disponiveis(base_dir) + rotas = carregar_rotas(base_dir) + + # Bloco 02 - Geracao das colunas candidatas. + colunas = gerar_colunas( + parametros["data_planejamento"], + rotas, + aeronaves, + cadastro, + indisponibilidades, + progresso, + catalogo, + disponiveis, + parametros["criterio_missao"], + parametros["criterio_sbv"], + ) + + # Bloco 03 - Modelo MILP e selecao final. + solucao = resolver_milp(colunas, rotas, aeronaves) + + # Bloco 04 - Persistencia operacional e relatorio. + registros = gerar_registros(solucao, catalogo) + salvar_historico(base_dir, registros) + arquivo_saida = gerar_excel(base_dir, parametros["data_planejamento"], solucao) + + return { + "data_planejamento": parametros["data_planejamento"], + "arquivo_saida": arquivo_saida, + "total_candidatas": len(colunas), + "total_selecionadas": len(solucao), + } diff --git a/src/planejador_missao/optimizer.py b/src/planejador_missao/optimizer.py new file mode 100644 index 0000000..fae8d56 --- /dev/null +++ b/src/planejador_missao/optimizer.py @@ -0,0 +1,60 @@ +"""Modelo MILP para escolher a combinacao final de escalas.""" + +from __future__ import annotations + +import numpy as np +import pandas as pd +from scipy.optimize import Bounds, LinearConstraint, milp + + +def resolver_milp(colunas: pd.DataFrame, rotas: pd.DataFrame, aeronaves: pd.DataFrame) -> pd.DataFrame: + if colunas.empty: + return colunas + + n = len(colunas) + integrality = np.ones(n) + bounds = Bounds(np.zeros(n), np.ones(n)) + restricoes = [] + + # R1 - Cada tripulante aparece no maximo uma vez. + for trip in sorted(set(colunas["trip1"]).union(set(colunas["trip2"]))): + linha = np.zeros(n) + linha[(colunas["trip1"] == trip) | (colunas["trip2"] == trip)] = 1 + restricoes.append(LinearConstraint(linha, -np.inf, 1)) + + livres = aeronaves[(aeronaves["disponivel_sede"]) & (~aeronaves["em_pane"]) & (~aeronaves["em_missao_rota"])]["aeronave"].unique() + for av in livres: + idx_rota = (colunas["aeronave"] == av) & (colunas["tipo_escala"] == "ROTA_ACIONADA") + idx_local = (colunas["aeronave"] == av) & (colunas["tipo_escala"] == "MISSAO_LOCAL") + idx_sbv = (colunas["aeronave"] == av) & (colunas["tipo_escala"] == "SBV") + if not (idx_rota | idx_sbv).any(): + raise RuntimeError(f"Modelo inviavel: nao ha rota nem SBV candidato para a aeronave {av}.") + linha = np.zeros(n) + linha[idx_rota | idx_sbv] = 1 + restricoes.append(LinearConstraint(linha, 1, 1)) + if idx_rota.any(): + linha = np.zeros(n) + linha[idx_rota] = 1 + restricoes.append(LinearConstraint(linha, -np.inf, 1)) + if idx_local.any(): + linha = np.zeros(n) + linha[idx_local] = 1 + restricoes.append(LinearConstraint(linha, -np.inf, 1)) + if idx_rota.any() and idx_local.any(): + linha = np.zeros(n) + linha[idx_rota | idx_local] = 1 + restricoes.append(LinearConstraint(linha, -np.inf, 1)) + + for rota_id in range(1, len(rotas) + 1): + idx = (colunas["tipo_escala"] == "ROTA_ACIONADA") & (colunas["rota_id"] == rota_id) + if not idx.any(): + raise RuntimeError(f"Modelo inviavel: nao ha coluna candidata para a rota acionada {rota_id}.") + linha = np.zeros(n) + linha[idx] = 1 + restricoes.append(LinearConstraint(linha, 1, 1)) + + resultado = milp(c=-colunas["score_milp"].to_numpy(dtype=float), integrality=integrality, bounds=bounds, constraints=restricoes) + if not resultado.success: + raise RuntimeError(f"MILP nao encontrou solucao viavel: {resultado.message}") + selecionadas = resultado.x > 0.5 + return colunas.loc[selecionadas].reset_index(drop=True) diff --git a/src/planejador_missao/quadrinhos.py b/src/planejador_missao/quadrinhos.py new file mode 100644 index 0000000..88785d4 --- /dev/null +++ b/src/planejador_missao/quadrinhos.py @@ -0,0 +1,100 @@ +"""Resumo operacional por tripulante para a aba de quadrinhos.""" + +from __future__ import annotations + +from pathlib import Path + +import pandas as pd + +from .candidates import proxima_oi +from .data_io import AERONAVES, carregar_cadastro, carregar_catalogo_ois, carregar_historico, carregar_progresso_ois +from .rules import combinar_progresso_com_historico, qualificacao_operacional +from .utils import normalizar_texto + + +def carregar_quadrinho_manual(base_dir: Path) -> pd.DataFrame: + path = base_dir / "dados" / "quadrinho_operacional_manual.csv" + cols = [ + "tripulante", + "aeronave", + "qualificacao_manual", + "horas_voadas_manual", + "sbv_manual", + "ultima_data_voo_manual", + "observacao", + ] + if not path.exists(): + return pd.DataFrame(columns=cols) + df = pd.read_csv(path) + for col in cols: + if col not in df.columns: + df[col] = pd.NA + df = df[cols].copy() + df["tripulante"] = df["tripulante"].map(normalizar_texto) + df["aeronave"] = df["aeronave"].map(normalizar_texto) + df["horas_voadas_manual"] = pd.to_numeric(df["horas_voadas_manual"], errors="coerce").fillna(0) + df["sbv_manual"] = pd.to_numeric(df["sbv_manual"], errors="coerce").fillna(0) + return df + + +def gerar_quadrinhos(base_dir: Path) -> pd.DataFrame: + historico = carregar_historico(base_dir) + cadastro = carregar_cadastro(base_dir, historico) + catalogo = carregar_catalogo_ois(base_dir) + progresso = combinar_progresso_com_historico(carregar_progresso_ois(base_dir), historico, catalogo) + manual = carregar_quadrinho_manual(base_dir) + + linhas = [] + for _, trip in cadastro.sort_values("tripulante").iterrows(): + trigrama = trip["tripulante"] + sbv_hist = float(trip.get("sbv_realizados_2026", 0) or 0) + horas_hist = float(trip.get("horas_realizadas_2026", 0) or 0) + for av in AERONAVES: + if not bool(trip.get(f"qt_{av.lower()}", False)): + continue + manual_row = manual[(manual["tripulante"] == trigrama) & (manual["aeronave"] == av)] + horas_manual = 0.0 if manual_row.empty else float(manual_row.iloc[0].get("horas_voadas_manual", 0) or 0) + sbv_manual = 0.0 if manual_row.empty else float(manual_row.iloc[0].get("sbv_manual", 0) or 0) + qual_manual = "" if manual_row.empty else normalizar_texto(manual_row.iloc[0].get("qualificacao_manual", "")) + obs = "" if manual_row.empty else str(manual_row.iloc[0].get("observacao", "") or "") + ultima_manual = "" if manual_row.empty else str(manual_row.iloc[0].get("ultima_data_voo_manual", "") or "") + + proximas = [] + subprogramas = [sp for sp in str(trip.get(f"subprograma_{av.lower()}", "")).split() if sp.startswith("SP")] + for sp in subprogramas: + oi = proxima_oi(trigrama, av, sp, progresso, catalogo) + if oi: + proximas.append(f"{sp}: {oi['oi']}") + else: + proximas.append(f"{sp}: concluido") + + voos_trip = historico[(historico["tripulante"] == trigrama) & (historico["aeronave"] == av) & (historico["horas_voadas"] > 0)] + ultima_hist = "" if voos_trip.empty else max(voos_trip["data"]).isoformat() + + linhas.append( + { + "tripulante": trigrama, + "aeronave": av, + "qualificacao": qual_manual or qualificacao_operacional(trigrama, av, cadastro, progresso, catalogo), + "proxima_oi": " | ".join(proximas) if proximas else "-", + "horas_aeronave_base": float(trip.get(f"horas_{av.lower()}", 0) or 0), + "horas_voadas_ano": horas_hist + horas_manual, + "sbv_ano": sbv_hist + sbv_manual, + "ultima_data_voo": ultima_manual or ultima_hist, + "meta_50_faltante": max(0.0, 50.0 - (horas_hist + horas_manual)), + "observacao": obs, + } + ) + return pd.DataFrame(linhas) + + +def quadrinhos_para_api(base_dir: Path) -> dict: + df = gerar_quadrinhos(base_dir) + if df.empty: + return {"linhas": [], "resumo": {"tripulantes": 0, "abaixo_50": 0, "horas_media": 0}} + resumo = { + "tripulantes": int(df["tripulante"].nunique()), + "abaixo_50": int((df["horas_voadas_ano"] < 50).sum()), + "horas_media": round(float(df["horas_voadas_ano"].mean()), 1), + } + return {"linhas": df.fillna("").to_dict(orient="records"), "resumo": resumo} diff --git a/src/planejador_missao/report.py b/src/planejador_missao/report.py new file mode 100644 index 0000000..7e53e9f --- /dev/null +++ b/src/planejador_missao/report.py @@ -0,0 +1,117 @@ +"""Registro de horas e geracao do relatorio Excel final.""" + +from __future__ import annotations + +from datetime import datetime +from pathlib import Path + +import pandas as pd +from openpyxl import Workbook +from openpyxl.styles import Alignment, Font, PatternFill, Border, Side +from openpyxl.utils.dataframe import dataframe_to_rows + +from .utils import parse_horas + + +def horas_previstas(linha: pd.Series, catalogo: pd.DataFrame) -> float: + if linha["tipo_escala"] == "SBV": + return 0.0 + horas = parse_horas(linha.get("tev_horas")) + if horas: + return horas + oi = linha.get("oi_trip1", "") + match = catalogo[(catalogo["aeronave"] == linha["aeronave"]) & (catalogo["oi"] == oi)] + if not match.empty and bool(match.iloc[0]["conta_hora_voo"]): + return parse_horas(match.iloc[0]["tev_horas"]) or 0.0 + return 0.0 + + +def gerar_registros(solucao: pd.DataFrame, catalogo: pd.DataFrame) -> pd.DataFrame: + linhas = [] + agora = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + for _, row in solucao.iterrows(): + horas = horas_previstas(row, catalogo) + sbv = 1 if row["tipo_escala"] == "SBV" else 0 + for funcao, trip, oi in [("TRIP1", row["trip1"], row.get("oi_trip1", "")), ("TRIP2", row["trip2"], row.get("oi_trip2", ""))]: + linhas.append( + { + "data": row["data"], + "aeronave": row["aeronave"], + "tipo_escala": row["tipo_escala"], + "tripulante": trip, + "funcao": funcao, + "oi": oi if isinstance(oi, str) else "", + "horas_voadas": horas, + "sbv": sbv, + "origem_registro": "escala_diaria", + "registrado_em": agora, + } + ) + return pd.DataFrame(linhas) + + +def bloco(solucao: pd.DataFrame, tipo: str) -> pd.DataFrame: + dados = solucao[solucao["tipo_escala"] == tipo] + if dados.empty: + return pd.DataFrame([{"AERONAVE": "-", "TRIP 1": "-", "TRIP 2": "-", "OI/FICHA": "-", "DETALHE": "Sem escala"}]) + detalhe = dados["observacao"] + if tipo == "ROTA_ACIONADA": + detalhe = dados["rota"].where(dados["rota"].astype(str) != "", dados["origem"] + " - " + dados["destino"]) + return pd.DataFrame( + { + "AERONAVE": dados["aeronave"], + "TRIP 1": dados["trip1"], + "TRIP 2": dados["trip2"], + "OI/FICHA": dados["oi_trip1"].replace("", "-"), + "DETALHE": detalhe, + } + ) + + +def escrever_tabela(ws, titulo: str, dados: pd.DataFrame, row: int, col: int, cor: str) -> None: + fill_titulo = PatternFill("solid", fgColor=cor) + fill_header = PatternFill("solid", fgColor="D9EAF7") + borda = Border(*(Side(style="thin", color="BFBFBF") for _ in range(4))) + ws.merge_cells(start_row=row, start_column=col, end_row=row, end_column=col + 4) + cell = ws.cell(row=row, column=col, value=titulo) + cell.fill = fill_titulo + cell.font = Font(color="FFFFFF", bold=True) + cell.alignment = Alignment(horizontal="center") + for r_idx, linha in enumerate(dataframe_to_rows(dados, index=False, header=True), row + 1): + for c_idx, valor in enumerate(linha, col): + c = ws.cell(row=r_idx, column=c_idx, value=valor) + c.border = borda + c.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) + if r_idx == row + 1: + c.fill = fill_header + c.font = Font(bold=True) + + +def gerar_excel(base_dir: Path, data_planejamento, solucao: pd.DataFrame) -> Path: + out_dir = base_dir / "resultados" + out_dir.mkdir(exist_ok=True) + arquivo = out_dir / f"planejamento_diario_{data_planejamento}.xlsx" + if arquivo.exists(): + arquivo = out_dir / f"planejamento_diario_{data_planejamento}_{datetime.now():%H%M%S}.xlsx" + + wb = Workbook() + ws = wb.active + ws.title = "ESCALA DIARIA" + ws.merge_cells("A1:Q1") + ws["A1"] = "Planejador diario de missoes e sobreaviso - 2 ETA" + ws["A1"].fill = PatternFill("solid", fgColor="1F4E78") + ws["A1"].font = Font(color="FFFFFF", bold=True, size=16) + ws["A1"].alignment = Alignment(horizontal="center") + ws.merge_cells("A2:Q2") + ws["A2"] = f"ESCALA DIARIA - {data_planejamento:%d/%m/%Y}" + ws["A2"].font = Font(color="1F4E78", bold=True, size=18) + ws["A2"].alignment = Alignment(horizontal="center") + + escrever_tabela(ws, "MISSAO ACIONADA", bloco(solucao, "ROTA_ACIONADA"), 5, 1, "C00000") + escrever_tabela(ws, "VOOS LOCAIS", bloco(solucao, "MISSAO_LOCAL"), 5, 7, "548235") + escrever_tabela(ws, "SOBREAVISO", bloco(solucao, "SBV"), 5, 13, "8064A2") + for col in range(1, 18): + ws.column_dimensions[chr(64 + col)].width = 14 if col not in {5, 11, 17} else 24 + ws.freeze_panes = "A5" + wb.save(arquivo) + return arquivo diff --git a/src/planejador_missao/rules.py b/src/planejador_missao/rules.py new file mode 100644 index 0000000..639b3b0 --- /dev/null +++ b/src/planejador_missao/rules.py @@ -0,0 +1,108 @@ +"""Regras operacionais de disponibilidade, qualificacao e progressao.""" + +from __future__ import annotations + +from datetime import date + +import pandas as pd + +from .utils import normalizar_texto + + +def piloto_disponivel(tripulante: str, data_missao: date, indisponibilidades: pd.DataFrame) -> bool: + trip = normalizar_texto(tripulante) + conflitos = indisponibilidades[ + (indisponibilidades["tripulante"] == trip) + & (indisponibilidades["inicio"] <= data_missao) + & (indisponibilidades["fim"] >= data_missao) + ] + return conflitos.empty + + +def classe_principal(qualificacao: str) -> str: + qual = normalizar_texto(qualificacao) + for classe in ["IN", "PO", "PB", "AL"]: + if classe in qual.split("/"): + return classe + if classe in qual.split(): + return classe + return "OUTRO" + + +def qualificacao_base_aeronave(tripulante: str, aeronave: str, cadastro: pd.DataFrame) -> str: + linha = cadastro[cadastro["tripulante"] == normalizar_texto(tripulante)] + if linha.empty: + return "OUTRO" + qt = [x for x in normalizar_texto(linha.iloc[0]["qt"]).split("/") if x] + quals = [x for x in normalizar_texto(linha.iloc[0]["qualificacao"]).split("/") if x] + if len(qt) == len(quals) and aeronave in qt: + return classe_principal(quals[qt.index(aeronave)]) + return classe_principal(linha.iloc[0]["qualificacao"]) + + +def subprograma_concluido(progresso: pd.DataFrame, tripulante: str, aeronave: str, subprograma: str, catalogo: pd.DataFrame) -> bool: + necessarias = set(catalogo[(catalogo["aeronave"] == aeronave) & (catalogo["subprograma"] == subprograma)]["oi"]) + if not necessarias: + return False + concluidas = set( + progresso[ + (progresso["tripulante"] == normalizar_texto(tripulante)) + & (progresso["aeronave"] == aeronave) + & (progresso["subprograma"] == subprograma) + & (progresso["concluida"] == True) + ]["oi"] + ) + return necessarias.issubset(concluidas) + + +def qualificacao_operacional(tripulante: str, aeronave: str, cadastro: pd.DataFrame, progresso: pd.DataFrame, catalogo: pd.DataFrame) -> str: + rank = {"OUTRO": 0, "AL": 1, "PB": 2, "PO": 3, "IN": 4}[qualificacao_base_aeronave(tripulante, aeronave, cadastro)] + if subprograma_concluido(progresso, tripulante, aeronave, "SPFO-1", catalogo): + rank = max(rank, 2) + if subprograma_concluido(progresso, tripulante, aeronave, "SPFO-2", catalogo): + rank = max(rank, 3) + if aeronave == "C98" and subprograma_concluido(progresso, tripulante, aeronave, "SPQE-3", catalogo): + rank = max(rank, 4) + if aeronave == "C97" and subprograma_concluido(progresso, tripulante, aeronave, "SPQE-4", catalogo): + rank = max(rank, 4) + return {0: "OUTRO", 1: "AL", 2: "PB", 3: "PO", 4: "IN"}[rank] + + +def concluiu_ois(progresso: pd.DataFrame, tripulante: str, aeronave: str, ois: list[str]) -> bool: + concluidas = set( + progresso[ + (progresso["tripulante"] == normalizar_texto(tripulante)) + & (progresso["aeronave"] == aeronave) + & (progresso["concluida"] == True) + ]["oi"] + ) + return set(ois).issubset(concluidas) + + +def eh_instrutor_adaptado(tripulante: str, aeronave: str, cadastro: pd.DataFrame, progresso: pd.DataFrame, catalogo: pd.DataFrame) -> bool: + trip = normalizar_texto(tripulante) + instrutores_base = { + "C95": {"MES", "BRI"}, + "C97": {"AEU", "MAT"}, + "C98": {"BRI", "FIA", "MDO", "SLS", "CFF"}, + } + if trip in instrutores_base.get(aeronave, set()): + return True + if aeronave == "C97" and trip in {"SEI", "LPS"}: + return concluiu_ois(progresso, trip, aeronave, ["01TL01D41", "04TL01N42"]) + if aeronave == "C98" and trip in {"MCH", "DOG"}: + return concluiu_ois(progresso, trip, aeronave, ["01TL01D31", "04TL01N32"]) + return qualificacao_operacional(trip, aeronave, cadastro, progresso, catalogo) == "IN" + + +def combinar_progresso_com_historico(progresso: pd.DataFrame, historico: pd.DataFrame, catalogo: pd.DataFrame) -> pd.DataFrame: + if historico.empty: + return progresso + voadas = historico[(historico["oi"] != "") & (historico["horas_voadas"] > 0)] + if voadas.empty: + return progresso + hist = voadas.merge(catalogo[["aeronave", "subprograma", "oi"]].drop_duplicates(), on=["aeronave", "oi"], how="left") + hist = hist.dropna(subset=["subprograma"]) + hist = hist[["tripulante", "aeronave", "subprograma", "oi", "horas_voadas"]].rename(columns={"horas_voadas": "horas_realizadas"}) + hist["concluida"] = True + return pd.concat([progresso, hist], ignore_index=True).drop_duplicates(["tripulante", "aeronave", "subprograma", "oi"]) diff --git a/src/planejador_missao/utils.py b/src/planejador_missao/utils.py new file mode 100644 index 0000000..94458fb --- /dev/null +++ b/src/planejador_missao/utils.py @@ -0,0 +1,84 @@ +"""Utilitarios pequenos de normalizacao usados em todo o planejador.""" + +from __future__ import annotations + +import math +import re +from datetime import date, datetime +from pathlib import Path +from typing import Any + +import pandas as pd + + +def normalizar_texto(valor: Any) -> str: + if valor is None or (isinstance(valor, float) and math.isnan(valor)): + return "" + return re.sub(r"\s+", " ", str(valor).strip()).upper() + + +def parse_bool(valor: Any) -> bool: + return normalizar_texto(valor) in {"TRUE", "T", "1", "SIM", "S", "YES", "Y", "VERDADEIRO"} + + +def parse_data(valor: Any) -> date: + texto = normalizar_texto(valor).lower() + if texto in {"", "hoje", "today", "sysdate"}: + return date.today() + return pd.to_datetime(valor).date() + + +def parse_data_hora(valor: Any) -> pd.Timestamp: + if valor is None or str(valor).strip() == "": + return pd.NaT + return pd.to_datetime(valor, errors="coerce") + + +def parse_horas(valor: Any) -> float | None: + if valor is None or (isinstance(valor, float) and math.isnan(valor)): + return None + if isinstance(valor, (int, float)): + return float(valor) + texto = str(valor).strip().replace(",", ".") + if texto == "": + return None + if ":" in texto: + partes = texto.split(":") + try: + return float(partes[0]) + float(partes[1]) / 60 + except (ValueError, IndexError): + return None + try: + return float(texto) + except ValueError: + return None + + +def clean_columns(df: pd.DataFrame) -> pd.DataFrame: + df = df.copy() + df.columns = [ + normalizar_texto(c) + .lower() + .replace("º", "") + .replace("ç", "c") + .replace("ã", "a") + .replace("á", "a") + .replace("é", "e") + .replace("í", "i") + .replace("ó", "o") + .replace("ú", "u") + .replace(" ", "_") + .replace(".", "") + for c in df.columns + ] + return df + + +def read_csv(path: Path, columns: list[str]) -> pd.DataFrame: + if not path.exists(): + return pd.DataFrame(columns=columns) + df = pd.read_csv(path) + for col in columns: + if col not in df.columns: + df[col] = pd.NA + return df[columns] diff --git a/src/planejador_missao/validacao.py b/src/planejador_missao/validacao.py new file mode 100644 index 0000000..efb46e4 --- /dev/null +++ b/src/planejador_missao/validacao.py @@ -0,0 +1,251 @@ +"""Validacao retrospectiva usando voos historicos.""" + +from __future__ import annotations + +from datetime import datetime +from pathlib import Path + +import numpy as np +import pandas as pd +from scipy.optimize import Bounds, LinearConstraint, milp + +from .data_io import carregar_cadastro +from .utils import normalizar_texto + + +COLUNAS_VOOS_2025 = ["data", "aeronave", "tipo_escala", "tripulante", "funcao", "oi", "horas_voadas", "sbv"] +COLUNAS_OPCIONAIS_VOOS_2025 = ["slot_id"] + + +def criar_modelo_voos_2025(path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + if path.exists(): + return + pd.DataFrame(columns=COLUNAS_VOOS_2025).to_csv(path, index=False) + + +def carregar_voos_2025(path: Path) -> pd.DataFrame: + df = pd.read_csv(path) + for col in COLUNAS_VOOS_2025: + if col not in df.columns: + df[col] = pd.NA + for col in COLUNAS_OPCIONAIS_VOOS_2025: + if col not in df.columns: + df[col] = pd.NA + df = df[COLUNAS_OPCIONAIS_VOOS_2025 + COLUNAS_VOOS_2025].copy() + df["data"] = pd.to_datetime(df["data"], errors="coerce").dt.date + for col in ["aeronave", "tipo_escala", "tripulante", "funcao", "oi"]: + df[col] = df[col].map(normalizar_texto) + df["horas_voadas"] = pd.to_numeric(df["horas_voadas"], errors="coerce").fillna(0) + df["sbv"] = pd.to_numeric(df["sbv"], errors="coerce").fillna(0) + return df.dropna(subset=["data"]).reset_index(drop=True) + + +def _montar_slots(voos: pd.DataFrame) -> pd.DataFrame: + voados = voos[voos["horas_voadas"] > 0].copy() + if voados.empty: + return pd.DataFrame(columns=["slot_id", "data", "aeronave", "tipo_escala", "horas_voadas"]) + if "slot_id" in voados.columns and not voados["slot_id"].isna().all(): + voos_unicos = ( + voados.groupby(["slot_id", "data", "aeronave", "tipo_escala"], as_index=False) + .agg( + horas_voadas=("horas_voadas", "max"), + tripulantes_reais=("tripulante", lambda s: ", ".join(sorted(set(s)))), + ) + ) + slots = ( + voos_unicos.groupby(["data", "aeronave", "tipo_escala"], as_index=False) + .agg( + horas_voadas=("horas_voadas", "sum"), + tripulantes_reais=("tripulantes_reais", lambda s: ", ".join(sorted(set(", ".join(s).split(", "))))), + ) + .sort_values(["data", "aeronave", "tipo_escala"]) + .reset_index(drop=True) + ) + else: + slots = ( + voados.groupby(["data", "aeronave", "tipo_escala"], as_index=False) + .agg(horas_voadas=("horas_voadas", "max"), tripulantes_reais=("tripulante", lambda s: ", ".join(sorted(set(s))))) + .sort_values(["data", "aeronave", "tipo_escala"]) + .reset_index(drop=True) + ) + slots["slot_id"] = slots.index + 1 + return slots[["slot_id", "data", "aeronave", "tipo_escala", "horas_voadas", "tripulantes_reais"]] + + +def _metricas(horas: pd.Series, meta_horas: float) -> dict: + horas = horas.astype(float) + return { + "tripulantes": int(len(horas)), + "horas_total": round(float(horas.sum()), 2), + "horas_media": round(float(horas.mean()), 2) if len(horas) else 0, + "desvio_padrao": round(float(horas.std(ddof=0)), 2) if len(horas) else 0, + "menor_horas": round(float(horas.min()), 2) if len(horas) else 0, + "maior_horas": round(float(horas.max()), 2) if len(horas) else 0, + "abaixo_meta": int((horas < meta_horas).sum()), + "desvio_medio_meta": round(float((horas - meta_horas).abs().mean()), 2) if len(horas) else 0, + } + + +def otimizar_equalizacao_50( + base_dir: Path, + voos: pd.DataFrame, + meta_horas: float = 50.0, + tempo_limite_segundos: float = 180.0, + gap_relativo: float = 0.05, +) -> dict: + historico_vazio = pd.DataFrame(columns=["tripulante", "horas_voadas", "sbv"]) + cadastro = carregar_cadastro(base_dir, historico_vazio) + trips_cadastro = set(cadastro["tripulante"]) + if "slot_id" in voos.columns and not voos["slot_id"].isna().all(): + slots_validos = ( + voos.groupby("slot_id")["tripulante"] + .apply(lambda s: set(s).issubset(trips_cadastro)) + .loc[lambda s: s] + .index + ) + voos = voos[voos["slot_id"].isin(slots_validos)].copy() + else: + voos = voos[voos["tripulante"].isin(trips_cadastro)].copy() + + slots = _montar_slots(voos) + if slots.empty: + raise RuntimeError("A base de 2025 nao possui linhas com horas_voadas maiores que zero.") + + trips = sorted(set(voos["tripulante"]) & trips_cadastro) + if not trips: + raise RuntimeError("Nao ha tripulantes da base de 2025 presentes no cadastro atual.") + + pares = [] + for s_idx, slot in slots.iterrows(): + qt_col = f"qt_{slot['aeronave'].lower()}" + for t_idx, trip in enumerate(trips): + linha = cadastro[cadastro["tripulante"] == trip] + if not linha.empty and bool(linha.iloc[0].get(qt_col, False)): + pares.append((s_idx, t_idx, float(slot["horas_voadas"]))) + if not pares: + raise RuntimeError("Nao ha candidatos compativeis entre aeronaves dos voos 2025 e cadastro.") + + n_x = len(pares) + n_t = len(trips) + hmax_idx = n_x + 2 * n_t + hmin_idx = hmax_idx + 1 + n_vars = hmin_idx + 1 + c = np.zeros(n_vars) + c[n_x : n_x + n_t] = 1 + c[n_x + n_t :] = 1 + c[hmax_idx] = 100 + c[hmin_idx] = -100 + integrality = np.zeros(n_vars) + integrality[:n_x] = 1 + bounds = Bounds(np.zeros(n_vars), np.r_[np.ones(n_x), np.full(2 * n_t + 2, np.inf)]) + + constraints = [] + for s_idx in range(len(slots)): + row = np.zeros(n_vars) + for var_idx, (slot_i, _, _) in enumerate(pares): + if slot_i == s_idx: + row[var_idx] = 1 + constraints.append(LinearConstraint(row, 2, 2)) + + for data in sorted(slots["data"].unique()): + slots_dia = set(slots.index[slots["data"] == data]) + for t_idx in range(n_t): + row = np.zeros(n_vars) + for var_idx, (slot_i, trip_i, _) in enumerate(pares): + if slot_i in slots_dia and trip_i == t_idx: + row[var_idx] = 1 + constraints.append(LinearConstraint(row, -np.inf, 1)) + + for t_idx in range(n_t): + row = np.zeros(n_vars) + for var_idx, (_, trip_i, horas) in enumerate(pares): + if trip_i == t_idx: + row[var_idx] = horas + row[n_x + t_idx] = -1 + row[n_x + n_t + t_idx] = 1 + constraints.append(LinearConstraint(row, meta_horas, meta_horas)) + + row = np.zeros(n_vars) + for var_idx, (_, trip_i, horas) in enumerate(pares): + if trip_i == t_idx: + row[var_idx] = horas + row[hmax_idx] = -1 + constraints.append(LinearConstraint(row, -np.inf, 0)) + + row = np.zeros(n_vars) + for var_idx, (_, trip_i, horas) in enumerate(pares): + if trip_i == t_idx: + row[var_idx] = horas + row[hmin_idx] = -1 + constraints.append(LinearConstraint(row, 0, np.inf)) + + result = milp( + c=c, + integrality=integrality, + bounds=bounds, + constraints=constraints, + options={"time_limit": tempo_limite_segundos, "mip_rel_gap": gap_relativo}, + ) + if result.x is None: + raise RuntimeError(f"Validacao MILP inviavel: {result.message}") + + escolhidos = result.x[:n_x] > 0.5 + linhas = [] + for var_idx, escolhido in enumerate(escolhidos): + if not escolhido: + continue + s_idx, t_idx, horas = pares[var_idx] + slot = slots.iloc[s_idx] + linhas.append( + { + "slot_id": int(slot["slot_id"]), + "data": slot["data"], + "aeronave": slot["aeronave"], + "tipo_escala": slot["tipo_escala"], + "tripulante_otimizado": trips[t_idx], + "horas_voadas": horas, + "tripulantes_reais": slot["tripulantes_reais"], + } + ) + otimizados = pd.DataFrame(linhas) + horas_reais = voos.groupby("tripulante")["horas_voadas"].sum().reindex(trips, fill_value=0) + horas_otimizadas = otimizados.groupby("tripulante_otimizado")["horas_voadas"].sum().reindex(trips, fill_value=0) + comparativo = pd.DataFrame( + { + "tripulante": trips, + "horas_reais_2025": horas_reais.to_numpy(), + "horas_otimizadas_meta_50": horas_otimizadas.to_numpy(), + } + ) + comparativo["delta_otimizado_menos_real"] = comparativo["horas_otimizadas_meta_50"] - comparativo["horas_reais_2025"] + + return { + "slots": slots, + "otimizados": otimizados, + "comparativo": comparativo, + "metricas": pd.DataFrame( + [ + {"cenario": "real_2025", **_metricas(comparativo["horas_reais_2025"], meta_horas)}, + {"cenario": "otimizado_meta_50", **_metricas(comparativo["horas_otimizadas_meta_50"], meta_horas)}, + ] + ), + "status_solver": { + "sucesso": bool(result.success), + "mensagem": str(result.message), + "fun_objetivo": float(result.fun) if result.fun is not None else None, + }, + } + + +def salvar_relatorio_validacao(base_dir: Path, resultado: dict, meta_horas: float = 50.0) -> Path: + out_dir = base_dir / "resultados" / "validacao" + out_dir.mkdir(parents=True, exist_ok=True) + arquivo = out_dir / f"validacao_2025_meta_{int(meta_horas)}_{datetime.now():%Y%m%d_%H%M%S}.xlsx" + with pd.ExcelWriter(arquivo, engine="openpyxl") as writer: + pd.DataFrame([resultado.get("status_solver", {})]).to_excel(writer, sheet_name="solver", index=False) + resultado["metricas"].to_excel(writer, sheet_name="metricas", index=False) + resultado["comparativo"].to_excel(writer, sheet_name="comparativo_trips", index=False) + resultado["slots"].to_excel(writer, sheet_name="voos_2025_slots", index=False) + resultado["otimizados"].to_excel(writer, sheet_name="escala_otimizada", index=False) + return arquivo diff --git a/web_app.py b/web_app.py new file mode 100644 index 0000000..e360f5b --- /dev/null +++ b/web_app.py @@ -0,0 +1,578 @@ +"""Interface web local em HTML para o planejador diario.""" + +from __future__ import annotations + +import json +import mimetypes +import os +import threading +import webbrowser +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from urllib.parse import urlparse + +import pandas as pd + +from src.planejador_missao.main import executar_planejamento +from src.planejador_missao.quadrinhos import quadrinhos_para_api +from src.planejador_missao.utils import normalizar_texto, parse_bool + + +BASE_DIR = Path(__file__).resolve().parent +DADOS_DIR = BASE_DIR / "dados" +HOST = "127.0.0.1" +PORT = 8050 + + +def read_csv(path: Path, default: pd.DataFrame) -> pd.DataFrame: + if not path.exists(): + return default.copy() + return pd.read_csv(path).fillna("") + + +def load_state() -> dict: + parametros_default = pd.DataFrame( + { + "parametro": ["data_planejamento", "criterio_missao", "criterio_sbv"], + "valor": ["hoje", "meta_50", "equalizar_quadrinhos"], + } + ) + parametros_df = read_csv(DADOS_DIR / "parametros_missao.csv", parametros_default) + parametros = dict(zip(parametros_df["parametro"], parametros_df["valor"])) + + aeronaves_default = pd.DataFrame( + { + "aeronave": ["C98", "C97", "C95"], + "disponivel_sede": [True, True, True], + "em_pane": [False, False, False], + "em_missao_rota": [False, False, False], + "hora_livre": ["08:00", "08:00", "08:00"], + } + ) + aeronaves = read_csv(DADOS_DIR / "aeronaves_disponiveis.csv", aeronaves_default) + for col in ["disponivel_sede", "em_pane", "em_missao_rota"]: + aeronaves[col] = aeronaves[col].map(parse_bool) + + rota_default = pd.DataFrame( + { + "origem": ["NT"], + "destino": [""], + "inicio": [pd.Timestamp.today().strftime("%Y-%m-%d 08:00")], + "fim": [pd.Timestamp.today().strftime("%Y-%m-%d 17:00")], + "tev_horas": [0], + "pernoite_dias": [0], + "rota": [""], + } + ) + rotas = read_csv(DADOS_DIR / "rotas_acionadas.csv", rota_default) + rota = rota_default.iloc[0].to_dict() if rotas.empty else rotas.iloc[0].to_dict() + + trips_default = pd.DataFrame({"tripulante": [], "disponivel": []}) + tripulantes = read_csv(DADOS_DIR / "tripulantes_disponiveis.csv", trips_default) + if not tripulantes.empty: + tripulantes["tripulante"] = tripulantes["tripulante"].map(normalizar_texto) + tripulantes["disponivel"] = tripulantes["disponivel"].map(parse_bool) + tripulantes = tripulantes.sort_values("tripulante") + + return { + "parametros": { + "data_planejamento": str(parametros.get("data_planejamento", "hoje")), + "criterio_missao": str(parametros.get("criterio_missao", "meta_50")), + "criterio_sbv": str(parametros.get("criterio_sbv", "equalizar_quadrinhos")), + }, + "aeronaves": aeronaves.to_dict(orient="records"), + "rota": rota, + "tem_rota": str(rota.get("destino", "")).strip() != "", + "tripulantes": tripulantes.to_dict(orient="records"), + } + + +def save_state(payload: dict) -> None: + parametros = payload.get("parametros", {}) + pd.DataFrame( + { + "parametro": ["data_planejamento", "criterio_missao", "criterio_sbv"], + "valor": [ + parametros.get("data_planejamento", "hoje"), + parametros.get("criterio_missao", "meta_50"), + parametros.get("criterio_sbv", "equalizar_quadrinhos"), + ], + } + ).to_csv(DADOS_DIR / "parametros_missao.csv", index=False) + + pd.DataFrame(payload.get("aeronaves", [])).to_csv(DADOS_DIR / "aeronaves_disponiveis.csv", index=False) + + if payload.get("tem_rota", False): + rota = payload.get("rota", {}) + pd.DataFrame( + { + "origem": [normalizar_texto(rota.get("origem", "NT"))], + "destino": [normalizar_texto(rota.get("destino", ""))], + "inicio": [str(rota.get("inicio", "")).strip()], + "fim": [str(rota.get("fim", "")).strip()], + "tev_horas": [str(rota.get("tev_horas", "0")).strip()], + "pernoite_dias": [str(rota.get("pernoite_dias", "0")).strip()], + "rota": [normalizar_texto(rota.get("rota", ""))], + } + ).to_csv(DADOS_DIR / "rotas_acionadas.csv", index=False) + else: + pd.DataFrame(columns=["origem", "destino", "inicio", "fim", "tev_horas", "pernoite_dias", "rota"]).to_csv( + DADOS_DIR / "rotas_acionadas.csv", index=False + ) + + tripulantes = pd.DataFrame(payload.get("tripulantes", [])) + if not tripulantes.empty: + tripulantes["tripulante"] = tripulantes["tripulante"].map(normalizar_texto) + tripulantes.to_csv(DADOS_DIR / "tripulantes_disponiveis.csv", index=False) + + +class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + path = urlparse(self.path).path + if path == "/": + self._send_html(INDEX_HTML) + return + if path == "/api/state": + self._send_json(load_state()) + return + if path == "/api/quadrinhos": + self._send_json(quadrinhos_para_api(BASE_DIR)) + return + if path.startswith("/assets/"): + self._send_file(DADOS_DIR / "assets" / path.removeprefix("/assets/")) + return + self.send_error(404) + + def do_POST(self) -> None: + path = urlparse(self.path).path + payload = self._read_json() + if path == "/api/save": + save_state(payload) + self._send_json({"ok": True, "message": "Entradas salvas em dados/."}) + return + if path == "/api/run": + save_state(payload) + resultado = executar_planejamento(BASE_DIR) + self._send_json( + { + "ok": True, + "message": "Escala gerada com sucesso.", + "data_planejamento": str(resultado["data_planejamento"]), + "arquivo_saida": str(resultado["arquivo_saida"]), + "total_candidatas": resultado["total_candidatas"], + "total_selecionadas": resultado["total_selecionadas"], + } + ) + return + if path == "/api/open-latest": + arquivos = sorted((BASE_DIR / "resultados").glob("planejamento_diario_*.xlsx"), key=lambda p: p.stat().st_mtime) + if not arquivos: + self._send_json({"ok": False, "message": "Nenhuma planilha encontrada."}, status=404) + return + os.startfile(arquivos[-1]) + self._send_json({"ok": True, "message": f"Abrindo {arquivos[-1]}"}) + return + self.send_error(404) + + def _read_json(self) -> dict: + length = int(self.headers.get("Content-Length", "0")) + if length == 0: + return {} + return json.loads(self.rfile.read(length).decode("utf-8")) + + def _send_json(self, payload: dict, status: int = 200) -> None: + data = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def _send_html(self, html: str) -> None: + data = html.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def _send_file(self, path: Path) -> None: + try: + resolved = path.resolve() + assets_dir = (DADOS_DIR / "assets").resolve() + if not resolved.is_file() or assets_dir not in resolved.parents: + self.send_error(404) + return + data = resolved.read_bytes() + content_type = mimetypes.guess_type(str(resolved))[0] or "application/octet-stream" + self.send_response(200) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + except OSError: + self.send_error(404) + + def log_message(self, format: str, *args) -> None: + return + + +INDEX_HTML = r""" + + + + + Planejador Diario de Missoes + + + + +
+
+
+ +
+

Planejador Diario de Missoes

+
Escala, sobreaviso e acompanhamento operacional
+
+
+
+ + + +
+
+
+
+
+ + +
+
+
+
+

Parametros

+
+
+
+
+
+
+
+
+ +
+

Aeronaves

+
+
+ +
+

Missao acionada

+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+

Tripulantes disponiveis

+
+ + +
+
+
+ +
+

Status

+
Carregando dados...
+
+
+
+ +
+
+

Quadrinho operacional

+
+
Tripulantes-
+
Quadrinhos abaixo de 50h-
+
Media de horas no ano-
+
+
+ + + +
+
+ + + + + + + + +
TripAeronaveQualificacaoProxima OIHoras baseHoras anoSBVFaltam 50hUltimo vooObs
Carregando...
+
+
+
+
+ + + +""" + + +def main() -> None: + server = ThreadingHTTPServer((HOST, PORT), Handler) + url = f"http://{HOST}:{PORT}" + threading.Timer(0.5, lambda: webbrowser.open(url)).start() + print(f"Interface web aberta em {url}") + print("Pressione Ctrl+C para encerrar.") + server.serve_forever() + + +if __name__ == "__main__": + main()