Skip to content

Profissionalização: CI que roda, ingestão em lote real, OPC-UA plugado e vitrine no padrão - #4

Merged
Roberton003 merged 6 commits into
masterfrom
chore/profissionalizacao-ci-idempotencia
Jul 27, 2026
Merged

Roberton003 merged 6 commits into
masterfrom
chore/profissionalizacao-ci-idempotencia

Conversation

@Roberton003

Copy link
Copy Markdown
Owner

Contexto

O repositório já tinha substância técnica — adapters Modbus/OPC-UA, regras de qualidade, dashboard HTMX, OTel opcional. O problema era que o que estava público não correspondia ao que o código fazia.

O que estava quebrado

Gap Evidência
O CI nunca rodou ci.yml disparava em main; o branch default é master. Os jobs de lint e de teste com Postgres nunca executaram uma única vez.
Ingestão em lote decorativa evaluate_and_alert() fazia um INSERT por leitura; o bulk_create() seguinte era no-op silencioso sobre objetos já com pk. Reprocessar a mesma janela estourava IntegrityError.
Docs contradizendo o código replay-idempotency.md afirmava "deduplicação não existe" com a UniqueConstraint(sensor, timestamp) no modelo desde a migration 0004.
Sem LICENSE Repo público sem licença = todos os direitos reservados.
Trabalho não publicado Todo o refactor de sources/ + 184 linhas de teste só existiam no disco local.
Vazamento de caminho local manual_validacao_ponta_a_ponta.md expunha o caminho absoluto do disco do autor, 4×.

O que mudou

CI (3c85544, 6a4d07f) — dois workflows sobrepostos consolidados em um, no branch certo: ruff + Postgres 16 + manage.py check + makemigrations --check + check --deploy informativo.

Ingestão (6a4d07f) — raise_alert() extraído de evaluate_and_alert(). O loop agora avalia em memória (evaluate_reading() é pura), persiste o lote uma vez e alerta depois. ignore_conflicts=True finalmente faz o que promete.

Teste negativo — test_replay_same_window_is_idempotent foi verificado falhando sem o fix, com IntegrityError: UNIQUE constraint failed. Guardrail só existe depois de observar o bloqueio.

Vitrine (30373fb) — LICENSE MIT; README em pt-BR nas 10 seções do padrão, com diagrama Mermaid nativo e badge de CI (agora honesto); wiki alinhada, incluindo página nova de Idempotência e a menção a OPC-UA que faltava.

Compose (eff8155) — validando docker compose up --build numa máquina com Postgres e collector OTLP locais, o stack não subia (colisão em 5432, depois 4317), e com o Jaeger fora do ar todo manage.py no container inundava a saída com erros de export. Portas agora sobrescrevíveis por variável.

Verificação

  • 73 testes verdes, ruff limpo, migrations em sincronia
  • Docker end-to-end em portas alternativas: dashboard HTTP 200, ingestão limpa, /api/summary/ com dados, Jaeger reportando o serviço labtelemetry
  • Comandos de setup do README executados antes de serem documentados

Fora de escopo (registrado, não feito)

  • OpcUaAdapter existe e tem testes, mas ingest_telemetry tem choices=["modbus", "simulator"] — inalcançável na prática
  • Modbus lê registradores uint16 crus, sem escala/decodificação float32
  • Deps não pinadas, sem pre-commit, sem gate de coverage

- Adapters Modbus/OPC-UA/Simulator sob a ABC TelemetrySource
- UniqueConstraint(sensor, timestamp) substitui o indice nao-unico
- simulate_telemetry substitui o comando duplicado telemetry_simulate
- +184 linhas de teste (72 testes verdes)
- Remove .last-handoff.json do versionamento (artefato de processo interno)
CI: o workflow disparava em `main` mas o branch default e `master` — os jobs
de lint e de teste com Postgres nunca executaram. Consolida os dois workflows
em um, no branch certo, com check --deploy informativo.

Ingestao: `evaluate_and_alert()` fazia um INSERT por leitura e o
`bulk_create()` seguinte virava no-op silencioso sobre objetos ja com pk.
Pior, reprocessar a mesma janela estourava IntegrityError na UniqueConstraint.
Extrai `raise_alert()` de `evaluate_and_alert()`; o loop agora avalia em
memoria, persiste o lote uma vez e alerta depois. Contador renomeado para
`total_processed` — `ignore_conflicts` nao devolve pks confiaveis.

Docs: `replay-idempotency.md` afirmava "deduplicacao nao existe" com a
UniqueConstraint ja no modelo desde a migration 0004. Reescrito para descrever
a garantia real e seus limites. Mesma correcao em `data-contract.md`.

Teste negativo: `test_replay_same_window_is_idempotent` verificado falhando
com IntegrityError sem o fix. 73 testes verdes.
- LICENSE MIT (o repo era publico sem licenca — todos os direitos reservados)
- README reescrito em pt-BR nas 10 secoes do padrao: header centralizado com
  badges reais (incluindo CI, agora que ele roda), diagrama Mermaid nativo no
  lugar do bloco de texto, tabela de endpoints, estrutura do projeto e
  fronteiras de escopo explicitas
- Wiki alinhada: pt-BR, Home com Sumario de Documentacao, pagina nova
  Idempotencia-e-Replay, mencao a OPC-UA que faltava nos adapters
- Remove o caminho local absoluto vazado 4x no manual de validacao

Comandos de setup executados de verdade antes de documentar: migrate, ingest,
/api/summary/, /api/health/sources/ e dashboard (HTTP 200).
Validando `docker compose up --build` numa maquina com Postgres e collector
OTLP locais, o stack nao subia: colisao em 5432, depois em 4317. Com o Jaeger
fora do ar, ele nunca entrava na rede do compose e todo comando `manage.py`
dentro do container inundava a saida com erros de export de span.

Portas publicadas agora usam `${VAR:-default}` — comportamento identico por
padrao, sobrescrevivel sem editar o compose. Documentado no README e no
.env.example.

Verificado end-to-end em portas alternativas: dashboard HTTP 200, ingestao
limpa, /api/summary/ com dados e Jaeger reportando o servico `labtelemetry`.
O OpcUaAdapter existia, tinha testes e servidor de teste, mas
`ingest_telemetry` tinha `choices=["modbus", "simulator"]` — era inalcancavel
na pratica, enquanto README e wiki anunciavam OPC-UA como fonte.

O que impedia plugar direto: o adapter emitia `sensor_id=idx` (indice
posicional do node), e `_sample_to_reading` trata esse campo como chave
primaria de TelemetrySensor. Node 0 vira "sensor 0", que ou nao existe ou e o
sensor errado — dado plausivel e silenciosamente incorreto.

- `OpcUaAdapter` aceita `sensor_ids` paralelo a `node_ids`, validado 1:1.
  Sem ele, mantem o indice posicional (compativel com os testes existentes).
- `--source opcua` com `--opcua-url`, `--opcua-timeout` e `--opcua-node`
  repetivel no formato `NODE_ID:SENSOR_ID`. Split no ultimo `:`, ja que node
  ids contem `=` e `;`. Sem mapeamento, o comando recusa iniciar.
- `_sample_to_reading` avisa quando o parametro da fonte diverge do sensor,
  sem descartar a leitura. Vale para as tres fontes.
- `/api/health/sources/` passa a incluir opcua — README e wiki afirmavam
  cobertura das tres fontes, o endpoint so devolvia duas.

Verificado contra servidor OPC-UA real, fora dos testes: recusa sem
mapeamento, grava PH=7.0 no sensor 1 e TOC=5.0 no sensor 5 com lineage, e o
guard dispara ao apontar o node de pH para o sensor de TOC.

79 testes verdes (6 novos), ruff limpo.
@Roberton003

Copy link
Copy Markdown
Owner Author

Adendo: OPC-UA plugado (ce16377)

O PR original listava isto como fora de escopo. Agora está dentro.

Por que não era só adicionar uma opção ao --source

O OpcUaAdapter emitia sensor_id=idx — o índice posicional do node. Mas _sample_to_reading() trata esse campo como chave primária de TelemetrySensor. Node 0 vira "sensor 0", que ou não existe ou é o sensor errado: dado plausível e silenciosamente incorreto, a pior classe de bug em telemetria.

O mesmo vale para o ModbusTCPAdapter (sensor_id = índice do registrador). Ele passa nos testes porque o adapter é stubado; contra um CLP real tem a mesma fragilidade. Não corrigi o Modbus neste PR — fora do escopo pedido, mas fica registrado.

O que foi feito

  • OpcUaAdapter aceita sensor_ids paralelo a node_ids, validado 1:1. Sem ele, mantém o índice posicional — os 3 testes existentes seguem válidos.
  • --source opcua com --opcua-url, --opcua-timeout e --opcua-node repetível, formato NODE_ID:SENSOR_ID. O split é no último :, já que node ids contêm = e ; (ns=2;i=101). Sem mapeamento, o comando recusa iniciar.
  • Guard de coerência em _sample_to_reading(): avisa quando o parâmetro da fonte diverge do sensor, sem descartar a leitura. Vale para as três fontes.
  • /api/health/sources/ passa a incluir opcua — README e wiki afirmavam cobertura das três fontes, o endpoint devolvia duas.

Verificação

Contra servidor OPC-UA real, fora da suíte de testes:

Cenário Resultado
Sem --opcua-node Recusa iniciar com mensagem acionável
Spec malformado (ns=2;i=101) Recusa, aponta o formato esperado
Mapeamento correto PH=7.0 → sensor 1, TOC=5.0 → sensor 5, com lineage opcua:opc.tcp://...
Node de pH → sensor de TOC Sensor 5 e TOC, mas a fonte enviou PH — verifique o mapeamento

79 testes verdes (6 novos, incluindo integração contra servidor OPC-UA real, rodada 3× para checar flakiness), ruff limpo, CI verde nos dois jobs.

@Roberton003 Roberton003 changed the title Profissionalização: CI que roda, ingestão em lote real e vitrine no padrão Profissionalização: CI que roda, ingestão em lote real, OPC-UA plugado e vitrine no padrão Jul 27, 2026
…scala

Mesma classe de bug corrigida no OPC-UA: o adapter emitia `sensor_id` igual
ao indice do registrador (0/1/2 via PARAMETER_MAP), e `_sample_to_reading`
trata esse campo como chave primaria de TelemetrySensor. Contra um CLP real,
o registrador 0 tentaria gravar no "sensor 0", que nunca existe — o
autoincrement do Django comeca em 1.

Junto vinha um segundo erro, esse silencioso: holding register e uint16 e o
valor era usado cru. Um pH de 7.40 e publicado pelo CLP como 740, entrava no
banco como pH 740 e caia em OUT_OF_BOUNDS — errado de um jeito que so aparece
no grafico. A escala e o mesmo tipo de ajuste que `calibration_factor` faz um
nivel acima: uma corrige o protocolo, a outra o sensor.

- `RegisterSpec(address, sensor_id, scale)` e o parametro `registers` no
  ModbusTCPAdapter. Sem ele, cai no caminho legado (bloco 0-2), preservado
  so para inspecao rapida de um CLP e documentado como tal.
- `--modbus-register "ADDRESS:SENSOR_ID[:SCALE]"`, repetivel. Escala omitida
  vale 1.0. Sem mapeamento, o comando recusa iniciar.
- Le um registrador por vez em vez de um bloco: enderecos esparsos tornam a
  leitura em bloco invalida em muitos CLPs. Ceiling e upgrade anotados no
  codigo.
- Registrador que falha e pulado sem derrubar os demais do ciclo.

85 testes verdes (6 novos), ruff limpo.
@Roberton003

Copy link
Copy Markdown
Owner Author

Adendo 2: Modbus corrigido (9e96c5d)

Fecha o gap que o adendo anterior deixou registrado.

Dois bugs, um deles silencioso

1. Índice de registrador tratado como chave primária. O adapter emitia sensor_id = índice do registrador (0/1/2 via PARAMETER_MAP). Contra um CLP real, o registrador 0 tentaria gravar no "sensor 0" — que nunca existe, porque o autoincrement do Django começa em 1. Passava nos testes só porque o adapter era stubado.

2. Holding register é uint16, e o valor era usado cru. Um pH de 7.40 é publicado pelo CLP como 740. Sem fator de escala, entrava no banco como pH 740:

Leitura bruta Escala Valor gravado Dentro de 6.0–8.5?
740 — (bug) pH 740.0 ❌
740 0.01 pH 7.40 ✅

Esse é o pior dos dois: não falha, só produz série temporal errada. A escala é o mesmo tipo de ajuste que calibration_factor faz um nível acima — uma corrige o protocolo, a outra o sensor.

O que foi feito

  • RegisterSpec(address, sensor_id, scale) e o parâmetro registers no ModbusTCPAdapter. Sem ele, cai no caminho legado (bloco 0–2), preservado só para inspeção rápida de um CLP e documentado como tal.
  • --modbus-register "ADDRESS:SENSOR_ID[:SCALE]", repetível. Escala omitida vale 1.0. Sem mapeamento, o comando recusa iniciar — simétrico ao --opcua-node.
  • Uma leitura por registrador em vez de um bloco 0..N: endereços esparsos tornam a leitura em bloco inválida em muitos CLPs (registrador não mapeado no meio do span). O ceiling e o caminho de upgrade estão anotados no código.
  • Registrador que falha é pulado sem derrubar os demais do ciclo.

85 testes verdes (6 novos), ruff limpo, CI verde nos dois jobs.

Com isso as três fontes têm o mesmo contrato: mapeamento explícito, recusa de iniciar sem ele, e guard de coerência de parâmetro na ingestão.

@Roberton003
Roberton003 merged commit 161f556 into master Jul 27, 2026
2 checks passed
@Roberton003
Roberton003 deleted the chore/profissionalizacao-ci-idempotencia branch July 27, 2026 22:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant