Corrige Gantt, adiciona escala de voo real e ajusta solver (v0.9)

- visualizar_resultado: barras Gantt via go.Scatter lines (renderizacao
  garantida), yaxis range fixo para FAB 2800 visivel, largura minima 2h
- oamrp_v3: parser --escala para Escala de Voo Modelo 1 (CSV real),
  coordenadas de 26 bases, time-limit default 5min, imports timedelta/date

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Cesa-V
2026-06-16 09:34:14 -03:00
parent 3756f961a8
commit 83a2dac624
4 changed files with 177 additions and 33 deletions

View File

@@ -0,0 +1,6 @@
{
"2800": {"2": 268, "3": 268, "4": 568, "6": 142, "7": 142, "19": 1800, "20": 2100, "22": 680},
"2803": {"2": 85, "3": 85, "4": 285, "6": 85, "7": 185, "19": 800, "20": 1200, "22": 350},
"2809": {"2": 155, "3": 455, "4": 155, "6": 288, "7": 288, "19": 1200, "20": 1800, "22": 500},
"2811": {"2": 120, "3": 572, "4": 572, "6": 120, "7": 220, "19": 1500, "20": 2200, "22": 720}
}

View File

@@ -0,0 +1,6 @@
{
"2800": {"2": 285, "3": 0, "4": 0, "6": 0, "7": 0, "19": 0, "20": 0, "22": 0},
"2803": {"2": 0, "3": 570, "4": 0, "6": 0, "7": 0, "19": 0, "20": 0, "22": 0},
"2809": {"2": 0, "3": 0, "4": 0, "6": 280, "7": 0, "19": 0, "20": 0, "22": 0},
"2811": {"2": 0, "3": 0, "4": 0, "6": 0, "7": 380, "19": 0, "20": 0, "22": 0}
}

View File

@@ -43,7 +43,7 @@ import csv
import json import json
import random import random
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import date, datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from typing import Literal, Optional from typing import Literal, Optional
@@ -428,6 +428,7 @@ def resolver(
arcos: list[Arco], arcos: list[Arco],
aeronaves: list[Aeronave], aeronaves: list[Aeronave],
catalogo: dict[int, InspecaoParam], catalogo: dict[int, InspecaoParam],
**kwargs,
) -> dict: ) -> dict:
ks = [a.id for a in aeronaves] ks = [a.id for a in aeronaves]
acft = {a.id: a for a in aeronaves} acft = {a.id: a for a in aeronaves}
@@ -675,7 +676,8 @@ def resolver(
# já impõe o limite LRT por ciclo corretamente. A versão simplificada somava # já impõe o limite LRT por ciclo corretamente. A versão simplificada somava
# horas totais sem respeitar resets de inspeção, super-restringindo o modelo. # horas totais sem respeitar resets de inspeção, super-restringindo o modelo.
solver = pulp.PULP_CBC_CMD(msg=False) time_limit = kwargs.get("time_limit", 0)
solver = pulp.PULP_CBC_CMD(msg=False, timeLimit=time_limit if time_limit > 0 else None)
mdl.solve(solver) mdl.solve(solver)
prio_otima = int(round(pulp.value(mdl.objective) or 0)) prio_otima = int(round(pulp.value(mdl.objective) or 0))
@@ -696,7 +698,7 @@ def resolver(
) )
mdl.sense = pulp.LpMaximize mdl.sense = pulp.LpMaximize
mdl.setObjective(horas_voadas) mdl.setObjective(horas_voadas)
mdl.solve(solver) mdl.solve(pulp.PULP_CBC_CMD(msg=False, timeLimit=time_limit if time_limit > 0 else None))
return { return {
"modelo": mdl, "modelo": mdl,
@@ -815,7 +817,7 @@ def escrever_resultado(sol: dict, caminho: Path) -> None:
# Inspeções executadas por esta aeronave # Inspeções executadas por esta aeronave
if horizonte_dt is not None: if horizonte_dt is not None:
for seq, s_val, insp in inspecoes_da_aeronave(sol, k): for seq, s_val, insp in inspecoes_da_aeronave(sol, k):
from datetime import timedelta
ini_dt = horizonte_dt + timedelta(hours=s_val) ini_dt = horizonte_dt + timedelta(hours=s_val)
fim_dt = horizonte_dt + timedelta(hours=s_val + insp.dt_h) fim_dt = horizonte_dt + timedelta(hours=s_val + insp.dt_h)
linhas.append({ linhas.append({
@@ -1158,18 +1160,37 @@ def derivar_bases(
# Missões sintéticas # Missões sintéticas
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Coordenadas (lat, lon) das 10 bases operacionais do C-105 # Coordenadas (lat, lon) das bases operacionais do C-105
BASES_COORDS: dict[str, tuple[float, float]] = { BASES_COORDS: dict[str, tuple[float, float]] = {
"SBMN": (-3.15, -59.99), # Manaus — Ponta Pelada (hub) # Amazônia (bases primárias)
"SBBE": (-1.38, -48.48), # Belém "SBMN": (-3.15, -59.99), # Manaus — Ponta Pelada (hub)
"SBBV": ( 2.84, -60.69), # Boa Vista "SBBE": (-1.38, -48.48), # Belém
"SBSN": (-2.42, -54.79), # Santarém "SBBV": ( 2.84, -60.69), # Boa Vista
"SBPV": (-8.71, -63.90), # Porto Velho "SBSN": (-2.42, -54.79), # Santarém
"SBTS": (-4.25, -69.94), # Tabatinga "SBPV": (-8.71, -63.90), # Porto Velho
"SBTT": (-3.38, -64.72), # Tefé "SBTS": (-4.25, -69.94), # Tabatinga
"SBUA": (-0.15, -67.05), # São Gabriel da Cachoeira "SBTT": (-3.38, -64.72), # Tefé
"SWBC": (-0.98, -62.92), # Barcelos "SBUA": (-0.15, -67.05), # São Gabriel da Cachoeira
"SBMY": (-5.81, -61.28), # Manicoré "SWBC": (-0.98, -62.92), # Barcelos
"SBMY": (-5.81, -61.28), # Manicoré
# Bases adicionais (escala de voo real)
"SBMQ": ( 0.05, -51.07), # Macapá
"SBOI": (-3.77, -38.53), # Fortaleza (Pinto Martins)
"SBVH": (-12.69, -60.10), # Vilhena
"SBBR": (-15.87, -47.92), # Brasília
"SBGL": (-22.81, -43.25), # Rio de Janeiro (Galeão)
"SBCY": (-15.65, -56.12), # Cuiabá
"SBCC": (-10.86, -51.80), # Canarana
"SBYS": (-23.00, -49.71), # Ourinhos
"SBCO": (-22.17, -47.88), # Cosmópolis
"SBSM": (-29.71, -53.69), # Santa Maria
"SBLO": (-23.33, -51.13), # Londrina
"SBAN": (-15.59, -56.10), # Anápolis
"SWEI": (-7.63, -72.68), # Eirunepé
"SWCA": (-8.85, -63.85), # Cacoal
"SWKO": (-11.50, -61.45), # Rolim de Moura
"SBTF": (-3.38, -64.72), # Tefé (alias SBTT)
"SBUY": (-29.78, -57.04), # Uruguaiana
} }
_VELOCIDADE_CRUZEIRO_KMH = 430.0 # C-105 Spartan (típico) _VELOCIDADE_CRUZEIRO_KMH = 430.0 # C-105 Spartan (típico)
@@ -1187,6 +1208,103 @@ def _haversine_km(b1: str, b2: str) -> float:
return R * 2 * math.asin(math.sqrt(a)) return R * 2 * math.asin(math.sqrt(a))
def carregar_escala(caminho: Path, inicio: datetime) -> tuple[list[Missao], datetime]:
"""
Lê a Escala de Voo Modelo 1 (CSV com separador ';') e retorna missões para o solver.
Formato esperado (2 linhas de cabeçalho):
DATA ; ETAPA ; DEP ; ARR ; HOR_DEP ; HOR_ARR ; DUR ; SEGMTO ; MISSÃO ; OFRAG
Cada linha vira uma Missao. Bases sem coordenadas conhecidas são descartadas.
A prioridade é derivada do tipo de missão:
69TV → prio 1 | 50TT/05TF → prio 3 | outros → prio 2
"""
_MES = {
"jan": 1, "fev": 2, "mar": 3, "abr": 4, "mai": 5, "jun": 6,
"jul": 7, "ago": 8, "set": 9, "out": 10, "nov": 11, "dez": 12,
}
def _parse_hora(s: str) -> tuple[int, int]:
parts = s.strip().split(":")
return int(parts[0]) % 24, int(parts[1])
def _prio(missao_id: str) -> int:
m = missao_id.upper()
if "69TV" in m:
return 1
if any(x in m for x in ("50TT", "05TF", "TT", "TF")):
return 3
return 2
missoes: list[Missao] = []
ano = inicio.year
data_atual: date | None = None
with caminho.open(newline="", encoding="latin-1") as f:
reader = csv.reader(f, delimiter=";")
next(reader) # cabeçalho 1
next(reader) # cabeçalho 2
for i, row in enumerate(reader):
if len(row) < 10:
continue
data_str, _, dep, arr, h_dep, h_arr, _, _, missao_id, ofrag = (
row[0].strip(), row[1].strip(), row[2].strip(), row[3].strip(),
row[4].strip(), row[5].strip(), row[6].strip(), row[7].strip(),
row[8].strip(), row[9].strip(),
)
if data_str:
partes = data_str.split("/")
if len(partes) == 2:
data_atual = date(ano, _MES.get(partes[1].lower(), 1), int(partes[0]))
if data_atual is None or not dep or not arr:
continue
if dep not in BASES_COORDS or arr not in BASES_COORDS:
continue
try:
hd, md = _parse_hora(h_dep)
ha, ma = _parse_hora(h_arr)
except Exception:
continue
t_dep_dt = datetime(data_atual.year, data_atual.month, data_atual.day,
hd, md, tzinfo=timezone.utc)
t_arr_dt = datetime(data_atual.year, data_atual.month, data_atual.day,
ha, ma, tzinfo=timezone.utc)
if t_arr_dt <= t_dep_dt: # chegada no dia seguinte
t_arr_dt += timedelta(days=1)
t_dep_h = (t_dep_dt - inicio).total_seconds() / 3600
t_arr_h = (t_arr_dt - inicio).total_seconds() / 3600
if t_dep_h < 0 or t_arr_h <= t_dep_h:
continue
missoes.append(Missao(
id=f"ESC_{i:03d}_{dep}_{arr}",
om=ofrag,
orig=dep,
dest=arr,
t_dep=t_dep_h,
t_arr=t_arr_h,
dur_h=t_arr_h - t_dep_h,
prioridade=_prio(missao_id),
obrigatoria=False,
partida_utc=t_dep_dt,
chegada_utc=t_arr_dt,
periodo="",
linhas_origem="",
aeronave_real="",
codigo_missao=missao_id,
))
if not missoes:
raise ValueError(f"Nenhuma missão válida carregada de {caminho}")
horizonte_dt = inicio
return missoes, horizonte_dt
TIPOS_MISSAO = ["Logística", "Médico", "Pessoal", "Reabastecimento", "Reconhecimento"] TIPOS_MISSAO = ["Logística", "Médico", "Pessoal", "Reabastecimento", "Reconhecimento"]
# Pesos de prioridade: prio 1 (urgente) é mais frequente que prio 5 (baixa) # Pesos de prioridade: prio 1 (urgente) é mais frequente que prio 5 (baixa)
@@ -1371,6 +1489,10 @@ def main() -> None:
"--hard-pattern", default=HARD_MISSAO_PADRAO, "--hard-pattern", default=HARD_MISSAO_PADRAO,
help="Regex no campo 'missao' do CSV que marca missões obrigatórias (Q2).", help="Regex no campo 'missao' do CSV que marca missões obrigatórias (Q2).",
) )
parser.add_argument(
"--escala", type=Path, default=None, metavar="CSV",
help="Escala de Voo Modelo 1 (CSV ';') com missões reais.",
)
parser.add_argument( parser.add_argument(
"--sintetico", action="store_true", "--sintetico", action="store_true",
help="Usa missões sintéticas cross-base em vez do CSV real.", help="Usa missões sintéticas cross-base em vez do CSV real.",
@@ -1387,6 +1509,10 @@ def main() -> None:
"--ofrag", type=Path, default=None, metavar="CSV", "--ofrag", type=Path, default=None, metavar="CSV",
help="CSV de OFRAG com prioridades por missão (missao_id, prioridade).", help="CSV de OFRAG com prioridades por missão (missao_id, prioridade).",
) )
parser.add_argument(
"--time-limit", type=int, default=300, dest="time_limit",
help="Limite de tempo em segundos para o solver CBC (padrão: 60). 0 = sem limite.",
)
args = parser.parse_args() args = parser.parse_args()
inicio = datetime.fromisoformat(args.inicio).replace(tzinfo=timezone.utc) inicio = datetime.fromisoformat(args.inicio).replace(tzinfo=timezone.utc)
@@ -1394,7 +1520,11 @@ def main() -> None:
catalogo = carregar_inspecoes(args.inspecoes) catalogo = carregar_inspecoes(args.inspecoes)
if args.sintetico: if args.escala:
missoes, horizonte_dt = carregar_escala(args.escala, inicio)
bases = sorted({m.orig for m in missoes} | {m.dest for m in missoes})
print(f" Escala de voo: {len(missoes)} missões carregadas de {args.escala.name}")
elif args.sintetico:
horizonte_h = (fim - inicio).total_seconds() / 3600 horizonte_h = (fim - inicio).total_seconds() / 3600
bases = list(BASES_COORDS.keys()) bases = list(BASES_COORDS.keys())
missoes, horizonte_dt = gerar_missoes_sinteticas( missoes, horizonte_dt = gerar_missoes_sinteticas(
@@ -1430,7 +1560,7 @@ def main() -> None:
] ]
nos, arcos = construir_rede(missoes, aeronaves, catalogo) nos, arcos = construir_rede(missoes, aeronaves, catalogo)
sol = resolver(nos, arcos, aeronaves, catalogo) sol = resolver(nos, arcos, aeronaves, catalogo, time_limit=args.time_limit)
sol["horizonte_dt"] = horizonte_dt sol["horizonte_dt"] = horizonte_dt
escrever_resultado(sol, args.resultado) escrever_resultado(sol, args.resultado)
imprimir_relatorio(sol, bases, args.resultado) imprimir_relatorio(sol, bases, args.resultado)

View File

@@ -234,7 +234,7 @@ def _construir_gantt(linhas: list[dict]) -> str:
) )
shapes = [] shapes = []
hover_traces = [] bar_traces = []
for r in linhas: for r in linhas:
k = r["aeronave"] k = r["aeronave"]
@@ -246,20 +246,15 @@ def _construir_gantt(linhas: list[dict]) -> str:
except Exception: except Exception:
continue continue
ini_dt = datetime.fromisoformat(ini)
fim_dt = datetime.fromisoformat(fim)
if (fim_dt - ini_dt).total_seconds() < 2 * 3600:
fim_dt = ini_dt + timedelta(hours=2)
yi = y_pos[k] yi = y_pos[k]
cor = _cor_gantt(r) cor = _cor_gantt(r)
status = r["status"] status = r["status"]
shapes.append(dict(
type="rect", xref="x", yref="y",
x0=ini, x1=fim,
y0=yi - 0.4, y1=yi + 0.4,
fillcolor=cor,
line=dict(color="white", width=0.5),
opacity=1.0 if status == "inspecao" else 0.85,
layer="above",
))
if status == "inspecao": if status == "inspecao":
txt = ( txt = (
f"<b>FAB {k}</b> — Inspeção {r['om']}<br>" f"<b>FAB {k}</b> — Inspeção {r['om']}<br>"
@@ -276,10 +271,16 @@ def _construir_gantt(linhas: list[dict]) -> str:
f"Chegada: {fim[:16].replace('T',' ')}Z<br>" f"Chegada: {fim[:16].replace('T',' ')}Z<br>"
f"Duração: {r['dur_h']}h" f"Duração: {r['dur_h']}h"
) )
hover_traces.append(go.Scatter(
x=[ini, fim], y=[yi, yi], mode="markers", # Barra como linha horizontal grossa — renderiza garantido no eixo de datas
marker=dict(size=0.1, color=cor, opacity=0), bar_traces.append(go.Scatter(
hovertext=[txt, txt], hoverinfo="text", showlegend=False, x=[ini_dt, fim_dt],
y=[yi, yi],
mode="lines",
line=dict(color=cor, width=64),
hovertext=[txt, txt],
hoverinfo="text",
showlegend=False,
)) ))
# Divisões diárias # Divisões diárias
@@ -318,7 +319,7 @@ def _construir_gantt(linhas: list[dict]) -> str:
name=nome, showlegend=True, name=nome, showlegend=True,
)) ))
for t in hover_traces: for t in bar_traces:
fig.add_trace(t) fig.add_trace(t)
fig.update_layout( fig.update_layout(
@@ -333,6 +334,7 @@ def _construir_gantt(linhas: list[dict]) -> str:
tickvals=list(range(len(aeronaves_ord))), tickvals=list(range(len(aeronaves_ord))),
ticktext=[f"FAB {k}" for k in aeronaves_ord], ticktext=[f"FAB {k}" for k in aeronaves_ord],
showgrid=False, showgrid=False,
range=[-0.5, len(aeronaves_ord) - 0.5],
), ),
height=max(300, 120 + 80 * len(aeronaves_ord)), height=max(300, 120 + 80 * len(aeronaves_ord)),
plot_bgcolor="white", plot_bgcolor="white",