Skip to content

Validação técnica e limitações

Esta página é a fonte canônica de honestidade técnica do produto. Auditores, procurement Enterprise, reguladores e clientes em due-diligence devem ler esta página primeiro antes de qualquer material de marketing. Todos os números são reproduzíveis a partir do código (coverage_predict.py, itu_p1812.py, rf_engines/) e do endpoint autenticado GET /coverage/model/info.

Para detalhes profundos, ver também:


🏆 Claim de acurácia de campo LIVE (desde 2026-07-13)

O engine P.1812/P.1411 (híbrido) + resíduo LightGBM é treinado com dados reais de drive-test (G-NetTrack Pro com calibração de RX declarada, geometria de torres licenciada ANATEL/Mosaico) e está servindo predições em produção (model_source: "residual-lgbm"). P.1411 cobre os caminhos curtos (<250 m) que o P.1812 declina — o baseline efetivo (baseline_engine) é declarado no artefato e cruzado com esta prosa pelo sync. A tabela abaixo é gerada automaticamente do artefato commitado por scripts/sync_claim_surfaces.py — os números não podem divergir de GET /coverage/model/info.

Métrica (artefato real) Valor
cv_rmse_db (CV espacial, folds por célula h3_res8, k=5) 7,86 dB
Banda conformal 90% (q_abs_db, resíduos OOF reais) ±12,56 dB
Banda servida em produção (override de campo) de ±14,57 a ±22,12 dB conforme a classe de sinal — override de campo (recalibração automática de campo, decisão widen), aplicado em 2026-09-07 · cobertura medida ao vivo: GET /coverage/effective-band
n_observations 71.965 (100% drive-test real)
Envelope calibrado 5G NR n78 3,5 GHz + LTE B28 700 MHz/B1/B3/B7 (Vivo, TIM, Claro), suburbano/urbano de Brasília
baseline_engine p1812-p1411-hybrid (P.1812/P.1411)
data_provenance real
field_accuracy_claim true
Grau sole-source (dentro do envelope) não atingido — modelo em grau de triagem (gate: <8 dB e ≥5.000 obs. reais) · veredito ao vivo: GET /coverage/promotion-readiness
Verificação ao vivo GET /coverage/claim-readiness (público, métricas do artefato + banda servida) + GET /coverage/effective-band (público, banda aplicada + cobertura medida) + GET /coverage/model/info (autenticado)

Honestidade metodológica: o primeiro artefato candidato (2026-07-12) reportava 3,82 dB — número descartado por vazamento (KFold por linha embaralhada sobre fixes quase-duplicados de caminhada). Os números publicados vêm de folds agrupados espacialmente (H3 res-8), sem vazamento entre folds; a expansão multi-operadora (Vivo n78 + TIM B3, PR #1128) foi provada em holdout genuíno (rota TIM coletada após o treino: 6,22 dB).

Envelope calibrado (limitação honesta): declarado na tabela acima. Fora desse envelope o produto cai no fallback ridge (~14,9 dB CV, tabela abaixo) e recusa extrapolação. Meta seguinte: mais rotas/morfologias para promoção sole-source.

As seções abaixo sobre o modelo ridge de cobertura (~14,9 dB CV) permanecem válidas para consultas fora do envelope calibrado.


1. O que o produto faz, com honestidade

O TELECOM-TOWER-POWER (TTP) é uma plataforma de screening RF calibrada para o Brasil, vendida por assinatura mensal, com foco em:

  • Middle-market (WISPs, neutral-host, tower-co, integradoras, consultorias RF, projetos rurais/municipais) que não pagam seat ATOLL/Planet.
  • Tender screening e due-diligence M&A — avaliação rápida de portfólio de candidatos antes de gastar drive-test ou ciclo de ATOLL.
  • Bypass de fila interna Tier-1 quando o time RF da operadora precisa triagem oportunista, não como ferramenta oficial de design RAN.

Não é substituto de ATOLL, Planet, Asset ou CelPlan para planejamento RAN macro Tier-1, simulação de tráfego/capacidade multi-célula, ou calibração contínua por drive-test em escala nacional. Estes limites estão declarados no README — Positioning e na landing (seção Escopo & limitações).


2. Engine de propagação — estado atual real

O TTP não usa um único modelo. A engine é selecionada pelo campo engine da API (ou engine='auto' com critérios documentados em propagation-models.md):

Engine Método Tier mínimo Status
fspl FSPL + 1ª zona de Fresnel + knife-edge único (k=4/3) Free / Solo Default atual — preview e fallback
p1812 ITU-R P.1812-7 (Py1812, multi-knife-edge Deygout) Pro / Business GA — não é default ainda
itm Longley-Rice / Irregular Terrain Model (NTIA) Pro+ Disponível (rf_engines/itmlogic_engine.py)
sionna_rt Ray-tracing 3D (Mitsuba 3 / Dr.Jit GPU, materiais ITU-R P.2040) Enterprise / Ultra GA — assíncrono via AWS Batch GPU
sionna (ML) Modelo treinado (TFLite) Bloqueado por guard-rail — artefato v2 é 100% sintético

Limitação reconhecida. O default hoje (fspl) é o mais fraco dos três modelos físicos disponíveis. Para LOS curto (<5 km, antenas altas, terreno suave) é razoável; para NLOS, multi-obstáculo ou clutter denso é insuficiente.

Decisão de roadmap (em execução): promover p1812 a default no caminho de inferência (com fspl mantido como fallback determinístico e para preview rápido <100 ms). Ver ROADMAP — Engine default promotion.


3. Modelo ML de cobertura (coverage_model.npz) — números atuais

O que está em produção hoje

Item Valor
Algoritmo Ridge regression (NumPy), 17 features físicas + terreno
Versão atual ridge-v1
cv_rmse_db (holdout, k=5) ~14,9 dB
rmse_db (in-sample) ~12,8 dB
n_train ~12 k (sintético + ~1 k real seedado)
Fonte canônica GET /coverage/model/info + métrica Prometheus coverage_model_cv_rmse_db

O número de marketing histórico "RMSE 12,94 dB" corresponde ao rmse_db in-sample do snapshot inicial, não ao cv_rmse_db em holdout. O número que importa para procurement é o cv_rmse_db (~14,9 dB), reportado de forma estratificada por banda e morfologia em model-validation.md §3.

O que isso significa para o cliente

Caso de uso cv_rmse_db aceitável TTP entrega hoje?
Screening go/no-go de candidato (descartar links obviamente ruins) ≤ 15 dB
Ranking de portfólio para tender ≤ 12 dB ⚠️ apenas bandas baixas (700/850 MHz: ~8–10 dB)
Link budget contratual (parecer técnico, multa por SLA) ≤ 8 dB não — exige medição CW de campo, P.1812 GA, ou LightGBM residual (Q3/2026)
Licenciamento ANATEL como sole-source predictor ≤ 6 dB + drive-test validado não — a recomendação atual é cross-check com /coverage/engines/compare + medição

Procedente: o auditor que apontar que RMSE 12,94 dB é inaceitável para link budget contratual está tecnicamente correto. O produto não é vendido para esse caso de uso (ver Pricing — os tiers Enterprise/Ultra destacam screening e due-diligence, não link-budget oficial).


4. Dataset de treino — composição honesta

Camada Fonte Volume (mai/2026) Realidade
Seed sintético Sionna RT + P.1812 com σ=4 dB log-normal ~10–11 k linhas Cap do RMSE ≈ 6,5 dB (variância injetada do shadowing)
Observações reais (link_observations) POST /coverage/observations[/batch] + import drive-test ~1 k linhas Crescimento orgânico via API; ingestion plumbing entregue 2026-05
Validação independente k-fold (k=5, seed=42) sobre o set acima Não-estratificada por região IBGE ainda — limitação reconhecida

Limitações reconhecidas

  1. Maioria do dataset é sintética, e o seed sintético foi gerado pelo próprio P.1812 + Sionna. O cv_rmse_db reportado mede em grande parte "consigo reproduzir meu próprio simulador". Auditor regulatório está certo em descontar esse número.
  2. Holdout geográfico ausente. Hoje o k-fold é aleatório. Falta leave-one-region-out (N / NE / CO / SE / S) e leave-one-band-out. Sem isso, regiões sub-representadas (Amazônia, Pantanal) podem ter RMSE 20+ dB sem aparecer no agregado.
  3. Calibração por banda parcialmente entregue: coverage_model_<MHz>.npz por banda comercial brasileira, mas treinada com o mesmo dataset sintético-dominante.
  4. Guard-rail Sionna ML. O artefato v2 é 100% sintético — a engine sionna é propositalmente invisível em /coverage/engines enquanto synthetic_only=True no sidecar (propagation-models.md §"Sionna ML").

Critério de promoção (gate Phase-3 do roadmap)

  • ≥ 5 k observações reais com source ∈ {drive_test, drivetest_fleet}.
  • Estratificação por região IBGE + banda + morfologia.
  • cv_rmse_db < 8 dB no fold geográfico (leave-one-city-pair-out).
  • Sem regressão por tier nas métricas residuais (coverage_observation_residual_db_* no Prometheus).

Detalhe completo em ROADMAP — Phase 1/2/3.


5. Terreno e clutter

SRTM 90 m — limitação conhecida

A fonte de elevação default é SRTM3 (90 m). Em terreno acidentado brasileiro (Serra do Mar, Mantiqueira, Caparaó, Espinhaço) 90 m pode esconder knife-edges críticos para Fresnel.

  • Mitigação atual: capped knife-edge penalty no worker.py — viesa conservadoramente (rejeita alguns links marginais que fechariam), reduzindo falsos positivos ao custo de falsos negativos.
  • Roadmap: migrar para NASADEM 30 m (drop-in, mesmo formato .hgt, sem custo de licença) e oferecer ALOS PALSAR 12,5 m como upgrade Enterprise opcional para enlaces > 5 km em terreno classificado rural_mountainous.

MapBiomas clutter — escopo real

O sampler de uso/cobertura do solo (MapBiomas Coleção 9) está ativo em produção:

  • 10 classes one-hot (Forest / Savanna / Grassland / Pasture / Mosaic / Urban / Bare / Water / Soybean / Other).
  • Cache Redis (TTL 30 d) + LRU(8192) in-process.
  • /coverage/predict retorna clutter_class + clutter_label quando rx_lat/rx_lon são fornecidos.

Limitação: MapBiomas é land-cover (classe de uso do solo), não altura de edificações nem footprint 3D. Para enlaces urbanos o modelo aprende perda de clutter por classe, não por geometria. Cadastro 3D público de edificações urbanas brasileiras não existe na escala nacional (diferente de OS UK ou TomTom Munique). Para densamente urbano, a recomendação é Sionna RT 3D (Enterprise) com cena Mitsuba construída de OSM + alturas inferidas.


6. IA (Bedrock / Claude) — risco de alucinação e mitigações

A explicação em linguagem natural via /bedrock/chat e /bedrock/compare pode alucinar valores numéricos. LLM não é fonte de verdade para link margin.

Posição declarada

A IA explica o número computado pelo motor determinístico (/analyze ou engine selecionada). Ela não é autorizada a citar margem de enlace que não tenha vindo do motor.

Mitigações em produção

  1. Contrato estrito de I/O. O prompt do bedrock_service.py inclui o resultado numérico do motor + contexto físico, com instrução explícita de não inventar valores de margem (bedrock_service.py).
  2. Audit log imutável. Toda saída de IA passa pelo audit_log.py com hash da entrada + resposta. Auditor pode reproduzir qualquer resposta a partir do log.
  3. Disclaimer obrigatório em todo PDF e em toda resposta IA: "explicação automática não substitui parecer técnico de engenheiro responsável (CREA)."

Mitigações em backlog (gate Q3/2026)

  1. Guard determinístico server-side. Parsear resposta da IA, extrair valores numéricos citados, comparar com o resultado da engine; se divergência > 0,5 dB, substituir pelo número canônico antes de devolver ao cliente.
  2. Eval suite golden-cases. ~50 enlaces conhecidos (licenciados públicos no MOSAICO ANATEL com RSSI medido publicado) rodando em CI nightly. Falha se a IA citar margem fora de tolerância.

7. O que o cliente recebe na resposta da API (auditável)

Toda resposta de /coverage/predict, /coverage/engines/predict e /analyze inclui:

  • engine_used — qual modelo físico produziu o número.
  • confidence — 0,75 para modelos empíricos na banda calibrada, 0,55 para fallback FSPL.
  • cv_rmse_db — RMSE holdout do modelo ML usado, quando aplicável.
  • model_versionridge-v1 (ou versão futura).

Para qualquer cenário onde o cliente requer mais de uma engine como evidência, POST /coverage/engines/compare retorna a tabela lado-a-lado com todas as engines disponíveis. Este é o procedimento recomendado para parecer técnico ou submissão regulatória.


8. Validação cruzada — planejada (🟡 não disponível hoje)

Status honesto: o cv_rmse_db ~14,9 dB reportado em §3 vem de um k-fold k=5 com shuffle aleatório sobre o dataset misto (sintético + ~1 k observações reais). Esse fold não controla viés geográfico — um link do interior de SP pode estar em treino enquanto outro link do mesmo município está em validação. Para procurement Enterprise / tender Tier-1, isso não é suficiente. A validação cruzada estratificada por região está planejada, não entregue, e nenhum número regional é reportado nesta página até que o fold descrito abaixo seja rodado e auditado.

8.0 Procedência dos dados e selo de acurácia (entregue)

O artefato residual_lightgbm versionado no repositório foi treinado sobre dados sintéticos (sweep de ruído controlado), e isso agora é declarado explicitamente em vez de inferido:

  • O sidecar do booster carrega data_provenance ∈ {synthetic, real, mixed, unknown, none} (scripts/train_residual_model.py --provenance).
  • rf_engines/residual_model.accuracy_grade() deriva um selo:
  • data_provenance != "real" (ou cv_rmse ausente) ⇒ grade = "pipeline_validation_only" e field_accuracy_claim = Falseum CV-RMSE baixo sobre dados sintéticos nunca é uma alegação de acurácia de campo;
  • data_provenance == "real" ⇒ ladder planejamento (≤ 8 dB) / triagem (8–12 dB) / screening (> 12 dB) com field_accuracy_claim = True.
  • Esses campos são expostos na API em GET /coverage/model/info (residual_engine.data_provenance + accuracy_grade) e propagados ao bloco audit_gate / GET /health/audit-gate (field_accuracy_claim, data_provenance) — um gate verde sobre dados sintéticos não pode ser lido como alegação de campo calibrada.
  • Relatório reproduzível: scripts/residual_rmse_honesty_report.py demonstra que o piso de CV-RMSE sintético ≈ ruído injetado (cv_rmse / sigma ≈ 1.0) e, com --db-path, mede a perna real (guard MIN_REAL_OBSERVATIONS=200 para dados insuficientes).
  • Sentinela em CI: o workflow residual-honesty-gate roda o relatório com --gate em cada PR que toca o stack residual + cron semanal, falhando se algum fold sintético reivindicar acurácia de campo ou se o rmse_over_sigma escapar da banda plausível (sinal de vazamento de rótulo ou pipeline quebrado). A perna real liga-se sozinha quando um db_path com ≥ 200 observações reais é fornecido.

Leitura para o auditor: o pipeline ML, o gate de promoção e o selo de procedência existem e são honestos hoje. O selo field_accuracy_claim = True só é emitido quando o booster for retreinado sobre observações reais — o que depende da massa crítica descrita na §8.3.

8.0.1 Intervalos conformais — incerteza calibrada (entregue)

Além do ponto basic_loss_db, o motor residual agora emite uma banda de predição conformal distribution-free, calibrada nos resíduos out-of-fold da validação GroupKFold (cross-conformal / CV+). Como cada observação é pontuada pelo booster que nunca viu o seu grupo espacial, esses resíduos são escores de não-conformidade honestos — sem vazamento de treino.

  • Cálculo (scripts/train_residual_model._compute_conformal): para nível-alvo 1 − α (default α = 0.1, i.e. 90 %), a meia-largura é o quantil de amostra-finita

$$q = \mathrm{Quantil}_{\lceil (n+1)(1-\alpha) \rceil / n}\big(|r_i|\big),$$

onde $r_i = y_i - \hat y_i$ são os resíduos OOF. Sob trocabilidade, a banda simétrica $[\hat y - q,\ \hat y + q]$ garante cobertura marginal $\ge 1 - \alpha$. - Sidecar: bloco conformal com method = "cv_split_conformal_abs", alpha, coverage, q_abs_db (meia-largura), q_lo_db/q_hi_db (quantis assinados, p/ diagnosticar viés assimétrico), n_calibration e level. - Inferência (rf_engines/residual_model.conformal_interval): a perda pontual recebe extra["conformal"] = {loss_interval_db: [lo, hi], half_width_db, coverage, …}. O limite superior da banda é a maior perda (sinal mais fraco) — a leitura conservadora para link budget. - Honestidade preservada: o bloco carrega data_provenance e field_accuracy_claim, espelhando accuracy_grade(). Um intervalo calibrado sobre dados sintéticos valida a maquinaria de cobertura — não a cobertura de campo — e por isso sai com field_accuracy_claim = False. Só calibração sobre drive-test real emite uma banda com alegação de campo. - Exposto em GET /coverage/model/info (residual_engine.conformal) e em cada LossEstimate do motor residual_lgbm.

Validação de cobertura (auditável, entregue)

Uma banda só é honesta se a cobertura prometida for de fato verificada. O bloco conformal passa a registrar a cobertura empírica medida, e a quebra por região mede se a garantia sobrevive à transferência espacial:

  • Cobertura marginal (conformal.empirical_coverage): fração dos resíduos de calibração efetivamente cobertos por $\pm q$. Por construção do quantil conformal, $\text{empirical_coverage} \ge 1 - \alpha$ — torna a garantia auditável em vez de apenas afirmada. mean_width_db = 2q expõe a nitidez (uma banda larga que "cobre" não é de graça). No artefato sintético atual: empirical_coverage = 0.904 para coverage = 0.90 ✔.
  • Cobertura fora-de-região (cv_by_region[*].conformal_coverage): para cada macrorregião IBGE, a meia-largura é calibrada nas demais regiões e a cobertura é medida na região retida (mesma malha leave-one-region-out do RMSE por região). A trocabilidade é violada entre regiões, então este número honestamente revela se a banda de 90 % continua cobrindo ~90 % num território não visto. No artefato sintético atual a cobertura cai para 0.87–0.89 fora-de-região — o sinal explícito de que a garantia marginal enfraquece sob transferência espacial e de que calibração de campo por região é o caminho para fechá-la. Nunca é apresentado como paridade com ATOLL/Planet/CelPlan.

Bandas condicionais por região (Mondrian, entregue)

A cobertura fora-de-região acima mostra que uma única banda global sub-cobre em territórios não vistos. O reverso também é verdade: dentro de uma região a banda global é frequentemente larga demais. As bandas conformais Mondrian fecham os dois lados — estratificam os mesmos resíduos OOF honestos por macrorregião IBGE e calibram uma meia-largura dentro-da-região por região (scripts/train_residual_model._compute_mondrian_conformal):

  • Sidecar: conformal.by_region[<nome>] com region_code (N/NE/CO/SE/S), q_abs_db local, alpha, coverage, n_calibration, empirical_coverage e mean_width_db.
  • Inferência: quando um ponto cai numa macrorregião com banda local (o motor resolve a região do RX via br_macroregions.point_to_macroregion, com cache LRU e quantização de ~0,25° — custo pago somente quando o artefato traz bandas Mondrian), conformal_interval troca a meia-largura pooled pela local e marca mondrian = true + region. Cai silenciosamente para a banda marginal quando não há calibração de região utilizável (artefatos antigos, ou pontos fora do Brasil).
  • Garantia sob trocabilidade interna: a trocabilidade vale dentro de uma região (violada apenas entre regiões), então a banda local é o intervalo honesto para um ponto naquela região. No artefato sintético atual toda região atinge empirical_coverage = 0.92 (≥ 0.90 ✔) — a garantia se mantém localmente, ao contrário da transferência fora-de-região (0.87–0.89) — com meia-larguras que variam de 3.15 dB (Sul) a 4.24 dB (Centro-Oeste), heterogeneidade que a banda pooled de 3.29 dB mascara.
  • Honestidade preservada: o bloco by_region herda data_provenance/field_accuracy_claim do bloco conformal pai — uma banda de região calibrada sobre dados sintéticos nunca é uma alegação de campo.

Observabilidade da calibração conformal (Prometheus, entregue)

No carregamento do artefato o motor residual publica a calibração conformal como gauges Prometheus (best-effort — nunca afeta load/inferência), de modo que a saúde da banda de incerteza fica visível em produção sem depender de um request de predição:

  • residual_conformal_halfwidth_db{scope} — meia-largura conformal em dB. scope="marginal" para a banda pooled; scope="N|NE|CO|SE|S" para cada macrorregião IBGE quando o artefato traz bandas Mondrian.
  • residual_conformal_empirical_coverage{scope} — cobertura empírica medida no fold de calibração (mesma convenção de scope).
  • residual_conformal_nominal_coverage — cobertura nominal alvo (ex.: 0.9).
  • residual_conformal_regions — número de macrorregiões com banda Mondrian publicada (0 para artefatos marginais-apenas ou sem bloco conformal).

Quando o artefato é removido/desabilitado os gauges são zerados/limpos, de forma que um scrape com residual_conformal_nominal_coverage == 0 sinaliza motor residual ausente. Um afastamento sustentado entre empirical_coverage e nominal_coverage denuncia calibração degradada (drift de distribuição) antes de qualquer impacto silencioso na banda entregue ao usuário.

Resumo (TL;DR para auditor):

  • Método: 5-fold leave-one-region-out — treina em 4 macrorregiões, testa na 5ª (N / NE / CO / SE / S).
  • Métricas: RMSE, MAE e bias por fold + média ± desvio padrão entre folds.
  • Fontes: observation_store (drive-test reais ingeridos via POST /coverage/observations[/batch]) cruzado com ANATEL MOSAICO/SMP + OpenCellID para geometria do enlace.
  • Gate de promoção: cv_rmse_db < 8 dB em todas as 5 regiões.

Status atual: 🟡 desenhado e implementado em coverage_predict._kfold_evaluate (PR #58), aguardando massa crítica de observações reais por região antes da primeira execução publicável.

Implementação de referência (scripts/train_residual_model.leave_one_region_out_cv):

from sklearn.model_selection import LeaveOneGroupOut, cross_validate

logo = LeaveOneGroupOut()
groups = X["ibge_region"]  # 0=N 1=NE 2=CO 3=SE 4=S
scoring = {
    "rmse": "neg_root_mean_squared_error",
    "mae":  "neg_mean_absolute_error",
}
cv_results = cross_validate(model, X, y, groups=groups, cv=logo, scoring=scoring)
rmse = -cv_results["test_rmse"]
mae  = -cv_results["test_mae"]
print(f"RMSE: {rmse.mean():.2f} ± {rmse.std():.2f} dB")
print(f"MAE:  {mae.mean():.2f} ± {mae.std():.2f} dB")

Saída agregada que esta página publicará (linha Média ± std da tabela abaixo):

RMSE: <pendente> ± <pendente> dB
MAE:  <pendente> ± <pendente> dB

Quando a massa crítica de observações por região for atingida (§8.3), esta página publicará a tabela abaixo preenchida — uma linha por macrorregião como fold de teste, mais a linha agregada média ± std:

Fold de teste RMSE (dB) MAE (dB) n_observações
Norte (N) pendente pendente pendente
Nordeste (NE) pendente pendente pendente
Centro-Oeste (CO) pendente pendente pendente
Sudeste (SE) pendente pendente pendente
Sul (S) pendente pendente pendente
Agregado (5 folds) mean ± std: pendente ± pendente mean ± std: pendente ± pendente

A tabela é deliberadamente publicada com valores _pendente_ hoje. Auditor / procurement deve interpretar como: o pipeline existe, o gate existe, os números regionais não existem até que o dataset cumpra o critério da §8.3.

Breakdown de variância por região

Para tornar visível a heterogeneidade entre macrorregiões (não apenas a variância entre folds), o helper scripts/train_residual_model.leave_one_region_out_breakdown itera sobre os splits do LeaveOneGroupOut, treina/avalia região-por-região e devolve uma tabela com RMSE, MAE, desvio padrão do erro intra-região e contagem de amostras:

import numpy as np
import pandas as pd
from sklearn.metrics import mean_absolute_error, mean_squared_error
from sklearn.model_selection import LeaveOneGroupOut

logo = LeaveOneGroupOut()
groups = X["ibge_region"]  # 0=N 1=NE 2=CO 3=SE 4=S

regional_results = []
for train_idx, test_idx in logo.split(X, y, groups):
    region = X.iloc[test_idx]["ibge_region"].mode()[0]
    model.fit(X.iloc[train_idx], y.iloc[train_idx])
    y_pred = model.predict(X.iloc[test_idx])
    y_test = y.iloc[test_idx].values

    rmse = float(np.sqrt(mean_squared_error(y_test, y_pred)))
    mae  = float(mean_absolute_error(y_test, y_pred))
    error_std = float((y_test - y_pred).std())

    regional_results.append({
        "Região": region,
        "RMSE": round(rmse, 2),
        "MAE":  round(mae, 2),
        "Std":  round(error_std, 2),
        "Amostras": len(test_idx),
    })

df_regional = pd.DataFrame(regional_results)
print(df_regional.to_markdown(index=False))

Tabela publicada (preenchida quando o gate da §8.3 for atendido):

Região RMSE (dB) MAE (dB) Std (dB) Amostras
Norte pendente pendente pendente pendente
Nordeste pendente pendente pendente pendente
Centro-Oeste pendente pendente pendente pendente
Sudeste pendente pendente pendente pendente
Sul pendente pendente pendente pendente

A coluna Std é o desvio padrão dos resíduos (y_test - y_pred) dentro de cada região, ortogonal à coluna ± std da tabela anterior (que mede a variabilidade da métrica entre os 5 folds). Auditor deve interpretar:

  • RMSE / MAE altos numa região isolada → o modelo treinado nas outras 4 não generaliza bem para aquela geografia (clutter / clima / densidade diferentes).
  • Std alto numa região → mesmo dentro daquela região o erro é heteroscedástico (possivelmente concentrado em sub-morfologias específicas).
  • Amostras baixo numa região → o número da linha é estatisticamente fraco e não deve ser usado como evidência sozinho.

Visualização (scripts/plot_cv_regional_errorbars.py, valores ilustrativos até o gate da §8.3 ser atendido — barras = média, whiskers = ± Std intra-região):

Validação Leave-One-Region-Out

import seaborn as sns
import matplotlib.pyplot as plt

fig, ax = plt.subplots(1, 2, figsize=(12, 5))
sns.barplot(data=df_regional, x="Região", y="RMSE", ax=ax[0])
ax[0].errorbar(x=range(len(df_regional)), y=df_regional["RMSE"],
               yerr=df_regional["Std"], fmt="none", ecolor="black", capsize=4)
ax[0].set_title("RMSE por Região ± Std")

sns.barplot(data=df_regional, x="Região", y="MAE", ax=ax[1])
ax[1].errorbar(x=range(len(df_regional)), y=df_regional["MAE"],
               yerr=df_regional["Std"], fmt="none", ecolor="black", capsize=4)
ax[1].set_title("MAE por Região ± Std")

plt.tight_layout()
plt.savefig("docs-site/docs/operations/assets/cv_regional_errorbars.png")

Normalidade dos resíduos

Para validar se os intervalos de confiança calculados a partir de mean ± std são interpretáveis sob suposição gaussiana, o helper scripts/plot_residuals_normality.py agrega y_test - y_pred de todos os folds e reporta skewness, kurtosis e Shapiro-Wilk p-value, além de histograma + KDE e Q-Q plot:

Normalidade dos resíduos LOO-CV

import matplotlib.pyplot as plt
import scipy.stats as stats
import seaborn as sns

# residuals = concat(y_test_fold - y_pred_fold) para os 5 folds
print(f"Skewness: {stats.skew(residuals):.3f} | "
      f"Kurtosis: {stats.kurtosis(residuals):.3f}")
shapiro_stat, shapiro_p = stats.shapiro(residuals[:5000])
print(f"Shapiro-Wilk p-value: {shapiro_p:.4f}")

fig, axes = plt.subplots(1, 2, figsize=(14, 5))
sns.histplot(residuals, kde=True, ax=axes[0])
axes[0].set_title("Distribuição dos Resíduos")
stats.probplot(residuals, dist="norm", plot=axes[1])
axes[1].set_title("Q-Q Plot dos Resíduos")
plt.tight_layout()
plt.savefig("docs-site/docs/operations/assets/residuals_normality.png")

Métricas publicadas (preenchidas quando o gate da §8.3 for atendido — os valores abaixo vêm da execução ilustrativa sobre resíduos sintéticos):

Métrica Valor (ilustrativo) Leitura
Skewness pendente |skew| ≤ 0.5 → ~simétrico; > 1 → assimetria material
Kurtosis (excesso) pendente ≈ 0 → mesokúrtico; > 3 → caudas pesadas (subestima IC)
Shapiro-Wilk p-value pendente p < 0.05 rejeita normalidade; em n grande quase sempre rejeita — Q-Q plot manda

Auditor deve ler ambos juntos: se Shapiro rejeita mas Q-Q é aproximadamente diagonal e skew/kurt são pequenos, o mean ± std segue sendo uma síntese honesta. Se Q-Q se afasta nas caudas, intervalos de confiança gaussianos subestimam a probabilidade de erro extremo e o cv_rmse_gate da §8.3 deve ser endurecido com percentil P95/P99 do erro absoluto antes de ir para produção.

Percentis do erro absoluto (P50/P95/P99/max)

Fechando o loop aberto pelo parágrafo anterior: RMSE e mean ± std sintetizam o erro médio — não dizem o pior caso que um cliente real vai ver. Para um SLA do tipo "99 % das predições terminam dentro de X dB do valor medido" o gate precisa de percentis não-paramétricos do |y_test − y_pred|, agregados sobre todos os folds do LOO-CV. O helper scripts/plot_abs_error_percentiles.py materializa essa visão:

CDF e distribuição do erro absoluto LOO-CV

import matplotlib.pyplot as plt
import numpy as np
import seaborn as sns

# abs_err = np.abs(np.concatenate([y_test_fold - y_pred_fold for fold in folds]))
pcts = {p: float(np.percentile(abs_err, p)) for p in (50, 75, 90, 95, 99)}
pcts["max"] = float(abs_err.max())
for label, value in pcts.items():
    print(f"{label}: {value:.2f} dB")

fig, axes = plt.subplots(1, 2, figsize=(14, 5))
sns.histplot(abs_err, kde=True, ax=axes[0], bins=40)
for p in (50, 95, 99):
    axes[0].axvline(pcts[p], linestyle="--")
axes[0].set_title("Distribuição do |erro|")

sorted_err = np.sort(abs_err)
cdf = np.arange(1, len(sorted_err) + 1) / len(sorted_err)
axes[1].plot(sorted_err, cdf)
axes[1].set_title("CDF empírica do |erro|")
plt.tight_layout()
plt.savefig("docs-site/docs/operations/assets/abs_error_percentiles.png")

Tabela publicada (preenchida quando o gate da §8.3 for atendido — valores abaixo vêm da execução ilustrativa sobre resíduos sintéticos):

Métrica Valor (ilustrativo, dB) Leitura
P50 (mediana) pendente Erro típico — comparar com RMSE para detectar assimetria
P75 pendente Quartil superior — 25 % das predições terão erro acima desse valor
P90 pendente 1 em 10 predições piora a partir daqui
P95 pendente Candidato a SLA "soft" — mencionar em propostas comerciais
P99 pendente Candidato a gate de promoção — endurece o cv_rmse_db da §8.3
max pendente Pior caso observado no fold — útil para tail-risk mas sensível a outlier único

Interpretação para auditoria:

  • Se P99 / RMSE ≫ 2,5 → resíduos com cauda pesada, e o gate gaussiano da §8.3 (cv_rmse_db < 8 dB) não garante que 99 % das predições fiquem abaixo de 8 dB. Decisão de produto: trocar o gate para P99(|erro|) < 12 dB (ou similar, calibrado quando o dataset chegar).
  • Se P99 / RMSE ≈ 2 → resíduos aproximadamente gaussianos e o gate atual é defensável; ainda assim, publicar P95 numa proposta comercial é mais honesto que publicar só RMSE.
  • max isolado não vira gate — é métrica de monitoramento para detectar regressão entre releases (max subir > 30 % entre versões dispara revisão manual).
Campo Valor planejado
Tipo de fold Leave-one-region-out k=5, estratificado por macrorregião IBGE
Folds Norte / Nordeste / Centro-Oeste / Sudeste / Sul
Modelos avaliados ridge-v1 (atual) e p1812 + LightGBM residual (alvo Q3/2026)
Métrica primária cv_rmse_db por fold + média ± desvio padrão
Métricas secundárias MAE, bias (média do erro com sinal), por banda (700/850/1800/2600/3500 MHz)
Critério adicional Sem regressão por banda vs ridge-v1 em nenhuma região

8.2 Fontes de dados (apenas fontes auditáveis)

Fonte Tipo Uso no fold
link_observations (tabela interna, POST /coverage/observations[/batch]) Drive-test e medições CW reais ingeridas por clientes/parceiros Verdade-base (measured_dBm)
ANATEL MOSAICO / SMP Cadastro público de ERBs e licenciamento Geometria do enlace (Tx lat/lon, altura, EIRP declarado)
OpenCellID Crowdsourced cell tower database Cross-check de geometria e descoberta de Rx populados
SRTM 90 m / NASADEM 30 m NASA Perfil de terreno
MapBiomas Coleção 8 MapBiomas Brasil Clutter (uso e cobertura do solo)

Não usamos datasets proprietários não auditáveis, datasets cuja licença não permita uso comercial, nem datasets sem rastreabilidade de origem.

8.3 Gate de promoção

A promoção do par p1812 + LightGBM residual a engine default (substituindo ridge-v1 no caminho automático) é gateada por:

$$\text{cv_rmse_db}_{\text{LOO-região}} < 8\ \text{dB}$$

para todas as 5 macrorregiões, e sem regressão por banda vs ridge-v1 em nenhuma região. Este gate está vinculado em ROADMAP.md — Q2→Q3/2026 e implementado em coverage_predict._kfold_evaluate (branch feat/residual-model-skeleton, PR #58).

8.4 O que esta página vai publicar quando o fold rodar

Quando o fold for executado pela primeira vez (target: Q3/2026 início), esta seção será atualizada substituindo o status 🟡 por 🟢 e adicionando uma tabela com:

  • cv_rmse_db por região (N / NE / CO / SE / S) — média ± std.
  • Comparativo ridge-v1 vs p1812 + LightGBM residual por região.
  • Hash do commit que produziu os números + comando reprodutível.
  • Snapshot do dataset (n_real, n_synthetic, data do cut).

Até lá, nenhum número de validação cruzada regional aparece nesta página. Procurement que exige esses números antes da data planejada deve solicitar acesso ao branch e rodar o k-fold localmente — o código é parte do produto, não um anexo de marketing.

8.5 Camada de diagnóstico de resíduos — três gates estatísticos

A validação cruzada regional acima mede acurácia média. Não detecta duas patologias frequentes em modelos físicos+ML: resíduos com estrutura serial (o modelo está deixando sinal previsível na tabela de erros) e resíduos espacialmente correlacionados (o modelo está errando do mesmo jeito na mesma vizinhança geográfica). Para cobrir esses dois ângulos, existem três gates independentes:

Gate Pergunta que responde Teste Onde rodar
Ljung-Box ordenado Resíduos têm autocorrelação ao longo do tempo de medição ou ao longo da distância TX→RX? Q(h) ~ χ²(h), α=0.05 nos lags 10/20/40 scripts.train_residual_model.leave_one_region_out_ljung_box + scripts.calibrate_propagation_for_br.climate_promotion_gate
Moran's I Resíduos formam clusters geográficos (mesmo erro em torres vizinhas)? I com pesos KNN sobre distância haversine, z-score gaussiano scripts.calibrate_propagation_for_br.spatial_promotion_gate
CV temporal Performance degrada com o tempo (drift de hardware, refresh de rede, sazonalidade)? Forward-chaining TimeSeriesSplit k=5 scripts.train_residual_model.time_series_cv

Política dos gates (calibração de clima e promoção de resíduo)

  • Lag de decisão: 20 (cobre eventos >5σ em AR(1) com φ=0.6).
  • α: 0.05.
  • Tolerância: no máximo 1 das 5 macrorregiões pode rejeitar H0 em qualquer das duas ordenações (temporal ou distância) sem bloquear a promoção. Justificativa: com α=0.05 em 5×2=10 testes independentes sob H0, espera-se ~0.5 rejeição espúria por candidato; tolerar uma rejeição absorve esse falso positivo sem permitir um modelo realmente patológico. Endurecer para 0 faria o gate piscar.
  • Moran's I: mesma tolerância (1 de 5 regiões); regiões com n < k+2 produzem linha NaN e não contam como rejeitantes.

Por que três gates e não um

Cada teste responde uma pergunta que os outros dois não respondem:

  • Ljung-Box temporal detecta autocorrelação em série temporal (drive-test em sequência) mas é cego para pontos espacialmente próximos que foram medidos em campanhas distintas.
  • Ljung-Box distância detecta estrutura ao longo de um único caminho TX→RX; é cego para nuvens de pontos com múltiplos transmissores.
  • Moran's I cobre exatamente o gap acima — nuvens não ordenadas de pontos (lat, lon), como o cadastro Anatel completo de uma região.

Implementação e cobertura de testes

A camada está em:

  • rf_engines/diagnostics.py — Ljung-Box reimplementado em numpy + scipy.stats.chi2 (sem nova dependência top-level; validado bit-exato contra statsmodels.acorr_ljungbox em cross-check único). Funções: ljung_box, ljung_box_time_ordered, ljung_box_distance_ordered.
  • rf_engines/spatial_diagnostics.py — Moran's I com pesos KNN haversine, variância pela hipótese de randomização (Cliff & Ord 1981 §2.5). Função principal: morans_i (e morans_i_by_region para tabela por macrorregião).
  • Cobertura: 49 testes (tests/test_ljung_box_diagnostics.py, tests/test_spatial_diagnostics.py, tests/test_loo_region_ljung_box.py, tests/test_temporal_cv.py, tests/test_calibrate_climate_gate.py). Inclui ruído branco (não rejeita), AR(1)(φ=0.6) (rejeita em ≥ 4 de 5 regiões), clusters latitudinais (Moran's I rejeita), drift linear no tempo (CV temporal mostra RMSE crescente), e todas as validações de input (length mismatch, n insuficiente, variância zero).

Status: camada de diagnóstico implementada hoje; está pronta para gatear a promoção de rf_engines/br_climate.json e do artefato residual no momento em que a calibração e o treino reais rodarem (condicionado ao fechamento do contrato Tutela, issue #57 step 0).


9. Resumo — o que vai mudar (próximos 90 dias)

Em execução / merge planejado para 2026-Q2 final / Q3 início:

Mudança Por quê Status
Promover p1812 a engine default (com fspl como fallback) Eliminar a crítica "FSPL dominante no caminho default" Branch feat/residual-model-skeleton
LightGBM residual sobre P.1812 + features SRTM/clutter Alvo cv_rmse_db < 8 dB no fold geográfico Branch feat/residual-model-skeleton (PR #58 base)
Ridge-v1 → fallback determinístico Manter compatibilidade offline / cold-start Em rebase
Migrar SRTM 90 m → NASADEM 30 m Resolver Fresnel em terreno acidentado Backlog próximo sprint
Holdout estratificado leave-one-region-out Eliminar viés geográfico no cv_rmse_db Backlog próximo sprint
Guard determinístico anti-alucinação IA Eliminar risco regulatório Backlog próximo sprint

Ver ROADMAP.md para o cronograma vinculante.