Coverage for src / tracekit / cli / characterize.py: 99%

80 statements  

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

1"""TraceKit Characterize Command implementing CLI-002. 

2 

3Provides CLI for buffer/signal characterization with automatic logic family 

4detection and optional reference comparison. 

5 

6 

7Example: 

8 $ tracekit characterize 74hc04_output.wfm 

9 $ tracekit characterize signal.wfm --logic-family CMOS_3V3 

10 $ tracekit characterize signal.wfm --compare reference.wfm --output html 

11""" 

12 

13from __future__ import annotations 

14 

15import logging 

16from pathlib import Path 

17from typing import TYPE_CHECKING, Any 

18 

19import click 

20 

21from tracekit.cli.main import format_output 

22 

23if TYPE_CHECKING: 

24 from tracekit.core.types import WaveformTrace 

25 

26logger = logging.getLogger("tracekit.cli.characterize") 

27 

28 

29@click.command() # type: ignore[misc] 

30@click.argument("file", type=click.Path(exists=True)) # type: ignore[misc] 

31@click.option( # type: ignore[misc] 

32 "--type", 

33 "analysis_type", 

34 type=click.Choice(["buffer", "signal", "power"], case_sensitive=False), 

35 default="buffer", 

36 help="Type of characterization to perform.", 

37) 

38@click.option( # type: ignore[misc] 

39 "--logic-family", 

40 type=click.Choice( 

41 ["TTL", "CMOS", "CMOS_3V3", "CMOS_5V", "LVTTL", "LVCMOS", "auto"], 

42 case_sensitive=False, 

43 ), 

44 default="auto", 

45 help="Logic family for buffer characterization (default: auto-detect).", 

46) 

47@click.option( # type: ignore[misc] 

48 "--compare", 

49 type=click.Path(exists=True), 

50 default=None, 

51 help="Reference file for comparison analysis.", 

52) 

53@click.option( # type: ignore[misc] 

54 "--output", 

55 type=click.Choice(["json", "csv", "html", "table"], case_sensitive=False), 

56 default="table", 

57 help="Output format (default: table).", 

58) 

59@click.option( # type: ignore[misc] 

60 "--save-report", 

61 type=click.Path(), 

62 default=None, 

63 help="Save HTML report to file.", 

64) 

65@click.pass_context # type: ignore[misc] 

66def characterize( 

67 ctx: click.Context, 

68 file: str, 

69 analysis_type: str, 

70 logic_family: str, 

71 compare: str | None, 

72 output: str, 

73 save_report: str | None, 

74) -> None: 

75 """Characterize buffer, signal, or power measurements. 

76 

77 Analyzes a waveform file and extracts timing, quality, and performance 

78 characteristics. Supports automatic logic family detection and optional 

79 comparison to a reference signal. 

80 

81 Args: 

82 ctx: Click context object. 

83 file: Path to waveform file to characterize. 

84 analysis_type: Type of characterization (buffer, signal, power). 

85 logic_family: Logic family for buffer characterization. 

86 compare: Path to reference file for comparison analysis. 

87 output: Output format (json, csv, html, table). 

88 save_report: Path to save HTML report file. 

89 

90 Raises: 

91 Exception: If characterization fails or file cannot be loaded. 

92 

93 Examples: 

94 

95 \b 

96 # Simple buffer characterization 

97 $ tracekit characterize 74hc04_output.wfm 

98 

99 \b 

100 # Full characterization with reference 

101 $ tracekit characterize signal.wfm \\ 

102 --logic-family CMOS_3V3 \\ 

103 --compare golden_reference.wfm \\ 

104 --save-report report.html 

105 

106 \b 

107 # Power analysis 

108 $ tracekit characterize power_rail.wfm --type power --output json 

109 """ 

110 verbose = ctx.obj.get("verbose", 0) 

111 

112 if verbose: 

113 logger.info(f"Characterizing: {file}") 

114 logger.info(f"Analysis type: {analysis_type}") 

115 logger.info(f"Logic family: {logic_family}") 

116 

117 try: 

118 # Import here to avoid circular imports 

119 from tracekit.loaders import load 

120 

121 # Load the main trace 

122 logger.debug(f"Loading trace from {file}") 

123 trace = load(file) 

124 

125 # Load reference trace if provided 

126 reference_trace: WaveformTrace | None = None 

127 if compare: 

128 logger.debug(f"Loading reference trace from {compare}") 

129 reference_trace = load(compare) # type: ignore[assignment] 

130 

131 # Perform characterization based on type 

132 results = _perform_characterization( 

133 trace=trace, 

134 reference_trace=reference_trace, 

135 analysis_type=analysis_type, 

136 logic_family=logic_family, 

137 ) 

138 

139 # Add metadata 

140 results["file"] = str(Path(file).name) 

141 if compare: 

142 results["reference_file"] = str(Path(compare).name) 

143 

144 # Generate HTML report if requested 

145 if save_report: 

146 html_content = format_output(results, "html") 

147 with open(save_report, "w") as f: 

148 f.write(html_content) 

149 logger.info(f"Report saved to {save_report}") 

150 

151 # Output results 

152 formatted = format_output(results, output) 

153 click.echo(formatted) 

154 

155 except Exception as e: 

156 logger.error(f"Characterization failed: {e}") 

157 if verbose > 1: 

158 raise 

159 click.echo(f"Error: {e}", err=True) 

160 ctx.exit(1) 

161 

162 

163def _perform_characterization( 

164 trace: Any, 

165 reference_trace: Any | None, 

166 analysis_type: str, 

167 logic_family: str, 

168) -> dict[str, Any]: 

169 """Perform characterization analysis. 

170 

171 Calls actual TraceKit analysis functions based on the analysis type. 

172 

173 Args: 

174 trace: Main trace to analyze. 

175 reference_trace: Optional reference trace for comparison. 

176 analysis_type: Type of analysis ('buffer', 'signal', 'power'). 

177 logic_family: Logic family for digital analysis. 

178 

179 Returns: 

180 Dictionary of analysis results. 

181 """ 

182 import numpy as np 

183 

184 from tracekit.analyzers.waveform.measurements import ( 

185 fall_time, 

186 overshoot, 

187 rise_time, 

188 undershoot, 

189 ) 

190 from tracekit.comparison.compare import compare_traces, similarity_score 

191 from tracekit.inference import detect_logic_family 

192 

193 sample_rate = trace.metadata.sample_rate 

194 data = trace.data 

195 

196 results: dict[str, Any] = { 

197 "analysis_type": analysis_type, 

198 "logic_family": logic_family, 

199 "sample_rate": f"{sample_rate / 1e6:.1f} MHz", 

200 "samples": len(data), 

201 "duration": f"{len(data) / sample_rate * 1e3:.3f} ms", 

202 } 

203 

204 if analysis_type == "buffer": 

205 # Buffer characterization using actual workflow (WRK-001) 

206 # Functions expect WaveformTrace, not ndarray 

207 rt = rise_time(trace) 

208 ft = fall_time(trace) 

209 os_pct = overshoot(trace) 

210 us_pct = undershoot(trace) 

211 

212 results.update( 

213 { 

214 "rise_time": f"{rt * 1e9:.2f} ns" if not np.isnan(rt) else "N/A", 

215 "fall_time": f"{ft * 1e9:.2f} ns" if not np.isnan(ft) else "N/A", 

216 "overshoot": f"{os_pct:.1f} %" if not np.isnan(os_pct) else "N/A", 

217 "undershoot": f"{us_pct:.1f} %" if not np.isnan(us_pct) else "N/A", 

218 "status": "PASS", 

219 } 

220 ) 

221 

222 # Logic family detection 

223 if logic_family == "auto": 

224 detected = detect_logic_family(trace) 

225 # detect_logic_family returns dict with 'primary' key 

226 primary = detected.get("primary", {}) 

227 results["logic_family_detected"] = primary.get("name", "unknown") 

228 results["confidence"] = f"{primary.get('confidence', 0) * 100:.0f}%" 

229 else: 

230 results["logic_family_detected"] = logic_family 

231 

232 elif analysis_type == "signal": 

233 # Signal analysis 

234 results.update( 

235 { 

236 "amplitude": f"{float(data.max() - data.min()):.3f} V", 

237 "peak_to_peak": f"{float(data.max() - data.min()):.3f} V", 

238 "mean": f"{float(data.mean()):.3f} V", 

239 "rms": f"{float(np.sqrt((data**2).mean())):.3f} V", 

240 } 

241 ) 

242 

243 elif analysis_type == "power": 243 ↛ 260line 243 didn't jump to line 260 because the condition on line 243 was always true

244 # Power analysis - compute power statistics directly from voltage 

245 # Assume voltage trace, compute power (P = V^2/R, assume R=1 for relative) 

246 power_data = data**2 

247 avg_pwr = float(np.mean(power_data)) 

248 peak_pwr = float(np.max(power_data)) 

249 total_energy = float(np.sum(power_data) / sample_rate) 

250 

251 results.update( 

252 { 

253 "average_power": f"{avg_pwr * 1e3:.3f} mW", 

254 "peak_power": f"{peak_pwr * 1e3:.3f} mW", 

255 "energy": f"{total_energy * 1e6:.3f} uJ", 

256 } 

257 ) 

258 

259 # Add comparison results if reference provided 

260 if reference_trace is not None: 

261 ref_data = reference_trace.data 

262 # similarity_score expects WaveformTrace objects 

263 sim = similarity_score(trace, reference_trace) 

264 # compare_traces returns ComparisonResult dataclass 

265 comparison_result = compare_traces(trace, reference_trace) 

266 

267 results["comparison"] = { 

268 "correlation": f"{comparison_result.correlation:.4f}", 

269 "amplitude_difference": f"{abs(float(data.mean()) - float(ref_data.mean())):.3f} V", 

270 "similarity": f"{sim * 100:.1f}%", 

271 } 

272 

273 return results