|
| 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