|
| 1 | +""" |
| 2 | +Force/torque interface models for IX-HapticSight. |
| 3 | +
|
| 4 | +This module defines backend-agnostic normalized structures for wrist or joint- |
| 5 | +adjacent force/torque sensing. The goal is to make measured contact-related |
| 6 | +signals explicit before they reach runtime coordination or safety logic. |
| 7 | +
|
| 8 | +This layer does not talk to hardware directly. |
| 9 | +It defines the normalized payload shape that a hardware-specific adapter or |
| 10 | +simulation bridge should produce. |
| 11 | +""" |
| 12 | + |
| 13 | +from __future__ import annotations |
| 14 | + |
| 15 | +from dataclasses import dataclass |
| 16 | +from math import sqrt |
| 17 | +from typing import Iterable, Optional |
| 18 | + |
| 19 | +from ohip.schemas import Vector3 |
| 20 | + |
| 21 | +from .signal_health import FreshnessPolicy, SignalQuality |
| 22 | + |
| 23 | + |
| 24 | +@dataclass(frozen=True) |
| 25 | +class ForceTorqueSample: |
| 26 | + """ |
| 27 | + Normalized force/torque sample. |
| 28 | +
|
| 29 | + Conventions: |
| 30 | + - vectors are expressed in the named `frame` |
| 31 | + - force is in newtons |
| 32 | + - torque is in newton-meters |
| 33 | + - `quality` carries health, freshness, and source metadata |
| 34 | + """ |
| 35 | + |
| 36 | + frame: str |
| 37 | + force: Vector3 |
| 38 | + torque: Vector3 |
| 39 | + quality: SignalQuality |
| 40 | + |
| 41 | + def force_magnitude_N(self) -> float: |
| 42 | + return sqrt(self.force.x ** 2 + self.force.y ** 2 + self.force.z ** 2) |
| 43 | + |
| 44 | + def torque_magnitude_Nm(self) -> float: |
| 45 | + return sqrt(self.torque.x ** 2 + self.torque.y ** 2 + self.torque.z ** 2) |
| 46 | + |
| 47 | + def is_fresh(self, policy: FreshnessPolicy, *, now_utc_s: Optional[float] = None) -> bool: |
| 48 | + return self.quality.is_fresh(policy, now_utc_s=now_utc_s) |
| 49 | + |
| 50 | + def is_usable(self, policy: Optional[FreshnessPolicy] = None, *, now_utc_s: Optional[float] = None) -> bool: |
| 51 | + return self.quality.is_usable(policy, now_utc_s=now_utc_s) |
| 52 | + |
| 53 | + def to_dict(self) -> dict: |
| 54 | + return { |
| 55 | + "frame": self.frame, |
| 56 | + "force": self.force.as_list(), |
| 57 | + "torque": self.torque.as_list(), |
| 58 | + "quality": self.quality.freshness_summary( |
| 59 | + FreshnessPolicy(max_age_ms=0, required=False), |
| 60 | + now_utc_s=self.quality.sample_timestamp_utc_s, |
| 61 | + ), |
| 62 | + } |
| 63 | + |
| 64 | + |
| 65 | +@dataclass(frozen=True) |
| 66 | +class ContactForceAssessment: |
| 67 | + """ |
| 68 | + Compact force/torque-derived contact assessment. |
| 69 | +
|
| 70 | + This is not a full safety verdict. It is a normalized signal-side summary |
| 71 | + that higher layers can consume when deciding whether contact looks nominal, |
| 72 | + absent, uncertain, or excessive. |
| 73 | + """ |
| 74 | + |
| 75 | + contact_detected: bool |
| 76 | + excessive_force: bool |
| 77 | + force_magnitude_N: float |
| 78 | + torque_magnitude_Nm: float |
| 79 | + threshold_contact_N: float |
| 80 | + threshold_excessive_N: float |
| 81 | + |
| 82 | + def to_dict(self) -> dict: |
| 83 | + return { |
| 84 | + "contact_detected": bool(self.contact_detected), |
| 85 | + "excessive_force": bool(self.excessive_force), |
| 86 | + "force_magnitude_N": float(self.force_magnitude_N), |
| 87 | + "torque_magnitude_Nm": float(self.torque_magnitude_Nm), |
| 88 | + "threshold_contact_N": float(self.threshold_contact_N), |
| 89 | + "threshold_excessive_N": float(self.threshold_excessive_N), |
| 90 | + } |
| 91 | + |
| 92 | + |
| 93 | +def make_force_torque_sample( |
| 94 | + *, |
| 95 | + frame: str, |
| 96 | + force_xyz: Iterable[float], |
| 97 | + torque_xyz: Iterable[float], |
| 98 | + quality: SignalQuality, |
| 99 | +) -> ForceTorqueSample: |
| 100 | + """ |
| 101 | + Convenience constructor for adapters that start with list/tuple payloads. |
| 102 | + """ |
| 103 | + force_vals = list(force_xyz) |
| 104 | + torque_vals = list(torque_xyz) |
| 105 | + |
| 106 | + if len(force_vals) != 3: |
| 107 | + raise ValueError("force_xyz must contain exactly 3 elements") |
| 108 | + if len(torque_vals) != 3: |
| 109 | + raise ValueError("torque_xyz must contain exactly 3 elements") |
| 110 | + |
| 111 | + return ForceTorqueSample( |
| 112 | + frame=str(frame), |
| 113 | + force=Vector3.from_list(force_vals), |
| 114 | + torque=Vector3.from_list(torque_vals), |
| 115 | + quality=quality, |
| 116 | + ) |
| 117 | + |
| 118 | + |
| 119 | +def assess_contact_force( |
| 120 | + sample: ForceTorqueSample, |
| 121 | + *, |
| 122 | + contact_threshold_N: float = 0.25, |
| 123 | + excessive_threshold_N: float = 2.0, |
| 124 | +) -> ContactForceAssessment: |
| 125 | + """ |
| 126 | + Derive a compact contact-force assessment from one normalized sample. |
| 127 | +
|
| 128 | + Parameters are intentionally simple at this stage: |
| 129 | + - `contact_threshold_N` is the minimum force magnitude counted as contact |
| 130 | + - `excessive_threshold_N` is the level treated as suspicious or excessive |
| 131 | +
|
| 132 | + Higher layers remain responsible for context, dwell, direction, and policy. |
| 133 | + """ |
| 134 | + if contact_threshold_N < 0.0: |
| 135 | + raise ValueError("contact_threshold_N must be non-negative") |
| 136 | + if excessive_threshold_N < contact_threshold_N: |
| 137 | + raise ValueError("excessive_threshold_N must be >= contact_threshold_N") |
| 138 | + |
| 139 | + force_mag = sample.force_magnitude_N() |
| 140 | + torque_mag = sample.torque_magnitude_Nm() |
| 141 | + |
| 142 | + return ContactForceAssessment( |
| 143 | + contact_detected=force_mag >= float(contact_threshold_N), |
| 144 | + excessive_force=force_mag >= float(excessive_threshold_N), |
| 145 | + force_magnitude_N=float(force_mag), |
| 146 | + torque_magnitude_Nm=float(torque_mag), |
| 147 | + threshold_contact_N=float(contact_threshold_N), |
| 148 | + threshold_excessive_N=float(excessive_threshold_N), |
| 149 | + ) |
| 150 | + |
| 151 | + |
| 152 | +__all__ = [ |
| 153 | + "ForceTorqueSample", |
| 154 | + "ContactForceAssessment", |
| 155 | + "make_force_torque_sample", |
| 156 | + "assess_contact_force", |
| 157 | +] |
0 commit comments