Carga Inicial - SBGR

This commit is contained in:
kolmeasi
2026-05-31 10:32:59 -03:00
parent afa0206cea
commit d8d219625a
1027 changed files with 3686166 additions and 0 deletions

View File

@@ -0,0 +1,352 @@
"""
Módulo SQLite para armazenamento de dados meteorológicos analíticos.
Tabelas:
observations — séries temporais analíticas (10 variáveis float por timestamp)
outlier_log — auditoria de valores tratados por limites físicos
aerodromes — catálogo de aeródromos disponíveis no site ICEA
Uso:
from db import get_connection, ensure_schema, upsert_analytics, query_analytics
conn = get_connection(Path("dados/met.db"))
ensure_schema(conn)
upsert_analytics(conn, "SBGR", anl_df)
df = query_analytics(conn, "SBGR", "2024-01-01", "2024-12-31")
"""
import sqlite3
from datetime import date, datetime, timezone
from pathlib import Path
from typing import Optional
import pandas as pd
# ── Colunas analíticas ────────────────────────────────────────────────────────
ANL_COLS = ["T", "Td", "UR", "QNH", "WS", "WG", "WD", "VIS", "TETO", "PREC"]
# ── Limites físicos por variável ─────────────────────────────────────────────
PHYSICAL_LIMITS: dict[str, tuple[float, float]] = {
"T": (-25.0, 55.0), # °C — extremos possíveis no Brasil
"Td": (-30.0, 40.0), # °C — ponto de orvalho
"UR": ( 0.0, 100.0), # %
"QNH": (940.0, 1060.0), # hPa — recordes mundiais absolutos
"WS": ( 0.0, 200.0), # kt
"WG": ( 0.0, 250.0), # kt — rajada furacão Cat 5 ≈ 175 kt
"WD": ( 0.0, 360.0), # °
"VIS": ( 0.0, 9999.0), # dam
"TETO": ( 0.0, 9999.0), # dam
"PREC": ( 0.0, 500.0), # mm — recorde horário mundial ≈ 300 mm/h
}
# ── Schema SQL ────────────────────────────────────────────────────────────────
_SCHEMA = """
CREATE TABLE IF NOT EXISTS observations (
aerodrome TEXT NOT NULL,
dt TEXT NOT NULL,
T REAL, Td REAL, UR REAL, QNH REAL,
WS REAL, WG REAL, WD REAL,
VIS REAL, TETO REAL, PREC REAL,
PRIMARY KEY (aerodrome, dt)
);
CREATE INDEX IF NOT EXISTS idx_aero_dt ON observations(aerodrome, dt);
CREATE TABLE IF NOT EXISTS outlier_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
aerodrome TEXT NOT NULL,
dt TEXT NOT NULL,
variable TEXT NOT NULL,
orig_value REAL NOT NULL,
treatment TEXT NOT NULL DEFAULT 'SET_NULL',
reason TEXT,
applied_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_outlier_aero ON outlier_log(aerodrome, variable);
CREATE TABLE IF NOT EXISTS aerodromes (
icao TEXT PRIMARY KEY,
nome TEXT,
uf TEXT,
updated_at TEXT
);
"""
# ── Conexão ───────────────────────────────────────────────────────────────────
def get_connection(db_path: Path) -> sqlite3.Connection:
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db_path), check_same_thread=False)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=NORMAL")
conn.execute("PRAGMA cache_size=-32000")
return conn
def ensure_schema(conn: sqlite3.Connection) -> None:
conn.executescript(_SCHEMA)
conn.commit()
# ── Clip de limites físicos no DataFrame ─────────────────────────────────────
def _clip_to_limits(df: pd.DataFrame) -> pd.DataFrame:
"""Define como NaN valores fora dos limites físicos (sem registrar no log)."""
df = df.copy()
for col, (lo, hi) in PHYSICAL_LIMITS.items():
if col in df.columns:
mask = df[col].notna() & ((df[col] < lo) | (df[col] > hi))
df.loc[mask, col] = float("nan")
return df
# ── Upsert analytics ──────────────────────────────────────────────────────────
def upsert_analytics(
conn: sqlite3.Connection,
aerodrome: str,
anl_df: pd.DataFrame,
) -> int:
"""
Insere ou substitui linhas no banco.
Aplica limites físicos automaticamente antes da inserção.
Retorna número de linhas processadas.
"""
if anl_df.empty:
return 0
anl_df = _clip_to_limits(anl_df)
cols_present = [c for c in ANL_COLS if c in anl_df.columns]
placeholders = ", ".join(["?"] * (2 + len(cols_present)))
col_names = ", ".join(["aerodrome", "dt"] + cols_present)
sql = f"INSERT OR REPLACE INTO observations ({col_names}) VALUES ({placeholders})"
def _dt_str(v):
if isinstance(v, (datetime, pd.Timestamp)):
return v.strftime("%Y-%m-%d %H:%M:%S")
return str(v)[:19]
rows = []
for _, row in anl_df.iterrows():
vals = [aerodrome, _dt_str(row["_dt"])]
for c in cols_present:
v = row[c]
vals.append(None if pd.isna(v) else float(v))
rows.append(tuple(vals))
with conn:
conn.executemany(sql, rows)
return len(rows)
# ── Repair de outliers no banco ───────────────────────────────────────────────
def apply_physical_limits(
conn: sqlite3.Connection,
aerodrome: Optional[str] = None,
) -> int:
"""
Aplica limites físicos aos dados existentes, com auditoria completa em outlier_log.
Se aerodrome=None, processa todos os aeródromos.
Retorna total de outliers registrados e corrigidos.
"""
now = datetime.now(timezone.utc).isoformat()
total = 0
aero_filter = "AND aerodrome=?" if aerodrome else ""
aero_params_suffix = (aerodrome,) if aerodrome else ()
for col, (lo, hi) in PHYSICAL_LIMITS.items():
# Registra valores abaixo do limite
rows = conn.execute(
f"SELECT aerodrome, dt, {col} FROM observations "
f"WHERE {col} IS NOT NULL AND {col} < ? {aero_filter}",
(lo,) + aero_params_suffix,
).fetchall()
if rows:
conn.executemany(
"INSERT INTO outlier_log"
"(aerodrome,dt,variable,orig_value,treatment,reason,applied_at)"
" VALUES(?,?,?,?,?,?,?)",
[(r[0], r[1], col, r[2], "SET_NULL", f"below_limit:{lo}", now)
for r in rows],
)
total += len(rows)
# Registra valores acima do limite
rows = conn.execute(
f"SELECT aerodrome, dt, {col} FROM observations "
f"WHERE {col} IS NOT NULL AND {col} > ? {aero_filter}",
(hi,) + aero_params_suffix,
).fetchall()
if rows:
conn.executemany(
"INSERT INTO outlier_log"
"(aerodrome,dt,variable,orig_value,treatment,reason,applied_at)"
" VALUES(?,?,?,?,?,?,?)",
[(r[0], r[1], col, r[2], "SET_NULL", f"above_limit:{hi}", now)
for r in rows],
)
total += len(rows)
# Aplica o UPDATE com CASE para todas as variáveis de uma vez
where_clause = f"WHERE aerodrome='{aerodrome}'" if aerodrome else ""
conn.execute(f"""
UPDATE observations SET
T = CASE WHEN T IS NOT NULL AND (T < -25 OR T > 55 ) THEN NULL ELSE T END,
Td = CASE WHEN Td IS NOT NULL AND (Td < -30 OR Td > 40 ) THEN NULL ELSE Td END,
UR = CASE WHEN UR IS NOT NULL AND (UR < 0 OR UR > 100 ) THEN NULL ELSE UR END,
QNH = CASE WHEN QNH IS NOT NULL AND (QNH < 940 OR QNH > 1060) THEN NULL ELSE QNH END,
WS = CASE WHEN WS IS NOT NULL AND (WS < 0 OR WS > 200 ) THEN NULL ELSE WS END,
WG = CASE WHEN WG IS NOT NULL AND (WG < 0 OR WG > 250 ) THEN NULL ELSE WG END,
WD = CASE WHEN WD IS NOT NULL AND (WD < 0 OR WD > 360 ) THEN NULL ELSE WD END,
VIS = CASE WHEN VIS IS NOT NULL AND (VIS < 0 OR VIS > 9999) THEN NULL ELSE VIS END,
TETO = CASE WHEN TETO IS NOT NULL AND (TETO < 0 OR TETO > 9999) THEN NULL ELSE TETO END,
PREC = CASE WHEN PREC IS NOT NULL AND (PREC < 0 OR PREC > 500 ) THEN NULL ELSE PREC END
{where_clause}
""")
conn.commit()
return total
# ── Consultas de auditoria ────────────────────────────────────────────────────
def get_outlier_summary(
conn: sqlite3.Connection,
aerodrome: Optional[str] = None,
) -> pd.DataFrame:
"""
Resumo de outliers por variável: contagem, min/max do valor original,
limite aplicado e data do último tratamento.
"""
aero_filter = "WHERE aerodrome=?" if aerodrome else ""
params = (aerodrome,) if aerodrome else ()
return pd.read_sql_query(
f"""
SELECT
variable,
aerodrome,
COUNT(*) AS n_outliers,
MIN(orig_value) AS min_orig,
MAX(orig_value) AS max_orig,
reason,
MAX(applied_at) AS last_applied
FROM outlier_log
{aero_filter}
GROUP BY variable, aerodrome, reason
ORDER BY variable, aerodrome
""",
conn, params=params,
)
def get_outlier_detail(
conn: sqlite3.Connection,
aerodrome: Optional[str] = None,
variable: Optional[str] = None,
limit: int = 500,
) -> pd.DataFrame:
"""Detalhe linha-a-linha dos outliers registrados."""
conditions = []
params: list = []
if aerodrome:
conditions.append("aerodrome=?")
params.append(aerodrome)
if variable:
conditions.append("variable=?")
params.append(variable)
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
return pd.read_sql_query(
f"SELECT aerodrome, dt, variable, orig_value, reason, applied_at "
f"FROM outlier_log {where} ORDER BY applied_at DESC, aerodrome, dt "
f"LIMIT {limit}",
conn, params=params,
)
def has_outlier_log(conn: sqlite3.Connection) -> bool:
"""Verifica se existe algum registro de outlier."""
n = conn.execute("SELECT COUNT(*) FROM outlier_log").fetchone()[0]
return n > 0
# ── Catálogo de aeródromos ────────────────────────────────────────────────────
def upsert_aerodromes(
conn: sqlite3.Connection,
aerodromes: list[dict],
) -> int:
"""
Insere ou atualiza lista de aeródromos.
Cada dict deve ter: icao, nome, uf.
Retorna número de registros processados.
"""
if not aerodromes:
return 0
now = datetime.now(timezone.utc).isoformat()
with conn:
conn.executemany(
"INSERT OR REPLACE INTO aerodromes(icao, nome, uf, updated_at)"
" VALUES(?, ?, ?, ?)",
[(a["icao"], a.get("nome", ""), a.get("uf", ""), now)
for a in aerodromes if a.get("icao")],
)
return len(aerodromes)
def list_all_aerodromes(conn: sqlite3.Connection) -> list[dict]:
"""
Retorna todos os aeródromos cadastrados (tabela aerodromes).
Formato: [{"icao": "SBGR", "nome": "Guarulhos, SP", "uf": "SP"}, ...]
"""
rows = conn.execute(
"SELECT icao, nome, uf FROM aerodromes ORDER BY icao"
).fetchall()
return [{"icao": r[0], "nome": r[1], "uf": r[2]} for r in rows]
# ── Queries gerais ────────────────────────────────────────────────────────────
def query_analytics(
conn: sqlite3.Connection,
aerodrome: str,
start_dt: Optional[str] = None,
end_dt: Optional[str] = None,
) -> pd.DataFrame:
sql = "SELECT dt, T, Td, UR, QNH, WS, WG, WD, VIS, TETO, PREC FROM observations WHERE aerodrome=?"
params: list = [aerodrome]
if start_dt:
sql += " AND dt >= ?"; params.append(start_dt)
if end_dt:
sql += " AND dt <= ?"
params.append(end_dt + " 23:59:59" if len(end_dt) == 10 else end_dt)
sql += " ORDER BY dt"
df = pd.read_sql_query(sql, conn, params=params)
if df.empty:
return df
df["_dt"] = pd.to_datetime(df["dt"], format="%Y-%m-%d %H:%M:%S", errors="coerce")
return df.drop(columns=["dt"]).dropna(subset=["_dt"]).reset_index(drop=True)
def get_coverage(
conn: sqlite3.Connection,
aerodrome: str,
) -> Optional[tuple[date, date]]:
row = conn.execute(
"SELECT MIN(dt), MAX(dt) FROM observations WHERE aerodrome=?",
(aerodrome,),
).fetchone()
if not row or row[0] is None:
return None
return date.fromisoformat(row[0][:10]), date.fromisoformat(row[1][:10])
def list_aerodromes(conn: sqlite3.Connection) -> list[str]:
rows = conn.execute(
"SELECT DISTINCT aerodrome FROM observations ORDER BY aerodrome"
).fetchall()
return [r[0] for r in rows]
def aerodrome_stats(conn: sqlite3.Connection, aerodrome: str) -> dict:
row = conn.execute(
"SELECT MIN(dt), MAX(dt), COUNT(*), COUNT(T), COUNT(VIS) "
"FROM observations WHERE aerodrome=?",
(aerodrome,),
).fetchone()
if not row or row[0] is None:
return {}
return {"min_dt": row[0], "max_dt": row[1],
"n_obs": row[2], "n_T": row[3], "n_VIS": row[4]}