Pular para conteúdo

NILM — Desagregação Não Intrusiva de Cargas

Este documento descreve o algoritmo de NILM (Non-Intrusive Load Monitoring) do Mileora, a lógica de recomendação de horário de uso derivada dele, o diagrama de arquitetura de ponta a ponta e a metodologia de avaliação de acurácia — item exigido pelo briefing do Desafio 6 ("relatório de acurácia obtida").

Revisão de 11/09/2026: este documento foi conferido contra o código em produção (VPS 74.208.234.215, commit 05a5301, container energia-web-1). As seções 1 a 3 foram corrigidas: a camada A usa 6 features (não 3), a camada C tem 10 assinaturas fixas (não 4) e as Sprints 30 a 34 (forma do acionamento, calibração assistida, fechamento de consumo, comparativo de ciclos e anomalia) estão implementadas — ver seções 2.1, 2.4 e 6 a 8.


1. Abordagem: detecção de eventos (event-based)

O briefing do Desafio 6 recomenda, para uma PoC, a "análise de transições de potência ativa (event-based), que identifica liga/desliga de equipamentos por saltos característicos no sinal agregado" — é exatamente a técnica implementada em analytics/services.py::detect_power_steps.

A cada leitura consecutiva de TelemetryReading.active_power, calcula-se o delta absoluto. Deltas acima de um limiar configurável (200 W por padrão) são registrados como um degrau de potência (PowerStepEvent), com:

  • delta_w — magnitude do salto em Watts.
  • delta_a — magnitude do salto de corrente em Amperes (informativo).
  • delta_pf — fator de potência implícito do degrau (ΔW / (ΔA × Vrms), saturado em [0.05; 1.0]; None quando ΔA ou tensão estão indisponíveis). É heurística de classificação, para separar cargas resistivas de indutivas, e não medição metrológica de FP.
  • duration_s — para degraus de subida, o tempo até a potência retornar perto da base anterior (estimativa de quanto tempo o aparelho ficou ligado), limitado a uma janela de 24h para evitar custo O(n²).
  • power_std_s — desvio-padrão da potência DENTRO do intervalo do evento. Potência estável gera desvio baixo (chuveiro), potência oscilante gera desvio alto.
  • cycle_count — número de re-acionamentos de magnitude compatível (±25%) nos 30 minutos seguintes ao fim do evento. Compressor religando gera contagem maior que zero; evento único longo gera zero.
  • hour_of_day — hora local em que o evento ocorreu (0-23).

1.1 Dois caminhos de detecção

Tempo real (analytics/signals.py::detect_power_step_realtime → detect_step_realtime): a cada TelemetryReading persistida, a leitura nova é comparada com a imediatamente anterior do mesmo medidor. O evento pendente nasce na hora, com o palpite de aparelho já preenchido. Guardas dessa rota:

  • Intervalo entre as duas leituras acima de REALTIME_STEP_MAX_GAP_S (600 s) descarta o cálculo — depois de queda de energia, de Wi-Fi ou de reenvio de buffer, o delta entre leituras distantes no tempo não representa acionamento.
  • Delta abaixo de 200 W é ignorado.
  • Deduplicação por (meter, measured_at): nunca cria um segundo evento no mesmo instante.
  • duration_s nasce None (calcular duração exige leituras futuras); os campos de forma são preenchidos depois pela análise em lote.
  • Teto de 30 eventos pendentes por medidor (_enforce_pending_cap), descartando os mais antigos. Eventos auto/confirmed nunca são tocados por essa poda.

Lote (disaggregate → detect_power_steps): roda sobre a série completa do período, quando o usuário pede "Analisar este ciclo". É a única rota que calcula duration_s, power_std_s e cycle_count. O lote não recria o que o tempo real já criou: deduplica por (medidor, measured_at) e usa a passagem apenas para preencher os campos que ainda estavam nulos (bloco shape_fields). Depois disso, classifica os degraus genuinamente novos.

Nenhum degrau é identificado automaticamente de forma definitiva: todo evento novo nasce com status='pending' e appliance=None. A classificação (seção 2) só preenche um palpite (suggested_appliance_name) para pré-preencher o formulário de identificação manual — os totais de consumo por aparelho (LoadDisaggregation) só refletem aparelhos que o usuário confirmou manualmente. Por isso o status auto existe no modelo mas não é usado pelo fluxo atual: a classificação é sempre sugestão, nunca aplicação.


2. Classificação em três camadas

analytics/services.py::_classify_event tenta, em ordem, da mais específica à mais genérica. O resultado é gravado apenas em suggested_appliance_name e o evento continua pending.

2.1 Camada A — Árvore de decisão por medidor

Um DecisionTreeClassifier (scikit-learn) treinado exclusivamente com os eventos confirmados manualmente (status='confirmed') daquele medidor, usando seis features:

[delta_w, duration_s, hour_of_day, delta_pf, power_std_s, cycle_count]

Campos nulos entram como 0.0 (eventos antigos, criados antes das Sprints 29/30, não têm PF nem forma do acionamento).

Exige no mínimo 6 eventos confirmados cobrindo pelo menos 2 aparelhos distintos; abaixo disso, a camada é pulada. A predição só é aceita se a probabilidade máxima for ≥ 60% — caso contrário, cai para a camada B.

A camada A exige também consentimento explícito do proprietário (accounts.User.consented_nilm_training_at), porque é a única que efetivamente treina um modelo de aprendizado de máquina com dados do usuário (LGPD — ver docs/lgpd.md). Sem consentimento, a classificação vai direto para a camada B, que apenas consulta o histórico já confirmado do próprio usuário.

Por que só eventos confirmados treinam o modelo: eventos auto (classificados automaticamente) nunca entram no treino. Se entrassem, um erro de classificação se tornaria dado de treino para o próximo erro — um loop de auto-reforço já identificado e corrigido no projeto (ver feedback_django_ops do histórico de decisões).

2.2 Camada B — Correspondência com o histórico confirmado

_match_known_appliance compara o delta_w do novo evento contra cada evento já confirmado do medidor (não uma média por aparelho, porque um mesmo aparelho tem vários níveis de potência legítimos: compressor ciclando, micro-ondas em potências diferentes, air fryer com termostato), aceitando o aparelho do evento histórico mais próximo dentro de 15% de tolerância. Funciona desde a primeira confirmação — ao contrário da árvore de decisão, que exige 6+ amostras.

O fator de potência restringe a correspondência ao mesmo tipo de carga: um degrau só casa com referências cujo delta_pf confirmado esteja dentro de PF_MATCH_TOLERANCE (0,15). Isso separa resistivas de indutivas que compartilham a mesma faixa de watts. Quando apenas um dos lados tem PF (eventos antigos), o critério não se aplica e a correspondência fica conservadoramente pela faixa de watts.

Só eventos CONFIRMED são usados como referência — nunca AUTO.

2.3 Camada C — Assinaturas fixas

Faixas típicas conhecidas (KNOWN_SIGNATURES), hoje com 10 entradas. Além da faixa de watts, cada assinatura pode restringir o fator de potência (pf_min/pf_max), a duração máxima típica de um uso (max_duration_s) e uma janela horária de uso (hours, tupla inclusiva nas pontas, atravessando meia-noite quando o início é maior que o fim). Cada critério só é aplicado quando o valor correspondente do evento é conhecido.

Aparelho Faixa (W) FP Duração máx (s) Janela
Chuveiro Elétrico 3000–8000 0,85–1,0 5400 —
Ar-Condicionado 800–4000 0,60–0,95 — —
Forno Elétrico 1000–3500 0,85–1,0 7200 —
Geladeira 100–400 0,50–0,90 1800 —
Câmara Fria / Freezer Comercial 400–2500 0,55–0,90 — —
Máquina de Café Profissional 1200–3000 0,88–1,0 900 5h–11h
Micro-ondas 700–1600 0,85–1,0 1800 —
Air Fryer 1000–2200 0,90–1,0 3600 —
Exaustor 200–750 0,60–0,92 — —
Iluminação (circuito) 150–800 0,85–1,0 43200 —

Só retorna um palpite quando o delta cai em exatamente uma assinatura depois de todos os filtros. Deltas ambíguos retornam None deliberadamente, em vez de arriscar um palpite confiante e possivelmente errado. Com a expansão para o ICP PME (Sprint 32), faixas antes únicas ficaram ambíguas de propósito — por exemplo, 900 W deixou de ser "Ar-Condicionado" porque passou a caber também em Câmara Fria e Micro-ondas. Não "consertar" isso voltando ao match único por watt.

2.4 Onboarding por calibração assistida (Sprint 31)

CalibrationSession resolve o cold-start da camada A, que exige 6 confirmados. O usuário diz qual aparelho vai acionar, o sistema arma a escuta por 30 minutos (CALIBRATION_TTL_MIN) e o próximo degrau em tempo real daquele medidor é vinculado à sessão (_link_calibration_session) para confirmação imediata, com o nome já preenchido.

Ciclo de vida: ARMED → (degrau detectado) LINKED → (confirmado/descartado) DONE/CANCELLED. Uma sessão ARMED por medidor; sessões vencidas são canceladas automaticamente na consulta (get_active_calibration_session).


3. Metodologia de avaliação de acurácia

Comando: python manage.py evaluate_nilm [--meter ID] [--test-ratio 0.3] [--min-events 6]

Para cada medidor com pelo menos --min-events eventos confirmados:

  1. Os eventos confirmados são ordenados cronologicamente e divididos em treino (mais antigos) e teste (mais recentes), na proporção 1 - test_ratio / test_ratio (padrão 70%/30%).
  2. Para cada evento do conjunto de teste, a pipeline de 3 camadas é executada usando apenas o conjunto de treino como histórico disponível (_classify_event(..., history=train)) — o rótulo verdadeiro do evento de teste nunca é usado para treinar ou como referência de correspondência, evitando vazamento.
  3. Reporta acurácia geral e, por aparelho, precisão/recall/F1/suporte (sklearn.metrics.precision_recall_fscore_support).

3.1 Medição real em produção (11/09/2026)

$ python manage.py evaluate_nilm

casa — 4 evento(s) de treino / 3 de teste
Acurácia geral: 66.7%  (3 eventos de teste)
Aparelho                    Precisão     Recall       F1  Suporte
Chuveiro Elétrico              100.0%    100.0%   100.0%        1
Máquina Lava e Seca              0.0%      0.0%     0.0%        1
Torneira Elétrica              100.0%    100.0%   100.0%        1

Mockup Trifásico: 0 evento(s) confirmado(s) — mínimo de 6 necessário, pulando.

=== Geral (todos os medidores avaliados) ===
Acurácia geral: 66.7%  (3 eventos de teste)

Nota de honestidade sobre os limites da PoC: são 3 eventos de teste e uma única residência instrumentada. O número serve para acompanhar tendência, não como métrica consolidada do produto. A precisão alta em chuveiro e torneira vem das assinaturas fixas e de potências bem separadas no histórico. A Máquina Lava e Seca erra porque é o aparelho com potência mais variável do ciclo (média de 1119 W contra degraus observados entre 500 W e 5107 W) e o split cronológico deixou pouca referência dela no treino. Rode o comando periodicamente à medida que mais eventos forem confirmados; números fabricados não substituem essa medição contínua.

3.2 Limitação conhecida do split cronológico

Se o usuário confirmar todos os eventos de um aparelho em um período e de outro aparelho só depois, o split 70/30 pode concentrar um aparelho inteiro no treino e outro no teste, produzindo um relatório de acurácia sobre um subconjunto de aparelhos apenas naquela rodada. Isso é esperado e documentado — não é um bug — mas reforça a recomendação de rodar o comando regularmente conforme novos eventos forem confirmados.


4. Recomendação de horário de uso (Sprint 17 do PRD)

A partir da tarifa branca (ver seção de tarifas do PRD, Sprint 16) e dos aparelhos já confirmados pelo NILM, analytics/services.py::suggest_best_usage_window(appliance, tariff) calcula:

  1. O posto horário mais barato (recommended_slot) e o mais caro (worst_slot) da tarifa branca ativa.
  2. O consumo estimado por uso (appliance.typical_power / 1000 × 1 hora, aproximação documentada — ciclos reais de chuveiro/máquina de lavar costumam ficar próximos disso).
  3. A economia estimada em R$: custo no posto mais caro − custo no posto recomendado.

get_usage_suggestions(meter, tariff) aplica isso a todos os aparelhos conhecidos do medidor, ordenado pela maior economia — é o que alimenta o card "Recomendação de horário de uso" na tela de Desagregação (web) e a seção equivalente do contexto do assistente (WhatsApp/chat), permitindo respostas como "qual o melhor horário para usar o chuveiro?" a partir dos mesmos dados, sem um intent matcher dedicado — o LLM já responde consultando o contexto textual, no mesmo padrão usado para tarifas e faturas.

Como reforço proativo (opcional, também implementado): quando um degrau de alta potência (≥ 1000 W, HIGH_POWER_THRESHOLD_W) é detectado durante o posto de ponta de uma tarifa branca ativa e existe posto mais barato, _maybe_alert_high_power_during_peak envia um alerta via WhatsApp sugerindo o deslocamento. Este e os demais alertas proativos (anomalia, limite configurado pelo usuário, previsão de fatura alta) estão documentados em docs/alertas.md — incluindo o card "Último alerta" no dashboard e o modo demonstração.


5. Diagrama de arquitetura (ponta a ponta)

flowchart LR
    subgraph Edge [Camada Edge — C++ / firmware]
        ESP[ESP32 + CT clamp + ZMPT101B]
    end

    subgraph Broker [Comunicação]
        HMQ[(HiveMQ Cloud — MQTTS 8883)]
    end

    subgraph Processamento [Processamento — Django]
        SUB[Subscriber MQTT]
        TEL[(TelemetryReading — TimescaleDB)]
        RT[detect_step_realtime — signal post_save]
        NILM[detect_power_steps + classificação 3 camadas]
        EVT[(PowerStepEvent / Appliance / LoadDisaggregation)]
        CAL[CalibrationSession — onboarding]
        CLO[get_consumption_closure / ciclo anterior]
        ANOM[detect_consumption_anomaly]
        TAR[Tarifa branca: get_period_for_datetime]
        REC[suggest_best_usage_window]
    end

    subgraph Aplicacao [Aplicação]
        WEB[Dashboard web / app Flet]
        BOT[Assistente WhatsApp / chat — RAG]
    end

    ESP -- MQTT/TLS --> HMQ
    HMQ --> SUB
    SUB --> TEL
    TEL --> RT
    TEL --> NILM
    RT --> EVT
    NILM --> EVT
    CAL --> RT
    EVT --> CLO
    CLO --> WEB
    TEL --> ANOM
    EVT --> REC
    TAR --> REC
    ANOM -- alerta proativo --> BOT
    REC -- alerta proativo --> BOT
    EVT --> WEB
    REC --> WEB
    TEL --> WEB
    WEB --> BOT

A camada Edge (aquisição/firmware) é responsabilidade de uma frente de hardware separada (ver docs/hardware.md) — este repositório Django consome os dados já processados (Vrms, Irms, potência, kWh) publicados via MQTT.


6. Fechamento do consumo (Sprint 33)

get_consumption_closure(meter, period_start, period_end) fecha o balanço do ciclo e responde à pergunta "quanto do meu consumo o NILM explica?":

  • total_kwh — soma dos incrementos positivos de energy_kwh entre leituras consecutivas do período.
  • explained_kwh — soma dos LoadDisaggregation (aparelhos confirmados) do período.
  • baseline_kwh — base contínua (standby / equipamentos 24h), aproximada pela potência mínima sustentada do período × horas totais.
  • unidentified_kwh — max(0, total − explicado), nunca negativo, porque a base contínua e o explicado podem se sobrepor.
  • pct_explained — fração explicada (0,0 quando o total é 0).

Retorna None quando há menos de duas leituras no período.


7. Comparativo com o ciclo anterior (Sprint 34)

get_previous_cycle_comparison(meter) compara o ritmo diário (kWh/dia) do ciclo atual com o ciclo anterior completo, e não o total absoluto — ciclos de faturamento têm durações diferentes, então comparar totais enganaria. Retorna None quando não há dados suficientes dos dois ciclos.


8. Detecção de anomalia

detect_consumption_anomaly(meter) roda no mesmo signal de leitura nova (via evaluate_and_alert_anomaly, com amostragem interna e cooldown):

  • Spike: compara a leitura atual com a média + desvio-padrão das leituras anteriores e sinaliza quando o escore z passa do limiar. Os três parâmetros — janela (anomaly_window_size), amostras mínimas (anomaly_min_samples) e limiar em sigmas (anomaly_std_threshold) — são configuráveis por medidor, caindo no padrão global do sistema quando não definidos.
  • Carga fantasma / standby elevado: _detect_standby_anomaly sinaliza quando a potência mínima das últimas 24h fica persistentemente acima do baseline histórico.

Anomalia confirmada dispara alerta proativo pelo assistente (send_proactive_alert).


9. Referências

  • analytics/services.py — implementação completa (detecção em tempo real e em lote, classificação, calibração, recomendação, fechamento, comparativo, anomalia).
  • analytics/models.py — PowerStepEvent, Appliance, LoadDisaggregation, CalibrationSession, UsagePreference.
  • analytics/signals.py — gatilhos de tempo real (degrau, anomalia, alerta de previsão alta).
  • analytics/management/commands/evaluate_nilm.py — comando de avaliação de acurácia.
  • analytics/test_sprints_28_29.py — testes das sprints 28–34.
  • PRD.md, seção 13-A (Sprints 16-21) — plano de adequação ao desafio.
  • Trabalho/Projetos/Mileora-Consultoria-NILM/Documentos/nilm-como-funciona-2026-09-11.md (vault) — levantamento conferido contra o deploy em 11/09/2026, incluindo o estado real dos dados em produção.