Files
kolmeasi 57fc64f120 V.1.0
2026-06-02 08:29:38 -03:00

1783 lines
74 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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", "37 kt", "712 kt", "1220 kt", "2030 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("""
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
html,body,[class*="css"]{font-family:'Inter',sans-serif!important}
.met-header{background:linear-gradient(135deg,#0d1b2a,#1b2b3d 55%,#243b55);
color:#ecf0f1;padding:1.4rem 2rem;border-radius:12px;margin-bottom:1.2rem}
.met-header h1{margin:0;font-size:1.7rem;font-weight:700}
.met-header .sub{margin:.3rem 0 0;opacity:.65;font-size:.83rem}
.kpi{background:white;border:1px solid #dde1e7;border-radius:10px;
padding:.85rem 1rem;text-align:center;box-shadow:0 1px 3px rgba(0,0,0,.07)}
.kpi .val{font-size:1.55rem;font-weight:700;line-height:1.15}
.kpi .lbl{font-size:.7rem;color:#6c757d;font-weight:600;
text-transform:uppercase;letter-spacing:.8px;margin-top:.25rem}
.kpi .unit{font-size:.82rem;color:#9e9e9e;font-weight:400}
.badge{padding:.15rem .55rem;border-radius:20px;font-size:.73rem;font-weight:700}
.badge-vmc {background:#c8e6c9;color:#1b5e20}
.badge-mvfr{background:#fff9c4;color:#e65100}
.badge-ifr {background:#ffe0b2;color:#bf360c}
.badge-lifr{background:#ffcdd2;color:#b71c1c}
.stTabs [data-baseweb="tab-list"]{background:#f0f2f6;border-radius:10px;
padding:.4rem;gap:.3rem}
.stTabs [data-baseweb="tab"]{border-radius:8px;padding:.4rem .85rem;
font-weight:500;font-size:.86rem;color:#374151}
.stTabs [data-baseweb="tab"]:hover{color:#111827;background:rgba(0,0,0,.04)}
.stTabs [aria-selected="true"]{background:white!important;
box-shadow:0 1px 4px rgba(0,0,0,.1);color:#1d4ed8!important;font-weight:600!important}
/* ── Sidebar escura ── */
section[data-testid="stSidebar"]>div,
section[data-testid="stSidebarContent"]{background:#145e63!important}
section[data-testid="stSidebarContent"],
section[data-testid="stSidebarContent"] p,
section[data-testid="stSidebarContent"] label,
section[data-testid="stSidebarContent"] span,
section[data-testid="stSidebarContent"] small,
section[data-testid="stSidebarContent"] div,
section[data-testid="stSidebarContent"] strong{color:#e0f2f1!important}
section[data-testid="stSidebarContent"] [data-testid="stCaptionContainer"],
section[data-testid="stSidebarContent"] .stCaption{color:#80cbc4!important}
section[data-testid="stSidebarContent"] h1,
section[data-testid="stSidebarContent"] h2,
section[data-testid="stSidebarContent"] h3,
section[data-testid="stSidebarContent"] h4{color:#ffffff!important;font-weight:700!important}
section[data-testid="stSidebarContent"] hr{border-color:rgba(255,255,255,.2)!important}
/* Inputs no sidebar */
section[data-testid="stSidebarContent"] .stSelectbox>div>div,
section[data-testid="stSidebarContent"] [data-baseweb="select"]>div{
background:rgba(255,255,255,.12)!important;border-color:rgba(255,255,255,.25)!important;color:#fff!important}
section[data-testid="stSidebarContent"] [data-baseweb="radio"] label{color:#e0f2f1!important}
section[data-testid="stSidebarContent"] [data-testid="stDateInput"] input{
background:rgba(255,255,255,.12)!important;color:#fff!important;border-color:rgba(255,255,255,.25)!important}
/* Markdown headings */
section[data-testid="stSidebarContent"] .stMarkdown{color:#e0f2f1!important}
</style>
""", 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/<ICAO>/<ICAO>_*.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/<ICAO>/
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 <prefix>_*_<keyword>*, 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 (0100).
"""
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 ``<span>`` string with the appropriate CSS class.
"""
cls = {
"VMC": "badge-vmc", "MVFR": "badge-mvfr",
"IFR": "badge-ifr", "LIFR": "badge-lifr",
}.get(cat, "")
return f'<span class="badge {cls}">{cat}</span>'
# ── 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}°<br>Vel: %{marker.color:.1f} kt<extra></extra>",
))
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'<div class="met-header">'
f'<h1>🛬 {sel_aero} — Dados Meteorológicos de Superfície</h1>'
f'<p class="sub">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</p></div>',
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'<div class="kpi">'
f'<div class="val" style="color:{clr}">{disp}'
f'<span class="unit"> {unit}</span></div>'
f'<div class="lbl">{lbl}</div></div>',
unsafe_allow_html=True)
st.markdown("<br>", 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("<br><br>", unsafe_allow_html=True)
for cat in ["VMC","MVFR","IFR","LIFR"]:
v = pct.get(cat, 0)
st.markdown(
f'{badge(cat)}&ensp;<strong>{v:.1f}%</strong> do tempo',
unsafe_allow_html=True)
st.markdown("<br>", 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.")