Skip to content

Commit 01ce3e8

Browse files
authored
Create thermal.py
1 parent 8e765f6 commit 01ce3e8

1 file changed

Lines changed: 181 additions & 0 deletions

File tree

src/ohip_interfaces/thermal.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
"""
2+
Thermal interface models for IX-HapticSight.
3+
4+
This module defines backend-agnostic normalized structures for local thermal
5+
sensing. The goal is to expose surface or near-surface temperature state in a
6+
form that runtime safety and contact-governance logic can reason about without
7+
depending on device-specific 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 ThermalSample:
25+
"""
26+
One normalized thermal reading.
27+
28+
Fields:
29+
- `zone_id`: logical sensor zone or ROI identifier
30+
- `temperature_c`: measured temperature in Celsius
31+
- `location_xyz`: optional estimated location of the reading in the named frame
32+
- `confidence`: optional [0, 1] confidence-like score from the adapter
33+
"""
34+
35+
zone_id: str
36+
temperature_c: float
37+
location_xyz: Optional[Vector3] = None
38+
confidence: float = 1.0
39+
40+
def to_dict(self) -> dict:
41+
return {
42+
"zone_id": self.zone_id,
43+
"temperature_c": float(self.temperature_c),
44+
"location_xyz": self.location_xyz.as_list() if self.location_xyz is not None else None,
45+
"confidence": float(self.confidence),
46+
}
47+
48+
49+
@dataclass(frozen=True)
50+
class ThermalFrame:
51+
"""
52+
Normalized thermal frame for one sensor surface or thermal ROI set.
53+
"""
54+
55+
sensor_name: str
56+
frame: str
57+
quality: SignalQuality
58+
samples: tuple[ThermalSample, ...] = field(default_factory=tuple)
59+
60+
def sample_count(self) -> int:
61+
return len(self.samples)
62+
63+
def has_samples(self) -> bool:
64+
return self.sample_count() > 0
65+
66+
def max_temperature_c(self) -> Optional[float]:
67+
if not self.samples:
68+
return None
69+
return float(max(sample.temperature_c for sample in self.samples))
70+
71+
def min_temperature_c(self) -> Optional[float]:
72+
if not self.samples:
73+
return None
74+
return float(min(sample.temperature_c for sample in self.samples))
75+
76+
def hottest_sample(self) -> Optional[ThermalSample]:
77+
if not self.samples:
78+
return None
79+
return max(self.samples, key=lambda sample: sample.temperature_c)
80+
81+
def is_fresh(self, policy: FreshnessPolicy, *, now_utc_s: Optional[float] = None) -> bool:
82+
return self.quality.is_fresh(policy, now_utc_s=now_utc_s)
83+
84+
def is_usable(self, policy: Optional[FreshnessPolicy] = None, *, now_utc_s: Optional[float] = None) -> bool:
85+
return self.quality.is_usable(policy, now_utc_s=now_utc_s)
86+
87+
def to_dict(self) -> dict:
88+
return {
89+
"sensor_name": self.sensor_name,
90+
"frame": self.frame,
91+
"quality": self.quality.freshness_summary(
92+
FreshnessPolicy(max_age_ms=0, required=False),
93+
now_utc_s=self.quality.sample_timestamp_utc_s,
94+
),
95+
"samples": [sample.to_dict() for sample in self.samples],
96+
}
97+
98+
99+
@dataclass(frozen=True)
100+
class ThermalAssessment:
101+
"""
102+
Compact thermal summary suitable for runtime safety checks.
103+
"""
104+
105+
heat_detected: bool
106+
over_limit: bool
107+
sample_count: int
108+
hottest_temperature_c: Optional[float]
109+
caution_temperature_c: float
110+
stop_temperature_c: float
111+
112+
def to_dict(self) -> dict:
113+
return {
114+
"heat_detected": bool(self.heat_detected),
115+
"over_limit": bool(self.over_limit),
116+
"sample_count": int(self.sample_count),
117+
"hottest_temperature_c": None if self.hottest_temperature_c is None else float(self.hottest_temperature_c),
118+
"caution_temperature_c": float(self.caution_temperature_c),
119+
"stop_temperature_c": float(self.stop_temperature_c),
120+
}
121+
122+
123+
def make_thermal_sample(
124+
*,
125+
zone_id: str,
126+
temperature_c: float,
127+
location_xyz: Optional[Iterable[float]] = None,
128+
confidence: float = 1.0,
129+
) -> ThermalSample:
130+
location = list(location_xyz) if location_xyz is not None else None
131+
132+
if location is not None and len(location) != 3:
133+
raise ValueError("location_xyz must contain exactly 3 elements when provided")
134+
if not (0.0 <= float(confidence) <= 1.0):
135+
raise ValueError("confidence must be between 0.0 and 1.0")
136+
137+
return ThermalSample(
138+
zone_id=str(zone_id),
139+
temperature_c=float(temperature_c),
140+
location_xyz=Vector3.from_list(location) if location is not None else None,
141+
confidence=float(confidence),
142+
)
143+
144+
145+
def assess_thermal(
146+
thermal_frame: ThermalFrame,
147+
*,
148+
caution_temperature_c: float = 38.0,
149+
stop_temperature_c: float = 45.0,
150+
) -> ThermalAssessment:
151+
"""
152+
Derive a compact thermal assessment from a normalized thermal frame.
153+
154+
Semantics:
155+
- `heat_detected`: there is at least one sample at or above the caution threshold
156+
- `over_limit`: hottest sample is at or above the stop threshold
157+
"""
158+
if stop_temperature_c < caution_temperature_c:
159+
raise ValueError("stop_temperature_c must be >= caution_temperature_c")
160+
161+
hottest = thermal_frame.max_temperature_c()
162+
heat_detected = hottest is not None and hottest >= float(caution_temperature_c)
163+
over_limit = hottest is not None and hottest >= float(stop_temperature_c)
164+
165+
return ThermalAssessment(
166+
heat_detected=bool(heat_detected),
167+
over_limit=bool(over_limit),
168+
sample_count=thermal_frame.sample_count(),
169+
hottest_temperature_c=None if hottest is None else float(hottest),
170+
caution_temperature_c=float(caution_temperature_c),
171+
stop_temperature_c=float(stop_temperature_c),
172+
)
173+
174+
175+
__all__ = [
176+
"ThermalSample",
177+
"ThermalFrame",
178+
"ThermalAssessment",
179+
"make_thermal_sample",
180+
"assess_thermal",
181+
]

0 commit comments

Comments
 (0)