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>
This commit is contained in:
Eduardo Carlos
2026-06-17 11:52:34 -03:00
parent 32ad7c9f23
commit 3607965c88
43 changed files with 3419 additions and 1936 deletions

755
app/dashboard.py Normal file
View File

@@ -0,0 +1,755 @@
"""
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)