Coverage for src / tracekit / config / thresholds.py: 99%

138 statements  

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

1"""Threshold configuration for voltage levels and logic families. 

2 

3This module provides threshold configuration for digital signal analysis 

4including logic family definitions, threshold profiles, and per-analysis 

5overrides. 

6""" 

7 

8from __future__ import annotations 

9 

10import logging 

11import os 

12from dataclasses import dataclass, field 

13from pathlib import Path 

14 

15import yaml 

16 

17from tracekit.config.schema import validate_against_schema 

18from tracekit.core.exceptions import ConfigurationError 

19 

20logger = logging.getLogger(__name__) 

21 

22 

23@dataclass 

24class LogicFamily: 

25 """Logic family voltage threshold definition. 

26 

27 Defines voltage thresholds per IEEE/JEDEC standards for digital 

28 signal interpretation. 

29 

30 Attributes: 

31 name: Logic family name (e.g., "TTL", "CMOS_3V3") 

32 VIH: Input high voltage threshold (V) 

33 VIL: Input low voltage threshold (V) 

34 VOH: Output high voltage (V) 

35 VOL: Output low voltage (V) 

36 VCC: Supply voltage (V) 

37 description: Human-readable description 

38 temperature_range: Operating temperature range (min, max) in C 

39 noise_margin_high: High state noise margin (V) 

40 noise_margin_low: Low state noise margin (V) 

41 source: Origin of definition (builtin, user, file path) 

42 

43 Example: 

44 >>> ttl = LogicFamily( 

45 ... name="TTL", 

46 ... VIH=2.0, VIL=0.8, 

47 ... VOH=2.4, VOL=0.4, 

48 ... VCC=5.0 

49 ... ) 

50 >>> print(f"TTL noise margin high: {ttl.noise_margin_high}V") 

51 """ 

52 

53 name: str 

54 VIH: float # Input high threshold 

55 VIL: float # Input low threshold 

56 VOH: float # Output high level 

57 VOL: float # Output low level 

58 VCC: float = 5.0 

59 description: str = "" 

60 temperature_range: tuple[float, float] = field(default_factory=lambda: (0, 70)) 

61 noise_margin_high: float | None = None 

62 noise_margin_low: float | None = None 

63 source: str = "builtin" 

64 

65 def __post_init__(self) -> None: 

66 """Validate thresholds and compute noise margins.""" 

67 # Validate threshold ordering 

68 if self.VIH <= self.VIL: 

69 raise ConfigurationError( 

70 f"Invalid thresholds for {self.name}: VIH ({self.VIH}V) must be > VIL ({self.VIL}V)" 

71 ) 

72 if self.VOH <= self.VOL: 

73 raise ConfigurationError( 

74 f"Invalid thresholds for {self.name}: VOH ({self.VOH}V) must be > VOL ({self.VOL}V)" 

75 ) 

76 

77 # Compute noise margins if not provided 

78 if self.noise_margin_high is None: 

79 self.noise_margin_high = self.VOH - self.VIH 

80 if self.noise_margin_low is None: 

81 self.noise_margin_low = self.VIL - self.VOL 

82 

83 def get_threshold(self, percent: float = 50.0) -> float: 

84 """Get threshold voltage at given percentage between VIL and VIH. 

85 

86 Args: 

87 percent: Percentage between VIL (0%) and VIH (100%) 

88 

89 Returns: 

90 Threshold voltage 

91 """ 

92 return self.VIL + (self.VIH - self.VIL) * (percent / 100.0) 

93 

94 def with_temperature_derating( 

95 self, temperature: float, derating_factor: float = 0.002 

96 ) -> LogicFamily: 

97 """Create copy with temperature-derated thresholds. 

98 

99 Args: 

100 temperature: Operating temperature in Celsius 

101 derating_factor: Derating factor per degree C (default 0.2%/C) 

102 

103 Returns: 

104 New LogicFamily with adjusted thresholds 

105 """ 

106 # Simple linear derating from nominal 25C 

107 delta_t = temperature - 25.0 

108 factor = 1.0 - (delta_t * derating_factor) 

109 

110 return LogicFamily( 

111 name=f"{self.name}@{temperature}C", 

112 VIH=self.VIH * factor, 

113 VIL=self.VIL * factor, 

114 VOH=self.VOH * factor, 

115 VOL=self.VOL * factor, 

116 VCC=self.VCC, 

117 description=f"{self.description} (derated for {temperature}C)", 

118 temperature_range=self.temperature_range, 

119 source=self.source, 

120 ) 

121 

122 

123@dataclass 

124class ThresholdProfile: 

125 """Named threshold profile combining logic family with adjustments. 

126 

127 Profiles allow users to save and reuse threshold configurations 

128 for specific analysis scenarios. 

129 

130 Attributes: 

131 name: Profile name 

132 base_family: Base logic family name 

133 overrides: Override values for specific thresholds 

134 tolerance: Tolerance percentage (0-100) 

135 description: Profile description 

136 

137 Example: 

138 >>> profile = ThresholdProfile( 

139 ... name="strict_ttl", 

140 ... base_family="TTL", 

141 ... overrides={"VIH": 2.2}, 

142 ... tolerance=0 

143 ... ) 

144 """ 

145 

146 name: str 

147 base_family: str = "TTL" 

148 overrides: dict[str, float] = field(default_factory=dict) 

149 tolerance: float = 0.0 # 0-100% 

150 description: str = "" 

151 

152 def apply_to(self, family: LogicFamily) -> LogicFamily: 

153 """Apply profile overrides to a logic family. 

154 

155 Args: 

156 family: Base logic family 

157 

158 Returns: 

159 New LogicFamily with overrides applied 

160 """ 

161 # Apply tolerance 

162 factor = 1.0 + (self.tolerance / 100.0) 

163 

164 return LogicFamily( 

165 name=f"{family.name}_{self.name}", 

166 VIH=self.overrides.get("VIH", family.VIH), 

167 VIL=self.overrides.get("VIL", family.VIL), 

168 VOH=self.overrides.get("VOH", family.VOH * factor), 

169 VOL=self.overrides.get("VOL", family.VOL / factor), 

170 VCC=self.overrides.get("VCC", family.VCC), 

171 description=f"{family.description} with {self.name} profile", 

172 temperature_range=family.temperature_range, 

173 source="profile", 

174 ) 

175 

176 

177class ThresholdRegistry: 

178 """Registry for logic families and threshold profiles. 

179 

180 Manages built-in and user-defined logic families with support 

181 for runtime overrides and profile switching. 

182 

183 Example: 

184 >>> registry = ThresholdRegistry() 

185 >>> ttl = registry.get_family("TTL") 

186 >>> cmos = registry.get_family("CMOS_3V3") 

187 >>> families = registry.list_families() 

188 """ 

189 

190 _instance: ThresholdRegistry | None = None 

191 

192 def __new__(cls) -> ThresholdRegistry: 

193 """Ensure singleton instance.""" 

194 if cls._instance is None: 

195 cls._instance = super().__new__(cls) 

196 cls._instance._families: dict[str, LogicFamily] = {} # type: ignore[misc, attr-defined] 

197 cls._instance._profiles: dict[str, ThresholdProfile] = {} # type: ignore[misc, attr-defined] 

198 cls._instance._session_overrides: dict[str, float] = {} # type: ignore[misc, attr-defined] 

199 cls._instance._register_builtins() 

200 return cls._instance 

201 

202 def _register_builtins(self) -> None: 

203 """Register built-in logic family definitions.""" 

204 builtins = [ 

205 # TTL 

206 LogicFamily( 

207 name="TTL", 

208 VIH=2.0, 

209 VIL=0.8, 

210 VOH=2.4, 

211 VOL=0.4, 

212 VCC=5.0, 

213 description="Standard TTL (74xx series)", 

214 ), 

215 # CMOS variants 

216 LogicFamily( 

217 name="CMOS_5V", 

218 VIH=3.5, 

219 VIL=1.5, 

220 VOH=4.9, 

221 VOL=0.1, 

222 VCC=5.0, 

223 description="CMOS 5V (74HCxx series)", 

224 ), 

225 LogicFamily( 

226 name="LVTTL_3V3", 

227 VIH=2.0, 

228 VIL=0.8, 

229 VOH=2.4, 

230 VOL=0.4, 

231 VCC=3.3, 

232 description="Low Voltage TTL 3.3V", 

233 ), 

234 LogicFamily( 

235 name="LVCMOS_3V3", 

236 VIH=2.0, 

237 VIL=0.7, 

238 VOH=2.4, 

239 VOL=0.4, 

240 VCC=3.3, 

241 description="Low Voltage CMOS 3.3V", 

242 ), 

243 LogicFamily( 

244 name="LVCMOS_2V5", 

245 VIH=1.7, 

246 VIL=0.7, 

247 VOH=2.0, 

248 VOL=0.4, 

249 VCC=2.5, 

250 description="Low Voltage CMOS 2.5V", 

251 ), 

252 LogicFamily( 

253 name="LVCMOS_1V8", 

254 VIH=1.17, 

255 VIL=0.63, 

256 VOH=1.35, 

257 VOL=0.45, 

258 VCC=1.8, 

259 description="Low Voltage CMOS 1.8V", 

260 ), 

261 LogicFamily( 

262 name="LVCMOS_1V5", 

263 VIH=0.975, 

264 VIL=0.525, 

265 VOH=1.125, 

266 VOL=0.375, 

267 VCC=1.5, 

268 description="Low Voltage CMOS 1.5V", 

269 ), 

270 # ECL 

271 LogicFamily( 

272 name="ECL", 

273 VIH=-0.9, 

274 VIL=-1.7, 

275 VOH=-0.9, 

276 VOL=-1.75, 

277 VCC=-5.2, 

278 description="Emitter-Coupled Logic (ECL 10K)", 

279 ), 

280 ] 

281 

282 for family in builtins: 

283 self._families[family.name] = family # type: ignore[attr-defined] 

284 

285 # Built-in profiles 

286 builtin_profiles = [ 

287 ThresholdProfile( 

288 name="strict", 

289 base_family="TTL", 

290 tolerance=0, 

291 description="Exact specification values", 

292 ), 

293 ThresholdProfile( 

294 name="relaxed", 

295 base_family="TTL", 

296 tolerance=20, 

297 description="20% tolerance for real-world signals", 

298 ), 

299 ThresholdProfile( 

300 name="auto", 

301 base_family="TTL", 

302 tolerance=10, 

303 description="Auto-adjusted based on signal confidence", 

304 ), 

305 ] 

306 

307 for profile in builtin_profiles: 

308 self._profiles[profile.name] = profile # type: ignore[attr-defined] 

309 

310 def get_family(self, name: str) -> LogicFamily: 

311 """Get logic family by name. 

312 

313 Args: 

314 name: Logic family name (case-insensitive) 

315 

316 Returns: 

317 Logic family definition 

318 

319 Raises: 

320 KeyError: If family not found 

321 """ 

322 # Try exact match first 

323 if name in self._families: # type: ignore[attr-defined] 

324 family = self._families[name] # type: ignore[attr-defined] 

325 # Try case-insensitive match 

326 elif name.upper() in self._families: # type: ignore[attr-defined] 

327 family = self._families[name.upper()] # type: ignore[attr-defined] 

328 else: 

329 available = list(self._families.keys()) # type: ignore[attr-defined] 

330 raise KeyError(f"Logic family '{name}' not found. Available: {available}") 

331 

332 # Apply session overrides 

333 if self._session_overrides: # type: ignore[attr-defined] 

334 return LogicFamily( 

335 name=family.name, 

336 VIH=self._session_overrides.get("VIH", family.VIH), # type: ignore[attr-defined] 

337 VIL=self._session_overrides.get("VIL", family.VIL), # type: ignore[attr-defined] 

338 VOH=self._session_overrides.get("VOH", family.VOH), # type: ignore[attr-defined] 

339 VOL=self._session_overrides.get("VOL", family.VOL), # type: ignore[attr-defined] 

340 VCC=self._session_overrides.get("VCC", family.VCC), # type: ignore[attr-defined] 

341 description=family.description, 

342 temperature_range=family.temperature_range, 

343 source="override", 

344 ) 

345 

346 return family # type: ignore[no-any-return] 

347 

348 def list_families(self) -> list[str]: 

349 """List all available logic families. 

350 

351 Returns: 

352 List of family names 

353 """ 

354 return sorted(self._families.keys()) # type: ignore[attr-defined] 

355 

356 def register_family(self, family: LogicFamily, *, namespace: str = "user") -> None: 

357 """Register custom logic family. 

358 

359 Args: 

360 family: Logic family definition 

361 namespace: Namespace prefix for custom families 

362 

363 Example: 

364 >>> custom = LogicFamily(name="my_custom", VIH=2.5, VIL=1.0, VOH=3.0, VOL=0.5) 

365 >>> registry.register_family(custom) 

366 >>> # Available as "user.my_custom" 

367 """ 

368 # Namespace custom families 

369 if namespace and not family.name.startswith(f"{namespace}."): 369 ↛ 372line 369 didn't jump to line 372 because the condition on line 369 was always true

370 name = f"{namespace}.{family.name}" 

371 else: 

372 name = family.name 

373 

374 # Update family with new name 

375 family = LogicFamily( 

376 name=name, 

377 VIH=family.VIH, 

378 VIL=family.VIL, 

379 VOH=family.VOH, 

380 VOL=family.VOL, 

381 VCC=family.VCC, 

382 description=family.description, 

383 temperature_range=family.temperature_range, 

384 source=family.source, 

385 ) 

386 

387 self._families[name] = family # type: ignore[attr-defined] 

388 logger.info(f"Registered custom logic family: {name}") 

389 

390 def set_threshold_override(self, **kwargs: float) -> None: 

391 """Set session-level threshold overrides. 

392 

393 Overrides persist for session lifetime until reset. 

394 

395 Args: 

396 **kwargs: Threshold overrides (VIH, VIL, VOH, VOL, VCC) 

397 

398 Raises: 

399 ValueError: If invalid threshold key or value out of range. 

400 

401 Example: 

402 >>> registry.set_threshold_override(VIH=2.5, VIL=0.7) 

403 """ 

404 valid_keys = {"VIH", "VIL", "VOH", "VOL", "VCC"} 

405 for key, value in kwargs.items(): 

406 if key not in valid_keys: 

407 raise ValueError(f"Invalid threshold key: {key}. Valid: {valid_keys}") 

408 if not 0.0 <= value <= 10.0: 

409 raise ValueError(f"Threshold {key}={value}V out of range (0-10V)") 

410 self._session_overrides[key] = value # type: ignore[attr-defined] 

411 

412 logger.info(f"Set threshold overrides: {kwargs}") 

413 

414 def reset_overrides(self) -> None: 

415 """Reset session threshold overrides.""" 

416 self._session_overrides.clear() # type: ignore[attr-defined] 

417 logger.info("Reset threshold overrides") 

418 

419 def get_profile(self, name: str) -> ThresholdProfile: 

420 """Get threshold profile by name. 

421 

422 Args: 

423 name: Profile name 

424 

425 Returns: 

426 Threshold profile 

427 

428 Raises: 

429 KeyError: If profile not found. 

430 """ 

431 if name not in self._profiles: # type: ignore[attr-defined] 

432 raise KeyError(f"Profile '{name}' not found. Available: {list(self._profiles.keys())}") # type: ignore[attr-defined] 

433 return self._profiles[name] # type: ignore[no-any-return, attr-defined] 

434 

435 def apply_profile(self, name: str) -> LogicFamily: 

436 """Apply a threshold profile. 

437 

438 Args: 

439 name: Profile name 

440 

441 Returns: 

442 Logic family with profile applied 

443 """ 

444 profile = self.get_profile(name) 

445 base_family = self.get_family(profile.base_family) 

446 return profile.apply_to(base_family) 

447 

448 def save_profile( 

449 self, name: str, base_family: str | None = None, path: str | Path | None = None 

450 ) -> None: 

451 """Save current settings as named profile. 

452 

453 Args: 

454 name: Profile name 

455 base_family: Base family name (default: "TTL") 

456 path: Optional file path to save 

457 

458 Example: 

459 >>> registry.set_threshold_override(VIH=2.5) 

460 >>> registry.save_profile("my_profile") 

461 """ 

462 profile = ThresholdProfile( 

463 name=name, 

464 base_family=base_family or "TTL", 

465 overrides=dict(self._session_overrides), # type: ignore[attr-defined] 

466 description=f"User profile {name}", 

467 ) 

468 self._profiles[name] = profile # type: ignore[attr-defined] 

469 

470 if path: 

471 path = Path(path) 

472 data = { 

473 "name": profile.name, 

474 "base_family": profile.base_family, 

475 "overrides": profile.overrides, 

476 "tolerance": profile.tolerance, 

477 "description": profile.description, 

478 } 

479 with open(path, "w", encoding="utf-8") as f: 

480 yaml.dump(data, f) 

481 logger.info(f"Saved profile to {path}") 

482 

483 

484def load_logic_family(path: str | Path) -> LogicFamily: 

485 """Load logic family from YAML/JSON file. 

486 

487 Args: 

488 path: Path to file 

489 

490 Returns: 

491 Loaded logic family 

492 """ 

493 path = Path(path) 

494 

495 with open(path, encoding="utf-8") as f: 

496 data = yaml.safe_load(f) 

497 

498 # Validate against schema 

499 validate_against_schema(data, "logic_family") 

500 

501 return LogicFamily( 

502 name=data["name"], 

503 VIH=data["VIH"], 

504 VIL=data["VIL"], 

505 VOH=data["VOH"], 

506 VOL=data["VOL"], 

507 VCC=data.get("VCC", 5.0), 

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

509 temperature_range=tuple(data.get("temperature_range", {}).values()) or (0, 70), 

510 noise_margin_high=data.get("noise_margin_high"), 

511 noise_margin_low=data.get("noise_margin_low"), 

512 source=str(path), 

513 ) 

514 

515 

516def get_threshold_registry() -> ThresholdRegistry: 

517 """Get the global threshold registry. 

518 

519 Returns: 

520 Global ThresholdRegistry instance 

521 """ 

522 return ThresholdRegistry() 

523 

524 

525def get_user_logic_families_dir() -> Path: 

526 """Get user directory for custom logic families. 

527 

528 Returns: 

529 Path to ~/.tracekit/logic_families/ 

530 """ 

531 home = Path.home() 

532 xdg_config = os.environ.get("XDG_CONFIG_HOME") 

533 base = Path(xdg_config) if xdg_config else home / ".config" 

534 

535 dir_path = base / "tracekit" / "logic_families" 

536 dir_path.mkdir(parents=True, exist_ok=True) 

537 return dir_path 

538 

539 

540def load_user_logic_families() -> list[LogicFamily]: 

541 """Load all user-defined logic families. 

542 

543 Returns: 

544 List of loaded logic families 

545 """ 

546 families = [] 

547 user_dir = get_user_logic_families_dir() 

548 

549 for file_path in user_dir.glob("*.yaml"): 

550 try: 

551 family = load_logic_family(file_path) 

552 families.append(family) 

553 except Exception as e: 

554 logger.warning(f"Failed to load logic family from {file_path}: {e}") 

555 

556 return families 

557 

558 

559__all__ = [ 

560 "LogicFamily", 

561 "ThresholdProfile", 

562 "ThresholdRegistry", 

563 "get_threshold_registry", 

564 "get_user_logic_families_dir", 

565 "load_logic_family", 

566 "load_user_logic_families", 

567]