Initial project import
This commit is contained in:
2
src/planejador_missao/__init__.py
Normal file
2
src/planejador_missao/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""Planejador de missao e sobreaviso por MILP."""
|
||||
|
||||
216
src/planejador_missao/candidates.py
Normal file
216
src/planejador_missao/candidates.py
Normal 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)]
|
||||
155
src/planejador_missao/data_io.py
Normal file
155
src/planejador_missao/data_io.py
Normal 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)
|
||||
64
src/planejador_missao/main.py
Normal file
64
src/planejador_missao/main.py
Normal 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),
|
||||
}
|
||||
60
src/planejador_missao/optimizer.py
Normal file
60
src/planejador_missao/optimizer.py
Normal 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)
|
||||
100
src/planejador_missao/quadrinhos.py
Normal file
100
src/planejador_missao/quadrinhos.py
Normal 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}
|
||||
117
src/planejador_missao/report.py
Normal file
117
src/planejador_missao/report.py
Normal 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
|
||||
108
src/planejador_missao/rules.py
Normal file
108
src/planejador_missao/rules.py
Normal 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"])
|
||||
84
src/planejador_missao/utils.py
Normal file
84
src/planejador_missao/utils.py
Normal 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]
|
||||
251
src/planejador_missao/validacao.py
Normal file
251
src/planejador_missao/validacao.py
Normal 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
|
||||
Reference in New Issue
Block a user