完善释放控制:稳定/迅速行为预设、力曲线夹稳门控与会话记录。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
cheng
2026-09-22 12:05:22 +08:00
parent daa0ec8aec
commit 858e020c77
20 changed files with 3753 additions and 281 deletions
+196
View File
@@ -0,0 +1,196 @@
"""Grip behavior presets: extreme rapid open/close vs stable hold."""
from __future__ import annotations
import copy
# rapid = 迅速开合:优先跟手释放 / 再武装
# stable = 夹持稳定:优先防误开、夹得住
GRIP_BEHAVIOR_MODES = ("rapid", "stable")
GRIP_BEHAVIOR_LABELS = {
"rapid": "迅速开合",
"stable": "夹持稳定",
}
# Keys owned by a behavior profile. Selecting a mode rewrites these fields.
GRIP_BEHAVIOR_KEYS = (
"filter_alpha",
"shear_filter_alpha",
"open_wait_stable_samples",
"open_wait_stable_range_n",
"open_wait_stable_trend_n",
"grip_settle_seconds",
"force_ramp_step",
"force_ramp_interval",
"adaptive_grip_enabled",
"adaptive_force_interval",
"release_open_speed",
"release_open_accel",
"release_open_decel",
"release_arm_delay_seconds",
"release_min_hold_seconds",
"release_settle_seconds",
"release_min_change",
"release_touch_window_seconds",
"release_side_touch_change",
"release_confirm_window_seconds",
"release_sample_timeout_seconds",
"release_confirm_samples",
"release_slow_confirm_samples",
"release_normal_rate",
"release_shear_rate",
"release_slow_rate",
"release_normal_change",
"release_shear_change",
"release_curve_history",
"release_slope_window_seconds",
"release_slope_settle_rate",
"release_slope_min_points",
"release_require_slope_settle",
"release_slope_hold_seconds",
"release_settle_normal_only",
"release_side_shear_enabled",
"release_side_normal_enabled",
"release_shear_onset_enabled",
"release_normal_onset_enabled",
"rearm_seconds",
"recover_stable_seconds",
)
GRIP_BEHAVIOR_PROFILES = {
"rapid": {
"filter_alpha": 0.72,
"shear_filter_alpha": 0.78,
"open_wait_stable_samples": 3,
"open_wait_stable_range_n": 0.04,
"open_wait_stable_trend_n": 0.03,
"grip_settle_seconds": 0.05,
"force_ramp_step": 3,
"force_ramp_interval": 0.04,
"adaptive_grip_enabled": False,
"adaptive_force_interval": 0.05,
"release_open_speed": 85,
"release_open_accel": 90,
"release_open_decel": 90,
# Almost no normal-contact lock; rely on curve/side-touch rejection.
"release_arm_delay_seconds": 0.04,
"release_min_hold_seconds": 0.0,
"release_settle_seconds": 0.03,
"release_min_change": 0.03,
"release_touch_window_seconds": 0.28,
"release_side_touch_change": 0.04,
"release_confirm_window_seconds": 0.28,
"release_sample_timeout_seconds": 0.25,
"release_confirm_samples": 2,
"release_slow_confirm_samples": 3,
"release_normal_rate": 1.0,
"release_shear_rate": 1.0,
"release_slow_rate": 0.55,
"release_normal_change": 0.14,
"release_shear_change": 0.14,
"release_curve_history": 6,
"release_slope_window_seconds": 0.35,
"release_slope_settle_rate": 0.8,
"release_slope_min_points": 4,
"release_require_slope_settle": False,
"release_slope_hold_seconds": 0.0,
"release_settle_normal_only": False,
"release_side_shear_enabled": True,
"release_side_normal_enabled": True,
"release_shear_onset_enabled": True,
"release_normal_onset_enabled": True,
"rearm_seconds": 0.12,
"recover_stable_seconds": 0.12,
},
"stable": {
"filter_alpha": 0.42,
"shear_filter_alpha": 0.52,
"open_wait_stable_samples": 8,
"open_wait_stable_range_n": 0.025,
"open_wait_stable_trend_n": 0.015,
"grip_settle_seconds": 0.2,
"force_ramp_step": 1,
"force_ramp_interval": 0.1,
"adaptive_grip_enabled": True,
"adaptive_force_interval": 0.08,
"release_open_speed": 55,
"release_open_accel": 70,
"release_open_decel": 70,
"release_arm_delay_seconds": 0.28,
"release_min_hold_seconds": 0.65,
"release_settle_seconds": 0.12,
"release_min_change": 0.08,
"release_touch_window_seconds": 0.14,
"release_side_touch_change": 0.12,
"release_confirm_window_seconds": 0.14,
"release_sample_timeout_seconds": 0.2,
"release_confirm_samples": 4,
"release_slow_confirm_samples": 8,
"release_normal_rate": 2.6,
"release_shear_rate": 2.4,
"release_slow_rate": 0.22,
"release_normal_change": 0.42,
"release_shear_change": 0.35,
# Clean split: settle on normal plateau; open only via shear.
"release_curve_history": 30,
"release_slope_window_seconds": 0.8,
"release_slope_settle_rate": 0.22,
"release_slope_min_points": 10,
"release_require_slope_settle": True,
"release_slope_hold_seconds": 0.45,
"release_settle_normal_only": True,
"release_side_shear_enabled": True,
"release_side_normal_enabled": False,
"release_shear_onset_enabled": True,
"release_normal_onset_enabled": False,
"rearm_seconds": 0.55,
"recover_stable_seconds": 0.55,
},
}
def normalize_grip_behavior_mode(mode) -> str:
text = str(mode or "").strip().lower()
aliases = {
"fast": "rapid",
"quick": "rapid",
"snap": "rapid",
"迅速": "rapid",
"迅速开合": "rapid",
"hold": "stable",
"firm": "stable",
"secure": "stable",
"稳定": "stable",
"夹持稳定": "stable",
}
text = aliases.get(text, text)
if text not in GRIP_BEHAVIOR_PROFILES:
raise ValueError(
f"unsupported grip_behavior_mode: {mode!r}; "
f"expected one of {list(GRIP_BEHAVIOR_PROFILES)}"
)
return text
def grip_behavior_profile(mode: str) -> dict:
mode = normalize_grip_behavior_mode(mode)
return copy.deepcopy(GRIP_BEHAVIOR_PROFILES[mode])
def apply_grip_behavior_profile(target, mode: str) -> str:
"""Write profile fields onto a config object or dict. Returns normalized mode."""
mode = normalize_grip_behavior_mode(mode)
profile = GRIP_BEHAVIOR_PROFILES[mode]
if isinstance(target, dict):
target["grip_behavior_mode"] = mode
for key, value in profile.items():
target[key] = copy.deepcopy(value)
return mode
setattr(target, "grip_behavior_mode", mode)
for key, value in profile.items():
if hasattr(target, key):
setattr(target, key, copy.deepcopy(value))
return mode
+80 -3
View File
@@ -13,6 +13,10 @@ from .rotation import (
DEFAULT_ROTATION2,
normalize_rotation_config,
)
from .behavior_profiles import (
apply_grip_behavior_profile,
normalize_grip_behavior_mode,
)
DEFAULT_CONTROL_CONFIG = str(
@@ -78,6 +82,7 @@ class Demo02Config:
force_ramp_step: int = 1
force_ramp_interval: float = 0.1
grip_settle_seconds: float = 0.1
grip_behavior_mode: str = "stable"
adaptive_grip_enabled: bool = True
adaptive_shear_start_force: float = 0.10
adaptive_shear_full_force: float = 0.60
@@ -85,9 +90,32 @@ class Demo02Config:
adaptive_force_max: int = 45
adaptive_force_step: int = 1
adaptive_force_interval: float = 0.15
release_arm_delay_seconds: float = 0.8
release_normal_change: float = 0.03
release_shear_change: float = 0.03
release_arm_delay_seconds: float = 0.12
release_min_hold_seconds: float = 0.28
release_settle_seconds: float = 0.06
release_min_change: float = 0.04
release_touch_window_seconds: float = 0.2
release_side_touch_change: float = 0.06
release_confirm_window_seconds: float = 0.2
release_sample_timeout_seconds: float = 0.2
release_confirm_samples: int = 2
release_slow_confirm_samples: int = 5
release_normal_rate: float = 1.6
release_shear_rate: float = 1.6
release_slow_rate: float = 0.4
release_normal_change: float = 0.28
release_shear_change: float = 0.28
release_curve_history: int = 6
release_slope_window_seconds: float = 0.8
release_slope_settle_rate: float = 0.2
release_slope_min_points: int = 8
release_require_slope_settle: bool = False
release_slope_hold_seconds: float = 0.0
release_settle_normal_only: bool = True
release_side_shear_enabled: bool = True
release_side_normal_enabled: bool = True
release_shear_onset_enabled: bool = True
release_normal_onset_enabled: bool = True
rearm_seconds: float = 0.5
recover_normal_force: float = 0.02
recover_shear_force: float = 0.02
@@ -171,6 +199,14 @@ class Demo02Config:
add("--force-ramp-step", type=int, default=get("force_ramp_step", cls.force_ramp_step))
add("--force-ramp-interval", type=float, default=get("force_ramp_interval", cls.force_ramp_interval))
add("--grip-settle-seconds", type=float, default=get("grip_settle_seconds", cls.grip_settle_seconds))
add(
"--grip-behavior-mode",
type=str,
choices=["rapid", "stable"],
default=normalize_grip_behavior_mode(
get("grip_behavior_mode", cls.grip_behavior_mode)
),
)
add("--enable-adaptive-grip", dest="adaptive_grip_enabled", action="store_true", default=get("adaptive_grip_enabled", cls.adaptive_grip_enabled))
add("--disable-adaptive-grip", dest="adaptive_grip_enabled", action="store_false")
add("--adaptive-shear-start-force", type=float, default=get("adaptive_shear_start_force", cls.adaptive_shear_start_force))
@@ -180,8 +216,43 @@ class Demo02Config:
add("--adaptive-force-step", type=int, default=get("adaptive_force_step", cls.adaptive_force_step))
add("--adaptive-force-interval", type=float, default=get("adaptive_force_interval", cls.adaptive_force_interval))
add("--release-arm-delay-seconds", type=float, default=get("release_arm_delay_seconds", cls.release_arm_delay_seconds))
add("--release-min-hold-seconds", type=float, default=get("release_min_hold_seconds", cls.release_min_hold_seconds))
add("--release-settle-seconds", type=float, default=get("release_settle_seconds", cls.release_settle_seconds))
add("--release-min-change", type=float, default=get("release_min_change", cls.release_min_change))
add("--release-touch-window-seconds", type=float, default=get("release_touch_window_seconds", cls.release_touch_window_seconds))
add("--release-side-touch-change", type=float, default=get("release_side_touch_change", cls.release_side_touch_change))
add("--release-confirm-window-seconds", type=float, default=get("release_confirm_window_seconds", cls.release_confirm_window_seconds))
add("--release-sample-timeout-seconds", type=float, default=get("release_sample_timeout_seconds", cls.release_sample_timeout_seconds))
add("--release-confirm-samples", type=int, default=get("release_confirm_samples", cls.release_confirm_samples))
add("--release-slow-confirm-samples", type=int, default=get("release_slow_confirm_samples", cls.release_slow_confirm_samples))
add("--release-normal-rate", type=float, default=get("release_normal_rate", cls.release_normal_rate))
add("--release-shear-rate", type=float, default=get("release_shear_rate", cls.release_shear_rate))
add("--release-slow-rate", type=float, default=get("release_slow_rate", cls.release_slow_rate))
add("--release-normal-change", type=float, default=get("release_normal_change", cls.release_normal_change))
add("--release-shear-change", type=float, default=get("release_shear_change", cls.release_shear_change))
add("--release-curve-history", type=int, default=get("release_curve_history", cls.release_curve_history))
add("--release-slope-window-seconds", type=float, default=get("release_slope_window_seconds", cls.release_slope_window_seconds))
add("--release-slope-settle-rate", type=float, default=get("release_slope_settle_rate", cls.release_slope_settle_rate))
add("--release-slope-min-points", type=int, default=get("release_slope_min_points", cls.release_slope_min_points))
add("--release-require-slope-settle", dest="release_require_slope_settle", action="store_true",
default=get("release_require_slope_settle", cls.release_require_slope_settle))
add("--no-release-require-slope-settle", dest="release_require_slope_settle", action="store_false")
add("--release-slope-hold-seconds", type=float, default=get("release_slope_hold_seconds", cls.release_slope_hold_seconds))
add("--release-settle-normal-only", dest="release_settle_normal_only", action="store_true",
default=get("release_settle_normal_only", cls.release_settle_normal_only))
add("--release-settle-all-channels", dest="release_settle_normal_only", action="store_false")
add("--release-side-shear-enabled", dest="release_side_shear_enabled", action="store_true",
default=get("release_side_shear_enabled", cls.release_side_shear_enabled))
add("--no-release-side-shear", dest="release_side_shear_enabled", action="store_false")
add("--release-side-normal-enabled", dest="release_side_normal_enabled", action="store_true",
default=get("release_side_normal_enabled", cls.release_side_normal_enabled))
add("--no-release-side-normal", dest="release_side_normal_enabled", action="store_false")
add("--release-shear-onset-enabled", dest="release_shear_onset_enabled", action="store_true",
default=get("release_shear_onset_enabled", cls.release_shear_onset_enabled))
add("--no-release-shear-onset", dest="release_shear_onset_enabled", action="store_false")
add("--release-normal-onset-enabled", dest="release_normal_onset_enabled", action="store_true",
default=get("release_normal_onset_enabled", cls.release_normal_onset_enabled))
add("--no-release-normal-onset", dest="release_normal_onset_enabled", action="store_false")
add("--rearm-seconds", type=float, default=get("rearm_seconds", cls.rearm_seconds))
add("--recover-normal-force", type=float, default=get("recover_normal_force", cls.recover_normal_force))
add("--recover-shear-force", type=float, default=get("recover_shear_force", cls.recover_shear_force))
@@ -208,4 +279,10 @@ class Demo02Config:
values["shear_force_calibration"] = copy.deepcopy(
config_data.get("shear_force_calibration", DEFAULT_SHEAR_FORCE_CALIBRATION)
)
values["grip_behavior_mode"] = normalize_grip_behavior_mode(
values.get("grip_behavior_mode", cls.grip_behavior_mode)
)
return cls(**values)
def apply_grip_behavior_mode(self, mode: str) -> str:
return apply_grip_behavior_profile(self, mode)
+7 -1
View File
@@ -1,5 +1,11 @@
from .controller import SideTriggerGripController
from .feedback import TactileFeedbackProcessor
from .actuator import ActuatorWorker, GripCommand
__all__ = ["SideTriggerGripController", "TactileFeedbackProcessor"]
__all__ = [
"SideTriggerGripController",
"TactileFeedbackProcessor",
"ActuatorWorker",
"GripCommand",
]
+362
View File
@@ -0,0 +1,362 @@
"""Serial-owning actuator worker: command queue + position cache."""
from __future__ import annotations
import threading
import time
from collections import deque
from dataclasses import dataclass
from typing import Optional
@dataclass(frozen=True)
class GripCommand:
kind: str
force_pct: Optional[int] = None
position: Optional[int] = None
label: str = ""
speed_pct: Optional[int] = None
accel: Optional[int] = None
decel: Optional[int] = None
_MOTION_KINDS = frozenset({"open", "close", "move", "hold"})
class ActuatorWorker:
"""Runs all GripperClient I/O on one thread; control loop only submits."""
def __init__(self, gripper, position_poll_hz: float = 10.0):
self.gripper = gripper
self.position_poll_hz = max(1.0, float(position_poll_hz))
self._stop_event = threading.Event()
self._started = threading.Event()
self._start_error: Optional[BaseException] = None
self._thread: Optional[threading.Thread] = None
self._cmd_lock = threading.Lock()
self._commands: deque[GripCommand] = deque()
self._active_command: Optional[GripCommand] = None
self._motion_completed_at: Optional[float] = None
self._wake = threading.Event()
self._busy = threading.Event()
self._state_lock = threading.Lock()
self._position: Optional[int] = None
self._hold_pos: Optional[int] = None
self._last_error: Optional[str] = None
self._cmd_count = 0
self._cmd_total_s = 0.0
self._cmd_max_s = 0.0
self._pos_count = 0
self._pos_total_s = 0.0
self._pos_max_s = 0.0
self._perf_last_log = 0.0
@property
def position(self) -> Optional[int]:
with self._state_lock:
return self._position
@property
def hold_pos(self) -> Optional[int]:
with self._state_lock:
return self._hold_pos
@property
def last_error(self) -> Optional[str]:
with self._state_lock:
return self._last_error
@property
def motion_status(self):
"""Atomic queue/in-flight snapshot and actual I/O completion time."""
with self._cmd_lock:
return bool(self._commands) or self._active_command is not None, self._motion_completed_at
def start(self, timeout: float = 10.0) -> None:
if self._thread is not None and self._thread.is_alive():
return
self._stop_event.clear()
self._started.clear()
self._start_error = None
self._thread = threading.Thread(
target=self._run,
name="gripper-actuator",
daemon=True,
)
self._thread.start()
if not self._started.wait(timeout=timeout):
raise TimeoutError("actuator worker failed to start in time")
if self._start_error is not None:
raise RuntimeError(f"actuator worker failed to connect: {self._start_error}")
def stop(self, timeout: float = 3.0) -> None:
self._stop_event.set()
self._wake.set()
thread = self._thread
if thread is not None:
thread.join(timeout=timeout)
self._thread = None
def submit(self, command: GripCommand) -> None:
with self._cmd_lock:
if command.kind in _MOTION_KINDS:
self._commands = deque(
item for item in self._commands
if item.kind not in _MOTION_KINDS and item.kind != "set_force"
)
elif command.kind == "set_force":
self._commands = deque(
item for item in self._commands if item.kind != "set_force"
)
self._commands.append(command)
self._wake.set()
def open(
self,
force_pct: int,
label: str = "open",
speed_pct=None,
accel=None,
decel=None,
) -> None:
self.submit(
GripCommand(
kind="open",
force_pct=int(force_pct),
label=label,
speed_pct=speed_pct,
accel=accel,
decel=decel,
)
)
def close(
self,
force_pct: int,
label: str = "close",
speed_pct=None,
accel=None,
decel=None,
) -> None:
self.submit(
GripCommand(
kind="close",
force_pct=int(force_pct),
label=label,
speed_pct=speed_pct,
accel=accel,
decel=decel,
)
)
def move(
self,
position: int,
force_pct: int,
label: str = "move",
speed_pct=None,
accel=None,
decel=None,
) -> None:
self.submit(
GripCommand(
kind="move",
position=int(position),
force_pct=int(force_pct),
label=label,
speed_pct=speed_pct,
accel=accel,
decel=decel,
)
)
def set_force(self, force_pct: int) -> None:
self.submit(GripCommand(kind="set_force", force_pct=int(force_pct)))
def hold(self, force_pct: int, speed_pct=None, accel=None, decel=None) -> None:
self.submit(
GripCommand(
kind="hold",
force_pct=int(force_pct),
speed_pct=speed_pct,
accel=accel,
decel=decel,
label="clamp-hold",
)
)
def final_open_if_needed(self) -> None:
self.submit(GripCommand(kind="final_open"))
self.wait_idle(timeout=5.0)
def wait_idle(self, timeout: float = 5.0) -> bool:
deadline = time.perf_counter() + timeout
while time.perf_counter() < deadline:
pending, _ = self.motion_status
if not pending:
return True
time.sleep(0.01)
return False
def _pop_command(self) -> Optional[GripCommand]:
with self._cmd_lock:
if not self._commands:
return None
self._active_command = self._commands.popleft()
return self._active_command
def _set_error(self, message: str) -> None:
with self._state_lock:
self._last_error = message
def _update_position(self, position: Optional[int]) -> None:
with self._state_lock:
if position is not None:
self._position = int(position)
def _run(self) -> None:
try:
self.gripper.connect()
except BaseException as exc:
self._start_error = exc
self._started.set()
return
self._started.set()
poll_interval = 1.0 / self.position_poll_hz
next_poll = time.perf_counter()
self._perf_last_log = time.perf_counter()
# Prime position cache once after connect.
self._poll_position()
while not self._stop_event.is_set():
cmd = self._pop_command()
if cmd is not None:
self._busy.set()
try:
self._execute(cmd)
finally:
with self._cmd_lock:
self._motion_completed_at = time.perf_counter()
self._active_command = None
still_pending = bool(self._commands)
if not still_pending:
self._busy.clear()
continue
self._busy.clear()
now = time.perf_counter()
if now >= next_poll:
self._poll_position()
next_poll = now + poll_interval
self._maybe_log_perf(now)
continue
wait_s = min(0.02, max(0.0, next_poll - now))
self._wake.wait(timeout=wait_s)
self._wake.clear()
def _execute(self, cmd: GripCommand) -> None:
t0 = time.perf_counter()
try:
if cmd.kind == "open":
self.gripper.open(
cmd.force_pct,
cmd.label or "open",
speed_pct=cmd.speed_pct,
accel=cmd.accel,
decel=cmd.decel,
)
self._update_position(self.gripper.last_commanded_position)
elif cmd.kind == "close":
self.gripper.close(
cmd.force_pct,
cmd.label or "close",
speed_pct=cmd.speed_pct,
accel=cmd.accel,
decel=cmd.decel,
)
self._update_position(self.gripper.last_commanded_position)
elif cmd.kind == "move":
self.gripper.move(
cmd.position,
cmd.force_pct,
cmd.label or "move",
speed_pct=cmd.speed_pct,
accel=cmd.accel,
decel=cmd.decel,
)
self._update_position(cmd.position)
elif cmd.kind == "set_force":
self.gripper.set_force(cmd.force_pct)
elif cmd.kind == "hold":
hold_pos = self.gripper.hold_current_position(
cmd.force_pct,
speed_pct=cmd.speed_pct,
accel=cmd.accel,
decel=cmd.decel,
)
with self._state_lock:
self._hold_pos = hold_pos
if hold_pos is not None:
self._position = int(hold_pos)
elif cmd.kind == "final_open":
self.gripper.final_open_if_needed()
self._update_position(self.gripper.last_commanded_position)
else:
self._set_error(f"unknown command kind: {cmd.kind}")
except Exception as exc:
self._set_error(f"{cmd.kind} failed: {exc}")
print(f"[actuator] {cmd.kind} failed: {exc}")
finally:
dt = time.perf_counter() - t0
with self._state_lock:
self._cmd_count += 1
self._cmd_total_s += dt
self._cmd_max_s = max(self._cmd_max_s, dt)
def _poll_position(self) -> None:
t0 = time.perf_counter()
try:
position = self.gripper.read_position()
self._update_position(position)
except Exception as exc:
self._set_error(f"read_position failed: {exc}")
finally:
dt = time.perf_counter() - t0
with self._state_lock:
self._pos_count += 1
self._pos_total_s += dt
self._pos_max_s = max(self._pos_max_s, dt)
def _maybe_log_perf(self, now: float) -> None:
if now - self._perf_last_log < 1.0:
return
with self._state_lock:
cmd_count = self._cmd_count
cmd_total = self._cmd_total_s
cmd_max = self._cmd_max_s
pos_count = self._pos_count
pos_total = self._pos_total_s
pos_max = self._pos_max_s
position = self._position
self._cmd_count = 0
self._cmd_total_s = 0.0
self._cmd_max_s = 0.0
self._pos_count = 0
self._pos_total_s = 0.0
self._pos_max_s = 0.0
self._perf_last_log = now
if cmd_count == 0 and pos_count == 0:
return
cmd_avg = (cmd_total / cmd_count * 1000.0) if cmd_count else 0.0
pos_avg = (pos_total / pos_count * 1000.0) if pos_count else 0.0
print(
"[perf] actuator "
f"cmd={cmd_count} avg={cmd_avg:.2f}ms max={cmd_max * 1000.0:.2f}ms "
f"pos_read={pos_count} avg={pos_avg:.2f}ms max={pos_max * 1000.0:.2f}ms "
f"pos={position}"
)
+383 -193
View File
@@ -1,8 +1,14 @@
import time
import threading
import json
from collections import deque
from dataclasses import asdict
from ..common.filters import clamp
from ..common.paths import REPO_ROOT
from .actuator import ActuatorWorker
from .release_watch import ReleaseWatch
from .session_recorder import SessionRecorder
class SideTriggerGripController:
@@ -21,11 +27,20 @@ class SideTriggerGripController:
self.normal_unit = feedback_processor.normal_converter.unit
self.shear_unit = feedback_processor.shear_converter.unit
self.status_callback = status_callback
self.actuator = None
self.release_watch = ReleaseWatch(config)
self._release_decision = None
self._force_trace = deque(maxlen=120)
self.release_trace_path = REPO_ROOT / "logs" / "release-last.json"
self.session_recorder = SessionRecorder(REPO_ROOT / "logs" / "sessions")
self._stop_event = threading.Event()
self._manual_lock = threading.Lock()
self._manual_commands = deque()
self._mode_lock = threading.Lock()
self._control_mode = "smart"
self._last_log_time = 0.0
self._log_interval_s = 0.2
self._last_log_key = None
def stop(self):
self._stop_event.set()
@@ -62,6 +77,59 @@ class SideTriggerGripController:
with self._mode_lock:
self._control_mode = mode
def set_grip_behavior_mode(self, mode):
"""Apply rapid/stable preset onto live config and reset release watch."""
applied = self.config.apply_grip_behavior_mode(mode)
# Filters should match the new profile immediately.
try:
self.feedback_processor.reset_filters()
except Exception:
pass
try:
self.release_watch.reset()
except Exception:
pass
print(
f"[behavior] grip_behavior_mode={applied} "
f"min_hold={self.config.release_min_hold_seconds:.2f}s "
f"side_touch={self.config.release_side_touch_change:.3f} "
f"confirm={self.config.release_confirm_samples} "
f"curve_hist={getattr(self.config, 'release_curve_history', 6)} "
f"slope_settle={getattr(self.config, 'release_require_slope_settle', False)}"
)
return applied
def start_session_recording(self, note=""):
meta = {
"note": note,
"grip_behavior_mode": getattr(self.config, "grip_behavior_mode", None),
"control_mode": self._get_control_mode(),
"config": {
key: getattr(self.config, key)
for key in dir(self.config)
if key.startswith("release_")
or key in {
"control_hz",
"filter_alpha",
"shear_filter_alpha",
"trigger_normal_force",
"trigger_shear_force",
"grip_normal_force",
"grip_requires_both",
"grip_behavior_mode",
"adaptive_grip_enabled",
}
},
}
return self.session_recorder.start(meta=meta)
def stop_session_recording(self):
return self.session_recorder.stop()
@property
def session_recording(self):
return self.session_recorder.enabled
def _get_control_mode(self):
with self._mode_lock:
return self._control_mode
@@ -75,16 +143,18 @@ class SideTriggerGripController:
def run(self):
self._stop_event.clear()
self.reader.start()
self.actuator = ActuatorWorker(self.gripper, position_poll_hz=10.0)
try:
self.gripper.connect()
self.actuator.start()
open_force = int(clamp(
self.config.open_force,
self.config.force_min,
self.config.force_max,
))
print("demo02 start: open gripper and wait for side touch")
self.gripper.open(open_force, "startup-open")
print("control loop uses async actuator thread (serial I/O off control tick)")
self.actuator.open(open_force, "startup-open")
self._print_thresholds()
print(
@@ -104,7 +174,16 @@ class SideTriggerGripController:
except KeyboardInterrupt:
print("\nStopping demo02.")
finally:
self.gripper.final_open_if_needed()
try:
self.stop_session_recording()
except Exception:
pass
if self.actuator is not None:
try:
self.actuator.final_open_if_needed()
finally:
self.actuator.stop()
self.actuator = None
self.reader.stop()
def _print_thresholds(self):
@@ -117,8 +196,11 @@ class SideTriggerGripController:
"Grip/release thresholds: "
f"grip_normal={self.config.grip_normal_force:.3f}{self.normal_unit}, "
f"requires_both={int(self.config.grip_requires_both)}, "
f"release_normal_change={self.config.release_normal_change:.3f}{self.normal_unit}, "
f"release_shear_change={self.config.release_shear_change:.3f}{self.shear_unit}"
f"release_abs_n={self.config.release_normal_change:.3f}{self.normal_unit}, "
f"release_abs_s={self.config.release_shear_change:.3f}{self.shear_unit}, "
f"release_rate_n={getattr(self.config, 'release_normal_rate', 1.8):.2f}/s, "
f"release_rate_s={getattr(self.config, 'release_shear_rate', 1.8):.2f}/s, "
f"slow_rate={getattr(self.config, 'release_slow_rate', 0.35):.2f}/s"
)
print(
"Adaptive grip: "
@@ -128,7 +210,10 @@ class SideTriggerGripController:
f"force={self.config.adaptive_force_min}-{self.config.adaptive_force_max}, "
f"step={self.config.adaptive_force_step}, "
f"interval={self.config.adaptive_force_interval:.2f}s, "
f"release_arm_delay={self.config.release_arm_delay_seconds:.2f}s"
f"self_force_cooldown={self.config.release_arm_delay_seconds:.2f}s, "
f"min_hold={getattr(self.config, 'release_min_hold_seconds', 0.28):.2f}s, "
f"curve_settle={self.config.release_settle_seconds:.2f}s, "
f"release_confirm={getattr(self.config, 'release_confirm_samples', 2)}"
)
print(
"Release open: "
@@ -136,6 +221,7 @@ class SideTriggerGripController:
f"accel={self.config.release_open_accel}, "
f"decel={self.config.release_open_decel}"
)
print("Release: side-touch watch starts at contact; normal clamp protection never waits for global quiet")
print(
"Recover: "
f"force_normal={self.config.recover_normal_force:.3f}{self.normal_unit}, "
@@ -157,66 +243,79 @@ class SideTriggerGripController:
last_open_position_ready = False
recover_quiet_since = None
recover_position_since = None
trigger_clear_since = None
trigger_armed = False
last_trigger = False
open_wait_samples = []
open_wait_stable_values = {}
force_pct = int(clamp(config.open_force, config.force_min, config.force_max))
adaptive_target_force = int(clamp(config.hold_force, config.force_min, config.force_max))
release_armed = False
release_watch = self.release_watch
release_watch.reset()
recent_feedback = deque(maxlen=6)
recent_pair_stamps = None
hold_pos = None
reference = None
current_position = None
last_position_status_time = 0.0
position_perf = {
"last_log": time.perf_counter(),
"count": 0,
"total": 0.0,
"max": 0.0,
}
def update_current_position(now, force=False):
nonlocal current_position, last_position_status_time, position_perf
if force or now - last_position_status_time >= 0.1:
last_position_status_time = now
t0 = time.perf_counter()
current_position = self.gripper.read_position()
dt = time.perf_counter() - t0
position_perf["count"] += 1
position_perf["total"] += dt
position_perf["max"] = max(position_perf["max"], dt)
if now - position_perf["last_log"] >= 1.0:
avg_ms = (
position_perf["total"] / position_perf["count"] * 1000.0
if position_perf["count"]
else 0.0
)
print(
"[perf] position_read "
f"count={position_perf['count']} "
f"avg={avg_ms:.2f}ms "
f"max={position_perf['max'] * 1000.0:.2f}ms "
f"pos={current_position}"
)
position_perf = {
"last_log": now,
"count": 0,
"total": 0.0,
"max": 0.0,
}
return current_position
actuator = self.actuator
def reset_open_wait_stability():
nonlocal open_wait_samples, open_wait_stable_values
open_wait_samples = []
open_wait_stable_values = {}
def current_position():
return None if actuator is None else actuator.position
def refresh_hold_pos():
nonlocal hold_pos
if actuator is None:
return hold_pos
cached = actuator.hold_pos
if cached is not None:
hold_pos = cached
return hold_pos
def reset_release_watch():
release_watch.reset()
def try_release_watch(now_ts, feedback_now):
# The release FSM returns effects; only this adapter submits I/O.
nonlocal state, state_start, force_pct, hold_pos
nonlocal last_open_position_check_time, last_open_position
nonlocal last_open_position_ready, recover_quiet_since, recover_position_since
pending, completed_at = actuator.motion_status
decision = release_watch.step(
now_ts, feedback_now,
motion_pending=pending, motion_completed_at=completed_at,
)
self._release_decision = decision
action = decision.action
if decision.release:
force_pct = int(clamp(config.open_force, config.force_min, config.force_max))
actuator.open(
force_pct, "force-change-release-open",
speed_pct=config.release_open_speed,
accel=config.release_open_accel, decel=config.release_open_decel,
)
self.feedback_processor.reset_filters()
state = "open_recover"
state_start = now_ts
last_open_position_check_time = 0.0
last_open_position = None
last_open_position_ready = False
recover_quiet_since = None
recover_position_since = None
reset_open_wait_stability()
reset_release_watch()
hold_pos = None
action = f"force-change-release-open({decision.reason})"
return decision.release, action, decision.normal_change, decision.shear_change
while not self._stop_event.is_set():
tick_start = time.perf_counter()
now = time.perf_counter()
manual_action = None
self._release_decision = None
control_mode = self._get_control_mode()
if control_mode == "manual":
release_watch.suspend()
manual_payload = self._pop_manual_command()
if isinstance(manual_payload, dict):
@@ -235,7 +334,7 @@ class SideTriggerGripController:
config.force_min,
config.force_max,
))
self.gripper.open(force_pct, "manual-open", speed_pct=manual_speed_pct)
actuator.open(force_pct, "manual-open", speed_pct=manual_speed_pct)
self.feedback_processor.reset_filters()
state = "open_recover"
state_start = now
@@ -244,12 +343,10 @@ class SideTriggerGripController:
last_open_position_ready = False
recover_quiet_since = None
recover_position_since = None
trigger_clear_since = None
trigger_armed = False
reset_open_wait_stability()
release_armed = False
reset_release_watch()
hold_pos = None
reference = None
manual_action = "manual-open"
elif manual_command == "close":
force_pct = int(clamp(
@@ -257,7 +354,7 @@ class SideTriggerGripController:
config.force_min,
config.force_max,
))
self.gripper.close(force_pct, "manual-close", speed_pct=manual_speed_pct)
actuator.close(force_pct, "manual-close", speed_pct=manual_speed_pct)
state = "manual_close"
state_start = now
last_force_ramp_time = now
@@ -266,12 +363,10 @@ class SideTriggerGripController:
last_open_position_ready = False
recover_quiet_since = None
recover_position_since = None
trigger_clear_since = None
trigger_armed = False
reset_open_wait_stability()
release_armed = False
reset_release_watch()
hold_pos = None
reference = None
manual_action = "manual-close"
elif manual_command == "hold":
force_pct = int(clamp(
@@ -279,30 +374,24 @@ class SideTriggerGripController:
config.force_min,
config.force_max,
))
self.gripper.set_force(force_pct)
hold_pos = self.gripper.hold_current_position(
force_pct,
speed_pct=manual_speed_pct,
)
actuator.set_force(force_pct)
actuator.hold(force_pct, speed_pct=manual_speed_pct)
hold_pos = refresh_hold_pos() or current_position()
state = "manual_hold"
state_start = now
last_force_ramp_time = now
trigger_clear_since = None
trigger_armed = False
reset_open_wait_stability()
release_armed = False
reference = None
reset_release_watch()
manual_action = "manual-hold"
elif manual_command == "release":
if state == "manual_hold":
state = "open_wait"
state_start = now
trigger_clear_since = None
trigger_armed = False
reset_open_wait_stability()
release_armed = False
reset_release_watch()
hold_pos = None
reference = None
manual_action = "manual-release"
elif manual_command == "move_position":
target_min = min(int(config.open_pos), int(config.close_pos))
@@ -317,7 +406,7 @@ class SideTriggerGripController:
config.force_min,
config.force_max,
))
self.gripper.move(
actuator.move(
target_position,
force_pct,
"manual-move-position",
@@ -331,36 +420,37 @@ class SideTriggerGripController:
last_open_position_ready = False
recover_quiet_since = None
recover_position_since = None
trigger_clear_since = None
trigger_armed = False
reset_open_wait_stability()
release_armed = False
reset_release_watch()
hold_pos = None
reference = None
current_position = target_position
manual_action = "manual-move-position"
elif manual_command == "restart_left_sensor":
if self.reader.restart_sensor(0):
release_watch.suspend()
reset_open_wait_stability()
trigger_clear_since = None
trigger_armed = False
manual_action = "left-sensor-restart"
else:
manual_action = "left-sensor-restart-failed"
elif manual_command == "restart_right_sensor":
if self.reader.restart_sensor(1):
release_watch.suspend()
reset_open_wait_stability()
trigger_clear_since = None
trigger_armed = False
manual_action = "right-sensor-restart"
else:
manual_action = "right-sensor-restart-failed"
feedback = self.feedback_processor.read(self.reader, timeout=0.3)
# Non-blocking sample read: never stall the FSM on sensor wait.
feedback = self.feedback_processor.read(self.reader, timeout=0.0)
if feedback is None:
release_watch.suspend()
recent_feedback.clear()
recent_pair_stamps = None
action = manual_action or "waiting-for-sensor-samples"
position_for_status = update_current_position(now)
print(f"state={state} action={action}")
position_for_status = current_position()
self._log_sparse(f"state={state} action={action}", now)
self._emit_status(
{
"timestamp": time.time(),
@@ -369,7 +459,9 @@ class SideTriggerGripController:
"force_pct": force_pct,
"adaptive_target_force": adaptive_target_force,
"adaptive_grip_enabled": config.adaptive_grip_enabled,
"release_armed": release_armed,
"release_armed": release_watch.armed,
"release_phase": release_watch.phase.value,
"release_reason": "sample-gap",
"manual_hold_active": state == "manual_hold",
"control_mode": control_mode,
"speed_pct": config.speed,
@@ -380,9 +472,19 @@ class SideTriggerGripController:
"shear_unit": self.shear_unit,
}
)
time.sleep(0.05)
sleep_time = interval - (time.perf_counter() - tick_start)
if sleep_time > 0:
time.sleep(sleep_time)
continue
pair_stamps = (feedback.left_timestamp, feedback.right_timestamp)
if (recent_pair_stamps is None or None in pair_stamps
or all(a != b for a, b in zip(pair_stamps, recent_pair_stamps))):
if recent_feedback and now - recent_feedback[-1][0] > config.release_sample_timeout_seconds:
recent_feedback.clear()
recent_feedback.append((now, feedback))
recent_pair_stamps = pair_stamps
trigger = self._side_triggered(feedback)
grip_contact = self._grip_contact(feedback)
normal_change = 0.0
@@ -397,14 +499,17 @@ class SideTriggerGripController:
open_wait_sample = self._open_wait_sample(feedback)
self._append_open_wait_sample(open_wait_samples, open_wait_sample)
stable_values = self._open_wait_stable_values(open_wait_samples)
trigger = self._open_wait_delta_triggered(
delta_trigger = self._open_wait_delta_triggered(
open_wait_sample,
open_wait_stable_values,
)
if not trigger:
open_wait_stable_values.update(stable_values)
# Lock each channel's first stable reference. Continuously
# replacing it makes the reference follow a slow touch.
for key, value in stable_values.items():
open_wait_stable_values.setdefault(key, value)
trigger_armed = bool(open_wait_stable_values)
trigger = trigger_armed and delta_trigger
if not trigger_armed:
action = "open-wait-stabilizing"
elif not trigger:
@@ -415,7 +520,7 @@ class SideTriggerGripController:
config.force_min,
config.force_max,
))
self.gripper.close(force_pct, "side-trigger-close")
actuator.close(force_pct, "side-trigger-close")
state = "closing"
state_start = now
last_force_ramp_time = now
@@ -424,13 +529,11 @@ class SideTriggerGripController:
last_open_position_ready = False
recover_quiet_since = None
recover_position_since = None
trigger_clear_since = None
trigger_armed = False
reset_open_wait_stability()
adaptive_target_force = self._adaptive_force_target(feedback)
release_armed = False
reset_release_watch()
hold_pos = None
reference = None
action = "side-trigger-close"
elif state == "closing":
@@ -440,14 +543,14 @@ class SideTriggerGripController:
config.force_min,
config.force_max,
))
self.gripper.set_force(force_pct)
hold_pos = self.gripper.hold_current_position(force_pct)
actuator.set_force(force_pct)
actuator.hold(force_pct)
hold_pos = refresh_hold_pos() or current_position()
state = "gripping"
state_start = now
last_force_ramp_time = now
adaptive_target_force = self._adaptive_force_target(feedback)
release_armed = False
reference = None
release_watch.contact(now, feedback, history=recent_feedback)
action = "object-detected-low-force"
elif now - state_start >= config.close_timeout_seconds:
force_pct = int(clamp(
@@ -455,7 +558,7 @@ class SideTriggerGripController:
config.force_min,
config.force_max,
))
self.gripper.open(force_pct, "close-timeout-open")
actuator.open(force_pct, "close-timeout-open")
self.feedback_processor.reset_filters()
state = "open_recover"
state_start = now
@@ -465,24 +568,31 @@ class SideTriggerGripController:
recover_quiet_since = None
recover_position_since = None
reset_open_wait_stability()
release_armed = False
reset_release_watch()
hold_pos = None
reference = None
action = "close-timeout-open"
else:
if (
config.closing_command_interval > 0
and now - last_force_ramp_time >= config.closing_command_interval
):
self.gripper.close(force_pct, "closing-continue")
actuator.close(force_pct, "closing-continue")
last_force_ramp_time = now
action = "closing-continue"
else:
action = "closing-to-grip"
elif state == "gripping":
if (
now - last_force_ramp_time >= config.force_ramp_interval
refresh_hold_pos()
released, release_action, normal_change, shear_change = try_release_watch(
now,
feedback,
)
if released:
action = release_action
elif (
self._release_decision.allow_force_update
and now - last_force_ramp_time >= config.force_ramp_interval
and force_pct < config.hold_force
):
force_pct = int(clamp(
@@ -490,32 +600,37 @@ class SideTriggerGripController:
config.force_min,
config.hold_force,
))
self.gripper.set_force(force_pct)
actuator.set_force(force_pct)
last_force_ramp_time = now
action = "grip-ramp-up"
action = f"{release_action}; grip-ramp-up"
elif (
force_pct >= config.hold_force
and now - state_start >= config.grip_settle_seconds
):
reference = self._make_reference(feedback)
# Keep release watch continuous; do not re-lock on enter.
state = "hold_check"
state_start = now
adaptive_target_force = self._adaptive_force_target(feedback)
release_armed = False
action = "hold-reference-armed"
action = release_action or "enter-hold-check"
else:
action = "grip-hold"
action = release_action or "grip-hold"
elif state == "hold_check":
refresh_hold_pos()
adaptive_target_force = self._adaptive_force_target(feedback)
should_adapt_force = (
config.adaptive_grip_enabled
and not release_armed
and force_pct < adaptive_target_force
)
# Always inspect external force first, even while the target
# asks for more clamp force. Confirmation takes priority.
released, release_action, normal_change, shear_change = try_release_watch(now, feedback)
action = release_action
if (
should_adapt_force
not released
and self._release_decision.allow_force_update
and should_adapt_force
and now - last_force_ramp_time >= config.adaptive_force_interval
):
force_pct = int(clamp(
@@ -523,59 +638,9 @@ class SideTriggerGripController:
config.force_min,
adaptive_target_force,
))
self.gripper.set_force(force_pct)
actuator.set_force(force_pct)
last_force_ramp_time = now
state_start = now
reference = self._make_reference(feedback)
release_armed = False
action = "adaptive-grip-ramp"
elif should_adapt_force:
release_armed = False
action = "adaptive-grip-wait"
elif (
not release_armed
and now - max(state_start, last_force_ramp_time)
< config.release_arm_delay_seconds
):
action = "release-arm-delay"
elif not release_armed:
reference = self._make_reference(feedback)
release_armed = True
action = "release-reference-armed"
else:
normal_change, shear_change = self._release_changes(feedback, reference)
should_release = (
normal_change >= config.release_normal_change
or shear_change >= config.release_shear_change
)
if should_release:
force_pct = int(clamp(
config.open_force,
config.force_min,
config.force_max,
))
self.gripper.open(
force_pct,
"force-change-release-open",
speed_pct=config.release_open_speed,
accel=config.release_open_accel,
decel=config.release_open_decel,
)
self.feedback_processor.reset_filters()
state = "open_recover"
state_start = now
last_open_position_check_time = 0.0
last_open_position = None
last_open_position_ready = False
recover_quiet_since = None
recover_position_since = None
reset_open_wait_stability()
release_armed = False
hold_pos = None
reference = None
action = "force-change-release-open"
else:
action = "hold-check"
action = f"{release_action}; adaptive-grip-ramp"
elif state == "open_recover":
current_pos = last_open_position
@@ -586,7 +651,8 @@ class SideTriggerGripController:
>= config.open_position_check_interval
):
last_open_position_check_time = now
current_pos = self.gripper.read_position()
# Position is polled by actuator thread; FSM only reads cache.
current_pos = current_position()
position_ready = self._open_position_ready(current_pos)
last_open_position = current_pos
last_open_position_ready = position_ready
@@ -606,13 +672,10 @@ class SideTriggerGripController:
state_start = now
recover_quiet_since = None
recover_position_since = None
trigger_clear_since = None
trigger_armed = True
reset_open_wait_stability()
last_trigger = trigger
release_armed = False
reset_release_watch()
hold_pos = None
reference = None
action = f"open-ready-position pos={current_pos}"
else:
action = f"open-recover-position-stabilizing pos={current_pos}"
@@ -629,13 +692,10 @@ class SideTriggerGripController:
state_start = now
recover_quiet_since = None
recover_position_since = None
trigger_clear_since = None
trigger_armed = True
reset_open_wait_stability()
last_trigger = trigger
release_armed = False
reset_release_watch()
hold_pos = None
reference = None
action = "open-ready"
else:
action = "open-recover-stabilizing"
@@ -645,6 +705,7 @@ class SideTriggerGripController:
action = "open-recover-wait-force-clear"
elif state == "manual_hold":
refresh_hold_pos()
action = "manual-hold"
elif state == "manual_close":
@@ -653,6 +714,23 @@ class SideTriggerGripController:
elif state == "manual_position":
action = "manual-position"
self._record_force_trace(now, feedback, state, force_pct, action)
self._record_session_tick(
now=now,
feedback=feedback,
state=state,
force_pct=force_pct,
adaptive_target_force=adaptive_target_force,
action=action,
trigger=trigger,
trigger_armed=trigger_armed,
grip_contact=grip_contact,
normal_change=normal_change,
shear_change=shear_change,
control_mode=control_mode,
gripper_position=current_position(),
hold_pos=hold_pos,
)
self._log_tick(
feedback=feedback,
state=state,
@@ -663,11 +741,12 @@ class SideTriggerGripController:
hold_pos=hold_pos,
force_pct=force_pct,
adaptive_target_force=adaptive_target_force,
release_armed=release_armed,
release_armed=release_watch.armed,
action=action,
trigger_armed=trigger_armed,
now=now,
)
position_for_status = update_current_position(now)
position_for_status = current_position()
self._emit_status(
self._make_status(
feedback=feedback,
@@ -679,14 +758,13 @@ class SideTriggerGripController:
hold_pos=hold_pos,
force_pct=force_pct,
adaptive_target_force=adaptive_target_force,
release_armed=release_armed,
release_armed=release_watch.armed,
action=action,
trigger_armed=trigger_armed,
control_mode=control_mode,
gripper_position=position_for_status,
)
)
last_trigger = trigger
sleep_time = interval - (time.perf_counter() - tick_start)
if sleep_time > 0:
@@ -796,29 +874,118 @@ class SideTriggerGripController:
target = round(force_min + ratio * (force_max - force_min))
return int(clamp(target, force_min, force_max))
def _make_reference(self, feedback):
return {
"left_normal": feedback.left_normal,
"right_normal": feedback.right_normal,
"left_shear": feedback.left_shear,
"right_shear": feedback.right_shear,
"max_shear": feedback.max_shear,
}
def _log_sparse(self, message, now=None):
now = time.perf_counter() if now is None else now
if now - self._last_log_time < self._log_interval_s:
return
self._last_log_time = now
print(message)
def _release_changes(self, feedback, reference):
if reference is None:
return 0.0, 0.0
normal_change = max(
abs(feedback.left_normal - reference["left_normal"]),
abs(feedback.right_normal - reference["right_normal"]),
)
shear_change = abs(feedback.max_shear - reference["max_shear"])
shear_change = max(
shear_change,
abs(feedback.left_shear - reference["left_shear"]),
abs(feedback.right_shear - reference["right_shear"]),
)
return normal_change, shear_change
def _record_force_trace(self, now, feedback, state, force_pct, action):
"""Keep the actual pre-release curve, including cached-frame timestamps.
File I/O happens only after a release command has already been submitted.
The bounded snapshot replaces the previous event, even in source runs.
"""
decision = self._release_decision
self._force_trace.append({
"time": now, "state": state, "force_pct": force_pct, "action": action,
"feedback": asdict(feedback),
"release": None if decision is None else asdict(decision),
})
if decision is None or not decision.release:
return
path = self.release_trace_path
try:
path.parent.mkdir(parents=True, exist_ok=True)
payload = {
"captured_at": time.time(),
"config": {key: value for key, value in vars(self.config).items()
if key.startswith("release_") or key in {
"control_hz", "filter_alpha", "shear_filter_alpha"}},
"frames": list(self._force_trace),
}
temporary = path.with_suffix(".tmp")
temporary.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
temporary.replace(path)
print(f"[release-trace] {path}")
except (OSError, TypeError, ValueError) as exc:
print(f"[release-trace] save failed: {exc}")
def _record_session_tick(
self,
now,
feedback,
state,
force_pct,
adaptive_target_force,
action,
trigger,
trigger_armed,
grip_contact,
normal_change,
shear_change,
control_mode,
gripper_position,
hold_pos,
):
if not self.session_recorder.enabled:
return
decision = self._release_decision
pending = completed_at = None
if self.actuator is not None:
try:
pending, completed_at = self.actuator.motion_status
except Exception:
pending, completed_at = None, None
left_sample = right_sample = None
try:
if len(self.reader.latest) >= 2:
left_sample = self.reader.latest[0]
right_sample = self.reader.latest[1]
except Exception:
pass
def _raw_force(sample):
if not isinstance(sample, dict):
return None
return {
"fnormal": sample.get("fnormal"),
"fshearx": sample.get("fshearx"),
"fsheary": sample.get("fsheary"),
"fps": sample.get("fps"),
"timestamp": sample.get("timestamp"),
}
self.session_recorder.record({
"t": now,
"wall": time.time(),
"state": state,
"action": action,
"control_mode": control_mode,
"grip_behavior_mode": getattr(self.config, "grip_behavior_mode", None),
"force_pct": force_pct,
"adaptive_target_force": adaptive_target_force,
"hold_pos": hold_pos,
"gripper_position": gripper_position,
"trigger": bool(trigger),
"trigger_armed": bool(trigger_armed),
"grip_contact": bool(grip_contact),
"normal_change": normal_change,
"shear_change": shear_change,
"feedback": asdict(feedback),
"raw_left": _raw_force(left_sample),
"raw_right": _raw_force(right_sample),
"release": None if decision is None else asdict(decision),
"release_phase": (
None if decision is None else getattr(decision.phase, "value", decision.phase)
),
"release_armed": bool(self.release_watch.armed),
"side_watch": bool(self.release_watch.side_watch_active),
"motion_pending": pending,
"motion_completed_at": completed_at,
})
def _log_tick(
self,
@@ -834,7 +1001,21 @@ class SideTriggerGripController:
release_armed,
action,
trigger_armed,
now=None,
):
now = time.perf_counter() if now is None else now
decision = self._release_decision
release_reason = decision.reason if decision is not None else ""
log_key = (state, release_reason, action.split(" ", 1)[0])
if (release_reason == "waiting-sample" and self._last_log_key is not None
and self._last_log_key[0] == state):
# Camera/control rates differ: cached ticks are not state changes.
log_key = self._last_log_key
if log_key == self._last_log_key and now - self._last_log_time < self._log_interval_s:
return
self._last_log_key = log_key
self._last_log_time = now
left_fps = 0.0
right_fps = 0.0
try:
@@ -858,6 +1039,7 @@ class SideTriggerGripController:
f"state={state} hold_pos={hold_pos} "
f"force_pct={force_pct:3d} target={adaptive_target_force:3d} "
f"release_armed={int(release_armed)} action={action}"
f" side_watch={int(self.release_watch.side_watch_active)}"
)
def _make_status(
@@ -878,7 +1060,7 @@ class SideTriggerGripController:
gripper_position=None,
):
speed_pct = self.config.speed
if action == "force-change-release-open":
if action.startswith("force-change-release-open"):
speed_pct = self.config.release_open_speed
left_sample = None
@@ -917,8 +1099,16 @@ class SideTriggerGripController:
"adaptive_target_force": adaptive_target_force,
"adaptive_grip_enabled": self.config.adaptive_grip_enabled,
"release_armed": bool(release_armed),
"release_phase": self._release_decision.phase.value if self._release_decision else self.release_watch.phase.value,
"release_side_watch": self.release_watch.side_watch_active,
"release_reason": self._release_decision.reason if self._release_decision else "",
"release_normal_rate": self._release_decision.normal_rate if self._release_decision else 0.0,
"release_shear_rate": self._release_decision.shear_rate if self._release_decision else 0.0,
"release_curve_motion": self._release_decision.curve_motion if self._release_decision else "",
"release_channel": self._release_decision.channel if self._release_decision else "",
"manual_hold_active": state == "manual_hold",
"control_mode": control_mode,
"grip_behavior_mode": getattr(self.config, "grip_behavior_mode", "stable"),
"speed_pct": speed_pct,
"gripper_position": gripper_position,
"open_pos": self.config.open_pos,
+22 -8
View File
@@ -39,6 +39,8 @@ class ForceFeedback:
left_shear_y: float = 0.0
right_shear_x: float = 0.0
right_shear_y: float = 0.0
left_timestamp: float | None = None
right_timestamp: float | None = None
class TactileFeedbackProcessor:
@@ -50,6 +52,7 @@ class TactileFeedbackProcessor:
self.reset_filters()
def reset_filters(self):
self._sample_timestamps = {}
self.left_normal_filter = ExponentialFilter(self.config.filter_alpha)
self.right_normal_filter = ExponentialFilter(self.config.filter_alpha)
self.left_shear_filter = ExponentialFilter(self.config.shear_filter_alpha)
@@ -59,6 +62,14 @@ class TactileFeedbackProcessor:
self.right_shear_x_filter = ExponentialFilter(self.config.shear_filter_alpha)
self.right_shear_y_filter = ExponentialFilter(self.config.shear_filter_alpha)
def _filter_sample(self, key, value, timestamp):
"""A cached camera frame must not advance the filter a second time."""
filter_ = getattr(self, f"{key}_filter")
if timestamp is None or self._sample_timestamps.get(key) != timestamp:
self._sample_timestamps[key] = timestamp
return filter_.update(value)
return filter_.value
def _convert_shear(self, sample):
if _component_shear_enabled(self.shear_converter):
shear_x = self.shear_converter.convert_component(sample["fshearx"])
@@ -142,14 +153,15 @@ class TactileFeedbackProcessor:
self.shear_converter.convert(shear_magnitude(right)) - self.baseline.right_shear,
)
left_normal = self.left_normal_filter.update(left_force)
right_normal = self.right_normal_filter.update(right_force)
left_shear_filtered = self.left_shear_filter.update(left_shear)
right_shear_filtered = self.right_shear_filter.update(right_shear)
left_shear_x_filtered = self.left_shear_x_filter.update(left_shear_x)
left_shear_y_filtered = self.left_shear_y_filter.update(left_shear_y)
right_shear_x_filtered = self.right_shear_x_filter.update(right_shear_x)
right_shear_y_filtered = self.right_shear_y_filter.update(right_shear_y)
left_timestamp, right_timestamp = left.get("timestamp"), right.get("timestamp")
left_normal = self._filter_sample("left_normal", left_force, left_timestamp)
right_normal = self._filter_sample("right_normal", right_force, right_timestamp)
left_shear_filtered = self._filter_sample("left_shear", left_shear, left_timestamp)
right_shear_filtered = self._filter_sample("right_shear", right_shear, right_timestamp)
left_shear_x_filtered = self._filter_sample("left_shear_x", left_shear_x, left_timestamp)
left_shear_y_filtered = self._filter_sample("left_shear_y", left_shear_y, left_timestamp)
right_shear_x_filtered = self._filter_sample("right_shear_x", right_shear_x, right_timestamp)
right_shear_y_filtered = self._filter_sample("right_shear_y", right_shear_y, right_timestamp)
return ForceFeedback(
left_normal=left_normal,
@@ -165,4 +177,6 @@ class TactileFeedbackProcessor:
left_shear_y=left_shear_y_filtered,
right_shear_x=right_shear_x_filtered,
right_shear_y=right_shear_y_filtered,
left_timestamp=left_timestamp,
right_timestamp=right_timestamp,
)
+211
View File
@@ -0,0 +1,211 @@
"""Recognize a new force onset, rather than accumulated settling movement."""
from collections import deque
from dataclasses import dataclass, replace
import math
from statistics import median
@dataclass(frozen=True)
class _CurveModel:
time: float
scale: float
coefficients: tuple
covariance: tuple
noise: float
def terms_at(self, stamp):
x = (stamp - self.time) / self.scale
return tuple(x ** i for i in range(len(self.coefficients)))
def predict(self, stamp):
return sum(c * x for c, x in zip(self.coefficients, self.terms_at(stamp)))
def uncertainty(self, stamp):
terms = self.terms_at(stamp)
variance = sum(a * b * self.covariance[i][j]
for i, a in enumerate(terms) for j, b in enumerate(terms))
# Include the new sample's noise, and widen the corridor with forecast
# distance. A tiny fitting error must not grow into release evidence.
return 4.0 * self.noise * math.sqrt(1.0 + max(0.0, variance))
def change_uncertainty(self, before, after):
terms = tuple(b - a for a, b in zip(self.terms_at(before), self.terms_at(after)))
variance = sum(a * b * self.covariance[i][j]
for i, a in enumerate(terms) for j, b in enumerate(terms))
return 4.0 * self.noise * math.sqrt(2.0 + max(0.0, variance))
@property
def slope(self):
"""Physical dy/dt from the linear term of the scaled polynomial."""
if len(self.coefficients) < 2 or self.scale <= 0:
return 0.0
return self.coefficients[1] / self.scale
def _fit_curve(history, degree, noise_floor):
"""Small least-squares fit with its prediction uncertainty (no I/O)."""
stamp = history[-1][0]
scale = max(stamp - history[0][0], 1e-6)
rows = [tuple(((t - stamp) / scale) ** i for i in range(degree + 1))
for t, _ in history]
size = degree + 1
matrix = [[sum(row[i] * row[j] for row in rows) for j in range(size)]
+ [float(i == j) for j in range(size)] for i in range(size)]
# Invert the at-most 3x3 normal matrix. Sensor times have already been
# checked for duplicates/restarts before samples enter the history.
for column in range(size):
pivot = max(range(column, size), key=lambda row: abs(matrix[row][column]))
matrix[column], matrix[pivot] = matrix[pivot], matrix[column]
divisor = matrix[column][column]
if abs(divisor) < 1e-12:
return _fit_curve(history, degree - 1, noise_floor)
matrix[column] = [value / divisor for value in matrix[column]]
for row in range(size):
if row != column:
multiplier = matrix[row][column]
matrix[row] = [a - multiplier * b for a, b in zip(matrix[row], matrix[column])]
covariance = tuple(tuple(row[size:]) for row in matrix)
target = [sum(row[i] * value for row, (_, value) in zip(rows, history)) for i in range(size)]
coefficients = tuple(sum(a * b for a, b in zip(row, target)) for row in covariance)
error = sum((value - sum(c * x for c, x in zip(coefficients, row))) ** 2
for row, (_, value) in zip(rows, history))
noise = max(noise_floor, math.sqrt(error / max(1, len(history) - size)))
return _CurveModel(stamp, scale, coefficients, covariance, noise)
@dataclass(frozen=True)
class ForceOnset:
time: float
origin: float
direction: float
innovation: float
model: _CurveModel
def baseline_at(self, stamp):
return self.model.predict(stamp)
def significant_at(self, stamp, value):
return abs(value - self.baseline_at(stamp)) > self.model.uncertainty(stamp)
class ForceCurve:
"""Local polynomial continuation plus signed slope/turn classification.
A short recent window still drives onset detection. A longer retained history
estimates the settling slope (grip force ramp decaying toward zero).
"""
def __init__(self, config):
self.config = config
history = max(6, int(getattr(config, "release_curve_history", 6) or 6))
self.history = deque(maxlen=history)
self._noise_history = deque(maxlen=6)
self.onset = None
self.rate = 0.0
self.delta = 0.0
self.innovation = 0.0
self.motion = "curve_follow"
self.fit_slope = 0.0
def discard_onset(self):
self.onset = None
def _onset_history(self):
"""Keep onset detection local even when the retained history is long."""
onset_points = max(3, min(6, len(self.history)))
if len(self.history) <= onset_points:
return list(self.history)
return list(self.history)[-onset_points:]
def window_slope(self, now=None, window_s=None):
"""Linear least-squares slope over a longer settling window (N/s)."""
if len(self.history) < 2:
return 0.0
window_s = float(
getattr(self.config, "release_slope_window_seconds", 0.8)
if window_s is None
else window_s
)
now = self.history[-1][0] if now is None else now
cutoff = now - max(0.05, window_s)
points = [(t, v) for t, v in self.history if t >= cutoff]
if len(points) < 2:
points = list(self.history)
floor = min(self.config.release_min_change, self.config.release_side_touch_change)
model = _fit_curve(points, min(1, len(points) - 1), max(1e-6, floor * 0.025))
return model.slope
def observe(self, stamp, value):
if self.history:
if stamp == self.history[-1][0]:
return
if stamp < self.history[-1][0]:
self.history.clear()
self._noise_history.clear()
self.onset = None
self.rate = self.delta = self.innovation = 0.0
self.fit_slope = 0.0
self.motion = "curve_follow"
if not self.history:
self.history.append((stamp, value))
return
previous_time, previous_value = self.history[-1]
dt = stamp - previous_time
self.delta = value - previous_value
self.rate = self.delta / dt
previous_rate = 0.0
prior_rates = []
if len(self.history) >= 2:
older_time, older_value = self.history[-2]
previous_rate = (previous_value - older_value) / (previous_time - older_time)
prior_rates.append(previous_rate)
if len(self.history) >= 3:
oldest_time, oldest_value = self.history[-3]
older_rate = (older_value - oldest_value) / (older_time - oldest_time)
prior_rates.append(older_rate)
floor = min(self.config.release_min_change, self.config.release_side_touch_change)
noise_floor = floor * 0.025
onset_history = self._onset_history()
model = _fit_curve(onset_history, min(2, len(onset_history) - 1), noise_floor)
self._noise_history.append(model.noise)
model = replace(model, noise=max(model.noise, median(self._noise_history)))
self.innovation = value - model.predict(stamp)
# Append first so the settling slope includes the new sample.
self.history.append((stamp, value))
self.fit_slope = self.window_slope(stamp)
# A sign reversal of comparable speed is a turn, not another impulse.
turning = (previous_rate * self.rate < 0
and abs(self.rate) <= 1.5 * max((abs(r) for r in prior_rates), default=0.0))
decelerating = (previous_rate * self.rate >= 0
and abs(self.rate) <= abs(previous_rate) + 1e-9)
onset = self.onset
if onset is not None:
if self.delta * onset.direction < -floor * 0.1:
self.onset = None
turning = True
elif not onset.significant_at(stamp, value):
# Losing evidence before reaching the amplitude threshold is
# not permission to accumulate later, unrelated curve travel.
self.onset = None
elif ((value - onset.origin) * onset.direction < floor
and self.delta * onset.direction < floor * 0.1):
# A sub-threshold onset which has already flattened is over.
# Do not let the forecast alone grow a small bump into a later
# candidate while the real signal resumes its natural curve.
self.onset = None
elif stamp - onset.time > self.config.release_touch_window_seconds:
self.onset = None
if turning:
self.motion = "settling_turn"
elif decelerating and abs(previous_rate) > 1e-9:
self.motion = "settling_flatten"
if (self.onset is None and not turning and not decelerating
and abs(self.innovation) + 1e-9 >= floor * 0.2
and abs(self.innovation) > model.uncertainty(stamp)
and self.innovation * self.delta > 0):
self.onset = ForceOnset(stamp, previous_value,
1.0 if self.innovation > 0 else -1.0, self.innovation,
model)
self.motion = "new_onset"
+598
View File
@@ -0,0 +1,598 @@
"""Release FSM. No clock, serial I/O or callbacks: step() returns a decision."""
from dataclasses import dataclass
from enum import Enum
import math
from .force_curve import ForceCurve, ForceOnset
CHANNELS = ("left_normal", "right_normal", "left_shear", "right_shear")
class ReleasePhase(str, Enum):
DISARMED = "disarmed"
GRIP_SETTLE = "grip_settle"
SELF_SETTLE = "self_settle"
WATCHING = "watching"
CONFIRMING = "confirming"
RELEASED = "released"
@dataclass(frozen=True)
class ReleaseDecision:
phase: ReleasePhase
reason: str
release: bool = False
allow_force_update: bool = False
normal_change: float = 0.0
shear_change: float = 0.0
normal_rate: float = 0.0
shear_rate: float = 0.0
confirm_count: int = 0
confirm_needed: int = 0
channel: str = ""
innovation: float = 0.0
curve_motion: str = ""
@property
def action(self):
return (
f"release-{self.reason} phase={self.phase.value} "
f"confirm={self.confirm_count}/{self.confirm_needed} "
f"rN={self.normal_rate:.2f} rS={self.shear_rate:.2f}"
f" channel={self.channel or '-'} innovation={self.innovation:.4f} curve={self.curve_motion or '-'}"
)
@dataclass
class _Candidate:
kind: str
channel: str
origin: float
direction: float
peak: float
started_at: float
needed: int
floor: float
count: int = 1
onset: ForceOnset | None = None
last_displacement: float = 0.0
last_sample_time: float = 0.0
@dataclass
class _Rebound:
origin: float
direction: float
remaining: float
def _returning_tail(tails, key, value, floor):
"""Suppress only a rejected pulse returning towards its original baseline.
This is channel-local and ends on return or renewed movement. It does not
wait for unrelated channels to become quiet or impose a new hold timer.
"""
tail = tails.get(key)
if tail is None:
return False
remaining = (value - tail.origin) * tail.direction
if abs(remaining) <= floor * 0.5:
del tails[key]
return True
if remaining < 0 or remaining > tail.remaining + floor * 0.25:
del tails[key]
return False
tail.remaining = min(tail.remaining, remaining)
return True
class _SideTouch:
"""Observe a new lateral touch even during clamp protection.
Bilateral common-mode force is cancelled. A vote needs new frames from
both sides so asynchronous camera updates cannot fabricate a difference.
Normal force stays masked during an actual force command and its tail;
differential shear remains available without a contact timer.
"""
def __init__(self, config):
self.config = config
self.reset()
def reset(self):
self.last_pair = None
self.seen = set()
self.candidate = None
self.recover_until = 0.0
self.tails = {}
self.curves = {index: ForceCurve(self.config) for index in (1, 2)}
def seed(self, now, values):
self.last_pair = (now, values["left_normal"] - values["right_normal"],
values["left_shear"] - values["right_shear"])
for index in (1, 2):
if not self.curves[index].history:
self.curves[index].observe(now, self.last_pair[index])
def prime(self, now, values):
for index, field in ((1, "normal"), (2, "shear")):
self.curves[index].observe(now, values[f"left_{field}"] - values[f"right_{field}"])
self.curves[index].discard_onset()
def step(self, now, values, fresh, enabled, normal_masked, sample_time,
onset_allowed=True):
self.seen.update(fresh)
if not all(key in self.seen for key in CHANNELS):
return None
self.seen.clear()
sample = (sample_time, values["left_normal"] - values["right_normal"],
values["left_shear"] - values["right_shear"])
for index in (1, 2):
self.curves[index].observe(sample_time, sample[index])
if normal_masked:
self.curves[1].discard_onset()
if not enabled or now < self.recover_until:
self.seed(sample_time, values)
self.candidate = None
for curve in self.curves.values():
curve.discard_onset()
return None
self.last_pair = sample
# Stable grip: keep learning the long ramp curve, but do not arm side
# touch while the settling slope is still decaying toward zero.
if not onset_allowed:
self.candidate = None
for curve in self.curves.values():
curve.discard_onset()
return None
# Drop leftover candidates that the profile has disabled.
if self.candidate is not None:
if (self.candidate.channel == "side_shear"
and not getattr(self.config, "release_side_shear_enabled", True)):
self.candidate = None
self.curves[2].discard_onset()
elif (self.candidate.channel == "side_normal"
and not getattr(self.config, "release_side_normal_enabled", True)):
self.candidate = None
self.curves[1].discard_onset()
returning = {index for index in (1, 2) if _returning_tail(
self.tails, index, sample[index], self.config.release_side_touch_change)}
if returning:
for index in returning:
self.curves[index].discard_onset()
candidate = self.candidate
if candidate is not None:
index = 1 if candidate.channel == "side_normal" else 2
baseline = candidate.onset.baseline_at(sample_time)
delta = (sample[index] - baseline) * candidate.direction
return_margin = max(self.config.release_min_change * 0.1,
candidate.onset.model.change_uncertainty(candidate.last_sample_time, sample_time))
turning = delta < candidate.last_displacement - return_margin
if (now - candidate.started_at > self.config.release_confirm_window_seconds
or delta + 1e-9 < max(candidate.floor, 0.6 * candidate.peak)
or not candidate.onset.significant_at(sample_time, sample[index])
or (normal_masked and index == 1) or turning):
self.candidate = None
self.tails[index] = _Rebound(baseline, candidate.direction, delta)
self.recover_until = now + self.config.release_settle_seconds
self.seed(sample_time, values)
self.curves[index].discard_onset()
return "settling-turn" if turning else "rejected"
candidate.peak = max(candidate.peak, delta)
candidate.last_displacement = delta
candidate.last_sample_time = sample_time
candidate.count += 1
else:
shear_ok = getattr(self.config, "release_side_shear_enabled", True)
normal_ok = getattr(self.config, "release_side_normal_enabled", True)
channels = []
if shear_ok:
channels.append((2, "side_shear"))
if normal_ok:
channels.append((1, "side_normal"))
for index, channel in channels:
if index in returning or (normal_masked and index == 1):
continue
onset = self.curves[index].onset
if onset is None:
continue
delta = sample[index] - onset.baseline_at(sample_time)
if (abs(delta) + 1e-9 >= self.config.release_side_touch_change
and (sample[index] - onset.origin) * onset.direction + 1e-9 >= self.config.release_side_touch_change
and onset.significant_at(sample_time, sample[index])
and delta * onset.direction > 0):
candidate = _Candidate("side_touch", channel, onset.origin,
1.0 if delta > 0 else -1.0, abs(delta), now,
max(2, self.config.release_confirm_samples),
self.config.release_side_touch_change,
onset=onset, last_displacement=abs(delta), last_sample_time=sample_time)
self.candidate = candidate
break
return candidate
class ReleaseWatch:
"""Immediate side touch + bounded clamp protection + persistent displacement.
A force command masks normal changes through execution and a short tail.
Lateral shear remains observable; global quiet never gates release.
New force commands require an observation window, so repeated adaptive
commands cannot continuously re-arm a timer or erase release evidence.
Sensor timestamps are optional for replay; production supplies both sides.
"""
def __init__(self, config):
self.config = config
self._side_touch = _SideTouch(config)
self.reset()
def reset(self):
self.phase = ReleasePhase.DISARMED
self._contact_at = None
self._grip_settled = False
self._slope_settle_since = None
self._plateau_latched = False
self._reference = {}
self._previous = {}
self._recovery_until = 0.0
self._settle_seen = set()
self._tails = {}
self._watch_since = None
self._candidate = None
self._last_fresh = {}
self._rates = {}
self._curves = {key: ForceCurve(self.config) for key in CHANNELS}
self._side_touch.reset()
@property
def armed(self):
return (self.phase in {ReleasePhase.WATCHING, ReleasePhase.CONFIRMING}
or self._side_touch.candidate is not None)
@property
def side_watch_active(self):
return (self.phase not in {ReleasePhase.DISARMED, ReleasePhase.RELEASED}
and self._side_touch.last_pair is not None and len(self._previous) == len(CHANNELS))
def contact(self, now, feedback=None, history=()):
self.reset()
self._contact_at = now
self.phase = ReleasePhase.GRIP_SETTLE
for frame_time, frame in history:
values = {key: float(getattr(frame, key)) for key in CHANNELS}
for key, value in values.items():
stamp = getattr(frame, f"{key.split('_', 1)[0]}_timestamp", None)
self._curves[key].observe(frame_time if stamp is None else stamp, value)
self._curves[key].discard_onset()
self._side_touch.prime(self._pair_time(frame_time, frame), values)
if feedback is not None:
self._side_touch.seed(self._pair_time(now, feedback), {key: float(getattr(feedback, key)) for key in CHANNELS})
for key in CHANNELS:
stamp = getattr(feedback, f"{key.split('_', 1)[0]}_timestamp", None)
self._curves[key].observe(now if stamp is None else stamp, float(getattr(feedback, key)))
self._curves[key].discard_onset()
@staticmethod
def _pair_time(now, feedback):
stamps = (getattr(feedback, "left_timestamp", None), getattr(feedback, "right_timestamp", None))
return now if None in stamps else max(stamps)
def suspend(self):
"""Discard evidence during missing feedback, sensor restart or manual mode."""
if self.phase in {ReleasePhase.DISARMED, ReleasePhase.RELEASED}:
return
self._protect("sample-gap", {})
self._side_touch.reset()
self._reference.clear()
self._previous.clear()
self._rates.clear()
self._last_fresh.clear()
self._tails.clear()
self._curves = {key: ForceCurve(self.config) for key in CHANNELS}
def _normal_slopes_live_ok(self, now):
"""Live check: normal long-window slopes are near zero long enough."""
if not getattr(self.config, "release_require_slope_settle", False):
return True
threshold = max(0.0, float(getattr(self.config, "release_slope_settle_rate", 0.2) or 0.2))
min_points = max(3, int(getattr(self.config, "release_slope_min_points", 8) or 8))
hold = max(0.0, float(getattr(self.config, "release_slope_hold_seconds", 0.0) or 0.0))
normal_only = getattr(self.config, "release_settle_normal_only", True)
keys = CHANNELS[:2] if normal_only else CHANNELS
for key in keys:
curve = self._curves[key]
if len(curve.history) < min_points or abs(curve.fit_slope) > threshold:
self._slope_settle_since = None
return False
if self._slope_settle_since is None:
self._slope_settle_since = now
if hold > 0 and now - self._slope_settle_since < hold:
return False
return True
def _force_settled(self, now):
"""Release-armed after the first relative normal plateau (latched).
The live slope near zero is only required to *enter* the armed state.
Once latched, further adaptive 加力 or an intentional open push may make
the slope non-zero again — that must not revoke release permission, or
the open gesture is mistaken for "still settling".
"""
if not getattr(self.config, "release_require_slope_settle", False):
self._plateau_latched = True
return True
if self._plateau_latched:
return True
if self._normal_slopes_live_ok(now):
self._plateau_latched = True
return True
return False
def _protect(self, reason, values, **metrics):
self.phase = (
ReleasePhase.SELF_SETTLE if self._grip_settled
else ReleasePhase.GRIP_SETTLE
)
self._reference.update(values)
self._candidate = None
self._watch_since = None
self._settle_seen.clear()
for curve in self._curves.values():
curve.discard_onset()
return ReleaseDecision(self.phase, reason, **metrics)
def step(self, now, feedback, *, motion_pending=False, motion_completed_at=None):
if self.phase in {ReleasePhase.DISARMED, ReleasePhase.RELEASED}:
return ReleaseDecision(self.phase, self.phase.value)
cfg = self.config
timeout = max(0.001, cfg.release_sample_timeout_seconds)
values = {key: float(getattr(feedback, key)) for key in CHANNELS}
if not all(math.isfinite(value) for value in values.values()):
self._previous.clear()
self._rates.clear()
self._side_touch.reset()
self._curves = {key: ForceCurve(cfg) for key in CHANNELS}
self._recovery_until = now + cfg.release_settle_seconds
return self._protect("invalid-sample", {})
rates, fresh = {}, set()
discontinuity = False
for key, value in values.items():
side = key.split("_", 1)[0]
stamp = getattr(feedback, f"{side}_timestamp", None)
stamp = now if stamp is None else stamp
previous = self._previous.get(key)
if previous is not None and stamp == previous[0]:
continue
fresh.add(key)
self._last_fresh[key] = now
if previous is not None:
dt = stamp - previous[0]
if 0 < dt <= timeout:
rates[key] = (value - previous[1]) / dt
else:
discontinuity = True
self._previous[key] = (stamp, value)
self._rates.update(rates)
normal_rate = max((abs(rates.get(k, 0.0)) for k in CHANNELS[:2]), default=0.0)
shear_rate = max((abs(rates.get(k, 0.0)) for k in CHANNELS[2:]), default=0.0)
normal_change = max(abs(values[k] - self._reference.get(k, values[k])) for k in CHANNELS[:2])
shear_change = max(abs(values[k] - self._reference.get(k, values[k])) for k in CHANNELS[2:])
metrics = dict(normal_change=normal_change, shear_change=shear_change,
normal_rate=normal_rate, shear_rate=shear_rate)
stale = any(now - self._last_fresh.get(k, now) > timeout for k in CHANNELS)
if stale or discontinuity:
self._side_touch.reset()
self._curves = {key: ForceCurve(cfg) for key in CHANNELS}
self._recovery_until = now + cfg.release_settle_seconds
return self._protect("sample-gap", values, **metrics)
for key in fresh:
self._curves[key].observe(self._previous[key][0], values[key])
motions = {curve.motion for curve in self._curves.values()}
metrics["curve_motion"] = ("new_onset" if "new_onset" in motions else
"settling_turn" if "settling_turn" in motions else
"settling_flatten" if "settling_flatten" in motions else "curve_follow")
self_tail = (
motion_completed_at is not None
and now - motion_completed_at < max(0.0, cfg.release_arm_delay_seconds)
)
contact_guard = now - self._contact_at < max(0.0, cfg.release_min_hold_seconds)
protected = motion_pending or self_tail or contact_guard or self.phase in {
ReleasePhase.GRIP_SETTLE, ReleasePhase.SELF_SETTLE,
}
force_settled = self._force_settled(now)
if not force_settled:
metrics["curve_motion"] = "force_settling"
side = self._side_touch.step(now, values, fresh,
enabled=protected or self._side_touch.candidate is not None,
normal_masked=motion_pending or self_tail,
sample_time=self._pair_time(now, feedback),
onset_allowed=force_settled)
if side in ("rejected", "settling-turn"):
reason = "settling-turn" if side == "settling-turn" else "transient-rejected"
return self._cancel(now, values, reason, metrics, reset_side=False)
if side is not None:
self._candidate = None
released = side.count >= side.needed
if released:
self.phase = ReleasePhase.RELEASED
return ReleaseDecision(ReleasePhase.RELEASED if released else ReleasePhase.CONFIRMING,
"side_touch", release=released, confirm_count=side.count,
confirm_needed=side.needed, channel=side.channel,
innovation=side.onset.innovation,
**dict(metrics, curve_motion="onset_confirm"))
slope_gate = getattr(cfg, "release_require_slope_settle", False)
# Contact min-hold always clamps. Self-motion clamps until the first
# normal plateau is latched (stable) — or always when slope gate is off.
if contact_guard or ((motion_pending or self_tail) and (not slope_gate or not force_settled)):
reason = ("force-settling" if slope_gate and not force_settled
else "self-settle" if self._grip_settled else "grip-settle")
return self._protect(reason, values, **metrics)
if slope_gate and force_settled and (motion_pending or self_tail):
# Adaptive 加力 after plateau: keep watching for intentional open.
if self.phase in {ReleasePhase.GRIP_SETTLE, ReleasePhase.SELF_SETTLE}:
self._grip_settled = True
self.phase = ReleasePhase.WATCHING
if self._watch_since is None:
self._watch_since = now
for curve in self._curves.values():
curve.discard_onset()
if not fresh:
return ReleaseDecision(self.phase, "waiting-sample", **metrics)
# Quiet is diagnostic only. No global "all four rates quiet" arming
# gate: even small continuous camera noise can keep that gate shut.
quiet = (
len(self._rates) == len(CHANNELS)
and max(abs(rate) for rate in self._rates.values()) <= cfg.release_slow_rate
)
if self.phase in {ReleasePhase.GRIP_SETTLE, ReleasePhase.SELF_SETTLE}:
self._settle_seen.update(fresh)
self._reference.update(values)
if now < self._recovery_until or len(self._settle_seen) < len(CHANNELS):
reason = "self-settle" if self._grip_settled else "grip-settle"
return ReleaseDecision(self.phase, reason, **metrics)
# Stable mode waits until the long-history slope has decayed near
# zero before ordinary watching / release paths arm.
if not force_settled:
return ReleaseDecision(self.phase, "force-settling", **metrics)
self._grip_settled = True
self.phase = ReleasePhase.WATCHING
self._watch_since = now
for curve in self._curves.values():
curve.discard_onset()
return ReleaseDecision(self.phase, "ready", **metrics)
returning = {key for key in fresh if _returning_tail(
self._tails, key, values[key], cfg.release_min_change)}
for key in returning:
self._reference[key] = values[key]
self._curves[key].discard_onset()
# Drop watching candidates on channel families the profile disabled.
if (self._candidate is not None
and self._candidate.channel.endswith("normal")
and not getattr(cfg, "release_normal_onset_enabled", True)):
self._candidate = None
if self._grip_settled:
self.phase = ReleasePhase.WATCHING
if (self._candidate is not None
and self._candidate.channel.endswith("shear")
and not getattr(cfg, "release_shear_onset_enabled", True)):
self._candidate = None
if self._grip_settled:
self.phase = ReleasePhase.WATCHING
candidate = self._candidate
if candidate is not None:
if now - candidate.started_at > cfg.release_confirm_window_seconds:
return self._cancel(now, values, "confirm-expired", metrics)
if candidate.channel not in fresh:
return ReleaseDecision(self.phase, "waiting-sample", **metrics)
stamp = self._previous[candidate.channel][0]
displacement = (values[candidate.channel] - candidate.onset.baseline_at(stamp)) * candidate.direction
return_margin = max(cfg.release_min_change * 0.1,
candidate.onset.model.change_uncertainty(candidate.last_sample_time, stamp))
if displacement < candidate.last_displacement - return_margin:
return self._cancel(now, values, "settling-turn", metrics)
# A spike returning to baseline, reversal, or channel swap is not a
# second vote. A step that stays displaced is, even with dF/dt=0.
if displacement + 1e-9 < max(candidate.floor, 0.6 * candidate.peak):
return self._cancel(now, values, "transient-rejected", metrics)
if not candidate.onset.significant_at(stamp, values[candidate.channel]):
return self._cancel(now, values, "transient-rejected", metrics)
candidate.peak = max(candidate.peak, displacement)
candidate.last_displacement = displacement
candidate.last_sample_time = stamp
candidate.count += 1
else:
choices = []
for key in fresh:
if key in returning:
continue
is_normal = key.endswith("normal")
if is_normal and not getattr(cfg, "release_normal_onset_enabled", True):
continue
if (not is_normal
and not getattr(cfg, "release_shear_onset_enabled", True)):
continue
onset = self._curves[key].onset
if onset is None or not onset.significant_at(self._previous[key][0], values[key]):
continue
rate_limit = cfg.release_normal_rate if is_normal else cfg.release_shear_rate
weak_limit = cfg.release_normal_change if is_normal else cfg.release_shear_change
delta = values[key] - onset.baseline_at(self._previous[key][0])
rate = rates.get(key, 0.0)
kind = None
origin = onset.origin
measured = (values[key] - origin) * onset.direction
if (abs(rate) >= rate_limit and abs(delta) >= cfg.release_min_change
and measured + 1e-9 >= cfg.release_min_change and rate * delta > 0):
kind, needed, floor = "sudden", max(2, cfg.release_confirm_samples), cfg.release_min_change
elif (abs(delta) + 1e-9 >= cfg.release_min_change
and measured + 1e-9 >= cfg.release_min_change and delta * onset.direction > 0):
kind, needed, floor = "touch", max(2, cfg.release_confirm_samples), cfg.release_min_change
elif abs(delta) >= weak_limit and measured >= weak_limit:
kind = "slow_external"
needed = max(2, cfg.release_confirm_samples, cfg.release_slow_confirm_samples)
floor = weak_limit
if kind:
choices.append((kind == "sudden", abs(delta), key, kind, needed, floor, delta, origin, onset))
if choices:
_, _, key, kind, needed, floor, delta, origin, onset = max(choices)
candidate = _Candidate(kind, key, origin,
1.0 if delta > 0 else -1.0, abs(delta), now, needed, floor,
onset=onset, last_displacement=abs(delta),
last_sample_time=self._previous[key][0])
self._candidate = candidate
self.phase = ReleasePhase.CONFIRMING
if candidate is not None:
released = candidate.count >= candidate.needed
if released:
self.phase = ReleasePhase.RELEASED
return ReleaseDecision(self.phase, candidate.kind, release=released,
confirm_count=candidate.count,
confirm_needed=candidate.needed, channel=candidate.channel,
innovation=candidate.onset.innovation,
**dict(metrics, curve_motion="onset_confirm"))
# Follow curve continuation, including large smooth turns. Displacement
# without a new onset must never become a weak-path release later.
for key in fresh:
if self._curves[key].onset is None:
self._reference[key] = values[key]
observation = max(0.1, max(2, cfg.release_confirm_samples) / max(1.0, cfg.control_hz))
# Ordinary noise must not prevent clamp force from reaching its target.
# Meaningful coherent movement reserves the next frame for release.
touch_pending = any(
self._curves[key].onset is not None
and abs(values[key] - self._curves[key].onset.baseline_at(self._previous[key][0])) >= cfg.release_min_change * 0.5
for key in fresh
)
allow_force = (not touch_pending and self._watch_since is not None
and now - self._watch_since >= observation)
reason = "settling-turn" if metrics["curve_motion"] == "settling_turn" else "slow_internal" if quiet else "watch"
return ReleaseDecision(self.phase, reason,
allow_force_update=allow_force, **metrics)
def _cancel(self, now, values, reason, metrics, *, reset_side=True):
# Wait for the rejected pulse's tail; otherwise its falling edge can
# become a fresh negative-direction candidate and release on rebound.
self._recovery_until = now + self.config.release_settle_seconds
if self._candidate is not None:
candidate = self._candidate
baseline = candidate.onset.baseline_at(self._previous[candidate.channel][0])
remaining = (values[candidate.channel] - baseline) * candidate.direction
self._tails[candidate.channel] = _Rebound(baseline, candidate.direction, remaining)
if reset_side:
self._side_touch.reset()
self._side_touch.recover_until = self._recovery_until
return self._protect(reason, values, **metrics)
+124
View File
@@ -0,0 +1,124 @@
"""Session recorder for grip/release tuning.
Writes one JSON object per control tick (JSONL). Enable from UI or config; keep
I/O off the hot path when disabled.
"""
from __future__ import annotations
import json
import threading
import time
from dataclasses import asdict, is_dataclass
from datetime import datetime
from pathlib import Path
def _jsonable(value):
if value is None or isinstance(value, (bool, int, float, str)):
return value
if is_dataclass(value):
return {key: _jsonable(item) for key, item in asdict(value).items()}
if isinstance(value, dict):
return {str(key): _jsonable(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [_jsonable(item) for item in value]
if hasattr(value, "value") and not callable(value):
# Enum-like
try:
return value.value
except Exception:
pass
return str(value)
class SessionRecorder:
def __init__(self, root: Path):
self.root = Path(root)
self._lock = threading.Lock()
self._enabled = False
self._path = None
self._file = None
self._frames = 0
self._started_at = None
self._meta = {}
@property
def enabled(self) -> bool:
with self._lock:
return self._enabled
@property
def path(self):
with self._lock:
return self._path
@property
def frames(self) -> int:
with self._lock:
return self._frames
def start(self, meta=None) -> Path:
with self._lock:
if self._enabled:
return self._path
self.root.mkdir(parents=True, exist_ok=True)
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
path = self.root / f"session-{stamp}.jsonl"
handle = path.open("w", encoding="utf-8")
header = {
"type": "session_header",
"started_at": time.time(),
"started_at_iso": datetime.now().isoformat(timespec="seconds"),
"meta": _jsonable(meta or {}),
}
handle.write(json.dumps(header, ensure_ascii=False) + "\n")
handle.flush()
self._file = handle
self._path = path
self._enabled = True
self._frames = 0
self._started_at = time.perf_counter()
self._meta = dict(meta or {})
print(f"[session-record] start {path}")
return path
def stop(self) -> Path | None:
with self._lock:
if not self._enabled:
return None
path = self._path
frames = self._frames
started = self._started_at
try:
footer = {
"type": "session_footer",
"stopped_at": time.time(),
"frames": frames,
"duration_s": (
None if started is None else time.perf_counter() - started
),
}
if self._file is not None:
self._file.write(json.dumps(footer, ensure_ascii=False) + "\n")
self._file.flush()
self._file.close()
finally:
self._file = None
self._enabled = False
self._path = None
self._frames = 0
self._started_at = None
print(f"[session-record] stop {path} frames={frames}")
return path
def record(self, frame: dict) -> None:
with self._lock:
if not self._enabled or self._file is None:
return
payload = {"type": "tick", **_jsonable(frame)}
self._file.write(json.dumps(payload, ensure_ascii=False) + "\n")
self._frames += 1
# Flush periodically so a crash still keeps most of the capture.
if self._frames % 30 == 0:
self._file.flush()
+11 -1
View File
@@ -32,7 +32,8 @@ class CameraInfo:
def label(self):
device = self.source if sys.platform.startswith("linux") else f"相机 {self.index}"
return f"{device} {self.width}x{self.height} [{self.backend_name}]"
resolution = f" {self.width}x{self.height}" if self.width and self.height else ""
return f"{device}{resolution} [{self.backend_name}]"
@contextlib.contextmanager
@@ -112,6 +113,15 @@ def _candidate_indices(max_index):
return list(range(max_index + 1))
def list_camera_ids(max_index=DEFAULT_MAX_CAMERA_INDEX):
"""List selectable camera IDs without opening devices or reading frames."""
backend_name, backend_api = _backend_candidates()[0]
return [
CameraInfo(index, backend_name, backend_api, 0, 0)
for index in _candidate_indices(max_index)
]
def _try_open(index, backend_name, backend_api):
capture = cv2.VideoCapture(index, backend_api)
try:
+489 -37
View File
@@ -98,7 +98,7 @@ else:
from ..app import build_controller
from ..common.paths import PACKAGE_DIR, REPO_ROOT
from ..sensors.camera_discovery import camera_source_index, list_cameras
from ..sensors.camera_discovery import list_camera_ids
PLOT_POINTS = 180
@@ -208,8 +208,21 @@ ACTION_TEXT = {
"hold-reference-armed": "记录夹持基准",
"adaptive-grip-ramp": "自适应加力",
"adaptive-grip-wait": "等待自适应加力",
"release-arm-delay": "释放检测延时",
"release-reference-armed": "释放检测就绪",
"release-grip-settle": "法向夹稳保护,侧触可释放",
"release-self-settle": "自运动保护,侧触可释放",
"release-ready": "释放检测就绪",
"release-sudden": "突变外力确认",
"release-touch": "轻触变化确认",
"release-side_touch": "即时侧触确认",
"release-slow_external": "缓推外力确认",
"release-slow_internal": "慢变跟随",
"release-watch": "监视外力变化",
"release-waiting-sample": "等待新传感器帧",
"release-sample-gap": "采样断流保护",
"release-invalid-sample": "传感器数据无效",
"release-transient-rejected": "短脉冲已拒绝",
"release-settling-turn": "平稳拐点,继续夹持",
"release-confirm-expired": "外力确认已过期",
"hold-check": "保持检测",
"force-change-release-open": "力变化张开",
"open-recover-position": "张开位置恢复",
@@ -238,7 +251,7 @@ def label_with_cn(value, mapping):
cn = mapping.get(value)
if cn is None:
for prefix, text in mapping.items():
if value.startswith(f"{prefix} "):
if value.startswith((f"{prefix} ", f"{prefix}(")):
cn = text
break
if cn is None:
@@ -2282,18 +2295,14 @@ class CameraDiscoveryDialog(QDialog):
self._scan_id += 1
scan_id = self._scan_id
self._scan_result = None
self.status_label.setText("正在快速搜索摄像头(编号 0-3)...")
self.status_label.setText("正在读取摄像头 ID...")
current_sources = self.viewer.camera_sources()
skip_indices = set()
for side, combo in self.camera_combos.items():
combo.clear()
current = current_sources.get(side, "")
if current:
combo.addItem(f"当前配置 {current}(可能正在使用)", current)
combo.setEnabled(True)
current_index = camera_source_index(current)
if current_index is not None:
skip_indices.add(current_index)
else:
combo.setEnabled(False)
self.refresh_button.setEnabled(False)
@@ -2305,7 +2314,7 @@ class CameraDiscoveryDialog(QDialog):
try:
self._scan_result = (
scan_id,
list_cameras(skip_indices=skip_indices),
list_camera_ids(),
None,
)
except Exception as exc:
@@ -2339,7 +2348,7 @@ class CameraDiscoveryDialog(QDialog):
if error:
self.status_label.setText(f"搜索失败:{error}")
elif cameras:
self.status_label.setText(f"找到 {len(cameras)} 个可用摄像头")
self.status_label.setText(f"已列出 {len(cameras)} 个摄像头 ID(未占用设备)")
else:
self.status_label.setText("未发现空闲摄像头;正在使用的当前配置仍可保留")
has_both = all(combo.count() > 0 for combo in self.camera_combos.values())
@@ -2358,6 +2367,12 @@ class CameraDiscoveryDialog(QDialog):
class VisualSettingsDialog(QDialog):
CONFIG_GROUPS = [
(
"行为模式",
[
"grip_behavior_mode",
],
),
(
"相机 / 传感器",
[
@@ -2440,8 +2455,31 @@ class VisualSettingsDialog(QDialog):
"adaptive_force_step",
"adaptive_force_interval",
"release_arm_delay_seconds",
"release_min_hold_seconds",
"release_settle_seconds",
"release_min_change",
"release_touch_window_seconds",
"release_side_touch_change",
"release_confirm_window_seconds",
"release_sample_timeout_seconds",
"release_confirm_samples",
"release_slow_confirm_samples",
"release_normal_rate",
"release_shear_rate",
"release_slow_rate",
"release_normal_change",
"release_shear_change",
"release_curve_history",
"release_slope_window_seconds",
"release_slope_settle_rate",
"release_slope_min_points",
"release_require_slope_settle",
"release_slope_hold_seconds",
"release_settle_normal_only",
"release_side_shear_enabled",
"release_side_normal_enabled",
"release_shear_onset_enabled",
"release_normal_onset_enabled",
],
),
(
@@ -2470,7 +2508,7 @@ class VisualSettingsDialog(QDialog):
"motion_threshold": "运动阈值",
"sensor_cpu": "传感器 CPU 模式",
"port": "串口号",
"slave_id": "从站 ID",
"slave_id": "夹爪从站 ID",
"baudrate": "波特率",
"serial_timeout": "串口超时",
"open_pos": "张开位置",
@@ -2497,7 +2535,7 @@ class VisualSettingsDialog(QDialog):
"trigger_force_update": "触发力更新",
"trigger_normal_force": "法向触发阈值",
"trigger_shear_force": "切向触发阈值",
"trigger_clear_stable_seconds": "触发清除稳定时间",
"trigger_clear_stable_seconds": "旧触发清除时间(当前未使用)",
"open_wait_stable_samples": "等待稳定样本数",
"open_wait_stable_range_n": "等待稳定范围",
"open_wait_stable_trend_n": "等待稳定趋势",
@@ -2508,6 +2546,7 @@ class VisualSettingsDialog(QDialog):
"force_ramp_step": "加力步进",
"force_ramp_interval": "加力间隔",
"grip_settle_seconds": "夹持稳定时间",
"grip_behavior_mode": "夹持行为模式",
"adaptive_grip_enabled": "启用自适应夹持",
"adaptive_shear_start_force": "自适应起始切向力",
"adaptive_shear_full_force": "自适应满量程切向力",
@@ -2515,9 +2554,32 @@ class VisualSettingsDialog(QDialog):
"adaptive_force_max": "自适应最大力",
"adaptive_force_step": "自适应加力步进",
"adaptive_force_interval": "自适应加力间隔",
"release_arm_delay_seconds": "释放检测延时",
"release_normal_change": "释放法向变化",
"release_shear_change": "释放切向变化",
"release_arm_delay_seconds": "自运动执行后保护时间 (s)",
"release_min_hold_seconds": "接触法向保护 (s,侧触不等待)",
"release_settle_seconds": "脉冲/断流恢复保护上限 (s)",
"release_min_change": "突变/轻触释放最小力变化",
"release_touch_window_seconds": "新外力起始证据有效期 (s,非等待时间)",
"release_side_touch_change": "即时侧触左右力差变化",
"release_confirm_window_seconds": "释放确认有效窗口 (s)",
"release_sample_timeout_seconds": "释放采样断流保护 (s)",
"release_confirm_samples": "突变释放确认帧数",
"release_slow_confirm_samples": "缓推外力确认新帧数",
"release_normal_rate": "释放法向突变速率",
"release_shear_rate": "释放切向突变速率",
"release_slow_rate": "慢变日志分类速率 (N/s)",
"release_normal_change": "慢外力法向阈值",
"release_shear_change": "慢外力切向阈值",
"release_curve_history": "力曲线历史点数(稳定模式宜更长)",
"release_slope_window_seconds": "夹稳斜率估计窗口 (s)",
"release_slope_settle_rate": "夹稳斜率门限 (N/s,趋近 0)",
"release_slope_min_points": "判定夹稳前最少历史点数",
"release_require_slope_settle": "释放前要求法向斜率趋零(夹持稳定)",
"release_slope_hold_seconds": "斜率趋零后还需保持多久才算夹稳 (s)",
"release_settle_normal_only": "夹稳判定仅看法向(忽略切向)",
"release_side_shear_enabled": "允许切向左右差侧触张开",
"release_side_normal_enabled": "允许法向左右差侧触张开",
"release_shear_onset_enabled": "允许切向通道触发普通释放",
"release_normal_onset_enabled": "允许法向通道触发普通释放",
"rearm_seconds": "重新武装休息时间",
"recover_normal_force": "恢复法向阈值",
"recover_shear_force": "恢复切向阈值",
@@ -2571,6 +2633,8 @@ class VisualSettingsDialog(QDialog):
self.config_data = {}
except Exception:
self.config_data = {}
if "grip_behavior_mode" not in self.config_data:
self.config_data["grip_behavior_mode"] = "stable"
def _build_ui(self):
outer = QVBoxLayout(self)
@@ -2748,6 +2812,29 @@ class VisualSettingsDialog(QDialog):
return scroll
def _make_control(self, key, value):
if key == "grip_behavior_mode":
from gripper.config.behavior_profiles import (
GRIP_BEHAVIOR_LABELS,
GRIP_BEHAVIOR_MODES,
normalize_grip_behavior_mode,
)
control = QComboBox()
try:
current = normalize_grip_behavior_mode(value)
except Exception:
current = "stable"
for mode in GRIP_BEHAVIOR_MODES:
control.addItem(f"{GRIP_BEHAVIOR_LABELS[mode]} ({mode})", mode)
index = control.findData(current)
control.setCurrentIndex(max(0, index))
control.setToolTip(
"迅速开合:极端跟手释放/再武装;夹持稳定:极端防误开、优先夹得住。"
"切换后会立刻填入对应预设参数。"
)
control.currentIndexChanged.connect(self._on_behavior_mode_changed)
self.controls[key] = control
return control
if key == "port":
container = QWidget()
layout = QHBoxLayout(container)
@@ -2763,7 +2850,11 @@ class VisualSettingsDialog(QDialog):
layout.addWidget(search_button)
self.controls[key] = control
return container
if isinstance(value, bool):
if key == "slave_id":
control = QSpinBox()
control.setRange(1, 247)
control.setValue(max(1, min(247, int(value))))
elif isinstance(value, bool):
control = QCheckBox()
control.setChecked(value)
elif isinstance(value, (dict, list)):
@@ -2785,6 +2876,8 @@ class VisualSettingsDialog(QDialog):
if control.currentIndex() >= 0 and selected is not None:
return str(selected)
return control.currentText().strip()
if isinstance(control, QSpinBox):
return int(control.value())
if isinstance(original, bool):
return bool(control.isChecked())
if isinstance(original, (dict, list)):
@@ -2809,6 +2902,8 @@ class VisualSettingsDialog(QDialog):
return
if isinstance(control, QCheckBox):
control.setChecked(bool(value))
elif isinstance(control, QSpinBox):
control.setValue(int(value))
elif isinstance(control, QComboBox):
text = "" if value is None else str(value)
index = control.findData(text)
@@ -2821,6 +2916,26 @@ class VisualSettingsDialog(QDialog):
elif isinstance(control, QLineEdit):
control.setText("null" if value is None else str(value))
def _on_behavior_mode_changed(self, _index=None):
control = self.controls.get("grip_behavior_mode")
if not isinstance(control, QComboBox):
return
mode = control.currentData()
if not mode:
return
from gripper.config.behavior_profiles import grip_behavior_profile
try:
profile = grip_behavior_profile(mode)
except Exception as exc:
QMessageBox.warning(self, "模式无效", str(exc))
return
for key, value in profile.items():
if key in self.controls:
self._set_control_value(key, value)
self.config_data[key] = value
self.config_data["grip_behavior_mode"] = mode
def _search_serial_ports(self):
control = self.controls.get("port")
if not isinstance(control, QComboBox):
@@ -2830,7 +2945,15 @@ class VisualSettingsDialog(QDialog):
QMessageBox.warning(self, "搜索失败", "PySerial 串口枚举模块未安装。")
return
try:
ports = list(serial_list_ports.comports())
ports = sorted(
serial_list_ports.comports(),
key=lambda port: (
0
if "USB" in f"{getattr(port, 'description', '')} {getattr(port, 'hwid', '')}".upper()
else 1,
str(port.device),
),
)
except Exception as exc:
QMessageBox.warning(self, "搜索失败", f"无法枚举串口:\n{exc}")
return
@@ -2845,13 +2968,15 @@ class VisualSettingsDialog(QDialog):
selected_index = control.count() - 1
if current and selected_index < 0:
control.addItem(f"{current} [当前配置]", current)
selected_index = control.count() - 1
if selected_index < 0 and ports:
selected_index = 0
if selected_index >= 0:
control.setCurrentIndex(selected_index)
if not ports:
QMessageBox.information(self, "搜索串口", "未发现可用串口,已保留当前配置。")
return
control.showPopup()
control.setToolTip(f"找到 {len(ports)} 个串口")
QTimer.singleShot(50, control.showPopup)
def _reload(self):
self._load_config()
@@ -2884,10 +3009,19 @@ class VisualSettingsDialog(QDialog):
return
self.config_data = updated
self.viewer.sync_main_device_controls(
updated.get("port", ""),
updated.get("slave_id", 1),
)
applied_live = self.viewer.apply_runtime_config(updated)
if applied_live:
tip = "配置已写入,并已对当前运行中的控制即时生效。"
else:
tip = "配置已写入。若控制未运行,启动后将使用新参数。"
QMessageBox.information(
self,
"已保存",
f"配置已写入 gripper.json,画面旋转已写入:\n{layout_path}\n重启控制程序后完整生效。",
f"{tip}\n画面旋转已写入:\n{layout_path}",
)
def _adjust(self, side, action):
@@ -3148,11 +3282,13 @@ class GripperDemo02Viewer(QMainWindow):
dialog.show()
dialog.raise_()
def _update_camera_argv(self, config_key, source):
def _update_config_argv(self, config_key, source):
args = list(self.argv or [])
option_names = {
"video1": ("--video1", "-v1"),
"video2": ("--video2", "-v2"),
"port": ("--port",),
"slave_id": ("--slave-id",),
}[config_key]
for index, arg in enumerate(args):
if arg in option_names and index + 1 < len(args):
@@ -3196,8 +3332,8 @@ class GripperDemo02Viewer(QMainWindow):
QMessageBox.warning(self, "保存失败", f"无法保存摄像头配置:\n{exc}")
return False
self._update_camera_argv("video1", left_source)
self._update_camera_argv("video2", right_source)
self._update_config_argv("video1", left_source)
self._update_config_argv("video2", right_source)
if self.visual_settings_dialog is not None:
for config_key, source in (("video1", left_source), ("video2", right_source)):
self.visual_settings_dialog.config_data[config_key] = source
@@ -3210,6 +3346,120 @@ class GripperDemo02Viewer(QMainWindow):
self.restart_control()
return True
def _load_serial_device_config(self):
port = ""
slave_id = 1
try:
with open(self._control_config_path(), "r", encoding="utf-8") as f:
data = json.load(f)
port = str(data.get("port", port))
slave_id = int(data.get("slave_id", slave_id))
except Exception:
pass
return port, max(1, min(247, slave_id))
def sync_main_device_controls(self, port, slave_id):
if not hasattr(self, "main_port_combo"):
return
port = str(port)
index = self.main_port_combo.findData(port)
if index < 0:
self.main_port_combo.addItem(port, port)
index = self.main_port_combo.count() - 1
self.main_port_combo.setCurrentIndex(index)
self.main_slave_id_spin.setValue(max(1, min(247, int(slave_id))))
@staticmethod
def _combo_source_value(combo):
selected = combo.currentData()
if combo.currentIndex() >= 0 and selected is not None:
return str(selected)
return combo.currentText().strip()
def search_main_serial_ports(self):
current = self._combo_source_value(self.main_port_combo)
if serial_list_ports is None:
QMessageBox.warning(self, "搜索失败", "PySerial 串口枚举模块未安装。")
return
try:
ports = sorted(
serial_list_ports.comports(),
key=lambda port: (
0
if "USB" in f"{getattr(port, 'description', '')} {getattr(port, 'hwid', '')}".upper()
else 1,
str(port.device),
),
)
except Exception as exc:
QMessageBox.warning(self, "搜索失败", f"无法枚举串口:\n{exc}")
return
self.main_port_combo.clear()
selected_index = -1
for port in ports:
device = str(port.device)
description = str(port.description or "未知设备")
self.main_port_combo.addItem(f"{device} [{description}]", device)
if device == current:
selected_index = self.main_port_combo.count() - 1
if current and selected_index < 0:
self.main_port_combo.addItem(f"{current} [当前配置]", current)
if selected_index < 0 and ports:
selected_index = 0
if selected_index >= 0:
self.main_port_combo.setCurrentIndex(selected_index)
if not ports:
QMessageBox.information(self, "搜索串口", "未发现可用串口,已保留当前配置。")
return
self.main_port_combo.setToolTip(f"找到 {len(ports)} 个串口")
QTimer.singleShot(50, self.main_port_combo.showPopup)
def apply_main_device_config(self):
port = self._combo_source_value(self.main_port_combo)
slave_id = int(self.main_slave_id_spin.value())
if not port:
QMessageBox.warning(self, "配置无效", "请选择或输入夹爪串口。")
return False
config_path = self._control_config_path()
temp_path = config_path.with_suffix(config_path.suffix + ".tmp")
try:
with open(config_path, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
raise ValueError("配置文件根节点不是 JSON 对象")
if str(data.get("port", "")) == port and int(data.get("slave_id", 1)) == slave_id:
return True
data["port"] = port
data["slave_id"] = slave_id
with open(temp_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
f.write("\n")
os.replace(temp_path, config_path)
except Exception as exc:
try:
if temp_path.exists():
temp_path.unlink()
except OSError:
pass
QMessageBox.warning(self, "保存失败", f"无法保存夹爪设备配置:\n{exc}")
return False
self._update_config_argv("port", port)
self._update_config_argv("slave_id", str(slave_id))
if self.visual_settings_dialog is not None:
for config_key, value in (("port", port), ("slave_id", slave_id)):
self.visual_settings_dialog.config_data[config_key] = value
self.visual_settings_dialog._set_control_value(config_key, value)
QMessageBox.information(
self,
"夹爪设备已更新",
f"串口: {port}\n夹爪从站 ID: {slave_id}\n控制程序将自动重启。",
)
self.restart_control()
return True
def _load_position_config(self):
open_pos = 0
close_pos = 9000
@@ -3377,6 +3627,36 @@ class GripperDemo02Viewer(QMainWindow):
self.smart_mode_button.setChecked(True)
self.smart_mode_button.clicked.connect(lambda: self.set_control_mode("smart"))
self.manual_mode_button.clicked.connect(lambda: self.set_control_mode("manual"))
self.rapid_behavior_button = AnimatedButton("迅速开合")
self.rapid_behavior_button.setObjectName("ControlModeButton")
self.rapid_behavior_button.setCheckable(True)
self.rapid_behavior_button.setToolTip("极端跟手:接触后尽快可释放,优先开合速度")
self.stable_behavior_button = AnimatedButton("夹持稳定")
self.stable_behavior_button.setObjectName("ControlModeButton")
self.stable_behavior_button.setCheckable(True)
self.stable_behavior_button.setToolTip("极端稳夹:加长保护与确认,优先防误开")
self.behavior_button_group = QButtonGroup(self)
self.behavior_button_group.setExclusive(True)
self.behavior_button_group.addButton(self.rapid_behavior_button)
self.behavior_button_group.addButton(self.stable_behavior_button)
self.stable_behavior_button.setChecked(True)
self.rapid_behavior_button.clicked.connect(
lambda: self.set_grip_behavior_mode("rapid")
)
self.stable_behavior_button.clicked.connect(
lambda: self.set_grip_behavior_mode("stable")
)
self._sync_behavior_buttons(self._load_grip_behavior_mode())
self.record_button = AnimatedButton("录制数据")
self.record_button.setObjectName("ControlModeButton")
self.record_button.setCheckable(True)
self.record_button.setToolTip(
"录制控制过程中的力/状态/释放判定到 logs/sessions/*.jsonl,便于后续优化张开检测"
)
self.record_button.clicked.connect(self.toggle_session_recording)
open_pos, close_pos, position_min, position_max = self._load_position_config()
self.gripper_open_pos = open_pos
self.gripper_close_pos = close_pos
@@ -3390,10 +3670,13 @@ class GripperDemo02Viewer(QMainWindow):
self.position_control.slider.sliderReleased.connect(self.move_to_slider_position)
self.position_control.minus_button.clicked.connect(self.move_to_slider_position)
self.position_control.plus_button.clicked.connect(self.move_to_slider_position)
gripper_layout.addWidget(gripper_title, 0, 0, 1, 2, Qt.AlignLeft | Qt.AlignTop)
gripper_layout.addWidget(gripper_title, 0, 0, 1, 1, Qt.AlignLeft | Qt.AlignTop)
gripper_layout.addWidget(self.rapid_behavior_button, 0, 1)
gripper_layout.addWidget(self.stable_behavior_button, 0, 2)
gripper_layout.addWidget(self.manual_mode_button, 0, 3)
gripper_layout.addWidget(self.smart_mode_button, 0, 4)
gripper_layout.addWidget(self.speed_control, 1, 0, 1, 4)
gripper_layout.addWidget(self.speed_control, 1, 0, 1, 3)
gripper_layout.addWidget(self.record_button, 1, 3)
gripper_layout.addWidget(self.force_control, 2, 0, 1, 4)
gripper_layout.addWidget(self.position_control, 3, 0, 1, 4)
gripper_layout.addWidget(self.manual_hold_button, 1, 4)
@@ -3418,6 +3701,29 @@ class GripperDemo02Viewer(QMainWindow):
sensor_title.setObjectName("SectionTitle")
sensor_title.setAlignment(Qt.AlignLeft | Qt.AlignTop)
sensor_panel.set_drag_handle(sensor_title)
port, slave_id = self._load_serial_device_config()
port_label = QLabel("串口")
port_label.setObjectName("MetricLabel")
self.main_port_combo = QComboBox()
self.main_port_combo.setEditable(True)
self.main_port_combo.addItem(port, port)
self.main_port_combo.setMinimumWidth(96)
self.main_port_combo.setToolTip("夹爪串口")
self.main_port_search_button = TopIconButton("search", "搜索夹爪串口")
self.main_port_search_button.setFixedSize(26, 26)
self.main_port_search_button.setIconSize(QSize(18, 18))
self.main_port_search_button.clicked.connect(self.search_main_serial_ports)
slave_id_label = QLabel("夹爪从站 ID")
slave_id_label.setObjectName("MetricLabel")
self.main_slave_id_spin = QSpinBox()
self.main_slave_id_spin.setRange(1, 247)
self.main_slave_id_spin.setValue(slave_id)
self.main_slave_id_spin.setToolTip("Modbus 从站 ID(1-247)")
self.main_slave_id_spin.setMaximumWidth(72)
self.main_device_apply_button = AnimatedButton("应用")
self.main_device_apply_button.setObjectName("ManualButton")
self.main_device_apply_button.setToolTip("保存串口和夹爪从站 ID,并重启控制程序")
self.main_device_apply_button.clicked.connect(self.apply_main_device_config)
self.left_sensor_reset_button = TopIconButton("restart", "重启左传感器")
self.left_sensor_reset_button.setEnabled(False)
self.left_sensor_reset_button.clicked.connect(
@@ -3429,10 +3735,16 @@ class GripperDemo02Viewer(QMainWindow):
lambda: self.send_manual_command("restart_right_sensor")
)
sensor_layout.addWidget(sensor_title, 0, 0, 1, 8, Qt.AlignLeft | Qt.AlignTop)
sensor_layout.addWidget(port_label, 1, 0)
sensor_layout.addWidget(self.main_port_combo, 1, 1, 1, 2)
sensor_layout.addWidget(self.main_port_search_button, 1, 3)
sensor_layout.addWidget(slave_id_label, 1, 4)
sensor_layout.addWidget(self.main_slave_id_spin, 1, 5)
sensor_layout.addWidget(self.main_device_apply_button, 1, 6, 1, 2)
self.left_sensor_dot = StatusDot("左传感器", self.left_sensor_reset_button)
self.right_sensor_dot = StatusDot("右传感器", self.right_sensor_reset_button)
sensor_layout.addWidget(self.left_sensor_dot, 1, 0, 1, 4)
sensor_layout.addWidget(self.right_sensor_dot, 1, 4, 1, 4)
sensor_layout.addWidget(self.left_sensor_dot, 2, 0, 1, 4)
sensor_layout.addWidget(self.right_sensor_dot, 2, 4, 1, 4)
self.left_force = MetricBox("左法向力", "--")
self.right_force = MetricBox("右法向力", "--")
self.left_shear_box = MetricBox("左切向力", "--")
@@ -3440,18 +3752,18 @@ class GripperDemo02Viewer(QMainWindow):
self.force_pct_box = MetricBox("夹爪当前力比例(%)", "--")
self.target_force_box = MetricBox("夹爪目标力比例(%)", "--")
self.speed_box = MetricBox("速度", "--")
sensor_layout.addWidget(self.left_force, 2, 0, 1, 2)
sensor_layout.addWidget(self.right_force, 2, 2, 1, 2)
sensor_layout.addWidget(self.left_shear_box, 2, 4, 1, 2)
sensor_layout.addWidget(self.right_shear_box, 2, 6, 1, 2)
sensor_layout.addWidget(self.force_pct_box, 3, 0, 1, 3)
sensor_layout.addWidget(self.target_force_box, 3, 3, 1, 3)
sensor_layout.addWidget(self.speed_box, 3, 6, 1, 2)
sensor_layout.addWidget(self.left_force, 3, 0, 1, 2)
sensor_layout.addWidget(self.right_force, 3, 2, 1, 2)
sensor_layout.addWidget(self.left_shear_box, 3, 4, 1, 2)
sensor_layout.addWidget(self.right_shear_box, 3, 6, 1, 2)
sensor_layout.addWidget(self.force_pct_box, 4, 0, 1, 3)
sensor_layout.addWidget(self.target_force_box, 4, 3, 1, 3)
sensor_layout.addWidget(self.speed_box, 4, 6, 1, 2)
for col in range(8):
sensor_layout.setColumnStretch(col, 1)
for row in range(4):
for row in range(5):
sensor_layout.setRowStretch(row, 0)
sensor_layout.setRowStretch(4, 1)
sensor_layout.setRowStretch(5, 1)
self._set_button_state(self.button_state)
self.sensor_control_panel = sensor_panel
self.gripper_stage.add_floating_control_panel(plot_panel, "plot")
@@ -3620,6 +3932,20 @@ class GripperDemo02Viewer(QMainWindow):
self.manual_mode_button.setEnabled(mode_enabled)
if hasattr(self, "smart_mode_button"):
self.smart_mode_button.setEnabled(mode_enabled)
if hasattr(self, "rapid_behavior_button"):
self.rapid_behavior_button.setEnabled(True)
if hasattr(self, "stable_behavior_button"):
self.stable_behavior_button.setEnabled(True)
if hasattr(self, "record_button"):
recording = (
self.controller is not None
and getattr(self.controller, "session_recording", False)
)
self.record_button.setEnabled(state == "running")
self.record_button.blockSignals(True)
self.record_button.setChecked(bool(recording))
self.record_button.setText("录制中…" if recording else "录制数据")
self.record_button.blockSignals(False)
if hasattr(self, "left_sensor_reset_button"):
self.left_sensor_reset_button.setEnabled(sensor_restart_enabled)
if hasattr(self, "right_sensor_reset_button"):
@@ -3644,6 +3970,130 @@ class GripperDemo02Viewer(QMainWindow):
self.manual_mode_button.blockSignals(False)
self._set_button_state(self.button_state)
def _load_grip_behavior_mode(self):
from gripper.config.behavior_profiles import normalize_grip_behavior_mode
path = REPO_ROOT / "config" / "gripper.json"
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
return normalize_grip_behavior_mode(data.get("grip_behavior_mode", "stable"))
except Exception:
return "stable"
def _sync_behavior_buttons(self, mode):
mode = "rapid" if mode == "rapid" else "stable"
if hasattr(self, "rapid_behavior_button"):
self.rapid_behavior_button.blockSignals(True)
self.stable_behavior_button.blockSignals(True)
self.rapid_behavior_button.setChecked(mode == "rapid")
self.stable_behavior_button.setChecked(mode == "stable")
self.rapid_behavior_button.blockSignals(False)
self.stable_behavior_button.blockSignals(False)
def set_grip_behavior_mode(self, mode, persist=True):
from gripper.config.behavior_profiles import (
apply_grip_behavior_profile,
normalize_grip_behavior_mode,
)
try:
mode = normalize_grip_behavior_mode(mode)
except Exception as exc:
QMessageBox.warning(self, "模式无效", str(exc))
return None
self._sync_behavior_buttons(mode)
if self.controller is not None:
try:
self.controller.set_grip_behavior_mode(mode)
except Exception as exc:
print(f"set_grip_behavior_mode failed: {exc}")
if persist:
path = REPO_ROOT / "config" / "gripper.json"
try:
with open(path, "r", encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
data = {}
apply_grip_behavior_profile(data, mode)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
f.write("\n")
except Exception as exc:
print(f"persist grip_behavior_mode failed: {exc}")
dialog = getattr(self, "visual_settings_dialog", None)
if dialog is not None and hasattr(dialog, "controls"):
if "grip_behavior_mode" in dialog.controls:
dialog.controls["grip_behavior_mode"].blockSignals(True)
dialog._set_control_value("grip_behavior_mode", mode)
dialog.controls["grip_behavior_mode"].blockSignals(False)
dialog._on_behavior_mode_changed()
return mode
def toggle_session_recording(self):
if self.controller is None:
self.record_button.blockSignals(True)
self.record_button.setChecked(False)
self.record_button.blockSignals(False)
QMessageBox.information(self, "录制数据", "请先启动控制程序再录制。")
return
try:
if self.controller.session_recording:
path = self.controller.stop_session_recording()
self.record_button.setText("录制数据")
self.record_button.setChecked(False)
if path is not None:
QMessageBox.information(
self,
"录制已停止",
f"会话数据已保存到:\n{path}",
)
else:
path = self.controller.start_session_recording(note="ui")
self.record_button.setText("录制中…")
self.record_button.setChecked(True)
QMessageBox.information(
self,
"开始录制",
f"正在写入:\n{path}\n再点一次按钮结束录制。",
)
except Exception as exc:
self.record_button.blockSignals(True)
self.record_button.setChecked(False)
self.record_button.setText("录制数据")
self.record_button.blockSignals(False)
QMessageBox.warning(self, "录制失败", str(exc))
def apply_runtime_config(self, updated):
"""Push saved JSON fields into the live controller config when possible."""
if self.controller is None or not hasattr(self.controller, "config"):
return False
config = self.controller.config
mode = updated.get("grip_behavior_mode")
if mode:
try:
self.controller.set_grip_behavior_mode(mode)
self._sync_behavior_buttons(mode)
except Exception as exc:
print(f"apply grip_behavior_mode failed: {exc}")
for key, value in updated.items():
if key == "grip_behavior_mode":
continue
if hasattr(config, key):
try:
setattr(config, key, value)
except Exception:
pass
try:
self.controller.feedback_processor.reset_filters()
except Exception:
pass
return True
def open_visual_settings(self):
if self.visual_settings_dialog is None:
self.visual_settings_dialog = VisualSettingsDialog(self)
@@ -3804,6 +4254,8 @@ class GripperDemo02Viewer(QMainWindow):
)
try:
self.controller.set_control_mode(self.control_mode)
behavior = getattr(self.controller.config, "grip_behavior_mode", "stable")
self._sync_behavior_buttons(behavior)
except Exception as exc:
print(f"control mode init failed: {exc}")
self.control_thread = threading.Thread(