diff --git a/AgroBase/AgroBase/Models/Modules/AtuadorModel.cs b/AgroBase/AgroBase/Models/Modules/AtuadorModel.cs index 436e65ffe..24a92446c 100644 --- a/AgroBase/AgroBase/Models/Modules/AtuadorModel.cs +++ b/AgroBase/AgroBase/Models/Modules/AtuadorModel.cs @@ -123,7 +123,7 @@ namespace AgroBase.Models.Modules [JsonIgnore] private SensorModel SensorFluxoLinha => Sensores.FirstOrDefault(x => x.Componente == S_Code.sFLX && x.ID == "FLXLN"); - const double FatorCalibracaoFluxo = 0.55; + const double FatorCalibracaoFluxo = 0.85; public double VazaoMaximaSensor_LMin { get; set; } = 20.0; diff --git a/AgroBase/AgroBase/Models/Modules/DirecionalModel.cs b/AgroBase/AgroBase/Models/Modules/DirecionalModel.cs index 1a1249786..032be9e94 100644 --- a/AgroBase/AgroBase/Models/Modules/DirecionalModel.cs +++ b/AgroBase/AgroBase/Models/Modules/DirecionalModel.cs @@ -139,6 +139,21 @@ namespace AgroBase.Models.Modules public Direcao UltimaDirecao { get; set; } = Direcao.Parado; public DateTime UltimoComandoEnviado { get; set; } = DateTime.MinValue; + + public double AnguloSPAtual + { + get + { + return !Comandar || Sentido_SP == Sentido.Parado ? 0 : Angulo_SP; + } + } + public double ErroAnguloSP + { + get + { + return Math.Abs(AnguloLeitura - AnguloSPAtual); + } + } public bool RetornandoAzero { get @@ -152,17 +167,14 @@ namespace AgroBase.Models.Modules { get { - return RPM != 0; + return Math.Abs(RPM) > 1; } } public bool AtingiuAnguloSP { get { - double anguloSp = - !Comandar || Sentido_SP == Sentido.Parado ? 0 : - Angulo_SP; - return FuncoesMatematicas.ValorEstaEntre(AnguloLeitura, anguloSp, 1); + return FuncoesMatematicas.ValorEstaEntre(AnguloLeitura, AnguloSPAtual, 2); } } diff --git a/AgroBase/AgroBase/Models/Operacoes/OperacaoModel.cs b/AgroBase/AgroBase/Models/Operacoes/OperacaoModel.cs index 34cd99f00..8127e96ff 100644 --- a/AgroBase/AgroBase/Models/Operacoes/OperacaoModel.cs +++ b/AgroBase/AgroBase/Models/Operacoes/OperacaoModel.cs @@ -518,7 +518,7 @@ namespace AgroBase.Models AtuPercentualErvasBicoOff = 2, AtuPercentualErvasBicoOn = 1, AtuPercentualInicioPulverizacao = 85, - AtuPressaoLinha = 22, + AtuPressaoLinha = 18, AtuAgitadorModo = ModoAgitadorCalda.SemAgitacao, AtuModoControle = ModoControleBomba.PID }; @@ -629,7 +629,7 @@ namespace AgroBase.Models AtuPercentualErvasBicoOff = 2, AtuPercentualErvasBicoOn = 1, AtuPercentualInicioPulverizacao = 85, - AtuPressaoLinha = 22, + AtuPressaoLinha = 18, AtuAgitadorModo = ModoAgitadorCalda.Continuo, AtuModoControle = ModoControleBomba.PID, }; @@ -768,8 +768,8 @@ namespace AgroBase.Models AtuDuracaoAtuacao = 200, AtuPercentualErvasBicoOff = 2, AtuPercentualErvasBicoOn = 1, - AtuPercentualInicioPulverizacao = 70, - AtuPressaoLinha = 22, + AtuPercentualInicioPulverizacao = 85, + AtuPressaoLinha = 18, AtuAgitadorModo = ModoAgitadorCalda.SemAgitacao, AtuModoControle = ModoControleBomba.PID, }; @@ -908,7 +908,7 @@ namespace AgroBase.Models AtuPercentualErvasBicoOff = 2, AtuPercentualErvasBicoOn = 1, AtuPercentualInicioPulverizacao = 85, - AtuPressaoLinha = 22, + AtuPressaoLinha = 18, AtuAgitadorModo = ModoAgitadorCalda.SemAgitacao, AtuModoControle = ModoControleBomba.PID, }; @@ -1007,7 +1007,7 @@ namespace AgroBase.Models AtuPercentualErvasBicoOff = 2, AtuPercentualErvasBicoOn = 1, AtuPercentualInicioPulverizacao = 85, - AtuPressaoLinha = 22, + AtuPressaoLinha = 18, AtuAgitadorModo = ModoAgitadorCalda.SemAgitacao, AtuModoControle = ModoControleBomba.PID, }; diff --git a/AgroBase/AgroBase/Services/MKS057DCanService.cs b/AgroBase/AgroBase/Services/MKS057DCanService.cs index faa8c80cd..7ca97ca90 100644 --- a/AgroBase/AgroBase/Services/MKS057DCanService.cs +++ b/AgroBase/AgroBase/Services/MKS057DCanService.cs @@ -95,12 +95,12 @@ namespace AgroBase.Services */ { MksFuncCode.PulsosRecebidos, TimeSpan.FromMilliseconds(70) }, { MksFuncCode.IO, TimeSpan.FromMilliseconds(700) }, + { MksFuncCode.RPM, TimeSpan.FromMilliseconds(500) }, /* * Diagnóstico opcional. * Não entram em OrdemPolling por padrão. */ - { MksFuncCode.RPM, TimeSpan.FromMilliseconds(500) }, { MksFuncCode.ErroAcumulado, TimeSpan.FromSeconds(1) }, { MksFuncCode.EncoderAcumulado, TimeSpan.FromSeconds(1) }, { MksFuncCode.EncoderCarregado, TimeSpan.FromSeconds(2) }, @@ -965,6 +965,15 @@ namespace AgroBase.Services return (ushort)((data[startIndex] << 8) | data[startIndex + 1]); } + private static short DecodeInt16BE(byte[] data, int startIndex) + { + if (data == null || data.Length < startIndex + 2) + return 0; + + ushort raw = (ushort)((data[startIndex] << 8) | data[startIndex + 1]); + return unchecked((short)raw); + } + private static int DecodeInt32BE(byte[] data, int startIndex) { if (data == null || data.Length < startIndex + 4) @@ -1213,7 +1222,9 @@ namespace AgroBase.Services if (data == null || data.Length < 3) return; - item.RPM.valor = DecodeUInt16BE(data, 1); + short rpm = DecodeInt16BE(data, 1); + + item.RPM.valor = rpm; } private void ProcessarPulsosRecebidos(MKS057DModel item, byte[] data) diff --git a/AgroBase/AgroBase/Services/Operadores/HealthWorkerService.cs b/AgroBase/AgroBase/Services/Operadores/HealthWorkerService.cs index 342ac3be2..b8e6c50b5 100644 --- a/AgroBase/AgroBase/Services/Operadores/HealthWorkerService.cs +++ b/AgroBase/AgroBase/Services/Operadores/HealthWorkerService.cs @@ -308,6 +308,9 @@ namespace AgroBase.Services.Operadores { percent_vel_max = pControle.MovVelocidadeSErvasPercent, percent_vel_min = pControle.MovVelocidadeCErvasPercent, + rampa_aceleracao_pct_s = 12.0, + rampa_desaceleracao_pct_s = 18.0, + rampa_desaceleracao_seguranca_pct_s = 35.0, } ), ("Dir", new diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/direcional.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/direcional.py index 9b6c946b9..0717734e5 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/direcional.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/direcional.py @@ -36,6 +36,7 @@ class ModuloDirecional(ModuloDiagnosticoBase): self.t_code = T_Code.Dir self.nome = "Direcional" self.timeout = 3 + self._estado_driver = {} def atualizar_saude(self): try: @@ -179,8 +180,8 @@ class ModuloDirecional(ModuloDiagnosticoBase): leitura = self._avaliar_resposta_recente(modulo, motivos, condicoes) feedback = self._avaliar_feedback(modulo, motivos, condicoes, referenciando) - comando = self._avaliar_seguimento_comando(modulo, motivos, condicoes) - coerencia = self._avaliar_coerencia_operacional(modulo, motivos, condicoes) + comando = self._avaliar_seguimento_comando(endereco_str, modulo, motivos, condicoes) + coerencia = self._avaliar_coerencia_operacional(endereco_str, modulo, motivos, condicoes) telemetria = self._avaliar_telemetria(modulo, motivos, referenciando) componentes = { @@ -336,62 +337,116 @@ class ModuloDirecional(ModuloDiagnosticoBase): "angulo_sp": angulo_sp, } - def _avaliar_seguimento_comando(self, modulo, motivos, condicoes): + def _avaliar_seguimento_comando(self, endereco_str, modulo, motivos, condicoes): + st = self._estado(endereco_str) + comandar = self._bool(modulo.get("comandar", True)) atingiu = self._bool(modulo.get("atingiu_angulo_sp", False)) retornando_a_zero = self._bool(modulo.get("retornando_a_zero", False)) + em_movimento = self._bool(modulo.get("em_movimento", False)) - erro_angulo = self._float( - modulo.get( - "erro_angulo", - abs( - self._float(modulo.get("angulo", 0.0), 0.0) - - self._float(modulo.get("angulo_sp", 0.0), 0.0) - ) - ), - 0.0, - ) + angulo = self._float(modulo.get("angulo", 0.0), 0.0) + angulo_sp = self._float(modulo.get("angulo_sp", 0.0), 0.0) + erro_angulo = abs(angulo_sp - angulo) - ultimo_comando_ms = self._float(modulo.get("ultimo_comando_ms", 999999), 999999) - ultima_direcao = self._safe_int( - modulo.get("ultima_direcao", self._direcao_parada_value()), - self._direcao_parada_value(), + ultimo_comando_ms = self._float( + modulo.get("ultimo_comando_ms", 999999), + 999999 ) if not comandar: - # Driver conectado, mas não participa do comando atual. return { "score": 100, "falha_seguimento": False, } - if atingiu: + if atingiu or erro_angulo <= self.TOLERANCIA_ANGULO_PADRAO_GRAUS: + st["ultimo_erro"] = erro_angulo + st["ultimo_sp"] = angulo_sp + st["ultimo_comando_ms"] = ultimo_comando_ms + return { "score": 100, "falha_seguimento": False, } - if ultimo_comando_ms < self.COMANDO_GRACE_MS: + sp_anterior = st.get("ultimo_sp") + erro_anterior = st.get("ultimo_erro") + comando_ms_anterior = st.get("ultimo_comando_ms") + + sp_mudou = ( + sp_anterior is None or + abs(angulo_sp - self._float(sp_anterior, angulo_sp)) > 0.5 + ) + + comando_reiniciou = ( + comando_ms_anterior is None or + ultimo_comando_ms < self._float(comando_ms_anterior, ultimo_comando_ms) + ) + + erro_diminuindo = ( + erro_anterior is not None and + erro_angulo < self._float(erro_anterior, erro_angulo) - 0.3 + ) + + # Estima janela maior quando o salto angular é grande. + delta_sp = abs(angulo_sp - self._float(sp_anterior, angulo_sp)) if sp_anterior is not None else erro_angulo + + grace_ms = max(self.COMANDO_GRACE_MS, min(2500.0, 500.0 + delta_sp * 35.0)) + alerta_ms = max(self.COMANDO_ALERTA_MS, min(7000.0, 1200.0 + delta_sp * 80.0)) + falha_ms = max(self.COMANDO_FALHA_MS, min(12000.0, 2500.0 + delta_sp * 140.0)) + + st["ultimo_erro"] = erro_angulo + st["ultimo_sp"] = angulo_sp + st["ultimo_comando_ms"] = ultimo_comando_ms + + # Transição esperada. + if sp_mudou or comando_reiniciou or ultimo_comando_ms < grace_ms: return { "score": 96, "falha_seguimento": False, } - if retornando_a_zero and erro_angulo <= self.ERRO_ALERTA_GRAUS: - motivos.append( - f"Retornando a zero com erro residual ({erro_angulo:.2f}°)" - ) + # Ainda longe, mas mexendo ou melhorando. + if ultimo_comando_ms < alerta_ms and (em_movimento or erro_diminuindo): return { - "score": 88, + "score": 92, "falha_seguimento": False, } - # Aqui começa a diferença entre telemetria lenta e comando que não pegou. - if ultimo_comando_ms >= self.COMANDO_FALHA_MS and erro_angulo >= self.ERRO_FALHA_GRAUS: + # Alerta real: demorou e ainda está longe, mas não é falha dura. + if ultimo_comando_ms >= alerta_ms and erro_angulo >= self.ERRO_ALERTA_GRAUS: + motivos.append( + f"Ângulo ainda distante do SP " + f"(erro={erro_angulo:.2f}°, comando há {ultimo_comando_ms:.0f} ms)" + ) + + condicoes.append({ + "valor": erro_angulo, + "severidade": min(89, max(45, erro_angulo * 5)), + "descricao": ( + "Ângulo direcional distante do SP após janela dinâmica " + f"(erro={erro_angulo:.2f}°, comando há {ultimo_comando_ms:.0f} ms)" + ), + }) + + return { + "score": 72, + "falha_seguimento": False, + } + + # Falha real: comando antigo, erro alto e sem sinal de convergência. + if ( + ultimo_comando_ms >= falha_ms and + erro_angulo >= self.ERRO_FALHA_GRAUS and + not em_movimento and + not erro_diminuindo + ): motivos.append( f"Comando direcional não convergiu " f"(erro={erro_angulo:.2f}°, comando há {ultimo_comando_ms:.0f} ms)" ) + condicoes.append({ "valor": erro_angulo, "severidade": 92, @@ -400,55 +455,44 @@ class ModuloDirecional(ModuloDiagnosticoBase): f"(erro={erro_angulo:.2f}°, comando há {ultimo_comando_ms:.0f} ms)" ), }) + return { "score": 20, "falha_seguimento": True, } - if ultimo_comando_ms >= self.COMANDO_ALERTA_MS and erro_angulo >= self.ERRO_ALERTA_GRAUS: + if retornando_a_zero and erro_angulo <= self.ERRO_ALERTA_GRAUS: motivos.append( - f"Ângulo ainda distante do SP " - f"(erro={erro_angulo:.2f}°, comando há {ultimo_comando_ms:.0f} ms)" + f"Retornando a zero com erro residual ({erro_angulo:.2f}°)" ) - condicoes.append({ - "valor": erro_angulo, - "severidade": min(89, max(40, erro_angulo * 5)), - "descricao": ( - "Ângulo direcional distante do SP, mas ainda dentro de janela de alerta " - f"(erro={erro_angulo:.2f}°, comando há {ultimo_comando_ms:.0f} ms)" - ), - }) - return { - "score": 70, - "falha_seguimento": False, - } - if erro_angulo > self.TOLERANCIA_ANGULO_PADRAO_GRAUS: - motivos.append( - f"Ângulo fora da tolerância momentaneamente (erro={erro_angulo:.2f}°)" - ) return { - "score": 90, + "score": 88, "falha_seguimento": False, } return { - "score": 100, + "score": 90, "falha_seguimento": False, } - def _avaliar_coerencia_operacional(self, modulo, motivos, condicoes): + def _avaliar_coerencia_operacional(self, endereco_str, modulo, motivos, condicoes): + st = self._estado(endereco_str) + referenciado = self._bool(modulo.get("referenciado", False)) referenciando = self._bool(modulo.get("referenciando", False)) + atingiu = self._bool(modulo.get("atingiu_angulo_sp", False)) + em_movimento = self._bool(modulo.get("em_movimento", False)) + + angulo = self._float(modulo.get("angulo", 0.0), 0.0) + angulo_sp = self._float(modulo.get("angulo_sp", 0.0), 0.0) + erro_angulo = abs(angulo_sp - angulo) - sentido = self._safe_int( - modulo.get("sentido", self._sentido_parado_value()), - self._sentido_parado_value(), - ) sentido_real = self._safe_int( modulo.get("sentido_real", self._sentido_parado_value()), self._sentido_parado_value(), ) + sentido_sp = self._safe_int( modulo.get("sentido_sp", self._sentido_parado_value()), self._sentido_parado_value(), @@ -466,24 +510,46 @@ class ModuloDirecional(ModuloDiagnosticoBase): }) score = min(score, 75) - # Sentido real vem do sensor do rasgo. - # Como esse sensor pode ter borda/transição, não vamos bloquear operação por isso sozinho. - if sentido != self._sentido_parado_value() and sentido != sentido_real: - motivos.append( - f"Sentido real incoerente " - f"(real={self._nome_sentido(sentido_real)}, esperado={self._nome_sentido(sentido)})" - ) - condicoes.append({ - "valor": sentido_real, - "severidade": 70, - "descricao": ( - "Sentido real difere do sentido calculado " - f"(real={self._nome_sentido(sentido_real)}, " - f"esperado={self._nome_sentido(sentido)}, " - f"SP={self._nome_sentido(sentido_sp)})" - ), - }) - score = min(score, 70) + # Durante movimento/transição, não julga sentido real. + if em_movimento or not atingiu or erro_angulo > 3.0: + st["incoerencia_sentido_count"] = 0 + return { + "score": score, + "falha_coerencia": False, + } + + # Perto de zero, o sentido/lado não é confiável. + if abs(angulo_sp) < 2.0: + st["incoerencia_sentido_count"] = 0 + return { + "score": score, + "falha_coerencia": False, + } + + # Só compara quando está parado/estável no SP. + if sentido_sp != self._sentido_parado_value() and sentido_real != sentido_sp: + st["incoerencia_sentido_count"] = st.get("incoerencia_sentido_count", 0) + 1 + + # Histerese: precisa repetir. + if st["incoerencia_sentido_count"] >= 3: + motivos.append( + f"Sensor de lado/sentido incoerente em posição estável " + f"(real={self._nome_sentido(sentido_real)}, esperado={self._nome_sentido(sentido_sp)})" + ) + + condicoes.append({ + "valor": sentido_real, + "severidade": 65, + "descricao": ( + "Sensor de lado/sentido difere do esperado após estabilização " + f"(real={self._nome_sentido(sentido_real)}, " + f"esperado={self._nome_sentido(sentido_sp)})" + ), + }) + + score = min(score, 75) + else: + st["incoerencia_sentido_count"] = 0 return { "score": score, @@ -812,3 +878,14 @@ class ModuloDirecional(ModuloDiagnosticoBase): return Sentido(valor).name except Exception: return str(valor) + + def _estado(self, endereco_str): + if endereco_str not in self._estado_driver: + self._estado_driver[endereco_str] = { + "ultimo_erro": None, + "ultimo_sp": None, + "ultimo_comando_ms": None, + "incoerencia_sentido_count": 0, + } + + return self._estado_driver[endereco_str] diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/ponte_ip.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/ponte_ip.py index ea81db374..51196c9be 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/ponte_ip.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/health_worker/modulos/ponte_ip.py @@ -29,7 +29,8 @@ class ModuloIPBribge(ModuloDiagnosticoBase): nunca pela frequência do loop do Health Worker. 4. Falha crítica exige persistência ou confirmação por mais de uma evidência. 5. Banda estimada é indício de pressão, não prova isolada de falha. - 6. O payload antigo é preservado na medida do possível para não quebrar + 6. Jitter isolado gera alerta e redução operacional, mas não bloqueio duro. + 7. O payload antigo é preservado na medida do possível para não quebrar consumidores existentes. """ @@ -1123,30 +1124,66 @@ class ModuloIPBribge(ModuloDiagnosticoBase): math.isfinite(hb_age) and hb_age >= self._heartbeat_critical_s ) - # Perda percentual só vira evidência após uma janela mínima e pelo - # menos duas falhas reais. Isso impede que 1 falha isolada durante o - # aquecimento apareça como 16,7% e dispare degradação. + # Perda percentual só vira evidência após janela mínima e pelo menos + # duas falhas reais. Uma falha isolada não degrada o enlace. loss_degraded = ( metrics["probe_sample_count"] >= 12 and metrics["failures_s"] >= 2 and loss_s >= 10.0 ) - qos_degraded = probe_ready and ( - loss_degraded - or (rtt_s >= 180.0 and jitter_s >= 50.0) - or jitter_s >= 100.0 - or self._consecutive_ping_failures >= 2 + # Jitter sozinho vira aviso operacional, não prova de perda de enlace. + # No teste de 03/07, havia jitter alto, mas perda 0%, RTT baixo, + # heartbeat ok e ping recente. Esse cenário deve reduzir tráfego, + # não travar avanço. + jitter_warning = bool( + probe_ready + and jitter_s >= 80.0 ) - corroborated_service_delay = heartbeat_delayed and ( - loss_s >= 5.0 - or rtt_s >= 150.0 - or jitter_s >= 60.0 - or self._consecutive_ping_failures >= 2 + jitter_isolated = bool( + jitter_warning + and loss_s <= 2.0 + and rtt_s <= 120.0 + and heartbeat_snapshot["heartbeat_ok"] + and evidence["strong_positive"] + and self._consecutive_ping_failures == 0 ) - bandwidth_corroborated = ( + jitter_degraded = bool( + probe_ready + and jitter_s >= 100.0 + and not jitter_isolated + and ( + rtt_s >= 120.0 + or loss_s >= 5.0 + or heartbeat_delayed + or self._consecutive_ping_failures >= 1 + or not evidence["strong_positive"] + ) + ) + + qos_degraded = bool( + probe_ready + and ( + loss_degraded + or (rtt_s >= 180.0 and jitter_s >= 50.0) + or jitter_degraded + or self._consecutive_ping_failures >= 2 + ) + ) + + corroborated_service_delay = bool( + heartbeat_delayed + and ( + loss_s >= 5.0 + or rtt_s >= 150.0 + or jitter_s >= 60.0 + or self._consecutive_ping_failures >= 2 + ) + ) + + bandwidth_corroborated = bool( metrics["bw_util_s"] >= 85.0 and ( loss_s >= 5.0 @@ -1184,7 +1221,7 @@ class ModuloIPBribge(ModuloDiagnosticoBase): ) ) - # Confirmação de queda total: nunca depende apenas de MQTT. + # Queda total nunca depende apenas de MQTT. down_candidate = bool( not evidence["route_ok"] or ( @@ -1194,6 +1231,21 @@ class ModuloIPBribge(ModuloDiagnosticoBase): ) ) + # Sinal de degradação que realmente merece restringir avanço. + # DEGRADED por perda, RTT, heartbeat ou ping falhando é diferente + # de jitter isolado com controle remoto comprovadamente vivo. + degraded_restrictive_signal = bool( + degraded_now + and ( + loss_s >= 5.0 + or rtt_s >= 150.0 + or heartbeat_delayed + or self._consecutive_ping_failures >= 1 + or not evidence["strong_positive"] + or not heartbeat_snapshot["heartbeat_ok"] + ) + ) + return { "degraded_now": degraded_now, "critical_now": critical_now, @@ -1202,9 +1254,12 @@ class ModuloIPBribge(ModuloDiagnosticoBase): "heartbeat_very_delayed": heartbeat_very_delayed, "bandwidth_corroborated": bandwidth_corroborated, "qos_degraded": qos_degraded, + "jitter_warning": jitter_warning, + "jitter_isolated": jitter_isolated, + "jitter_degraded": jitter_degraded, + "degraded_restrictive_signal": degraded_restrictive_signal, } - @staticmethod def _elapsed_since(now_mono: float, started_mono: float) -> float: return max(0.0, now_mono - started_mono) if started_mono > 0 else 0.0 @@ -1300,7 +1355,7 @@ class ModuloIPBribge(ModuloDiagnosticoBase): else: bw_effective_score = max(85.0, metrics["bw_score_raw"]) - # Reachability: usa várias evidências. MQTT sozinho não decide nada. + # Reachability usa várias evidências. MQTT sozinho não decide nada. if evidence["strong_positive"]: reachability_score = 100.0 elif evidence["positive_count"] >= 2: @@ -1308,7 +1363,7 @@ class ModuloIPBribge(ModuloDiagnosticoBase): elif evidence["any_positive"]: reachability_score = 80.0 elif not metrics["probe_ready"] and evidence["route_ok"]: - reachability_score = 75.0 # aquecimento + reachability_score = 75.0 elif evidence["route_ok"]: reachability_score = 30.0 else: @@ -1323,8 +1378,6 @@ class ModuloIPBribge(ModuloDiagnosticoBase): + 0.03 * bw_effective_score ) - # Serviço MQTT/heartbeat pode reduzir um pouco a saúde observada, mas - # jamais transformar sozinho um enlace comprovadamente ativo em falha. service_penalty = 0.0 if not heartbeat_snapshot["mqtt_connected"]: service_penalty += 4.0 @@ -1336,7 +1389,13 @@ class ModuloIPBribge(ModuloDiagnosticoBase): if self.link_state == self.LINK_CRITICAL: health = min(health, 45.0) elif self.link_state == self.LINK_DEGRADED: - health = min(health, 75.0) + # Se o degradado é restritivo, mantém cap forte. Se é apenas um + # degradado brando com controle vivo, cap menos agressivo para + # não virar bloqueio indireto por score. + if instant.get("degraded_restrictive_signal", True): + health = min(health, 75.0) + else: + health = min(health, 82.0) return { "health": round(health, 1), @@ -1463,6 +1522,76 @@ class ModuloIPBribge(ModuloDiagnosticoBase): self._max_distancia_desde_degradado = 0.0 self._max_distancia_ok = max(self._max_distancia_ok, distancia_base) + + def _decisao_operacional_rede( + self, + conectado: bool, + metrics, + evidence, + heartbeat_snapshot, + instant, + down_confirmed: bool, + ): + rtt_s = metrics["avg_rtt_s"] or 0.0 + loss_s = metrics["loss_s"] + + controle_remoto_estavel = bool( + conectado + and evidence["route_ok"] + and evidence["strong_positive"] + and heartbeat_snapshot["heartbeat_ok"] + and loss_s <= 2.0 + and rtt_s <= 120.0 + and self._consecutive_ping_failures == 0 + ) + + link_critico = bool( + not conectado + or down_confirmed + or self.link_state == self.LINK_CRITICAL + or instant["critical_now"] + or instant["down_candidate"] + ) + + degradado_restritivo = bool( + conectado + and self.link_state == self.LINK_DEGRADED + and instant.get("degraded_restrictive_signal", False) + and not controle_remoto_estavel + ) + + pode_avancar_com_restricao = bool( + conectado + and self.link_state == self.LINK_DEGRADED + and not link_critico + and not degradado_restritivo + and controle_remoto_estavel + ) + + deve_reduzir_trafego = bool( + instant.get("jitter_warning", False) + or instant["degraded_now"] + or instant["bandwidth_corroborated"] + or self.link_state != self.LINK_OK + ) + + deve_limitar_velocidade = bool( + deve_reduzir_trafego + and not link_critico + ) + + return { + "controle_remoto_estavel": controle_remoto_estavel, + "link_critico": link_critico, + "degradado_restritivo": degradado_restritivo, + "pode_avancar": bool(conectado and not link_critico and not degradado_restritivo), + "pode_avancar_com_restricao": pode_avancar_com_restricao, + "deve_conter_avanco": bool(conectado and self.link_state == self.LINK_DEGRADED), + "deve_limitar_velocidade": deve_limitar_velocidade, + "deve_reduzir_trafego": deve_reduzir_trafego, + "deve_parar_por_rede": link_critico, + } + # ====================================================================== # PAYLOAD / APRESENTAÇÃO # ====================================================================== @@ -1518,6 +1647,7 @@ class ModuloIPBribge(ModuloDiagnosticoBase): down_confirmed, regra_distancia_ativa, distancia_base, + decisao_operacional, ): motivos = [] condicoes = [] @@ -1636,6 +1766,31 @@ class ModuloIPBribge(ModuloDiagnosticoBase): condicoes.append(condition) jitter_cond.append(condition) + if instant.get("jitter_warning", False) and not instant.get("jitter_degraded", False): + text = ( + f"Jitter elevado isolado: fast {metrics['jitter_f']:.1f} ms; " + f"slow {metrics['jitter_s']:.1f} ms. Enlace ainda possui " + "ping/heartbeat ativos, sem perda relevante." + ) + motivos.append(text) + jitter_motivos.append(text) + condition = { + "label": "Jitter elevado isolado", + "valor": round(metrics["jitter_f"], 2), + "severidade": 55, + "descricao": ( + "Jitter alto sem perda, sem RTT alto e com evidência forte " + "de alcance. Deve reduzir tráfego e velocidade, não parar." + ), + "acoes": [ + "Reduzir bitrate/FPS dos streams", + "Evitar salvamento pesado de imagens", + "Manter controle, RTCM e heartbeat priorizados", + ], + } + condicoes.append(condition) + jitter_cond.append(condition) + self._append_individual( individuais, "ip_jitter", @@ -1789,6 +1944,7 @@ class ModuloIPBribge(ModuloDiagnosticoBase): "acoes": [ "Reduzir tráfego não crítico", "Priorizar controle, RTCM e heartbeat", + "Permitir avanço restrito se controle remoto estiver estável", "Permitir retorno ou manutenção de posição", ], } @@ -1830,9 +1986,14 @@ class ModuloIPBribge(ModuloDiagnosticoBase): } ) - dist_violando_cerca = ( + distancia_restritiva_ativa = bool( regra_distancia_ativa and self.link_state == self.LINK_DEGRADED + and decisao_operacional.get("degradado_restritivo", False) + ) + + dist_violando_cerca = ( + distancia_restritiva_ativa and self._distancia_inicio_degradado is not None and distancia_base is not None and distancia_base > self._distancia_inicio_degradado + 5.0 @@ -1929,6 +2090,15 @@ class ModuloIPBribge(ModuloDiagnosticoBase): else: status = StatusModulo.OPERANTE + decisao_operacional = self._decisao_operacional_rede( + conectado, + metrics, + evidence, + heartbeat_snapshot, + instant, + down_confirmed, + ) + motivos, condicoes, saude_individual, risk_score = self._build_messages( metrics, heartbeat_snapshot, @@ -1938,6 +2108,7 @@ class ModuloIPBribge(ModuloDiagnosticoBase): down_confirmed, regra_distancia_ativa, distancia_base, + decisao_operacional, ) degraded_for_s = self._elapsed_since(now_mono, self._degraded_since_mono) @@ -1985,11 +2156,17 @@ class ModuloIPBribge(ModuloDiagnosticoBase): "cycles_degraded": self._cycles_degraded, "cycles_critical": self._cycles_critical, "cycles_recovered": self._cycles_recovered, - "pode_avancar": conectado and self.link_state == self.LINK_OK, - "deve_conter_avanco": conectado and self.link_state == self.LINK_DEGRADED, - "deve_parar_por_rede": ( - not conectado or self.link_state == self.LINK_CRITICAL - ), + # Chaves antigas preservadas, mas com semântica menos ansiosa. + # DEGRADED não trava avanço por si só. Só trava se for + # degradado restritivo ou crítico confirmado. + "pode_avancar": decisao_operacional["pode_avancar"], + "pode_avancar_com_restricao": decisao_operacional["pode_avancar_com_restricao"], + "deve_conter_avanco": decisao_operacional["deve_conter_avanco"], + "deve_limitar_velocidade": decisao_operacional["deve_limitar_velocidade"], + "deve_reduzir_trafego": decisao_operacional["deve_reduzir_trafego"], + "degradado_restritivo": decisao_operacional["degradado_restritivo"], + "controle_remoto_estavel": decisao_operacional["controle_remoto_estavel"], + "deve_parar_por_rede": decisao_operacional["deve_parar_por_rede"], "distancia_inicio_degradado": self._distancia_inicio_degradado, "distancia_inicio_critico": self._distancia_inicio_critico, "max_distancia_ok": self._max_distancia_ok, @@ -2052,6 +2229,13 @@ class ModuloIPBribge(ModuloDiagnosticoBase): "bandwidth_corroborated": instant[ "bandwidth_corroborated" ], + "jitter_warning": instant.get("jitter_warning", False), + "jitter_isolated": instant.get("jitter_isolated", False), + "jitter_degraded": instant.get("jitter_degraded", False), + "degraded_restrictive_signal": instant.get( + "degraded_restrictive_signal", + False, + ), "degraded_for_s": round(degraded_for_s, 2), "critical_for_s": round(critical_for_s, 2), "healthy_for_s": round(healthy_for_s, 2), diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/manager_worker/modulos/movimentacao.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/manager_worker/modulos/movimentacao.py index 5ffcc8ffe..a9c28fabb 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/manager_worker/modulos/movimentacao.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/manager_worker/modulos/movimentacao.py @@ -116,7 +116,7 @@ def definir_comando(filtro_vel: FiltroVelocidade, erro_orientacao: float = None) motivo = estado.get("motivo", "Estado operacional inválido para movimento") debug["motivos"].append(motivo) - filtro_vel.reset(0.0) + _resetar_rampa_velocidade(filtro_vel) return _montar_comando_retorno( velocidade_sp=0.0, frear=True, @@ -135,6 +135,7 @@ def definir_comando(filtro_vel: FiltroVelocidade, erro_orientacao: float = None) reduzir_para_pulverizar = False reduzir_para_fim_corredor = False reduzir_por_curva = False + reduzir_por_ipb = False if status_carro == StatusCarroMapa.Parado: velocidade_sp = 0.0 @@ -150,7 +151,6 @@ def definir_comando(filtro_vel: FiltroVelocidade, erro_orientacao: float = None) ]: velocidade_sp = vel_curva reduzir_por_curva = True - resetar_filtro = True debug["motivos"].append("movimento em curva/manobra") elif status_carro in [ @@ -188,13 +188,11 @@ def definir_comando(filtro_vel: FiltroVelocidade, erro_orientacao: float = None) debug["weed"] = info_ervas if reduzir_para_pulverizar: - velocidade_sp = vel_com_ervas - resetar_filtro = True + velocidade_sp = min(velocidade_sp, vel_com_ervas) debug["motivos"].append("ervas no radar: reduzindo para pulverizar") elif reduzir_para_fim_corredor: - velocidade_sp = vel_com_ervas - resetar_filtro = True + velocidade_sp = min(velocidade_sp, vel_com_ervas) debug["motivos"].append("fim de corredor próximo: reduzindo") else: @@ -220,7 +218,7 @@ def definir_comando(filtro_vel: FiltroVelocidade, erro_orientacao: float = None) # Se já decidiu parar, não faz limitador tentar reviver velocidade. if hard_stop: - filtro_vel.reset(0.0) + _resetar_rampa_velocidade(filtro_vel) return _montar_comando_retorno( velocidade_sp=0.0, frear=frear, @@ -239,7 +237,7 @@ def definir_comando(filtro_vel: FiltroVelocidade, erro_orientacao: float = None) if status_ipb not in [StatusModulo.OPERANTE, StatusModulo.ALERTA]: velocidade_sp = min(velocidade_sp, vel_degradada) - resetar_filtro = True + reduzir_por_ipb = True debug["limitadores"].append( f"IPB não operante: {status_ipb.name}, velocidade degradada" ) @@ -270,7 +268,7 @@ def definir_comando(filtro_vel: FiltroVelocidade, erro_orientacao: float = None) debug["limitadores"].append("Visual Worker solicitou stop") if hard_stop: - filtro_vel.reset(0.0) + _resetar_rampa_velocidade(filtro_vel) return _montar_comando_retorno( velocidade_sp=0.0, frear=frear, @@ -300,7 +298,7 @@ def definir_comando(filtro_vel: FiltroVelocidade, erro_orientacao: float = None) debug["limitadores"].append("IMU solicitou stop") if hard_stop: - filtro_vel.reset(0.0) + _resetar_rampa_velocidade(filtro_vel) return _montar_comando_retorno( velocidade_sp=0.0, frear=frear, @@ -329,16 +327,27 @@ def definir_comando(filtro_vel: FiltroVelocidade, erro_orientacao: float = None) # Reduções por segurança/curva/pulverização entram imediatamente. # Aceleração normal é filtrada para não dar tranco. + reducao_seguranca = bool( + reduzir_para_fim_corredor + or reduzir_por_curva + or reduzir_por_ipb + or debug.get("visual", {}).get("mode") in ["caution", "slowdown"] + or debug.get("imu", {}).get("risk_level_num", 0) >= 1 + ) + if velocidade_sp <= 0.0: - filtro_vel.reset(0.0) + _resetar_rampa_velocidade(filtro_vel) velocidade_filtrada = 0.0 - - elif resetar_filtro or reduzir_para_pulverizar or reduzir_para_fim_corredor or reduzir_por_curva: - filtro_vel.reset(velocidade_sp) - velocidade_filtrada = velocidade_sp - else: - velocidade_filtrada = filtro_vel.filtrar(velocidade_sp) + velocidade_filtrada = _aplicar_rampa_velocidade( + filtro_vel, + velocidade_sp, + hard_stop=False, + frear=frear, + reducao_seguranca=reducao_seguranca, + mov_cfg=mov_cfg, + debug=debug, + ) velocidade_filtrada = round( _clamp(velocidade_filtrada, 0.0, vel_sem_ervas), @@ -359,7 +368,7 @@ def definir_comando(filtro_vel: FiltroVelocidade, erro_orientacao: float = None) mostrar_log(f"Erro ao definir o comando de movimentacao: {e}") try: - filtro_vel.reset(0.0) + _resetar_rampa_velocidade(filtro_vel) except Exception: pass @@ -570,7 +579,7 @@ def _aplicar_limitador_visual( }.get(mode, 1.00) vel_nova = float(velocidade_atual) * fator_mode - resetar_filtro = fator_mode <= 0.75 + resetar_filtro = False stop = fator_mode <= 0.0 debug.update({ @@ -664,7 +673,7 @@ def _aplicar_limitador_imu(*, velocidade_atual): return velocidade_atual, False, False, debug vel_nova = float(velocidade_atual) * velocidade_factor - resetar_filtro = velocidade_factor <= 0.75 + resetar_filtro = False return vel_nova, resetar_filtro, False, debug @@ -722,6 +731,105 @@ def calcular_velocidade_relativa( return round(_clamp(vel, vel_min, vel_max), 2) +def _aplicar_rampa_velocidade( + filtro_vel, + velocidade_alvo, + *, + hard_stop=False, + frear=False, + reducao_seguranca=False, + mov_cfg=None, + debug=None +): + agora = time.monotonic() + + if hard_stop or frear or velocidade_alvo <= 0.0: + try: + _resetar_rampa_velocidade(filtro_vel) + except Exception: + pass + + try: + filtro_vel._ultimo_sp_campo = 0.0 + filtro_vel._ultimo_ts_campo = agora + except Exception: + pass + + return 0.0 + + mov_cfg = _dict(mov_cfg or {}) + + subida_pct_s = max( + 1.0, + _float(mov_cfg.get("rampa_aceleracao_pct_s", 12.0), 12.0) + ) + + descida_normal_pct_s = max( + 1.0, + _float(mov_cfg.get("rampa_desaceleracao_pct_s", 18.0), 18.0) + ) + + descida_seguranca_pct_s = max( + descida_normal_pct_s, + _float(mov_cfg.get("rampa_desaceleracao_seguranca_pct_s", 35.0), 35.0) + ) + + try: + ultimo = float(getattr(filtro_vel, "_ultimo_sp_campo", 0.0)) + ultimo_ts = float(getattr(filtro_vel, "_ultimo_ts_campo", 0.0)) + except Exception: + ultimo = 0.0 + ultimo_ts = 0.0 + + if ultimo_ts <= 0.0: + ultimo = 0.0 + ultimo_ts = agora + try: + filtro_vel._ultimo_sp_campo = ultimo + filtro_vel._ultimo_ts_campo = ultimo_ts + except Exception: + pass + + if debug is not None: + debug["rampa_velocidade_init"] = "inicializada_em_zero" + + return 0.0 + + dt = _clamp(agora - ultimo_ts, 0.02, 0.5) + + delta = float(velocidade_alvo) - ultimo + + if delta >= 0: + max_delta = subida_pct_s * dt + else: + taxa_descida = descida_seguranca_pct_s if reducao_seguranca else descida_normal_pct_s + max_delta = taxa_descida * dt + + delta_limitado = _clamp(delta, -max_delta, max_delta) + saida = ultimo + delta_limitado + + try: + filtro_vel._ultimo_sp_campo = saida + filtro_vel._ultimo_ts_campo = agora + except Exception: + pass + + if debug is not None: + debug["rampa_velocidade"] = { + "alvo": round(float(velocidade_alvo), 2), + "saida": round(float(saida), 2), + "ultimo": round(float(ultimo), 2), + "dt": round(float(dt), 3), + "delta": round(float(delta), 2), + "delta_limitado": round(float(delta_limitado), 2), + "subida_pct_s": subida_pct_s, + "descida_normal_pct_s": descida_normal_pct_s, + "descida_seguranca_pct_s": descida_seguranca_pct_s, + "reducao_seguranca": bool(reducao_seguranca), + } + + return saida + # ============================================================ # Retorno # ============================================================ @@ -738,6 +846,21 @@ def _montar_comando_retorno(velocidade_sp: float, frear: bool = False, debug: di # Helpers # ============================================================ +def _resetar_rampa_velocidade(filtro_vel): + agora = time.monotonic() + + try: + filtro_vel.reset(0.0) + except Exception: + pass + + try: + filtro_vel._ultimo_sp_campo = 0.0 + filtro_vel._ultimo_ts_campo = agora + except Exception: + pass + + def _status_modulo(t_code): modulo = _dict(ContextoGlobalRedis.get_modulo(t_code)) saude = _dict(modulo.get("saude", {})) diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/visual_worker/camera_manager.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/visual_worker/camera_manager.py index 7ebf97b97..cb0dea483 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/visual_worker/camera_manager.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/visual_worker/camera_manager.py @@ -180,6 +180,9 @@ class CameraManager: "campos": 0, } + self._ultimo_frame_preview_por_tipo = {} + self._ultimo_frame_preview_ts_por_tipo = {} + def inicializar(self, mx_id): if self.iniciando: return @@ -826,13 +829,19 @@ class CameraManager: # ============================================================ def get_selected_frame(self, frame_type: TipoFrameCamera): - if not self._visual_disponivel_para_frame(): - return None - try: + try: + tipo_enum = TipoFrameCamera(frame_type) + except Exception: + tipo_enum = frame_type + + # Se visual não está pronto, ainda tenta devolver último frame válido. + if not self._visual_disponivel_para_frame(): + return self._get_cached_preview_frame(tipo_enum) + with self._cache_lock: - rgb = self._ultimo_rgb_frame - depth = self._ultimo_depth_frame + rgb = self._copiar_frame(self._ultimo_rgb_frame) + depth = self._copiar_frame(self._ultimo_depth_frame) pred = self._ultimo_predictions dets = list(self._ultimo_detections or []) snapshot = self._ultimo_snapshot @@ -840,15 +849,15 @@ class CameraManager: params = getattr(self.camera, "parametros", {}) if self.camera is not None else {} # PosProcessamento no Visual Worker = imagem RGB original em melhor qualidade. - # Aqui não renderiza overlay, segmentação ou debug. - if frame_type == TipoFrameCamera.PosProcessamento: - if rgb is None or not hasattr(rgb, "size") or rgb.size <= 0: - return None + if tipo_enum == TipoFrameCamera.PosProcessamento: + if self._frame_valido(rgb): + self._set_cached_preview_frame(tipo_enum, rgb) + return self._copiar_frame(rgb) - return rgb.copy() + return self._get_cached_preview_frame(tipo_enum) - return self.renderer.get_selected_frame( - frame_type=frame_type, + frame = self.renderer.get_selected_frame( + frame_type=tipo_enum, rgb_frame=rgb, pred_ids=pred, depth_frame=depth, @@ -858,10 +867,102 @@ class CameraManager: alpha=0.50, ) + if self._frame_valido(frame): + self._set_cached_preview_frame(tipo_enum, frame) + return self._copiar_frame(frame) + + # Se o renderer falhou ou faltou algum insumo naquele instante, + # devolve o último frame válido desse mesmo tipo. + cached = self._get_cached_preview_frame(tipo_enum) + if self._frame_valido(cached): + return cached + + return None + except Exception as e: self.mostrar_log(f"[visual] erro em get_selected_frame({frame_type}): {e}") + + try: + return self._get_cached_preview_frame(frame_type) + except Exception: + return None + + def _frame_valido(self, frame) -> bool: + return ( + frame is not None and + hasattr(frame, "size") and + frame.size > 0 + ) + + def _copiar_frame(self, frame): + if not self._frame_valido(frame): return None + try: + return frame.copy() + except Exception: + return frame + + def _frame_cache_key(self, frame_type): + try: + return TipoFrameCamera(frame_type).name + except Exception: + return str(frame_type) + + def _set_cached_preview_frame(self, frame_type, frame, ts=None): + if not self._frame_valido(frame): + return False + + key = self._frame_cache_key(frame_type) + ts = time.time() if ts is None else ts + frame_copy = self._copiar_frame(frame) + + if not self._frame_valido(frame_copy): + return False + + with self._cache_lock: + self._ultimo_frame_preview_por_tipo[key] = frame_copy + self._ultimo_frame_preview_ts_por_tipo[key] = ts + + return True + + def _get_cached_preview_frame(self, frame_type): + key = self._frame_cache_key(frame_type) + + with self._cache_lock: + frame = self._ultimo_frame_preview_por_tipo.get(key) + + return self._copiar_frame(frame) + + def _normalizar_frame_para_uint8(self, frame): + """ + Converte frame float/uint16/etc para uint8 apenas para replay visual. + Útil para Heatmap/MatrizCusto caso algum renderer devolva matriz não-uint8. + """ + + try: + if frame is None: + return None + + if getattr(frame, "dtype", None) == np.uint8: + return frame + + arr = frame.astype("float32") + + min_v = float(arr.min()) + max_v = float(arr.max()) + + if max_v <= min_v: + return np.zeros(arr.shape, dtype=np.uint8) + + arr = (arr - min_v) / (max_v - min_v) + arr = np.clip(arr * 255.0, 0, 255).astype("uint8") + + return arr + + except Exception: + return frame + # ============================================================ # Loops # ============================================================ @@ -1487,9 +1588,17 @@ class CameraManager: frame = self.get_selected_frame(tipo_enum) - if frame is None or not hasattr(frame, "size") or frame.size <= 0: + if not self._frame_valido(frame): + self.mostrar_log( + f"⚠️ Frame visual não salvo | " + f"tipo={tipo_enum.name} " + f"nome={frame_name}_{tipo_enum.name} " + f"motivo=sem_frame_valido_em_cache" + ) continue + frame = self._copiar_frame(frame) + nome_base = f"{frame_name}_{tipo_enum.name}" # ==================================================== @@ -1517,13 +1626,14 @@ class CameraManager: caminho = os.path.join(pasta_async, f"{nome_base_async}.png") ok = cv2.imwrite(caminho, frame_async) + ok_final = bool(ok and os.path.exists(caminho)) t_pp1 = time.perf_counter() self.mostrar_log( f"[visual][SAVE_PP] salvo async | " f"nome={nome_base_async} " - f"ok={ok} " + f"ok={ok_final} " f"tempo_ms={(t_pp1 - t_pp0) * 1000.0:.1f}" ) @@ -1548,14 +1658,26 @@ class CameraManager: # Salva JPEG leve para não entupir disco durante operação. caminho = os.path.join(pasta, f"{nome_base}.jpg") + if getattr(frame, "dtype", None) != np.uint8: + frame_salvar = self._normalizar_frame_para_uint8(frame) + else: + frame_salvar = frame + ok = cv2.imwrite( caminho, - frame, + frame_salvar, [int(cv2.IMWRITE_JPEG_QUALITY), int(replay_jpeg_quality)] ) - if ok: + if ok and os.path.exists(caminho): frames_salvos.append(caminho) + else: + self.mostrar_log( + f"❌ Falha ao salvar frame visual | " + f"tipo={tipo_enum.name} " + f"nome={nome_base} " + f"caminho={caminho}" + ) return frames_salvos diff --git a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/weed_worker/camera_manager.py b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/weed_worker/camera_manager.py index 05234e052..1764ffee6 100644 --- a/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/weed_worker/camera_manager.py +++ b/AgroBase/AgroBase/bin/x64/Debug/Python/Scripts/workers/weed_worker/camera_manager.py @@ -81,6 +81,7 @@ class CameraManager: self._lock_tensor = threading.Lock() self._pred_lock = threading.RLock() self._pub_lock = threading.RLock() + self._preview_lock = threading.RLock() self.perf = VisualPerfMonitor(janela=180) @@ -131,6 +132,11 @@ class CameraManager: self._ultimo_preview_overlay = None self._ultimo_preview_debug = None + self._ultimo_preview_rgb_ts = 0.0 + self._ultimo_preview_seg_ts = 0.0 + self._ultimo_preview_overlay_ts = 0.0 + self._ultimo_preview_debug_ts = 0.0 + self._fps_infer_last_ts = None self._fps_infer_ema = 0.0 self._ultimo_infer_ms = 0.0 @@ -612,9 +618,11 @@ class CameraManager: if _frame_type not in self.FRAME_TYPES_PREVIEW: return None + # Se ainda não tem runtime/tensor, tenta devolver o último preview válido. if self.model_svc is None or self._ultimo_raw_input is None: - return None + return self._get_cached_preview_frame(_frame_type) + # Janela curta: usa cache recente. if (agora - self._ultimo_preview_ts) < 0.20: cached = self._get_cached_preview_frame(_frame_type) if cached is not None: @@ -633,28 +641,44 @@ class CameraManager: overlay_bgr=overlay_frame, ) + # Atualiza o timestamp da tentativa de preview. self._ultimo_preview_ts = agora - self._ultimo_preview_rgb = rgb_frame - self._ultimo_preview_seg = seg_frame - self._ultimo_preview_overlay = overlay_frame - self._ultimo_preview_debug = debug_frame + # Importante: + # só atualiza cache se o frame novo for válido. + # Nunca apaga último válido com None. + self._set_cached_preview_frame(TipoFrameCamera.Rgb, rgb_frame, agora) + self._set_cached_preview_frame(TipoFrameCamera.Segmentacao, seg_frame, agora) + self._set_cached_preview_frame(TipoFrameCamera.Overlay, overlay_frame, agora) + self._set_cached_preview_frame(TipoFrameCamera.Debug, debug_frame, agora) + + # Retorna o frame pedido. + # Se o novo veio None, devolve o último válido cacheado. return self._get_cached_preview_frame(_frame_type) except Exception as e: self.mostrar_log(f"[weed] erro em get_selected_frame: {e}") - return None + + # Mesmo em erro, tenta devolver último válido. + try: + return self._get_cached_preview_frame(_frame_type) + except Exception: + return None def _get_cached_preview_frame(self, frame_type): - if frame_type == TipoFrameCamera.Rgb: - return self._ultimo_preview_rgb - if frame_type == TipoFrameCamera.Segmentacao: - return self._ultimo_preview_seg - if frame_type == TipoFrameCamera.Overlay: - return self._ultimo_preview_overlay - if frame_type == TipoFrameCamera.Debug: - return self._ultimo_preview_debug - return None + with self._preview_lock: + if frame_type == TipoFrameCamera.Rgb: + frame = self._ultimo_preview_rgb + elif frame_type == TipoFrameCamera.Segmentacao: + frame = self._ultimo_preview_seg + elif frame_type == TipoFrameCamera.Overlay: + frame = self._ultimo_preview_overlay + elif frame_type == TipoFrameCamera.Debug: + frame = self._ultimo_preview_debug + else: + frame = None + + return self._copiar_frame(frame) def get_debug_frame(self, mostrar=False, overlay_bgr=None): overlay = overlay_bgr if overlay_bgr is not None else self._ultimo_preview_overlay @@ -810,6 +834,55 @@ class CameraManager: self.mostrar_log(f"Erro ao montar debug overlay weed: {e}") return None + def _frame_valido(self, frame) -> bool: + return ( + frame is not None and + hasattr(frame, "size") and + frame.size > 0 + ) + + def _copiar_frame(self, frame): + if not self._frame_valido(frame): + return None + + try: + return frame.copy() + except Exception: + return frame + + def _set_cached_preview_frame(self, frame_type, frame, ts=None): + if not self._frame_valido(frame): + return False + + ts = time.time() if ts is None else ts + frame_copy = self._copiar_frame(frame) + + if not self._frame_valido(frame_copy): + return False + + with self._preview_lock: + if frame_type == TipoFrameCamera.Rgb: + self._ultimo_preview_rgb = frame_copy + self._ultimo_preview_rgb_ts = ts + return True + + if frame_type == TipoFrameCamera.Segmentacao: + self._ultimo_preview_seg = frame_copy + self._ultimo_preview_seg_ts = ts + return True + + if frame_type == TipoFrameCamera.Overlay: + self._ultimo_preview_overlay = frame_copy + self._ultimo_preview_overlay_ts = ts + return True + + if frame_type == TipoFrameCamera.Debug: + self._ultimo_preview_debug = frame_copy + self._ultimo_preview_debug_ts = ts + return True + + return False + # ============================================================ # Loops # ============================================================ @@ -1989,13 +2062,18 @@ class CameraManager: # ==================================================== # 2) FLUXO ATUAL: imagens para replay # ==================================================== - frame = self.get_selected_frame(frame_type) + frame = self.get_selected_frame(tipo_enum) - if frame is None: + if not self._frame_valido(frame): + self.mostrar_log( + f"⚠️ Frame não salvo | " + f"tipo={tipo_nome} " + f"nome={nome_frame} " + f"motivo=sem_frame_valido_em_cache" + ) continue - if not hasattr(frame, "size") or frame.size <= 0: - continue + frame = self._copiar_frame(frame) caminho = os.path.join(pasta, f"{nome_frame}.jpg") @@ -2006,11 +2084,16 @@ class CameraManager: else: frame_salvar = frame - cv2.imwrite( - caminho, - frame_salvar, - [int(cv2.IMWRITE_JPEG_QUALITY), 70] - ) + ok = cv2.imwrite(caminho, frame_salvar, [int(cv2.IMWRITE_JPEG_QUALITY), 70]) + + if not ok or not os.path.exists(caminho): + self.mostrar_log( + f"❌ Falha ao salvar frame | " + f"tipo={tipo_nome} " + f"nome={nome_frame} " + f"caminho={caminho}" + ) + continue frames_salvos.append(caminho) diff --git a/Firmware/Modulos/BombaPressurizadoraModel_v2.h b/Firmware/Modulos/BombaPressurizadoraModel_v2.h index 6f42e70df..cbc071e66 100644 --- a/Firmware/Modulos/BombaPressurizadoraModel_v2.h +++ b/Firmware/Modulos/BombaPressurizadoraModel_v2.h @@ -233,14 +233,14 @@ class BombaPressurizadora_v2 : public ComponenteCAN { }; double PotenciaBase22Psi[8] = { - 0.0, - 35.0, - 56.4, - 62.5, - 65.8, - 71.7, - 77.5, - 100.0 + 0.0, // 0 bicos + 35.0, // 1 bico + 56.0, // 2 bicos + 63.0, // 3 bicos + 68.0, // 4 bicos + 82.0, // 5 bicos + 88.0, // 6 bicos + 100.0 // 7 bicos }; double PressaoReferenciaBaixaPsi = 15.0;