V.1.0
This commit is contained in:
22
softwares/test/meteorologia_aeroportos/LICENSE
Normal file
22
softwares/test/meteorologia_aeroportos/LICENSE
Normal 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.
|
||||
@@ -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** | 31–91 | 16–48 | Voo por instrumentos |
|
||||
| **MVFR** | 91–300 | 48–800 | VFR Marginal |
|
||||
| **VMC** | > 300 | > 800 | Condições visuais |
|
||||
| **LIFR** | < 15 | < 50 | IFR Severo |
|
||||
| **IFR** | 15–30 | 50–100 | Voo por instrumentos |
|
||||
| **MVFR** | 30–100 | 100–300 | 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*
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
141
softwares/test/meteorologia_aeroportos/_apps/_diag_outliers.py
Normal file
141
softwares/test/meteorologia_aeroportos/_apps/_diag_outliers.py
Normal 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()
|
||||
@@ -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()
|
||||
@@ -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 (0–100).
|
||||
"""
|
||||
df = anl[["VIS", "TETO"]].dropna()
|
||||
if df.empty:
|
||||
return {}
|
||||
cats = df.apply(lambda r: _icao_cat(r.VIS, r.TETO), axis=1)
|
||||
return (cats.value_counts() / len(cats) * 100).to_dict()
|
||||
|
||||
|
||||
def badge(cat: str) -> str:
|
||||
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,
|
||||
@@ -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],
|
||||
}
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
169
softwares/test/meteorologia_aeroportos/docs/about.md
Normal file
169
softwares/test/meteorologia_aeroportos/docs/about.md
Normal 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`.
|
||||
51
softwares/test/meteorologia_aeroportos/docs/authors.md
Normal file
51
softwares/test/meteorologia_aeroportos/docs/authors.md
Normal 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`.
|
||||
39
softwares/test/meteorologia_aeroportos/docs/changelog.md
Normal file
39
softwares/test/meteorologia_aeroportos/docs/changelog.md
Normal 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 -->
|
||||
41
softwares/test/meteorologia_aeroportos/run/run.bat
Normal file
41
softwares/test/meteorologia_aeroportos/run/run.bat
Normal 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 %*
|
||||
32
softwares/test/meteorologia_aeroportos/run/run.sh
Normal file
32
softwares/test/meteorologia_aeroportos/run/run.sh
Normal 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 "$@"
|
||||
Reference in New Issue
Block a user