""" 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"{code}
Base de manutenção" if is_base else code, ).add_to(m) st_folium(m, height=700, use_container_width=True)