开合控制调整:切向触发只看 Y 轴,三挡位只控制释放、触发固定灵敏参数;自适应负载改看切向 X;恢复原灵敏档张开逻辑与排查前夹持力参数。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
cheng
2026-09-28 15:36:05 +08:00
parent c064cdb191
commit 6393be9518
11 changed files with 1091 additions and 501 deletions
+21 -7
View File
@@ -26,7 +26,7 @@ _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):
def __init__(self, gripper, position_poll_hz: float = 30.0):
self.gripper = gripper
self.position_poll_hz = max(1.0, float(position_poll_hz))
self._stop_event = threading.Event()
@@ -40,6 +40,10 @@ class ActuatorWorker:
self._motion_completed_at: Optional[float] = None
self._wake = threading.Event()
self._busy = threading.Event()
# After a motion command, poll faster so the 3D view tracks travel.
self._boost_poll_until = 0.0
self._boost_poll_hz = 50.0
self._boost_poll_seconds = 2.5
self._state_lock = threading.Lock()
self._position: Optional[int] = None
@@ -225,7 +229,6 @@ class ActuatorWorker:
return
self._started.set()
poll_interval = 1.0 / self.position_poll_hz
next_poll = time.perf_counter()
self._perf_last_log = time.perf_counter()
@@ -251,7 +254,7 @@ class ActuatorWorker:
now = time.perf_counter()
if now >= next_poll:
self._poll_position()
next_poll = now + poll_interval
next_poll = now + self._current_poll_interval(now)
self._maybe_log_perf(now)
continue
@@ -259,6 +262,16 @@ class ActuatorWorker:
self._wake.wait(timeout=wait_s)
self._wake.clear()
def _current_poll_interval(self, now: float) -> float:
if now < self._boost_poll_until:
return 1.0 / self._boost_poll_hz
return 1.0 / self.position_poll_hz
def _note_motion_command(self) -> None:
"""Prefer measured travel over commanded target for position cache."""
self._boost_poll_until = time.perf_counter() + self._boost_poll_seconds
self._poll_position()
def _execute(self, cmd: GripCommand) -> None:
t0 = time.perf_counter()
try:
@@ -270,7 +283,7 @@ class ActuatorWorker:
accel=cmd.accel,
decel=cmd.decel,
)
self._update_position(self.gripper.last_commanded_position)
self._note_motion_command()
elif cmd.kind == "close":
self.gripper.close(
cmd.force_pct,
@@ -279,7 +292,7 @@ class ActuatorWorker:
accel=cmd.accel,
decel=cmd.decel,
)
self._update_position(self.gripper.last_commanded_position)
self._note_motion_command()
elif cmd.kind == "move":
self.gripper.move(
cmd.position,
@@ -289,7 +302,7 @@ class ActuatorWorker:
accel=cmd.accel,
decel=cmd.decel,
)
self._update_position(cmd.position)
self._note_motion_command()
elif cmd.kind == "set_force":
self.gripper.set_force(cmd.force_pct)
elif cmd.kind == "hold":
@@ -303,9 +316,10 @@ class ActuatorWorker:
self._hold_pos = hold_pos
if hold_pos is not None:
self._position = int(hold_pos)
self._note_motion_command()
elif cmd.kind == "final_open":
self.gripper.final_open_if_needed()
self._update_position(self.gripper.last_commanded_position)
self._note_motion_command()
else:
self._set_error(f"unknown command kind: {cmd.kind}")
except Exception as exc:
+20 -4
View File
@@ -78,7 +78,7 @@ class SideTriggerGripController:
self._control_mode = mode
def set_grip_behavior_mode(self, mode):
"""Apply rapid/stable preset onto live config and reset release watch."""
"""Apply sensitive/stable/firm grip preset onto live config and reset release watch."""
applied = self.config.apply_grip_behavior_mode(mode)
# Filters should match the new profile immediately.
try:
@@ -112,6 +112,7 @@ class SideTriggerGripController:
"control_hz",
"filter_alpha",
"shear_filter_alpha",
"shear_control_axes",
"trigger_normal_force",
"trigger_shear_force",
"grip_normal_force",
@@ -143,7 +144,7 @@ class SideTriggerGripController:
def run(self):
self._stop_event.clear()
self.reader.start()
self.actuator = ActuatorWorker(self.gripper, position_poll_hz=10.0)
self.actuator = ActuatorWorker(self.gripper, position_poll_hz=30.0)
try:
self.actuator.start()
@@ -812,6 +813,11 @@ class SideTriggerGripController:
return stable
def _open_wait_delta_triggered(self, sample, stable_values):
"""Arm close only when a channel rises enough above its quiet baseline.
Stable mode previously used 0.06N which sits inside typical optical-flow
residual noise and caused spontaneous side-trigger-close.
"""
thresholds = {
"left_normal": self.config.trigger_normal_force,
"right_normal": self.config.trigger_normal_force,
@@ -844,6 +850,16 @@ class SideTriggerGripController:
<= int(self.config.open_position_tolerance)
)
def _load_shear(self, feedback):
"""Payload/gravity estimate uses shear X; trigger may stay on Y."""
left = abs(float(getattr(feedback, "left_shear_x", 0.0) or 0.0))
right = abs(float(getattr(feedback, "right_shear_x", 0.0) or 0.0))
magnitude = max(left, right)
if magnitude > 0.0:
return magnitude
# Magnitude-mode fallback (no per-axis components).
return max(0.0, float(getattr(feedback, "max_shear", 0.0) or 0.0))
def _adaptive_force_target(self, feedback):
config = self.config
if not config.adaptive_grip_enabled:
@@ -864,7 +880,7 @@ class SideTriggerGripController:
shear_start = max(0.0, float(config.adaptive_shear_start_force))
shear_full = max(shear_start + 1e-6, float(config.adaptive_shear_full_force))
shear = max(0.0, float(feedback.max_shear))
shear = self._load_shear(feedback)
if shear <= shear_start:
return force_min
if shear >= shear_full:
@@ -1108,7 +1124,7 @@ class SideTriggerGripController:
"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"),
"grip_behavior_mode": getattr(self.config, "grip_behavior_mode", "sensitive"),
"speed_pct": speed_pct,
"gripper_position": gripper_position,
"open_pos": self.config.open_pos,
+38 -8
View File
@@ -4,8 +4,33 @@ import time
from ..common.filters import ExponentialFilter
def shear_magnitude(sample):
return (float(sample["fshearx"]) ** 2 + float(sample["fsheary"]) ** 2) ** 0.5
def _normalize_shear_axes(axes):
value = str(axes or "xy").strip().lower()
if value in {"y", "fy", "sheary", "fsheary"}:
return "y"
if value in {"x", "fx", "shearx", "fshearx"}:
return "x"
return "xy"
def shear_magnitude(sample, axes="xy"):
axes = _normalize_shear_axes(axes)
shear_x = float(sample["fshearx"])
shear_y = float(sample["fsheary"])
if axes == "y":
return abs(shear_y)
if axes == "x":
return abs(shear_x)
return (shear_x ** 2 + shear_y ** 2) ** 0.5
def combine_shear_components(shear_x, shear_y, axes="xy"):
axes = _normalize_shear_axes(axes)
if axes == "y":
return abs(float(shear_y))
if axes == "x":
return abs(float(shear_x))
return (float(shear_x) ** 2 + float(shear_y) ** 2) ** 0.5
def _component_shear_enabled(converter):
@@ -70,12 +95,16 @@ class TactileFeedbackProcessor:
return filter_.update(value)
return filter_.value
def _shear_axes(self):
return _normalize_shear_axes(getattr(self.config, "shear_control_axes", "xy"))
def _convert_shear(self, sample):
axes = self._shear_axes()
if _component_shear_enabled(self.shear_converter):
shear_x = self.shear_converter.convert_component(sample["fshearx"])
shear_y = self.shear_converter.convert_component(sample["fsheary"])
return (shear_x ** 2 + shear_y ** 2) ** 0.5, shear_x, shear_y
return self.shear_converter.convert(shear_magnitude(sample)), 0.0, 0.0
return combine_shear_components(shear_x, shear_y, axes), shear_x, shear_y
return self.shear_converter.convert(shear_magnitude(sample, axes)), 0.0, 0.0
def calibrate_baseline(self, reader):
seconds = self.config.baseline_seconds
@@ -133,6 +162,7 @@ class TactileFeedbackProcessor:
0.0,
self.normal_converter.convert(right["fnormal"]) - self.baseline.right_normal,
)
axes = self._shear_axes()
if _component_shear_enabled(self.shear_converter):
_, raw_left_shear_x, raw_left_shear_y = self._convert_shear(left)
_, raw_right_shear_x, raw_right_shear_y = self._convert_shear(right)
@@ -140,17 +170,17 @@ class TactileFeedbackProcessor:
left_shear_y = raw_left_shear_y - self.baseline.left_shear_y
right_shear_x = raw_right_shear_x - self.baseline.right_shear_x
right_shear_y = raw_right_shear_y - self.baseline.right_shear_y
left_shear = (left_shear_x ** 2 + left_shear_y ** 2) ** 0.5
right_shear = (right_shear_x ** 2 + right_shear_y ** 2) ** 0.5
left_shear = combine_shear_components(left_shear_x, left_shear_y, axes)
right_shear = combine_shear_components(right_shear_x, right_shear_y, axes)
else:
left_shear_x = left_shear_y = right_shear_x = right_shear_y = 0.0
left_shear = max(
0.0,
self.shear_converter.convert(shear_magnitude(left)) - self.baseline.left_shear,
self.shear_converter.convert(shear_magnitude(left, axes)) - self.baseline.left_shear,
)
right_shear = max(
0.0,
self.shear_converter.convert(shear_magnitude(right)) - self.baseline.right_shear,
self.shear_converter.convert(shear_magnitude(right, axes)) - self.baseline.right_shear,
)
left_timestamp, right_timestamp = left.get("timestamp"), right.get("timestamp")
+77
View File
@@ -241,6 +241,8 @@ class ReleaseWatch:
self._tails = {}
self._watch_since = None
self._candidate = None
self._osc_count = 0
self._osc_channel = ""
self._last_fresh = {}
self._rates = {}
self._curves = {key: ForceCurve(self.config) for key in CHANNELS}
@@ -330,6 +332,74 @@ class ReleaseWatch:
return True
return False
def _shear_oscillation(self, now):
"""Detect repeated shear direction flips with enough peak-to-peak swing.
Intentional open/close often shows as left/right shear oscillating while
normals stay flat and the grip has not yet latched a plateau. Returns
the strongest oscillating shear channel name, or "".
"""
if not getattr(self.config, "release_shear_oscillation_open", False):
return ""
if not getattr(self.config, "release_shear_onset_enabled", True):
return ""
window = max(0.12, float(getattr(self.config, "release_shear_oscillation_window_seconds", 0.4) or 0.4))
min_flips = max(2, int(getattr(self.config, "release_shear_oscillation_min_flips", 3) or 3))
amplitude = max(0.04, float(getattr(self.config, "release_shear_oscillation_amplitude", 0.15) or 0.15))
step_floor = max(0.02, amplitude * 0.2)
best_key, best_flips, best_span = "", 0, 0.0
cutoff = now - window
for key in CHANNELS[2:]:
points = [(t, v) for t, v in self._curves[key].history if t >= cutoff]
if len(points) < min_flips + 2:
continue
values = [v for _, v in points]
span = max(values) - min(values)
if span + 1e-9 < amplitude:
continue
flips = 0
prev_sign = 0
for i in range(1, len(points)):
delta = points[i][1] - points[i - 1][1]
if abs(delta) < step_floor:
continue
sign = 1 if delta > 0 else -1
if prev_sign and sign != prev_sign:
flips += 1
prev_sign = sign
if flips >= min_flips and (flips > best_flips or (flips == best_flips and span > best_span)):
best_key, best_flips, best_span = key, flips, span
return best_key
def _shear_oscillation_release(self, now, metrics):
"""Confirm oscillating shear and release without waiting for plateau."""
channel = self._shear_oscillation(now)
if not channel:
self._osc_count = 0
self._osc_channel = ""
return None
needed = max(2, int(getattr(self.config, "release_shear_oscillation_confirm_samples", 2) or 2))
if self._osc_channel != channel:
self._osc_channel = channel
self._osc_count = 1
else:
self._osc_count += 1
released = self._osc_count >= needed
if released:
self.phase = ReleasePhase.RELEASED
else:
self.phase = ReleasePhase.CONFIRMING
self._candidate = None
return ReleaseDecision(
self.phase,
"shear_oscillation",
release=released,
confirm_count=self._osc_count,
confirm_needed=needed,
channel=channel,
**dict(metrics, curve_motion="shear_oscillation"),
)
def _protect(self, reason, values, **metrics):
self.phase = (
ReleasePhase.SELF_SETTLE if self._grip_settled
@@ -337,6 +407,8 @@ class ReleaseWatch:
)
self._reference.update(values)
self._candidate = None
self._osc_count = 0
self._osc_channel = ""
self._watch_since = None
self._settle_seen.clear()
for curve in self._curves.values():
@@ -398,6 +470,11 @@ class ReleaseWatch:
"settling_turn" if "settling_turn" in motions else
"settling_flatten" if "settling_flatten" in motions else "curve_follow")
# Shear oscillation = intentional open/close: bypass plateau / settle wait.
osc_decision = self._shear_oscillation_release(now, metrics)
if osc_decision is not None:
return osc_decision
self_tail = (
motion_completed_at is not None
and now - motion_completed_at < max(0.0, cfg.release_arm_delay_seconds)