Coverage for src / tracekit / core / types.py: 100%

171 statements  

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

1"""Core data types for TraceKit signal analysis framework. 

2 

3This module implements the fundamental data structures for oscilloscope 

4and logic analyzer data analysis. 

5 

6Requirements addressed: 

7- CORE-001: TraceMetadata Data Class 

8- CORE-002: WaveformTrace Data Class 

9- CORE-003: DigitalTrace Data Class 

10- CORE-004: ProtocolPacket Data Class 

11- CORE-005: CalibrationInfo Data Class (regulatory compliance) 

12""" 

13 

14from __future__ import annotations 

15 

16from dataclasses import dataclass, field 

17from typing import TYPE_CHECKING, Any 

18 

19import numpy as np 

20 

21if TYPE_CHECKING: 

22 from datetime import datetime 

23 

24 from numpy.typing import NDArray 

25 

26 

27@dataclass 

28class CalibrationInfo: 

29 """Calibration and instrument provenance information. 

30 

31 Stores traceability metadata for measurements performed on oscilloscope 

32 or logic analyzer data. Essential for regulatory compliance and quality 

33 assurance in DOD/aerospace/medical applications. 

34 

35 Attributes: 

36 instrument: Instrument make and model (e.g., "Tektronix DPO7254C"). 

37 serial_number: Instrument serial number for traceability (optional). 

38 calibration_date: Date of last calibration (optional). 

39 calibration_due_date: Date when next calibration is due (optional). 

40 firmware_version: Instrument firmware version (optional). 

41 calibration_lab: Calibration lab name or accreditation (optional). 

42 calibration_cert_number: Calibration certificate number (optional). 

43 probe_attenuation: Probe attenuation factor (e.g., 10.0 for 10x probe) (optional). 

44 coupling: Input coupling ("DC", "AC", "GND") (optional). 

45 bandwidth_limit: Bandwidth limit in Hz, None if disabled (optional). 

46 vertical_resolution: ADC resolution in bits (optional). 

47 

48 Example: 

49 >>> from datetime import datetime 

50 >>> cal_info = CalibrationInfo( 

51 ... instrument="Tektronix DPO7254C", 

52 ... serial_number="C012345", 

53 ... calibration_date=datetime(2024, 12, 15), 

54 ... probe_attenuation=10.0, 

55 ... vertical_resolution=8 

56 ... ) 

57 >>> print(f"Instrument: {cal_info.instrument}") 

58 Instrument: Tektronix DPO7254C 

59 

60 References: 

61 ISO/IEC 17025: General Requirements for Testing/Calibration Laboratories 

62 NIST Handbook 150: Laboratory Accreditation Program Requirements 

63 21 CFR Part 11: Electronic Records (FDA) 

64 """ 

65 

66 instrument: str 

67 serial_number: str | None = None 

68 calibration_date: datetime | None = None 

69 calibration_due_date: datetime | None = None 

70 firmware_version: str | None = None 

71 calibration_lab: str | None = None 

72 calibration_cert_number: str | None = None 

73 probe_attenuation: float | None = None 

74 coupling: str | None = None 

75 bandwidth_limit: float | None = None 

76 vertical_resolution: int | None = None 

77 

78 def __post_init__(self) -> None: 

79 """Validate calibration info after initialization.""" 

80 if self.probe_attenuation is not None and self.probe_attenuation <= 0: 

81 raise ValueError(f"probe_attenuation must be positive, got {self.probe_attenuation}") 

82 if self.bandwidth_limit is not None and self.bandwidth_limit <= 0: 

83 raise ValueError(f"bandwidth_limit must be positive, got {self.bandwidth_limit}") 

84 if self.vertical_resolution is not None and self.vertical_resolution <= 0: 

85 raise ValueError( 

86 f"vertical_resolution must be positive, got {self.vertical_resolution}" 

87 ) 

88 

89 @property 

90 def is_calibration_current(self) -> bool | None: 

91 """Check if calibration is current. 

92 

93 Returns: 

94 True if calibration is current, False if expired, None if dates not set. 

95 """ 

96 if self.calibration_date is None or self.calibration_due_date is None: 

97 return None 

98 from datetime import datetime 

99 

100 return datetime.now() < self.calibration_due_date 

101 

102 @property 

103 def traceability_summary(self) -> str: 

104 """Generate a traceability summary string. 

105 

106 Returns: 

107 Human-readable summary of calibration traceability. 

108 """ 

109 parts = [f"Instrument: {self.instrument}"] 

110 if self.serial_number: 

111 parts.append(f"S/N: {self.serial_number}") 

112 if self.calibration_date: 

113 parts.append(f"Cal Date: {self.calibration_date.strftime('%Y-%m-%d')}") 

114 if self.calibration_due_date: 

115 parts.append(f"Due: {self.calibration_due_date.strftime('%Y-%m-%d')}") 

116 if self.calibration_cert_number: 

117 parts.append(f"Cert: {self.calibration_cert_number}") 

118 return ", ".join(parts) 

119 

120 

121@dataclass 

122class TraceMetadata: 

123 """Metadata describing a captured trace. 

124 

125 Contains sample rate, scaling information, acquisition details, 

126 and provenance information for a captured waveform or digital trace. 

127 

128 Attributes: 

129 sample_rate: Sample rate in Hz (required). 

130 vertical_scale: Vertical scale in volts/division (optional). 

131 vertical_offset: Vertical offset in volts (optional). 

132 acquisition_time: Time of acquisition (optional). 

133 trigger_info: Trigger configuration dictionary (optional). 

134 source_file: Path to source file (optional). 

135 channel_name: Name of the channel (optional). 

136 calibration_info: Calibration and instrument traceability information (optional). 

137 

138 Example: 

139 >>> metadata = TraceMetadata(sample_rate=1e9) # 1 GSa/s 

140 >>> print(f"Time base: {metadata.time_base} s/sample") 

141 Time base: 1e-09 s/sample 

142 

143 Example with calibration info: 

144 >>> from datetime import datetime 

145 >>> cal = CalibrationInfo( 

146 ... instrument="Tektronix DPO7254C", 

147 ... calibration_date=datetime(2024, 12, 15) 

148 ... ) 

149 >>> metadata = TraceMetadata(sample_rate=1e9, calibration_info=cal) 

150 >>> print(metadata.calibration_info.traceability_summary) 

151 Instrument: Tektronix DPO7254C, Cal Date: 2024-12-15 

152 

153 References: 

154 IEEE 181-2011: Standard for Transitional Waveform Definitions 

155 ISO/IEC 17025: General Requirements for Testing/Calibration Laboratories 

156 """ 

157 

158 sample_rate: float 

159 vertical_scale: float | None = None 

160 vertical_offset: float | None = None 

161 acquisition_time: datetime | None = None 

162 trigger_info: dict[str, Any] | None = None 

163 source_file: str | None = None 

164 channel_name: str | None = None 

165 calibration_info: CalibrationInfo | None = None 

166 

167 def __post_init__(self) -> None: 

168 """Validate metadata after initialization.""" 

169 if self.sample_rate <= 0: 

170 raise ValueError(f"sample_rate must be positive, got {self.sample_rate}") 

171 

172 @property 

173 def time_base(self) -> float: 

174 """Time between samples in seconds (derived from sample_rate). 

175 

176 Returns: 

177 Time per sample in seconds (1 / sample_rate). 

178 """ 

179 return 1.0 / self.sample_rate 

180 

181 

182@dataclass 

183class WaveformTrace: 

184 """Analog waveform data with metadata. 

185 

186 Stores sampled analog voltage data as a numpy array along with 

187 associated metadata for timing and scaling. 

188 

189 Attributes: 

190 data: Waveform samples as numpy float array. 

191 metadata: Associated trace metadata. 

192 

193 Example: 

194 >>> import numpy as np 

195 >>> data = np.sin(2 * np.pi * 1e6 * np.linspace(0, 1e-3, 1000)) 

196 >>> trace = WaveformTrace(data=data, metadata=TraceMetadata(sample_rate=1e6)) 

197 >>> print(f"Duration: {trace.time_vector[-1]:.6f} seconds") 

198 Duration: 0.000999 seconds 

199 

200 References: 

201 IEEE 1241-2010: Standard for Terminology and Test Methods for ADCs 

202 """ 

203 

204 data: NDArray[np.floating[Any]] 

205 metadata: TraceMetadata 

206 

207 def __post_init__(self) -> None: 

208 """Validate waveform data after initialization.""" 

209 if not isinstance(self.data, np.ndarray): 

210 raise TypeError(f"data must be a numpy array, got {type(self.data).__name__}") 

211 if not np.issubdtype(self.data.dtype, np.floating): 

212 # Convert to float64 if not already floating point 

213 self.data = self.data.astype(np.float64) 

214 

215 @property 

216 def time_vector(self) -> NDArray[np.float64]: 

217 """Time axis in seconds. 

218 

219 Computes a time vector starting from 0, with intervals 

220 determined by the sample rate. 

221 

222 Returns: 

223 Array of time values in seconds, same length as data. 

224 """ 

225 n_samples = len(self.data) 

226 return np.arange(n_samples, dtype=np.float64) * self.metadata.time_base 

227 

228 @property 

229 def duration(self) -> float: 

230 """Total duration of the trace in seconds. 

231 

232 Returns: 

233 Duration from first to last sample in seconds. 

234 """ 

235 if len(self.data) == 0: 

236 return 0.0 

237 return (len(self.data) - 1) * self.metadata.time_base 

238 

239 def __len__(self) -> int: 

240 """Return number of samples in the trace.""" 

241 return len(self.data) 

242 

243 

244@dataclass 

245class DigitalTrace: 

246 """Digital/logic signal data with metadata. 

247 

248 Stores sampled digital signal data as a boolean numpy array, 

249 with optional edge timestamp information. 

250 

251 Attributes: 

252 data: Digital samples as numpy boolean array. 

253 metadata: Associated trace metadata. 

254 edges: Optional list of (timestamp, is_rising) tuples. 

255 

256 Example: 

257 >>> import numpy as np 

258 >>> data = np.array([False, False, True, True, False], dtype=bool) 

259 >>> trace = DigitalTrace(data=data, metadata=TraceMetadata(sample_rate=1e6)) 

260 >>> print(f"High samples: {np.sum(trace.data)}") 

261 High samples: 2 

262 

263 References: 

264 IEEE 1076.6-2004: Standard for VHDL Register Transfer Level Synthesis 

265 """ 

266 

267 data: NDArray[np.bool_] 

268 metadata: TraceMetadata 

269 edges: list[tuple[float, bool]] | None = None 

270 

271 def __post_init__(self) -> None: 

272 """Validate digital data after initialization.""" 

273 if not isinstance(self.data, np.ndarray): 

274 raise TypeError(f"data must be a numpy array, got {type(self.data).__name__}") 

275 if self.data.dtype != np.bool_: 

276 # Convert to boolean if not already 

277 self.data = self.data.astype(np.bool_) 

278 

279 @property 

280 def time_vector(self) -> NDArray[np.float64]: 

281 """Time axis in seconds. 

282 

283 Returns: 

284 Array of time values in seconds, same length as data. 

285 """ 

286 n_samples = len(self.data) 

287 return np.arange(n_samples, dtype=np.float64) * self.metadata.time_base 

288 

289 @property 

290 def duration(self) -> float: 

291 """Total duration of the trace in seconds. 

292 

293 Returns: 

294 Duration from first to last sample in seconds. 

295 """ 

296 if len(self.data) == 0: 

297 return 0.0 

298 return (len(self.data) - 1) * self.metadata.time_base 

299 

300 @property 

301 def rising_edges(self) -> list[float]: 

302 """Timestamps of rising edges. 

303 

304 Returns: 

305 List of timestamps where signal transitions from low to high. 

306 """ 

307 if self.edges is None: 

308 return [] 

309 return [ts for ts, is_rising in self.edges if is_rising] 

310 

311 @property 

312 def falling_edges(self) -> list[float]: 

313 """Timestamps of falling edges. 

314 

315 Returns: 

316 List of timestamps where signal transitions from high to low. 

317 """ 

318 if self.edges is None: 

319 return [] 

320 return [ts for ts, is_rising in self.edges if not is_rising] 

321 

322 def __len__(self) -> int: 

323 """Return number of samples in the trace.""" 

324 return len(self.data) 

325 

326 

327@dataclass 

328class IQTrace: 

329 """I/Q (In-phase/Quadrature) waveform data with metadata. 

330 

331 Stores complex-valued signal data as separate I and Q components, 

332 commonly used for RF and software-defined radio applications. 

333 

334 Attributes: 

335 i_data: In-phase component samples as numpy float array. 

336 q_data: Quadrature component samples as numpy float array. 

337 metadata: Associated trace metadata. 

338 

339 Example: 

340 >>> import numpy as np 

341 >>> t = np.linspace(0, 1e-3, 1000) 

342 >>> i_data = np.cos(2 * np.pi * 1e6 * t) 

343 >>> q_data = np.sin(2 * np.pi * 1e6 * t) 

344 >>> trace = IQTrace(i_data=i_data, q_data=q_data, metadata=TraceMetadata(sample_rate=1e6)) 

345 >>> print(f"Complex samples: {len(trace)}") 

346 Complex samples: 1000 

347 

348 References: 

349 IEEE Std 181-2011: Transitional Waveform Definitions 

350 """ 

351 

352 i_data: NDArray[np.floating[Any]] 

353 q_data: NDArray[np.floating[Any]] 

354 metadata: TraceMetadata 

355 

356 def __post_init__(self) -> None: 

357 """Validate I/Q data after initialization.""" 

358 if not isinstance(self.i_data, np.ndarray): 

359 raise TypeError(f"i_data must be a numpy array, got {type(self.i_data).__name__}") 

360 if not isinstance(self.q_data, np.ndarray): 

361 raise TypeError(f"q_data must be a numpy array, got {type(self.q_data).__name__}") 

362 if len(self.i_data) != len(self.q_data): 

363 raise ValueError( 

364 f"I and Q data must have same length, got {len(self.i_data)} and {len(self.q_data)}" 

365 ) 

366 # Convert to float64 if not already floating point 

367 if not np.issubdtype(self.i_data.dtype, np.floating): 

368 self.i_data = self.i_data.astype(np.float64) 

369 if not np.issubdtype(self.q_data.dtype, np.floating): 

370 self.q_data = self.q_data.astype(np.float64) 

371 

372 @property 

373 def complex_data(self) -> NDArray[np.complex128]: 

374 """Return I/Q data as complex array. 

375 

376 Returns: 

377 Complex array where real=I, imag=Q. 

378 """ 

379 return self.i_data + 1j * self.q_data 

380 

381 @property 

382 def magnitude(self) -> NDArray[np.float64]: 

383 """Magnitude (amplitude) of the complex signal. 

384 

385 Returns: 

386 Array of magnitude values sqrt(I² + Q²). 

387 """ 

388 return np.sqrt(self.i_data**2 + self.q_data**2) 

389 

390 @property 

391 def phase(self) -> NDArray[np.float64]: 

392 """Phase angle of the complex signal in radians. 

393 

394 Returns: 

395 Array of phase values atan2(Q, I). 

396 """ 

397 return np.arctan2(self.q_data, self.i_data) 

398 

399 @property 

400 def time_vector(self) -> NDArray[np.float64]: 

401 """Time axis in seconds. 

402 

403 Returns: 

404 Array of time values in seconds, same length as data. 

405 """ 

406 n_samples = len(self.i_data) 

407 return np.arange(n_samples, dtype=np.float64) * self.metadata.time_base 

408 

409 @property 

410 def duration(self) -> float: 

411 """Total duration of the trace in seconds. 

412 

413 Returns: 

414 Duration from first to last sample in seconds. 

415 """ 

416 if len(self.i_data) == 0: 

417 return 0.0 

418 return (len(self.i_data) - 1) * self.metadata.time_base 

419 

420 def __len__(self) -> int: 

421 """Return number of samples in the trace.""" 

422 return len(self.i_data) 

423 

424 

425@dataclass 

426class ProtocolPacket: 

427 """Decoded protocol packet data. 

428 

429 Represents a decoded packet from a serial protocol (UART, SPI, I2C, etc.) 

430 with timing, data content, annotations, and error information. 

431 

432 Attributes: 

433 timestamp: Start time of the packet in seconds. 

434 protocol: Name of the protocol (e.g., "UART", "SPI", "I2C"). 

435 data: Decoded data bytes. 

436 annotations: Multi-level annotations dictionary (optional). 

437 errors: List of detected errors (optional). 

438 end_timestamp: End time of the packet in seconds (optional). 

439 

440 Example: 

441 >>> packet = ProtocolPacket( 

442 ... timestamp=1.23e-3, 

443 ... protocol="UART", 

444 ... data=b"Hello" 

445 ... ) 

446 >>> print(f"Received at {packet.timestamp}s: {packet.data.decode()}") 

447 Received at 0.00123s: Hello 

448 

449 References: 

450 sigrok Protocol Decoder API 

451 """ 

452 

453 timestamp: float 

454 protocol: str 

455 data: bytes 

456 annotations: dict[str, Any] = field(default_factory=dict) 

457 errors: list[str] = field(default_factory=list) 

458 end_timestamp: float | None = None 

459 

460 def __post_init__(self) -> None: 

461 """Validate packet data after initialization.""" 

462 if self.timestamp < 0: 

463 raise ValueError(f"timestamp must be non-negative, got {self.timestamp}") 

464 if not isinstance(self.data, bytes): 

465 raise TypeError(f"data must be bytes, got {type(self.data).__name__}") 

466 

467 @property 

468 def duration(self) -> float | None: 

469 """Duration of the packet in seconds. 

470 

471 Returns: 

472 Duration if end_timestamp is set, None otherwise. 

473 """ 

474 if self.end_timestamp is None: 

475 return None 

476 return self.end_timestamp - self.timestamp 

477 

478 @property 

479 def has_errors(self) -> bool: 

480 """Check if packet has any errors. 

481 

482 Returns: 

483 True if errors list is non-empty. 

484 """ 

485 return len(self.errors) > 0 

486 

487 def __len__(self) -> int: 

488 """Return number of bytes in the packet.""" 

489 return len(self.data) 

490 

491 

492# Type aliases for convenience 

493Trace = WaveformTrace | DigitalTrace | IQTrace 

494"""Union type for any trace type.""" 

495 

496__all__ = [ 

497 "CalibrationInfo", 

498 "DigitalTrace", 

499 "IQTrace", 

500 "ProtocolPacket", 

501 "Trace", 

502 "TraceMetadata", 

503 "WaveformTrace", 

504]