diff --git a/db/processed/f0_cenario_esquadrao.json b/db/processed/f0_cenario_esquadrao.json
new file mode 100644
index 0000000..981db18
--- /dev/null
+++ b/db/processed/f0_cenario_esquadrao.json
@@ -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}
+}
diff --git a/db/processed/f0_cenario_insp_grande.json b/db/processed/f0_cenario_insp_grande.json
new file mode 100644
index 0000000..ff48df5
--- /dev/null
+++ b/db/processed/f0_cenario_insp_grande.json
@@ -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}
+}
diff --git a/software/oamrp_v3.py b/software/oamrp_v3.py
index 919c94a..75836d7 100644
--- a/software/oamrp_v3.py
+++ b/software/oamrp_v3.py
@@ -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)
diff --git a/software/visualizar_resultado.py b/software/visualizar_resultado.py
index 5b7b716..9a05773 100644
--- a/software/visualizar_resultado.py
+++ b/software/visualizar_resultado.py
@@ -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"FAB {k} — Inspeção {r['om']}
"
@@ -276,10 +271,16 @@ def _construir_gantt(linhas: list[dict]) -> str:
f"Chegada: {fim[:16].replace('T',' ')}Z
"
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",