This commit is contained in:
kolmeasi
2026-06-01 14:21:31 -03:00
parent 77ec04cc43
commit e5aa85fe9f
27 changed files with 1589 additions and 1908885 deletions

View File

@@ -0,0 +1,141 @@
"""
Command-line diagnostic tool for the surface meteorology SQLite database.
Reports values outside the physical limits defined in :mod:`db`, showing
per-variable outlier counts and basic summary statistics.
Usage:
python _diag_outliers.py
python _diag_outliers.py --db path/to/met.db
python _diag_outliers.py --top 20
"""
import argparse
import sys
from pathlib import Path
import pandas as pd
# Resolved relative to this file so the script works from any directory.
_APPS_DIR = Path(__file__).resolve().parent # .../meteorologia_aeroportos/_apps/
_BASE_DIR = _APPS_DIR.parent # .../meteorologia_aeroportos/
_DEFAULT_DB = _BASE_DIR / "db" / "met.db"
def print_outliers(
db_path: Path,
top_n: int = 10,
) -> None:
"""Queries and prints outlier statistics for all analytic variables.
Args:
db_path: Path to the SQLite database file.
top_n: Number of extreme values to display per variable.
"""
import db as _db
if not db_path.exists():
print(f"[erro] Banco não encontrado: {db_path}", file=sys.stderr)
sys.exit(1)
conn = _db.get_connection(db_path)
_db.ensure_schema(conn)
total: int = conn.execute(
"SELECT COUNT(*) FROM observations"
).fetchone()[0]
print(f"=== Outliers por variável (fora dos limites físicos) ===\n")
print(f"Total de observações: {total:,}\n")
for col, (lo, hi) in _db.PHYSICAL_LIMITS.items():
rows = conn.execute(
f"""
SELECT aerodrome, dt, {col}
FROM observations
WHERE {col} IS NOT NULL
AND ({col} < ? OR {col} > ?)
ORDER BY ABS({col}) DESC
LIMIT ?
""",
(lo, hi, top_n),
).fetchall()
if not rows:
continue
cnt: int = conn.execute(
f"SELECT COUNT(*) FROM observations "
f"WHERE {col} < ? OR {col} > ?",
(lo, hi),
).fetchone()[0]
print(f"--- {col} (limite: {lo} a {hi}) ---")
for r in rows:
print(f" {r[0]} {r[1]} {col}={r[2]}")
print(f" Total fora do limite: {cnt:,} ({cnt / total * 100:.2f}%)\n")
conn.close()
def print_stats(db_path: Path) -> None:
"""Prints min/max/mean for every analytic variable.
Args:
db_path: Path to the SQLite database file.
"""
import db as _db
conn = _db.get_connection(db_path)
_db.ensure_schema(conn)
cols_expr = ", ".join(
f"MIN({c}) AS min_{c}, MAX({c}) AS max_{c}, AVG({c}) AS avg_{c}"
for c in _db.ANL_COLS
)
row = conn.execute(f"SELECT {cols_expr} FROM observations").fetchone()
keys = [
f"{fn}_{c}"
for c in _db.ANL_COLS
for fn in ("min", "max", "avg")
]
print("\n=== Estatísticas básicas ===\n")
for k, v in zip(keys, row):
if v is not None:
print(f" {k}: {v:.2f}")
conn.close()
def main() -> None:
"""Entry point: parse arguments and run diagnostics."""
parser = argparse.ArgumentParser(
description="Outlier diagnostics for the meteorology SQLite database",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
examples:
python _diag_outliers.py
python _diag_outliers.py --db /path/to/met.db --top 20
""",
)
parser.add_argument(
"--db",
default=str(_DEFAULT_DB),
metavar="PATH",
help=f"Path to the SQLite database (default: {_DEFAULT_DB})",
)
parser.add_argument(
"--top",
type=int,
default=10,
metavar="N",
help="Number of extreme values to display per variable (default: 10)",
)
args = parser.parse_args()
print_outliers(Path(args.db), top_n=args.top)
print_stats(Path(args.db))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,521 @@
"""
CSV aggregator for ICEA/DECEA surface meteorology data.
Merges the per-tab, per-quarter CSV files produced by the scraper into a single
wide table per aerodrome. Includes spot-check validation and optional cleanup
of the quarterly source files.
Standalone usage:
python concat_meteorologia.py [--dados-dir <dir>] [--validate] [--n-samples 20] [--cleanup]
Importable by the pipeline:
from concat_meteorologia import build_aerodrome_table, validate_sample, cleanup_source_files
"""
import argparse
import csv
import io
import random
import re
import sys
from pathlib import Path
from typing import Callable, Optional
import pandas as pd
# ── 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 quarterly CSVs
PREPROC_DIR = (
_REPO_ROOT / "tabelas" / "preproc" / "meteorologia_aeroportos"
) # permanent compiled-CSV output (CLI mode)
DATETIME_COL = "Data e HoraObservação"
DATETIME_FMT = "%d/%m/%Y - %H:%M:%S"
# Prefixo curto + coluna pivô (None = sem pivô, 1 linha por timestamp)
TAB_CONFIG: dict[str, tuple[str, Optional[str]]] = {
"CGT": ("cgt", None),
"Nuvem": ("nuv", None),
"Precipitação": ("prec", None),
"Pressão": ("pres", None),
"RVR": ("rvr", "Cabeceira"),
"Temperatura": ("temp", "Pista"),
"Teto": ("teto", "Pista"),
"Vento": ("vent", "Cabeceira"),
"Visibilidade": ("visib", None),
}
_FNAME_RE = re.compile(
r"Dados de Superfície (.+?) - Localidade (\w+) - .+ - Período (\d{8})\s+-\s+(\d{8})\.csv"
)
# ---------------------------------------------------------------------------
# Utilitários
# ---------------------------------------------------------------------------
def _clean(name: str) -> str:
"""Normalises a column name to a safe identifier fragment.
Removes parenthesised content, unit symbols, and replaces whitespace/
punctuation runs with a single underscore.
Args:
name: Raw column name from a source CSV.
Returns:
Cleaned string suitable for use as part of a column identifier.
"""
name = re.sub(r"\(.*?\)", "", name)
name = re.sub(r"[ºµ%°]", "", name)
name = re.sub(r"[\s/\\,;]+", "_", name.strip())
return name.strip("_")
def parse_filename(
fname: str,
) -> Optional[tuple[str, str, pd.Timestamp, pd.Timestamp]]:
"""Extracts tab name, aerodrome code, and date range from a CSV filename.
Expected pattern:
``Dados de Superfície <tab> - Localidade <ICAO> - Período <DDMMYYYY> - <DDMMYYYY>.csv``
Args:
fname: Bare filename (no directory component).
Returns:
``(tab_name, aerodrome, start_ts, end_ts)`` tuple, or ``None`` if the
filename does not match the expected pattern.
"""
m = _FNAME_RE.match(fname)
if not m:
return None
tab = m.group(1).strip()
aero = m.group(2).strip()
s = pd.to_datetime(m.group(3), format="%d%m%Y")
e = pd.to_datetime(m.group(4), format="%d%m%Y")
return tab, aero, s, e
# ---------------------------------------------------------------------------
# Carregamento e pivotagem
# ---------------------------------------------------------------------------
def load_tab(path: Path, tab_name: str) -> pd.DataFrame:
"""
Carrega CSV de uma aba, normaliza coluna de data/hora e aplica pivô se necessário.
Retorna DataFrame com colunas prefixadas (ex: cgt_CGT_1, temp_10R_Bulbo_Seco).
"""
prefix, pivot_col = TAB_CONFIG.get(tab_name, (_clean(tab_name[:5].lower()), None))
df = pd.read_csv(path, encoding="utf-8-sig")
dt_candidates = [c for c in df.columns if "Data" in c and "Hora" in c]
if dt_candidates and dt_candidates[0] != DATETIME_COL:
df = df.rename(columns={dt_candidates[0]: DATETIME_COL})
if DATETIME_COL not in df.columns:
return df
if pivot_col and pivot_col in df.columns:
value_cols = [c for c in df.columns if c not in (DATETIME_COL, pivot_col)]
pivoted = df.pivot_table(
index=DATETIME_COL, columns=pivot_col, values=value_cols, aggfunc="first"
)
pivoted.columns = [
f"{prefix}_{_clean(str(piv))}_{_clean(col)}"
for col, piv in pivoted.columns
]
return pivoted.reset_index()
# Múltiplas linhas por timestamp sem chave nomeada (ex: camadas de nuvem)
if df.duplicated(subset=[DATETIME_COL], keep=False).any():
df = df.copy()
df["_camada"] = df.groupby(DATETIME_COL).cumcount() + 1
value_cols = [c for c in df.columns if c not in (DATETIME_COL, "_camada")]
pivoted = df.pivot_table(
index=DATETIME_COL, columns="_camada", values=value_cols, aggfunc="first"
)
pivoted.columns = [
f"{prefix}_c{int(cam)}_{_clean(col)}"
for col, cam in pivoted.columns
]
return pivoted.reset_index()
rename = {c: f"{prefix}_{_clean(c)}" for c in df.columns if c != DATETIME_COL}
return df.rename(columns=rename)
# ---------------------------------------------------------------------------
# Construção da tabela por aeródromo
# ---------------------------------------------------------------------------
def build_aerodrome_table(
files: list[Path],
extra_df: Optional[pd.DataFrame] = None,
log: Callable[[str], None] = print,
) -> tuple[str, pd.DataFrame]:
"""
Agrupa CSVs por aba, concatena no tempo e mescla todas as abas.
Se extra_df for fornecido (arquivo compilado anterior), é incluído no merge final.
Retorna (aerodrome_code, DataFrame_mesclado).
"""
tab_frames: dict[str, list[pd.DataFrame]] = {}
aerodrome = ""
for f in sorted(files):
parsed = parse_filename(f.name)
if not parsed:
log(f" [skip] nome nao reconhecido: {f.name}")
continue
tab_name, aero, *_ = parsed
aerodrome = aero
df = load_tab(f, tab_name)
tab_frames.setdefault(tab_name, []).append(df)
if not tab_frames and extra_df is None:
return aerodrome, pd.DataFrame()
per_tab: list[pd.DataFrame] = []
for tab_name in TAB_CONFIG:
if tab_name not in tab_frames:
continue
combined = pd.concat(tab_frames[tab_name], ignore_index=True)
combined = combined.sort_values(DATETIME_COL).reset_index(drop=True)
per_tab.append(combined)
if not per_tab:
return aerodrome, extra_df if extra_df is not None else pd.DataFrame()
result = per_tab[0]
for df in per_tab[1:]:
result = pd.merge(result, df, on=DATETIME_COL, how="outer")
result = result.sort_values(DATETIME_COL).reset_index(drop=True)
# Inclui dados já compilados anteriormente
if extra_df is not None and not extra_df.empty:
result = pd.concat([extra_df, result], ignore_index=True)
result = result.sort_values(DATETIME_COL).drop_duplicates(
subset=[DATETIME_COL], keep="last"
).reset_index(drop=True)
return aerodrome, result
def date_range_from_data(df: pd.DataFrame) -> tuple[str, str]:
"""Returns the formatted ``(start, end)`` date strings from a merged DataFrame.
Args:
df: DataFrame that contains the :data:`DATETIME_COL` column.
Returns:
``("YYYY_MM_DD", "YYYY_MM_DD")`` strings for the earliest and latest
parsed timestamps.
"""
dates = pd.to_datetime(df[DATETIME_COL], format=DATETIME_FMT, errors="coerce")
return dates.min().strftime("%Y_%m_%d"), dates.max().strftime("%Y_%m_%d")
# ---------------------------------------------------------------------------
# Validação amostral
# ---------------------------------------------------------------------------
def validate_sample(
merged_df: pd.DataFrame,
source_files: list[Path],
n: int = 20,
log: Callable[[str], None] = print,
) -> tuple[int, int, list[str]]:
"""
Valida a compilação sorteando n linhas do DataFrame mesclado e comparando
com os arquivos trimestrais originais.
Retorna (n_ok, n_fail, lista_erros).
"""
if not source_files or merged_df.empty:
return 0, 0, []
# Índice: (tab_name, periodo_start, periodo_end) -> Path
file_index: dict[tuple[str, str, str], Path] = {}
for f in source_files:
parsed = parse_filename(f.name)
if parsed:
tab_name, _, s, e = parsed
key = (tab_name, s.strftime("%d%m%Y"), e.strftime("%d%m%Y"))
file_index[key] = f
if not file_index:
return 0, 0, ["Nenhum arquivo trimestral indexado para validação"]
# Tabs simples (sem pivô) são mais fáceis de validar diretamente
validatable_tabs = [
tab for tab, (pfx, piv) in TAB_CONFIG.items() if piv is None
]
# Filtra tabs que realmente existem nos arquivos disponíveis
available_tabs = {parse_filename(f.name)[0] for f in source_files if parse_filename(f.name)}
validatable_tabs = [t for t in validatable_tabs if t in available_tabs]
if not validatable_tabs:
return 0, 0, ["Nenhuma aba sem pivô disponível para validar"]
# Mapeia colunas mescladas → (tab, coluna_original) para tabs sem pivô
col_map: dict[str, tuple[str, str]] = {}
for tab_name in validatable_tabs:
prefix, _ = TAB_CONFIG[tab_name]
# Pega colunas do merged que começam com esse prefixo
merged_cols = [c for c in merged_df.columns if c.startswith(f"{prefix}_")]
for mc in merged_cols:
col_map[mc] = (tab_name, mc) # guardaremos o prefixo para busca
sample_size = min(n, len(merged_df))
sample_idx = random.sample(range(len(merged_df)), sample_size)
n_ok = 0
n_fail = 0
errors = []
for idx in sample_idx:
row = merged_df.iloc[idx]
timestamp = row[DATETIME_COL]
# Escolhe uma aba aleatória para validar nesta linha
tab_name = random.choice(validatable_tabs)
prefix, _ = TAB_CONFIG[tab_name]
merged_cols = [c for c in merged_df.columns if c.startswith(f"{prefix}_")]
if not merged_cols:
continue
# Localiza o arquivo trimestral correspondente ao timestamp
try:
ts = pd.to_datetime(timestamp, format=DATETIME_FMT, errors="coerce")
except Exception:
continue
if pd.isna(ts):
continue
source_path = None
for (tab, s_str, e_str), fpath in file_index.items():
if tab != tab_name:
continue
s_dt = pd.to_datetime(s_str, format="%d%m%Y")
e_dt = pd.to_datetime(e_str, format="%d%m%Y")
if s_dt <= ts <= e_dt + pd.Timedelta(days=1):
source_path = fpath
break
if source_path is None:
continue # timestamp fora do range de arquivos disponíveis — pula
# Carrega o CSV original e procura o timestamp
try:
src_df = pd.read_csv(source_path, encoding="utf-8-sig")
dt_col = next((c for c in src_df.columns if "Data" in c and "Hora" in c), None)
if dt_col is None:
continue
src_row = src_df[src_df[dt_col] == timestamp]
if src_row.empty:
continue
# Compara as colunas (pega a primeira coluna de dados não-data)
val_cols = [c for c in src_df.columns if c != dt_col]
if not val_cols:
continue
check_col = val_cols[0]
src_val = str(src_row.iloc[0][check_col]).strip()
# Mapeia para o nome mesclado
merged_col_name = f"{prefix}_{_clean(check_col)}"
if merged_col_name not in merged_df.columns:
continue
merged_val = str(row.get(merged_col_name, "")).strip()
if src_val == merged_val or (src_val == "-" and merged_val in ("-", "nan", "")):
n_ok += 1
else:
n_fail += 1
errors.append(
f" MISMATCH @ {timestamp} | aba={tab_name} | col={check_col} "
f"| src={src_val!r} != merged={merged_val!r}"
)
except Exception as exc:
errors.append(f" ERRO ao validar {timestamp}: {exc}")
return n_ok, n_fail, errors
# ---------------------------------------------------------------------------
# Limpeza de arquivos trimestrais
# ---------------------------------------------------------------------------
def cleanup_source_files(
files: list[Path],
log: Callable[[str], None] = print,
) -> int:
"""Apaga os arquivos trimestrais. Retorna quantos foram deletados."""
deleted = 0
for f in files:
try:
f.unlink()
deleted += 1
except Exception as exc:
log(f" [warn] nao foi possivel apagar {f.name}: {exc}")
log(f" {deleted} arquivo(s) intermediario(s) removidos.")
return deleted
# ---------------------------------------------------------------------------
# CLI standalone
# ---------------------------------------------------------------------------
def main(
dados_dir: Path = DADOS_DIR,
compiled_dir: Path = PREPROC_DIR,
do_validate: bool = True,
n_samples: int = 20,
do_cleanup: bool = False,
log: Callable[[str], None] = print,
) -> Optional[pd.DataFrame]:
"""Aggregates all quarterly CSVs found in *dados_dir* into per-aerodrome tables.
Args:
dados_dir: Directory containing ``Dados de Superfície*.csv`` files.
compiled_dir: Root output directory for compiled aerodrome CSVs.
Each aerodrome gets a sub-directory: ``<compiled_dir>/<ICAO>/``.
do_validate: Run spot-check validation against the source files.
n_samples: Number of rows to spot-check.
do_cleanup: Remove quarterly CSVs after successful validation.
log: Logging callback.
Returns:
The last compiled DataFrame, or ``None`` if no data was found.
"""
base = Path(dados_dir)
files = [f for f in base.glob("Dados de Superfície*.csv") if f.is_file()]
if not files:
log(f"Nenhum CSV encontrado em {base.resolve()}")
return None
# Agrupa por aeródromo
aero_files: dict[str, list[Path]] = {}
for f in files:
parsed = parse_filename(f.name)
if parsed:
_, aero, *_ = parsed
aero_files.setdefault(aero, []).append(f)
final_df = None
for aero, afiles in aero_files.items():
log(f"\n{'=' * 64}")
log(f"Aeródromo : {aero} ({len(afiles)} arquivo(s))")
log(f"{'=' * 64}")
# Check for an existing compiled file (incremental append)
aero_compiled = Path(compiled_dir) / aero
extra_df: Optional[pd.DataFrame] = None
existing = sorted(aero_compiled.glob(f"{aero}_*.csv")) if aero_compiled.is_dir() else []
if existing:
log(f" Arquivo compilado existente: {existing[-1].name} — será atualizado")
extra_df = pd.read_csv(existing[-1], encoding="utf-8-sig")
aerodrome, df = build_aerodrome_table(afiles, extra_df=extra_df, log=log)
if df.empty:
log(" Sem dados.")
continue
start_str, end_str = date_range_from_data(df)
aero_compiled.mkdir(parents=True, exist_ok=True)
out_name = f"{aero}_{start_str}_{end_str}.csv"
out_path = aero_compiled / out_name
df.to_csv(out_path, index=False, encoding="utf-8-sig", quoting=csv.QUOTE_ALL)
log(f" Linhas : {len(df)}")
log(f" Colunas : {len(df.columns)}")
log(f" Periodo : {start_str.replace('_', '-')} -> {end_str.replace('_', '-')}")
log(f" Arquivo : {out_path}")
for old in existing:
if old.name != out_name:
old.unlink()
log(f" [removido arquivo anterior: {old.name}]")
# Validação amostral
validated = True
if do_validate and afiles:
log(f"\n Validando {n_samples} amostras...")
n_ok, n_fail, errs = validate_sample(df, afiles, n=n_samples, log=log)
log(f" Validacao: {n_ok} OK | {n_fail} divergencias")
for e in errs:
log(e)
if n_fail > 0:
validated = False
log(" [ATENCAO] Divergencias encontradas — arquivos intermediarios NAO removidos.")
# Limpeza dos trimestrais
if do_cleanup:
if validated:
log(f"\n Removendo {len(afiles)} arquivo(s) trimestral(is)...")
cleanup_source_files(afiles, log=log)
else:
log(" [skip cleanup] Validação com falhas — arquivos mantidos para inspeção.")
final_df = df
return final_df
if __name__ == "__main__":
try:
if hasattr(sys.stdout, "buffer"):
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
if hasattr(sys.stderr, "buffer"):
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
except Exception:
pass
ap = argparse.ArgumentParser(
description="Aggregates surface meteorology CSVs into per-aerodrome tables"
)
ap.add_argument("--dados-dir", default=str(DADOS_DIR), metavar="DIR",
help="Directory containing quarterly CSV files")
ap.add_argument("--compiled-dir", default=str(PREPROC_DIR), metavar="DIR",
help="Root output directory for compiled CSVs")
ap.add_argument("--validate", action="store_true", default=True)
ap.add_argument("--no-validate", dest="validate", action="store_false")
ap.add_argument("--n-samples", type=int, default=20, metavar="N")
ap.add_argument("--cleanup", action="store_true",
help="Remove quarterly CSVs after successful validation")
ap.add_argument("--db", action="store_true",
help="Also upsert analytics into the SQLite database")
_args = ap.parse_args()
result_df = main(
dados_dir = Path(_args.dados_dir),
compiled_dir = Path(_args.compiled_dir),
do_validate = _args.validate,
n_samples = _args.n_samples,
do_cleanup = _args.cleanup,
)
if _args.db and result_df is not None:
import db as _db
from pipeline import build_analytics as _build
db_path = DB_PATH
conn = _db.get_connection(db_path)
_db.ensure_schema(conn)
anl = _build(result_df)
_RE2 = re.compile(r"Dados de Superfície .+ - Localidade (\w+) -")
aeros: set[str] = set()
for f in Path(_args.dados_dir).glob("Dados de Superfície*.csv"):
m2 = _RE2.match(f.name)
if m2:
aeros.add(m2.group(1))
aerodrome = next(iter(aeros), "UNKN")
n = _db.upsert_analytics(conn, aerodrome, anl)
conn.close()
print(f"[db] {n} linhas upsertadas em {db_path} para {aerodrome}")

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,493 @@
"""
SQLite storage layer for aerodrome meteorological analytics.
Tables:
observations — Hourly analytic time-series (10 float variables per timestamp).
outlier_log — Audit trail of values treated by physical-limit enforcement.
aerodromes — Catalog of aerodromes available on the ICEA/DECEA website.
Example:
>>> from db import get_connection, ensure_schema, upsert_analytics, query_analytics
>>> conn = get_connection(Path("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
# ── Analytic columns ──────────────────────────────────────────────────────────
ANL_COLS: list[str] = [
"T", "Td", "UR", "QNH", "WS", "WG", "WD", "VIS", "TETO", "PREC"
]
# ── Physical limits per variable ──────────────────────────────────────────────
PHYSICAL_LIMITS: dict[str, tuple[float, float]] = {
"T": (-25.0, 55.0), # °C — Brazilian climate extremes
"Td": (-30.0, 40.0), # °C — dew point
"UR": ( 0.0, 100.0), # %
"QNH": (940.0, 1060.0), # hPa — absolute world records
"WS": ( 0.0, 200.0), # kt
"WG": ( 0.0, 250.0), # kt — Cat-5 hurricane gust ≈ 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 — world record hourly ≈ 300 mm/h
}
# ── SQL schema ────────────────────────────────────────────────────────────────
_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
);
"""
# ── Connection ────────────────────────────────────────────────────────────────
def get_connection(db_path: Path) -> sqlite3.Connection:
"""Opens (or creates) a SQLite database with WAL mode enabled.
Args:
db_path: Filesystem path to the ``.db`` file. Parent directories are
created automatically if they do not exist.
Returns:
An open :class:`sqlite3.Connection` configured with WAL journal mode,
NORMAL synchronous writes, and a 32 MB page cache.
"""
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:
"""Creates all tables and indices if they do not already exist.
Args:
conn: Open SQLite connection.
"""
conn.executescript(_SCHEMA)
conn.commit()
# ── Physical-limit clipping ───────────────────────────────────────────────────
def _clip_to_limits(df: pd.DataFrame) -> pd.DataFrame:
"""Sets values outside physical limits to NaN (no audit log entry).
Args:
df: DataFrame whose columns may include any subset of ANL_COLS.
Returns:
Copy of *df* with out-of-range values replaced by ``float("nan")``.
"""
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:
"""Inserts or replaces rows in the observations table.
Physical limits are applied automatically before insertion; out-of-range
values are silently set to NULL.
Args:
conn: Open SQLite connection with schema already applied.
aerodrome: ICAO code of the aerodrome (e.g. ``"SBGR"``).
anl_df: DataFrame with a ``_dt`` column (datetime-like) and any subset
of :data:`ANL_COLS` as float columns.
Returns:
Number of rows processed (inserted or replaced).
"""
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: object) -> str:
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: list = [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)
# ── Physical-limit repair on existing rows ────────────────────────────────────
def apply_physical_limits(
conn: sqlite3.Connection,
aerodrome: Optional[str] = None,
) -> int:
"""Audits and corrects out-of-range values already stored in the database.
Every corrected value is logged in ``outlier_log`` before being set to
NULL. Pass ``aerodrome=None`` to process all aerodromes.
Args:
conn: Open SQLite connection with schema already applied.
aerodrome: ICAO code to restrict processing, or ``None`` for all.
Returns:
Total number of outlier records created and corrected.
"""
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():
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)
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)
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
# ── Outlier audit queries ─────────────────────────────────────────────────────
def get_outlier_summary(
conn: sqlite3.Connection,
aerodrome: Optional[str] = None,
) -> pd.DataFrame:
"""Returns per-variable outlier counts and statistics from the audit log.
Args:
conn: Open SQLite connection.
aerodrome: Filter to a single ICAO code, or ``None`` for all.
Returns:
DataFrame with columns ``variable``, ``aerodrome``, ``n_outliers``,
``min_orig``, ``max_orig``, ``reason``, ``last_applied``.
"""
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:
"""Returns row-level outlier records from the audit log.
Args:
conn: Open SQLite connection.
aerodrome: Filter by ICAO code, or ``None`` for all.
variable: Filter by variable name (e.g. ``"T"``), or ``None`` for all.
limit: Maximum number of rows to return.
Returns:
DataFrame with columns ``aerodrome``, ``dt``, ``variable``,
``orig_value``, ``reason``, ``applied_at``.
"""
conditions: list[str] = []
params: list[object] = []
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:
"""Returns True if at least one outlier record exists in the audit log.
Args:
conn: Open SQLite connection.
"""
n = conn.execute("SELECT COUNT(*) FROM outlier_log").fetchone()[0]
return n > 0
# ── Aerodrome catalog ─────────────────────────────────────────────────────────
def upsert_aerodromes(
conn: sqlite3.Connection,
aerodromes: list[dict],
) -> int:
"""Inserts or updates aerodrome catalog entries.
Args:
conn: Open SQLite connection with schema already applied.
aerodromes: List of dicts, each with keys ``icao``, ``nome``, ``uf``.
Returns:
Number of records processed.
"""
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]:
"""Returns all entries from the aerodrome catalog table.
Args:
conn: Open SQLite connection.
Returns:
List of ``{"icao": str, "nome": str, "uf": str}`` dicts ordered by
ICAO code. Returns an empty list if the catalog is empty.
"""
rows = conn.execute(
"SELECT icao, nome, uf FROM aerodromes ORDER BY icao"
).fetchall()
return [{"icao": r[0], "nome": r[1] or "", "uf": r[2] or ""} for r in rows]
def list_aerodromes_with_data(conn: sqlite3.Connection) -> list[dict]:
"""Returns catalog entries for aerodromes that have rows in observations.
Joins the ``observations`` table with the ``aerodromes`` catalog so the
caller receives full display metadata (name, state) for each code that
actually has data.
Args:
conn: Open SQLite connection with schema already applied.
Returns:
List of ``{"icao": str, "nome": str, "uf": str}`` dicts ordered by
ICAO code. Returns an empty list when no observations exist yet.
"""
rows = conn.execute(
"""SELECT DISTINCT o.aerodrome,
COALESCE(a.nome, ''),
COALESCE(a.uf, '')
FROM observations o
LEFT JOIN aerodromes a ON a.icao = o.aerodrome
ORDER BY o.aerodrome"""
).fetchall()
return [{"icao": r[0], "nome": r[1], "uf": r[2]} for r in rows]
# ── General queries ───────────────────────────────────────────────────────────
def query_analytics(
conn: sqlite3.Connection,
aerodrome: str,
start_dt: Optional[str] = None,
end_dt: Optional[str] = None,
) -> pd.DataFrame:
"""Queries analytic observations for one aerodrome over an optional date range.
Args:
conn: Open SQLite connection.
aerodrome: ICAO code to query.
start_dt: Inclusive start date/datetime string (``"YYYY-MM-DD"`` or
``"YYYY-MM-DD HH:MM:SS"``). No lower bound if ``None``.
end_dt: Inclusive end date (``"YYYY-MM-DD"``). When only a date is
given, the comparison extends to ``23:59:59`` of that day.
Returns:
DataFrame with columns ``_dt`` (``datetime64``) plus the ANL_COLS
floats. Returns an empty DataFrame if no rows match.
"""
sql = (
"SELECT dt, T, Td, UR, QNH, WS, WG, WD, VIS, TETO, PREC "
"FROM observations WHERE aerodrome=?"
)
params: list[object] = [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]]:
"""Returns the earliest and latest observation dates for one aerodrome.
Args:
conn: Open SQLite connection.
aerodrome: ICAO code to inspect.
Returns:
``(min_date, max_date)`` tuple, or ``None`` if no observations exist.
"""
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]:
"""Returns ICAO codes of all aerodromes that have at least one observation.
Args:
conn: Open SQLite connection.
Returns:
Sorted list of ICAO code strings.
"""
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:
"""Returns basic row-count statistics for one aerodrome.
Args:
conn: Open SQLite connection.
aerodrome: ICAO code to inspect.
Returns:
Dict with keys ``min_dt``, ``max_dt``, ``n_obs``, ``n_T``, ``n_VIS``.
Returns an empty dict if no observations exist.
"""
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],
}

View File

@@ -0,0 +1,537 @@
"""
End-to-end pipeline orchestrator for ICEA/DECEA surface meteorology.
Workflow:
1. Check existing coverage in SQLite (``db.get_coverage``).
2. Forward pass — download new data after ``ex_max`` (update).
3. Backward pass — download history before ``ex_min`` (auto-stop on empty years).
4. Compute analytics from the quarterly CSV files.
5. Upsert into SQLite and write the analytics CSV backup to PREPROC_DIR.
6. Spot-check validation against the quarterly source files.
7. Remove quarterly intermediate CSVs (if validation passed and cleanup enabled).
Example:
python pipeline.py --aerodrome SBGR --all-years
python pipeline.py --aerodrome SBGR --start-year 2020 --end-year 2025 --no-cleanup
"""
import argparse
import io
import sys
import unicodedata
from datetime import date
from pathlib import Path
from typing import Callable, Optional
import pandas as pd
from selenium.webdriver.support.ui import WebDriverWait
import db as _db
from scraper_meteorologia import (
SITE_MIN_DATE,
STOP_EMPTY_YEARS,
make_driver,
scrape_year,
)
from concat_meteorologia import (
build_aerodrome_table,
cleanup_source_files,
validate_sample,
)
# ── 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 CSV files
DB_PATH = _BASE_DIR / "db" / "met.db" # SQLite database
PREPROC_DIR = (
_REPO_ROOT / "tabelas" / "preproc" / "meteorologia_aeroportos"
) # permanent analytics CSV backups
# ── Progress milestones ───────────────────────────────────────────────────────
_PROG_START = 0.05
_PROG_FORWARD_DONE = 0.35
_PROG_ANALYTICS = 0.72
_PROG_UPSERT = 0.80
_PROG_VALIDATE = 0.88
_PROG_CLEANUP = 0.95
_PROG_DONE = 1.00
# ---------------------------------------------------------------------------
# Analytics helpers (standalone, no dependency on dashboard.py)
# ---------------------------------------------------------------------------
def _norm(s: str) -> str:
"""Returns *s* lowercased, ASCII-only, with diacritics stripped."""
return unicodedata.normalize("NFKD", s.lower()).encode("ascii", "ignore").decode("ascii")
def _to_num(s: pd.Series) -> pd.Series:
"""Coerces *s* to float, treating ``'-'`` and blank strings as NaN.
Args:
s: A pandas Series of raw string or numeric values.
Returns:
Float Series with unparseable values replaced by ``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(
df: pd.DataFrame,
prefix: str,
kw: str,
fn: str = "mean",
) -> pd.Series:
"""Aggregates columns matching ``<prefix>_*<kw>*``, preferring generic ones.
Columns whose name contains ``_-_`` are treated as generic (station-wide)
and preferred over runway/heading-specific columns. When multiple specific
columns are present, they are combined using *fn*.
Args:
df: Merged wide DataFrame with prefixed column names.
prefix: Column prefix to filter on (e.g. ``"temp"``).
kw: Keyword fragment to match in normalised column names.
fn: Aggregation function: ``"mean"``, ``"min"``, or ``"max"``.
Returns:
Float Series aligned with *df*'s index.
"""
kw_n = _norm(kw)
cols = [c for c in df.columns if c.startswith(f"{prefix}_") and kw_n in _norm(c)]
if not cols:
return pd.Series(dtype=float, index=df.index)
gen = [c for c in cols if "_-_" in c]
spc = [c for c in cols if "_-_" not in c]
if gen:
s = _to_num(df[gen[0]])
if s.notna().mean() > 0.05:
return s
if spc:
mat = pd.DataFrame({c: _to_num(df[c]) for c in spc})
return {"mean": mat.mean, "min": mat.min, "max": mat.max}[fn](axis=1)
return _to_num(df[cols[0]])
def build_analytics(merged: pd.DataFrame) -> pd.DataFrame:
"""Derives the 10 clean analytic variables from the 84-column merged CSV.
Args:
merged: Raw merged DataFrame produced by
:func:`concat_meteorologia.build_aerodrome_table`.
Returns:
DataFrame with columns ``_dt`` (``datetime64``) plus
``T``, ``Td``, ``UR``, ``QNH``, ``WS``, ``WG``, ``WD``,
``VIS``, ``TETO``, ``PREC`` (all float).
"""
dt_col = next((c for c in merged.columns if "Data" in c and "Hora" in c), None)
dt = (
pd.to_datetime(merged[dt_col], format="%d/%m/%Y - %H:%M:%S", errors="coerce")
if dt_col
else pd.Series(dtype="datetime64[ns]")
)
qnh = (
_to_num(merged["pres_QNH"])
if "pres_QNH" in merged.columns
else _agg(merged, "pres", "QNH")
)
vis = next(
(
_to_num(merged[c])
for c in merged.columns
if "visib" in c.lower() and "predominante" in _norm(c)
),
pd.Series(dtype=float, index=merged.index),
)
prec = next(
(
_to_num(merged[c])
for c in merged.columns
if "prec_precipita" in c.lower() and "dura" not in c.lower()
),
pd.Series(dtype=float, index=merged.index),
)
return pd.DataFrame({
"_dt": dt.values,
"T": _agg(merged, "temp", "Bulbo_Seco").values,
"Td": _agg(merged, "temp", "Orvalho").values,
"UR": _agg(merged, "temp", "Umidade").values,
"QNH": qnh.values,
"WS": _agg(merged, "vent", "Velocidade").values,
"WG": _agg(merged, "vent", "Rajada", "max").values,
"WD": _agg(merged, "vent", "Dire").values,
"VIS": vis.values,
"TETO": _agg(merged, "teto", "Teto", "min").values,
"PREC": prec.values,
})
# ---------------------------------------------------------------------------
# Two-pass scraping
# ---------------------------------------------------------------------------
def _run_forward(
driver: object,
wait: WebDriverWait,
aerodrome: str,
ex_max: date,
dados_dir: str,
log: Callable[[str], None],
) -> int:
"""Downloads new observations after *ex_max* (update pass).
Args:
driver: Selenium WebDriver instance.
wait: Configured WebDriverWait bound to *driver*.
aerodrome: ICAO code to scrape.
ex_max: Latest date already stored; scraping starts just after this.
dados_dir: Directory where quarterly CSVs are written.
log: Logging callback.
Returns:
Number of non-empty quarters downloaded.
"""
today = date.today()
if ex_max >= today:
log("[pipeline] Dados já atualizados até hoje.")
return 0
log(f"\n[pipeline] === Passe 1: atualização {ex_max.year}{today.year} ===")
total = 0
for year in range(today.year, ex_max.year - 1, -1):
log(f"\n{'=' * 55}\nAno {year}{aerodrome} (forward)\n{'=' * 55}")
total += scrape_year(driver, wait, aerodrome, year, dados_dir,
stop_before=ex_max, log=log)
return total
def _run_backward(
driver: object,
wait: WebDriverWait,
aerodrome: str,
ex_min: date,
all_years: bool,
dados_dir: str,
log: Callable[[str], None],
progress: Callable[[float, str], None],
) -> int:
"""Downloads historical data before *ex_min* (backward pass).
Stops automatically after :data:`STOP_EMPTY_YEARS` consecutive empty years
when *all_years* is ``True``.
Args:
driver: Selenium WebDriver instance.
wait: Configured WebDriverWait bound to *driver*.
aerodrome: ICAO code to scrape.
ex_min: Earliest date already stored; scraping goes back from here.
all_years: When ``True``, enable the consecutive-empty-year stop logic.
dados_dir: Directory where quarterly CSVs are written.
log: Logging callback.
progress: Progress callback ``(fraction, message)``.
Returns:
Number of non-empty quarters downloaded.
"""
start_year = ex_min.year - 1
if start_year < SITE_MIN_DATE.year:
log("[pipeline] Histórico já vai até o limite do site.")
return 0
log(f"\n[pipeline] === Passe 2: histórico {start_year}{SITE_MIN_DATE.year} ===")
total = 0
consecutive_empty = 0
years = list(range(start_year, SITE_MIN_DATE.year - 1, -1))
for i, year in enumerate(years):
pct = _PROG_FORWARD_DONE + (_PROG_ANALYTICS - _PROG_FORWARD_DONE) * (
i / max(len(years), 1)
)
progress(pct, f"Histórico {aerodrome} {year}")
log(f"\n{'=' * 55}\nAno {year}{aerodrome} (backward)\n{'=' * 55}")
n = scrape_year(driver, wait, aerodrome, year, dados_dir,
stop_before=None, log=log)
total += n
if n == 0:
consecutive_empty += 1
log(f" [sem dados] ({consecutive_empty}/{STOP_EMPTY_YEARS})")
if all_years and consecutive_empty >= STOP_EMPTY_YEARS:
log(f"\n{STOP_EMPTY_YEARS} anos consecutivos sem dados — parando.")
break
else:
consecutive_empty = 0
return total
# ---------------------------------------------------------------------------
# Main pipeline
# ---------------------------------------------------------------------------
def run_pipeline(
aerodrome: str,
dados_dir: Path = DADOS_DIR,
db_path: Path = DB_PATH,
preproc_dir: Path = PREPROC_DIR,
all_years: bool = True,
start_year: Optional[int] = None,
end_year: Optional[int] = None,
headless: bool = True,
n_samples: int = 20,
do_validate: bool = True,
do_cleanup: bool = True,
log: Callable[[str], None] = print,
progress: Callable[[float, str], None] = lambda pct, msg: None,
) -> dict:
"""Runs the full scrape → analytics → SQLite → validate → cleanup pipeline.
Args:
aerodrome: ICAO code to process (e.g. ``"SBGR"``).
dados_dir: Directory for temporary quarterly CSV files.
db_path: Path to the SQLite database file.
preproc_dir: Root directory for permanent analytics CSV backups.
Each aerodrome gets a sub-directory: ``<preproc_dir>/<aerodrome>/``.
all_years: When ``True``, scrape the full available history.
start_year: First year to scrape (used when *all_years* is ``False``).
end_year: Last year to scrape (used when *all_years* is ``False``).
headless: Run Chrome in headless mode (``True``) or visible (``False``).
n_samples: Number of spot-check samples for validation.
do_validate: Enable spot-check validation against source CSVs.
do_cleanup: Remove quarterly CSVs after a successful validation.
log: Logging callback receiving a single string.
progress: Progress callback receiving ``(fraction: float, message: str)``.
Returns:
Dict with keys ``rows``, ``period_start``, ``period_end``,
``n_ok``, ``n_fail``, ``errors``, ``db_path``.
"""
base = Path(dados_dir)
base.mkdir(parents=True, exist_ok=True)
db_path = Path(db_path)
# 1. Existing coverage ────────────────────────────────────────────────────
conn = _db.get_connection(db_path)
_db.ensure_schema(conn)
coverage = _db.get_coverage(conn, aerodrome)
if coverage:
ex_min, ex_max = coverage
log(f"\n[pipeline] Cobertura existente: {ex_min}{ex_max}")
else:
log(f"\n[pipeline] Sem dados anteriores para {aerodrome}.")
ex_min = ex_max = None
progress(_PROG_START, f"Iniciando scraping {aerodrome}")
# 2. Scraping ─────────────────────────────────────────────────────────────
driver = make_driver(headless=headless)
wait = WebDriverWait(driver, 60)
try:
if coverage:
_run_forward(driver, wait, aerodrome, ex_max, str(base), log)
progress(_PROG_FORWARD_DONE, f"Histórico {aerodrome}")
_run_backward(driver, wait, aerodrome, ex_min, all_years,
str(base), log, progress)
else:
log(f"\n[pipeline] === Coleta inicial: {date.today().year}{SITE_MIN_DATE.year} ===")
end_yr = end_year if not all_years else date.today().year
start_yr = start_year if not all_years else SITE_MIN_DATE.year
consec = 0
years = list(range(end_yr, start_yr - 1, -1))
for i, year in enumerate(years):
pct = _PROG_START + (_PROG_FORWARD_DONE + 0.30) * (i / max(len(years), 1))
progress(pct, f"Scraping {aerodrome} {year}")
log(f"\n{'=' * 55}\nAno {year}{aerodrome}\n{'=' * 55}")
n = scrape_year(driver, wait, aerodrome, year, str(base), log=log)
if n == 0:
consec += 1
log(f" [sem dados] ({consec}/{STOP_EMPTY_YEARS})")
if all_years and consec >= STOP_EMPTY_YEARS:
log(f"\n{STOP_EMPTY_YEARS} anos consecutivos — parando.")
break
else:
consec = 0
except KeyboardInterrupt:
log("\n[pipeline] Interrompido.")
finally:
driver.quit()
progress(_PROG_ANALYTICS, "Construindo analytics…")
# 3. Concat → analytics ───────────────────────────────────────────────────
new_files = [f for f in base.glob("Dados de Superfície*.csv") if f.is_file()]
if not new_files:
cov = _db.get_coverage(conn, aerodrome)
if cov:
s, e = str(cov[0]), str(cov[1])
stats = _db.aerodrome_stats(conn, aerodrome)
progress(_PROG_DONE, "Concluído")
return dict(rows=stats.get("n_obs", 0), period_start=s, period_end=e,
n_ok=0, n_fail=0, errors=[], db_path=str(db_path))
log("[pipeline] Nenhum dado disponível.")
return dict(rows=0, period_start="", period_end="",
n_ok=0, n_fail=0, errors=[], db_path=str(db_path))
_, merged = build_aerodrome_table(new_files, log=log)
if merged.empty:
log("[pipeline] Merged vazio.")
progress(_PROG_DONE, "Concluído")
return dict(rows=0, period_start="", period_end="",
n_ok=0, n_fail=0, errors=[], db_path=str(db_path))
anl = build_analytics(merged)
anl = anl.dropna(subset=["_dt"]).reset_index(drop=True)
progress(_PROG_UPSERT, "Inserindo no banco SQLite…")
# 4. Upsert into SQLite ───────────────────────────────────────────────────
n_upserted = _db.upsert_analytics(conn, aerodrome, anl)
log(f"\n[pipeline] {n_upserted} linhas upsertadas no SQLite ({db_path.name})")
# Write analytics CSV backup to tabelas/preproc/meteorologia_aeroportos/<ICAO>/
anl_dir = Path(preproc_dir) / aerodrome
anl_dir.mkdir(parents=True, exist_ok=True)
cov_final = _db.get_coverage(conn, aerodrome)
if cov_final:
s_str = str(cov_final[0]).replace("-", "_")
e_str = str(cov_final[1]).replace("-", "_")
csv_backup = anl_dir / f"{aerodrome}_{s_str}_{e_str}.csv"
for old in anl_dir.glob(f"{aerodrome}_*.csv"):
old.unlink()
anl_out = anl.copy()
anl_out["_dt"] = anl_out["_dt"].dt.strftime("%Y-%m-%d %H:%M:%S")
anl_out.to_csv(csv_backup, index=False, encoding="utf-8-sig")
log(f"[pipeline] Backup CSV: {csv_backup}")
progress(_PROG_VALIDATE, "Validando…")
# 5. Validation ───────────────────────────────────────────────────────────
n_ok = n_fail = 0
val_errors: list[str] = []
validated = True
if do_validate and new_files:
log(f"\n[pipeline] Validando {n_samples} amostras…")
n_ok, n_fail, val_errors = validate_sample(merged, new_files, n=n_samples, log=log)
log(f"[pipeline] Validação: {n_ok} OK | {n_fail} divergências")
for e in val_errors:
log(e)
if n_fail > 0:
validated = False
progress(_PROG_CLEANUP, "Cleanup…")
# 6. Cleanup ──────────────────────────────────────────────────────────────
if do_cleanup and new_files:
if validated:
log(f"\n[pipeline] Removendo {len(new_files)} CSVs trimestrais…")
cleanup_source_files(new_files, log=log)
else:
log("[pipeline] Validação com falhas — CSVs trimestrais mantidos.")
cov_now = _db.get_coverage(conn, aerodrome)
stats = _db.aerodrome_stats(conn, aerodrome)
conn.close()
progress(_PROG_DONE, "Concluído")
log("\n[pipeline] Pipeline finalizado.")
return dict(
rows = stats.get("n_obs", len(anl)),
period_start = str(cov_now[0]) if cov_now else "",
period_end = str(cov_now[1]) if cov_now else "",
n_ok = n_ok,
n_fail = n_fail,
errors = val_errors,
db_path = str(db_path),
)
# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------
def main() -> None:
"""Parses CLI arguments and runs the pipeline."""
try:
if hasattr(sys.stdout, "buffer"):
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
if hasattr(sys.stderr, "buffer"):
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
except Exception:
pass
parser = argparse.ArgumentParser(
description="Pipeline meteorologia ICEA/DECEA → SQLite",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
exemplos:
python pipeline.py --aerodrome SBGR --all-years
python pipeline.py --aerodrome SBGR --start-year 2020 --end-year 2025
python pipeline.py --aerodrome SBSP --all-years --no-headless --no-cleanup
""",
)
parser.add_argument("--aerodrome", default="SBGR", metavar="ICAO")
parser.add_argument("--all-years", action="store_true")
parser.add_argument("--start-year", type=int, metavar="ANO")
parser.add_argument("--end-year", type=int, metavar="ANO")
parser.add_argument("--dados-dir", default=str(DADOS_DIR),metavar="DIR",
help="Directory for temporary quarterly CSVs")
parser.add_argument("--db-path", default=str(DB_PATH), metavar="PATH",
help="Path to the SQLite database file")
parser.add_argument("--preproc-dir", default=str(PREPROC_DIR), metavar="DIR",
help="Root directory for permanent analytics CSV backups")
parser.add_argument("--no-headless", action="store_true")
parser.add_argument("--no-validate", action="store_true")
parser.add_argument("--n-samples", type=int, default=20)
parser.add_argument("--no-cleanup", action="store_true")
args = parser.parse_args()
if not args.all_years and (args.start_year is None or args.end_year is None):
parser.error("Use --all-years ou informe --start-year e --end-year")
result = run_pipeline(
aerodrome = args.aerodrome,
dados_dir = Path(args.dados_dir),
db_path = Path(args.db_path),
preproc_dir = Path(args.preproc_dir),
all_years = args.all_years,
start_year = args.start_year,
end_year = args.end_year,
headless = not args.no_headless,
n_samples = args.n_samples,
do_validate = not args.no_validate,
do_cleanup = not args.no_cleanup,
)
print(f"\n{'=' * 55}")
print(f" Linhas : {result['rows']}")
print(f" Período : {result['period_start']}{result['period_end']}")
print(f" Validação : {result['n_ok']} OK | {result['n_fail']} divergências")
print(f" Banco : {result['db_path']}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,605 @@
"""
Surface meteorology web scraper for the ICEA/DECEA data portal.
Source: https://pesquisa.icea.decea.mil.br/superficie_list/
Standalone usage:
python scraper_meteorologia.py --aerodrome SBGR --all-years
python scraper_meteorologia.py --aerodrome SBGR --start-year 2020 --end-year 2025
python scraper_meteorologia.py --aerodrome SBSP --start-year 2023 --end-year 2023 --no-headless
python scraper_meteorologia.py --fetch-catalog # writes aerodrome catalog to SQLite
Importable by the pipeline:
from scraper_meteorologia import make_driver, scrape_year, fetch_aerodrome_catalog, SITE_MIN_DATE
"""
import argparse
import calendar
import csv
import io
import os
import re
import sys
import time
from datetime import date
from pathlib import Path
from typing import Callable, Optional
import pandas as pd
from bs4 import BeautifulSoup
from selenium import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.ui import WebDriverWait
from webdriver_manager.chrome import ChromeDriverManager
# ── Site constants ────────────────────────────────────────────────────────────
BASE_URL = "https://pesquisa.icea.decea.mil.br/superficie_list/"
SITE_MIN_DATE = date(1947, 12, 1)
SITE_MAX_DATE = date.today() # dynamic: never beyond today
CHUNK_MONTHS = 3
STOP_EMPTY_YEARS = 2
# ── 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/
DADOS_DIR = _BASE_DIR / "db" / "dados" # temporary CSV output
DB_PATH = _BASE_DIR / "db" / "met.db" # SQLite database
def fetch_site_max_date(driver: webdriver.Chrome) -> date:
"""
Lê o atributo data-date-end-date do campo de período no site e atualiza
SITE_MAX_DATE globalmente. Retorna date.today() como fallback.
"""
global SITE_MAX_DATE
try:
val = driver.execute_script(
"var el = document.getElementById('dtPeriodo');"
"return el ? el.getAttribute('data-date-end-date') : null;"
)
if val:
d, m, y = val.strip().split("/")
SITE_MAX_DATE = date(int(y), int(m), int(d))
return SITE_MAX_DATE
except Exception:
pass
return SITE_MAX_DATE
TABS = [
("tab_cgt", "CGT"),
("tab_nuvem", "Nuvem"),
("tab_prec", "Precipitação"),
("tab_pres", "Pressão"),
("tab_rvr", "RVR"),
("tab_temp", "Temperatura"),
("tab_teto", "Teto"),
("tab_vent", "Vento"),
("tab_visib", "Visibilidade"),
]
# ---------------------------------------------------------------------------
# WebDriver
# ---------------------------------------------------------------------------
def make_driver(headless: bool = True) -> webdriver.Chrome:
"""Creates a configured Chrome WebDriver instance.
Downloads the matching ChromeDriver automatically via ``webdriver-manager``
if it is not already cached.
Args:
headless: Run Chrome without a visible window (``True``) or visibly
(``False``). Set to ``False`` for interactive debugging.
Returns:
A :class:`selenium.webdriver.Chrome` instance ready for use.
"""
opts = webdriver.ChromeOptions()
if headless:
opts.add_argument("--headless=new")
opts.add_argument("--no-sandbox")
opts.add_argument("--disable-dev-shm-usage")
opts.add_argument("--window-size=1920,1080")
opts.add_argument("--disable-blink-features=AutomationControlled")
opts.add_experimental_option("excludeSwitches", ["enable-automation"])
service = Service(ChromeDriverManager().install())
driver = webdriver.Chrome(service=service, options=opts)
driver.set_page_load_timeout(300)
driver.set_script_timeout(120)
try:
driver.command_executor.client_config.timeout = 300
except Exception:
pass
return driver
# ---------------------------------------------------------------------------
# Formulário
# ---------------------------------------------------------------------------
def select_aerodrome(driver: webdriver.Chrome, wait: WebDriverWait, code: str) -> str:
"""Seleciona aeródromo no Bootstrap Select (JS-first, fallback visual)."""
result = driver.execute_script(f"""
var candidates = document.querySelectorAll(
'select.selectpicker, select[name*="localidade"], select[name*="aerodrome"], select'
);
for (var s = 0; s < candidates.length; s++) {{
var sel = candidates[s];
for (var i = 0; i < sel.options.length; i++) {{
if (sel.options[i].text.indexOf('{code}') !== -1) {{
sel.selectedIndex = i;
var txt = sel.options[i].text;
if (typeof jQuery !== 'undefined') {{
try {{ jQuery(sel).selectpicker('val', sel.options[i].value); }} catch(e) {{}}
jQuery(sel).trigger('change');
}} else {{
sel.dispatchEvent(new Event('change', {{bubbles: true}}));
}}
return txt;
}}
}}
}}
return null;
""")
if result:
return result.strip()
btn = wait.until(EC.element_to_be_clickable(
(By.CSS_SELECTOR, "div.bootstrap-select > button.dropdown-toggle")
))
btn.click()
time.sleep(0.4)
try:
search = driver.find_element(By.CSS_SELECTOR, ".bootstrap-select .bs-searchbox input")
search.clear()
search.send_keys(code)
time.sleep(0.5)
except Exception:
pass
option_link = wait.until(EC.element_to_be_clickable((
By.XPATH,
f"//ul[contains(@class,'dropdown-menu inner')]"
f"//li[not(contains(@class,'hidden')) and not(contains(@class,'d-none'))]"
f"//span[contains(text(),'{code}')]/..",
)))
try:
text = option_link.find_element(By.CSS_SELECTOR, "span.text").text.strip()
except Exception:
text = code
option_link.click()
time.sleep(0.3)
return text
def fetch_aerodrome_catalog(headless: bool = True) -> list[dict]:
"""
Acessa o site ICEA e extrai a lista completa de aeródromos disponíveis.
Retorna lista de dicts com chaves 'icao', 'nome', 'uf'.
"""
driver = make_driver(headless=headless)
try:
driver.get(BASE_URL)
WebDriverWait(driver, 30).until(
EC.presence_of_element_located(
(By.CSS_SELECTOR, "select.selectpicker, select")
)
)
time.sleep(1.5) # aguarda Bootstrap Select renderizar todas as opções
raw_options = driver.execute_script("""
var candidates = document.querySelectorAll(
'select.selectpicker, select[name*="localidade"], select[name*="aerodrome"], select'
);
var result = [];
for (var s = 0; s < candidates.length; s++) {
var sel = candidates[s];
if (sel.options.length > 5) {
for (var i = 0; i < sel.options.length; i++) {
var txt = sel.options[i].text.trim();
if (txt) result.push(txt);
}
break;
}
}
return result;
""")
finally:
driver.quit()
catalog = []
for text in (raw_options or []):
if not text or len(text) < 4:
continue
icao = text[:4].upper()
if not re.match(r"[A-Z]{4}", icao):
continue
# formato típico: "SBGR - GUARULHOS / CUMBICA (SP)"
nome = text[4:].lstrip(" -–—").strip()
uf_match = re.search(r"\(([A-Z]{2})\)\s*$", nome)
uf = uf_match.group(1) if uf_match else ""
if uf_match:
nome = nome[: uf_match.start()].strip().rstrip("(),- ")
catalog.append({"icao": icao, "nome": nome, "uf": uf})
return catalog
def set_period(driver: webdriver.Chrome, start: date, end: date) -> None:
"""Sets the date-range picker on the ICEA portal using JavaScript.
Args:
driver: Active Chrome WebDriver instance on the ICEA portal page.
start: First day of the desired period.
end: Last day of the desired period.
"""
s = start.strftime("%d/%m/%Y")
e = end.strftime("%d/%m/%Y")
driver.execute_script(f"""
var el = document.getElementById('dtPeriodo');
if (!el) return;
if (typeof jQuery !== 'undefined' && jQuery(el).data('daterangepicker')) {{
jQuery(el).data('daterangepicker').setStartDate('{s}');
jQuery(el).data('daterangepicker').setEndDate('{e}');
}}
el.value = '{s} - {e}';
el.dispatchEvent(new Event('change', {{bubbles: true}}));
""")
# ---------------------------------------------------------------------------
# Extração de dados
# ---------------------------------------------------------------------------
def show_all_rows(driver: webdriver.Chrome, tab_id: str) -> None:
"""Expands the DataTable in *tab_id* to show all rows at once.
Tries the DataTables JS API first; falls back to the ``<select>`` length
control if jQuery/DataTables is not available.
Args:
driver: Active Chrome WebDriver instance.
tab_id: HTML ``id`` attribute of the tab ``<div>`` element.
"""
driver.execute_script(f"""
try {{
var dt = jQuery('#{tab_id} table').DataTable();
dt.page.len(-1).draw();
}} catch(e) {{
var sel = document.querySelector('#{tab_id} select[name$="_length"]');
if (sel) {{
var best = 0;
for (var i = 0; i < sel.options.length; i++) {{
var v = parseInt(sel.options[i].value);
if (v === -1 || v > parseInt(sel.options[best].value)) best = i;
}}
sel.selectedIndex = best;
sel.dispatchEvent(new Event('change', {{bubbles: true}}));
}}
}}
""")
time.sleep(1.2)
def parse_html_table(html: str) -> Optional[pd.DataFrame]:
"""Parses the first HTML ``<table>`` found in *html* into a DataFrame.
Args:
html: Raw HTML string containing a ``<table>`` element.
Returns:
DataFrame with headers from ``<thead>`` and rows from ``<tbody>``,
or ``None`` if no table or no data rows are found.
"""
soup = BeautifulSoup(html, "lxml")
table = soup.find("table")
if not table:
return None
headers = []
thead = table.find("thead")
if thead:
headers = [th.get_text(strip=True) for th in thead.find_all(["th", "td"])]
rows = []
tbody = table.find("tbody")
if tbody:
for tr in tbody.find_all("tr"):
cells = [td.get_text(strip=True) for td in tr.find_all(["th", "td"])]
if any(c.strip() for c in cells):
rows.append(cells)
if not rows:
return None
return pd.DataFrame(rows, columns=headers if headers else None)
def get_full_table(driver: webdriver.Chrome, tab_id: str) -> Optional[pd.DataFrame]:
"""Collects all DataTable pages from a tab and returns the concatenated DataFrame.
Handles pagination by iterating the "next" button until it is disabled.
Deduplicates rows that appear on multiple pages.
Args:
driver: Active Chrome WebDriver instance.
tab_id: HTML ``id`` of the tab ``<div>`` to read.
Returns:
Deduplicated DataFrame, or ``None`` if the tab contains no data rows.
"""
frames: list[pd.DataFrame] = []
seen: set[str] = set()
while True:
tab_elem = driver.find_element(By.ID, tab_id)
html = tab_elem.get_attribute("innerHTML")
fp = html[:800]
if fp in seen:
break
seen.add(fp)
df = parse_html_table(html)
if df is not None:
frames.append(df)
try:
next_btn = driver.find_element(
By.CSS_SELECTOR, f"#{tab_id} .dataTables_paginate .next"
)
if "disabled" in (next_btn.get_attribute("class") or ""):
break
driver.execute_script("arguments[0].click();", next_btn)
time.sleep(0.5)
except Exception:
break
if not frames:
return None
if len(frames) == 1:
return frames[0]
return pd.concat(frames, ignore_index=True).drop_duplicates()
def extract_tab(
driver: webdriver.Chrome,
wait: WebDriverWait,
tab_id: str,
) -> Optional[pd.DataFrame]:
"""Clicks the tab, waits for it to become active, and extracts its data.
Args:
driver: Active Chrome WebDriver instance.
wait: Configured :class:`WebDriverWait` bound to *driver*.
tab_id: HTML ``id`` of the target tab ``<div>``.
Returns:
DataFrame with the tab's data, or ``None`` if no rows are found.
"""
link = driver.find_element(By.CSS_SELECTOR, f"a[href='#{tab_id}']")
driver.execute_script("arguments[0].click();", link)
try:
wait.until(lambda d: "active" in (
d.find_element(By.ID, tab_id).get_attribute("class") or ""
))
except Exception:
time.sleep(1.5)
show_all_rows(driver, tab_id)
return get_full_table(driver, tab_id)
# ---------------------------------------------------------------------------
# Saída
# ---------------------------------------------------------------------------
def make_filename(tab_name: str, localidade: str, start: date, end: date) -> str:
"""Builds the standard output CSV filename for one tab and period.
Args:
tab_name: Display name of the meteorological tab (e.g. ``"Temperatura"``).
localidade: Aerodrome label returned by the site's selectbox.
start: First day of the scraped period.
end: Last day of the scraped period.
Returns:
Filename string without a directory component.
"""
safe = re.sub(r'[<>:"/\\|?*\r\n]', '', localidade).strip()
s = start.strftime("%d%m%Y")
e = end.strftime("%d%m%Y")
return f"Dados de Superfície {tab_name} - Localidade {safe} - Período {s} - {e}.csv"
def save_csv(df: pd.DataFrame, path: str) -> None:
"""Saves *df* to *path* as a UTF-8-BOM CSV with full quoting.
Args:
df: DataFrame to serialise.
path: Destination file path (created or overwritten).
"""
df.to_csv(path, index=False, encoding="utf-8-sig", quoting=csv.QUOTE_ALL)
# ---------------------------------------------------------------------------
# Lógica principal de scraping
# ---------------------------------------------------------------------------
def _quarter_chunks(year: int) -> list[tuple[date, date]]:
"""Divide o ano em chunks de CHUNK_MONTHS meses (do mais recente para o mais antigo)."""
chunks = []
month = 1
while month <= 12:
end_month = min(month + CHUNK_MONTHS - 1, 12)
last_day = calendar.monthrange(year, end_month)[1]
chunks.append((date(year, month, 1), date(year, end_month, last_day)))
month = end_month + 1
return list(reversed(chunks)) # Q4 → Q3 → Q2 → Q1
def scrape_period(
driver: webdriver.Chrome,
wait: WebDriverWait,
aerodrome: str,
start: date,
end: date,
output_dir: str,
log: Callable[[str], None] = print,
) -> bool:
"""
Faz o scraping de um único período e salva os CSVs das 9 abas.
Retorna True se pelo menos uma aba teve dados, False se tudo veio vazio.
"""
log(f" [{start.strftime('%d/%m/%Y')} -> {end.strftime('%d/%m/%Y')}]")
driver.get(BASE_URL)
wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, "div.bootstrap-select")))
fetch_site_max_date(driver) # atualiza SITE_MAX_DATE com o valor real do site
time.sleep(1.5)
localidade = select_aerodrome(driver, wait, aerodrome)
if not localidade:
log(f" ERR Aerodrome '{aerodrome}' nao encontrado.")
return False
log(f" Localidade: {localidade}")
set_period(driver, start, end)
submit = wait.until(EC.element_to_be_clickable((By.CSS_SELECTOR, "button[type='submit']")))
submit.click()
wait.until(EC.presence_of_element_located((By.CSS_SELECTOR, ".nav-tabs")))
time.sleep(3)
found_any = False
for tab_id, tab_name in TABS:
try:
df = extract_tab(driver, wait, tab_id)
if df is not None and not df.empty:
found_any = True
fname = make_filename(tab_name, localidade, start, end)
fpath = os.path.join(output_dir, fname)
save_csv(df, fpath)
log(f" OK {tab_name:<15s} {len(df):>6} linhas -> {fname}")
else:
log(f" -- {tab_name:<15s} sem dados")
except Exception as exc:
log(f" ERR {tab_name:<15s} {exc}")
return found_any
def scrape_year(
driver: webdriver.Chrome,
wait: WebDriverWait,
aerodrome: str,
year: int,
output_dir: str,
stop_before: Optional[date] = None,
log: Callable[[str], None] = print,
) -> int:
"""
Itera os chunks trimestrais do ano (do mais recente para o mais antigo).
Para se o trimestre for anterior a stop_before (data já coberta).
Retorna o número de trimestres que tiveram dados.
"""
non_empty = 0
for chunk_start, chunk_end in _quarter_chunks(year):
start = max(chunk_start, SITE_MIN_DATE)
end = min(chunk_end, SITE_MAX_DATE)
if start > end:
continue
# Para quando atingir dados já cobertos pelo arquivo existente
if stop_before is not None and end <= stop_before:
log(f" [trimestre {start.strftime('%d/%m/%Y')} -> {end.strftime('%d/%m/%Y')} já coberto, parando]")
break
had_data = scrape_period(driver, wait, aerodrome, start, end, output_dir, log)
if had_data:
non_empty += 1
return non_empty
# ---------------------------------------------------------------------------
# Entry point (CLI standalone)
# ---------------------------------------------------------------------------
def _fix_stdout() -> None:
"""Re-wraps stdout/stderr to UTF-8 on Windows terminals."""
try:
if hasattr(sys.stdout, "buffer"):
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
if hasattr(sys.stderr, "buffer"):
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
except Exception:
pass
def main() -> None:
_fix_stdout()
parser = argparse.ArgumentParser(
description="Scraper de meteorologia de superfície — ICEA/DECEA",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
exemplos:
python scraper_meteorologia.py --aerodrome SBGR --all-years
python scraper_meteorologia.py --aerodrome SBGR --start-year 2020 --end-year 2025
python scraper_meteorologia.py --aerodrome SBSP --start-year 2023 --end-year 2023 --no-headless
""",
)
parser.add_argument("--aerodrome", default="SBGR", metavar="ICAO",
help="Código ICAO do aeródromo (padrão: SBGR)")
parser.add_argument("--all-years", action="store_true",
help="Coleta todos os anos disponíveis (para automaticamente sem dados)")
parser.add_argument("--start-year", type=int, metavar="ANO",
help="Ano inicial (obrigatório sem --all-years)")
parser.add_argument("--end-year", type=int, metavar="ANO",
help="Ano final (obrigatório sem --all-years)")
parser.add_argument("--output-dir", default=str(DADOS_DIR), metavar="DIR",
help="Output directory for temporary quarterly CSVs")
parser.add_argument("--no-headless", action="store_true",
help="Open Chrome in visible mode (useful for debugging)")
parser.add_argument("--fetch-catalog", action="store_true",
help="Download aerodrome list from the ICEA site and save to SQLite")
parser.add_argument("--db-path", default=str(DB_PATH), metavar="PATH",
help="Path to the SQLite database (used with --fetch-catalog)")
args = parser.parse_args()
# ── fetch-catalog: ação independente ──────────────────────────────────────
if args.fetch_catalog:
from pathlib import Path
import db as _db
print("Acessando site ICEA para obter lista de aeródromos…")
catalog = fetch_aerodrome_catalog(headless=not args.no_headless)
print(f" {len(catalog)} aeródromos encontrados.")
db_path = Path(args.db_path)
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = _db.get_connection(db_path)
_db.ensure_schema(conn)
n = _db.upsert_aerodromes(conn, catalog)
conn.close()
print(f" {n} registros gravados em '{db_path}'.")
return
if not args.all_years and (args.start_year is None or args.end_year is None):
parser.error("Use --all-years ou informe --start-year e --end-year")
end_year = args.end_year if not args.all_years else date.today().year
start_year = args.start_year if not args.all_years else SITE_MIN_DATE.year
Path(args.output_dir).mkdir(parents=True, exist_ok=True)
driver = make_driver(headless=not args.no_headless)
wait = WebDriverWait(driver, 60)
try:
consecutive_empty = 0
for year in range(end_year, start_year - 1, -1): # do mais recente para o mais antigo
print(f"\n{'=' * 60}")
print(f"Ano {year}{args.aerodrome}")
print(f"{'=' * 60}")
non_empty = scrape_year(driver, wait, args.aerodrome, year, args.output_dir)
if non_empty == 0:
consecutive_empty += 1
print(f" [sem dados] ({consecutive_empty}/{STOP_EMPTY_YEARS} consecutivos)")
if args.all_years and consecutive_empty >= STOP_EMPTY_YEARS:
print(f"\n{STOP_EMPTY_YEARS} anos consecutivos sem dados — parando busca.")
break
else:
consecutive_empty = 0
except KeyboardInterrupt:
print("\nInterrompido pelo usuário.")
finally:
driver.quit()
print("\nFinalizado.")
if __name__ == "__main__":
main()