From 3756f961a8b657958340cde87885ebb166441627 Mon Sep 17 00:00:00 2001 From: Cesa-V Date: Tue, 16 Jun 2026 01:48:36 -0300 Subject: [PATCH] Adiciona visualizacao combinada (Gantt + mapa + tabela) e exporta inspecoes no CSV (v0.8) oamrp_v3.py: exporta linhas status=inspecao no CSV resultado com datas UTC reais visualizar_resultado.py: refatorado para gerar um unico HTML (planejamento.html) com cabecalho, Gantt Plotly (divisoes diarias, FAB labels), mapa Folium em iframe e tabela de voos com filtros por aeronave/status/prioridade/base e busca livre Co-Authored-By: Claude Sonnet 4.6 --- software/oamrp_v3.py | 26 ++ software/visualizar_resultado.py | 583 ++++++++++++++++++++++++++----- 2 files changed, 513 insertions(+), 96 deletions(-) diff --git a/software/oamrp_v3.py b/software/oamrp_v3.py index e2de218..919c94a 100644 --- a/software/oamrp_v3.py +++ b/software/oamrp_v3.py @@ -787,6 +787,8 @@ def escrever_resultado(sol: dict, caminho: Path) -> None: missoes_nos = sol["missoes_nos"] linhas = [] + horizonte_dt = sol.get("horizonte_dt") + cobertas_ids: set[str] = set() for k in sol["ks"]: missoes_rota = [n for n in rota_da_aeronave(sol, k) if n.tipo == "missao"] @@ -810,6 +812,29 @@ def escrever_resultado(sol: dict, caminho: Path) -> None: "linhas_origem": m.linhas_origem, }) + # 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({ + "status": "inspecao", + "aeronave": k, + "ordem": "", + "id": f"INSP_{k}_{seq}", + "om": insp.sigla, + "orig": "SBMN", + "dest": "SBMN", + "partida_utc": ini_dt.isoformat().replace("+00:00", "Z"), + "chegada_utc": fim_dt.isoformat().replace("+00:00", "Z"), + "dur_h": f"{insp.dt_h:.2f}", + "prioridade": "", + "periodo": "", + "aeronave_real_2025": "", + "linhas_origem": "", + }) + for nid, n in missoes_nos.items(): if nid in cobertas_ids: continue @@ -1406,6 +1431,7 @@ def main() -> None: nos, arcos = construir_rede(missoes, aeronaves, catalogo) sol = resolver(nos, arcos, aeronaves, catalogo) + 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 9b30d75..5b7b716 100644 --- a/software/visualizar_resultado.py +++ b/software/visualizar_resultado.py @@ -1,29 +1,29 @@ """ Visualização interativa do resultado OAMRP — Esquadrão Arara C-105. -Gera um mapa HTML (Folium) com: - - Marcadores para cada base operacional - - Rotas por aeronave (cor distinta por EVAM) - - Missões não cumpridas em cinza tracejado - - Popup com detalhes de cada missão +Gera um único HTML com título "Planejamento de Diagonal de Manutenção" contendo: + - Gantt (Plotly): calendário aeronave × tempo com voos e inspeções + - Mapa (Folium): rotas por aeronave, missões não cumpridas, bases Uso: python software/visualizar_resultado.py \ --resultado db/processed/resultado_oamrp_sintetico.csv \ - --saida db/processed/mapa_rotas.html + --saida db/processed/planejamento.html """ import argparse import csv +import io +from datetime import datetime, timedelta, timezone +from html import escape from pathlib import Path import folium -from folium import plugins BASE_DIR = Path(__file__).resolve().parents[1] RESULTADO_PADRAO = BASE_DIR / "db" / "processed" / "resultado_oamrp_sintetico.csv" -MAPA_PADRAO = BASE_DIR / "db" / "processed" / "mapa_rotas.html" +SAIDA_PADRAO = BASE_DIR / "db" / "processed" / "planejamento.html" # Coordenadas das bases (lat, lon) BASES_COORDS: dict[str, tuple[float, float]] = { @@ -39,7 +39,6 @@ BASES_COORDS: dict[str, tuple[float, float]] = { "SBMY": (-5.81, -61.28), } -# Nomes legíveis das bases BASES_NOMES = { "SBMN": "Manaus / Ponta Pelada (Hub)", "SBBE": "Belém", @@ -53,21 +52,39 @@ BASES_NOMES = { "SBMY": "Manicoré", } -# Paleta de cores por aeronave CORES_AERONAVE = { - "2800": "#1f77b4", # azul - "2803": "#2ca02c", # verde - "2809": "#d62728", # vermelho - "2811": "#ff7f0e", # laranja + "2800": "#1f77b4", + "2803": "#2ca02c", + "2809": "#d62728", + "2811": "#ff7f0e", } COR_NAO_CUMPRIDA = "#aaaaaa" -COR_HUB = "#d62728" -COR_BASE = "#1f77b4" + +_GANTT_CORES = { + "cumprida": { + "1": "#d62728", + "2": "#ff7f0e", + "3": "#2ca02c", + "4": "#1f77b4", + "5": "#9467bd", + "": "#1f77b4", + }, + "inspecao": "#8c564b", + "nao_cumprida": "#aaaaaa", +} def _cor_prio(prio: str) -> str: - mapa = {"1": "red", "2": "orange", "3": "yellow", "4": "lightblue", "5": "lightgray"} - return mapa.get(str(prio), "white") + return {"1": "red", "2": "orange", "3": "yellow", "4": "lightblue", "5": "lightgray"}.get(str(prio), "white") + + +def _cor_gantt(linha: dict) -> str: + status = linha["status"] + if status == "inspecao": + return _GANTT_CORES["inspecao"] + if status == "nao_cumprida": + return _GANTT_CORES["nao_cumprida"] + return _GANTT_CORES["cumprida"].get(linha.get("prioridade", ""), "#1f77b4") def carregar_resultado(caminho: Path) -> list[dict]: @@ -75,57 +92,49 @@ def carregar_resultado(caminho: Path) -> list[dict]: return list(csv.DictReader(f)) -def adicionar_bases(mapa: folium.Map) -> None: - for icao, (lat, lon) in BASES_COORDS.items(): - cor = COR_HUB if icao == "SBMN" else COR_BASE - icone = folium.Icon(color="red" if icao == "SBMN" else "blue", icon="plane", prefix="fa") - folium.Marker( - location=[lat, lon], - tooltip=f"{icao}
{BASES_NOMES.get(icao, '')}", - popup=folium.Popup(f"{icao}
{BASES_NOMES.get(icao, '')}", max_width=200), - icon=icone, - ).add_to(mapa) +def _resumir_resultado(linhas: list[dict]) -> None: + cumpridas = sum(1 for r in linhas if r["status"] == "cumprida") + total_missoes = sum(1 for r in linhas if r["status"] in ("cumprida", "nao_cumprida")) + print(f" Missões: {cumpridas}/{total_missoes} cumpridas") + aeronaves: dict[str, int] = {} + for r in linhas: + if r["aeronave"] and r["status"] == "cumprida": + aeronaves.setdefault(r["aeronave"], 0) + aeronaves[r["aeronave"]] += 1 + for k, c in sorted(aeronaves.items()): + print(f" FAB {k}: {c} missões") -def gerar_mapa(resultado: Path, saida: Path) -> None: - linhas = carregar_resultado(resultado) +# --------------------------------------------------------------------------- +# Mapa Folium → string HTML +# --------------------------------------------------------------------------- - mapa = folium.Map( - location=[-3.5, -62.0], - zoom_start=5, - tiles="CartoDB positron", - ) +def _construir_mapa(linhas: list[dict]) -> str: + mapa = folium.Map(location=[-3.5, -62.0], zoom_start=5, tiles="CartoDB positron") - # Grupos por aeronave + grupo para não cumpridas aeronaves_vistas = sorted({r["aeronave"] for r in linhas if r["aeronave"]}) grupos: dict[str, folium.FeatureGroup] = {} for k in aeronaves_vistas: - grupos[k] = folium.FeatureGroup(name=f"EVAM {k}", show=True) + grupos[k] = folium.FeatureGroup(name=f"FAB {k}", show=True) grupos["nao_cumprida"] = folium.FeatureGroup(name="Não cumpridas", show=True) - cumpridas = [r for r in linhas if r["status"] == "cumprida"] - nao_cumpridas = [r for r in linhas if r["status"] != "cumprida"] - - # Rotas cumpridas - for r in cumpridas: - orig = r["orig"] - dest = r["dest"] + for r in linhas: + if r["status"] != "cumprida": + continue + orig, dest = r["orig"], r["dest"] if orig not in BASES_COORDS or dest not in BASES_COORDS: continue - lat_o, lon_o = BASES_COORDS[orig] - lat_d, lon_d = BASES_COORDS[dest] k = r["aeronave"] cor = CORES_AERONAVE.get(k, "#333333") + lat_o, lon_o = BASES_COORDS[orig] + lat_d, lon_d = BASES_COORDS[dest] - # Linha de rota folium.PolyLine( locations=[[lat_o, lon_o], [lat_d, lon_d]], - color=cor, - weight=2.5, - opacity=0.8, - tooltip=f"EVAM {k} | {orig}→{dest} | OM {r['om']} | prio {r['prioridade']}", + color=cor, weight=2.5, opacity=0.8, + tooltip=f"FAB {k} | {orig}→{dest} | OM {r['om']} | prio {r['prioridade']}", popup=folium.Popup( - f"EVAM {k} — Missão #{r['ordem']}
" + f"FAB {k} — Missão #{r['ordem']}
" f"{orig} → {dest}
" f"OM: {r['om']} | Prio: {r['prioridade']}
" f"Partida: {r['partida_utc'][:16].replace('T',' ')}Z
" @@ -134,43 +143,38 @@ def gerar_mapa(resultado: Path, saida: Path) -> None: max_width=250, ), ).add_to(grupos[k]) - - # Marcador de origem (pequeno círculo) folium.CircleMarker( - location=[lat_o, lon_o], - radius=4, - color=cor, - fill=True, - fill_color=_cor_prio(r["prioridade"]), - fill_opacity=0.9, + location=[lat_o, lon_o], radius=4, color=cor, + fill=True, fill_color=_cor_prio(r["prioridade"]), fill_opacity=0.9, tooltip=f"{orig} | prio {r['prioridade']}", ).add_to(grupos[k]) - # Missões não cumpridas (linha tracejada cinza) - for r in nao_cumpridas: - orig = r["orig"] - dest = r["dest"] - if orig not in BASES_COORDS or dest not in BASES_COORDS: - continue - lat_o, lon_o = BASES_COORDS[orig] - lat_d, lon_d = BASES_COORDS[dest] - folium.PolyLine( - locations=[[lat_o, lon_o], [lat_d, lon_d]], - color=COR_NAO_CUMPRIDA, - weight=1.5, - opacity=0.5, - dash_array="6 4", - tooltip=f"NÃO CUMPRIDA | {orig}→{dest} | OM {r['om']} | prio {r['prioridade']}", - ).add_to(grupos["nao_cumprida"]) + for r in linhas: + if r["status"] == "nao_cumprida": + orig, dest = r["orig"], r["dest"] + if orig not in BASES_COORDS or dest not in BASES_COORDS: + continue + lat_o, lon_o = BASES_COORDS[orig] + lat_d, lon_d = BASES_COORDS[dest] + folium.PolyLine( + locations=[[lat_o, lon_o], [lat_d, lon_d]], + color=COR_NAO_CUMPRIDA, weight=1.5, opacity=0.5, dash_array="6 4", + tooltip=f"NÃO CUMPRIDA | {orig}→{dest} | OM {r['om']} | prio {r['prioridade']}", + ).add_to(grupos["nao_cumprida"]) - # Adiciona grupos ao mapa for g in grupos.values(): g.add_to(mapa) - adicionar_bases(mapa) + for icao, (lat, lon) in BASES_COORDS.items(): + folium.Marker( + location=[lat, lon], + tooltip=f"{icao}
{BASES_NOMES.get(icao, '')}", + popup=folium.Popup(f"{icao}
{BASES_NOMES.get(icao, '')}", max_width=200), + icon=folium.Icon(color="red" if icao == "SBMN" else "blue", icon="plane", prefix="fa"), + ).add_to(mapa) + folium.LayerControl(collapsed=False).add_to(mapa) - # Legenda HTML legenda_html = """
' - f'EVAM {k}
' + f'FAB {k}
' ) legenda_html += """
Prioridade (preenchimento):
@@ -201,31 +205,418 @@ def gerar_mapa(resultado: Path, saida: Path) -> None: """ mapa.get_root().html.add_child(folium.Element(legenda_html)) - saida.parent.mkdir(parents=True, exist_ok=True) - mapa.save(str(saida)) - print(f" Mapa salvo: {saida}") - _resumir_resultado(linhas) + return mapa.get_root().render() -def _resumir_resultado(linhas: list[dict]) -> None: - total = len(linhas) - cumpridas = sum(1 for r in linhas if r["status"] == "cumprida") - print(f" Missões: {cumpridas}/{total} cumpridas") - aeronaves = {} +# --------------------------------------------------------------------------- +# Gantt Plotly → string HTML (só o div, sem ) +# --------------------------------------------------------------------------- + +def _construir_gantt(linhas: list[dict]) -> str: + try: + import plotly.graph_objects as go + except ImportError: + return "

plotly não instalado — instale com: pip install plotly

" + + aeronaves_ord = sorted( + {r["aeronave"] for r in linhas if r["aeronave"]}, + key=lambda k: int(k) if k.isdigit() else 0, + ) + y_pos = {k: i for i, k in enumerate(aeronaves_ord)} + + fig = go.Figure() + + for i, k in enumerate(aeronaves_ord): + fig.add_hrect( + y0=i - 0.45, y1=i + 0.45, + fillcolor="#f0f0f0" if i % 2 == 0 else "white", + layer="below", line_width=0, + ) + + shapes = [] + hover_traces = [] + for r in linhas: - if r["aeronave"]: - aeronaves.setdefault(r["aeronave"], 0) - aeronaves[r["aeronave"]] += 1 - for k, c in sorted(aeronaves.items()): - print(f" EVAM {k}: {c} missões") + k = r["aeronave"] + if not k or k not in y_pos: + continue + try: + ini = r["partida_utc"].replace("Z", "+00:00") + fim = r["chegada_utc"].replace("Z", "+00:00") + except Exception: + continue + + 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']}
" + f"Início: {ini[:16].replace('T',' ')}Z
" + f"Fim: {fim[:16].replace('T',' ')}Z
" + f"Duração: {r['dur_h']}h" + ) + else: + txt = ( + f"FAB {k} — Missão #{r['ordem']}
" + f"{r['orig']} → {r['dest']}
" + f"OM: {r['om']} | Prio: {r['prioridade']}
" + f"Partida: {ini[:16].replace('T',' ')}Z
" + 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, + )) + + # Divisões diárias + datas_utc = [ + r[campo].replace("Z", "+00:00") + for r in linhas + for campo in ("partida_utc", "chegada_utc") + if r.get(campo) + ] + if datas_utc: + t_min = datetime.fromisoformat(min(datas_utc)) + t_max = datetime.fromisoformat(max(datas_utc)) + dia = datetime(t_min.year, t_min.month, t_min.day, tzinfo=timezone.utc) + while dia <= t_max + timedelta(days=1): + shapes.append(dict( + type="line", xref="x", yref="paper", + x0=dia.isoformat(), x1=dia.isoformat(), + y0=0, y1=1, + line=dict(color="#bbbbbb", width=1, dash="dot"), + layer="above", + )) + dia += timedelta(days=1) + + # Legenda + for nome, cor in [ + ("Prio 1", _GANTT_CORES["cumprida"]["1"]), + ("Prio 2", _GANTT_CORES["cumprida"]["2"]), + ("Prio 3", _GANTT_CORES["cumprida"]["3"]), + ("Prio 4", _GANTT_CORES["cumprida"]["4"]), + ("Prio 5", _GANTT_CORES["cumprida"]["5"]), + ("Inspeção", _GANTT_CORES["inspecao"]), + ]: + fig.add_trace(go.Scatter( + x=[None], y=[None], mode="markers", + marker=dict(size=12, color=cor, symbol="square"), + name=nome, showlegend=True, + )) + + for t in hover_traces: + fig.add_trace(t) + + fig.update_layout( + shapes=shapes, + xaxis=dict( + title="Data / Hora (UTC)", type="date", + tickformat="%d/%m", dtick=86400000, + showgrid=True, gridcolor="#dddddd", + ), + yaxis=dict( + title="Aeronave", + tickvals=list(range(len(aeronaves_ord))), + ticktext=[f"FAB {k}" for k in aeronaves_ord], + showgrid=False, + ), + height=max(300, 120 + 80 * len(aeronaves_ord)), + plot_bgcolor="white", + paper_bgcolor="white", + legend=dict(title="Legenda", orientation="v", x=1.01, y=1, xanchor="left"), + margin=dict(l=100, r=160, t=10, b=60), + hovermode="closest", + ) + + return fig.to_html(full_html=False, include_plotlyjs="cdn") + + +# --------------------------------------------------------------------------- +# Tabela de voos com filtros +# --------------------------------------------------------------------------- + +def _construir_tabela(linhas: list[dict]) -> str: + import json + + # Monta lista de registros para a tabela (missões + inspeções, sem nao_cumprida sem aeronave) + registros = [] + for r in linhas: + status = r["status"] + k = r["aeronave"] + if status == "nao_cumprida" and not k: + # mantém como linha sem aeronave + k = "—" + registros.append({ + "status": status, + "aeronave": f"FAB {k}" if k and k != "—" else "—", + "ordem": r.get("ordem", ""), + "om": r.get("om", ""), + "orig": r.get("orig", ""), + "dest": r.get("dest", ""), + "partida": r.get("partida_utc", "")[:16].replace("T", " ") + "Z" if r.get("partida_utc") else "", + "chegada": r.get("chegada_utc", "")[:16].replace("T", " ") + "Z" if r.get("chegada_utc") else "", + "dur_h": r.get("dur_h", ""), + "prioridade": r.get("prioridade", ""), + }) + + dados_json = json.dumps(registros, ensure_ascii=False) + + opcoes_aeronave = sorted({r["aeronave"] for r in registros if r["aeronave"] != "—"}) + opcoes_status = ["cumprida", "nao_cumprida", "inspecao"] + opcoes_prio = sorted({r["prioridade"] for r in registros if r["prioridade"]}) + opcoes_base = sorted({b for r in registros for b in (r["orig"], r["dest"]) if b}) + + def opts(valores: list[str], label: str) -> str: + html = f'' + for v in valores: + html += f'' + return html + + return f""" + + +
+ + + + + + +
+
+ + + + + + + + + + + + + + + + +
Aeronave Status #Missão OM Origem Destino Partida (UTC) Chegada (UTC) Duração (h) Prio
+ +""" + + +# --------------------------------------------------------------------------- +# HTML combinado +# --------------------------------------------------------------------------- + +def gerar_planejamento(resultado: Path, saida: Path) -> None: + linhas = carregar_resultado(resultado) + + gantt_div = _construir_gantt(linhas) + mapa_html = _construir_mapa(linhas) + tabela_div = _construir_tabela(linhas) + + html = f""" + + + + Planejamento de Diagonal de Manutenção + + + +
+

Planejamento de Diagonal de Manutenção

+

Esquadrão Arara · C-105 · OAMRP v3

+
+
+
+
Calendário de Emprego das Aeronaves
+
{gantt_div}
+
+
+
Rotas Operacionais
+ +
+
+
Planejamento de Voos
+
{tabela_div}
+
+
+ +""" + + saida.parent.mkdir(parents=True, exist_ok=True) + saida.write_text(html, encoding="utf-8") + print(f" Planejamento salvo: {saida}") + _resumir_resultado(linhas) def main() -> None: parser = argparse.ArgumentParser(description="Visualização OAMRP — Arara C-105") parser.add_argument("--resultado", type=Path, default=RESULTADO_PADRAO) - parser.add_argument("--saida", type=Path, default=MAPA_PADRAO) + parser.add_argument("--saida", type=Path, default=SAIDA_PADRAO) args = parser.parse_args() - gerar_mapa(resultado=args.resultado, saida=args.saida) + gerar_planejamento(resultado=args.resultado, saida=args.saida) if __name__ == "__main__":