""" 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) # โ”€โ”€ 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.")