V.1.0
This commit is contained in:
@@ -8,8 +8,17 @@ Coleta, processa e visualiza dados meteorológicos horários de aeródromos bras
|
||||
|
||||
Clone o repositório, entre na pasta do módulo e execute:
|
||||
|
||||
**Windows**
|
||||
```bash
|
||||
cd softwares/test/meteorologia_aeroportos
|
||||
```
|
||||
|
||||
**Windows (PowerShell)**
|
||||
```powershell
|
||||
.\run\run.bat
|
||||
```
|
||||
|
||||
**Windows (CMD)**
|
||||
```cmd
|
||||
run\run.bat
|
||||
```
|
||||
|
||||
@@ -21,6 +30,8 @@ chmod +x run/run.sh && ./run/run.sh
|
||||
O script cria o ambiente virtual, instala as dependências e abre o dashboard em **http://localhost:8501**.
|
||||
|
||||
> **Pré-requisito:** Python 3.10+ e Google Chrome instalados.
|
||||
>
|
||||
> No PowerShell o `.\` antes do caminho é obrigatório para executar arquivos `.bat` por caminho relativo.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
457
softwares/test/meteorologia_aeroportos/_apps/collect_all.py
Normal file
457
softwares/test/meteorologia_aeroportos/_apps/collect_all.py
Normal file
@@ -0,0 +1,457 @@
|
||||
"""
|
||||
Bulk data collection for all aerodromes in the ICEA surface-data catalog.
|
||||
|
||||
Designed as a one-time migration script to bootstrap the database with the
|
||||
full available history for every aerodrome on the portal. A JSON status
|
||||
file (``db/collect_all_status.json``) tracks progress so interrupted runs
|
||||
can be resumed with ``--resume``.
|
||||
|
||||
Usage:
|
||||
# First time: fetch catalog, then collect everything
|
||||
python _apps/collect_all.py --fetch-catalog
|
||||
python _apps/collect_all.py
|
||||
|
||||
# Resume after interruption (skip aerodromes already with data)
|
||||
python _apps/collect_all.py --resume
|
||||
|
||||
# Force re-collection of all aerodromes
|
||||
python _apps/collect_all.py --force
|
||||
|
||||
# Specific aerodromes only
|
||||
python _apps/collect_all.py --aerodrome SBSP SBGL SBRJ
|
||||
|
||||
# Dry run / test (first 3 aerodromes only)
|
||||
python _apps/collect_all.py --max-aerodromes 3
|
||||
|
||||
# Visible Chrome (useful for debugging portal issues)
|
||||
python _apps/collect_all.py --no-headless --aerodrome SBSP
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Callable, Optional
|
||||
|
||||
# ── Paths ─────────────────────────────────────────────────────────────────────
|
||||
_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"
|
||||
DB_PATH = _BASE_DIR / "db" / "met.db"
|
||||
PREPROC_DIR = _REPO_ROOT / "tabelas" / "preproc" / "meteorologia_aeroportos"
|
||||
STATUS_FILE = _BASE_DIR / "db" / "collect_all_status.json"
|
||||
DEFAULT_LOG = _BASE_DIR / "db" / "collect_all.log"
|
||||
|
||||
|
||||
# ── Status helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
def _load_status() -> dict:
|
||||
"""Loads the JSON status file, returning an empty dict on any error."""
|
||||
if STATUS_FILE.exists():
|
||||
try:
|
||||
return json.loads(STATUS_FILE.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def _save_status(status: dict) -> None:
|
||||
"""Persists the status dict to the JSON status file.
|
||||
|
||||
Args:
|
||||
status: Dict mapping ICAO codes to their collection state.
|
||||
"""
|
||||
STATUS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
STATUS_FILE.write_text(
|
||||
json.dumps(status, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
# ── Logging ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _make_logger(log_path: Optional[Path]) -> Callable[[str], None]:
|
||||
"""Returns a logger that writes to stdout and optionally to a file.
|
||||
|
||||
Args:
|
||||
log_path: File to append log lines to, or ``None`` for stdout only.
|
||||
|
||||
Returns:
|
||||
A callable that accepts a single string message.
|
||||
"""
|
||||
def _log(msg: str) -> None:
|
||||
print(msg)
|
||||
if log_path:
|
||||
try:
|
||||
with log_path.open("a", encoding="utf-8") as f:
|
||||
f.write(msg + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
return _log
|
||||
|
||||
|
||||
# ── Catalog helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
def fetch_and_store_catalog(headless: bool = True, log: Callable = print) -> int:
|
||||
"""Downloads the aerodrome catalog from the ICEA site and upserts it.
|
||||
|
||||
Args:
|
||||
headless: Run Chrome in headless mode.
|
||||
log: Logging callback.
|
||||
|
||||
Returns:
|
||||
Number of aerodrome records stored.
|
||||
"""
|
||||
import db as _db
|
||||
from scraper_meteorologia import fetch_aerodrome_catalog
|
||||
|
||||
log("Atualizando catálogo do site ICEA…")
|
||||
catalog = fetch_aerodrome_catalog(headless=headless)
|
||||
log(f" {len(catalog)} aeródromos encontrados no portal.")
|
||||
|
||||
conn = _db.get_connection(DB_PATH)
|
||||
_db.ensure_schema(conn)
|
||||
n = _db.upsert_aerodromes(conn, catalog)
|
||||
conn.close()
|
||||
|
||||
log(f" {n} registros gravados no catálogo.")
|
||||
return n
|
||||
|
||||
|
||||
def _get_catalog() -> list[dict]:
|
||||
"""Returns all entries from the local aerodrome catalog table.
|
||||
|
||||
Returns:
|
||||
List of ``{"icao", "nome", "uf"}`` dicts.
|
||||
"""
|
||||
import db as _db
|
||||
conn = _db.get_connection(DB_PATH)
|
||||
_db.ensure_schema(conn)
|
||||
catalog = _db.list_all_aerodromes(conn)
|
||||
conn.close()
|
||||
return catalog
|
||||
|
||||
|
||||
def _aerodromes_with_data() -> set[str]:
|
||||
"""Returns the set of ICAO codes that have at least one observation row.
|
||||
|
||||
Returns:
|
||||
Set of ICAO code strings.
|
||||
"""
|
||||
import db as _db
|
||||
conn = _db.get_connection(DB_PATH)
|
||||
_db.ensure_schema(conn)
|
||||
aeros = set(_db.list_aerodromes(conn))
|
||||
conn.close()
|
||||
return aeros
|
||||
|
||||
|
||||
# ── Core collection loop ──────────────────────────────────────────────────────
|
||||
|
||||
def collect_all(
|
||||
target_aerodromes: Optional[list[str]] = None,
|
||||
resume: bool = True,
|
||||
force: bool = False,
|
||||
max_aerodromes: Optional[int] = None,
|
||||
headless: bool = True,
|
||||
n_samples: int = 5,
|
||||
do_cleanup: bool = True,
|
||||
log: Callable[[str], None] = print,
|
||||
on_start: Optional[Callable[[str, int, int, str], None]] = None,
|
||||
on_done: Optional[Callable[[str, str, dict], None]] = None,
|
||||
) -> dict:
|
||||
"""Collects full historical data for every aerodrome in the catalog.
|
||||
|
||||
For each aerodrome, the full pipeline (``all_years=True``) is run.
|
||||
Progress is saved to ``db/collect_all_status.json`` after every aerodrome
|
||||
so the run can be resumed if interrupted.
|
||||
|
||||
Args:
|
||||
target_aerodromes: Specific ICAO codes to process, or ``None`` for all.
|
||||
resume: Skip aerodromes that already have data in the database.
|
||||
force: Re-collect aerodromes even if they already have data.
|
||||
Overrides *resume*.
|
||||
max_aerodromes: Cap the number of aerodromes processed (useful for
|
||||
testing).
|
||||
headless: Run Chrome without a visible window.
|
||||
n_samples: Spot-check sample size per aerodrome (0 disables validation).
|
||||
do_cleanup: Remove intermediate quarterly CSVs after processing.
|
||||
log: Logging callback.
|
||||
on_start: Optional callback fired before each aerodrome starts.
|
||||
Signature: ``on_start(icao, idx, total, nome)``.
|
||||
on_done: Optional callback fired after each aerodrome finishes.
|
||||
Signature: ``on_done(icao, event, result)`` where *event* is one
|
||||
of ``"completed"``, ``"skipped"``, ``"failed"``,
|
||||
``"interrupted"``.
|
||||
|
||||
Returns:
|
||||
Summary dict with keys ``n_total``, ``n_done``, ``n_skipped``,
|
||||
``n_failed``, ``elapsed_s``.
|
||||
"""
|
||||
from pipeline import run_pipeline
|
||||
|
||||
status = _load_status()
|
||||
catalog = _get_catalog()
|
||||
|
||||
if not catalog:
|
||||
log(
|
||||
"[collect_all] Catálogo vazio.\n"
|
||||
" Execute: python _apps/collect_all.py --fetch-catalog"
|
||||
)
|
||||
return {"n_total": 0, "n_done": 0, "n_skipped": 0, "n_failed": 0, "elapsed_s": 0}
|
||||
|
||||
# Filter to requested aerodromes
|
||||
if target_aerodromes:
|
||||
icao_set = {a.upper() for a in target_aerodromes}
|
||||
catalog = [d for d in catalog if d["icao"] in icao_set]
|
||||
missing = icao_set - {d["icao"] for d in catalog}
|
||||
if missing:
|
||||
log(f"[collect_all] AVISO — não encontrados no catálogo: {sorted(missing)}")
|
||||
|
||||
if max_aerodromes:
|
||||
catalog = catalog[:max_aerodromes]
|
||||
|
||||
existing_data = _aerodromes_with_data() if (resume and not force) else set()
|
||||
|
||||
total = len(catalog)
|
||||
n_done = 0
|
||||
n_skip = 0
|
||||
n_fail = 0
|
||||
started = datetime.now(timezone.utc)
|
||||
|
||||
log(f"\n{'='*60}")
|
||||
log(f"collect_all — {total} aeródromos — {started.strftime('%Y-%m-%d %H:%M UTC')}")
|
||||
log(f"resume={resume} force={force} headless={headless} n_samples={n_samples}")
|
||||
log(f"{'='*60}\n")
|
||||
|
||||
for idx, aero_dict in enumerate(catalog, 1):
|
||||
icao = aero_dict["icao"]
|
||||
nome = aero_dict.get("nome", "")
|
||||
label = f"{icao} ({nome})" if nome else icao
|
||||
|
||||
log(f"\n[{idx:>4}/{total}] {label}")
|
||||
if on_start:
|
||||
on_start(icao, idx, total, nome)
|
||||
|
||||
# Resume logic: skip aerodromes that already have data
|
||||
if not force and icao in existing_data:
|
||||
log(f" SKIP dados existentes no banco")
|
||||
status[icao] = {"status": "skipped", "reason": "data_exists"}
|
||||
_save_status(status)
|
||||
if on_done:
|
||||
on_done(icao, "skipped", {})
|
||||
n_skip += 1
|
||||
continue
|
||||
|
||||
# Mark as in-progress
|
||||
status[icao] = {
|
||||
"status": "in_progress",
|
||||
"started_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
_save_status(status)
|
||||
|
||||
try:
|
||||
result = run_pipeline(
|
||||
aerodrome = icao,
|
||||
dados_dir = DADOS_DIR,
|
||||
db_path = DB_PATH,
|
||||
preproc_dir = PREPROC_DIR,
|
||||
all_years = True,
|
||||
headless = headless,
|
||||
n_samples = n_samples,
|
||||
do_validate = n_samples > 0,
|
||||
do_cleanup = do_cleanup,
|
||||
log = log,
|
||||
progress = lambda pct, msg: None,
|
||||
)
|
||||
rows = result.get("rows", 0)
|
||||
p_min = result.get("period_start", "")
|
||||
p_max = result.get("period_end", "")
|
||||
log(f" DONE {rows:,} observações | {p_min} → {p_max}")
|
||||
status[icao] = {
|
||||
"status": "completed",
|
||||
"rows": rows,
|
||||
"period_start": p_min,
|
||||
"period_end": p_max,
|
||||
"completed_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
if on_done:
|
||||
on_done(icao, "completed", result)
|
||||
n_done += 1
|
||||
|
||||
except KeyboardInterrupt:
|
||||
log("\n[collect_all] Interrompido pelo usuário.")
|
||||
status[icao]["status"] = "interrupted"
|
||||
_save_status(status)
|
||||
if on_done:
|
||||
on_done(icao, "interrupted", {})
|
||||
break
|
||||
|
||||
except Exception as exc:
|
||||
err = str(exc)[:300]
|
||||
log(f" ERRO {err}")
|
||||
status[icao] = {
|
||||
"status": "failed",
|
||||
"error": err,
|
||||
"failed_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
if on_done:
|
||||
on_done(icao, "failed", {"error": err})
|
||||
n_fail += 1
|
||||
|
||||
_save_status(status)
|
||||
|
||||
elapsed = (datetime.now(timezone.utc) - started).total_seconds()
|
||||
|
||||
log(f"\n{'='*60}")
|
||||
log(
|
||||
f"Resultado | total={total} done={n_done} "
|
||||
f"skipped={n_skip} failed={n_fail} "
|
||||
f"elapsed={elapsed / 3600:.1f}h"
|
||||
)
|
||||
log(f"Status gravado em: {STATUS_FILE}")
|
||||
log(f"{'='*60}\n")
|
||||
|
||||
return {
|
||||
"n_total": total,
|
||||
"n_done": n_done,
|
||||
"n_skipped": n_skip,
|
||||
"n_failed": n_fail,
|
||||
"elapsed_s": elapsed,
|
||||
}
|
||||
|
||||
|
||||
# ── Status report ─────────────────────────────────────────────────────────────
|
||||
|
||||
def print_status_report() -> None:
|
||||
"""Prints a human-readable summary of the collect_all status file."""
|
||||
status = _load_status()
|
||||
if not status:
|
||||
print("Nenhum status encontrado. Execute collect_all.py primeiro.")
|
||||
return
|
||||
|
||||
from collections import Counter
|
||||
counts: Counter = Counter(v.get("status", "unknown") for v in status.values())
|
||||
|
||||
print(f"\n=== collect_all status: {STATUS_FILE} ===\n")
|
||||
for state, n in sorted(counts.items()):
|
||||
print(f" {state:<15} {n:>5}")
|
||||
print(f" {'TOTAL':<15} {sum(counts.values()):>5}")
|
||||
|
||||
failed = [(k, v) for k, v in status.items() if v.get("status") == "failed"]
|
||||
if failed:
|
||||
print(f"\n Falhas ({len(failed)}):")
|
||||
for icao, info in failed:
|
||||
print(f" {icao}: {info.get('error','?')[:80]}")
|
||||
|
||||
|
||||
# ── CLI ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main() -> None:
|
||||
"""Parses arguments and runs the bulk collection or a status report."""
|
||||
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="Bulk ICEA aerodrome data collection (one-time migration)",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
examples:
|
||||
# Step 1 — populate catalog
|
||||
python _apps/collect_all.py --fetch-catalog
|
||||
|
||||
# Step 2 — collect all (skip aerodromes that already have data)
|
||||
python _apps/collect_all.py
|
||||
|
||||
# Resume after interruption
|
||||
python _apps/collect_all.py --resume
|
||||
|
||||
# Re-collect everything from scratch
|
||||
python _apps/collect_all.py --force
|
||||
|
||||
# Specific aerodromes only
|
||||
python _apps/collect_all.py --aerodrome SBSP SBGL SBRJ
|
||||
|
||||
# Test run: first 3 aerodromes, visible Chrome
|
||||
python _apps/collect_all.py --max-aerodromes 3 --no-headless
|
||||
|
||||
# Show collection status
|
||||
python _apps/collect_all.py --status
|
||||
""",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--fetch-catalog", action="store_true",
|
||||
help="Fetch/update aerodrome catalog from the ICEA site before collecting",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--aerodrome", nargs="+", metavar="ICAO",
|
||||
help="Collect only these ICAO codes (default: all catalog entries)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--resume", action="store_true", default=True,
|
||||
help="Skip aerodromes that already have data in the database (default: on)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-resume", dest="resume", action="store_false",
|
||||
help="Re-attempt aerodromes even if they already have data",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--force", action="store_true",
|
||||
help="Re-collect aerodromes even if they already have data (stronger than --no-resume)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-aerodromes", type=int, metavar="N",
|
||||
help="Limit processing to the first N catalog entries (testing)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-headless", action="store_true",
|
||||
help="Open Chrome in visible mode (useful for debugging)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--n-samples", type=int, default=5,
|
||||
help="Spot-check validation sample size per aerodrome (0 = skip, default: 5)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-cleanup", action="store_true",
|
||||
help="Keep intermediate quarterly CSVs after processing",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--log", metavar="PATH", default=str(DEFAULT_LOG),
|
||||
help=f"Log file path (default: {DEFAULT_LOG})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--status", action="store_true",
|
||||
help="Print a summary of the collect_all status file and exit",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.status:
|
||||
print_status_report()
|
||||
return
|
||||
|
||||
log = _make_logger(Path(args.log))
|
||||
|
||||
if args.fetch_catalog:
|
||||
fetch_and_store_catalog(headless=not args.no_headless, log=log)
|
||||
|
||||
collect_all(
|
||||
target_aerodromes = args.aerodrome,
|
||||
resume = args.resume,
|
||||
force = args.force,
|
||||
max_aerodromes = args.max_aerodromes,
|
||||
headless = not args.no_headless,
|
||||
n_samples = args.n_samples,
|
||||
do_cleanup = not args.no_cleanup,
|
||||
log = log,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1281,6 +1281,342 @@ with tab0:
|
||||
with st.expander("Divergências"):
|
||||
for e in r["errors"]: st.text(e)
|
||||
|
||||
# ── Atualização Total ─────────────────────────────────────────────────────
|
||||
st.divider()
|
||||
st.markdown("### 🌐 Atualização Total")
|
||||
st.markdown(
|
||||
"**O que faz:** verifica novos aeródromos no catálogo ICEA "
|
||||
"e baixa **apenas os dados novos** para cada aeródromo que já "
|
||||
"possui observações no banco. Recomendado a cada 2 meses.\n\n"
|
||||
"> **Nota:** aeródromos que ainda **não têm dados** no banco não são "
|
||||
"coletados aqui — apenas adicionados ao catálogo. "
|
||||
"Para coletar o histórico completo de todos os aeródromos, "
|
||||
"use o script `_apps/collect_all.py` (pode levar horas/dias)."
|
||||
)
|
||||
|
||||
_at_c1, _at_c2, _ = st.columns([1, 1, 3])
|
||||
with _at_c1:
|
||||
_at_headless = not st.checkbox("Mostrar Chrome", value=False, key="at_headless")
|
||||
with _at_c2:
|
||||
_at_validate = st.checkbox("Validar amostras", value=False, key="at_validate")
|
||||
|
||||
_at_submitted = st.button(
|
||||
"🌐 Iniciar Atualização Total",
|
||||
type="primary",
|
||||
help="Atualiza catálogo + coleta dados novos para todos os aeródromos com dados no banco",
|
||||
)
|
||||
|
||||
if _at_submitted:
|
||||
import db as _db_at
|
||||
|
||||
_at_conn_check = _db_at.get_connection(DB_PATH)
|
||||
_db_at.ensure_schema(_at_conn_check)
|
||||
_at_aeros = _db_at.list_aerodromes_with_data(_at_conn_check)
|
||||
_at_conn_check.close()
|
||||
|
||||
if not _at_aeros:
|
||||
st.warning(
|
||||
"Nenhum aeródromo com dados no banco. "
|
||||
"Use o formulário acima para coletar dados primeiro."
|
||||
)
|
||||
else:
|
||||
_at_n_total = len(_at_aeros)
|
||||
_at_q: queue.Queue = queue.Queue()
|
||||
_at_results: list[dict] = []
|
||||
_at_log_lines: list[str] = []
|
||||
|
||||
_at_prog_bar = st.progress(0.0)
|
||||
_at_stat_txt = st.empty()
|
||||
_at_log_box = st.empty()
|
||||
_at_table_box = st.empty()
|
||||
|
||||
_at_aero_list = [d["icao"] for d in _at_aeros]
|
||||
_at_aero_meta = {d["icao"]: d for d in _at_aeros}
|
||||
|
||||
def _at_worker() -> None:
|
||||
try:
|
||||
# Phase 1 — refresh aerodrome catalog
|
||||
_at_q.put(("status", "Fase 1 — Atualizando catálogo ICEA…"))
|
||||
from scraper_meteorologia import fetch_aerodrome_catalog as _fat
|
||||
import db as _dbi
|
||||
_new_cat = _fat(headless=_at_headless)
|
||||
_cat_conn = _dbi.get_connection(DB_PATH)
|
||||
_dbi.ensure_schema(_cat_conn)
|
||||
_n_new = _dbi.upsert_aerodromes(_cat_conn, _new_cat)
|
||||
_cat_conn.close()
|
||||
_at_q.put(("log",
|
||||
f"[catálogo] {len(_new_cat)} aeródromos no portal "
|
||||
f"| {_n_new} registros atualizados"
|
||||
))
|
||||
|
||||
# Phase 2 — forward-only update for each aerodrome
|
||||
for _i, _icao in enumerate(_at_aero_list):
|
||||
_at_q.put(("aero_start", _icao, _i))
|
||||
try:
|
||||
from pipeline import run_pipeline as _rp
|
||||
_res = _rp(
|
||||
aerodrome = _icao,
|
||||
dados_dir = DADOS_DIR,
|
||||
db_path = DB_PATH,
|
||||
preproc_dir = PREPROC_DIR,
|
||||
all_years = False,
|
||||
update_only = True,
|
||||
headless = _at_headless,
|
||||
n_samples = 5 if _at_validate else 0,
|
||||
do_validate = _at_validate,
|
||||
do_cleanup = True,
|
||||
log = lambda m: _at_q.put(("log", m)),
|
||||
progress = lambda p, m: None,
|
||||
)
|
||||
_at_q.put(("aero_done", _icao, _res))
|
||||
except Exception as _exc:
|
||||
_at_q.put(("aero_error", _icao, str(_exc)[:120]))
|
||||
|
||||
except Exception as _exc:
|
||||
_at_q.put(("log", f"ERRO: {_exc}"))
|
||||
finally:
|
||||
_at_q.put(("done",))
|
||||
|
||||
_at_t = threading.Thread(target=_at_worker, daemon=True)
|
||||
_at_t.start()
|
||||
|
||||
_at_n_done = 0
|
||||
|
||||
while _at_t.is_alive() or not _at_q.empty():
|
||||
try:
|
||||
_item = _at_q.get(timeout=0.4)
|
||||
except queue.Empty:
|
||||
continue
|
||||
|
||||
if _item[0] == "done":
|
||||
break
|
||||
|
||||
elif _item[0] == "status":
|
||||
_at_stat_txt.caption(_item[1])
|
||||
|
||||
elif _item[0] == "log":
|
||||
_at_log_lines.append(_item[1])
|
||||
_at_log_box.code("\n".join(_at_log_lines[-60:]))
|
||||
|
||||
elif _item[0] == "aero_start":
|
||||
_, _icao, _i = _item
|
||||
_at_prog_bar.progress(_i / max(_at_n_total, 1))
|
||||
_at_stat_txt.caption(
|
||||
f"Fase 2 — {_icao} ({_i + 1} / {_at_n_total})"
|
||||
)
|
||||
_meta = _at_aero_meta.get(_icao, {})
|
||||
_at_results.append({
|
||||
"ICAO": _icao,
|
||||
"Nome": _meta.get("nome", ""),
|
||||
"UF": _meta.get("uf", ""),
|
||||
"Status": "⏳ Coletando…",
|
||||
"Novas linhas": "—",
|
||||
})
|
||||
_at_table_box.dataframe(
|
||||
_at_results, use_container_width=True, hide_index=True
|
||||
)
|
||||
|
||||
elif _item[0] == "aero_done":
|
||||
_, _icao, _res = _item
|
||||
_at_n_done += 1
|
||||
_new = _res.get("n_upserted", 0)
|
||||
for _row in _at_results:
|
||||
if _row["ICAO"] == _icao:
|
||||
_row["Status"] = "✅ Concluído"
|
||||
_row["Novas linhas"] = f"{_new:,}"
|
||||
break
|
||||
_at_table_box.dataframe(
|
||||
_at_results, use_container_width=True, hide_index=True
|
||||
)
|
||||
|
||||
elif _item[0] == "aero_error":
|
||||
_, _icao, _err = _item
|
||||
for _row in _at_results:
|
||||
if _row["ICAO"] == _icao:
|
||||
_row["Status"] = "❌ Erro"
|
||||
_row["Novas linhas"] = _err[:50]
|
||||
break
|
||||
_at_table_box.dataframe(
|
||||
_at_results, use_container_width=True, hide_index=True
|
||||
)
|
||||
|
||||
_at_t.join()
|
||||
_at_prog_bar.progress(1.0)
|
||||
st.cache_data.clear()
|
||||
_at_stat_txt.empty()
|
||||
st.success(
|
||||
f"✅ Atualização Total concluída — "
|
||||
f"{_at_n_done} / {_at_n_total} aeródromos atualizados."
|
||||
)
|
||||
st.rerun()
|
||||
|
||||
# ── Coleta Total — Histórico Completo ─────────────────────────────────────
|
||||
st.divider()
|
||||
st.markdown("### 🗄️ Coleta Total — Histórico Completo")
|
||||
st.markdown(
|
||||
"Coleta o **histórico completo** de todos os aeródromos do catálogo "
|
||||
"que ainda **não têm dados** no banco. "
|
||||
"Aeródromos com dados são ignorados automaticamente.\n\n"
|
||||
"> ⚠️ Esta operação pode demorar **horas ou dias** dependendo do número "
|
||||
"de aeródromos. Não feche o dashboard durante a coleta. "
|
||||
"Em caso de interrupção, basta clicar novamente — os aeródromos já "
|
||||
"coletados serão pulados automaticamente."
|
||||
)
|
||||
|
||||
# Status summary
|
||||
if _db_available():
|
||||
import db as _db_ct_info
|
||||
_ct_conn_info = _db_ct_info.get_connection(DB_PATH)
|
||||
_db_ct_info.ensure_schema(_ct_conn_info)
|
||||
_ct_catalog = _db_ct_info.list_all_aerodromes(_ct_conn_info)
|
||||
_ct_with_data = set(_db_ct_info.list_aerodromes(_ct_conn_info))
|
||||
_ct_conn_info.close()
|
||||
_ct_total = len(_ct_catalog)
|
||||
_ct_done = len(_ct_with_data)
|
||||
_ct_pending = _ct_total - _ct_done
|
||||
else:
|
||||
_ct_total = _ct_done = _ct_pending = 0
|
||||
|
||||
_ct_info_col1, _ct_info_col2, _ct_info_col3 = st.columns(3)
|
||||
_ct_info_col1.metric("No catálogo", _ct_total)
|
||||
_ct_info_col2.metric("Com dados", _ct_done)
|
||||
_ct_info_col3.metric("Aguardando coleta", _ct_pending)
|
||||
|
||||
_ct_opt1, _ct_opt2, _ct_opt3 = st.columns([1, 1, 2])
|
||||
with _ct_opt1:
|
||||
_ct_headless = not st.checkbox("Mostrar Chrome", value=False, key="ct_headless")
|
||||
with _ct_opt2:
|
||||
_ct_max = int(st.number_input(
|
||||
"Limite de aeródromos (0 = todos)",
|
||||
min_value=0, max_value=200, value=0, step=1, key="ct_max",
|
||||
help="Útil para testes. 0 processa todos os pendentes.",
|
||||
))
|
||||
|
||||
if _ct_pending == 0 and _ct_total > 0:
|
||||
st.info("✅ Todos os aeródromos do catálogo já têm dados no banco.")
|
||||
elif _ct_total == 0:
|
||||
st.warning(
|
||||
"Catálogo vazio. Clique em **Atualizar catálogo ICEA** (acima) para "
|
||||
"baixar a lista de aeródromos disponíveis."
|
||||
)
|
||||
|
||||
_ct_submitted = st.button(
|
||||
"🗄️ Iniciar Coleta Total",
|
||||
type="primary",
|
||||
disabled=(_ct_pending == 0),
|
||||
help="Coleta o histórico completo para os aeródromos sem dados no banco",
|
||||
)
|
||||
|
||||
if _ct_submitted and _ct_pending > 0:
|
||||
_ct_q: queue.Queue = queue.Queue()
|
||||
_ct_results: list[dict] = []
|
||||
_ct_log_lines: list[str] = []
|
||||
|
||||
_ct_prog_bar = st.progress(0.0)
|
||||
_ct_stat_txt = st.empty()
|
||||
_ct_log_box = st.empty()
|
||||
_ct_table_box = st.empty()
|
||||
|
||||
# Build metadata dict for display
|
||||
_ct_meta = {d["icao"]: d for d in _ct_catalog}
|
||||
_ct_max_arg = _ct_max if _ct_max > 0 else None
|
||||
|
||||
def _ct_worker() -> None:
|
||||
try:
|
||||
from collect_all import collect_all as _ca
|
||||
|
||||
def _ct_on_start(icao: str, idx: int, total: int, nome: str) -> None:
|
||||
_ct_q.put(("aero_start", icao, idx, total, nome))
|
||||
|
||||
def _ct_on_done(icao: str, event: str, result: dict) -> None:
|
||||
_ct_q.put(("aero_done", icao, event, result))
|
||||
|
||||
_ca(
|
||||
resume = True,
|
||||
max_aerodromes = _ct_max_arg,
|
||||
headless = _ct_headless,
|
||||
n_samples = 0,
|
||||
do_cleanup = True,
|
||||
log = lambda m: _ct_q.put(("log", m)),
|
||||
on_start = _ct_on_start,
|
||||
on_done = _ct_on_done,
|
||||
)
|
||||
except Exception as _exc:
|
||||
_ct_q.put(("log", f"ERRO: {_exc}"))
|
||||
finally:
|
||||
_ct_q.put(("done",))
|
||||
|
||||
_ct_t = threading.Thread(target=_ct_worker, daemon=True)
|
||||
_ct_t.start()
|
||||
|
||||
_ct_n_done = 0
|
||||
_ct_n_total_run = _ct_max_arg or _ct_pending
|
||||
|
||||
while _ct_t.is_alive() or not _ct_q.empty():
|
||||
try:
|
||||
_citem = _ct_q.get(timeout=0.4)
|
||||
except queue.Empty:
|
||||
continue
|
||||
|
||||
if _citem[0] == "done":
|
||||
break
|
||||
|
||||
elif _citem[0] == "log":
|
||||
_ct_log_lines.append(_citem[1])
|
||||
_ct_log_box.code("\n".join(_ct_log_lines[-80:]))
|
||||
|
||||
elif _citem[0] == "aero_start":
|
||||
_, _icao, _idx, _tot, _nome = _citem
|
||||
_ct_prog_bar.progress((_idx - 1) / max(_tot, 1))
|
||||
_ct_stat_txt.caption(f"Coletando {_icao} — {_idx} / {_tot}")
|
||||
_meta = _ct_meta.get(_icao, {})
|
||||
_ct_results.append({
|
||||
"ICAO": _icao,
|
||||
"Nome": _meta.get("nome", _nome),
|
||||
"UF": _meta.get("uf", ""),
|
||||
"Status": "⏳ Coletando…",
|
||||
"Período": "—",
|
||||
"Linhas": "—",
|
||||
})
|
||||
_ct_table_box.dataframe(
|
||||
_ct_results, use_container_width=True, hide_index=True
|
||||
)
|
||||
|
||||
elif _citem[0] == "aero_done":
|
||||
_, _icao, _event, _res = _citem
|
||||
_status_map = {
|
||||
"completed": "✅ Concluído",
|
||||
"skipped": "⏭️ Já tinha dados",
|
||||
"failed": "❌ Erro",
|
||||
"interrupted": "⏸️ Interrompido",
|
||||
}
|
||||
_st = _status_map.get(_event, _event)
|
||||
for _row in _ct_results:
|
||||
if _row["ICAO"] == _icao:
|
||||
_row["Status"] = _st
|
||||
if _event == "completed":
|
||||
_ct_n_done += 1
|
||||
_row["Período"] = (
|
||||
f"{_res.get('period_start','')} → "
|
||||
f"{_res.get('period_end','')}"
|
||||
)
|
||||
_row["Linhas"] = f"{_res.get('rows', 0):,}"
|
||||
elif _event == "failed":
|
||||
_row["Período"] = _res.get("error", "")[:40]
|
||||
break
|
||||
_ct_table_box.dataframe(
|
||||
_ct_results, use_container_width=True, hide_index=True
|
||||
)
|
||||
|
||||
_ct_t.join()
|
||||
_ct_prog_bar.progress(1.0)
|
||||
st.cache_data.clear()
|
||||
_ct_stat_txt.empty()
|
||||
st.success(
|
||||
f"✅ Coleta Total concluída — {_ct_n_done} aeródromos coletados."
|
||||
)
|
||||
st.rerun()
|
||||
|
||||
# ── Visão Geral ───────────────────────────────────────────────────────────────
|
||||
with tab1:
|
||||
st.plotly_chart(chart_overview(anl_r), use_container_width=True)
|
||||
|
||||
@@ -26,6 +26,8 @@ from typing import Callable, Optional
|
||||
import pandas as pd
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
|
||||
import sqlite3
|
||||
|
||||
import db as _db
|
||||
from scraper_meteorologia import (
|
||||
SITE_MIN_DATE,
|
||||
@@ -281,6 +283,52 @@ def _run_backward(
|
||||
return total
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CSV backup helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _write_csv_backup(
|
||||
conn: sqlite3.Connection,
|
||||
aerodrome: str,
|
||||
preproc_dir: Path,
|
||||
log: Callable[[str], None],
|
||||
) -> None:
|
||||
"""Writes the complete analytics history for *aerodrome* to its CSV backup.
|
||||
|
||||
Always queries the **full** history from the database so the backup reflects
|
||||
the entire dataset, regardless of how much data was collected in this run.
|
||||
Deletes any previous backup file before writing the updated one.
|
||||
|
||||
Args:
|
||||
conn: Open SQLite connection.
|
||||
aerodrome: ICAO code (e.g. ``"SBGR"``).
|
||||
preproc_dir: Root directory for permanent analytics CSV backups.
|
||||
log: Logging callback.
|
||||
"""
|
||||
cov = _db.get_coverage(conn, aerodrome)
|
||||
if not cov:
|
||||
return
|
||||
|
||||
anl_dir = preproc_dir / aerodrome
|
||||
anl_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
s_str = str(cov[0]).replace("-", "_")
|
||||
e_str = str(cov[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_full = _db.query_analytics(conn, aerodrome)
|
||||
if anl_full.empty:
|
||||
return
|
||||
|
||||
anl_out = anl_full.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.name} ({len(anl_full):,} linhas)")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -297,6 +345,7 @@ def run_pipeline(
|
||||
n_samples: int = 20,
|
||||
do_validate: bool = True,
|
||||
do_cleanup: bool = True,
|
||||
update_only: bool = False,
|
||||
log: Callable[[str], None] = print,
|
||||
progress: Callable[[float, str], None] = lambda pct, msg: None,
|
||||
) -> dict:
|
||||
@@ -315,6 +364,9 @@ def run_pipeline(
|
||||
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.
|
||||
update_only: When ``True``, skip the backward (historical) pass and only
|
||||
download data newer than the latest existing record. Aerodromes
|
||||
without any existing data are skipped entirely.
|
||||
log: Logging callback receiving a single string.
|
||||
progress: Progress callback receiving ``(fraction: float, message: str)``.
|
||||
|
||||
@@ -347,9 +399,12 @@ def run_pipeline(
|
||||
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)
|
||||
if not update_only:
|
||||
progress(_PROG_FORWARD_DONE, f"Histórico {aerodrome}")
|
||||
_run_backward(driver, wait, aerodrome, ex_min, all_years,
|
||||
str(base), log, progress)
|
||||
elif update_only:
|
||||
log(f"[pipeline] {aerodrome}: sem dados existentes e update_only=True — ignorado.")
|
||||
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
|
||||
@@ -386,11 +441,18 @@ def run_pipeline(
|
||||
if cov:
|
||||
s, e = str(cov[0]), str(cov[1])
|
||||
stats = _db.aerodrome_stats(conn, aerodrome)
|
||||
# Regenerate CSV backup if it is missing (e.g. after a partial run)
|
||||
anl_dir = Path(preproc_dir) / aerodrome
|
||||
csv_exists = anl_dir.is_dir() and bool(list(anl_dir.glob(f"{aerodrome}_*.csv")))
|
||||
if not csv_exists:
|
||||
log("[pipeline] CSV de backup ausente — regenerando do banco…")
|
||||
_write_csv_backup(conn, aerodrome, Path(preproc_dir), log)
|
||||
progress(_PROG_DONE, "Concluído")
|
||||
return dict(rows=stats.get("n_obs", 0), period_start=s, period_end=e,
|
||||
return dict(rows=stats.get("n_obs", 0), n_upserted=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="",
|
||||
return dict(rows=0, n_upserted=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)
|
||||
@@ -398,7 +460,7 @@ def run_pipeline(
|
||||
if merged.empty:
|
||||
log("[pipeline] Merged vazio.")
|
||||
progress(_PROG_DONE, "Concluído")
|
||||
return dict(rows=0, period_start="", period_end="",
|
||||
return dict(rows=0, n_upserted=0, period_start="", period_end="",
|
||||
n_ok=0, n_fail=0, errors=[], db_path=str(db_path))
|
||||
|
||||
anl = build_analytics(merged)
|
||||
@@ -410,20 +472,8 @@ def run_pipeline(
|
||||
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}")
|
||||
# Write analytics CSV backup — full history from DB, not just the new records
|
||||
_write_csv_backup(conn, aerodrome, Path(preproc_dir), log)
|
||||
|
||||
progress(_PROG_VALIDATE, "Validando…")
|
||||
|
||||
@@ -460,6 +510,7 @@ def run_pipeline(
|
||||
|
||||
return dict(
|
||||
rows = stats.get("n_obs", len(anl)),
|
||||
n_upserted = n_upserted,
|
||||
period_start = str(cov_now[0]) if cov_now else "",
|
||||
period_end = str(cov_now[1]) if cov_now else "",
|
||||
n_ok = n_ok,
|
||||
@@ -507,6 +558,9 @@ exemplos:
|
||||
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")
|
||||
parser.add_argument("--update-only", action="store_true",
|
||||
help="Only download data newer than the last existing record "
|
||||
"(skip backward/historical pass)")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.all_years and (args.start_year is None or args.end_year is None):
|
||||
@@ -524,6 +578,7 @@ exemplos:
|
||||
n_samples = args.n_samples,
|
||||
do_validate = not args.no_validate,
|
||||
do_cleanup = not args.no_cleanup,
|
||||
update_only = args.update_only,
|
||||
)
|
||||
|
||||
print(f"\n{'=' * 55}")
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"SBAA": {
|
||||
"status": "in_progress",
|
||||
"started_at": "2026-06-02T11:28:18.831671+00:00"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
BIN
softwares/test/meteorologia_aeroportos/db/met.db-shm
Normal file
BIN
softwares/test/meteorologia_aeroportos/db/met.db-shm
Normal file
Binary file not shown.
File diff suppressed because it is too large
Load Diff
447980
tabelas/preproc/meteorologia_aeroportos/SBAN/SBAN_1973_11_01_2026_05_31.csv
Normal file
447980
tabelas/preproc/meteorologia_aeroportos/SBAN/SBAN_1973_11_01_2026_05_31.csv
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user