Initial project import

This commit is contained in:
Lucas
2026-06-26 18:07:04 -03:00
commit 6443c0e2dc
48 changed files with 3948 additions and 0 deletions

View File

@@ -0,0 +1,2 @@
"""Planejador de missao e sobreaviso por MILP."""

View File

@@ -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)]

View File

@@ -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)

View File

@@ -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),
}

View File

@@ -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)

View File

@@ -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}

View File

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

View File

@@ -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"])

View File

@@ -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]

View File

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