"""
Interactive meteorological dashboard for Brazilian aerodromes β ICEA/DECEA.
Displays hourly surface observation time-series, climatological distributions,
ICAO flight-category analysis, and an integrated data-collection form.
Tabs:
π₯ Coleta β trigger the scraping pipeline from the UI
π VisΓ£o Geral β four-panel overview (T, wind, visibility, QNH)
π‘οΈ Temperatura β temperature, dew point, relative humidity
π¨ Vento β wind speed/gust time-series and wind rose
π§οΈ PressΓ£o e PrecipitaΓ§Γ£o β QNH and rainfall charts
ποΈ Visibilidade e Teto β VIS/ceiling with ICAO category shading
π
Climatologia β monthly boxplots and summary table
π Dados β scrollable data table with CSV export
Usage:
streamlit run dashboard.py
"""
import queue
import re
import threading
import unicodedata
from datetime import date
from pathlib import Path
from typing import Optional
import numpy as np
import pandas as pd
import plotly.graph_objects as go
from plotly.subplots import make_subplots
import streamlit as st
# ββ Page config (must be first Streamlit call) ββββββββββββββββββββββββββββββββ
st.set_page_config(
page_title="MET AeroportuΓ‘rio",
page_icon="π¬",
layout="wide",
initial_sidebar_state="expanded",
)
# ββ Paths (resolved relative to this file, independent of CWD) βββββββββββββββ
_APPS_DIR = Path(__file__).resolve().parent # .../meteorologia_aeroportos/_apps/
_BASE_DIR = _APPS_DIR.parent # .../meteorologia_aeroportos/
_REPO_ROOT = _BASE_DIR.parents[2] # dataset/
DADOS_DIR = _BASE_DIR / "db" / "dados" # temporary files (scraper)
DB_PATH = _BASE_DIR / "db" / "met.db" # SQLite database
PREPROC_DIR = (
_REPO_ROOT / "tabelas" / "preproc" / "meteorologia_aeroportos"
) # permanent analytics CSV backups
DADOS_DIR.mkdir(parents=True, exist_ok=True)
# ββ Paleta βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
C = dict(
temp = "#d32f2f", temp_fill = "rgba(211,47,47,.13)",
dewpoint = "#1565c0", dew_fill = "rgba(21,101,192,.10)",
humidity = "#2e7d32", hum_fill = "rgba(46,125,50,.13)",
pressure = "#6a1b9a", pres_fill = "rgba(106,27,154,.10)",
wind = "#e65100", wind_fill = "rgba(230,81,0,.13)",
gust = "#bf360c", gust_fill = "rgba(191,54,12,.08)",
visibility = "#00695c", vis_fill = "rgba(0,105,92,.12)",
ceiling = "#33691e", ceil_fill = "rgba(51,105,30,.12)",
precip = "#1565c0", prec_fill = "rgba(21,101,192,.15)",
card_border= "#dde1e7",
)
TEMPLATE = "plotly_white"
ICAO_VIS = [ # (nome, min_dam, max_dam, fundo, fonte)
("LIFR", 0, 50, "rgba(255,205,210,.55)", "#b71c1c"),
("IFR", 50, 100, "rgba(255,224,178,.55)", "#bf360c"),
("MVFR", 100, 300, "rgba(255,249,196,.55)", "#f57f17"),
("VMC", 300, None, "rgba(200,230,201,.40)", "#1b5e20"),
]
ICAO_TETO = [
("LIFR", 0, 15, "rgba(255,205,210,.55)", "#b71c1c"),
("IFR", 15, 30, "rgba(255,224,178,.55)", "#bf360c"),
("MVFR", 30, 100, "rgba(255,249,196,.55)", "#f57f17"),
("VMC", 100, None, "rgba(200,230,201,.40)", "#1b5e20"),
]
WIND_BINS = [0, 3, 7, 12, 20, 30, 999]
WIND_LABELS = ["< 3 kt", "3β7 kt", "7β12 kt", "12β20 kt", "20β30 kt", "β₯ 30 kt"]
WIND_COLORS = ["#b0bec5", "#81d4fa", "#26c6da", "#ffca28", "#ff7043", "#b71c1c"]
COMPASS_16 = ["N","NNE","NE","ENE","E","ESE","SE","SSE",
"S","SSW","SW","WSW","W","WNW","NW","NNW"]
RESAMPLE = {"HorΓ‘rio": None, "DiΓ‘rio": "D", "Semanal": "W", "Mensal": "ME"}
MONTHS_PT = ["Jan","Fev","Mar","Abr","Mai","Jun","Jul","Ago","Set","Out","Nov","Dez"]
AGG_RULES = dict(T="mean", Td="mean", UR="mean", QNH="mean",
WS="mean", WG="max", WD="mean",
VIS="min", TETO="min", PREC="sum")
# ββ Chart heights (pixels) ββββββββββββββββββββββββββββββββββββββββββββββββββββ
H_OVERVIEW = 700
H_TEMP = 500
H_TEMP_BOX = 380
H_WIND_SPEED = 380
H_WIND_ROSE = 500
H_WIND_DIR = 340
H_PRESSURE = 360
H_PRECIP = 500
H_VISIBILITY = 420
H_CEILING = 420
H_ICAO_BARS = 340
H_PRECIP_BOX = 380
H_CLIM_BOX = 370
_COMPILED_RE = re.compile(r"(\w+)_(\d{4}_\d{2}_\d{2})_(\d{4}_\d{2}_\d{2})\.csv")
# ββ CSS ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
st.markdown("""
""", unsafe_allow_html=True)
# ββ UtilitΓ‘rios de dados: SQLite-first, fallback CSV βββββββββββββββββββββββββ
def _db_available() -> bool:
"""Returns True when the SQLite database file exists."""
return DB_PATH.exists()
def _migrate_legacy_paths() -> None:
"""Silently moves data files from older layouts to the current one.
Handles two legacy layouts, applying whichever migration is needed:
* v1 (original): ``dados/met.db`` and ``dados//_*.csv``
* v2 (mid-2026): ``met.db`` at the module root (before the _apps/ reorganisation)
Safe to call on every startup β does nothing when files are already in the
expected locations.
"""
# v1 β current: dados/met.db (old flat layout)
v1_db = _BASE_DIR / "dados" / "met.db"
if v1_db.exists() and not DB_PATH.exists():
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
v1_db.rename(DB_PATH)
st.info("Banco migrado de dados/met.db β db/met.db.")
# v2 β current: met.db at _BASE_DIR root (pre-_apps/ reorganisation)
v2_db = _BASE_DIR / "met.db"
if v2_db.exists() and not DB_PATH.exists():
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
v2_db.rename(DB_PATH)
st.info("Banco migrado de met.db β db/met.db.")
# v1 β current: analytics CSVs in dados//
v1_dados = _BASE_DIR / "dados"
if not v1_dados.is_dir():
return
for aero_dir in sorted(v1_dados.iterdir()):
if not aero_dir.is_dir():
continue
csv_files = list(aero_dir.glob(f"{aero_dir.name}_*.csv"))
if not csv_files:
continue
dest = PREPROC_DIR / aero_dir.name
dest.mkdir(parents=True, exist_ok=True)
moved = 0
for f in csv_files:
target = dest / f.name
if not target.exists():
f.rename(target)
moved += 1
if moved:
st.info(f"CSVs de {aero_dir.name} migrados para tabelas/preproc/β¦")
def list_aerodromes() -> list[str]:
"""Returns ICAO codes of aerodromes that have data (SQLite-first, CSV fallback)."""
if _db_available():
import db as _db
conn = _db.get_connection(DB_PATH)
_db.ensure_schema(conn)
aeros = _db.list_aerodromes(conn)
conn.close()
if aeros:
return sorted(aeros)
# Fallback: scan permanent analytics CSV directory
result: list[str] = []
if PREPROC_DIR.is_dir():
for d in sorted(PREPROC_DIR.iterdir()):
if not d.is_dir():
continue
for f in sorted(d.glob(f"{d.name}_*.csv")):
if _COMPILED_RE.match(f.name):
result.append(d.name)
break
return result
def list_catalog() -> list[dict]:
"""Returns the full aerodrome catalog (all entries, with or without data).
Used by the data-collection form so operators can select any aerodrome.
Returns:
List of ``{"icao", "nome", "uf"}`` dicts from the ``aerodromes`` table,
or a minimal list derived from :func:`list_aerodromes` as a fallback.
"""
if _db_available():
import db as _db
conn = _db.get_connection(DB_PATH)
_db.ensure_schema(conn)
catalog = _db.list_all_aerodromes(conn)
conn.close()
if catalog:
return catalog
return [{"icao": a, "nome": "", "uf": ""} for a in list_aerodromes()]
def list_catalog_with_data() -> list[dict]:
"""Returns only aerodromes that have observations in the database.
Used by the sidebar so users only see options they can actually analyse.
Returns:
List of ``{"icao", "nome", "uf"}`` dicts from the ``observations`` table
(joined with the catalog for display metadata). Falls back to
:func:`list_catalog` when no database is available.
"""
if _db_available():
import db as _db
conn = _db.get_connection(DB_PATH)
_db.ensure_schema(conn)
result = _db.list_aerodromes_with_data(conn)
conn.close()
if result:
return result
return list_catalog()
def _fmt_aero(d: dict) -> str:
"""Formata dict de aerΓ³dromo para exibiΓ§Γ£o no selectbox."""
nome = d.get("nome", "")
uf = d.get("uf", "")
if nome and uf:
return f"{d['icao']} β {nome} ({uf})"
if nome:
return f"{d['icao']} β {nome}"
return d["icao"]
def _save_csv_to_folder(df: pd.DataFrame, aerodrome: str,
start: str, end: str) -> str | None:
"""Abre diΓ‘logo nativo de pasta e salva o DataFrame como CSV. Retorna caminho ou None."""
import tkinter as tk
from tkinter import filedialog
root = tk.Tk()
root.withdraw()
root.wm_attributes("-topmost", True)
folder = filedialog.askdirectory(title="Escolha a pasta de destino")
root.destroy()
if not folder:
return None
s = start.replace("-", "")
e = end.replace("-", "")
filename = f"{aerodrome}_{s}_{e}.csv"
filepath = Path(folder) / filename
df.to_csv(filepath, index=False, encoding="utf-8-sig")
return str(filepath)
def get_period(aerodrome: str) -> tuple[str, str]:
"""Returns the ``(start, end)`` date strings for available data.
Args:
aerodrome: ICAO code to look up.
Returns:
``("YYYY-MM-DD", "YYYY-MM-DD")`` strings, or ``("", "")`` if no data
is available.
"""
if _db_available():
import db as _db
conn = _db.get_connection(DB_PATH)
cov = _db.get_coverage(conn, aerodrome)
conn.close()
if cov:
return str(cov[0]), str(cov[1])
# Fallback: scan permanent analytics CSV directory
csv_dir = PREPROC_DIR / aerodrome
if csv_dir.is_dir():
for f in sorted(csv_dir.glob(f"{aerodrome}_*.csv")):
m = _COMPILED_RE.match(f.name)
if m:
return m.group(2).replace("_", "-"), m.group(3).replace("_", "-")
return "", ""
@st.cache_data(ttl=600, show_spinner="Carregando dadosβ¦")
def load_analytics(aerodrome: str, start_str: str, end_str: str) -> pd.DataFrame:
"""
Carrega dados analΓticos: SQLite-first (query parcial), fallback CSV.
Retorna DataFrame com colunas: _dt, T, Td, UR, QNH, WS, WG, WD, VIS, TETO, PREC.
"""
if _db_available():
import db as _db
conn = _db.get_connection(DB_PATH)
df = _db.query_analytics(conn, aerodrome, start_str, end_str)
conn.close()
if not df.empty:
return df
# Fallback: read permanent analytics CSV backup
csv_dir = PREPROC_DIR / aerodrome
if csv_dir.is_dir():
for f in sorted(csv_dir.glob(f"{aerodrome}_*.csv")):
if _COMPILED_RE.match(f.name):
df = pd.read_csv(f, encoding="utf-8-sig", low_memory=False)
dt_col = next((c for c in df.columns if c in ("_dt", "dt") or
("Data" in c and "Hora" in c)), None)
if dt_col:
df["_dt"] = pd.to_datetime(df[dt_col], errors="coerce")
df = df.drop(columns=[dt_col], errors="ignore") if dt_col != "_dt" else df
df = df.dropna(subset=["_dt"]).sort_values("_dt").reset_index(drop=True)
return df
return pd.DataFrame()
# ββ ConversΓ£o numΓ©rica βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _norm(s: str) -> str:
return unicodedata.normalize("NFKD", s.lower()).encode("ascii","ignore").decode("ascii")
def to_num(s: pd.Series) -> pd.Series:
"""Converte para float: suporta vΓrgula decimal e '-' como NaN."""
if pd.api.types.is_numeric_dtype(s):
return s.astype(float)
v = s.astype(str).str.strip().replace({"-":None,"nan":None,"NaN":None,"None":None,"":None})
v = v.str.replace(",", ".", regex=False)
return pd.to_numeric(v, errors="coerce")
def _agg(raw: pd.DataFrame, prefix: str, keyword: str,
agg: str = "mean") -> pd.Series:
"""
Encontra colunas _*_*, prioriza genΓ©rica (_-_).
Agrega por 'mean', 'min' ou 'max' quando hΓ‘ mΓΊltiplas pistas/cabeceiras.
"""
kw = _norm(keyword)
cols = [c for c in raw.columns if c.startswith(f"{prefix}_") and kw in _norm(c)]
if not cols:
return pd.Series(dtype=float, index=raw.index)
generic = [c for c in cols if "_-_" in c]
specific = [c for c in cols if "_-_" not in c]
if generic:
s = to_num(raw[generic[0]])
if s.notna().mean() > 0.05:
return s
if not specific:
return to_num(raw[cols[0]])
mat = pd.DataFrame({c: to_num(raw[c]) for c in specific})
return {"mean": mat.mean, "min": mat.min, "max": mat.max}[agg](axis=1)
# ββ DataFrame analΓtico limpo ββββββββββββββββββββββββββββββββββββββββββββββββ
def build_analytics(raw: pd.DataFrame) -> pd.DataFrame:
"""
ConstrΓ³i um DataFrame com uma sΓ©rie float por variΓ‘vel meteorolΓ³gica,
eliminando a heterogeneidade de colunas por pista/cabeceira.
"""
qnh = to_num(raw["pres_QNH"]) if "pres_QNH" in raw.columns else _agg(raw,"pres","QNH")
vis = next((to_num(raw[c]) for c in raw.columns
if "visib" in c.lower() and "predominante" in _norm(c)), _agg(raw,"visib","Predominante"))
prec = next((to_num(raw[c]) for c in raw.columns
if "prec_precipita" in c.lower() and "dura" not in c.lower()),
pd.Series(dtype=float, index=raw.index))
return pd.DataFrame({
"_dt" : raw["_dt"].values,
"T" : _agg(raw, "temp", "Bulbo_Seco").values,
"Td" : _agg(raw, "temp", "Orvalho").values,
"UR" : _agg(raw, "temp", "Umidade").values,
"QNH" : qnh.values,
"WS" : _agg(raw, "vent", "Velocidade").values,
"WG" : _agg(raw, "vent", "Rajada", agg="max").values,
"WD" : _agg(raw, "vent", "Dire").values,
"VIS" : vis.values,
"TETO": _agg(raw, "teto", "Teto", agg="min").values,
"PREC": prec.values,
})
def resample_anl(anl: pd.DataFrame, freq: Optional[str]) -> pd.DataFrame:
"""Resamples the analytics DataFrame to the requested frequency.
Args:
anl: Analytics DataFrame with a ``_dt`` datetime column.
freq: Pandas offset alias (e.g. ``"D"``, ``"W"``, ``"ME"``), or
``None`` to return the data unchanged.
Returns:
Resampled (or unchanged) DataFrame.
"""
if freq is None:
return anl.copy()
rules = {k: v for k, v in AGG_RULES.items() if k in anl.columns}
return anl.set_index("_dt").resample(freq).agg(rules).reset_index()
# ββ ICAO helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _icao_cat(vis: float, teto: float) -> str:
"""Returns the ICAO flight category for the given visibility and ceiling.
Args:
vis: Visibility in dam.
teto: Ceiling in dam.
Returns:
One of ``"LIFR"``, ``"IFR"``, ``"MVFR"``, ``"VMC"``, or ``"β"`` when
either value is NaN.
"""
if np.isnan(vis) or np.isnan(teto):
return "β"
if vis < 50 or teto < 15: return "LIFR"
if vis < 100 or teto < 30: return "IFR"
if vis < 300 or teto < 100: return "MVFR"
return "VMC"
def pct_icao(anl: pd.DataFrame) -> dict[str, float]:
"""Returns percentage of time in each ICAO flight category.
Args:
anl: Analytics DataFrame with ``VIS`` and ``TETO`` columns.
Returns:
Dict mapping category name to percentage (0β100).
"""
df = anl[["VIS", "TETO"]].dropna()
if df.empty:
return {}
cats = df.apply(lambda r: _icao_cat(r.VIS, r.TETO), axis=1)
return (cats.value_counts() / len(cats) * 100).to_dict()
def badge(cat: str) -> str:
"""Returns an HTML badge span for the given ICAO category.
Args:
cat: Category code (``"VMC"``, ``"MVFR"``, ``"IFR"``, or ``"LIFR"``).
Returns:
HTML ```` string with the appropriate CSS class.
"""
cls = {
"VMC": "badge-vmc", "MVFR": "badge-mvfr",
"IFR": "badge-ifr", "LIFR": "badge-lifr",
}.get(cat, "")
return f'{cat}'
# ββ Chart helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _rng_btns() -> list[dict]:
"""Returns the standard time-range selector button list for Plotly."""
return [
dict(count=7, label="7d", step="day", stepmode="backward"),
dict(count=1, label="1m", step="month", stepmode="backward"),
dict(count=6, label="6m", step="month", stepmode="backward"),
dict(count=1, label="1a", step="year", stepmode="backward"),
dict(step="all", label="Tudo"),
]
def _base_layout(
fig: go.Figure,
h: int = H_VISIBILITY,
title: str = "",
legend_h: bool = True,
) -> None:
"""Applies standard layout settings to *fig* in-place.
Args:
fig: Plotly Figure to update.
h: Chart height in pixels.
title: Optional chart title.
legend_h: Show legend horizontally below the chart (``True``) or hide
it (``False``).
"""
kw = dict(orientation="h", y=-0.18, x=0, font=dict(size=11)) if legend_h else dict(visible=False)
fig.update_layout(template=TEMPLATE, height=h, title=title,
margin=dict(l=55,r=20,t=48,b=48),
hovermode="x unified", showlegend=legend_h,
legend=kw, plot_bgcolor="white")
fig.update_xaxes(showgrid=True, gridcolor="#f0f0f0", zeroline=False)
fig.update_yaxes(showgrid=True, gridcolor="#f0f0f0", zeroline=False)
def _hrect(
fig: go.Figure,
thresholds: list[tuple],
row: int = 1,
col: int = 1,
ymax: float = 9999,
) -> None:
"""Adds ICAO category shaded horizontal bands to *fig*.
Args:
fig: Plotly Figure (must support ``add_hrect``).
thresholds: List of ``(name, lo, hi, fill_color, font_color)`` tuples.
row: Subplot row (1-based).
col: Subplot column (1-based).
ymax: Upper bound of the visible axis range (caps the top band).
"""
for name, lo, hi, fill, clr in thresholds:
y1 = min(hi, ymax) if hi else ymax
if lo >= ymax: continue
fig.add_hrect(y0=lo, y1=y1, fillcolor=fill, opacity=1, line_width=0,
row=row, col=col,
annotation_text=f" {name}",
annotation_position="left",
annotation_font=dict(size=9.5, color=clr, family="Inter"))
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# GRΓFICOS
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def chart_overview(anl_r: pd.DataFrame) -> go.Figure:
"""Four-panel overview: temperature, wind, visibility, QNH.
Args:
anl_r: Resampled analytics DataFrame.
Returns:
Plotly Figure with 4 subplots sharing the x-axis.
"""
dt = anl_r["_dt"]
fig = make_subplots(rows=4, cols=1, shared_xaxes=True,
subplot_titles=("Temperatura (Β°C)","Vento (kt)","Visibilidade (dam)","PressΓ£o QNH (hPa)"),
vertical_spacing=0.055, row_heights=[.30,.23,.25,.22])
T, Td = anl_r.get("T"), anl_r.get("Td")
WS = anl_r.get("WS")
VIS = anl_r.get("VIS")
QNH = anl_r.get("QNH")
if T is not None and T.notna().any():
fig.add_trace(go.Scatter(x=dt, y=T, name="T (Β°C)", line=dict(color=C["temp"],width=1.8)), 1, 1)
if Td is not None and Td.notna().any():
fig.add_trace(go.Scatter(x=dt, y=Td, name="Td (Β°C)", line=dict(color=C["dewpoint"],width=1.2,dash="dot")), 1, 1)
if WS is not None and WS.notna().any():
fig.add_trace(go.Scatter(x=dt, y=WS, name="Vento (kt)", fill="tozeroy",
fillcolor=C["wind_fill"], line=dict(color=C["wind"],width=1.8)), 2, 1)
if VIS is not None and VIS.notna().any():
fig.add_trace(go.Scatter(x=dt, y=VIS, name="Visib (dam)", fill="tozeroy",
fillcolor=C["vis_fill"], line=dict(color=C["visibility"],width=1.8)), 3, 1)
if QNH is not None and QNH.notna().any():
fig.add_trace(go.Scatter(x=dt, y=QNH, name="QNH (hPa)", line=dict(color=C["pressure"],width=1.8)), 4, 1)
for i in range(1, 5):
fig.update_xaxes(row=i, showgrid=True, gridcolor="#f0f0f0")
fig.update_yaxes(row=i, showgrid=True, gridcolor="#f0f0f0")
fig.update_xaxes(row=4, rangeselector=dict(buttons=_rng_btns()))
fig.update_layout(template=TEMPLATE, height=H_OVERVIEW, showlegend=False,
hovermode="x unified", margin=dict(l=55,r=20,t=50,b=40),
plot_bgcolor="white")
return fig
def chart_temp(anl_r: pd.DataFrame) -> go.Figure:
"""Temperature, dew point and relative humidity subplots.
Args:
anl_r: Resampled analytics DataFrame.
Returns:
Plotly Figure with 2 subplots (T/Td on top, UR below).
"""
dt = anl_r["_dt"]
T = anl_r.get("T", pd.Series(dtype=float))
Td = anl_r.get("Td", pd.Series(dtype=float))
UR = anl_r.get("UR", pd.Series(dtype=float))
fig = make_subplots(rows=2, cols=1, shared_xaxes=True,
subplot_titles=("Temperatura e Ponto de Orvalho (Β°C)","Umidade Relativa (%)"),
vertical_spacing=0.10, row_heights=[.65,.35])
if T.notna().any():
fig.add_trace(go.Scatter(x=dt, y=T, name="Temperatura", line=dict(color=C["temp"],width=2.2)), 1, 1)
if Td.notna().any():
fig.add_trace(go.Scatter(x=dt, y=Td, name="Ponto de Orvalho",
line=dict(color=C["dewpoint"],width=1.5,dash="dot")), 1, 1)
# Faixa entre T e Td
if T.notna().any() and Td.notna().any():
mask = T.notna() & Td.notna()
dt_v = dt[mask].values; T_v = T[mask].values; Td_v = Td[mask].values
if len(dt_v) > 1:
fig.add_trace(go.Scatter(
x=np.concatenate([dt_v, dt_v[::-1]]),
y=np.concatenate([T_v, Td_v[::-1]]),
fill="toself", fillcolor="rgba(211,47,47,.10)",
line=dict(color="rgba(0,0,0,0)"), showlegend=False, hoverinfo="skip"), 1, 1)
if UR.notna().any():
fig.add_trace(go.Scatter(x=dt, y=UR, name="UR (%)", fill="tozeroy",
fillcolor=C["hum_fill"], line=dict(color=C["humidity"],width=1.8)), 2, 1)
fig.update_yaxes(range=[0,105], row=2)
_base_layout(fig, h=H_TEMP)
fig.update_xaxes(row=2, rangeselector=dict(buttons=_rng_btns()))
return fig
def chart_temp_boxplot(anl: pd.DataFrame) -> Optional[go.Figure]:
df = pd.DataFrame({"T": anl["T"].values, "m": pd.to_datetime(anl["_dt"]).dt.month}).dropna()
if len(df) < 30:
return None
fig = go.Figure()
for m in range(1, 13):
vals = df[df["m"] == m]["T"]
if vals.empty: continue
fig.add_trace(go.Box(y=vals.values, name=MONTHS_PT[m-1], boxpoints=False,
marker_color=C["temp"], line_color=C["temp"],
fillcolor="rgba(211,47,47,.15)", showlegend=False))
fig.update_layout(template=TEMPLATE, height=H_TEMP_BOX,
title="DistribuiΓ§Γ£o Mensal de Temperatura",
yaxis_title="Β°C", margin=dict(l=55,r=20,t=50,b=40))
return fig
def chart_wind_speed(anl_r: pd.DataFrame) -> go.Figure:
"""Wind speed and gust time-series chart.
Args:
anl_r: Resampled analytics DataFrame.
Returns:
Plotly Figure showing WS (mean) and WG (gust) as filled areas.
"""
dt = anl_r["_dt"]
WS = anl_r.get("WS", pd.Series(dtype=float))
WG = anl_r.get("WG", pd.Series(dtype=float))
fig = go.Figure()
if WG.notna().any():
fig.add_trace(go.Scatter(x=dt, y=WG, name="Rajada mΓ‘x.", fill="tozeroy",
fillcolor=C["gust_fill"], line=dict(color=C["gust"],width=1.2,dash="dot")))
if WS.notna().any():
fig.add_trace(go.Scatter(x=dt, y=WS, name="Velocidade mΓ©dia", fill="tozeroy",
fillcolor=C["wind_fill"], line=dict(color=C["wind"],width=2.0)))
_base_layout(fig, h=H_WIND_SPEED, title="Velocidade do Vento (kt)")
fig.update_yaxes(title="kt")
fig.update_xaxes(rangeselector=dict(buttons=_rng_btns()))
return fig
def chart_wind_rose(anl: pd.DataFrame) -> Optional[go.Figure]:
"""16-point compass wind rose with 6 speed bins.
Args:
anl: Full (non-resampled) analytics DataFrame.
Returns:
Plotly polar bar chart, or ``None`` if fewer than 20 valid rows.
"""
df = pd.DataFrame({"ws": anl["WS"].values, "wd": anl["WD"].values}).dropna()
df = df[(df["ws"] >= 0) & (df["wd"] >= 0) & (df["wd"] <= 360)]
if len(df) < 20:
return None
sector = 360 / 16
df["bin"] = ((df["wd"] + sector/2) % 360 // sector).astype(int).clip(0, 15)
df["cat"] = pd.cut(df["ws"], bins=WIND_BINS, labels=WIND_LABELS, right=False)
total = len(df)
fig = go.Figure()
for label, color in zip(WIND_LABELS, WIND_COLORS):
sub = df[df["cat"] == label]
r = [sub[sub["bin"] == i].shape[0] / total * 100 for i in range(16)]
fig.add_trace(go.Barpolar(r=r, theta=COMPASS_16, name=label,
marker_color=color, marker_line_color="white",
marker_line_width=0.5, opacity=0.92))
fig.update_layout(
template=None, paper_bgcolor="white",
title="Rosa dos Ventos β FrequΓͺncia por DireΓ§Γ£o e Intensidade",
polar=dict(bgcolor="white",
radialaxis=dict(ticksuffix="%", tickformat=".1f",
gridcolor="#e0e0e0", linecolor="#bdbdbd"),
angularaxis=dict(direction="clockwise", rotation=90,
gridcolor="#e0e0e0", tickfont=dict(size=11,color="#333"))),
legend=dict(orientation="h", y=-0.18, x=0.5, xanchor="center", font=dict(size=11)),
height=H_WIND_ROSE, margin=dict(l=40,r=40,t=55,b=90),
)
return fig
def chart_wind_direction(anl_r: pd.DataFrame) -> Optional[go.Figure]:
"""Wind direction scatter coloured by wind speed.
Args:
anl_r: Resampled analytics DataFrame.
Returns:
Plotly scatter Figure, or ``None`` if the DataFrame is empty after
dropping NaNs.
"""
dt = anl_r["_dt"]
WD = anl_r.get("WD", pd.Series(dtype=float))
WS = anl_r.get("WS", pd.Series(dtype=float))
df = pd.DataFrame({"dt": dt.values, "wd": WD.values, "ws": WS.values}).dropna()
if df.empty:
return None
fig = go.Figure(go.Scatter(
x=df["dt"], y=df["wd"], mode="markers",
marker=dict(size=4, color=df["ws"], colorscale="YlOrRd",
colorbar=dict(title="kt", thickness=12, len=0.9),
showscale=True, opacity=0.75),
hovertemplate="Dir: %{y:.0f}Β°
Vel: %{marker.color:.1f} kt",
))
fig.update_yaxes(title="DireΓ§Γ£o (Β°)", range=[0,360],
tickvals=[0,45,90,135,180,225,270,315,360],
ticktext=["N","NE","L","SE","S","SO","O","NO","N"])
_base_layout(fig, h=H_WIND_DIR, title="DireΓ§Γ£o do Vento (cor = velocidade)", legend_h=False)
fig.update_xaxes(rangeselector=dict(buttons=_rng_btns()))
return fig
def chart_pressure(anl_r: pd.DataFrame) -> go.Figure:
"""QNH time-series with a mean reference line.
Args:
anl_r: Resampled analytics DataFrame.
Returns:
Plotly Figure.
"""
dt = anl_r["_dt"]
QNH = anl_r.get("QNH", pd.Series(dtype=float))
fig = go.Figure()
if QNH.notna().any():
fig.add_trace(go.Scatter(x=dt, y=QNH, name="QNH", fill="tozeroy",
fillcolor=C["pres_fill"], line=dict(color=C["pressure"],width=2)))
mn = float(QNH.dropna().mean())
fig.add_hline(y=mn, line_dash="dash", line_color="#9e9e9e", line_width=1.2,
annotation_text=f" MΓ©dia: {mn:.1f} hPa",
annotation_font=dict(size=10, color="#555"),
annotation_position="right")
_base_layout(fig, h=H_PRESSURE, title="PressΓ£o QNH (hPa)")
fig.update_yaxes(title="hPa")
fig.update_xaxes(rangeselector=dict(buttons=_rng_btns()))
return fig
def chart_precipitation(anl_r: pd.DataFrame, anl: pd.DataFrame) -> Optional[go.Figure]:
"""PrecipitaΓ§Γ£o na resoluΓ§Γ£o selecionada (barras) + acumulado mensal."""
prec_r = anl_r.get("PREC", pd.Series(dtype=float)) if "PREC" in anl_r.columns else pd.Series(dtype=float)
if prec_r.isna().all() or prec_r.sum() == 0:
return None
dt_r = anl_r["_dt"]
daily = pd.DataFrame({"dt": dt_r.values, "p": prec_r.values}).dropna()
daily = daily[daily["p"] > 0]
monthly = (anl[["_dt","PREC"]].dropna()
.set_index("_dt").resample("ME")["PREC"].sum()
.reset_index())
fig = make_subplots(rows=2, cols=1, shared_xaxes=False,
subplot_titles=("PrecipitaΓ§Γ£o por perΓodo (mm)", "Acumulado mensal (mm)"),
vertical_spacing=0.18, row_heights=[.5,.5])
if not daily.empty:
fig.add_trace(go.Bar(x=daily["dt"], y=daily["p"], name="PrecipitaΓ§Γ£o",
marker_color=C["precip"], opacity=0.85), 1, 1)
if not monthly.empty:
fig.add_trace(go.Bar(x=monthly["_dt"].dt.strftime("%Y-%m"), y=monthly["PREC"],
name="Mensal", marker_color="#1976d2", opacity=0.85), 2, 1)
fig.update_layout(template=TEMPLATE, height=H_PRECIP, showlegend=False,
margin=dict(l=55,r=20,t=55,b=50),
yaxis_title="mm", yaxis2_title="mm")
fig.update_xaxes(showgrid=True, gridcolor="#f0f0f0")
return fig
def chart_visibility(anl_r: pd.DataFrame) -> go.Figure:
"""Visibility time-series with ICAO category background bands.
Args:
anl_r: Resampled analytics DataFrame.
Returns:
Plotly Figure.
"""
dt = anl_r["_dt"]
VIS = anl_r.get("VIS", pd.Series(dtype=float))
mx = float(VIS.dropna().max()) * 1.05 if VIS.notna().any() else 600
mx = max(mx, 600)
fig = go.Figure()
_hrect(fig, ICAO_VIS, ymax=mx)
if VIS.notna().any():
fig.add_trace(go.Scatter(x=dt, y=VIS, name="Visibilidade",
line=dict(color=C["visibility"], width=2)))
_base_layout(fig, h=H_VISIBILITY, title="Visibilidade Predominante (dam)", legend_h=False)
fig.update_yaxes(range=[0, mx], title="dam")
fig.update_xaxes(rangeselector=dict(buttons=_rng_btns()))
return fig
def chart_ceiling(anl_r: pd.DataFrame) -> Optional[go.Figure]:
"""Ceiling time-series with ICAO category background bands.
Args:
anl_r: Resampled analytics DataFrame.
Returns:
Plotly Figure, or ``None`` if all ceiling values are NaN.
"""
dt = anl_r["_dt"]
TETO = anl_r.get("TETO", pd.Series(dtype=float))
if TETO.isna().all():
return None
mx = float(TETO.dropna().max()) * 1.05
mx = max(mx, 200)
fig = go.Figure()
_hrect(fig, ICAO_TETO, ymax=mx)
fig.add_trace(go.Scatter(x=dt, y=TETO, name="Teto",
line=dict(color=C["ceiling"], width=2)))
_base_layout(fig, h=H_CEILING, title="Teto (dam)", legend_h=False)
fig.update_yaxes(range=[0, mx], title="dam")
fig.update_xaxes(rangeselector=dict(buttons=_rng_btns()))
return fig
def chart_icao_bars(anl: pd.DataFrame) -> Optional[go.Figure]:
"""Bar chart showing percentage of time in each ICAO flight category.
Args:
anl: Full (non-resampled) analytics DataFrame.
Returns:
Plotly Figure, or ``None`` if no valid VIS/TETO data.
"""
df = anl[["VIS","TETO"]].dropna()
if df.empty:
return None
cats = df.apply(lambda r: _icao_cat(r.VIS, r.TETO), axis=1)
order = ["VMC","MVFR","IFR","LIFR"]
colors_bar = [C["visibility"], "#f9a825", "#e65100", "#b71c1c"]
counts = [(cats == c).sum() for c in order]
total = len(cats)
pcts = [v / total * 100 for v in counts]
fig = go.Figure(go.Bar(
x=order, y=pcts, text=[f"{p:.1f}%" for p in pcts],
textposition="outside", marker_color=colors_bar,
textfont=dict(size=13, color="#222"),
))
fig.update_layout(template=TEMPLATE, height=H_ICAO_BARS,
title="FrequΓͺncia por Categoria de Voo (% do tempo)",
yaxis_title="%", yaxis=dict(range=[0, max(pcts)*1.2 if pcts else 100]),
margin=dict(l=55,r=20,t=50,b=40), showlegend=False)
return fig
def _hex_rgba(hex_color: str, alpha: float = 0.15) -> str:
"""Converte '#rrggbb' para 'rgba(r,g,b,alpha)' compatΓvel com Plotly."""
h = hex_color.lstrip("#")
r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
return f"rgba({r},{g},{b},{alpha})"
def chart_precip_boxplot(anl: pd.DataFrame) -> Optional[go.Figure]:
"""
Boxplot mensal da precipitaΓ§Γ£o diΓ‘ria total (mm/dia).
Usa totais diΓ‘rios β nΓ£o horΓ‘rios β para evitar distribuiΓ§Γ£o degenerada.
Mostra outliers (eventos extremos), meteorologicamente relevantes.
"""
df = pd.DataFrame({"p": anl["PREC"].values, "dt": anl["_dt"].values}).dropna()
if df.empty or df["p"].sum() == 0:
return None
df["dt"] = pd.to_datetime(df["dt"])
daily = (df.groupby(df["dt"].dt.date)["p"]
.sum()
.reset_index(name="p"))
daily["m"] = pd.to_datetime(daily["dt"]).dt.month
fill = _hex_rgba(C["precip"], 0.15)
fig = go.Figure()
for m in range(1, 13):
vals = daily[daily["m"] == m]["p"]
if vals.empty:
continue
fig.add_trace(go.Box(
y=vals.values, name=MONTHS_PT[m - 1],
marker_color=C["precip"], line_color=C["precip"],
fillcolor=fill, showlegend=False,
boxpoints="outliers", # eventos extremos visΓveis
marker=dict(size=4, opacity=0.6),
))
fig.update_layout(
template=TEMPLATE, height=H_PRECIP_BOX,
title="PrecipitaΓ§Γ£o DiΓ‘ria por MΓͺs (mm/dia) β caixas + outliers",
yaxis_title="mm/dia",
margin=dict(l=55, r=20, t=50, b=40),
)
return fig
def chart_climatology(
anl: pd.DataFrame,
var: str,
title: str,
unit: str,
color: str,
) -> Optional[go.Figure]:
"""Monthly boxplot for a single meteorological variable.
Args:
anl: Full analytics DataFrame.
var: Column name to plot (e.g. ``"T"``).
title: Chart title.
unit: Y-axis unit label.
color: Hex colour for the boxes.
Returns:
Plotly Figure, or ``None`` if fewer than 30 valid observations.
"""
df = pd.DataFrame({"v": anl[var].values,
"m": pd.to_datetime(anl["_dt"]).dt.month.values}).dropna()
if len(df) < 30:
return None
fill = _hex_rgba(color, 0.15)
fig = go.Figure()
for m in range(1, 13):
vals = df[df["m"] == m]["v"]
if vals.empty: continue
fig.add_trace(go.Box(y=vals.values, name=MONTHS_PT[m-1], boxpoints=False,
marker_color=color, line_color=color,
fillcolor=fill, showlegend=False))
fig.update_layout(template=TEMPLATE, height=H_CLIM_BOX, title=title,
yaxis_title=unit, margin=dict(l=55,r=20,t=50,b=40))
return fig
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# STARTUP β migration + catalog loading
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
_migrate_legacy_paths()
catalog_full = list_catalog() # all aerodromes β collection form
catalog_with_data = list_catalog_with_data() # only aerodromes with data β sidebar
with st.sidebar:
st.markdown("## βοΈ MET AeroportuΓ‘rio")
fonte = "ποΈ SQLite" if _db_available() else "π CSV"
st.caption(fonte)
st.markdown("---")
if not catalog_with_data:
if catalog_full:
st.info(
"Nenhum dado coletado ainda.\n\n"
"Use a aba **π₯ Coleta** para baixar dados do site ICEA."
)
else:
st.warning(
"Banco vazio e catΓ‘logo nΓ£o inicializado.\n\n"
"Use **Atualizar catΓ‘logo ICEA** na aba Coleta e depois inicie a coleta."
)
st.stop()
sel_dict = st.selectbox("AerΓ³dromo", catalog_with_data, format_func=_fmt_aero)
sel_aero = sel_dict["icao"]
p_start, p_end = get_period(sel_aero)
st.caption(f"π
DisponΓvel: `{p_start}` β `{p_end}`")
st.markdown("---")
try:
dt_min = date.fromisoformat(p_start)
dt_max = date.fromisoformat(p_end)
except Exception:
dt_min = date(2000, 1, 1)
dt_max = date.today()
st.markdown("**PerΓodo de anΓ‘lise**")
date_range = st.date_input("", value=(dt_min, dt_max),
min_value=dt_min, max_value=dt_max)
st.markdown("---")
freq_label = st.radio("**AgregaΓ§Γ£o temporal**", list(RESAMPLE.keys()), index=1)
freq = RESAMPLE[freq_label]
st.markdown("---")
st.caption("Fonte: ICEA/DECEA β Dados de SuperfΓcie")
# ββ Carrega analytics para o perΓodo selecionado βββββββββββββββββββββββββββββ
if isinstance(date_range, (list, tuple)) and len(date_range) == 2:
sel_start = str(date_range[0])
sel_end = str(date_range[1])
else:
sel_start = p_start
sel_end = p_end
anl_all = load_analytics(sel_aero, sel_start, sel_end)
if anl_all.empty:
st.error("Nenhum dado no perΓodo selecionado.")
st.stop()
# Garante que '_dt' Γ© datetime
if not pd.api.types.is_datetime64_any_dtype(anl_all["_dt"]):
anl_all["_dt"] = pd.to_datetime(anl_all["_dt"], errors="coerce")
anl_all = anl_all.dropna(subset=["_dt"]).reset_index(drop=True)
# Dados jΓ‘ chegam em formato analΓtico (SQLite ou backup CSV de analytics)
# Se as colunas T, Td etc. existem β jΓ‘ Γ© analytics
_IS_ANALYTICS = "T" in anl_all.columns
if _IS_ANALYTICS:
anl = anl_all.copy()
else:
# Fallback: dados no formato bruto (84 colunas) β aplica build_analytics
anl = build_analytics(anl_all)
raw = anl.copy() # alias para a tab Dados
anl_r = resample_anl(anl, freq)
# ββ Header βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
n_obs = len(raw)
n_days = (raw["_dt"].max() - raw["_dt"].min()).days + 1
st.markdown(
f'',
unsafe_allow_html=True)
# ββ KPIs βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
def _kv(s: pd.Series) -> float:
"""Returns the mean (or sum for PREC) of *s*, or NaN if empty."""
v = s.dropna().mean() if "PREC" not in str(s.name) else s.dropna().sum()
return float(v) if pd.notna(v) else float("nan")
pct = pct_icao(anl)
T_med = float(anl["T"].dropna().mean()) if anl["T"].notna().any() else float("nan")
QNH_med = float(anl["QNH"].dropna().mean()) if anl["QNH"].notna().any() else float("nan")
WS_med = float(anl["WS"].dropna().mean()) if anl["WS"].notna().any() else float("nan")
VIS_med = float(anl["VIS"].dropna().mean()) if anl["VIS"].notna().any() else float("nan")
PREC_tot= float(anl["PREC"].dropna().sum()) if anl["PREC"].notna().any() else float("nan")
VMC_pct = pct.get("VMC", 0.0)
kpi_cfg = [
(T_med, "Temp. MΓ©dia", "Β°C", C["temp"]),
(QNH_med, "QNH MΓ©dio", "hPa", C["pressure"]),
(WS_med, "Vento MΓ©dio", "kt", C["wind"]),
(VIS_med, "Visib. MΓ©dia", "dam", C["visibility"]),
(PREC_tot,"PrecipitaΓ§Γ£o", "mm", C["precip"]),
(VMC_pct, "% VMC", "%", C["ceiling"]),
]
cols_k = st.columns(6)
for col, (val, lbl, unit, clr) in zip(cols_k, kpi_cfg):
disp = f"{val:.1f}" if not np.isnan(val) else "β"
col.markdown(
f''
f'
{disp}'
f' {unit}
'
f'
{lbl}
',
unsafe_allow_html=True)
st.markdown("
", unsafe_allow_html=True)
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
# TABS
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
tab0,tab1,tab2,tab3,tab4,tab5,tab6,tab7 = st.tabs([
"π₯ Coleta",
"π VisΓ£o Geral",
"π‘οΈ Temperatura",
"π¨ Vento",
"π§οΈ PressΓ£o e PrecipitaΓ§Γ£o",
"ποΈ Visibilidade e Teto",
"π
Climatologia",
"π Dados",
])
# ββ Tab Coleta ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
with tab0:
st.markdown("### AquisiΓ§Γ£o de Dados β ICEA/DECEA")
st.markdown("Baixa dados de superfΓcie do site da ICEA e armazena no banco SQLite local.")
_btn_col, _info_col = st.columns([1, 3])
with _btn_col:
if st.button("π Atualizar catΓ‘logo ICEA",
help="Acessa o site ICEA e baixa a lista completa de aerΓ³dromos disponΓveis"):
from scraper_meteorologia import fetch_aerodrome_catalog
import db as _db
with st.spinner("Acessando site ICEAβ¦"):
_new_catalog = fetch_aerodrome_catalog(headless=True)
_conn = _db.get_connection(DB_PATH)
_db.ensure_schema(_conn)
_n = _db.upsert_aerodromes(_conn, _new_catalog)
_conn.close()
st.success(f"{_n} aerΓ³dromos gravados no catΓ‘logo.")
st.rerun()
with _info_col:
_n_cat = len(catalog_full)
st.caption(
f"CatΓ‘logo atual: **{_n_cat}** aerΓ³dromos. "
"Use o botΓ£o ao lado para atualizar do site ICEA."
if _n_cat else
"CatΓ‘logo vazio β clique em **Atualizar catΓ‘logo ICEA** para baixar a lista do site."
)
col_form, col_log = st.columns([1, 1.6])
with col_form:
with st.form("form_coleta"):
_coleta_catalog = catalog_full if catalog_full else [{"icao": "SBGR", "nome": "", "uf": ""}]
_default_idx = next(
(i for i, d in enumerate(_coleta_catalog) if d["icao"] == sel_aero), 0
)
_sel_coleta = st.selectbox(
"AerΓ³dromo (ICAO)", _coleta_catalog,
index=_default_idx, format_func=_fmt_aero,
)
aero_input = _sel_coleta["icao"]
modo = st.radio("Modo de coleta", [
"Todos os perΓodos (histΓ³rico completo)",
"Atualizar (apenas dados novos)",
"PerΓodo especΓfico",
], index=0)
yr_col = st.columns(2)
start_yr = yr_col[0].number_input("Ano inicial", min_value=1948,
max_value=date.today().year,
value=date.today().year - 2,
disabled=(modo != "PerΓodo especΓfico"))
end_yr = yr_col[1].number_input("Ano final", min_value=1948,
max_value=date.today().year,
value=date.today().year,
disabled=(modo != "PerΓodo especΓfico"))
st.markdown("**OpΓ§Γ΅es**")
headless = not st.checkbox("Mostrar Chrome", value=False)
validate = st.checkbox("Validar amostragem", value=True)
n_samp = st.slider("Amostras de validaΓ§Γ£o", 5, 50, 20,
disabled=not validate)
cleanup = st.checkbox("Remover CSVs intermediΓ‘rios", value=True)
submitted = st.form_submit_button("βΆ Iniciar coleta",
type="primary", use_container_width=True)
with col_log:
st.markdown("**Log de execuΓ§Γ£o**")
log_box = st.empty()
prog_bar = st.progress(0.0)
stat_txt = st.empty()
res_box = st.empty()
if submitted:
all_years = (modo == "Todos os perΓodos (histΓ³rico completo)")
update_only= (modo == "Atualizar (apenas dados novos)")
log_lines: list[str] = []
q: queue.Queue = queue.Queue()
result_holder: dict = {}
def _log(msg: str):
q.put(("log", msg))
def _prog(pct: float, msg: str):
q.put(("prog", pct, msg))
def _worker():
try:
from pipeline import run_pipeline
from pathlib import Path as _Path
kw: dict = dict(
aerodrome = aero_input,
dados_dir = DADOS_DIR,
db_path = DB_PATH,
preproc_dir = PREPROC_DIR,
all_years = all_years,
headless = headless,
n_samples = n_samp,
do_validate = validate,
do_cleanup = cleanup,
log = _log,
progress = _prog,
)
if modo == "PerΓodo especΓfico":
kw["start_year"] = int(start_yr)
kw["end_year"] = int(end_yr)
elif update_only:
# Atualizar: passa all_years=False e usa o ano atual
kw["all_years"] = False
kw["start_year"] = date.today().year - 1
kw["end_year"] = date.today().year
result_holder.update(run_pipeline(**kw))
except Exception as exc:
q.put(("log", f"ERRO: {exc}"))
finally:
q.put(("done",))
t = threading.Thread(target=_worker, daemon=True)
t.start()
while t.is_alive() or not q.empty():
try:
item = q.get(timeout=0.4)
except queue.Empty:
continue
if item[0] == "done":
break
elif item[0] == "log":
log_lines.append(item[1])
log_box.code("\n".join(log_lines[-150:]))
elif item[0] == "prog":
prog_bar.progress(min(item[1], 1.0))
stat_txt.caption(item[2])
t.join()
prog_bar.progress(1.0)
st.cache_data.clear()
if result_holder:
r = result_holder
stat_txt.empty()
with res_box.container():
st.success("β
Coleta concluΓda!")
m1, m2, m3, m4 = st.columns(4)
m1.metric("ObservaΓ§Γ΅es", f"{r.get('rows',0):,}")
m2.metric("Val. OK", r.get("n_ok", 0))
m3.metric("DivergΓͺncias", r.get("n_fail",0))
m4.metric("PerΓodo inΓcio",r.get("period_start","β"))
st.caption(f"Banco: `{r.get('db_path','')}`")
if r.get("errors"):
with st.expander("DivergΓͺncias"):
for e in r["errors"]: st.text(e)
# ββ AtualizaΓ§Γ£o Total βββββββββββββββββββββββββββββββββββββββββββββββββββββ
st.divider()
st.markdown("### π AtualizaΓ§Γ£o Total")
st.markdown(
"**O que faz:** verifica novos aerΓ³dromos no catΓ‘logo ICEA "
"e baixa **apenas os dados novos** para cada aerΓ³dromo que jΓ‘ "
"possui observaΓ§Γ΅es no banco. Recomendado a cada 2 meses.\n\n"
"> **Nota:** aerΓ³dromos que ainda **nΓ£o tΓͺm dados** no banco nΓ£o sΓ£o "
"coletados aqui β apenas adicionados ao catΓ‘logo. "
"Para coletar o histΓ³rico completo de todos os aerΓ³dromos, "
"use o script `_apps/collect_all.py` (pode levar horas/dias)."
)
_at_c1, _at_c2, _ = st.columns([1, 1, 3])
with _at_c1:
_at_headless = not st.checkbox("Mostrar Chrome", value=False, key="at_headless")
with _at_c2:
_at_validate = st.checkbox("Validar amostras", value=False, key="at_validate")
_at_submitted = st.button(
"π Iniciar AtualizaΓ§Γ£o Total",
type="primary",
help="Atualiza catΓ‘logo + coleta dados novos para todos os aerΓ³dromos com dados no banco",
)
if _at_submitted:
import db as _db_at
_at_conn_check = _db_at.get_connection(DB_PATH)
_db_at.ensure_schema(_at_conn_check)
_at_aeros = _db_at.list_aerodromes_with_data(_at_conn_check)
_at_conn_check.close()
if not _at_aeros:
st.warning(
"Nenhum aerΓ³dromo com dados no banco. "
"Use o formulΓ‘rio acima para coletar dados primeiro."
)
else:
_at_n_total = len(_at_aeros)
_at_q: queue.Queue = queue.Queue()
_at_results: list[dict] = []
_at_log_lines: list[str] = []
_at_prog_bar = st.progress(0.0)
_at_stat_txt = st.empty()
_at_log_box = st.empty()
_at_table_box = st.empty()
_at_aero_list = [d["icao"] for d in _at_aeros]
_at_aero_meta = {d["icao"]: d for d in _at_aeros}
def _at_worker() -> None:
try:
# Phase 1 β refresh aerodrome catalog
_at_q.put(("status", "Fase 1 β Atualizando catΓ‘logo ICEAβ¦"))
from scraper_meteorologia import fetch_aerodrome_catalog as _fat
import db as _dbi
_new_cat = _fat(headless=_at_headless)
_cat_conn = _dbi.get_connection(DB_PATH)
_dbi.ensure_schema(_cat_conn)
_n_new = _dbi.upsert_aerodromes(_cat_conn, _new_cat)
_cat_conn.close()
_at_q.put(("log",
f"[catΓ‘logo] {len(_new_cat)} aerΓ³dromos no portal "
f"| {_n_new} registros atualizados"
))
# Phase 2 β forward-only update for each aerodrome
for _i, _icao in enumerate(_at_aero_list):
_at_q.put(("aero_start", _icao, _i))
try:
from pipeline import run_pipeline as _rp
_res = _rp(
aerodrome = _icao,
dados_dir = DADOS_DIR,
db_path = DB_PATH,
preproc_dir = PREPROC_DIR,
all_years = False,
update_only = True,
headless = _at_headless,
n_samples = 5 if _at_validate else 0,
do_validate = _at_validate,
do_cleanup = True,
log = lambda m: _at_q.put(("log", m)),
progress = lambda p, m: None,
)
_at_q.put(("aero_done", _icao, _res))
except Exception as _exc:
_at_q.put(("aero_error", _icao, str(_exc)[:120]))
except Exception as _exc:
_at_q.put(("log", f"ERRO: {_exc}"))
finally:
_at_q.put(("done",))
_at_t = threading.Thread(target=_at_worker, daemon=True)
_at_t.start()
_at_n_done = 0
while _at_t.is_alive() or not _at_q.empty():
try:
_item = _at_q.get(timeout=0.4)
except queue.Empty:
continue
if _item[0] == "done":
break
elif _item[0] == "status":
_at_stat_txt.caption(_item[1])
elif _item[0] == "log":
_at_log_lines.append(_item[1])
_at_log_box.code("\n".join(_at_log_lines[-60:]))
elif _item[0] == "aero_start":
_, _icao, _i = _item
_at_prog_bar.progress(_i / max(_at_n_total, 1))
_at_stat_txt.caption(
f"Fase 2 β {_icao} ({_i + 1} / {_at_n_total})"
)
_meta = _at_aero_meta.get(_icao, {})
_at_results.append({
"ICAO": _icao,
"Nome": _meta.get("nome", ""),
"UF": _meta.get("uf", ""),
"Status": "β³ Coletandoβ¦",
"Novas linhas": "β",
})
_at_table_box.dataframe(
_at_results, use_container_width=True, hide_index=True
)
elif _item[0] == "aero_done":
_, _icao, _res = _item
_at_n_done += 1
_new = _res.get("n_upserted", 0)
for _row in _at_results:
if _row["ICAO"] == _icao:
_row["Status"] = "β
ConcluΓdo"
_row["Novas linhas"] = f"{_new:,}"
break
_at_table_box.dataframe(
_at_results, use_container_width=True, hide_index=True
)
elif _item[0] == "aero_error":
_, _icao, _err = _item
for _row in _at_results:
if _row["ICAO"] == _icao:
_row["Status"] = "β Erro"
_row["Novas linhas"] = _err[:50]
break
_at_table_box.dataframe(
_at_results, use_container_width=True, hide_index=True
)
_at_t.join()
_at_prog_bar.progress(1.0)
st.cache_data.clear()
_at_stat_txt.empty()
st.success(
f"β
AtualizaΓ§Γ£o Total concluΓda β "
f"{_at_n_done} / {_at_n_total} aerΓ³dromos atualizados."
)
st.rerun()
# ββ Coleta Total β HistΓ³rico Completo βββββββββββββββββββββββββββββββββββββ
st.divider()
st.markdown("### ποΈ Coleta Total β HistΓ³rico Completo")
st.markdown(
"Coleta o **histΓ³rico completo** de todos os aerΓ³dromos do catΓ‘logo "
"que ainda **nΓ£o tΓͺm dados** no banco. "
"AerΓ³dromos com dados sΓ£o ignorados automaticamente.\n\n"
"> β οΈ Esta operaΓ§Γ£o pode demorar **horas ou dias** dependendo do nΓΊmero "
"de aerΓ³dromos. NΓ£o feche o dashboard durante a coleta. "
"Em caso de interrupΓ§Γ£o, basta clicar novamente β os aerΓ³dromos jΓ‘ "
"coletados serΓ£o pulados automaticamente."
)
# Status summary
if _db_available():
import db as _db_ct_info
_ct_conn_info = _db_ct_info.get_connection(DB_PATH)
_db_ct_info.ensure_schema(_ct_conn_info)
_ct_catalog = _db_ct_info.list_all_aerodromes(_ct_conn_info)
_ct_with_data = set(_db_ct_info.list_aerodromes(_ct_conn_info))
_ct_conn_info.close()
_ct_total = len(_ct_catalog)
_ct_done = len(_ct_with_data)
_ct_pending = _ct_total - _ct_done
else:
_ct_total = _ct_done = _ct_pending = 0
_ct_info_col1, _ct_info_col2, _ct_info_col3 = st.columns(3)
_ct_info_col1.metric("No catΓ‘logo", _ct_total)
_ct_info_col2.metric("Com dados", _ct_done)
_ct_info_col3.metric("Aguardando coleta", _ct_pending)
_ct_opt1, _ct_opt2, _ct_opt3 = st.columns([1, 1, 2])
with _ct_opt1:
_ct_headless = not st.checkbox("Mostrar Chrome", value=False, key="ct_headless")
with _ct_opt2:
_ct_max = int(st.number_input(
"Limite de aerΓ³dromos (0 = todos)",
min_value=0, max_value=200, value=0, step=1, key="ct_max",
help="Γtil para testes. 0 processa todos os pendentes.",
))
if _ct_pending == 0 and _ct_total > 0:
st.info("β
Todos os aerΓ³dromos do catΓ‘logo jΓ‘ tΓͺm dados no banco.")
elif _ct_total == 0:
st.warning(
"CatΓ‘logo vazio. Clique em **Atualizar catΓ‘logo ICEA** (acima) para "
"baixar a lista de aerΓ³dromos disponΓveis."
)
_ct_submitted = st.button(
"ποΈ Iniciar Coleta Total",
type="primary",
disabled=(_ct_pending == 0),
help="Coleta o histΓ³rico completo para os aerΓ³dromos sem dados no banco",
)
if _ct_submitted and _ct_pending > 0:
_ct_q: queue.Queue = queue.Queue()
_ct_results: list[dict] = []
_ct_log_lines: list[str] = []
_ct_prog_bar = st.progress(0.0)
_ct_stat_txt = st.empty()
_ct_log_box = st.empty()
_ct_table_box = st.empty()
# Build metadata dict for display
_ct_meta = {d["icao"]: d for d in _ct_catalog}
_ct_max_arg = _ct_max if _ct_max > 0 else None
def _ct_worker() -> None:
try:
from collect_all import collect_all as _ca
def _ct_on_start(icao: str, idx: int, total: int, nome: str) -> None:
_ct_q.put(("aero_start", icao, idx, total, nome))
def _ct_on_done(icao: str, event: str, result: dict) -> None:
_ct_q.put(("aero_done", icao, event, result))
_ca(
resume = True,
max_aerodromes = _ct_max_arg,
headless = _ct_headless,
n_samples = 0,
do_cleanup = True,
log = lambda m: _ct_q.put(("log", m)),
on_start = _ct_on_start,
on_done = _ct_on_done,
)
except Exception as _exc:
_ct_q.put(("log", f"ERRO: {_exc}"))
finally:
_ct_q.put(("done",))
_ct_t = threading.Thread(target=_ct_worker, daemon=True)
_ct_t.start()
_ct_n_done = 0
_ct_n_total_run = _ct_max_arg or _ct_pending
while _ct_t.is_alive() or not _ct_q.empty():
try:
_citem = _ct_q.get(timeout=0.4)
except queue.Empty:
continue
if _citem[0] == "done":
break
elif _citem[0] == "log":
_ct_log_lines.append(_citem[1])
_ct_log_box.code("\n".join(_ct_log_lines[-80:]))
elif _citem[0] == "aero_start":
_, _icao, _idx, _tot, _nome = _citem
_ct_prog_bar.progress((_idx - 1) / max(_tot, 1))
_ct_stat_txt.caption(f"Coletando {_icao} β {_idx} / {_tot}")
_meta = _ct_meta.get(_icao, {})
_ct_results.append({
"ICAO": _icao,
"Nome": _meta.get("nome", _nome),
"UF": _meta.get("uf", ""),
"Status": "β³ Coletandoβ¦",
"PerΓodo": "β",
"Linhas": "β",
})
_ct_table_box.dataframe(
_ct_results, use_container_width=True, hide_index=True
)
elif _citem[0] == "aero_done":
_, _icao, _event, _res = _citem
_status_map = {
"completed": "β
ConcluΓdo",
"skipped": "βοΈ JΓ‘ tinha dados",
"failed": "β Erro",
"interrupted": "βΈοΈ Interrompido",
}
_st = _status_map.get(_event, _event)
for _row in _ct_results:
if _row["ICAO"] == _icao:
_row["Status"] = _st
if _event == "completed":
_ct_n_done += 1
_row["PerΓodo"] = (
f"{_res.get('period_start','')} β "
f"{_res.get('period_end','')}"
)
_row["Linhas"] = f"{_res.get('rows', 0):,}"
elif _event == "failed":
_row["PerΓodo"] = _res.get("error", "")[:40]
break
_ct_table_box.dataframe(
_ct_results, use_container_width=True, hide_index=True
)
_ct_t.join()
_ct_prog_bar.progress(1.0)
st.cache_data.clear()
_ct_stat_txt.empty()
st.success(
f"β
Coleta Total concluΓda β {_ct_n_done} aerΓ³dromos coletados."
)
st.rerun()
# ββ VisΓ£o Geral βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
with tab1:
st.plotly_chart(chart_overview(anl_r), use_container_width=True)
# ββ Temperatura βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
with tab2:
st.plotly_chart(chart_temp(anl_r), use_container_width=True)
f = chart_temp_boxplot(anl)
if f:
st.plotly_chart(f, use_container_width=True)
# ββ Vento βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
with tab3:
st.plotly_chart(chart_wind_speed(anl_r), use_container_width=True)
c1, c2 = st.columns([1.1, 1])
with c1:
f = chart_wind_rose(anl)
if f: st.plotly_chart(f, use_container_width=True)
with c2:
f = chart_wind_direction(anl_r)
if f: st.plotly_chart(f, use_container_width=True)
# ββ PressΓ£o & PrecipitaΓ§Γ£o ββββββββββββββββββββββββββββββββββββββββββββββββββββ
with tab4:
st.plotly_chart(chart_pressure(anl_r), use_container_width=True)
f = chart_precipitation(anl_r, anl)
if f:
st.plotly_chart(f, use_container_width=True)
else:
st.info("Dados de precipitaΓ§Γ£o nΓ£o disponΓveis ou sem registro no perΓodo.")
# ββ Visibilidade & Teto βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
with tab5:
c1, c2 = st.columns(2)
with c1:
st.plotly_chart(chart_visibility(anl_r), use_container_width=True)
with c2:
f = chart_ceiling(anl_r)
if f: st.plotly_chart(f, use_container_width=True)
else: st.info("Dados de teto nΓ£o disponΓveis.")
st.markdown("#### CondiΓ§Γ΅es de voo por categoria ICAO")
c3, c4 = st.columns([1.5, 1])
with c3:
f = chart_icao_bars(anl)
if f: st.plotly_chart(f, use_container_width=True)
with c4:
if pct:
st.markdown("
", unsafe_allow_html=True)
for cat in ["VMC","MVFR","IFR","LIFR"]:
v = pct.get(cat, 0)
st.markdown(
f'{badge(cat)} {v:.1f}% do tempo',
unsafe_allow_html=True)
st.markdown("
", unsafe_allow_html=True)
# ββ Climatologia ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
with tab6:
st.markdown("DistribuiΓ§Γ£o estatΓstica mensal das variΓ‘veis no perΓodo selecionado.")
c1, c2 = st.columns(2)
with c1:
f = chart_climatology(anl, "T", "Temperatura Mensal (Β°C)", "Β°C", C["temp"])
if f: st.plotly_chart(f, use_container_width=True)
with c2:
f = chart_climatology(anl, "WS", "Vento Mensal (kt)", "kt", C["wind"])
if f: st.plotly_chart(f, use_container_width=True)
c3, c4 = st.columns(2)
with c3:
f = chart_climatology(anl, "VIS", "Visibilidade Mensal (dam)","dam", C["visibility"])
if f: st.plotly_chart(f, use_container_width=True)
with c4:
f = chart_climatology(anl, "QNH", "PressΓ£o QNH Mensal (hPa)", "hPa", C["pressure"])
if f: st.plotly_chart(f, use_container_width=True)
f = chart_precip_boxplot(anl)
if f:
st.plotly_chart(f, use_container_width=True)
else:
st.info("Dados de precipitaΓ§Γ£o insuficientes para o boxplot mensal.")
st.markdown("#### Resumo mensal")
rows = []
for m in range(1, 13):
mask_m = pd.to_datetime(anl["_dt"]).dt.month == m
if mask_m.sum() < 2: continue
r = {"MΓͺs": MONTHS_PT[m-1], "N": int(mask_m.sum())}
for key, lbl, fmt in [("T","T mΓ©dia (Β°C)","{:.1f}"),("T","T mΓ‘x (Β°C)","{:.1f}"),
("T","T mΓn (Β°C)","{:.1f}"),("WS","WS mΓ©dia (kt)","{:.1f}"),
("VIS","VIS mΓ©dia (dam)","{:.0f}"),("QNH","QNH (hPa)","{:.1f}"),
("PREC","Precip (mm)","{:.1f}")]:
vals = anl[key][mask_m].dropna()
if vals.empty: r[lbl] = "β"
elif "mΓ‘x" in lbl: r[lbl] = fmt.format(vals.max())
elif "mΓn" in lbl: r[lbl] = fmt.format(vals.min())
elif "Precip" in lbl: r[lbl] = fmt.format(vals.sum())
else: r[lbl] = fmt.format(vals.mean())
rows.append(r)
if rows:
st.dataframe(pd.DataFrame(rows).set_index("MΓͺs"), use_container_width=True)
# ββ Dados βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
with tab7:
COL_LABELS = {
"T": "Temperatura Bulbo Seco (Β°C)",
"Td": "Ponto de Orvalho (Β°C)",
"UR": "Umidade Relativa (%)",
"QNH": "PressΓ£o QNH (hPa)",
"WS": "Velocidade do Vento (kt)",
"WG": "Rajada MΓ‘xima (kt)",
"WD": "DireΓ§Γ£o do Vento (Β°)",
"VIS": "Visibilidade Predominante (dam)",
"TETO": "Teto (dam)",
"PREC": "PrecipitaΓ§Γ£o (mm)",
}
avail = [c for c in COL_LABELS if c in anl.columns]
sel = st.multiselect(
"VariΓ‘veis",
options=avail,
default=[c for c in ["T", "Td", "UR", "QNH", "WS", "VIS"] if c in avail],
format_func=lambda c: COL_LABELS.get(c, c),
)
show_df = anl[["_dt"] + sel].copy()
show_df["_dt"] = show_df["_dt"].dt.strftime("%Y-%m-%d %H:%M")
show_df = show_df.rename(columns={"_dt": "Data/Hora", **{c: COL_LABELS[c] for c in sel}})
st.dataframe(show_df, use_container_width=True, height=480)
st.caption(f"{len(show_df):,} observaΓ§Γ΅es Β· {len(sel)} variΓ‘veis selecionadas")
_exp_left, _exp_right = st.columns([1, 1])
with _exp_left:
csv_bytes = show_df.to_csv(index=False, encoding="utf-8-sig").encode("utf-8-sig")
st.download_button(
"β¬οΈ Baixar CSV (variΓ‘veis selecionadas)",
csv_bytes,
f"{sel_aero}_export.csv",
"text/csv",
use_container_width=True,
)
with _exp_right:
if st.button("π Salvar CSV completo em pastaβ¦",
use_container_width=True,
help="Abre um seletor de pasta e salva todas as variΓ‘veis meteorolΓ³gicas"):
# monta DataFrame completo com todos as variΓ‘veis disponΓveis
_full_labels = {
"T": "Temperatura Bulbo Seco (Β°C)", "Td": "Ponto de Orvalho (Β°C)",
"UR": "Umidade Relativa (%)", "QNH": "PressΓ£o QNH (hPa)",
"WS": "Velocidade do Vento (kt)", "WG": "Rajada MΓ‘xima (kt)",
"WD": "DireΓ§Γ£o do Vento (Β°)", "VIS": "Visibilidade Predominante (dam)",
"TETO": "Teto (dam)", "PREC": "PrecipitaΓ§Γ£o (mm)",
}
_all_cols = [c for c in _full_labels if c in anl.columns]
_export_df = anl[["_dt"] + _all_cols].copy()
_export_df["_dt"] = _export_df["_dt"].dt.strftime("%Y-%m-%d %H:%M")
_export_df = _export_df.rename(
columns={"_dt": "Data/Hora", **{c: _full_labels[c] for c in _all_cols}}
)
_path = _save_csv_to_folder(_export_df, sel_aero, sel_start, sel_end)
if _path:
st.success(f"Arquivo salvo em:\n`{_path}`")
else:
st.info("ExportaΓ§Γ£o cancelada.")