1447 lines
60 KiB
Python
1447 lines
60 KiB
Python
"""
|
||
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("""
|
||
<style>
|
||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||
html,body,[class*="css"]{font-family:'Inter',sans-serif!important}
|
||
.met-header{background:linear-gradient(135deg,#0d1b2a,#1b2b3d 55%,#243b55);
|
||
color:#ecf0f1;padding:1.4rem 2rem;border-radius:12px;margin-bottom:1.2rem}
|
||
.met-header h1{margin:0;font-size:1.7rem;font-weight:700}
|
||
.met-header .sub{margin:.3rem 0 0;opacity:.65;font-size:.83rem}
|
||
.kpi{background:white;border:1px solid #dde1e7;border-radius:10px;
|
||
padding:.85rem 1rem;text-align:center;box-shadow:0 1px 3px rgba(0,0,0,.07)}
|
||
.kpi .val{font-size:1.55rem;font-weight:700;line-height:1.15}
|
||
.kpi .lbl{font-size:.7rem;color:#6c757d;font-weight:600;
|
||
text-transform:uppercase;letter-spacing:.8px;margin-top:.25rem}
|
||
.kpi .unit{font-size:.82rem;color:#9e9e9e;font-weight:400}
|
||
.badge{padding:.15rem .55rem;border-radius:20px;font-size:.73rem;font-weight:700}
|
||
.badge-vmc {background:#c8e6c9;color:#1b5e20}
|
||
.badge-mvfr{background:#fff9c4;color:#e65100}
|
||
.badge-ifr {background:#ffe0b2;color:#bf360c}
|
||
.badge-lifr{background:#ffcdd2;color:#b71c1c}
|
||
.stTabs [data-baseweb="tab-list"]{background:#f0f2f6;border-radius:10px;
|
||
padding:.4rem;gap:.3rem}
|
||
.stTabs [data-baseweb="tab"]{border-radius:8px;padding:.4rem .85rem;
|
||
font-weight:500;font-size:.86rem;color:#374151}
|
||
.stTabs [data-baseweb="tab"]:hover{color:#111827;background:rgba(0,0,0,.04)}
|
||
.stTabs [aria-selected="true"]{background:white!important;
|
||
box-shadow:0 1px 4px rgba(0,0,0,.1);color:#1d4ed8!important;font-weight:600!important}
|
||
/* ── Sidebar escura ── */
|
||
section[data-testid="stSidebar"]>div,
|
||
section[data-testid="stSidebarContent"]{background:#145e63!important}
|
||
section[data-testid="stSidebarContent"],
|
||
section[data-testid="stSidebarContent"] p,
|
||
section[data-testid="stSidebarContent"] label,
|
||
section[data-testid="stSidebarContent"] span,
|
||
section[data-testid="stSidebarContent"] small,
|
||
section[data-testid="stSidebarContent"] div,
|
||
section[data-testid="stSidebarContent"] strong{color:#e0f2f1!important}
|
||
section[data-testid="stSidebarContent"] [data-testid="stCaptionContainer"],
|
||
section[data-testid="stSidebarContent"] .stCaption{color:#80cbc4!important}
|
||
section[data-testid="stSidebarContent"] h1,
|
||
section[data-testid="stSidebarContent"] h2,
|
||
section[data-testid="stSidebarContent"] h3,
|
||
section[data-testid="stSidebarContent"] h4{color:#ffffff!important;font-weight:700!important}
|
||
section[data-testid="stSidebarContent"] hr{border-color:rgba(255,255,255,.2)!important}
|
||
/* Inputs no sidebar */
|
||
section[data-testid="stSidebarContent"] .stSelectbox>div>div,
|
||
section[data-testid="stSidebarContent"] [data-baseweb="select"]>div{
|
||
background:rgba(255,255,255,.12)!important;border-color:rgba(255,255,255,.25)!important;color:#fff!important}
|
||
section[data-testid="stSidebarContent"] [data-baseweb="radio"] label{color:#e0f2f1!important}
|
||
section[data-testid="stSidebarContent"] [data-testid="stDateInput"] input{
|
||
background:rgba(255,255,255,.12)!important;color:#fff!important;border-color:rgba(255,255,255,.25)!important}
|
||
/* Markdown headings */
|
||
section[data-testid="stSidebarContent"] .stMarkdown{color:#e0f2f1!important}
|
||
</style>
|
||
""", unsafe_allow_html=True)
|
||
|
||
|
||
# ── Utilitários de dados: SQLite-first, fallback CSV ─────────────────────────
|
||
|
||
def _db_available() -> bool:
|
||
"""Returns True when the SQLite database file exists."""
|
||
return DB_PATH.exists()
|
||
|
||
|
||
def _migrate_legacy_paths() -> None:
|
||
"""Silently moves data files from older layouts to the current one.
|
||
|
||
Handles two legacy layouts, applying whichever migration is needed:
|
||
|
||
* v1 (original): ``dados/met.db`` and ``dados/<ICAO>/<ICAO>_*.csv``
|
||
* v2 (mid-2026): ``met.db`` at the module root (before the _apps/ reorganisation)
|
||
|
||
Safe to call on every startup — does nothing when files are already in the
|
||
expected locations.
|
||
"""
|
||
# v1 → current: dados/met.db (old flat layout)
|
||
v1_db = _BASE_DIR / "dados" / "met.db"
|
||
if v1_db.exists() and not DB_PATH.exists():
|
||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||
v1_db.rename(DB_PATH)
|
||
st.info("Banco migrado de dados/met.db → db/met.db.")
|
||
|
||
# v2 → current: met.db at _BASE_DIR root (pre-_apps/ reorganisation)
|
||
v2_db = _BASE_DIR / "met.db"
|
||
if v2_db.exists() and not DB_PATH.exists():
|
||
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||
v2_db.rename(DB_PATH)
|
||
st.info("Banco migrado de met.db → db/met.db.")
|
||
|
||
# v1 → current: analytics CSVs in dados/<ICAO>/
|
||
v1_dados = _BASE_DIR / "dados"
|
||
if not v1_dados.is_dir():
|
||
return
|
||
for aero_dir in sorted(v1_dados.iterdir()):
|
||
if not aero_dir.is_dir():
|
||
continue
|
||
csv_files = list(aero_dir.glob(f"{aero_dir.name}_*.csv"))
|
||
if not csv_files:
|
||
continue
|
||
dest = PREPROC_DIR / aero_dir.name
|
||
dest.mkdir(parents=True, exist_ok=True)
|
||
moved = 0
|
||
for f in csv_files:
|
||
target = dest / f.name
|
||
if not target.exists():
|
||
f.rename(target)
|
||
moved += 1
|
||
if moved:
|
||
st.info(f"CSVs de {aero_dir.name} migrados para tabelas/preproc/…")
|
||
|
||
|
||
def list_aerodromes() -> list[str]:
|
||
"""Returns ICAO codes of aerodromes that have data (SQLite-first, CSV fallback)."""
|
||
if _db_available():
|
||
import db as _db
|
||
conn = _db.get_connection(DB_PATH)
|
||
_db.ensure_schema(conn)
|
||
aeros = _db.list_aerodromes(conn)
|
||
conn.close()
|
||
if aeros:
|
||
return sorted(aeros)
|
||
# Fallback: scan permanent analytics CSV directory
|
||
result: list[str] = []
|
||
if PREPROC_DIR.is_dir():
|
||
for d in sorted(PREPROC_DIR.iterdir()):
|
||
if not d.is_dir():
|
||
continue
|
||
for f in sorted(d.glob(f"{d.name}_*.csv")):
|
||
if _COMPILED_RE.match(f.name):
|
||
result.append(d.name)
|
||
break
|
||
return result
|
||
|
||
|
||
def list_catalog() -> list[dict]:
|
||
"""Returns the full aerodrome catalog (all entries, with or without data).
|
||
|
||
Used by the data-collection form so operators can select any aerodrome.
|
||
|
||
Returns:
|
||
List of ``{"icao", "nome", "uf"}`` dicts from the ``aerodromes`` table,
|
||
or a minimal list derived from :func:`list_aerodromes` as a fallback.
|
||
"""
|
||
if _db_available():
|
||
import db as _db
|
||
conn = _db.get_connection(DB_PATH)
|
||
_db.ensure_schema(conn)
|
||
catalog = _db.list_all_aerodromes(conn)
|
||
conn.close()
|
||
if catalog:
|
||
return catalog
|
||
return [{"icao": a, "nome": "", "uf": ""} for a in list_aerodromes()]
|
||
|
||
|
||
def list_catalog_with_data() -> list[dict]:
|
||
"""Returns only aerodromes that have observations in the database.
|
||
|
||
Used by the sidebar so users only see options they can actually analyse.
|
||
|
||
Returns:
|
||
List of ``{"icao", "nome", "uf"}`` dicts from the ``observations`` table
|
||
(joined with the catalog for display metadata). Falls back to
|
||
:func:`list_catalog` when no database is available.
|
||
"""
|
||
if _db_available():
|
||
import db as _db
|
||
conn = _db.get_connection(DB_PATH)
|
||
_db.ensure_schema(conn)
|
||
result = _db.list_aerodromes_with_data(conn)
|
||
conn.close()
|
||
if result:
|
||
return result
|
||
return list_catalog()
|
||
|
||
|
||
def _fmt_aero(d: dict) -> str:
|
||
"""Formata dict de aeródromo para exibição no selectbox."""
|
||
nome = d.get("nome", "")
|
||
uf = d.get("uf", "")
|
||
if nome and uf:
|
||
return f"{d['icao']} — {nome} ({uf})"
|
||
if nome:
|
||
return f"{d['icao']} — {nome}"
|
||
return d["icao"]
|
||
|
||
|
||
def _save_csv_to_folder(df: pd.DataFrame, aerodrome: str,
|
||
start: str, end: str) -> str | None:
|
||
"""Abre diálogo nativo de pasta e salva o DataFrame como CSV. Retorna caminho ou None."""
|
||
import tkinter as tk
|
||
from tkinter import filedialog
|
||
root = tk.Tk()
|
||
root.withdraw()
|
||
root.wm_attributes("-topmost", True)
|
||
folder = filedialog.askdirectory(title="Escolha a pasta de destino")
|
||
root.destroy()
|
||
if not folder:
|
||
return None
|
||
s = start.replace("-", "")
|
||
e = end.replace("-", "")
|
||
filename = f"{aerodrome}_{s}_{e}.csv"
|
||
filepath = Path(folder) / filename
|
||
df.to_csv(filepath, index=False, encoding="utf-8-sig")
|
||
return str(filepath)
|
||
|
||
|
||
def get_period(aerodrome: str) -> tuple[str, str]:
|
||
"""Returns the ``(start, end)`` date strings for available data.
|
||
|
||
Args:
|
||
aerodrome: ICAO code to look up.
|
||
|
||
Returns:
|
||
``("YYYY-MM-DD", "YYYY-MM-DD")`` strings, or ``("", "")`` if no data
|
||
is available.
|
||
"""
|
||
if _db_available():
|
||
import db as _db
|
||
conn = _db.get_connection(DB_PATH)
|
||
cov = _db.get_coverage(conn, aerodrome)
|
||
conn.close()
|
||
if cov:
|
||
return str(cov[0]), str(cov[1])
|
||
# Fallback: scan permanent analytics CSV directory
|
||
csv_dir = PREPROC_DIR / aerodrome
|
||
if csv_dir.is_dir():
|
||
for f in sorted(csv_dir.glob(f"{aerodrome}_*.csv")):
|
||
m = _COMPILED_RE.match(f.name)
|
||
if m:
|
||
return m.group(2).replace("_", "-"), m.group(3).replace("_", "-")
|
||
return "", ""
|
||
|
||
|
||
@st.cache_data(ttl=600, show_spinner="Carregando dados…")
|
||
def load_analytics(aerodrome: str, start_str: str, end_str: str) -> pd.DataFrame:
|
||
"""
|
||
Carrega dados analíticos: SQLite-first (query parcial), fallback CSV.
|
||
Retorna DataFrame com colunas: _dt, T, Td, UR, QNH, WS, WG, WD, VIS, TETO, PREC.
|
||
"""
|
||
if _db_available():
|
||
import db as _db
|
||
conn = _db.get_connection(DB_PATH)
|
||
df = _db.query_analytics(conn, aerodrome, start_str, end_str)
|
||
conn.close()
|
||
if not df.empty:
|
||
return df
|
||
|
||
# Fallback: read permanent analytics CSV backup
|
||
csv_dir = PREPROC_DIR / aerodrome
|
||
if csv_dir.is_dir():
|
||
for f in sorted(csv_dir.glob(f"{aerodrome}_*.csv")):
|
||
if _COMPILED_RE.match(f.name):
|
||
df = pd.read_csv(f, encoding="utf-8-sig", low_memory=False)
|
||
dt_col = next((c for c in df.columns if c in ("_dt", "dt") or
|
||
("Data" in c and "Hora" in c)), None)
|
||
if dt_col:
|
||
df["_dt"] = pd.to_datetime(df[dt_col], errors="coerce")
|
||
df = df.drop(columns=[dt_col], errors="ignore") if dt_col != "_dt" else df
|
||
df = df.dropna(subset=["_dt"]).sort_values("_dt").reset_index(drop=True)
|
||
return df
|
||
return pd.DataFrame()
|
||
|
||
|
||
# ── Conversão numérica ───────────────────────────────────────────────────────
|
||
def _norm(s: str) -> str:
|
||
return unicodedata.normalize("NFKD", s.lower()).encode("ascii","ignore").decode("ascii")
|
||
|
||
def to_num(s: pd.Series) -> pd.Series:
|
||
"""Converte para float: suporta vírgula decimal e '-' como NaN."""
|
||
if pd.api.types.is_numeric_dtype(s):
|
||
return s.astype(float)
|
||
v = s.astype(str).str.strip().replace({"-":None,"nan":None,"NaN":None,"None":None,"":None})
|
||
v = v.str.replace(",", ".", regex=False)
|
||
return pd.to_numeric(v, errors="coerce")
|
||
|
||
def _agg(raw: pd.DataFrame, prefix: str, keyword: str,
|
||
agg: str = "mean") -> pd.Series:
|
||
"""
|
||
Encontra colunas <prefix>_*_<keyword>*, prioriza genérica (_-_).
|
||
Agrega por 'mean', 'min' ou 'max' quando há múltiplas pistas/cabeceiras.
|
||
"""
|
||
kw = _norm(keyword)
|
||
cols = [c for c in raw.columns if c.startswith(f"{prefix}_") and kw in _norm(c)]
|
||
if not cols:
|
||
return pd.Series(dtype=float, index=raw.index)
|
||
generic = [c for c in cols if "_-_" in c]
|
||
specific = [c for c in cols if "_-_" not in c]
|
||
|
||
if generic:
|
||
s = to_num(raw[generic[0]])
|
||
if s.notna().mean() > 0.05:
|
||
return s
|
||
|
||
if not specific:
|
||
return to_num(raw[cols[0]])
|
||
|
||
mat = pd.DataFrame({c: to_num(raw[c]) for c in specific})
|
||
return {"mean": mat.mean, "min": mat.min, "max": mat.max}[agg](axis=1)
|
||
|
||
|
||
# ── DataFrame analítico limpo ────────────────────────────────────────────────
|
||
def build_analytics(raw: pd.DataFrame) -> pd.DataFrame:
|
||
"""
|
||
Constrói um DataFrame com uma série float por variável meteorológica,
|
||
eliminando a heterogeneidade de colunas por pista/cabeceira.
|
||
"""
|
||
qnh = to_num(raw["pres_QNH"]) if "pres_QNH" in raw.columns else _agg(raw,"pres","QNH")
|
||
vis = next((to_num(raw[c]) for c in raw.columns
|
||
if "visib" in c.lower() and "predominante" in _norm(c)), _agg(raw,"visib","Predominante"))
|
||
prec = next((to_num(raw[c]) for c in raw.columns
|
||
if "prec_precipita" in c.lower() and "dura" not in c.lower()),
|
||
pd.Series(dtype=float, index=raw.index))
|
||
|
||
return pd.DataFrame({
|
||
"_dt" : raw["_dt"].values,
|
||
"T" : _agg(raw, "temp", "Bulbo_Seco").values,
|
||
"Td" : _agg(raw, "temp", "Orvalho").values,
|
||
"UR" : _agg(raw, "temp", "Umidade").values,
|
||
"QNH" : qnh.values,
|
||
"WS" : _agg(raw, "vent", "Velocidade").values,
|
||
"WG" : _agg(raw, "vent", "Rajada", agg="max").values,
|
||
"WD" : _agg(raw, "vent", "Dire").values,
|
||
"VIS" : vis.values,
|
||
"TETO": _agg(raw, "teto", "Teto", agg="min").values,
|
||
"PREC": prec.values,
|
||
})
|
||
|
||
|
||
def resample_anl(anl: pd.DataFrame, freq: Optional[str]) -> pd.DataFrame:
|
||
"""Resamples the analytics DataFrame to the requested frequency.
|
||
|
||
Args:
|
||
anl: Analytics DataFrame with a ``_dt`` datetime column.
|
||
freq: Pandas offset alias (e.g. ``"D"``, ``"W"``, ``"ME"``), or
|
||
``None`` to return the data unchanged.
|
||
|
||
Returns:
|
||
Resampled (or unchanged) DataFrame.
|
||
"""
|
||
if freq is None:
|
||
return anl.copy()
|
||
rules = {k: v for k, v in AGG_RULES.items() if k in anl.columns}
|
||
return anl.set_index("_dt").resample(freq).agg(rules).reset_index()
|
||
|
||
|
||
# ── ICAO helpers ─────────────────────────────────────────────────────────────
|
||
def _icao_cat(vis: float, teto: float) -> str:
|
||
"""Returns the ICAO flight category for the given visibility and ceiling.
|
||
|
||
Args:
|
||
vis: Visibility in dam.
|
||
teto: Ceiling in dam.
|
||
|
||
Returns:
|
||
One of ``"LIFR"``, ``"IFR"``, ``"MVFR"``, ``"VMC"``, or ``"—"`` when
|
||
either value is NaN.
|
||
"""
|
||
if np.isnan(vis) or np.isnan(teto):
|
||
return "—"
|
||
if vis < 50 or teto < 15: return "LIFR"
|
||
if vis < 100 or teto < 30: return "IFR"
|
||
if vis < 300 or teto < 100: return "MVFR"
|
||
return "VMC"
|
||
|
||
|
||
def pct_icao(anl: pd.DataFrame) -> dict[str, float]:
|
||
"""Returns percentage of time in each ICAO flight category.
|
||
|
||
Args:
|
||
anl: Analytics DataFrame with ``VIS`` and ``TETO`` columns.
|
||
|
||
Returns:
|
||
Dict mapping category name to percentage (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 ``<span>`` string with the appropriate CSS class.
|
||
"""
|
||
cls = {
|
||
"VMC": "badge-vmc", "MVFR": "badge-mvfr",
|
||
"IFR": "badge-ifr", "LIFR": "badge-lifr",
|
||
}.get(cat, "")
|
||
return f'<span class="badge {cls}">{cat}</span>'
|
||
|
||
|
||
# ── Chart helpers ─────────────────────────────────────────────────────────────
|
||
def _rng_btns() -> list[dict]:
|
||
"""Returns the standard time-range selector button list for Plotly."""
|
||
return [
|
||
dict(count=7, label="7d", step="day", stepmode="backward"),
|
||
dict(count=1, label="1m", step="month", stepmode="backward"),
|
||
dict(count=6, label="6m", step="month", stepmode="backward"),
|
||
dict(count=1, label="1a", step="year", stepmode="backward"),
|
||
dict(step="all", label="Tudo"),
|
||
]
|
||
|
||
|
||
def _base_layout(
|
||
fig: go.Figure,
|
||
h: int = H_VISIBILITY,
|
||
title: str = "",
|
||
legend_h: bool = True,
|
||
) -> None:
|
||
"""Applies standard layout settings to *fig* in-place.
|
||
|
||
Args:
|
||
fig: Plotly Figure to update.
|
||
h: Chart height in pixels.
|
||
title: Optional chart title.
|
||
legend_h: Show legend horizontally below the chart (``True``) or hide
|
||
it (``False``).
|
||
"""
|
||
kw = dict(orientation="h", y=-0.18, x=0, font=dict(size=11)) if legend_h else dict(visible=False)
|
||
fig.update_layout(template=TEMPLATE, height=h, title=title,
|
||
margin=dict(l=55,r=20,t=48,b=48),
|
||
hovermode="x unified", showlegend=legend_h,
|
||
legend=kw, plot_bgcolor="white")
|
||
fig.update_xaxes(showgrid=True, gridcolor="#f0f0f0", zeroline=False)
|
||
fig.update_yaxes(showgrid=True, gridcolor="#f0f0f0", zeroline=False)
|
||
|
||
def _hrect(
|
||
fig: go.Figure,
|
||
thresholds: list[tuple],
|
||
row: int = 1,
|
||
col: int = 1,
|
||
ymax: float = 9999,
|
||
) -> None:
|
||
"""Adds ICAO category shaded horizontal bands to *fig*.
|
||
|
||
Args:
|
||
fig: Plotly Figure (must support ``add_hrect``).
|
||
thresholds: List of ``(name, lo, hi, fill_color, font_color)`` tuples.
|
||
row: Subplot row (1-based).
|
||
col: Subplot column (1-based).
|
||
ymax: Upper bound of the visible axis range (caps the top band).
|
||
"""
|
||
for name, lo, hi, fill, clr in thresholds:
|
||
y1 = min(hi, ymax) if hi else ymax
|
||
if lo >= ymax: continue
|
||
fig.add_hrect(y0=lo, y1=y1, fillcolor=fill, opacity=1, line_width=0,
|
||
row=row, col=col,
|
||
annotation_text=f" {name}",
|
||
annotation_position="left",
|
||
annotation_font=dict(size=9.5, color=clr, family="Inter"))
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# GRÁFICOS
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
def chart_overview(anl_r: pd.DataFrame) -> go.Figure:
|
||
"""Four-panel overview: temperature, wind, visibility, QNH.
|
||
|
||
Args:
|
||
anl_r: Resampled analytics DataFrame.
|
||
|
||
Returns:
|
||
Plotly Figure with 4 subplots sharing the x-axis.
|
||
"""
|
||
dt = anl_r["_dt"]
|
||
fig = make_subplots(rows=4, cols=1, shared_xaxes=True,
|
||
subplot_titles=("Temperatura (°C)","Vento (kt)","Visibilidade (dam)","Pressão QNH (hPa)"),
|
||
vertical_spacing=0.055, row_heights=[.30,.23,.25,.22])
|
||
T, Td = anl_r.get("T"), anl_r.get("Td")
|
||
WS = anl_r.get("WS")
|
||
VIS = anl_r.get("VIS")
|
||
QNH = anl_r.get("QNH")
|
||
|
||
if T is not None and T.notna().any():
|
||
fig.add_trace(go.Scatter(x=dt, y=T, name="T (°C)", line=dict(color=C["temp"],width=1.8)), 1, 1)
|
||
if Td is not None and Td.notna().any():
|
||
fig.add_trace(go.Scatter(x=dt, y=Td, name="Td (°C)", line=dict(color=C["dewpoint"],width=1.2,dash="dot")), 1, 1)
|
||
if WS is not None and WS.notna().any():
|
||
fig.add_trace(go.Scatter(x=dt, y=WS, name="Vento (kt)", fill="tozeroy",
|
||
fillcolor=C["wind_fill"], line=dict(color=C["wind"],width=1.8)), 2, 1)
|
||
if VIS is not None and VIS.notna().any():
|
||
fig.add_trace(go.Scatter(x=dt, y=VIS, name="Visib (dam)", fill="tozeroy",
|
||
fillcolor=C["vis_fill"], line=dict(color=C["visibility"],width=1.8)), 3, 1)
|
||
if QNH is not None and QNH.notna().any():
|
||
fig.add_trace(go.Scatter(x=dt, y=QNH, name="QNH (hPa)", line=dict(color=C["pressure"],width=1.8)), 4, 1)
|
||
|
||
for i in range(1, 5):
|
||
fig.update_xaxes(row=i, showgrid=True, gridcolor="#f0f0f0")
|
||
fig.update_yaxes(row=i, showgrid=True, gridcolor="#f0f0f0")
|
||
fig.update_xaxes(row=4, rangeselector=dict(buttons=_rng_btns()))
|
||
fig.update_layout(template=TEMPLATE, height=H_OVERVIEW, showlegend=False,
|
||
hovermode="x unified", margin=dict(l=55,r=20,t=50,b=40),
|
||
plot_bgcolor="white")
|
||
return fig
|
||
|
||
|
||
def chart_temp(anl_r: pd.DataFrame) -> go.Figure:
|
||
"""Temperature, dew point and relative humidity subplots.
|
||
|
||
Args:
|
||
anl_r: Resampled analytics DataFrame.
|
||
|
||
Returns:
|
||
Plotly Figure with 2 subplots (T/Td on top, UR below).
|
||
"""
|
||
dt = anl_r["_dt"]
|
||
T = anl_r.get("T", pd.Series(dtype=float))
|
||
Td = anl_r.get("Td", pd.Series(dtype=float))
|
||
UR = anl_r.get("UR", pd.Series(dtype=float))
|
||
fig = make_subplots(rows=2, cols=1, shared_xaxes=True,
|
||
subplot_titles=("Temperatura e Ponto de Orvalho (°C)","Umidade Relativa (%)"),
|
||
vertical_spacing=0.10, row_heights=[.65,.35])
|
||
if T.notna().any():
|
||
fig.add_trace(go.Scatter(x=dt, y=T, name="Temperatura", line=dict(color=C["temp"],width=2.2)), 1, 1)
|
||
if Td.notna().any():
|
||
fig.add_trace(go.Scatter(x=dt, y=Td, name="Ponto de Orvalho",
|
||
line=dict(color=C["dewpoint"],width=1.5,dash="dot")), 1, 1)
|
||
# Faixa entre T e Td
|
||
if T.notna().any() and Td.notna().any():
|
||
mask = T.notna() & Td.notna()
|
||
dt_v = dt[mask].values; T_v = T[mask].values; Td_v = Td[mask].values
|
||
if len(dt_v) > 1:
|
||
fig.add_trace(go.Scatter(
|
||
x=np.concatenate([dt_v, dt_v[::-1]]),
|
||
y=np.concatenate([T_v, Td_v[::-1]]),
|
||
fill="toself", fillcolor="rgba(211,47,47,.10)",
|
||
line=dict(color="rgba(0,0,0,0)"), showlegend=False, hoverinfo="skip"), 1, 1)
|
||
if UR.notna().any():
|
||
fig.add_trace(go.Scatter(x=dt, y=UR, name="UR (%)", fill="tozeroy",
|
||
fillcolor=C["hum_fill"], line=dict(color=C["humidity"],width=1.8)), 2, 1)
|
||
fig.update_yaxes(range=[0,105], row=2)
|
||
_base_layout(fig, h=H_TEMP)
|
||
fig.update_xaxes(row=2, rangeselector=dict(buttons=_rng_btns()))
|
||
return fig
|
||
|
||
|
||
def chart_temp_boxplot(anl: pd.DataFrame) -> Optional[go.Figure]:
|
||
df = pd.DataFrame({"T": anl["T"].values, "m": pd.to_datetime(anl["_dt"]).dt.month}).dropna()
|
||
if len(df) < 30:
|
||
return None
|
||
fig = go.Figure()
|
||
for m in range(1, 13):
|
||
vals = df[df["m"] == m]["T"]
|
||
if vals.empty: continue
|
||
fig.add_trace(go.Box(y=vals.values, name=MONTHS_PT[m-1], boxpoints=False,
|
||
marker_color=C["temp"], line_color=C["temp"],
|
||
fillcolor="rgba(211,47,47,.15)", showlegend=False))
|
||
fig.update_layout(template=TEMPLATE, height=H_TEMP_BOX,
|
||
title="Distribuição Mensal de Temperatura",
|
||
yaxis_title="°C", margin=dict(l=55,r=20,t=50,b=40))
|
||
return fig
|
||
|
||
|
||
def chart_wind_speed(anl_r: pd.DataFrame) -> go.Figure:
|
||
"""Wind speed and gust time-series chart.
|
||
|
||
Args:
|
||
anl_r: Resampled analytics DataFrame.
|
||
|
||
Returns:
|
||
Plotly Figure showing WS (mean) and WG (gust) as filled areas.
|
||
"""
|
||
dt = anl_r["_dt"]
|
||
WS = anl_r.get("WS", pd.Series(dtype=float))
|
||
WG = anl_r.get("WG", pd.Series(dtype=float))
|
||
fig = go.Figure()
|
||
if WG.notna().any():
|
||
fig.add_trace(go.Scatter(x=dt, y=WG, name="Rajada máx.", fill="tozeroy",
|
||
fillcolor=C["gust_fill"], line=dict(color=C["gust"],width=1.2,dash="dot")))
|
||
if WS.notna().any():
|
||
fig.add_trace(go.Scatter(x=dt, y=WS, name="Velocidade média", fill="tozeroy",
|
||
fillcolor=C["wind_fill"], line=dict(color=C["wind"],width=2.0)))
|
||
_base_layout(fig, h=H_WIND_SPEED, title="Velocidade do Vento (kt)")
|
||
fig.update_yaxes(title="kt")
|
||
fig.update_xaxes(rangeselector=dict(buttons=_rng_btns()))
|
||
return fig
|
||
|
||
|
||
def chart_wind_rose(anl: pd.DataFrame) -> Optional[go.Figure]:
|
||
"""16-point compass wind rose with 6 speed bins.
|
||
|
||
Args:
|
||
anl: Full (non-resampled) analytics DataFrame.
|
||
|
||
Returns:
|
||
Plotly polar bar chart, or ``None`` if fewer than 20 valid rows.
|
||
"""
|
||
df = pd.DataFrame({"ws": anl["WS"].values, "wd": anl["WD"].values}).dropna()
|
||
df = df[(df["ws"] >= 0) & (df["wd"] >= 0) & (df["wd"] <= 360)]
|
||
if len(df) < 20:
|
||
return None
|
||
sector = 360 / 16
|
||
df["bin"] = ((df["wd"] + sector/2) % 360 // sector).astype(int).clip(0, 15)
|
||
df["cat"] = pd.cut(df["ws"], bins=WIND_BINS, labels=WIND_LABELS, right=False)
|
||
total = len(df)
|
||
fig = go.Figure()
|
||
for label, color in zip(WIND_LABELS, WIND_COLORS):
|
||
sub = df[df["cat"] == label]
|
||
r = [sub[sub["bin"] == i].shape[0] / total * 100 for i in range(16)]
|
||
fig.add_trace(go.Barpolar(r=r, theta=COMPASS_16, name=label,
|
||
marker_color=color, marker_line_color="white",
|
||
marker_line_width=0.5, opacity=0.92))
|
||
fig.update_layout(
|
||
template=None, paper_bgcolor="white",
|
||
title="Rosa dos Ventos — Frequência por Direção e Intensidade",
|
||
polar=dict(bgcolor="white",
|
||
radialaxis=dict(ticksuffix="%", tickformat=".1f",
|
||
gridcolor="#e0e0e0", linecolor="#bdbdbd"),
|
||
angularaxis=dict(direction="clockwise", rotation=90,
|
||
gridcolor="#e0e0e0", tickfont=dict(size=11,color="#333"))),
|
||
legend=dict(orientation="h", y=-0.18, x=0.5, xanchor="center", font=dict(size=11)),
|
||
height=H_WIND_ROSE, margin=dict(l=40,r=40,t=55,b=90),
|
||
)
|
||
return fig
|
||
|
||
|
||
def chart_wind_direction(anl_r: pd.DataFrame) -> Optional[go.Figure]:
|
||
"""Wind direction scatter coloured by wind speed.
|
||
|
||
Args:
|
||
anl_r: Resampled analytics DataFrame.
|
||
|
||
Returns:
|
||
Plotly scatter Figure, or ``None`` if the DataFrame is empty after
|
||
dropping NaNs.
|
||
"""
|
||
dt = anl_r["_dt"]
|
||
WD = anl_r.get("WD", pd.Series(dtype=float))
|
||
WS = anl_r.get("WS", pd.Series(dtype=float))
|
||
df = pd.DataFrame({"dt": dt.values, "wd": WD.values, "ws": WS.values}).dropna()
|
||
if df.empty:
|
||
return None
|
||
fig = go.Figure(go.Scatter(
|
||
x=df["dt"], y=df["wd"], mode="markers",
|
||
marker=dict(size=4, color=df["ws"], colorscale="YlOrRd",
|
||
colorbar=dict(title="kt", thickness=12, len=0.9),
|
||
showscale=True, opacity=0.75),
|
||
hovertemplate="Dir: %{y:.0f}°<br>Vel: %{marker.color:.1f} kt<extra></extra>",
|
||
))
|
||
fig.update_yaxes(title="Direção (°)", range=[0,360],
|
||
tickvals=[0,45,90,135,180,225,270,315,360],
|
||
ticktext=["N","NE","L","SE","S","SO","O","NO","N"])
|
||
_base_layout(fig, h=H_WIND_DIR, title="Direção do Vento (cor = velocidade)", legend_h=False)
|
||
fig.update_xaxes(rangeselector=dict(buttons=_rng_btns()))
|
||
return fig
|
||
|
||
|
||
def chart_pressure(anl_r: pd.DataFrame) -> go.Figure:
|
||
"""QNH time-series with a mean reference line.
|
||
|
||
Args:
|
||
anl_r: Resampled analytics DataFrame.
|
||
|
||
Returns:
|
||
Plotly Figure.
|
||
"""
|
||
dt = anl_r["_dt"]
|
||
QNH = anl_r.get("QNH", pd.Series(dtype=float))
|
||
fig = go.Figure()
|
||
if QNH.notna().any():
|
||
fig.add_trace(go.Scatter(x=dt, y=QNH, name="QNH", fill="tozeroy",
|
||
fillcolor=C["pres_fill"], line=dict(color=C["pressure"],width=2)))
|
||
mn = float(QNH.dropna().mean())
|
||
fig.add_hline(y=mn, line_dash="dash", line_color="#9e9e9e", line_width=1.2,
|
||
annotation_text=f" Média: {mn:.1f} hPa",
|
||
annotation_font=dict(size=10, color="#555"),
|
||
annotation_position="right")
|
||
_base_layout(fig, h=H_PRESSURE, title="Pressão QNH (hPa)")
|
||
fig.update_yaxes(title="hPa")
|
||
fig.update_xaxes(rangeselector=dict(buttons=_rng_btns()))
|
||
return fig
|
||
|
||
|
||
def chart_precipitation(anl_r: pd.DataFrame, anl: pd.DataFrame) -> Optional[go.Figure]:
|
||
"""Precipitação na resolução selecionada (barras) + acumulado mensal."""
|
||
prec_r = anl_r.get("PREC", pd.Series(dtype=float)) if "PREC" in anl_r.columns else pd.Series(dtype=float)
|
||
if prec_r.isna().all() or prec_r.sum() == 0:
|
||
return None
|
||
|
||
dt_r = anl_r["_dt"]
|
||
daily = pd.DataFrame({"dt": dt_r.values, "p": prec_r.values}).dropna()
|
||
daily = daily[daily["p"] > 0]
|
||
|
||
monthly = (anl[["_dt","PREC"]].dropna()
|
||
.set_index("_dt").resample("ME")["PREC"].sum()
|
||
.reset_index())
|
||
|
||
fig = make_subplots(rows=2, cols=1, shared_xaxes=False,
|
||
subplot_titles=("Precipitação por período (mm)", "Acumulado mensal (mm)"),
|
||
vertical_spacing=0.18, row_heights=[.5,.5])
|
||
if not daily.empty:
|
||
fig.add_trace(go.Bar(x=daily["dt"], y=daily["p"], name="Precipitação",
|
||
marker_color=C["precip"], opacity=0.85), 1, 1)
|
||
if not monthly.empty:
|
||
fig.add_trace(go.Bar(x=monthly["_dt"].dt.strftime("%Y-%m"), y=monthly["PREC"],
|
||
name="Mensal", marker_color="#1976d2", opacity=0.85), 2, 1)
|
||
|
||
fig.update_layout(template=TEMPLATE, height=H_PRECIP, showlegend=False,
|
||
margin=dict(l=55,r=20,t=55,b=50),
|
||
yaxis_title="mm", yaxis2_title="mm")
|
||
fig.update_xaxes(showgrid=True, gridcolor="#f0f0f0")
|
||
return fig
|
||
|
||
|
||
def chart_visibility(anl_r: pd.DataFrame) -> go.Figure:
|
||
"""Visibility time-series with ICAO category background bands.
|
||
|
||
Args:
|
||
anl_r: Resampled analytics DataFrame.
|
||
|
||
Returns:
|
||
Plotly Figure.
|
||
"""
|
||
dt = anl_r["_dt"]
|
||
VIS = anl_r.get("VIS", pd.Series(dtype=float))
|
||
mx = float(VIS.dropna().max()) * 1.05 if VIS.notna().any() else 600
|
||
mx = max(mx, 600)
|
||
fig = go.Figure()
|
||
_hrect(fig, ICAO_VIS, ymax=mx)
|
||
if VIS.notna().any():
|
||
fig.add_trace(go.Scatter(x=dt, y=VIS, name="Visibilidade",
|
||
line=dict(color=C["visibility"], width=2)))
|
||
_base_layout(fig, h=H_VISIBILITY, title="Visibilidade Predominante (dam)", legend_h=False)
|
||
fig.update_yaxes(range=[0, mx], title="dam")
|
||
fig.update_xaxes(rangeselector=dict(buttons=_rng_btns()))
|
||
return fig
|
||
|
||
|
||
def chart_ceiling(anl_r: pd.DataFrame) -> Optional[go.Figure]:
|
||
"""Ceiling time-series with ICAO category background bands.
|
||
|
||
Args:
|
||
anl_r: Resampled analytics DataFrame.
|
||
|
||
Returns:
|
||
Plotly Figure, or ``None`` if all ceiling values are NaN.
|
||
"""
|
||
dt = anl_r["_dt"]
|
||
TETO = anl_r.get("TETO", pd.Series(dtype=float))
|
||
if TETO.isna().all():
|
||
return None
|
||
mx = float(TETO.dropna().max()) * 1.05
|
||
mx = max(mx, 200)
|
||
fig = go.Figure()
|
||
_hrect(fig, ICAO_TETO, ymax=mx)
|
||
fig.add_trace(go.Scatter(x=dt, y=TETO, name="Teto",
|
||
line=dict(color=C["ceiling"], width=2)))
|
||
_base_layout(fig, h=H_CEILING, title="Teto (dam)", legend_h=False)
|
||
fig.update_yaxes(range=[0, mx], title="dam")
|
||
fig.update_xaxes(rangeselector=dict(buttons=_rng_btns()))
|
||
return fig
|
||
|
||
|
||
def chart_icao_bars(anl: pd.DataFrame) -> Optional[go.Figure]:
|
||
"""Bar chart showing percentage of time in each ICAO flight category.
|
||
|
||
Args:
|
||
anl: Full (non-resampled) analytics DataFrame.
|
||
|
||
Returns:
|
||
Plotly Figure, or ``None`` if no valid VIS/TETO data.
|
||
"""
|
||
df = anl[["VIS","TETO"]].dropna()
|
||
if df.empty:
|
||
return None
|
||
cats = df.apply(lambda r: _icao_cat(r.VIS, r.TETO), axis=1)
|
||
order = ["VMC","MVFR","IFR","LIFR"]
|
||
colors_bar = [C["visibility"], "#f9a825", "#e65100", "#b71c1c"]
|
||
counts = [(cats == c).sum() for c in order]
|
||
total = len(cats)
|
||
pcts = [v / total * 100 for v in counts]
|
||
fig = go.Figure(go.Bar(
|
||
x=order, y=pcts, text=[f"{p:.1f}%" for p in pcts],
|
||
textposition="outside", marker_color=colors_bar,
|
||
textfont=dict(size=13, color="#222"),
|
||
))
|
||
fig.update_layout(template=TEMPLATE, height=H_ICAO_BARS,
|
||
title="Frequência por Categoria de Voo (% do tempo)",
|
||
yaxis_title="%", yaxis=dict(range=[0, max(pcts)*1.2 if pcts else 100]),
|
||
margin=dict(l=55,r=20,t=50,b=40), showlegend=False)
|
||
return fig
|
||
|
||
|
||
def _hex_rgba(hex_color: str, alpha: float = 0.15) -> str:
|
||
"""Converte '#rrggbb' para 'rgba(r,g,b,alpha)' compatível com Plotly."""
|
||
h = hex_color.lstrip("#")
|
||
r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
|
||
return f"rgba({r},{g},{b},{alpha})"
|
||
|
||
|
||
def chart_precip_boxplot(anl: pd.DataFrame) -> Optional[go.Figure]:
|
||
"""
|
||
Boxplot mensal da precipitação diária total (mm/dia).
|
||
Usa totais diários — não horários — para evitar distribuição degenerada.
|
||
Mostra outliers (eventos extremos), meteorologicamente relevantes.
|
||
"""
|
||
df = pd.DataFrame({"p": anl["PREC"].values, "dt": anl["_dt"].values}).dropna()
|
||
if df.empty or df["p"].sum() == 0:
|
||
return None
|
||
df["dt"] = pd.to_datetime(df["dt"])
|
||
daily = (df.groupby(df["dt"].dt.date)["p"]
|
||
.sum()
|
||
.reset_index(name="p"))
|
||
daily["m"] = pd.to_datetime(daily["dt"]).dt.month
|
||
|
||
fill = _hex_rgba(C["precip"], 0.15)
|
||
fig = go.Figure()
|
||
for m in range(1, 13):
|
||
vals = daily[daily["m"] == m]["p"]
|
||
if vals.empty:
|
||
continue
|
||
fig.add_trace(go.Box(
|
||
y=vals.values, name=MONTHS_PT[m - 1],
|
||
marker_color=C["precip"], line_color=C["precip"],
|
||
fillcolor=fill, showlegend=False,
|
||
boxpoints="outliers", # eventos extremos visíveis
|
||
marker=dict(size=4, opacity=0.6),
|
||
))
|
||
fig.update_layout(
|
||
template=TEMPLATE, height=H_PRECIP_BOX,
|
||
title="Precipitação Diária por Mês (mm/dia) — caixas + outliers",
|
||
yaxis_title="mm/dia",
|
||
margin=dict(l=55, r=20, t=50, b=40),
|
||
)
|
||
return fig
|
||
|
||
|
||
def chart_climatology(
|
||
anl: pd.DataFrame,
|
||
var: str,
|
||
title: str,
|
||
unit: str,
|
||
color: str,
|
||
) -> Optional[go.Figure]:
|
||
"""Monthly boxplot for a single meteorological variable.
|
||
|
||
Args:
|
||
anl: Full analytics DataFrame.
|
||
var: Column name to plot (e.g. ``"T"``).
|
||
title: Chart title.
|
||
unit: Y-axis unit label.
|
||
color: Hex colour for the boxes.
|
||
|
||
Returns:
|
||
Plotly Figure, or ``None`` if fewer than 30 valid observations.
|
||
"""
|
||
df = pd.DataFrame({"v": anl[var].values,
|
||
"m": pd.to_datetime(anl["_dt"]).dt.month.values}).dropna()
|
||
if len(df) < 30:
|
||
return None
|
||
fill = _hex_rgba(color, 0.15)
|
||
fig = go.Figure()
|
||
for m in range(1, 13):
|
||
vals = df[df["m"] == m]["v"]
|
||
if vals.empty: continue
|
||
fig.add_trace(go.Box(y=vals.values, name=MONTHS_PT[m-1], boxpoints=False,
|
||
marker_color=color, line_color=color,
|
||
fillcolor=fill, showlegend=False))
|
||
fig.update_layout(template=TEMPLATE, height=H_CLIM_BOX, title=title,
|
||
yaxis_title=unit, margin=dict(l=55,r=20,t=50,b=40))
|
||
return fig
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# STARTUP — migration + catalog loading
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
_migrate_legacy_paths()
|
||
|
||
catalog_full = list_catalog() # all aerodromes → collection form
|
||
catalog_with_data = list_catalog_with_data() # only aerodromes with data → sidebar
|
||
|
||
with st.sidebar:
|
||
st.markdown("## ✈️ MET Aeroportuário")
|
||
fonte = "🗄️ SQLite" if _db_available() else "📄 CSV"
|
||
st.caption(fonte)
|
||
st.markdown("---")
|
||
|
||
if not catalog_with_data:
|
||
if catalog_full:
|
||
st.info(
|
||
"Nenhum dado coletado ainda.\n\n"
|
||
"Use a aba **📥 Coleta** para baixar dados do site ICEA."
|
||
)
|
||
else:
|
||
st.warning(
|
||
"Banco vazio e catálogo não inicializado.\n\n"
|
||
"Use **Atualizar catálogo ICEA** na aba Coleta e depois inicie a coleta."
|
||
)
|
||
st.stop()
|
||
|
||
sel_dict = st.selectbox("Aeródromo", catalog_with_data, format_func=_fmt_aero)
|
||
sel_aero = sel_dict["icao"]
|
||
p_start, p_end = get_period(sel_aero)
|
||
st.caption(f"📅 Disponível: `{p_start}` → `{p_end}`")
|
||
st.markdown("---")
|
||
|
||
try:
|
||
dt_min = date.fromisoformat(p_start)
|
||
dt_max = date.fromisoformat(p_end)
|
||
except Exception:
|
||
dt_min = date(2000, 1, 1)
|
||
dt_max = date.today()
|
||
|
||
st.markdown("**Período de análise**")
|
||
date_range = st.date_input("", value=(dt_min, dt_max),
|
||
min_value=dt_min, max_value=dt_max)
|
||
st.markdown("---")
|
||
freq_label = st.radio("**Agregação temporal**", list(RESAMPLE.keys()), index=1)
|
||
freq = RESAMPLE[freq_label]
|
||
st.markdown("---")
|
||
st.caption("Fonte: ICEA/DECEA — Dados de Superfície")
|
||
|
||
|
||
# ── Carrega analytics para o período selecionado ─────────────────────────────
|
||
if isinstance(date_range, (list, tuple)) and len(date_range) == 2:
|
||
sel_start = str(date_range[0])
|
||
sel_end = str(date_range[1])
|
||
else:
|
||
sel_start = p_start
|
||
sel_end = p_end
|
||
|
||
anl_all = load_analytics(sel_aero, sel_start, sel_end)
|
||
|
||
if anl_all.empty:
|
||
st.error("Nenhum dado no período selecionado.")
|
||
st.stop()
|
||
|
||
# Garante que '_dt' é datetime
|
||
if not pd.api.types.is_datetime64_any_dtype(anl_all["_dt"]):
|
||
anl_all["_dt"] = pd.to_datetime(anl_all["_dt"], errors="coerce")
|
||
anl_all = anl_all.dropna(subset=["_dt"]).reset_index(drop=True)
|
||
|
||
# Dados já chegam em formato analítico (SQLite ou backup CSV de analytics)
|
||
# Se as colunas T, Td etc. existem → já é analytics
|
||
_IS_ANALYTICS = "T" in anl_all.columns
|
||
|
||
if _IS_ANALYTICS:
|
||
anl = anl_all.copy()
|
||
else:
|
||
# Fallback: dados no formato bruto (84 colunas) — aplica build_analytics
|
||
anl = build_analytics(anl_all)
|
||
|
||
raw = anl.copy() # alias para a tab Dados
|
||
anl_r = resample_anl(anl, freq)
|
||
|
||
|
||
# ── Header ───────────────────────────────────────────────────────────────────
|
||
n_obs = len(raw)
|
||
n_days = (raw["_dt"].max() - raw["_dt"].min()).days + 1
|
||
st.markdown(
|
||
f'<div class="met-header">'
|
||
f'<h1>🛬 {sel_aero} — Dados Meteorológicos de Superfície</h1>'
|
||
f'<p class="sub">ICEA/DECEA · '
|
||
f'{raw["_dt"].min().strftime("%d/%m/%Y")} → {raw["_dt"].max().strftime("%d/%m/%Y")} · '
|
||
f'{n_obs:,} observações · {n_days} dias</p></div>',
|
||
unsafe_allow_html=True)
|
||
|
||
|
||
# ── KPIs ─────────────────────────────────────────────────────────────────────
|
||
def _kv(s: pd.Series) -> float:
|
||
"""Returns the mean (or sum for PREC) of *s*, or NaN if empty."""
|
||
v = s.dropna().mean() if "PREC" not in str(s.name) else s.dropna().sum()
|
||
return float(v) if pd.notna(v) else float("nan")
|
||
|
||
pct = pct_icao(anl)
|
||
T_med = float(anl["T"].dropna().mean()) if anl["T"].notna().any() else float("nan")
|
||
QNH_med = float(anl["QNH"].dropna().mean()) if anl["QNH"].notna().any() else float("nan")
|
||
WS_med = float(anl["WS"].dropna().mean()) if anl["WS"].notna().any() else float("nan")
|
||
VIS_med = float(anl["VIS"].dropna().mean()) if anl["VIS"].notna().any() else float("nan")
|
||
PREC_tot= float(anl["PREC"].dropna().sum()) if anl["PREC"].notna().any() else float("nan")
|
||
VMC_pct = pct.get("VMC", 0.0)
|
||
|
||
kpi_cfg = [
|
||
(T_med, "Temp. Média", "°C", C["temp"]),
|
||
(QNH_med, "QNH Médio", "hPa", C["pressure"]),
|
||
(WS_med, "Vento Médio", "kt", C["wind"]),
|
||
(VIS_med, "Visib. Média", "dam", C["visibility"]),
|
||
(PREC_tot,"Precipitação", "mm", C["precip"]),
|
||
(VMC_pct, "% VMC", "%", C["ceiling"]),
|
||
]
|
||
cols_k = st.columns(6)
|
||
for col, (val, lbl, unit, clr) in zip(cols_k, kpi_cfg):
|
||
disp = f"{val:.1f}" if not np.isnan(val) else "—"
|
||
col.markdown(
|
||
f'<div class="kpi">'
|
||
f'<div class="val" style="color:{clr}">{disp}'
|
||
f'<span class="unit"> {unit}</span></div>'
|
||
f'<div class="lbl">{lbl}</div></div>',
|
||
unsafe_allow_html=True)
|
||
|
||
st.markdown("<br>", unsafe_allow_html=True)
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# TABS
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
tab0,tab1,tab2,tab3,tab4,tab5,tab6,tab7 = st.tabs([
|
||
"📥 Coleta",
|
||
"📈 Visão Geral",
|
||
"🌡️ Temperatura",
|
||
"💨 Vento",
|
||
"🌧️ Pressão e Precipitação",
|
||
"👁️ Visibilidade e Teto",
|
||
"📅 Climatologia",
|
||
"📋 Dados",
|
||
])
|
||
|
||
# ── Tab Coleta ────────────────────────────────────────────────────────────────
|
||
with tab0:
|
||
st.markdown("### Aquisição de Dados — ICEA/DECEA")
|
||
st.markdown("Baixa dados de superfície do site da ICEA e armazena no banco SQLite local.")
|
||
|
||
_btn_col, _info_col = st.columns([1, 3])
|
||
with _btn_col:
|
||
if st.button("🔄 Atualizar catálogo ICEA",
|
||
help="Acessa o site ICEA e baixa a lista completa de aeródromos disponíveis"):
|
||
from scraper_meteorologia import fetch_aerodrome_catalog
|
||
import db as _db
|
||
with st.spinner("Acessando site ICEA…"):
|
||
_new_catalog = fetch_aerodrome_catalog(headless=True)
|
||
_conn = _db.get_connection(DB_PATH)
|
||
_db.ensure_schema(_conn)
|
||
_n = _db.upsert_aerodromes(_conn, _new_catalog)
|
||
_conn.close()
|
||
st.success(f"{_n} aeródromos gravados no catálogo.")
|
||
st.rerun()
|
||
with _info_col:
|
||
_n_cat = len(catalog_full)
|
||
st.caption(
|
||
f"Catálogo atual: **{_n_cat}** aeródromos. "
|
||
"Use o botão ao lado para atualizar do site ICEA."
|
||
if _n_cat else
|
||
"Catálogo vazio — clique em **Atualizar catálogo ICEA** para baixar a lista do site."
|
||
)
|
||
|
||
col_form, col_log = st.columns([1, 1.6])
|
||
|
||
with col_form:
|
||
with st.form("form_coleta"):
|
||
_coleta_catalog = catalog_full if catalog_full else [{"icao": "SBGR", "nome": "", "uf": ""}]
|
||
_default_idx = next(
|
||
(i for i, d in enumerate(_coleta_catalog) if d["icao"] == sel_aero), 0
|
||
)
|
||
_sel_coleta = st.selectbox(
|
||
"Aeródromo (ICAO)", _coleta_catalog,
|
||
index=_default_idx, format_func=_fmt_aero,
|
||
)
|
||
aero_input = _sel_coleta["icao"]
|
||
|
||
modo = st.radio("Modo de coleta", [
|
||
"Todos os períodos (histórico completo)",
|
||
"Atualizar (apenas dados novos)",
|
||
"Período específico",
|
||
], index=0)
|
||
|
||
yr_col = st.columns(2)
|
||
start_yr = yr_col[0].number_input("Ano inicial", min_value=1948,
|
||
max_value=date.today().year,
|
||
value=date.today().year - 2,
|
||
disabled=(modo != "Período específico"))
|
||
end_yr = yr_col[1].number_input("Ano final", min_value=1948,
|
||
max_value=date.today().year,
|
||
value=date.today().year,
|
||
disabled=(modo != "Período específico"))
|
||
|
||
st.markdown("**Opções**")
|
||
headless = not st.checkbox("Mostrar Chrome", value=False)
|
||
validate = st.checkbox("Validar amostragem", value=True)
|
||
n_samp = st.slider("Amostras de validação", 5, 50, 20,
|
||
disabled=not validate)
|
||
cleanup = st.checkbox("Remover CSVs intermediários", value=True)
|
||
|
||
submitted = st.form_submit_button("▶ Iniciar coleta",
|
||
type="primary", use_container_width=True)
|
||
|
||
with col_log:
|
||
st.markdown("**Log de execução**")
|
||
log_box = st.empty()
|
||
prog_bar = st.progress(0.0)
|
||
stat_txt = st.empty()
|
||
res_box = st.empty()
|
||
|
||
if submitted:
|
||
all_years = (modo == "Todos os períodos (histórico completo)")
|
||
update_only= (modo == "Atualizar (apenas dados novos)")
|
||
|
||
log_lines: list[str] = []
|
||
q: queue.Queue = queue.Queue()
|
||
result_holder: dict = {}
|
||
|
||
def _log(msg: str):
|
||
q.put(("log", msg))
|
||
|
||
def _prog(pct: float, msg: str):
|
||
q.put(("prog", pct, msg))
|
||
|
||
def _worker():
|
||
try:
|
||
from pipeline import run_pipeline
|
||
from pathlib import Path as _Path
|
||
kw: dict = dict(
|
||
aerodrome = aero_input,
|
||
dados_dir = DADOS_DIR,
|
||
db_path = DB_PATH,
|
||
preproc_dir = PREPROC_DIR,
|
||
all_years = all_years,
|
||
headless = headless,
|
||
n_samples = n_samp,
|
||
do_validate = validate,
|
||
do_cleanup = cleanup,
|
||
log = _log,
|
||
progress = _prog,
|
||
)
|
||
if modo == "Período específico":
|
||
kw["start_year"] = int(start_yr)
|
||
kw["end_year"] = int(end_yr)
|
||
elif update_only:
|
||
# Atualizar: passa all_years=False e usa o ano atual
|
||
kw["all_years"] = False
|
||
kw["start_year"] = date.today().year - 1
|
||
kw["end_year"] = date.today().year
|
||
result_holder.update(run_pipeline(**kw))
|
||
except Exception as exc:
|
||
q.put(("log", f"ERRO: {exc}"))
|
||
finally:
|
||
q.put(("done",))
|
||
|
||
t = threading.Thread(target=_worker, daemon=True)
|
||
t.start()
|
||
|
||
while t.is_alive() or not q.empty():
|
||
try:
|
||
item = q.get(timeout=0.4)
|
||
except queue.Empty:
|
||
continue
|
||
if item[0] == "done":
|
||
break
|
||
elif item[0] == "log":
|
||
log_lines.append(item[1])
|
||
log_box.code("\n".join(log_lines[-150:]))
|
||
elif item[0] == "prog":
|
||
prog_bar.progress(min(item[1], 1.0))
|
||
stat_txt.caption(item[2])
|
||
|
||
t.join()
|
||
prog_bar.progress(1.0)
|
||
st.cache_data.clear()
|
||
|
||
if result_holder:
|
||
r = result_holder
|
||
stat_txt.empty()
|
||
with res_box.container():
|
||
st.success("✅ Coleta concluída!")
|
||
m1, m2, m3, m4 = st.columns(4)
|
||
m1.metric("Observações", f"{r.get('rows',0):,}")
|
||
m2.metric("Val. OK", r.get("n_ok", 0))
|
||
m3.metric("Divergências", r.get("n_fail",0))
|
||
m4.metric("Período início",r.get("period_start","—"))
|
||
st.caption(f"Banco: `{r.get('db_path','')}`")
|
||
if r.get("errors"):
|
||
with st.expander("Divergências"):
|
||
for e in r["errors"]: st.text(e)
|
||
|
||
# ── Visão Geral ───────────────────────────────────────────────────────────────
|
||
with tab1:
|
||
st.plotly_chart(chart_overview(anl_r), use_container_width=True)
|
||
|
||
# ── Temperatura ───────────────────────────────────────────────────────────────
|
||
with tab2:
|
||
st.plotly_chart(chart_temp(anl_r), use_container_width=True)
|
||
f = chart_temp_boxplot(anl)
|
||
if f:
|
||
st.plotly_chart(f, use_container_width=True)
|
||
|
||
# ── Vento ─────────────────────────────────────────────────────────────────────
|
||
with tab3:
|
||
st.plotly_chart(chart_wind_speed(anl_r), use_container_width=True)
|
||
c1, c2 = st.columns([1.1, 1])
|
||
with c1:
|
||
f = chart_wind_rose(anl)
|
||
if f: st.plotly_chart(f, use_container_width=True)
|
||
with c2:
|
||
f = chart_wind_direction(anl_r)
|
||
if f: st.plotly_chart(f, use_container_width=True)
|
||
|
||
# ── Pressão & Precipitação ────────────────────────────────────────────────────
|
||
with tab4:
|
||
st.plotly_chart(chart_pressure(anl_r), use_container_width=True)
|
||
f = chart_precipitation(anl_r, anl)
|
||
if f:
|
||
st.plotly_chart(f, use_container_width=True)
|
||
else:
|
||
st.info("Dados de precipitação não disponíveis ou sem registro no período.")
|
||
|
||
# ── Visibilidade & Teto ───────────────────────────────────────────────────────
|
||
with tab5:
|
||
c1, c2 = st.columns(2)
|
||
with c1:
|
||
st.plotly_chart(chart_visibility(anl_r), use_container_width=True)
|
||
with c2:
|
||
f = chart_ceiling(anl_r)
|
||
if f: st.plotly_chart(f, use_container_width=True)
|
||
else: st.info("Dados de teto não disponíveis.")
|
||
|
||
st.markdown("#### Condições de voo por categoria ICAO")
|
||
c3, c4 = st.columns([1.5, 1])
|
||
with c3:
|
||
f = chart_icao_bars(anl)
|
||
if f: st.plotly_chart(f, use_container_width=True)
|
||
with c4:
|
||
if pct:
|
||
st.markdown("<br><br>", unsafe_allow_html=True)
|
||
for cat in ["VMC","MVFR","IFR","LIFR"]:
|
||
v = pct.get(cat, 0)
|
||
st.markdown(
|
||
f'{badge(cat)} <strong>{v:.1f}%</strong> do tempo',
|
||
unsafe_allow_html=True)
|
||
st.markdown("<br>", unsafe_allow_html=True)
|
||
|
||
# ── Climatologia ──────────────────────────────────────────────────────────────
|
||
with tab6:
|
||
st.markdown("Distribuição estatística mensal das variáveis no período selecionado.")
|
||
c1, c2 = st.columns(2)
|
||
with c1:
|
||
f = chart_climatology(anl, "T", "Temperatura Mensal (°C)", "°C", C["temp"])
|
||
if f: st.plotly_chart(f, use_container_width=True)
|
||
with c2:
|
||
f = chart_climatology(anl, "WS", "Vento Mensal (kt)", "kt", C["wind"])
|
||
if f: st.plotly_chart(f, use_container_width=True)
|
||
c3, c4 = st.columns(2)
|
||
with c3:
|
||
f = chart_climatology(anl, "VIS", "Visibilidade Mensal (dam)","dam", C["visibility"])
|
||
if f: st.plotly_chart(f, use_container_width=True)
|
||
with c4:
|
||
f = chart_climatology(anl, "QNH", "Pressão QNH Mensal (hPa)", "hPa", C["pressure"])
|
||
if f: st.plotly_chart(f, use_container_width=True)
|
||
|
||
f = chart_precip_boxplot(anl)
|
||
if f:
|
||
st.plotly_chart(f, use_container_width=True)
|
||
else:
|
||
st.info("Dados de precipitação insuficientes para o boxplot mensal.")
|
||
|
||
st.markdown("#### Resumo mensal")
|
||
rows = []
|
||
for m in range(1, 13):
|
||
mask_m = pd.to_datetime(anl["_dt"]).dt.month == m
|
||
if mask_m.sum() < 2: continue
|
||
r = {"Mês": MONTHS_PT[m-1], "N": int(mask_m.sum())}
|
||
for key, lbl, fmt in [("T","T média (°C)","{:.1f}"),("T","T máx (°C)","{:.1f}"),
|
||
("T","T mín (°C)","{:.1f}"),("WS","WS média (kt)","{:.1f}"),
|
||
("VIS","VIS média (dam)","{:.0f}"),("QNH","QNH (hPa)","{:.1f}"),
|
||
("PREC","Precip (mm)","{:.1f}")]:
|
||
vals = anl[key][mask_m].dropna()
|
||
if vals.empty: r[lbl] = "—"
|
||
elif "máx" in lbl: r[lbl] = fmt.format(vals.max())
|
||
elif "mín" in lbl: r[lbl] = fmt.format(vals.min())
|
||
elif "Precip" in lbl: r[lbl] = fmt.format(vals.sum())
|
||
else: r[lbl] = fmt.format(vals.mean())
|
||
rows.append(r)
|
||
if rows:
|
||
st.dataframe(pd.DataFrame(rows).set_index("Mês"), use_container_width=True)
|
||
|
||
# ── Dados ─────────────────────────────────────────────────────────────────────
|
||
with tab7:
|
||
COL_LABELS = {
|
||
"T": "Temperatura Bulbo Seco (°C)",
|
||
"Td": "Ponto de Orvalho (°C)",
|
||
"UR": "Umidade Relativa (%)",
|
||
"QNH": "Pressão QNH (hPa)",
|
||
"WS": "Velocidade do Vento (kt)",
|
||
"WG": "Rajada Máxima (kt)",
|
||
"WD": "Direção do Vento (°)",
|
||
"VIS": "Visibilidade Predominante (dam)",
|
||
"TETO": "Teto (dam)",
|
||
"PREC": "Precipitação (mm)",
|
||
}
|
||
avail = [c for c in COL_LABELS if c in anl.columns]
|
||
sel = st.multiselect(
|
||
"Variáveis",
|
||
options=avail,
|
||
default=[c for c in ["T", "Td", "UR", "QNH", "WS", "VIS"] if c in avail],
|
||
format_func=lambda c: COL_LABELS.get(c, c),
|
||
)
|
||
show_df = anl[["_dt"] + sel].copy()
|
||
show_df["_dt"] = show_df["_dt"].dt.strftime("%Y-%m-%d %H:%M")
|
||
show_df = show_df.rename(columns={"_dt": "Data/Hora", **{c: COL_LABELS[c] for c in sel}})
|
||
|
||
st.dataframe(show_df, use_container_width=True, height=480)
|
||
st.caption(f"{len(show_df):,} observações · {len(sel)} variáveis selecionadas")
|
||
|
||
_exp_left, _exp_right = st.columns([1, 1])
|
||
|
||
with _exp_left:
|
||
csv_bytes = show_df.to_csv(index=False, encoding="utf-8-sig").encode("utf-8-sig")
|
||
st.download_button(
|
||
"⬇️ Baixar CSV (variáveis selecionadas)",
|
||
csv_bytes,
|
||
f"{sel_aero}_export.csv",
|
||
"text/csv",
|
||
use_container_width=True,
|
||
)
|
||
|
||
with _exp_right:
|
||
if st.button("📁 Salvar CSV completo em pasta…",
|
||
use_container_width=True,
|
||
help="Abre um seletor de pasta e salva todas as variáveis meteorológicas"):
|
||
# monta DataFrame completo com todos as variáveis disponíveis
|
||
_full_labels = {
|
||
"T": "Temperatura Bulbo Seco (°C)", "Td": "Ponto de Orvalho (°C)",
|
||
"UR": "Umidade Relativa (%)", "QNH": "Pressão QNH (hPa)",
|
||
"WS": "Velocidade do Vento (kt)", "WG": "Rajada Máxima (kt)",
|
||
"WD": "Direção do Vento (°)", "VIS": "Visibilidade Predominante (dam)",
|
||
"TETO": "Teto (dam)", "PREC": "Precipitação (mm)",
|
||
}
|
||
_all_cols = [c for c in _full_labels if c in anl.columns]
|
||
_export_df = anl[["_dt"] + _all_cols].copy()
|
||
_export_df["_dt"] = _export_df["_dt"].dt.strftime("%Y-%m-%d %H:%M")
|
||
_export_df = _export_df.rename(
|
||
columns={"_dt": "Data/Hora", **{c: _full_labels[c] for c in _all_cols}}
|
||
)
|
||
_path = _save_csv_to_folder(_export_df, sel_aero, sel_start, sel_end)
|
||
if _path:
|
||
st.success(f"Arquivo salvo em:\n`{_path}`")
|
||
else:
|
||
st.info("Exportação cancelada.")
|