""" Dashboard Meteorológico Aeroportuário — ICEA/DECEA Série temporal de superfície · Climatologia · Análise operacional Iniciar: streamlit run dashboard.py """ import queue import re import threading import unicodedata from datetime import date from pathlib import Path from typing import Optional import numpy as np import pandas as pd import plotly.graph_objects as go from plotly.subplots import make_subplots import streamlit as st # ── Config ────────────────────────────────────────────────────────────────── st.set_page_config(page_title="MET Aeroportuário", page_icon="🛬", layout="wide", initial_sidebar_state="expanded") DADOS_DIR = Path(__file__).parent / "dados" DADOS_DIR.mkdir(exist_ok=True) DB_PATH = DADOS_DIR / "met.db" # ── Paleta ─────────────────────────────────────────────────────────────────── C = dict( temp = "#d32f2f", temp_fill = "rgba(211,47,47,.13)", dewpoint = "#1565c0", dew_fill = "rgba(21,101,192,.10)", humidity = "#2e7d32", hum_fill = "rgba(46,125,50,.13)", pressure = "#6a1b9a", pres_fill = "rgba(106,27,154,.10)", wind = "#e65100", wind_fill = "rgba(230,81,0,.13)", gust = "#bf360c", gust_fill = "rgba(191,54,12,.08)", visibility = "#00695c", vis_fill = "rgba(0,105,92,.12)", ceiling = "#33691e", ceil_fill = "rgba(51,105,30,.12)", precip = "#1565c0", prec_fill = "rgba(21,101,192,.15)", card_border= "#dde1e7", ) TEMPLATE = "plotly_white" ICAO_VIS = [ # (nome, min_dam, max_dam, fundo, fonte) ("LIFR", 0, 50, "rgba(255,205,210,.55)", "#b71c1c"), ("IFR", 50, 100, "rgba(255,224,178,.55)", "#bf360c"), ("MVFR", 100, 300, "rgba(255,249,196,.55)", "#f57f17"), ("VMC", 300, None, "rgba(200,230,201,.40)", "#1b5e20"), ] ICAO_TETO = [ ("LIFR", 0, 15, "rgba(255,205,210,.55)", "#b71c1c"), ("IFR", 15, 30, "rgba(255,224,178,.55)", "#bf360c"), ("MVFR", 30, 100, "rgba(255,249,196,.55)", "#f57f17"), ("VMC", 100, None, "rgba(200,230,201,.40)", "#1b5e20"), ] WIND_BINS = [0, 3, 7, 12, 20, 30, 999] WIND_LABELS = ["< 3 kt", "3–7 kt", "7–12 kt", "12–20 kt", "20–30 kt", "≥ 30 kt"] WIND_COLORS = ["#b0bec5", "#81d4fa", "#26c6da", "#ffca28", "#ff7043", "#b71c1c"] COMPASS_16 = ["N","NNE","NE","ENE","E","ESE","SE","SSE", "S","SSW","SW","WSW","W","WNW","NW","NNW"] RESAMPLE = {"Horário": None, "Diário": "D", "Semanal": "W", "Mensal": "ME"} MONTHS_PT = ["Jan","Fev","Mar","Abr","Mai","Jun","Jul","Ago","Set","Out","Nov","Dez"] AGG_RULES = dict(T="mean", Td="mean", UR="mean", QNH="mean", WS="mean", WG="max", WD="mean", VIS="min", TETO="min", PREC="sum") _COMPILED_RE = re.compile(r"(\w+)_(\d{4}_\d{2}_\d{2})_(\d{4}_\d{2}_\d{2})\.csv") # ── CSS ────────────────────────────────────────────────────────────────────── st.markdown(""" """, unsafe_allow_html=True) # ── Utilitários de dados: SQLite-first, fallback CSV ───────────────────────── def _db_available() -> bool: return DB_PATH.exists() def list_aerodromes() -> list[str]: """Retorna aeródromos disponíveis (SQLite-first, fallback CSV).""" if _db_available(): import db as _db conn = _db.get_connection(DB_PATH) _db.ensure_schema(conn) aeros = _db.list_aerodromes(conn) conn.close() if aeros: return sorted(aeros) # Fallback CSV result = [] if DADOS_DIR.is_dir(): for d in sorted(DADOS_DIR.iterdir()): if not d.is_dir(): continue for f in sorted(d.glob(f"{d.name}_*.csv")): if _COMPILED_RE.match(f.name): result.append(d.name) break return result def list_catalog() -> list[dict]: """Retorna catálogo completo da tabela aerodromes. Fallback: só aeros com dados.""" if _db_available(): import db as _db conn = _db.get_connection(DB_PATH) _db.ensure_schema(conn) catalog = _db.list_all_aerodromes(conn) conn.close() if catalog: return catalog return [{"icao": a, "nome": "", "uf": ""} for a in list_aerodromes()] def _fmt_aero(d: dict) -> str: """Formata dict de aeródromo para exibição no selectbox.""" nome = d.get("nome", "") uf = d.get("uf", "") if nome and uf: return f"{d['icao']} — {nome} ({uf})" if nome: return f"{d['icao']} — {nome}" return d["icao"] def _save_csv_to_folder(df: pd.DataFrame, aerodrome: str, start: str, end: str) -> str | None: """Abre diálogo nativo de pasta e salva o DataFrame como CSV. Retorna caminho ou None.""" import tkinter as tk from tkinter import filedialog root = tk.Tk() root.withdraw() root.wm_attributes("-topmost", True) folder = filedialog.askdirectory(title="Escolha a pasta de destino") root.destroy() if not folder: return None s = start.replace("-", "") e = end.replace("-", "") filename = f"{aerodrome}_{s}_{e}.csv" filepath = Path(folder) / filename df.to_csv(filepath, index=False, encoding="utf-8-sig") return str(filepath) def get_period(aerodrome: str) -> tuple[str, str]: """Retorna (inicio, fim) dos dados disponíveis para o aeródromo.""" if _db_available(): import db as _db conn = _db.get_connection(DB_PATH) cov = _db.get_coverage(conn, aerodrome) conn.close() if cov: return str(cov[0]), str(cov[1]) # Fallback CSV d = DADOS_DIR / aerodrome if d.is_dir(): for f in sorted(d.glob(f"{aerodrome}_*.csv")): m = _COMPILED_RE.match(f.name) if m: return m.group(2).replace("_", "-"), m.group(3).replace("_", "-") return "", "" @st.cache_data(ttl=600, show_spinner="Carregando dados…") def load_analytics(aerodrome: str, start_str: str, end_str: str) -> pd.DataFrame: """ Carrega dados analíticos: SQLite-first (query parcial), fallback CSV. Retorna DataFrame com colunas: _dt, T, Td, UR, QNH, WS, WG, WD, VIS, TETO, PREC. """ if _db_available(): import db as _db conn = _db.get_connection(DB_PATH) df = _db.query_analytics(conn, aerodrome, start_str, end_str) conn.close() if not df.empty: return df # Fallback: lê o CSV de analytics (backup gerado pelo pipeline) csv_dir = DADOS_DIR / aerodrome if csv_dir.is_dir(): for f in sorted(csv_dir.glob(f"{aerodrome}_*.csv")): if _COMPILED_RE.match(f.name): df = pd.read_csv(f, encoding="utf-8-sig", low_memory=False) dt_col = next((c for c in df.columns if c in ("_dt", "dt") or ("Data" in c and "Hora" in c)), None) if dt_col: df["_dt"] = pd.to_datetime(df[dt_col], errors="coerce") df = df.drop(columns=[dt_col], errors="ignore") if dt_col != "_dt" else df df = df.dropna(subset=["_dt"]).sort_values("_dt").reset_index(drop=True) return df return pd.DataFrame() # ── Conversão numérica ─────────────────────────────────────────────────────── def _norm(s: str) -> str: return unicodedata.normalize("NFKD", s.lower()).encode("ascii","ignore").decode("ascii") def to_num(s: pd.Series) -> pd.Series: """Converte para float: suporta vírgula decimal e '-' como NaN.""" if pd.api.types.is_numeric_dtype(s): return s.astype(float) v = s.astype(str).str.strip().replace({"-":None,"nan":None,"NaN":None,"None":None,"":None}) v = v.str.replace(",", ".", regex=False) return pd.to_numeric(v, errors="coerce") def _agg(raw: pd.DataFrame, prefix: str, keyword: str, agg: str = "mean") -> pd.Series: """ Encontra colunas _*_*, prioriza genérica (_-_). Agrega por 'mean', 'min' ou 'max' quando há múltiplas pistas/cabeceiras. """ kw = _norm(keyword) cols = [c for c in raw.columns if c.startswith(f"{prefix}_") and kw in _norm(c)] if not cols: return pd.Series(dtype=float, index=raw.index) generic = [c for c in cols if "_-_" in c] specific = [c for c in cols if "_-_" not in c] if generic: s = to_num(raw[generic[0]]) if s.notna().mean() > 0.05: return s if not specific: return to_num(raw[cols[0]]) mat = pd.DataFrame({c: to_num(raw[c]) for c in specific}) return {"mean": mat.mean, "min": mat.min, "max": mat.max}[agg](axis=1) # ── DataFrame analítico limpo ──────────────────────────────────────────────── def build_analytics(raw: pd.DataFrame) -> pd.DataFrame: """ Constrói um DataFrame com uma série float por variável meteorológica, eliminando a heterogeneidade de colunas por pista/cabeceira. """ qnh = to_num(raw["pres_QNH"]) if "pres_QNH" in raw.columns else _agg(raw,"pres","QNH") vis = next((to_num(raw[c]) for c in raw.columns if "visib" in c.lower() and "predominante" in _norm(c)), _agg(raw,"visib","Predominante")) prec = next((to_num(raw[c]) for c in raw.columns if "prec_precipita" in c.lower() and "dura" not in c.lower()), pd.Series(dtype=float, index=raw.index)) return pd.DataFrame({ "_dt" : raw["_dt"].values, "T" : _agg(raw, "temp", "Bulbo_Seco").values, "Td" : _agg(raw, "temp", "Orvalho").values, "UR" : _agg(raw, "temp", "Umidade").values, "QNH" : qnh.values, "WS" : _agg(raw, "vent", "Velocidade").values, "WG" : _agg(raw, "vent", "Rajada", agg="max").values, "WD" : _agg(raw, "vent", "Dire").values, "VIS" : vis.values, "TETO": _agg(raw, "teto", "Teto", agg="min").values, "PREC": prec.values, }) def resample_anl(anl: pd.DataFrame, freq: Optional[str]) -> pd.DataFrame: if freq is None: return anl.copy() rules = {k: v for k, v in AGG_RULES.items() if k in anl.columns} return anl.set_index("_dt").resample(freq).agg(rules).reset_index() # ── ICAO helpers ───────────────────────────────────────────────────────────── def _icao_cat(vis: float, teto: float) -> str: if np.isnan(vis) or np.isnan(teto): return "—" if vis < 50 or teto < 15: return "LIFR" if vis < 100 or teto < 30: return "IFR" if vis < 300 or teto < 100: return "MVFR" return "VMC" def pct_icao(anl: pd.DataFrame) -> dict: df = anl[["VIS","TETO"]].dropna() if df.empty: return {} cats = df.apply(lambda r: _icao_cat(r.VIS, r.TETO), axis=1) return (cats.value_counts() / len(cats) * 100).to_dict() def badge(cat: str) -> str: cls = {"VMC":"badge-vmc","MVFR":"badge-mvfr","IFR":"badge-ifr","LIFR":"badge-lifr"}.get(cat,"") return f'{cat}' # ── Helpers de gráfico ──────────────────────────────────────────────────────── def _rng_btns(): return [dict(count=7, label="7d", step="day", stepmode="backward"), dict(count=1, label="1m", step="month", stepmode="backward"), dict(count=6, label="6m", step="month", stepmode="backward"), dict(count=1, label="1a", step="year", stepmode="backward"), dict(step="all", label="Tudo")] def _base_layout(fig, h=420, title="", legend_h=True): kw = dict(orientation="h", y=-0.18, x=0, font=dict(size=11)) if legend_h else dict(visible=False) fig.update_layout(template=TEMPLATE, height=h, title=title, margin=dict(l=55,r=20,t=48,b=48), hovermode="x unified", showlegend=legend_h, legend=kw, plot_bgcolor="white") fig.update_xaxes(showgrid=True, gridcolor="#f0f0f0", zeroline=False) fig.update_yaxes(showgrid=True, gridcolor="#f0f0f0", zeroline=False) def _hrect(fig, thresholds, row=1, col=1, ymax=9999): for name, lo, hi, fill, clr in thresholds: y1 = min(hi, ymax) if hi else ymax if lo >= ymax: continue fig.add_hrect(y0=lo, y1=y1, fillcolor=fill, opacity=1, line_width=0, row=row, col=col, annotation_text=f" {name}", annotation_position="left", annotation_font=dict(size=9.5, color=clr, family="Inter")) # ══════════════════════════════════════════════════════════════════════════════ # GRÁFICOS # ══════════════════════════════════════════════════════════════════════════════ def chart_overview(anl_r: pd.DataFrame) -> go.Figure: dt = anl_r["_dt"] fig = make_subplots(rows=4, cols=1, shared_xaxes=True, subplot_titles=("Temperatura (°C)","Vento (kt)","Visibilidade (dam)","Pressão QNH (hPa)"), vertical_spacing=0.055, row_heights=[.30,.23,.25,.22]) T, Td = anl_r.get("T"), anl_r.get("Td") WS = anl_r.get("WS") VIS = anl_r.get("VIS") QNH = anl_r.get("QNH") if T is not None and T.notna().any(): fig.add_trace(go.Scatter(x=dt, y=T, name="T (°C)", line=dict(color=C["temp"],width=1.8)), 1, 1) if Td is not None and Td.notna().any(): fig.add_trace(go.Scatter(x=dt, y=Td, name="Td (°C)", line=dict(color=C["dewpoint"],width=1.2,dash="dot")), 1, 1) if WS is not None and WS.notna().any(): fig.add_trace(go.Scatter(x=dt, y=WS, name="Vento (kt)", fill="tozeroy", fillcolor=C["wind_fill"], line=dict(color=C["wind"],width=1.8)), 2, 1) if VIS is not None and VIS.notna().any(): fig.add_trace(go.Scatter(x=dt, y=VIS, name="Visib (dam)", fill="tozeroy", fillcolor=C["vis_fill"], line=dict(color=C["visibility"],width=1.8)), 3, 1) if QNH is not None and QNH.notna().any(): fig.add_trace(go.Scatter(x=dt, y=QNH, name="QNH (hPa)", line=dict(color=C["pressure"],width=1.8)), 4, 1) for i in range(1, 5): fig.update_xaxes(row=i, showgrid=True, gridcolor="#f0f0f0") fig.update_yaxes(row=i, showgrid=True, gridcolor="#f0f0f0") fig.update_xaxes(row=4, rangeselector=dict(buttons=_rng_btns())) fig.update_layout(template=TEMPLATE, height=700, showlegend=False, hovermode="x unified", margin=dict(l=55,r=20,t=50,b=40), plot_bgcolor="white") return fig def chart_temp(anl_r: pd.DataFrame) -> go.Figure: dt = anl_r["_dt"] T = anl_r.get("T", pd.Series(dtype=float)) Td = anl_r.get("Td", pd.Series(dtype=float)) UR = anl_r.get("UR", pd.Series(dtype=float)) fig = make_subplots(rows=2, cols=1, shared_xaxes=True, subplot_titles=("Temperatura e Ponto de Orvalho (°C)","Umidade Relativa (%)"), vertical_spacing=0.10, row_heights=[.65,.35]) if T.notna().any(): fig.add_trace(go.Scatter(x=dt, y=T, name="Temperatura", line=dict(color=C["temp"],width=2.2)), 1, 1) if Td.notna().any(): fig.add_trace(go.Scatter(x=dt, y=Td, name="Ponto de Orvalho", line=dict(color=C["dewpoint"],width=1.5,dash="dot")), 1, 1) # Faixa entre T e Td if T.notna().any() and Td.notna().any(): mask = T.notna() & Td.notna() dt_v = dt[mask].values; T_v = T[mask].values; Td_v = Td[mask].values if len(dt_v) > 1: fig.add_trace(go.Scatter( x=np.concatenate([dt_v, dt_v[::-1]]), y=np.concatenate([T_v, Td_v[::-1]]), fill="toself", fillcolor="rgba(211,47,47,.10)", line=dict(color="rgba(0,0,0,0)"), showlegend=False, hoverinfo="skip"), 1, 1) if UR.notna().any(): fig.add_trace(go.Scatter(x=dt, y=UR, name="UR (%)", fill="tozeroy", fillcolor=C["hum_fill"], line=dict(color=C["humidity"],width=1.8)), 2, 1) fig.update_yaxes(range=[0,105], row=2) _base_layout(fig, h=500) fig.update_xaxes(row=2, rangeselector=dict(buttons=_rng_btns())) return fig def chart_temp_boxplot(anl: pd.DataFrame) -> Optional[go.Figure]: df = pd.DataFrame({"T": anl["T"].values, "m": pd.to_datetime(anl["_dt"]).dt.month}).dropna() if len(df) < 30: return None fig = go.Figure() for m in range(1, 13): vals = df[df["m"] == m]["T"] if vals.empty: continue fig.add_trace(go.Box(y=vals.values, name=MONTHS_PT[m-1], boxpoints=False, marker_color=C["temp"], line_color=C["temp"], fillcolor="rgba(211,47,47,.15)", showlegend=False)) fig.update_layout(template=TEMPLATE, height=380, title="Distribuição Mensal de Temperatura", yaxis_title="°C", margin=dict(l=55,r=20,t=50,b=40)) return fig def chart_wind_speed(anl_r: pd.DataFrame) -> go.Figure: dt = anl_r["_dt"] WS = anl_r.get("WS", pd.Series(dtype=float)) WG = anl_r.get("WG", pd.Series(dtype=float)) fig = go.Figure() if WG.notna().any(): fig.add_trace(go.Scatter(x=dt, y=WG, name="Rajada máx.", fill="tozeroy", fillcolor=C["gust_fill"], line=dict(color=C["gust"],width=1.2,dash="dot"))) if WS.notna().any(): fig.add_trace(go.Scatter(x=dt, y=WS, name="Velocidade média", fill="tozeroy", fillcolor=C["wind_fill"], line=dict(color=C["wind"],width=2.0))) _base_layout(fig, h=380, title="Velocidade do Vento (kt)") fig.update_yaxes(title="kt") fig.update_xaxes(rangeselector=dict(buttons=_rng_btns())) return fig def chart_wind_rose(anl: pd.DataFrame) -> Optional[go.Figure]: df = pd.DataFrame({"ws": anl["WS"].values, "wd": anl["WD"].values}).dropna() df = df[(df["ws"] >= 0) & (df["wd"] >= 0) & (df["wd"] <= 360)] if len(df) < 20: return None sector = 360 / 16 df["bin"] = ((df["wd"] + sector/2) % 360 // sector).astype(int).clip(0, 15) df["cat"] = pd.cut(df["ws"], bins=WIND_BINS, labels=WIND_LABELS, right=False) total = len(df) fig = go.Figure() for label, color in zip(WIND_LABELS, WIND_COLORS): sub = df[df["cat"] == label] r = [sub[sub["bin"] == i].shape[0] / total * 100 for i in range(16)] fig.add_trace(go.Barpolar(r=r, theta=COMPASS_16, name=label, marker_color=color, marker_line_color="white", marker_line_width=0.5, opacity=0.92)) fig.update_layout( template=None, paper_bgcolor="white", title="Rosa dos Ventos — Frequência por Direção e Intensidade", polar=dict(bgcolor="white", radialaxis=dict(ticksuffix="%", tickformat=".1f", gridcolor="#e0e0e0", linecolor="#bdbdbd"), angularaxis=dict(direction="clockwise", rotation=90, gridcolor="#e0e0e0", tickfont=dict(size=11,color="#333"))), legend=dict(orientation="h", y=-0.18, x=0.5, xanchor="center", font=dict(size=11)), height=500, margin=dict(l=40,r=40,t=55,b=90), ) return fig def chart_wind_direction(anl_r: pd.DataFrame) -> Optional[go.Figure]: dt = anl_r["_dt"] WD = anl_r.get("WD", pd.Series(dtype=float)) WS = anl_r.get("WS", pd.Series(dtype=float)) df = pd.DataFrame({"dt": dt.values, "wd": WD.values, "ws": WS.values}).dropna() if df.empty: return None fig = go.Figure(go.Scatter( x=df["dt"], y=df["wd"], mode="markers", marker=dict(size=4, color=df["ws"], colorscale="YlOrRd", colorbar=dict(title="kt", thickness=12, len=0.9), showscale=True, opacity=0.75), hovertemplate="Dir: %{y:.0f}°
Vel: %{marker.color:.1f} kt", )) fig.update_yaxes(title="Direção (°)", range=[0,360], tickvals=[0,45,90,135,180,225,270,315,360], ticktext=["N","NE","L","SE","S","SO","O","NO","N"]) _base_layout(fig, h=340, title="Direção do Vento (cor = velocidade)", legend_h=False) fig.update_xaxes(rangeselector=dict(buttons=_rng_btns())) return fig def chart_pressure(anl_r: pd.DataFrame) -> go.Figure: dt = anl_r["_dt"] QNH = anl_r.get("QNH", pd.Series(dtype=float)) fig = go.Figure() if QNH.notna().any(): fig.add_trace(go.Scatter(x=dt, y=QNH, name="QNH", fill="tozeroy", fillcolor=C["pres_fill"], line=dict(color=C["pressure"],width=2))) mn = float(QNH.dropna().mean()) fig.add_hline(y=mn, line_dash="dash", line_color="#9e9e9e", line_width=1.2, annotation_text=f" Média: {mn:.1f} hPa", annotation_font=dict(size=10, color="#555"), annotation_position="right") _base_layout(fig, h=360, title="Pressão QNH (hPa)") fig.update_yaxes(title="hPa") fig.update_xaxes(rangeselector=dict(buttons=_rng_btns())) return fig def chart_precipitation(anl_r: pd.DataFrame, anl: pd.DataFrame) -> Optional[go.Figure]: """Precipitação na resolução selecionada (barras) + acumulado mensal.""" prec_r = anl_r.get("PREC", pd.Series(dtype=float)) if "PREC" in anl_r.columns else pd.Series(dtype=float) if prec_r.isna().all() or prec_r.sum() == 0: return None dt_r = anl_r["_dt"] daily = pd.DataFrame({"dt": dt_r.values, "p": prec_r.values}).dropna() daily = daily[daily["p"] > 0] monthly = (anl[["_dt","PREC"]].dropna() .set_index("_dt").resample("ME")["PREC"].sum() .reset_index()) fig = make_subplots(rows=2, cols=1, shared_xaxes=False, subplot_titles=("Precipitação por período (mm)", "Acumulado mensal (mm)"), vertical_spacing=0.18, row_heights=[.5,.5]) if not daily.empty: fig.add_trace(go.Bar(x=daily["dt"], y=daily["p"], name="Precipitação", marker_color=C["precip"], opacity=0.85), 1, 1) if not monthly.empty: fig.add_trace(go.Bar(x=monthly["_dt"].dt.strftime("%Y-%m"), y=monthly["PREC"], name="Mensal", marker_color="#1976d2", opacity=0.85), 2, 1) fig.update_layout(template=TEMPLATE, height=500, showlegend=False, margin=dict(l=55,r=20,t=55,b=50), yaxis_title="mm", yaxis2_title="mm") fig.update_xaxes(showgrid=True, gridcolor="#f0f0f0") return fig def chart_visibility(anl_r: pd.DataFrame) -> go.Figure: dt = anl_r["_dt"] VIS = anl_r.get("VIS", pd.Series(dtype=float)) mx = float(VIS.dropna().max()) * 1.05 if VIS.notna().any() else 600 mx = max(mx, 600) fig = go.Figure() _hrect(fig, ICAO_VIS, ymax=mx) if VIS.notna().any(): fig.add_trace(go.Scatter(x=dt, y=VIS, name="Visibilidade", line=dict(color=C["visibility"], width=2))) _base_layout(fig, h=420, title="Visibilidade Predominante (dam)", legend_h=False) fig.update_yaxes(range=[0, mx], title="dam") fig.update_xaxes(rangeselector=dict(buttons=_rng_btns())) return fig def chart_ceiling(anl_r: pd.DataFrame) -> Optional[go.Figure]: dt = anl_r["_dt"] TETO = anl_r.get("TETO", pd.Series(dtype=float)) if TETO.isna().all(): return None mx = float(TETO.dropna().max()) * 1.05 mx = max(mx, 200) fig = go.Figure() _hrect(fig, ICAO_TETO, ymax=mx) fig.add_trace(go.Scatter(x=dt, y=TETO, name="Teto", line=dict(color=C["ceiling"], width=2))) _base_layout(fig, h=420, title="Teto (dam)", legend_h=False) fig.update_yaxes(range=[0, mx], title="dam") fig.update_xaxes(rangeselector=dict(buttons=_rng_btns())) return fig def chart_icao_bars(anl: pd.DataFrame) -> Optional[go.Figure]: df = anl[["VIS","TETO"]].dropna() if df.empty: return None cats = df.apply(lambda r: _icao_cat(r.VIS, r.TETO), axis=1) order = ["VMC","MVFR","IFR","LIFR"] colors_bar = [C["visibility"], "#f9a825", "#e65100", "#b71c1c"] counts = [(cats == c).sum() for c in order] total = len(cats) pcts = [v / total * 100 for v in counts] fig = go.Figure(go.Bar( x=order, y=pcts, text=[f"{p:.1f}%" for p in pcts], textposition="outside", marker_color=colors_bar, textfont=dict(size=13, color="#222"), )) fig.update_layout(template=TEMPLATE, height=340, title="Frequência por Categoria de Voo (% do tempo)", yaxis_title="%", yaxis=dict(range=[0, max(pcts)*1.2 if pcts else 100]), margin=dict(l=55,r=20,t=50,b=40), showlegend=False) return fig def _hex_rgba(hex_color: str, alpha: float = 0.15) -> str: """Converte '#rrggbb' para 'rgba(r,g,b,alpha)' compatível com Plotly.""" h = hex_color.lstrip("#") r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) return f"rgba({r},{g},{b},{alpha})" def chart_precip_boxplot(anl: pd.DataFrame) -> Optional[go.Figure]: """ Boxplot mensal da precipitação diária total (mm/dia). Usa totais diários — não horários — para evitar distribuição degenerada. Mostra outliers (eventos extremos), meteorologicamente relevantes. """ df = pd.DataFrame({"p": anl["PREC"].values, "dt": anl["_dt"].values}).dropna() if df.empty or df["p"].sum() == 0: return None df["dt"] = pd.to_datetime(df["dt"]) daily = (df.groupby(df["dt"].dt.date)["p"] .sum() .reset_index(name="p")) daily["m"] = pd.to_datetime(daily["dt"]).dt.month fill = _hex_rgba(C["precip"], 0.15) fig = go.Figure() for m in range(1, 13): vals = daily[daily["m"] == m]["p"] if vals.empty: continue fig.add_trace(go.Box( y=vals.values, name=MONTHS_PT[m - 1], marker_color=C["precip"], line_color=C["precip"], fillcolor=fill, showlegend=False, boxpoints="outliers", # eventos extremos visíveis marker=dict(size=4, opacity=0.6), )) fig.update_layout( template=TEMPLATE, height=380, title="Precipitação Diária por Mês (mm/dia) — caixas + outliers", yaxis_title="mm/dia", margin=dict(l=55, r=20, t=50, b=40), ) return fig def chart_climatology(anl: pd.DataFrame, var: str, title: str, unit: str, color: str) -> Optional[go.Figure]: df = pd.DataFrame({"v": anl[var].values, "m": pd.to_datetime(anl["_dt"]).dt.month.values}).dropna() if len(df) < 30: return None fill = _hex_rgba(color, 0.15) fig = go.Figure() for m in range(1, 13): vals = df[df["m"] == m]["v"] if vals.empty: continue fig.add_trace(go.Box(y=vals.values, name=MONTHS_PT[m-1], boxpoints=False, marker_color=color, line_color=color, fillcolor=fill, showlegend=False)) fig.update_layout(template=TEMPLATE, height=370, title=title, yaxis_title=unit, margin=dict(l=55,r=20,t=50,b=40)) return fig # ══════════════════════════════════════════════════════════════════════════════ # SIDEBAR # ══════════════════════════════════════════════════════════════════════════════ catalog = list_catalog() with st.sidebar: st.markdown("## ✈️ MET Aeroportuário") fonte = "🗄️ SQLite" if _db_available() else "📄 CSV" st.caption(fonte) st.markdown("---") if not catalog: st.warning("Nenhum dado encontrado.\nExecute a coleta primeiro.") st.stop() sel_dict = st.selectbox("Aeródromo", catalog, format_func=_fmt_aero) sel_aero = sel_dict["icao"] p_start, p_end = get_period(sel_aero) st.caption(f"📅 Disponível: `{p_start}` → `{p_end}`") st.markdown("---") try: dt_min = date.fromisoformat(p_start) dt_max = date.fromisoformat(p_end) except Exception: dt_min = date(2000, 1, 1) dt_max = date.today() st.markdown("**Período de análise**") date_range = st.date_input("", value=(dt_min, dt_max), min_value=dt_min, max_value=dt_max) st.markdown("---") freq_label = st.radio("**Agregação temporal**", list(RESAMPLE.keys()), index=1) freq = RESAMPLE[freq_label] st.markdown("---") st.caption("Fonte: ICEA/DECEA — Dados de Superfície") # ── Carrega analytics para o período selecionado ───────────────────────────── if isinstance(date_range, (list, tuple)) and len(date_range) == 2: sel_start = str(date_range[0]) sel_end = str(date_range[1]) else: sel_start = p_start sel_end = p_end anl_all = load_analytics(sel_aero, sel_start, sel_end) if anl_all.empty: st.error("Nenhum dado no período selecionado.") st.stop() # Garante que '_dt' é datetime if not pd.api.types.is_datetime64_any_dtype(anl_all["_dt"]): anl_all["_dt"] = pd.to_datetime(anl_all["_dt"], errors="coerce") anl_all = anl_all.dropna(subset=["_dt"]).reset_index(drop=True) # Dados já chegam em formato analítico (SQLite ou backup CSV de analytics) # Se as colunas T, Td etc. existem → já é analytics _IS_ANALYTICS = "T" in anl_all.columns if _IS_ANALYTICS: anl = anl_all.copy() else: # Fallback: dados no formato bruto (84 colunas) — aplica build_analytics anl = build_analytics(anl_all) raw = anl.copy() # alias para a tab Dados anl_r = resample_anl(anl, freq) # ── Header ─────────────────────────────────────────────────────────────────── n_obs = len(raw) n_days = (raw["_dt"].max() - raw["_dt"].min()).days + 1 st.markdown( f'
' f'

🛬 {sel_aero} — Dados Meteorológicos de Superfície

' f'

ICEA/DECEA · ' f'{raw["_dt"].min().strftime("%d/%m/%Y")} → {raw["_dt"].max().strftime("%d/%m/%Y")} · ' f'{n_obs:,} observações · {n_days} dias

', unsafe_allow_html=True) # ── KPIs ───────────────────────────────────────────────────────────────────── def _kv(s: pd.Series) -> float: v = s.dropna().mean() if "PREC" not in str(s.name) else s.dropna().sum() return float(v) if pd.notna(v) else float("nan") pct = pct_icao(anl) T_med = float(anl["T"].dropna().mean()) if anl["T"].notna().any() else float("nan") QNH_med = float(anl["QNH"].dropna().mean()) if anl["QNH"].notna().any() else float("nan") WS_med = float(anl["WS"].dropna().mean()) if anl["WS"].notna().any() else float("nan") VIS_med = float(anl["VIS"].dropna().mean()) if anl["VIS"].notna().any() else float("nan") PREC_tot= float(anl["PREC"].dropna().sum()) if anl["PREC"].notna().any() else float("nan") VMC_pct = pct.get("VMC", 0.0) kpi_cfg = [ (T_med, "Temp. Média", "°C", C["temp"]), (QNH_med, "QNH Médio", "hPa", C["pressure"]), (WS_med, "Vento Médio", "kt", C["wind"]), (VIS_med, "Visib. Média", "dam", C["visibility"]), (PREC_tot,"Precipitação", "mm", C["precip"]), (VMC_pct, "% VMC", "%", C["ceiling"]), ] cols_k = st.columns(6) for col, (val, lbl, unit, clr) in zip(cols_k, kpi_cfg): disp = f"{val:.1f}" if not np.isnan(val) else "—" col.markdown( f'
' f'
{disp}' f' {unit}
' f'
{lbl}
', unsafe_allow_html=True) st.markdown("
", unsafe_allow_html=True) # ══════════════════════════════════════════════════════════════════════════════ # TABS # ══════════════════════════════════════════════════════════════════════════════ tab0,tab1,tab2,tab3,tab4,tab5,tab6,tab7 = st.tabs([ "📥 Coleta", "📈 Visão Geral", "🌡️ Temperatura", "💨 Vento", "🌧️ Pressão e Precipitação", "👁️ Visibilidade e Teto", "📅 Climatologia", "📋 Dados", ]) # ── Tab Coleta ──────────────────────────────────────────────────────────────── with tab0: st.markdown("### Aquisição de Dados — ICEA/DECEA") st.markdown("Baixa dados de superfície do site da ICEA e armazena no banco SQLite local.") _btn_col, _info_col = st.columns([1, 3]) with _btn_col: if st.button("🔄 Atualizar catálogo ICEA", help="Acessa o site ICEA e baixa a lista completa de aeródromos disponíveis"): from scraper_meteorologia import fetch_aerodrome_catalog import db as _db with st.spinner("Acessando site ICEA…"): _new_catalog = fetch_aerodrome_catalog(headless=True) _conn = _db.get_connection(DB_PATH) _db.ensure_schema(_conn) _n = _db.upsert_aerodromes(_conn, _new_catalog) _conn.close() st.success(f"{_n} aeródromos gravados no catálogo.") st.rerun() with _info_col: _n_cat = len(catalog) st.caption( f"Catálogo atual: **{_n_cat}** aeródromos. " "Use o botão ao lado para atualizar do site ICEA." if _n_cat else "Catálogo vazio — clique em **Atualizar catálogo ICEA** para baixar a lista do site." ) col_form, col_log = st.columns([1, 1.6]) with col_form: with st.form("form_coleta"): _coleta_catalog = catalog if catalog else [{"icao": "SBGR", "nome": "", "uf": ""}] _default_idx = next( (i for i, d in enumerate(_coleta_catalog) if d["icao"] == sel_aero), 0 ) _sel_coleta = st.selectbox( "Aeródromo (ICAO)", _coleta_catalog, index=_default_idx, format_func=_fmt_aero, ) aero_input = _sel_coleta["icao"] modo = st.radio("Modo de coleta", [ "Todos os períodos (histórico completo)", "Atualizar (apenas dados novos)", "Período específico", ], index=0) yr_col = st.columns(2) start_yr = yr_col[0].number_input("Ano inicial", min_value=1948, max_value=date.today().year, value=date.today().year - 2, disabled=(modo != "Período específico")) end_yr = yr_col[1].number_input("Ano final", min_value=1948, max_value=date.today().year, value=date.today().year, disabled=(modo != "Período específico")) st.markdown("**Opções**") headless = not st.checkbox("Mostrar Chrome", value=False) validate = st.checkbox("Validar amostragem", value=True) n_samp = st.slider("Amostras de validação", 5, 50, 20, disabled=not validate) cleanup = st.checkbox("Remover CSVs intermediários", value=True) submitted = st.form_submit_button("▶ Iniciar coleta", type="primary", use_container_width=True) with col_log: st.markdown("**Log de execução**") log_box = st.empty() prog_bar = st.progress(0.0) stat_txt = st.empty() res_box = st.empty() if submitted: all_years = (modo == "Todos os períodos (histórico completo)") update_only= (modo == "Atualizar (apenas dados novos)") log_lines: list[str] = [] q: queue.Queue = queue.Queue() result_holder: dict = {} def _log(msg: str): q.put(("log", msg)) def _prog(pct: float, msg: str): q.put(("prog", pct, msg)) def _worker(): try: from pipeline import run_pipeline kw: dict = dict( aerodrome = aero_input, dados_dir = str(DADOS_DIR), all_years = all_years, headless = headless, n_samples = n_samp, do_validate = validate, do_cleanup = cleanup, log = _log, progress = _prog, ) if modo == "Período específico": kw["start_year"] = int(start_yr) kw["end_year"] = int(end_yr) elif update_only: # Atualizar: passa all_years=False e usa o ano atual kw["all_years"] = False kw["start_year"] = date.today().year - 1 kw["end_year"] = date.today().year result_holder.update(run_pipeline(**kw)) except Exception as exc: q.put(("log", f"ERRO: {exc}")) finally: q.put(("done",)) t = threading.Thread(target=_worker, daemon=True) t.start() while t.is_alive() or not q.empty(): try: item = q.get(timeout=0.4) except queue.Empty: continue if item[0] == "done": break elif item[0] == "log": log_lines.append(item[1]) log_box.code("\n".join(log_lines[-150:])) elif item[0] == "prog": prog_bar.progress(min(item[1], 1.0)) stat_txt.caption(item[2]) t.join() prog_bar.progress(1.0) st.cache_data.clear() if result_holder: r = result_holder stat_txt.empty() with res_box.container(): st.success("✅ Coleta concluída!") m1, m2, m3, m4 = st.columns(4) m1.metric("Observações", f"{r.get('rows',0):,}") m2.metric("Val. OK", r.get("n_ok", 0)) m3.metric("Divergências", r.get("n_fail",0)) m4.metric("Período início",r.get("period_start","—")) st.caption(f"Banco: `{r.get('db_path','')}`") if r.get("errors"): with st.expander("Divergências"): for e in r["errors"]: st.text(e) # ── Visão Geral ─────────────────────────────────────────────────────────────── with tab1: st.plotly_chart(chart_overview(anl_r), use_container_width=True) # ── Temperatura ─────────────────────────────────────────────────────────────── with tab2: st.plotly_chart(chart_temp(anl_r), use_container_width=True) f = chart_temp_boxplot(anl) if f: st.plotly_chart(f, use_container_width=True) # ── Vento ───────────────────────────────────────────────────────────────────── with tab3: st.plotly_chart(chart_wind_speed(anl_r), use_container_width=True) c1, c2 = st.columns([1.1, 1]) with c1: f = chart_wind_rose(anl) if f: st.plotly_chart(f, use_container_width=True) with c2: f = chart_wind_direction(anl_r) if f: st.plotly_chart(f, use_container_width=True) # ── Pressão & Precipitação ──────────────────────────────────────────────────── with tab4: st.plotly_chart(chart_pressure(anl_r), use_container_width=True) f = chart_precipitation(anl_r, anl) if f: st.plotly_chart(f, use_container_width=True) else: st.info("Dados de precipitação não disponíveis ou sem registro no período.") # ── Visibilidade & Teto ─────────────────────────────────────────────────────── with tab5: c1, c2 = st.columns(2) with c1: st.plotly_chart(chart_visibility(anl_r), use_container_width=True) with c2: f = chart_ceiling(anl_r) if f: st.plotly_chart(f, use_container_width=True) else: st.info("Dados de teto não disponíveis.") st.markdown("#### Condições de voo por categoria ICAO") c3, c4 = st.columns([1.5, 1]) with c3: f = chart_icao_bars(anl) if f: st.plotly_chart(f, use_container_width=True) with c4: if pct: st.markdown("

", unsafe_allow_html=True) for cat in ["VMC","MVFR","IFR","LIFR"]: v = pct.get(cat, 0) st.markdown( f'{badge(cat)} {v:.1f}% do tempo', unsafe_allow_html=True) st.markdown("
", unsafe_allow_html=True) # ── Climatologia ────────────────────────────────────────────────────────────── with tab6: st.markdown("Distribuição estatística mensal das variáveis no período selecionado.") c1, c2 = st.columns(2) with c1: f = chart_climatology(anl, "T", "Temperatura Mensal (°C)", "°C", C["temp"]) if f: st.plotly_chart(f, use_container_width=True) with c2: f = chart_climatology(anl, "WS", "Vento Mensal (kt)", "kt", C["wind"]) if f: st.plotly_chart(f, use_container_width=True) c3, c4 = st.columns(2) with c3: f = chart_climatology(anl, "VIS", "Visibilidade Mensal (dam)","dam", C["visibility"]) if f: st.plotly_chart(f, use_container_width=True) with c4: f = chart_climatology(anl, "QNH", "Pressão QNH Mensal (hPa)", "hPa", C["pressure"]) if f: st.plotly_chart(f, use_container_width=True) f = chart_precip_boxplot(anl) if f: st.plotly_chart(f, use_container_width=True) else: st.info("Dados de precipitação insuficientes para o boxplot mensal.") st.markdown("#### Resumo mensal") rows = [] for m in range(1, 13): mask_m = pd.to_datetime(anl["_dt"]).dt.month == m if mask_m.sum() < 2: continue r = {"Mês": MONTHS_PT[m-1], "N": int(mask_m.sum())} for key, lbl, fmt in [("T","T média (°C)","{:.1f}"),("T","T máx (°C)","{:.1f}"), ("T","T mín (°C)","{:.1f}"),("WS","WS média (kt)","{:.1f}"), ("VIS","VIS média (dam)","{:.0f}"),("QNH","QNH (hPa)","{:.1f}"), ("PREC","Precip (mm)","{:.1f}")]: vals = anl[key][mask_m].dropna() if vals.empty: r[lbl] = "—" elif "máx" in lbl: r[lbl] = fmt.format(vals.max()) elif "mín" in lbl: r[lbl] = fmt.format(vals.min()) elif "Precip" in lbl: r[lbl] = fmt.format(vals.sum()) else: r[lbl] = fmt.format(vals.mean()) rows.append(r) if rows: st.dataframe(pd.DataFrame(rows).set_index("Mês"), use_container_width=True) # ── Dados ───────────────────────────────────────────────────────────────────── with tab7: COL_LABELS = { "T": "Temperatura Bulbo Seco (°C)", "Td": "Ponto de Orvalho (°C)", "UR": "Umidade Relativa (%)", "QNH": "Pressão QNH (hPa)", "WS": "Velocidade do Vento (kt)", "WG": "Rajada Máxima (kt)", "WD": "Direção do Vento (°)", "VIS": "Visibilidade Predominante (dam)", "TETO": "Teto (dam)", "PREC": "Precipitação (mm)", } avail = [c for c in COL_LABELS if c in anl.columns] sel = st.multiselect( "Variáveis", options=avail, default=[c for c in ["T", "Td", "UR", "QNH", "WS", "VIS"] if c in avail], format_func=lambda c: COL_LABELS.get(c, c), ) show_df = anl[["_dt"] + sel].copy() show_df["_dt"] = show_df["_dt"].dt.strftime("%Y-%m-%d %H:%M") show_df = show_df.rename(columns={"_dt": "Data/Hora", **{c: COL_LABELS[c] for c in sel}}) st.dataframe(show_df, use_container_width=True, height=480) st.caption(f"{len(show_df):,} observações · {len(sel)} variáveis selecionadas") _exp_left, _exp_right = st.columns([1, 1]) with _exp_left: csv_bytes = show_df.to_csv(index=False, encoding="utf-8-sig").encode("utf-8-sig") st.download_button( "⬇️ Baixar CSV (variáveis selecionadas)", csv_bytes, f"{sel_aero}_export.csv", "text/csv", use_container_width=True, ) with _exp_right: if st.button("📁 Salvar CSV completo em pasta…", use_container_width=True, help="Abre um seletor de pasta e salva todas as variáveis meteorológicas"): # monta DataFrame completo com todos as variáveis disponíveis _full_labels = { "T": "Temperatura Bulbo Seco (°C)", "Td": "Ponto de Orvalho (°C)", "UR": "Umidade Relativa (%)", "QNH": "Pressão QNH (hPa)", "WS": "Velocidade do Vento (kt)", "WG": "Rajada Máxima (kt)", "WD": "Direção do Vento (°)", "VIS": "Visibilidade Predominante (dam)", "TETO": "Teto (dam)", "PREC": "Precipitação (mm)", } _all_cols = [c for c in _full_labels if c in anl.columns] _export_df = anl[["_dt"] + _all_cols].copy() _export_df["_dt"] = _export_df["_dt"].dt.strftime("%Y-%m-%d %H:%M") _export_df = _export_df.rename( columns={"_dt": "Data/Hora", **{c: _full_labels[c] for c in _all_cols}} ) _path = _save_csv_to_folder(_export_df, sel_aero, sel_start, sel_end) if _path: st.success(f"Arquivo salvo em:\n`{_path}`") else: st.info("Exportação cancelada.")