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

This commit is contained in:
Diego Freitas 2026-08-07 13:28:59 -03:00
parent 69e87c3f20
commit b148d987b7
10 changed files with 1643 additions and 546 deletions

View File

@ -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;

View File

@ -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)

View File

@ -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}"
);
}
}
}

View File

@ -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 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;

View File

@ -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

View File

@ -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(),

View File

@ -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

View File

@ -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),

View File

@ -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
)

View File

@ -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