Skip to content

Commit 8c96065

Browse files
authored
Create force_torque.py
1 parent 72a790d commit 8c96065

1 file changed

Lines changed: 157 additions & 0 deletions

File tree

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
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

Comments
 (0)