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