Pular para conteúdo

Alertas Proativos

Este documento descreve o sistema de alertas proativos do EnergIA: os gatilhos, os mecanismos de controle (amostragem e cooldown), os canais de entrega, o modo demonstração e como testar. Atende os requisitos de alerta do briefing do Desafio 6 (seções 4.2, 5 e 11) — em particular as duas vias exigidas: limites pré-configurados pelo usuário e padrões atípicos detectados automaticamente.


1. Gatilhos

# Alerta Gatilho Onde
1 ⚠️ Previsão de fatura alta Novo BillForecast com estimated_cost > 20% acima da média das 3 previsões anteriores do medidor analytics/signals.py::alert_on_high_forecast (post_save de BillForecast)
2 📈 Limite pré-configurado Tempo real, por transição: leitura com active_power cruzando o limite Meter.power_alert_threshold_w (abaixo → acima). Rearma quando a potência cai abaixo do limite (Meter.power_alert_active); não usa o cooldown estatístico analytics/services.py::evaluate_and_alert_anomaly
3 🚨 Pico de consumo (spike) Leitura atual ≥ 3 desvios-padrão acima da média móvel das últimas 30 leituras (limiar adaptativo; mínimo de 10 leituras de baseline) — os 3 parâmetros são configuráveis por medidor (Sprint 23, ver seção 2) analytics/services.py::detect_consumption_anomaly
4 🔌 Carga fantasma (standby) Potência mínima das últimas 24h ≥ 1,5× e pelo menos +30 W acima do mínimo dos 7 dias anteriores analytics/services.py::_detect_standby_anomaly
5 ⚡ Alta potência na ponta Degrau NILM ≥ 1000 W detectado durante o posto de ponta de uma tarifa branca ativa — sugere deslocar o uso para o posto mais barato analytics/services.py::_maybe_alert_high_power_during_peak
6 💰 Tarifa branca CELESC Criação/atualização de preços pela sincronização automática tariffs/management/commands/sync_white_tariff.py
7 🕯️ Falta de energia / 📶 Queda de Wi-Fi No retorno do medidor: queda consolidada com duração ≥ OUTAGE_ALERT_MIN_MINUTES (padrão 5 min), com data/hora da queda, do retorno e a duração. A causa vem do uptime do boot: baixo = ESP religou do zero (faltou energia); alto = ficou ligado (só perdeu a rede) meters/services.py::register_outage_recovery

Os gatilhos 2–4 são avaliados pelo signal post_save de TelemetryReading (analytics/signals.py::check_consumption_anomaly), ou seja, em tempo real conforme o subscriber MQTT persiste leituras. O gatilho 5 dispara no momento do uso (a detecção de degraus também roda em tempo real via detect_step_realtime — o evento pendente aparece na tela sem esperar o "Analisar este ciclo") e novamente não é reenviado quando a análise em lote reprocessa o mesmo degrau (dedupe por measured_at). Todos os pontos de disparo são envoltos em try/except com log — uma falha no envio nunca interrompe a ingestão de telemetria nem o NILM.

Registro em banco: toda queda ≥ 60 s vira um OutageEvent consolidado (medidor, início, fim, duração, causa, se alertou) — consultável no admin em "Quedas (energia/rede)" e pelo próprio usuário em /medidores/quedas/ (meters:outage_history, Sprint 24), com seletor de período (30/90/365 dias ou todo o período) e indicador de disponibilidade (meters/services.py::get_availability_stats). O dashboard principal também mostra um card resumido de disponibilidade dos últimos 30 dias, linkando para o histórico completo. A causa do DeviceStatusEvent de queda é corrigida retroativamente pelo uptime do retorno. Micro-quedas de segundos (flapping de rede) não geram registro consolidado nem alerta; permanecem apenas no log bruto de DeviceStatusEvent.


2. Amostragem e cooldown

Para controlar custo computacional e falsos positivos repetidos (critério da seção 11 do briefing):

Constante Padrão Efeito
ANOMALY_EVAL_EVERY_N 5 A detecção estatística (spike/standby) roda 1 a cada N leituras (pk % N). O limite pré-configurado (gatilho 2) é verificado em toda leitura — comparação em memória, sem consultas extras
ANOMALY_COOLDOWN_MINUTES 30 Intervalo mínimo entre alertas estatísticos do mesmo medidor (Meter.last_anomaly_alert_at), gatilhos 3–4. O gatilho 2 não espera cooldown: é controlado por transição (Meter.power_alert_active — dispara ao cruzar o limite, rearma ao cair abaixo). Quando o gatilho 2 dispara, ele registra last_anomaly_alert_at para suprimir um alerta estatístico redundante em seguida
ANOMALY_WINDOW_SIZE / ANOMALY_MIN_SAMPLES / ANOMALY_STD_THRESHOLD 30 / 10 / 3.0 Janela, mínimo de amostras e limiar (em σ) da detecção de spike — valor global usado quando o medidor não tem override

| OUTAGE_ALERT_MIN_MINUTES | 5 | Duração mínima de uma queda (energia/Wi-Fi) para o alerta de retorno — micro-quedas ficam só no log |

ANOMALY_EVAL_EVERY_N, ANOMALY_COOLDOWN_MINUTES e OUTAGE_ALERT_MIN_MINUTES são configuráveis via .env (lidos em core/settings.py); os demais são constantes documentadas em analytics/services.py e meters/services.py.

Sensibilidade por medidor (Sprint 23): Meter.anomaly_window_size/anomaly_min_samples/anomaly_std_threshold (nullable, expostos no formulário de edição do medidor, seção "Sensibilidade de detecção de picos") sobrescrevem ANOMALY_WINDOW_SIZE/ANOMALY_MIN_SAMPLES/ANOMALY_STD_THRESHOLD só para aquele medidor — útil quando um aparelho de variação naturalmente alta (ex.: chuveiro) precisa de um limiar menos sensível que outro medidor mais estável. Vazio (padrão) usa a constante global normalmente.


3. Entrega: canal configurável (WhatsApp/e-mail) + dashboard

Todos os gatilhos convergem em assistant/services.py::send_proactive_alert(user, message, alert_type=None). Cada gatilho passa o alert_type correspondente (bill_forecast, power_threshold, spike, standby, peak_tariff, white_tariff_sync, outage — ver tabela da seção 1):

  1. Preferência (Sprint 23): busca NotificationPreference do usuário (accounts/models.py, 1:1 com User, criada com get_or_create na primeira visita à tela de perfil). Se o usuário desabilitou esse alert_type (notify_<alert_type>=False), a função retorna imediatamente ''nenhum envio e nenhum registro em AssistantMessage para esse disparo. Sem preferência salva (usuário nunca configurou nada), o comportamento é idêntico ao anterior à Sprint 23: WhatsApp, todos os tipos habilitados.
  2. Canal: NotificationPreference.channel define whatsapp / email / both (padrão whatsapp). O número WhatsApp é resolvido pela última AssistantMessage do usuário com phone_number — só é usado se o usuário já conversou com o bot alguma vez. Envio por e-mail (_send_alert_email) usa django.core.mail.send_mail com o EMAIL_BACKEND/SMTP já configurado em core/settings.py (o mesmo usado pelo reset de senha), removendo os marcadores markdown do WhatsApp (*) da mensagem; nunca levanta exceção — falha de e-mail é só logada.
  3. Sempre (quando o tipo não foi suprimido pela preferência) registra AssistantMessage(direction='outbound', is_proactive_alert=True) — independente do canal escolhido, é o log interno que alimenta o dashboard e o histórico, não uma "entrega" selecionável.
  4. Retorna o número WhatsApp usado (string vazia se nenhum foi encontrado, ou se o alerta foi suprimido pela preferência) — usado por chamadores que precisam saber se a entrega teve destino real, como o envio sob demanda da seção 3.1.

Configuração pelo usuário: tela de Perfil (/perfil/), seção "Notificações" — canal + um checkbox por tipo de alerta (templates/accounts/profile.html, accounts:notification_preference_update).

O registro alimenta dois pontos da interface web: - O card "Último alerta" no dashboard (dashboard/views.pyassistant/services.py::get_recent_alerts, exibe apenas o alerta mais recente) — garante que o alerta chegue a quem nunca usou o WhatsApp; o histórico completo fica na página do assistente. - O histórico do assistente (/assistente/historico/).

3.1 Envio sob demanda: simulação de tarifa branca

Diferente dos gatilhos 1–7 (automáticos, disparados por signals/comandos), a tela de Simulação de Custo por Posto Horário (/tarifas/simulacao/) tem um botão "Enviar via WhatsApp" que envia o resultado já calculado a pedido do próprio usuário — mesmo pipeline de entrega (send_proactive_alert), mas iniciado por uma ação humana, não por uma condição do sistema. Por isso chama send_proactive_alert sem alert_type: não é filtrável pela preferência de notificação (o usuário clicou o botão agora, a intenção é explícita) e sempre vai por WhatsApp, independente do channel configurado.

  • tariffs/services.py::send_simulation_whatsapp(user, tariff, result) monta a mensagem (breakdown por posto, total, melhor/pior caso, economia potencial) e retorna 'sent' / 'no_phone' / 'error' para feedback via django.contrib.messages na mesma tela — nunca levanta exceção, a simulação exibida não pode quebrar por causa do WhatsApp.
  • Formatação numérica: a mensagem usa exclusivamente vírgula decimal (tariffs/services.py::_br_num/_brl), nunca o ponto padrão do Python — texto enviado a um usuário real não pode misturar as duas convenções (ex.: "10.5 kWh" ao lado de "R$ 21,00" no mesmo parágrafo). Na tela, os números já saem formatados corretamente porque floatformat do Django é locale-aware com LANGUAGE_CODE = 'pt-br'; o texto do WhatsApp é montado à mão (f-strings), por isso precisa do helper.
  • CostSimulationView.form_valid distingue as duas ações do mesmo formulário pelo campo oculto action (simulate vs send_whatsapp), sem view nem URL extra.

4. Modo demonstração

Para a demo ao vivo recomendada pelo briefing ("ligar um equipamento de alta potência e ver o alerta disparar"), os padrões de produção atrapalham: a amostragem atrasa o disparo e o cooldown de 30 min impede repetir a demonstração. Duas ferramentas:

# .env — disparo imediato e repetível
ANOMALY_EVAL_EVERY_N=1
ANOMALY_COOLDOWN_MINUTES=0
# Alternativa sem mexer no .env: zerar o cooldown entre demonstrações
python manage.py reset_anomaly_cooldown            # todos os medidores
python manage.py reset_anomaly_cooldown --meter 3  # um medidor específico

Em produção (Docker), mudar o .env exige recriar o container (docker compose -f docker-compose.prod.yml up -d web mqtt_subscriber); o comando de reset roda direto: docker compose -f docker-compose.prod.yml exec web python manage.py reset_anomaly_cooldown.

O gatilho 2 (limite pré-configurado) é o mais confiável para demo: configure, por exemplo, 1000 W no medidor e ligue um chuveiro — dispara na primeira leitura acima do limite, sem depender de baseline estatístico nem de cooldown. Para repetir a demonstração, basta desligar e religar a carga (o alerta rearma quando a potência cai abaixo do limite); o reset_anomaly_cooldown e o .env de demo continuam necessários apenas para os gatilhos estatísticos (3–4).


5. Como testar

Automatizadopython manage.py test analytics cobre detecção (spike/standby), limite pré-configurado, amostragem e cooldown (DetectConsumptionAnomalyTest, DetectStandbyAnomalyTest, EvaluateAndAlertAnomalyTest, PowerThresholdAlertTest), sempre com send_proactive_alert mockado.

Ponta a ponta — no Django shell:

from accounts.models import User
from assistant.services import send_proactive_alert

user = User.objects.get(email='...')
send_proactive_alert(user, '🧪 Teste de alerta EnergIA')

Se chegar no WhatsApp, toda a cadeia de entrega está OK. O que foi disparado (com ou sem WhatsApp) é auditável em:

from assistant.models import AssistantMessage
AssistantMessage.objects.filter(is_proactive_alert=True).order_by('-created_at')

Pré-requisitos em produção: o mqtt_subscriber precisa estar rodando (é ele quem salva as leituras que disparam os gatilhos 2–4) e, para receber no WhatsApp, o usuário precisa ter trocado ao menos uma mensagem com o bot.


6. Cenários de teste pré-definidos e falsos positivos

A seção 11 do briefing avalia o alerta pela "corretude do disparo em cenários de teste previamente definidos" e pela "taxa de falsos positivos". Os cenários formais são estes (todos reproduzíveis no medidor real com o modo demo ativo):

# Cenário Como reproduzir Resultado esperado
C1 Spike estatístico Consumo estável por ≥ 30 leituras; ligar carga que eleve a potência ≥ 3σ acima da média (ex.: chuveiro) Alerta 🚨 em até ANOMALY_EVAL_EVERY_N leituras
C2 Sem falso positivo em operação normal Rotina normal da casa por 24h, sem cargas novas Nenhum alerta estatístico
C3 Limite pré-configurado Configurar limite abaixo de uma carga conhecida; ligar a carga Alerta 📈 na primeira leitura acima do limite (tempo real, sem cooldown)
C4a Sem reenvio com carga ligada Manter a carga de C3 ligada acima do limite Nenhum reenvio enquanto a potência não cair abaixo do limite
C4b Rearme por transição Desligar a carga de C3 (potência cai abaixo do limite) e religar Novo alerta 📈 imediato, mesmo dentro dos 30 min
C4c Cooldown estatístico Repetir C1 dentro da janela de cooldown (produção: 30 min) Segundo alerta estatístico suprimido
C5 Carga fantasma Deixar um aparelho de standby elevado (≥ +30 W e ≥ 1,5× o mínimo histórico) ligado por 24h Alerta 🔌
C6 Alta potência na ponta Com tarifa branca ativa, ligar carga ≥ 1000 W dentro do posto de ponta Alerta ⚡ com comparação de preço entre postos

Os cenários C1–C4c têm equivalentes automatizados na suíte (analytics/tests.py); C5 e C6 são cobertos por DetectStandbyAnomalyTest e pelos testes de _maybe_alert_high_power_during_peak com dados sintéticos.

Medição da taxa de falsos positivos em produção: todo disparo fica auditável em AssistantMessage(is_proactive_alert=True). O procedimento é revisar semanalmente os alertas do período e classificar cada um como procedente (havia causa real: aparelho ligado, standby, etc.) ou falso positivo:

from assistant.models import AssistantMessage
AssistantMessage.objects.filter(is_proactive_alert=True).order_by('-created_at').values_list('created_at', 'content')

Taxa = falsos positivos / total de alertas do período. Registrar o resultado aqui a cada revisão:

Período Alertas Falsos positivos Taxa Observações

Limite conhecido: com uma única residência instrumentada, a amostra é pequena — declarar isso à banca em vez de extrapolar (a honestidade sobre os limites da PoC é critério positivo, seção 10 do briefing).


7. Referências

  • analytics/signals.py — signals de disparo (forecast e leitura).
  • analytics/services.py — detecção, limiares e mensagens (seção "Detecção de anomalia em tempo real").
  • assistant/services.py — entrega (send_proactive_alert, get_recent_alerts).
  • analytics/management/commands/reset_anomaly_cooldown.py — reset do cooldown.
  • PRD.md, Sprints 18 e 22 — histórico de implementação.
  • Briefing do Desafio 6 (arquivos/Desafio06_briefing_MedidorAssistente.docx.pdf), seções 4.2, 5 e 11.