开合控制调整:切向触发只看 Y 轴,三挡位只控制释放、触发固定灵敏参数;自适应负载改看切向 X;恢复原灵敏档张开逻辑与排查前夹持力参数。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user