Compare commits

...

2 Commits

5 changed files with 393 additions and 90 deletions

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], max_disagreement_deg=4.0, max_skew_imu_s=0.12),
T_Code.Imu: CameraIMU(modulos_imu=[T_Code.Snr]), # CameraIMU(modulos_imu=[T_Code.Snr, T_Code.Cam], max_disagreement_deg=4.0, max_skew_imu_s=0.12),
T_Code.Npc: ModuloPC(),
T_Code.Lvx: ModuloLivox(),
T_Code.Ipb: ModuloIPBribge(),

View File

@ -1,4 +1,6 @@
import time
import math
from collections import deque
from shared.enums import T_Code, StatusModulo
from health_worker.modulos.base import ModuloDiagnosticoBase
from health_worker.aprendizado.padrao_dinamico import PadraoDinamico
@ -75,7 +77,6 @@ class ModuloMovimentacao(ModuloDiagnosticoBase):
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
@ -94,6 +95,21 @@ class ModuloMovimentacao(ModuloDiagnosticoBase):
CORRENTE_MOTOR_EXTREMA_A = 90.0
CORRENTE_BARRAMENTO_EXTREMA_A = 55.0
TEMP_MIN_FISICA_C = 0.0
TEMP_MAX_FISICA_C = 150.0
# Uma elevação maior que isso entre amostras próximas entra em quarentena.
TEMP_SALTO_SUSPEITO_C = 12.0
# Velocidade térmica aceitável. O piso de 3 °C tolera ruído e quantização.
TEMP_VARIACAO_BASE_C = 3.0
TEMP_SUBIDA_MAX_C_S = 2.0
TEMP_DESCIDA_MAX_C_S = 4.0
# Para aceitar um novo patamar após salto.
TEMP_CONFIRMAR_PATAMAR_AMOSTRAS = 3
TEMP_CONFIRMAR_PATAMAR_TOL_C = 5.0
def __init__(self):
self.t_code = T_Code.Mov
self.nome = "Movimentacao"
@ -140,6 +156,7 @@ class ModuloMovimentacao(ModuloDiagnosticoBase):
self._estados_persistencia = {}
self._historico_amostras = {}
self._estados_temperatura = {}
def atualizar_saude(self):
try:
@ -979,8 +996,12 @@ class ModuloMovimentacao(ModuloDiagnosticoBase):
def _temperatura_valida(self, temperatura):
try:
valor = float(temperatura)
return 0.0 < valor < 200.0
except (TypeError, ValueError):
return (
math.isfinite(valor)
and self.TEMP_MIN_FISICA_C < valor < self.TEMP_MAX_FISICA_C
)
except (TypeError, ValueError, OverflowError):
return False
def _avaliar_temperatura(
@ -1005,61 +1026,76 @@ class ModuloMovimentacao(ModuloDiagnosticoBase):
label = f"Temperatura do {nome_origem}"
amostra = self._registrar_amostra(endereco, campo, temperatura, modulo)
normalizada = self._normalizar_temperatura(endereco=endereco, campo=campo, temperatura=temperatura, amostra=amostra)
if not self._temperatura_valida(temperatura):
if normalizada["status"] == "invalida":
motivos.append(
f"Temperatura do {nome_origem} inválida: "
f"{temperatura} °C"
f"Temperatura do {nome_origem} inválida: {temperatura}"
)
condicoes.append({
"label": label,
"valor": temperatura,
"severidade": 50,
"descricao": (
"Leitura de temperatura inválida"
"Amostra térmica inválida; ignorada pela proteção "
"de sobretemperatura"
),
"acoes": [
"Verificar sensor de temperatura.",
"Verificar sensor, conversão, aterramento e cabeamento.",
],
})
return (
penalidade,
motivos,
condicoes,
risco_eletrico_termico,
self._resetar_candidatos_temperatura(endereco, regra_desc)
return self._resultado_temperatura_inconclusiva(endereco, regra_desc, motivos, condicoes)
if normalizada["status"] == "desatualizada":
motivos.append(
f"Temperatura do {nome_origem} desatualizada; "
"sobretemperatura não confirmada"
)
if not amostra["fresca"]:
return self._resultado_temperatura_inconclusiva(endereco, regra_desc, motivos, condicoes)
if normalizada["status"] == "suspeita":
motivos.append(
f"Temperatura do {nome_origem} sem amostra fresca "
f"({amostra['idade_s']:.1f} s)"
f"Amostra térmica suspeita ignorada no {nome_origem}: "
f"{float(temperatura):.1f} °C; "
f"referência validada={normalizada['valor_validado']:.1f} °C; "
f"delta={normalizada['delta_c']:+.1f} °C; "
f"confirmações="
f"{normalizada['amostras_candidatas']}/"
f"{self.TEMP_CONFIRMAR_PATAMAR_AMOSTRAS}"
)
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."],
"severidade": 35,
"descricao": (
"Salto térmico fisicamente implausível em quarentena; "
"não classificado como temperatura elevada"
),
"acoes": [
"Aguardar as próximas amostras.",
"Verificar sensor e aterramento se o ruído persistir.",
],
})
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."],
})
self._resetar_candidatos_temperatura(endereco, regra_desc)
return self._resultado_temperatura_inconclusiva(endereco, regra_desc, motivos, condicoes)
if normalizada["status"] == "repetida":
return self._resultado_temperatura_inconclusiva(endereco, regra_desc, motivos, condicoes)
temperatura_avaliada = normalizada["valor_validado"]
estado_temp_emergencia = self._estado_limite_alto(
driver=endereco,
regra=f"temperatura_emergencia_{regra_desc}",
valor=temperatura,
valor=temperatura_avaliada,
limite_entrada=self.TEMP_EMERGENCIA_C,
limite_saida=self.TEMP_RECUPERA_EMERGENCIA_C,
tempo_intermediario_s=0.0,
@ -1072,7 +1108,7 @@ class ModuloMovimentacao(ModuloDiagnosticoBase):
estado_temp_falha = self._estado_limite_alto(
driver=endereco,
regra=f"temperatura_falha_{regra_desc}",
valor=temperatura,
valor=temperatura_avaliada,
limite_entrada=self.TEMP_FALHA_C,
limite_saida=self.TEMP_RECUPERA_FALHA_C,
tempo_intermediario_s=self.TEMP_FALHA_INTERMEDIARIO_S,
@ -1085,7 +1121,7 @@ class ModuloMovimentacao(ModuloDiagnosticoBase):
estado_temp_alerta = self._estado_limite_alto(
driver=endereco,
regra=f"temperatura_alerta_{regra_desc}",
valor=temperatura,
valor=temperatura_avaliada,
limite_entrada=self.TEMP_ALERTA_C,
limite_saida=self.TEMP_RECUPERA_ALERTA_C,
tempo_intermediario_s=self.TEMP_ALERTA_INTERMEDIARIO_S,
@ -1102,12 +1138,12 @@ class ModuloMovimentacao(ModuloDiagnosticoBase):
if estado_temp_emergencia["nivel"] == "confirmado":
motivos.append(
f"Temperatura do {nome_origem} em nível de emergência "
f"confirmado: {temperatura:.1f} °C"
f"confirmado: {temperatura_avaliada:.1f} °C"
)
condicoes.append({
"label": label,
"valor": temperatura,
"valor": temperatura_avaliada,
"severidade": 98,
"descricao": (
"Temperatura de emergência confirmada após "
@ -1129,12 +1165,12 @@ class ModuloMovimentacao(ModuloDiagnosticoBase):
elif estado_temp_emergencia["nivel"] == "recuperando":
motivos.append(
f"Temperatura do {nome_origem} normalizando após emergência: "
f"{temperatura:.1f} °C. Aguardando estabilidade para liberação."
f"{temperatura_avaliada:.1f} °C. Aguardando estabilidade para liberação."
)
condicoes.append({
"label": label,
"valor": temperatura,
"valor": temperatura_avaliada,
"severidade": 98,
"descricao": (
"Temperatura caiu abaixo do limite de recuperação da "
@ -1160,13 +1196,13 @@ class ModuloMovimentacao(ModuloDiagnosticoBase):
elif estado_temp_emergencia["ativa"]:
motivos.append(
f"Temperatura de emergência do {nome_origem} detectada: "
f"{temperatura:.1f} °C "
f"{temperatura_avaliada:.1f} °C "
f"({estado_temp_emergencia['duracao_s']:.1f} s)"
)
condicoes.append({
"label": label,
"valor": temperatura,
"valor": temperatura_avaliada,
"severidade": 89,
"descricao": (
"Temperatura em nível de emergência, aguardando "
@ -1188,12 +1224,12 @@ class ModuloMovimentacao(ModuloDiagnosticoBase):
elif estado_temp_falha["nivel"] == "confirmado":
motivos.append(
f"Temperatura perigosa do {nome_origem} confirmada: "
f"{temperatura:.1f} °C"
f"{temperatura_avaliada:.1f} °C"
)
condicoes.append({
"label": label,
"valor": temperatura,
"valor": temperatura_avaliada,
"severidade": 95,
"descricao": (
"Temperatura perigosa persistiu por "
@ -1216,13 +1252,13 @@ class ModuloMovimentacao(ModuloDiagnosticoBase):
elif estado_temp_falha["nivel"] == "recuperando":
motivos.append(
f"Temperatura do {nome_origem} normalizando após condição "
f"perigosa: {temperatura:.1f} °C. "
f"perigosa: {temperatura_avaliada:.1f} °C. "
"Aguardando estabilidade para liberação."
)
condicoes.append({
"label": label,
"valor": temperatura,
"valor": temperatura_avaliada,
"severidade": 95,
"descricao": (
"Temperatura caiu abaixo do limite de recuperação da "
@ -1248,13 +1284,13 @@ class ModuloMovimentacao(ModuloDiagnosticoBase):
elif estado_temp_falha["nivel"] == "persistente":
motivos.append(
f"Temperatura perigosa do {nome_origem} persistindo: "
f"{temperatura:.1f} °C "
f"{temperatura_avaliada:.1f} °C "
f"({estado_temp_falha['duracao_s']:.1f} s)"
)
condicoes.append({
"label": label,
"valor": temperatura,
"valor": temperatura_avaliada,
"severidade": 85,
"descricao": (
"Temperatura perigosa persistindo, aguardando "
@ -1275,12 +1311,12 @@ class ModuloMovimentacao(ModuloDiagnosticoBase):
elif estado_temp_falha["nivel"] == "detectado":
motivos.append(
f"Pico de temperatura perigosa do {nome_origem} detectado: "
f"{temperatura:.1f} °C"
f"{temperatura_avaliada:.1f} °C"
)
condicoes.append({
"label": label,
"valor": temperatura,
"valor": temperatura_avaliada,
"severidade": 70,
"descricao": (
"Temperatura perigosa detectada, ainda sem persistência"
@ -1299,12 +1335,12 @@ class ModuloMovimentacao(ModuloDiagnosticoBase):
elif estado_temp_alerta["nivel"] == "confirmado":
motivos.append(
f"Temperatura elevada do {nome_origem} confirmada: "
f"{temperatura:.1f} °C"
f"{temperatura_avaliada:.1f} °C"
)
condicoes.append({
"label": label,
"valor": temperatura,
"valor": temperatura_avaliada,
"severidade": 60,
"descricao": (
"Temperatura elevada persistiu por "
@ -1325,12 +1361,12 @@ class ModuloMovimentacao(ModuloDiagnosticoBase):
elif estado_temp_alerta["nivel"] == "recuperando":
motivos.append(
f"Temperatura do {nome_origem} normalizando após alerta: "
f"{temperatura:.1f} °C"
f"{temperatura_avaliada:.1f} °C"
)
condicoes.append({
"label": label,
"valor": temperatura,
"valor": temperatura_avaliada,
"severidade": 60,
"descricao": (
"Temperatura caiu abaixo do limite de recuperação do "
@ -1355,13 +1391,13 @@ class ModuloMovimentacao(ModuloDiagnosticoBase):
elif estado_temp_alerta["nivel"] == "persistente":
motivos.append(
f"Aquecimento do {nome_origem} persistindo: "
f"{temperatura:.1f} °C "
f"{temperatura_avaliada:.1f} °C "
f"({estado_temp_alerta['duracao_s']:.1f} s)"
)
condicoes.append({
"label": label,
"valor": temperatura,
"valor": temperatura_avaliada,
"severidade": 50,
"descricao": (
"Temperatura elevada persistindo, aguardando confirmação"
@ -1381,12 +1417,12 @@ class ModuloMovimentacao(ModuloDiagnosticoBase):
elif estado_temp_alerta["nivel"] == "detectado":
motivos.append(
f"Aquecimento do {nome_origem} detectado: "
f"{temperatura:.1f} °C"
f"{temperatura_avaliada:.1f} °C"
)
condicoes.append({
"label": label,
"valor": temperatura,
"valor": temperatura_avaliada,
"severidade": 40,
"descricao": (
"Temperatura acima da faixa ideal, aguardando persistência"
@ -1909,6 +1945,183 @@ class ModuloMovimentacao(ModuloDiagnosticoBase):
if str(k).strip()
}
def _normalizar_temperatura(
self,
endereco,
campo,
temperatura,
amostra,
):
chave = (str(endereco), str(campo))
estado = self._estados_temperatura.setdefault(chave, {
"valor_validado": None,
"mono_validado": None,
"candidatas": deque(
maxlen=self.TEMP_CONFIRMAR_PATAMAR_AMOSTRAS
),
})
if not self._temperatura_valida(temperatura):
estado["candidatas"].clear()
return {
"status": "invalida",
"valor_validado": estado["valor_validado"],
"motivo": "valor fora da faixa física ou não finito",
}
valor = float(temperatura)
if not amostra["fresca"]:
return {
"status": "desatualizada",
"valor_validado": estado["valor_validado"],
"motivo": "amostra térmica sem frescor",
}
# Polling repetido do mesmo snapshot não confirma tendência.
if not amostra["nova"]:
return {
"status": "repetida",
"valor_validado": estado["valor_validado"],
"motivo": "snapshot já avaliado",
}
agora = time.monotonic()
anterior = estado["valor_validado"]
mono_anterior = estado["mono_validado"]
# Primeira amostra fisicamente válida vira referência.
if anterior is None or mono_anterior is None:
estado["valor_validado"] = valor
estado["mono_validado"] = agora
estado["candidatas"].clear()
return {
"status": "validada",
"valor_validado": valor,
"motivo": "primeira referência térmica válida",
}
dt = max(0.001, agora - mono_anterior)
delta = valor - anterior
limite_delta = (
self.TEMP_VARIACAO_BASE_C
+ (
self.TEMP_SUBIDA_MAX_C_S
if delta >= 0
else self.TEMP_DESCIDA_MAX_C_S
) * dt
)
if abs(delta) <= limite_delta:
estado["valor_validado"] = valor
estado["mono_validado"] = agora
estado["candidatas"].clear()
return {
"status": "validada",
"valor_validado": valor,
"delta_c": delta,
"dt_s": dt,
"motivo": "variação térmica plausível",
}
# Salto implausível: guarda o possível novo patamar,
# mas ainda não o entrega à proteção térmica.
estado["candidatas"].append({
"valor": valor,
"mono": agora,
})
candidatas = list(estado["candidatas"])
patamar_coerente = (
len(candidatas) >= self.TEMP_CONFIRMAR_PATAMAR_AMOSTRAS
and (
max(x["valor"] for x in candidatas)
- min(x["valor"] for x in candidatas)
) <= self.TEMP_CONFIRMAR_PATAMAR_TOL_C
)
if patamar_coerente:
valor_confirmado = sum(
x["valor"] for x in candidatas
) / len(candidatas)
estado["valor_validado"] = valor_confirmado
estado["mono_validado"] = agora
estado["candidatas"].clear()
return {
"status": "patamar_confirmado",
"valor_validado": valor_confirmado,
"delta_c": valor_confirmado - anterior,
"dt_s": dt,
"motivo": (
"novo patamar confirmado por "
f"{self.TEMP_CONFIRMAR_PATAMAR_AMOSTRAS} amostras"
),
}
return {
"status": "suspeita",
"valor_validado": anterior,
"delta_c": delta,
"dt_s": dt,
"amostras_candidatas": len(candidatas),
"motivo": "salto térmico em quarentena",
}
def _resetar_candidatos_temperatura(self, endereco, regra_desc):
for nivel in ("alerta", "falha", "emergencia"):
chave = (
str(endereco),
f"temperatura_{nivel}_{regra_desc}",
)
estado = self._estados_persistencia.get(chave)
if estado and not estado.get("confirmada", False):
estado["ativa_desde"] = None
estado["normal_desde"] = None
estado["amostras_ativas"] = 0
def _resultado_temperatura_inconclusiva(
self,
endereco,
regra_desc,
motivos,
condicoes,
):
def obter_estado(nivel):
chave = (
str(endereco),
f"temperatura_{nivel}_{regra_desc}",
)
return self._estados_persistencia.get(chave, {})
emergencia = obter_estado("emergencia")
falha = obter_estado("falha")
alerta = obter_estado("alerta")
nivel_emergencia = emergencia.get("nivel")
nivel_falha = falha.get("nivel")
nivel_alerta = alerta.get("nivel")
if nivel_emergencia in ("confirmado", "recuperando"):
return (90, motivos, condicoes, True)
if nivel_falha in ("confirmado", "recuperando"):
return (80, motivos, condicoes, True)
if nivel_alerta in ("confirmado", "recuperando"):
return (25, motivos, condicoes, False)
return (0, motivos, condicoes, False)
@staticmethod
def _bool(valor, default=False):
try:

View File

@ -52,13 +52,15 @@ class RegrasTaticas:
freio_necessario = freio_necessario or freio
novos_motivos, freio = self._avaliar_oak_parada_por_bloqueio(
controle=controle
controle=controle,
operacao=operacao
)
motivos.extend(novos_motivos)
freio_necessario = freio_necessario or freio
novos_motivos, freio = self._avaliar_imu_parada_por_inclinacao(
controle=controle
controle=controle,
operacao=operacao
)
motivos.extend(novos_motivos)
freio_necessario = freio_necessario or freio
@ -124,7 +126,7 @@ class RegrasTaticas:
return motivos, freio_necessario
def _avaliar_oak_parada_por_bloqueio(self, controle: dict):
def _avaliar_oak_parada_por_bloqueio(self, controle: dict, operacao: dict):
"""
Bloqueia somente se a função de parada por bloqueio estiver ativa.
@ -132,6 +134,12 @@ class RegrasTaticas:
Esses são auxílios, não garantias absolutas de segurança.
"""
sonar_mandatorio = self._modulo_mandatorio(operacao, T_Code.Snr)
if not sonar_mandatorio:
self._resetar_persistencia("oak_indisponivel")
self._resetar_persistencia("oak_obstaculo")
return [], False
oak_parada_por_bloqueio = self._bool(
controle.get("oak_parada_por_bloqueio", False)
)
@ -234,12 +242,10 @@ class RegrasTaticas:
return [], False
def _avaliar_imu_parada_por_inclinacao(self, controle: dict):
imu_parada_por_inclinacao = self._bool(
controle.get("imu_parada_por_inclinacao", False)
)
if not imu_parada_por_inclinacao:
def _avaliar_imu_parada_por_inclinacao(self, controle: dict, operacao: dict):
protecao_ativa = self._bool(controle.get("imu_parada_por_inclinacao", False))
imu_mandatorio = self._modulo_mandatorio(operacao, T_Code.Imu)
if not protecao_ativa or not imu_mandatorio:
self._resetar_persistencia("imu_indisponivel")
self._resetar_persistencia("imu_perigo")
return [], False
@ -537,6 +543,30 @@ class RegrasTaticas:
return idade_s <= max_idade_s, idade_ms
def _modulo_mandatorio(self, operacao: dict, t_code: T_Code) -> bool:
# Se o campo ainda não chegou ao Python, preserva a proteção atual.
if "modulos_mandatorios" not in operacao:
return True
valores = operacao.get("modulos_mandatorios", []) or []
normalizados = set()
for item in valores:
try:
if isinstance(item, dict):
item = item.get(
"modulo",
item.get("codigo", item.get("t_code"))
)
item = getattr(item, "value", item)
normalizados.add(int(item))
except (TypeError, ValueError):
continue
return int(t_code.value) in normalizados
# ============================================================
# Helpers genéricos seguros
# ============================================================

View File

@ -130,7 +130,7 @@ WEED_DEFAULT_CONFIG = {
# ============================================================
# Gate global: só libera bicos quando existe alvo suficiente no frame.
"usar_radar_global_gate": True,
"inverter_ordem_bicos": True,
"inverter_ordem_bicos": False,
"inverter_sentido_vertical_bicos": True,
# Histerese global da fração de alvo no frame completo.

View File

@ -296,6 +296,8 @@ class WeedDetector:
cfg = self.config
vel_mps = float(cfg.get("velocidade_robo", 0.0) or 0.0)
# A geometria de atuação foi definida para o avanço. A ré não é
# reinterpretada como um percurso espacial inverso dos bicos.
if vel_mps < 0:
vel_mps = 0.0
@ -463,9 +465,14 @@ class WeedDetector:
self._frac_grid_last = frac_grid
# 3) Atualiza score espacial.
# Em movimento, acumula evidência e desloca a esteira normalmente.
# Parado, acompanha a evidência do frame atual por EMA: o mesmo ruído
# não pode ser somado indefinidamente, mas uma erva persistente continua
# podendo manter/ligar o bico.
score_update_info = self._atualizar_score_memoria(
frac_grid=frac_grid,
cfg=cfg,
em_movimento=bool(shift_info["em_movimento"]),
)
# 4) Decide bicos pela zona física de atuação, com compensação de latência.
@ -533,6 +540,7 @@ class WeedDetector:
return {
"dt_s": 0.0,
"vel_mps": float(vel_mps),
"em_movimento": False,
"delta_frac": 0.0,
"shift_cells": 0.0,
"distancia_deslocada_total_cm": float(self._distancia_deslocada_total_cm),
@ -545,10 +553,17 @@ class WeedDetector:
if max_dt > 0:
dt = min(dt, max_dt)
if dt <= 0 or vel_mps <= 0:
vel_min = max(
0.0,
float(cfg.get("vel_min_movimento_memoria_mps", 0.03) or 0.03),
)
em_movimento = bool(abs(vel_mps) >= vel_min)
if dt <= 0 or not em_movimento:
return {
"dt_s": float(max(0.0, dt)),
"vel_mps": float(vel_mps),
"em_movimento": False,
"delta_frac": 0.0,
"shift_cells": 0.0,
"distancia_deslocada_total_cm": float(self._distancia_deslocada_total_cm),
@ -582,6 +597,7 @@ class WeedDetector:
return {
"dt_s": float(dt),
"vel_mps": float(vel_mps),
"em_movimento": True,
"delta_frac": float(delta_frac),
"shift_cells": float(shift_cells),
"k_shift_frame_por_mps": float(k_shift),
@ -723,6 +739,7 @@ class WeedDetector:
self,
frac_grid: np.ndarray,
cfg: dict,
em_movimento: bool = True,
):
min_frac_pred = float(
cfg.get(
@ -748,14 +765,35 @@ class WeedDetector:
evidencia = np.clip(frac_grid / percent_ref, 0.0, 1.0).astype(np.float32)
cond_pos = frac_grid >= min_frac_pred
# Decaimento geral, depois reforço positivo.
score = np.maximum(0.0, self._score_grid - down_decay)
if em_movimento:
# Em movimento, cada frame traz evidência de uma nova faixa física
# do terreno; mantém a estratégia acumulativa original.
score = np.maximum(0.0, self._score_grid - down_decay)
score = np.where(
cond_pos,
np.minimum(1.0, score + up_gain * evidencia),
score,
).astype(np.float32)
score = np.where(
cond_pos,
np.minimum(1.0, score + up_gain * evidencia),
score,
).astype(np.float32)
modo_atualizacao = "acumulativo_movimento"
else:
# Parado, vários frames observam essencialmente o mesmo terreno.
# Uma EMA faz o score convergir para a evidência atual, em vez de
# transformar repetição temporal em confiança espacial infinita.
alpha_parado = float(cfg.get("score_alpha_parado", 0.70) or 0.70)
alpha_parado = float(np.clip(alpha_parado, 0.0, 0.99))
evidencia_atual = np.where(
cond_pos,
evidencia,
0.0,
).astype(np.float32)
score = (
alpha_parado * self._score_grid
+ (1.0 - alpha_parado) * evidencia_atual
).astype(np.float32)
modo_atualizacao = "ema_parado"
self._score_grid = score
@ -764,6 +802,11 @@ class WeedDetector:
"percent_ref_evidencia": float(percent_ref),
"score_up_gain": float(up_gain),
"score_down_decay": float(down_decay),
"em_movimento": bool(em_movimento),
"modo_atualizacao": modo_atualizacao,
"score_alpha_parado": float(
cfg.get("score_alpha_parado", 0.70) or 0.70
),
"frac_grid_max": float(frac_grid.max()) if frac_grid.size else 0.0,
"evidencia_max": float(evidencia.max()) if evidencia.size else 0.0,
}
@ -788,20 +831,32 @@ class WeedDetector:
k_shift = max(0.0, k_shift)
# Antecipação em fração da imagem:
# Se demora para atuar, avalia células um pouco acima da zona real,
# porque elas chegarão na zona quando o bico abrir de fato.
antecipacao_frac = max(0.0, vel_mps * lat_s * k_shift)
# A zona configurada representa a posição física fixa dos bicos.
# Com faixa=0,85, área=0,10 e inversão vertical, 85%..95% da imagem
# original vira 5%..15% na convenção interna. Essa zona NÃO muda de
# parado até a velocidade de referência. Somente a parcela de
# velocidade ACIMA da referência antecipa a avaliação para compensar
# o tempo de resposta do sistema.
vel_referencia = max(
0.0,
float(cfg.get("velocidade_referencia_atuacao_mps", 0.65) or 0.65),
)
velocidade_abs = abs(float(vel_mps))
velocidade_excedente = max(0.0, velocidade_abs - vel_referencia)
antecipacao_extra_frac = velocidade_excedente * lat_s * k_shift
if getattr(self, "inverter_sentido_vertical_bicos", False):
# Se a erva vem do pé para o topo, antecipar significa olhar
# um pouco abaixo da zona real, pois ela ainda vai subir até o bico.
zona_eval_ini_frac = zona_ini_frac + antecipacao_frac
zona_eval_fim_frac = zona_fim_frac + antecipacao_frac
else:
# Lógica atual: erva vem do topo para o pé.
zona_eval_ini_frac = zona_ini_frac - antecipacao_frac
zona_eval_fim_frac = zona_fim_frac - antecipacao_frac
# A posição dos bicos é fixa. Não tentamos reinterpretar essa zona na
# marcha a ré; a antecipação só indica uma região anterior no sentido
# visual configurado para a operação normal de avanço.
sentido_vertical = (
1.0
if getattr(self, "inverter_sentido_vertical_bicos", False)
else -1.0
)
offset_assinado = antecipacao_extra_frac * sentido_vertical
zona_eval_ini_frac = zona_ini_frac + offset_assinado
zona_eval_fim_frac = zona_fim_frac + offset_assinado
zona_eval_ini_frac = max(0.0, min(1.0, zona_eval_ini_frac))
zona_eval_fim_frac = max(0.0, min(1.0, zona_eval_fim_frac))
@ -872,7 +927,12 @@ class WeedDetector:
# Zona avaliada com compensação de latência.
"latencia_total_atuacao_ms": float(lat_ms),
"antecipacao_frac": float(antecipacao_frac),
"antecipacao_frac": float(antecipacao_extra_frac),
"velocidade_referencia_atuacao_mps": float(vel_referencia),
"velocidade_excedente_atuacao_mps": float(velocidade_excedente),
"offset_base_referencia_frac": 0.0,
"antecipacao_extra_frac": float(antecipacao_extra_frac),
"offset_assinado_frac": float(offset_assinado),
"zona_eval_inicio_frac": float(zona_eval_ini_frac),
"zona_eval_fim_frac": float(zona_eval_fim_frac),