""" Interactive meteorological dashboard for Brazilian aerodromes β€” ICEA/DECEA. Displays hourly surface observation time-series, climatological distributions, ICAO flight-category analysis, and an integrated data-collection form. Tabs: πŸ“₯ Coleta β€” trigger the scraping pipeline from the UI πŸ“ˆ VisΓ£o Geral β€” four-panel overview (T, wind, visibility, QNH) 🌑️ Temperatura β€” temperature, dew point, relative humidity πŸ’¨ Vento β€” wind speed/gust time-series and wind rose 🌧️ PressΓ£o e PrecipitaΓ§Γ£o β€” QNH and rainfall charts πŸ‘οΈ Visibilidade e Teto β€” VIS/ceiling with ICAO category shading πŸ“… Climatologia β€” monthly boxplots and summary table πŸ“‹ Dados β€” scrollable data table with CSV export Usage: 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 # ── Page config (must be first Streamlit call) ──────────────────────────────── st.set_page_config( page_title="MET AeroportuΓ‘rio", page_icon="πŸ›¬", layout="wide", initial_sidebar_state="expanded", ) # ── Paths (resolved relative to this file, independent of CWD) ─────────────── _APPS_DIR = Path(__file__).resolve().parent # .../meteorologia_aeroportos/_apps/ _BASE_DIR = _APPS_DIR.parent # .../meteorologia_aeroportos/ _REPO_ROOT = _BASE_DIR.parents[2] # dataset/ DADOS_DIR = _BASE_DIR / "db" / "dados" # temporary files (scraper) DB_PATH = _BASE_DIR / "db" / "met.db" # SQLite database PREPROC_DIR = ( _REPO_ROOT / "tabelas" / "preproc" / "meteorologia_aeroportos" ) # permanent analytics CSV backups DADOS_DIR.mkdir(parents=True, exist_ok=True) # ── 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") # ── Chart heights (pixels) ──────────────────────────────────────────────────── H_OVERVIEW = 700 H_TEMP = 500 H_TEMP_BOX = 380 H_WIND_SPEED = 380 H_WIND_ROSE = 500 H_WIND_DIR = 340 H_PRESSURE = 360 H_PRECIP = 500 H_VISIBILITY = 420 H_CEILING = 420 H_ICAO_BARS = 340 H_PRECIP_BOX = 380 H_CLIM_BOX = 370 _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: """Returns True when the SQLite database file exists.""" return DB_PATH.exists() def _migrate_legacy_paths() -> None: """Silently moves data files from older layouts to the current one. Handles two legacy layouts, applying whichever migration is needed: * v1 (original): ``dados/met.db`` and ``dados//_*.csv`` * v2 (mid-2026): ``met.db`` at the module root (before the _apps/ reorganisation) Safe to call on every startup β€” does nothing when files are already in the expected locations. """ # v1 β†’ current: dados/met.db (old flat layout) v1_db = _BASE_DIR / "dados" / "met.db" if v1_db.exists() and not DB_PATH.exists(): DB_PATH.parent.mkdir(parents=True, exist_ok=True) v1_db.rename(DB_PATH) st.info("Banco migrado de dados/met.db β†’ db/met.db.") # v2 β†’ current: met.db at _BASE_DIR root (pre-_apps/ reorganisation) v2_db = _BASE_DIR / "met.db" if v2_db.exists() and not DB_PATH.exists(): DB_PATH.parent.mkdir(parents=True, exist_ok=True) v2_db.rename(DB_PATH) st.info("Banco migrado de met.db β†’ db/met.db.") # v1 β†’ current: analytics CSVs in dados// v1_dados = _BASE_DIR / "dados" if not v1_dados.is_dir(): return for aero_dir in sorted(v1_dados.iterdir()): if not aero_dir.is_dir(): continue csv_files = list(aero_dir.glob(f"{aero_dir.name}_*.csv")) if not csv_files: continue dest = PREPROC_DIR / aero_dir.name dest.mkdir(parents=True, exist_ok=True) moved = 0 for f in csv_files: target = dest / f.name if not target.exists(): f.rename(target) moved += 1 if moved: st.info(f"CSVs de {aero_dir.name} migrados para tabelas/preproc/…") def list_aerodromes() -> list[str]: """Returns ICAO codes of aerodromes that have data (SQLite-first, CSV fallback).""" 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: scan permanent analytics CSV directory result: list[str] = [] if PREPROC_DIR.is_dir(): for d in sorted(PREPROC_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]: """Returns the full aerodrome catalog (all entries, with or without data). Used by the data-collection form so operators can select any aerodrome. Returns: List of ``{"icao", "nome", "uf"}`` dicts from the ``aerodromes`` table, or a minimal list derived from :func:`list_aerodromes` as a fallback. """ 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 list_catalog_with_data() -> list[dict]: """Returns only aerodromes that have observations in the database. Used by the sidebar so users only see options they can actually analyse. Returns: List of ``{"icao", "nome", "uf"}`` dicts from the ``observations`` table (joined with the catalog for display metadata). Falls back to :func:`list_catalog` when no database is available. """ if _db_available(): import db as _db conn = _db.get_connection(DB_PATH) _db.ensure_schema(conn) result = _db.list_aerodromes_with_data(conn) conn.close() if result: return result return list_catalog() 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]: """Returns the ``(start, end)`` date strings for available data. Args: aerodrome: ICAO code to look up. Returns: ``("YYYY-MM-DD", "YYYY-MM-DD")`` strings, or ``("", "")`` if no data is available. """ 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: scan permanent analytics CSV directory csv_dir = PREPROC_DIR / aerodrome if csv_dir.is_dir(): for f in sorted(csv_dir.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: read permanent analytics CSV backup csv_dir = PREPROC_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: """Resamples the analytics DataFrame to the requested frequency. Args: anl: Analytics DataFrame with a ``_dt`` datetime column. freq: Pandas offset alias (e.g. ``"D"``, ``"W"``, ``"ME"``), or ``None`` to return the data unchanged. Returns: Resampled (or unchanged) 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: """Returns the ICAO flight category for the given visibility and ceiling. Args: vis: Visibility in dam. teto: Ceiling in dam. Returns: One of ``"LIFR"``, ``"IFR"``, ``"MVFR"``, ``"VMC"``, or ``"β€”"`` when either value is NaN. """ 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[str, float]: """Returns percentage of time in each ICAO flight category. Args: anl: Analytics DataFrame with ``VIS`` and ``TETO`` columns. Returns: Dict mapping category name to percentage (0–100). """ 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: """Returns an HTML badge span for the given ICAO category. Args: cat: Category code (``"VMC"``, ``"MVFR"``, ``"IFR"``, or ``"LIFR"``). Returns: HTML ```` string with the appropriate CSS class. """ cls = { "VMC": "badge-vmc", "MVFR": "badge-mvfr", "IFR": "badge-ifr", "LIFR": "badge-lifr", }.get(cat, "") return f'{cat}' # ── Chart helpers ───────────────────────────────────────────────────────────── def _rng_btns() -> list[dict]: """Returns the standard time-range selector button list for Plotly.""" 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: go.Figure, h: int = H_VISIBILITY, title: str = "", legend_h: bool = True, ) -> None: """Applies standard layout settings to *fig* in-place. Args: fig: Plotly Figure to update. h: Chart height in pixels. title: Optional chart title. legend_h: Show legend horizontally below the chart (``True``) or hide it (``False``). """ 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: go.Figure, thresholds: list[tuple], row: int = 1, col: int = 1, ymax: float = 9999, ) -> None: """Adds ICAO category shaded horizontal bands to *fig*. Args: fig: Plotly Figure (must support ``add_hrect``). thresholds: List of ``(name, lo, hi, fill_color, font_color)`` tuples. row: Subplot row (1-based). col: Subplot column (1-based). ymax: Upper bound of the visible axis range (caps the top band). """ 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: """Four-panel overview: temperature, wind, visibility, QNH. Args: anl_r: Resampled analytics DataFrame. Returns: Plotly Figure with 4 subplots sharing the x-axis. """ 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=H_OVERVIEW, 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: """Temperature, dew point and relative humidity subplots. Args: anl_r: Resampled analytics DataFrame. Returns: Plotly Figure with 2 subplots (T/Td on top, UR below). """ 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=H_TEMP) 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=H_TEMP_BOX, 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: """Wind speed and gust time-series chart. Args: anl_r: Resampled analytics DataFrame. Returns: Plotly Figure showing WS (mean) and WG (gust) as filled areas. """ 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=H_WIND_SPEED, 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]: """16-point compass wind rose with 6 speed bins. Args: anl: Full (non-resampled) analytics DataFrame. Returns: Plotly polar bar chart, or ``None`` if fewer than 20 valid rows. """ 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=H_WIND_ROSE, margin=dict(l=40,r=40,t=55,b=90), ) return fig def chart_wind_direction(anl_r: pd.DataFrame) -> Optional[go.Figure]: """Wind direction scatter coloured by wind speed. Args: anl_r: Resampled analytics DataFrame. Returns: Plotly scatter Figure, or ``None`` if the DataFrame is empty after dropping NaNs. """ 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=H_WIND_DIR, 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: """QNH time-series with a mean reference line. Args: anl_r: Resampled analytics DataFrame. Returns: Plotly 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=H_PRESSURE, 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=H_PRECIP, 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: """Visibility time-series with ICAO category background bands. Args: anl_r: Resampled analytics DataFrame. Returns: Plotly 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=H_VISIBILITY, 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]: """Ceiling time-series with ICAO category background bands. Args: anl_r: Resampled analytics DataFrame. Returns: Plotly Figure, or ``None`` if all ceiling values are NaN. """ 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=H_CEILING, 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]: """Bar chart showing percentage of time in each ICAO flight category. Args: anl: Full (non-resampled) analytics DataFrame. Returns: Plotly Figure, or ``None`` if no valid VIS/TETO data. """ 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=H_ICAO_BARS, 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=H_PRECIP_BOX, 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]: """Monthly boxplot for a single meteorological variable. Args: anl: Full analytics DataFrame. var: Column name to plot (e.g. ``"T"``). title: Chart title. unit: Y-axis unit label. color: Hex colour for the boxes. Returns: Plotly Figure, or ``None`` if fewer than 30 valid observations. """ 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=H_CLIM_BOX, title=title, yaxis_title=unit, margin=dict(l=55,r=20,t=50,b=40)) return fig # ══════════════════════════════════════════════════════════════════════════════ # STARTUP β€” migration + catalog loading # ══════════════════════════════════════════════════════════════════════════════ _migrate_legacy_paths() catalog_full = list_catalog() # all aerodromes β†’ collection form catalog_with_data = list_catalog_with_data() # only aerodromes with data β†’ sidebar with st.sidebar: st.markdown("## ✈️ MET AeroportuΓ‘rio") fonte = "πŸ—„οΈ SQLite" if _db_available() else "πŸ“„ CSV" st.caption(fonte) st.markdown("---") if not catalog_with_data: if catalog_full: st.info( "Nenhum dado coletado ainda.\n\n" "Use a aba **πŸ“₯ Coleta** para baixar dados do site ICEA." ) else: st.warning( "Banco vazio e catΓ‘logo nΓ£o inicializado.\n\n" "Use **Atualizar catΓ‘logo ICEA** na aba Coleta e depois inicie a coleta." ) st.stop() sel_dict = st.selectbox("AerΓ³dromo", catalog_with_data, 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: """Returns the mean (or sum for PREC) of *s*, or NaN if empty.""" 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_full) 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_full if catalog_full 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 from pathlib import Path as _Path kw: dict = dict( aerodrome = aero_input, dados_dir = DADOS_DIR, db_path = DB_PATH, preproc_dir = PREPROC_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) # ── AtualizaΓ§Γ£o Total ───────────────────────────────────────────────────── st.divider() st.markdown("### 🌐 AtualizaΓ§Γ£o Total") st.markdown( "**O que faz:** verifica novos aerΓ³dromos no catΓ‘logo ICEA " "e baixa **apenas os dados novos** para cada aerΓ³dromo que jΓ‘ " "possui observaΓ§Γ΅es no banco. Recomendado a cada 2 meses.\n\n" "> **Nota:** aerΓ³dromos que ainda **nΓ£o tΓͺm dados** no banco nΓ£o sΓ£o " "coletados aqui β€” apenas adicionados ao catΓ‘logo. " "Para coletar o histΓ³rico completo de todos os aerΓ³dromos, " "use o script `_apps/collect_all.py` (pode levar horas/dias)." ) _at_c1, _at_c2, _ = st.columns([1, 1, 3]) with _at_c1: _at_headless = not st.checkbox("Mostrar Chrome", value=False, key="at_headless") with _at_c2: _at_validate = st.checkbox("Validar amostras", value=False, key="at_validate") _at_submitted = st.button( "🌐 Iniciar AtualizaΓ§Γ£o Total", type="primary", help="Atualiza catΓ‘logo + coleta dados novos para todos os aerΓ³dromos com dados no banco", ) if _at_submitted: import db as _db_at _at_conn_check = _db_at.get_connection(DB_PATH) _db_at.ensure_schema(_at_conn_check) _at_aeros = _db_at.list_aerodromes_with_data(_at_conn_check) _at_conn_check.close() if not _at_aeros: st.warning( "Nenhum aerΓ³dromo com dados no banco. " "Use o formulΓ‘rio acima para coletar dados primeiro." ) else: _at_n_total = len(_at_aeros) _at_q: queue.Queue = queue.Queue() _at_results: list[dict] = [] _at_log_lines: list[str] = [] _at_prog_bar = st.progress(0.0) _at_stat_txt = st.empty() _at_log_box = st.empty() _at_table_box = st.empty() _at_aero_list = [d["icao"] for d in _at_aeros] _at_aero_meta = {d["icao"]: d for d in _at_aeros} def _at_worker() -> None: try: # Phase 1 β€” refresh aerodrome catalog _at_q.put(("status", "Fase 1 β€” Atualizando catΓ‘logo ICEA…")) from scraper_meteorologia import fetch_aerodrome_catalog as _fat import db as _dbi _new_cat = _fat(headless=_at_headless) _cat_conn = _dbi.get_connection(DB_PATH) _dbi.ensure_schema(_cat_conn) _n_new = _dbi.upsert_aerodromes(_cat_conn, _new_cat) _cat_conn.close() _at_q.put(("log", f"[catΓ‘logo] {len(_new_cat)} aerΓ³dromos no portal " f"| {_n_new} registros atualizados" )) # Phase 2 β€” forward-only update for each aerodrome for _i, _icao in enumerate(_at_aero_list): _at_q.put(("aero_start", _icao, _i)) try: from pipeline import run_pipeline as _rp _res = _rp( aerodrome = _icao, dados_dir = DADOS_DIR, db_path = DB_PATH, preproc_dir = PREPROC_DIR, all_years = False, update_only = True, headless = _at_headless, n_samples = 5 if _at_validate else 0, do_validate = _at_validate, do_cleanup = True, log = lambda m: _at_q.put(("log", m)), progress = lambda p, m: None, ) _at_q.put(("aero_done", _icao, _res)) except Exception as _exc: _at_q.put(("aero_error", _icao, str(_exc)[:120])) except Exception as _exc: _at_q.put(("log", f"ERRO: {_exc}")) finally: _at_q.put(("done",)) _at_t = threading.Thread(target=_at_worker, daemon=True) _at_t.start() _at_n_done = 0 while _at_t.is_alive() or not _at_q.empty(): try: _item = _at_q.get(timeout=0.4) except queue.Empty: continue if _item[0] == "done": break elif _item[0] == "status": _at_stat_txt.caption(_item[1]) elif _item[0] == "log": _at_log_lines.append(_item[1]) _at_log_box.code("\n".join(_at_log_lines[-60:])) elif _item[0] == "aero_start": _, _icao, _i = _item _at_prog_bar.progress(_i / max(_at_n_total, 1)) _at_stat_txt.caption( f"Fase 2 β€” {_icao} ({_i + 1} / {_at_n_total})" ) _meta = _at_aero_meta.get(_icao, {}) _at_results.append({ "ICAO": _icao, "Nome": _meta.get("nome", ""), "UF": _meta.get("uf", ""), "Status": "⏳ Coletando…", "Novas linhas": "β€”", }) _at_table_box.dataframe( _at_results, use_container_width=True, hide_index=True ) elif _item[0] == "aero_done": _, _icao, _res = _item _at_n_done += 1 _new = _res.get("n_upserted", 0) for _row in _at_results: if _row["ICAO"] == _icao: _row["Status"] = "βœ… ConcluΓ­do" _row["Novas linhas"] = f"{_new:,}" break _at_table_box.dataframe( _at_results, use_container_width=True, hide_index=True ) elif _item[0] == "aero_error": _, _icao, _err = _item for _row in _at_results: if _row["ICAO"] == _icao: _row["Status"] = "❌ Erro" _row["Novas linhas"] = _err[:50] break _at_table_box.dataframe( _at_results, use_container_width=True, hide_index=True ) _at_t.join() _at_prog_bar.progress(1.0) st.cache_data.clear() _at_stat_txt.empty() st.success( f"βœ… AtualizaΓ§Γ£o Total concluΓ­da β€” " f"{_at_n_done} / {_at_n_total} aerΓ³dromos atualizados." ) st.rerun() # ── Coleta Total β€” HistΓ³rico Completo ───────────────────────────────────── st.divider() st.markdown("### πŸ—„οΈ Coleta Total β€” HistΓ³rico Completo") st.markdown( "Coleta o **histΓ³rico completo** de todos os aerΓ³dromos do catΓ‘logo " "que ainda **nΓ£o tΓͺm dados** no banco. " "AerΓ³dromos com dados sΓ£o ignorados automaticamente.\n\n" "> ⚠️ Esta operaΓ§Γ£o pode demorar **horas ou dias** dependendo do nΓΊmero " "de aerΓ³dromos. NΓ£o feche o dashboard durante a coleta. " "Em caso de interrupΓ§Γ£o, basta clicar novamente β€” os aerΓ³dromos jΓ‘ " "coletados serΓ£o pulados automaticamente." ) # Status summary if _db_available(): import db as _db_ct_info _ct_conn_info = _db_ct_info.get_connection(DB_PATH) _db_ct_info.ensure_schema(_ct_conn_info) _ct_catalog = _db_ct_info.list_all_aerodromes(_ct_conn_info) _ct_with_data = set(_db_ct_info.list_aerodromes(_ct_conn_info)) _ct_conn_info.close() _ct_total = len(_ct_catalog) _ct_done = len(_ct_with_data) _ct_pending = _ct_total - _ct_done else: _ct_total = _ct_done = _ct_pending = 0 _ct_info_col1, _ct_info_col2, _ct_info_col3 = st.columns(3) _ct_info_col1.metric("No catΓ‘logo", _ct_total) _ct_info_col2.metric("Com dados", _ct_done) _ct_info_col3.metric("Aguardando coleta", _ct_pending) _ct_opt1, _ct_opt2, _ct_opt3 = st.columns([1, 1, 2]) with _ct_opt1: _ct_headless = not st.checkbox("Mostrar Chrome", value=False, key="ct_headless") with _ct_opt2: _ct_max = int(st.number_input( "Limite de aerΓ³dromos (0 = todos)", min_value=0, max_value=200, value=0, step=1, key="ct_max", help="Útil para testes. 0 processa todos os pendentes.", )) if _ct_pending == 0 and _ct_total > 0: st.info("βœ… Todos os aerΓ³dromos do catΓ‘logo jΓ‘ tΓͺm dados no banco.") elif _ct_total == 0: st.warning( "CatΓ‘logo vazio. Clique em **Atualizar catΓ‘logo ICEA** (acima) para " "baixar a lista de aerΓ³dromos disponΓ­veis." ) _ct_submitted = st.button( "πŸ—„οΈ Iniciar Coleta Total", type="primary", disabled=(_ct_pending == 0), help="Coleta o histΓ³rico completo para os aerΓ³dromos sem dados no banco", ) if _ct_submitted and _ct_pending > 0: _ct_q: queue.Queue = queue.Queue() _ct_results: list[dict] = [] _ct_log_lines: list[str] = [] _ct_prog_bar = st.progress(0.0) _ct_stat_txt = st.empty() _ct_log_box = st.empty() _ct_table_box = st.empty() # Build metadata dict for display _ct_meta = {d["icao"]: d for d in _ct_catalog} _ct_max_arg = _ct_max if _ct_max > 0 else None def _ct_worker() -> None: try: from collect_all import collect_all as _ca def _ct_on_start(icao: str, idx: int, total: int, nome: str) -> None: _ct_q.put(("aero_start", icao, idx, total, nome)) def _ct_on_done(icao: str, event: str, result: dict) -> None: _ct_q.put(("aero_done", icao, event, result)) _ca( resume = True, max_aerodromes = _ct_max_arg, headless = _ct_headless, n_samples = 0, do_cleanup = True, log = lambda m: _ct_q.put(("log", m)), on_start = _ct_on_start, on_done = _ct_on_done, ) except Exception as _exc: _ct_q.put(("log", f"ERRO: {_exc}")) finally: _ct_q.put(("done",)) _ct_t = threading.Thread(target=_ct_worker, daemon=True) _ct_t.start() _ct_n_done = 0 _ct_n_total_run = _ct_max_arg or _ct_pending while _ct_t.is_alive() or not _ct_q.empty(): try: _citem = _ct_q.get(timeout=0.4) except queue.Empty: continue if _citem[0] == "done": break elif _citem[0] == "log": _ct_log_lines.append(_citem[1]) _ct_log_box.code("\n".join(_ct_log_lines[-80:])) elif _citem[0] == "aero_start": _, _icao, _idx, _tot, _nome = _citem _ct_prog_bar.progress((_idx - 1) / max(_tot, 1)) _ct_stat_txt.caption(f"Coletando {_icao} β€” {_idx} / {_tot}") _meta = _ct_meta.get(_icao, {}) _ct_results.append({ "ICAO": _icao, "Nome": _meta.get("nome", _nome), "UF": _meta.get("uf", ""), "Status": "⏳ Coletando…", "PerΓ­odo": "β€”", "Linhas": "β€”", }) _ct_table_box.dataframe( _ct_results, use_container_width=True, hide_index=True ) elif _citem[0] == "aero_done": _, _icao, _event, _res = _citem _status_map = { "completed": "βœ… ConcluΓ­do", "skipped": "⏭️ JΓ‘ tinha dados", "failed": "❌ Erro", "interrupted": "⏸️ Interrompido", } _st = _status_map.get(_event, _event) for _row in _ct_results: if _row["ICAO"] == _icao: _row["Status"] = _st if _event == "completed": _ct_n_done += 1 _row["PerΓ­odo"] = ( f"{_res.get('period_start','')} β†’ " f"{_res.get('period_end','')}" ) _row["Linhas"] = f"{_res.get('rows', 0):,}" elif _event == "failed": _row["PerΓ­odo"] = _res.get("error", "")[:40] break _ct_table_box.dataframe( _ct_results, use_container_width=True, hide_index=True ) _ct_t.join() _ct_prog_bar.progress(1.0) st.cache_data.clear() _ct_stat_txt.empty() st.success( f"βœ… Coleta Total concluΓ­da β€” {_ct_n_done} aerΓ³dromos coletados." ) st.rerun() # ── 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.")