Skip to content

Fontes de dados externos — ANATEL e OpenCelliD

Este documento descreve, de forma auditável e honesta, de onde vêm os dados de torres usados pela plataforma, quais são as limitações de cada fonte, e quais controles compensatórios foram adotados.

Matriz de fontes

Fonte Tipo Origem real SLA upstream Cadência Resiliência
ANATEL ERBs externa Planilha XLSX obtida via LAI, redistribuída por LuSrodri/ERBs_per_city_per_operators_brazil nenhum (curadoria manual a montante) cron 4x/dia (00:05/06:05/12:05/18:05 UTC) via .github/workflows/pull-anatel.yml; re-ingere apenas se SHA do XLSX upstream mudar — o ciclo sem mudança é uma única chamada à Contents API, sem download nem escrita geocodificação com cache local; dedupe 443k→105k; sync-state em S3; Slack
OpenCelliD MCC-724 externa https://opencellid.org/ (API com token) nenhum (serviço gratuito, rate-limited) cron 4x/dia (00:25/06:25/12:25/18:25 UTC) via .github/workflows/pull-opencellid.yml; sem skip por mudança de conteúdo — todo ciclo tenta baixar (~1,2 MB gz) e reexecuta o COPY idempotente. A quota do vendor está medida em 2 downloads por ficheiro por dia, portanto só os dois primeiros ciclos de cada dia UTC ingerem; os outros dois publicam skipped_rate_limited (ciclo verde, sem ingestão) 4 tentativas totais (3 retries) com backoff exponencial (30s/60s/120s); o fallback de cache local vale só para execução de operador — o runner de CI é efêmero, então um ciclo estrangulado pela quota publica skipped_rate_limited e preserva o last_ingest_at e o towers_in_db_after anteriores em vez de zerar o relógio ou a contagem — um download recusado não verificou nada, logo não pode creditar o relógio nem apagar a última medição; sync-state em S3; Slack
AWS RDS → Railway replicação interna (APOSENTADA) sync-towers.yml foi desativado (#501): o Postgres do Railway é a DB primária e os crons de ingestão escrevem nela diretamente

ANATEL — o que é e o que não é

O que é: snapshot manual de uma planilha Excel publicada via LAI (Lei de Acesso à Informação), redistribuída por um repositório público de terceiro. Cada execução de load_anatel.py --file ERBs_com_equipamentos_v2.xlsx carrega 443.396 registros brutos, deduplica para 105.240 estações únicas, geocodifica 5.570 municípios brasileiros via Nominatim (com cache em geocode_cache_br.json) e popula a tabela towers.

O que não é: não é integração em tempo real com sistemas.anatel.gov.br. Não existe API pública oficial da ANATEL para ERBs com coordenadas geográficas. Cabe ao operador da plataforma atualizar a planilha periodicamente.

Limitação honesta: o repositório upstream depende de curadoria manual de um terceiro. A plataforma detecta novas versões automaticamente via GitHub Contents API (compara o SHA do blob com o último ingerido, persistido em s3://.../snapshots/anatel-pull/_state.json) e re-ingere apenas quando o upstream muda. O endpoint /status/data-freshness expõe age_hours desta última ingestão e flag stale=true quando excede 240h (10 dias).

OpenCelliD — limitações conhecidas

O que é: API gratuita de uma base colaborativa de células rádio mantida pela comunidade. Token de autenticação obrigatório (registro gratuito em opencellid.org).

Limitações:

  • Sem SLA. Serviço já passou por períodos de instabilidade e mudanças de política de acesso historicamente.
  • Rate-limited: a resposta RATE_LIMITED indica cota excedida. A cota está medida em 2 downloads por ficheiro por dia ("we only allow 2 downloads per file, per day"), o que com o cron 4x/dia torna os ciclos 3 e 4 uma recusa determinística — um skipped_rate_limited nomeado, não uma falha intermitente.
  • Cobertura é colaborativa — qualidade varia por região.

Controles compensatórios (load_opencellid.py::download_brazil_csv):

  1. Timeout de 300s no requests.get.
  2. Retry com backoff exponencial: 4 tentativas totais (3 retries), esperas 30s/60s/120s entre tentativas, apenas em erros transitórios (ConnectionError, Timeout, HTTPError 5xx/429). Erros 4xx (exceto 429) falham imediatamente para não mascarar token inválido.
  3. Cache local persistente em .opencellid_cache/724.csv.gz. Acionado automaticamente em:
    • resposta RATE_LIMITED,
    • exaustão das 3 tentativas com erro de rede.
  4. Validação de magic bytes do gzip — qualquer arquivo que não seja gzip válido aborta a ingestão (defesa contra HTML/JSON disfarçado).

Replicação AWS RDS → Railway (aposentada)

O cron .github/workflows/sync-towers.yml foi desativado em #501: com a migração AWS→Railway concluída, o Postgres do Railway é a base primária de torres e os crons de ingestão (ANATEL/OpenCelliD) escrevem nela diretamente — não existe mais lag de replicação a monitorar. O endpoint /status/data-freshness mantém a chave tower_sync com status: "retired" por compatibilidade de shape da API.

Endpoint /status/data-freshness

Exposto pela API. Lê os sync-state JSONs das duas fontes ativas (anatel-pull e opencellid-pull, ambas 4x ao dia) do storage S3-compatível (MinIO do Railway em produção, via S3_ENDPOINT_URL) com cache em memória (TTL 5 min). Devolve por fonte: status, idade, threshold de staleness e flag stale. Campo any_stale resume as fontes ativas e é o gatilho do gate 409 (Fase C).

Thresholds:

  • anatel_pull: > 18h → stale (cron 4x/dia; 3x o período de 6 h). O ciclo no-op publica skipped_unchanged, cuja base de idade é o instante da verificação, então a idade em regime é ≤ 6 h e 18 h ainda absorve dois ciclos perdidos seguidos.
  • opencellid_pull: > 36h → stale (cron 4x/dia; 6x o período). Só um ciclo bem-sucedido (ingested) move o relógio: o skip de quota (skipped_rate_limited) e uma falha (failed) preservam o last_ingest_at anterior, porque um download recusado não verificou nada — ao contrário do skipped_unchanged da ANATEL, não pode pedir emprestada a hora da verificação. Com a quota medida em 2 downloads/ficheiro/dia, o regime são dois ingests reais por dia (00:25 e 06:25 UTC), ou seja ≤ 18 h entre ingests, e as 36 h absorvem um dia inteiro perdido — ainda um aperto de 6,7x em relação aos 240 h antigos.

Os dois thresholds são acoplados mecanicamente ao cron por tests/test_ingest_cadence_slo_copy_invariant.py: mover um sem o outro reprova o CI.

Exemplo de resposta abreviada:

{
  "sources": [ { "name": "ANATEL ERBs (LAI XLSX via LuSrodri)", "...": "..." } ],
  "anatel_pull": {
    "status": "ok",
    "last_completed_at": "2026-07-13T08:07:42Z",
    "age_hours": 112.4,
    "stale": false
  },
  "tower_sync": { "status": "retired", "stale": false },
  "towers_in_db": 105240
}

Gaps reconhecidos

  • ANATEL upstream é curadoria manual de terceiro — não há mecanismo contratual para garantir cadência. Mitigação: cron 4x/dia automatizado detecta novas versões via SHA do GitHub upstream; endpoint expõe idade da última ingestão; alerta operacional via Slack em falhas.
  • OpenCelliD é serviço gratuito sem SLA. Mitigação: cron 4x/dia + cache local + retry/backoff. Em caso de descontinuação prolongada, será necessária outra fonte (avaliação aberta).
  • A ingestão é agendada e auditável via workflows .github/workflows/ pull-anatel.yml e .github/workflows/pull-opencellid.yml, com sync-state JSON no MinIO do Railway (S3-compatível) e Slack notify. A publicação do sync-state é fatal em falha — uma publicação silenciosamente quebrada já deixou o gate de staleness desarmado por semanas (bucket AWS da conta encerrada).

Referências de código