Processa registro de voo 2025

This commit is contained in:
Cesa-V
2026-06-15 20:22:57 -03:00
parent 8d7dd07b18
commit 11821eaa93
17 changed files with 8973 additions and 25 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,509 @@
"""
Pre-processa a planilha bruta de voos de 2025.
Entrada:
db/raw/Planilha 2025.xlsx
Saidas:
db/pre_process/registro_voo_2025/registro_voo_2025_limpo.xlsx
db/pre_process/registro_voo_2025/registro_voo_2025_limpo.csv
db/pre_process/registro_voo_2025/missoes_oamrp_2025.csv
db/pre_process/registro_voo_2025/resumo_registro_voo_2025.json
db/processed/registro_voo_2025.csv
"""
from __future__ import annotations
import csv
import json
from collections import Counter
from datetime import date, datetime, time, timedelta
from pathlib import Path
from typing import Any
import openpyxl
ANO = 2025
BASE_DIR = Path(__file__).resolve().parents[3]
RAW_PATH = BASE_DIR / "db" / "raw" / "Planilha 2025.xlsx"
PREPROCESS_DIR = BASE_DIR / "db" / "pre_process" / "registro_voo_2025"
PROCESSED_DIR = BASE_DIR / "db" / "processed"
LIMPO_XLSX = PREPROCESS_DIR / "registro_voo_2025_limpo.xlsx"
LIMPO_CSV = PREPROCESS_DIR / "registro_voo_2025_limpo.csv"
CONSOLIDADO_CSV = PREPROCESS_DIR / "registro_voo_2025_consolidado.csv"
MISSOES_CSV = PREPROCESS_DIR / "missoes_oamrp_2025.csv"
MISSOES_CONSOLIDADAS_CSV = PREPROCESS_DIR / "missoes_oamrp_2025_consolidado.csv"
RESUMO_JSON = PREPROCESS_DIR / "resumo_registro_voo_2025.json"
PROCESSED_CSV = PROCESSED_DIR / "registro_voo_2025.csv"
PROCESSED_CONSOLIDADO_CSV = PROCESSED_DIR / "registro_voo_2025_consolidado.csv"
MES_MAP = {
"JAN": 1,
"FEV": 2,
"MAR": 3,
"ABR": 4,
"MAI": 5,
"JUN": 6,
"JUL": 7,
"AGO": 8,
"SET": 9,
"OUT": 10,
"NOV": 11,
"DEZ": 12,
}
MES_ABBR = {numero: nome for nome, numero in MES_MAP.items()}
LINHAS_ORIGEM_EXCLUIDAS = {793}
COLUNAS_REGISTRO = [
"id_registro",
"linha_origem",
"data",
"data_iso",
"mes",
"mes_numero",
"om",
"aeronave",
"etapa",
"localidade_dep",
"localidade_arr",
"horario_z_dep",
"horario_z_arr",
"partida_utc",
"chegada_utc",
"tempo_voo",
"tempo_voo_min",
"segmento",
"pousos",
"missao",
"periodo",
"cruza_meia_noite",
]
COLUNAS_OAMRP = [
"id",
"orig",
"dest",
"partida_utc",
"chegada_utc",
"prioridade",
]
COLUNAS_CONSOLIDADAS = [
"id_registro",
"linhas_origem",
"data",
"data_iso",
"mes",
"mes_numero",
"om",
"aeronave",
"etapa_inicio",
"etapa_fim",
"localidade_dep",
"localidade_arr",
"horario_z_dep",
"horario_z_arr",
"partida_utc",
"chegada_utc",
"tempo_voo",
"tempo_voo_min",
"segmento_inicio",
"segmento_fim",
"pousos",
"missao",
"periodo",
"cruza_meia_noite",
"qtd_linhas_originais",
"tem_rota_intermediaria",
]
def texto_limpo(valor: Any) -> str:
if valor is None:
return ""
return str(valor).strip()
def normalizar_mes(valor: Any) -> str:
texto = texto_limpo(valor).upper().replace(".", "")
return texto
def parse_data(valor_data: Any, valor_mes: Any) -> date:
if isinstance(valor_data, datetime):
return valor_data.date()
if isinstance(valor_data, date):
return valor_data
texto = texto_limpo(valor_data).lower().replace(".", "")
if "/" not in texto:
raise ValueError(f"Data invalida: {valor_data!r}")
dia_txt, mes_txt = texto.split("/", 1)
mes_key = normalizar_mes(mes_txt or valor_mes)
if mes_key not in MES_MAP:
raise ValueError(f"Mes invalido: data={valor_data!r}, mes={valor_mes!r}")
return date(ANO, MES_MAP[mes_key], int(dia_txt))
def minutos_do_dia(valor: Any) -> tuple[int, int]:
"""Retorna (minutos no dia, deslocamento em dias informado pelo Excel)."""
if isinstance(valor, datetime):
return valor.hour * 60 + valor.minute, 0
if isinstance(valor, time):
return valor.hour * 60 + valor.minute, 0
if isinstance(valor, timedelta):
total_min = int(round(valor.total_seconds() / 60))
return total_min % (24 * 60), total_min // (24 * 60)
if isinstance(valor, (int, float)):
total_min = int(round(float(valor) * 24 * 60))
return total_min % (24 * 60), total_min // (24 * 60)
texto = texto_limpo(valor)
if ":" in texto:
partes = texto.split(":")
return int(partes[0]) * 60 + int(partes[1]), 0
raise ValueError(f"Horario invalido: {valor!r}")
def minutos_duracao(valor: Any) -> int:
if isinstance(valor, datetime):
return valor.hour * 60 + valor.minute
if isinstance(valor, time):
return valor.hour * 60 + valor.minute
if isinstance(valor, timedelta):
return int(round(valor.total_seconds() / 60))
if isinstance(valor, (int, float)):
return int(round(float(valor) * 24 * 60))
texto = texto_limpo(valor)
if ":" in texto:
horas, minutos = texto.split(":", 1)
return int(horas) * 60 + int(minutos)
raise ValueError(f"Duracao invalida: {valor!r}")
def hhmm(minutos: int) -> str:
minutos = minutos % (24 * 60)
return f"{minutos // 60:02d}:{minutos % 60:02d}"
def duracao_hhmm(minutos: int) -> str:
return f"{minutos // 60:02d}:{minutos % 60:02d}"
def iso_utc(data_voo: date, minutos: int, deslocamento_dias: int = 0) -> str:
data_hora = datetime.combine(data_voo, time()) + timedelta(
days=deslocamento_dias,
minutes=minutos,
)
return data_hora.isoformat(timespec="minutes") + ":00Z"
def atualizar_datas_do_registro(
registro: dict[str, Any],
partida: datetime,
chegada: datetime,
) -> None:
data_voo = partida.date()
registro["data"] = data_voo.strftime("%d/%m")
registro["data_iso"] = data_voo.isoformat()
registro["mes_numero"] = data_voo.month
registro["mes"] = MES_ABBR[data_voo.month]
registro["partida_utc"] = partida.isoformat(timespec="minutes").replace("+00:00", "") + ":00Z"
registro["chegada_utc"] = chegada.isoformat(timespec="minutes").replace("+00:00", "") + ":00Z"
registro["cruza_meia_noite"] = chegada.date() > partida.date()
def id_registro(linha: dict[str, Any]) -> str:
om = texto_limpo(linha["om"]) or "SEMOM"
etapa = texto_limpo(linha["etapa"]) or "SEMETAPA"
return f"RV2025_OM{om}_E{etapa}_L{linha['linha_origem']}"
def linha_tem_voo(ws: openpyxl.worksheet.worksheet.Worksheet, row: int) -> bool:
return any(ws.cell(row, col).value not in (None, "") for col in range(1, 13))
def extrair_registros() -> list[dict[str, Any]]:
wb = openpyxl.load_workbook(RAW_PATH, data_only=True)
ws = wb.active
registros: list[dict[str, Any]] = []
for row in range(4, ws.max_row + 1):
if row in LINHAS_ORIGEM_EXCLUIDAS:
continue
if not linha_tem_voo(ws, row):
continue
data_voo = parse_data(ws.cell(row, 1).value, ws.cell(row, 2).value)
mes = normalizar_mes(ws.cell(row, 2).value)
dep_min, dep_offset = minutos_do_dia(ws.cell(row, 8).value)
arr_min, arr_offset_excel = minutos_do_dia(ws.cell(row, 9).value)
arr_offset = max(arr_offset_excel, 1 if arr_min <= dep_min else 0)
tempo_min = minutos_duracao(ws.cell(row, 10).value)
registro = {
"linha_origem": row,
"data": data_voo.strftime("%d/%m"),
"data_iso": data_voo.isoformat(),
"mes": mes,
"mes_numero": MES_MAP.get(mes, ""),
"om": ws.cell(row, 3).value,
"aeronave": ws.cell(row, 4).value,
"etapa": ws.cell(row, 5).value,
"localidade_dep": texto_limpo(ws.cell(row, 6).value).upper(),
"localidade_arr": texto_limpo(ws.cell(row, 7).value).upper(),
"horario_z_dep": hhmm(dep_min),
"horario_z_arr": hhmm(arr_min),
"partida_utc": iso_utc(data_voo, dep_min, dep_offset),
"chegada_utc": iso_utc(data_voo, arr_min, arr_offset),
"tempo_voo": duracao_hhmm(tempo_min),
"tempo_voo_min": tempo_min,
"segmento": ws.cell(row, 11).value,
"pousos": ws.cell(row, 12).value,
"missao": texto_limpo(ws.cell(row, 35).value).upper(),
"periodo": texto_limpo(ws.cell(row, 36).value).upper(),
"cruza_meia_noite": arr_offset > dep_offset,
}
registro["id_registro"] = id_registro(registro)
registros.append(registro)
corrigir_continuidade_temporal(registros)
return registros
def corrigir_continuidade_temporal(registros: list[dict[str, Any]]) -> None:
grupos: dict[tuple[str, str], list[dict[str, Any]]] = {}
for registro in registros:
chave = (texto_limpo(registro["om"]), texto_limpo(registro["aeronave"]))
grupos.setdefault(chave, []).append(registro)
for linhas in grupos.values():
linhas.sort(key=lambda registro: registro["linha_origem"])
chegada_anterior: datetime | None = None
for registro in linhas:
partida = parse_iso_utc(registro["partida_utc"])
chegada = parse_iso_utc(registro["chegada_utc"])
while chegada_anterior is not None and partida < chegada_anterior:
partida += timedelta(days=1)
chegada += timedelta(days=1)
atualizar_datas_do_registro(registro, partida, chegada)
chegada_anterior = chegada
def escrever_csv(caminho: Path, colunas: list[str], linhas: list[dict[str, Any]]) -> None:
with caminho.open("w", newline="", encoding="utf-8-sig") as arquivo:
escritor = csv.DictWriter(arquivo, fieldnames=colunas)
escritor.writeheader()
escritor.writerows(linhas)
def escrever_xlsx(caminho: Path, colunas: list[str], linhas: list[dict[str, Any]]) -> None:
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "registro_voo_2025"
ws.append(colunas)
for linha in linhas:
ws.append([linha[coluna] for coluna in colunas])
ws.freeze_panes = "A2"
ws.auto_filter.ref = ws.dimensions
for column_cells in ws.columns:
letter = column_cells[0].column_letter
max_len = max(len(texto_limpo(cell.value)) for cell in column_cells)
ws.column_dimensions[letter].width = min(max(max_len + 2, 10), 28)
wb.save(caminho)
def preparar_oamrp(registros: list[dict[str, Any]]) -> list[dict[str, Any]]:
linhas = []
for registro in registros:
linhas.append(
{
"id": registro["id_registro"],
"orig": registro["localidade_dep"],
"dest": registro["localidade_arr"],
"partida_utc": registro["partida_utc"],
"chegada_utc": registro["chegada_utc"],
"prioridade": 1,
}
)
return linhas
def toca_rota(registro: dict[str, Any]) -> bool:
return "ROTA" in {registro["localidade_dep"], registro["localidade_arr"]}
def parse_iso_utc(valor: str) -> datetime:
return datetime.fromisoformat(valor.replace("Z", "+00:00"))
def consolidar_grupo(grupo: list[dict[str, Any]]) -> dict[str, Any]:
primeiro = grupo[0]
ultimo = grupo[-1]
tempo_total = sum(int(registro["tempo_voo_min"]) for registro in grupo)
pousos_total = sum(int(registro["pousos"] or 0) for registro in grupo)
missoes = sorted({registro["missao"] for registro in grupo if registro["missao"]})
periodos = sorted({registro["periodo"] for registro in grupo if registro["periodo"]})
linhas = [str(registro["linha_origem"]) for registro in grupo]
cruza_meia_noite = parse_iso_utc(ultimo["chegada_utc"]).date() > parse_iso_utc(
primeiro["partida_utc"]
).date()
return {
"id_registro": f"{primeiro['id_registro']}_C{ultimo['linha_origem']}",
"linhas_origem": ";".join(linhas),
"data": primeiro["data"],
"data_iso": primeiro["data_iso"],
"mes": primeiro["mes"],
"mes_numero": primeiro["mes_numero"],
"om": primeiro["om"],
"aeronave": primeiro["aeronave"],
"etapa_inicio": primeiro["etapa"],
"etapa_fim": ultimo["etapa"],
"localidade_dep": primeiro["localidade_dep"],
"localidade_arr": ultimo["localidade_arr"],
"horario_z_dep": primeiro["horario_z_dep"],
"horario_z_arr": ultimo["horario_z_arr"],
"partida_utc": primeiro["partida_utc"],
"chegada_utc": ultimo["chegada_utc"],
"tempo_voo": duracao_hhmm(tempo_total),
"tempo_voo_min": tempo_total,
"segmento_inicio": primeiro["segmento"],
"segmento_fim": ultimo["segmento"],
"pousos": pousos_total,
"missao": ";".join(missoes),
"periodo": ";".join(periodos),
"cruza_meia_noite": cruza_meia_noite,
"qtd_linhas_originais": len(grupo),
"tem_rota_intermediaria": any(toca_rota(registro) for registro in grupo),
}
def consolidar_transicoes_rota(registros: list[dict[str, Any]]) -> list[dict[str, Any]]:
grupos: dict[tuple[str, str], list[dict[str, Any]]] = {}
for registro in registros:
chave = (texto_limpo(registro["om"]), texto_limpo(registro["aeronave"]))
grupos.setdefault(chave, []).append(registro)
consolidados: list[dict[str, Any]] = []
for chave in sorted(grupos):
linhas = sorted(
grupos[chave],
key=lambda registro: (parse_iso_utc(registro["partida_utc"]), registro["linha_origem"]),
)
idx = 0
while idx < len(linhas):
atual = linhas[idx]
if not toca_rota(atual):
consolidados.append(consolidar_grupo([atual]))
idx += 1
continue
grupo = [atual]
idx += 1
while (
idx < len(linhas)
and toca_rota(linhas[idx])
and grupo[-1]["localidade_arr"] == linhas[idx]["localidade_dep"]
):
grupo.append(linhas[idx])
idx += 1
if grupo[-1]["localidade_arr"] != "ROTA":
break
consolidados.append(consolidar_grupo(grupo))
return sorted(consolidados, key=lambda registro: parse_iso_utc(registro["partida_utc"]))
def preparar_oamrp_consolidado(registros: list[dict[str, Any]]) -> list[dict[str, Any]]:
linhas = []
for registro in registros:
linhas.append(
{
"id": registro["id_registro"],
"orig": registro["localidade_dep"],
"dest": registro["localidade_arr"],
"partida_utc": registro["partida_utc"],
"chegada_utc": registro["chegada_utc"],
"prioridade": 1,
}
)
return linhas
def gerar_resumo(
registros: list[dict[str, Any]],
registros_consolidados: list[dict[str, Any]],
) -> dict[str, Any]:
faltantes = {
coluna: sum(registro[coluna] in (None, "") for registro in registros)
for coluna in COLUNAS_REGISTRO
}
return {
"arquivo_origem": str(RAW_PATH.relative_to(BASE_DIR)),
"ano_assumido": ANO,
"linhas_origem_excluidas": sorted(LINHAS_ORIGEM_EXCLUIDAS),
"registros_extraidos": len(registros),
"registros_consolidados": len(registros_consolidados),
"linhas_com_cruzamento_de_meia_noite": sum(
1 for registro in registros if registro["cruza_meia_noite"]
),
"linhas_com_rota": sum(1 for registro in registros if toca_rota(registro)),
"trechos_consolidados_com_rota": sum(
1 for registro in registros_consolidados if registro["tem_rota_intermediaria"]
),
"aeronaves": dict(sorted(Counter(r["aeronave"] for r in registros).items())),
"periodos": dict(Counter(r["periodo"] for r in registros)),
"meses": dict(Counter(r["mes"] for r in registros)),
"campos_faltantes": {k: v for k, v in faltantes.items() if v},
"observacoes": [
"Colunas REAL ate COM2 descartadas conforme criterio informado.",
"Horarios tratados como UTC/Z; nao houve conversao de fuso.",
"Chegadas iguais ou anteriores a partida foram deslocadas para o dia seguinte.",
"SEGMTO foi preservado da planilha original, sem recalculo.",
"Transicoes com ROTA tambem foram consolidadas em arquivo separado.",
"Prioridade OAMRP definida como 1 por falta de coluna de prioridade na origem.",
],
}
def main() -> None:
PREPROCESS_DIR.mkdir(parents=True, exist_ok=True)
PROCESSED_DIR.mkdir(parents=True, exist_ok=True)
registros = extrair_registros()
registros_consolidados = consolidar_transicoes_rota(registros)
escrever_csv(LIMPO_CSV, COLUNAS_REGISTRO, registros)
escrever_csv(PROCESSED_CSV, COLUNAS_REGISTRO, registros)
escrever_xlsx(LIMPO_XLSX, COLUNAS_REGISTRO, registros)
escrever_csv(CONSOLIDADO_CSV, COLUNAS_CONSOLIDADAS, registros_consolidados)
escrever_csv(PROCESSED_CONSOLIDADO_CSV, COLUNAS_CONSOLIDADAS, registros_consolidados)
escrever_csv(MISSOES_CSV, COLUNAS_OAMRP, preparar_oamrp(registros))
escrever_csv(
MISSOES_CONSOLIDADAS_CSV,
COLUNAS_OAMRP,
preparar_oamrp_consolidado(registros_consolidados),
)
with RESUMO_JSON.open("w", encoding="utf-8") as arquivo:
json.dump(
gerar_resumo(registros, registros_consolidados),
arquivo,
ensure_ascii=False,
indent=2,
)
arquivo.write("\n")
print(f"Registros extraidos: {len(registros)}")
print(f"Registros consolidados: {len(registros_consolidados)}")
print(f"CSV final: {PROCESSED_CSV.relative_to(BASE_DIR)}")
print(f"CSV consolidado: {PROCESSED_CONSOLIDADO_CSV.relative_to(BASE_DIR)}")
print(f"CSV OAMRP: {MISSOES_CSV.relative_to(BASE_DIR)}")
print(f"CSV OAMRP consolidado: {MISSOES_CONSOLIDADAS_CSV.relative_to(BASE_DIR)}")
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,48 @@
{
"arquivo_origem": "db\\raw\\Planilha 2025.xlsx",
"ano_assumido": 2025,
"linhas_origem_excluidas": [
793
],
"registros_extraidos": 1420,
"registros_consolidados": 1267,
"linhas_com_cruzamento_de_meia_noite": 53,
"linhas_com_rota": 288,
"trechos_consolidados_com_rota": 135,
"aeronaves": {
"2800": 329,
"2803": 513,
"2809": 212,
"2811": 366
},
"periodos": {
"NOTURNO": 247,
"DIURNO": 1145,
"NVG": 28
},
"meses": {
"JAN": 69,
"FEV": 179,
"MAR": 166,
"ABR": 184,
"MAI": 218,
"JUN": 144,
"JUL": 106,
"AGO": 36,
"SET": 160,
"OUT": 29,
"NOV": 66,
"DEZ": 63
},
"campos_faltantes": {
"pousos": 9
},
"observacoes": [
"Colunas REAL ate COM2 descartadas conforme criterio informado.",
"Horarios tratados como UTC/Z; nao houve conversao de fuso.",
"Chegadas iguais ou anteriores a partida foram deslocadas para o dia seguinte.",
"SEGMTO foi preservado da planilha original, sem recalculo.",
"Transicoes com ROTA tambem foram consolidadas em arquivo separado.",
"Prioridade OAMRP definida como 1 por falta de coluna de prioridade na origem."
]
}

View File

@@ -0,0 +1,57 @@
# Validacao do registro de voo 2025
Fonte validada: `db/processed/registro_voo_2025_consolidado.csv`
## Resultado geral
- Linhas originais limpas: 1420.
- Trechos consolidados: 1267.
- Linhas originais excluidas: 793.
- Trechos com `ROTA` no consolidado: 0.
- Transicoes `ROTA` convertidas para origem/destino reais: 135.
- Cruzamentos de 00Z preservados: 53.
- IDs duplicados: 0.
- Divergencias de duracao no registro original: 0.
- Trechos consolidados com tempo decorrido menor que tempo de voo: 0.
- Sobreposicoes temporais dentro da mesma OM/aeronave: 0.
## Pendencias encontradas
### 1. Trecho consolidado com pousos zero
Trecho consolidado:
| Campo | Valor |
| --- | --- |
| ID | RV2025_OM219_E4_L1108_C1109 |
| Linhas originais | 1108;1109 |
| Data | 01/09 |
| OM | 219 |
| Aeronave | 2809 |
| Trecho | SBCG -> SBCG |
| Tempo de voo | 01:50 |
| Pousos | 0 |
| Missao | 50TT04;50TT05 |
| Periodo | NOTURNO |
Classificacao: verificar se o pouso ficou ausente por estar dividido pela transicao `ROTA` em 00Z.
### 2. Quebras de continuidade espacial
Foram encontradas 3 quebras de continuidade espacial dentro da mesma OM/aeronave, sem sobreposicao temporal.
| OM | Aeronave | Trecho anterior | Trecho seguinte | Hipotese |
| --- | --- | --- | --- | --- |
| 7 | 2809 | SBMN -> SBBV | SBMN -> SBBV | Provavel erro ou duplicidade de origem; nao ha retorno registrado entre as duas etapas. |
| 55 | 2803 | SBUA -> SWMK | SWMQ -> SBUA | Provavel divergencia de digitacao entre `SWMK` e `SWMQ`. |
| 199 | 2800 | SBBR -> SBBR | SBGL -> SBAN | Lacuna operacional ou etapa ausente entre Brasilia e Galeao. |
## Decisao de validacao
O arquivo consolidado esta tecnicamente consistente para um primeiro uso no modelo OAMRP, desde que as 3 quebras espaciais sejam tratadas como excecoes conhecidas ou filtradas em uma rodada inicial.
Recomendacao para a primeira simulacao:
- Usar `db/pre_process/registro_voo_2025/missoes_oamrp_2025_consolidado.csv`.
- Comecar por janeiro/2025, pois o periodo contem apenas a quebra da OM 7 entre as pendencias de continuidade listadas.
- Manter as pendencias acima sem correcao automatica ate confirmacao humana.