From b148d987b7e70ceabd1e5b7774db6db5a61fdb12 Mon Sep 17 00:00:00 2001 From: Diego Freitas Date: Fri, 7 Aug 2026 13:28:59 -0300 Subject: [PATCH] ajustes gerais pre BP, regras de imu, gps, camera de ervas, status da operacao, fim da calibragem, carregamento do mapa, contador de carga de bateria --- AgroBase/AgroBase/Models/GeneralJoystick.cs | 8 + AgroBase/AgroBase/Models/MapasModel.cs | 4 +- .../Models/Operacoes/OperacaoModel.cs | 82 +- .../Services/CoulombCounterService.cs | 177 +++- .../camera_worker/camera_multispectral.py | 326 ++++++- .../Scripts/workers/health_worker/main.py | 2 +- .../workers/health_worker/modulos/gps.py | 860 ++++++++++++------ .../workers/health_worker/modulos/imu.py | 16 +- .../health_worker/modulos/movimentacao.py | 357 +++++++- .../workers/shared/contexto_global_redis.py | 357 +++++--- 10 files changed, 1643 insertions(+), 546 deletions(-) diff --git a/AgroBase/AgroBase/Models/GeneralJoystick.cs b/AgroBase/AgroBase/Models/GeneralJoystick.cs index 0b7c28893..45288a431 100644 --- a/AgroBase/AgroBase/Models/GeneralJoystick.cs +++ b/AgroBase/AgroBase/Models/GeneralJoystick.cs @@ -896,6 +896,14 @@ namespace AgroBase Variaveis.MostrarLog($"[GeneralJoystick] Comando manual não aplicado. Motivo: {motivo}"); } + public static void RemoverMotivoParada(string motivo) + { + var motivos = Variaveis.OperacaoEmAndamento?.Controle?.MotivosManual; + if (motivos == null) return; + motivos.Remove(motivo); + Variaveis.MostrarLog($"[GeneralJoystick] Comando manual não aplicado. Motivo: {motivo}"); + } + public static void EnviarComandoAtuadorAtualizado(DeParaComandos Comando, bool ForcarComando = false) { var op = Variaveis.OperacaoEmAndamento; diff --git a/AgroBase/AgroBase/Models/MapasModel.cs b/AgroBase/AgroBase/Models/MapasModel.cs index b49207f3c..a485a0476 100644 --- a/AgroBase/AgroBase/Models/MapasModel.cs +++ b/AgroBase/AgroBase/Models/MapasModel.cs @@ -454,8 +454,8 @@ namespace AgroBase.Models MapaFeatureModel feature = mapaService.DadosMapa.features .FirstOrDefault(x => x != null && - x.geometry != null && - string.Equals(x.geometry.id, idSelecionado, StringComparison.Ordinal) + x.properties != null && + string.Equals(x.properties?.Id?.Trim(), idSelecionado?.Trim(), StringComparison.Ordinal) ); if (feature?.geometry?.coordinates == null || feature.geometry.coordinates.Count < 2) diff --git a/AgroBase/AgroBase/Models/Operacoes/OperacaoModel.cs b/AgroBase/AgroBase/Models/Operacoes/OperacaoModel.cs index b86167a0d..041cd2b92 100644 --- a/AgroBase/AgroBase/Models/Operacoes/OperacaoModel.cs +++ b/AgroBase/AgroBase/Models/Operacoes/OperacaoModel.cs @@ -1363,10 +1363,15 @@ namespace AgroBase.Models await Task.Delay(2000); op.Sensoriamento.Operacao.OperacaoIniciada = false; + op.DefinirEstadoCalibragemRuntime(false); op.DefinirEmergencia(false); - op.Sensoriamento.Operacao.Calibrando = false; op.DefinirPausa(false); - RedisService.AtualizarCampos(CtxKey.DadosOperacao, ("calibrando", false)); + + RedisService.AtualizarCampos( + CtxKey.DadosOperacao, + ("calibrando", false), + ("operacao_iniciada", false) + ); op.Sensoriamento.Operacao.DataFim = DateTime.Now; @@ -1732,7 +1737,10 @@ namespace AgroBase.Models { await allTasks; - Variaveis.MostrarLog("[OperacaoModel] [RealizarCalibragemInicialAsync] Calibragem finalizada."); + Variaveis.MostrarLog( + "[OperacaoModel.RealizarCalibragemInicialAsync] " + + "Tasks de calibragem dos módulos concluídas." + ); op.Sensoriamento.InserirLog(T_Code.Mod, StatusModulo.Operante, 100, "Calibragem finalizada."); } @@ -1751,11 +1759,73 @@ namespace AgroBase.Models } finally { - op.DefinirTipoMovimentoControle(tipoControle); + try + { + op.DefinirEstadoCalibragemRuntime(false); - op.DefinirEstadoCalibragemRuntime(false); + Variaveis.MostrarLog( + $"[OperacaoModel.RealizarCalibragemInicialAsync] " + + $"Estado global liberado. " + + $"Calibrando={op.CalibrandoRuntime}, " + + $"Pausa={op.Sensoriamento?.Operacao?.Pausa}" + ); + } + catch (Exception ex) + { + Variaveis.MostrarLog( + $"[OperacaoModel.RealizarCalibragemInicialAsync] " + + $"Erro ao liberar estado global da calibragem: {ex}" + ); - _calibragemGate.Release(); + // Fallback direto para não deixar o rover preso em calibragem. + if (op.Sensoriamento?.Operacao != null) + { + op.Sensoriamento.Operacao.Calibrando = false; + op.Sensoriamento.Operacao.Pausa = _pausaAntesCalibragem; + } + + RedisService.AtualizarCampos( + CtxKey.DadosOperacao, + ("calibrando", false) + ); + } + + try + { + op.DefinirTipoMovimentoControle(tipoControle); + } + catch (Exception ex) + { + Variaveis.MostrarLog( + $"[OperacaoModel.RealizarCalibragemInicialAsync] " + + $"Erro ao restaurar tipo de movimento: {ex}" + ); + } + + try + { + // Usar aqui o método real existente no GeneralJoystick. + GeneralJoystick.RemoverMotivoParada("Calibragem iniciada"); + } + catch (Exception ex) + { + Variaveis.MostrarLog( + $"[OperacaoModel.RealizarCalibragemInicialAsync] " + + $"Erro ao remover bloqueio manual da calibragem: {ex}" + ); + } + + try + { + _calibragemGate.Release(); + } + catch (Exception ex) + { + Variaveis.MostrarLog( + $"[OperacaoModel.RealizarCalibragemInicialAsync] " + + $"Erro ao liberar gate: {ex}" + ); + } } } diff --git a/AgroBase/AgroBase/Services/CoulombCounterService.cs b/AgroBase/AgroBase/Services/CoulombCounterService.cs index 78039e767..84207092b 100644 --- a/AgroBase/AgroBase/Services/CoulombCounterService.cs +++ b/AgroBase/AgroBase/Services/CoulombCounterService.cs @@ -40,7 +40,12 @@ namespace AgroBase.Services private const double DiferencaCorrenteMinAlerta_A = 3.0; private const double DiferencaCorrenteRelativaAlerta = 0.35; - private const double DtMaximoIntegracao_s = 5.0; + /* + * O Daly observado em campo pode permanecer 2-3 s com o mesmo + * timestamp. A integração só acontece quando chega uma amostra nova. + * O limite de 10 s tolera jitter real sem integrar lacunas longas. + */ + private const double DtMaximoIntegracao_s = 10.0; private readonly double _timeoutBms_s; private readonly double _timeoutSensor_s; @@ -81,8 +86,8 @@ namespace AgroBase.Services double tensao_nominal_V = 36.0, double daly_corrente_descarga_sinal = -1.0, double sensor_corrente_descarga_sinal = 1.0, - double timeout_bms_s = 3.0, - double timeout_sensor_s = 3.0) + double timeout_bms_s = 6.0, + double timeout_sensor_s = 4.0) { CapacidadeMaxima_Ah = Math.Max(0, max_Ah); CargaMaxima_C = CapacidadeMaxima_Ah * 3600.0; @@ -140,6 +145,9 @@ namespace AgroBase.Services private string _ultimaFonteIntegrada = ""; private string _ultimaFonteLogada = ""; + private double _tensaoAnteriorIntegracao_V; + private double _correnteAnteriorIntegracao_A; + public double CapacidadeMaxima_Ah { get; private set; } private double CargaMaxima_C { get; set; } @@ -507,6 +515,18 @@ namespace AgroBase.Services private double _somaEnergiaJanela_Wh; private double _distanciaTotalMetrosAnterior; + private double _distanciaPendenteAutonomia_m; + private bool _intervaloAutonomiaConfiavel; + private bool _aguardandoBaselineAutonomia; + + /* Diagnóstico útil para confirmar o comportamento no próximo campo. */ + public double DistanciaPendenteAutonomia_m => + _distanciaPendenteAutonomia_m; + + public double DistanciaDescartadaAutonomia_m { get; private set; } + + public string EstadoIntervaloAutonomia { get; private set; } = + "aguardando_baseline"; public double WhPorMetroJanela { get; private set; } @@ -607,8 +627,14 @@ namespace AgroBase.Services AplicarFonteAtiva(ativa); + if (_estadoInicializado) + { + AcumularDistanciaPendente(distanciaTotalMetrosAtual); + } + if (!Iniciado || ativa == null) { + InvalidarIntervaloAutonomia("fonte_indisponivel"); MarcarAutonomiaIndisponivel(); return; } @@ -617,6 +643,7 @@ namespace AgroBase.Services if (!leituraAtual.HasValue || leituraAtual.Value == DateTime.MinValue) { + InvalidarIntervaloAutonomia("timestamp_invalido"); MarcarAutonomiaIndisponivel(); return; } @@ -637,16 +664,13 @@ namespace AgroBase.Services * Ao trocar de Daly para AB36V, ou vice-versa, não integra * o intervalo entre relógios/fontes diferentes. */ - if (!string.Equals(_ultimaFonteIntegrada,FonteAtiva, StringComparison.OrdinalIgnoreCase)) + if (!string.Equals(_ultimaFonteIntegrada, FonteAtiva, StringComparison.OrdinalIgnoreCase)) { - _ultimaFonteIntegrada = FonteAtiva; - - _ultimaLeituraIntegrada = leituraAtual.Value; - - _dt = 0; + DescartarIntervaloPendente("troca_fonte"); + PrepararBaselineEletrico(ativa, leituraAtual.Value); AtualizarEstadoEnergia(); - AtualizarAutonomiaSemIntegrar(distanciaTotalMetrosAtual); + RecalcularAutonomia(); return; } @@ -656,26 +680,32 @@ namespace AgroBase.Services _dt = 0; AtualizarEstadoEnergia(); - AtualizarAutonomiaSemIntegrar(distanciaTotalMetrosAtual); + RecalcularAutonomia(); return; } _dt = (leituraAtual.Value - _ultimaLeituraIntegrada.Value).TotalSeconds; - _ultimaLeituraIntegrada = leituraAtual.Value; - if (_dt <= 0 || _dt > DtMaximoIntegracao_s) { Variaveis.MostrarLog($"[COULOMB COUNTER] dt inválido: " + $"{_dt:F3}s | fonte={FonteAtiva}"); + DescartarIntervaloPendente("dt_invalido"); + PrepararBaselineEletrico(ativa, leituraAtual.Value); + AtualizarEstadoEnergia(); - AtualizarAutonomiaSemIntegrar(distanciaTotalMetrosAtual); + RecalcularAutonomia(); return; } - IntegrarEnergiaECarga(_dt); + double deltaEnergiaDescarga_Wh = + IntegrarEnergiaECarga(_dt, ativa); + + _ultimaLeituraIntegrada = leituraAtual.Value; + _tensaoAnteriorIntegracao_V = ativa.Tensao_V; + _correnteAnteriorIntegracao_A = ativa.CorrenteLiquida_A; AtualizarEstadoEnergia(); @@ -683,7 +713,7 @@ namespace AgroBase.Services AtualizarTempoRestante(); - AtualizarAutonomia(distanciaTotalMetrosAtual, _dt); + FecharIntervaloAutonomia(_dt, deltaEnergiaDescarga_Wh); AtualizarDiferencasSoc(); } @@ -937,6 +967,9 @@ namespace AgroBase.Services _ultimaFonteIntegrada = FonteAtiva; + _tensaoAnteriorIntegracao_V = TensaoInstantanea; + _correnteAnteriorIntegracao_A = CorrenteLiquida_A; + _estadoInicializado = true; _dt = 0; @@ -945,12 +978,29 @@ namespace AgroBase.Services ResetarHistoricoAutonomia(distanciaTotalMetrosAtual); - AtualizarAutonomiaSemIntegrar(distanciaTotalMetrosAtual); + RecalcularAutonomia(); } - private void IntegrarEnergiaECarga(double dt_s) + private double IntegrarEnergiaECarga( + double dt_s, + FonteEnergiaSnapshot atual) { - double deltaCargaLiquida_C = CorrenteLiquida_A * dt_s; + /* + * Regra trapezoidal: representa melhor todo o intervalo quando + * as leituras elétricas chegam espaçadas em 2-3 segundos. + */ + double correnteMedia_A = + (_correnteAnteriorIntegracao_A + atual.CorrenteLiquida_A) / + 2.0; + + double tensaoMedia_V = + (_tensaoAnteriorIntegracao_V + atual.Tensao_V) / 2.0; + + double correnteDescargaMedia_A = Math.Max(0, correnteMedia_A); + double correnteCargaMedia_A = Math.Max(0, -correnteMedia_A); + + double deltaCargaLiquida_C = correnteMedia_A * dt_s; + double deltaEnergiaDescarga_Wh = 0; /* * Descarga positiva reduz a carga integrada. @@ -958,9 +1008,9 @@ namespace AgroBase.Services */ _cargaIntegradaAtual_C = Clamp(_cargaIntegradaAtual_C - deltaCargaLiquida_C, 0, CargaMaxima_C); - if (CorrenteDescarga_A > 0) + if (correnteDescargaMedia_A > 0) { - double deltaDescarga_C = CorrenteDescarga_A * dt_s; + double deltaDescarga_C = correnteDescargaMedia_A * dt_s; _consumidoIntegradoTotal_C = Clamp(_consumidoIntegradoTotal_C + deltaDescarga_C, 0, CargaMaxima_C); @@ -969,14 +1019,16 @@ namespace AgroBase.Services _consumidoIntegradoOperacao_C = Clamp(_consumidoIntegradoOperacao_C + deltaDescarga_C, 0, CargaMaxima_C); } - double deltaEnergia_Wh = PotenciaDescarga_W * dt_s / 3600.0; + double deltaEnergia_Wh = + tensaoMedia_V * correnteDescargaMedia_A * dt_s / 3600.0; EnergiaConsumidaTotal_Wh += Math.Max(0, deltaEnergia_Wh); + deltaEnergiaDescarga_Wh = Math.Max(0, deltaEnergia_Wh); } - if (CorrenteCarga_A > 0) + if (correnteCargaMedia_A > 0) { - double deltaCarga_C = CorrenteCarga_A * dt_s; + double deltaCarga_C = correnteCargaMedia_A * dt_s; _carregadoIntegradoTotal_C = Clamp(_carregadoIntegradoTotal_C + deltaCarga_C, 0, CargaMaxima_C); @@ -985,10 +1037,13 @@ namespace AgroBase.Services _carregadoIntegradoOperacao_C = Clamp(_carregadoIntegradoOperacao_C + deltaCarga_C, 0, CargaMaxima_C); } - double deltaEnergiaCarga_Wh = PotenciaCarga_W * dt_s / 3600.0; + double deltaEnergiaCarga_Wh = + tensaoMedia_V * correnteCargaMedia_A * dt_s / 3600.0; EnergiaCarregadaTotal_Wh += Math.Max(0, deltaEnergiaCarga_Wh); } + + return deltaEnergiaDescarga_Wh; } private void AtualizarEstadoEnergia() @@ -1051,22 +1106,73 @@ namespace AgroBase.Services // Autonomia por distância // ============================================================ - private void AtualizarAutonomia(double distanciaTotalMetrosAtual, double deltaTempo_s) + private void AcumularDistanciaPendente(double distanciaTotalMetrosAtual) { double deltaDist_m = CalcularDeltaDistancia(distanciaTotalMetrosAtual); - double deltaEnergia_Wh = PotenciaDescarga_W * Math.Max(0, deltaTempo_s) / 3600.0; + _distanciaPendenteAutonomia_m += deltaDist_m; + } - AtualizarJanelaAutonomia(deltaDist_m, deltaTempo_s, deltaEnergia_Wh); + private void FecharIntervaloAutonomia( + double deltaTempo_s, + double deltaEnergia_Wh) + { + if (_aguardandoBaselineAutonomia) + { + DescartarIntervaloPendente("baseline_autonomia"); + _aguardandoBaselineAutonomia = false; + _intervaloAutonomiaConfiavel = true; + EstadoIntervaloAutonomia = "baseline_estabelecido"; + RecalcularAutonomia(); + return; + } + + if (!_intervaloAutonomiaConfiavel) + { + DescartarIntervaloPendente("intervalo_nao_confiavel"); + _intervaloAutonomiaConfiavel = true; + EstadoIntervaloAutonomia = "recuperado"; + RecalcularAutonomia(); + return; + } + + AtualizarJanelaAutonomia( + _distanciaPendenteAutonomia_m, + deltaTempo_s, + deltaEnergia_Wh + ); + + _distanciaPendenteAutonomia_m = 0; + EstadoIntervaloAutonomia = "integrado"; RecalcularAutonomia(); } - private void AtualizarAutonomiaSemIntegrar(double distanciaTotalMetrosAtual) + private void InvalidarIntervaloAutonomia(string motivo) { - CalcularDeltaDistancia(distanciaTotalMetrosAtual); + _intervaloAutonomiaConfiavel = false; + EstadoIntervaloAutonomia = motivo; + } - RecalcularAutonomia(); + private void DescartarIntervaloPendente(string motivo) + { + DistanciaDescartadaAutonomia_m += + _distanciaPendenteAutonomia_m; + + _distanciaPendenteAutonomia_m = 0; + EstadoIntervaloAutonomia = motivo; + } + + private void PrepararBaselineEletrico( + FonteEnergiaSnapshot fonte, + DateTime timestamp) + { + _ultimaFonteIntegrada = fonte.Nome; + _ultimaLeituraIntegrada = timestamp; + _tensaoAnteriorIntegracao_V = fonte.Tensao_V; + _correnteAnteriorIntegracao_A = fonte.CorrenteLiquida_A; + _dt = 0; + _intervaloAutonomiaConfiavel = true; } private double CalcularDeltaDistancia(double distanciaTotalMetrosAtual) @@ -1314,6 +1420,12 @@ namespace AgroBase.Services _somaEnergiaJanela_Wh = 0; _distanciaTotalMetrosAnterior = distanciaAtual_m; + _distanciaPendenteAutonomia_m = 0; + _intervaloAutonomiaConfiavel = true; + _aguardandoBaselineAutonomia = true; + + DistanciaDescartadaAutonomia_m = 0; + EstadoIntervaloAutonomia = "aguardando_baseline"; WhPorMetroJanela = 0; WhPorMetroHistorico = 0; @@ -1349,6 +1461,9 @@ namespace AgroBase.Services _ultimaFonteIntegrada = ""; _ultimaFonteLogada = ""; + _tensaoAnteriorIntegracao_V = 0; + _correnteAnteriorIntegracao_A = 0; + _cargaIntegradaAtual_C = 0; CargaAtual_C = 0; diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/camera_multispectral.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/camera_multispectral.py index c446bc514..4e520db7e 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/camera_multispectral.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/camera_worker/camera_multispectral.py @@ -179,7 +179,7 @@ class CameraMultispectral: nome_sensor=f"{self.modelo}_imu", modo=self.imu_modo, gyro_unidade="deg_s", - referencial_robo="r,p,y", + referencial_robo="-r,p,-y", ) else: self.tem_imu = False @@ -752,12 +752,30 @@ class CameraMultispectral: if self.imu is not None: self.imu.atualizar_saude() - conectado_ctx = (ContextoGlobalRedis.get_cameras() or {}).get(self.mx_id) is not None + # ============================================================ + # CONFIGURAÇÃO DE ROBUSTEZ + # ============================================================ + + # Quanto tempo uma degradação transitória pode persistir antes + # de ser promovida para FALHA. + # + # Como a saúde normalmente roda a cada ~500 ms: + # 1º ciclo ruim -> ALERTA + # 2º ciclo ruim -> ALERTA + # persistiu ~1 s -> FALHA + TEMPO_CONFIRMACAO_FALHA_S = 1.0 + + conectado_ctx = ( + (ContextoGlobalRedis.get_cameras() or {}).get(self.mx_id) + is not None + ) motivos = [] saude = 50 + status = self._safe_get_status() running = bool(status.get("running", False)) + conectado = bool( self.iniciado and self.client is not None @@ -765,6 +783,10 @@ class CameraMultispectral: and not self._falha_fatal_pendente ) + # Falha realmente crítica de hardware/pipeline. + # Estas NÃO recebem tolerância. + falha_critica_imediata = False + performance = { "temperatura": 0.0, "memoria_usada": 0.0, @@ -774,12 +796,22 @@ class CameraMultispectral: "async_capture": status.get("async_capture") or {}, } + # ============================================================ + # PIPELINE IA + # ============================================================ + if pipeline_ia is not None: self._ultimo_pipeline_ia = dict(pipeline_ia) + pipeline_ia = dict(self._ultimo_pipeline_ia or {}) - pipeline_ia_ok = bool(pipeline_ia.get("ok", False)) + pipeline_ia_raw_ok = bool(pipeline_ia.get("ok", False)) + performance["pipeline_ia"] = pipeline_ia + # ============================================================ + # IMU + # ============================================================ + imu = self.imu.get_dados() if self.imu else { "valido": False, "calibrado": False, @@ -800,68 +832,118 @@ class CameraMultispectral: resultado = dict(self._ultimo_resultado_tensor) + # ============================================================ + # MÉTRICAS DO DEVICE + # ============================================================ + try: metrics = ( self.client.get_device_metrics() if self.client is not None - else {"ok": False, "error": "client indisponível"} + else { + "ok": False, + "error": "client indisponível", + } ) + performance["device_metrics"] = metrics temp_raw = metrics.get("temperature_average_c") + if temp_raw is None: performance["temperatura"] = None motivos.append("Temperatura interna indisponível") saude -= 10 + else: temp = float(temp_raw) performance["temperatura"] = temp + if temp >= 100: - motivos.append(f"Temperatura de emergência: {temp:.1f} °C") + motivos.append( + f"Temperatura de emergência: {temp:.1f} °C" + ) saude = min(saude, 10) + falha_critica_imediata = True + elif temp >= 95: - motivos.append(f"Temperatura crítica: {temp:.1f} °C") + motivos.append( + f"Temperatura crítica: {temp:.1f} °C" + ) saude = min(saude, 35) + elif temp >= 85: - motivos.append(f"Temperatura elevada: {temp:.1f} °C") + motivos.append( + f"Temperatura elevada: {temp:.1f} °C" + ) saude -= 15 + elif temp >= 75: - motivos.append(f"Aquecimento preventivo: {temp:.1f} °C") + motivos.append( + f"Aquecimento preventivo: {temp:.1f} °C" + ) saude -= 5 - ddr = float(metrics.get("ddr_used_bytes", 0) or 0) / 1024.0 / 1024.0 + ddr = ( + float(metrics.get("ddr_used_bytes", 0) or 0) + / 1024.0 + / 1024.0 + ) + performance["memoria_usada"] = ddr if ddr >= 500: - motivos.append(f"Memória DDR crítica: {ddr:.1f} MB") + motivos.append( + f"Memória DDR crítica: {ddr:.1f} MB" + ) saude -= 30 + elif ddr >= 400: - motivos.append(f"Memória DDR elevada: {ddr:.1f} MB") + motivos.append( + f"Memória DDR elevada: {ddr:.1f} MB" + ) saude -= 15 + elif ddr >= 300: - motivos.append(f"Memória DDR acima do ideal: {ddr:.1f} MB") + motivos.append( + f"Memória DDR acima do ideal: {ddr:.1f} MB" + ) saude -= 5 speed = str(metrics.get("usb_speed", "") or "") performance["velocidade"] = speed + if speed and speed.lower() not in ("super", "superplus"): motivos.append(f"USB lenta: {speed}") saude -= 15 - async_status = metrics.get("async_capture") or performance["async_capture"] + async_status = ( + metrics.get("async_capture") + or performance["async_capture"] + ) + performance["async_capture"] = async_status if running and async_status: if not bool(async_status.get("thread_alive", False)): - motivos.append("Thread assíncrona de captura parada") + motivos.append( + "Thread assíncrona de captura parada" + ) + + falha_critica_imediata = True saude = 0 + erro_async = async_status.get("last_error") + if erro_async: - motivos.append(f"Captura assíncrona: {erro_async}") + motivos.append( + f"Captura assíncrona: {erro_async}" + ) if not running: motivos.append("Pipeline parada") - saude -= 20 + falha_critica_imediata = True + saude = 0 except Exception as e: resultado = { @@ -869,66 +951,243 @@ class CameraMultispectral: "duracao": 0.0, "frame_valido": False, } + if self._is_erro_fatal_depthai(e): conectado = False + falha_critica_imediata = True + + # ============================================================ + # TENSOR + # ============================================================ agora = time.time() + agora_mono = time.monotonic() + ts_tensor = self.timestamp_ultimo_tensor or 0.0 - frame_recente = ts_tensor > 0 and (agora - ts_tensor) <= 2.0 + + idade_tensor = ( + agora - ts_tensor + if ts_tensor > 0 + else float("inf") + ) + + frame_recente_raw = ( + ts_tensor > 0 + and idade_tensor <= 2.0 + ) try: - performance["sync_dt_ms"] = float(resultado.get("sync_dt_ms", 0.0) or 0.0) + performance["sync_dt_ms"] = float( + resultado.get("sync_dt_ms", 0.0) or 0.0 + ) except Exception: pass + # ============================================================ + # PERSISTÊNCIA DO TENSOR ATRASADO + # ============================================================ + + tensor_falha_confirmada = False + tensor_degradado = False + + if frame_recente_raw: + # Recuperou -> limpa imediatamente a persistência. + self._tensor_atrasado_desde = None + + else: + tensor_degradado = True + + inicio = getattr( + self, + "_tensor_atrasado_desde", + None, + ) + + if inicio is None: + self._tensor_atrasado_desde = agora_mono + inicio = agora_mono + + duracao_atraso = agora_mono - inicio + + if duracao_atraso >= TEMPO_CONFIRMACAO_FALHA_S: + tensor_falha_confirmada = True + + # ============================================================ + # ESTADO BASE DO TENSOR / CÂMERA + # ============================================================ + if not conectado and not conectado_ctx: motivos.append("desconectado") saude = 0 + falha_critica_imediata = True + elif resultado.get("erro"): motivos.append(resultado["erro"]) saude = 0 + falha_critica_imediata = True + elif not resultado.get("frame_valido", False): motivos.append("Tensor inválido ou indisponível") saude = 0 + falha_critica_imediata = True + else: saude += 50 - if not frame_recente: - motivos.append(f"Tensor sem atualização há {agora - ts_tensor:.2f}s") - saude = 0 + if tensor_degradado: + motivos.append( + f"Tensor sem atualização há {idade_tensor:.2f}s" + ) + + if tensor_falha_confirmada: + saude = 0 + else: + # Existe problema, mas ainda é transitório. + # Mantemos como ALERTA. + saude = min(saude, 70) + + dur = float( + resultado.get("duracao", 0.0) or 0.0 + ) - dur = float(resultado.get("duracao", 0.0) or 0.0) if dur > 1.5: saude -= 20 - motivos.append(f"Tempo elevado para captura tensor: {dur:.2f}s") + motivos.append( + f"Tempo elevado para captura tensor: {dur:.2f}s" + ) + elif dur > 0.8: saude -= 10 - motivos.append(f"Tempo moderado para captura tensor: {dur:.2f}s") + motivos.append( + f"Tempo moderado para captura tensor: {dur:.2f}s" + ) + + sync_dt = float( + resultado.get("sync_dt_ms", 0.0) or 0.0 + ) - sync_dt = float(resultado.get("sync_dt_ms", 0.0) or 0.0) if sync_dt > 35.0: saude -= 10 - motivos.append(f"Sincronismo alto: {sync_dt:.1f} ms") + motivos.append( + f"Sincronismo alto: {sync_dt:.1f} ms" + ) + + # ============================================================ + # PERSISTÊNCIA DO PIPELINE IA + # ============================================================ + + pipeline_falha_confirmada = False + pipeline_degradado = False + + if pipeline_ia_raw_ok: + # Recuperou -> limpa imediatamente. + self._pipeline_ia_falha_desde = None + + else: + pipeline_degradado = True - if not pipeline_ia_ok: for motivo in pipeline_ia.get("motivos", []) or []: texto = f"Pipeline IA: {motivo}" + if texto not in motivos: motivos.append(texto) - saude = 0 - motivos = list(dict.fromkeys(str(m) for m in motivos if m)) + inicio = getattr( + self, + "_pipeline_ia_falha_desde", + None, + ) + + if inicio is None: + self._pipeline_ia_falha_desde = agora_mono + inicio = agora_mono + + duracao_falha = agora_mono - inicio + + if duracao_falha >= TEMPO_CONFIRMACAO_FALHA_S: + pipeline_falha_confirmada = True + saude = 0 + + else: + # Não escondemos a degradação. + # Só impedimos que vire FALHA imediatamente. + saude = min(saude, 70) + + # ============================================================ + # DIAGNÓSTICO DE PERSISTÊNCIA + # ============================================================ + + performance["watchdog"] = { + "tensor_degradado": tensor_degradado, + "tensor_falha_confirmada": tensor_falha_confirmada, + "pipeline_degradado": pipeline_degradado, + "pipeline_falha_confirmada": pipeline_falha_confirmada, + "tempo_confirmacao_s": TEMPO_CONFIRMACAO_FALHA_S, + } + + # ============================================================ + # RESULTADO FINAL + # ============================================================ + + motivos = list( + dict.fromkeys( + str(m) + for m in motivos + if m + ) + ) + saude = min(max(saude, 0), 100) - status_mod = StatusModulo.OPERANTE + # Durante a janela de tolerância, consideramos o pipeline + # operacional, mas degradado. + frame_ok_operacional = ( + frame_recente_raw + or ( + tensor_degradado + and not tensor_falha_confirmada + ) + ) + + pipeline_ok_operacional = ( + pipeline_ia_raw_ok + or ( + pipeline_degradado + and not pipeline_falha_confirmada + ) + ) + + # ============================================================ + # STATUS + # ============================================================ + if not conectado: status_mod = StatusModulo.DESCONECTADO - elif not frame_recente or not pipeline_ia_ok or saude <= 0: + + elif ( + falha_critica_imediata + or tensor_falha_confirmada + or pipeline_falha_confirmada + ): status_mod = StatusModulo.FALHA - elif saude < 80: + + elif ( + tensor_degradado + or pipeline_degradado + or saude < 80 + ): status_mod = StatusModulo.ALERTA - self.rodando = bool(conectado and frame_recente and pipeline_ia_ok) + else: + status_mod = StatusModulo.OPERANTE + + # Durante ALERTA transitório continua rodando. + self.rodando = bool( + conectado + and frame_ok_operacional + and pipeline_ok_operacional + and not falha_critica_imediata + ) saude_geral = { "timestamp": agora, @@ -938,6 +1197,7 @@ class CameraMultispectral: "motivos": motivos, "saude_individual": [], } + self.ultima_saude = saude_geral from camera_worker.manager import definir_saude_camera diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/main.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/main.py index e6613785c..eed422331 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/main.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/main.py @@ -30,7 +30,7 @@ def main(): T_Code.Sen: ModuloSensoriamento(), T_Code.Atu: ModuloAtuador(), #T_Code.Lra: ModuloLoRa(), - T_Code.Imu: CameraIMU(modulos_imu=[T_Code.Snr, T_Code.Cam]), + T_Code.Imu: CameraIMU(modulos_imu=[T_Code.Snr]), T_Code.Npc: ModuloPC(), T_Code.Lvx: ModuloLivox(), T_Code.Ipb: ModuloIPBribge(), diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/gps.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/gps.py index 6e5132f7f..2f119b290 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/gps.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/gps.py @@ -1,356 +1,628 @@ import math import time + from shared.enums import T_Code, StatusModulo -from shared.contexto_global_redis import ContextoGlobalRedis, CtxKey +from shared.contexto_global_redis import ContextoGlobalRedis from health_worker.modulos.base import ModuloDiagnosticoBase + class ModuloGPS(ModuloDiagnosticoBase): + """Health do UM982 preparado para operacao real de campo. + + Principios: + - perda de pacote/frequencia momentanea nao derruba a saude; + - ausencia REAL de GGA/THS continua sendo tratada como falha; + - qualidade RTK e fonte da correcao sao coisas diferentes; + - alertas entram e saem com persistencia/histerese; + - frequencia e avaliada por faixas, nao por diferenca percentual do alvo; + - NTRIP e apenas fallback. NTRIP desligado nao e defeito se o RTK esta bom. + """ + + # ------------------------------------------------------------------ + # Recepcao das sentencas + # ------------------------------------------------------------------ + RX_WARN_RATIO = 0.60 # abaixo de 60% da freq_base pode gerar alerta + RX_FAIL_AGE_S = 3.0 # nenhuma sentenca por 3 s = candidato a falha + RX_FAIL_PERSIST_S = 2.0 # confirma perda somente se persistir + RX_RECOVER_S = 2.0 # exige dados frescos por 2 s para limpar falha + + FREQ_WARN_PERSIST_S = 8.0 # frequencia baixa precisa durar 8 s + FREQ_RECOVER_S = 4.0 # recuperacao sustentada antes de limpar alerta + FREQ_PENALTY = 8 # penalidade pequena; freshness e mais importante + + # ------------------------------------------------------------------ + # Heading / THS + # ------------------------------------------------------------------ + ORI_INVALID_PERSIST_S = 2.0 + ORI_RECOVER_S = 2.0 + ORI_INVALID_PENALTY = 40 + + # ------------------------------------------------------------------ + # RTK + # ------------------------------------------------------------------ + AGE_GOOD_S = 3.0 + AGE_WARN_S = 5.0 + AGE_FAIL_S = 15.0 + RTK_FAIL_PERSIST_S = 3.0 + RTK_RECOVER_S = 4.0 + + FIX_BAD_PERSIST_S = 3.0 + FIX_RECOVER_S = 3.0 + + HACC_WARN_CM = 40.0 + HACC_BAD_CM = 120.0 + HACC_PERSIST_S = 5.0 + HACC_RECOVER_S = 3.0 + + SAUDE_MIN_ALERTA = 80 + def __init__(self): self.t_code = T_Code.Gps self.nome = "UM982" self.timeout = 5 - self._rtk_expirado_desde = 0.0 - self._rtk_recuperado_desde = 0.0 - self._rtk_falha_persistente = False + # Cada condicao possui latch proprio. Assim um unico frame ruim nao + # cria um alerta e um unico frame bom tambem nao mascara uma falha. + self._persist = {} + # ================================================================== + # API PRINCIPAL + # ================================================================== def atualizar_saude(self): try: - now = time.perf_counter() + agora_mono = time.monotonic() modulo = ContextoGlobalRedis.get_modulo(self.t_code) or {} - FREQ_BASE = modulo.get("freq_base", 1) - FREQ_MIN = FREQ_BASE * 0.2 - SAUDE_MIN_ALERTA = 80 - conectado = modulo.get("conectado", False) - freq_pos = modulo.get("freq", {}).get("posicao", 0.0) - freq_ori = modulo.get("freq", {}).get("orientacao", 0.0) - ts_pos = modulo.get("timestamp", {}).get("posicao", 0.0) - ts_ori = modulo.get("timestamp", {}).get("orientacao", 0.0) - fail_pos = (now - ts_pos) > 2.0 - fail_ori = (now - ts_ori) > 2.0 - fix = modulo.get("fix", 0) - rtk = modulo.get("rtk", False) - hAcc = modulo.get("hAcc", 0) - age_bruto = modulo.get("age", -1) - ntrip = modulo.get("correcao_ntrip", False) + conectado = self._bool(modulo.get("conectado", False)) + freq_base = self._float(modulo.get("freq_base", 1.0), 1.0) + if not math.isfinite(freq_base) or freq_base <= 0: + freq_base = 1.0 + + freq = modulo.get("freq") or {} + timestamps = modulo.get("timestamp") or {} + + freq_pos = max(0.0, self._float(freq.get("posicao", 0.0), 0.0)) + freq_ori = max(0.0, self._float(freq.get("orientacao", 0.0), 0.0)) + ts_pos = self._float(timestamps.get("posicao", 0.0), 0.0) + ts_ori = self._float(timestamps.get("orientacao", 0.0), 0.0) + + idade_pos = self._idade_timestamp(ts_pos) + idade_ori = self._idade_timestamp(ts_ori) + + fix = self._int(modulo.get("fix", 0), 0) + rtk = self._bool(modulo.get("rtk", False)) + hacc = self._float(modulo.get("hAcc", 0.0), 0.0) + age = self._float(modulo.get("age", -1.0), -1.0) + age_valido = math.isfinite(age) and age >= 0.0 + + # correcao_ntrip descreve A FONTE usada como fallback, nao a + # qualidade da solucao GNSS. Nunca penalizar a saude so por False. + correcao_ntrip = self._bool(modulo.get("correcao_ntrip", False)) + orientacao = modulo.get("orientacao") or {} status_ori = str(orientacao.get("status", "")).strip().upper() - valor_ori_bruto = orientacao.get("valor", 9999.0) - valida_redis = orientacao.get("valida", False) + valor_ori = self._float(orientacao.get("valor", math.nan), math.nan) + valida_ori = self._bool(orientacao.get("valida", False)) + valor_ori_valido = math.isfinite(valor_ori) and 0.0 <= valor_ori < 360.0 - try: - age = float(age_bruto) - except (TypeError, ValueError): - age = -1.0 + # ---------------------------------------------------------- + # Persistencias / latches + # ---------------------------------------------------------- + perda_pos = self._persistente( + "perda_pos", + ruim=idade_pos > self.RX_FAIL_AGE_S, + agora=agora_mono, + entrar_s=self.RX_FAIL_PERSIST_S, + sair_s=self.RX_RECOVER_S, + ) + perda_ori = self._persistente( + "perda_ori", + ruim=idade_ori > self.RX_FAIL_AGE_S, + agora=agora_mono, + entrar_s=self.RX_FAIL_PERSIST_S, + sair_s=self.RX_RECOVER_S, + ) - AGE_WARN = 5.0 - AGE_FAIL = 15.0 - AGE_RECOVER = 3.0 - T_FAIL = 2.0 - T_RECOVER = 3.0 - agora_mono = time.monotonic() - age_valido = math.isfinite(age) and age >= 0.0 - correcao_expirada = not age_valido or age >= AGE_FAIL - correcao_recuperada = (age_valido and age <= AGE_RECOVER and bool(rtk)) - if correcao_expirada: - if self._rtk_expirado_desde <= 0: - self._rtk_expirado_desde = agora_mono - self._rtk_recuperado_desde = 0.0 - if (agora_mono - self._rtk_expirado_desde >= T_FAIL): - self._rtk_falha_persistente = True - elif correcao_recuperada: - self._rtk_expirado_desde = 0.0 - if self._rtk_recuperado_desde <= 0: - self._rtk_recuperado_desde = agora_mono - if (agora_mono - self._rtk_recuperado_desde >= T_RECOVER): - self._rtk_falha_persistente = False - else: - self._rtk_expirado_desde = 0.0 - self._rtk_recuperado_desde = 0.0 + freq_min_alerta = freq_base * self.RX_WARN_RATIO + freq_pos_baixa = self._persistente( + "freq_pos_baixa", + ruim=(idade_pos <= self.RX_FAIL_AGE_S and freq_pos < freq_min_alerta), + agora=agora_mono, + entrar_s=self.FREQ_WARN_PERSIST_S, + sair_s=self.FREQ_RECOVER_S, + ) + freq_ori_baixa = self._persistente( + "freq_ori_baixa", + ruim=(idade_ori <= self.RX_FAIL_AGE_S and freq_ori < freq_min_alerta), + agora=agora_mono, + entrar_s=self.FREQ_WARN_PERSIST_S, + sair_s=self.FREQ_RECOVER_S, + ) - # Proteção caso o Redis devolva booleano como texto. - if isinstance(valida_redis, str): - valida_redis = valida_redis.strip().lower() in ( - "true", - "1", - "yes", - "sim", - ) - else: - valida_redis = bool(valida_redis) + ori_invalida_agora = not ( + idade_ori <= self.RX_FAIL_AGE_S + and valida_ori + and status_ori == "A" + and valor_ori_valido + ) + ori_invalida = self._persistente( + "ori_invalida", + ruim=ori_invalida_agora and not perda_ori, + agora=agora_mono, + entrar_s=self.ORI_INVALID_PERSIST_S, + sair_s=self.ORI_RECOVER_S, + ) - try: - valor_ori = float(valor_ori_bruto) - except (TypeError, ValueError): - valor_ori = 9999.0 + # Fix 4 = RTK Fixed; 5 = RTK Float. Float e degradado, nao falha. + fix_ruim = fix not in (4, 5) + fix_ruim_persistente = self._persistente( + "fix_ruim", + ruim=fix_ruim, + agora=agora_mono, + entrar_s=self.FIX_BAD_PERSIST_S, + sair_s=self.FIX_RECOVER_S, + ) - valor_ori_valido = (math.isfinite(valor_ori) and 0.0 <= valor_ori < 360.0) - orientacao_recebendo = (freq_ori > FREQ_MIN and not fail_ori) - posicao_recebendo = (freq_pos > FREQ_MIN and not fail_pos) - orientacao_valida = (orientacao_recebendo and valida_redis and status_ori == "A" and valor_ori_valido) + correcao_expirada = (not age_valido) or age >= self.AGE_FAIL_S + rtk_falha_persistente = self._persistente( + "rtk_expirado", + ruim=correcao_expirada, + agora=agora_mono, + entrar_s=self.RTK_FAIL_PERSIST_S, + sair_s=self.RTK_RECOVER_S, + ) - saude = 100 + hacc_ruim_agora = ( + (not math.isfinite(hacc)) + or hacc <= 0.0 + or hacc >= self.HACC_WARN_CM + ) + hacc_ruim = self._persistente( + "hacc_ruim", + ruim=hacc_ruim_agora, + agora=agora_mono, + entrar_s=self.HACC_PERSIST_S, + sair_s=self.HACC_RECOVER_S, + ) + + # ---------------------------------------------------------- + # Score de saude + # ---------------------------------------------------------- + saude = 100.0 motivos = [] if not conectado: + saude = 0.0 motivos.append("GNSS desconectado") - saude = 0 - - elif not posicao_recebendo: - motivos.append("Sem resposta de coordenadas GGA") - saude = 0 - - elif not orientacao_recebendo: - motivos.append("Sem resposta da sentença de orientação THS") - saude = 0 else: - f_pos = min(freq_pos, FREQ_BASE) - erro_pos = (FREQ_BASE - f_pos) / FREQ_BASE - p_freq_pos = min(int(erro_pos * 100), 40) - saude -= abs(p_freq_pos) - if abs(p_freq_pos) > 0: - motivos.append(f"Frequência de coordenadas baixa: {f_pos:.2f} Hz (penalidade {p_freq_pos}%)") + # Ausencia persistente de sentenca e falha real de comunicacao. + if perda_pos: + saude = 0.0 + motivos.append( + f"Sem coordenadas GGA recentes ha {idade_pos:.1f}s" + ) - f_ori = min(freq_ori, FREQ_BASE) - erro_ori = (FREQ_BASE - f_ori) / FREQ_BASE - p_freq_ori = min(int(erro_ori * 100), 40) - saude -= abs(p_freq_ori) - if abs(p_freq_ori) > 0: - motivos.append(f"Frequência de orientacao baixa: {f_ori:.2f} Hz (penalidade {p_freq_ori}%)") - - # A sentença está chegando, mas a solução pode estar inválida. - if not orientacao_valida: - P_ORIENTACAO_INVALIDA = 45 - saude -= P_ORIENTACAO_INVALIDA + if perda_ori: + saude = 0.0 + motivos.append( + f"Sem orientacao THS recente ha {idade_ori:.1f}s" + ) + # Frequencia baixa isoladamente e diagnostico leve. O dado + # continua util se esta chegando fresco. + if not perda_pos and freq_pos_baixa: + saude -= self.FREQ_PENALTY + motivos.append( + "Frequencia GGA persistentemente baixa: " + f"{freq_pos:.2f} Hz (esperado ~{freq_base:.2f} Hz, " + f"penalidade {self.FREQ_PENALTY}%)" + ) + + if not perda_ori and freq_ori_baixa: + saude -= self.FREQ_PENALTY + motivos.append( + "Frequencia THS persistentemente baixa: " + f"{freq_ori:.2f} Hz (esperado ~{freq_base:.2f} Hz, " + f"penalidade {self.FREQ_PENALTY}%)" + ) + + if not perda_ori and ori_invalida: + saude -= self.ORI_INVALID_PENALTY if status_ori == "V": - motivos.append( - "Sentença THS recebida, mas solução de heading inválida " - f"(status V, penalidade {P_ORIENTACAO_INVALIDA}%)" - ) - + detalhe = "status V" elif not valor_ori_valido: - motivos.append( - "Sentença THS recebida com valor de heading inválido " - f"({valor_ori}, penalidade {P_ORIENTACAO_INVALIDA}%)" - ) - + detalhe = f"heading invalido ({valor_ori})" + elif not valida_ori: + detalhe = "orientacao nao marcada como valida" else: - motivos.append( - "Sentença THS recebida, mas orientação não foi marcada " - f"como válida (penalidade {P_ORIENTACAO_INVALIDA}%)" + detalhe = f"status inesperado ({status_ori or 'vazio'})" + + motivos.append( + "Solucao de heading THS persistentemente invalida: " + f"{detalhe} (penalidade {self.ORI_INVALID_PENALTY}%)" + ) + + # Fix: so penaliza depois de persistir. RTK Float recebe uma + # penalidade leve mesmo sendo utilizavel. + if fix == 5: + saude -= 5 + motivos.append("Solucao RTK Float (penalidade 5%)") + elif fix_ruim_persistente: + p_fix = self._penalidade_fix(fix) + saude -= p_fix + motivos.append( + f"Fix GNSS inadequado persistente: {fix} " + f"(penalidade {p_fix}%)" + ) + + # Age e avaliado em faixas. Nao descontamos continuamente + # desde age=0 e nao citamos ntrip=False como defeito. + if rtk_falha_persistente: + saude = 0.0 + detalhe_age = f"{age:.1f}s" if age_valido else "indisponivel" + motivos.append( + "Correcao RTK expirada persistentemente: " + f"age={detalhe_age}, limite={self.AGE_FAIL_S:.1f}s" + ) + elif age_valido and age >= self.AGE_WARN_S: + p_age = self._penalidade_age(age) + saude -= p_age + fonte = "NTRIP" if correcao_ntrip else "correcao primaria/radio" + motivos.append( + f"Correcoes RTK envelhecidas: age={age:.1f}s " + f"(fonte={fonte}, penalidade {p_age}%)" + ) + + # rtk=False sozinho nao zera saude. O fix e o age dizem se a + # solucao de fato esta ruim. Evita dupla/tripla penalizacao. + if not rtk and not fix_ruim_persistente and not rtk_falha_persistente: + saude -= 5 + motivos.append( + "Flag RTK inativa apesar de fix/correcao ainda utilizaveis " + "(penalidade 5%)" + ) + + if hacc_ruim: + p_hacc = self._penalidade_hacc(hacc) + if p_hacc > 0: + saude -= p_hacc + hacc_txt = ( + f"{hacc:.2f} cm" + if math.isfinite(hacc) and hacc > 0 + else "indisponivel" + ) + motivos.append( + f"hAcc persistentemente degradado: {hacc_txt} " + f"(penalidade {p_hacc}%)" ) - - # 🔹 Fix - - p_fix = self._penalidade_fix(fix) - saude -= p_fix - if fix < 4: - motivos.append(f"Fix inadequado: {fix} (penalidade {p_fix}%)") - # 🔹 RTK - p_age = self._penalidade_age(age) - if not rtk: - p_rtk = max(20, p_age) - saude -= p_rtk - motivos.append(f"RTK inativo (penalidade {p_rtk}%; age={age:.1f}s)") - else: - # RTK reporta ativo, mas pode estar degradado se age alto - if p_age > 0: - saude -= p_age - motivos.append(f"Correção RTK degradada (age={age:.1f}s, penalidade {p_age}%, ntrip: {ntrip})") - - if self._rtk_falha_persistente: - saude = 0 - motivos.append(f"Correção RTK expirada persistentemente: age={age:.1f}s, limite={AGE_FAIL:.1f}s") - # 🔹 hAcc - p_hacc = self._penalidade_hacc(hAcc) - if p_hacc > 0: - saude -= p_hacc - motivos.append(f"hAcc {'nao definido' if hAcc == 0 else 'alto'}: {hAcc:.2f} cm (penalidade {p_hacc}%)") - - saude = max(round(saude, 1), 0) + saude = max(0.0, min(100.0, round(saude, 1))) if not conectado: status = StatusModulo.DESCONECTADO - - elif not posicao_recebendo or not orientacao_recebendo: + elif perda_pos or perda_ori or saude <= 0: status = StatusModulo.FALHA - - elif saude <= 0: - status = StatusModulo.FALHA - - elif saude < SAUDE_MIN_ALERTA: + elif saude < self.SAUDE_MIN_ALERTA: status = StatusModulo.ALERTA - else: status = StatusModulo.OPERANTE - + saude_geral = { - "conectado": conectado, + "conectado": conectado, "status": status.value, - "saude": saude, - "motivos": motivos, - "saude_idividual": [] + "saude": saude, + "motivos": motivos, + "saude_idividual": [], + # Diagnostico adicional. Nao interfere na interface antiga e + # facilita entender o comportamento no proximo log de campo. + "diagnostico": { + "freq_base_hz": round(freq_base, 3), + "freq_pos_hz": round(freq_pos, 3), + "freq_ori_hz": round(freq_ori, 3), + "idade_pos_s": self._round_age(idade_pos), + "idade_ori_s": self._round_age(idade_ori), + "fix": fix, + "rtk": rtk, + "age_s": round(age, 2) if age_valido else None, + "hacc_cm": round(hacc, 2) if math.isfinite(hacc) and hacc > 0 else None, + "heading_valido": not ori_invalida, + "correcao_ntrip": correcao_ntrip, + "fonte_correcao": "ntrip" if correcao_ntrip else "primaria_radio", + "persistencias": { + "perda_pos": perda_pos, + "perda_ori": perda_ori, + "freq_pos_baixa": freq_pos_baixa, + "freq_ori_baixa": freq_ori_baixa, + "orientacao_invalida": ori_invalida, + "fix_ruim": fix_ruim_persistente, + "rtk_expirado": rtk_falha_persistente, + "hacc_ruim": hacc_ruim, + }, + }, } ContextoGlobalRedis.atualizar_ctx_dict( ContextoGlobalRedis.ModKey(self.t_code), - saude=saude_geral + saude=saude_geral, ) - # Fallback de correcao RTK via NTRIP + # Fallback automatico para NTRIP permanece independente do score. + # A funcao usa fix/age/hAcc, nao os alertas de frequencia. if conectado: - AGE_HI = 5 # s (ruim) - AGE_LO = 2 # s (bem estabilizado) - HACC_OK = 40 # cm - T_BAD = 4 # s p/ ligar NTRIP - T_GOOD = 10 # s p/ desligar NTRIP - MIN_ON = 25 # s mínimo ligado - MIN_OFF = 12 # s mínimo desligado - - # ---- NOVOS PARÂMETROS DE RETENTATIVA/BACKOFF ---- - MAX_TRIES = 3 # nº máx de tentativas falhas de voltar ao LoRa - PROBE_EVAL_WINDOW = 30 # s: janela máxima de avaliação ao desligar NTRIP p/ testar LoRa - PROBE_BACKOFF = 5 * 60 # s: espera entre testes após esgotar tentativas - PROBE_MIN_OFF = max(MIN_OFF, 5) # s: mínimo off durante um "probe" curto - - estado = ContextoGlobalRedis.get_modulo(self.t_code) or {} - - correcao_ntrip = bool(estado.get("correcao_ntrip", False)) - last_switch_ts = float(estado.get("ntrip_last_switch_ts", 0)) - bad_since_ts = float(estado.get("ntrip_bad_since_ts", 0)) - good_since_ts = float(estado.get("ntrip_good_since_ts", 0)) - - # ---- NOVOS CAMPOS DE CONTROLE ---- - probe_attempts = int(estado.get("ntrip_probe_attempts", 0)) # falhas acumuladas - probe_start_ts = float(estado.get("ntrip_probe_start_ts", 0)) # quando começou o teste atual (NTRIP desligado) - next_probe_ts = float(estado.get("ntrip_next_probe_ts", 0)) # quando pode tentar de novo (backoff) - - now = time.time() - - # Qualidade atual - qual_ruim = (fix < 4) or (age > AGE_HI) or (hAcc >= HACC_OK and hAcc > 0) - qual_boa = (fix >= 4) and (age < AGE_LO) and (0 < hAcc < HACC_OK) - severa = (fix <= 2) or (age > 15) - - # Atualiza contadores de estabilidade - if qual_ruim: - bad_since_ts = bad_since_ts or now - good_since_ts = 0 - elif qual_boa: - good_since_ts = good_since_ts or now - bad_since_ts = 0 - else: - bad_since_ts = 0 - good_since_ts = 0 - - # Cooldowns - tempo_desde_troca = now - last_switch_ts - pode_ligar = (not correcao_ntrip) and (tempo_desde_troca >= MIN_OFF) - pode_deslig = correcao_ntrip and (tempo_desde_troca >= MIN_ON) - - # -------------------------------------------------- - # LÓGICA COM TENTATIVAS/BACKOFF - # -------------------------------------------------- - ligar = False - desligar = False - - if not correcao_ntrip: - # Estamos em LoRa (ou sem correção) → decidir ligar NTRIP - if severa: - ligar = True - elif qual_ruim and bad_since_ts and (now - bad_since_ts >= T_BAD) and pode_ligar: - ligar = True - - # Se estamos em um "probe" (testando LoRa) e já passou a janela sem estabilizar, contabiliza falha - if probe_start_ts and (now - probe_start_ts >= PROBE_EVAL_WINDOW) and not qual_boa: - # falha de tentativa - probe_attempts += 1 - # força ligar NTRIP e inicia backoff - ligar = True - next_probe_ts = now + PROBE_BACKOFF - probe_start_ts = 0 # encerra janela de teste - - else: - # Estamos com NTRIP ligado → decidir desligar (testar LoRa) - # Regra normal: só desliga se qualidade está boa e estável - pronto_pra_desligar = qual_boa and good_since_ts and (now - good_since_ts >= T_GOOD) and pode_deslig - - if pronto_pra_desligar: - if probe_attempts >= MAX_TRIES and now < next_probe_ts: - # Em modo "persistente NTRIP": já falhou muitas vezes, respeitar backoff (não desliga ainda) - desligar = False - else: - # Podemos testar o LoRa: desliga NTRIP e inicia janela de avaliação ("probe") - desligar = True - probe_start_ts = now - # zera good_since_ts para exigir nova estabilidade sem NTRIP - good_since_ts = 0 - - # Aplicar decisão - if ligar and pode_ligar: - correcao_ntrip = True - last_switch_ts = now - # ao ligar NTRIP, encerra probe (se existia) - probe_start_ts = 0 - - elif desligar and pode_deslig: - correcao_ntrip = False - last_switch_ts = now - # iniciar "probe": garantir um off mínimo - if next_probe_ts and now < next_probe_ts: - # segurança: se por algum motivo desligou antes do backoff, reativa NTRIP rápido - pass - # mantém off por pelo menos PROBE_MIN_OFF (garantido indiretamente via pode_ligar) - - # Se NTRIP está desligado e qualidade ficou realmente estável dentro do probe, considera sucesso - if (not correcao_ntrip) and probe_start_ts: - # sucesso se já atingiu estabilidade boa por T_GOOD enquanto NTRIP está off - if qual_boa and good_since_ts and (now - good_since_ts >= T_GOOD): - # sucesso: permanece em LoRa e zera contadores - probe_attempts = 0 - next_probe_ts = 0 - probe_start_ts = 0 - - # Persistir estado - ContextoGlobalRedis.atualizar_ctx_dict( - ContextoGlobalRedis.ModKey(self.t_code), - correcao_ntrip=correcao_ntrip, - ntrip_last_switch_ts=last_switch_ts, - ntrip_bad_since_ts=bad_since_ts, - ntrip_good_since_ts=good_since_ts, - ntrip_probe_attempts=probe_attempts, - ntrip_probe_start_ts=probe_start_ts, - ntrip_next_probe_ts=next_probe_ts + self._atualizar_fallback_ntrip( + fix=fix, + age=age, + age_valido=age_valido, + hacc=hacc, ) - + except Exception as e: print(f"Erro ao atualizar saude do modulo {self.t_code.name}: {e}") + # ================================================================== + # FALLBACK NTRIP + # ================================================================== + def _atualizar_fallback_ntrip(self, fix, age, age_valido, hacc): + """Mantem a estrategia existente de radio -> NTRIP com histerese. + + NTRIP nao participa da nota de saude por estar ligado/desligado. Ele e + somente uma fonte alternativa quando a qualidade RTK primaria degrada. + """ + AGE_HI = 5.0 + AGE_LO = 2.0 + HACC_OK = 40.0 + T_BAD = 4.0 + T_GOOD = 10.0 + MIN_ON = 25.0 + MIN_OFF = 12.0 + + MAX_TRIES = 3 + PROBE_EVAL_WINDOW = 30.0 + PROBE_BACKOFF = 5 * 60.0 + + estado = ContextoGlobalRedis.get_modulo(self.t_code) or {} + now = time.time() + + correcao_ntrip = self._bool(estado.get("correcao_ntrip", False)) + last_switch_ts = self._float(estado.get("ntrip_last_switch_ts", 0.0), 0.0) + bad_since_ts = self._float(estado.get("ntrip_bad_since_ts", 0.0), 0.0) + good_since_ts = self._float(estado.get("ntrip_good_since_ts", 0.0), 0.0) + probe_attempts = self._int(estado.get("ntrip_probe_attempts", 0), 0) + probe_start_ts = self._float(estado.get("ntrip_probe_start_ts", 0.0), 0.0) + next_probe_ts = self._float(estado.get("ntrip_next_probe_ts", 0.0), 0.0) + + hacc_valido = math.isfinite(hacc) and hacc > 0.0 + qual_ruim = ( + fix < 4 + or not age_valido + or age > AGE_HI + or (hacc_valido and hacc >= HACC_OK) + ) + qual_boa = ( + fix in (4, 5) + and age_valido + and age < AGE_LO + and hacc_valido + and hacc < HACC_OK + ) + severa = fix <= 2 or (age_valido and age > self.AGE_FAIL_S) + + if qual_ruim: + bad_since_ts = bad_since_ts or now + good_since_ts = 0.0 + elif qual_boa: + good_since_ts = good_since_ts or now + bad_since_ts = 0.0 + else: + bad_since_ts = 0.0 + good_since_ts = 0.0 + + tempo_desde_troca = now - last_switch_ts + pode_ligar = not correcao_ntrip and tempo_desde_troca >= MIN_OFF + pode_desligar = correcao_ntrip and tempo_desde_troca >= MIN_ON + + ligar = False + desligar = False + + if not correcao_ntrip: + # Mesmo condicao severa precisa respeitar o cooldown para evitar + # liga/desliga rapido e repetitivo. + if severa and pode_ligar: + ligar = True + elif ( + qual_ruim + and bad_since_ts + and now - bad_since_ts >= T_BAD + and pode_ligar + ): + ligar = True + + # Probe so falha depois da janela completa. + if probe_start_ts and now - probe_start_ts >= PROBE_EVAL_WINDOW: + if qual_boa and good_since_ts and now - good_since_ts >= T_GOOD: + probe_attempts = 0 + next_probe_ts = 0.0 + probe_start_ts = 0.0 + else: + probe_attempts += 1 + next_probe_ts = now + PROBE_BACKOFF + probe_start_ts = 0.0 + if pode_ligar: + ligar = True + + else: + pronto_probe = ( + qual_boa + and good_since_ts + and now - good_since_ts >= T_GOOD + and pode_desligar + ) + + backoff_ativo = probe_attempts >= MAX_TRIES and now < next_probe_ts + if pronto_probe and not backoff_ativo: + desligar = True + + if ligar: + correcao_ntrip = True + last_switch_ts = now + probe_start_ts = 0.0 + good_since_ts = 0.0 + + elif desligar: + correcao_ntrip = False + last_switch_ts = now + probe_start_ts = now + bad_since_ts = 0.0 + good_since_ts = 0.0 + + # Sucesso do probe pode ocorrer antes do fim da janela. + if ( + not correcao_ntrip + and probe_start_ts + and qual_boa + and good_since_ts + and now - good_since_ts >= T_GOOD + ): + probe_attempts = 0 + next_probe_ts = 0.0 + probe_start_ts = 0.0 + + ContextoGlobalRedis.atualizar_ctx_dict( + ContextoGlobalRedis.ModKey(self.t_code), + correcao_ntrip=correcao_ntrip, + ntrip_last_switch_ts=last_switch_ts, + ntrip_bad_since_ts=bad_since_ts, + ntrip_good_since_ts=good_since_ts, + ntrip_probe_attempts=probe_attempts, + ntrip_probe_start_ts=probe_start_ts, + ntrip_next_probe_ts=next_probe_ts, + ) + + # ================================================================== + # PERSISTENCIA / HISTERese + # ================================================================== + def _persistente(self, chave, ruim, agora, entrar_s, sair_s): + """Latch temporal independente para cada condicao de health. + + ruim precisa permanecer por entrar_s para ativar. Depois de ativo, + precisa permanecer bom por sair_s para limpar. + """ + estado = self._persist.setdefault( + chave, + {"ativo": False, "ruim_desde": 0.0, "bom_desde": 0.0}, + ) + + if ruim: + estado["bom_desde"] = 0.0 + if estado["ruim_desde"] <= 0.0: + estado["ruim_desde"] = agora + + if ( + not estado["ativo"] + and agora - estado["ruim_desde"] >= entrar_s + ): + estado["ativo"] = True + else: + estado["ruim_desde"] = 0.0 + if estado["ativo"]: + if estado["bom_desde"] <= 0.0: + estado["bom_desde"] = agora + if agora - estado["bom_desde"] >= sair_s: + estado["ativo"] = False + estado["bom_desde"] = 0.0 + else: + estado["bom_desde"] = 0.0 + + return estado["ativo"] + + # ================================================================== + # PENALIDADES + # ================================================================== def _penalidade_fix(self, fix) -> int: - if fix == 0: return 90 # inválido - if fix == 1: return 70 # single - if fix == 2: return 40 # DGPS - if fix == 3: return 30 # PPS - if fix == 4: return 0 # RTK Fixed (melhor caso) - if fix == 5: return 5 # RTK Float (quase bom) - if fix == 6: return 50 # dead reckoning - if fix == 7: return 80 # manual - if fix == 8: return 80 # simulador - return 90 + if fix == 0: + return 70 + if fix == 1: + return 55 + if fix == 2: + return 30 + if fix == 3: + return 25 + if fix == 4: + return 0 + if fix == 5: + return 5 + if fix == 6: + return 40 + if fix in (7, 8): + return 60 + return 70 def _penalidade_hacc(self, h) -> int: - if h == 0: return 30 - if h < 40: return 0 - if h < 80: return 5 - if h < 120: return 10 - if h < 200: return 20 + if not math.isfinite(h) or h <= 0: + return 15 + if h < self.HACC_WARN_CM: + return 0 + if h < 80: + return 5 + if h < self.HACC_BAD_CM: + return 10 + if h < 200: + return 20 return 30 - def _penalidade_age(self, age: float) -> float: - if age < 0: - return 35 - return round(min(35, 35 * (1 - math.exp(-age / 8))), 2) + def _penalidade_age(self, age) -> int: + if not math.isfinite(age) or age < 0: + return 20 + if age < self.AGE_WARN_S: + return 0 + if age < 8: + return 5 + if age < 12: + return 10 + if age < self.AGE_FAIL_S: + return 20 + return 30 + + # ================================================================== + # CONVERSOES SEGURAS + # ================================================================== + @staticmethod + def _bool(valor): + if isinstance(valor, str): + return valor.strip().lower() in ("true", "1", "yes", "sim", "on") + return bool(valor) + + @staticmethod + def _float(valor, padrao): + try: + return float(valor) + except (TypeError, ValueError): + return padrao + + @staticmethod + def _int(valor, padrao): + try: + return int(valor) + except (TypeError, ValueError): + return padrao + + @staticmethod + def _idade_timestamp(timestamp): + """Calcula idade aceitando timestamp monotonic/perf_counter. + + O codigo existente grava timestamps no mesmo dominio monotonic do + processo. Valores ausentes ou futuros sao considerados sem dado. + """ + if not math.isfinite(timestamp) or timestamp <= 0: + return math.inf + + agora = time.monotonic() + idade = agora - timestamp + if idade < -1.0: + return math.inf + return max(0.0, idade) + + @staticmethod + def _round_age(valor): + return round(valor, 2) if math.isfinite(valor) else None diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/imu.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/imu.py index 633d1984d..d97ae23aa 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/imu.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/imu.py @@ -699,8 +699,8 @@ class CameraIMU(ModuloDiagnosticoBase): limites principais definidos pelo operador. No contrato físico deste rover: - - roll = inclinação frontal - - pitch = inclinação lateral + - roll = inclinação lateral + - pitch = inclinação frontal """ self.roll_attention_deg = ( @@ -777,8 +777,8 @@ class CameraIMU(ModuloDiagnosticoBase): if mudou: self.mostrar_log( "Limites angulares atualizados | " - f"roll/frontal={roll_anterior:.1f}°→{self.roll_max_deg:.1f}° | " - f"pitch/lateral={pitch_anterior:.1f}°→{self.pitch_max_deg:.1f}° | " + f"roll/lateral={roll_anterior:.1f}°→{self.roll_max_deg:.1f}° | " + f"pitch/frontal={pitch_anterior:.1f}°→{self.pitch_max_deg:.1f}° | " f"critical=({self.roll_critical_deg:.1f}°, " f"{self.pitch_critical_deg:.1f}°) | " f"emergency=({self.roll_emergency_deg:.1f}°, " @@ -1322,13 +1322,13 @@ class CameraIMU(ModuloDiagnosticoBase): "emergency_stop": risco["emergency_stop"], "convencao_eixos": { - "roll": "frontal", - "pitch": "lateral", + "roll": "lateral", + "pitch": "frontal", }, "limites_angulo": { "roll": { - "semantica": "frontal", + "semantica": "lateral", "attention_deg": round(self.roll_attention_deg, 3), "risk_deg": round(self.roll_risk_deg, 3), "critical_deg": round(self.roll_critical_deg, 3), @@ -1336,7 +1336,7 @@ class CameraIMU(ModuloDiagnosticoBase): "max_configurado_deg": round(self.roll_max_deg, 3), }, "pitch": { - "semantica": "lateral", + "semantica": "frontal", "attention_deg": round(self.pitch_attention_deg, 3), "risk_deg": round(self.pitch_risk_deg, 3), "critical_deg": round(self.pitch_critical_deg, 3), 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 dab0d4b21..0ee79d1b3 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 @@ -29,8 +29,16 @@ class ModuloMovimentacao(ModuloDiagnosticoBase): SAUDE_MIN_ALERTA = 80 - RESPOSTA_RECENTE_MS = 3000.0 - RESPOSTA_LENTA_MS = 1800.0 + # Comunicação OID: atraso passageiro não deve parar a operação. A idade da + # própria resposta já representa persistência; por isso a falha só ocorre + # no último estágio e depende de haver comando de movimento ativo. + RESPOSTA_LENTA_MS = 3000.0 + RESPOSTA_DEGRADADA_MS = 5000.0 + RESPOSTA_FALHA_MOVENDO_MS = 8000.0 + RESPOSTA_FALHA_PARADO_MS = 12000.0 + RESPOSTA_RECUPERACAO_S = 2.0 + DESCONECTADO_FALHA_MOVENDO_S = 6.0 + DESCONECTADO_FALHA_PARADO_S = 10.0 COMANDO_GRACE_MS = 1200.0 COMANDO_ALERTA_MS = 3500.0 @@ -64,6 +72,10 @@ class ModuloMovimentacao(ModuloDiagnosticoBase): TEMP_EMERGENCIA_CONFIRMADO_S = 1.0 TEMP_RECUPERACAO_S = 10.0 + TEMP_AMOSTRAS_MIN_FALHA = 3 + TEMP_AMOSTRAS_MIN_EMERGENCIA = 3 + TEMP_AMOSTRA_MAX_IDADE_S = 3.0 + TEMP_SALTO_SUSPEITO_C = 35.0 # Elétricos TENSAO_FALHA_PERSISTENCIA_S = 2.0 @@ -73,11 +85,21 @@ class ModuloMovimentacao(ModuloDiagnosticoBase): # Freio/comando FREIO_RPM_FALHA_PERSISTENCIA_S = 1.0 ALARME_COMANDO_PERSISTENCIA_S = 2.0 + RECUPERACAO_CONDICAO_S = 2.0 + + # Limites extremos permanecem rápidos. São reservados para valores muito + # além dos picos vistos em campo, não para pequenas ultrapassagens. + TENSAO_EXTREMA_MIN_V = 18.0 + TENSAO_EXTREMA_MAX_V = 50.0 + CORRENTE_MOTOR_EXTREMA_A = 90.0 + CORRENTE_BARRAMENTO_EXTREMA_A = 55.0 def __init__(self): self.t_code = T_Code.Mov self.nome = "Movimentacao" - self.timeout = 3 + # Deve ficar acima do maior timeout tratado por esta classe para que o + # diagnóstico específico seja publicado antes do timeout genérico. + self.timeout = 15 self.padroes = {} self.freq_defaults = { @@ -117,6 +139,7 @@ class ModuloMovimentacao(ModuloDiagnosticoBase): } self._estados_persistencia = {} + self._historico_amostras = {} def atualizar_saude(self): try: @@ -162,7 +185,7 @@ class ModuloMovimentacao(ModuloDiagnosticoBase): saude_individual.append(saude_mod) updates_drivers[f"{endereco_str}.saude"] = saude_mod - conectado = self._bool(modulo.get("conectado", False)) + conectado = self._bool(saude_mod.get("conectado_efetivo", False)) em_uso = self._bool(saude_mod.get("em_uso", True)) if conectado: @@ -244,7 +267,21 @@ class ModuloMovimentacao(ModuloDiagnosticoBase): motivos = [] condicoes = [] - if not conectado: + movimento_ativo = self._movimento_ativo(modulo) + estado_desconectado = self._estado_persistencia( + driver=endereco_str, + regra="payload_desconectado", + ativa=not conectado, + tempo_intermediario_s=2.0, + tempo_confirmacao_s=( + self.DESCONECTADO_FALHA_MOVENDO_S + if movimento_ativo + else self.DESCONECTADO_FALHA_PARADO_S + ), + tempo_recuperacao_s=self.RESPOSTA_RECUPERACAO_S, + ) + + if not conectado and estado_desconectado["nivel"] == "confirmado": return { "id": endereco_str, "label": mod_id, @@ -253,15 +290,35 @@ class ModuloMovimentacao(ModuloDiagnosticoBase): "motivos": ["Desconectado"], "condicoes_operacionais": [], "em_uso": comandar, + "conectado_efetivo": False, "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) + conectado_efetivo = conectado or estado_desconectado["nivel"] in ( + "detectado", "persistente", "recuperando" + ) + + if not conectado: + motivos.append( + "Sinal de desconexão transitório; aguardando confirmação " + f"({estado_desconectado['duracao_s']:.1f} s)" + ) + condicoes.append({ + "label": "Conexão OID", + "valor": estado_desconectado["duracao_s"], + "severidade": 85, + "descricao": "Driver publicou desconexão, ainda dentro da tolerância de campo", + "acoes": ["Acompanhar a recuperação da comunicação CAN."], + }) + elif estado_desconectado["nivel"] == "recuperando": + motivos.append("Comunicação OID recuperada; confirmando estabilidade") + + conectividade = self._avaliar_resposta_recente(endereco_str, modulo, motivos, condicoes) + comando = self._avaliar_seguimento_comando(endereco_str, modulo, motivos, condicoes) valores = self._avaliar_valores_eletricos(endereco_str, modulo, motivos, condicoes) - freio = self._avaliar_freio(modulo, motivos, condicoes) + freio = self._avaliar_freio(endereco_str, modulo, motivos, condicoes) telemetria = self._avaliar_telemetria(modulo, motivos) componentes = { @@ -303,7 +360,7 @@ class ModuloMovimentacao(ModuloDiagnosticoBase): saude = int(max(0, min(100, round(saude)))) status = self._resolver_status_driver( - conectado=conectado, + conectado=conectado_efetivo, saude=saude, condicoes_operacionais=condicoes, ) @@ -316,6 +373,7 @@ class ModuloMovimentacao(ModuloDiagnosticoBase): "motivos": self._dedupe(motivos), "condicoes_operacionais": condicoes, "em_uso": comandar, + "conectado_efetivo": conectado_efetivo, "componentes": componentes, "parametros": telemetria["parametros"], "detalhes_valores": valores["detalhes"], @@ -351,10 +409,16 @@ class ModuloMovimentacao(ModuloDiagnosticoBase): # Componentes # ============================================================ - def _avaliar_resposta_recente(self, modulo, motivos, condicoes): + def _avaliar_resposta_recente(self, endereco, modulo, motivos, condicoes): ultima_resposta_ms = self._obter_ultima_resposta_ms(modulo) + movimento_ativo = self._movimento_ativo(modulo) + limite_falha_ms = ( + self.RESPOSTA_FALHA_MOVENDO_MS + if movimento_ativo + else self.RESPOSTA_FALHA_PARADO_MS + ) - if ultima_resposta_ms > self.RESPOSTA_RECENTE_MS: + if ultima_resposta_ms >= limite_falha_ms: motivos.append(f"Sem resposta recente do OID ({ultima_resposta_ms:.0f} ms)") condicoes.append({ "label": "Resposta OID", @@ -369,13 +433,30 @@ class ModuloMovimentacao(ModuloDiagnosticoBase): }) return {"score": 0, "sem_resposta": True} + if ultima_resposta_ms > self.RESPOSTA_DEGRADADA_MS: + motivos.append( + f"Comunicação OID degradada ({ultima_resposta_ms:.0f} ms); " + f"falha em {limite_falha_ms:.0f} ms" + ) + condicoes.append({ + "label": "Resposta OID", + "valor": ultima_resposta_ms, + "severidade": 82, + "descricao": "Resposta do driver atrasada, ainda sem perda confirmada", + "acoes": [ + "Acompanhar se a comunicação CAN se recupera.", + "Verificar ruído, conectores e carga do polling se persistir.", + ], + }) + return {"score": 55, "sem_resposta": False} + 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} + return {"score": 78, "sem_resposta": False} return {"score": 100, "sem_resposta": False} - def _avaliar_seguimento_comando(self, modulo, motivos, condicoes): + def _avaliar_seguimento_comando(self, endereco, 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)) @@ -418,7 +499,16 @@ class ModuloMovimentacao(ModuloDiagnosticoBase): # O componente de valores elétricos vai tratar o código. return {"score": 70, "falha_seguimento": False} - if alarme_comando: + estado_alarme = self._estado_persistencia( + driver=endereco, + regra="alarme_comando", + ativa=alarme_comando, + tempo_intermediario_s=0.5, + tempo_confirmacao_s=self.ALARME_COMANDO_PERSISTENCIA_S, + tempo_recuperacao_s=self.RECUPERACAO_CONDICAO_S, + ) + + if estado_alarme["nivel"] in ("confirmado", "recuperando"): motivos.append("Alarme de comando MOV ativo: comando ligado, mas leitura não indica acionamento") condicoes.append({ "label": "Comando MOV", @@ -433,6 +523,20 @@ class ModuloMovimentacao(ModuloDiagnosticoBase): }) return {"score": 20, "falha_seguimento": True} + if estado_alarme["ativa"]: + motivos.append( + "Alarme de comando MOV detectado; aguardando persistência " + f"({estado_alarme['duracao_s']:.1f} s)" + ) + condicoes.append({ + "label": "Comando MOV", + "valor": rpm, + "severidade": 75, + "descricao": "Possível falha de comando ainda não confirmada", + "acoes": ["Acompanhar RPM, corrente e próximas atualizações do OID."], + }) + return {"score": 65, "falha_seguimento": False} + if freio_sp or freado: # Freio é avaliado em _avaliar_freio. Aqui não cobra RPM_SP. return {"score": 100, "falha_seguimento": False} @@ -573,17 +677,31 @@ class ModuloMovimentacao(ModuloDiagnosticoBase): # Faixas absolutas. Ajustar depois com logs reais. if tensao > 0: if tensao < 25 or tensao > 45: - motivos.append(f"Tensão MOV em faixa perigosa: {tensao:.1f} V") + extrema = tensao < self.TENSAO_EXTREMA_MIN_V or tensao > self.TENSAO_EXTREMA_MAX_V + estado = self._estado_persistencia( + endereco, "tensao_perigosa", True, + 0.5, 0.0 if extrema else self.TENSAO_FALHA_PERSISTENCIA_S, + self.RECUPERACAO_CONDICAO_S, + ) + confirmada = estado["nivel"] in ("confirmado", "recuperando") + motivos.append( + f"Tensão MOV em faixa perigosa: {tensao:.1f} V " + f"({'confirmada' if confirmada else 'aguardando persistência'})" + ) condicoes.append({ "label": "Tensão MOV", "valor": tensao, - "severidade": 92, + "severidade": 92 if confirmada else 75, "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 + score -= 70 if confirmada else 20 + risco_eletrico_termico = risco_eletrico_termico or confirmada elif tensao < 30 or tensao > 43: + self._estado_persistencia( + endereco, "tensao_perigosa", False, 0.5, + self.TENSAO_FALHA_PERSISTENCIA_S, self.RECUPERACAO_CONDICAO_S, + ) motivos.append(f"Tensão MOV fora da faixa esperada: {tensao:.1f} V") condicoes.append({ "label": "Tensão MOV", @@ -593,22 +711,41 @@ class ModuloMovimentacao(ModuloDiagnosticoBase): "acoes": ["Acompanhar alimentação dos drivers de movimento."], }) score -= 20 + else: + self._estado_persistencia( + endereco, "tensao_perigosa", False, 0.5, + self.TENSAO_FALHA_PERSISTENCIA_S, self.RECUPERACAO_CONDICAO_S, + ) if abs(corrente_motor) > 60: - motivos.append(f"Corrente do motor muito alta: {corrente_motor:.1f} A") + extrema = abs(corrente_motor) >= self.CORRENTE_MOTOR_EXTREMA_A + estado = self._estado_persistencia( + endereco, "corrente_motor_perigosa", True, + 0.25, 0.0 if extrema else self.CORRENTE_MOTOR_FALHA_PERSISTENCIA_S, + self.RECUPERACAO_CONDICAO_S, + ) + confirmada = estado["nivel"] in ("confirmado", "recuperando") + motivos.append( + f"Corrente do motor muito alta: {corrente_motor:.1f} A " + f"({'confirmada' if confirmada else 'pico em confirmação'})" + ) condicoes.append({ "label": "Corrente motor", "valor": corrente_motor, - "severidade": 93, + "severidade": 93 if confirmada else 78, "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 + score -= 75 if confirmada else 25 + risco_eletrico_termico = risco_eletrico_termico or confirmada elif abs(corrente_motor) > 50: + self._estado_persistencia( + endereco, "corrente_motor_perigosa", False, 0.25, + self.CORRENTE_MOTOR_FALHA_PERSISTENCIA_S, self.RECUPERACAO_CONDICAO_S, + ) motivos.append(f"Corrente do motor elevada: {corrente_motor:.1f} A") condicoes.append({ "label": "Corrente motor", @@ -621,19 +758,38 @@ class ModuloMovimentacao(ModuloDiagnosticoBase): ], }) score -= 30 + else: + self._estado_persistencia( + endereco, "corrente_motor_perigosa", False, 0.25, + self.CORRENTE_MOTOR_FALHA_PERSISTENCIA_S, self.RECUPERACAO_CONDICAO_S, + ) if abs(corrente_barramento) > 35: - motivos.append(f"Corrente de barramento muito alta: {corrente_barramento:.1f} A") + extrema = abs(corrente_barramento) >= self.CORRENTE_BARRAMENTO_EXTREMA_A + estado = self._estado_persistencia( + endereco, "corrente_barramento_perigosa", True, + 0.25, 0.0 if extrema else self.CORRENTE_BARRAMENTO_FALHA_PERSISTENCIA_S, + self.RECUPERACAO_CONDICAO_S, + ) + confirmada = estado["nivel"] in ("confirmado", "recuperando") + motivos.append( + f"Corrente de barramento muito alta: {corrente_barramento:.1f} A " + f"({'confirmada' if confirmada else 'pico em confirmação'})" + ) condicoes.append({ "label": "Corrente barramento", "valor": corrente_barramento, - "severidade": 90, + "severidade": 92 if confirmada else 75, "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 + score -= 55 if confirmada else 20 + risco_eletrico_termico = risco_eletrico_termico or confirmada elif abs(corrente_barramento) > 25: + self._estado_persistencia( + endereco, "corrente_barramento_perigosa", False, 0.25, + self.CORRENTE_BARRAMENTO_FALHA_PERSISTENCIA_S, self.RECUPERACAO_CONDICAO_S, + ) motivos.append(f"Corrente de barramento elevada: {corrente_barramento:.1f} A") condicoes.append({ "label": "Corrente barramento", @@ -643,6 +799,11 @@ class ModuloMovimentacao(ModuloDiagnosticoBase): "acoes": ["Acompanhar consumo e esforço do conjunto."], }) score -= 20 + else: + self._estado_persistencia( + endereco, "corrente_barramento_perigosa", False, 0.25, + self.CORRENTE_BARRAMENTO_FALHA_PERSISTENCIA_S, self.RECUPERACAO_CONDICAO_S, + ) # Temperatura do driver _penalidade_d, _motivos_d, _condicoes_d, _risco_d = ( @@ -650,6 +811,8 @@ class ModuloMovimentacao(ModuloDiagnosticoBase): endereco=endereco, temperatura=temperatura_driver, regra_desc="driver", + modulo=modulo, + campo="temperatura_driver", ) ) motivos.extend(_motivos_d) @@ -661,6 +824,8 @@ class ModuloMovimentacao(ModuloDiagnosticoBase): endereco=endereco, temperatura=temperatura_motor, regra_desc="motor", + modulo=modulo, + campo="temperatura_motor", ) ) motivos.extend(_motivos_m) @@ -710,14 +875,23 @@ class ModuloMovimentacao(ModuloDiagnosticoBase): "detalhes": detalhes, } - def _avaliar_freio(self, modulo, motivos, condicoes): + def _avaliar_freio(self, endereco, 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)) if (freado or freio_sp) and rpm > self.RPM_FREIO_TOL: - severidade = 92 if rpm > 100 else 75 + estado = self._estado_persistencia( + driver=endereco, + regra="freio_com_rpm", + ativa=True, + tempo_intermediario_s=0.25, + tempo_confirmacao_s=self.FREIO_RPM_FALHA_PERSISTENCIA_S, + tempo_recuperacao_s=self.RECUPERACAO_CONDICAO_S, + ) + confirmada = estado["nivel"] in ("confirmado", "recuperando") + severidade = 92 if confirmada and rpm > 100 else 78 if rpm > 100 else 70 motivos.append(f"Freio ativo com RPM presente ({rpm:.0f})") condicoes.append({ "label": "Freio MOV", @@ -735,6 +909,26 @@ class ModuloMovimentacao(ModuloDiagnosticoBase): "falha_freio": severidade > 90, } + estado_freio = self._estado_persistencia( + driver=endereco, + regra="freio_com_rpm", + ativa=False, + tempo_intermediario_s=0.25, + tempo_confirmacao_s=self.FREIO_RPM_FALHA_PERSISTENCIA_S, + tempo_recuperacao_s=self.RECUPERACAO_CONDICAO_S, + ) + + if estado_freio["nivel"] == "recuperando": + motivos.append("Freio/RPM normalizou; confirmando estabilidade") + condicoes.append({ + "label": "Freio MOV", + "valor": rpm, + "severidade": 92, + "descricao": "Condição de freio com movimento em recuperação", + "acoes": ["Aguardar estabilidade antes da liberação automática."], + }) + return {"score": 25, "falha_freio": True} + if freado and comando_ligado: motivos.append("Freio ativo enquanto comando de movimento aparece ligado") condicoes.append({ @@ -794,6 +988,8 @@ class ModuloMovimentacao(ModuloDiagnosticoBase): endereco, temperatura, regra_desc, + modulo, + campo, ): motivos = [] condicoes = [] @@ -808,6 +1004,8 @@ class ModuloMovimentacao(ModuloDiagnosticoBase): label = f"Temperatura do {nome_origem}" + amostra = self._registrar_amostra(endereco, campo, temperatura, modulo) + if not self._temperatura_valida(temperatura): motivos.append( f"Temperatura do {nome_origem} inválida: " @@ -832,6 +1030,32 @@ class ModuloMovimentacao(ModuloDiagnosticoBase): risco_eletrico_termico, ) + if not amostra["fresca"]: + motivos.append( + f"Temperatura do {nome_origem} sem amostra fresca " + f"({amostra['idade_s']:.1f} s)" + ) + condicoes.append({ + "label": label, + "valor": temperatura, + "severidade": 55, + "descricao": "Leitura térmica está congelada ou desatualizada; não confirma sobretemperatura", + "acoes": ["Verificar publicação e timestamp do sensor térmico."], + }) + return (0, motivos, condicoes, False) + + if amostra["salto_suspeito"]: + motivos.append( + f"Salto térmico suspeito no {nome_origem}: {amostra['delta_c']:.1f} °C" + ) + condicoes.append({ + "label": label, + "valor": temperatura, + "severidade": 70, + "descricao": "Variação térmica instantânea acima do fisicamente plausível; aguardando confirmação", + "acoes": ["Verificar sensor, divisor, aterramento e próximas amostras."], + }) + estado_temp_emergencia = self._estado_limite_alto( driver=endereco, regra=f"temperatura_emergencia_{regra_desc}", @@ -841,6 +1065,8 @@ class ModuloMovimentacao(ModuloDiagnosticoBase): tempo_intermediario_s=0.0, tempo_confirmacao_s=self.TEMP_EMERGENCIA_CONFIRMADO_S, tempo_recuperacao_s=self.TEMP_RECUPERACAO_S, + amostra_nova=amostra["nova"], + min_amostras=self.TEMP_AMOSTRAS_MIN_EMERGENCIA, ) estado_temp_falha = self._estado_limite_alto( @@ -852,6 +1078,8 @@ class ModuloMovimentacao(ModuloDiagnosticoBase): tempo_intermediario_s=self.TEMP_FALHA_INTERMEDIARIO_S, tempo_confirmacao_s=self.TEMP_FALHA_CONFIRMADO_S, tempo_recuperacao_s=self.TEMP_RECUPERACAO_S, + amostra_nova=amostra["nova"], + min_amostras=self.TEMP_AMOSTRAS_MIN_FALHA, ) estado_temp_alerta = self._estado_limite_alto( @@ -863,6 +1091,8 @@ class ModuloMovimentacao(ModuloDiagnosticoBase): tempo_intermediario_s=self.TEMP_ALERTA_INTERMEDIARIO_S, tempo_confirmacao_s=self.TEMP_ALERTA_CONFIRMADO_S, tempo_recuperacao_s=self.TEMP_RECUPERACAO_S, + amostra_nova=amostra["nova"], + min_amostras=3, ) # ============================================================ @@ -1331,6 +1561,8 @@ class ModuloMovimentacao(ModuloDiagnosticoBase): tempo_intermediario_s, tempo_confirmacao_s, tempo_recuperacao_s=0.0, + amostra_nova=True, + min_amostras=1, ): agora = time.monotonic() chave = (str(driver), str(regra)) @@ -1339,6 +1571,7 @@ class ModuloMovimentacao(ModuloDiagnosticoBase): "ativa_desde": None, "normal_desde": None, "confirmada": False, + "amostras_ativas": 0, }) if ativa: @@ -1346,10 +1579,17 @@ class ModuloMovimentacao(ModuloDiagnosticoBase): if estado["ativa_desde"] is None: estado["ativa_desde"] = agora + estado["amostras_ativas"] = 0 + + if amostra_nova: + estado["amostras_ativas"] += 1 duracao_s = max(0.0, agora - estado["ativa_desde"]) - if duracao_s >= tempo_confirmacao_s: + if ( + duracao_s >= tempo_confirmacao_s + and estado["amostras_ativas"] >= max(1, int(min_amostras)) + ): estado["confirmada"] = True if estado["confirmada"]: @@ -1364,9 +1604,11 @@ class ModuloMovimentacao(ModuloDiagnosticoBase): "nivel": nivel, "duracao_s": duracao_s, "confirmada": estado["confirmada"], + "amostras": estado["amostras_ativas"], } estado["ativa_desde"] = None + estado["amostras_ativas"] = 0 if not estado["confirmada"]: estado["normal_desde"] = None @@ -1375,6 +1617,7 @@ class ModuloMovimentacao(ModuloDiagnosticoBase): "nivel": "normal", "duracao_s": 0.0, "confirmada": False, + "amostras": 0, } if estado["normal_desde"] is None: @@ -1391,6 +1634,7 @@ class ModuloMovimentacao(ModuloDiagnosticoBase): "nivel": "normal", "duracao_s": 0.0, "confirmada": False, + "amostras": 0, } return { @@ -1398,6 +1642,7 @@ class ModuloMovimentacao(ModuloDiagnosticoBase): "nivel": "recuperando", "duracao_s": normal_ha_s, "confirmada": True, + "amostras": 0, } def _estado_limite_alto( @@ -1410,6 +1655,8 @@ class ModuloMovimentacao(ModuloDiagnosticoBase): tempo_intermediario_s, tempo_confirmacao_s, tempo_recuperacao_s, + amostra_nova=True, + min_amostras=1, ): chave = (str(driver), str(regra)) persistencia = self._estados_persistencia.get(chave, {}) @@ -1431,6 +1678,58 @@ class ModuloMovimentacao(ModuloDiagnosticoBase): tempo_intermediario_s=tempo_intermediario_s, tempo_confirmacao_s=tempo_confirmacao_s, tempo_recuperacao_s=tempo_recuperacao_s, + amostra_nova=amostra_nova, + min_amostras=min_amostras, + ) + + def _registrar_amostra(self, endereco, campo, valor, modulo): + """Identifica amostras novas sem confundir polling do health com sensor novo.""" + agora_mono = time.monotonic() + chave = (str(endereco), str(campo)) + anterior = self._historico_amostras.get(chave) + + ts = 0.0 + for nome in ( + f"{campo}_timestamp", f"timestamp_{campo}", f"{campo}_ts", + "timestamp", + ): + ts = self._normalizar_timestamp_segundos(modulo.get(nome)) + if ts > 0: + break + + idade_s = max(0.0, time.time() - ts) if ts > 0 else 0.0 + fresca = ts <= 0 or idade_s <= self.TEMP_AMOSTRA_MAX_IDADE_S + valor_f = self._float(valor, 0.0) + marcador = (ts, valor_f) if ts > 0 else (None, valor_f) + nova = anterior is None or anterior.get("marcador") != marcador + + delta_c = 0.0 + salto_suspeito = False + if nova and anterior is not None: + delta_c = abs(valor_f - anterior.get("valor", valor_f)) + dt = max(0.001, agora_mono - anterior.get("mono", agora_mono)) + salto_suspeito = delta_c >= self.TEMP_SALTO_SUSPEITO_C and dt <= 5.0 + + if nova: + self._historico_amostras[chave] = { + "marcador": marcador, + "valor": valor_f, + "mono": agora_mono, + } + + return { + "nova": nova, + "fresca": fresca, + "idade_s": idade_s, + "delta_c": delta_c, + "salto_suspeito": salto_suspeito, + } + + def _movimento_ativo(self, modulo): + rpm_sp = abs(self._float(modulo.get("rpm_sp", 0.0), 0.0)) + return ( + self._bool(modulo.get("comando_ligado", rpm_sp > self.RPM_SP_MIN_AVALIAR)) + or rpm_sp > self.RPM_SP_MIN_AVALIAR ) diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/shared/contexto_global_redis.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/shared/contexto_global_redis.py index fd416f966..40c1593e7 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/shared/contexto_global_redis.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/shared/contexto_global_redis.py @@ -911,256 +911,329 @@ class ContextoGlobalRedis: operacao.get("modo", ModoOperacao.NaoDefinido.value), ModoOperacao.NaoDefinido, ) - status_atual = cls._enum_or( StatusOperacao, operacao.get("status", StatusOperacao.NaoIniciado.value), StatusOperacao.NaoIniciado, ) - equipamento_pronto = cls.get_equipamento().get("pronto", False) - + equipamento_pronto = cls._bool(cls.get_equipamento().get("pronto", False)) iniciado = cls._bool(operacao.get("iniciado", False)) configurado = cls._bool(operacao.get("configurado", False)) - status_iniciado_anterior = cls._bool(operacao.get("status_iniciado_anterior", False)) + status_iniciado_anterior = cls._bool( + operacao.get("status_iniciado_anterior", False) + ) finalizando = cls._bool(operacao.get("finalizando", False)) calibrando = cls._bool(operacao.get("calibrando", False)) pausa = cls._bool(operacao.get("pausa", False)) emergencia = cls._bool(operacao.get("emergencia", False)) op_liberada = cls._bool(operacao.get("liberado", False)) + ja_entrou_em_andamento = cls._bool( + operacao.get("ja_entrou_em_andamento", False) + ) status_anterior_parado = cls._int( operacao.get("status_anterior_parado", StatusOperacao.Parado.value), StatusOperacao.Parado.value, ) - updates = { + updates_base = { "status_iniciado_anterior": iniciado, "status_atualizado_em": agora, } - if status_iniciado_anterior != iniciado: - updates["status_iniciado_alterado_em"] = agora + updates_base["status_iniciado_alterado_em"] = agora def _commit(status, motivo, **extras): - updates["status"] = status.value if isinstance(status, StatusOperacao) else int(status) - updates["motivo_status_operacao"] = motivo + novo_status = ( + status.value if isinstance(status, StatusOperacao) else int(status) + ) + updates = dict(updates_base) updates.update(extras) + updates["status"] = novo_status + updates["motivo_status_operacao"] = motivo + + # Registra somente transições reais, sem gerar log a cada 100 ms. + if novo_status != status_atual.value: + updates.update({ + "status_anterior": status_atual.value, + "status_alterado_em": agora, + "ultima_transicao_status": { + "de": status_atual.value, + "para": novo_status, + "motivo": motivo, + "em": agora, + "liberado": op_liberada, + }, + }) + mostrar_log( + f"Status operação: {status_atual.name} -> " + f"{getattr(status, 'name', novo_status)} | {motivo}" + ) + cls.atualizar_ctx_dict(CtxKey.DadosOperacao, **updates) - # ------------------------------------------------------------ - # 0) Iniciando - # ------------------------------------------------------------ + def _limpar_controle_transitorio(): + return { + "bloqueio_pendente_desde": 0.0, + "bloqueio_pendente_motivo": "", + "retomada_aguardando_desde": 0.0, + } - if not equipamento_pronto: + def _parar_imediatamente(motivo): + anterior = status_anterior_parado + if status_atual != StatusOperacao.Parado: + anterior = status_atual.value _commit( - StatusOperacao.NaoIniciado, - "Iniciando equipamento", + StatusOperacao.Parado, + motivo, + status_anterior_parado=anterior, + ultimo_motivo_parada=motivo, + ultima_parada_em=agora, + bloqueio_confirmado_em=agora, + **_limpar_controle_transitorio(), ) - return - # ------------------------------------------------------------ - # 1) Não configurada - # ------------------------------------------------------------ + # 0) Pré-condições estruturais. + if not equipamento_pronto: + _commit(StatusOperacao.NaoIniciado, "Iniciando equipamento") + return if not configurado: - _commit( - StatusOperacao.Parametrizando, - "Operação ainda não configurada", - ) + _commit(StatusOperacao.Parametrizando, "Operação ainda não configurada") return - # ------------------------------------------------------------ - # 2) Não iniciada - # ------------------------------------------------------------ - if not iniciado: + extras = { + "ja_entrou_em_andamento": False, + "inicializacao_operacao_executada": False, + "tempo_aguardando": 0.0, + **_limpar_controle_transitorio(), + } if status_atual == StatusOperacao.Concluido: _commit( StatusOperacao.Concluido, "Operação concluída e não reiniciada", + **extras, ) else: _commit( StatusOperacao.Parametrizando, "Operação configurada, mas ainda não iniciada", + **extras, ) return - # ------------------------------------------------------------ - # 3) Estados forçados - # ------------------------------------------------------------ - + # 1) Estados explicitamente comandados não recebem debounce. if finalizando: - # Mantém compatibilidade caso não exista StatusOperacao.Finalizando. status_finalizando = cls._status_operacao_por_nome( - "Finalizando", - StatusOperacao.Parado, - ) - - _commit( - status_finalizando, - "Operação em finalização", + "Finalizando", StatusOperacao.Parado ) + _commit(status_finalizando, "Operação em finalização") return if calibrando: - _commit( - StatusOperacao.Calibrando, - "Operação em calibração", - ) - return - - if pausa: - _commit( - StatusOperacao.Parado, - "Operação pausada pela base", - status_anterior_parado=( - status_atual.value - if status_atual != StatusOperacao.Parado - else status_anterior_parado - ), - ) + _commit(StatusOperacao.Calibrando, "Operação em calibração") return if emergencia: - _commit( - StatusOperacao.Parado, - "Parada de emergência solicitada pela base", - status_anterior_parado=( - status_atual.value - if status_atual != StatusOperacao.Parado - else status_anterior_parado - ), - ) + _parar_imediatamente("Parada de emergência solicitada pela base") return - # ------------------------------------------------------------ - # 4) Manual - # ------------------------------------------------------------ + if pausa: + _parar_imediatamente("Operação pausada pela base") + return + # 2) Manual: mantém resposta imediata, como no comportamento original. if modo == ModoOperacao.Manual: if op_liberada: _commit( StatusOperacao.EmAndamento, "Operação manual liberada", status_anterior_parado=StatusOperacao.Parado.value, + ja_entrou_em_andamento=True, + **_limpar_controle_transitorio(), ) else: - _commit( - StatusOperacao.Parado, - cls._motivo_nao_liberado_status(operacao), - ) + _parar_imediatamente(cls._motivo_nao_liberado_status(operacao)) return - # ------------------------------------------------------------ - # 5) Modos automáticos suportados - # ------------------------------------------------------------ - - modos_automaticos = [ + modos_automaticos = { ModoOperacao.MapaGPS, ModoOperacao.TreinamentoIA, ModoOperacao.MapeamentoVisual, ModoOperacao.RetornoBase, - ] - + } if modo not in modos_automaticos: - _commit( - StatusOperacao.Parado, - f"Modo de operação sem estratégia automática: {modo.name}", + _parar_imediatamente( + f"Modo de operação sem estratégia automática: {modo.name}" ) return - # Borda de início da operação. - if iniciado and not status_iniciado_anterior: + # 3) Inicializa uma única vez. Não depende apenas da borda de `iniciado`, + # porque essa borda pode ocorrer enquanto `calibrando=True` e ser perdida. + inicializacao_executada = cls._bool( + operacao.get("inicializacao_operacao_executada", False) + ) + if not inicializacao_executada: cls._iniciar_operacao() - updates["tempo_aguardando"] = agora + updates_base["tempo_aguardando"] = agora + updates_base["ja_entrou_em_andamento"] = False + updates_base["inicializacao_operacao_executada"] = True + ja_entrou_em_andamento = False - # Não liberado. + # 4) Bloqueio automático: confirma apenas glitches muito curtos. + # As proteções de cada módulo continuam responsáveis por suas próprias + # persistências; este filtro de 0,5 s evita um único snapshot incoerente. if not op_liberada: motivo = cls._motivo_nao_liberado_status(operacao) - - novo_status_anterior = status_anterior_parado - if status_atual != StatusOperacao.Parado: - novo_status_anterior = status_atual.value - mostrar_log(f"Operação parada: {motivo}") - - _commit( - StatusOperacao.Parado, - motivo, - status_anterior_parado=novo_status_anterior, + pendente_desde = cls._float( + operacao.get("bloqueio_pendente_desde", 0.0), 0.0 ) + confirmar_s = max( + 0.0, + cls._float( + operacao.get("tempo_confirmar_bloqueio_operacao", 0.5), 0.5 + ), + ) + + if pendente_desde <= 0: + pendente_desde = agora + _commit( + status_atual, + f"Confirmando bloqueio: {motivo}", + bloqueio_pendente_desde=agora, + bloqueio_pendente_motivo=motivo, + bloqueio_pendente_ultimo_motivo=motivo, + ) + return + + decorrido = agora - pendente_desde + if decorrido < confirmar_s: + _commit( + status_atual, + f"Confirmando bloqueio ({decorrido:.1f}/{confirmar_s:.1f}s): {motivo}", + bloqueio_pendente_ultimo_motivo=motivo, + ) + return + + _parar_imediatamente(motivo) return - # Tempo inicial de espera. - tempo_aguardando = cls._float( - operacao.get("tempo_aguardando", updates.get("tempo_aguardando", 0.0)), - 0.0, - ) - tempo_aguardar = cls._float( - operacao.get("tempo_aguardar_inicio_operacao", 25.0), - 25.0, + # Liberação recuperou antes da confirmação: cancela o pulso sem parar. + bloqueio_pendente_desde = cls._float( + operacao.get("bloqueio_pendente_desde", 0.0), 0.0 ) + if bloqueio_pendente_desde > 0: + updates_base.update({ + "ultimo_bloqueio_transitorio_em": agora, + "ultimo_bloqueio_transitorio_motivo": str( + operacao.get("bloqueio_pendente_motivo", "") + ), + "bloqueio_pendente_desde": 0.0, + "bloqueio_pendente_motivo": "", + }) - if tempo_aguardando <= 0: - tempo_aguardando = agora - updates["tempo_aguardando"] = agora - - em_espera = (tempo_aguardando + tempo_aguardar) > agora - - if em_espera: - restante = max(0.0, (tempo_aguardando + tempo_aguardar) - agora) - - _commit( - StatusOperacao.Aguardando, - f"Aguardando início da operação: {restante:.1f}s restantes", - status_anterior_parado=StatusOperacao.Parado.value, + # 5) Espera inicial existe somente antes do primeiro EmAndamento. + if not ja_entrou_em_andamento: + tempo_aguardando = cls._float( + operacao.get( + "tempo_aguardando", updates_base.get("tempo_aguardando", 0.0) + ), + 0.0, ) - return + tempo_aguardar = max( + 0.0, + cls._float( + operacao.get("tempo_aguardar_inicio_operacao", 25.0), 25.0 + ), + ) + if tempo_aguardando <= 0: + tempo_aguardando = agora + updates_base["tempo_aguardando"] = agora - # Conclusão da trajetória. + restante = (tempo_aguardando + tempo_aguardar) - agora + if restante > 0: + _commit( + StatusOperacao.Aguardando, + f"Aguardando início da operação: {restante:.1f}s restantes", + status_anterior_parado=StatusOperacao.Parado.value, + ) + return + + # 6) Conclusão só é avaliada com a operação liberada. if cls._trajetoria_concluida(): _commit( StatusOperacao.Concluido, "Trajetória concluída", status_anterior_parado=StatusOperacao.Parado.value, + **_limpar_controle_transitorio(), ) - if iniciado and not finalizando: cls._finalizar_operacao() - return - # Recuperou de Parado. - if status_atual == StatusOperacao.Parado: - _commit( - StatusOperacao.Aguardando, - "Operação liberada novamente. Reiniciando janela de segurança", - tempo_aguardando=agora, - status_anterior_parado=StatusOperacao.Parado.value, + # 7) Retomada usa uma janela própria e curta; nunca reinicia a partida. + if status_atual == StatusOperacao.Parado and ja_entrou_em_andamento: + retomada_desde = cls._float( + operacao.get("retomada_aguardando_desde", 0.0), 0.0 ) - return + tempo_retomada = max( + 0.0, + cls._float(operacao.get("tempo_aguardar_retomada", 1.0), 1.0), + ) + if retomada_desde <= 0: + retomada_desde = agora - # Restaura status anterior à parada somente se fizer sentido. - status_anterior_enum = cls._enum_or( - StatusOperacao, - status_anterior_parado, - StatusOperacao.Parado, + restante = (retomada_desde + tempo_retomada) - agora + if restante > 0: + _commit( + StatusOperacao.Aguardando, + f"Bloqueio removido; retomada segura em {restante:.1f}s", + retomada_aguardando_desde=retomada_desde, + status_anterior_parado=StatusOperacao.EmAndamento.value, + ) + return + + # Se o ciclo anterior já publicou Aguardando de retomada, respeita o prazo. + retomada_desde = cls._float( + operacao.get("retomada_aguardando_desde", 0.0), 0.0 ) - - if status_anterior_enum in [StatusOperacao.Aguardando, StatusOperacao.EmAndamento]: - _commit( - status_anterior_enum, - "Restaurando status anterior à parada", - status_anterior_parado=StatusOperacao.Parado.value, + if retomada_desde > 0 and ja_entrou_em_andamento: + tempo_retomada = max( + 0.0, + cls._float(operacao.get("tempo_aguardar_retomada", 1.0), 1.0), ) - return + restante = (retomada_desde + tempo_retomada) - agora + if restante > 0: + _commit( + StatusOperacao.Aguardando, + f"Bloqueio removido; retomada segura em {restante:.1f}s", + status_anterior_parado=StatusOperacao.EmAndamento.value, + ) + return - # Normal. + + # 8) Estado normal. A marca evita que uma recuperação futura repita os 25 s. + motivo = ( + "Operação retomada com segurança" + if status_atual in (StatusOperacao.Parado, StatusOperacao.Aguardando) + and ja_entrou_em_andamento + else "Operação em andamento" + ) _commit( StatusOperacao.EmAndamento, - "Operação em andamento", + motivo, status_anterior_parado=StatusOperacao.Parado.value, + ja_entrou_em_andamento=True, + bloqueio_pendente_desde=0.0, + bloqueio_pendente_motivo="", + retomada_aguardando_desde=0.0, + ultima_retomada_em=( + agora + if status_atual in (StatusOperacao.Parado, StatusOperacao.Aguardando) + else cls._float(operacao.get("ultima_retomada_em", 0.0), 0.0) + ), ) @classmethod