"""
Dashboard Meteorológico Aeroportuário — ICEA/DECEA
Série temporal de superfície · Climatologia · Análise operacional
Iniciar: streamlit run dashboard.py
"""
import queue
import re
import threading
import unicodedata
from datetime import date
from pathlib import Path
from typing import Optional
import numpy as np
import pandas as pd
import plotly.graph_objects as go
from plotly.subplots import make_subplots
import streamlit as st
# ── Config ──────────────────────────────────────────────────────────────────
st.set_page_config(page_title="MET Aeroportuário", page_icon="🛬",
layout="wide", initial_sidebar_state="expanded")
DADOS_DIR = Path(__file__).parent / "dados"
DADOS_DIR.mkdir(exist_ok=True)
DB_PATH = DADOS_DIR / "met.db"
# ── Paleta ───────────────────────────────────────────────────────────────────
C = dict(
temp = "#d32f2f", temp_fill = "rgba(211,47,47,.13)",
dewpoint = "#1565c0", dew_fill = "rgba(21,101,192,.10)",
humidity = "#2e7d32", hum_fill = "rgba(46,125,50,.13)",
pressure = "#6a1b9a", pres_fill = "rgba(106,27,154,.10)",
wind = "#e65100", wind_fill = "rgba(230,81,0,.13)",
gust = "#bf360c", gust_fill = "rgba(191,54,12,.08)",
visibility = "#00695c", vis_fill = "rgba(0,105,92,.12)",
ceiling = "#33691e", ceil_fill = "rgba(51,105,30,.12)",
precip = "#1565c0", prec_fill = "rgba(21,101,192,.15)",
card_border= "#dde1e7",
)
TEMPLATE = "plotly_white"
ICAO_VIS = [ # (nome, min_dam, max_dam, fundo, fonte)
("LIFR", 0, 50, "rgba(255,205,210,.55)", "#b71c1c"),
("IFR", 50, 100, "rgba(255,224,178,.55)", "#bf360c"),
("MVFR", 100, 300, "rgba(255,249,196,.55)", "#f57f17"),
("VMC", 300, None, "rgba(200,230,201,.40)", "#1b5e20"),
]
ICAO_TETO = [
("LIFR", 0, 15, "rgba(255,205,210,.55)", "#b71c1c"),
("IFR", 15, 30, "rgba(255,224,178,.55)", "#bf360c"),
("MVFR", 30, 100, "rgba(255,249,196,.55)", "#f57f17"),
("VMC", 100, None, "rgba(200,230,201,.40)", "#1b5e20"),
]
WIND_BINS = [0, 3, 7, 12, 20, 30, 999]
WIND_LABELS = ["< 3 kt", "3–7 kt", "7–12 kt", "12–20 kt", "20–30 kt", "≥ 30 kt"]
WIND_COLORS = ["#b0bec5", "#81d4fa", "#26c6da", "#ffca28", "#ff7043", "#b71c1c"]
COMPASS_16 = ["N","NNE","NE","ENE","E","ESE","SE","SSE",
"S","SSW","SW","WSW","W","WNW","NW","NNW"]
RESAMPLE = {"Horário": None, "Diário": "D", "Semanal": "W", "Mensal": "ME"}
MONTHS_PT = ["Jan","Fev","Mar","Abr","Mai","Jun","Jul","Ago","Set","Out","Nov","Dez"]
AGG_RULES = dict(T="mean", Td="mean", UR="mean", QNH="mean",
WS="mean", WG="max", WD="mean",
VIS="min", TETO="min", PREC="sum")
_COMPILED_RE = re.compile(r"(\w+)_(\d{4}_\d{2}_\d{2})_(\d{4}_\d{2}_\d{2})\.csv")
# ── CSS ──────────────────────────────────────────────────────────────────────
st.markdown("""
""", unsafe_allow_html=True)
# ── Utilitários de dados: SQLite-first, fallback CSV ─────────────────────────
def _db_available() -> bool:
return DB_PATH.exists()
def list_aerodromes() -> list[str]:
"""Retorna aeródromos disponíveis (SQLite-first, fallback CSV)."""
if _db_available():
import db as _db
conn = _db.get_connection(DB_PATH)
_db.ensure_schema(conn)
aeros = _db.list_aerodromes(conn)
conn.close()
if aeros:
return sorted(aeros)
# Fallback CSV
result = []
if DADOS_DIR.is_dir():
for d in sorted(DADOS_DIR.iterdir()):
if not d.is_dir():
continue
for f in sorted(d.glob(f"{d.name}_*.csv")):
if _COMPILED_RE.match(f.name):
result.append(d.name)
break
return result
def list_catalog() -> list[dict]:
"""Retorna catálogo completo da tabela aerodromes. Fallback: só aeros com dados."""
if _db_available():
import db as _db
conn = _db.get_connection(DB_PATH)
_db.ensure_schema(conn)
catalog = _db.list_all_aerodromes(conn)
conn.close()
if catalog:
return catalog
return [{"icao": a, "nome": "", "uf": ""} for a in list_aerodromes()]
def _fmt_aero(d: dict) -> str:
"""Formata dict de aeródromo para exibição no selectbox."""
nome = d.get("nome", "")
uf = d.get("uf", "")
if nome and uf:
return f"{d['icao']} — {nome} ({uf})"
if nome:
return f"{d['icao']} — {nome}"
return d["icao"]
def _save_csv_to_folder(df: pd.DataFrame, aerodrome: str,
start: str, end: str) -> str | None:
"""Abre diálogo nativo de pasta e salva o DataFrame como CSV. Retorna caminho ou None."""
import tkinter as tk
from tkinter import filedialog
root = tk.Tk()
root.withdraw()
root.wm_attributes("-topmost", True)
folder = filedialog.askdirectory(title="Escolha a pasta de destino")
root.destroy()
if not folder:
return None
s = start.replace("-", "")
e = end.replace("-", "")
filename = f"{aerodrome}_{s}_{e}.csv"
filepath = Path(folder) / filename
df.to_csv(filepath, index=False, encoding="utf-8-sig")
return str(filepath)
def get_period(aerodrome: str) -> tuple[str, str]:
"""Retorna (inicio, fim) dos dados disponíveis para o aeródromo."""
if _db_available():
import db as _db
conn = _db.get_connection(DB_PATH)
cov = _db.get_coverage(conn, aerodrome)
conn.close()
if cov:
return str(cov[0]), str(cov[1])
# Fallback CSV
d = DADOS_DIR / aerodrome
if d.is_dir():
for f in sorted(d.glob(f"{aerodrome}_*.csv")):
m = _COMPILED_RE.match(f.name)
if m:
return m.group(2).replace("_", "-"), m.group(3).replace("_", "-")
return "", ""
@st.cache_data(ttl=600, show_spinner="Carregando dados…")
def load_analytics(aerodrome: str, start_str: str, end_str: str) -> pd.DataFrame:
"""
Carrega dados analíticos: SQLite-first (query parcial), fallback CSV.
Retorna DataFrame com colunas: _dt, T, Td, UR, QNH, WS, WG, WD, VIS, TETO, PREC.
"""
if _db_available():
import db as _db
conn = _db.get_connection(DB_PATH)
df = _db.query_analytics(conn, aerodrome, start_str, end_str)
conn.close()
if not df.empty:
return df
# Fallback: lê o CSV de analytics (backup gerado pelo pipeline)
csv_dir = DADOS_DIR / aerodrome
if csv_dir.is_dir():
for f in sorted(csv_dir.glob(f"{aerodrome}_*.csv")):
if _COMPILED_RE.match(f.name):
df = pd.read_csv(f, encoding="utf-8-sig", low_memory=False)
dt_col = next((c for c in df.columns if c in ("_dt", "dt") or
("Data" in c and "Hora" in c)), None)
if dt_col:
df["_dt"] = pd.to_datetime(df[dt_col], errors="coerce")
df = df.drop(columns=[dt_col], errors="ignore") if dt_col != "_dt" else df
df = df.dropna(subset=["_dt"]).sort_values("_dt").reset_index(drop=True)
return df
return pd.DataFrame()
# ── Conversão numérica ───────────────────────────────────────────────────────
def _norm(s: str) -> str:
return unicodedata.normalize("NFKD", s.lower()).encode("ascii","ignore").decode("ascii")
def to_num(s: pd.Series) -> pd.Series:
"""Converte para float: suporta vírgula decimal e '-' como NaN."""
if pd.api.types.is_numeric_dtype(s):
return s.astype(float)
v = s.astype(str).str.strip().replace({"-":None,"nan":None,"NaN":None,"None":None,"":None})
v = v.str.replace(",", ".", regex=False)
return pd.to_numeric(v, errors="coerce")
def _agg(raw: pd.DataFrame, prefix: str, keyword: str,
agg: str = "mean") -> pd.Series:
"""
Encontra colunas _*_*, prioriza genérica (_-_).
Agrega por 'mean', 'min' ou 'max' quando há múltiplas pistas/cabeceiras.
"""
kw = _norm(keyword)
cols = [c for c in raw.columns if c.startswith(f"{prefix}_") and kw in _norm(c)]
if not cols:
return pd.Series(dtype=float, index=raw.index)
generic = [c for c in cols if "_-_" in c]
specific = [c for c in cols if "_-_" not in c]
if generic:
s = to_num(raw[generic[0]])
if s.notna().mean() > 0.05:
return s
if not specific:
return to_num(raw[cols[0]])
mat = pd.DataFrame({c: to_num(raw[c]) for c in specific})
return {"mean": mat.mean, "min": mat.min, "max": mat.max}[agg](axis=1)
# ── DataFrame analítico limpo ────────────────────────────────────────────────
def build_analytics(raw: pd.DataFrame) -> pd.DataFrame:
"""
Constrói um DataFrame com uma série float por variável meteorológica,
eliminando a heterogeneidade de colunas por pista/cabeceira.
"""
qnh = to_num(raw["pres_QNH"]) if "pres_QNH" in raw.columns else _agg(raw,"pres","QNH")
vis = next((to_num(raw[c]) for c in raw.columns
if "visib" in c.lower() and "predominante" in _norm(c)), _agg(raw,"visib","Predominante"))
prec = next((to_num(raw[c]) for c in raw.columns
if "prec_precipita" in c.lower() and "dura" not in c.lower()),
pd.Series(dtype=float, index=raw.index))
return pd.DataFrame({
"_dt" : raw["_dt"].values,
"T" : _agg(raw, "temp", "Bulbo_Seco").values,
"Td" : _agg(raw, "temp", "Orvalho").values,
"UR" : _agg(raw, "temp", "Umidade").values,
"QNH" : qnh.values,
"WS" : _agg(raw, "vent", "Velocidade").values,
"WG" : _agg(raw, "vent", "Rajada", agg="max").values,
"WD" : _agg(raw, "vent", "Dire").values,
"VIS" : vis.values,
"TETO": _agg(raw, "teto", "Teto", agg="min").values,
"PREC": prec.values,
})
def resample_anl(anl: pd.DataFrame, freq: Optional[str]) -> pd.DataFrame:
if freq is None:
return anl.copy()
rules = {k: v for k, v in AGG_RULES.items() if k in anl.columns}
return anl.set_index("_dt").resample(freq).agg(rules).reset_index()
# ── ICAO helpers ─────────────────────────────────────────────────────────────
def _icao_cat(vis: float, teto: float) -> str:
if np.isnan(vis) or np.isnan(teto): return "—"
if vis < 50 or teto < 15: return "LIFR"
if vis < 100 or teto < 30: return "IFR"
if vis < 300 or teto < 100: return "MVFR"
return "VMC"
def pct_icao(anl: pd.DataFrame) -> dict:
df = anl[["VIS","TETO"]].dropna()
if df.empty: return {}
cats = df.apply(lambda r: _icao_cat(r.VIS, r.TETO), axis=1)
return (cats.value_counts() / len(cats) * 100).to_dict()
def badge(cat: str) -> str:
cls = {"VMC":"badge-vmc","MVFR":"badge-mvfr","IFR":"badge-ifr","LIFR":"badge-lifr"}.get(cat,"")
return f'{cat}'
# ── Helpers de gráfico ────────────────────────────────────────────────────────
def _rng_btns():
return [dict(count=7, label="7d", step="day", stepmode="backward"),
dict(count=1, label="1m", step="month", stepmode="backward"),
dict(count=6, label="6m", step="month", stepmode="backward"),
dict(count=1, label="1a", step="year", stepmode="backward"),
dict(step="all", label="Tudo")]
def _base_layout(fig, h=420, title="", legend_h=True):
kw = dict(orientation="h", y=-0.18, x=0, font=dict(size=11)) if legend_h else dict(visible=False)
fig.update_layout(template=TEMPLATE, height=h, title=title,
margin=dict(l=55,r=20,t=48,b=48),
hovermode="x unified", showlegend=legend_h,
legend=kw, plot_bgcolor="white")
fig.update_xaxes(showgrid=True, gridcolor="#f0f0f0", zeroline=False)
fig.update_yaxes(showgrid=True, gridcolor="#f0f0f0", zeroline=False)
def _hrect(fig, thresholds, row=1, col=1, ymax=9999):
for name, lo, hi, fill, clr in thresholds:
y1 = min(hi, ymax) if hi else ymax
if lo >= ymax: continue
fig.add_hrect(y0=lo, y1=y1, fillcolor=fill, opacity=1, line_width=0,
row=row, col=col,
annotation_text=f" {name}",
annotation_position="left",
annotation_font=dict(size=9.5, color=clr, family="Inter"))
# ══════════════════════════════════════════════════════════════════════════════
# GRÁFICOS
# ══════════════════════════════════════════════════════════════════════════════
def chart_overview(anl_r: pd.DataFrame) -> go.Figure:
dt = anl_r["_dt"]
fig = make_subplots(rows=4, cols=1, shared_xaxes=True,
subplot_titles=("Temperatura (°C)","Vento (kt)","Visibilidade (dam)","Pressão QNH (hPa)"),
vertical_spacing=0.055, row_heights=[.30,.23,.25,.22])
T, Td = anl_r.get("T"), anl_r.get("Td")
WS = anl_r.get("WS")
VIS = anl_r.get("VIS")
QNH = anl_r.get("QNH")
if T is not None and T.notna().any():
fig.add_trace(go.Scatter(x=dt, y=T, name="T (°C)", line=dict(color=C["temp"],width=1.8)), 1, 1)
if Td is not None and Td.notna().any():
fig.add_trace(go.Scatter(x=dt, y=Td, name="Td (°C)", line=dict(color=C["dewpoint"],width=1.2,dash="dot")), 1, 1)
if WS is not None and WS.notna().any():
fig.add_trace(go.Scatter(x=dt, y=WS, name="Vento (kt)", fill="tozeroy",
fillcolor=C["wind_fill"], line=dict(color=C["wind"],width=1.8)), 2, 1)
if VIS is not None and VIS.notna().any():
fig.add_trace(go.Scatter(x=dt, y=VIS, name="Visib (dam)", fill="tozeroy",
fillcolor=C["vis_fill"], line=dict(color=C["visibility"],width=1.8)), 3, 1)
if QNH is not None and QNH.notna().any():
fig.add_trace(go.Scatter(x=dt, y=QNH, name="QNH (hPa)", line=dict(color=C["pressure"],width=1.8)), 4, 1)
for i in range(1, 5):
fig.update_xaxes(row=i, showgrid=True, gridcolor="#f0f0f0")
fig.update_yaxes(row=i, showgrid=True, gridcolor="#f0f0f0")
fig.update_xaxes(row=4, rangeselector=dict(buttons=_rng_btns()))
fig.update_layout(template=TEMPLATE, height=700, showlegend=False,
hovermode="x unified", margin=dict(l=55,r=20,t=50,b=40),
plot_bgcolor="white")
return fig
def chart_temp(anl_r: pd.DataFrame) -> go.Figure:
dt = anl_r["_dt"]
T = anl_r.get("T", pd.Series(dtype=float))
Td = anl_r.get("Td", pd.Series(dtype=float))
UR = anl_r.get("UR", pd.Series(dtype=float))
fig = make_subplots(rows=2, cols=1, shared_xaxes=True,
subplot_titles=("Temperatura e Ponto de Orvalho (°C)","Umidade Relativa (%)"),
vertical_spacing=0.10, row_heights=[.65,.35])
if T.notna().any():
fig.add_trace(go.Scatter(x=dt, y=T, name="Temperatura", line=dict(color=C["temp"],width=2.2)), 1, 1)
if Td.notna().any():
fig.add_trace(go.Scatter(x=dt, y=Td, name="Ponto de Orvalho",
line=dict(color=C["dewpoint"],width=1.5,dash="dot")), 1, 1)
# Faixa entre T e Td
if T.notna().any() and Td.notna().any():
mask = T.notna() & Td.notna()
dt_v = dt[mask].values; T_v = T[mask].values; Td_v = Td[mask].values
if len(dt_v) > 1:
fig.add_trace(go.Scatter(
x=np.concatenate([dt_v, dt_v[::-1]]),
y=np.concatenate([T_v, Td_v[::-1]]),
fill="toself", fillcolor="rgba(211,47,47,.10)",
line=dict(color="rgba(0,0,0,0)"), showlegend=False, hoverinfo="skip"), 1, 1)
if UR.notna().any():
fig.add_trace(go.Scatter(x=dt, y=UR, name="UR (%)", fill="tozeroy",
fillcolor=C["hum_fill"], line=dict(color=C["humidity"],width=1.8)), 2, 1)
fig.update_yaxes(range=[0,105], row=2)
_base_layout(fig, h=500)
fig.update_xaxes(row=2, rangeselector=dict(buttons=_rng_btns()))
return fig
def chart_temp_boxplot(anl: pd.DataFrame) -> Optional[go.Figure]:
df = pd.DataFrame({"T": anl["T"].values, "m": pd.to_datetime(anl["_dt"]).dt.month}).dropna()
if len(df) < 30:
return None
fig = go.Figure()
for m in range(1, 13):
vals = df[df["m"] == m]["T"]
if vals.empty: continue
fig.add_trace(go.Box(y=vals.values, name=MONTHS_PT[m-1], boxpoints=False,
marker_color=C["temp"], line_color=C["temp"],
fillcolor="rgba(211,47,47,.15)", showlegend=False))
fig.update_layout(template=TEMPLATE, height=380,
title="Distribuição Mensal de Temperatura",
yaxis_title="°C", margin=dict(l=55,r=20,t=50,b=40))
return fig
def chart_wind_speed(anl_r: pd.DataFrame) -> go.Figure:
dt = anl_r["_dt"]
WS = anl_r.get("WS", pd.Series(dtype=float))
WG = anl_r.get("WG", pd.Series(dtype=float))
fig = go.Figure()
if WG.notna().any():
fig.add_trace(go.Scatter(x=dt, y=WG, name="Rajada máx.", fill="tozeroy",
fillcolor=C["gust_fill"], line=dict(color=C["gust"],width=1.2,dash="dot")))
if WS.notna().any():
fig.add_trace(go.Scatter(x=dt, y=WS, name="Velocidade média", fill="tozeroy",
fillcolor=C["wind_fill"], line=dict(color=C["wind"],width=2.0)))
_base_layout(fig, h=380, title="Velocidade do Vento (kt)")
fig.update_yaxes(title="kt")
fig.update_xaxes(rangeselector=dict(buttons=_rng_btns()))
return fig
def chart_wind_rose(anl: pd.DataFrame) -> Optional[go.Figure]:
df = pd.DataFrame({"ws": anl["WS"].values, "wd": anl["WD"].values}).dropna()
df = df[(df["ws"] >= 0) & (df["wd"] >= 0) & (df["wd"] <= 360)]
if len(df) < 20:
return None
sector = 360 / 16
df["bin"] = ((df["wd"] + sector/2) % 360 // sector).astype(int).clip(0, 15)
df["cat"] = pd.cut(df["ws"], bins=WIND_BINS, labels=WIND_LABELS, right=False)
total = len(df)
fig = go.Figure()
for label, color in zip(WIND_LABELS, WIND_COLORS):
sub = df[df["cat"] == label]
r = [sub[sub["bin"] == i].shape[0] / total * 100 for i in range(16)]
fig.add_trace(go.Barpolar(r=r, theta=COMPASS_16, name=label,
marker_color=color, marker_line_color="white",
marker_line_width=0.5, opacity=0.92))
fig.update_layout(
template=None, paper_bgcolor="white",
title="Rosa dos Ventos — Frequência por Direção e Intensidade",
polar=dict(bgcolor="white",
radialaxis=dict(ticksuffix="%", tickformat=".1f",
gridcolor="#e0e0e0", linecolor="#bdbdbd"),
angularaxis=dict(direction="clockwise", rotation=90,
gridcolor="#e0e0e0", tickfont=dict(size=11,color="#333"))),
legend=dict(orientation="h", y=-0.18, x=0.5, xanchor="center", font=dict(size=11)),
height=500, margin=dict(l=40,r=40,t=55,b=90),
)
return fig
def chart_wind_direction(anl_r: pd.DataFrame) -> Optional[go.Figure]:
dt = anl_r["_dt"]
WD = anl_r.get("WD", pd.Series(dtype=float))
WS = anl_r.get("WS", pd.Series(dtype=float))
df = pd.DataFrame({"dt": dt.values, "wd": WD.values, "ws": WS.values}).dropna()
if df.empty:
return None
fig = go.Figure(go.Scatter(
x=df["dt"], y=df["wd"], mode="markers",
marker=dict(size=4, color=df["ws"], colorscale="YlOrRd",
colorbar=dict(title="kt", thickness=12, len=0.9),
showscale=True, opacity=0.75),
hovertemplate="Dir: %{y:.0f}°
Vel: %{marker.color:.1f} kt",
))
fig.update_yaxes(title="Direção (°)", range=[0,360],
tickvals=[0,45,90,135,180,225,270,315,360],
ticktext=["N","NE","L","SE","S","SO","O","NO","N"])
_base_layout(fig, h=340, title="Direção do Vento (cor = velocidade)", legend_h=False)
fig.update_xaxes(rangeselector=dict(buttons=_rng_btns()))
return fig
def chart_pressure(anl_r: pd.DataFrame) -> go.Figure:
dt = anl_r["_dt"]
QNH = anl_r.get("QNH", pd.Series(dtype=float))
fig = go.Figure()
if QNH.notna().any():
fig.add_trace(go.Scatter(x=dt, y=QNH, name="QNH", fill="tozeroy",
fillcolor=C["pres_fill"], line=dict(color=C["pressure"],width=2)))
mn = float(QNH.dropna().mean())
fig.add_hline(y=mn, line_dash="dash", line_color="#9e9e9e", line_width=1.2,
annotation_text=f" Média: {mn:.1f} hPa",
annotation_font=dict(size=10, color="#555"),
annotation_position="right")
_base_layout(fig, h=360, title="Pressão QNH (hPa)")
fig.update_yaxes(title="hPa")
fig.update_xaxes(rangeselector=dict(buttons=_rng_btns()))
return fig
def chart_precipitation(anl_r: pd.DataFrame, anl: pd.DataFrame) -> Optional[go.Figure]:
"""Precipitação na resolução selecionada (barras) + acumulado mensal."""
prec_r = anl_r.get("PREC", pd.Series(dtype=float)) if "PREC" in anl_r.columns else pd.Series(dtype=float)
if prec_r.isna().all() or prec_r.sum() == 0:
return None
dt_r = anl_r["_dt"]
daily = pd.DataFrame({"dt": dt_r.values, "p": prec_r.values}).dropna()
daily = daily[daily["p"] > 0]
monthly = (anl[["_dt","PREC"]].dropna()
.set_index("_dt").resample("ME")["PREC"].sum()
.reset_index())
fig = make_subplots(rows=2, cols=1, shared_xaxes=False,
subplot_titles=("Precipitação por período (mm)", "Acumulado mensal (mm)"),
vertical_spacing=0.18, row_heights=[.5,.5])
if not daily.empty:
fig.add_trace(go.Bar(x=daily["dt"], y=daily["p"], name="Precipitação",
marker_color=C["precip"], opacity=0.85), 1, 1)
if not monthly.empty:
fig.add_trace(go.Bar(x=monthly["_dt"].dt.strftime("%Y-%m"), y=monthly["PREC"],
name="Mensal", marker_color="#1976d2", opacity=0.85), 2, 1)
fig.update_layout(template=TEMPLATE, height=500, showlegend=False,
margin=dict(l=55,r=20,t=55,b=50),
yaxis_title="mm", yaxis2_title="mm")
fig.update_xaxes(showgrid=True, gridcolor="#f0f0f0")
return fig
def chart_visibility(anl_r: pd.DataFrame) -> go.Figure:
dt = anl_r["_dt"]
VIS = anl_r.get("VIS", pd.Series(dtype=float))
mx = float(VIS.dropna().max()) * 1.05 if VIS.notna().any() else 600
mx = max(mx, 600)
fig = go.Figure()
_hrect(fig, ICAO_VIS, ymax=mx)
if VIS.notna().any():
fig.add_trace(go.Scatter(x=dt, y=VIS, name="Visibilidade",
line=dict(color=C["visibility"], width=2)))
_base_layout(fig, h=420, title="Visibilidade Predominante (dam)", legend_h=False)
fig.update_yaxes(range=[0, mx], title="dam")
fig.update_xaxes(rangeselector=dict(buttons=_rng_btns()))
return fig
def chart_ceiling(anl_r: pd.DataFrame) -> Optional[go.Figure]:
dt = anl_r["_dt"]
TETO = anl_r.get("TETO", pd.Series(dtype=float))
if TETO.isna().all():
return None
mx = float(TETO.dropna().max()) * 1.05
mx = max(mx, 200)
fig = go.Figure()
_hrect(fig, ICAO_TETO, ymax=mx)
fig.add_trace(go.Scatter(x=dt, y=TETO, name="Teto",
line=dict(color=C["ceiling"], width=2)))
_base_layout(fig, h=420, title="Teto (dam)", legend_h=False)
fig.update_yaxes(range=[0, mx], title="dam")
fig.update_xaxes(rangeselector=dict(buttons=_rng_btns()))
return fig
def chart_icao_bars(anl: pd.DataFrame) -> Optional[go.Figure]:
df = anl[["VIS","TETO"]].dropna()
if df.empty:
return None
cats = df.apply(lambda r: _icao_cat(r.VIS, r.TETO), axis=1)
order = ["VMC","MVFR","IFR","LIFR"]
colors_bar = [C["visibility"], "#f9a825", "#e65100", "#b71c1c"]
counts = [(cats == c).sum() for c in order]
total = len(cats)
pcts = [v / total * 100 for v in counts]
fig = go.Figure(go.Bar(
x=order, y=pcts, text=[f"{p:.1f}%" for p in pcts],
textposition="outside", marker_color=colors_bar,
textfont=dict(size=13, color="#222"),
))
fig.update_layout(template=TEMPLATE, height=340,
title="Frequência por Categoria de Voo (% do tempo)",
yaxis_title="%", yaxis=dict(range=[0, max(pcts)*1.2 if pcts else 100]),
margin=dict(l=55,r=20,t=50,b=40), showlegend=False)
return fig
def _hex_rgba(hex_color: str, alpha: float = 0.15) -> str:
"""Converte '#rrggbb' para 'rgba(r,g,b,alpha)' compatível com Plotly."""
h = hex_color.lstrip("#")
r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
return f"rgba({r},{g},{b},{alpha})"
def chart_precip_boxplot(anl: pd.DataFrame) -> Optional[go.Figure]:
"""
Boxplot mensal da precipitação diária total (mm/dia).
Usa totais diários — não horários — para evitar distribuição degenerada.
Mostra outliers (eventos extremos), meteorologicamente relevantes.
"""
df = pd.DataFrame({"p": anl["PREC"].values, "dt": anl["_dt"].values}).dropna()
if df.empty or df["p"].sum() == 0:
return None
df["dt"] = pd.to_datetime(df["dt"])
daily = (df.groupby(df["dt"].dt.date)["p"]
.sum()
.reset_index(name="p"))
daily["m"] = pd.to_datetime(daily["dt"]).dt.month
fill = _hex_rgba(C["precip"], 0.15)
fig = go.Figure()
for m in range(1, 13):
vals = daily[daily["m"] == m]["p"]
if vals.empty:
continue
fig.add_trace(go.Box(
y=vals.values, name=MONTHS_PT[m - 1],
marker_color=C["precip"], line_color=C["precip"],
fillcolor=fill, showlegend=False,
boxpoints="outliers", # eventos extremos visíveis
marker=dict(size=4, opacity=0.6),
))
fig.update_layout(
template=TEMPLATE, height=380,
title="Precipitação Diária por Mês (mm/dia) — caixas + outliers",
yaxis_title="mm/dia",
margin=dict(l=55, r=20, t=50, b=40),
)
return fig
def chart_climatology(anl: pd.DataFrame, var: str, title: str,
unit: str, color: str) -> Optional[go.Figure]:
df = pd.DataFrame({"v": anl[var].values,
"m": pd.to_datetime(anl["_dt"]).dt.month.values}).dropna()
if len(df) < 30:
return None
fill = _hex_rgba(color, 0.15)
fig = go.Figure()
for m in range(1, 13):
vals = df[df["m"] == m]["v"]
if vals.empty: continue
fig.add_trace(go.Box(y=vals.values, name=MONTHS_PT[m-1], boxpoints=False,
marker_color=color, line_color=color,
fillcolor=fill, showlegend=False))
fig.update_layout(template=TEMPLATE, height=370, title=title,
yaxis_title=unit, margin=dict(l=55,r=20,t=50,b=40))
return fig
# ══════════════════════════════════════════════════════════════════════════════
# SIDEBAR
# ══════════════════════════════════════════════════════════════════════════════
catalog = list_catalog()
with st.sidebar:
st.markdown("## ✈️ MET Aeroportuário")
fonte = "🗄️ SQLite" if _db_available() else "📄 CSV"
st.caption(fonte)
st.markdown("---")
if not catalog:
st.warning("Nenhum dado encontrado.\nExecute a coleta primeiro.")
st.stop()
sel_dict = st.selectbox("Aeródromo", catalog, format_func=_fmt_aero)
sel_aero = sel_dict["icao"]
p_start, p_end = get_period(sel_aero)
st.caption(f"📅 Disponível: `{p_start}` → `{p_end}`")
st.markdown("---")
try:
dt_min = date.fromisoformat(p_start)
dt_max = date.fromisoformat(p_end)
except Exception:
dt_min = date(2000, 1, 1)
dt_max = date.today()
st.markdown("**Período de análise**")
date_range = st.date_input("", value=(dt_min, dt_max),
min_value=dt_min, max_value=dt_max)
st.markdown("---")
freq_label = st.radio("**Agregação temporal**", list(RESAMPLE.keys()), index=1)
freq = RESAMPLE[freq_label]
st.markdown("---")
st.caption("Fonte: ICEA/DECEA — Dados de Superfície")
# ── Carrega analytics para o período selecionado ─────────────────────────────
if isinstance(date_range, (list, tuple)) and len(date_range) == 2:
sel_start = str(date_range[0])
sel_end = str(date_range[1])
else:
sel_start = p_start
sel_end = p_end
anl_all = load_analytics(sel_aero, sel_start, sel_end)
if anl_all.empty:
st.error("Nenhum dado no período selecionado.")
st.stop()
# Garante que '_dt' é datetime
if not pd.api.types.is_datetime64_any_dtype(anl_all["_dt"]):
anl_all["_dt"] = pd.to_datetime(anl_all["_dt"], errors="coerce")
anl_all = anl_all.dropna(subset=["_dt"]).reset_index(drop=True)
# Dados já chegam em formato analítico (SQLite ou backup CSV de analytics)
# Se as colunas T, Td etc. existem → já é analytics
_IS_ANALYTICS = "T" in anl_all.columns
if _IS_ANALYTICS:
anl = anl_all.copy()
else:
# Fallback: dados no formato bruto (84 colunas) — aplica build_analytics
anl = build_analytics(anl_all)
raw = anl.copy() # alias para a tab Dados
anl_r = resample_anl(anl, freq)
# ── Header ───────────────────────────────────────────────────────────────────
n_obs = len(raw)
n_days = (raw["_dt"].max() - raw["_dt"].min()).days + 1
st.markdown(
f'',
unsafe_allow_html=True)
# ── KPIs ─────────────────────────────────────────────────────────────────────
def _kv(s: pd.Series) -> float:
v = s.dropna().mean() if "PREC" not in str(s.name) else s.dropna().sum()
return float(v) if pd.notna(v) else float("nan")
pct = pct_icao(anl)
T_med = float(anl["T"].dropna().mean()) if anl["T"].notna().any() else float("nan")
QNH_med = float(anl["QNH"].dropna().mean()) if anl["QNH"].notna().any() else float("nan")
WS_med = float(anl["WS"].dropna().mean()) if anl["WS"].notna().any() else float("nan")
VIS_med = float(anl["VIS"].dropna().mean()) if anl["VIS"].notna().any() else float("nan")
PREC_tot= float(anl["PREC"].dropna().sum()) if anl["PREC"].notna().any() else float("nan")
VMC_pct = pct.get("VMC", 0.0)
kpi_cfg = [
(T_med, "Temp. Média", "°C", C["temp"]),
(QNH_med, "QNH Médio", "hPa", C["pressure"]),
(WS_med, "Vento Médio", "kt", C["wind"]),
(VIS_med, "Visib. Média", "dam", C["visibility"]),
(PREC_tot,"Precipitação", "mm", C["precip"]),
(VMC_pct, "% VMC", "%", C["ceiling"]),
]
cols_k = st.columns(6)
for col, (val, lbl, unit, clr) in zip(cols_k, kpi_cfg):
disp = f"{val:.1f}" if not np.isnan(val) else "—"
col.markdown(
f''
f'
{disp}'
f' {unit}
'
f'
{lbl}
',
unsafe_allow_html=True)
st.markdown("
", unsafe_allow_html=True)
# ══════════════════════════════════════════════════════════════════════════════
# TABS
# ══════════════════════════════════════════════════════════════════════════════
tab0,tab1,tab2,tab3,tab4,tab5,tab6,tab7 = st.tabs([
"📥 Coleta",
"📈 Visão Geral",
"🌡️ Temperatura",
"💨 Vento",
"🌧️ Pressão e Precipitação",
"👁️ Visibilidade e Teto",
"📅 Climatologia",
"📋 Dados",
])
# ── Tab Coleta ────────────────────────────────────────────────────────────────
with tab0:
st.markdown("### Aquisição de Dados — ICEA/DECEA")
st.markdown("Baixa dados de superfície do site da ICEA e armazena no banco SQLite local.")
_btn_col, _info_col = st.columns([1, 3])
with _btn_col:
if st.button("🔄 Atualizar catálogo ICEA",
help="Acessa o site ICEA e baixa a lista completa de aeródromos disponíveis"):
from scraper_meteorologia import fetch_aerodrome_catalog
import db as _db
with st.spinner("Acessando site ICEA…"):
_new_catalog = fetch_aerodrome_catalog(headless=True)
_conn = _db.get_connection(DB_PATH)
_db.ensure_schema(_conn)
_n = _db.upsert_aerodromes(_conn, _new_catalog)
_conn.close()
st.success(f"{_n} aeródromos gravados no catálogo.")
st.rerun()
with _info_col:
_n_cat = len(catalog)
st.caption(
f"Catálogo atual: **{_n_cat}** aeródromos. "
"Use o botão ao lado para atualizar do site ICEA."
if _n_cat else
"Catálogo vazio — clique em **Atualizar catálogo ICEA** para baixar a lista do site."
)
col_form, col_log = st.columns([1, 1.6])
with col_form:
with st.form("form_coleta"):
_coleta_catalog = catalog if catalog else [{"icao": "SBGR", "nome": "", "uf": ""}]
_default_idx = next(
(i for i, d in enumerate(_coleta_catalog) if d["icao"] == sel_aero), 0
)
_sel_coleta = st.selectbox(
"Aeródromo (ICAO)", _coleta_catalog,
index=_default_idx, format_func=_fmt_aero,
)
aero_input = _sel_coleta["icao"]
modo = st.radio("Modo de coleta", [
"Todos os períodos (histórico completo)",
"Atualizar (apenas dados novos)",
"Período específico",
], index=0)
yr_col = st.columns(2)
start_yr = yr_col[0].number_input("Ano inicial", min_value=1948,
max_value=date.today().year,
value=date.today().year - 2,
disabled=(modo != "Período específico"))
end_yr = yr_col[1].number_input("Ano final", min_value=1948,
max_value=date.today().year,
value=date.today().year,
disabled=(modo != "Período específico"))
st.markdown("**Opções**")
headless = not st.checkbox("Mostrar Chrome", value=False)
validate = st.checkbox("Validar amostragem", value=True)
n_samp = st.slider("Amostras de validação", 5, 50, 20,
disabled=not validate)
cleanup = st.checkbox("Remover CSVs intermediários", value=True)
submitted = st.form_submit_button("▶ Iniciar coleta",
type="primary", use_container_width=True)
with col_log:
st.markdown("**Log de execução**")
log_box = st.empty()
prog_bar = st.progress(0.0)
stat_txt = st.empty()
res_box = st.empty()
if submitted:
all_years = (modo == "Todos os períodos (histórico completo)")
update_only= (modo == "Atualizar (apenas dados novos)")
log_lines: list[str] = []
q: queue.Queue = queue.Queue()
result_holder: dict = {}
def _log(msg: str):
q.put(("log", msg))
def _prog(pct: float, msg: str):
q.put(("prog", pct, msg))
def _worker():
try:
from pipeline import run_pipeline
kw: dict = dict(
aerodrome = aero_input,
dados_dir = str(DADOS_DIR),
all_years = all_years,
headless = headless,
n_samples = n_samp,
do_validate = validate,
do_cleanup = cleanup,
log = _log,
progress = _prog,
)
if modo == "Período específico":
kw["start_year"] = int(start_yr)
kw["end_year"] = int(end_yr)
elif update_only:
# Atualizar: passa all_years=False e usa o ano atual
kw["all_years"] = False
kw["start_year"] = date.today().year - 1
kw["end_year"] = date.today().year
result_holder.update(run_pipeline(**kw))
except Exception as exc:
q.put(("log", f"ERRO: {exc}"))
finally:
q.put(("done",))
t = threading.Thread(target=_worker, daemon=True)
t.start()
while t.is_alive() or not q.empty():
try:
item = q.get(timeout=0.4)
except queue.Empty:
continue
if item[0] == "done":
break
elif item[0] == "log":
log_lines.append(item[1])
log_box.code("\n".join(log_lines[-150:]))
elif item[0] == "prog":
prog_bar.progress(min(item[1], 1.0))
stat_txt.caption(item[2])
t.join()
prog_bar.progress(1.0)
st.cache_data.clear()
if result_holder:
r = result_holder
stat_txt.empty()
with res_box.container():
st.success("✅ Coleta concluída!")
m1, m2, m3, m4 = st.columns(4)
m1.metric("Observações", f"{r.get('rows',0):,}")
m2.metric("Val. OK", r.get("n_ok", 0))
m3.metric("Divergências", r.get("n_fail",0))
m4.metric("Período início",r.get("period_start","—"))
st.caption(f"Banco: `{r.get('db_path','')}`")
if r.get("errors"):
with st.expander("Divergências"):
for e in r["errors"]: st.text(e)
# ── Visão Geral ───────────────────────────────────────────────────────────────
with tab1:
st.plotly_chart(chart_overview(anl_r), use_container_width=True)
# ── Temperatura ───────────────────────────────────────────────────────────────
with tab2:
st.plotly_chart(chart_temp(anl_r), use_container_width=True)
f = chart_temp_boxplot(anl)
if f:
st.plotly_chart(f, use_container_width=True)
# ── Vento ─────────────────────────────────────────────────────────────────────
with tab3:
st.plotly_chart(chart_wind_speed(anl_r), use_container_width=True)
c1, c2 = st.columns([1.1, 1])
with c1:
f = chart_wind_rose(anl)
if f: st.plotly_chart(f, use_container_width=True)
with c2:
f = chart_wind_direction(anl_r)
if f: st.plotly_chart(f, use_container_width=True)
# ── Pressão & Precipitação ────────────────────────────────────────────────────
with tab4:
st.plotly_chart(chart_pressure(anl_r), use_container_width=True)
f = chart_precipitation(anl_r, anl)
if f:
st.plotly_chart(f, use_container_width=True)
else:
st.info("Dados de precipitação não disponíveis ou sem registro no período.")
# ── Visibilidade & Teto ───────────────────────────────────────────────────────
with tab5:
c1, c2 = st.columns(2)
with c1:
st.plotly_chart(chart_visibility(anl_r), use_container_width=True)
with c2:
f = chart_ceiling(anl_r)
if f: st.plotly_chart(f, use_container_width=True)
else: st.info("Dados de teto não disponíveis.")
st.markdown("#### Condições de voo por categoria ICAO")
c3, c4 = st.columns([1.5, 1])
with c3:
f = chart_icao_bars(anl)
if f: st.plotly_chart(f, use_container_width=True)
with c4:
if pct:
st.markdown("
", unsafe_allow_html=True)
for cat in ["VMC","MVFR","IFR","LIFR"]:
v = pct.get(cat, 0)
st.markdown(
f'{badge(cat)} {v:.1f}% do tempo',
unsafe_allow_html=True)
st.markdown("
", unsafe_allow_html=True)
# ── Climatologia ──────────────────────────────────────────────────────────────
with tab6:
st.markdown("Distribuição estatística mensal das variáveis no período selecionado.")
c1, c2 = st.columns(2)
with c1:
f = chart_climatology(anl, "T", "Temperatura Mensal (°C)", "°C", C["temp"])
if f: st.plotly_chart(f, use_container_width=True)
with c2:
f = chart_climatology(anl, "WS", "Vento Mensal (kt)", "kt", C["wind"])
if f: st.plotly_chart(f, use_container_width=True)
c3, c4 = st.columns(2)
with c3:
f = chart_climatology(anl, "VIS", "Visibilidade Mensal (dam)","dam", C["visibility"])
if f: st.plotly_chart(f, use_container_width=True)
with c4:
f = chart_climatology(anl, "QNH", "Pressão QNH Mensal (hPa)", "hPa", C["pressure"])
if f: st.plotly_chart(f, use_container_width=True)
f = chart_precip_boxplot(anl)
if f:
st.plotly_chart(f, use_container_width=True)
else:
st.info("Dados de precipitação insuficientes para o boxplot mensal.")
st.markdown("#### Resumo mensal")
rows = []
for m in range(1, 13):
mask_m = pd.to_datetime(anl["_dt"]).dt.month == m
if mask_m.sum() < 2: continue
r = {"Mês": MONTHS_PT[m-1], "N": int(mask_m.sum())}
for key, lbl, fmt in [("T","T média (°C)","{:.1f}"),("T","T máx (°C)","{:.1f}"),
("T","T mín (°C)","{:.1f}"),("WS","WS média (kt)","{:.1f}"),
("VIS","VIS média (dam)","{:.0f}"),("QNH","QNH (hPa)","{:.1f}"),
("PREC","Precip (mm)","{:.1f}")]:
vals = anl[key][mask_m].dropna()
if vals.empty: r[lbl] = "—"
elif "máx" in lbl: r[lbl] = fmt.format(vals.max())
elif "mín" in lbl: r[lbl] = fmt.format(vals.min())
elif "Precip" in lbl: r[lbl] = fmt.format(vals.sum())
else: r[lbl] = fmt.format(vals.mean())
rows.append(r)
if rows:
st.dataframe(pd.DataFrame(rows).set_index("Mês"), use_container_width=True)
# ── Dados ─────────────────────────────────────────────────────────────────────
with tab7:
COL_LABELS = {
"T": "Temperatura Bulbo Seco (°C)",
"Td": "Ponto de Orvalho (°C)",
"UR": "Umidade Relativa (%)",
"QNH": "Pressão QNH (hPa)",
"WS": "Velocidade do Vento (kt)",
"WG": "Rajada Máxima (kt)",
"WD": "Direção do Vento (°)",
"VIS": "Visibilidade Predominante (dam)",
"TETO": "Teto (dam)",
"PREC": "Precipitação (mm)",
}
avail = [c for c in COL_LABELS if c in anl.columns]
sel = st.multiselect(
"Variáveis",
options=avail,
default=[c for c in ["T", "Td", "UR", "QNH", "WS", "VIS"] if c in avail],
format_func=lambda c: COL_LABELS.get(c, c),
)
show_df = anl[["_dt"] + sel].copy()
show_df["_dt"] = show_df["_dt"].dt.strftime("%Y-%m-%d %H:%M")
show_df = show_df.rename(columns={"_dt": "Data/Hora", **{c: COL_LABELS[c] for c in sel}})
st.dataframe(show_df, use_container_width=True, height=480)
st.caption(f"{len(show_df):,} observações · {len(sel)} variáveis selecionadas")
_exp_left, _exp_right = st.columns([1, 1])
with _exp_left:
csv_bytes = show_df.to_csv(index=False, encoding="utf-8-sig").encode("utf-8-sig")
st.download_button(
"⬇️ Baixar CSV (variáveis selecionadas)",
csv_bytes,
f"{sel_aero}_export.csv",
"text/csv",
use_container_width=True,
)
with _exp_right:
if st.button("📁 Salvar CSV completo em pasta…",
use_container_width=True,
help="Abre um seletor de pasta e salva todas as variáveis meteorológicas"):
# monta DataFrame completo com todos as variáveis disponíveis
_full_labels = {
"T": "Temperatura Bulbo Seco (°C)", "Td": "Ponto de Orvalho (°C)",
"UR": "Umidade Relativa (%)", "QNH": "Pressão QNH (hPa)",
"WS": "Velocidade do Vento (kt)", "WG": "Rajada Máxima (kt)",
"WD": "Direção do Vento (°)", "VIS": "Visibilidade Predominante (dam)",
"TETO": "Teto (dam)", "PREC": "Precipitação (mm)",
}
_all_cols = [c for c in _full_labels if c in anl.columns]
_export_df = anl[["_dt"] + _all_cols].copy()
_export_df["_dt"] = _export_df["_dt"].dt.strftime("%Y-%m-%d %H:%M")
_export_df = _export_df.rename(
columns={"_dt": "Data/Hora", **{c: _full_labels[c] for c in _all_cols}}
)
_path = _save_csv_to_folder(_export_df, sel_aero, sel_start, sel_end)
if _path:
st.success(f"Arquivo salvo em:\n`{_path}`")
else:
st.info("Exportação cancelada.")