Ir para o conteúdo

Ingestão de observações de cobertura (link_observations)

Runbook para parceiros e operações: como submeter medições reais de RSSI ao TTP para alimentar o retreino do resíduo LightGBM.

A tabela link_observations é a fonte de verdade para o retreino supervisionado. Hoje a cadeia em produção é ITU-R P.1812/P.1411 + resíduo LightGBM, treinada só com medições reais de drive-test (GET /coverage/claim-readiness mostra a proveniência ao vivo); a engine Sionna ML está desligada. Cada observação real entra no retreino do resíduo e nas métricas de drift.

1. Provisionamento

  1. Tier mínimo: PRO (PRO/BUSINESS/ENTERPRISE/ULTRA). Endpoints de ingestão fazem require_tier(...) — chaves FREE/STARTER recebem 403 Forbidden.
  2. Chave de API: emitida via console em https://app.telecomtowerpower.com.br/keys ou, para parceiros enterprise, via POST /admin/keys (uso interno). Prefixo ttp_ identifica chaves de tenant; ttpci_ é reservado para automação interna.
  3. Onboarding mínimo do parceiro:
  4. hostname-pinning de api.telecomtowerpower.com.br
  5. rate-limit por padrão: o do plano da chave (Pro: 100 req/min) — solicitar elevação em sales@telecomtowerpower.com.br se for ingerir > 100k linhas/dia
  6. OBSERVATION_REQUIRE_SIGNATURE está off em produção; opt-in de assinatura é recomendado (peso 1.0 vs 0.1).

2. Endpoints

Método Caminho Uso
POST /coverage/observations 1 linha JSON (CoverageObservationInput).
POST /coverage/observations/batch até 10.000 linhas/request, com dedup.
POST /coverage/observations/drivetest upload de CSV (TEMS / G-NetTrack / QualiPoc / Anatel).
GET /coverage/observations/stats counts de link_observations e cell_signal_samples.

Schema de uma observação

Todos os campos são obrigatórios para source que começa com drivetest_ — defaults só valem para source=api/sintéticos.

{
  "tower_id": "ANATEL-12345",
  "tx_lat": -23.5505,
  "tx_lon": -46.6333,
  "tx_height_m": 32.0,
  "tx_power_dbm": 43.0,
  "tx_gain_dbi": 17.0,
  "rx_lat": -23.5612,
  "rx_lon": -46.6401,
  "rx_height_m": 1.5,
  "rx_gain_dbi": 0.0,
  "cable_loss_db": 1.8,
  "freq_hz": 2600000000,
  "observed_dbm": -92.3,
  "source": "drivetest_tems",
  "ts": 1747171800.0
}

Faixas validadas (Pydantic):

Campo Range
tx_lat, rx_lat [-90, 90]
tx_lon, rx_lon [-180, 180]
tx_height_m, rx_height_m (0, 500]
tx_power_dbm [0, 80]
cable_loss_db [0, 20]
freq_hz (1e6, 100e9]
observed_dbm [-150, 30]
source string ≤ 32 chars; convenção drivetest_<tool>

3. Assinatura HMAC (opt-in mas recomendado)

O endpoint /batch verifica X-Observation-Signature sobre o corpo cru da request. Chaves não assinadas entram com peso 0.1; assinadas com peso 1.0.

Segredo: derivado da própria API key, sem key-rotation separado. Desde

2714 existem duas derivações e o servidor aceita ambas enquanto

OBSERVATION_SIG_KDF=dual (o default):

Versão Derivação Estado
hkdf HKDF-SHA256(api_key, info=b"ttp-observation-signing-v1", length=32) preferida para clientes novos
legacy sha256("obs-sign:" + api_key) continua a verificar; é o que os field kits e miners em campo ainda assinam

O header não mudou: continua X-Observation-Signature: sha256=<hex> sobre o corpo cru. Uma assinatura verificada conta o contador observation_sig_kdf_total{version=…} — é essa métrica que autoriza o cutover: só quando version="legacy" estiver em ~0 em produção é que OBSERVATION_SIG_KDF=hkdf pode ser ligado. Ligá-lo antes disso não devolve erro: os uploads legados passam a contar com peso 0.1 em vez de 1.0, uma degradação silenciosa do drift.

Assinatura sem timestamp (mais simples):

PAYLOAD='{"observations":[…]}'
SECRET=$(printf '%s' "obs-sign:${TTP_API_KEY}" | openssl dgst -sha256 -binary | xxd -p -c 64)
SIG=$(printf '%s' "$PAYLOAD" | openssl dgst -sha256 -hmac "$(printf '%s' obs-sign:${TTP_API_KEY} | openssl dgst -sha256 -binary)" -hex | awk '{print $2}')

Em Python (recomendado, evita problemas de binding HMAC do openssl CLI):

import hashlib, hmac, json, requests

api_key = os.environ["TTP_API_KEY"]
payload = {"observations": [obs1, obs2, ...]}
raw = json.dumps(payload, separators=(",", ":")).encode("utf-8")

# Derivação preferida (hkdf). A legada — sha256("obs-sign:" + api_key) —
# continua aceita enquanto OBSERVATION_SIG_KDF=dual.
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF

secret = HKDF(
    algorithm=hashes.SHA256(),
    length=32,
    salt=None,
    info=b"ttp-observation-signing-v1",
).derive(api_key.encode("utf-8"))
sig = hmac.new(secret, raw, hashlib.sha256).hexdigest()

r = requests.post(
    "https://api.telecomtowerpower.com.br/coverage/observations/batch",
    data=raw,
    headers={
        "X-API-Key": api_key,
        "Content-Type": "application/json",
        "X-Observation-Signature": f"sha256={sig}",
    },
    timeout=30,
)
r.raise_for_status()

Com timestamp (recomendado em pipelines de produção; janela de skew = 300 s, env OBSERVATION_SIGNATURE_MAX_SKEW_SEC):

ts = int(time.time())
payload_to_sign = f"{ts}.".encode("utf-8") + raw
sig = hmac.new(secret, payload_to_sign, hashlib.sha256).hexdigest()

headers = {
    "X-API-Key": api_key,
    "Content-Type": "application/json",
    "X-Observation-Signature": f"sha256={sig}",
    "X-Observation-Signature-Ts": str(ts),
}

4. Dedup

Rows são deduplicadas por chave (tower_id, round(rx_lat,5), round(rx_lon,5), round(freq_hz), int(ts // window)), onde window = OBSERVATION_DEDUP_WINDOW_SEC (default 60 s). Dedup acontece tanto dentro do batch quanto contra o intervalo [min_ts - window, max_ts + window] no store. Resposta inclui:

{
  "ingested": 2451,
  "deduped_intra_batch": 12,
  "deduped_existing": 38,
  "submitted": 2501,
  "signature_status": "verified"
}

5. Upload de CSV drive-test

POST /coverage/observations/drivetest (multipart/form-data):

curl -X POST https://api.telecomtowerpower.com.br/coverage/observations/drivetest \
  -H "X-API-Key: $TTP_API_KEY" \
  -F "csv_file=@drive_test_2026-05-13.csv" \
  -F "tower_id=ANATEL-12345" \
  -F "device=tems"

Aliases de coluna auto-detectados (lat/lon, signal_dbm/rsrp/rscp/rxlev, freq_hz ou band_mhz). Mínimo: lat, lon, e uma coluna de RSSI. TX context vem do tower_id se fornecido; senão exige tx_lat/tx_lon/tx_height_m/tx_power_dbm como form fields.

6. Verificação

# ADMIN_API_KEYS é variável do serviço da API (`web`) no Railway, uma lista
# separada por vírgulas; use a primeira (o SSM da AWS foi aposentado, #410).
ADMIN_KEY="<primeira chave de ADMIN_API_KEYS>"

curl -sS -H "X-API-Key: $ADMIN_KEY" \
  https://api.telecomtowerpower.com.br/coverage/observations/stats
# {"link_observations": 0, "cell_signal_samples": 0}

7. Cadência de re-treinamento

O re-treinamento automático vive no workflow residual-drift-retrain.yml (LightGBM residual):

  • Cron a cada 6 h + workflow_dispatch; re-treina quando o drift dos dados rotulados ou o crescimento do store passam dos limiares do workflow, ou quando forçado manualmente.
  • Consome as observações reais ingeridas por este pipeline (link_observations). Se o candidato vence o artefato servido no gate de promoção, o workflow abre um PR com o booster e o sidecar; a promoção é o merge humano desse PR (Validação do modelo).
  • Runbook: docs/runbooks/residual-drift.md.

Nota histórica: o workflow mensal retrain-sionna.yml (Keras MLP → TFLite, upload para s3://telecom-tower-power-results/) foi retirado em 2026-07 na decomissão AWS (#410) — o bucket morreu com a conta encerrada. Treino Sionna hoje é manual via scripts/train_sionna.py.

8. Configuração relevante (env vars)

Variável Default Uso
OBSERVATION_DEDUP_WINDOW_SEC 60 janela temporal de dedup
OBSERVATION_SIGNED_WEIGHT 1.0 peso de drift para rows assinadas
OBSERVATION_UNSIGNED_WEIGHT 0.1 peso para rows sem assinatura
OBSERVATION_SIGNATURE_MAX_SKEW_SEC 300 janela aceita para ts
OBSERVATION_REQUIRE_SIGNATURE false se true, rejeita unsigned/invalid com 401
OBSERVATION_SIG_KDF dual derivações aceitas: legacy, dual ou hkdf. Um valor não reconhecido cai para dual — um typo não pode deixar a frota inteira pesando 0,1

9. Troubleshooting

  • 401 observation signature invalid — confira que o HMAC foi computado sobre o corpo cru (não o JSON re-serializado pelo cliente). Use data=raw em requests, não json=payload.
  • 422 source=drivetest_* requires explicit values for: … — todo row com source começando em drivetest_ precisa popular tx_gain_dbi, rx_gain_dbi, cable_loss_db, rx_height_m explicitamente (defaults só servem para sintético).
  • 403 Tier insufficient — endpoint exige PRO+. Confirme com GET /v1/usage qual o tier da chave.
  • 500 plain-text Internal Server Error — bug interno (não exception handler do FastAPI). Reportar para support@telecomtowerpower.com.br com X-Request-Id da response.