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:
755
app/dashboard.py
Normal file
755
app/dashboard.py
Normal 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)
|
||||
232
app/dashboard_backup.py
Normal file
232
app/dashboard_backup.py
Normal file
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
Aircraft Routing Dashboard – Streamlit application.
|
||||
|
||||
Run with: streamlit run app/dashboard.py (from project root)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Allow imports from src/ when running from project root
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
import streamlit as st
|
||||
import pandas as pd
|
||||
import plotly.express as px
|
||||
import plotly.graph_objects as go
|
||||
from datetime import timedelta
|
||||
|
||||
from src.routing_engine import RoutingPipeline, DEFAULT_CONFIG
|
||||
|
||||
# ── 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")
|
||||
|
||||
# ── Sidebar – parameters ──────────────────────────────────────────────────────
|
||||
with st.sidebar:
|
||||
st.header("⚙ Parameters")
|
||||
tat = st.number_input("TAT mínimo (min)", min_value=0, max_value=240, value=60, step=10)
|
||||
year = st.number_input("Ano do planejamento", min_value=2024, max_value=2030, value=2026)
|
||||
time_limit = st.number_input("Limite tempo solver (s)", min_value=30, max_value=600, value=120)
|
||||
run_btn = st.button("▶ Executar Otimização", type="primary")
|
||||
|
||||
# ── Session state ─────────────────────────────────────────────────────────────
|
||||
if "result" not in st.session_state:
|
||||
st.session_state["result"] = None
|
||||
|
||||
# ── Run pipeline ──────────────────────────────────────────────────────────────
|
||||
if run_btn:
|
||||
cfg = DEFAULT_CONFIG
|
||||
cfg.tat_minutes = tat
|
||||
cfg.planning_year = int(year)
|
||||
cfg.mip_time_limit_seconds = int(time_limit)
|
||||
|
||||
with st.spinner("Executando otimização…"):
|
||||
try:
|
||||
pipe = RoutingPipeline(cfg)
|
||||
st.session_state["result"] = pipe.run(save_outputs=True)
|
||||
st.success("Otimização concluída!")
|
||||
except Exception as exc:
|
||||
st.error(f"Erro: {exc}")
|
||||
st.exception(exc)
|
||||
|
||||
result = st.session_state.get("result")
|
||||
|
||||
# ── Tabs ──────────────────────────────────────────────────────────────────────
|
||||
tab_summary, tab_gantt, tab_maint, tab_fleet, tab_ofrags = st.tabs(
|
||||
["📊 Resumo", "📅 Gantt", "🔧 Manutenção", "✈ Frota", "📋 OFRAGs"]
|
||||
)
|
||||
|
||||
# ── Tab: Summary ──────────────────────────────────────────────────────────────
|
||||
with tab_summary:
|
||||
if result is None:
|
||||
st.info("Execute a otimização para ver os resultados.")
|
||||
else:
|
||||
s = result["summary"]
|
||||
c1, c2, c3, c4 = st.columns(4)
|
||||
c1.metric("Status", s["status"])
|
||||
c2.metric("Objetivo (FH perdidas)", 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']}")
|
||||
|
||||
st.subheader("Escala de voo otimizada")
|
||||
sched = result["schedule"]
|
||||
if not sched.empty:
|
||||
st.dataframe(
|
||||
sched.style.apply(
|
||||
lambda r: ["background-color:#fff3cd" if r.get("maintenance_before") else "" for _ in r],
|
||||
axis=1,
|
||||
),
|
||||
use_container_width=True,
|
||||
)
|
||||
|
||||
st.caption(f"Colunas geradas no CG: {s['columns_generated']}")
|
||||
|
||||
# ── Tab: Gantt ────────────────────────────────────────────────────────────────
|
||||
with tab_gantt:
|
||||
if result is None:
|
||||
st.info("Execute a otimização para ver o diagrama de Gantt.")
|
||||
else:
|
||||
sched = result["schedule"]
|
||||
maint = result["maintenance"]
|
||||
|
||||
gantt_rows = []
|
||||
|
||||
if not sched.empty:
|
||||
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"],
|
||||
Resource=row["ofrag_id"],
|
||||
Type="OFRAG",
|
||||
Label=row["ofrag_id"],
|
||||
)
|
||||
)
|
||||
|
||||
if not maint.empty:
|
||||
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"],
|
||||
Resource="Manutenção",
|
||||
Type="Maintenance",
|
||||
Label=f"CHECK (perde {row['ttm_loss_hours']:.1f}h)",
|
||||
)
|
||||
)
|
||||
|
||||
if gantt_rows:
|
||||
df_gantt = pd.DataFrame(gantt_rows)
|
||||
color_map = {"Manutenção": "#636EFA"}
|
||||
fig = px.timeline(
|
||||
df_gantt,
|
||||
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", "Maintenance": "#EF553B"},
|
||||
)
|
||||
fig.update_yaxes(categoryorder="category ascending")
|
||||
fig.update_traces(textposition="inside")
|
||||
fig.update_layout(height=400 + len(sched["aircraft"].unique()) * 40)
|
||||
st.plotly_chart(fig, use_container_width=True)
|
||||
|
||||
cfg_obj = DEFAULT_CONFIG
|
||||
try:
|
||||
fig.write_html(str(cfg_obj.figures_dir / "gantt.html"))
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
st.info("Nenhum dado de Gantt disponível.")
|
||||
|
||||
# ── Tab: Maintenance ──────────────────────────────────────────────────────────
|
||||
with tab_maint:
|
||||
if result is None:
|
||||
st.info("Execute a otimização para ver eventos de manutenção.")
|
||||
else:
|
||||
maint = result["maintenance"]
|
||||
if maint.empty:
|
||||
st.success("Nenhum evento de manutenção forçado.")
|
||||
else:
|
||||
st.dataframe(maint, use_container_width=True)
|
||||
|
||||
fig_loss = px.bar(
|
||||
maint,
|
||||
x="aircraft",
|
||||
y="ttm_loss_hours",
|
||||
color="check_cycle_index",
|
||||
title="Horas de TTM perdidas por aeronave / ciclo de check",
|
||||
labels={"ttm_loss_hours": "TTM perdido (h)", "aircraft": "Aeronave"},
|
||||
text_auto=".2f",
|
||||
)
|
||||
st.plotly_chart(fig_loss, use_container_width=True)
|
||||
|
||||
# ── Tab: Fleet ────────────────────────────────────────────────────────────────
|
||||
with tab_fleet:
|
||||
if result is None:
|
||||
st.info("Execute a otimização.")
|
||||
else:
|
||||
fleet_df = result["fleet"]
|
||||
st.dataframe(fleet_df, use_container_width=True)
|
||||
|
||||
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],
|
||||
)
|
||||
st.plotly_chart(fig_util, use_container_width=True)
|
||||
|
||||
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"},
|
||||
)
|
||||
st.plotly_chart(fig_fh, use_container_width=True)
|
||||
|
||||
# ── Tab: OFRAGs ───────────────────────────────────────────────────────────────
|
||||
with tab_ofrags:
|
||||
if result is None:
|
||||
st.info("Execute a otimização.")
|
||||
else:
|
||||
ofrags = result["ofrags"]
|
||||
st.write(f"**{len(ofrags)} OFRAGs** elegíveis (iniciam e terminam na base de manutenção).")
|
||||
cols_show = [c for c in ["ofrag_id", "departure", "arrival", "flight_hours", "n_legs", "missions", "origin", "destination"] if c in ofrags.columns]
|
||||
st.dataframe(ofrags[cols_show], use_container_width=True)
|
||||
|
||||
fig_fh = px.bar(
|
||||
ofrags,
|
||||
x="ofrag_id",
|
||||
y="flight_hours",
|
||||
title="Horas de voo por OFRAG",
|
||||
labels={"flight_hours": "FH total", "ofrag_id": "OFRAG"},
|
||||
text_auto=".2f",
|
||||
)
|
||||
st.plotly_chart(fig_fh, use_container_width=True)
|
||||
Reference in New Issue
Block a user