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
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
1"""Trace annotation support.
3This module provides annotation capabilities for marking points of interest
4in signal traces.
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"""
13from __future__ import annotations
15from dataclasses import dataclass, field
16from datetime import datetime
17from enum import Enum
18from typing import Any
21class AnnotationType(Enum):
22 """Types of annotations."""
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
32@dataclass
33class Annotation:
34 """Single annotation on a trace.
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 """
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)
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]
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
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
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 }
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)
112@dataclass
113class AnnotationLayer:
114 """Collection of related annotations.
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 """
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 = ""
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.
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.
150 Returns:
151 Added annotation.
153 Raises:
154 ValueError: If layer is locked.
155 """
156 if self.locked:
157 raise ValueError(f"Layer '{self.name}' is locked")
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 )
168 self.annotations.append(annotation)
169 return annotation
171 def remove(self, annotation: Annotation) -> bool:
172 """Remove annotation from layer.
174 Args:
175 annotation: Annotation to remove.
177 Returns:
178 True if removed, False if not found.
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")
186 try:
187 self.annotations.remove(annotation)
188 return True
189 except ValueError:
190 return False
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.
199 Args:
200 time: Time to search.
201 tolerance: Time tolerance for matching.
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
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.
224 Args:
225 start_time: Range start.
226 end_time: Range end.
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
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)
242 return matches
244 def clear(self) -> int:
245 """Remove all annotations.
247 Returns:
248 Number of annotations removed.
250 Raises:
251 ValueError: If layer is locked.
252 """
253 if self.locked:
254 raise ValueError(f"Layer '{self.name}' is locked")
256 count = len(self.annotations)
257 self.annotations.clear()
258 return count
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 }
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 )
285__all__ = [
286 "Annotation",
287 "AnnotationLayer",
288 "AnnotationType",
289]