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:
@@ -43,7 +43,7 @@ import csv
|
||||
import json
|
||||
import random
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Literal, Optional
|
||||
|
||||
@@ -428,6 +428,7 @@ def resolver(
|
||||
arcos: list[Arco],
|
||||
aeronaves: list[Aeronave],
|
||||
catalogo: dict[int, InspecaoParam],
|
||||
**kwargs,
|
||||
) -> dict:
|
||||
ks = [a.id 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
|
||||
# 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)
|
||||
prio_otima = int(round(pulp.value(mdl.objective) or 0))
|
||||
|
||||
@@ -696,7 +698,7 @@ def resolver(
|
||||
)
|
||||
mdl.sense = pulp.LpMaximize
|
||||
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 {
|
||||
"modelo": mdl,
|
||||
@@ -815,7 +817,7 @@ def escrever_resultado(sol: dict, caminho: Path) -> None:
|
||||
# Inspeções executadas por esta aeronave
|
||||
if horizonte_dt is not None:
|
||||
for seq, s_val, insp in inspecoes_da_aeronave(sol, k):
|
||||
from datetime import timedelta
|
||||
|
||||
ini_dt = horizonte_dt + timedelta(hours=s_val)
|
||||
fim_dt = horizonte_dt + timedelta(hours=s_val + insp.dt_h)
|
||||
linhas.append({
|
||||
@@ -1158,18 +1160,37 @@ def derivar_bases(
|
||||
# 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]] = {
|
||||
"SBMN": (-3.15, -59.99), # Manaus — Ponta Pelada (hub)
|
||||
"SBBE": (-1.38, -48.48), # Belém
|
||||
"SBBV": ( 2.84, -60.69), # Boa Vista
|
||||
"SBSN": (-2.42, -54.79), # Santarém
|
||||
"SBPV": (-8.71, -63.90), # Porto Velho
|
||||
"SBTS": (-4.25, -69.94), # Tabatinga
|
||||
"SBTT": (-3.38, -64.72), # Tefé
|
||||
"SBUA": (-0.15, -67.05), # São Gabriel da Cachoeira
|
||||
"SWBC": (-0.98, -62.92), # Barcelos
|
||||
"SBMY": (-5.81, -61.28), # Manicoré
|
||||
# Amazônia (bases primárias)
|
||||
"SBMN": (-3.15, -59.99), # Manaus — Ponta Pelada (hub)
|
||||
"SBBE": (-1.38, -48.48), # Belém
|
||||
"SBBV": ( 2.84, -60.69), # Boa Vista
|
||||
"SBSN": (-2.42, -54.79), # Santarém
|
||||
"SBPV": (-8.71, -63.90), # Porto Velho
|
||||
"SBTS": (-4.25, -69.94), # Tabatinga
|
||||
"SBTT": (-3.38, -64.72), # Tefé
|
||||
"SBUA": (-0.15, -67.05), # São Gabriel da Cachoeira
|
||||
"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)
|
||||
@@ -1187,6 +1208,103 @@ def _haversine_km(b1: str, b2: str) -> float:
|
||||
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"]
|
||||
|
||||
# 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,
|
||||
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(
|
||||
"--sintetico", action="store_true",
|
||||
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",
|
||||
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()
|
||||
|
||||
inicio = datetime.fromisoformat(args.inicio).replace(tzinfo=timezone.utc)
|
||||
@@ -1394,7 +1520,11 @@ def main() -> None:
|
||||
|
||||
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
|
||||
bases = list(BASES_COORDS.keys())
|
||||
missoes, horizonte_dt = gerar_missoes_sinteticas(
|
||||
@@ -1430,7 +1560,7 @@ def main() -> None:
|
||||
]
|
||||
|
||||
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
|
||||
escrever_resultado(sol, args.resultado)
|
||||
imprimir_relatorio(sol, bases, args.resultado)
|
||||
|
||||
@@ -234,7 +234,7 @@ def _construir_gantt(linhas: list[dict]) -> str:
|
||||
)
|
||||
|
||||
shapes = []
|
||||
hover_traces = []
|
||||
bar_traces = []
|
||||
|
||||
for r in linhas:
|
||||
k = r["aeronave"]
|
||||
@@ -246,20 +246,15 @@ def _construir_gantt(linhas: list[dict]) -> str:
|
||||
except Exception:
|
||||
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]
|
||||
cor = _cor_gantt(r)
|
||||
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":
|
||||
txt = (
|
||||
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"Duração: {r['dur_h']}h"
|
||||
)
|
||||
hover_traces.append(go.Scatter(
|
||||
x=[ini, fim], y=[yi, yi], mode="markers",
|
||||
marker=dict(size=0.1, color=cor, opacity=0),
|
||||
hovertext=[txt, txt], hoverinfo="text", showlegend=False,
|
||||
|
||||
# Barra como linha horizontal grossa — renderiza garantido no eixo de datas
|
||||
bar_traces.append(go.Scatter(
|
||||
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
|
||||
@@ -318,7 +319,7 @@ def _construir_gantt(linhas: list[dict]) -> str:
|
||||
name=nome, showlegend=True,
|
||||
))
|
||||
|
||||
for t in hover_traces:
|
||||
for t in bar_traces:
|
||||
fig.add_trace(t)
|
||||
|
||||
fig.update_layout(
|
||||
@@ -333,6 +334,7 @@ def _construir_gantt(linhas: list[dict]) -> str:
|
||||
tickvals=list(range(len(aeronaves_ord))),
|
||||
ticktext=[f"FAB {k}" for k in aeronaves_ord],
|
||||
showgrid=False,
|
||||
range=[-0.5, len(aeronaves_ord) - 0.5],
|
||||
),
|
||||
height=max(300, 120 + 80 * len(aeronaves_ord)),
|
||||
plot_bgcolor="white",
|
||||
|
||||
Reference in New Issue
Block a user