From 97c9a49f3ecef330b4a80c16ac3f994ea461552c Mon Sep 17 00:00:00 2001 From: Diego Freitas Date: Wed, 24 Jun 2026 13:32:47 -0300 Subject: [PATCH] =?UTF-8?q?robustez=20nas=20m=C3=A9tricas=20de=20sa=C3=BAd?= =?UTF-8?q?e=20dos=20m=C3=B3dulos=20Mov,=20Dir,=20Sen=20e=20Atu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AgroBase/Forms/IHM/frmDiagnosticos.cs | 4 +- AgroBase/AgroBase/Forms/frmJoystick.cs | 8 +- AgroBase/AgroBase/Models/GeneralJoystick.cs | 23 +- .../AgroBase/Models/Modules/AtuadorModel.cs | 28 +- .../Models/Modules/MovimentacaoModel.cs | 12 + .../Modules/MovimentacaoUnificadoModel.cs | 9 +- .../Models/Modules/SensoriamentoModel.cs | 15 + .../Models/Operacoes/OperacaoModel.cs | 35 +- .../AgroBase/Services/MKS057DCanService.cs | 1132 +++++------ AgroBase/AgroBase/Services/OIDCanService.cs | 6 +- .../Operadores/HealthWorkerService.cs | 326 +++- .../workers/health_worker/modulos/atuador.py | 1669 ++++++++++++++--- .../health_worker/modulos/direcional.py | 884 ++++++++- .../health_worker/modulos/movimentacao.py | 1612 ++++++++++------ .../health_worker/modulos/sensoriamento.py | 1326 +++++++++---- 15 files changed, 5078 insertions(+), 2011 deletions(-) diff --git a/AgroBase/AgroBase/Forms/IHM/frmDiagnosticos.cs b/AgroBase/AgroBase/Forms/IHM/frmDiagnosticos.cs index e3cb9da06..23a5c1422 100644 --- a/AgroBase/AgroBase/Forms/IHM/frmDiagnosticos.cs +++ b/AgroBase/AgroBase/Forms/IHM/frmDiagnosticos.cs @@ -475,7 +475,7 @@ namespace AgroBase.Forms.IHM await EnviarComandoBico(B01, true, 3000, Testes[idx].ValorPadrao, Dispositivo.Dados); - double Fluxo = FluxoInicializado ? Dispositivo.Dados.VazaoFluxoMLpSInstantanea : -1; + double Fluxo = FluxoInicializado ? Dispositivo.Dados.VazaoFluxoMLpSInstantaneaCorrigida : -1; idx = PreencheDadosLinha(grid, Testes, idx, Fluxo); bool statusB01_1 = B01Inicializado && B01._EstadoLeitura == Estado.Ligado; @@ -550,7 +550,7 @@ namespace AgroBase.Forms.IHM { await Task.Delay(100); - if (disp != null && disp.VazaoFluxoMLpSInstantanea >= ValorComparar) + if (disp != null && disp.VazaoFluxoMLpSInstantaneaCorrigida >= ValorComparar) { break; } diff --git a/AgroBase/AgroBase/Forms/frmJoystick.cs b/AgroBase/AgroBase/Forms/frmJoystick.cs index eb939e999..f8c2b97f6 100644 --- a/AgroBase/AgroBase/Forms/frmJoystick.cs +++ b/AgroBase/AgroBase/Forms/frmJoystick.cs @@ -32,13 +32,13 @@ namespace AgroBase.Forms switch (comando.Dispositivo) { case T_Code.Mov: - GeneralJoystick.ProcessarDadosControle(comando.botaoJoy, false, key: comando.key, valorDesejado: Variaveis.OperacaoEmAndamento.Parametros.Controle.MovVelocidadeCErvasPercent); + GeneralJoystick.ProcessarDadosControle(comando.botaoJoy, false, key: comando.key, valorDesejado: Variaveis.OperacaoEmAndamento.Parametros.Controle.MovVelocidadeCErvasPercent, ForcarComando: true); break; case T_Code.Dir: - GeneralJoystick.ProcessarDadosControle(comando.botaoJoy, false, key: comando.key, valorDesejado: Variaveis.OperacaoEmAndamento.Parametros.Controle.DirAnguloMaximo); + GeneralJoystick.ProcessarDadosControle(comando.botaoJoy, false, key: comando.key, valorDesejado: Variaveis.OperacaoEmAndamento.Parametros.Controle.DirAnguloMaximo, ForcarComando: true); break; case T_Code.Atu: - GeneralJoystick.ProcessarDadosControle(comando.botaoJoy, false, key: comando.key); + GeneralJoystick.ProcessarDadosControle(comando.botaoJoy, false, key: comando.key, ForcarComando: true); break; } } @@ -47,7 +47,7 @@ namespace AgroBase.Forms { var comando = GeneralJoystick.DeParaComandos.FirstOrDefault(x => x.key == e.KeyCode); if (comando == null) return; - GeneralJoystick.ProcessarDadosControle(comando.botaoJoy, true, key: comando.key); + GeneralJoystick.ProcessarDadosControle(comando.botaoJoy, true, key: comando.key, ForcarComando: true); } } diff --git a/AgroBase/AgroBase/Models/GeneralJoystick.cs b/AgroBase/AgroBase/Models/GeneralJoystick.cs index faaebcff4..582f8caca 100644 --- a/AgroBase/AgroBase/Models/GeneralJoystick.cs +++ b/AgroBase/AgroBase/Models/GeneralJoystick.cs @@ -1,15 +1,16 @@ -using System.Collections.Generic; -using System.Linq; -using System.Windows.Forms; -using System; -using static AgroBase.Models.Enums; -using AgroBase.Forms; +using AgroBase.Forms; using AgroBase.Models; -using System.Threading.Tasks; using AgroBase.Models.Modules; -using SharpDX.DirectInput; using AgroBase.Services; +using SharpDX.DirectInput; +using StackExchange.Redis; +using System; +using System.Collections.Generic; using System.Data; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Forms; +using static AgroBase.Models.Enums; namespace AgroBase { @@ -927,6 +928,8 @@ namespace AgroBase { Sinaleiro.Comportamento = (SensorSinaleiroComportamentoModel)Status; (id_num, posicao, data) = Sinaleiro.ProtocoloComando(); + Sinaleiro.UltimoComandoEnviado = DateTime.Now; + Sinaleiro.UltimoComportamentoDesejado = (SensorSinaleiroComportamentoModel)Status; } break; case S_Code.sRLE: @@ -935,6 +938,8 @@ namespace AgroBase { Rele.StatusControle = (Estado)Status; (id_num, posicao, data) = Rele.ProtocoloComando(); + Rele.UltimoComandoEnviado = DateTime.Now; + Rele.UltimoEstadoDesejado = (Estado)Status; } break; case S_Code.sSRV: @@ -943,6 +948,8 @@ namespace AgroBase { ServoFreio._AnguloControle = (int)Status; (id_num, posicao, data) = ServoFreio.ProtocoloComando(); + ServoFreio.UltimoComandoEnviado = DateTime.Now; + ServoFreio.UltimoAnguloDesejado = (int)Status; } break; } diff --git a/AgroBase/AgroBase/Models/Modules/AtuadorModel.cs b/AgroBase/AgroBase/Models/Modules/AtuadorModel.cs index e7e7d2f57..ad6ae29f3 100644 --- a/AgroBase/AgroBase/Models/Modules/AtuadorModel.cs +++ b/AgroBase/AgroBase/Models/Modules/AtuadorModel.cs @@ -121,10 +121,14 @@ namespace AgroBase.Models.Modules [JsonIgnore] private SensorModel SensorFluxoLinha => Sensores.FirstOrDefault(x => x.Componente == S_Code.sFLX && x.ID == "FLXLN"); + const double FatorCalibracaoFluxo = 0.776; + public double VazaoMaximaSensor_LMin { get; set; } = 20.0; public double VazaoFluxoMLpSInstantanea => SensorFluxoLinha?.ValoresLeituras?.FirstOrDefault(x => x.funcao == FuncoesPinout.Fluxo)?.atual?.valor ?? 0; + public double VazaoFluxoMLpSInstantaneaCorrigida => VazaoFluxoMLpSInstantanea * FatorCalibracaoFluxo; + public double VazaoFluxoMLpSInstantaneaCalc { get @@ -140,8 +144,6 @@ namespace AgroBase.Models.Modules if (pulsos <= 0 || periodo <= 0) return 0.0; - const double FatorCalibracaoFluxo = 0.776; - double frequencia = (double)pulsos / periodo * 1000.0; double q_L_min = (frequencia + 3.0) / 8.1; @@ -978,9 +980,9 @@ namespace AgroBase.Models.Modules posicao = CanMessagePosicaoDados.Dados1, indice = 1, analise_health = true, - maximo = 35, - minimo = 0, - nominal = 30, + maximo = VariaveisEquipamento.CapacidadeReservatorio + 10.0, + minimo = VariaveisEquipamento.PercentualReservatorioMinCritio, + nominal = VariaveisEquipamento.CapacidadeReservatorio, unidade_medida = "Kg" } } @@ -2049,6 +2051,9 @@ namespace AgroBase.Models.Modules }; dispAtu.AdicionarMensagemFila(bico.ID_Num, CanMessagePosicaoDados.Command1, CanMessagePosicaoDados.Command1, data, false); + bico.UltimoComandoEnviado = DateTime.Now; + bico.UltimoStatusDesejado = status; + bico.UltimoAnguloDesejado = angulo; } private void EnviarComandoFisicoBomba(AtuadorBombaModel bomba, bool status, double potencia, double pressao) @@ -2074,6 +2079,11 @@ namespace AgroBase.Models.Modules }; dispAtu.AdicionarMensagemFila(bomba.ID_Num, CanMessagePosicaoDados.Command1, CanMessagePosicaoDados.Command1, data, false); + + bomba.UltimoComandoEnviado = DateTime.Now; + bomba.UltimoStatusDesejado = status; + bomba.UltimaPotenciaDesejada = potencia; + bomba.UltimaPressaoDesejada = pressao; } @@ -2295,6 +2305,9 @@ namespace AgroBase.Models.Modules public Estado _EstadoLeitura => (Estado)(ValoresLeituras?.FirstOrDefault(x => x.funcao == FuncoesPinout.EstadoLeitura)?.atual?.valor ?? 0); public double _AnguloBicoLeitura => ValoresLeituras?.FirstOrDefault(x => x.funcao == FuncoesPinout.ServoAnguloLeitura)?.atual? .valor ?? 0; + public DateTime UltimoComandoEnviado { get; set; } = DateTime.MinValue; + public bool UltimoStatusDesejado { get; set; } = false; + public double UltimoAnguloDesejado { get; set; } = 0; public (CanMessagePosicaoDados, byte[]) ProtocoloConfiguracao(PinoutDataModel pinout, bool conectar) { @@ -2529,6 +2542,11 @@ namespace AgroBase.Models.Modules public double PotenciaMediaAtiva { get; set; } + public DateTime UltimoComandoEnviado { get; set; } = DateTime.MinValue; + public bool UltimoStatusDesejado { get; set; } = false; + public double UltimaPotenciaDesejada { get; set; } = 0; + public double UltimaPressaoDesejada { get; set; } = 0; + public List<(CanMessagePosicaoDados, byte[])> ProtocoloConfiguracao(PinoutDataModel pinout, bool conectar) diff --git a/AgroBase/AgroBase/Models/Modules/MovimentacaoModel.cs b/AgroBase/AgroBase/Models/Modules/MovimentacaoModel.cs index d24764726..8d0d2f3b6 100644 --- a/AgroBase/AgroBase/Models/Modules/MovimentacaoModel.cs +++ b/AgroBase/AgroBase/Models/Modules/MovimentacaoModel.cs @@ -266,6 +266,13 @@ namespace AgroBase.Models.Modules public MvdServoFreioModel ConfigFreio { get; set; } + public DateTime UltimoComandoEnviado { get; set; } = DateTime.MinValue; + public OidFuncCode UltimoModoControleEnviado { get; set; } + public double UltimoSetPointEnviado { get; set; } = 0; + public Direcao UltimaDirecao { get; set; } = Direcao.Parado; + public bool UltimoFreioSP { get; set; } = false; + + public static MovimentacaoModel CarregarParametrosIniciais(string Mod_ID, byte _EnderecoTx, byte _EnderecoRx) { return new MovimentacaoModel() @@ -450,6 +457,11 @@ namespace AgroBase.Models.Modules OIDCanService.ComandoControle(_EnderecoCAN_Tx, _EnderecoCAN_Rx, modoControle, setPoint); LeituraRPM = RPM_SP; + UltimoComandoEnviado = DateTime.Now; + UltimoModoControleEnviado = modoControle; + UltimoSetPointEnviado = setPoint; + UltimaDirecao = DirecaoAtual; + UltimoFreioSP = Freado; } } } diff --git a/AgroBase/AgroBase/Models/Modules/MovimentacaoUnificadoModel.cs b/AgroBase/AgroBase/Models/Modules/MovimentacaoUnificadoModel.cs index b57841674..b56ceeb33 100644 --- a/AgroBase/AgroBase/Models/Modules/MovimentacaoUnificadoModel.cs +++ b/AgroBase/AgroBase/Models/Modules/MovimentacaoUnificadoModel.cs @@ -238,7 +238,6 @@ namespace AgroBase.Models.Modules private DateTime _ultimoEnvioControleMov = DateTime.MinValue; private DateTime _ultimoRegistroLogMov = DateTime.MinValue; - private static readonly TimeSpan IntervaloControleMov = TimeSpan.FromSeconds(1); private static readonly TimeSpan IntervaloLogMov = TimeSpan.FromSeconds(1); private async Task tmrRegistradorMOV_Tick() @@ -340,10 +339,8 @@ namespace AgroBase.Models.Modules private DateTime _ultimoEnvioControleDir = DateTime.MinValue; private DateTime _ultimoSensoresExternosDir = DateTime.MinValue; - private static readonly TimeSpan IntervaloControleDir = TimeSpan.FromMilliseconds(250); private static readonly TimeSpan IntervaloSensoresExternosDir = TimeSpan.FromMilliseconds(500); - private async Task tmrRegistradorDIR_Tick() { if (Variaveis.Fechando) @@ -351,11 +348,9 @@ namespace AgroBase.Models.Modules DateTime agora = DateTime.Now; - if (!MKS057DCanService.Iniciado && - SerialService.DispositivosMapeados.Any(x => x.Dispositivo == T_Code.Dir)) + if (!MKS057DCanService.Iniciado && SerialService.DispositivosMapeados.Any(x => x.Dispositivo == T_Code.Dir)) { - foreach (var leitura in MKS057DCanService.DadosLeitura - .Where(x => x.UltimoComandoRecebido.AddSeconds(10) < agora)) + foreach (var leitura in MKS057DCanService.DadosLeitura.Where(x => x.UltimoComandoRecebido.AddSeconds(10) < agora)) { /* * Poke leve para tentar recuperar leitura. diff --git a/AgroBase/AgroBase/Models/Modules/SensoriamentoModel.cs b/AgroBase/AgroBase/Models/Modules/SensoriamentoModel.cs index 99266eac5..328539a94 100644 --- a/AgroBase/AgroBase/Models/Modules/SensoriamentoModel.cs +++ b/AgroBase/AgroBase/Models/Modules/SensoriamentoModel.cs @@ -1818,6 +1818,7 @@ namespace AgroBase.Models.Modules }, AnguloNeutro = 0, _AnguloControle = 0, + UltimoAnguloDesejado = 0, Incremental = true, ValoresLeituras = new List() { @@ -1850,6 +1851,7 @@ namespace AgroBase.Models.Modules }, AnguloNeutro = 0, _AnguloControle = 0, + UltimoAnguloDesejado = 0, Incremental = true, ValoresLeituras = new List() { @@ -1882,6 +1884,7 @@ namespace AgroBase.Models.Modules }, AnguloNeutro = 180, _AnguloControle = 180, + UltimoAnguloDesejado = 180, Incremental = false, ValoresLeituras = new List() { @@ -1914,6 +1917,7 @@ namespace AgroBase.Models.Modules }, AnguloNeutro = 180, _AnguloControle = 180, + UltimoAnguloDesejado = 180, Incremental = false, ValoresLeituras = new List() { @@ -2487,6 +2491,10 @@ namespace AgroBase.Models.Modules public int _StatusLedId => (int)(ValoresLeituras?.FirstOrDefault(x => x.funcao == FuncoesPinout.StatusLedID)?.atual?.valor ?? 99); public StatusLED _StatusLed => (StatusLED)(ValoresLeituras?.FirstOrDefault(x => x.funcao == FuncoesPinout.StatusLedLeitura)?.atual?.valor ?? StatusLED.Apagado); + + public DateTime UltimoComandoEnviado { get; set; } = DateTime.MinValue; + public SensorSinaleiroComportamentoModel UltimoComportamentoDesejado { get; set; } + public (CanMessagePosicaoDados, byte[]) ProtocoloConfiguracao(PinoutDataModel pinout, bool conectar) { var pino = pinout.Pinos.FirstOrDefault(p => p.ComponenteID == ID); @@ -2547,6 +2555,10 @@ namespace AgroBase.Models.Modules public Estado _EstadoLeitura => ValoresLeituras?.FirstOrDefault(x => x.funcao == FuncoesPinout.EstadoLeitura)?.atual?.valor ?? Estado.Desligado; + public DateTime UltimoComandoEnviado { get; set; } = DateTime.MinValue; + public Estado UltimoEstadoDesejado { get; set; } = Estado.Desligado; + + public (CanMessagePosicaoDados, byte[]) ProtocoloConfiguracao(PinoutDataModel pinout, bool conectar) { var pino = pinout.Pinos.FirstOrDefault(p => p.ComponenteID == ID); @@ -2607,6 +2619,9 @@ namespace AgroBase.Models.Modules } public List ValoresLeituras { get; set; } + public DateTime UltimoComandoEnviado { get; set; } = DateTime.MinValue; + public double UltimoAnguloDesejado { get; set; } = 0; + public (CanMessagePosicaoDados, byte[]) ProtocoloConfiguracao(PinoutDataModel pinout, bool conectar) { var pino = pinout.Pinos.FirstOrDefault(p => p.ComponenteID == ID); diff --git a/AgroBase/AgroBase/Models/Operacoes/OperacaoModel.cs b/AgroBase/AgroBase/Models/Operacoes/OperacaoModel.cs index ccead0f0c..65001989a 100644 --- a/AgroBase/AgroBase/Models/Operacoes/OperacaoModel.cs +++ b/AgroBase/AgroBase/Models/Operacoes/OperacaoModel.cs @@ -3038,7 +3038,7 @@ namespace AgroBase.Models PressaoLinha = Variaveis.OperacaoEmAndamento.DispAtu.Dados.PressaoLinha, PotenciaBomba = Variaveis.OperacaoEmAndamento.DispAtu.Dados.BombaPressurizadora?._Potencia ?? 0, BombaLigada = Variaveis.OperacaoEmAndamento.DispAtu.Dados.BombaPressurizadora?.ComandoAtuar ?? false, - VazaoFluxoMLpSInstantanea = Variaveis.OperacaoEmAndamento.DispAtu.Dados.VazaoFluxoMLpSInstantanea, + VazaoFluxoMLpSInstantanea = Variaveis.OperacaoEmAndamento.DispAtu.Dados.VazaoFluxoMLpSInstantaneaCorrigida, VazaoFluxoMLpSMedia = Variaveis.OperacaoEmAndamento.DispAtu.Dados.VazaoMediaMLs, VolumeVazaoML = Variaveis.OperacaoEmAndamento.DispAtu.Dados.VolumeVazadoML, QuantidadeBicos = Variaveis.OperacaoEmAndamento.Parametros.QtdBicos ?? VariaveisEquipamento.QuantidadeBicosPulverizadores, @@ -3665,19 +3665,26 @@ namespace AgroBase.Models public void InserirLog(T_Code dispositivo, StatusModulo status, double saude, string mensagem, string id = null, string condicoes = null) { - if (Logs == null) - Logs = new List(); - - Logs.Add(new OperacaoSensoriamentoLogErrosModel() + try { - Momento = DateTime.Now, - Dispositivo = dispositivo, - ID = id, - Status = status, - Saude = saude, - Mensagem = mensagem, - Condicoes = condicoes - }); + if (Logs == null) + Logs = new List(); + + Logs.Add(new OperacaoSensoriamentoLogErrosModel() + { + Momento = DateTime.Now, + Dispositivo = dispositivo, + ID = id, + Status = status, + Saude = saude, + Mensagem = mensagem, + Condicoes = condicoes + }); + } + catch (Exception ex) + { + Variaveis.MostrarLog($"[OperacaoModel.InserirLog] Erro ao inserir log: {ex.Message}"); + } } @@ -3736,7 +3743,7 @@ namespace AgroBase.Models MassaReservatorio = _Reservatorio.MassaAtual_Kg, PressaoLinha = _DispAtu.Dados.PressaoLinha, PressaoLinhaCalc = _DispAtu.Dados.PressaoLinhaCalc, - VazaoInstantanea = _DispAtu.Dados.VazaoFluxoMLpSInstantanea, + VazaoInstantanea = _DispAtu.Dados.VazaoFluxoMLpSInstantaneaCorrigida, VazaoInstantaneaCalc = _DispAtu.Dados.VazaoFluxoMLpSInstantaneaCalc, VazaoMedia = _DispAtu.Dados.VazaoMediaMLs, TempoBombaAtuada = _DispAtu.Dados.TempoAtuado, diff --git a/AgroBase/AgroBase/Services/MKS057DCanService.cs b/AgroBase/AgroBase/Services/MKS057DCanService.cs index 9127353a9..694010c2b 100644 --- a/AgroBase/AgroBase/Services/MKS057DCanService.cs +++ b/AgroBase/AgroBase/Services/MKS057DCanService.cs @@ -15,126 +15,265 @@ namespace AgroBase.Services private static int _MStep { get; set; } = 16; private static T_Code Dispositivo { get; set; } = T_Code.Mks; - public static MksHandler _MksHandler { get; set; } = - new MksHandler(); + public static MksHandler _MksHandler { get; set; } = new MksHandler(); - private static readonly object _sync = - new object(); + private static readonly object _sync = new object(); - private static readonly HashSet _handlersRegistrados = - new HashSet(); - - private static readonly Dictionary _ultimoEnvioConsultaPorDriver = - new Dictionary(); - - private static readonly Dictionary _ultimoEnvioParametro = - new Dictionary(); - - private static readonly Dictionary _ultimaConsultaPendente = - new Dictionary(); - - private static readonly Dictionary _cursorPollingPorDriver = - new Dictionary(); - - private static readonly Dictionary _ultimoComandoControleAssinatura = - new Dictionary(); - - private static readonly Dictionary _ultimoComandoControleQuando = - new Dictionary(); + private static readonly HashSet _handlersRegistrados = new HashSet(); + private static readonly Dictionary _ultimoEnvioConsultaPorDriver = new Dictionary(); + private static readonly Dictionary _ultimoEnvioParametro = new Dictionary(); + private static readonly Dictionary _cursorPollingPorDriver = new Dictionary(); + private static readonly Dictionary _ultimoComandoControleAssinatura = new Dictionary(); + private static readonly Dictionary _ultimoComandoControleQuando = new Dictionary(); /* - * Filosofia MKS: - * - Comando direcional tem prioridade e não deve ficar preso por polling. - * - Polling de telemetria deve ser escalonado para não congestionar CAN. - * - Cada resposta MKS vem com funcCode próprio, então a janela pendente pode - * ser mais leve que no OID. + * MKS V3 + * + * Classe completa: + * - mantém parsers de todos os dados úteis do MKS; + * - mantém intervalos configurados para todos; + * - permite RequisitarDado(...) manual para qualquer parâmetro suportado. + * + * Polling normal: + * - só pede PulsosRecebidos e IO. + * + * Motivo: + * - PulsosRecebidos calcula ângulo. + * - IO identifica o lado real pelo sensor do rasgo/rebaixo no eixo tubular. + * - demais leituras ficam disponíveis para diagnóstico, mas não entram no loop normal. + * + * Controle: + * - Movimento, Parar e SetZero são prioridade e não usam dedupe. */ - private static readonly TimeSpan IntervaloMinimoConsultaPorDriver = - TimeSpan.FromMilliseconds(90); - private static readonly TimeSpan JanelaConsultaPendente = - TimeSpan.FromMilliseconds(180); - - private static readonly TimeSpan JanelaDedupeComandoControle = - TimeSpan.FromMilliseconds(50); - - private static readonly TimeSpan TempoRespostaRecente = - TimeSpan.FromSeconds(3); - - private static readonly TimeSpan TempoRemoverDriver = - TimeSpan.FromSeconds(15); - - private static readonly TimeSpan TempoRespostaDescoberta = - TimeSpan.FromMilliseconds(700); + private static readonly TimeSpan TempoRespostaRecente = TimeSpan.FromSeconds(3); + private static readonly TimeSpan TempoRemoverDriver = TimeSpan.FromSeconds(15); + private static readonly TimeSpan TempoRespostaDescoberta = TimeSpan.FromMilliseconds(700); + private static readonly TimeSpan JanelaDedupeComandoControle = TimeSpan.FromMilliseconds(20); /* - * Ordem normal: - * - PulsosRecebidos = posição real usada para diagnóstico/zero. - * - RPM e IO dão saúde dinâmica. - * - ErroAcumulado é diagnóstico. - * - Versao raramente precisa repetir, mas entra devagar. + * Pooling de produção: + * operação normal = ângulo rápido + IO lento. */ public static readonly MksFuncCode[] OrdemPolling = { MksFuncCode.PulsosRecebidos, - MksFuncCode.RPM, MksFuncCode.IO, - MksFuncCode.ErroAcumulado, - MksFuncCode.EncoderAcumulado, - MksFuncCode.EncoderCarregado, - MksFuncCode.Versao, + MksFuncCode.RPM, }; /* - * Durante referência, IO e pulsos ficam mais importantes. + * Pooling de referenciamento: + * IO e ângulo precisam ser rápidos para pegar a transição do sensor. */ public static readonly MksFuncCode[] OrdemPollingReferenciando = { MksFuncCode.IO, MksFuncCode.PulsosRecebidos, - MksFuncCode.RPM, - MksFuncCode.ErroAcumulado, + }; + + /* + * Todos os parâmetros suportados pela classe. + * Não significa que todos entram no polling. + */ + public static readonly MksFuncCode[] ParametrosSuportados = + { + MksFuncCode.EncoderCarregado, MksFuncCode.EncoderAcumulado, + MksFuncCode.RPM, + MksFuncCode.PulsosRecebidos, + MksFuncCode.IO, + MksFuncCode.ErroAcumulado, + MksFuncCode.Versao, }; private static readonly Dictionary IntervaloParametro = new Dictionary { - { MksFuncCode.PulsosRecebidos, TimeSpan.FromMilliseconds(180) }, - { MksFuncCode.RPM, TimeSpan.FromMilliseconds(300) }, - { MksFuncCode.IO, TimeSpan.FromMilliseconds(300) }, - { MksFuncCode.ErroAcumulado, TimeSpan.FromMilliseconds(800) }, - { MksFuncCode.EncoderAcumulado, TimeSpan.FromMilliseconds(600) }, - { MksFuncCode.EncoderCarregado, TimeSpan.FromMilliseconds(1200) }, + /* + * Produção. + */ + { MksFuncCode.PulsosRecebidos, TimeSpan.FromMilliseconds(70) }, + { MksFuncCode.IO, TimeSpan.FromMilliseconds(700) }, + + /* + * Diagnóstico opcional. + * Não entram em OrdemPolling por padrão. + */ + { MksFuncCode.RPM, TimeSpan.FromMilliseconds(500) }, + { MksFuncCode.ErroAcumulado, TimeSpan.FromSeconds(1) }, + { MksFuncCode.EncoderAcumulado, TimeSpan.FromSeconds(1) }, + { MksFuncCode.EncoderCarregado, TimeSpan.FromSeconds(2) }, { MksFuncCode.Versao, TimeSpan.FromSeconds(5) }, }; private static readonly Dictionary IntervaloParametroReferenciando = new Dictionary { - { MksFuncCode.IO, TimeSpan.FromMilliseconds(90) }, - { MksFuncCode.PulsosRecebidos, TimeSpan.FromMilliseconds(100) }, - { MksFuncCode.RPM, TimeSpan.FromMilliseconds(250) }, - { MksFuncCode.ErroAcumulado, TimeSpan.FromMilliseconds(500) }, - { MksFuncCode.EncoderAcumulado, TimeSpan.FromMilliseconds(350) }, - { MksFuncCode.EncoderCarregado, TimeSpan.FromMilliseconds(1000) }, + /* + * Referenciamento. + */ + { MksFuncCode.IO, TimeSpan.FromMilliseconds(35) }, + { MksFuncCode.PulsosRecebidos, TimeSpan.FromMilliseconds(35) }, + + /* + * Diagnóstico opcional durante referenciamento. + */ + { MksFuncCode.RPM, TimeSpan.FromMilliseconds(500) }, + { MksFuncCode.ErroAcumulado, TimeSpan.FromSeconds(1) }, + { MksFuncCode.EncoderAcumulado, TimeSpan.FromMilliseconds(700) }, + { MksFuncCode.EncoderCarregado, TimeSpan.FromSeconds(2) }, { MksFuncCode.Versao, TimeSpan.FromSeconds(5) }, }; + public class MksParametroHealthConfig + { + public MksFuncCode Parametro { get; set; } + public string RedisKey { get; set; } + + /* + * Frequência saudável esperada. + * Não é a frequência máxima possível, nem 1000 / polling_ms. + */ + public double FreqBaseHz { get; set; } + + /* + * Abaixo disso começa alerta. + */ + public double FreqMinHz { get; set; } + + /* + * Se false, publica config e frequência, mas não cobra no health. + */ + public bool EmUsoHealth { get; set; } = true; + } + + public static readonly Dictionary HealthParametroConfig = new Dictionary + { + { + MksFuncCode.PulsosRecebidos, + new MksParametroHealthConfig + { + Parametro = MksFuncCode.PulsosRecebidos, + RedisKey = "pulsosrecebidos", + FreqBaseHz = 5.0, + FreqMinHz = 2.0, + EmUsoHealth = true + } + }, + { + MksFuncCode.IO, + new MksParametroHealthConfig + { + Parametro = MksFuncCode.IO, + RedisKey = "io", + FreqBaseHz = 0.7, + FreqMinHz = 0.25, + EmUsoHealth = true + } + }, + { + MksFuncCode.RPM, + new MksParametroHealthConfig + { + Parametro = MksFuncCode.RPM, + RedisKey = "rpm", + FreqBaseHz = 0.7, + FreqMinHz = 0.25, + EmUsoHealth = true + } + }, + + /* + * Diagnóstico disponível, mas não cobrado no health. + */ + { + MksFuncCode.ErroAcumulado, + new MksParametroHealthConfig + { + Parametro = MksFuncCode.ErroAcumulado, + RedisKey = "erroacumulado", + FreqBaseHz = 0.2, + FreqMinHz = 0.08, + EmUsoHealth = false + } + }, + { + MksFuncCode.EncoderAcumulado, + new MksParametroHealthConfig + { + Parametro = MksFuncCode.EncoderAcumulado, + RedisKey = "encoderacumulado", + FreqBaseHz = 0.2, + FreqMinHz = 0.08, + EmUsoHealth = false + } + }, + { + MksFuncCode.EncoderCarregado, + new MksParametroHealthConfig + { + Parametro = MksFuncCode.EncoderCarregado, + RedisKey = "encodercarregado", + FreqBaseHz = 0.1, + FreqMinHz = 0.04, + EmUsoHealth = false + } + }, + { + MksFuncCode.Versao, + new MksParametroHealthConfig + { + Parametro = MksFuncCode.Versao, + RedisKey = "versao", + FreqBaseHz = 0.1, + FreqMinHz = 0.04, + EmUsoHealth = false + } + }, + }; + + public static MksParametroHealthConfig ObterConfigHealth(MksFuncCode parametro) + { + if (HealthParametroConfig.TryGetValue(parametro, out var cfg)) + return cfg; + + return new MksParametroHealthConfig + { + Parametro = parametro, + RedisKey = parametro.ToString().ToLowerInvariant(), + FreqBaseHz = 0.5, + FreqMinHz = 0.2, + EmUsoHealth = false + }; + } + + public static double ObterFreqBaseHz(MksFuncCode parametro) + { + return ObterConfigHealth(parametro).FreqBaseHz; + } + + public static double ObterFreqMinHz(MksFuncCode parametro) + { + return ObterConfigHealth(parametro).FreqMinHz; + } + + public static bool ParametroEmUsoHealth(MksFuncCode parametro) + { + return ObterConfigHealth(parametro).EmUsoHealth; + } + public static bool Iniciado { get { return CanManager.CanService.IsConnected && - SerialService.DispositivosMapeados.Any( - x => x.Dispositivo == T_Code.Dir - ) && - DadosLeitura.Any( - x => x.Iniciado && - x.UltimoComandoRecebido != DateTime.MinValue && - DateTime.Now - x.UltimoComandoRecebido <= - TempoRespostaRecente + SerialService.DispositivosMapeados.Any(x => x.Dispositivo == T_Code.Dir) && + DadosLeitura.Any(x => + x.Iniciado && + x.UltimoComandoRecebido != DateTime.MinValue && + DateTime.Now - x.UltimoComandoRecebido <= TempoRespostaRecente ); } } @@ -154,55 +293,68 @@ namespace AgroBase.Services } } - public static DateTime UltimoReferenciamento = - DateTime.MinValue; + public static DateTime UltimoReferenciamento = DateTime.MinValue; public static int TaxaAmostragem { get { - /* - * Health/loop pode chamar AtualizarDados nessa taxa. - * O throttle interno garante que não vamos lotar o barramento. - */ - return Referenciando ? 80 : 120; + return Referenciando ? 40 : 80; } } public static double ObterIntervaloPollingParametroMs(MksFuncCode parametro) { - TimeSpan intervalo = - ObterIntervaloParametro(parametro); - - return intervalo.TotalMilliseconds; + return ObterIntervaloParametro(parametro).TotalMilliseconds; } public static (string campo, object valor)[] GerarCamposRedisHealthBase(int enderecoDir) { - var campos = - new List<(string campo, object valor)>(); + var campos = new List<(string campo, object valor)>(); - foreach (var parametro in OrdemPolling) + foreach (var parametro in ParametrosSuportados) { + var cfg = ObterConfigHealth(parametro); + string key = cfg.RedisKey; + + campos.Add(($"{enderecoDir}.freq_base.{key}", cfg.FreqBaseHz)); + campos.Add(($"{enderecoDir}.freq_min.{key}", cfg.FreqMinHz)); + campos.Add(($"{enderecoDir}.freq_uso.{key}", cfg.EmUsoHealth)); + campos.Add(( - $"{enderecoDir}.polling_ms.{parametro.ToString().ToLowerInvariant()}", + $"{enderecoDir}.polling_ms.{key}", ObterIntervaloPollingParametroMs(parametro) )); + + campos.Add(( + $"{enderecoDir}.polling_em_uso.{key}", + OrdemPolling.Contains(parametro) || + OrdemPollingReferenciando.Contains(parametro) + )); } - campos.Add(( - $"{enderecoDir}.polling.intervalo_timer_ms", - TaxaAmostragem - )); - - campos.Add(( - $"{enderecoDir}.polling.qtd_parametros", - OrdemPolling.Length - )); + campos.Add(($"{enderecoDir}.polling.intervalo_timer_ms", TaxaAmostragem)); + campos.Add(($"{enderecoDir}.polling.qtd_parametros_pool", OrdemPolling.Length)); + campos.Add(($"{enderecoDir}.polling.qtd_parametros_suportados", ParametrosSuportados.Length)); campos.Add(( $"{enderecoDir}.polling.ordem", - OrdemPolling.Select(x => x.ToString().ToLowerInvariant()).ToList() + OrdemPolling.Select(x => ObterConfigHealth(x).RedisKey).ToList() + )); + + campos.Add(( + $"{enderecoDir}.polling.ordem_referenciando", + OrdemPollingReferenciando.Select(x => ObterConfigHealth(x).RedisKey).ToList() + )); + + campos.Add(( + $"{enderecoDir}.polling.suportados", + ParametrosSuportados.Select(x => ObterConfigHealth(x).RedisKey).ToList() + )); + + campos.Add(( + $"{enderecoDir}.polling.filosofia", + "MKS completo nos parsers, health por parâmetro e polling enxuto" )); return campos.ToArray(); @@ -220,8 +372,7 @@ namespace AgroBase.Services if (Variaveis.OperacaoEmAndamento.DispMvd == null) return false; - List modIds = - new List(); + List modIds = new List(); var modulos = Variaveis @@ -229,31 +380,24 @@ namespace AgroBase.Services .DispMvd .Dados .Modulos - .Where(x => - x?.DirMotor != null && - !x.DirMotor.Inicializado) + .Where(x => x?.DirMotor != null && !x.DirMotor.Inicializado) .ToList(); foreach (var modulo in modulos) { - byte idTx = - modulo.DirMotor._EnderecoCAN_Tx; - - byte idRx = - modulo.DirMotor._EnderecoCAN_Rx; + byte idTx = modulo.DirMotor._EnderecoCAN_Tx; + byte idRx = modulo.DirMotor._EnderecoCAN_Rx; RegistrarHandlersMotor(idTx, idRx); EnviarComandoDescoberta(idTx, idRx); - DateTime inicio = - DateTime.Now; + DateTime inicio = DateTime.Now; bool Respondido() => - DadosLeitura.Any( - x => - x.EnderecoRx == idRx && - x.UltimoComandoRecebido > inicio + DadosLeitura.Any(x => + x.EnderecoRx == idRx && + x.UltimoComandoRecebido > inicio ); await FuncoesGlobais.AguardarCondicaoAsync( @@ -292,33 +436,24 @@ namespace AgroBase.Services modulo.DirMotor._EnderecoCAN_Rx ); - if (SerialService.DispositivosMapeados.Any( - x => x.Dispositivo == T_Code.Dir && - x.Mod_ID == modId)) - { + if (SerialService.DispositivosMapeados.Any(x => x.Dispositivo == T_Code.Dir && x.Mod_ID == modId)) continue; - } var leitura = - DadosLeitura.FirstOrDefault( - x => x.EnderecoTx == modulo.DirMotor._EnderecoCAN_Tx - ); + DadosLeitura.FirstOrDefault(x => x.EnderecoTx == modulo.DirMotor._EnderecoCAN_Tx); if (leitura == null) continue; - SerialService.DispositivosMapeados.Add( - new DispositivoDetalhesModel() - { - Dispositivo = T_Code.Dir, - Endereco = CanManager.CanService._portName, - Versao = (leitura.FV ?? "").ToString(), - Mod_ID = modId, - } - ); + SerialService.DispositivosMapeados.Add(new DispositivoDetalhesModel() + { + Dispositivo = T_Code.Dir, + Endereco = CanManager.CanService._portName, + Versao = (leitura.FV ?? "").ToString(), + Mod_ID = modId, + }); - Variaveis.OperacaoEmAndamento.ModsCalibragem - .TryGetValue(T_Code.Dir, out var m); + Variaveis.OperacaoEmAndamento.ModsCalibragem.TryGetValue(T_Code.Dir, out var m); if (m == null) { @@ -328,18 +463,15 @@ namespace AgroBase.Services ); } - Variaveis.OperacaoEmAndamento - .ModsCalibragem[T_Code.Dir][modId] = false; + Variaveis.OperacaoEmAndamento.ModsCalibragem[T_Code.Dir][modId] = false; - Variaveis.OperacaoEmAndamento - .Sensoriamento - .InserirLog( - T_Code.Dir, - StatusModulo.Conectado, - 0, - "Dispositivo conectado", - modId - ); + Variaveis.OperacaoEmAndamento.Sensoriamento.InserirLog( + T_Code.Dir, + StatusModulo.Conectado, + 0, + "Dispositivo conectado", + modId + ); } } @@ -354,21 +486,13 @@ namespace AgroBase.Services if (_handlersRegistrados.Contains(idRx)) return; - CanManager.CanService.RegistrarHandler( - idRx, - _MksHandler - ); - + CanManager.CanService.RegistrarHandler(idRx, _MksHandler); _handlersRegistrados.Add(idRx); } } private static void RegistrarHandlersMotor(byte idTx, byte idRx) { - /* - * Na prática o MKS normalmente responde no idRx, mas registrar ambos - * evita buraco quando o gateway/CanService inverter ou normalizar IDs. - */ RegistrarHandler(idRx); if (idTx != idRx) @@ -379,31 +503,19 @@ namespace AgroBase.Services // Utils // ============================================================ - private static int ConverterAnguloParaValor( - double angulo, - ModosControle modo) + private static int ConverterAnguloParaValor(double angulo, ModosControle modo) { int val = 0; if (modo == ModosControle.Pulsos) { - double pulsos = - _MStep * 200.0; - - double relacao = - (pulsos * VariaveisEquipamento.ReducaoDirecional) / - 360.0; - - val = - Convert.ToInt32(angulo * relacao); + double pulsos = _MStep * 200.0; + double relacao = (pulsos * VariaveisEquipamento.ReducaoDirecional) / 360.0; + val = Convert.ToInt32(angulo * relacao); } else { - val = - Convert.ToInt32( - angulo * - VariaveisEquipamento.ReducaoDirecional - ); + val = Convert.ToInt32(angulo * VariaveisEquipamento.ReducaoDirecional); } return val; @@ -411,10 +523,7 @@ namespace AgroBase.Services private static TimeSpan ObterIntervaloParametro(MksFuncCode parametro) { - var tabela = - Referenciando - ? IntervaloParametroReferenciando - : IntervaloParametro; + var tabela = Referenciando ? IntervaloParametroReferenciando : IntervaloParametro; if (tabela.TryGetValue(parametro, out TimeSpan intervalo)) return intervalo; @@ -422,46 +531,28 @@ namespace AgroBase.Services return TimeSpan.FromMilliseconds(500); } + private static TimeSpan ObterIntervaloMinimoConsultaPorDriver() + { + return Referenciando + ? TimeSpan.FromMilliseconds(18) + : TimeSpan.FromMilliseconds(45); + } + private static bool DeveContabilizarTimeoutHealth(MksFuncCode dado) { - /* - * Saúde CAN deve refletir leituras importantes, não toda telemetria. - */ - return - dado == MksFuncCode.PulsosRecebidos || - dado == MksFuncCode.RPM || - dado == MksFuncCode.ErroAcumulado; + return dado == MksFuncCode.PulsosRecebidos; } private static bool EhFuncaoConsulta(MksFuncCode dado) { - return - dado == MksFuncCode.EncoderCarregado || - dado == MksFuncCode.EncoderAcumulado || - dado == MksFuncCode.RPM || - dado == MksFuncCode.PulsosRecebidos || - dado == MksFuncCode.IO || - dado == MksFuncCode.ErroAcumulado || - dado == MksFuncCode.Versao; - } - - private static bool EhFuncaoControle(MksFuncCode dado) - { - return - dado == MksFuncCode.SetZero || - dado == MksFuncCode.HabilitarMotor || - dado == MksFuncCode.ModoControleAnguloRelativo || - dado == MksFuncCode.ModoControleAnguloAbsoluto || - dado == MksFuncCode.ModoControlePulsos; + return ParametrosSuportados.Contains(dado); } // ============================================================ // Comandos CAN // ============================================================ - private static void EnviarComandoDescoberta( - byte idTx, - byte idRx) + private static void EnviarComandoDescoberta(byte idTx, byte idRx) { RegistrarHandlersMotor(idTx, idRx); @@ -502,35 +593,36 @@ namespace AgroBase.Services ); } - public static void SetModoOperacao( - byte idTx, - byte idRx, - byte modo) + public static void SetModoOperacao(byte idTx, byte idRx, byte modo) { - byte[] payload = - new byte[] { modo }; - EnviarComandoControle( idTx, idRx, 0x82, - payload, + new byte[] { modo }, dedupe: true ); } - public static void RequisitarDado( - byte idTx, - byte idRx, - MksFuncCode dado) + public static void RequisitarDado(byte idTx, byte idRx, MksFuncCode dado) { RequisitarDadoInterno( idTx, idRx, dado, forcar: false, - contabilizarTimeoutHealth: - DeveContabilizarTimeoutHealth(dado) + contabilizarTimeoutHealth: DeveContabilizarTimeoutHealth(dado) + ); + } + + public static void RequisitarDadoForcado(byte idTx, byte idRx, MksFuncCode dado) + { + RequisitarDadoInterno( + idTx, + idRx, + dado, + forcar: true, + contabilizarTimeoutHealth: DeveContabilizarTimeoutHealth(dado) ); } @@ -544,48 +636,27 @@ namespace AgroBase.Services if (!EhFuncaoConsulta(dado)) return false; - DateTime agora = - DateTime.Now; + DateTime agora = DateTime.Now; lock (_sync) { string chaveParametro = - idTx.ToString("X2") + - ":" + - ((byte)dado).ToString("X2"); + idTx.ToString("X2") + ":" + ((byte)dado).ToString("X2"); if (!forcar) { - if (_ultimoEnvioConsultaPorDriver - .TryGetValue(idTx, out DateTime ultimoDriver) && - agora - ultimoDriver < - IntervaloMinimoConsultaPorDriver) + TimeSpan intervaloMinDriver = ObterIntervaloMinimoConsultaPorDriver(); + + if (_ultimoEnvioConsultaPorDriver.TryGetValue(idTx, out DateTime ultimoDriver) && + agora - ultimoDriver < intervaloMinDriver) { return false; } - /* - * MKS tem funcCode próprio na resposta, então não precisa ser - * tão rígido quanto OID. Ainda assim, evita empilhar consulta - * em cima de consulta quando o driver acabou de receber uma. - */ - if (_ultimaConsultaPendente - .TryGetValue(idTx, out _) && - _ultimoEnvioConsultaPorDriver - .TryGetValue(idTx, out DateTime ultimoPendente) && - agora - ultimoPendente < - JanelaConsultaPendente) - { - return false; - } + TimeSpan intervalo = ObterIntervaloParametro(dado); - TimeSpan intervalo = - ObterIntervaloParametro(dado); - - if (_ultimoEnvioParametro - .TryGetValue(chaveParametro, out DateTime ultimoParam) && - agora - ultimoParam < - intervalo) + if (_ultimoEnvioParametro.TryGetValue(chaveParametro, out DateTime ultimoParam) && + agora - ultimoParam < intervalo) { return false; } @@ -593,7 +664,6 @@ namespace AgroBase.Services _ultimoEnvioConsultaPorDriver[idTx] = agora; _ultimoEnvioParametro[chaveParametro] = agora; - _ultimaConsultaPendente[idTx] = dado; } EnviarComando( @@ -603,19 +673,15 @@ namespace AgroBase.Services (byte)dado, payload: null, get: true, - contabilizarTimeoutHealth: - contabilizarTimeoutHealth + contabilizarTimeoutHealth: contabilizarTimeoutHealth ); return true; } - public static void RequisitarProximoDado( - byte idTx, - byte idRx) + public static void RequisitarProximoDado(byte idTx, byte idRx) { - MksFuncCode? proximo = - EscolherProximoParametro(idTx); + MksFuncCode? proximo = EscolherProximoParametro(idTx); if (proximo.HasValue) { @@ -624,8 +690,7 @@ namespace AgroBase.Services idRx, proximo.Value, forcar: false, - contabilizarTimeoutHealth: - DeveContabilizarTimeoutHealth(proximo.Value) + contabilizarTimeoutHealth: DeveContabilizarTimeoutHealth(proximo.Value) ); } } @@ -646,8 +711,7 @@ namespace AgroBase.Services foreach (var mod in modulos) { - var driver = - mod?.DirMotor; + var driver = mod?.DirMotor; if (driver == null || !driver.Inicializado) continue; @@ -662,53 +726,35 @@ namespace AgroBase.Services } catch (Exception ex) { - Console.WriteLine( - "[MKS] Erro em AtualizarDados: " + - ex.Message - ); + Console.WriteLine("[MKS] Erro em AtualizarDados: " + ex.Message); } } private static MksFuncCode? EscolherProximoParametro(byte idTx) { - DateTime agora = - DateTime.Now; + DateTime agora = DateTime.Now; - MksFuncCode[] ordem = - Referenciando - ? OrdemPollingReferenciando - : OrdemPolling; + MksFuncCode[] ordem = Referenciando ? OrdemPollingReferenciando : OrdemPolling; if (ordem == null || ordem.Length == 0) return null; - int cursor = - _cursorPollingPorDriver.TryGetValue(idTx, out int c) - ? c - : 0; + int cursor = _cursorPollingPorDriver.TryGetValue(idTx, out int c) ? c : 0; for (int tentativa = 0; tentativa < ordem.Length; tentativa++) { - int index = - (cursor + tentativa) % ordem.Length; - - MksFuncCode parametro = - ordem[index]; + int index = (cursor + tentativa) % ordem.Length; + MksFuncCode parametro = ordem[index]; string chave = - idTx.ToString("X2") + - ":" + - ((byte)parametro).ToString("X2"); + idTx.ToString("X2") + ":" + ((byte)parametro).ToString("X2"); - TimeSpan intervalo = - ObterIntervaloParametro(parametro); + TimeSpan intervalo = ObterIntervaloParametro(parametro); if (!_ultimoEnvioParametro.TryGetValue(chave, out DateTime ultimo) || agora - ultimo >= intervalo) { - _cursorPollingPorDriver[idTx] = - (index + 1) % ordem.Length; - + _cursorPollingPorDriver[idTx] = (index + 1) % ordem.Length; return parametro; } } @@ -718,8 +764,7 @@ namespace AgroBase.Services private static void RemoverDriversMuitoAntigos() { - DateTime agora = - DateTime.Now; + DateTime agora = DateTime.Now; List antigos; @@ -730,8 +775,7 @@ namespace AgroBase.Services .Where(x => x.Iniciado && x.UltimoComandoRecebido != DateTime.MinValue && - agora - x.UltimoComandoRecebido > - TempoRemoverDriver) + agora - x.UltimoComandoRecebido > TempoRemoverDriver) .ToList(); } @@ -749,8 +793,7 @@ namespace AgroBase.Services dedupe: false ); - UltimoReferenciamento = - DateTime.Now; + UltimoReferenciamento = DateTime.Now; } public static void Parar( @@ -759,18 +802,16 @@ namespace AgroBase.Services ModosControle modo = ModosControle.AnguloAbsoluto) { byte funcCode = - modo == ModosControle.Pulsos - ? (byte)MksFuncCode.ModoControlePulsos - : modo == ModosControle.AnguloRelativo - ? (byte)MksFuncCode.ModoControleAnguloRelativo - : (byte)MksFuncCode.ModoControleAnguloAbsoluto; + modo == ModosControle.Pulsos ? (byte)MksFuncCode.ModoControlePulsos : + modo == ModosControle.AnguloRelativo ? (byte)MksFuncCode.ModoControleAnguloRelativo : + (byte)MksFuncCode.ModoControleAnguloAbsoluto; EnviarComandoControle( idTx, idRx, funcCode, new byte[] { 0x00 }, - dedupe: true + dedupe: false ); } @@ -800,17 +841,13 @@ namespace AgroBase.Services idRx, funcCode, payload, - dedupe: true + dedupe: false ); } - public static void AlterarStatusMotor( - byte idTx, - byte idRx, - bool habilitado) + public static void AlterarStatusMotor(byte idTx, byte idRx, bool habilitado) { - byte comando = - habilitado ? (byte)0x01 : (byte)0x00; + byte comando = habilitado ? (byte)0x01 : (byte)0x00; EnviarComandoControle( idTx, @@ -828,19 +865,9 @@ namespace AgroBase.Services byte[] payload, bool dedupe) { - if (dedupe && - DeveDroparComandoDuplicado( - idTx, - funcCode, - payload)) - { + if (dedupe && DeveDroparComandoDuplicado(idTx, funcCode, payload)) return; - } - /* - * Controle do MKS pode responder com status do movimento, - * mas não deve entrar na fila como consulta pendente de health. - */ EnviarComando( Dispositivo, idTx, @@ -852,45 +879,29 @@ namespace AgroBase.Services ); } - private static bool DeveDroparComandoDuplicado( - byte idTx, - byte funcCode, - byte[] payload) + private static bool DeveDroparComandoDuplicado(byte idTx, byte funcCode, byte[] payload) { - string chave = - idTx.ToString("X2") + - ":" + - funcCode.ToString("X2"); + string chave = idTx.ToString("X2") + ":" + funcCode.ToString("X2"); string assinatura = payload == null ? "" - : string.Join( - "", - payload.Select(x => x.ToString("X2")) - ); + : string.Join("", payload.Select(x => x.ToString("X2"))); - DateTime agora = - DateTime.Now; + DateTime agora = DateTime.Now; lock (_sync) { - if (_ultimoComandoControleAssinatura - .TryGetValue(chave, out string anterior) && + if (_ultimoComandoControleAssinatura.TryGetValue(chave, out string anterior) && anterior == assinatura && - _ultimoComandoControleQuando - .TryGetValue(chave, out DateTime quando) && - agora - quando < - JanelaDedupeComandoControle) + _ultimoComandoControleQuando.TryGetValue(chave, out DateTime quando) && + agora - quando < JanelaDedupeComandoControle) { return true; } - _ultimoComandoControleAssinatura[chave] = - assinatura; - - _ultimoComandoControleQuando[chave] = - agora; + _ultimoComandoControleAssinatura[chave] = assinatura; + _ultimoComandoControleQuando[chave] = agora; return false; } @@ -903,92 +914,41 @@ namespace AgroBase.Services ModosControle modo, out byte funcCode) { - List payload = - new List(); + List payload = new List(); - velocidade = - Math.Max( - 0, - Math.Min(velocidade, ushort.MaxValue) - ); + ushort vel = (ushort)Math.Max(0, Math.Min(velocidade, ushort.MaxValue)); + byte[] velocidadeBytes = BitConverter.GetBytes(vel); - ushort vel = - (ushort)velocidade; - - byte[] velocidadeBytes = - BitConverter.GetBytes(vel); - - byte acc = - 0; + byte acc = 0; if (modo == ModosControle.Pulsos) { - funcCode = - (byte)MksFuncCode.ModoControlePulsos; + byte sentidoByte = sentido == Sentido.Horario ? (byte)0x80 : (byte)0x00; - byte sentidoByte = - sentido == Sentido.Horario - ? (byte)0x80 - : (byte)0x00; + uint pulsos = (uint)ConverterAnguloParaValor(angulo, modo); + byte[] pulsosBytes = BitConverter.GetBytes(pulsos); - uint pulsos = - (uint)Math.Abs( - ConverterAnguloParaValor( - angulo, - modo - ) - ); - - byte[] pulsosBytes = - BitConverter.GetBytes(pulsos); - - velocidadeBytes[1] |= - sentidoByte; + velocidadeBytes[1] |= sentidoByte; payload.Add(velocidadeBytes[1]); payload.Add(velocidadeBytes[0]); payload.Add(acc); - /* - * Driver espera 24 bits em Big Endian. - */ payload.Add(pulsosBytes[2]); payload.Add(pulsosBytes[1]); payload.Add(pulsosBytes[0]); + funcCode = (byte)MksFuncCode.ModoControlePulsos; return payload.ToArray(); } - int pos = - ConverterAnguloParaValor( - angulo, - modo - ); + int pos = ConverterAnguloParaValor(angulo, modo); - long absAxisText = - Convert.ToInt32(pos.ToString(), 10); - - byte[] absAxis = - BitConverter.GetBytes( - absAxisText * 16384 / 360 - ); - - byte[] posInt24 = - new byte[] - { - absAxis[2], - absAxis[1], - absAxis[0] - }; - - payload.AddRange( - new byte[] - { - velocidadeBytes[1], - velocidadeBytes[0] - } - ); + long absAxisText = Convert.ToInt32(pos.ToString(), 10); + byte[] absAxis = BitConverter.GetBytes(absAxisText * 16384 / 360); + byte[] posInt24 = new byte[] { absAxis[2], absAxis[1], absAxis[0] }; + payload.AddRange(new byte[] { velocidadeBytes[1], velocidadeBytes[0] }); payload.Add(acc); payload.AddRange(posInt24); @@ -1004,69 +964,40 @@ namespace AgroBase.Services // Decodificação // ============================================================ - private static ushort DecodeUInt16BE( - byte[] data, - int startIndex) + private static ushort DecodeUInt16BE(byte[] data, int startIndex) { - if (data == null || - data.Length < startIndex + 2) - { + if (data == null || data.Length < startIndex + 2) return 0; - } - return (ushort)( - (data[startIndex] << 8) | - data[startIndex + 1] - ); + return (ushort)((data[startIndex] << 8) | data[startIndex + 1]); } - private static int DecodeInt32BE( - byte[] data, - int startIndex) + private static int DecodeInt32BE(byte[] data, int startIndex) { - if (data == null || - data.Length < startIndex + 4) - { + if (data == null || data.Length < startIndex + 4) return 0; - } - int valor = + return (data[startIndex] << 24) | (data[startIndex + 1] << 16) | (data[startIndex + 2] << 8) | data[startIndex + 3]; - - return valor; } - private static long DecodeInt48BE( - byte[] data, - int startIndex) + private static long DecodeInt48BE(byte[] data, int startIndex) { - if (data == null || - data.Length < startIndex + 6) - { + if (data == null || data.Length < startIndex + 6) return 0; - } - byte[] buffer = - new byte[8]; + byte[] buffer = new byte[8]; for (int i = 0; i < 6; i++) buffer[i] = data[startIndex + 5 - i]; - buffer[6] = - (buffer[5] & 0x80) != 0 - ? (byte)0xFF - : (byte)0x00; + buffer[6] = (buffer[5] & 0x80) != 0 ? (byte)0xFF : (byte)0x00; + buffer[7] = buffer[6]; - buffer[7] = - buffer[6]; - - return BitConverter.ToInt64( - buffer, - 0 - ); + return BitConverter.ToInt64(buffer, 0); } // ============================================================ @@ -1096,15 +1027,9 @@ namespace AgroBase.Services private double PulsosRecebidosParaAngulo(int valor) { - double reducao = - VariaveisEquipamento.ReducaoDirecional; - - double pulsos = - _MStep * 200.0; - - double relacao = - (pulsos * reducao) / - 360.0; + double reducao = VariaveisEquipamento.ReducaoDirecional; + double pulsos = _MStep * 200.0; + double relacao = (pulsos * reducao) / 360.0; return valor / relacao; } @@ -1118,19 +1043,9 @@ namespace AgroBase.Services { lock (DadosLeitura) { - byte idTxMsg = - CanManager.ConverterIdCan( - mensagem.IdTx - )[0]; + byte idTxMsg = CanManager.ConverterIdCan(mensagem.IdTx)[0]; + byte idRxMsg = CanManager.ConverterIdCan(mensagem.IdRx)[0]; - byte idRxMsg = - CanManager.ConverterIdCan( - mensagem.IdRx - )[0]; - - /* - * Primeiro tenta par exato. - */ var item = DadosLeitura.FirstOrDefault( x => x.EnderecoTx == idTxMsg && @@ -1140,10 +1055,6 @@ namespace AgroBase.Services if (item != null) return item; - /* - * Depois tenta achar pelo motor configurado, igual à estratégia - * mais robusta do OID. - */ var motor = Variaveis .OperacaoEmAndamento @@ -1165,33 +1076,27 @@ namespace AgroBase.Services { item = DadosLeitura.FirstOrDefault( - x => x.EnderecoTx == - motor._EnderecoCAN_Tx + x => x.EnderecoTx == motor._EnderecoCAN_Tx ); if (item == null) { - item = - new MKS057DModel - { - EnderecoTx = motor._EnderecoCAN_Tx, - EnderecoRx = motor._EnderecoCAN_Rx - }; + item = new MKS057DModel + { + EnderecoTx = motor._EnderecoCAN_Tx, + EnderecoRx = motor._EnderecoCAN_Rx + }; DadosLeitura.Add(item); } else { - item.EnderecoRx = - motor._EnderecoCAN_Rx; + item.EnderecoRx = motor._EnderecoCAN_Rx; } return item; } - /* - * Fallback final. - */ item = DadosLeitura.FirstOrDefault( x => @@ -1203,12 +1108,11 @@ namespace AgroBase.Services if (item == null) { - item = - new MKS057DModel - { - EnderecoTx = idTxMsg, - EnderecoRx = idRxMsg - }; + item = new MKS057DModel + { + EnderecoTx = idTxMsg, + EnderecoRx = idRxMsg + }; DadosLeitura.Add(item); } @@ -1228,228 +1132,148 @@ namespace AgroBase.Services return; } - MksFuncCode func = - (MksFuncCode)mensagem.DataRx[0]; + MksFuncCode func = (MksFuncCode)mensagem.DataRx[0]; + byte[] data = mensagem.DataRx; - byte[] data = - mensagem.DataRx; + var item = ObterOuCriar(mensagem); - var item = - ObterOuCriar(mensagem); - - ProcessarFuncao( - item, - func, - data - ); - - item.UltimoComandoRecebido = - DateTime.Now; - - item.Iniciado = - true; - - lock (_sync) + switch (func) { - _ultimaConsultaPendente.Remove(item.EnderecoTx); - _ultimaConsultaPendente.Remove(item.EnderecoRx); + case MksFuncCode.EncoderCarregado: + ProcessarEncoderCarregado(item, data); + break; + + case MksFuncCode.EncoderAcumulado: + ProcessarEncoderAcumulado(item, data); + break; + + case MksFuncCode.RPM: + ProcessarRpm(item, data); + break; + + case MksFuncCode.PulsosRecebidos: + ProcessarPulsosRecebidos(item, data); + break; + + case MksFuncCode.IO: + ProcessarIo(item, data); + break; + + case MksFuncCode.ErroAcumulado: + ProcessarErroAcumulado(item, data); + break; + + case MksFuncCode.Versao: + ProcessarVersao(item, data); + break; + + case MksFuncCode.ModoControleAnguloRelativo: + case MksFuncCode.ModoControleAnguloAbsoluto: + case MksFuncCode.ModoControlePulsos: + ProcessarStatusMovimento(item, data); + break; + + case MksFuncCode.SetZero: + case MksFuncCode.HabilitarMotor: + /* + * ACK simples, quando existir. + */ + break; } + + item.UltimoComandoRecebido = DateTime.Now; + item.Iniciado = true; } catch (Exception ex) { - Console.WriteLine( - "[MKS] Erro ao interpretar resposta CAN: " + - ex.Message - ); + Console.WriteLine("[MKS] Erro ao interpretar resposta CAN: " + ex.Message); } } - private void ProcessarFuncao( - MKS057DModel item, - MksFuncCode func, - byte[] data) - { - switch (func) - { - case MksFuncCode.EncoderCarregado: - ProcessarEncoderCarregado(item, data); - break; - - case MksFuncCode.EncoderAcumulado: - ProcessarEncoderAcumulado(item, data); - break; - - case MksFuncCode.RPM: - ProcessarRpm(item, data); - break; - - case MksFuncCode.PulsosRecebidos: - ProcessarPulsosRecebidos(item, data); - break; - - case MksFuncCode.IO: - ProcessarIo(item, data); - break; - - case MksFuncCode.ErroAcumulado: - ProcessarErroAcumulado(item, data); - break; - - case MksFuncCode.Versao: - ProcessarVersao(item, data); - break; - - case MksFuncCode.ModoControleAnguloRelativo: - case MksFuncCode.ModoControleAnguloAbsoluto: - case MksFuncCode.ModoControlePulsos: - ProcessarStatusMovimento(item, data); - break; - - case MksFuncCode.SetZero: - case MksFuncCode.HabilitarMotor: - /* - * Alguns drivers respondem ACK simples. Só atualizar - * UltimoComandoRecebido/Iniciado já é suficiente. - */ - break; - } - } - - private void ProcessarEncoderCarregado( - MKS057DModel item, - byte[] data) + private void ProcessarEncoderCarregado(MKS057DModel item, byte[] data) { if (data == null || data.Length < 7) return; - int carry = - DecodeInt32BE(data, 1); + int carry = DecodeInt32BE(data, 1); + ushort value = DecodeUInt16BE(data, 5); - ushort value = - DecodeUInt16BE(data, 5); + int pulsos = (int)(carry * 16384.0) + value; - int pulsos = - (int)(carry * 16384.0) + - value; - - item.NumeroPulsosRelativo = - pulsos; - - item.AnguloPulsosRelativo = - PulsosEncoderParaAngulo(pulsos); + item.NumeroPulsosRelativo = pulsos; + item.AnguloPulsosRelativo = PulsosEncoderParaAngulo(pulsos); } - private void ProcessarEncoderAcumulado( - MKS057DModel item, - byte[] data) + private void ProcessarEncoderAcumulado(MKS057DModel item, byte[] data) { if (data == null || data.Length < 7) return; - long pulsos = - DecodeInt48BE(data, 1); + long pulsos = DecodeInt48BE(data, 1); - item.NumeroPulsosAbsoluto = - (int)pulsos; - - item.AnguloPulsosAbsoluto = - PulsosEncoderParaAngulo((int)pulsos); + item.NumeroPulsosAbsoluto = (int)pulsos; + item.AnguloPulsosAbsoluto = PulsosEncoderParaAngulo((int)pulsos); } - private void ProcessarRpm( - MKS057DModel item, - byte[] data) + private void ProcessarRpm(MKS057DModel item, byte[] data) { if (data == null || data.Length < 3) return; - item.Velocidade = - DecodeUInt16BE(data, 1); + item.RPM.valor = DecodeUInt16BE(data, 1); } - private void ProcessarPulsosRecebidos( - MKS057DModel item, - byte[] data) + private void ProcessarPulsosRecebidos(MKS057DModel item, byte[] data) { if (data == null || data.Length < 5) return; - int pulsos = - DecodeInt32BE(data, 1); + int pulsos = DecodeInt32BE(data, 1); - item.NumeroPulsos.valor = - pulsos; - - item.AnguloPulsos = - PulsosRecebidosParaAngulo(pulsos); + item.NumeroPulsos.valor = pulsos; + item.AnguloPulsos = PulsosRecebidosParaAngulo(pulsos); } - private void ProcessarIo( - MKS057DModel item, - byte[] data) + private void ProcessarIo(MKS057DModel item, byte[] data) { if (data == null || data.Length < 3) return; - byte status = - data[1]; + byte status = data[1]; - item.Entradas = - new List() { false, false }; + item.Entradas = new List() { false, false }; + item.Entradas[0] = (status & 0b00000001) != 0; + item.Entradas[1] = (status & 0b00000010) != 0; - item.Entradas[0] = - (status & 0b00000001) != 0; + item.Saidas = new List() { false, false }; + item.Saidas[0] = (status & 0b00000100) != 0; + item.Saidas[1] = (status & 0b00001000) != 0; - item.Entradas[1] = - (status & 0b00000010) != 0; - - item.Saidas = - new List() { false, false }; - - item.Saidas[0] = - (status & 0b00000100) != 0; - - item.Saidas[1] = - (status & 0b00001000) != 0; - - item.EntradaIN1.valor = - item.Entradas[0]; + item.EntradaIN1.valor = item.Entradas[0]; } - private void ProcessarErroAcumulado( - MKS057DModel item, - byte[] data) + private void ProcessarErroAcumulado(MKS057DModel item, byte[] data) { if (data == null || data.Length < 3) return; - item.ErroAcumulado = - DecodeUInt16BE(data, 1); + item.ErroAcumulado = DecodeUInt16BE(data, 1); } - private void ProcessarVersao( - MKS057DModel item, - byte[] data) + private void ProcessarVersao(MKS057DModel item, byte[] data) { if (data == null || data.Length < 5) return; - item.FV = - $"{data[1]}.{data[2]}.{data[3]}.{data[4]}"; + item.FV = $"{data[1]}.{data[2]}.{data[3]}.{data[4]}"; } - private void ProcessarStatusMovimento( - MKS057DModel item, - byte[] data) + private void ProcessarStatusMovimento(MKS057DModel item, byte[] data) { if (data == null || data.Length < 2) return; - MksMovimentStatus statusMovimento = - (MksMovimentStatus)data[1]; - - item.StatusMovimento = - statusMovimento; + item.StatusMovimento = (MksMovimentStatus)data[1]; } } } diff --git a/AgroBase/AgroBase/Services/OIDCanService.cs b/AgroBase/AgroBase/Services/OIDCanService.cs index 1968782b5..a2838b55c 100644 --- a/AgroBase/AgroBase/Services/OIDCanService.cs +++ b/AgroBase/AgroBase/Services/OIDCanService.cs @@ -112,7 +112,7 @@ namespace AgroBase.Services Parametro = OidParametros.VelocidadeAtual, RedisKey = "rpm", FreqBaseHz = 1.0, - FreqMinHz = 0.45, + FreqMinHz = 0.35, EmUsoHealth = true } }, @@ -123,7 +123,7 @@ namespace AgroBase.Services Parametro = OidParametros.CorrenteMotor, RedisKey = "corrente_motor", FreqBaseHz = 1.0, - FreqMinHz = 0.45, + FreqMinHz = 0.35, EmUsoHealth = true } }, @@ -134,7 +134,7 @@ namespace AgroBase.Services Parametro = OidParametros.CorrenteBarramento, RedisKey = "corrente_barramento", FreqBaseHz = 1.0, - FreqMinHz = 0.45, + FreqMinHz = 0.35, EmUsoHealth = true } }, diff --git a/AgroBase/AgroBase/Services/Operadores/HealthWorkerService.cs b/AgroBase/AgroBase/Services/Operadores/HealthWorkerService.cs index 89a860f78..4c3cb99a9 100644 --- a/AgroBase/AgroBase/Services/Operadores/HealthWorkerService.cs +++ b/AgroBase/AgroBase/Services/Operadores/HealthWorkerService.cs @@ -1,6 +1,7 @@ using AgroBase.Models; using AgroBase.Models.Operadores; using Newtonsoft.Json; +using OpenHardwareMonitor.Hardware; using System; using System.Collections.Generic; using System.Linq; @@ -417,6 +418,7 @@ namespace AgroBase.Services.Operadores if (Variaveis.OperacaoEmAndamento.DispSen == null) return; var _Sensoriamento = Variaveis.OperacaoEmAndamento.Sensoriamento; + var agora_dt = DateTime.Now; var agora = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); //Console.WriteLine(RedisService.Get(RedisService.ModKey(Enums.T_Code.Gps))); @@ -431,27 +433,90 @@ namespace AgroBase.Services.Operadores var dirAtivos = new List(); var movAtivos = new List(); + foreach (var mod in Variaveis.OperacaoEmAndamento.DispMvd.Dados.Modulos) { + double erroAnguloDir = Math.Abs(mod.DirMotor.AnguloLeitura - mod.DirMotor.Angulo_SP); + + double idadeUltimoComandoMs = + mod.DirMotor.UltimoComandoEnviado == DateTime.MinValue + ? 999999 + : (agora_dt - mod.DirMotor.UltimoComandoEnviado).TotalMilliseconds; + + double idadeUltimaRespostaMs = + mod.DirMotor.Leitura?.UltimoComandoRecebido == DateTime.MinValue + ? 999999 + : (agora_dt - (mod.DirMotor.Leitura?.UltimoComandoRecebido ?? DateTime.MinValue)).TotalMilliseconds; + int endereco_dir = mod.DirMotor._EnderecoCAN_Tx; - RedisService.AtualizarCampos( - RedisService.ModKey(Enums.T_Code.Dir), + var camposDir = new List<(string campo, object valor)> + { ($"{endereco_dir}.timestamp", agora), ($"{endereco_dir}.conectado", mod.DirMotor.Inicializado), + + // legado ($"{endereco_dir}.freq_base", Variaveis.OperacaoEmAndamento.DispMvd.Dados.tmrRegistradorDIR.IntervalHz), + ($"{endereco_dir}.endereco", mod.DirMotor._EnderecoCAN_Tx), ($"{endereco_dir}.mod_id", mod.Modulo_ID), + ($"{endereco_dir}.angulo", mod.DirMotor.AnguloLeitura), ($"{endereco_dir}.angulo_sp", mod.DirMotor.Angulo_SP), ($"{endereco_dir}.comandar", mod.DirMotor.Comandar), ($"{endereco_dir}.referenciado", mod.DirMotor.Referenciado), + ($"{endereco_dir}.in1_atuado", mod.DirMotor.IN1_Atuado), ($"{endereco_dir}.sentido", mod.DirMotor.Sentido), ($"{endereco_dir}.sentido_sp", mod.DirMotor.Sentido_SP), ($"{endereco_dir}.sentido_real", mod.DirMotor.SentidoReal), - ($"{endereco_dir}.freq.pulsos", mod.DirMotor.Leitura?.NumeroPulsos?.frequencia ?? 0), - ($"{endereco_dir}.freq.io", mod.DirMotor.Leitura?.EntradaIN1?.frequencia ?? 0) - ); + + ($"{endereco_dir}.erro_angulo", erroAnguloDir), + ($"{endereco_dir}.atingiu_angulo_sp", mod.DirMotor.AtingiuAnguloSP), + ($"{endereco_dir}.ultimo_comando_ms", idadeUltimoComandoMs), + ($"{endereco_dir}.ultima_resposta_ms", idadeUltimaRespostaMs), + ($"{endereco_dir}.ultima_direcao", mod.DirMotor.UltimaDirecao), + ($"{endereco_dir}.status_movimento", mod.DirMotor.Leitura?.StatusMovimento), + ($"{endereco_dir}.rpm", mod.DirMotor.RPM), + ($"{endereco_dir}.em_movimento", mod.DirMotor.EmMovimento), + ($"{endereco_dir}.retornando_a_zero", mod.DirMotor.RetornandoAzero), + + // frequências reais medidas + ($"{endereco_dir}.freq.pulsosrecebidos", mod.DirMotor.Leitura?.NumeroPulsos?.frequencia ?? 0), + ($"{endereco_dir}.freq.io", mod.DirMotor.Leitura?.EntradaIN1?.frequencia ?? 0), + ($"{endereco_dir}.freq.rpm", mod.DirMotor.Leitura?.RPM?.frequencia ?? 0), + + // mantém disponíveis para diagnóstico futuro, mesmo que não estejam no polling normal + ($"{endereco_dir}.freq.erro", 0), + ($"{endereco_dir}.freq.encoder_carregado", 0), + ($"{endereco_dir}.freq.encoder_acumulado", 0), + ($"{endereco_dir}.freq.versao", 0), + }; + + camposDir.AddRange(MKS057DCanService.GerarCamposRedisHealthBase(endereco_dir)); + RedisService.AtualizarCampos(RedisService.ModKey(Enums.T_Code.Dir), camposDir.ToArray()); + + + double erroRpmMov = Math.Abs(mod.MovMotor.RPM_Roda - mod.MovMotor.RPM_SP_Leitura); + + double erroRpmPctMov = + Math.Abs(mod.MovMotor.RPM_SP_Leitura) > 1 + ? erroRpmMov / Math.Abs(mod.MovMotor.RPM_SP_Leitura) + : 0; + + bool atingiuRpmSp = + Math.Abs(mod.MovMotor.RPM_SP_Leitura) < 25 + ? Math.Abs(mod.MovMotor.RPM_Roda) < 35 + : erroRpmPctMov <= 0.45 || erroRpmMov <= 45; + + double idadeUltimoComandoMovMs = + mod.MovMotor.UltimoComandoEnviado == DateTime.MinValue + ? 999999 + : (agora_dt - mod.MovMotor.UltimoComandoEnviado).TotalMilliseconds; + + double idadeUltimaRespostaMovMs = + mod.MovMotor.Leitura?.UltimoComandoRecebido == DateTime.MinValue + ? 999999 + : (agora_dt - (mod.MovMotor.Leitura?.UltimoComandoRecebido ?? DateTime.MinValue)).TotalMilliseconds; int endereco_mov = mod.MovMotor._EnderecoCAN_Tx; var camposMov = new List<(string campo, object valor)> @@ -460,10 +525,7 @@ namespace AgroBase.Services.Operadores ($"{endereco_mov}.conectado", mod.MovMotor.Inicializado), // legado - ($"{endereco_mov}.freq_base", - OIDCanService.ObterFreqBaseHz( - OIDCanService.OidHandler.OidParametros.VelocidadeAtual - )), + ($"{endereco_mov}.freq_base", OIDCanService.ObterFreqBaseHz(OIDCanService.OidHandler.OidParametros.VelocidadeAtual)), ($"{endereco_mov}.endereco", mod.MovMotor._EnderecoCAN_Tx), ($"{endereco_mov}.mod_id", mod.Modulo_ID), @@ -487,26 +549,60 @@ namespace AgroBase.Services.Operadores ($"{endereco_mov}.freq.ciclo_trabalho", mod.MovMotor.Leitura?.CicloTrabalho?.frequencia ?? 0), ($"{endereco_mov}.freq.potencia", mod.MovMotor.Leitura?.Potencia?.frequencia ?? 0), + + + ($"{endereco_mov}.comandar", mod.MovMotor.Comandar), + ($"{endereco_mov}.comando_ligado", mod.MovMotor.ComandoLigado), + ($"{endereco_mov}.leitura_ligado", mod.MovMotor.LeituraLigado), + ($"{endereco_mov}.alarme_motor", mod.MovMotor.AlarmeMotor), + ($"{endereco_mov}.alarme_comando", mod.MovMotor.AlarmeComando), + ($"{endereco_mov}.alarme_sensores", mod.MovMotor.AlarmeSensores), + + ($"{endereco_mov}.sentido", mod.MovMotor.Sentido), + ($"{endereco_mov}.sentido_sp", mod.MovMotor.Sentido_SP), + + ($"{endereco_mov}.erro_rpm", erroRpmMov), + ($"{endereco_mov}.erro_rpm_pct", erroRpmPctMov), + ($"{endereco_mov}.atingiu_rpm_sp", atingiuRpmSp), + + ($"{endereco_mov}.ultimo_comando_ms", idadeUltimoComandoMovMs), + ($"{endereco_mov}.ultima_resposta_ms", idadeUltimaRespostaMovMs), + + ($"{endereco_mov}.freio_sp", mod.MovMotor.UltimoFreioSP), + ($"{endereco_mov}.modo_controle", mod.MovMotor.Leitura?.ModoControle), + ($"{endereco_mov}.ultimo_modo_controle", mod.MovMotor.UltimoModoControleEnviado), + ($"{endereco_mov}.ultimo_setpoint", mod.MovMotor.UltimoSetPointEnviado), }; + camposMov.AddRange(OIDCanService.GerarCamposRedisHealthBase(endereco_mov)); - RedisService.AtualizarCampos( - RedisService.ModKey(Enums.T_Code.Mov), - camposMov.ToArray() - ); + RedisService.AtualizarCampos(RedisService.ModKey(Enums.T_Code.Mov), camposMov.ToArray()); dirAtivos.Add(endereco_dir); movAtivos.Add(endereco_mov); } - RedisService.AtualizarCampos(RedisService.ModKey(Enums.T_Code.Dir), ($"ativos", dirAtivos.ToList())); - RedisService.AtualizarCampos(RedisService.ModKey(Enums.T_Code.Mov), ($"ativos", movAtivos.ToList())); + + RedisService.AtualizarCampos(RedisService.ModKey(Enums.T_Code.Dir), ("ativos", dirAtivos.ToList())); + RedisService.AtualizarCampos(RedisService.ModKey(Enums.T_Code.Mov), ("ativos", movAtivos.ToList())); + + var DadosSen = Variaveis.OperacaoEmAndamento.DispSen.Dados; - //Console.WriteLine("[HEALTH SEN] " + DadosSen.Conectado); + DateTime ultimaRespostaSen = + DadosSen.DadosLeitura?.UltimoComandoRespondido ?? DateTime.MinValue; + + double idadeUltimaRespostaSenMs = + ultimaRespostaSen == DateTime.MinValue + ? 999999 + : (agora_dt - ultimaRespostaSen).TotalMilliseconds; RedisService.AtualizarCampos(RedisService.ModKey(Enums.T_Code.Sen), ("timestamp", agora), ("conectado", DadosSen.Conectado), ("freq_base", DadosSen.FrequenciaBase), - ("bms_ligado", _Sensoriamento.Bateria?.BmsLigado ?? false) + ("bms_ligado", _Sensoriamento.Bateria?.BmsLigado ?? false), + + ("ultima_resposta_ms", idadeUltimaRespostaSenMs), + ("latencia_loop_ms", DadosSen.DadosLeitura?.LatenciaLoop ?? 0), + ("percentual_bateria", _Sensoriamento.Bateria?.PercentualBateria ?? 0) ); var senSensoresAtualizados = new List<(string caminho, object valor)>(); var senSensoresAtivos = new List(); @@ -527,10 +623,16 @@ namespace AgroBase.Services.Operadores maximo = y.maximo, minimo = y.minimo, atual = y.atual.valor, - freq = y.atual.frequencia + freq = y.atual.frequencia, + unidade = y.unidade_medida, }) .ToList(); + double idadeUltimaRespostaSensorMs = + sensor.UltimaLeitura == DateTime.MinValue + ? 999999 + : (agora_dt - sensor.UltimaLeitura).TotalMilliseconds; + var sensorObj = new { conectado = sensor.Inicializado, @@ -541,7 +643,9 @@ namespace AgroBase.Services.Operadores mandatorio = sensor.Mandatorio, dados, timestamp = agora, - saude = saude ?? new { } + saude = saude ?? new { }, + + ultima_resposta_ms = idadeUltimaRespostaSensorMs, }; senSensoresAtivos.Add($"{tipo}.{id}"); @@ -570,6 +674,16 @@ namespace AgroBase.Services.Operadores }) .ToList(); + double idadeUltimoComandoServoMs = + servo.UltimoComandoEnviado == DateTime.MinValue + ? 999999 + : (agora_dt - servo.UltimoComandoEnviado).TotalMilliseconds; + + double idadeUltimaRespostaServoMs = + servo.UltimaLeitura == DateTime.MinValue + ? 999999 + : (agora_dt - servo.UltimaLeitura).TotalMilliseconds; + var servoObj = new { conectado = servo.Inicializado, @@ -581,7 +695,12 @@ namespace AgroBase.Services.Operadores angulo_sp = servo._AnguloControle, dados, timestamp = agora, - saude = saude ?? new { } + saude = saude ?? new { }, + + angulo_desejado = servo.UltimoAnguloDesejado, + angulo_leitura = servo._AnguloLeiutra, + ultimo_comando_ms = idadeUltimoComandoServoMs, + ultima_resposta_ms = idadeUltimaRespostaServoMs, }; senServosAtivos.Add($"{tipo}.{id}"); @@ -610,6 +729,16 @@ namespace AgroBase.Services.Operadores }) .ToList(); + double idadeUltimoComandoReleMs = + rele.UltimoComandoEnviado == DateTime.MinValue + ? 999999 + : (agora_dt - rele.UltimoComandoEnviado).TotalMilliseconds; + + double idadeUltimaRespostaReleMs = + rele.UltimaLeitura == DateTime.MinValue + ? 999999 + : (agora_dt - rele.UltimaLeitura).TotalMilliseconds; + var releObj = new { conectado = rele.Inicializado, @@ -620,7 +749,12 @@ namespace AgroBase.Services.Operadores mandatorio = rele.Mandatorio, dados, timestamp = agora, - saude = saude ?? new { } + saude = saude ?? new { }, + + estado_desejado = rele.UltimoEstadoDesejado, + estado_leitura = rele._EstadoLeitura, + ultimo_comando_ms = idadeUltimoComandoReleMs, + ultima_resposta_ms = idadeUltimaRespostaReleMs, }; senRelesAtivos.Add($"{tipo}.{id}"); @@ -649,6 +783,16 @@ namespace AgroBase.Services.Operadores }) .ToList(); + double idadeUltimoComandoLedMs = + led.UltimoComandoEnviado == DateTime.MinValue + ? 999999 + : (agora_dt - led.UltimoComandoEnviado).TotalMilliseconds; + + double idadeUltimaRespostaLedMs = + led.UltimaLeitura == DateTime.MinValue + ? 999999 + : (agora_dt - led.UltimaLeitura).TotalMilliseconds; + var ledObj = new { conectado = led.Inicializado, @@ -659,7 +803,14 @@ namespace AgroBase.Services.Operadores mandatorio = led.Mandatorio, dados, timestamp = agora, - saude = saude ?? new { } + saude = saude ?? new { }, + + status_led_id_sp = led.UltimoComportamentoDesejado?.id ?? -1, + status_led_sp = led.UltimoComportamentoDesejado?.statusLED, + status_led_id = led._StatusLedId, + status_led_leitura = led._StatusLed, + ultimo_comando_ms = idadeUltimoComandoLedMs, + ultima_resposta_ms = idadeUltimaRespostaLedMs, }; senLedsAtivos.Add($"{tipo}.{id}"); @@ -669,11 +820,55 @@ namespace AgroBase.Services.Operadores RedisService.AtualizarCampos(RedisService.ModKey(Enums.T_Code.Sen), senLedsAtualizados.ToArray()); + var DadosAtu = Variaveis.OperacaoEmAndamento.DispAtu.Dados; - RedisService.AtualizarCampos(RedisService.ModKey(Enums.T_Code.Atu), + DateTime ultimaRespostaAtu = + DadosAtu.DadosLeitura?.UltimoComandoRespondido ?? DateTime.MinValue; + + double idadeUltimaRespostaAtuMs = + ultimaRespostaAtu == DateTime.MinValue + ? 999999 + : (agora_dt - ultimaRespostaAtu).TotalMilliseconds; + + bool pulverizadorAutomatico = + Variaveis.OperacaoEmAndamento.Parametros?.Controle?.PulverizadorAutomatico ?? false; + + double pressaoAlvo = + Variaveis.OperacaoEmAndamento.Parametros?.Controle?.AtuPressaoLinha ?? 0; + + double pressaoLinha = + DadosAtu.PressaoLinha; + + bool pressaoLinhaRegular = + pressaoAlvo <= 0 || pressaoLinha >= pressaoAlvo - 20; + RedisService.AtualizarCampos( + RedisService.ModKey(Enums.T_Code.Atu), ("timestamp", agora), ("conectado", DadosAtu.Conectado), - ("freq_base", DadosAtu.FrequenciaBase) + ("freq_base", DadosAtu.FrequenciaBase), + + ("ultima_resposta_ms", idadeUltimaRespostaAtuMs), + ("latencia_loop_ms", DadosAtu.DadosLeitura?.LatenciaLoop ?? 0), + + ("pulverizador_automatico", pulverizadorAutomatico), + ("pulverizador_liberado", DadosAtu.ContadorCarga.PulverizadorLiberado), + + ("operacao_iniciada", Variaveis.OperacaoEmAndamento.Iniciado), + ("calibrando", Variaveis.OperacaoEmAndamento.Calibrando), + ("pausa", Variaveis.OperacaoEmAndamento.Pausa), + ("emergencia", Variaveis.OperacaoEmAndamento.Emergencia), + ("finalizando", Variaveis.OperacaoEmAndamento.Finalizando), + + ("percentual_reservatorio", DadosAtu.ContadorCarga.PercentualAtual), + ("pressao_linha", pressaoLinha), + ("pressao_linha_regular", pressaoLinhaRegular), + ("vazao_linha_mls", DadosAtu.VazaoFluxoMLpSReferencia), + + ("tempo_atuado", DadosAtu.TempoAtuado), + ("volume_vazado_ml", DadosAtu.VolumeVazadoML), + ("vazao_media_mls", DadosAtu.VazaoMediaMLs), + + ("bomba_cavitada", _Sensoriamento.Atuador?.BombaCavitada ?? false) ); var atuSensoresAtualizados = new List<(string caminho, object valor)>(); var atuSensoresAtivos = new List(); @@ -698,6 +893,11 @@ namespace AgroBase.Services.Operadores }) .ToList(); + double idadeUltimaRespostaSensorMs = + sensor.UltimaLeitura == DateTime.MinValue + ? 999999 + : (agora_dt - sensor.UltimaLeitura).TotalMilliseconds; + var sensorObj = new { conectado = sensor.Inicializado, @@ -708,7 +908,8 @@ namespace AgroBase.Services.Operadores mandatorio = sensor.Mandatorio, dados, timestamp = agora, - saude = saude ?? new { } + saude = saude ?? new { }, + ultima_resposta_ms = idadeUltimaRespostaSensorMs, }; atuSensoresAtivos.Add($"{tipo}.{id}"); @@ -740,6 +941,16 @@ namespace AgroBase.Services.Operadores }) .ToList(); + double idadeUltimoComandoBicoMs = + bico.UltimoComandoEnviado == DateTime.MinValue + ? 999999 + : (agora_dt - bico.UltimoComandoEnviado).TotalMilliseconds; + + double idadeUltimaRespostaBicoMs = + bico.UltimaLeitura == DateTime.MinValue + ? 999999 + : (agora_dt - bico.UltimaLeitura).TotalMilliseconds; + var bicoObj = new { conectado = bico.Inicializado, @@ -750,7 +961,29 @@ namespace AgroBase.Services.Operadores mandatorio = bico.Mandatorio, dados, timestamp = agora, - saude = saude ?? new { } + saude = saude ?? new { }, + + comando_atuar = bico.ComandoAtuar, + estado_desejado = bico.UltimoStatusDesejado, + angulo_desejado = bico.UltimoAnguloDesejado, + + estado_leitura = bico._EstadoLeitura, + angulo_leitura = bico._AnguloBicoLeitura, + + angulo_neutro = bico.AnguloNeutro, + angulo_controle = bico.AnguloControle, + angulo_abertura = bico.AnguloAbertura, + + ultimo_comando_ms = idadeUltimoComandoBicoMs, + ultima_resposta_ms = idadeUltimaRespostaBicoMs, + + atuacoes = bico.Atuacoes, + tempo_atuado = bico.TempoAtuado, + tempo_atuado_em_movimento = bico.TempoAtuadoEmMovimento, + tempo_com_fluxo = bico.TempoComFluxo, + vazao_instantanea_mls = bico.VazaoInstantaneaMLs, + vazao_media_mls = bico.VazaoMediaMLs, + volume_vazado_ml = bico.VolumeVazadoML, }; atuBicosAtualizados.Add(($"{basePath}", bicoObj)); @@ -782,6 +1015,16 @@ namespace AgroBase.Services.Operadores }) .ToList(); + double idadeUltimoComandoBombaMs = + bomba.UltimoComandoEnviado == DateTime.MinValue + ? 999999 + : (agora_dt - bomba.UltimoComandoEnviado).TotalMilliseconds; + + double idadeUltimaRespostaBombaMs = + bomba.UltimaLeitura == DateTime.MinValue + ? 999999 + : (agora_dt - bomba.UltimaLeitura).TotalMilliseconds; + var sensorObj = new { conectado = bomba.Inicializado, @@ -792,7 +1035,34 @@ namespace AgroBase.Services.Operadores mandatorio = bomba.Mandatorio, dados, timestamp = agora, - saude = saude ?? new { } + saude = saude ?? new { }, + + comando_atuar = bomba.ComandoAtuar, + estado_desejado = bomba.UltimoStatusDesejado, + potencia_desejada = bomba.UltimaPotenciaDesejada, + pressao_desejada = bomba.UltimaPressaoDesejada, + + estado_leitura = bomba._EstadoLeitura, + bomba_ligada = bomba._BombaLigada, + + potencia_sp_model = bomba.PotenciaSP, + pressao_sp_model = bomba.PressaoSP, + + potencia_sp = bomba._PotenciaSP, + potencia = bomba._Potencia, + pressao_sp = bomba._PressaoSP, + pressao = bomba._PressaoAtual, + + modo_controle = bomba.Controle?.Modo, + sensor_id_num = bomba.Sensor_ID_Num, + + ultimo_comando_ms = idadeUltimoComandoBombaMs, + ultima_resposta_ms = idadeUltimaRespostaBombaMs, + + atuacoes = bomba.Atuacoes, + tempo_atuado = bomba.TempoAtuado, + tempo_com_potencia = bomba.TempoComPotencia, + potencia_media_ativa = bomba.PotenciaMediaAtiva, }; atuBombasAtivos.Add($"{tipo}.{id}"); @@ -801,6 +1071,8 @@ namespace AgroBase.Services.Operadores atuBombasAtualizados.Add(("bombas.ativos", atuBombasAtivos)); RedisService.AtualizarCampos(RedisService.ModKey(Enums.T_Code.Atu), atuBombasAtualizados.ToArray()); + + var DadosLra = Variaveis.LoraService; RedisService.AtualizarCampos(RedisService.ModKey(Enums.T_Code.Lra), ("timestamp", agora), diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/atuador.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/atuador.py index 86c6a3eb1..a89ac7b6c 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/atuador.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/atuador.py @@ -5,318 +5,1411 @@ from health_worker.modulos.base import ModuloDiagnosticoBase from health_worker.aprendizado.padrao_dinamico import PadraoDinamico from shared.contexto_global_redis import ContextoGlobalRedis + class ModuloAtuador(ModuloDiagnosticoBase): + """ + Health inteligente do módulo atuador/ATU. + + Filosofia: + - Frequência baixa é diagnóstico, não sentença. + - O que manda é coerência de atuação: + comando desejado -> estado lido -> pressão/fluxo/reservatório coerentes. + - Condição operacional com severidade > 90 só aparece para risco real, + como atuação indevida, bomba cavitada, sem resposta do ATU ou falta de produto. + """ + + SAUDE_MIN_ALERTA = 80 + + RESPOSTA_RECENTE_MS = 8000.0 + RESPOSTA_LENTA_MS = 4500.0 + + COMANDO_GRACE_MS = 900.0 + COMANDO_ALERTA_MS = 2500.0 + COMANDO_FALHA_MS = 6000.0 + + FREQ_MIN_DEFAULT_FACTOR = 0.35 + PENALIDADE_FREQ_MAX_PADRAO = 4 + PENALIDADE_FREQ_MAX_CRITICA = 8 + + ANGULO_TOL_BICO_GRAUS = 4.0 + PRESSAO_TOL_PSI = 20.0 + FLUXO_MIN_COM_BICOS_MLS = 1.0 + POTENCIA_MIN_BOMBA_LIGADA = 3.0 + def __init__(self): - super().__init__() # ✅ garante que a classe base é corretamente iniciada + super().__init__() self.t_code = T_Code.Atu self.nome = "Atuador" + self.timeout = 3 + self.padroes_sensores = {} self.padroes_bicos = {} self.padroes_bombas = {} - self.timeout = 3 # pode ser usado para validar timestamp - + def atualizar_saude(self): try: - agora = time.time() - motivos_gerais = [] - saude_mandatorios = 0 - total_mandatorios = 0 - saude_total = 0 - total_ativos = 0 + dados_atu = ContextoGlobalRedis.get_modulo(self.t_code) or {} + contexto = ContextoGlobalRedis.get_contexto() or {} + operacao = ContextoGlobalRedis.get_operacao() or {} + equipamento = ContextoGlobalRedis.get_equipamento() or {} + + mod_conectado = self._bool(dados_atu.get("conectado", False)) + root_score, root_motivos, root_cond = self._avaliar_raiz(dados_atu) + + sensores = self._coletar_componentes(dados_atu, "sensores") + bicos = self._coletar_componentes(dados_atu, "bicos") + bombas = self._coletar_componentes(dados_atu, "bombas") + + contexto_operacional = self._montar_contexto_operacional( + dados_atu=dados_atu, + sensores=sensores, + bicos=bicos, + bombas=bombas, + contexto=contexto, + operacao=operacao, + equipamento=equipamento, + ) + saude_individual = [] + updates_componentes = {} - dados_atu = ContextoGlobalRedis.get_modulo(self.t_code) - - mod_conectado = dados_atu.get("conectado", False) - FREQ_BASE = dados_atu.get("freq_base", 1) - FREQ_MIN = FREQ_BASE * 0.5 - SAUDE_MIN_ALERTA = 80 - - mods_mandatorios = ContextoGlobalRedis.get_operacao().get("modulos_mandatorios", []) - atu_mandatorio = self.t_code.value in mods_mandatorios - - sensor_massa_em_uso = False - sensor_massa_conectado = False - sensor_pressao_em_uso = False - sensor_pressao_conectado = False - - sensores_ativos = dados_atu.get("sensores", {}).get("ativos", []) - for key in sensores_ativos: - tipo, sensor_id = key.split(".") - sensores_tipo = dados_atu.get("sensores", {}).get(tipo, {}) - dados_sensor = sensores_tipo.get(sensor_id) - if not dados_sensor: - continue - saude = 100 - motivos = [] - condicoes_operacionais = [] - aferir = dados_sensor.get("aferir", False) - padroes = self.padroes_sensores.get(tipo, {}).get(sensor_id, {}) - conectado = dados_sensor.get("conectado", False) - mandatorio = dados_sensor.get("mandatorio", False) if atu_mandatorio else False - if dados_sensor.get("label") == "MASRS": - sensor_massa_em_uso = mandatorio and aferir - sensor_massa_conectado = conectado - elif dados_sensor.get("label") == "PRSLN": - sensor_pressao_em_uso = mandatorio and aferir - sensor_pressao_conectado = conectado - if not conectado: - saude = 0 - motivos.append(f"desconectado") - else: - for campo in dados_sensor.get("dados", []): - label = campo.get("label") - if label is None: - continue - freq = campo.get("freq", 0.0) - if freq == 0: - saude = 0 - motivos.append(f"{label} sem resposta (freq = 0 Hz)") - else: - penalidade = penalizar_frequencia(freq, FREQ_BASE, FREQ_MIN, (100 - SAUDE_MIN_ALERTA)) - saude -= penalidade - if (freq <= FREQ_MIN): - motivos.append(f"{label} frequência baixa ({freq:.2f}/{FREQ_BASE} Hz, penalidade {penalidade}%)") - atual = campo.get("atual") - padrao = padroes.get(label) if atual else None - if padrao is not None: - # Avaliação de condição operacional (sem penalizar saúde) - if not padrao.esta_dentro_do_padrao(atual): - grau = padrao.calcular_quao_anormal(atual) - condicoes_operacionais.append({ - "valor": atual, - "severidade": grau, - "descricao": f"{label} fora do padrão" - }) - saude = max(0, saude) - chave = f"{S_Code(int(tipo)).name}_{sensor_id}" - saude_mod = { - "id": sensor_id, - "label": dados_sensor.get("label"), - "status": (StatusModulo.DESCONECTADO if not conectado else StatusModulo.OPERANTE if saude >= SAUDE_MIN_ALERTA else StatusModulo.ALERTA if saude > 0 else StatusModulo.FALHA).value, - "saude": saude, - "motivos": motivos, - "condicoes_operacionais": condicoes_operacionais, - "em_uso": aferir - } + for item in sensores: + saude_mod = self._avaliar_sensor(item, contexto_operacional) saude_individual.append(saude_mod) - if aferir: - total_ativos += 1 - saude_total += saude - if mandatorio: - total_mandatorios += 1 - saude_mandatorios += saude - motivos_gerais.extend([f"{chave}: {m}" for m in motivos]) - dados_sensor["saude"] = saude_mod - - bicos_ativos = dados_atu.get("bicos", {}).get("ativos", []) - for key in bicos_ativos: - tipo, bico_id = key.split(".") - bicos_tipo = dados_atu.get("bicos", {}).get(tipo, {}) - dados_bico = bicos_tipo.get(bico_id) - if not dados_bico: - continue - saude = 100 - motivos = [] - condicoes_operacionais = [] - comandar = dados_bico.get("comandar", False) - padroes = self.padroes_bicos.get(tipo, {}).get(bico_id, {}) - conectado = dados_bico.get("conectado", False) - mandatorio = dados_bico.get("mandatorio", False) if atu_mandatorio else False - if not conectado: - saude = 0 - motivos.append(f"desconectado") - else: - for campo in dados_bico.get("dados", []): - label = campo.get("label") - if label is None: - continue - freq = campo.get("freq", 0.0) - if freq == 0: - saude = 0 - motivos.append(f"{label} sem resposta (freq = 0 Hz)") - else: - penalidade = penalizar_frequencia(freq, FREQ_BASE, FREQ_MIN, (100 - SAUDE_MIN_ALERTA)) - saude -= penalidade - if (freq <= FREQ_MIN): - motivos.append(f"{label} frequência baixa ({freq:.2f}/{FREQ_BASE} Hz, penalidade {penalidade}%)") - atual = campo.get("atual") - padrao = padroes.get(label) if atual else None - if padrao is not None: - # Avaliação de condição operacional (sem penalizar saúde) - if not padrao.esta_dentro_do_padrao(atual): - grau = padrao.calcular_quao_anormal(atual) - condicoes_operacionais.append({ - "valor": atual, - "severidade": grau, - "descricao": f"{label} fora do padrão" - }) - saude = max(0, saude) - chave = f"{S_Code(int(tipo)).name}_{bico_id}" - saude_mod = { - "id": bico_id, - "label": dados_bico.get("label"), - "status": (StatusModulo.DESCONECTADO if not conectado else StatusModulo.OPERANTE if saude >= SAUDE_MIN_ALERTA else StatusModulo.ALERTA if saude > 0 else StatusModulo.FALHA).value, - "saude": saude, - "motivos": motivos, - "condicoes_operacionais": condicoes_operacionais, - "em_uso": comandar - } + updates_componentes[f"sensores.{item['key']}.saude"] = saude_mod + + for item in bicos: + saude_mod = self._avaliar_bico(item, contexto_operacional) saude_individual.append(saude_mod) - if comandar: - total_ativos += 1 - saude_total += saude - if mandatorio: - total_mandatorios += 1 - saude_mandatorios += saude - motivos_gerais.extend([f"{chave}: {m}" for m in motivos]) - dados_bico["saude"] = saude_mod - - bombas_ativos = dados_atu.get("bombas", {}).get("ativos", []) - for key in bombas_ativos: - tipo, bomba_id = key.split(".") - bombas_tipo = dados_atu.get("bombas", {}).get(tipo, {}) - dados_bomba = bombas_tipo.get(bomba_id) - if not dados_bomba: - continue - saude = 100 - motivos = [] - condicoes_operacionais = [] - comandar = dados_bomba.get("comandar", False) - padroes = self.padroes_bombas.get(tipo, {}).get(bomba_id, {}) - conectado = dados_bomba.get("conectado", False) - mandatorio = dados_bomba.get("mandatorio", False) if atu_mandatorio else False - if not conectado: - saude = 0 - motivos.append(f"desconectado") - else: - for campo in dados_bomba.get("dados", []): - label = campo.get("label") - if label is None: - continue - freq = campo.get("freq", 0.0) - if freq == 0: - saude = 0 - motivos.append(f"{label} sem resposta (freq = 0 Hz)") - else: - penalidade = penalizar_frequencia(freq, FREQ_BASE, FREQ_MIN, (100 - SAUDE_MIN_ALERTA)) - saude -= penalidade - if (freq <= FREQ_MIN): - motivos.append(f"{label} frequência baixa ({freq:.2f}/{FREQ_BASE} Hz, penalidade {penalidade}%)") - atual = campo.get("atual") - padrao = padroes.get(label) if atual else None - if padrao is not None: - # Avaliação de condição operacional (sem penalizar saúde) - if not padrao.esta_dentro_do_padrao(atual): - grau = padrao.calcular_quao_anormal(atual) - condicoes_operacionais.append({ - "valor": atual, - "severidade": grau, - "descricao": f"{label} fora do padrão" - }) - saude = max(0, saude) - chave = f"{S_Code(int(tipo)).name}_{bomba_id}" - saude_mod = { - "id": bomba_id, - "label": dados_bomba.get("label"), - "status": (StatusModulo.DESCONECTADO if not conectado else StatusModulo.OPERANTE if saude >= SAUDE_MIN_ALERTA else StatusModulo.ALERTA if saude > 0 else StatusModulo.FALHA).value, - "saude": saude, - "motivos": motivos, - "condicoes_operacionais": condicoes_operacionais, - "em_uso": comandar - } + updates_componentes[f"bicos.{item['key']}.saude"] = saude_mod + + for item in bombas: + saude_mod = self._avaliar_bomba(item, contexto_operacional) saude_individual.append(saude_mod) - if comandar: - total_ativos += 1 - saude_total += saude - if mandatorio: - total_mandatorios += 1 - saude_mandatorios += saude - motivos_gerais.extend([f"{chave}: {m}" for m in motivos]) - dados_bomba["saude"] = saude_mod - + updates_componentes[f"bombas.{item['key']}.saude"] = saude_mod - cond_op_mod = [] - penalidade_percentual_herbicida = 0 - if sensor_massa_em_uso: - if sensor_massa_conectado: - _contexto = ContextoGlobalRedis.get_contexto() - herbicida_suficiente_corredor = _contexto.get("Trajetoria", {}).get("CorredorAtual", {}).get("herbicida_ok", True) - percent_atual = round(_contexto.get("Gerais", {}).get("percentual_reservatorio", 0.0), 2) - percent_min = ContextoGlobalRedis.get_equipamento().get("percentual_reservatorio_min", 5.0) - if percent_atual < percent_min: - penalidade_percentual_herbicida = 50 - cond_op_mod.append({ - "valor": percent_atual, - "severidade": 100, - "descricao": f"Nível do reservatório de herbicida abaixo do limite mínimo!" - }) - elif percent_atual < 20.0: - penalidade_percentual_herbicida = 21 - cond_op_mod.append({ - "valor": percent_atual, - "severidade": 30, - "descricao": f"Nível do reservatório de herbicida baixo!" - }) - if not herbicida_suficiente_corredor: - _motivo_herb = _contexto.get("Trajetoria", {}).get("CorredorAtual", {}).get("motivo_hrb", "Herbicida insuficiente") - cond_op_mod.append({ - "valor": percent_atual, - "severidade": 90, - "descricao": _motivo_herb - }) - else: - cond_op_mod.append({ - "valor": 0, - "severidade": 100, - "descricao": f"Sensor do nível do reservatório desconectado!" - }) + processo = self._avaliar_processo_atuacao( + contexto_operacional, + saude_individual, + ) - penalidade_pressao_baixa = 0 - if sensor_pressao_em_uso: - if sensor_pressao_conectado: - _contexto = ContextoGlobalRedis.get_contexto() - pressao_linha = _contexto.get("Atuador", {}).get("pressao_linha", 0) - pressao_linha_ok = _contexto.get("Atuador", {}).get("pressao_linha_regular", False) - if not pressao_linha_ok: - penalidade_percentual_herbicida = 50 - cond_op_mod.append({ - "valor": pressao_linha, - "severidade": 50, - "descricao": f"Pressão da linha abaixo do esperado!" - }) - else: - cond_op_mod.append({ - "valor": 0, - "severidade": 100, - "descricao": f"Sensor de pressão da linha desconectado!" - }) + em_uso = [ + item for item in saude_individual + if self._bool(item.get("em_uso", False)) + ] + if em_uso: + score_componentes = sum(self._float(x.get("saude", 0), 0) for x in em_uso) / len(em_uso) + else: + score_componentes = 100.0 if mod_conectado else 0.0 - saude_final = (saude_mandatorios // total_mandatorios) if total_mandatorios > 0 else 100 - saude_final_ativos = (saude_total // total_ativos) if total_ativos > 0 else 0 - saude_final = saude_final_ativos - penalidade_percentual_herbicida + saude_final = ( + root_score * 0.25 + + score_componentes * 0.35 + + processo["score"] * 0.40 + ) - status = StatusModulo.OPERANTE + # Travas: falhas reais não podem ser escondidas pela média. if not mod_conectado: - status = StatusModulo.DESCONECTADO - elif saude_final <= 0: - status = StatusModulo.FALHA - elif saude_final < SAUDE_MIN_ALERTA or any(s["em_uso"] is True and s["saude"] < SAUDE_MIN_ALERTA for s in saude_individual): - status = StatusModulo.ALERTA + saude_final = 0 + + if any(self._is_falha_hardware(c) for c in root_cond + processo["condicoes_operacionais"]): + saude_final = min(saude_final, 40) + + if processo["pulverizacao_incoerente"]: + saude_final = min(saude_final, 45) + + if processo["bomba_cavitada"]: + saude_final = min(saude_final, 35) + + saude_final = int(max(0, min(100, round(saude_final)))) + + motivos_gerais = [] + motivos_gerais.extend(root_motivos) + motivos_gerais.extend(processo["motivos"]) + + for item in em_uso: + for motivo in item.get("motivos", []) or []: + motivos_gerais.append(f"{item.get('grupo')} {item.get('label') or item.get('id')}: {motivo}") + + condicoes_gerais = [] + condicoes_gerais.extend(root_cond) + condicoes_gerais.extend(processo["condicoes_operacionais"]) + + for item in em_uso: + for cond in item.get("condicoes_operacionais", []) or []: + condicoes_gerais.append({ + **cond, + "grupo": item.get("grupo"), + "id": item.get("id"), + "label": item.get("label"), + }) + + status = self._resolver_status_geral( + conectado=mod_conectado, + saude=saude_final, + saude_individual=saude_individual, + condicoes=condicoes_gerais, + ) saude_geral = { "conectado": mod_conectado, - "status": status.value, - "saude": max(0, saude_final), - "motivos": motivos_gerais, - "saude_individual": saude_individual, - "condicoes_operacionais": cond_op_mod + "status": status.value, + "saude": saude_final, + "motivos": self._dedupe(motivos_gerais), + "saude_individual": sorted( + saude_individual, + key=lambda x: (str(x.get("grupo")), self._safe_int(x.get("id"), 0)), + ), + "condicoes_operacionais": condicoes_gerais, + "componentes": { + "root": int(round(root_score)), + "componentes": int(round(score_componentes)), + "processo_atuacao": int(round(processo["score"])), + }, + "detalhes": contexto_operacional["debug"], } - ContextoGlobalRedis.atualizar_ctx_dict( - ContextoGlobalRedis.ModKey(self.t_code), - saude=saude_geral - ) + self._publicar_saude(saude_geral, updates_componentes) + except Exception as e: print(f"Erro ao atualizar saude do modulo {self.t_code.name}: {e}") + + try: + self._publicar_saude( + { + "conectado": False, + "status": StatusModulo.FALHA.value, + "saude": 0, + "motivos": [f"Erro interno no health do atuador: {e}"], + "saude_individual": [], + "condicoes_operacionais": [], + "componentes": {}, + "detalhes": {}, + }, + {}, + ) + except Exception: + pass + + # ============================================================ + # Raiz do módulo + # ============================================================ + + def _avaliar_raiz(self, dados_atu): + conectado = self._bool(dados_atu.get("conectado", False)) + idade_ms = self._obter_ultima_resposta_ms(dados_atu) + + motivos = [] + condicoes = [] + + if not conectado: + motivos.append("ATU desconectado") + return 0, motivos, condicoes + + if idade_ms > self.RESPOSTA_RECENTE_MS: + motivos.append(f"ATU sem resposta recente ({idade_ms:.0f} ms)") + condicoes.append({ + "label": "Resposta ATU", + "valor": idade_ms, + "severidade": 95, + "classe": "falha_hardware", + "descricao": f"Módulo atuador sem resposta recente ({idade_ms:.0f} ms)", + "acoes": [ + "Verificar barramento CAN do ATU.", + "Verificar alimentação do módulo atuador.", + "Conferir se o registrador do ATU está ativo.", + ], + }) + return 0, motivos, condicoes + + if idade_ms > self.RESPOSTA_LENTA_MS: + motivos.append(f"Resposta ATU lenta ({idade_ms:.0f} ms)") + return 75, motivos, condicoes + + return 100, motivos, condicoes + + # ============================================================ + # Contexto operacional + # ============================================================ + + def _montar_contexto_operacional(self, dados_atu, sensores, bicos, bombas, contexto, operacao, equipamento): + gerais = self._as_dict(contexto.get("Gerais", {})) + ctx_atu = self._as_dict(contexto.get("Atuador", {})) + traj = self._as_dict(contexto.get("Trajetoria", {})) + corredor = self._as_dict(traj.get("CorredorAtual", {})) + + pulverizador_automatico = self._bool( + dados_atu.get( + "pulverizador_automatico", + self._as_dict(operacao.get("controle", {})).get("pulverizador_automatico", False), + ) + ) + + calibrando = self._bool(dados_atu.get("calibrando", operacao.get("calibrando", False))) + operacao_iniciada = self._bool(dados_atu.get("operacao_iniciada", operacao.get("iniciado", False))) + emergencia = self._bool(dados_atu.get("emergencia", operacao.get("emergencia", False))) + pausa = self._bool(dados_atu.get("pausa", operacao.get("pausa", False))) + finalizando = self._bool(dados_atu.get("finalizando", operacao.get("finalizando", False))) + + pulverizador_liberado = self._bool( + dados_atu.get( + "pulverizador_liberado", + ctx_atu.get("pulverizador_liberado", True), + ) + ) + + percent_reservatorio = self._float( + dados_atu.get( + "percentual_reservatorio", + gerais.get("percentual_reservatorio", 0.0), + ), + 0.0, + ) + + percent_min = self._float( + equipamento.get("percentual_reservatorio_min", 5.0), + 5.0, + ) + + pressao_linha = self._float( + dados_atu.get( + "pressao_linha", + ctx_atu.get("pressao_linha", self._valor_sensor_por_label(sensores, "PRSLN", "Pressao")), + ), + 0.0, + ) + + pressao_linha_regular = self._bool( + dados_atu.get( + "pressao_linha_regular", + ctx_atu.get("pressao_linha_regular", False), + ) + ) + + vazao_linha = self._float( + dados_atu.get( + "vazao_linha_mls", + self._valor_sensor_por_label(sensores, "FLXLN", "Fluxo"), + ), + 0.0, + ) + + bomba_cavitada = self._bool( + dados_atu.get( + "bomba_cavitada", + ctx_atu.get("bomba_cavitada", False), + ) + ) + + herbicida_ok_corredor = self._bool(corredor.get("herbicida_ok", True), True) + motivo_herbicida_corredor = corredor.get("motivo_hrb", "Herbicida insuficiente para o corredor") + + bomba_principal = self._achar_por_label(bombas, "BMBLN") + agitador = self._achar_por_label(bombas, "BMBAGT") + + bicos_comandados = [b for b in bicos if self._estado_desejado_ligado(b["dados"])] + bicos_ligados = [ + b for b in bicos + if self._estado_ligado(self._get(b["dados"], "estado_leitura", self._campo_atual(b["dados"], "EstadoLeitura", 0))) + ] + + bombas_comandadas = [b for b in bombas if self._estado_desejado_ligado(b["dados"])] + bombas_ligadas = [ + b for b in bombas + if self._estado_ligado(self._get(b["dados"], "estado_leitura", self._campo_atual(b["dados"], "EstadoLeitura", 0))) or + self._float(self._get(b["dados"], "potencia", self._campo_atual(b["dados"], "Potencia", 0)), 0) > self.POTENCIA_MIN_BOMBA_LIGADA + ] + + return { + "pulverizador_automatico": pulverizador_automatico, + "calibrando": calibrando, + "operacao_iniciada": operacao_iniciada, + "emergencia": emergencia, + "pausa": pausa, + "finalizando": finalizando, + "pulverizador_liberado": pulverizador_liberado, + "percent_reservatorio": percent_reservatorio, + "percent_min": percent_min, + "pressao_linha": pressao_linha, + "pressao_linha_regular": pressao_linha_regular, + "vazao_linha_mls": vazao_linha, + "bomba_cavitada": bomba_cavitada, + "herbicida_ok_corredor": herbicida_ok_corredor, + "motivo_herbicida_corredor": motivo_herbicida_corredor, + "bomba_principal": bomba_principal, + "agitador": agitador, + "bicos_comandados": bicos_comandados, + "bicos_ligados": bicos_ligados, + "bombas_comandadas": bombas_comandadas, + "bombas_ligadas": bombas_ligadas, + "debug": { + "pulverizador_automatico": pulverizador_automatico, + "calibrando": calibrando, + "operacao_iniciada": operacao_iniciada, + "pulverizador_liberado": pulverizador_liberado, + "percent_reservatorio": percent_reservatorio, + "percent_min": percent_min, + "pressao_linha": pressao_linha, + "pressao_linha_regular": pressao_linha_regular, + "vazao_linha_mls": vazao_linha, + "bomba_cavitada": bomba_cavitada, + "qtd_bicos_comandados": len(bicos_comandados), + "qtd_bicos_ligados": len(bicos_ligados), + "qtd_bombas_comandadas": len(bombas_comandadas), + "qtd_bombas_ligadas": len(bombas_ligadas), + }, + } + + # ============================================================ + # Sensores + # ============================================================ + + def _avaliar_sensor(self, item, ctx): + dados = item["dados"] + label = dados.get("label") + conectado = self._bool(dados.get("conectado", False)) + aferir = self._bool(dados.get("aferir", False)) + mandatorio = self._bool(dados.get("mandatorio", False)) + em_uso = aferir + + motivos = [] + condicoes = [] + + if not conectado: + saude = 0 + motivos.append("desconectado") + status = StatusModulo.DESCONECTADO + return self._montar_saude_item(item, status, saude, motivos, condicoes, em_uso, {}, "sensor") + + resposta_score = self._score_resposta_componente(dados, motivos, condicoes, "Sensor ATU") + valor_score, valor_debug = self._avaliar_valores_sensor(item, ctx, motivos, condicoes) + telemetria = self._avaliar_telemetria_dados(dados, motivos, criticos=["Pressao", "Fluxo", "Massa"] if mandatorio else []) + + saude = ( + resposta_score * 0.35 + + valor_score * 0.45 + + telemetria["score"] * 0.20 + ) + + if any(self._is_falha_hardware(c) for c in condicoes): + saude = min(saude, 45) + + saude = int(max(0, min(100, round(saude)))) + status = self._resolver_status_item(conectado, saude, condicoes) + + debug = { + "resposta": resposta_score, + "valores": valor_debug, + "telemetria": telemetria["parametros"], + } + + return self._montar_saude_item(item, status, saude, motivos, condicoes, em_uso, debug, "sensor") + + def _avaliar_valores_sensor(self, item, ctx, motivos, condicoes): + dados = item["dados"] + label_sensor = dados.get("label") + campos = self._campos_por_label(dados) + score = 100.0 + debug = {} + + for label, campo in campos.items(): + atual = self._float(campo.get("atual", 0), 0) + minimo = campo.get("minimo", None) + maximo = campo.get("maximo", None) + + debug[label] = { + "atual": atual, + "minimo": minimo, + "maximo": maximo, + } + + if minimo is not None and atual < self._float(minimo, atual): + motivos.append(f"{campo.get('label')} abaixo do mínimo ({atual})") + score -= 8 + + if maximo is not None and atual > self._float(maximo, atual): + motivos.append(f"{campo.get('label')} acima do máximo ({atual})") + score -= 8 + + if label_sensor == "MASRS": + percent = ctx["percent_reservatorio"] + percent_min = ctx["percent_min"] + + if percent <= 0 and ctx["pulverizador_automatico"]: + motivos.append("Percentual do reservatório indisponível ou zerado") + score = min(score, 70) + + if percent < percent_min: + motivos.append(f"Nível de herbicida abaixo do mínimo ({percent:.1f}% < {percent_min:.1f}%)") + condicoes.append({ + "label": "Reservatório", + "valor": percent, + "severidade": 95, + "classe": "recurso_operacional", + "descricao": "Nível do reservatório de herbicida abaixo do limite mínimo", + "acoes": [ + "Reabastecer reservatório.", + "Recalibrar sensor de massa se o valor estiver incoerente.", + ], + }) + score = min(score, 65) + elif percent < 20: + motivos.append(f"Nível de herbicida baixo ({percent:.1f}%)") + condicoes.append({ + "label": "Reservatório", + "valor": percent, + "severidade": 35, + "classe": "recurso_operacional", + "descricao": "Nível de herbicida baixo", + "acoes": ["Planejar reabastecimento."], + }) + score = min(score, 90) + + if label_sensor == "PRSLN": + bomba_on = self._bomba_principal_ligada(ctx) + if bomba_on and not ctx["pressao_linha_regular"]: + pressao = ctx["pressao_linha"] + motivos.append(f"Pressão baixa com bomba ligada ({pressao:.1f} psi)") + condicoes.append({ + "label": "Pressão de linha", + "valor": pressao, + "severidade": 75, + "classe": "processo", + "descricao": "Pressão de linha abaixo do esperado com bomba ligada", + "acoes": [ + "Verificar se há ar na linha ou cavitação.", + "Verificar filtro, mangueiras, bicos e sensor de pressão.", + ], + }) + score = min(score, 70) + + if label_sensor == "FLXLN": + bicos_on = len(ctx["bicos_ligados"]) > 0 or len(ctx["bicos_comandados"]) > 0 + bomba_on = self._bomba_principal_ligada(ctx) + vazao = ctx["vazao_linha_mls"] + + if bicos_on and bomba_on and vazao <= self.FLUXO_MIN_COM_BICOS_MLS: + motivos.append(f"Fluxo baixo com bicos e bomba ativos ({vazao:.1f} mL/s)") + condicoes.append({ + "label": "Fluxo de linha", + "valor": vazao, + "severidade": 82, + "classe": "processo", + "descricao": "Fluxo baixo mesmo com bomba e bicos ativos", + "acoes": [ + "Verificar entupimento, filtro e ar na linha.", + "Conferir se o sensor de fluxo está lendo corretamente.", + ], + }) + score = min(score, 65) + + return int(max(0, min(100, round(score)))), debug + + # ============================================================ + # Bicos + # ============================================================ + + def _avaliar_bico(self, item, ctx): + dados = item["dados"] + conectado = self._bool(dados.get("conectado", False)) + comandar = self._bool(dados.get("comandar", False)) + mandatorio = self._bool(dados.get("mandatorio", False)) + em_uso = comandar + + motivos = [] + condicoes = [] + + if not conectado: + saude = 0 + motivos.append("desconectado") + status = StatusModulo.DESCONECTADO + return self._montar_saude_item(item, status, saude, motivos, condicoes, em_uso, {}, "bico") + + resposta_score = self._score_resposta_componente(dados, motivos, condicoes, "Bico ATU") + comando_score, comando_debug = self._avaliar_comando_bico(item, ctx, motivos, condicoes) + telemetria = self._avaliar_telemetria_dados(dados, motivos, criticos=["EstadoLeitura"] if mandatorio else []) + + saude = ( + resposta_score * 0.25 + + comando_score * 0.60 + + telemetria["score"] * 0.15 + ) + + if any(self._is_falha_hardware(c) for c in condicoes): + saude = min(saude, 45) + + saude = int(max(0, min(100, round(saude)))) + status = self._resolver_status_item(conectado, saude, condicoes) + + debug = { + "resposta": resposta_score, + "comando": comando_debug, + "telemetria": telemetria["parametros"], + } + + return self._montar_saude_item(item, status, saude, motivos, condicoes, em_uso, debug, "bico") + + def _avaliar_comando_bico(self, item, ctx, motivos, condicoes): + dados = item["dados"] + label = dados.get("label") + comando_presente = ( + "comando_atuar" in dados or + "estado_desejado" in dados or + "status_desejado_fisico" in dados or + "ultimo_status_desejado" in dados + ) + comando = self._estado_desejado_ligado(dados) + estado = self._get(dados, "estado_leitura", self._campo_atual(dados, "EstadoLeitura", 0)) + estado_on = self._estado_ligado(estado) + + angulo = self._float( + self._get(dados, "angulo_leitura", self._campo_atual(dados, "ServoAnguloLeitura", 0)), + 0, + ) + + angulo_neutro = self._float(dados.get("angulo_neutro", 90), 90) + angulo_controle = self._float(dados.get("angulo_controle", dados.get("angulo_sp", 90)), 90) + angulo_desejado = self._float( + dados.get("angulo_desejado", angulo_controle if comando else angulo_neutro), + angulo_controle if comando else angulo_neutro, + ) + + ultimo_comando_ms = self._float(dados.get("ultimo_comando_ms", 999999), 999999) + tem_idade_comando = ultimo_comando_ms < 900000 + + score = 100.0 + + if not comando_presente: + return 92, { + "comando_disponivel": False, + "estado_on": estado_on, + "angulo": angulo, + } + + if tem_idade_comando and ultimo_comando_ms < self.COMANDO_GRACE_MS: + return 96, { + "comando_atuar": comando, + "estado_on": estado_on, + "angulo": angulo, + "angulo_desejado": angulo_desejado, + "ultimo_comando_ms": ultimo_comando_ms, + } + + if comando and not estado_on: + severidade = 92 if tem_idade_comando and ultimo_comando_ms >= self.COMANDO_FALHA_MS else 75 + motivos.append(f"Bico comandado ligado, mas leitura indica desligado ({label})") + condicoes.append({ + "label": "Bico", + "valor": 0, + "severidade": severidade, + "classe": "falha_hardware" if severidade > 90 else "processo", + "descricao": f"Bico {label} comandado ligado, mas estado lido está desligado", + "acoes": [ + "Verificar solenoide do bico.", + "Verificar comando físico no ATU.", + "Conferir alimentação e retorno do bico.", + ], + }) + score = min(score, 35 if severidade > 90 else 70) + + if not comando and estado_on: + severidade = 95 if tem_idade_comando and ultimo_comando_ms >= self.COMANDO_ALERTA_MS else 85 + motivos.append(f"Bico ligado sem comando ({label})") + condicoes.append({ + "label": "Bico", + "valor": 1, + "severidade": severidade, + "classe": "falha_hardware" if severidade > 90 else "processo", + "descricao": f"Bico {label} aparenta estar ligado sem comando", + "acoes": [ + "Verificar travamento da solenoide.", + "Verificar se o comando de desligamento chegou ao ATU.", + ], + }) + score = min(score, 25 if severidade > 90 else 60) + + erro_ang = abs(angulo - angulo_desejado) + + if erro_ang > self.ANGULO_TOL_BICO_GRAUS and (comando or estado_on): + motivos.append(f"Ângulo do bico distante do desejado ({angulo:.1f}°/{angulo_desejado:.1f}°)") + condicoes.append({ + "label": "Ângulo bico", + "valor": angulo, + "severidade": min(75, max(30, int(erro_ang * 3))), + "classe": "processo", + "descricao": f"Ângulo do bico {label} distante do desejado", + "acoes": [ + "Verificar servo/posição mecânica do bico.", + "Conferir neutro e ângulo de abertura configurados.", + ], + }) + score = min(score, 78) + + return int(max(0, min(100, round(score)))), { + "comando_disponivel": True, + "comando_atuar": comando, + "estado_on": estado_on, + "angulo": angulo, + "angulo_desejado": angulo_desejado, + "ultimo_comando_ms": ultimo_comando_ms, + } + + # ============================================================ + # Bombas + # ============================================================ + + def _avaliar_bomba(self, item, ctx): + dados = item["dados"] + conectado = self._bool(dados.get("conectado", False)) + comandar = self._bool(dados.get("comandar", False)) + mandatorio = self._bool(dados.get("mandatorio", False)) + em_uso = comandar + + motivos = [] + condicoes = [] + + if not conectado: + saude = 0 + motivos.append("desconectado") + status = StatusModulo.DESCONECTADO + return self._montar_saude_item(item, status, saude, motivos, condicoes, em_uso, {}, "bomba") + + resposta_score = self._score_resposta_componente(dados, motivos, condicoes, "Bomba ATU") + comando_score, comando_debug = self._avaliar_comando_bomba(item, ctx, motivos, condicoes) + telemetria = self._avaliar_telemetria_dados(dados, motivos, criticos=["EstadoLeitura", "Potencia"] if mandatorio else []) + + saude = ( + resposta_score * 0.25 + + comando_score * 0.60 + + telemetria["score"] * 0.15 + ) + + if any(self._is_falha_hardware(c) for c in condicoes): + saude = min(saude, 45) + + saude = int(max(0, min(100, round(saude)))) + status = self._resolver_status_item(conectado, saude, condicoes) + + debug = { + "resposta": resposta_score, + "comando": comando_debug, + "telemetria": telemetria["parametros"], + } + + return self._montar_saude_item(item, status, saude, motivos, condicoes, em_uso, debug, "bomba") + + def _avaliar_comando_bomba(self, item, ctx, motivos, condicoes): + dados = item["dados"] + label = dados.get("label") + eh_principal = label == "BMBLN" + eh_agitador = label == "BMBAGT" + + comando_presente = ( + "comando_atuar" in dados or + "estado_desejado" in dados or + "status_desejado_fisico" in dados or + "ultimo_status_desejado" in dados + ) + comando = self._estado_desejado_ligado(dados) + estado = self._get(dados, "estado_leitura", self._campo_atual(dados, "EstadoLeitura", 0)) + estado_on = self._estado_ligado(estado) + + potencia = self._float(self._get(dados, "potencia", self._campo_atual(dados, "Potencia", 0)), 0) + potencia_sp = self._float(self._get(dados, "potencia_sp", self._campo_atual(dados, "PotenciaSP", 0)), 0) + pressao = self._float(self._get(dados, "pressao", self._campo_atual(dados, "Pressao", 0)), 0) + pressao_sp = self._float(self._get(dados, "pressao_sp", self._campo_atual(dados, "PressaoSP", 0)), 0) + + ultimo_comando_ms = self._float(dados.get("ultimo_comando_ms", 999999), 999999) + tem_idade_comando = ultimo_comando_ms < 900000 + + bomba_ligada = estado_on or potencia > self.POTENCIA_MIN_BOMBA_LIGADA + + score = 100.0 + + if not comando_presente: + return 92, { + "comando_disponivel": False, + "estado_on": estado_on, + "potencia": potencia, + "pressao": pressao, + } + + if tem_idade_comando and ultimo_comando_ms < self.COMANDO_GRACE_MS: + return 96, { + "comando_atuar": comando, + "estado_on": estado_on, + "potencia": potencia, + "potencia_sp": potencia_sp, + "pressao": pressao, + "pressao_sp": pressao_sp, + "ultimo_comando_ms": ultimo_comando_ms, + } + + if comando and not bomba_ligada: + severidade = 92 if tem_idade_comando and ultimo_comando_ms >= self.COMANDO_FALHA_MS else 78 + motivos.append(f"Bomba {label} comandada ligada, mas leitura não indica acionamento") + condicoes.append({ + "label": "Bomba", + "valor": potencia, + "severidade": severidade, + "classe": "falha_hardware" if severidade > 90 else "processo", + "descricao": f"Bomba {label} comandada ligada, mas estado/potência indicam desligado", + "acoes": [ + "Verificar relé/driver da bomba.", + "Verificar alimentação da bomba.", + "Conferir comando físico Command1 do ATU.", + ], + }) + score = min(score, 35 if severidade > 90 else 68) + + if not comando and bomba_ligada: + severidade = 95 if tem_idade_comando and ultimo_comando_ms >= self.COMANDO_ALERTA_MS else 85 + motivos.append(f"Bomba {label} ligada sem comando") + condicoes.append({ + "label": "Bomba", + "valor": potencia, + "severidade": severidade, + "classe": "falha_hardware" if severidade > 90 else "processo", + "descricao": f"Bomba {label} aparenta ligada sem comando", + "acoes": [ + "Verificar travamento de relé/saída.", + "Verificar se comando de desligamento chegou ao ATU.", + ], + }) + score = min(score, 25 if severidade > 90 else 60) + + if eh_principal and comando and bomba_ligada: + pressao_ref = pressao if pressao > 0 else ctx["pressao_linha"] + pressao_alvo = pressao_sp if pressao_sp > 0 else self._float(ctx.get("pressao_sp", 0), 0) + + if ctx["bomba_cavitada"]: + motivos.append("Bomba principal cavitada") + condicoes.append({ + "label": "Bomba cavitada", + "valor": pressao_ref, + "severidade": 95, + "classe": "falha_hardware", + "descricao": "Bomba principal com indício de cavitação", + "acoes": [ + "Verificar nível de calda.", + "Verificar entrada de ar na sucção.", + "Verificar filtro e mangueiras.", + ], + }) + score = min(score, 25) + + elif pressao_alvo > 0 and pressao_ref < max(0, pressao_alvo - self.PRESSAO_TOL_PSI): + severidade = 88 if len(ctx["bicos_ligados"]) > 0 else 75 + motivos.append(f"Pressão abaixo do SP ({pressao_ref:.1f}/{pressao_alvo:.1f} psi)") + condicoes.append({ + "label": "Pressão bomba", + "valor": pressao_ref, + "severidade": severidade, + "classe": "processo", + "descricao": "Bomba principal ligada, mas pressão abaixo do alvo", + "acoes": [ + "Verificar vazamento, ar na linha, filtro ou bicos abertos.", + "Conferir PID e potência mínima da bomba.", + ], + }) + score = min(score, 65) + + if eh_agitador and comando and potencia <= self.POTENCIA_MIN_BOMBA_LIGADA and estado_on: + motivos.append("Agitador ligado com potência muito baixa") + condicoes.append({ + "label": "Agitador", + "valor": potencia, + "severidade": 45, + "classe": "processo", + "descricao": "Agitador está ligado, mas potência lida está muito baixa", + "acoes": ["Conferir saída do agitador e leitura de potência."], + }) + score = min(score, 82) + + return int(max(0, min(100, round(score)))), { + "comando_disponivel": True, + "comando_atuar": comando, + "estado_on": estado_on, + "bomba_ligada": bomba_ligada, + "potencia": potencia, + "potencia_sp": potencia_sp, + "pressao": pressao, + "pressao_sp": pressao_sp, + "ultimo_comando_ms": ultimo_comando_ms, + } + + # ============================================================ + # Processo geral do pulverizador + # ============================================================ + + def _avaliar_processo_atuacao(self, ctx, saude_individual): + motivos = [] + condicoes = [] + score = 100.0 + + pulverizacao_incoerente = False + bomba_cavitada = False + + auto = ctx["pulverizador_automatico"] + calibrando = ctx["calibrando"] + bomba_principal = ctx["bomba_principal"] + + bomba_principal_on = self._bomba_principal_ligada(ctx) + bicos_cmd = len(ctx["bicos_comandados"]) > 0 + bicos_on = len(ctx["bicos_ligados"]) > 0 + + if ctx["bomba_cavitada"]: + bomba_cavitada = True + motivos.append("Bomba cavitada") + condicoes.append({ + "label": "Bomba cavitada", + "valor": 1, + "severidade": 95, + "classe": "falha_hardware", + "descricao": "Bomba pressurizadora com indício de cavitação", + "acoes": [ + "Parar pulverização.", + "Verificar nível de calda e entrada de ar.", + "Inspecionar filtro, sucção e mangueiras.", + ], + }) + score = min(score, 25) + + if auto and not calibrando and not ctx["pulverizador_liberado"]: + motivos.append("Pulverizador automático não liberado") + condicoes.append({ + "label": "Pulverizador automático", + "valor": 0, + "severidade": 88, + "classe": "processo", + "descricao": "Pulverizador automático bloqueado por regra operacional", + "acoes": [ + "Verificar reservatório, autonomia do corredor e estado da operação.", + ], + }) + score = min(score, 75) + + if ctx["percent_reservatorio"] < ctx["percent_min"]: + motivos.append( + f"Reservatório abaixo do mínimo ({ctx['percent_reservatorio']:.1f}% < {ctx['percent_min']:.1f}%)" + ) + condicoes.append({ + "label": "Reservatório", + "valor": ctx["percent_reservatorio"], + "severidade": 95, + "classe": "recurso_operacional", + "descricao": "Nível de herbicida abaixo do mínimo operacional", + "acoes": [ + "Reabastecer reservatório.", + "Validar leitura do sensor de massa se o tanque não estiver baixo.", + ], + }) + score = min(score, 65) + + elif ctx["percent_reservatorio"] < 20: + motivos.append(f"Reservatório baixo ({ctx['percent_reservatorio']:.1f}%)") + condicoes.append({ + "label": "Reservatório", + "valor": ctx["percent_reservatorio"], + "severidade": 35, + "classe": "recurso_operacional", + "descricao": "Nível de herbicida baixo", + "acoes": ["Planejar reabastecimento."], + }) + score = min(score, 90) + + if not ctx["herbicida_ok_corredor"]: + motivos.append(ctx["motivo_herbicida_corredor"]) + condicoes.append({ + "label": "Autonomia de herbicida", + "valor": ctx["percent_reservatorio"], + "severidade": 90, + "classe": "recurso_operacional", + "descricao": ctx["motivo_herbicida_corredor"], + "acoes": [ + "Reabastecer ou reduzir distância do corredor planejado.", + ], + }) + score = min(score, 80) + + if (bicos_cmd or bicos_on) and auto and not bomba_principal_on and not calibrando: + pulverizacao_incoerente = True + motivos.append("Bicos ativos ou comandados com bomba principal desligada") + condicoes.append({ + "label": "Pulverização incoerente", + "valor": len(ctx["bicos_ligados"]), + "severidade": 93, + "classe": "falha_hardware", + "descricao": "Há bicos ativos/comandados, mas a bomba principal não está ligada", + "acoes": [ + "Verificar sincronismo da bomba automática.", + "Verificar se BMBLN está inicializada e recebendo comando.", + "Conferir regra de ContadorCarga.PulverizadorLiberado.", + ], + }) + score = min(score, 35) + + if bomba_principal_on and bicos_on and ctx["vazao_linha_mls"] <= self.FLUXO_MIN_COM_BICOS_MLS: + motivos.append("Fluxo ausente com bomba e bicos ligados") + condicoes.append({ + "label": "Fluxo de pulverização", + "valor": ctx["vazao_linha_mls"], + "severidade": 85, + "classe": "processo", + "descricao": "Bomba e bicos aparentam ativos, mas o fluxo está baixo ou zerado", + "acoes": [ + "Verificar entupimento, válvulas, ar na linha e sensor de fluxo.", + ], + }) + score = min(score, 62) + + if bomba_principal_on and not ctx["pressao_linha_regular"]: + motivos.append("Pressão de linha irregular com bomba ligada") + condicoes.append({ + "label": "Pressão de pulverização", + "valor": ctx["pressao_linha"], + "severidade": 78, + "classe": "processo", + "descricao": "Bomba ligada, mas pressão de linha não está regular", + "acoes": [ + "Verificar bomba, pressão alvo, ar na linha, bicos e vazamentos.", + ], + }) + score = min(score, 68) + + return { + "score": int(max(0, min(100, round(score)))), + "motivos": self._dedupe(motivos), + "condicoes_operacionais": condicoes, + "pulverizacao_incoerente": pulverizacao_incoerente, + "bomba_cavitada": bomba_cavitada, + } + + # ============================================================ + # Telemetria e valores genéricos + # ============================================================ + + def _score_resposta_componente(self, dados, motivos, condicoes, label_tipo): + conectado = self._bool(dados.get("conectado", False)) + if not conectado: + return 0 + + idade_ms = self._obter_ultima_resposta_ms(dados) + + if idade_ms > self.RESPOSTA_RECENTE_MS: + motivos.append(f"{label_tipo} sem resposta recente ({idade_ms:.0f} ms)") + condicoes.append({ + "label": label_tipo, + "valor": idade_ms, + "severidade": 92, + "classe": "falha_hardware", + "descricao": f"{label_tipo} sem resposta recente", + "acoes": [ + "Verificar retorno do componente no pacote DadosAll.", + "Verificar conexão física e configuração do componente.", + ], + }) + return 0 + + if idade_ms > self.RESPOSTA_LENTA_MS: + motivos.append(f"{label_tipo} com resposta lenta ({idade_ms:.0f} ms)") + return 75 + + return 100 + + def _avaliar_telemetria_dados(self, dados, motivos, criticos=None): + criticos = [self._canon(c) for c in (criticos or [])] + freq_base_root = self._float(dados.get("freq_base", 1.0), 1.0) + campos = dados.get("dados", []) + parametros = {} + scores = [] + + if not isinstance(campos, list) or not campos: + return {"score": 90, "parametros": {}} + + for campo in campos: + if not isinstance(campo, dict): + continue + + label = campo.get("label") + if not label: + continue + + key = self._canon(label) + freq = self._float(campo.get("freq", 0.0), 0.0) + freq_base = self._float(campo.get("freq_base", freq_base_root), freq_base_root) + freq_min = self._float(campo.get("freq_min", freq_base * self.FREQ_MIN_DEFAULT_FACTOR), freq_base * self.FREQ_MIN_DEFAULT_FACTOR) + critico = key in criticos + max_pen = self.PENALIDADE_FREQ_MAX_CRITICA if critico else self.PENALIDADE_FREQ_MAX_PADRAO + + pen = self._penalizar_freq(freq, freq_base, freq_min, max_pen) + score = max(0, min(100, 100 - pen * 4)) + + if freq <= 0: + score = 0 if critico else 65 + motivos.append(f"Telemetria {label} ausente (0 Hz)") + elif freq < freq_min: + motivos.append(f"Telemetria {label} baixa ({freq:.2f}/{freq_base:.2f} Hz, min={freq_min:.2f} Hz)") + + parametros[key] = { + "label": label, + "freq": freq, + "freq_base": freq_base, + "freq_min": freq_min, + "critico": critico, + "penalidade": pen, + "score": score, + } + + scores.append(score) + + score_total = sum(scores) / len(scores) if scores else 90 + return { + "score": int(max(0, min(100, round(score_total)))), + "parametros": parametros, + } + + @staticmethod + def _penalizar_freq(freq, base, minimo, max_penalidade): + try: + return float(penalizar_frequencia(freq, base, minimo, max_penalidade) or 0) + except Exception: + if freq >= minimo: + return 0.0 + if freq <= 0: + return float(max_penalidade) + return float(max_penalidade) * (1.0 - max(0.0, min(1.0, freq / max(minimo, 0.001)))) + + # ============================================================ + # Coleta e montagem + # ============================================================ + + def _coletar_componentes(self, dados_atu, grupo): + raiz = self._as_dict(dados_atu.get(grupo, {})) + ativos = raiz.get("ativos", []) + + resultado = [] + + if not isinstance(ativos, list): + ativos = [] + + for key in ativos: + try: + tipo, comp_id = str(key).split(".") + except Exception: + continue + + dados = self._as_dict(self._as_dict(raiz.get(str(tipo), {})).get(str(comp_id), {})) + + if not dados: + continue + + resultado.append({ + "grupo": grupo, + "tipo": str(tipo), + "id": str(comp_id), + "key": f"{tipo}.{comp_id}", + "dados": dados, + }) + + return resultado + + def _montar_saude_item(self, item, status, saude, motivos, condicoes, em_uso, debug, grupo_singular): + dados = item["dados"] + return { + "id": item.get("id"), + "tipo": item.get("tipo"), + "grupo": grupo_singular, + "label": dados.get("label"), + "status": status.value, + "saude": int(max(0, min(100, saude))), + "motivos": self._dedupe(motivos), + "condicoes_operacionais": condicoes, + "em_uso": bool(em_uso), + "mandatorio": self._bool(dados.get("mandatorio", False)), + "debug": debug, + } + + # ============================================================ + # Status + # ============================================================ + + def _resolver_status_item(self, conectado, saude, condicoes): + if not conectado: + return StatusModulo.DESCONECTADO + + if any(self._is_falha_hardware(c) for c in condicoes): + return StatusModulo.FALHA + + if saude <= 0: + return StatusModulo.FALHA + + if saude < self.SAUDE_MIN_ALERTA: + return StatusModulo.ALERTA + + if any(self._float(c.get("severidade", 0), 0) >= 70 for c in condicoes): + return StatusModulo.ALERTA + + return StatusModulo.OPERANTE + + def _resolver_status_geral(self, conectado, saude, saude_individual, condicoes): + if not conectado: + return StatusModulo.DESCONECTADO + + em_uso = [x for x in saude_individual if self._bool(x.get("em_uso", False))] + + if any(self._is_falha_hardware(c) for c in condicoes): + return StatusModulo.FALHA + + if any(x.get("status") == StatusModulo.FALHA.value for x in em_uso): + return StatusModulo.FALHA + + if saude <= 0: + return StatusModulo.FALHA + + if any(self._float(c.get("severidade", 0), 0) > 90 for c in condicoes): + # Exemplo: herbicida baixo pode bloquear operação, mas é recurso, + # não necessariamente falha elétrica do ATU. + return StatusModulo.ALERTA + + if saude < self.SAUDE_MIN_ALERTA: + return StatusModulo.ALERTA + + if any(x.get("status") == StatusModulo.ALERTA.value for x in em_uso): + return StatusModulo.ALERTA + + return StatusModulo.OPERANTE + + @staticmethod + def _is_falha_hardware(cond): + try: + return ( + float(cond.get("severidade", 0) or 0) > 90 and + cond.get("classe") == "falha_hardware" + ) + except Exception: + return False + + # ============================================================ + # Publicação + # ============================================================ + + def _publicar_saude(self, saude_geral, updates_componentes): + payload = {"saude": saude_geral} + payload.update(updates_componentes or {}) + + ContextoGlobalRedis.atualizar_ctx_dict( + ContextoGlobalRedis.ModKey(self.t_code), + **payload, + ) + + # ============================================================ + # Helpers de dados + # ============================================================ + + @staticmethod + def _as_dict(valor): + return valor if isinstance(valor, dict) else {} + + @staticmethod + def _canon(valor): + texto = str(valor or "").strip().lower() + return "".join(ch for ch in texto if ch.isalnum() or ch == "_") + + def _campos_por_label(self, dados): + campos = {} + for campo in dados.get("dados", []) or []: + if isinstance(campo, dict) and campo.get("label"): + campos[self._canon(campo.get("label"))] = campo + return campos + + def _campo_atual(self, dados, label, padrao=0): + campos = self._campos_por_label(dados) + campo = campos.get(self._canon(label)) + if not campo: + return padrao + return campo.get("atual", padrao) + + @staticmethod + def _get(dados, key, padrao=None): + if isinstance(dados, dict) and key in dados: + return dados.get(key) + return padrao + + def _valor_sensor_por_label(self, sensores, sensor_label, campo_label): + item = self._achar_por_label(sensores, sensor_label) + if not item: + return 0.0 + return self._float(self._campo_atual(item["dados"], campo_label, 0), 0) + + @staticmethod + def _achar_por_label(lista, label): + for item in lista or []: + dados = item.get("dados", {}) if isinstance(item, dict) else {} + if dados.get("label") == label: + return item + return None + + def _bomba_principal_ligada(self, ctx): + bomba = ctx.get("bomba_principal") + if not bomba: + return False + + dados = bomba.get("dados", {}) + estado = self._get(dados, "estado_leitura", self._campo_atual(dados, "EstadoLeitura", 0)) + potencia = self._float(self._get(dados, "potencia", self._campo_atual(dados, "Potencia", 0)), 0) + comando = self._bool(dados.get("comando_atuar", False)) + + return self._estado_ligado(estado) or potencia > self.POTENCIA_MIN_BOMBA_LIGADA or comando + + def _obter_ultima_resposta_ms(self, dados): + if not isinstance(dados, dict): + return 999999.0 + + if dados.get("ultima_resposta_ms") is not None: + return self._float(dados.get("ultima_resposta_ms"), 999999) + + idade_s = self._idade_modulo_s(dados) + + if idade_s is not None: + return max(0.0, idade_s * 1000.0) + + return 999999.0 + + def _idade_modulo_s(self, dados): + ts = self._normalizar_timestamp_segundos(dados.get("timestamp")) + + if ts <= 0: + return None + + dt = time.time() - ts + + if -5.0 < dt < 0: + return 0.0 + + if dt < -5.0: + return None + + return dt + + @staticmethod + def _normalizar_timestamp_segundos(valor): + try: + ts = float(valor or 0.0) + except Exception: + return 0.0 + + if ts <= 0: + return 0.0 + + if ts > 1_000_000_000_000: + return ts / 1000.0 + + if ts > 1_000_000_000: + return ts + + return 0.0 + + @staticmethod + def _estado_ligado(valor): + if valor is None: + return False + + if hasattr(valor, "value"): + valor = valor.value + + if isinstance(valor, bool): + return valor + + if isinstance(valor, (int, float)): + return int(valor) == 1 + + texto = str(valor).strip().lower() + return texto in ["1", "ligado", "true", "on", "sim", "yes"] + + def _estado_desejado_ligado(self, dados): + if not isinstance(dados, dict): + return False + + for key in [ + "estado_desejado", + "status_desejado", + "status_desejado_fisico", + "ultimo_status_desejado", + ]: + if key in dados: + return self._estado_ligado(dados.get(key)) + + return self._bool(dados.get("comando_atuar", False)) + + + @staticmethod + def _bool(valor, default=False): + try: + if isinstance(valor, bool): + return valor + + if valor is None: + return default + + if hasattr(valor, "value"): + valor = valor.value + + if isinstance(valor, (int, float)): + return valor != 0 + + texto = str(valor).strip().lower() + + if texto in ["true", "1", "sim", "yes", "y", "on", "ligado"]: + return True + + if texto in ["false", "0", "nao", "não", "no", "n", "off", "desligado", ""]: + return False + + return bool(valor) + + except Exception: + return default + + @staticmethod + def _float(valor, padrao=0.0): + try: + if valor is None: + return float(padrao) + + if hasattr(valor, "value"): + valor = valor.value + + return float(valor) + except Exception: + return float(padrao) + + @staticmethod + def _safe_int(valor, padrao=0): + try: + if valor is None: + return int(padrao) + + if hasattr(valor, "value"): + valor = valor.value + + return int(valor) + except Exception: + return int(padrao) + + @staticmethod + def _dedupe(lista): + saida = [] + vistos = set() + + for item in lista or []: + texto = str(item) + + if texto in vistos: + continue + + vistos.add(texto) + saida.append(item) + + return saida diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/direcional.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/direcional.py index 0682a9567..9b6c946b9 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/direcional.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/direcional.py @@ -3,126 +3,812 @@ from shared.utils import penalizar_frequencia from shared.contexto_global_redis import ContextoGlobalRedis from health_worker.modulos.base import ModuloDiagnosticoBase + class ModuloDirecional(ModuloDiagnosticoBase): + """ + Health inteligente do módulo direcional/MKS. + + Nova filosofia: + - A frequência dos parâmetros é diagnóstico de telemetria, não juiz principal. + - O que mais importa é: driver vivo, leitura útil recente e comando sendo obedecido. + - Condição operacional com severidade > 90 só aparece quando existe risco real + para a operação, porque isso pode bloquear a operação liberada. + """ + + SAUDE_MIN_ALERTA = 80 + + RESPOSTA_RECENTE_MS = 3000.0 + RESPOSTA_LENTA_MS = 1800.0 + + TOLERANCIA_ANGULO_PADRAO_GRAUS = 2.0 + COMANDO_GRACE_MS = 900.0 + COMANDO_ALERTA_MS = 3000.0 + COMANDO_FALHA_MS = 6500.0 + ERRO_ALERTA_GRAUS = 5.0 + ERRO_FALHA_GRAUS = 10.0 + + # Penalidade de telemetria deve ser pequena. + # Frequência baixa precisa aparecer no motivo, mas não derrubar módulo obediente. + PENALIDADE_FREQ_MAX_PADRAO = 4 + PENALIDADE_FREQ_MAX_CRITICA = 8 + def __init__(self): self.t_code = T_Code.Dir self.nome = "Direcional" - self.timeout = 3 # segundos + self.timeout = 3 def atualizar_saude(self): try: - saude_total = 0 - total_drivers_em_uso = 0 - motivos_dos_ativos = [] + dados_dir = ContextoGlobalRedis.get_modulo(self.t_code) or {} + ativos = self._normalizar_ativos(dados_dir.get("ativos", [])) + saude_individual = [] + updates_drivers = {} - SAUDE_MIN_ALERTA = 80 - mod_conectado = False + motivos_gerais = [] + condicoes_gerais = [] + saude_total_ativos = 0.0 + total_drivers_em_uso = 0 + algum_conectado = False - dados_dir = ContextoGlobalRedis.get_modulo(self.t_code) + if not ativos: + saude_geral = self._montar_saude_geral( + conectado=False, + status=StatusModulo.DESCONECTADO, + saude=0, + motivos=["Nenhum driver direcional ativo publicado no Redis"], + saude_individual=[], + condicoes_operacionais=[], + ) + self._publicar_saude(saude_geral, {}) + return - ativos = dados_dir.get("ativos", []) for endereco in ativos: - endereco = str(endereco) # Redis costuma serializar chave como string - modulo = dados_dir.get(endereco) + endereco_str = str(endereco) + modulo = self._as_dict( + dados_dir.get(endereco_str) or dados_dir.get(endereco) + ) + if not modulo: - continue # ativo listado, mas sem dados (race condition) + saude_mod = self._driver_sem_payload(endereco_str) + saude_individual.append(saude_mod) + updates_drivers[f"{endereco_str}.saude"] = saude_mod + continue + + saude_mod = self._avaliar_driver(endereco_str, modulo) - #for endereco_str, modulo in dados_dir.items(): - # try: - # endereco = int(endereco_str) - # except ValueError: - # continue - mod_id = modulo.get("mod_id") - #print(f"atualizando saude do modulo {mod_id} com endereco {endereco}") - FREQ_BASE = modulo.get("freq_base", 1) - FREQ_MIN = FREQ_BASE * 0.5 - saude = 100 - motivos = [] - condicoes_operacionais = [] - conectado = modulo.get("conectado", False) - comandar = modulo.get("comandar", True) - freqs = modulo.get("freq", {}) - maior_freq = max(freqs.values() if freqs != {} else [0]) - if not conectado: - saude = 0 - motivos.append("Desconectado") - elif maior_freq == 0: - saude = 0 - motivos.append("Sem resposta de todos os parametros (freq = 0 Hz)") - else: - for label, freq in freqs.items(): - pen = penalizar_frequencia(freq, FREQ_BASE, FREQ_MIN, 10) - saude -= pen - if freq < FREQ_MIN: - motivos.append(f"Frequencia {label} baixa ({freq:.2f}/{FREQ_BASE} Hz, penalidade {pen}%)") - referenciado = modulo.get("referenciado", False) - angulo = modulo.get("angulo", 0.0) - angulo_sp = modulo.get("angulo_sp", 0.0) - sentido = modulo.get("sentido", Sentido.PARADO.value) - sentido_real = modulo.get("sentido_real", Sentido.PARADO.value) - if referenciado is False: - condicoes_operacionais.append({ - "valor": 0, - "severidade": 50, - "descricao": "Driver nunca foi referenciado" - }) - if angulo_sp != angulo: - erro_angulo = abs(angulo - angulo_sp) - condicoes_operacionais.append({ - "valor": angulo, - "severidade": erro_angulo, - "descricao": f"Angulo incoerente (Atual: {angulo}°, SP: {angulo_sp}°)" - }) - if sentido != Sentido.PARADO.value and sentido != sentido_real: - condicoes_operacionais.append({ - "valor": angulo, - "severidade": (10 + angulo), - "descricao": f"Direção incoerente (real: {Sentido(sentido_real).name}, esperada: {Sentido(sentido).name}, ângulo: {angulo:.2f}°)" - }) - saude = max(saude, 0) - saude_mod = { - "id": endereco, - "label": mod_id, - "status": (StatusModulo.DESCONECTADO if not conectado else StatusModulo.OPERANTE if saude >= SAUDE_MIN_ALERTA else StatusModulo.ALERTA if saude > 0 else StatusModulo.FALHA).value, - "saude": saude, - "motivos": motivos, - "condicoes_operacionais": condicoes_operacionais, - "em_uso": comandar - } saude_individual.append(saude_mod) - # Se o driver está sendo usado, entra na média geral - if comandar is True: - saude_total += saude - total_drivers_em_uso += 1 - motivos_dos_ativos.extend([f"Driver {endereco}: {m}" for m in motivos]) - modulo["saude"] = saude_mod + updates_drivers[f"{endereco_str}.saude"] = saude_mod + + conectado = self._bool(modulo.get("conectado", False)) + em_uso = self._bool(saude_mod.get("em_uso", True)) + if conectado: - mod_conectado = True + algum_conectado = True - saude_individual.sort(key=lambda x: x["id"]) + if em_uso: + saude_total_ativos += self._float(saude_mod.get("saude", 0), 0) + total_drivers_em_uso += 1 - saude_final = (saude_total // total_drivers_em_uso) if total_drivers_em_uso > 0 else 0 + for motivo in saude_mod.get("motivos", []) or []: + motivos_gerais.append(f"Driver {endereco_str}: {motivo}") - status = StatusModulo.OPERANTE - if not mod_conectado: - status = StatusModulo.DESCONECTADO - elif saude_final <= 0: - status = StatusModulo.FALHA - elif saude_final < SAUDE_MIN_ALERTA or any(saude["saude"] < SAUDE_MIN_ALERTA for saude in saude_individual): - status = StatusModulo.ALERTA + for cond in saude_mod.get("condicoes_operacionais", []) or []: + condicoes_gerais.append({ + **cond, + "driver": endereco_str, + "label": saude_mod.get("label"), + }) - saude_geral = { - "conectado": mod_conectado, - "status": status.value, - "saude": saude_final, - "motivos": motivos_dos_ativos, - "saude_individual": saude_individual - } + saude_individual.sort(key=lambda x: self._safe_int(x.get("id"), 0)) - ContextoGlobalRedis.atualizar_ctx_dict( - ContextoGlobalRedis.ModKey(self.t_code), - saude=saude_geral + if total_drivers_em_uso > 0: + saude_final = int(round(saude_total_ativos / total_drivers_em_uso)) + else: + saude_final = 0 + if algum_conectado: + motivos_gerais.append("Nenhum driver direcional marcado como em uso") + + status_geral = self._resolver_status_geral( + conectado=algum_conectado, + saude_final=saude_final, + saude_individual=saude_individual, + total_drivers_em_uso=total_drivers_em_uso, ) + + saude_geral = self._montar_saude_geral( + conectado=algum_conectado, + status=status_geral, + saude=saude_final, + motivos=self._dedupe(motivos_gerais), + saude_individual=saude_individual, + condicoes_operacionais=condicoes_gerais, + ) + + self._publicar_saude(saude_geral, updates_drivers) + except Exception as e: print(f"Erro ao atualizar saude do modulo {self.t_code.name}: {e}") + try: + self._publicar_saude( + { + "conectado": False, + "status": StatusModulo.FALHA.value, + "saude": 0, + "motivos": [f"Erro interno no health do direcional: {e}"], + "saude_individual": [], + "condicoes_operacionais": [], + }, + {}, + ) + except Exception: + pass + + # ============================================================ + # Avaliação por driver + # ============================================================ + + def _avaliar_driver(self, endereco_str, modulo): + mod_id = modulo.get("mod_id") + conectado = self._bool(modulo.get("conectado", False)) + comandar = self._bool(modulo.get("comandar", True)) + referenciando = self._bool(modulo.get("referenciando", False)) + + motivos = [] + condicoes = [] + + if not conectado: + return { + "id": endereco_str, + "label": mod_id, + "status": StatusModulo.DESCONECTADO.value, + "saude": 0, + "motivos": ["Desconectado"], + "condicoes_operacionais": [], + "em_uso": comandar, + "componentes": { + "conectividade": 0, + "feedback": 0, + "seguimento_comando": 0, + "telemetria": 0, + "coerencia": 0, + }, + "parametros": {}, + "debug": self._snapshot_debug(modulo), + } + + leitura = self._avaliar_resposta_recente(modulo, motivos, condicoes) + feedback = self._avaliar_feedback(modulo, motivos, condicoes, referenciando) + comando = self._avaliar_seguimento_comando(modulo, motivos, condicoes) + coerencia = self._avaliar_coerencia_operacional(modulo, motivos, condicoes) + telemetria = self._avaliar_telemetria(modulo, motivos, referenciando) + + componentes = { + "conectividade": leitura["score"], + "feedback": feedback["score"], + "seguimento_comando": comando["score"], + "coerencia": coerencia["score"], + "telemetria": telemetria["score"], + } + + # Pesos intencionais: + # - A telemetria pesa pouco. + # - Se o driver está vivo e obedecendo, frequência baixa vira motivo/alerta local, + # mas não derruba o módulo inteiro. + saude = ( + componentes["conectividade"] * 0.25 + + componentes["feedback"] * 0.25 + + componentes["seguimento_comando"] * 0.35 + + componentes["coerencia"] * 0.10 + + componentes["telemetria"] * 0.05 + ) + + # Travas de realidade: alguns casos não podem ser mascarados por média ponderada. + if leitura["sem_resposta"]: + saude = min(saude, 20) + + if feedback["sem_pulsos"]: + saude = min(saude, 35) + + if comando["falha_seguimento"]: + saude = min(saude, 45) + + if coerencia["falha_coerencia"]: + saude = min(saude, 65) + + saude = int(max(0, min(100, round(saude)))) + + status = self._resolver_status_driver( + conectado=conectado, + saude=saude, + condicoes_operacionais=condicoes, + ) + + return { + "id": endereco_str, + "label": mod_id, + "status": status.value, + "saude": saude, + "motivos": self._dedupe(motivos), + "condicoes_operacionais": condicoes, + "em_uso": comandar, + "referenciando": referenciando, + "componentes": componentes, + "parametros": telemetria["parametros"], + "debug": self._snapshot_debug(modulo), + } + + def _driver_sem_payload(self, endereco_str): + return { + "id": endereco_str, + "label": None, + "status": StatusModulo.DESCONECTADO.value, + "saude": 0, + "motivos": ["Driver ativo listado, mas sem payload no Redis"], + "condicoes_operacionais": [], + "em_uso": False, + "componentes": { + "conectividade": 0, + "feedback": 0, + "seguimento_comando": 0, + "telemetria": 0, + "coerencia": 0, + }, + "parametros": {}, + "debug": {}, + } + + # ============================================================ + # Componentes de saúde + # ============================================================ + + def _avaliar_resposta_recente(self, modulo, motivos, condicoes): + ultima_resposta_ms = self._float(modulo.get("ultima_resposta_ms", 999999), 999999) + + if ultima_resposta_ms > self.RESPOSTA_RECENTE_MS: + motivos.append( + f"Sem resposta recente do MKS ({ultima_resposta_ms:.0f} ms)" + ) + condicoes.append({ + "valor": ultima_resposta_ms, + "severidade": 95, + "descricao": f"Driver direcional sem resposta recente ({ultima_resposta_ms:.0f} ms)", + }) + return { + "score": 0, + "sem_resposta": True, + } + + if ultima_resposta_ms > self.RESPOSTA_LENTA_MS: + motivos.append( + f"Resposta CAN lenta ({ultima_resposta_ms:.0f} ms)" + ) + return { + "score": 75, + "sem_resposta": False, + } + + return { + "score": 100, + "sem_resposta": False, + } + + def _avaliar_feedback(self, modulo, motivos, condicoes, referenciando): + freqs = self._normalizar_dict_chaves(modulo.get("freq", {})) + freq_pulsos = self._float(freqs.get("pulsosrecebidos", 0.0), 0.0) + freq_io = self._float(freqs.get("io", 0.0), 0.0) + + angulo = self._float(modulo.get("angulo", 0.0), 0.0) + angulo_sp = self._float(modulo.get("angulo_sp", 0.0), 0.0) + + # Pulsos é o feedback central do MKS. + if freq_pulsos <= 0: + motivos.append("Sem feedback de PulsosRecebidos") + condicoes.append({ + "valor": freq_pulsos, + "severidade": 92, + "descricao": "Sem leitura de PulsosRecebidos, não há feedback confiável de ângulo", + }) + return { + "score": 0, + "sem_pulsos": True, + } + + score = 100 + + # IO é crítico durante referenciamento, mas só auxiliar na operação. + if referenciando and freq_io <= 0: + motivos.append("Referenciando sem leitura de IO") + condicoes.append({ + "valor": freq_io, + "severidade": 91, + "descricao": "Referenciamento sem leitura de IO do sensor do rasgo", + }) + score = min(score, 30) + elif freq_io <= 0: + motivos.append("Sem leitura de IO; sentido real pode ficar desatualizado") + score = min(score, 85) + + return { + "score": score, + "sem_pulsos": False, + "angulo": angulo, + "angulo_sp": angulo_sp, + } + + def _avaliar_seguimento_comando(self, modulo, motivos, condicoes): + comandar = self._bool(modulo.get("comandar", True)) + atingiu = self._bool(modulo.get("atingiu_angulo_sp", False)) + retornando_a_zero = self._bool(modulo.get("retornando_a_zero", False)) + + erro_angulo = self._float( + modulo.get( + "erro_angulo", + abs( + self._float(modulo.get("angulo", 0.0), 0.0) - + self._float(modulo.get("angulo_sp", 0.0), 0.0) + ) + ), + 0.0, + ) + + ultimo_comando_ms = self._float(modulo.get("ultimo_comando_ms", 999999), 999999) + ultima_direcao = self._safe_int( + modulo.get("ultima_direcao", self._direcao_parada_value()), + self._direcao_parada_value(), + ) + + if not comandar: + # Driver conectado, mas não participa do comando atual. + return { + "score": 100, + "falha_seguimento": False, + } + + if atingiu: + return { + "score": 100, + "falha_seguimento": False, + } + + if ultimo_comando_ms < self.COMANDO_GRACE_MS: + return { + "score": 96, + "falha_seguimento": False, + } + + if retornando_a_zero and erro_angulo <= self.ERRO_ALERTA_GRAUS: + motivos.append( + f"Retornando a zero com erro residual ({erro_angulo:.2f}°)" + ) + return { + "score": 88, + "falha_seguimento": False, + } + + # Aqui começa a diferença entre telemetria lenta e comando que não pegou. + if ultimo_comando_ms >= self.COMANDO_FALHA_MS and erro_angulo >= self.ERRO_FALHA_GRAUS: + motivos.append( + f"Comando direcional não convergiu " + f"(erro={erro_angulo:.2f}°, comando há {ultimo_comando_ms:.0f} ms)" + ) + condicoes.append({ + "valor": erro_angulo, + "severidade": 92, + "descricao": ( + "Driver direcional não acompanhou o comando " + f"(erro={erro_angulo:.2f}°, comando há {ultimo_comando_ms:.0f} ms)" + ), + }) + return { + "score": 20, + "falha_seguimento": True, + } + + if ultimo_comando_ms >= self.COMANDO_ALERTA_MS and erro_angulo >= self.ERRO_ALERTA_GRAUS: + motivos.append( + f"Ângulo ainda distante do SP " + f"(erro={erro_angulo:.2f}°, comando há {ultimo_comando_ms:.0f} ms)" + ) + condicoes.append({ + "valor": erro_angulo, + "severidade": min(89, max(40, erro_angulo * 5)), + "descricao": ( + "Ângulo direcional distante do SP, mas ainda dentro de janela de alerta " + f"(erro={erro_angulo:.2f}°, comando há {ultimo_comando_ms:.0f} ms)" + ), + }) + return { + "score": 70, + "falha_seguimento": False, + } + + if erro_angulo > self.TOLERANCIA_ANGULO_PADRAO_GRAUS: + motivos.append( + f"Ângulo fora da tolerância momentaneamente (erro={erro_angulo:.2f}°)" + ) + return { + "score": 90, + "falha_seguimento": False, + } + + return { + "score": 100, + "falha_seguimento": False, + } + + def _avaliar_coerencia_operacional(self, modulo, motivos, condicoes): + referenciado = self._bool(modulo.get("referenciado", False)) + referenciando = self._bool(modulo.get("referenciando", False)) + + sentido = self._safe_int( + modulo.get("sentido", self._sentido_parado_value()), + self._sentido_parado_value(), + ) + sentido_real = self._safe_int( + modulo.get("sentido_real", self._sentido_parado_value()), + self._sentido_parado_value(), + ) + sentido_sp = self._safe_int( + modulo.get("sentido_sp", self._sentido_parado_value()), + self._sentido_parado_value(), + ) + + score = 100 + falha_coerencia = False + + if not referenciado and not referenciando: + motivos.append("Driver ainda não foi referenciado") + condicoes.append({ + "valor": 0, + "severidade": 50, + "descricao": "Driver direcional ainda não foi referenciado", + }) + score = min(score, 75) + + # Sentido real vem do sensor do rasgo. + # Como esse sensor pode ter borda/transição, não vamos bloquear operação por isso sozinho. + if sentido != self._sentido_parado_value() and sentido != sentido_real: + motivos.append( + f"Sentido real incoerente " + f"(real={self._nome_sentido(sentido_real)}, esperado={self._nome_sentido(sentido)})" + ) + condicoes.append({ + "valor": sentido_real, + "severidade": 70, + "descricao": ( + "Sentido real difere do sentido calculado " + f"(real={self._nome_sentido(sentido_real)}, " + f"esperado={self._nome_sentido(sentido)}, " + f"SP={self._nome_sentido(sentido_sp)})" + ), + }) + score = min(score, 70) + + return { + "score": score, + "falha_coerencia": falha_coerencia, + } + + def _avaliar_telemetria(self, modulo, motivos, referenciando): + parametros = self._resolver_parametros_monitorados(modulo) + parametros_debug = {} + + if not parametros: + motivos.append("Contrato de telemetria sem parâmetros em uso") + return { + "score": 70, + "parametros": {}, + } + + scores = [] + + for parametro in parametros: + avaliacao = self._avaliar_frequencia_parametro(modulo, parametro, referenciando) + parametros_debug[parametro] = avaliacao + + freq = avaliacao["freq"] + freq_min = avaliacao["freq_min"] + freq_base = avaliacao["freq_base"] + score_param = avaliacao["score"] + + scores.append(score_param) + + if freq <= 0: + motivos.append(f"Telemetria {parametro} ausente (0 Hz)") + elif freq < freq_min: + motivos.append( + f"Telemetria {parametro} baixa " + f"({freq:.2f}/{freq_base:.2f} Hz, min={freq_min:.2f} Hz)" + ) + + score = sum(scores) / len(scores) if scores else 70 + + return { + "score": int(max(0, min(100, round(score)))), + "parametros": parametros_debug, + } + + # ============================================================ + # Frequência / contrato + # ============================================================ + + def _resolver_parametros_monitorados(self, modulo): + freq_uso = self._normalizar_dict_chaves(modulo.get("freq_uso", {})) + polling = self._as_dict(modulo.get("polling", {})) + + parametros = [ + parametro + for parametro, em_uso in freq_uso.items() + if self._bool(em_uso) + ] + + if parametros: + return sorted(parametros) + + ordem = polling.get("ordem", []) + if isinstance(ordem, list) and ordem: + return sorted([ + self._canon(p) + for p in ordem + if str(p).strip() + ]) + + freqs = self._normalizar_dict_chaves(modulo.get("freq", {})) + return sorted(freqs.keys()) + + def _avaliar_frequencia_parametro(self, modulo, parametro, referenciando): + freqs = self._normalizar_dict_chaves(modulo.get("freq", {})) + freq_base = self._normalizar_dict_chaves(modulo.get("freq_base", {})) + freq_min = self._normalizar_dict_chaves(modulo.get("freq_min", {})) + polling_ms = self._normalizar_dict_chaves(modulo.get("polling_ms", {})) + + freq = self._float(freqs.get(parametro, 0.0), 0.0) + base = self._float(freq_base.get(parametro, 0.0), 0.0) + minimo = self._float(freq_min.get(parametro, 0.0), 0.0) + ms = self._float(polling_ms.get(parametro, 0.0), 0.0) + + if base <= 0 and ms > 0: + # Fallback. O contrato novo deveria publicar freq_base. + base = 1000.0 / ms + + if base <= 0: + base = 1.0 + + if minimo <= 0: + minimo = base * 0.40 + + critico = parametro == "pulsosrecebidos" or (referenciando and parametro == "io") + penalidade_max = self.PENALIDADE_FREQ_MAX_CRITICA if critico else self.PENALIDADE_FREQ_MAX_PADRAO + + penalidade = float( + penalizar_frequencia(freq, base, minimo, penalidade_max) or 0 + ) + + # Transformado em score local da telemetria. Não derruba sozinho o driver. + score = max(0, min(100, 100 - (penalidade * 4))) + + if freq <= 0: + score = 0 if critico else 55 + + return { + "freq": freq, + "freq_base": base, + "freq_min": minimo, + "polling_ms": ms, + "critico": critico, + "penalidade": penalidade, + "score": score, + "ok": freq >= minimo, + } + + # ============================================================ + # Status + # ============================================================ + + def _resolver_status_driver(self, conectado, saude, condicoes_operacionais): + if not conectado: + return StatusModulo.DESCONECTADO + + if any(self._float(c.get("severidade", 0), 0) > 90 for c in condicoes_operacionais): + return StatusModulo.FALHA + + if saude <= 0: + return StatusModulo.FALHA + + if saude < self.SAUDE_MIN_ALERTA: + return StatusModulo.ALERTA + + return StatusModulo.OPERANTE + + def _resolver_status_geral(self, conectado, saude_final, saude_individual, total_drivers_em_uso): + if not conectado: + return StatusModulo.DESCONECTADO + + if total_drivers_em_uso <= 0: + return StatusModulo.ALERTA + + in_uso = [ + item for item in saude_individual + if self._bool(item.get("em_uso", False)) + ] + + if any(item.get("status") == StatusModulo.FALHA.value for item in in_uso): + return StatusModulo.FALHA + + if saude_final <= 0: + return StatusModulo.FALHA + + if any(item.get("status") == StatusModulo.ALERTA.value for item in in_uso): + return StatusModulo.ALERTA + + if saude_final < self.SAUDE_MIN_ALERTA: + return StatusModulo.ALERTA + + return StatusModulo.OPERANTE + + def _montar_saude_geral(self, conectado, status, saude, motivos, saude_individual, condicoes_operacionais): + return { + "conectado": conectado, + "status": status.value, + "saude": int(max(0, min(100, saude))), + "motivos": self._dedupe(motivos), + "saude_individual": saude_individual, + "condicoes_operacionais": condicoes_operacionais, + } + + def _publicar_saude(self, saude_geral, updates_drivers): + payload = {"saude": saude_geral} + payload.update(updates_drivers or {}) + + ContextoGlobalRedis.atualizar_ctx_dict( + ContextoGlobalRedis.ModKey(self.t_code), + **payload, + ) + + # ============================================================ + # Debug / helpers + # ============================================================ + + def _snapshot_debug(self, modulo): + return { + "angulo": self._float(modulo.get("angulo", 0.0), 0.0), + "angulo_sp": self._float(modulo.get("angulo_sp", 0.0), 0.0), + "erro_angulo": self._float(modulo.get("erro_angulo", 0.0), 0.0), + "atingiu_angulo_sp": self._bool(modulo.get("atingiu_angulo_sp", False)), + "ultimo_comando_ms": self._float(modulo.get("ultimo_comando_ms", 999999), 999999), + "ultima_resposta_ms": self._float(modulo.get("ultima_resposta_ms", 999999), 999999), + "ultima_direcao": modulo.get("ultima_direcao"), + "status_movimento": modulo.get("status_movimento"), + "rpm": self._float(modulo.get("rpm", 0.0), 0.0), + "em_movimento": self._bool(modulo.get("em_movimento", False)), + "retornando_a_zero": self._bool(modulo.get("retornando_a_zero", False)), + "referenciado": self._bool(modulo.get("referenciado", False)), + "referenciando": self._bool(modulo.get("referenciando", False)), + "sentido": modulo.get("sentido"), + "sentido_sp": modulo.get("sentido_sp"), + "sentido_real": modulo.get("sentido_real"), + } + + @staticmethod + def _normalizar_ativos(ativos): + if not isinstance(ativos, list): + return [] + + normalizados = [] + for item in ativos: + try: + normalizados.append(int(item)) + except Exception: + try: + normalizados.append(int(str(item).strip())) + except Exception: + pass + + return normalizados + + @staticmethod + def _as_dict(valor): + return valor if isinstance(valor, dict) else {} + + @staticmethod + def _canon(valor): + texto = str(valor or "").strip().lower() + return "".join(ch for ch in texto if ch.isalnum()) + + def _normalizar_dict_chaves(self, valor): + if not isinstance(valor, dict): + return {} + + return { + self._canon(k): v + for k, v in valor.items() + if str(k).strip() + } + + @staticmethod + def _bool(valor): + try: + if isinstance(valor, bool): + return valor + + if valor is None: + return False + + if isinstance(valor, (int, float)): + return valor != 0 + + texto = str(valor).strip().lower() + + if texto in ["true", "1", "sim", "yes", "y", "on"]: + return True + + if texto in ["false", "0", "nao", "não", "no", "n", "off", ""]: + return False + + return bool(valor) + + except Exception: + return False + + @staticmethod + def _float(valor, padrao=0.0): + try: + if valor is None: + return float(padrao) + + return float(valor) + except Exception: + return float(padrao) + + @staticmethod + def _safe_int(valor, padrao=0): + try: + if valor is None: + return int(padrao) + + return int(valor) + except Exception: + return int(padrao) + + @staticmethod + def _dedupe(lista): + saida = [] + vistos = set() + + for item in lista or []: + texto = str(item) + + if texto in vistos: + continue + + vistos.add(texto) + saida.append(item) + + return saida + + @staticmethod + def _enum_value(enum_cls, nomes, padrao=0): + for nome in nomes: + try: + if hasattr(enum_cls, nome): + return getattr(enum_cls, nome).value + except Exception: + pass + + return padrao + + def _sentido_parado_value(self): + return self._enum_value(Sentido, ["PARADO", "Parado"], 0) + + def _direcao_parada_value(self): + # Direcao não está importado neste módulo. + # Nos enums do C# normalmente Parado = 0. + return 0 + + @staticmethod + def _nome_sentido(valor): + try: + return Sentido(valor).name + except Exception: + return str(valor) diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/movimentacao.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/movimentacao.py index d068402d5..7bf1d5c98 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/movimentacao.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/movimentacao.py @@ -15,385 +15,499 @@ class ModuloMovimentacaoPadroes: class ModuloMovimentacao(ModuloDiagnosticoBase): + """ + Health inteligente do módulo de movimentação/OID. + + Filosofia: + - Frequência baixa é diagnóstico, não sentença. + - O driver pode continuar OPERANTE se estiver conectado, com resposta recente, + sem erro crítico e acompanhando comando. + - Condição operacional severidade > 90 só é usada quando há risco real: + sem resposta, erro crítico OID, freio com movimento, comando ativo sem resposta + prática do motor ou temperatura/tensão em faixa perigosa. + """ + + SAUDE_MIN_ALERTA = 80 + + RESPOSTA_RECENTE_MS = 3000.0 + RESPOSTA_LENTA_MS = 1800.0 + + COMANDO_GRACE_MS = 1200.0 + COMANDO_ALERTA_MS = 3500.0 + COMANDO_FALHA_MS = 7000.0 + + RPM_SP_MIN_AVALIAR = 25.0 + RPM_ERRO_ALERTA_PCT = 0.45 + RPM_ERRO_FALHA_PCT = 0.80 + RPM_ERRO_ABS_ALERTA = 45.0 + RPM_ERRO_ABS_FALHA = 90.0 + RPM_PARADO_TOL = 35.0 + RPM_FREIO_TOL = 30.0 + + PENALIDADE_FREQ_MAX_PADRAO = 4 + PENALIDADE_FREQ_MAX_CRITICA = 8 + def __init__(self): self.t_code = T_Code.Mov self.nome = "Movimentacao" self.timeout = 3 self.padroes = {} - # Fallback caso o C# ainda não envie freq_base/freq_min/freq_uso por parâmetro. - # As chaves aqui precisam bater com Redis: - # freq.rpm, freq.tensao, freq.corrente_motor, etc. self.freq_defaults = { - "rpm": { - "label": "rpm", - "base": 1.0, - "min": 0.45, - "peso": 1.25, - "em_uso": True, - }, - "corrente_motor": { - "label": "corrente_motor", - "base": 1.0, - "min": 0.45, - "peso": 1.15, - "em_uso": True, - }, - "corrente_barramento": { - "label": "corrente_barramento", - "base": 1.0, - "min": 0.45, - "peso": 1.15, - "em_uso": True, - }, - "tensao": { - "label": "tensao", - "base": 0.5, - "min": 0.20, - "peso": 0.85, - "em_uso": True, - }, - "temperatura": { - "label": "temperatura", - "base": 0.5, - "min": 0.20, - "peso": 0.85, - "em_uso": True, - }, - "erro": { - "label": "erro", - "base": 0.5, - "min": 0.20, - "peso": 0.80, - "em_uso": True, - }, - # Telemetrias úteis, mas não devem derrubar o módulo se ficarem lentas. - "ciclo_trabalho": { - "label": "ciclo_trabalho", - "base": 0.5, - "min": 0.20, - "peso": 0.35, - "em_uso": False, - }, - "potencia": { - "label": "potencia", - "base": 0.5, - "min": 0.20, - "peso": 0.35, - "em_uso": False, - }, - "posicao_absoluta": { - "label": "posicao_absoluta", - "base": 0.2, - "min": 0.08, - "peso": 0.20, - "em_uso": False, - }, - "io_status": { - "label": "io_status", - "base": 0.2, - "min": 0.08, - "peso": 0.20, - "em_uso": False, - }, + "rpm": {"base": 1.0, "min": 0.35, "em_uso": True, "critico": True}, + "erro": {"base": 0.5, "min": 0.20, "em_uso": True, "critico": True}, + "corrente_motor": {"base": 1.0, "min": 0.35, "em_uso": True, "critico": False}, + "corrente_barramento": {"base": 1.0, "min": 0.35, "em_uso": True, "critico": False}, + "tensao": {"base": 0.5, "min": 0.20, "em_uso": True, "critico": False}, + "temperatura": {"base": 0.5, "min": 0.20, "em_uso": True, "critico": False}, + "ciclo_trabalho": {"base": 0.5, "min": 0.20, "em_uso": True, "critico": False}, + "potencia": {"base": 0.5, "min": 0.20, "em_uso": True, "critico": False}, + "posicao_absoluta": {"base": 0.2, "min": 0.08, "em_uso": False, "critico": False}, + "io_status": {"base": 0.2, "min": 0.08, "em_uso": False, "critico": False}, } - self.oid_erros_penalidades = { - 0: 0, - 1: 100, # Sobretensão - 2: 80, # Subtensão - 3: 100, # Corrente máxima excedida - 4: 80, # Temperatura MOS - 5: 60, # Subtensão MCU - 6: 100, # Watchdog - 7: 60, # Erro SPI - 8: 100, # Flash danificado - 9: 30, - 10: 30, - 11: 30, - 12: 70, - 13: 100, - 14: 100, - 15: 10, - 16: 0, - 17: 0, - 18: 40, - 19: 20, + self.oid_erros = { + 0: ("NenhumErro", 0, False), + 1: ("Sobretensão", 100, True), + 2: ("Subtensão", 80, False), + 3: ("Corrente máxima excedida", 100, True), + 4: ("Temperatura MOS", 90, True), + 5: ("Subtensão MCU", 70, False), + 6: ("Watchdog", 100, True), + 7: ("Erro SPI", 80, False), + 8: ("Flash danificado", 100, True), + 9: ("Deslocamento sensor U", 40, False), + 10: ("Deslocamento sensor V", 40, False), + 11: ("Deslocamento sensor W", 40, False), + 12: ("Desequilíbrio trifásico", 75, False), + 13: ("Motor flash corrompido", 100, True), + 14: ("Aplicação flash corrompida", 100, True), + 15: ("Pulsação CANOPEN", 15, False), + 16: ("Parado", 0, False), + 17: ("Parar", 0, False), + 18: ("Fora de tolerância", 45, False), + 19: ("Sinal Z não encontrado", 25, False), } - def _status_por_score(self, score): - if score >= 80: - return StatusModulo.OPERANTE - if score > 0: - return StatusModulo.ALERTA - return StatusModulo.FALHA - - @staticmethod - def _to_float(valor, default=0.0): + def atualizar_saude(self): try: - if valor is None: - return default - return float(valor) - except Exception: - return default + dados_mov = ContextoGlobalRedis.get_modulo(self.t_code) or {} + ativos = self._listar_ativos(dados_mov) - @staticmethod - def _to_int(valor, default=0): - try: - if valor is None: - return default - return int(float(valor)) - except Exception: - return default + saude_individual = [] + updates_drivers = {} - @staticmethod - def _to_bool(valor, default=False): - if isinstance(valor, bool): - return valor - if valor is None: - return default - if isinstance(valor, (int, float)): - return valor != 0 - if isinstance(valor, str): - return valor.strip().lower() in ("1", "true", "sim", "yes", "on") - return default + motivos_gerais = [] + condicoes_gerais = [] + saude_total_ativos = 0.0 + total_drivers_em_uso = 0 + algum_conectado = False - @staticmethod - def _as_dict(valor): - return valor if isinstance(valor, dict) else {} + if not ativos: + saude_geral = self._montar_saude_geral( + conectado=False, + status=StatusModulo.DESCONECTADO, + saude=0, + motivos=["Nenhum driver de movimentação ativo publicado no Redis"], + saude_individual=[], + condicoes_operacionais=[], + detalhes={"drivers_ativos": [], "drivers_em_uso": 0}, + ) + self._publicar_saude(saude_geral, {}) + return - def _normalizar_timestamp_segundos(self, valor): - try: - ts = float(valor or 0.0) - except Exception: - return 0.0 + for endereco in ativos: + endereco_str = str(endereco) + modulo = self._as_dict( + dados_mov.get(endereco_str) or dados_mov.get(endereco) + ) - if ts <= 0: - return 0.0 + if not modulo: + saude_mod = self._driver_sem_payload(endereco_str) + saude_individual.append(saude_mod) + updates_drivers[f"{endereco_str}.saude"] = saude_mod + continue - # Unix ms - if ts > 1_000_000_000_000: - return ts / 1000.0 + saude_mod = self._avaliar_driver(endereco_str, modulo) - # Unix s - if ts > 1_000_000_000: - return ts + saude_individual.append(saude_mod) + updates_drivers[f"{endereco_str}.saude"] = saude_mod - # Valor relativo/perf_counter ou inválido para comparar com time.time. - return 0.0 + conectado = self._bool(modulo.get("conectado", False)) + em_uso = self._bool(saude_mod.get("em_uso", True)) - def _idade_modulo_s(self, modulo): - ts = self._normalizar_timestamp_segundos(modulo.get("timestamp")) - if ts <= 0: - return None + if conectado: + algum_conectado = True - dt = time.time() - ts + if em_uso: + saude_total_ativos += self._float(saude_mod.get("saude", 0), 0) + total_drivers_em_uso += 1 - # Pequena diferença de relógio não deve gerar alerta. - if -5.0 < dt < 0: - return 0.0 + for motivo in saude_mod.get("motivos", []) or []: + motivos_gerais.append(f"Driver {endereco_str}: {motivo}") - if dt < -5.0: - return None + for cond in saude_mod.get("condicoes_operacionais", []) or []: + condicoes_gerais.append({ + **cond, + "driver": endereco_str, + "label": saude_mod.get("label"), + }) - return dt + saude_individual.sort(key=lambda x: self._safe_int(x.get("id"), 0)) - def _listar_ativos(self, dados_mov): - ativos = dados_mov.get("ativos", []) + if total_drivers_em_uso > 0: + saude_final = int(round(saude_total_ativos / total_drivers_em_uso)) + else: + saude_final = 0 + if algum_conectado: + motivos_gerais.append("Nenhum driver MOV marcado como em uso") - if isinstance(ativos, list) and ativos: - return [str(x) for x in ativos] + status_geral = self._resolver_status_geral( + conectado=algum_conectado, + saude_final=saude_final, + saude_individual=saude_individual, + total_drivers_em_uso=total_drivers_em_uso, + ) - # Fallback legado: percorre chaves numéricas. - resultado = [] - for chave, valor in dados_mov.items(): - if isinstance(valor, dict): - try: - int(chave) - resultado.append(str(chave)) - except Exception: - pass + saude_geral = self._montar_saude_geral( + conectado=algum_conectado, + status=status_geral, + saude=saude_final, + motivos=self._dedupe(motivos_gerais), + saude_individual=saude_individual, + condicoes_operacionais=condicoes_gerais, + detalhes={ + "drivers_ativos": ativos, + "drivers_em_uso": total_drivers_em_uso, + "modelo": "movimentacao_inteligente_por_resposta_comando_valores_e_telemetria", + }, + ) - return resultado + self._publicar_saude(saude_geral, updates_drivers) - def _ler_freq_config(self, modulo, parametro): - freqs = self._as_dict(modulo.get("freq")) - freq_base_map = self._as_dict(modulo.get("freq_base")) - freq_min_map = self._as_dict(modulo.get("freq_min")) - freq_uso_map = self._as_dict(modulo.get("freq_uso")) + except Exception as e: + print(f"Erro ao atualizar saude do modulo {self.t_code.name}: {e}") + try: + self._publicar_saude( + { + "conectado": False, + "status": StatusModulo.FALHA.value, + "saude": 0, + "motivos": [f"Erro interno no health de movimentação: {e}"], + "saude_individual": [], + "condicoes_operacionais": [], + "detalhes": {}, + }, + {}, + ) + except Exception: + pass - default = self.freq_defaults.get( - parametro, - { - "label": parametro, - "base": 0.5, - "min": 0.2, - "peso": 0.3, - "em_uso": False, - }, + # ============================================================ + # Avaliação principal + # ============================================================ + + def _avaliar_driver(self, endereco_str, modulo): + mod_id = modulo.get("mod_id", endereco_str) + conectado = self._bool(modulo.get("conectado", False)) + comandar = self._bool(modulo.get("comandar", True)) + + motivos = [] + condicoes = [] + + if not conectado: + return { + "id": endereco_str, + "label": mod_id, + "status": StatusModulo.DESCONECTADO.value, + "saude": 0, + "motivos": ["Desconectado"], + "condicoes_operacionais": [], + "em_uso": comandar, + "componentes": self._componentes_zero(), + "parametros": {}, + "debug": self._snapshot_debug(modulo), + } + + conectividade = self._avaliar_resposta_recente(modulo, motivos, condicoes) + comando = self._avaliar_seguimento_comando(modulo, motivos, condicoes) + valores = self._avaliar_valores_eletricos(endereco_str, modulo, motivos, condicoes) + freio = self._avaliar_freio(modulo, motivos, condicoes) + telemetria = self._avaliar_telemetria(modulo, motivos) + + componentes = { + "conectividade": conectividade["score"], + "seguimento_comando": comando["score"], + "valores_eletricos": valores["score"], + "freio": freio["score"], + "telemetria": telemetria["score"], + } + + # Pesos de campo: + # - comando/controle e resposta recente mandam mais; + # - frequência pesa pouco; + # - valores elétricos entram forte porque podem danificar hardware. + saude = ( + componentes["conectividade"] * 0.25 + + componentes["seguimento_comando"] * 0.35 + + componentes["valores_eletricos"] * 0.25 + + componentes["freio"] * 0.10 + + componentes["telemetria"] * 0.05 ) - freq_real = self._to_float(freqs.get(parametro), 0.0) + # Travas para não deixar média mascarar problemas reais. + if conectividade["sem_resposta"]: + saude = min(saude, 20) - # Compatibilidade com campo antigo freq_base escalar. - freq_base_bruto = modulo.get("freq_base", None) - freq_base_legado = self._to_float(freq_base_bruto, default["base"]) + if comando["falha_seguimento"]: + saude = min(saude, 45) - if isinstance(freq_base_bruto, dict): - freq_base_legado = default["base"] + if valores["erro_critico"]: + saude = min(saude, 35) - base = self._to_float( - freq_base_map.get(parametro), - freq_base_legado, - ) + if valores["risco_eletrico_termico"]: + saude = min(saude, 55) - minimo = self._to_float( - freq_min_map.get(parametro), - default["min"], - ) + if freio["falha_freio"]: + saude = min(saude, 45) - if minimo <= 0 and base > 0: - minimo = base * 0.5 + saude = int(max(0, min(100, round(saude)))) - if base <= 0: - base = default["base"] - - if minimo > base: - minimo = base * 0.5 - - em_uso = self._to_bool( - freq_uso_map.get(parametro), - default["em_uso"], + status = self._resolver_status_driver( + conectado=conectado, + saude=saude, + condicoes_operacionais=condicoes, ) return { - "parametro": parametro, - "freq": freq_real, - "base": base, - "min": minimo, - "peso": default["peso"], - "em_uso": em_uso, - "existe_no_redis": parametro in freqs, + "id": endereco_str, + "label": mod_id, + "status": status.value, + "saude": saude, + "motivos": self._dedupe(motivos), + "condicoes_operacionais": condicoes, + "em_uso": comandar, + "componentes": componentes, + "parametros": telemetria["parametros"], + "detalhes_valores": valores["detalhes"], + "debug": self._snapshot_debug(modulo), + } + + def _driver_sem_payload(self, endereco_str): + return { + "id": endereco_str, + "label": None, + "status": StatusModulo.DESCONECTADO.value, + "saude": 0, + "motivos": ["Driver ativo listado, mas sem payload no Redis"], + "condicoes_operacionais": [], + "em_uso": False, + "componentes": self._componentes_zero(), + "parametros": {}, + "detalhes_valores": {}, + "debug": {}, } @staticmethod - def _score_frequencia(freq, base, minimo): - if base <= 0: - return 100.0 + def _componentes_zero(): + return { + "conectividade": 0, + "seguimento_comando": 0, + "valores_eletricos": 0, + "freio": 0, + "telemetria": 0, + } - if freq >= base: - return 100.0 + # ============================================================ + # Componentes + # ============================================================ - if minimo <= 0: - minimo = base * 0.5 + def _avaliar_resposta_recente(self, modulo, motivos, condicoes): + ultima_resposta_ms = self._obter_ultima_resposta_ms(modulo) - if freq <= 0: - return 0.0 - - if freq <= minimo: - return max(0.0, min(50.0, 50.0 * (freq / minimo))) - - faixa = base - minimo - if faixa <= 0: - return 50.0 - - return max(50.0, min(100.0, 50.0 + 50.0 * ((freq - minimo) / faixa))) - - def _avaliar_frequencias(self, modulo): - motivos = [] - detalhes = [] - soma = 0.0 - peso_total = 0.0 - algum_parametro_avaliado = False - algum_freq_viva = False - - # Avalia união entre defaults e frequências que vierem do Redis. - freqs = self._as_dict(modulo.get("freq")) - parametros = list(self.freq_defaults.keys()) - for p in freqs.keys(): - if p not in parametros: - parametros.append(p) - - for parametro in parametros: - cfg = self._ler_freq_config(modulo, parametro) - - # Se o C# disse explicitamente que não está em uso, ignora. - if not cfg["em_uso"]: - continue - - # Se não existe no Redis e não é um default crítico, ignora para não criar alerta falso. - if not cfg["existe_no_redis"] and parametro not in self.freq_defaults: - continue - - algum_parametro_avaliado = True - - freq = cfg["freq"] - base = cfg["base"] - minimo = cfg["min"] - peso = cfg["peso"] - - if freq > 0: - algum_freq_viva = True - - score = self._score_frequencia(freq, base, minimo) - - detalhes.append({ - "parametro": parametro, - "freq": round(freq, 3), - "freq_base": round(base, 3), - "freq_min": round(minimo, 3), - "score": round(score, 1), - "em_uso": cfg["em_uso"], + if ultima_resposta_ms > self.RESPOSTA_RECENTE_MS: + motivos.append(f"Sem resposta recente do OID ({ultima_resposta_ms:.0f} ms)") + condicoes.append({ + "label": "Resposta OID", + "valor": ultima_resposta_ms, + "severidade": 95, + "descricao": f"Driver de movimentação sem resposta recente ({ultima_resposta_ms:.0f} ms)", + "acoes": [ + "Verificar comunicação CAN com o OID.", + "Verificar alimentação do driver.", + "Conferir se o heartbeat e polling estão ativos.", + ], }) + return {"score": 0, "sem_resposta": True} - soma += score * peso - peso_total += peso + if ultima_resposta_ms > self.RESPOSTA_LENTA_MS: + motivos.append(f"Resposta OID lenta ({ultima_resposta_ms:.0f} ms)") + return {"score": 75, "sem_resposta": False} - if freq <= 0: - motivos.append( - f"Frequencia {parametro} ausente/zerada ({freq:.2f}/{base:.2f} Hz)." + return {"score": 100, "sem_resposta": False} + + def _avaliar_seguimento_comando(self, modulo, motivos, condicoes): + comandar = self._bool(modulo.get("comandar", True)) + rpm = abs(self._float(modulo.get("rpm", 0.0), 0.0)) + rpm_sp = abs(self._float(modulo.get("rpm_sp", 0.0), 0.0)) + + comando_ligado = self._bool( + modulo.get("comando_ligado", rpm_sp > self.RPM_SP_MIN_AVALIAR) + ) + leitura_ligado = self._bool( + modulo.get( + "leitura_ligado", + abs(self._float(modulo.get("corrente_motor", 0.0), 0.0)) > 0.2 or rpm > self.RPM_PARADO_TOL, + ) + ) + freado = self._bool(modulo.get("freado", False)) + freio_sp = self._bool(modulo.get("freio_sp", freado)) + alarme_comando = self._bool(modulo.get("alarme_comando", False)) + alarme_motor = self._bool(modulo.get("alarme_motor", False)) + ultimo_comando_ms = self._float(modulo.get("ultimo_comando_ms", 999999), 999999) + tem_idade_comando = ultimo_comando_ms < 900000 + + erro_abs = abs(rpm - rpm_sp) + erro_pct = erro_abs / max(abs(rpm_sp), 1.0) + + atingiu_rpm_sp = self._bool( + modulo.get( + "atingiu_rpm_sp", + ( + rpm_sp <= self.RPM_SP_MIN_AVALIAR and rpm <= self.RPM_PARADO_TOL + ) or ( + rpm_sp > self.RPM_SP_MIN_AVALIAR and + (erro_pct <= self.RPM_ERRO_ALERTA_PCT or erro_abs <= self.RPM_ERRO_ABS_ALERTA) ) - elif freq < minimo: + ) + ) + + if not comandar: + return {"score": 100, "falha_seguimento": False} + + if alarme_motor: + # O componente de valores elétricos vai tratar o código. + return {"score": 70, "falha_seguimento": False} + + if alarme_comando: + motivos.append("Alarme de comando MOV ativo: comando ligado, mas leitura não indica acionamento") + condicoes.append({ + "label": "Comando MOV", + "valor": rpm, + "severidade": 92, + "descricao": "Comando de movimento aparenta não estar sendo efetivado pelo driver", + "acoes": [ + "Conferir se o comando de velocidade chegou ao OID.", + "Verificar estado de freio, alarme do driver e alimentação.", + "Comparar RPM_SP, RPM atual e corrente do motor.", + ], + }) + return {"score": 20, "falha_seguimento": True} + + if freio_sp or freado: + # Freio é avaliado em _avaliar_freio. Aqui não cobra RPM_SP. + return {"score": 100, "falha_seguimento": False} + + if comando_ligado or rpm_sp > self.RPM_SP_MIN_AVALIAR: + if atingiu_rpm_sp or leitura_ligado: + # Mesmo que RPM não esteja perfeito, se há corrente/rotação, o comando está vivo. + if not atingiu_rpm_sp: + motivos.append( + f"RPM ainda fora do alvo, mas comando aparenta atuar " + f"(RPM={rpm:.0f}, SP={rpm_sp:.0f})" + ) + return {"score": 85, "falha_seguimento": False} + return {"score": 100, "falha_seguimento": False} + + if tem_idade_comando and ultimo_comando_ms < self.COMANDO_GRACE_MS: + return {"score": 96, "falha_seguimento": False} + + if tem_idade_comando and ultimo_comando_ms >= self.COMANDO_FALHA_MS: motivos.append( - f"Frequencia {parametro} baixa ({freq:.2f}/{base:.2f} Hz, mínimo {minimo:.2f} Hz)." + f"Comando de RPM não convergiu " + f"(RPM={rpm:.0f}, SP={rpm_sp:.0f}, comando há {ultimo_comando_ms:.0f} ms)" ) + condicoes.append({ + "label": "Seguimento de RPM", + "valor": rpm, + "severidade": 92, + "descricao": ( + f"OID não acompanhou comando de velocidade " + f"(RPM={rpm:.0f}, SP={rpm_sp:.0f}, comando há {ultimo_comando_ms:.0f} ms)" + ), + "acoes": [ + "Verificar se a roda está travada ou sem aderência.", + "Verificar alarme do driver e corrente do motor.", + "Conferir envio de comando de velocidade no C#.", + ], + }) + return {"score": 20, "falha_seguimento": True} - if not algum_parametro_avaliado: - return 0.0, ["Nenhuma frequência de telemetria MOV disponível."], detalhes, False + if tem_idade_comando and ultimo_comando_ms >= self.COMANDO_ALERTA_MS: + motivos.append( + f"RPM distante do SP " + f"(RPM={rpm:.0f}, SP={rpm_sp:.0f}, comando há {ultimo_comando_ms:.0f} ms)" + ) + condicoes.append({ + "label": "Seguimento de RPM", + "valor": rpm, + "severidade": min(89, max(45, int(erro_pct * 80))), + "descricao": ( + f"RPM distante do SP, mas ainda em janela de alerta " + f"(RPM={rpm:.0f}, SP={rpm_sp:.0f})" + ), + "acoes": [ + "Acompanhar se o erro de RPM persiste.", + "Verificar terreno, rampa, carga e compensação de curva.", + ], + }) + return {"score": 68, "falha_seguimento": False} - score_final = soma / peso_total if peso_total > 0 else 0.0 - return score_final, motivos, detalhes, algum_freq_viva + # Sem idade de comando publicada, não vamos inventar falha. + motivos.append( + f"Comando ativo sem confirmação plena de RPM " + f"(RPM={rpm:.0f}, SP={rpm_sp:.0f})" + ) + return {"score": 82, "falha_seguimento": False} - def _adicionar_padrao_se_valido(self, padrao, valor): - if valor is None: - return + # Comando parado: motor deveria estar baixo. + if rpm_sp <= self.RPM_SP_MIN_AVALIAR and rpm > self.RPM_PARADO_TOL: + if tem_idade_comando and ultimo_comando_ms >= self.COMANDO_FALHA_MS and rpm > self.RPM_ERRO_ABS_FALHA: + motivos.append( + f"RPM presente com comando parado (RPM={rpm:.0f})" + ) + condicoes.append({ + "label": "Parada MOV", + "valor": rpm, + "severidade": 91, + "descricao": f"Motor continua girando mesmo com comando parado (RPM={rpm:.0f})", + "acoes": [ + "Verificar se comando de parada/freio chegou ao OID.", + "Conferir leitura de RPM e estado físico da roda.", + ], + }) + return {"score": 25, "falha_seguimento": True} - try: - v = float(valor) - except Exception: - return + motivos.append(f"RPM residual com comando parado (RPM={rpm:.0f})") + return {"score": 86, "falha_seguimento": False} - # Evita que zeros de boot contaminem o padrão dinâmico. - if v == 0: - return + return {"score": 100, "falha_seguimento": False} - padrao.adicionar_valor(v) + def _avaliar_valores_eletricos(self, endereco, modulo, motivos, condicoes): + mod_id = modulo.get("mod_id", endereco) - def _avaliar_valores_operacionais(self, endereco, modulo): - condicoes = [] - motivos = [] - penalidade_total = 0.0 + cod_erro = self._int_enum(modulo.get("cod_erro", 0), 0) + tensao = self._float(modulo.get("tensao", 0.0), 0.0) + corrente_motor = self._float(modulo.get("corrente_motor", 0.0), 0.0) + corrente_barramento = self._float(modulo.get("corrente_barramento", 0.0), 0.0) + temperatura_motor = self._float(modulo.get("temperatura_motor", 0.0), 0.0) + temperatura_driver = self._float(modulo.get("temperatura_driver", 0.0), 0.0) + rpm = self._float(modulo.get("rpm", 0.0), 0.0) + rpm_sp = self._float(modulo.get("rpm_sp", 0.0), 0.0) - mod_id = modulo.get("mod_id") - rpm = self._to_float(modulo.get("rpm"), 0.0) - rpm_sp = self._to_float(modulo.get("rpm_sp"), 0.0) - cod_erro = self._to_int(modulo.get("cod_erro"), 0) - tensao = self._to_float(modulo.get("tensao"), 0.0) - corrente_motor = self._to_float(modulo.get("corrente_motor"), 0.0) - corrente_barramento = self._to_float(modulo.get("corrente_barramento"), 0.0) - temperatura_motor = self._to_float(modulo.get("temperatura_motor"), 0.0) - temperatura_driver = self._to_float(modulo.get("temperatura_driver"), 0.0) - freado = self._to_bool(modulo.get("freado"), False) + score = 100.0 + erro_critico = False + risco_eletrico_termico = False padroes = self.padroes.get(endereco) if not isinstance(padroes, ModuloMovimentacaoPadroes): @@ -406,308 +520,642 @@ class ModuloMovimentacao(ModuloDiagnosticoBase): self._adicionar_padrao_se_valido(padroes.padrao_temperatura_driver, temperatura_driver) self._adicionar_padrao_se_valido(padroes.padrao_temperatura_motor, temperatura_motor) - penalidade_erro = self.oid_erros_penalidades.get(cod_erro, 0) - if penalidade_erro > 0: - penalidade_total += min(100.0, penalidade_erro) - desc = f"Erro ativo no OID: código {cod_erro}." - motivos.append(desc) + nome_erro, severidade_erro, critico = self.oid_erros.get( + cod_erro, + (f"Erro desconhecido {cod_erro}", 60, False), + ) + + if severidade_erro > 0: + motivos.append(f"Erro ativo no OID: {nome_erro} ({cod_erro})") condicoes.append({ "label": "Erro OID", "valor": cod_erro, - "severidade": int(penalidade_erro), - "descricao": desc, + "severidade": int(severidade_erro), + "descricao": f"Erro ativo no driver OID: {nome_erro} ({cod_erro})", "acoes": [ "Verificar código de falha do driver OID.", "Reduzir comando de movimento se o erro persistir.", - "Inspecionar alimentação, cabos de fase e comunicação CAN." - ] + "Inspecionar alimentação, cabos de fase, freio e comunicação CAN.", + ], }) + score -= min(100, severidade_erro) + erro_critico = bool(critico) - # Erro de RPM só pesa quando existe SP significativo. - limite_sp_rpm = 30.0 - limite_erro_pct = 0.30 - - if abs(rpm_sp) > limite_sp_rpm: - erro_percentual_rpm = abs(rpm - rpm_sp) / max(abs(rpm_sp), 1.0) - if erro_percentual_rpm > limite_erro_pct: - penalidade = min(35, int(erro_percentual_rpm * 100)) - penalidade_total += penalidade - desc = ( - f"RPM divergente (SP: {rpm_sp:.0f}, atual: {rpm:.0f}, " - f"erro: {erro_percentual_rpm * 100:.1f}%)." - ) - motivos.append(desc) + # Faixas absolutas. Ajustar depois com logs reais. + if tensao > 0: + if tensao < 24 or tensao > 50: + motivos.append(f"Tensão MOV em faixa perigosa: {tensao:.1f} V") condicoes.append({ - "label": "Acompanhamento de RPM", - "valor": rpm, - "severidade": penalidade, - "descricao": desc, - "acoes": [ - "Verificar se há roda travando, baixa aderência ou limitação de corrente.", - "Conferir comando enviado ao OID e leitura de RPM.", - "Reduzir velocidade de operação se houver oscilação persistente." - ] + "label": "Tensão MOV", + "valor": tensao, + "severidade": 92, + "descricao": f"Tensão do driver em faixa perigosa ({tensao:.1f} V)", + "acoes": ["Verificar alimentação/bateria/conversor antes de seguir."], }) + score -= 70 + risco_eletrico_termico = True + elif tensao < 28 or tensao > 45: + motivos.append(f"Tensão MOV fora da faixa esperada: {tensao:.1f} V") + condicoes.append({ + "label": "Tensão MOV", + "valor": tensao, + "severidade": 45, + "descricao": f"Tensão do driver fora da faixa esperada ({tensao:.1f} V)", + "acoes": ["Acompanhar alimentação dos drivers de movimento."], + }) + score -= 20 - if freado and abs(rpm) > 30: - penalidade = min(35, int(abs(rpm) / 5)) - penalidade_total += penalidade - desc = f"Motor aparenta movimento com freio ativo (RPM atual: {rpm:.0f})." - motivos.append(desc) + if abs(corrente_motor) > 60: + motivos.append(f"Corrente do motor muito alta: {corrente_motor:.1f} A") condicoes.append({ - "label": "Freio / movimento", - "valor": rpm, - "severidade": penalidade, - "descricao": desc, - "acoes": [ - "Verificar estado real do freio.", - "Verificar comando de parada e retorno do driver.", - "Inspecionar encoder/RPM se a roda estiver parada fisicamente." - ] - }) - - # Limites absolutos simples, sem depender só do padrão dinâmico. - if tensao > 0 and (tensao < 28 or tensao > 45): - penalidade = 30 - penalidade_total += penalidade - desc = f"Tensão MOV fora da faixa esperada: {tensao:.1f} V." - motivos.append(desc) - condicoes.append({ - "label": "Tensão do driver", - "valor": tensao, - "severidade": penalidade, - "descricao": desc, - "acoes": ["Verificar alimentação dos drivers de movimento."] - }) - - if abs(corrente_motor) > 45: - penalidade = min(50, int(abs(corrente_motor) - 30)) - penalidade_total += penalidade - desc = f"Corrente do motor elevada: {corrente_motor:.1f} A." - motivos.append(desc) - condicoes.append({ - "label": "Corrente do motor", + "label": "Corrente motor", "valor": corrente_motor, - "severidade": penalidade, - "descricao": desc, + "severidade": 93, + "descricao": f"Corrente do motor em faixa perigosa ({corrente_motor:.1f} A)", + "acoes": [ + "Verificar roda travada, esforço mecânico ou curto.", + "Reduzir comando imediatamente se persistir.", + ], + }) + score -= 75 + risco_eletrico_termico = True + elif abs(corrente_motor) > 45: + motivos.append(f"Corrente do motor elevada: {corrente_motor:.1f} A") + condicoes.append({ + "label": "Corrente motor", + "valor": corrente_motor, + "severidade": 65, + "descricao": f"Corrente do motor elevada ({corrente_motor:.1f} A)", "acoes": [ "Verificar esforço mecânico, roda travada ou terreno pesado.", - "Reduzir aceleração/velocidade se o pico persistir." - ] + "Reduzir aceleração/velocidade se o pico persistir.", + ], }) + score -= 30 - if temperatura_driver > 70: - penalidade = min(60, int(temperatura_driver - 50)) - penalidade_total += penalidade - desc = f"Temperatura do driver elevada: {temperatura_driver:.1f} °C." - motivos.append(desc) + if abs(corrente_barramento) > 35: + motivos.append(f"Corrente de barramento muito alta: {corrente_barramento:.1f} A") condicoes.append({ - "label": "Temperatura do driver", - "valor": temperatura_driver, - "severidade": penalidade, - "descricao": desc, + "label": "Corrente barramento", + "valor": corrente_barramento, + "severidade": 90, + "descricao": f"Corrente de barramento muito alta ({corrente_barramento:.1f} A)", + "acoes": ["Verificar alimentação e esforço dos motores."], + }) + score -= 55 + risco_eletrico_termico = True + elif abs(corrente_barramento) > 25: + motivos.append(f"Corrente de barramento elevada: {corrente_barramento:.1f} A") + condicoes.append({ + "label": "Corrente barramento", + "valor": corrente_barramento, + "severidade": 55, + "descricao": f"Corrente de barramento elevada ({corrente_barramento:.1f} A)", + "acoes": ["Acompanhar consumo e esforço do conjunto."], + }) + score -= 20 + + temp_max = max(temperatura_motor, temperatura_driver) + + if temp_max > 85: + motivos.append(f"Temperatura MOV perigosa: {temp_max:.1f} °C") + condicoes.append({ + "label": "Temperatura MOV", + "valor": temp_max, + "severidade": 95, + "descricao": f"Temperatura em faixa perigosa ({temp_max:.1f} °C)", "acoes": [ "Reduzir carga dos motores.", - "Verificar ventilação do compartimento dos drivers." - ] + "Verificar ventilação do compartimento.", + "Pausar operação se persistir.", + ], }) + score -= 80 + risco_eletrico_termico = True + elif temp_max > 70: + motivos.append(f"Temperatura MOV elevada: {temp_max:.1f} °C") + condicoes.append({ + "label": "Temperatura MOV", + "valor": temp_max, + "severidade": 60, + "descricao": f"Temperatura elevada ({temp_max:.1f} °C)", + "acoes": [ + "Reduzir carga dos motores.", + "Verificar ventilação do compartimento dos drivers.", + ], + }) + score -= 25 - # Padrões dinâmicos entram como alerta leve/moderado, para não assustar amanhã. - checks_padrao = [ - ("tensão fora do padrão", tensao, padroes.padrao_tensao), - ("corrente do motor fora do padrão", corrente_motor, padroes.padrao_corrente_motor), - ("corrente no barramento fora do padrão", corrente_barramento, padroes.padrao_corrente_barramento), - ("temperatura do driver fora do padrão", temperatura_driver, padroes.padrao_temperatura_driver), - ] - - for label, valor, padrao in checks_padrao: - if valor == 0: - continue - - try: - if not padrao.esta_dentro_do_padrao(valor): - grau = min(25, int(padrao.calcular_quao_anormal(valor))) - if grau > 0: - penalidade_total += grau * 0.3 - condicoes.append({ - "label": "Padrão dinâmico MOV", - "valor": valor, - "severidade": grau, - "descricao": f"{label}: {valor:.1f}", - "acoes": [ - "Acompanhar se o desvio persiste.", - "Comparar com outros drivers e com o esforço mecânico no momento." - ] - }) - except Exception: - pass - - valor_score = max(0.0, 100.0 - penalidade_total) + # Padrões dinâmicos: úteis para diagnóstico, mas não travam operação. + self._avaliar_padroes_dinamicos( + padroes, + { + "tensão fora do padrão": tensao, + "corrente do motor fora do padrão": corrente_motor, + "corrente no barramento fora do padrão": corrente_barramento, + "temperatura do driver fora do padrão": temperatura_driver, + "temperatura do motor fora do padrão": temperatura_motor, + }, + condicoes, + ) detalhes = { "mod_id": mod_id, "rpm": rpm, "rpm_sp": rpm_sp, "cod_erro": cod_erro, + "erro_nome": nome_erro, "tensao": tensao, "corrente_motor": corrente_motor, "corrente_barramento": corrente_barramento, "temperatura_motor": temperatura_motor, "temperatura_driver": temperatura_driver, - "freado": freado, - "score_valores": round(valor_score, 1), - "penalidade_valores": round(penalidade_total, 1), + "score_valores": int(max(0, min(100, round(score)))), } - return valor_score, motivos, condicoes, detalhes + return { + "score": int(max(0, min(100, round(score)))), + "erro_critico": erro_critico, + "risco_eletrico_termico": risco_eletrico_termico, + "detalhes": detalhes, + } - def atualizar_saude(self): - try: - SAUDE_MIN_ALERTA = 80 + def _avaliar_freio(self, modulo, motivos, condicoes): + rpm = abs(self._float(modulo.get("rpm", 0.0), 0.0)) + freado = self._bool(modulo.get("freado", False)) + freio_sp = self._bool(modulo.get("freio_sp", freado)) + comando_ligado = self._bool(modulo.get("comando_ligado", False)) - dados_mov = ContextoGlobalRedis.get_modulo(self.t_code) or {} - - saude_total = 0.0 - total_drivers_em_uso = 0 - motivos_dos_ativos = [] - saude_individual = [] - mod_conectado = False - - ativos = self._listar_ativos(dados_mov) - - for endereco in ativos: - endereco = str(endereco) - modulo = dados_mov.get(endereco) - - if not isinstance(modulo, dict): - continue - - mod_id = modulo.get("mod_id", endereco) - conectado = self._to_bool(modulo.get("conectado"), False) - comandar = self._to_bool(modulo.get("comandar"), True) - - motivos = [] - condicoes_operacionais = [] - detalhes = {} - - if not conectado: - saude = 0.0 - motivos.append("Desconectado") - else: - idade = self._idade_modulo_s(modulo) - if idade is not None: - detalhes["idade_telemetria_s"] = round(idade, 3) - - score_freq, motivos_freq, detalhes_freq, alguma_freq_viva = \ - self._avaliar_frequencias(modulo) - - valor_score, motivos_valores, cond_valores, detalhes_valores = \ - self._avaliar_valores_operacionais(endereco, modulo) - - motivos.extend(motivos_freq) - motivos.extend(motivos_valores) - condicoes_operacionais.extend(cond_valores) - - detalhes["frequencias"] = detalhes_freq - detalhes.update(detalhes_valores) - - if not alguma_freq_viva: - saude = 0.0 - motivos.append("Sem resposta dos parâmetros de movimentação (freq = 0 Hz).") - else: - # Comunicação/frequência é o principal para health. - # Valores operacionais entram forte, mas sem transformar - # uma frequência levemente baixa em falha falsa. - saude = (0.65 * score_freq) + (0.35 * valor_score) - - if idade is not None and idade > self.timeout: - penalidade_idade = min(40.0, (idade - self.timeout) * 8.0) - saude -= penalidade_idade - motivos.append( - f"Telemetria MOV desatualizada ({idade:.1f}s sem atualização)." - ) - condicoes_operacionais.append({ - "label": "Telemetria MOV", - "valor": round(idade, 2), - "severidade": int(penalidade_idade), - "descricao": "Dados de movimentação desatualizados no Redis.", - "acoes": [ - "Verificar rotina C# que publica dados MOV no Redis.", - "Verificar comunicação CAN dos drivers OID.", - "Conferir carga do Redis/health worker." - ] - }) - - saude = max(0.0, min(100.0, round(saude, 1))) - - status_individual = ( - StatusModulo.DESCONECTADO - if not conectado - else self._status_por_score(saude) - ) - - saude_mod = { - "id": endereco, - "label": mod_id, - "status": status_individual.value, - "saude": saude, - "motivos": motivos, - "condicoes_operacionais": condicoes_operacionais, - "em_uso": comandar, - "detalhes": detalhes, - } - - saude_individual.append(saude_mod) - - if comandar: - saude_total += saude - total_drivers_em_uso += 1 - motivos_dos_ativos.extend( - [f"Driver {endereco}: {m}" for m in motivos] - ) - - modulo["saude"] = saude_mod - - if conectado: - mod_conectado = True - - saude_individual.sort(key=lambda x: str(x["id"])) - - if total_drivers_em_uso > 0: - saude_final = round(saude_total / total_drivers_em_uso, 1) - else: - saude_final = 0.0 - - status = StatusModulo.OPERANTE - - if not mod_conectado: - status = StatusModulo.DESCONECTADO - elif saude_final <= 0: - status = StatusModulo.FALHA - elif ( - saude_final < SAUDE_MIN_ALERTA or - any(item["saude"] < SAUDE_MIN_ALERTA and item.get("em_uso", True) - for item in saude_individual) - ): - status = StatusModulo.ALERTA - - saude_geral = { - "conectado": mod_conectado, - "status": status.value, - "saude": saude_final, - "motivos": motivos_dos_ativos, - "saude_individual": saude_individual, - "detalhes": { - "drivers_ativos": ativos, - "drivers_em_uso": total_drivers_em_uso, - "modelo_frequencia": "por_parametro", - } + if (freado or freio_sp) and rpm > self.RPM_FREIO_TOL: + severidade = 92 if rpm > 100 else 75 + motivos.append(f"Freio ativo com RPM presente ({rpm:.0f})") + condicoes.append({ + "label": "Freio MOV", + "valor": rpm, + "severidade": severidade, + "descricao": f"Motor aparenta girar com freio ativo (RPM={rpm:.0f})", + "acoes": [ + "Verificar comando de freio e leitura real do driver.", + "Verificar se a roda está girando fisicamente ou se é ruído de RPM.", + "Inspecionar servo/freio mecânico.", + ], + }) + return { + "score": 25 if severidade > 90 else 65, + "falha_freio": severidade > 90, } - ContextoGlobalRedis.atualizar_ctx_dict( - ContextoGlobalRedis.ModKey(self.t_code), - saude=saude_geral - ) + if freado and comando_ligado: + motivos.append("Freio ativo enquanto comando de movimento aparece ligado") + condicoes.append({ + "label": "Freio MOV", + "valor": 1, + "severidade": 70, + "descricao": "Freio ativo simultâneo a comando de movimento", + "acoes": ["Conferir lógica de Direcao.EmFreio e comando de velocidade."], + }) + return {"score": 65, "falha_freio": False} - except Exception as e: - print(f"Erro ao atualizar saude do modulo {self.t_code.name}: {e}") + return {"score": 100, "falha_freio": False} + + def _avaliar_telemetria(self, modulo, motivos): + parametros = self._resolver_parametros_monitorados(modulo) + + if not parametros: + motivos.append("Contrato de telemetria MOV sem parâmetros em uso") + return {"score": 70, "parametros": {}} + + parametros_debug = {} + scores = [] + + for parametro in parametros: + avaliacao = self._avaliar_frequencia_parametro(modulo, parametro) + parametros_debug[parametro] = avaliacao + scores.append(avaliacao["score"]) + + freq = avaliacao["freq"] + freq_min = avaliacao["freq_min"] + freq_base = avaliacao["freq_base"] + + if freq <= 0: + motivos.append(f"Telemetria {parametro} ausente (0 Hz)") + elif freq < freq_min: + motivos.append( + f"Telemetria {parametro} baixa " + f"({freq:.2f}/{freq_base:.2f} Hz, min={freq_min:.2f} Hz)" + ) + + score = sum(scores) / len(scores) if scores else 70 + + return { + "score": int(max(0, min(100, round(score)))), + "parametros": parametros_debug, + } + + # ============================================================ + # Frequência / contrato + # ============================================================ + + def _resolver_parametros_monitorados(self, modulo): + freq_uso = self._normalizar_dict_chaves(modulo.get("freq_uso", {})) + polling = self._as_dict(modulo.get("polling", {})) + + parametros = [ + parametro + for parametro, em_uso in freq_uso.items() + if self._bool(em_uso) + ] + + if parametros: + return sorted(parametros) + + ordem = polling.get("ordem", []) + if isinstance(ordem, list) and ordem: + return sorted([ + self._canon(p) + for p in ordem + if str(p).strip() + ]) + + freqs = self._normalizar_dict_chaves(modulo.get("freq", {})) + if freqs: + return sorted(freqs.keys()) + + return sorted([ + p for p, cfg in self.freq_defaults.items() + if cfg.get("em_uso", False) + ]) + + def _avaliar_frequencia_parametro(self, modulo, parametro): + freqs = self._normalizar_dict_chaves(modulo.get("freq", {})) + freq_base = self._normalizar_dict_chaves(modulo.get("freq_base", {})) + freq_min = self._normalizar_dict_chaves(modulo.get("freq_min", {})) + polling_ms = self._normalizar_dict_chaves(modulo.get("polling_ms", {})) + + cfg = self.freq_defaults.get( + parametro, + {"base": 0.5, "min": 0.2, "em_uso": False, "critico": False}, + ) + + freq = self._float(freqs.get(parametro, 0.0), 0.0) + base = self._float(freq_base.get(parametro, cfg["base"]), cfg["base"]) + minimo = self._float(freq_min.get(parametro, cfg["min"]), cfg["min"]) + ms = self._float(polling_ms.get(parametro, 0.0), 0.0) + + if base <= 0 and ms > 0: + base = 1000.0 / ms + + if base <= 0: + base = cfg["base"] + + if minimo <= 0: + minimo = cfg["min"] if cfg["min"] > 0 else base * 0.40 + + critico = bool(cfg.get("critico", False)) + penalidade_max = self.PENALIDADE_FREQ_MAX_CRITICA if critico else self.PENALIDADE_FREQ_MAX_PADRAO + + penalidade = self._penalizar_frequencia(freq, base, minimo, penalidade_max) + + # Score local. Como telemetria pesa só 5%, isso não derruba sozinho. + score = max(0, min(100, 100 - (penalidade * 4))) + + if freq <= 0: + score = 0 if critico else 55 + + return { + "freq": freq, + "freq_base": base, + "freq_min": minimo, + "polling_ms": ms, + "critico": critico, + "penalidade": float(penalidade or 0), + "score": score, + "ok": freq >= minimo, + } + + @staticmethod + def _penalizar_frequencia(freq, base, minimo, max_penalidade): + try: + return float(penalizar_frequencia(freq, base, minimo, max_penalidade) or 0) + except Exception: + if base <= 0: + return 0.0 + if freq >= minimo: + return 0.0 + if freq <= 0: + return float(max_penalidade) + return float(max_penalidade) * (1.0 - max(0.0, min(1.0, freq / max(minimo, 0.001)))) + + # ============================================================ + # Padrões dinâmicos + # ============================================================ + + def _adicionar_padrao_se_valido(self, padrao, valor): + try: + v = float(valor) + except Exception: + return + + if v == 0: + return + + try: + padrao.adicionar_valor(v) + except Exception: + pass + + def _avaliar_padroes_dinamicos(self, padroes, valores, condicoes): + mapa = { + "tensão fora do padrão": padroes.padrao_tensao, + "corrente do motor fora do padrão": padroes.padrao_corrente_motor, + "corrente no barramento fora do padrão": padroes.padrao_corrente_barramento, + "temperatura do driver fora do padrão": padroes.padrao_temperatura_driver, + "temperatura do motor fora do padrão": padroes.padrao_temperatura_motor, + } + + for label, valor in valores.items(): + try: + v = float(valor) + except Exception: + continue + + if v == 0: + continue + + padrao = mapa.get(label) + if padrao is None: + continue + + try: + if not padrao.esta_dentro_do_padrao(v): + grau = min(25, int(padrao.calcular_quao_anormal(v))) + if grau > 0: + condicoes.append({ + "label": "Padrão dinâmico MOV", + "valor": round(v, 2), + "severidade": grau, + "descricao": f"{label}: {v:.1f}", + "acoes": [ + "Acompanhar se o desvio persiste.", + "Comparar com outros drivers e com esforço mecânico no momento.", + ], + }) + except Exception: + pass + + # ============================================================ + # Status/publicação + # ============================================================ + + def _resolver_status_driver(self, conectado, saude, condicoes_operacionais): + if not conectado: + return StatusModulo.DESCONECTADO + + if any(self._float(c.get("severidade", 0), 0) > 90 for c in condicoes_operacionais): + return StatusModulo.FALHA + + if saude <= 0: + return StatusModulo.FALHA + + if saude < self.SAUDE_MIN_ALERTA: + return StatusModulo.ALERTA + + return StatusModulo.OPERANTE + + def _resolver_status_geral(self, conectado, saude_final, saude_individual, total_drivers_em_uso): + if not conectado: + return StatusModulo.DESCONECTADO + + if total_drivers_em_uso <= 0: + return StatusModulo.ALERTA + + em_uso = [ + item for item in saude_individual + if self._bool(item.get("em_uso", False)) + ] + + if any(item.get("status") == StatusModulo.FALHA.value for item in em_uso): + return StatusModulo.FALHA + + if saude_final <= 0: + return StatusModulo.FALHA + + if any(item.get("status") == StatusModulo.ALERTA.value for item in em_uso): + return StatusModulo.ALERTA + + if saude_final < self.SAUDE_MIN_ALERTA: + return StatusModulo.ALERTA + + return StatusModulo.OPERANTE + + def _montar_saude_geral(self, conectado, status, saude, motivos, saude_individual, condicoes_operacionais, detalhes): + return { + "conectado": conectado, + "status": status.value, + "saude": int(max(0, min(100, saude))), + "motivos": self._dedupe(motivos), + "saude_individual": saude_individual, + "condicoes_operacionais": condicoes_operacionais, + "detalhes": detalhes, + } + + def _publicar_saude(self, saude_geral, updates_drivers): + payload = {"saude": saude_geral} + payload.update(updates_drivers or {}) + + ContextoGlobalRedis.atualizar_ctx_dict( + ContextoGlobalRedis.ModKey(self.t_code), + **payload, + ) + + # ============================================================ + # Debug/helpers + # ============================================================ + + def _snapshot_debug(self, modulo): + rpm = self._float(modulo.get("rpm", 0.0), 0.0) + rpm_sp = self._float(modulo.get("rpm_sp", 0.0), 0.0) + + return { + "rpm": rpm, + "rpm_sp": rpm_sp, + "erro_rpm": self._float(modulo.get("erro_rpm", abs(rpm - rpm_sp)), abs(rpm - rpm_sp)), + "atingiu_rpm_sp": self._bool(modulo.get("atingiu_rpm_sp", False)), + "ultimo_comando_ms": self._float(modulo.get("ultimo_comando_ms", 999999), 999999), + "ultima_resposta_ms": self._obter_ultima_resposta_ms(modulo), + "comando_ligado": self._bool(modulo.get("comando_ligado", rpm_sp > self.RPM_SP_MIN_AVALIAR)), + "leitura_ligado": self._bool(modulo.get("leitura_ligado", False)), + "alarme_comando": self._bool(modulo.get("alarme_comando", False)), + "alarme_motor": self._bool(modulo.get("alarme_motor", False)), + "sentido": modulo.get("sentido"), + "sentido_sp": modulo.get("sentido_sp"), + "freado": self._bool(modulo.get("freado", False)), + "freio_sp": self._bool(modulo.get("freio_sp", False)), + "cod_erro": self._int_enum(modulo.get("cod_erro", 0), 0), + "tensao": self._float(modulo.get("tensao", 0.0), 0.0), + "corrente_motor": self._float(modulo.get("corrente_motor", 0.0), 0.0), + "corrente_barramento": self._float(modulo.get("corrente_barramento", 0.0), 0.0), + "temperatura_driver": self._float(modulo.get("temperatura_driver", 0.0), 0.0), + "temperatura_motor": self._float(modulo.get("temperatura_motor", 0.0), 0.0), + } + + def _obter_ultima_resposta_ms(self, modulo): + valor = modulo.get("ultima_resposta_ms", None) + if valor is not None: + return self._float(valor, 999999) + + idade_s = self._idade_modulo_s(modulo) + if idade_s is not None: + return max(0.0, idade_s * 1000.0) + + return 999999.0 + + def _idade_modulo_s(self, modulo): + ts = self._normalizar_timestamp_segundos(modulo.get("timestamp")) + if ts <= 0: + return None + + dt = time.time() - ts + + if -5.0 < dt < 0: + return 0.0 + + if dt < -5.0: + return None + + return dt + + @staticmethod + def _normalizar_timestamp_segundos(valor): + try: + ts = float(valor or 0.0) + except Exception: + return 0.0 + + if ts <= 0: + return 0.0 + + if ts > 1_000_000_000_000: + return ts / 1000.0 + + if ts > 1_000_000_000: + return ts + + return 0.0 + + def _listar_ativos(self, dados_mov): + ativos = dados_mov.get("ativos", []) + + if isinstance(ativos, list) and ativos: + return [str(x) for x in ativos] + + resultado = [] + for chave, valor in dados_mov.items(): + if isinstance(valor, dict): + try: + int(chave) + resultado.append(str(chave)) + except Exception: + pass + + return resultado + + @staticmethod + def _as_dict(valor): + return valor if isinstance(valor, dict) else {} + + @staticmethod + def _canon(valor): + texto = str(valor or "").strip().lower() + return "".join(ch for ch in texto if ch.isalnum() or ch == "_") + + def _normalizar_dict_chaves(self, valor): + if not isinstance(valor, dict): + return {} + + return { + self._canon(k): v + for k, v in valor.items() + if str(k).strip() + } + + @staticmethod + def _bool(valor, default=False): + try: + if isinstance(valor, bool): + return valor + + if valor is None: + return default + + if isinstance(valor, (int, float)): + return valor != 0 + + texto = str(valor).strip().lower() + + if texto in ["true", "1", "sim", "yes", "y", "on"]: + return True + + if texto in ["false", "0", "nao", "não", "no", "n", "off", ""]: + return False + + return bool(valor) + + except Exception: + return default + + @staticmethod + def _float(valor, padrao=0.0): + try: + if valor is None: + return float(padrao) + return float(valor) + except Exception: + return float(padrao) + + @staticmethod + def _safe_int(valor, padrao=0): + try: + if valor is None: + return int(padrao) + return int(valor) + except Exception: + return int(padrao) + + @staticmethod + def _int_enum(valor, padrao=0): + try: + if valor is None: + return int(padrao) + + if hasattr(valor, "value"): + return int(valor.value) + + if isinstance(valor, str): + texto = valor.strip() + if texto == "": + return int(padrao) + + # Caso venha "NenhumErro" ou outro nome, não tenta mapear aqui. + # O C# idealmente publica int. + return int(float(texto)) + + return int(valor) + except Exception: + return int(padrao) + + @staticmethod + def _dedupe(lista): + saida = [] + vistos = set() + + for item in lista or []: + texto = str(item) + + if texto in vistos: + continue + + vistos.add(texto) + saida.append(item) + + return saida diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/sensoriamento.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/sensoriamento.py index 18e1e6365..0f91d3a4e 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/sensoriamento.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/sensoriamento.py @@ -1,350 +1,1040 @@ import time -from shared.enums import T_Code, S_Code, StatusModulo +from shared.enums import T_Code, StatusModulo from shared.utils import penalizar_frequencia from health_worker.modulos.base import ModuloDiagnosticoBase -from health_worker.aprendizado.padrao_dinamico import PadraoDinamico from shared.contexto_global_redis import ContextoGlobalRedis + class ModuloSensoriamento(ModuloDiagnosticoBase): + """ + Health inteligente do SEN. + + Ideia principal: + - frequência baixa é sintoma de telemetria, não sentença; + - sensores passivos são avaliados por validade física e criticidade; + - servos, relés e sinaleiros são avaliados por coerência comando x leitura; + - bateria/SOC entra como condição operacional, não como falha elétrica do SEN, + exceto quando o fallback crítico de medição está indisponível. + """ + + SAUDE_MIN_ALERTA = 80 + + RESPOSTA_RECENTE_MS = 8000.0 + RESPOSTA_LENTA_MS = 4500.0 + + COMANDO_GRACE_MS = 900.0 + COMANDO_ALERTA_MS = 2500.0 + COMANDO_FALHA_MS = 6500.0 + + FREQ_MIN_FACTOR = 0.35 + PEN_FREQ_PADRAO = 4 + PEN_FREQ_CRITICA = 8 + + SERVO_TOL_GRAUS = 3.0 + SERVO_ALERTA_GRAUS = 8.0 + SERVO_FALHA_GRAUS = 18.0 + def __init__(self): - super().__init__() # ✅ garante que a classe base é corretamente iniciada + super().__init__() self.t_code = T_Code.Sen self.nome = "Sensoriamento" - self.padroes_sensores = {} - self.padroes_servos = {} - self.timeout = 3 # pode ser usado para validar timestamp - + self.timeout = 3 + + # ============================================================ + # Entrada principal + # ============================================================ + def atualizar_saude(self): try: - agora = time.time() - motivos_gerais = [] - saude_mandatorios = 0 - total_mandatorios = 0 - saude_total = 0 - total_ativos = 0 + dados_sen = ContextoGlobalRedis.get_modulo(self.t_code) or {} + contexto = ContextoGlobalRedis.get_contexto() or {} + operacao = ContextoGlobalRedis.get_operacao() or {} + equipamento = ContextoGlobalRedis.get_equipamento() or {} + + conectado = self._bool(dados_sen.get("conectado", False)) + root_score, root_motivos, root_cond = self._avaliar_root(dados_sen) + + sensores = self._coletar(dados_sen, "sensores") + servos = self._coletar(dados_sen, "servos") + reles = self._coletar(dados_sen, "reles") + sinaleiros = self._coletar(dados_sen, "sinaleiros") + + ctx = self._montar_contexto( + dados_sen=dados_sen, + sensores=sensores, + contexto=contexto, + operacao=operacao, + equipamento=equipamento, + ) + saude_individual = [] + updates = {} - dados_sen = ContextoGlobalRedis.get_modulo(self.t_code) + for item in sensores: + s = self._avaliar_sensor(item, ctx) + saude_individual.append(s) + updates[f"sensores.{item['key']}.saude"] = s - mod_conectado = dados_sen.get("conectado", False) - FREQ_BASE = dados_sen.get("freq_base", 1) - FREQ_MIN = FREQ_BASE * 0.5 - SAUDE_MIN_ALERTA = 80 + for item in servos: + s = self._avaliar_servo(item) + saude_individual.append(s) + updates[f"servos.{item['key']}.saude"] = s - mods_mandatorios = ContextoGlobalRedis.get_operacao().get("modulos_mandatorios", []) - sen_mandatorio = self.t_code.value in mods_mandatorios + for item in reles: + s = self._avaliar_rele(item) + saude_individual.append(s) + updates[f"reles.{item['key']}.saude"] = s - sensor_bateria_em_uso = False - sensor_bateria_conectado = False + for item in sinaleiros: + s = self._avaliar_sinaleiro(item) + saude_individual.append(s) + updates[f"sinaleiros.{item['key']}.saude"] = s - sensores_ativos = dados_sen.get("sensores", {}).get("ativos", []) - for key in sensores_ativos: - tipo, sensor_id = key.split(".") - sensores_tipo = dados_sen.get("sensores", {}).get(tipo, {}) - dados_sensor = sensores_tipo.get(sensor_id) - if not dados_sensor: - continue - saude = 100 - motivos = [] - condicoes_operacionais = [] - aferir = dados_sensor.get("aferir", False) - padroes = self.padroes_sensores.get(tipo, {}).get(sensor_id, {}) - conectado = dados_sensor.get("conectado", False) - mandatorio = dados_sensor.get("mandatorio", False) if sen_mandatorio else False - if dados_sensor.get("label") == "AB36V": - bms_conetado = dados_sen.get("bms_ligado", False) - sensor_bateria_em_uso = not bms_conetado and mandatorio and aferir - sensor_bateria_conectado = conectado - if not conectado: - saude = 0 - motivos.append(f"desconectado") - else: - for campo in dados_sensor.get("dados", []): - label = campo.get("label") - if label is None: - continue - freq = campo.get("freq", 0.0) - if freq == 0: - saude = 0 - motivos.append(f"{label} sem resposta (freq = 0 Hz)") - else: - penalidade = penalizar_frequencia(freq, FREQ_BASE, FREQ_MIN, (100 - SAUDE_MIN_ALERTA)) - saude -= penalidade - if (freq <= FREQ_MIN): - motivos.append(f"{label} frequência baixa ({freq:.2f}/{FREQ_BASE} Hz, penalidade {penalidade}%)") - atual = campo.get("atual") - padrao = padroes.get(label) if atual else None - if padrao is not None: - # Avaliação de condição operacional (sem penalizar saúde) - if not padrao.esta_dentro_do_padrao(atual): - grau = padrao.calcular_quao_anormal(atual) - condicoes_operacionais.append({ - "valor": atual, - "severidade": grau, - "descricao": f"{label} fora do padrão" - }) - saude = max(0, saude) - chave = f"{S_Code(int(tipo)).name}_{sensor_id}" - saude_mod = { - "id": sensor_id, - "label": dados_sensor.get("label"), - "status": (StatusModulo.DESCONECTADO if not conectado else StatusModulo.OPERANTE if saude >= SAUDE_MIN_ALERTA else StatusModulo.ALERTA if saude > 0 else StatusModulo.FALHA).value, - "saude": saude, - "motivos": motivos, - "condicoes_operacionais": condicoes_operacionais, - "em_uso": aferir - } - saude_individual.append(saude_mod) - if aferir: - total_ativos += 1 - saude_total += saude - if mandatorio: - total_mandatorios += 1 - saude_mandatorios += saude - motivos_gerais.extend([f"{chave}: {m}" for m in motivos]) - dados_sensor["saude"] = saude_mod + processo = self._avaliar_processo(ctx) - servos_ativos = dados_sen.get("servos", {}).get("ativos", []) - for key in servos_ativos: - tipo, servo_id = key.split(".") - servos_tipo = dados_sen.get("servos", {}).get(tipo, {}) - dados_servo = servos_tipo.get(servo_id) - if not dados_servo: - continue - saude = 100 - motivos = [] - condicoes_operacionais = [] - controlar = dados_servo.get("controlar", False) - conectado = dados_servo.get("conectado", False) - mandatorio = dados_servo.get("mandatorio", False) if sen_mandatorio else False - if not conectado: - saude = 0 - motivos.append(f"desconectado") - else: - for dado in dados_servo.get("dados", {}): - label = dado.get("label") - if label is None: - continue - freq = dado.get("freq", 0.0) - if freq == 0: - saude = 0 - motivos.append(f"{label} sem resposta (freq = 0 Hz)") - else: - penalidade = penalizar_frequencia(freq, FREQ_BASE, FREQ_MIN, (100 - SAUDE_MIN_ALERTA)) - saude -= penalidade - if freq <= FREQ_MIN: - motivos.append(f"{label} frequência baixa ({freq:.2f}/{FREQ_BASE} Hz, penalidade {penalidade}%)") + sensores_em_uso = [ + x for x in saude_individual + if x.get("grupo") == "sensor" and self._bool(x.get("em_uso", False)) + ] + atuadores_em_uso = [ + x for x in saude_individual + if x.get("grupo") in ["servo", "rele", "sinaleiro"] and self._bool(x.get("em_uso", False)) + ] - if label == "ServoAnguloLeitura": - angulo = dado.get("atual", 0.0) - angulo_sp = dados_servo.get("angulo_sp", 0.0) - erro = abs(angulo - angulo_sp) - if erro > 0: - condicoes_operacionais.append({ - "valor": angulo, - "severidade": erro, - "descricao": f"{label} fora do padrão" - }) - saude = max(0, saude) - chave = f"servo_{servo_id}" - saude_mod = { - "id": servo_id, - "label": dados_servo.get("label"), - "status": (StatusModulo.DESCONECTADO if not conectado else StatusModulo.OPERANTE if saude >= SAUDE_MIN_ALERTA else StatusModulo.ALERTA if saude > 0 else StatusModulo.FALHA).value, - "saude": saude, - "motivos": motivos, - "condicoes_operacionais": condicoes_operacionais, - "em_uso": controlar - } - saude_individual.append(saude_mod) - if controlar: - total_ativos += 1 - saude_total += saude - if mandatorio: - total_mandatorios += 1 - saude_mandatorios += saude - motivos_gerais.extend([f"{chave}: {m}" for m in motivos]) - dados_servo["saude"] = saude_mod + score_sensores = self._media_saude(sensores_em_uso, 100.0 if conectado else 0.0) + score_atuadores = self._media_saude(atuadores_em_uso, 100.0 if conectado else 0.0) - reles_ativos = dados_sen.get("reles", {}).get("ativos", []) - for key in reles_ativos: - tipo, rele_id = key.split(".") - reles_tipo = dados_sen.get("reles", {}).get(tipo, {}) - dados_rele = reles_tipo.get(rele_id) - if not dados_rele: - continue - saude = 100 - motivos = [] - condicoes_operacionais = [] - controlar = dados_rele.get("controlar", False) - conectado = dados_rele.get("conectado", False) - mandatorio = dados_rele.get("mandatorio", False) if sen_mandatorio else False - if not conectado: - saude = 0 - motivos.append(f"desconectado") - else: - for dado in dados_rele.get("dados", {}): - label = dado.get("label") - if label is None: - continue - freq = dado.get("freq", 0.0) - if freq == 0: - saude = 0 - motivos.append(f"{label} sem resposta (freq = 0 Hz)") - else: - penalidade = penalizar_frequencia(freq, FREQ_BASE, FREQ_MIN, (100 - SAUDE_MIN_ALERTA)) - saude -= penalidade - if freq <= FREQ_MIN: - motivos.append(f"{label} frequência baixa ({freq:.2f}/{FREQ_BASE} Hz, penalidade {penalidade}%)") + saude_final = ( + root_score * 0.25 + + score_sensores * 0.40 + + score_atuadores * 0.20 + + processo["score"] * 0.15 + ) - saude = max(0, saude) - chave = f"rele_{rele_id}" - saude_mod = { - "id": rele_id, - "label": dados_rele.get("label"), - "status": (StatusModulo.DESCONECTADO if not conectado else StatusModulo.OPERANTE if saude >= SAUDE_MIN_ALERTA else StatusModulo.ALERTA if saude > 0 else StatusModulo.FALHA).value, - "saude": saude, - "motivos": motivos, - "condicoes_operacionais": condicoes_operacionais, - "em_uso": controlar - } - saude_individual.append(saude_mod) - if controlar: - total_ativos += 1 - saude_total += saude - if mandatorio: - total_mandatorios += 1 - saude_mandatorios += saude - motivos_gerais.extend([f"{chave}: {m}" for m in motivos]) - dados_rele["saude"] = saude_mod + if not conectado: + saude_final = 0 + if any(self._is_falha_hardware(c) for c in root_cond + processo["condicoes_operacionais"]): + saude_final = min(saude_final, 40) + if processo.get("energia_critica", False): + saude_final = min(saude_final, 55) - leds_ativos = dados_sen.get("sinaleiros", {}).get("ativos", []) - for key in leds_ativos: - tipo, led_id = key.split(".") - leds_tipo = dados_sen.get("sinaleiros", {}).get(tipo, {}) - dados_led = leds_tipo.get(led_id) - if not dados_led: - continue - saude = 100 - motivos = [] - condicoes_operacionais = [] - controlar = dados_led.get("controlar", False) - conectado = dados_led.get("conectado", False) - mandatorio = dados_led.get("mandatorio", False) if sen_mandatorio else False - if not conectado: - saude = 0 - motivos.append(f"desconectado") - else: - for dado in dados_led.get("dados", {}): - label = dado.get("label") - if label is None: - continue - freq = dado.get("freq", 0.0) - if freq == 0: - saude = 0 - motivos.append(f"{label} sem resposta (freq = 0 Hz)") - else: - penalidade = penalizar_frequencia(freq, FREQ_BASE, FREQ_MIN, (100 - SAUDE_MIN_ALERTA)) - saude -= penalidade - if freq <= FREQ_MIN: - motivos.append(f"{label} frequência baixa ({freq:.2f}/{FREQ_BASE} Hz, penalidade {penalidade}%)") + saude_final = int(max(0, min(100, round(saude_final)))) - saude = max(0, saude) - chave = f"sinaleiro_{led_id}" - saude_mod = { - "id": led_id, - "label": dados_led.get("label"), - "status": (StatusModulo.DESCONECTADO if not conectado else StatusModulo.OPERANTE if saude >= SAUDE_MIN_ALERTA else StatusModulo.ALERTA if saude > 0 else StatusModulo.FALHA).value, - "saude": saude, - "motivos": motivos, - "condicoes_operacionais": condicoes_operacionais, - "em_uso": controlar - } - saude_individual.append(saude_mod) - if controlar: - total_ativos += 1 - saude_total += saude - if mandatorio: - total_mandatorios += 1 - saude_mandatorios += saude - motivos_gerais.extend([f"{chave}: {m}" for m in motivos]) - dados_led["saude"] = saude_mod + motivos = [] + motivos.extend(root_motivos) + motivos.extend(processo["motivos"]) + for item in sensores_em_uso + atuadores_em_uso: + for m in item.get("motivos", []) or []: + motivos.append(f"{item.get('grupo')} {item.get('label') or item.get('id')}: {m}") - - cond_op_mod = [] - penalidade_percentual_bateria = 0 - if sensor_bateria_em_uso: - if sensor_bateria_conectado: - _contexto = ContextoGlobalRedis.get_contexto() - bateria_suficiente_corredor = _contexto.get("Trajetoria", {}).get("CorredorAtual", {}).get("bateria_ok", True) - percent_atual = _contexto.get("Gerais", {}).get("percentual_bateria", 0.0) - percent_min = ContextoGlobalRedis.get_equipamento().get("percentual_tensao_bateria_min", 5.0) - if percent_atual <= 3.0: - penalidade_percentual_bateria = 90 - cond_op_mod.append({ - "valor": percent_atual, - "severidade": 100, - "descricao": f"SOC crítico ({percent_atual:.1f}%)!" - }) - elif percent_atual <= 10.0: - penalidade_percentual_bateria = 75 - cond_op_mod.append({ - "valor": percent_atual, - "severidade": 90, - "descricao": f"SOC muito baixo ({percent_atual:.1f}%)!" - }) - elif percent_atual <= 20.0: - penalidade_percentual_bateria = 30 - cond_op_mod.append({ - "valor": percent_atual, - "severidade": 50, - "descricao": f"SOC baixo ({percent_atual:.1f}%)!" - }) - if percent_atual < percent_min: - penalidade_percentual_bateria += 20 - cond_op_mod.append({ - "valor": percent_atual, - "severidade": 100, - "descricao": f"Nível da bateria abaixo do limite mínimo ({percent_atual:.1f}/{percent_min:.1f}%)" - }) - if not bateria_suficiente_corredor: - penalidade_percentual_bateria += 15 - cond_op_mod.append({ - "valor": percent_atual, - "severidade": 100, - "descricao": _contexto.get("Trajetoria", {}).get("CorredorAtual", {}).get("motivo_bat", "Bateria insuficiente") - }) - else: - cond_op_mod.append({ - "valor": 0, - "severidade": 100, - "descricao": f"Sensor de nível da bateria desconectado!" + condicoes = [] + condicoes.extend(root_cond) + condicoes.extend(processo["condicoes_operacionais"]) + for item in sensores_em_uso + atuadores_em_uso: + for c in item.get("condicoes_operacionais", []) or []: + condicoes.append({ + **c, + "grupo": item.get("grupo"), + "id": item.get("id"), + "label": item.get("label"), }) - saude_final = (saude_mandatorios // total_mandatorios) if total_mandatorios > 0 else 100 - saude_final_ativos = (saude_total // total_ativos) if total_ativos > 0 else 0 - saude_final = saude_final_ativos - penalidade_percentual_bateria - - status = StatusModulo.OPERANTE - if not mod_conectado: - status = StatusModulo.DESCONECTADO - elif saude_final <= 0: - status = StatusModulo.FALHA - elif saude_final < SAUDE_MIN_ALERTA or any(s["em_uso"] is True and s["saude"] < SAUDE_MIN_ALERTA for s in saude_individual): - status = StatusModulo.ALERTA + status = self._resolver_status_geral(conectado, saude_final, saude_individual, condicoes) saude_geral = { - "conectado": mod_conectado, - "status": status.value, - "saude": max(0, saude_final), - "motivos": motivos_gerais, - "saude_individual": saude_individual, - "condicoes_operacionais": cond_op_mod + "conectado": conectado, + "status": status.value, + "saude": saude_final, + "motivos": self._dedupe(motivos), + "saude_individual": sorted( + saude_individual, + key=lambda x: (str(x.get("grupo")), self._safe_int(x.get("id"), 0)), + ), + "condicoes_operacionais": condicoes, + "componentes": { + "root": int(round(root_score)), + "sensores": int(round(score_sensores)), + "atuadores_auxiliares": int(round(score_atuadores)), + "processo_sensoriamento": int(round(processo["score"])), + }, + "detalhes": ctx["debug"], } - ContextoGlobalRedis.atualizar_ctx_dict( - ContextoGlobalRedis.ModKey(self.t_code), - saude=saude_geral - ) + self._publicar(saude_geral, updates) + except Exception as e: print(f"Erro ao atualizar saude do modulo {self.t_code.name}: {e}") + try: + self._publicar({ + "conectado": False, + "status": StatusModulo.FALHA.value, + "saude": 0, + "motivos": [f"Erro interno no health do sensoriamento: {e}"], + "saude_individual": [], + "condicoes_operacionais": [], + "componentes": {}, + "detalhes": {}, + }, {}) + except Exception: + pass + + # ============================================================ + # Root e contexto + # ============================================================ + + def _avaliar_root(self, dados_sen): + conectado = self._bool(dados_sen.get("conectado", False)) + idade_ms = self._ultima_resposta_ms(dados_sen) + motivos = [] + condicoes = [] + + if not conectado: + motivos.append("SEN desconectado") + return 0, motivos, condicoes + + if idade_ms > self.RESPOSTA_RECENTE_MS: + motivos.append(f"SEN sem resposta recente ({idade_ms:.0f} ms)") + condicoes.append({ + "label": "Resposta SEN", + "valor": idade_ms, + "severidade": 95, + "classe": "falha_hardware", + "descricao": f"Módulo de sensoriamento sem resposta recente ({idade_ms:.0f} ms)", + "acoes": [ + "Verificar barramento CAN do SEN.", + "Verificar alimentação do módulo SEN.", + "Conferir se o registrador do SEN está ativo.", + ], + }) + return 0, motivos, condicoes + + if idade_ms > self.RESPOSTA_LENTA_MS: + motivos.append(f"Resposta SEN lenta ({idade_ms:.0f} ms)") + return 75, motivos, condicoes + + return 100, motivos, condicoes + + def _montar_contexto(self, dados_sen, sensores, contexto, operacao, equipamento): + gerais = self._as_dict(contexto.get("Gerais", {})) + traj = self._as_dict(contexto.get("Trajetoria", {})) + corredor = self._as_dict(traj.get("CorredorAtual", {})) + + bms_ligado = self._bool(dados_sen.get("bms_ligado", False)) + percent_bateria = self._float(dados_sen.get("percentual_bateria", gerais.get("percentual_bateria", 0.0)), 0.0) + percent_min = self._float(equipamento.get("percentual_tensao_bateria_min", 5.0), 5.0) + bateria_ok_corredor = self._bool(corredor.get("bateria_ok", True), True) + motivo_bateria_corredor = corredor.get("motivo_bat", "Bateria insuficiente") + + sensor_ab36v = self._achar_por_label(sensores, "AB36V") + ab36v_conectado = self._bool(sensor_ab36v.get("dados", {}).get("conectado", False)) if sensor_ab36v else False + ab36v_em_uso = bool(sensor_ab36v) and not bms_ligado and self._bool(sensor_ab36v.get("dados", {}).get("aferir", False)) + + tensao_36v = self._valor_sensor(sensores, "AB36V", "Tensao") + corrente_36v = self._valor_sensor(sensores, "AB36V", "Corrente") + temp_bateria = self._valor_sensor(sensores, "TBAT", "Temperatura") + + temps_motor = [] + for s in sensores: + label = str(s.get("dados", {}).get("label", "")) + if label.startswith("TMV"): + temps_motor.append(self._float(self._campo_atual(s["dados"], "Temperatura", 0), 0)) + + return { + "bms_ligado": bms_ligado, + "percent_bateria": percent_bateria, + "percent_min_bateria": percent_min, + "bateria_ok_corredor": bateria_ok_corredor, + "motivo_bateria_corredor": motivo_bateria_corredor, + "sensor_ab36v": sensor_ab36v, + "sensor_ab36v_conectado": ab36v_conectado, + "sensor_ab36v_em_uso": ab36v_em_uso, + "tensao_36v": tensao_36v, + "corrente_36v": corrente_36v, + "temp_bateria": temp_bateria, + "temperaturas_motor": temps_motor, + "debug": { + "bms_ligado": bms_ligado, + "percent_bateria": percent_bateria, + "percent_min_bateria": percent_min, + "bateria_ok_corredor": bateria_ok_corredor, + "sensor_ab36v_conectado": ab36v_conectado, + "sensor_ab36v_em_uso": ab36v_em_uso, + "tensao_36v": tensao_36v, + "corrente_36v": corrente_36v, + "temp_motor_max": max(temps_motor) if temps_motor else 0, + "temp_bateria": temp_bateria, + }, + } + + # ============================================================ + # Sensores passivos + # ============================================================ + + def _avaliar_sensor(self, item, ctx): + dados = item["dados"] + conectado = self._bool(dados.get("conectado", False)) + aferir = self._bool(dados.get("aferir", False)) + mandatorio = self._bool(dados.get("mandatorio", False)) + em_uso = aferir + motivos = [] + condicoes = [] + + if not conectado: + return self._item(item, StatusModulo.DESCONECTADO, 0, ["desconectado"], [], em_uso, {}, "sensor") + + resposta_score = self._score_resposta(dados, motivos, condicoes, "Sensor SEN") + valor_score, valor_debug = self._avaliar_valores_sensor(item, ctx, motivos, condicoes) + telemetria = self._avaliar_telemetria(dados, motivos, self._labels_criticos_sensor(dados, ctx, mandatorio)) + + saude = resposta_score * 0.35 + valor_score * 0.45 + telemetria["score"] * 0.20 + if any(self._is_falha_hardware(c) for c in condicoes): + saude = min(saude, 45) + + saude = int(max(0, min(100, round(saude)))) + status = self._resolver_status_item(conectado, saude, condicoes) + debug = {"resposta": resposta_score, "valores": valor_debug, "telemetria": telemetria["parametros"]} + return self._item(item, status, saude, motivos, condicoes, em_uso, debug, "sensor") + + def _avaliar_valores_sensor(self, item, ctx, motivos, condicoes): + dados = item["dados"] + sensor_label = str(dados.get("label", "")) + score = 100.0 + debug = {} + + for key, campo in self._campos(dados).items(): + label = str(campo.get("label") or key) + valor = self._float(campo.get("atual", 0), 0) + minimo = campo.get("minimo", None) + maximo = campo.get("maximo", None) + nominal = campo.get("nominal", None) + unidade = campo.get("unidade", campo.get("unidade_medida", "")) + + debug[key] = {"label": label, "atual": valor, "minimo": minimo, "maximo": maximo, "nominal": nominal, "unidade": unidade} + sc, conds, mots = self._avaliar_campo(sensor_label, label, valor, minimo, maximo) + score = min(score, sc) + motivos.extend(mots) + condicoes.extend(conds) + + return int(max(0, min(100, round(score)))), debug + + def _avaliar_campo(self, sensor_label, campo_label, valor, minimo, maximo): + motivos = [] + condicoes = [] + key_sensor = str(sensor_label or "").upper() + key_campo = self._canon(campo_label) + minv = None if minimo is None else self._float(minimo, None) + maxv = None if maximo is None else self._float(maximo, None) + + baixo = minv is not None and valor < minv + alto = maxv is not None and valor > maxv + if not baixo and not alto: + return 100, [], [] + + # Corrente abaixo do mínimo geralmente é pouca carga, não falha. + if key_campo == "corrente" and baixo: + motivos.append(f"{sensor_label}.{campo_label} abaixo do esperado ({valor:.2f})") + return 92, [], motivos + + ambiental = key_sensor in ["LUZABT", "IUVABT", "MQ2", "MQ7", "QLDABT", "SHTABT", "CHVABT"] + + if key_campo == "temperatura": + if key_sensor.startswith("TMV"): + severidade = 95 if valor >= 90 else 75 + condicoes.append({ + "label": "Temperatura motor", + "valor": valor, + "severidade": severidade, + "classe": "falha_hardware" if severidade > 90 else "risco_operacional", + "descricao": f"Temperatura de motor elevada em {sensor_label} ({valor:.1f} °C)", + "acoes": ["Reduzir carga dos motores.", "Verificar ventilação e esforço mecânico."], + }) + motivos.append(f"Temperatura de motor elevada ({sensor_label}: {valor:.1f} °C)") + return (35 if severidade > 90 else 70), condicoes, motivos + + if key_sensor == "TBAT": + severidade = 95 if valor >= 60 else 75 + condicoes.append({ + "label": "Temperatura bateria", + "valor": valor, + "severidade": severidade, + "classe": "falha_hardware" if severidade > 90 else "risco_operacional", + "descricao": f"Temperatura da bateria elevada ({valor:.1f} °C)", + "acoes": ["Verificar compartimento da bateria.", "Comparar com BMS se disponível."], + }) + motivos.append(f"Temperatura da bateria elevada ({valor:.1f} °C)") + return (40 if severidade > 90 else 72), condicoes, motivos + + if key_campo == "tensao" and key_sensor.startswith("AB"): + severidade = 92 if key_sensor == "AB36V" else 82 + condicoes.append({ + "label": "Barramento elétrico", + "valor": valor, + "severidade": severidade, + "classe": "falha_hardware" if severidade > 90 else "risco_operacional", + "descricao": f"Tensão fora da faixa no barramento {sensor_label} ({valor:.2f} V)", + "acoes": ["Verificar fonte/conversor.", "Verificar carga, fusível e conectores."], + }) + motivos.append(f"Tensão fora da faixa no {sensor_label} ({valor:.2f} V)") + return (42 if severidade > 90 else 68), condicoes, motivos + + if key_campo == "corrente" and alto: + severidade = 93 if key_sensor in ["AB36V", "AB24V"] else 82 + condicoes.append({ + "label": "Corrente barramento", + "valor": valor, + "severidade": severidade, + "classe": "falha_hardware" if severidade > 90 else "risco_operacional", + "descricao": f"Corrente alta no barramento {sensor_label} ({valor:.1f} mA)", + "acoes": ["Verificar curto, sobrecarga ou consumo anormal."], + }) + motivos.append(f"Corrente alta no {sensor_label} ({valor:.1f} mA)") + return (40 if severidade > 90 else 70), condicoes, motivos + + if ambiental: + severidade = 80 if key_sensor == "CHVABT" and alto else 70 + condicoes.append({ + "label": "Ambiente", + "valor": valor, + "severidade": severidade, + "classe": "ambiente", + "descricao": f"{sensor_label}.{campo_label} fora da faixa esperada ({valor:.2f})", + "acoes": ["Usar como contexto operacional, não como falha do SEN."], + }) + motivos.append(f"{sensor_label}.{campo_label} fora da faixa ambiental ({valor:.2f})") + return 82, condicoes, motivos + + condicoes.append({ + "label": "Sensor SEN", + "valor": valor, + "severidade": 60, + "classe": "processo", + "descricao": f"{sensor_label}.{campo_label} fora da faixa esperada ({valor:.2f})", + "acoes": ["Acompanhar tendência e comparar com sensores redundantes."], + }) + motivos.append(f"{sensor_label}.{campo_label} fora da faixa ({valor:.2f})") + return 80, condicoes, motivos + + def _labels_criticos_sensor(self, dados, ctx, mandatorio): + label = str(dados.get("label", "")) + if label == "AB36V" and ctx["sensor_ab36v_em_uso"]: + return ["Tensao", "Corrente", "Temperatura"] + if label.startswith("TMV"): + return ["Temperatura"] + if label == "TBAT" and not ctx["bms_ligado"]: + return ["Temperatura"] + if mandatorio: + return [c.get("label") for c in dados.get("dados", []) if isinstance(c, dict) and c.get("label")] + return [] + + # ============================================================ + # Atuadores auxiliares + # ============================================================ + + def _avaliar_servo(self, item): + dados = item["dados"] + conectado = self._bool(dados.get("conectado", False)) + controlar = self._bool(dados.get("controlar", False)) + if not conectado: + return self._item(item, StatusModulo.DESCONECTADO, 0, ["desconectado"], [], controlar, {}, "servo") + + motivos, condicoes = [], [] + resposta = self._score_resposta(dados, motivos, condicoes, "Servo SEN") + comando, dbg_cmd = self._avaliar_comando_servo(item, motivos, condicoes) + telem = self._avaliar_telemetria(dados, motivos, ["ServoAnguloLeitura"] if controlar else []) + saude = resposta * 0.25 + comando * 0.60 + telem["score"] * 0.15 + if any(self._is_falha_hardware(c) for c in condicoes): + saude = min(saude, 45) + saude = int(max(0, min(100, round(saude)))) + return self._item(item, self._resolver_status_item(conectado, saude, condicoes), saude, motivos, condicoes, controlar, {"resposta": resposta, "comando": dbg_cmd, "telemetria": telem["parametros"]}, "servo") + + def _avaliar_comando_servo(self, item, motivos, condicoes): + dados = item["dados"] + label = dados.get("label") + controlar = self._bool(dados.get("controlar", False)) + angulo_sp = self._float(dados.get("angulo_desejado", dados.get("angulo_sp", 0)), 0) + angulo = self._float(dados.get("angulo_leitura", self._campo_atual(dados, "ServoAnguloLeitura", -1)), -1) + ultimo_ms = self._float(dados.get("ultimo_comando_ms", 999999), 999999) + tem_idade = ultimo_ms < 900000 + + if angulo < 0: + motivos.append("Servo sem leitura de ângulo") + condicoes.append({ + "label": "Servo SEN", + "valor": angulo, + "severidade": 70 if controlar else 35, + "classe": "processo", + "descricao": f"Servo {label} sem leitura de ângulo válida", + "acoes": ["Verificar retorno de ServoAnguloLeitura."], + }) + return (60 if controlar else 85), {"angulo": angulo, "angulo_sp": angulo_sp, "sem_leitura": True} + + erro = abs(angulo - angulo_sp) + if not controlar or erro <= self.SERVO_TOL_GRAUS: + return 100, {"angulo": angulo, "angulo_sp": angulo_sp, "erro": erro, "controlar": controlar} + if tem_idade and ultimo_ms < self.COMANDO_GRACE_MS: + return 96, {"angulo": angulo, "angulo_sp": angulo_sp, "erro": erro, "ultimo_comando_ms": ultimo_ms} + if erro >= self.SERVO_FALHA_GRAUS and tem_idade and ultimo_ms >= self.COMANDO_FALHA_MS: + motivos.append(f"Servo {label} não convergiu para o SP ({angulo:.1f}/{angulo_sp:.1f})") + condicoes.append({ + "label": "Servo SEN", + "valor": angulo, + "severidade": 92, + "classe": "falha_hardware", + "descricao": f"Servo {label} não acompanhou o comando de ângulo", + "acoes": ["Verificar travamento mecânico ou alimentação do servo."], + }) + return 25, {"angulo": angulo, "angulo_sp": angulo_sp, "erro": erro, "ultimo_comando_ms": ultimo_ms} + if erro >= self.SERVO_ALERTA_GRAUS: + motivos.append(f"Servo {label} distante do SP ({angulo:.1f}/{angulo_sp:.1f})") + condicoes.append({ + "label": "Servo SEN", + "valor": angulo, + "severidade": min(89, max(45, int(erro * 4))), + "classe": "processo", + "descricao": f"Servo {label} distante do ângulo desejado", + "acoes": ["Acompanhar se o erro persiste."], + }) + return 70, {"angulo": angulo, "angulo_sp": angulo_sp, "erro": erro, "ultimo_comando_ms": ultimo_ms} + motivos.append(f"Servo {label} fora da tolerância momentaneamente ({erro:.1f}°)") + return 88, {"angulo": angulo, "angulo_sp": angulo_sp, "erro": erro, "ultimo_comando_ms": ultimo_ms} + + def _avaliar_rele(self, item): + return self._avaliar_saida_binaria(item, grupo="rele", nome="Relé SEN", peso_comando=0.60, severidade_falha=92) + + def _avaliar_sinaleiro(self, item): + return self._avaliar_sinaleiro_impl(item) + + def _avaliar_saida_binaria(self, item, grupo, nome, peso_comando=0.60, severidade_falha=92): + dados = item["dados"] + conectado = self._bool(dados.get("conectado", False)) + controlar = self._bool(dados.get("controlar", False)) + if not conectado: + return self._item(item, StatusModulo.DESCONECTADO, 0, ["desconectado"], [], controlar, {}, grupo) + + motivos, condicoes = [], [] + resposta = self._score_resposta(dados, motivos, condicoes, nome) + comando, dbg_cmd = self._avaliar_comando_binario(item, motivos, condicoes, nome, severidade_falha) + telem = self._avaliar_telemetria(dados, motivos, ["EstadoLeitura"] if controlar else []) + saude = resposta * 0.25 + comando * peso_comando + telem["score"] * (0.75 - peso_comando) + if any(self._is_falha_hardware(c) for c in condicoes): + saude = min(saude, 45) + saude = int(max(0, min(100, round(saude)))) + return self._item(item, self._resolver_status_item(conectado, saude, condicoes), saude, motivos, condicoes, controlar, {"resposta": resposta, "comando": dbg_cmd, "telemetria": telem["parametros"]}, grupo) + + def _avaliar_comando_binario(self, item, motivos, condicoes, nome, severidade_falha): + dados = item["dados"] + label = dados.get("label") + controlar = self._bool(dados.get("controlar", False)) + desejado_presente = any(k in dados for k in ["estado_desejado", "status_desejado", "status_controle"]) + leitura_presente = "estado_leitura" in dados or self._campo_existe(dados, "EstadoLeitura") + desejado = self._estado_ligado(dados.get("estado_desejado", dados.get("status_desejado", dados.get("status_controle", False)))) + lido = self._estado_ligado(dados.get("estado_leitura", self._campo_atual(dados, "EstadoLeitura", False))) + ultimo_ms = self._float(dados.get("ultimo_comando_ms", 999999), 999999) + tem_idade = ultimo_ms < 900000 + + if not controlar: + return 100, {"controlar": False, "estado_lido": lido, "estado_desejado": desejado} + if not desejado_presente or not leitura_presente: + return 92, {"contrato_comando_completo": False, "estado_lido": lido, "estado_desejado": desejado} + if lido == desejado: + return 100, {"estado_lido": lido, "estado_desejado": desejado, "ultimo_comando_ms": ultimo_ms} + if tem_idade and ultimo_ms < self.COMANDO_GRACE_MS: + return 96, {"estado_lido": lido, "estado_desejado": desejado, "ultimo_comando_ms": ultimo_ms} + + severidade = severidade_falha if tem_idade and ultimo_ms >= self.COMANDO_FALHA_MS else 78 + motivos.append(f"{label} não acompanhou estado desejado") + condicoes.append({ + "label": nome, + "valor": int(lido), + "severidade": severidade, + "classe": "falha_hardware" if severidade > 90 else "processo", + "descricao": f"{label} com estado lido diferente do desejado", + "acoes": ["Verificar saída física e comando enviado pelo SEN."], + }) + return (30 if severidade > 90 else 68), {"estado_lido": lido, "estado_desejado": desejado, "ultimo_comando_ms": ultimo_ms} + + def _avaliar_sinaleiro_impl(self, item): + dados = item["dados"] + conectado = self._bool(dados.get("conectado", False)) + controlar = self._bool(dados.get("controlar", False)) + if not conectado: + return self._item(item, StatusModulo.DESCONECTADO, 0, ["desconectado"], [], controlar, {}, "sinaleiro") + + motivos, condicoes = [], [] + resposta = self._score_resposta(dados, motivos, condicoes, "Sinaleiro SEN") + comando, dbg_cmd = self._avaliar_comando_sinaleiro(item, motivos, condicoes) + telem = self._avaliar_telemetria(dados, motivos, []) + # Sinaleiro é diagnóstico visual: não derruba operação sozinho. + saude = resposta * 0.45 + comando * 0.45 + telem["score"] * 0.10 + saude = int(max(0, min(100, round(saude)))) + return self._item(item, self._resolver_status_item(conectado, saude, condicoes), saude, motivos, condicoes, controlar, {"resposta": resposta, "comando": dbg_cmd, "telemetria": telem["parametros"]}, "sinaleiro") + + def _avaliar_comando_sinaleiro(self, item, motivos, condicoes): + dados = item["dados"] + label = dados.get("label") + controlar = self._bool(dados.get("controlar", False)) + id_sp = self._safe_int(dados.get("status_led_id_sp", dados.get("led_id_sp", -1)), -1) + st_sp = self._safe_int(dados.get("status_led_sp", dados.get("led_status_sp", -1)), -1) + id_lido = self._safe_int(dados.get("status_led_id", self._campo_atual(dados, "StatusLedID", -1)), -1) + st_lido = self._safe_int(dados.get("status_led_leitura", self._campo_atual(dados, "StatusLedLeitura", -1)), -1) + ultimo_ms = self._float(dados.get("ultimo_comando_ms", 999999), 999999) + + if not controlar: + return 100, {"controlar": False, "status_lido": st_lido, "id_lido": id_lido} + if id_sp < 0 and st_sp < 0: + return 94, {"contrato_comando_completo": False, "status_lido": st_lido, "id_lido": id_lido} + + coerente = True + if id_sp >= 0 and id_lido >= 0 and id_sp != id_lido: + coerente = False + if st_sp >= 0 and st_lido >= 0 and st_sp != st_lido: + coerente = False + if coerente: + return 100, {"status_lido": st_lido, "status_sp": st_sp, "id_lido": id_lido, "id_sp": id_sp} + if ultimo_ms < self.COMANDO_GRACE_MS: + return 96, {"status_lido": st_lido, "status_sp": st_sp, "id_lido": id_lido, "id_sp": id_sp, "ultimo_comando_ms": ultimo_ms} + + motivos.append(f"Sinaleiro {label} diferente do comportamento desejado") + condicoes.append({ + "label": "Sinaleiro SEN", + "valor": st_lido, + "severidade": 45, + "classe": "processo", + "descricao": f"Sinaleiro {label} não está refletindo o comportamento desejado", + "acoes": ["Conferir LED/sinaleiro físico e comando pelo SEN."], + }) + return 82, {"status_lido": st_lido, "status_sp": st_sp, "id_lido": id_lido, "id_sp": id_sp, "ultimo_comando_ms": ultimo_ms} + + # ============================================================ + # Processo geral + # ============================================================ + + def _avaliar_processo(self, ctx): + motivos, condicoes = [], [] + score = 100.0 + energia_critica = False + + if not ctx["bms_ligado"] and ctx["sensor_ab36v_em_uso"] and not ctx["sensor_ab36v_conectado"]: + motivos.append("BMS desligado e sensor AB36V indisponível") + condicoes.append({ + "label": "Energia", + "valor": 0, + "severidade": 95, + "classe": "falha_hardware", + "descricao": "Sem BMS e sem sensor AB36V para estimar energia do rover", + "acoes": ["Verificar AB36V ou religar/validar BMS."], + }) + score = min(score, 35) + energia_critica = True + + if not ctx["bms_ligado"] and ctx["sensor_ab36v_em_uso"]: + percent = ctx["percent_bateria"] + percent_min = ctx["percent_min_bateria"] + + if percent <= 3.0: + motivos.append(f"SOC crítico ({percent:.1f}%)") + condicoes.append({ + "label": "SOC bateria", + "valor": percent, + "severidade": 95, + "classe": "recurso_operacional", + "descricao": f"SOC crítico ({percent:.1f}%)", + "acoes": ["Parar operação e recarregar bateria."], + }) + score = min(score, 45) + energia_critica = True + elif percent <= 10.0: + motivos.append(f"SOC muito baixo ({percent:.1f}%)") + condicoes.append({ + "label": "SOC bateria", + "valor": percent, + "severidade": 90, + "classe": "recurso_operacional", + "descricao": f"SOC muito baixo ({percent:.1f}%)", + "acoes": ["Retornar para base ou planejar recarga."], + }) + score = min(score, 68) + elif percent <= 20.0: + motivos.append(f"SOC baixo ({percent:.1f}%)") + condicoes.append({ + "label": "SOC bateria", + "valor": percent, + "severidade": 50, + "classe": "recurso_operacional", + "descricao": f"SOC baixo ({percent:.1f}%)", + "acoes": ["Acompanhar autonomia."], + }) + score = min(score, 88) + + if percent < percent_min: + motivos.append(f"Bateria abaixo do mínimo ({percent:.1f}/{percent_min:.1f}%)") + condicoes.append({ + "label": "Bateria mínima", + "valor": percent, + "severidade": 95, + "classe": "recurso_operacional", + "descricao": f"Nível da bateria abaixo do limite mínimo ({percent:.1f}/{percent_min:.1f}%)", + "acoes": ["Recarregar bateria ou revisar estimativa."], + }) + score = min(score, 55) + energia_critica = True + + if not ctx["bateria_ok_corredor"]: + motivos.append(ctx["motivo_bateria_corredor"]) + condicoes.append({ + "label": "Autonomia bateria", + "valor": percent, + "severidade": 95, + "classe": "recurso_operacional", + "descricao": ctx["motivo_bateria_corredor"], + "acoes": ["Recarregar ou reduzir trecho/corredor."], + }) + score = min(score, 60) + energia_critica = True + + temp_motor_max = max(ctx["temperaturas_motor"]) if ctx["temperaturas_motor"] else 0 + if temp_motor_max >= 90: + motivos.append(f"Temperatura máxima dos motores crítica ({temp_motor_max:.1f} °C)") + condicoes.append({ + "label": "Temperatura motores", + "valor": temp_motor_max, + "severidade": 95, + "classe": "falha_hardware", + "descricao": f"Temperatura de motor crítica ({temp_motor_max:.1f} °C)", + "acoes": ["Reduzir carga/parar movimentação e verificar refrigeração."], + }) + score = min(score, 45) + elif temp_motor_max >= 80: + motivos.append(f"Temperatura máxima dos motores elevada ({temp_motor_max:.1f} °C)") + condicoes.append({ + "label": "Temperatura motores", + "valor": temp_motor_max, + "severidade": 75, + "classe": "risco_operacional", + "descricao": f"Temperatura de motor elevada ({temp_motor_max:.1f} °C)", + "acoes": ["Reduzir esforço se persistir."], + }) + score = min(score, 75) + + if ctx["temp_bateria"] >= 60: + motivos.append(f"Temperatura da bateria crítica ({ctx['temp_bateria']:.1f} °C)") + condicoes.append({ + "label": "Temperatura bateria", + "valor": ctx["temp_bateria"], + "severidade": 95, + "classe": "falha_hardware", + "descricao": f"Temperatura da bateria crítica ({ctx['temp_bateria']:.1f} °C)", + "acoes": ["Parar operação e verificar bateria."], + }) + score = min(score, 45) + + return { + "score": int(max(0, min(100, round(score)))), + "motivos": self._dedupe(motivos), + "condicoes_operacionais": condicoes, + "energia_critica": energia_critica, + } + + # ============================================================ + # Telemetria genérica + # ============================================================ + + def _score_resposta(self, dados, motivos, condicoes, label_tipo): + if not self._bool(dados.get("conectado", False)): + return 0 + + idade_ms = self._ultima_resposta_ms(dados) + if idade_ms > self.RESPOSTA_RECENTE_MS: + motivos.append(f"{label_tipo} sem resposta recente ({idade_ms:.0f} ms)") + condicoes.append({ + "label": label_tipo, + "valor": idade_ms, + "severidade": 92, + "classe": "falha_hardware", + "descricao": f"{label_tipo} sem resposta recente", + "acoes": ["Verificar retorno no pacote DadosAll e configuração do componente."], + }) + return 0 + if idade_ms > self.RESPOSTA_LENTA_MS: + motivos.append(f"{label_tipo} com resposta lenta ({idade_ms:.0f} ms)") + return 75 + return 100 + + def _avaliar_telemetria(self, dados, motivos, criticos=None): + criticos = [self._canon(c) for c in (criticos or []) if c] + freq_base_root = self._float(dados.get("freq_base", 1.0), 1.0) + campos = dados.get("dados", []) + parametros = {} + scores = [] + + if not isinstance(campos, list) or not campos: + return {"score": 94, "parametros": {}} + + for campo in campos: + if not isinstance(campo, dict) or not campo.get("label"): + continue + label = campo.get("label") + key = self._canon(label) + freq = self._float(campo.get("freq", 0.0), 0.0) + base = self._float(campo.get("freq_base", freq_base_root), freq_base_root) + minimo = self._float(campo.get("freq_min", base * self.FREQ_MIN_FACTOR), base * self.FREQ_MIN_FACTOR) + critico = key in criticos + max_pen = self.PEN_FREQ_CRITICA if critico else self.PEN_FREQ_PADRAO + pen = self._penalizar_freq(freq, base, minimo, max_pen) + score = max(0, min(100, 100 - pen * 4)) + + if freq <= 0: + score = 0 if critico else 65 + motivos.append(f"Telemetria {label} ausente (0 Hz)") + elif freq < minimo: + motivos.append(f"Telemetria {label} baixa ({freq:.2f}/{base:.2f} Hz, min={minimo:.2f} Hz)") + + parametros[key] = { + "label": label, + "freq": freq, + "freq_base": base, + "freq_min": minimo, + "critico": critico, + "penalidade": pen, + "score": score, + } + scores.append(score) + + return {"score": int(max(0, min(100, round(sum(scores) / len(scores) if scores else 94)))), "parametros": parametros} + + @staticmethod + def _penalizar_freq(freq, base, minimo, max_pen): + try: + return float(penalizar_frequencia(freq, base, minimo, max_pen) or 0) + except Exception: + if freq >= minimo: + return 0.0 + if freq <= 0: + return float(max_pen) + return float(max_pen) * (1.0 - max(0.0, min(1.0, freq / max(minimo, 0.001)))) + + # ============================================================ + # Coleta/status/publicação + # ============================================================ + + def _coletar(self, dados_sen, grupo): + raiz = self._as_dict(dados_sen.get(grupo, {})) + ativos = raiz.get("ativos", []) + out = [] + if not isinstance(ativos, list): + return out + for key in ativos: + try: + tipo, comp_id = str(key).split(".") + except Exception: + continue + dados = self._as_dict(self._as_dict(raiz.get(str(tipo), {})).get(str(comp_id), {})) + if dados: + out.append({"grupo": grupo, "tipo": str(tipo), "id": str(comp_id), "key": f"{tipo}.{comp_id}", "dados": dados}) + return out + + def _item(self, item, status, saude, motivos, condicoes, em_uso, debug, grupo): + dados = item["dados"] + return { + "id": item.get("id"), + "tipo": item.get("tipo"), + "grupo": grupo, + "label": dados.get("label"), + "status": status.value, + "saude": int(max(0, min(100, saude))), + "motivos": self._dedupe(motivos), + "condicoes_operacionais": condicoes, + "em_uso": bool(em_uso), + "mandatorio": self._bool(dados.get("mandatorio", False)), + "debug": debug, + } + + def _resolver_status_item(self, conectado, saude, condicoes): + if not conectado: + return StatusModulo.DESCONECTADO + if any(self._is_falha_hardware(c) for c in condicoes): + return StatusModulo.FALHA + if saude <= 0: + return StatusModulo.FALHA + if saude < self.SAUDE_MIN_ALERTA: + return StatusModulo.ALERTA + if any(self._float(c.get("severidade", 0), 0) >= 70 for c in condicoes): + return StatusModulo.ALERTA + return StatusModulo.OPERANTE + + def _resolver_status_geral(self, conectado, saude, saude_individual, condicoes): + if not conectado: + return StatusModulo.DESCONECTADO + em_uso = [x for x in saude_individual if self._bool(x.get("em_uso", False))] + if any(self._is_falha_hardware(c) for c in condicoes): + return StatusModulo.FALHA + if any(x.get("status") == StatusModulo.FALHA.value for x in em_uso): + return StatusModulo.FALHA + if saude <= 0: + return StatusModulo.FALHA + if any(self._float(c.get("severidade", 0), 0) > 90 for c in condicoes): + return StatusModulo.ALERTA + if saude < self.SAUDE_MIN_ALERTA: + return StatusModulo.ALERTA + if any(x.get("status") == StatusModulo.ALERTA.value for x in em_uso): + return StatusModulo.ALERTA + return StatusModulo.OPERANTE + + @staticmethod + def _is_falha_hardware(cond): + try: + return float(cond.get("severidade", 0) or 0) > 90 and cond.get("classe") == "falha_hardware" + except Exception: + return False + + def _publicar(self, saude_geral, updates): + payload = {"saude": saude_geral} + payload.update(updates or {}) + ContextoGlobalRedis.atualizar_ctx_dict(ContextoGlobalRedis.ModKey(self.t_code), **payload) + + # ============================================================ + # Helpers + # ============================================================ + + @staticmethod + def _media_saude(items, default=100.0): + if not items: + return float(default) + return sum(float(x.get("saude", 0) or 0) for x in items) / len(items) + + @staticmethod + def _as_dict(v): + return v if isinstance(v, dict) else {} + + @staticmethod + def _canon(v): + txt = str(v or "").strip().lower() + return "".join(ch for ch in txt if ch.isalnum() or ch == "_") + + def _campos(self, dados): + campos = {} + for campo in dados.get("dados", []) or []: + if isinstance(campo, dict) and campo.get("label"): + campos[self._canon(campo.get("label"))] = campo + return campos + + def _campo_existe(self, dados, label): + return self._canon(label) in self._campos(dados) + + def _campo_atual(self, dados, label, padrao=0): + campo = self._campos(dados).get(self._canon(label)) + return campo.get("atual", padrao) if campo else padrao + + def _valor_sensor(self, sensores, sensor_label, campo_label): + item = self._achar_por_label(sensores, sensor_label) + if not item: + return 0.0 + return self._float(self._campo_atual(item["dados"], campo_label, 0), 0) + + @staticmethod + def _achar_por_label(lista, label): + for item in lista or []: + dados = item.get("dados", {}) if isinstance(item, dict) else {} + if dados.get("label") == label: + return item + return None + + def _ultima_resposta_ms(self, dados): + if not isinstance(dados, dict): + return 999999.0 + if dados.get("ultima_resposta_ms") is not None: + return self._float(dados.get("ultima_resposta_ms"), 999999) + idade = self._idade_s(dados) + if idade is not None: + return max(0.0, idade * 1000.0) + return 999999.0 + + def _idade_s(self, dados): + ts = self._normalizar_ts(dados.get("timestamp")) + if ts <= 0: + return None + dt = time.time() - ts + if -5.0 < dt < 0: + return 0.0 + if dt < -5.0: + return None + return dt + + @staticmethod + def _normalizar_ts(valor): + try: + ts = float(valor or 0.0) + except Exception: + return 0.0 + if ts <= 0: + return 0.0 + if ts > 1_000_000_000_000: + return ts / 1000.0 + if ts > 1_000_000_000: + return ts + return 0.0 + + @staticmethod + def _estado_ligado(valor): + if valor is None: + return False + if hasattr(valor, "value"): + valor = valor.value + if isinstance(valor, bool): + return valor + if isinstance(valor, (int, float)): + return int(valor) == 1 + return str(valor).strip().lower() in ["1", "ligado", "true", "on", "sim", "yes", "aceso"] + + @staticmethod + def _bool(valor, default=False): + try: + if isinstance(valor, bool): + return valor + if valor is None: + return default + if hasattr(valor, "value"): + valor = valor.value + if isinstance(valor, (int, float)): + return valor != 0 + txt = str(valor).strip().lower() + if txt in ["true", "1", "sim", "yes", "y", "on", "ligado", "aceso"]: + return True + if txt in ["false", "0", "nao", "não", "no", "n", "off", "desligado", "apagado", ""]: + return False + return bool(valor) + except Exception: + return default + + @staticmethod + def _float(valor, padrao=0.0): + try: + if valor is None: + return float(padrao) + if hasattr(valor, "value"): + valor = valor.value + return float(valor) + except Exception: + return float(padrao) + + @staticmethod + def _safe_int(valor, padrao=0): + try: + if valor is None: + return int(padrao) + if hasattr(valor, "value"): + valor = valor.value + return int(valor) + except Exception: + return int(padrao) + + @staticmethod + def _dedupe(lista): + out, seen = [], set() + for item in lista or []: + txt = str(item) + if txt in seen: + continue + seen.add(txt) + out.append(item) + return out