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,22 @@
MIT License
Copyright (c) 2026 Sérgio Rebouças — ITA/IED (Instituto Tecnológico de Aeronáutica /
Instituto de Estudos para o Desenvolvimento)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,328 +1,143 @@
# Dataset IED — Meteorologia e Transporte Aéreo Brasileiro
# Meteorologia de Superfície — Aeródromos Brasileiros
Plataforma de coleta, processamento e visualização de dados meteorológicos e de transporte aéreo brasileiro, com foco em aeroportos operados pelo sistema DECEA/ICEA.
Coleta, processa e visualiza dados meteorológicos horários de aeródromos brasileiros a partir do portal de dados de superfície do ICEA/DECEA.
---
## Sumário
## Início rápido
- [Pré-requisitos](#pré-requisitos)
- [Instalação](#instalação)
- [Estrutura do Projeto](#estrutura-do-projeto)
- [Módulos e Uso](#módulos-e-uso)
- [Scraper de Meteorologia](#1-scraper-de-meteorologia)
- [Pipeline Completo](#2-pipeline-completo)
- [Concatenação de CSVs](#3-concatenação-de-csvs)
- [Dashboard Interativo](#4-dashboard-interativo)
- [Diagnóstico de Outliers](#5-diagnóstico-de-outliers)
- [Banco de Dados](#banco-de-dados)
- [Categorias ICAO](#categorias-icao)
- [Solução de Problemas](#solução-de-problemas)
Clone o repositório, entre na pasta do módulo e execute:
---
## Pré-requisitos
| Requisito | Versão mínima | Observação |
|-----------|--------------|------------|
| Python | 3.10+ | [python.org](https://www.python.org/downloads/) |
| Google Chrome | Qualquer versão recente | Necessário para o Selenium (scraper) |
| Git | Qualquer | Para clonar o repositório |
> O `webdriver-manager` baixa automaticamente o ChromeDriver compatível com a versão do Chrome instalada. Não é necessário instalar o ChromeDriver manualmente.
---
## Instalação
### 1. Clonar o repositório
**Windows**
```
run\run.bat
```
**Linux / macOS**
```bash
git clone ssh://git@git.ppgao.ita.br:2222/reboucassr/dataset.git
cd dataset
chmod +x run/run.sh && ./run/run.sh
```
> Caso não tenha acesso SSH configurado, solicite ao administrador a chave de acesso ou use HTTPS se disponível.
O script cria o ambiente virtual, instala as dependências e abre o dashboard em **http://localhost:8501**.
### 2. Criar e ativar o ambiente virtual
Navegue até o diretório do módulo de meteorologia:
```bash
cd softwares/test
```
**Windows (PowerShell):**
```powershell
python -m venv .venv # Instala o ambiente virtual no Windows
.\.venv\Scripts\Activate.ps1 # Ativa o ambiente virtual
```
**Linux / macOS:**
```bash
python3 -m venv .venv
source .venv/bin/activate # Para Linux e macOS
```
> No Windows, se receber erro de política de execução, execute antes:
> `Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser`
### 3. Instalar dependências
Com o ambiente virtual ativado:
```PowerShell
pip install -r requirements.txt
```
Principais pacotes instalados:
| Pacote | Finalidade |
|--------|-----------|
| `selenium` | Automação do navegador para scraping |
| `webdriver-manager` | Gerencia o ChromeDriver automaticamente |
| `beautifulsoup4` + `lxml` | Parsing de HTML |
| `pandas` | Processamento e análise de dados |
| `streamlit` | Dashboard interativo |
| `plotly` | Gráficos interativos |
> **Pré-requisito:** Python 3.10+ e Google Chrome instalados.
---
### 4. Executar o projeto
Com o ambiente virtual ativado:
```PowerShell
cd meteorologia_aeroportos
streamlit run dashboard.py
```
> O dashboard será executado na porta 8501.
## Estrutura do Projeto
## Estrutura
```
dataset/
├── softwares/
│ └── test/
│ └── meteorologia_aeroportos/
│ ├── dados/ # Banco SQLite + CSVs por aeródromo
│ ├── met.db # Banco de dados principal
│ ├── SBGR/ # CSVs do aeroporto de Guarulhos
│ └── SBAN/ # CSVs de outros aeródromos
├── scraper_meteorologia.py # Coleta dados do ICEA/DECEA
├── pipeline.py # Orquestrador completo
├── concat_meteorologia.py # Mescla arquivos CSV
│ ├── db.py # Lógica do banco SQLite
├── dashboard.py # Dashboard Streamlit
├── _diag_outliers.py # Diagnóstico de qualidade
│ └── requirements.txt
├── tabelas/
│ ├── raw/ # Dados brutos ANAC
├── preproc/ # Dados intermediários
└── proc/ # Dados processados finais
└── textos/
├── artigos/ # Artigos acadêmicos
├── livros/
└── relatorios/ # Relatórios ANAC e setor
meteorologia_aeroportos/
├── README.md este arquivo
├── LICENSE MIT License
├── docs/ documentação técnica e autoria
├── _apps/ módulos Python
├── dashboard.py dashboard interativo (Streamlit)
├── pipeline.py orquestrador completo
├── scraper_meteorologia.py coleta de dados via web
├── concat_meteorologia.py concatenação de CSVs
├── db.py camada SQLite
└── _diag_outliers.py diagnóstico de qualidade
├── run/
├── run.bat inicialização Windows
└── run.sh inicialização Linux/macOS
├── env/
│ └── requirements.txt
└── db/
├── met.db banco SQLite principal
└── dados/ CSVs temporários (removidos após coleta)
```
CSVs analíticos permanentes ficam fora da pasta do módulo:
```
dataset/tabelas/preproc/meteorologia_aeroportos/<ICAO>/
```
---
## Módulos e Uso
## Uso via linha de comando
> Todos os comandos abaixo devem ser executados a partir do diretório `softwares/test/meteorologia_aeroportos/`, com o ambiente virtual ativado.
Todos os comandos são executados a partir da pasta `meteorologia_aeroportos/`.
### 1. Scraper de Meteorologia
Coleta dados de superfície horários do portal ICEA/DECEA para um dado aeródromo.
**Coletar todos os anos disponíveis:**
**Coletar histórico completo de um aeródromo:**
```bash
python scraper_meteorologia.py --aerodrome SBGR --all-years
python _apps/pipeline.py --aerodrome SBGR --all-years
```
**Coletar um intervalo de anos específico:**
**Coletar um intervalo de anos:**
```bash
python scraper_meteorologia.py --aerodrome SBGR --start-year 2020 --end-year 2025
python _apps/pipeline.py --aerodrome SBGR --start-year 2020 --end-year 2025
```
**Executar com navegador visível (sem modo headless — útil para depuração):**
**Abrir o dashboard:**
```bash
python scraper_meteorologia.py --aerodrome SBGR --start-year 2023 --end-year 2023 --no-headless
streamlit run _apps/dashboard.py
```
**Parâmetros disponíveis:**
**Diagnóstico de qualidade do banco:**
```bash
python _apps/_diag_outliers.py
```
| Parâmetro | Tipo | Descrição |
|-----------|------|-----------|
| `--aerodrome` | string | Código ICAO do aeródromo (ex: `SBGR`, `SBSP`, `SBAN`) |
| `--all-years` | flag | Coleta desde o início do histórico disponível |
| `--start-year` | int | Ano inicial do intervalo |
| `--end-year` | int | Ano final do intervalo |
| `--no-headless` | flag | Abre o Chrome de forma visível |
Os arquivos CSV são salvos em `dados/{CÓDIGO_ICAO}/`.
| Parâmetro | Descrição |
|-----------|-----------|
| `--aerodrome` | Código ICAO (ex: `SBGR`, `SBSP`) |
| `--all-years` | Coleta todo o histórico disponível |
| `--start-year` / `--end-year` | Intervalo de anos |
| `--no-headless` | Abre o Chrome de forma visível (debug) |
| `--no-cleanup` | Mantém CSVs trimestrais intermediários |
---
### 2. Pipeline Completo
## Banco de dados
Orquestra todo o fluxo: verifica cobertura existente, executa scraping incremental, valida dados com limites físicos, calcula variáveis analíticas e persiste no banco SQLite.
`db/met.db` — SQLite com três tabelas:
**Processar todos os anos para um aeródromo:**
```bash
python pipeline.py --aerodrome SBGR --all-years
```
**Processar intervalo específico:**
```bash
python pipeline.py --aerodrome SBGR --start-year 2020 --end-year 2025
```
**Manter arquivos temporários após o processamento:**
```bash
python pipeline.py --aerodrome SBGR --all-years --no-cleanup
```
**Parâmetros disponíveis:**
| Parâmetro | Tipo | Descrição |
|-----------|------|-----------|
| `--aerodrome` | string | Código ICAO do aeródromo |
| `--all-years` | flag | Processa todo o histórico disponível |
| `--start-year` | int | Ano inicial |
| `--end-year` | int | Ano final |
| `--no-cleanup` | flag | Mantém CSVs intermediários após upsert |
> **Recomendado para uso normal.** O pipeline é idempotente: pode ser executado múltiplas vezes sem duplicar dados.
---
### 3. Concatenação de CSVs
Mescla os arquivos CSV trimestrais baixados pelo scraper em uma tabela única por aeródromo.
```bash
python concat_meteorologia.py --dados-dir dados --validate --cleanup
```
**Parâmetros:**
| Parâmetro | Tipo | Descrição |
|-----------|------|-----------|
| `--dados-dir` | string | Diretório raiz dos CSVs (padrão: `dados`) |
| `--validate` | flag | Aplica validação por limites físicos |
| `--cleanup` | flag | Remove arquivos temporários após concatenação |
> Este passo é executado automaticamente pelo `pipeline.py`. Use-o de forma isolada apenas se precisar reprocessar os CSVs sem coletar novos dados.
---
### 4. Dashboard Interativo
Interface web para análise e visualização das séries temporais meteorológicas.
```bash
streamlit run dashboard.py
```
Acesse no navegador: **http://localhost:8501**
**Funcionalidades do dashboard:**
- Seleção de aeródromo e variável meteorológica
- Reamostagem temporal: Horária, Diária, Semanal, Mensal
- Gráficos de série temporal interativos (Plotly)
- Rosa dos ventos
- Análise de teto e visibilidade com classificação ICAO
- Distribuição estatística por variável
---
### 5. Diagnóstico de Outliers
Analisa a qualidade dos dados no banco, exibindo estatísticas sobre valores tratados por limites físicos.
```bash
python _diag_outliers.py
```
Exibe contagens e proporções de outliers registrados na tabela `outlier_log` por variável meteorológica.
---
## Banco de Dados
O banco SQLite (`dados/met.db`) possui três tabelas:
| Tabela | Descrição |
|--------|-----------|
| `observations` | Série temporal horária com 10 variáveis meteorológicas |
| `outlier_log` | Registro de valores anômalos tratados (auditoria) |
| `aerodromes` | Catálogo de aeródromos disponíveis |
| Tabela | Conteúdo |
|--------|----------|
| `observations` | Série temporal horária (10 variáveis por observação) |
| `outlier_log` | Auditoria de valores tratados por limites físicos |
| `aerodromes` | Catálogo de aeródromos disponíveis no portal ICEA |
**Variáveis na tabela `observations`:**
| Coluna | Descrição | Unidade |
|--------|-----------|---------|
| `T` | Temperatura | °C |
| `T` | Temperatura do bulbo seco | °C |
| `Td` | Ponto de orvalho | °C |
| `UR` | Umidade relativa | % |
| `QNH` | Pressão atmosférica | hPa |
| `QNH` | Pressão atmosférica ao nível do mar | hPa |
| `WS` | Velocidade do vento | kt |
| `WG` | Rajada de vento | kt |
| `WD` | Direção do vento | graus |
| `VIS` | Visibilidade | dam |
| `WG` | Rajada máxima de vento | kt |
| `WD` | Direção do vento | ° |
| `VIS` | Visibilidade predominante | dam |
| `TETO` | Teto de nuvens | dam |
| `PREC` | Precipitação acumulada | mm |
---
## Categorias ICAO
O dashboard classifica as condições meteorológicas segundo os critérios ICAO:
## Categorias ICAO de condições de voo
| Categoria | Teto (dam) | Visibilidade (dam) | Condição |
|-----------|------------|-------------------|----------|
| **LIFR** | < 31 | < 16 | IFR Severo |
| **IFR** | 3191 | 1648 | Voo por instrumentos |
| **MVFR** | 91300 | 48800 | VFR Marginal |
| **VMC** | > 300 | > 800 | Condições visuais |
| **LIFR** | < 15 | < 50 | IFR Severo |
| **IFR** | 1530 | 50100 | Voo por instrumentos |
| **MVFR** | 30100 | 100300 | VFR Marginal |
| **VMC** | > 100 | > 300 | Condições visuais |
---
## Solução de Problemas
## Documentação e autoria
**Erro de ChromeDriver / Selenium:**
- Verifique se o Google Chrome está instalado na máquina.
- O `webdriver-manager` tentará baixar o ChromeDriver automaticamente; verifique se há acesso à internet.
- Em ambientes corporativos com proxy, pode ser necessário configurar `HTTP_PROXY` / `HTTPS_PROXY`.
**Erro ao ativar o ambiente virtual no Windows:**
```powershell
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
```
**Dashboard não abre / porta ocupada:**
```bash
streamlit run dashboard.py --server.port 8502
```
**Banco de dados não encontrado:**
- Execute o `pipeline.py` ao menos uma vez para criar e popular o banco `dados/met.db`.
**Dados ausentes para um aeródromo:**
- Verifique se o código ICAO está correto e disponível no portal ICEA: https://pesquisa.icea.decea.mil.br/superficie_list/
- Nem todos os aeródromos possuem histórico completo desde 1947.
- [`docs/about.md`](docs/about.md) — descrição técnica detalhada, limitações e oportunidades de melhoria
- [`docs/authors.md`](docs/authors.md) — autoria, licença e guia para contribuições futuras
- [`docs/changelog.md`](docs/changelog.md) — histórico de versões por autor
---
## Fonte dos Dados
## Fonte dos dados
- **Meteorologia:** Portal ICEA/DECEA — https://pesquisa.icea.decea.mil.br/superficie_list/
- **Transporte Aéreo:** ANAC — Relatório Anual 2024 e séries históricas de demanda e oferta
Portal ICEA/DECEA — Dados de Superfície:
**https://pesquisa.icea.decea.mil.br/superficie_list/**
---
*Projeto de pesquisa acadêmica — IED / ITA*
*IED — Instituto de Estudos para o Desenvolvimento · ITA — Instituto Tecnológico de Aeronáutica*

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

@@ -1,11 +1,14 @@
"""
Concatena os CSVs de dados de superfície (por aba/trimestre) numa única tabela por aeródromo.
Inclui validação amostral e limpeza opcional dos arquivos intermediários.
CSV aggregator for ICEA/DECEA surface meteorology data.
Uso standalone:
python concat_meteorologia.py [--dados-dir dados] [--validate] [--n-samples 20] [--cleanup]
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.
Importável pelo pipeline:
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
"""
@@ -20,6 +23,15 @@ 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"
@@ -46,13 +58,38 @@ _FNAME_RE = re.compile(
# ---------------------------------------------------------------------------
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):
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
@@ -169,6 +206,15 @@ def build_aerodrome_table(
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")
@@ -325,12 +371,27 @@ def cleanup_source_files(
# ---------------------------------------------------------------------------
def main(
dados_dir: str = "dados",
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()]
@@ -352,10 +413,10 @@ def main(
log(f"Aeródromo : {aero} ({len(afiles)} arquivo(s))")
log(f"{'=' * 64}")
# Verifica se já existe arquivo compilado (para append incremental)
compiled_dir = base / aero
# Check for an existing compiled file (incremental append)
aero_compiled = Path(compiled_dir) / aero
extra_df: Optional[pd.DataFrame] = None
existing = sorted(compiled_dir.glob(f"{aero}_*.csv")) if compiled_dir.is_dir() else []
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")
@@ -367,9 +428,9 @@ def main(
continue
start_str, end_str = date_range_from_data(df)
compiled_dir.mkdir(exist_ok=True)
aero_compiled.mkdir(parents=True, exist_ok=True)
out_name = f"{aero}_{start_str}_{end_str}.csv"
out_path = compiled_dir / out_name
out_path = aero_compiled / out_name
df.to_csv(out_path, index=False, encoding="utf-8-sig", quoting=csv.QUOTE_ALL)
@@ -378,7 +439,6 @@ def main(
log(f" Periodo : {start_str.replace('_', '-')} -> {end_str.replace('_', '-')}")
log(f" Arquivo : {out_path}")
# Remove arquivos compilados anteriores (com datas diferentes)
for old in existing:
if old.name != out_name:
old.unlink()
@@ -417,42 +477,44 @@ if __name__ == "__main__":
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
except Exception:
pass
ap = argparse.ArgumentParser(description="Concatena CSVs de superfície por aeródromo")
ap.add_argument("--dados-dir", default="dados", metavar="DIR")
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 CSVs trimestrais após compilação validada")
ap.add_argument("--db", action="store_true",
help="Salva analytics no SQLite (dados/met.db) além do CSV")
args = ap.parse_args()
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=args.dados_dir,
do_validate=args.validate,
n_samples=args.n_samples,
do_cleanup=args.cleanup,
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:
if _args.db and result_df is not None:
import db as _db
from pathlib import Path
db_path = Path(args.dados_dir) / "met.db"
from pipeline import build_analytics as _build
db_path = DB_PATH
conn = _db.get_connection(db_path)
_db.ensure_schema(conn)
# Detecta aeródromo pelo nome da primeira coluna analítica presente
# (reutiliza build_analytics do pipeline para converter)
from pipeline import build_analytics
anl = build_analytics(result_df)
# Tenta inferir aerodrome dos arquivos presentes
import re as _re
_RE = _re.compile(r"Dados de Superfície .+ - Localidade (\w+) -")
aeros = set()
for f in Path(args.dados_dir).glob("Dados de Superfície*.csv"):
m = _RE.match(f.name)
if m:
aeros.add(m.group(1))
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()

View File

@@ -1,9 +1,23 @@
"""
Dashboard Meteorológico Aeroportuário ICEA/DECEA
Série temporal de superfície · Climatologia · Análise operacional
Interactive meteorological dashboard for Brazilian aerodromes ICEA/DECEA.
Iniciar: streamlit run dashboard.py
Displays hourly surface observation time-series, climatological distributions,
ICAO flight-category analysis, and an integrated data-collection form.
Tabs:
📥 Coleta trigger the scraping pipeline from the UI
📈 Visão Geral four-panel overview (T, wind, visibility, QNH)
🌡 Temperatura temperature, dew point, relative humidity
💨 Vento wind speed/gust time-series and wind rose
🌧 Pressão e Precipitação QNH and rainfall charts
👁 Visibilidade e Teto VIS/ceiling with ICAO category shading
📅 Climatologia monthly boxplots and summary table
📋 Dados scrollable data table with CSV export
Usage:
streamlit run dashboard.py
"""
import queue
import re
import threading
@@ -18,13 +32,25 @@ import plotly.graph_objects as go
from plotly.subplots import make_subplots
import streamlit as st
# ── Config ──────────────────────────────────────────────────────────────────
st.set_page_config(page_title="MET Aeroportuário", page_icon="🛬",
layout="wide", initial_sidebar_state="expanded")
# ── Page config (must be first Streamlit call) ────────────────────────────────
st.set_page_config(
page_title="MET Aeroportuário",
page_icon="🛬",
layout="wide",
initial_sidebar_state="expanded",
)
DADOS_DIR = Path(__file__).parent / "dados"
DADOS_DIR.mkdir(exist_ok=True)
DB_PATH = DADOS_DIR / "met.db"
# ── Paths (resolved relative to this file, independent of CWD) ───────────────
_APPS_DIR = Path(__file__).resolve().parent # .../meteorologia_aeroportos/_apps/
_BASE_DIR = _APPS_DIR.parent # .../meteorologia_aeroportos/
_REPO_ROOT = _BASE_DIR.parents[2] # dataset/
DADOS_DIR = _BASE_DIR / "db" / "dados" # temporary files (scraper)
DB_PATH = _BASE_DIR / "db" / "met.db" # SQLite database
PREPROC_DIR = (
_REPO_ROOT / "tabelas" / "preproc" / "meteorologia_aeroportos"
) # permanent analytics CSV backups
DADOS_DIR.mkdir(parents=True, exist_ok=True)
# ── Paleta ───────────────────────────────────────────────────────────────────
C = dict(
@@ -68,6 +94,21 @@ AGG_RULES = dict(T="mean", Td="mean", UR="mean", QNH="mean",
WS="mean", WG="max", WD="mean",
VIS="min", TETO="min", PREC="sum")
# ── Chart heights (pixels) ────────────────────────────────────────────────────
H_OVERVIEW = 700
H_TEMP = 500
H_TEMP_BOX = 380
H_WIND_SPEED = 380
H_WIND_ROSE = 500
H_WIND_DIR = 340
H_PRESSURE = 360
H_PRECIP = 500
H_VISIBILITY = 420
H_CEILING = 420
H_ICAO_BARS = 340
H_PRECIP_BOX = 380
H_CLIM_BOX = 370
_COMPILED_RE = re.compile(r"(\w+)_(\d{4}_\d{2}_\d{2})_(\d{4}_\d{2}_\d{2})\.csv")
# ── CSS ──────────────────────────────────────────────────────────────────────
@@ -130,11 +171,59 @@ section[data-testid="stSidebarContent"] .stMarkdown{color:#e0f2f1!important}
# ── Utilitários de dados: SQLite-first, fallback CSV ─────────────────────────
def _db_available() -> bool:
"""Returns True when the SQLite database file exists."""
return DB_PATH.exists()
def _migrate_legacy_paths() -> None:
"""Silently moves data files from older layouts to the current one.
Handles two legacy layouts, applying whichever migration is needed:
* v1 (original): ``dados/met.db`` and ``dados/<ICAO>/<ICAO>_*.csv``
* v2 (mid-2026): ``met.db`` at the module root (before the _apps/ reorganisation)
Safe to call on every startup does nothing when files are already in the
expected locations.
"""
# v1 → current: dados/met.db (old flat layout)
v1_db = _BASE_DIR / "dados" / "met.db"
if v1_db.exists() and not DB_PATH.exists():
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
v1_db.rename(DB_PATH)
st.info("Banco migrado de dados/met.db → db/met.db.")
# v2 → current: met.db at _BASE_DIR root (pre-_apps/ reorganisation)
v2_db = _BASE_DIR / "met.db"
if v2_db.exists() and not DB_PATH.exists():
DB_PATH.parent.mkdir(parents=True, exist_ok=True)
v2_db.rename(DB_PATH)
st.info("Banco migrado de met.db → db/met.db.")
# v1 → current: analytics CSVs in dados/<ICAO>/
v1_dados = _BASE_DIR / "dados"
if not v1_dados.is_dir():
return
for aero_dir in sorted(v1_dados.iterdir()):
if not aero_dir.is_dir():
continue
csv_files = list(aero_dir.glob(f"{aero_dir.name}_*.csv"))
if not csv_files:
continue
dest = PREPROC_DIR / aero_dir.name
dest.mkdir(parents=True, exist_ok=True)
moved = 0
for f in csv_files:
target = dest / f.name
if not target.exists():
f.rename(target)
moved += 1
if moved:
st.info(f"CSVs de {aero_dir.name} migrados para tabelas/preproc/…")
def list_aerodromes() -> list[str]:
"""Retorna aeródromos disponíveis (SQLite-first, fallback CSV)."""
"""Returns ICAO codes of aerodromes that have data (SQLite-first, CSV fallback)."""
if _db_available():
import db as _db
conn = _db.get_connection(DB_PATH)
@@ -143,10 +232,10 @@ def list_aerodromes() -> list[str]:
conn.close()
if aeros:
return sorted(aeros)
# Fallback CSV
result = []
if DADOS_DIR.is_dir():
for d in sorted(DADOS_DIR.iterdir()):
# Fallback: scan permanent analytics CSV directory
result: list[str] = []
if PREPROC_DIR.is_dir():
for d in sorted(PREPROC_DIR.iterdir()):
if not d.is_dir():
continue
for f in sorted(d.glob(f"{d.name}_*.csv")):
@@ -157,7 +246,14 @@ def list_aerodromes() -> list[str]:
def list_catalog() -> list[dict]:
"""Retorna catálogo completo da tabela aerodromes. Fallback: só aeros com dados."""
"""Returns the full aerodrome catalog (all entries, with or without data).
Used by the data-collection form so operators can select any aerodrome.
Returns:
List of ``{"icao", "nome", "uf"}`` dicts from the ``aerodromes`` table,
or a minimal list derived from :func:`list_aerodromes` as a fallback.
"""
if _db_available():
import db as _db
conn = _db.get_connection(DB_PATH)
@@ -169,6 +265,27 @@ def list_catalog() -> list[dict]:
return [{"icao": a, "nome": "", "uf": ""} for a in list_aerodromes()]
def list_catalog_with_data() -> list[dict]:
"""Returns only aerodromes that have observations in the database.
Used by the sidebar so users only see options they can actually analyse.
Returns:
List of ``{"icao", "nome", "uf"}`` dicts from the ``observations`` table
(joined with the catalog for display metadata). Falls back to
:func:`list_catalog` when no database is available.
"""
if _db_available():
import db as _db
conn = _db.get_connection(DB_PATH)
_db.ensure_schema(conn)
result = _db.list_aerodromes_with_data(conn)
conn.close()
if result:
return result
return list_catalog()
def _fmt_aero(d: dict) -> str:
"""Formata dict de aeródromo para exibição no selectbox."""
nome = d.get("nome", "")
@@ -201,7 +318,15 @@ def _save_csv_to_folder(df: pd.DataFrame, aerodrome: str,
def get_period(aerodrome: str) -> tuple[str, str]:
"""Retorna (inicio, fim) dos dados disponíveis para o aeródromo."""
"""Returns the ``(start, end)`` date strings for available data.
Args:
aerodrome: ICAO code to look up.
Returns:
``("YYYY-MM-DD", "YYYY-MM-DD")`` strings, or ``("", "")`` if no data
is available.
"""
if _db_available():
import db as _db
conn = _db.get_connection(DB_PATH)
@@ -209,10 +334,10 @@ def get_period(aerodrome: str) -> tuple[str, str]:
conn.close()
if cov:
return str(cov[0]), str(cov[1])
# Fallback CSV
d = DADOS_DIR / aerodrome
if d.is_dir():
for f in sorted(d.glob(f"{aerodrome}_*.csv")):
# Fallback: scan permanent analytics CSV directory
csv_dir = PREPROC_DIR / aerodrome
if csv_dir.is_dir():
for f in sorted(csv_dir.glob(f"{aerodrome}_*.csv")):
m = _COMPILED_RE.match(f.name)
if m:
return m.group(2).replace("_", "-"), m.group(3).replace("_", "-")
@@ -233,8 +358,8 @@ def load_analytics(aerodrome: str, start_str: str, end_str: str) -> pd.DataFrame
if not df.empty:
return df
# Fallback: lê o CSV de analytics (backup gerado pelo pipeline)
csv_dir = DADOS_DIR / aerodrome
# Fallback: read permanent analytics CSV backup
csv_dir = PREPROC_DIR / aerodrome
if csv_dir.is_dir():
for f in sorted(csv_dir.glob(f"{aerodrome}_*.csv")):
if _COMPILED_RE.match(f.name):
@@ -315,6 +440,16 @@ def build_analytics(raw: pd.DataFrame) -> pd.DataFrame:
def resample_anl(anl: pd.DataFrame, freq: Optional[str]) -> pd.DataFrame:
"""Resamples the analytics DataFrame to the requested frequency.
Args:
anl: Analytics DataFrame with a ``_dt`` datetime column.
freq: Pandas offset alias (e.g. ``"D"``, ``"W"``, ``"ME"``), or
``None`` to return the data unchanged.
Returns:
Resampled (or unchanged) DataFrame.
"""
if freq is None:
return anl.copy()
rules = {k: v for k, v in AGG_RULES.items() if k in anl.columns}
@@ -323,32 +458,83 @@ def resample_anl(anl: pd.DataFrame, freq: Optional[str]) -> pd.DataFrame:
# ── ICAO helpers ─────────────────────────────────────────────────────────────
def _icao_cat(vis: float, teto: float) -> str:
if np.isnan(vis) or np.isnan(teto): return ""
"""Returns the ICAO flight category for the given visibility and ceiling.
Args:
vis: Visibility in dam.
teto: Ceiling in dam.
Returns:
One of ``"LIFR"``, ``"IFR"``, ``"MVFR"``, ``"VMC"``, or ``""`` when
either value is NaN.
"""
if np.isnan(vis) or np.isnan(teto):
return ""
if vis < 50 or teto < 15: return "LIFR"
if vis < 100 or teto < 30: return "IFR"
if vis < 300 or teto < 100: return "MVFR"
return "VMC"
def pct_icao(anl: pd.DataFrame) -> dict:
df = anl[["VIS","TETO"]].dropna()
if df.empty: return {}
def pct_icao(anl: pd.DataFrame) -> dict[str, float]:
"""Returns percentage of time in each ICAO flight category.
Args:
anl: Analytics DataFrame with ``VIS`` and ``TETO`` columns.
Returns:
Dict mapping category name to percentage (0100).
"""
df = anl[["VIS", "TETO"]].dropna()
if df.empty:
return {}
cats = df.apply(lambda r: _icao_cat(r.VIS, r.TETO), axis=1)
return (cats.value_counts() / len(cats) * 100).to_dict()
def badge(cat: str) -> str:
cls = {"VMC":"badge-vmc","MVFR":"badge-mvfr","IFR":"badge-ifr","LIFR":"badge-lifr"}.get(cat,"")
"""Returns an HTML badge span for the given ICAO category.
Args:
cat: Category code (``"VMC"``, ``"MVFR"``, ``"IFR"``, or ``"LIFR"``).
Returns:
HTML ``<span>`` string with the appropriate CSS class.
"""
cls = {
"VMC": "badge-vmc", "MVFR": "badge-mvfr",
"IFR": "badge-ifr", "LIFR": "badge-lifr",
}.get(cat, "")
return f'<span class="badge {cls}">{cat}</span>'
# ── Helpers de gráfico ────────────────────────────────────────────────────────
def _rng_btns():
return [dict(count=7, label="7d", step="day", stepmode="backward"),
dict(count=1, label="1m", step="month", stepmode="backward"),
dict(count=6, label="6m", step="month", stepmode="backward"),
dict(count=1, label="1a", step="year", stepmode="backward"),
dict(step="all", label="Tudo")]
# ── Chart helpers ─────────────────────────────────────────────────────────────
def _rng_btns() -> list[dict]:
"""Returns the standard time-range selector button list for Plotly."""
return [
dict(count=7, label="7d", step="day", stepmode="backward"),
dict(count=1, label="1m", step="month", stepmode="backward"),
dict(count=6, label="6m", step="month", stepmode="backward"),
dict(count=1, label="1a", step="year", stepmode="backward"),
dict(step="all", label="Tudo"),
]
def _base_layout(fig, h=420, title="", legend_h=True):
def _base_layout(
fig: go.Figure,
h: int = H_VISIBILITY,
title: str = "",
legend_h: bool = True,
) -> None:
"""Applies standard layout settings to *fig* in-place.
Args:
fig: Plotly Figure to update.
h: Chart height in pixels.
title: Optional chart title.
legend_h: Show legend horizontally below the chart (``True``) or hide
it (``False``).
"""
kw = dict(orientation="h", y=-0.18, x=0, font=dict(size=11)) if legend_h else dict(visible=False)
fig.update_layout(template=TEMPLATE, height=h, title=title,
margin=dict(l=55,r=20,t=48,b=48),
@@ -357,7 +543,22 @@ def _base_layout(fig, h=420, title="", legend_h=True):
fig.update_xaxes(showgrid=True, gridcolor="#f0f0f0", zeroline=False)
fig.update_yaxes(showgrid=True, gridcolor="#f0f0f0", zeroline=False)
def _hrect(fig, thresholds, row=1, col=1, ymax=9999):
def _hrect(
fig: go.Figure,
thresholds: list[tuple],
row: int = 1,
col: int = 1,
ymax: float = 9999,
) -> None:
"""Adds ICAO category shaded horizontal bands to *fig*.
Args:
fig: Plotly Figure (must support ``add_hrect``).
thresholds: List of ``(name, lo, hi, fill_color, font_color)`` tuples.
row: Subplot row (1-based).
col: Subplot column (1-based).
ymax: Upper bound of the visible axis range (caps the top band).
"""
for name, lo, hi, fill, clr in thresholds:
y1 = min(hi, ymax) if hi else ymax
if lo >= ymax: continue
@@ -373,6 +574,14 @@ def _hrect(fig, thresholds, row=1, col=1, ymax=9999):
# ══════════════════════════════════════════════════════════════════════════════
def chart_overview(anl_r: pd.DataFrame) -> go.Figure:
"""Four-panel overview: temperature, wind, visibility, QNH.
Args:
anl_r: Resampled analytics DataFrame.
Returns:
Plotly Figure with 4 subplots sharing the x-axis.
"""
dt = anl_r["_dt"]
fig = make_subplots(rows=4, cols=1, shared_xaxes=True,
subplot_titles=("Temperatura (°C)","Vento (kt)","Visibilidade (dam)","Pressão QNH (hPa)"),
@@ -399,13 +608,21 @@ def chart_overview(anl_r: pd.DataFrame) -> go.Figure:
fig.update_xaxes(row=i, showgrid=True, gridcolor="#f0f0f0")
fig.update_yaxes(row=i, showgrid=True, gridcolor="#f0f0f0")
fig.update_xaxes(row=4, rangeselector=dict(buttons=_rng_btns()))
fig.update_layout(template=TEMPLATE, height=700, showlegend=False,
fig.update_layout(template=TEMPLATE, height=H_OVERVIEW, showlegend=False,
hovermode="x unified", margin=dict(l=55,r=20,t=50,b=40),
plot_bgcolor="white")
return fig
def chart_temp(anl_r: pd.DataFrame) -> go.Figure:
"""Temperature, dew point and relative humidity subplots.
Args:
anl_r: Resampled analytics DataFrame.
Returns:
Plotly Figure with 2 subplots (T/Td on top, UR below).
"""
dt = anl_r["_dt"]
T = anl_r.get("T", pd.Series(dtype=float))
Td = anl_r.get("Td", pd.Series(dtype=float))
@@ -432,7 +649,7 @@ def chart_temp(anl_r: pd.DataFrame) -> go.Figure:
fig.add_trace(go.Scatter(x=dt, y=UR, name="UR (%)", fill="tozeroy",
fillcolor=C["hum_fill"], line=dict(color=C["humidity"],width=1.8)), 2, 1)
fig.update_yaxes(range=[0,105], row=2)
_base_layout(fig, h=500)
_base_layout(fig, h=H_TEMP)
fig.update_xaxes(row=2, rangeselector=dict(buttons=_rng_btns()))
return fig
@@ -448,13 +665,21 @@ def chart_temp_boxplot(anl: pd.DataFrame) -> Optional[go.Figure]:
fig.add_trace(go.Box(y=vals.values, name=MONTHS_PT[m-1], boxpoints=False,
marker_color=C["temp"], line_color=C["temp"],
fillcolor="rgba(211,47,47,.15)", showlegend=False))
fig.update_layout(template=TEMPLATE, height=380,
fig.update_layout(template=TEMPLATE, height=H_TEMP_BOX,
title="Distribuição Mensal de Temperatura",
yaxis_title="°C", margin=dict(l=55,r=20,t=50,b=40))
return fig
def chart_wind_speed(anl_r: pd.DataFrame) -> go.Figure:
"""Wind speed and gust time-series chart.
Args:
anl_r: Resampled analytics DataFrame.
Returns:
Plotly Figure showing WS (mean) and WG (gust) as filled areas.
"""
dt = anl_r["_dt"]
WS = anl_r.get("WS", pd.Series(dtype=float))
WG = anl_r.get("WG", pd.Series(dtype=float))
@@ -465,13 +690,21 @@ def chart_wind_speed(anl_r: pd.DataFrame) -> go.Figure:
if WS.notna().any():
fig.add_trace(go.Scatter(x=dt, y=WS, name="Velocidade média", fill="tozeroy",
fillcolor=C["wind_fill"], line=dict(color=C["wind"],width=2.0)))
_base_layout(fig, h=380, title="Velocidade do Vento (kt)")
_base_layout(fig, h=H_WIND_SPEED, title="Velocidade do Vento (kt)")
fig.update_yaxes(title="kt")
fig.update_xaxes(rangeselector=dict(buttons=_rng_btns()))
return fig
def chart_wind_rose(anl: pd.DataFrame) -> Optional[go.Figure]:
"""16-point compass wind rose with 6 speed bins.
Args:
anl: Full (non-resampled) analytics DataFrame.
Returns:
Plotly polar bar chart, or ``None`` if fewer than 20 valid rows.
"""
df = pd.DataFrame({"ws": anl["WS"].values, "wd": anl["WD"].values}).dropna()
df = df[(df["ws"] >= 0) & (df["wd"] >= 0) & (df["wd"] <= 360)]
if len(df) < 20:
@@ -496,12 +729,21 @@ def chart_wind_rose(anl: pd.DataFrame) -> Optional[go.Figure]:
angularaxis=dict(direction="clockwise", rotation=90,
gridcolor="#e0e0e0", tickfont=dict(size=11,color="#333"))),
legend=dict(orientation="h", y=-0.18, x=0.5, xanchor="center", font=dict(size=11)),
height=500, margin=dict(l=40,r=40,t=55,b=90),
height=H_WIND_ROSE, margin=dict(l=40,r=40,t=55,b=90),
)
return fig
def chart_wind_direction(anl_r: pd.DataFrame) -> Optional[go.Figure]:
"""Wind direction scatter coloured by wind speed.
Args:
anl_r: Resampled analytics DataFrame.
Returns:
Plotly scatter Figure, or ``None`` if the DataFrame is empty after
dropping NaNs.
"""
dt = anl_r["_dt"]
WD = anl_r.get("WD", pd.Series(dtype=float))
WS = anl_r.get("WS", pd.Series(dtype=float))
@@ -518,12 +760,20 @@ def chart_wind_direction(anl_r: pd.DataFrame) -> Optional[go.Figure]:
fig.update_yaxes(title="Direção (°)", range=[0,360],
tickvals=[0,45,90,135,180,225,270,315,360],
ticktext=["N","NE","L","SE","S","SO","O","NO","N"])
_base_layout(fig, h=340, title="Direção do Vento (cor = velocidade)", legend_h=False)
_base_layout(fig, h=H_WIND_DIR, title="Direção do Vento (cor = velocidade)", legend_h=False)
fig.update_xaxes(rangeselector=dict(buttons=_rng_btns()))
return fig
def chart_pressure(anl_r: pd.DataFrame) -> go.Figure:
"""QNH time-series with a mean reference line.
Args:
anl_r: Resampled analytics DataFrame.
Returns:
Plotly Figure.
"""
dt = anl_r["_dt"]
QNH = anl_r.get("QNH", pd.Series(dtype=float))
fig = go.Figure()
@@ -535,7 +785,7 @@ def chart_pressure(anl_r: pd.DataFrame) -> go.Figure:
annotation_text=f" Média: {mn:.1f} hPa",
annotation_font=dict(size=10, color="#555"),
annotation_position="right")
_base_layout(fig, h=360, title="Pressão QNH (hPa)")
_base_layout(fig, h=H_PRESSURE, title="Pressão QNH (hPa)")
fig.update_yaxes(title="hPa")
fig.update_xaxes(rangeselector=dict(buttons=_rng_btns()))
return fig
@@ -565,7 +815,7 @@ def chart_precipitation(anl_r: pd.DataFrame, anl: pd.DataFrame) -> Optional[go.F
fig.add_trace(go.Bar(x=monthly["_dt"].dt.strftime("%Y-%m"), y=monthly["PREC"],
name="Mensal", marker_color="#1976d2", opacity=0.85), 2, 1)
fig.update_layout(template=TEMPLATE, height=500, showlegend=False,
fig.update_layout(template=TEMPLATE, height=H_PRECIP, showlegend=False,
margin=dict(l=55,r=20,t=55,b=50),
yaxis_title="mm", yaxis2_title="mm")
fig.update_xaxes(showgrid=True, gridcolor="#f0f0f0")
@@ -573,6 +823,14 @@ def chart_precipitation(anl_r: pd.DataFrame, anl: pd.DataFrame) -> Optional[go.F
def chart_visibility(anl_r: pd.DataFrame) -> go.Figure:
"""Visibility time-series with ICAO category background bands.
Args:
anl_r: Resampled analytics DataFrame.
Returns:
Plotly Figure.
"""
dt = anl_r["_dt"]
VIS = anl_r.get("VIS", pd.Series(dtype=float))
mx = float(VIS.dropna().max()) * 1.05 if VIS.notna().any() else 600
@@ -582,13 +840,21 @@ def chart_visibility(anl_r: pd.DataFrame) -> go.Figure:
if VIS.notna().any():
fig.add_trace(go.Scatter(x=dt, y=VIS, name="Visibilidade",
line=dict(color=C["visibility"], width=2)))
_base_layout(fig, h=420, title="Visibilidade Predominante (dam)", legend_h=False)
_base_layout(fig, h=H_VISIBILITY, title="Visibilidade Predominante (dam)", legend_h=False)
fig.update_yaxes(range=[0, mx], title="dam")
fig.update_xaxes(rangeselector=dict(buttons=_rng_btns()))
return fig
def chart_ceiling(anl_r: pd.DataFrame) -> Optional[go.Figure]:
"""Ceiling time-series with ICAO category background bands.
Args:
anl_r: Resampled analytics DataFrame.
Returns:
Plotly Figure, or ``None`` if all ceiling values are NaN.
"""
dt = anl_r["_dt"]
TETO = anl_r.get("TETO", pd.Series(dtype=float))
if TETO.isna().all():
@@ -599,13 +865,21 @@ def chart_ceiling(anl_r: pd.DataFrame) -> Optional[go.Figure]:
_hrect(fig, ICAO_TETO, ymax=mx)
fig.add_trace(go.Scatter(x=dt, y=TETO, name="Teto",
line=dict(color=C["ceiling"], width=2)))
_base_layout(fig, h=420, title="Teto (dam)", legend_h=False)
_base_layout(fig, h=H_CEILING, title="Teto (dam)", legend_h=False)
fig.update_yaxes(range=[0, mx], title="dam")
fig.update_xaxes(rangeselector=dict(buttons=_rng_btns()))
return fig
def chart_icao_bars(anl: pd.DataFrame) -> Optional[go.Figure]:
"""Bar chart showing percentage of time in each ICAO flight category.
Args:
anl: Full (non-resampled) analytics DataFrame.
Returns:
Plotly Figure, or ``None`` if no valid VIS/TETO data.
"""
df = anl[["VIS","TETO"]].dropna()
if df.empty:
return None
@@ -620,7 +894,7 @@ def chart_icao_bars(anl: pd.DataFrame) -> Optional[go.Figure]:
textposition="outside", marker_color=colors_bar,
textfont=dict(size=13, color="#222"),
))
fig.update_layout(template=TEMPLATE, height=340,
fig.update_layout(template=TEMPLATE, height=H_ICAO_BARS,
title="Frequência por Categoria de Voo (% do tempo)",
yaxis_title="%", yaxis=dict(range=[0, max(pcts)*1.2 if pcts else 100]),
margin=dict(l=55,r=20,t=50,b=40), showlegend=False)
@@ -663,7 +937,7 @@ def chart_precip_boxplot(anl: pd.DataFrame) -> Optional[go.Figure]:
marker=dict(size=4, opacity=0.6),
))
fig.update_layout(
template=TEMPLATE, height=380,
template=TEMPLATE, height=H_PRECIP_BOX,
title="Precipitação Diária por Mês (mm/dia) — caixas + outliers",
yaxis_title="mm/dia",
margin=dict(l=55, r=20, t=50, b=40),
@@ -671,8 +945,25 @@ def chart_precip_boxplot(anl: pd.DataFrame) -> Optional[go.Figure]:
return fig
def chart_climatology(anl: pd.DataFrame, var: str, title: str,
unit: str, color: str) -> Optional[go.Figure]:
def chart_climatology(
anl: pd.DataFrame,
var: str,
title: str,
unit: str,
color: str,
) -> Optional[go.Figure]:
"""Monthly boxplot for a single meteorological variable.
Args:
anl: Full analytics DataFrame.
var: Column name to plot (e.g. ``"T"``).
title: Chart title.
unit: Y-axis unit label.
color: Hex colour for the boxes.
Returns:
Plotly Figure, or ``None`` if fewer than 30 valid observations.
"""
df = pd.DataFrame({"v": anl[var].values,
"m": pd.to_datetime(anl["_dt"]).dt.month.values}).dropna()
if len(df) < 30:
@@ -685,15 +976,18 @@ def chart_climatology(anl: pd.DataFrame, var: str, title: str,
fig.add_trace(go.Box(y=vals.values, name=MONTHS_PT[m-1], boxpoints=False,
marker_color=color, line_color=color,
fillcolor=fill, showlegend=False))
fig.update_layout(template=TEMPLATE, height=370, title=title,
fig.update_layout(template=TEMPLATE, height=H_CLIM_BOX, title=title,
yaxis_title=unit, margin=dict(l=55,r=20,t=50,b=40))
return fig
# ══════════════════════════════════════════════════════════════════════════════
# SIDEBAR
# STARTUP — migration + catalog loading
# ══════════════════════════════════════════════════════════════════════════════
catalog = list_catalog()
_migrate_legacy_paths()
catalog_full = list_catalog() # all aerodromes → collection form
catalog_with_data = list_catalog_with_data() # only aerodromes with data → sidebar
with st.sidebar:
st.markdown("## ✈️ MET Aeroportuário")
@@ -701,11 +995,20 @@ with st.sidebar:
st.caption(fonte)
st.markdown("---")
if not catalog:
st.warning("Nenhum dado encontrado.\nExecute a coleta primeiro.")
if not catalog_with_data:
if catalog_full:
st.info(
"Nenhum dado coletado ainda.\n\n"
"Use a aba **📥 Coleta** para baixar dados do site ICEA."
)
else:
st.warning(
"Banco vazio e catálogo não inicializado.\n\n"
"Use **Atualizar catálogo ICEA** na aba Coleta e depois inicie a coleta."
)
st.stop()
sel_dict = st.selectbox("Aeródromo", catalog, format_func=_fmt_aero)
sel_dict = st.selectbox("Aeródromo", catalog_with_data, format_func=_fmt_aero)
sel_aero = sel_dict["icao"]
p_start, p_end = get_period(sel_aero)
st.caption(f"📅 Disponível: `{p_start}` → `{p_end}`")
@@ -775,6 +1078,7 @@ st.markdown(
# ── KPIs ─────────────────────────────────────────────────────────────────────
def _kv(s: pd.Series) -> float:
"""Returns the mean (or sum for PREC) of *s*, or NaN if empty."""
v = s.dropna().mean() if "PREC" not in str(s.name) else s.dropna().sum()
return float(v) if pd.notna(v) else float("nan")
@@ -841,7 +1145,7 @@ with tab0:
st.success(f"{_n} aeródromos gravados no catálogo.")
st.rerun()
with _info_col:
_n_cat = len(catalog)
_n_cat = len(catalog_full)
st.caption(
f"Catálogo atual: **{_n_cat}** aeródromos. "
"Use o botão ao lado para atualizar do site ICEA."
@@ -853,7 +1157,7 @@ with tab0:
with col_form:
with st.form("form_coleta"):
_coleta_catalog = catalog if catalog else [{"icao": "SBGR", "nome": "", "uf": ""}]
_coleta_catalog = catalog_full if catalog_full else [{"icao": "SBGR", "nome": "", "uf": ""}]
_default_idx = next(
(i for i, d in enumerate(_coleta_catalog) if d["icao"] == sel_aero), 0
)
@@ -913,9 +1217,12 @@ with tab0:
def _worker():
try:
from pipeline import run_pipeline
from pathlib import Path as _Path
kw: dict = dict(
aerodrome = aero_input,
dados_dir = str(DADOS_DIR),
dados_dir = DADOS_DIR,
db_path = DB_PATH,
preproc_dir = PREPROC_DIR,
all_years = all_years,
headless = headless,
n_samples = n_samp,

View File

@@ -1,17 +1,17 @@
"""
Módulo SQLite para armazenamento de dados meteorológicos analíticos.
SQLite storage layer for aerodrome meteorological analytics.
Tabelas:
observations séries temporais analíticas (10 variáveis float por timestamp)
outlier_log auditoria de valores tratados por limites físicos
aerodromes catálogo de aeródromos disponíveis no site ICEA
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.
Uso:
from db import get_connection, ensure_schema, upsert_analytics, query_analytics
conn = get_connection(Path("dados/met.db"))
ensure_schema(conn)
upsert_analytics(conn, "SBGR", anl_df)
df = query_analytics(conn, "SBGR", "2024-01-01", "2024-12-31")
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
@@ -21,24 +21,26 @@ from typing import Optional
import pandas as pd
# ── Colunas analíticas ────────────────────────────────────────────────────────
ANL_COLS = ["T", "Td", "UR", "QNH", "WS", "WG", "WD", "VIS", "TETO", "PREC"]
# ── Analytic columns ──────────────────────────────────────────────────────────
ANL_COLS: list[str] = [
"T", "Td", "UR", "QNH", "WS", "WG", "WD", "VIS", "TETO", "PREC"
]
# ── Limites físicos por variável ─────────────────────────────────────────────
# ── Physical limits per variable ──────────────────────────────────────────────
PHYSICAL_LIMITS: dict[str, tuple[float, float]] = {
"T": (-25.0, 55.0), # °C — extremos possíveis no Brasil
"Td": (-30.0, 40.0), # °C — ponto de orvalho
"UR": ( 0.0, 100.0), # %
"QNH": (940.0, 1060.0), # hPa — recordes mundiais absolutos
"WS": ( 0.0, 200.0), # kt
"WG": ( 0.0, 250.0), # kt — rajada furacão Cat 5 ≈ 175 kt
"WD": ( 0.0, 360.0), # °
"VIS": ( 0.0, 9999.0), # dam
"TETO": ( 0.0, 9999.0), # dam
"PREC": ( 0.0, 500.0), # mm — recorde horário mundial ≈ 300 mm/h
"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
}
# ── Schema SQL ────────────────────────────────────────────────────────────────
# ── SQL schema ────────────────────────────────────────────────────────────────
_SCHEMA = """
CREATE TABLE IF NOT EXISTS observations (
aerodrome TEXT NOT NULL,
@@ -71,8 +73,18 @@ CREATE TABLE IF NOT EXISTS aerodromes (
"""
# ── Conexão ───────────────────────────────────────────────────────────────────
# ── 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")
@@ -82,13 +94,25 @@ def get_connection(db_path: Path) -> sqlite3.Connection:
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()
# ── Clip de limites físicos no DataFrame ─────────────────────────────────────
# ── Physical-limit clipping ───────────────────────────────────────────────────
def _clip_to_limits(df: pd.DataFrame) -> pd.DataFrame:
"""Define como NaN valores fora dos limites físicos (sem registrar no log)."""
"""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:
@@ -103,29 +127,40 @@ def upsert_analytics(
aerodrome: str,
anl_df: pd.DataFrame,
) -> int:
"""
Insere ou substitui linhas no banco.
Aplica limites físicos automaticamente antes da inserção.
Retorna número de linhas processadas.
"""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]
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})"
sql = (
f"INSERT OR REPLACE INTO observations ({col_names}) VALUES ({placeholders})"
)
def _dt_str(v):
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 = [aerodrome, _dt_str(row["_dt"])]
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))
@@ -136,23 +171,29 @@ def upsert_analytics(
return len(rows)
# ── Repair de outliers no banco ───────────────────────────────────────────────
# ── Physical-limit repair on existing rows ────────────────────────────────────
def apply_physical_limits(
conn: sqlite3.Connection,
aerodrome: Optional[str] = None,
) -> int:
"""
Aplica limites físicos aos dados existentes, com auditoria completa em outlier_log.
Se aerodrome=None, processa todos os aeródromos.
Retorna total de outliers registrados e corrigidos.
"""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_filter = "AND aerodrome=?" if aerodrome else ""
aero_params_suffix = (aerodrome,) if aerodrome else ()
for col, (lo, hi) in PHYSICAL_LIMITS.items():
# Registra valores abaixo do limite
rows = conn.execute(
f"SELECT aerodrome, dt, {col} FROM observations "
f"WHERE {col} IS NOT NULL AND {col} < ? {aero_filter}",
@@ -168,7 +209,6 @@ def apply_physical_limits(
)
total += len(rows)
# Registra valores acima do limite
rows = conn.execute(
f"SELECT aerodrome, dt, {col} FROM observations "
f"WHERE {col} IS NOT NULL AND {col} > ? {aero_filter}",
@@ -184,7 +224,6 @@ def apply_physical_limits(
)
total += len(rows)
# Aplica o UPDATE com CASE para todas as variáveis de uma vez
where_clause = f"WHERE aerodrome='{aerodrome}'" if aerodrome else ""
conn.execute(f"""
UPDATE observations SET
@@ -204,14 +243,20 @@ def apply_physical_limits(
return total
# ── Consultas de auditoria ────────────────────────────────────────────────────
# ── Outlier audit queries ─────────────────────────────────────────────────────
def get_outlier_summary(
conn: sqlite3.Connection,
aerodrome: Optional[str] = None,
) -> pd.DataFrame:
"""
Resumo de outliers por variável: contagem, min/max do valor original,
limite aplicado e data do último tratamento.
"""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 ()
@@ -237,12 +282,23 @@ def get_outlier_summary(
def get_outlier_detail(
conn: sqlite3.Connection,
aerodrome: Optional[str] = None,
variable: Optional[str] = None,
variable: Optional[str] = None,
limit: int = 500,
) -> pd.DataFrame:
"""Detalhe linha-a-linha dos outliers registrados."""
conditions = []
params: list = []
"""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)
@@ -259,20 +315,28 @@ def get_outlier_detail(
def has_outlier_log(conn: sqlite3.Connection) -> bool:
"""Verifica se existe algum registro de outlier."""
"""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
# ── Catálogo de aeródromos ────────────────────────────────────────────────────
# ── Aerodrome catalog ─────────────────────────────────────────────────────────
def upsert_aerodromes(
conn: sqlite3.Connection,
aerodromes: list[dict],
) -> int:
"""
Insere ou atualiza lista de aeródromos.
Cada dict deve ter: icao, nome, uf.
Retorna número de registros processados.
"""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
@@ -288,27 +352,75 @@ def upsert_aerodromes(
def list_all_aerodromes(conn: sqlite3.Connection) -> list[dict]:
"""
Retorna todos os aeródromos cadastrados (tabela aerodromes).
Formato: [{"icao": "SBGR", "nome": "Guarulhos, SP", "uf": "SP"}, ...]
"""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]
# ── Queries gerais ───────────────────────────────────────────────────────────
# ── General queries ───────────────────────────────────────────────────────────
def query_analytics(
conn: sqlite3.Connection,
aerodrome: str,
start_dt: Optional[str] = None,
end_dt: Optional[str] = None,
) -> pd.DataFrame:
sql = "SELECT dt, T, Td, UR, QNH, WS, WG, WD, VIS, TETO, PREC FROM observations WHERE aerodrome=?"
params: list = [aerodrome]
"""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)
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)
@@ -324,6 +436,15 @@ 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,),
@@ -334,6 +455,14 @@ def get_coverage(
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()
@@ -341,6 +470,16 @@ def list_aerodromes(conn: sqlite3.Connection) -> list[str]:
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=?",
@@ -348,5 +487,7 @@ def aerodrome_stats(conn: sqlite3.Connection, aerodrome: str) -> dict:
).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]}
return {
"min_dt": row[0], "max_dt": row[1],
"n_obs": row[2], "n_T": row[3], "n_VIS": row[4],
}

View File

@@ -1,16 +1,16 @@
"""
Orquestrador do pipeline completo de meteorologia ICEA/DECEA.
End-to-end pipeline orchestrator for ICEA/DECEA surface meteorology.
Fluxo:
1. Verifica cobertura existente no banco SQLite (db.get_coverage)
2. Passe 1 forward: baixa dados novos após ex_max (stop_before=ex_max)
3. Passe 2 backward: baixa histórico antes de ex_min (auto-stop sem limit)
4. Computa analytics a partir dos CSVs trimestrais
5. Upserta no SQLite + salva CSV de analytics como backup
6. Valida por amostragem (CSVs trimestrais ainda presentes)
7. Remove CSVs trimestrais intermediários (se validação OK e --cleanup)
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).
Uso:
Example:
python pipeline.py --aerodrome SBGR --all-years
python pipeline.py --aerodrome SBGR --start-year 2020 --end-year 2025 --no-cleanup
"""
@@ -39,26 +39,76 @@ from concat_meteorologia import (
validate_sample,
)
DB_FILENAME = "met.db"
# ── 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 inline (independente do dashboard.py)
# 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 = (
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:
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:
@@ -76,18 +126,45 @@ def _agg(df: pd.DataFrame, prefix: str, kw: str, fn: str = "mean") -> pd.Series:
def build_analytics(merged: pd.DataFrame) -> pd.DataFrame:
"""Computa as 10 variáveis analíticas limpas a partir do merged bruto de 84 colunas."""
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]"))
"""Derives the 10 clean analytic variables from the 84-column merged CSV.
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))
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,
@@ -105,12 +182,30 @@ def build_analytics(merged: pd.DataFrame) -> pd.DataFrame:
# ---------------------------------------------------------------------------
# Scraping em dois passes
# Two-pass scraping
# ---------------------------------------------------------------------------
def _run_forward(driver, wait, aerodrome, ex_max: date, dados_dir: str,
log: Callable) -> int:
"""Passe 1: baixa dados novos posteriores a ex_max (atualização)."""
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.")
@@ -125,9 +220,34 @@ def _run_forward(driver, wait, aerodrome, ex_max: date, dados_dir: str,
return total
def _run_backward(driver, wait, aerodrome, ex_min: date, all_years: bool,
dados_dir: str, log: Callable, progress: Callable) -> int:
"""Passe 2: baixa histórico anterior a ex_min (extensão regressiva com auto-stop)."""
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.")
@@ -139,7 +259,9 @@ def _run_backward(driver, wait, aerodrome, ex_min: date, all_years: bool,
years = list(range(start_year, SITE_MIN_DATE.year - 1, -1))
for i, year in enumerate(years):
pct = 0.10 + 0.55 * (i / max(len(years), 1))
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}")
@@ -160,12 +282,14 @@ def _run_backward(driver, wait, aerodrome, ex_min: date, all_years: bool,
# ---------------------------------------------------------------------------
# Pipeline principal
# Main pipeline
# ---------------------------------------------------------------------------
def run_pipeline(
aerodrome: str,
dados_dir: str = "dados",
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,
@@ -176,16 +300,33 @@ def run_pipeline(
log: Callable[[str], None] = print,
progress: Callable[[float, str], None] = lambda pct, msg: None,
) -> dict:
"""
Pipeline completo: scraping analytics SQLite validação cleanup.
"""Runs the full scrape → analytics → SQLite → validate → cleanup pipeline.
Returns dict: rows, period_start, period_end, n_ok, n_fail, errors, db_path
"""
base = Path(dados_dir)
base.mkdir(exist_ok=True)
db_path = base / DB_FILENAME
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)``.
# ── 1. Cobertura existente no SQLite ─────────────────────────────────────
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)
@@ -197,21 +338,19 @@ def run_pipeline(
log(f"\n[pipeline] Sem dados anteriores para {aerodrome}.")
ex_min = ex_max = None
progress(0.05, f"Iniciando scraping {aerodrome}")
progress(_PROG_START, f"Iniciando scraping {aerodrome}")
# ── 2. Scraping ───────────────────────────────────────────────────────────
# 2. Scraping ─────────────────────────────────────────────────────────────
driver = make_driver(headless=headless)
wait = WebDriverWait(driver, 60)
try:
if coverage:
# Modo incremental: dois passes
_run_forward(driver, wait, aerodrome, ex_max, str(base), log)
progress(0.35, f"Histórico {aerodrome}")
progress(_PROG_FORWARD_DONE, f"Histórico {aerodrome}")
_run_backward(driver, wait, aerodrome, ex_min, all_years,
str(base), log, progress)
else:
# Primeira coleta: único passe regressivo com auto-stop
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
@@ -219,7 +358,7 @@ def run_pipeline(
years = list(range(end_yr, start_yr - 1, -1))
for i, year in enumerate(years):
pct = 0.05 + 0.65 * (i / max(len(years), 1))
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)
@@ -237,18 +376,17 @@ def run_pipeline(
finally:
driver.quit()
progress(0.72, "Construindo analytics…")
progress(_PROG_ANALYTICS, "Construindo analytics…")
# ── 3. Concat → 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:
# Sem novos dados, apenas reporta cobertura existente
cov = _db.get_coverage(conn, aerodrome)
if cov:
s, e = str(cov[0]), str(cov[1])
stats = _db.aerodrome_stats(conn, aerodrome)
progress(1.0, "Concluído")
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.")
@@ -259,38 +397,37 @@ def run_pipeline(
if merged.empty:
log("[pipeline] Merged vazio.")
progress(1.0, "Concluído")
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(0.80, "Inserindo no banco SQLite…")
progress(_PROG_UPSERT, "Inserindo no banco SQLite…")
# ── 4. Upsert no 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})")
# Salva CSV de analytics como backup legível
anl_dir = base / aerodrome
anl_dir.mkdir(exist_ok=True)
# 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"
# Remove arquivos de backup anteriores
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.name}")
log(f"[pipeline] Backup CSV: {csv_backup}")
progress(0.88, "Validando…")
progress(_PROG_VALIDATE, "Validando…")
# ── 5. Validação ─────────────────────────────────────────────────────────
# 5. Validation ───────────────────────────────────────────────────────────
n_ok = n_fail = 0
val_errors: list[str] = []
validated = True
@@ -304,9 +441,9 @@ def run_pipeline(
if n_fail > 0:
validated = False
progress(0.95, "Cleanup…")
progress(_PROG_CLEANUP, "Cleanup…")
# ── 6. Cleanup ────────────────────────────────────────────────────────────
# 6. Cleanup ──────────────────────────────────────────────────────────────
if do_cleanup and new_files:
if validated:
log(f"\n[pipeline] Removendo {len(new_files)} CSVs trimestrais…")
@@ -318,7 +455,7 @@ def run_pipeline(
stats = _db.aerodrome_stats(conn, aerodrome)
conn.close()
progress(1.0, "Concluído")
progress(_PROG_DONE, "Concluído")
log("\n[pipeline] Pipeline finalizado.")
return dict(
@@ -333,10 +470,11 @@ def run_pipeline(
# ---------------------------------------------------------------------------
# CLI standalone
# 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")
@@ -355,30 +493,37 @@ exemplos:
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="dados", metavar="DIR")
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")
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 = args.dados_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,
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}")

View File

@@ -1,14 +1,15 @@
"""
Scraper de dados meteorológicos de superfície - ICEA/DECEA
Site: https://pesquisa.icea.decea.mil.br/superficie_list/
Surface meteorology web scraper for the ICEA/DECEA data portal.
Uso standalone:
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 # grava catálogo de aeródromos no banco
python scraper_meteorologia.py --fetch-catalog # writes aerodrome catalog to SQLite
Importável pelo pipeline:
Importable by the pipeline:
from scraper_meteorologia import make_driver, scrape_year, fetch_aerodrome_catalog, SITE_MIN_DATE
"""
@@ -21,6 +22,7 @@ import re
import sys
import time
from datetime import date
from pathlib import Path
from typing import Callable, Optional
import pandas as pd
@@ -32,12 +34,19 @@ 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() # dinâmico: nunca maior que hoje
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:
"""
@@ -76,6 +85,18 @@ TABS = [
# ---------------------------------------------------------------------------
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")
@@ -205,6 +226,13 @@ def fetch_aerodrome_catalog(headless: bool = True) -> list[dict]:
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"""
@@ -224,6 +252,15 @@ def set_period(driver: webdriver.Chrome, start: date, end: date) -> None:
# ---------------------------------------------------------------------------
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();
@@ -245,6 +282,15 @@ def show_all_rows(driver: webdriver.Chrome, tab_id: str) -> None:
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:
@@ -266,6 +312,18 @@ def parse_html_table(html: str) -> Optional[pd.DataFrame]:
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:
@@ -295,7 +353,21 @@ def get_full_table(driver: webdriver.Chrome, tab_id: str) -> Optional[pd.DataFra
return pd.concat(frames, ignore_index=True).drop_duplicates()
def extract_tab(driver: webdriver.Chrome, wait: WebDriverWait, tab_id: str) -> Optional[pd.DataFrame]:
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:
@@ -313,6 +385,17 @@ def extract_tab(driver: webdriver.Chrome, wait: WebDriverWait, tab_id: str) -> O
# ---------------------------------------------------------------------------
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")
@@ -320,6 +403,12 @@ def make_filename(tab_name: str, localidade: str, start: date, end: date) -> str
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)
@@ -424,7 +513,7 @@ def scrape_year(
# ---------------------------------------------------------------------------
def _fix_stdout() -> None:
"""Garante UTF-8 no terminal Windows ao rodar como script."""
"""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")
@@ -454,14 +543,14 @@ exemplos:
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="dados", metavar="DIR",
help="Diretório de saída (padrão: dados/)")
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="Abre janela do Chrome visível (útil para debug)")
help="Open Chrome in visible mode (useful for debugging)")
parser.add_argument("--fetch-catalog", action="store_true",
help="Baixa lista de aeródromos do site ICEA e grava no banco SQLite")
parser.add_argument("--db-path", default="dados/met.db", metavar="PATH",
help="Caminho do banco SQLite (usado com --fetch-catalog)")
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 ──────────────────────────────────────
@@ -486,7 +575,7 @@ exemplos:
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
os.makedirs(args.output_dir, exist_ok=True)
Path(args.output_dir).mkdir(parents=True, exist_ok=True)
driver = make_driver(headless=not args.no_headless)
wait = WebDriverWait(driver, 60)

View File

@@ -1,40 +0,0 @@
"""Diagnóstico de outliers no banco SQLite."""
import sys; sys.path.insert(0, ".")
from pathlib import Path
import sqlite3
import pandas as pd
conn = sqlite3.connect("dados/met.db")
LIMITS = {"T": (-25, 55), "Td": (-30, 40), "UR": (0, 100),
"QNH": (940, 1060), "WS": (0, 200), "WG": (0, 250),
"WD": (0, 360), "VIS": (0, 9999), "TETO": (0, 9999), "PREC": (0, 500)}
print("=== Outliers por variável (fora dos limites físicos) ===\n")
total = conn.execute("SELECT COUNT(*) FROM observations").fetchone()[0]
print(f"Total observações: {total:,}\n")
for col, (lo, hi) in LIMITS.items():
rows = conn.execute(f"""
SELECT aerodrome, dt, {col}
FROM observations
WHERE {col} IS NOT NULL AND ({col} < {lo} OR {col} > {hi})
ORDER BY ABS({col}) DESC LIMIT 10
""").fetchall()
if rows:
print(f"--- {col} (limite: {lo} a {hi}) ---")
for r in rows:
print(f" {r[0]} {r[1]} {col}={r[2]}")
cnt = conn.execute(f"SELECT COUNT(*) FROM observations WHERE {col} < {lo} OR {col} > {hi}").fetchone()[0]
print(f" Total fora do limite: {cnt:,} ({cnt/total*100:.2f}%)\n")
# Estatísticas básicas
print("\n=== Estatísticas básicas ===\n")
cols = ", ".join([f"MIN({c}) as min_{c}, MAX({c}) as max_{c}, AVG({c}) as avg_{c}" for c in LIMITS])
row = conn.execute(f"SELECT {cols} FROM observations").fetchone()
keys = [f"{fn}_{c}" for c in LIMITS for fn in ("min","max","avg")]
for k, v in zip(keys, row):
if v is not None:
print(f" {k}: {v:.2f}")
conn.close()

View File

@@ -0,0 +1,169 @@
# Documentação Técnica — Meteorologia de Superfície
## O que é
Sistema de coleta, processamento e visualização de dados meteorológicos horários de aeródromos brasileiros. Os dados são extraídos do portal de dados de superfície do ICEA/DECEA, armazenados em um banco SQLite local e disponibilizados em um dashboard interativo para análise de séries temporais, climatologia e condições operacionais de voo.
O sistema foi desenvolvido como parte de projeto de pesquisa no IED/ITA.
---
## Fonte dos dados
**Portal:** https://pesquisa.icea.decea.mil.br/superficie_list/
**Órgão:** ICEA — Instituto de Controle do Espaço Aéreo / DECEA — Departamento de Controle do Espaço Aéreo
**Tipo:** Observações meteorológicas de superfície de aeródromos brasileiros
Os dados são organizados no portal em **9 abas temáticas** por período trimestral:
| Aba | Conteúdo |
|-----|----------|
| CGT | Dados corrigidos de superfície (conjunto) |
| Nuvem | Cobertura e tipo de nuvem |
| Precipitação | Intensidade e duração da chuva |
| Pressão | QNH e pressão de superfície |
| RVR | Alcance visual de pista por cabeceira |
| Temperatura | Bulbo seco, orvalho, umidade por pista |
| Teto | Teto de nuvens por pista |
| Vento | Velocidade, rajada e direção por cabeceira |
| Visibilidade | Visibilidade predominante |
A cobertura histórica varia por aeródromo. Os três aeródromos com dados carregados
no banco na versão inicial são:
| ICAO | Aeródromo | Início do histórico |
|------|-----------|---------------------|
| SBGR | Guarulhos / Cumbica (SP) | 1951 |
| SBAN | Santos (SP) | 1973 |
| SBCC | Campinas / Viracopos (SP) | 1992 |
---
## Como funciona
O fluxo completo é orquestrado pelo módulo `pipeline.py`:
```
1. Verifica cobertura existente no banco (db.get_coverage)
2. Passe forward — baixa dados novos após a data máxima existente
3. Passe backward — baixa histórico anterior à data mínima existente
4. Concatena os CSVs trimestrais por aba (concat_meteorologia)
5. Deriva as 10 variáveis analíticas (build_analytics)
6. Persiste no SQLite com validação de limites físicos (db.upsert_analytics)
7. Salva CSV de backup em tabelas/preproc/meteorologia_aeroportos/<ICAO>/
8. Valida por amostragem contra os arquivos fonte
9. Remove os CSVs temporários (se validação aprovada e --cleanup ativo)
```
### Derivação das variáveis analíticas
As 84+ colunas brutas (uma por medição específica, por pista ou cabeceira) são reduzidas
a 10 variáveis float por timestamp:
| Variável | Derivação |
|----------|-----------|
| `T` | Média das colunas de temperatura de bulbo seco |
| `Td` | Média das colunas de ponto de orvalho |
| `UR` | Média das colunas de umidade relativa |
| `QNH` | Coluna genérica de QNH ou agregação das específicas |
| `WS` | Média das velocidades de vento por cabeceira |
| `WG` | Máximo das rajadas por cabeceira |
| `WD` | Média da direção do vento |
| `VIS` | Mínimo da visibilidade predominante |
| `TETO` | Mínimo do teto por pista (conservador) |
| `PREC` | Soma da precipitação acumulada |
### Limites físicos aplicados
Valores fora dos intervalos abaixo são substituídos por `NULL` e registrados em `outlier_log`:
| Variável | Mínimo | Máximo |
|----------|--------|--------|
| T | 25 °C | 55 °C |
| Td | 30 °C | 40 °C |
| UR | 0 % | 100 % |
| QNH | 940 hPa | 1060 hPa |
| WS | 0 kt | 200 kt |
| WG | 0 kt | 250 kt |
| WD | 0° | 360° |
| VIS | 0 dam | 9999 dam |
| TETO | 0 dam | 9999 dam |
| PREC | 0 mm | 500 mm |
---
## Limitações conhecidas
1. **Extração via web scraping, não via API oficial.**
O sistema automatiza interações de navegador (Selenium + Chrome). Qualquer alteração
no layout, estrutura HTML ou comportamento JavaScript do portal ICEA pode interromper
a coleta sem aviso prévio.
2. **Fonte única e exclusiva.**
Os dados provêm exclusivamente da tabela de dados de superfície do portal
https://pesquisa.icea.decea.mil.br/superficie_list/. Não há integração com METAR,
SYNOP, REDEMET, SPECI, SIGMET, ou qualquer outra fonte meteorológica ou aeronáutica.
3. **Sem tratamento de erros de lançamento.**
Os dados são replicados como estão no portal. Erros de digitação, transcrição incorreta
ou valores biologicamente implausíveis mas dentro dos limites físicos definidos não são
detectados nem corrigidos. A qualidade dos dados depende integralmente da fonte.
4. **Limites físicos são condição necessária, não suficiente.**
Os filtros removem valores meteorologicamente impossíveis (ex: temperatura de 500 °C),
mas não detectam erros plausíveis dentro dos limites (ex: QNH de 1010 hPa registrado
incorretamente como 1001 hPa).
5. **Dependência de hardware e software local.**
A coleta requer Google Chrome instalado. A performance e o sucesso da coleta dependem
de conexão com a internet, versão do Chrome, e disponibilidade do portal.
6. **Cobertura histórica variável.**
Nem todos os aeródromos possuem histórico completo. A disponibilidade de dados depende
do que foi inserido no portal — aeródromos mais antigos ou de menor porte podem ter
lacunas significativas.
7. **Processamento sequencial.**
A coleta é executada para um aeródromo por vez. Não há paralelização da coleta
de múltiplos aeródromos.
8. **Sem atualização automática.**
A coleta de dados novos deve ser iniciada manualmente pelo usuário, seja via dashboard
ou via linha de comando.
---
## Oportunidades de melhoria
| Prioridade | Melhoria |
|------------|----------|
| Alta | Agendamento automático da coleta (cron / Task Scheduler) |
| Alta | Notificação de falha na coleta (e-mail, webhook) |
| Alta | Coleta paralela de múltiplos aeródromos |
| Média | Integração com REDEMET / METAR para validação cruzada |
| Média | Detecção de anomalias por métodos estatísticos (z-score, IQR) |
| Média | API REST sobre o banco SQLite para consumo programático |
| Média | Suporte a dados SYNOP e SPECI do portal |
| Baixa | Exportação em formatos científicos (NetCDF, HDF5, Parquet) |
| Baixa | Interface de configuração de alertas por variável e limiar |
| Baixa | Mapa de cobertura dos aeródromos disponíveis |
---
## Solução de problemas
**Chrome não encontrado / erro de WebDriver:**
Verifique se o Google Chrome está instalado. O `webdriver-manager` baixa o ChromeDriver
compatível automaticamente — é necessária conexão com a internet.
**Portal ICEA indisponível ou com layout alterado:**
A coleta depende da estrutura HTML do portal. Se o portal alterar sua interface, o scraper
pode parar de funcionar. Verifique o portal manualmente e abra uma issue no repositório.
**Dashboard não abre / porta ocupada:**
```bash
streamlit run _apps/dashboard.py --server.port 8502
```
**Banco de dados não encontrado:**
Execute o pipeline ao menos uma vez para criar e popular `db/met.db`.

View File

@@ -0,0 +1,51 @@
# Autoria e Contribuições
## Autor original
| Campo | Informação |
|-------|------------|
| **Nome** | Sérgio Rebouças |
| **Instituição** | IED — Instituto de Estudos para o Desenvolvimento / ITA — Instituto Tecnológico de Aeronáutica |
| **Contato** | sreboucas1979@gmail.com |
| **Versão inicial** | 2026 |
---
## Licença
Este software é distribuído sob a **MIT License** — veja o arquivo [`../LICENSE`](../LICENSE).
Em síntese: você pode usar, modificar e redistribuir livremente, inclusive para fins
comerciais, desde que o aviso de copyright original seja mantido em todas as cópias
e derivações.
---
## Como contribuir
Contribuições são bem-vindas. Ao modificar ou estender este software:
1. **Mantenha o aviso de copyright original** no topo de cada arquivo modificado.
2. **Documente suas mudanças** em [`changelog.md`](changelog.md) com o seguinte formato:
```
## v<versão> — <Seu Nome> — <YYYY-MM>
- Descrição objetiva da melhoria ou correção
- Outra mudança relevante
```
3. **Siga o padrão de código** do projeto: Google Python Style Guide, type hints (PEP 484),
docstrings Google-style (Args / Returns / Raises).
4. **Não altere a autoria original** nos cabeçalhos existentes — apenas adicione a sua
entrada no changelog.
5. **Identifique claramente o escopo** da contribuição: use o campo de descrição do commit
e o changelog para delimitar o que foi criado ou modificado por você.
---
## Histórico de contribuições
| Versão | Autor | Data | Descrição resumida |
|--------|-------|------|--------------------|
| v1.0 | Sérgio Rebouças | 2026-06 | Versão inicial — coleta, processamento, dashboard e documentação |
> Contribuidores futuros: adicione uma linha nesta tabela e crie a entrada correspondente
> em `changelog.md`.

View File

@@ -0,0 +1,39 @@
# Changelog
Todas as versões significativas estão registradas aqui.
Cada entrada identifica o autor responsável pelas mudanças daquela versão.
Formato:
```
## v<versão> — <Autor> — <YYYY-MM>
### Adicionado / Corrigido / Alterado / Removido
- Descrição objetiva
```
---
## v1.0 — Sérgio Rebouças — 2026-06
### Adicionado
- Coleta automatizada de dados de superfície do portal ICEA/DECEA via Selenium
- Pipeline completo: scraping → concatenação → analytics → SQLite → validação amostral
- Dashboard interativo Streamlit com 8 abas (coleta, visão geral, temperatura, vento,
pressão/precipitação, visibilidade/teto, climatologia, dados)
- Classificação automática de condições de voo por categorias ICAO (LIFR/IFR/MVFR/VMC)
- Banco SQLite com tabelas `observations`, `outlier_log` e `aerodromes`
- Filtros de limites físicos com auditoria completa em `outlier_log`
- Seletor de aeródromo no dashboard: sidebar mostra apenas aeródromos com dados;
formulário de coleta mostra catálogo completo
- Scripts de inicialização one-command (`run/run.bat`, `run/run.sh`)
- Estrutura de diretórios organizada: `_apps/`, `run/`, `env/`, `db/`, `docs/`
- Migração automática de layouts anteriores na inicialização do dashboard
- Documentação técnica com limitações e oportunidades de melhoria (`docs/about.md`)
### Aeródromos com dados na versão inicial
- SBGR — Guarulhos / Cumbica (SP) — histórico desde 1951
- SBAN — Santos (SP) — histórico desde 1973
- SBCC — Campinas / Viracopos (SP) — histórico desde 1992
---
<!-- Próximas versões: adicione acima desta linha -->

View File

@@ -0,0 +1,41 @@
@echo off
REM ============================================================
REM MET Aeroportuário — launcher Windows
REM Creates the virtual environment if needed, installs
REM dependencies and opens the Streamlit dashboard.
REM
REM Usage (from any directory):
REM run\run.bat
REM run\run.bat --server.port 8502
REM ============================================================
setlocal EnableDelayedExpansion
REM Resolve the project root (one level above this script's folder)
set "PROJ_DIR=%~dp0.."
cd /d "%PROJ_DIR%"
REM ── 1. Virtual environment ────────────────────────────────────
if not exist ".venv" (
echo [1/3] Criando ambiente virtual...
python -m venv .venv
if errorlevel 1 (
echo [ERRO] Falha ao criar o ambiente virtual.
echo Verifique se Python 3.10+ esta instalado e no PATH.
pause
exit /b 1
)
)
REM ── 2. Activate and install dependencies ─────────────────────
echo [2/3] Instalando dependencias...
call .venv\Scripts\activate.bat
pip install -q -r env\requirements.txt
if errorlevel 1 (
echo [ERRO] Falha ao instalar dependencias.
pause
exit /b 1
)
REM ── 3. Launch dashboard ───────────────────────────────────────
echo [3/3] Iniciando dashboard em http://localhost:8501 ...
streamlit run _apps\dashboard.py %*

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# ============================================================
# MET Aeroportuário — launcher Linux/macOS
# Creates a virtual environment if needed, installs
# dependencies and launches the Streamlit dashboard.
#
# Usage (from any directory):
# ./run/run.sh
# ./run/run.sh --server.port 8502
# ============================================================
set -euo pipefail
# Resolve the project root (one level above this script's folder).
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJ_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$PROJ_DIR"
# ── 1. Virtual environment ───────────────────────────────────
if [ ! -d ".venv" ]; then
echo "[1/3] Criando ambiente virtual..."
python3 -m venv .venv
fi
# ── 2. Install dependencies ──────────────────────────────────
echo "[2/3] Instalando dependencias..."
# shellcheck source=/dev/null
source .venv/bin/activate
pip install -q -r env/requirements.txt
# ── 3. Launch dashboard ──────────────────────────────────────
echo "[3/3] Iniciando dashboard em http://localhost:8501 ..."
exec streamlit run _apps/dashboard.py "$@"