Skip to content

Commit 84a3f59

Browse files
authored
Create tactile.py
1 parent d3e3523 commit 84a3f59

1 file changed

Lines changed: 213 additions & 0 deletions

File tree

src/ohip_interfaces/tactile.py

Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
"""
2+
Tactile interface models for IX-HapticSight.
3+
4+
This module defines backend-agnostic normalized structures for tactile sensing.
5+
The goal is to expose tactile contact state in a form that runtime safety and
6+
contact-governance logic can reason about without depending on device-specific
7+
payloads.
8+
9+
This module does not talk to hardware directly.
10+
It defines normalized payloads that adapters or simulators should emit.
11+
"""
12+
13+
from __future__ import annotations
14+
15+
from dataclasses import dataclass, field
16+
from typing import Iterable, Optional
17+
18+
from ohip.schemas import Vector3
19+
20+
from .signal_health import FreshnessPolicy, SignalQuality
21+
22+
23+
@dataclass(frozen=True)
24+
class TactilePatch:
25+
"""
26+
One localized tactile contact patch.
27+
28+
Fields:
29+
- `patch_id`: stable identifier for the active patch within one frame
30+
- `location_xyz`: patch center in the named sensor or body frame
31+
- `normal_xyz`: estimated outward contact normal
32+
- `area_mm2`: estimated contact area
33+
- `pressure_kpa`: estimated average pressure
34+
- `shear_xy_kpa`: estimated tangential shear vector in the local patch plane
35+
"""
36+
37+
patch_id: str
38+
location_xyz: Vector3
39+
normal_xyz: Vector3
40+
area_mm2: float
41+
pressure_kpa: float
42+
shear_xy_kpa: tuple[float, float] = (0.0, 0.0)
43+
44+
def shear_magnitude_kpa(self) -> float:
45+
x, y = self.shear_xy_kpa
46+
return float((x ** 2 + y ** 2) ** 0.5)
47+
48+
def to_dict(self) -> dict:
49+
return {
50+
"patch_id": self.patch_id,
51+
"location_xyz": self.location_xyz.as_list(),
52+
"normal_xyz": self.normal_xyz.as_list(),
53+
"area_mm2": float(self.area_mm2),
54+
"pressure_kpa": float(self.pressure_kpa),
55+
"shear_xy_kpa": [float(self.shear_xy_kpa[0]), float(self.shear_xy_kpa[1])],
56+
}
57+
58+
59+
@dataclass(frozen=True)
60+
class TactileFrame:
61+
"""
62+
Normalized tactile frame for one sensing surface and sample time.
63+
64+
This representation supports sparse contact patches rather than requiring
65+
a dense raw taxel map. That keeps the interface smaller and easier to use
66+
in runtime logic, replay, and benchmarks.
67+
"""
68+
69+
surface_name: str
70+
frame: str
71+
quality: SignalQuality
72+
patches: tuple[TactilePatch, ...] = field(default_factory=tuple)
73+
74+
def patch_count(self) -> int:
75+
return len(self.patches)
76+
77+
def has_contact(self) -> bool:
78+
return self.patch_count() > 0
79+
80+
def total_area_mm2(self) -> float:
81+
return float(sum(patch.area_mm2 for patch in self.patches))
82+
83+
def max_pressure_kpa(self) -> float:
84+
if not self.patches:
85+
return 0.0
86+
return float(max(patch.pressure_kpa for patch in self.patches))
87+
88+
def max_shear_kpa(self) -> float:
89+
if not self.patches:
90+
return 0.0
91+
return float(max(patch.shear_magnitude_kpa() for patch in self.patches))
92+
93+
def is_fresh(self, policy: FreshnessPolicy, *, now_utc_s: Optional[float] = None) -> bool:
94+
return self.quality.is_fresh(policy, now_utc_s=now_utc_s)
95+
96+
def is_usable(self, policy: Optional[FreshnessPolicy] = None, *, now_utc_s: Optional[float] = None) -> bool:
97+
return self.quality.is_usable(policy, now_utc_s=now_utc_s)
98+
99+
def to_dict(self) -> dict:
100+
return {
101+
"surface_name": self.surface_name,
102+
"frame": self.frame,
103+
"quality": self.quality.freshness_summary(
104+
FreshnessPolicy(max_age_ms=0, required=False),
105+
now_utc_s=self.quality.sample_timestamp_utc_s,
106+
),
107+
"patches": [patch.to_dict() for patch in self.patches],
108+
}
109+
110+
111+
@dataclass(frozen=True)
112+
class TactileContactAssessment:
113+
"""
114+
Compact tactile summary suitable for runtime safety and contact checks.
115+
"""
116+
117+
contact_detected: bool
118+
multi_patch_contact: bool
119+
patch_count: int
120+
total_area_mm2: float
121+
max_pressure_kpa: float
122+
max_shear_kpa: float
123+
excessive_pressure: bool
124+
excessive_shear: bool
125+
126+
def to_dict(self) -> dict:
127+
return {
128+
"contact_detected": bool(self.contact_detected),
129+
"multi_patch_contact": bool(self.multi_patch_contact),
130+
"patch_count": int(self.patch_count),
131+
"total_area_mm2": float(self.total_area_mm2),
132+
"max_pressure_kpa": float(self.max_pressure_kpa),
133+
"max_shear_kpa": float(self.max_shear_kpa),
134+
"excessive_pressure": bool(self.excessive_pressure),
135+
"excessive_shear": bool(self.excessive_shear),
136+
}
137+
138+
139+
def make_tactile_patch(
140+
*,
141+
patch_id: str,
142+
location_xyz: Iterable[float],
143+
normal_xyz: Iterable[float],
144+
area_mm2: float,
145+
pressure_kpa: float,
146+
shear_xy_kpa: Iterable[float] = (0.0, 0.0),
147+
) -> TactilePatch:
148+
loc = list(location_xyz)
149+
normal = list(normal_xyz)
150+
shear = list(shear_xy_kpa)
151+
152+
if len(loc) != 3:
153+
raise ValueError("location_xyz must contain exactly 3 elements")
154+
if len(normal) != 3:
155+
raise ValueError("normal_xyz must contain exactly 3 elements")
156+
if len(shear) != 2:
157+
raise ValueError("shear_xy_kpa must contain exactly 2 elements")
158+
if area_mm2 < 0.0:
159+
raise ValueError("area_mm2 must be non-negative")
160+
if pressure_kpa < 0.0:
161+
raise ValueError("pressure_kpa must be non-negative")
162+
163+
return TactilePatch(
164+
patch_id=str(patch_id),
165+
location_xyz=Vector3.from_list(loc),
166+
normal_xyz=Vector3.from_list(normal),
167+
area_mm2=float(area_mm2),
168+
pressure_kpa=float(pressure_kpa),
169+
shear_xy_kpa=(float(shear[0]), float(shear[1])),
170+
)
171+
172+
173+
def assess_tactile_contact(
174+
tactile_frame: TactileFrame,
175+
*,
176+
pressure_threshold_kpa: float = 0.5,
177+
excessive_pressure_threshold_kpa: float = 10.0,
178+
excessive_shear_threshold_kpa: float = 5.0,
179+
) -> TactileContactAssessment:
180+
"""
181+
Derive a compact tactile contact assessment from a normalized tactile frame.
182+
"""
183+
if pressure_threshold_kpa < 0.0:
184+
raise ValueError("pressure_threshold_kpa must be non-negative")
185+
if excessive_pressure_threshold_kpa < pressure_threshold_kpa:
186+
raise ValueError("excessive_pressure_threshold_kpa must be >= pressure_threshold_kpa")
187+
if excessive_shear_threshold_kpa < 0.0:
188+
raise ValueError("excessive_shear_threshold_kpa must be non-negative")
189+
190+
patch_count = tactile_frame.patch_count()
191+
total_area = tactile_frame.total_area_mm2()
192+
max_pressure = tactile_frame.max_pressure_kpa()
193+
max_shear = tactile_frame.max_shear_kpa()
194+
195+
return TactileContactAssessment(
196+
contact_detected=patch_count > 0 and max_pressure >= float(pressure_threshold_kpa),
197+
multi_patch_contact=patch_count > 1,
198+
patch_count=patch_count,
199+
total_area_mm2=float(total_area),
200+
max_pressure_kpa=float(max_pressure),
201+
max_shear_kpa=float(max_shear),
202+
excessive_pressure=max_pressure >= float(excessive_pressure_threshold_kpa),
203+
excessive_shear=max_shear >= float(excessive_shear_threshold_kpa),
204+
)
205+
206+
207+
__all__ = [
208+
"TactilePatch",
209+
"TactileFrame",
210+
"TactileContactAssessment",
211+
"make_tactile_patch",
212+
"assess_tactile_contact",
213+
]

0 commit comments

Comments
 (0)