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",