Coverage for src / tracekit / session / annotations.py: 87%

105 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-11 23:04 +0000

1"""Trace annotation support. 

2 

3This module provides annotation capabilities for marking points of interest 

4in signal traces. 

5 

6 

7Example: 

8 >>> layer = AnnotationLayer("Debug Markers") 

9 >>> layer.add(Annotation(time=1.5e-6, text="Glitch detected")) 

10 >>> layer.add(Annotation(time_range=(2e-6, 3e-6), text="Data packet")) 

11""" 

12 

13from __future__ import annotations 

14 

15from dataclasses import dataclass, field 

16from datetime import datetime 

17from enum import Enum 

18from typing import Any 

19 

20 

21class AnnotationType(Enum): 

22 """Types of annotations.""" 

23 

24 POINT = "point" # Single time point 

25 RANGE = "range" # Time range 

26 VERTICAL = "vertical" # Vertical line 

27 HORIZONTAL = "horizontal" # Horizontal line 

28 REGION = "region" # 2D region (time + amplitude) 

29 TEXT = "text" # Free-floating text 

30 

31 

32@dataclass 

33class Annotation: 

34 """Single annotation on a trace. 

35 

36 Attributes: 

37 text: Annotation text/label 

38 time: Time point (for point annotations) 

39 time_range: (start, end) time range 

40 amplitude: Amplitude value (for horizontal lines) 

41 amplitude_range: (min, max) amplitude range 

42 annotation_type: Type of annotation 

43 color: Display color (hex or name) 

44 style: Line style ('solid', 'dashed', 'dotted') 

45 visible: Whether annotation is visible 

46 created_at: Creation timestamp 

47 metadata: Additional metadata 

48 """ 

49 

50 text: str 

51 time: float | None = None 

52 time_range: tuple[float, float] | None = None 

53 amplitude: float | None = None 

54 amplitude_range: tuple[float, float] | None = None 

55 annotation_type: AnnotationType = AnnotationType.POINT 

56 color: str = "#FF6B6B" 

57 style: str = "solid" 

58 visible: bool = True 

59 created_at: datetime = field(default_factory=datetime.now) 

60 metadata: dict[str, Any] = field(default_factory=dict) 

61 

62 def __post_init__(self) -> None: 

63 """Infer annotation type from provided parameters.""" 

64 if self.annotation_type == AnnotationType.POINT: 64 ↛ exitline 64 didn't return from function '__post_init__' because the condition on line 64 was always true

65 if self.time_range is not None: 

66 self.annotation_type = AnnotationType.RANGE 

67 elif self.amplitude is not None and self.time is None: 67 ↛ 68line 67 didn't jump to line 68 because the condition on line 67 was never true

68 self.annotation_type = AnnotationType.HORIZONTAL 

69 elif self.amplitude_range is not None and self.time_range is not None: 69 ↛ 70line 69 didn't jump to line 70 because the condition on line 69 was never true

70 self.annotation_type = AnnotationType.REGION # type: ignore[unreachable] 

71 

72 @property 

73 def start_time(self) -> float | None: 

74 """Get start time for range annotations.""" 

75 if self.time_range: 

76 return self.time_range[0] 

77 return self.time 

78 

79 @property 

80 def end_time(self) -> float | None: 

81 """Get end time for range annotations.""" 

82 if self.time_range: 

83 return self.time_range[1] 

84 return self.time 

85 

86 def to_dict(self) -> dict[str, Any]: 

87 """Convert to dictionary for serialization.""" 

88 return { 

89 "text": self.text, 

90 "time": self.time, 

91 "time_range": self.time_range, 

92 "amplitude": self.amplitude, 

93 "amplitude_range": self.amplitude_range, 

94 "annotation_type": self.annotation_type.value, 

95 "color": self.color, 

96 "style": self.style, 

97 "visible": self.visible, 

98 "created_at": self.created_at.isoformat(), 

99 "metadata": self.metadata, 

100 } 

101 

102 @classmethod 

103 def from_dict(cls, data: dict[str, Any]) -> Annotation: 

104 """Create from dictionary.""" 

105 data = data.copy() 

106 data["annotation_type"] = AnnotationType(data.get("annotation_type", "point")) 

107 if "created_at" in data and isinstance(data["created_at"], str): 107 ↛ 109line 107 didn't jump to line 109 because the condition on line 107 was always true

108 data["created_at"] = datetime.fromisoformat(data["created_at"]) 

109 return cls(**data) 

110 

111 

112@dataclass 

113class AnnotationLayer: 

114 """Collection of related annotations. 

115 

116 Attributes: 

117 name: Layer name 

118 annotations: List of annotations 

119 visible: Whether layer is visible 

120 locked: Whether layer is locked (read-only) 

121 color: Default color for new annotations 

122 description: Layer description 

123 """ 

124 

125 name: str 

126 annotations: list[Annotation] = field(default_factory=list) 

127 visible: bool = True 

128 locked: bool = False 

129 color: str = "#FF6B6B" 

130 description: str = "" 

131 

132 def add( 

133 self, 

134 annotation: Annotation | None = None, 

135 *, 

136 text: str = "", 

137 time: float | None = None, 

138 time_range: tuple[float, float] | None = None, 

139 **kwargs: Any, 

140 ) -> Annotation: 

141 """Add annotation to layer. 

142 

143 Args: 

144 annotation: Pre-built Annotation object. 

145 text: Annotation text (if not using pre-built). 

146 time: Time point. 

147 time_range: Time range. 

148 **kwargs: Additional Annotation parameters. 

149 

150 Returns: 

151 Added annotation. 

152 

153 Raises: 

154 ValueError: If layer is locked. 

155 """ 

156 if self.locked: 

157 raise ValueError(f"Layer '{self.name}' is locked") 

158 

159 if annotation is None: 

160 annotation = Annotation( 

161 text=text, 

162 time=time, 

163 time_range=time_range, 

164 color=kwargs.pop("color", self.color), 

165 **kwargs, 

166 ) 

167 

168 self.annotations.append(annotation) 

169 return annotation 

170 

171 def remove(self, annotation: Annotation) -> bool: 

172 """Remove annotation from layer. 

173 

174 Args: 

175 annotation: Annotation to remove. 

176 

177 Returns: 

178 True if removed, False if not found. 

179 

180 Raises: 

181 ValueError: If layer is locked. 

182 """ 

183 if self.locked: 183 ↛ 184line 183 didn't jump to line 184 because the condition on line 183 was never true

184 raise ValueError(f"Layer '{self.name}' is locked") 

185 

186 try: 

187 self.annotations.remove(annotation) 

188 return True 

189 except ValueError: 

190 return False 

191 

192 def find_at_time( 

193 self, 

194 time: float, 

195 tolerance: float = 0.0, 

196 ) -> list[Annotation]: 

197 """Find annotations at or near a specific time. 

198 

199 Args: 

200 time: Time to search. 

201 tolerance: Time tolerance for matching. 

202 

203 Returns: 

204 List of matching annotations. 

205 """ 

206 matches = [] 

207 for ann in self.annotations: 

208 if ann.time is not None: 

209 if abs(ann.time - time) <= tolerance: 

210 matches.append(ann) 

211 elif ann.time_range is not None and ( 211 ↛ 207line 211 didn't jump to line 207 because the condition on line 211 was always true

212 ann.time_range[0] - tolerance <= time <= ann.time_range[1] + tolerance 

213 ): 

214 matches.append(ann) 

215 return matches 

216 

217 def find_in_range( 

218 self, 

219 start_time: float, 

220 end_time: float, 

221 ) -> list[Annotation]: 

222 """Find annotations within a time range. 

223 

224 Args: 

225 start_time: Range start. 

226 end_time: Range end. 

227 

228 Returns: 

229 List of annotations within range. 

230 """ 

231 matches = [] 

232 for ann in self.annotations: 

233 ann_start = ann.start_time 

234 ann_end = ann.end_time 

235 

236 if ann_start is not None and ( 

237 start_time <= ann_start <= end_time 

238 or (ann_end is not None and ann_start <= end_time and ann_end >= start_time) 

239 ): 

240 matches.append(ann) 

241 

242 return matches 

243 

244 def clear(self) -> int: 

245 """Remove all annotations. 

246 

247 Returns: 

248 Number of annotations removed. 

249 

250 Raises: 

251 ValueError: If layer is locked. 

252 """ 

253 if self.locked: 

254 raise ValueError(f"Layer '{self.name}' is locked") 

255 

256 count = len(self.annotations) 

257 self.annotations.clear() 

258 return count 

259 

260 def to_dict(self) -> dict[str, Any]: 

261 """Convert to dictionary for serialization.""" 

262 return { 

263 "name": self.name, 

264 "annotations": [a.to_dict() for a in self.annotations], 

265 "visible": self.visible, 

266 "locked": self.locked, 

267 "color": self.color, 

268 "description": self.description, 

269 } 

270 

271 @classmethod 

272 def from_dict(cls, data: dict[str, Any]) -> AnnotationLayer: 

273 """Create from dictionary.""" 

274 annotations = [Annotation.from_dict(a) for a in data.get("annotations", [])] 

275 return cls( 

276 name=data["name"], 

277 annotations=annotations, 

278 visible=data.get("visible", True), 

279 locked=data.get("locked", False), 

280 color=data.get("color", "#FF6B6B"), 

281 description=data.get("description", ""), 

282 ) 

283 

284 

285__all__ = [ 

286 "Annotation", 

287 "AnnotationLayer", 

288 "AnnotationType", 

289]