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¶
- Tier mínimo:
PRO(PRO/BUSINESS/ENTERPRISE/ULTRA). Endpoints de ingestão fazemrequire_tier(...)— chaves FREE/STARTER recebem403 Forbidden. - Chave de API: emitida via console em
https://app.telecomtowerpower.com.br/keysou, para parceiros enterprise, viaPOST /admin/keys(uso interno). Prefixottp_identifica chaves de tenant;ttpci_é reservado para automação interna. - Onboarding mínimo do parceiro:
- hostname-pinning de
api.telecomtowerpower.com.br - rate-limit por padrão: o do plano da chave (Pro: 100 req/min) — solicitar
elevação em
sales@telecomtowerpower.com.brse for ingerir > 100k linhas/dia OBSERVATION_REQUIRE_SIGNATUREestá 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 paras3://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 viascripts/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). Usedata=rawemrequests, nãojson=payload. - 422
source=drivetest_* requires explicit values for: …— todo row comsourcecomeçando emdrivetest_precisa populartx_gain_dbi,rx_gain_dbi,cable_loss_db,rx_height_mexplicitamente (defaults só servem para sintético). - 403
Tier insufficient— endpoint exige PRO+. Confirme comGET /v1/usagequal o tier da chave. - 500 plain-text
Internal Server Error— bug interno (não exception handler do FastAPI). Reportar parasupport@telecomtowerpower.com.brcomX-Request-Idda response.