Files
aircraftrouting_arara/app/dashboard.py
Eduardo Carlos 3607965c88 Add OARMP routing engine, dashboard and documentation
- Complete routing engine: ingest, optimizer (CG+B&B), maintenance
  monitor, metrics, pipeline, quality checks
- Streamlit dashboard with Input/Output tab structure, editable data
  editors, interactive Folium map with satellite layer and maintenance
  base highlights, FH stacked bar chart with TTM availability
- CSV data files: AERONAVES, CHECKS, AIRPORTS, ESCALA DE VOO
- README, CONTEXTO and CHANGELOG added
- Remove legacy pre_process scripts and raw binary files (PDFs/xlsx)
- Update .gitignore to exclude outputs/, data/, raw/*.pdf, raw/*.xlsx

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 11:52:34 -03:00

756 lines
35 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Aircraft Routing Dashboard Streamlit application (v2).
Run with: streamlit run app/dashboard.py (from project root)
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import pandas as pd
import plotly.express as px
import plotly.graph_objects as go
import streamlit as st
from src.routing_engine import DEFAULT_CONFIG, RoutingPipeline
# ── Page config ───────────────────────────────────────────────────────────────
st.set_page_config(
page_title="OARMP Aircraft Routing",
page_icon="",
layout="wide",
)
st.title("✈ Aircraft Routing & Maintenance Planning (OARMP)")
st.caption("Set Partitioning + Column Generation + Branch & Bound")
# ── Constants ─────────────────────────────────────────────────────────────────
_RAW = Path(__file__).resolve().parents[1] / "raw"
_SCHED_COLS = ["DATA", "ETAPA", "DEP", "ARR", "HORA_DEP", "HORA_ARR", "TEMPO_VOO", "SEGMTO", "MISSAO", "OFRAG"]
def _read_csv(filename: str, skiprows: int = 0, names=None) -> pd.DataFrame:
path = _RAW / filename
if not path.exists():
return pd.DataFrame(columns=names or [])
for enc in ("utf-8-sig", "utf-8", "latin-1", "cp1252"):
try:
return pd.read_csv(path, sep=";", encoding=enc, skiprows=skiprows, names=names, dtype=str)
except Exception:
continue
return pd.DataFrame(columns=names or [])
# ── Session-state defaults ────────────────────────────────────────────────────
def _init_state():
if "df_aircraft" not in st.session_state:
st.session_state["df_aircraft"] = _read_csv("AERONAVES.csv")
if "df_checks" not in st.session_state:
st.session_state["df_checks"] = _read_csv("CHECKS.csv")
if "df_airports" not in st.session_state:
st.session_state["df_airports"] = _read_csv("AIRPORTS.csv")
if "df_schedule" not in st.session_state:
_BR_MON = {"jan":"01","fev":"02","mar":"03","abr":"04","mai":"05","jun":"06",
"jul":"07","ago":"08","set":"09","out":"10","nov":"11","dez":"12"}
_DEFAULT_YEAR = 2026
def _to_dmy(val: str) -> str:
parts = str(val).strip().split("/")
if len(parts) == 3:
return val # already DD/MM/AAAA
if len(parts) == 2:
mon = _BR_MON.get(parts[1].lower())
if mon:
return f"{int(parts[0]):02d}/{mon}/{_DEFAULT_YEAR}"
return val
df = _read_csv("ESCALA DE VOO MODELO 1.csv", skiprows=2, names=_SCHED_COLS)
df = df[df["DATA"].notna() & (df["DATA"].str.strip() != "")]
df = df[df["OFRAG"].notna() & (df["OFRAG"].str.strip() != "")]
df["DATA"] = df["DATA"].apply(_to_dmy)
st.session_state["df_schedule"] = df.reset_index(drop=True)
if "result" not in st.session_state:
st.session_state["result"] = None
_init_state()
# ── Main tabs ─────────────────────────────────────────────────────────────────
tab_input, tab_output = st.tabs(["📥 Input", "📤 Output"])
# ══════════════════════════════════════════════════════════════════════════════
# INPUT TAB
# ══════════════════════════════════════════════════════════════════════════════
with tab_input:
_col_solver, _col_btn, _ = st.columns([1, 1, 4])
with _col_solver:
time_limit = st.number_input(
"Tempo solver (s)",
min_value=30, max_value=600, value=st.session_state.get("time_limit", 120),
step=10, key="time_limit",
)
with _col_btn:
st.write("") # alinhamento vertical
run_btn = st.button("▶ Executar Otimização", type="primary", use_container_width=True)
st.divider()
sub_sched, sub_aircraft, sub_checks, sub_bases = st.tabs(
["📋 Escala de Voo", "✈ Aeronaves", "🔧 Checks", "🏭 Bases de Manutenção"]
)
with sub_aircraft:
st.subheader("Aeronaves")
st.caption("Matrícula, modelo e horas de voo totais acumuladas de cada aeronave.")
tat = st.number_input(
"TAT mínimo (min)",
min_value=0, max_value=240,
value=st.session_state.get("tat", 60),
step=10,
key="tat",
)
edited = st.data_editor(
st.session_state["df_aircraft"],
num_rows="dynamic",
use_container_width=True,
key="editor_aircraft",
column_config={
"MATRICULA": st.column_config.TextColumn("Matrícula"),
"MODELO": st.column_config.TextColumn("Modelo"),
"FH TOTAIS": st.column_config.NumberColumn("FH Totais", min_value=0, format="%.0f"),
},
)
st.session_state["df_aircraft"] = edited
with sub_checks:
st.subheader("Checks de Manutenção")
st.caption("Tipos de check, limiar de FH, duração em dias e local de execução.")
edited = st.data_editor(
st.session_state["df_checks"],
num_rows="dynamic",
use_container_width=True,
key="editor_checks",
column_config={
"CHECKS": st.column_config.TextColumn("Denominação do Check"),
"FH": st.column_config.NumberColumn("FH Limite", min_value=0, format="%.0f"),
"TEMPO DE EXECUCAO (DIAS)": st.column_config.NumberColumn("Duração (dias)", min_value=0),
"LOCAL DE EXECUCAO": st.column_config.TextColumn("Local (ICAO)"),
},
)
st.session_state["df_checks"] = edited
with sub_bases:
st.subheader("Aeroportos e Bases de Manutenção")
st.caption(
"Lista de aeroportos utilizados. Defina IS_MAINTENANCE_BASE = 1 "
"para os aeroportos que são base de manutenção."
)
edited = st.data_editor(
st.session_state["df_airports"],
num_rows="dynamic",
use_container_width=True,
key="editor_airports",
column_config={
"AIRPORT_CODE": st.column_config.TextColumn("Código ICAO"),
"AIRPORT_NAME": st.column_config.TextColumn("Nome"),
"LATITUDE": st.column_config.NumberColumn("Latitude", format="%.4f"),
"LONGITUDE": st.column_config.NumberColumn("Longitude", format="%.4f"),
"IS_MAINTENANCE_BASE": st.column_config.SelectboxColumn(
"Base de Manutenção?", options=["0", "1"]
),
"COUNTRY": st.column_config.TextColumn("País"),
},
)
st.session_state["df_airports"] = edited
with sub_sched:
st.subheader("Escala de Voo")
st.caption(
"Etapas agrupadas em OFRAGs. "
"Cada OFRAG deve iniciar e terminar na base de manutenção para ser elegível."
)
edited = st.data_editor(
st.session_state["df_schedule"],
num_rows="dynamic",
use_container_width=True,
key="editor_schedule",
column_config={
"DATA": st.column_config.TextColumn("Data (DD/MM/AAAA)"),
"ETAPA": st.column_config.NumberColumn("Etapa", min_value=1),
"DEP": st.column_config.TextColumn("Partida (ICAO)"),
"ARR": st.column_config.TextColumn("Destino (ICAO)"),
"HORA_DEP": st.column_config.TextColumn("Hora Dep (Z)"),
"HORA_ARR": st.column_config.TextColumn("Hora Arr (Z)"),
"TEMPO_VOO": st.column_config.TextColumn("Tempo Voo"),
"SEGMTO": st.column_config.NumberColumn("Segmento"),
"MISSAO": st.column_config.TextColumn("Missão"),
"OFRAG": st.column_config.NumberColumn("OFRAG", min_value=1),
},
)
st.session_state["df_schedule"] = edited
# Controles de adição em lote e limpeza
_c1, _c2, _c3, _ = st.columns([1, 1, 1, 3])
with _c1:
n_add = st.number_input(
"Qtd. de linhas", min_value=1, max_value=100, value=1, step=1,
key="n_add_sched",
)
with _c2:
st.write("")
if st.button(" Adicionar", key="btn_add_sched", use_container_width=True):
empty = pd.DataFrame([{col: None for col in _SCHED_COLS}] * int(n_add))
st.session_state["df_schedule"] = pd.concat(
[st.session_state["df_schedule"], empty], ignore_index=True
)
st.rerun()
with _c3:
st.write("")
if st.button("🗑 Limpar tudo", key="btn_clear_sched", use_container_width=True):
st.session_state["df_schedule"] = pd.DataFrame(columns=_SCHED_COLS)
st.rerun()
# ── Run pipeline (after input tabs so session state is current) ───────────────
if run_btn:
cfg = DEFAULT_CONFIG
cfg.tat_minutes = int(st.session_state.get("tat", 60))
cfg.planning_year = pd.Timestamp.now().year
cfg.mip_time_limit_seconds = int(time_limit)
raw_dfs = {
"aircraft": st.session_state["df_aircraft"],
"checks": st.session_state["df_checks"],
"airports": st.session_state["df_airports"],
"schedule": st.session_state["df_schedule"],
}
with st.spinner("Executando otimização…"):
try:
import time as _time
_t0 = _time.perf_counter()
pipe = RoutingPipeline(cfg)
st.session_state["result"] = pipe.run(save_outputs=True, raw_dfs=raw_dfs)
_elapsed = _time.perf_counter() - _t0
_mins, _secs = divmod(int(_elapsed), 60)
_dur = f"{_mins}m {_secs}s" if _mins else f"{_secs}s"
st.success(f"Otimização concluída em {_dur}.")
except Exception as exc:
st.error(f"Erro: {exc}")
st.exception(exc)
result = st.session_state.get("result")
# ══════════════════════════════════════════════════════════════════════════════
# OUTPUT TAB
# ══════════════════════════════════════════════════════════════════════════════
with tab_output:
if result is None:
st.info("Configure o cenário na aba Input e execute a otimização.")
else:
(
sub_resumo,
sub_escala,
sub_maint,
sub_frota,
sub_mapa,
) = st.tabs(
[
"📊 Resumo",
"📋 Escala com Atribuição",
"🔧 Manutenção e Aeronaves",
"✈ Frota",
"🗺 Mapa",
]
)
# ── Escala com atribuição (construída uma vez, reutilizada em sub_escala e sub_mapa) ──
_sched_result = result["schedule"]
_raw = st.session_state["df_schedule"].copy()
_ofrag_map: dict[int, str] = {}
for _, _r in _sched_result.iterrows():
try:
_ofrag_map[int(_r["ofrag_id"].replace("OFRAG", ""))] = _r["aircraft"]
except Exception:
pass
_raw.insert(
0, "AERONAVE",
_raw["OFRAG"].apply(
lambda x: _ofrag_map.get(int(str(x).strip()), "")
if str(x).strip().isdigit() else ""
),
)
raw_sched_atrib = _raw # DataFrame completo com coluna AERONAVE
# ── Resumo + Gantt ────────────────────────────────────────────────────
with sub_resumo:
s = result["summary"]
c1, c2, c3, c4 = st.columns(4)
c1.metric("Status", s["status"])
c2.metric("FH Perdidas (objetivo)", f"{s['total_ttm_loss_hours']:.2f} h")
c3.metric("OFRAGs cobertas", f"{s['covered_ofrags']} / {s['total_ofrags']}")
c4.metric("Eventos de manutenção", s["n_maintenance_events"])
if s["uncovered_ofrags"]:
st.warning(f"OFRAGs NÃO cobertas: {s['uncovered_ofrags']}")
qc = result.get("quality", {})
if qc.get("issues"):
with st.expander("Avisos de qualidade de dados"):
for issue in qc["issues"]:
st.warning(issue)
st.caption(f"Colunas geradas no CG: {s['columns_generated']}")
st.divider()
sched = result["schedule"]
maint = result["maintenance"]
gantt_rows = []
for _, row in sched.iterrows():
if pd.isna(row.get("departure")) or pd.isna(row.get("arrival")):
continue
gantt_rows.append(
dict(
Task=row["aircraft"],
Start=row["departure"],
Finish=row["arrival"],
Type="OFRAG",
Label=row["ofrag_id"],
)
)
for _, row in maint.iterrows():
if pd.isna(row.get("maint_start")) or pd.isna(row.get("maint_end")):
continue
gantt_rows.append(
dict(
Task=row["aircraft"],
Start=row["maint_start"],
Finish=row["maint_end"],
Type="Manutenção",
Label=f"CHECK {row['fh_threshold']:.0f}h (perde {row['ttm_loss_hours']:.1f}h)",
)
)
if gantt_rows:
df_g = pd.DataFrame(gantt_rows)
fig = px.timeline(
df_g,
x_start="Start",
x_end="Finish",
y="Task",
color="Type",
text="Label",
title="Escala de Voo e Manutenção por Aeronave",
color_discrete_map={"OFRAG": "#00CC96", "Manutenção": "#EF553B"},
)
fig.update_yaxes(categoryorder="category ascending")
fig.update_traces(textposition="inside")
n_ac = df_g["Task"].nunique()
fig.update_layout(height=420 + n_ac * 40)
st.plotly_chart(fig, use_container_width=True)
try:
fig.write_html(str(DEFAULT_CONFIG.figures_dir / "gantt.html"))
except Exception:
pass
else:
st.info("Nenhum dado de Gantt disponível.")
# ── Escala com Atribuição ─────────────────────────────────────────────
with sub_escala:
st.subheader("Escala de Voo com Atribuição de Aeronaves")
if _sched_result.empty:
st.info("Sem escala otimizada disponível.")
else:
st.dataframe(raw_sched_atrib, use_container_width=True)
# ── Manutenção ────────────────────────────────────────────────────────
with sub_maint:
st.subheader("Eventos de Manutenção Planejados")
maint = result["maintenance"]
if maint.empty:
st.success("Nenhum evento de manutenção forçado no período planejado.")
else:
# Enrich with check name and location from input data
checks_input = st.session_state["df_checks"].copy()
# Identify relevant columns by position / name patterns
_fh_col = next(
(c for c in checks_input.columns if "FH" in c.upper() and "TEMPO" not in c.upper()),
None,
)
_loc_col = next((c for c in checks_input.columns if "LOCAL" in c.upper()), None)
_name_col = checks_input.columns[0] if len(checks_input.columns) > 0 else None
maint_disp = maint.copy()
if _fh_col:
checks_input[_fh_col] = pd.to_numeric(checks_input[_fh_col], errors="coerce")
if _loc_col:
fh_to_loc = checks_input.dropna(subset=[_fh_col]).set_index(_fh_col)[_loc_col].to_dict()
maint_disp["local_execucao"] = maint_disp["fh_threshold"].map(fh_to_loc)
if _name_col:
fh_to_name = checks_input.dropna(subset=[_fh_col]).set_index(_fh_col)[_name_col].to_dict()
maint_disp["check_nome"] = maint_disp["fh_threshold"].map(fh_to_name)
ordered_cols = [
"aircraft",
"check_nome",
"local_execucao",
"fh_threshold",
"accum_fh_at_check",
"maint_start",
"maint_end",
"ttm_loss_hours",
]
show_cols = [c for c in ordered_cols if c in maint_disp.columns]
rename_map = {
"aircraft": "Aeronave",
"check_nome": "Check",
"local_execucao": "Local",
"fh_threshold": "FH Limite",
"accum_fh_at_check": "FH na Manutenção",
"maint_start": "Início",
"maint_end": "Término",
"ttm_loss_hours": "FH Perdidas",
}
st.dataframe(
maint_disp[show_cols].rename(columns=rename_map),
use_container_width=True,
)
col_a, col_b = st.columns(2)
with col_a:
fig_loss = px.bar(
maint_disp,
x="aircraft",
y="ttm_loss_hours",
title="FH de TTM perdidas por aeronave / ciclo",
labels={"ttm_loss_hours": "TTM perdido (h)", "aircraft": "Aeronave"},
text_auto=".2f",
color_discrete_sequence=["#ef4444"],
)
st.plotly_chart(fig_loss, use_container_width=True)
with col_b:
fig_fh_chk = px.bar(
maint_disp,
x="aircraft",
y="accum_fh_at_check",
color="check_cycle_index",
title="FH acumuladas na manutenção por aeronave",
labels={"accum_fh_at_check": "FH acumuladas", "aircraft": "Aeronave"},
text_auto=".1f",
)
st.plotly_chart(fig_fh_chk, use_container_width=True)
# ── Gráfico FH por aeronave + horas disponíveis ───────────────────
st.divider()
st.subheader("Horas de Voo por Aeronave")
fleet_df = result["fleet"]
maint_df = result["maintenance"]
ac_input = st.session_state["df_aircraft"].copy()
_mat_col = next(
(c for c in ac_input.columns if any(k in c.upper() for k in ("MATRICULA", "TAIL", "AC"))),
ac_input.columns[0],
)
_fh_col_ac = next((c for c in ac_input.columns if "FH" in c.upper()), None)
fh_lookup: dict = {}
if _fh_col_ac:
ac_input["_fh"] = pd.to_numeric(ac_input[_fh_col_ac], errors="coerce").fillna(0)
fh_lookup = ac_input.set_index(_mat_col)["_fh"].to_dict()
checks_inp = st.session_state["df_checks"].copy()
_ck_fh_col = next(
(c for c in checks_inp.columns if "FH" in c.upper() and "TEMPO" not in c.upper()), None
)
_thresholds = sorted(
pd.to_numeric(checks_inp[_ck_fh_col], errors="coerce").dropna().tolist()
) if _ck_fh_col else []
def _next_ttm(done_threshold: float) -> float:
for i, t in enumerate(_thresholds):
if abs(t - done_threshold) < 0.01:
if i + 1 < len(_thresholds):
return _thresholds[i + 1] - t
return t - (_thresholds[i - 1] if i > 0 else 0)
return 0.0
# all_maint: all events per aircraft sorted by time
all_maint: dict = {}
first_maint: dict = {}
if not maint_df.empty:
for ac, grp in maint_df.sort_values("maint_start").groupby("aircraft"):
events = grp[["accum_fh_at_check", "fh_threshold"]].to_dict("records")
all_maint[ac] = events
first_maint[ac] = {
"fh_antes": events[0]["accum_fh_at_check"],
"threshold": events[0]["fh_threshold"],
}
fleet_plot = fleet_df[["aircraft", "flight_hours_scheduled", "initial_ttm_h"]].copy()
fleet_plot["fh_inicial"] = fleet_plot["aircraft"].map(fh_lookup).fillna(0)
fleet_plot["fh_antes"] = fleet_plot.apply(
lambda r: first_maint[r["aircraft"]]["fh_antes"]
if r["aircraft"] in first_maint else r["flight_hours_scheduled"], axis=1,
)
fleet_plot["fh_apos"] = fleet_plot.apply(
lambda r: max(0.0, r["flight_hours_scheduled"] - first_maint[r["aircraft"]]["fh_antes"])
if r["aircraft"] in first_maint else 0.0, axis=1,
)
# fh_disp: remaining TTM after the LAST maintenance event
# fh_after_last = total FH - sum of all accum_fh_at_check (each event resets the counter)
fleet_plot["fh_disp"] = fleet_plot.apply(
lambda r: max(0.0,
_next_ttm(all_maint[r["aircraft"]][-1]["fh_threshold"])
- max(0.0, r["flight_hours_scheduled"]
- sum(e["accum_fh_at_check"] for e in all_maint[r["aircraft"]]))
) if r["aircraft"] in all_maint
else max(0.0, r["initial_ttm_h"] - r["flight_hours_scheduled"]),
axis=1,
)
fig_fh = go.Figure()
fig_fh.add_trace(go.Bar(
name="FH Iniciais", x=fleet_plot["aircraft"], y=fleet_plot["fh_inicial"],
marker_color="#94a3b8",
text=fleet_plot["fh_inicial"].apply(lambda v: f"{v:.0f}h"), textposition="inside",
))
fig_fh.add_trace(go.Bar(
name="FH Planejadas (antes manut.)", x=fleet_plot["aircraft"], y=fleet_plot["fh_antes"],
marker_color="#3b82f6",
text=fleet_plot["fh_antes"].apply(lambda v: f"{v:.1f}h" if v > 0 else ""), textposition="inside",
))
fig_fh.add_trace(go.Bar(
name="FH Planejadas (após manut.)", x=fleet_plot["aircraft"], y=fleet_plot["fh_apos"],
marker_color="#f59e0b",
text=fleet_plot["fh_apos"].apply(lambda v: f"{v:.1f}h" if v > 0 else ""), textposition="inside",
))
fig_fh.add_trace(go.Bar(
name="FH Disponíveis (próx. manut.)", x=fleet_plot["aircraft"], y=fleet_plot["fh_disp"],
marker_color="#22c55e", opacity=0.55,
text=fleet_plot["fh_disp"].apply(lambda v: f"{v:.1f}h" if v > 0 else ""), textposition="inside",
))
_maint_legend_added = False
for ac, info in first_maint.items():
y_mark = fh_lookup.get(ac, 0) + info["fh_antes"]
fig_fh.add_trace(go.Scatter(
x=[ac], y=[y_mark], mode="markers+text",
marker=dict(symbol="diamond", size=14, color="#ef4444",
line=dict(color="white", width=1.5)),
text=[f"CHECK {info['threshold']:.0f}h"], textposition="top center",
name="Manutenção", showlegend=not _maint_legend_added, legendgroup="maint",
))
_maint_legend_added = True
fig_fh.update_layout(
barmode="stack",
title="Horas de Voo por Aeronave — Histórico + Planejadas + Disponíveis",
xaxis_title="Aeronave", yaxis_title="Horas de Voo (FH)", height=800,
legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1),
)
st.plotly_chart(fig_fh, use_container_width=True)
st.subheader("Horas disponíveis até a próxima manutenção")
disp_cols = st.columns(len(fleet_plot))
for col, (_, row) in zip(disp_cols, fleet_plot.iterrows()):
col.metric(
label=row["aircraft"],
value=f"{row['fh_disp']:.1f} h",
delta=f"limiar: {first_maint[row['aircraft']]['threshold']:.0f}h"
if row["aircraft"] in first_maint else None,
)
# ── Frota ─────────────────────────────────────────────────────────────
with sub_frota:
st.subheader("Frota Utilização das Aeronaves")
fleet_df = result["fleet"].copy()
# ── Métricas gerais ───────────────────────────────────────────────
avg_util = fleet_df["ttm_utilisation_pct"].mean()
total_fh = fleet_df["flight_hours_scheduled"].sum()
n_active = int((~fleet_df["idle"]).sum())
m1, m2, m3, m4 = st.columns(4)
m1.metric("Utilização Média TTM", f"{avg_util:.1f}%")
m2.metric("FH Totais Planejadas", f"{total_fh:.1f} h")
m3.metric("Aeronaves Ativas", f"{n_active} / {len(fleet_df)}")
m4.metric("FH Médias por Aeronave", f"{total_fh / len(fleet_df):.1f} h" if len(fleet_df) else "")
# ── Tabela com linha "Média" ───────────────────────────────────────
numeric_cols = ["initial_ttm_h", "flight_hours_scheduled", "ttm_utilisation_pct",
"n_ofrags_assigned", "n_maintenance_events", "total_ttm_loss_h"]
avg_row = {col: round(fleet_df[col].mean(), 2) for col in numeric_cols if col in fleet_df.columns}
avg_row["aircraft"] = "Média"
avg_row["model"] = ""
avg_row["idle"] = False
fleet_display = pd.concat(
[fleet_df, pd.DataFrame([avg_row])],
ignore_index=True,
)
rename_fleet = {
"aircraft": "Aeronave",
"model": "Modelo",
"initial_ttm_h": "TTM Inicial (h)",
"flight_hours_scheduled": "FH Planejadas",
"ttm_utilisation_pct": "Utilização TTM (%)",
"n_ofrags_assigned": "OFRAGs Atribuídas",
"n_maintenance_events": "Eventos Manut.",
"total_ttm_loss_h": "FH Perdidas",
"idle": "Ociosa",
}
def _style_frota(row):
if row.get("Aeronave") == "Média":
return ["font-weight:bold"] * len(row)
return [""] * len(row)
st.dataframe(
fleet_display.rename(columns=rename_fleet)
.style.apply(_style_frota, axis=1),
use_container_width=True,
)
# ── Gráfico: utilização TTM ───────────────────────────────────────
fig_util = px.bar(
fleet_df,
x="aircraft",
y="ttm_utilisation_pct",
title="Utilização do TTM por aeronave (%)",
labels={"ttm_utilisation_pct": "Utilização TTM (%)", "aircraft": "Aeronave"},
text_auto=".1f",
color="ttm_utilisation_pct",
color_continuous_scale="RdYlGn",
range_color=[0, 100],
)
fig_util.add_hline(
y=avg_util,
line_dash="dash",
line_color="navy",
annotation_text=f"Média: {avg_util:.1f}%",
annotation_position="top right",
)
st.plotly_chart(fig_util, use_container_width=True)
# ── Gráfico: FH detalhado ─────────────────────────────────────────
fig_fh = px.bar(
fleet_df,
x="aircraft",
y=["initial_ttm_h", "flight_hours_scheduled", "total_ttm_loss_h"],
barmode="group",
title="TTM inicial vs FH planejadas vs FH perdidas",
labels={"value": "Horas de voo", "aircraft": "Aeronave", "variable": ""},
)
fig_fh.update_layout(
legend=dict(
orientation="h",
yanchor="bottom",
y=1.02,
xanchor="right",
x=1,
)
)
st.plotly_chart(fig_fh, use_container_width=True)
# ── Mapa ──────────────────────────────────────────────────────────────
with sub_mapa:
st.subheader("Rotas por Aeronave")
if _sched_result.empty:
st.info("Sem escala otimizada disponível.")
else:
import airportsdata as _apdata
_icao_db = _apdata.load("ICAO")
def _coord(icao: str):
ap = _icao_db.get(icao.strip().upper())
return {"_lat": ap["lat"], "_lon": ap["lon"]} if ap else None
# Usa exatamente os campos DEP/ARR da Escala com Atribuição
legs_atrib = raw_sched_atrib[raw_sched_atrib["AERONAVE"] != ""].copy()
import folium
from streamlit_folium import st_folium
_palette_hex = [
"#e41a1c", "#377eb8", "#4daf4a", "#984ea3",
"#ff7f00", "#a65628", "#f781bf", "#999999",
]
m = folium.Map(location=[-8, -60], zoom_start=4, tiles=None)
folium.TileLayer(
tiles="CartoDB positron",
name="Mapa base",
control=True,
).add_to(m)
folium.TileLayer(
tiles="https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
attr="Esri World Imagery",
name="Satélite",
control=True,
).add_to(m)
folium.LayerControl(position="topright").add_to(m)
# Linhas de rota por aeronave
for i, (ac, legs) in enumerate(legs_atrib.groupby("AERONAVE")):
color = _palette_hex[i % len(_palette_hex)]
for _, leg in legs.sort_values(["OFRAG", "ETAPA"]).iterrows():
dep = str(leg["DEP"]).strip().upper()
arr = str(leg["ARR"]).strip().upper()
c_dep = _coord(dep)
c_arr = _coord(arr)
if c_dep and c_arr:
folium.PolyLine(
[(c_dep["_lat"], c_dep["_lon"]), (c_arr["_lat"], c_arr["_lon"])],
color=color,
weight=2.5,
opacity=0.85,
tooltip=f"{ac}: {dep}{arr}",
).add_to(m)
# Bases de manutenção a partir da tabela de aeroportos
_ap_df = st.session_state["df_airports"].copy()
_ap_df.columns = [c.strip().upper() for c in _ap_df.columns]
_base_col = next((c for c in _ap_df.columns if "MAINTENANCE" in c or "BASE" in c), None)
_code_col = next((c for c in _ap_df.columns if "CODE" in c or "ICAO" in c), None)
_maint_bases: set = set()
if _base_col and _code_col:
_mask = _ap_df[_base_col].astype(str).str.strip().isin(["1", "True", "true"])
_maint_bases = set(_ap_df.loc[_mask, _code_col].str.strip().str.upper())
# Marcadores dos aeroportos usados
used_codes = set(
legs_atrib["DEP"].str.strip().str.upper().tolist()
+ legs_atrib["ARR"].str.strip().str.upper().tolist()
)
for code in used_codes:
c = _coord(code)
if c:
is_base = code in _maint_bases
folium.CircleMarker(
location=[c["_lat"], c["_lon"]],
radius=10 if is_base else 5,
color="#f59e0b" if is_base else "#1e293b",
weight=2.5 if is_base else 1.5,
fill=True,
fill_color="#f59e0b" if is_base else "#1e293b",
fill_opacity=0.95,
tooltip=f"🔧 Base de manutenção: {code}" if is_base else code,
popup=f"<b>{code}</b><br>Base de manutenção" if is_base else code,
).add_to(m)
st_folium(m, height=700, use_container_width=True)