Coverage for src / tracekit / cli / compare.py: 95%

186 statements  

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

1"""TraceKit Compare Command implementing CLI-005. 

2 

3Provides CLI for comparing two signal captures with timing, noise, and 

4spectral difference analysis. 

5 

6 

7Example: 

8 $ tracekit compare before.wfm after.wfm 

9 $ tracekit compare golden.wfm measured.wfm --threshold 5 --save-report diff.html 

10""" 

11 

12from __future__ import annotations 

13 

14import logging 

15from pathlib import Path 

16from typing import TYPE_CHECKING, Any 

17 

18import click 

19import numpy as np 

20from numpy.typing import NDArray 

21from scipy import fft, signal 

22 

23from tracekit.cli.main import format_output 

24 

25if TYPE_CHECKING: 

26 from tracekit.core.types import WaveformTrace 

27 

28logger = logging.getLogger("tracekit.cli.compare") 

29 

30 

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

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

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

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

35 "--threshold", 

36 type=float, 

37 default=5.0, 

38 help="Report differences greater than this percentage (default: 5%).", 

39) 

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

41 "--output", 

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

43 default="table", 

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

45) 

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

47 "--save-report", 

48 type=click.Path(), 

49 default=None, 

50 help="Save detailed HTML comparison report.", 

51) 

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

53 "--align", 

54 is_flag=True, 

55 help="Align signals using cross-correlation before comparison.", 

56) 

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

58def compare( 

59 ctx: click.Context, 

60 file1: str, 

61 file2: str, 

62 threshold: float, 

63 output: str, 

64 save_report: str | None, 

65 align: bool, 

66) -> None: 

67 """Compare two signal captures. 

68 

69 Analyzes differences between two waveforms including timing drift, 

70 amplitude changes, noise variations, and spectral differences. 

71 

72 Args: 

73 ctx: Click context object. 

74 file1: Path to first waveform file. 

75 file2: Path to second waveform file. 

76 threshold: Percentage threshold for reporting differences. 

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

78 save_report: Path to save HTML comparison report. 

79 align: Align signals using cross-correlation before comparison. 

80 

81 Raises: 

82 Exception: If comparison fails or files cannot be loaded. 

83 

84 Examples: 

85 

86 \b 

87 # Simple comparison 

88 $ tracekit compare before.wfm after.wfm 

89 

90 \b 

91 # Report only significant differences (>10%) 

92 $ tracekit compare golden.wfm measured.wfm --threshold 10 

93 

94 \b 

95 # Full comparison with alignment and HTML report 

96 $ tracekit compare reference.wfm test.wfm \\ 

97 --align \\ 

98 --save-report comparison.html 

99 

100 \b 

101 # JSON output for automation 

102 $ tracekit compare before.wfm after.wfm --output json 

103 """ 

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

105 

106 if verbose: 

107 logger.info(f"Comparing: {file1} vs {file2}") 

108 logger.info(f"Threshold: {threshold}%") 

109 logger.info(f"Align signals: {align}") 

110 

111 try: 

112 # Import here to avoid circular imports 

113 from tracekit.loaders import load 

114 

115 # Load both traces 

116 logger.debug(f"Loading first trace from {file1}") 

117 trace1 = load(file1) 

118 

119 logger.debug(f"Loading second trace from {file2}") 

120 trace2 = load(file2) 

121 

122 # Perform comparison 

123 results = _perform_comparison( 

124 trace1=trace1, # type: ignore[arg-type] 

125 trace2=trace2, # type: ignore[arg-type] 

126 threshold=threshold, 

127 align_signals=align, 

128 ) 

129 

130 # Add metadata 

131 results["file1"] = str(Path(file1).name) 

132 results["file2"] = str(Path(file2).name) 

133 

134 # Generate HTML report if requested 

135 if save_report: 

136 html_content = _generate_html_report(results, file1, file2) 

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

138 f.write(html_content) 

139 logger.info(f"Comparison report saved to {save_report}") 

140 results["report_saved"] = str(save_report) 

141 

142 # Output results 

143 formatted = format_output(results, output) 

144 click.echo(formatted) 

145 

146 except Exception as e: 

147 logger.error(f"Comparison failed: {e}") 

148 if verbose > 1: 

149 raise 

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

151 ctx.exit(1) 

152 

153 

154def _align_signals( 

155 data1: NDArray[np.float64], 

156 data2: NDArray[np.float64], 

157 sample_rate: float, 

158) -> tuple[NDArray[np.float64], NDArray[np.float64], dict[str, Any]]: 

159 """Align two signals using cross-correlation. 

160 

161 Args: 

162 data1: Reference signal. 

163 data2: Signal to align. 

164 sample_rate: Sample rate in Hz. 

165 

166 Returns: 

167 Tuple of (aligned_data1, aligned_data2, alignment_info). 

168 """ 

169 # Use cross-correlation to find optimal alignment 

170 # For efficiency, use FFT-based correlation 

171 n = len(data1) + len(data2) - 1 

172 n_fft = 2 ** int(np.ceil(np.log2(n))) # Next power of 2 

173 

174 # Compute cross-correlation using FFT 

175 fft1 = fft.fft(data1, n=n_fft) 

176 fft2 = fft.fft(data2, n=n_fft) 

177 cross_corr = fft.ifft(fft1 * np.conj(fft2)).real 

178 

179 # Find peak 

180 peak_idx = np.argmax(np.abs(cross_corr)) 

181 offset = peak_idx - n_fft if peak_idx > n_fft // 2 else peak_idx 

182 

183 # Compute correlation coefficient at peak 

184 corr_peak = cross_corr[peak_idx] / np.sqrt(np.sum(data1**2) * np.sum(data2**2)) 

185 

186 # Apply offset 

187 if offset > 0: 

188 aligned1 = data1[offset:] 

189 aligned2 = data2[: len(aligned1)] 

190 elif offset < 0: 

191 aligned2 = data2[-offset:] 

192 aligned1 = data1[: len(aligned2)] 

193 else: 

194 min_len = min(len(data1), len(data2)) 

195 aligned1 = data1[:min_len] 

196 aligned2 = data2[:min_len] 

197 

198 # Ensure equal length 

199 min_len = min(len(aligned1), len(aligned2)) 

200 aligned1 = aligned1[:min_len] 

201 aligned2 = aligned2[:min_len] 

202 

203 # Calculate timing offset in ns 

204 offset_time_ns = offset / sample_rate * 1e9 

205 

206 alignment_info = { 

207 "offset_samples": int(offset), 

208 "offset_time_ns": f"{offset_time_ns:.2f}", 

209 "correlation_peak": f"{corr_peak:.6f}", 

210 "quality": "excellent" 

211 if abs(corr_peak) > 0.95 

212 else "good" 

213 if abs(corr_peak) > 0.8 

214 else "poor", 

215 } 

216 

217 return aligned1, aligned2, alignment_info 

218 

219 

220def _compute_timing_drift( 

221 data1: NDArray[np.float64], 

222 data2: NDArray[np.float64], 

223 sample_rate: float, 

224) -> dict[str, Any]: 

225 """Compute timing drift between two signals using edge detection. 

226 

227 Args: 

228 data1: Reference signal. 

229 data2: Comparison signal. 

230 sample_rate: Sample rate in Hz. 

231 

232 Returns: 

233 Dictionary with timing drift metrics. 

234 """ 

235 # Find edges using threshold crossing 

236 threshold1 = (np.max(data1) + np.min(data1)) / 2 

237 threshold2 = (np.max(data2) + np.min(data2)) / 2 

238 

239 # Rising edges 

240 edges1 = np.where(np.diff(data1 > threshold1).astype(int) > 0)[0] 

241 edges2 = np.where(np.diff(data2 > threshold2).astype(int) > 0)[0] 

242 

243 if len(edges1) < 2 or len(edges2) < 2: 

244 return { 

245 "value_ns": "N/A", 

246 "percentage": "N/A", 

247 "significant": False, 

248 "note": "Insufficient edges for timing analysis", 

249 } 

250 

251 # Match edges and compute timing differences 

252 # Use nearest-neighbor matching 

253 timing_diffs = [] 

254 for e1 in edges1[: min(100, len(edges1))]: # Limit to first 100 edges 

255 nearest_idx = np.argmin(np.abs(edges2 - e1)) 

256 if abs(edges2[nearest_idx] - e1) < sample_rate * 0.1: # Within 100ms 256 ↛ 254line 256 didn't jump to line 254 because the condition on line 256 was always true

257 timing_diffs.append((edges2[nearest_idx] - e1) / sample_rate) 

258 

259 if len(timing_diffs) < 3: 

260 return { 

261 "value_ns": "N/A", 

262 "percentage": "N/A", 

263 "significant": False, 

264 "note": "Could not match sufficient edges", 

265 } 

266 

267 timing_diffs_arr = np.array(timing_diffs) 

268 mean_drift_ns = float(np.mean(timing_diffs_arr)) * 1e9 

269 std_drift_ns = float(np.std(timing_diffs_arr)) * 1e9 

270 

271 # Calculate period for percentage 

272 periods1 = np.diff(edges1) / sample_rate 

273 mean_period = float(np.mean(periods1)) if len(periods1) > 0 else 1.0 

274 mean_diff = float(np.mean(timing_diffs_arr)) 

275 drift_percent = abs(mean_diff / mean_period * 100) if mean_period > 0 else 0.0 

276 

277 return { 

278 "value_ns": f"{mean_drift_ns:.2f}", 

279 "std_ns": f"{std_drift_ns:.2f}", 

280 "percentage": f"{drift_percent:.4f}%", 

281 "edges_analyzed": len(timing_diffs_arr), 

282 "significant": bool(drift_percent > 0.1), # >0.1% is significant 

283 } 

284 

285 

286def _compute_spectral_difference( 

287 data1: NDArray[np.float64], 

288 data2: NDArray[np.float64], 

289 sample_rate: float, 

290 threshold: float, 

291) -> dict[str, Any]: 

292 """Compute spectral differences between two signals. 

293 

294 Args: 

295 data1: Reference signal. 

296 data2: Comparison signal. 

297 sample_rate: Sample rate in Hz. 

298 threshold: Percentage threshold for significance. 

299 

300 Returns: 

301 Dictionary with spectral comparison metrics. 

302 """ 

303 # Compute FFT for both signals 

304 n = len(data1) 

305 # Use zero-padding for better frequency resolution 

306 # Pad to at least 10x the original length for good interpolation 

307 n_fft = 2 ** int(np.ceil(np.log2(n * 10))) 

308 

309 # Apply window to reduce spectral leakage 

310 window = signal.windows.hann(n) 

311 windowed1 = data1 * window 

312 windowed2 = data2 * window 

313 

314 # Compute magnitude spectra 

315 fft1 = np.abs(fft.rfft(windowed1, n=n_fft)) 

316 fft2 = np.abs(fft.rfft(windowed2, n=n_fft)) 

317 freqs = fft.rfftfreq(n_fft, d=1 / sample_rate) 

318 

319 # Avoid division by zero 

320 fft1 = np.maximum(fft1, 1e-12) 

321 fft2 = np.maximum(fft2, 1e-12) 

322 

323 # Find dominant frequencies 

324 peak1_idx = np.argmax(fft1[1:]) + 1 # Skip DC 

325 peak2_idx = np.argmax(fft2[1:]) + 1 

326 dominant_freq1 = freqs[peak1_idx] 

327 dominant_freq2 = freqs[peak2_idx] 

328 freq_diff = abs(dominant_freq2 - dominant_freq1) 

329 freq_diff_percent = freq_diff / dominant_freq1 * 100 if dominant_freq1 > 0 else 0 

330 

331 # Compute magnitude differences in dB 

332 db_diff = 20 * np.log10(fft2 / fft1) 

333 max_db_diff = np.max(np.abs(db_diff)) 

334 mean_db_diff = np.mean(np.abs(db_diff)) 

335 

336 # Check for harmonic changes 

337 # Find first 5 harmonics of dominant frequency 

338 harmonic_changes = [] 

339 for h in range(1, 6): 

340 harm_freq = dominant_freq1 * h 

341 harm_idx = int(harm_freq / (sample_rate / n_fft)) 

342 if harm_idx < len(fft1): 342 ↛ 339line 342 didn't jump to line 339 because the condition on line 342 was always true

343 harm_db_diff = 20 * np.log10(fft2[harm_idx] / fft1[harm_idx]) 

344 harmonic_changes.append( 

345 { 

346 "harmonic": h, 

347 "frequency_hz": f"{harm_freq:.1f}", 

348 "change_db": f"{harm_db_diff:.2f}", 

349 } 

350 ) 

351 

352 return { 

353 "dominant_freq1_hz": f"{dominant_freq1:.1f}", 

354 "dominant_freq2_hz": f"{dominant_freq2:.1f}", 

355 "freq_diff_hz": f"{freq_diff:.2f}", 

356 "freq_diff_percent": f"{freq_diff_percent:.4f}%", 

357 "max_magnitude_diff_db": f"{max_db_diff:.2f}", 

358 "mean_magnitude_diff_db": f"{mean_db_diff:.2f}", 

359 "harmonic_changes": harmonic_changes[:3], # First 3 harmonics 

360 "significant": bool(freq_diff_percent > threshold or max_db_diff > 6.0), # 6dB = 2x power 

361 } 

362 

363 

364def _perform_comparison( 

365 trace1: WaveformTrace, 

366 trace2: WaveformTrace, 

367 threshold: float, 

368 align_signals: bool, 

369) -> dict[str, Any]: 

370 """Perform comprehensive signal comparison analysis. 

371 

372 Args: 

373 trace1: First trace (reference). 

374 trace2: Second trace (comparison). 

375 threshold: Percentage threshold for reporting differences. 

376 align_signals: Whether to align signals using cross-correlation. 

377 

378 Returns: 

379 Dictionary of comparison results. 

380 """ 

381 sample_rate1 = trace1.metadata.sample_rate 

382 sample_rate2 = trace2.metadata.sample_rate 

383 

384 # Check sample rate compatibility 

385 rate_mismatch = False 

386 if sample_rate1 != sample_rate2: 

387 logger.warning(f"Sample rates differ: {sample_rate1:.2e} vs {sample_rate2:.2e} Hz") 

388 rate_mismatch = True 

389 

390 # Initialize results 

391 results: dict[str, Any] = { 

392 "threshold_percent": threshold, 

393 "aligned": align_signals, 

394 "sample_rate_mismatch": rate_mismatch, 

395 } 

396 

397 # Basic statistics for each trace 

398 results["trace1_stats"] = { 

399 "samples": len(trace1.data), 

400 "sample_rate": f"{sample_rate1 / 1e6:.2f} MHz", 

401 "duration_ms": f"{len(trace1.data) / sample_rate1 * 1e3:.3f} ms", 

402 "mean": f"{float(trace1.data.mean()):.6f} V", 

403 "rms": f"{float(np.sqrt((trace1.data**2).mean())):.6f} V", 

404 "peak_to_peak": f"{float(trace1.data.max() - trace1.data.min()):.6f} V", 

405 "min": f"{float(trace1.data.min()):.6f} V", 

406 "max": f"{float(trace1.data.max()):.6f} V", 

407 } 

408 

409 results["trace2_stats"] = { 

410 "samples": len(trace2.data), 

411 "sample_rate": f"{sample_rate2 / 1e6:.2f} MHz", 

412 "duration_ms": f"{len(trace2.data) / sample_rate2 * 1e3:.3f} ms", 

413 "mean": f"{float(trace2.data.mean()):.6f} V", 

414 "rms": f"{float(np.sqrt((trace2.data**2).mean())):.6f} V", 

415 "peak_to_peak": f"{float(trace2.data.max() - trace2.data.min()):.6f} V", 

416 "min": f"{float(trace2.data.min()):.6f} V", 

417 "max": f"{float(trace2.data.max()):.6f} V", 

418 } 

419 

420 # Prepare data for comparison 

421 data1 = trace1.data.astype(np.float64) 

422 data2 = trace2.data.astype(np.float64) 

423 

424 # Signal alignment using cross-correlation 

425 if align_signals: 

426 data1, data2, alignment_info = _align_signals(data1, data2, sample_rate1) 

427 results["alignment"] = alignment_info 

428 else: 

429 # Ensure equal length 

430 min_len = min(len(data1), len(data2)) 

431 data1 = data1[:min_len] 

432 data2 = data2[:min_len] 

433 

434 # Timing drift analysis 

435 results["timing_drift"] = _compute_timing_drift(data1, data2, sample_rate1) 

436 

437 # Amplitude difference analysis 

438 diff = data2 - data1 

439 abs_diff = np.abs(diff) 

440 

441 mean1 = data1.mean() 

442 mean_diff = float(diff.mean()) 

443 mean_diff_percent = abs(mean_diff / mean1 * 100) if mean1 != 0 else 0 

444 max_diff = float(abs_diff.max()) 

445 rms_diff = float(np.sqrt((diff**2).mean())) 

446 

447 # Compare to reference RMS 

448 rms1 = float(np.sqrt((data1**2).mean())) 

449 rms_diff_percent = rms_diff / rms1 * 100 if rms1 > 0 else 0 

450 

451 results["amplitude_difference"] = { 

452 "mean_diff_v": f"{mean_diff:.6f}", 

453 "mean_diff_percent": f"{mean_diff_percent:.2f}%", 

454 "max_diff_v": f"{max_diff:.6f}", 

455 "rms_diff_v": f"{rms_diff:.6f}", 

456 "rms_diff_percent": f"{rms_diff_percent:.2f}%", 

457 "significant": bool(mean_diff_percent > threshold), 

458 } 

459 

460 # Noise analysis 

461 # Use high-pass filter to extract noise component 

462 nyquist = sample_rate1 / 2 

463 cutoff = min(1000, nyquist * 0.9) # 1kHz or 90% of Nyquist 

464 b, a = signal.butter(4, cutoff / nyquist, btype="high") 

465 

466 try: 

467 noise1 = signal.filtfilt(b, a, data1) 

468 noise2 = signal.filtfilt(b, a, data2) 

469 noise_std1 = float(np.std(noise1)) 

470 noise_std2 = float(np.std(noise2)) 

471 except Exception: 

472 # Fallback to simple std if filter fails 

473 noise_std1 = float(np.std(data1)) 

474 noise_std2 = float(np.std(data2)) 

475 

476 noise_change = ((noise_std2 - noise_std1) / noise_std1 * 100) if noise_std1 != 0 else 0 

477 

478 results["noise_change"] = { 

479 "noise1_v": f"{noise_std1:.6f}", 

480 "noise2_v": f"{noise_std2:.6f}", 

481 "change_percent": f"{noise_change:.2f}%", 

482 "significant": bool(abs(noise_change) > threshold), 

483 } 

484 

485 # Correlation coefficient 

486 if len(data1) > 1 and len(data2) > 1: 486 ↛ 501line 486 didn't jump to line 501 because the condition on line 486 was always true

487 with np.errstate(divide="ignore", invalid="ignore"): 

488 correlation = float(np.corrcoef(data1, data2)[0, 1]) 

489 results["correlation"] = { 

490 "coefficient": f"{correlation:.6f}", 

491 "quality": "excellent" 

492 if correlation > 0.99 

493 else "good" 

494 if correlation > 0.95 

495 else "fair" 

496 if correlation > 0.8 

497 else "poor", 

498 } 

499 

500 # Spectral differences 

501 results["spectral_difference"] = _compute_spectral_difference( 

502 data1, data2, sample_rate1, threshold 

503 ) 

504 

505 # Overall assessment 

506 significant_count = sum( 

507 [ 

508 results.get("amplitude_difference", {}).get("significant", False), 

509 results.get("noise_change", {}).get("significant", False), 

510 results.get("timing_drift", {}).get("significant", False), 

511 results.get("spectral_difference", {}).get("significant", False), 

512 ] 

513 ) 

514 

515 if significant_count == 0: 

516 match_quality = "excellent" 

517 elif significant_count == 1: 517 ↛ 518line 517 didn't jump to line 518 because the condition on line 517 was never true

518 match_quality = "good" 

519 elif significant_count == 2: 519 ↛ 522line 519 didn't jump to line 522 because the condition on line 519 was always true

520 match_quality = "fair" 

521 else: 

522 match_quality = "poor" 

523 

524 results["summary"] = { 

525 "significant_differences": significant_count, 

526 "overall_match": match_quality, 

527 "categories_with_differences": [ 

528 cat 

529 for cat in [ 

530 "amplitude_difference", 

531 "noise_change", 

532 "timing_drift", 

533 "spectral_difference", 

534 ] 

535 if results.get(cat, {}).get("significant", False) 

536 ], 

537 } 

538 

539 return results 

540 

541 

542def _generate_html_report( 

543 results: dict[str, Any], 

544 file1: str, 

545 file2: str, 

546) -> str: 

547 """Generate HTML comparison report. 

548 

549 Args: 

550 results: Comparison results dictionary. 

551 file1: First file path. 

552 file2: Second file path. 

553 

554 Returns: 

555 HTML content as string. 

556 """ 

557 # Get summary info 

558 summary = results.get("summary", {}) 

559 match_quality = summary.get("overall_match", "unknown") 

560 significant_diffs = summary.get("significant_differences", 0) 

561 

562 # Color based on quality 

563 quality_colors = { 

564 "excellent": "#28a745", 

565 "good": "#17a2b8", 

566 "fair": "#ffc107", 

567 "poor": "#dc3545", 

568 } 

569 quality_color = quality_colors.get(match_quality, "#6c757d") 

570 

571 html = f"""<!DOCTYPE html> 

572<html lang="en"> 

573<head> 

574 <meta charset="UTF-8"> 

575 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 

576 <title>TraceKit Signal Comparison Report</title> 

577 <style> 

578 body {{ 

579 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 

580 max-width: 1200px; 

581 margin: 0 auto; 

582 padding: 20px; 

583 background: #f5f5f5; 

584 }} 

585 .header {{ 

586 background: #2c3e50; 

587 color: white; 

588 padding: 20px; 

589 border-radius: 8px 8px 0 0; 

590 }} 

591 .content {{ 

592 background: white; 

593 padding: 20px; 

594 border-radius: 0 0 8px 8px; 

595 box-shadow: 0 2px 4px rgba(0,0,0,0.1); 

596 }} 

597 .summary {{ 

598 background: {quality_color}; 

599 color: white; 

600 padding: 15px; 

601 border-radius: 8px; 

602 margin: 20px 0; 

603 }} 

604 .section {{ 

605 margin: 20px 0; 

606 padding: 15px; 

607 border: 1px solid #e0e0e0; 

608 border-radius: 8px; 

609 }} 

610 .section h3 {{ 

611 margin-top: 0; 

612 color: #2c3e50; 

613 }} 

614 table {{ 

615 width: 100%; 

616 border-collapse: collapse; 

617 }} 

618 th, td {{ 

619 padding: 10px; 

620 text-align: left; 

621 border-bottom: 1px solid #e0e0e0; 

622 }} 

623 th {{ 

624 background: #f8f9fa; 

625 }} 

626 .significant {{ 

627 color: #dc3545; 

628 font-weight: bold; 

629 }} 

630 .ok {{ 

631 color: #28a745; 

632 }} 

633 </style> 

634</head> 

635<body> 

636 <div class="header"> 

637 <h1>TraceKit Signal Comparison Report</h1> 

638 <p>File 1: {Path(file1).name}</p> 

639 <p>File 2: {Path(file2).name}</p> 

640 </div> 

641 

642 <div class="content"> 

643 <div class="summary"> 

644 <h2>Overall Match: {match_quality.upper()}</h2> 

645 <p>{significant_diffs} significant difference(s) detected</p> 

646 </div> 

647 

648 <div class="section"> 

649 <h3>Trace Statistics</h3> 

650 <table> 

651 <tr> 

652 <th>Metric</th> 

653 <th>Trace 1</th> 

654 <th>Trace 2</th> 

655 </tr> 

656 <tr> 

657 <td>Samples</td> 

658 <td>{results.get("trace1_stats", {}).get("samples", "N/A")}</td> 

659 <td>{results.get("trace2_stats", {}).get("samples", "N/A")}</td> 

660 </tr> 

661 <tr> 

662 <td>Sample Rate</td> 

663 <td>{results.get("trace1_stats", {}).get("sample_rate", "N/A")}</td> 

664 <td>{results.get("trace2_stats", {}).get("sample_rate", "N/A")}</td> 

665 </tr> 

666 <tr> 

667 <td>Mean</td> 

668 <td>{results.get("trace1_stats", {}).get("mean", "N/A")}</td> 

669 <td>{results.get("trace2_stats", {}).get("mean", "N/A")}</td> 

670 </tr> 

671 <tr> 

672 <td>RMS</td> 

673 <td>{results.get("trace1_stats", {}).get("rms", "N/A")}</td> 

674 <td>{results.get("trace2_stats", {}).get("rms", "N/A")}</td> 

675 </tr> 

676 <tr> 

677 <td>Peak-to-Peak</td> 

678 <td>{results.get("trace1_stats", {}).get("peak_to_peak", "N/A")}</td> 

679 <td>{results.get("trace2_stats", {}).get("peak_to_peak", "N/A")}</td> 

680 </tr> 

681 </table> 

682 </div> 

683 

684 <div class="section"> 

685 <h3>Amplitude Difference</h3> 

686 <table> 

687 <tr> 

688 <td>Mean Difference</td> 

689 <td>{results.get("amplitude_difference", {}).get("mean_diff_v", "N/A")}</td> 

690 <td class="{"significant" if results.get("amplitude_difference", {}).get("significant") else "ok"}"> 

691 {results.get("amplitude_difference", {}).get("mean_diff_percent", "N/A")} 

692 </td> 

693 </tr> 

694 <tr> 

695 <td>RMS Difference</td> 

696 <td>{results.get("amplitude_difference", {}).get("rms_diff_v", "N/A")}</td> 

697 <td>{results.get("amplitude_difference", {}).get("rms_diff_percent", "N/A")}</td> 

698 </tr> 

699 <tr> 

700 <td>Max Difference</td> 

701 <td colspan="2">{results.get("amplitude_difference", {}).get("max_diff_v", "N/A")}</td> 

702 </tr> 

703 </table> 

704 </div> 

705 

706 <div class="section"> 

707 <h3>Timing Drift</h3> 

708 <table> 

709 <tr> 

710 <td>Mean Drift</td> 

711 <td>{results.get("timing_drift", {}).get("value_ns", "N/A")} ns</td> 

712 <td class="{"significant" if results.get("timing_drift", {}).get("significant") else "ok"}"> 

713 {results.get("timing_drift", {}).get("percentage", "N/A")} 

714 </td> 

715 </tr> 

716 </table> 

717 </div> 

718 

719 <div class="section"> 

720 <h3>Noise Change</h3> 

721 <table> 

722 <tr> 

723 <td>Trace 1 Noise</td> 

724 <td>{results.get("noise_change", {}).get("noise1_v", "N/A")}</td> 

725 </tr> 

726 <tr> 

727 <td>Trace 2 Noise</td> 

728 <td>{results.get("noise_change", {}).get("noise2_v", "N/A")}</td> 

729 </tr> 

730 <tr> 

731 <td>Change</td> 

732 <td class="{"significant" if results.get("noise_change", {}).get("significant") else "ok"}"> 

733 {results.get("noise_change", {}).get("change_percent", "N/A")} 

734 </td> 

735 </tr> 

736 </table> 

737 </div> 

738 

739 <div class="section"> 

740 <h3>Spectral Difference</h3> 

741 <table> 

742 <tr> 

743 <td>Dominant Frequency 1</td> 

744 <td>{results.get("spectral_difference", {}).get("dominant_freq1_hz", "N/A")} Hz</td> 

745 </tr> 

746 <tr> 

747 <td>Dominant Frequency 2</td> 

748 <td>{results.get("spectral_difference", {}).get("dominant_freq2_hz", "N/A")} Hz</td> 

749 </tr> 

750 <tr> 

751 <td>Frequency Difference</td> 

752 <td class="{"significant" if results.get("spectral_difference", {}).get("significant") else "ok"}"> 

753 {results.get("spectral_difference", {}).get("freq_diff_percent", "N/A")} 

754 </td> 

755 </tr> 

756 <tr> 

757 <td>Max Magnitude Difference</td> 

758 <td>{results.get("spectral_difference", {}).get("max_magnitude_diff_db", "N/A")} dB</td> 

759 </tr> 

760 </table> 

761 </div> 

762 

763 <div class="section"> 

764 <h3>Correlation</h3> 

765 <p>Coefficient: {results.get("correlation", {}).get("coefficient", "N/A")}</p> 

766 <p>Quality: {results.get("correlation", {}).get("quality", "N/A")}</p> 

767 </div> 

768 

769 <footer style="margin-top: 30px; text-align: center; color: #6c757d;"> 

770 <p>Generated by TraceKit - Signal Analysis Toolkit</p> 

771 </footer> 

772 </div> 

773</body> 

774</html>""" 

775 return html