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
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
1"""TraceKit Compare Command implementing CLI-005.
3Provides CLI for comparing two signal captures with timing, noise, and
4spectral difference analysis.
7Example:
8 $ tracekit compare before.wfm after.wfm
9 $ tracekit compare golden.wfm measured.wfm --threshold 5 --save-report diff.html
10"""
12from __future__ import annotations
14import logging
15from pathlib import Path
16from typing import TYPE_CHECKING, Any
18import click
19import numpy as np
20from numpy.typing import NDArray
21from scipy import fft, signal
23from tracekit.cli.main import format_output
25if TYPE_CHECKING:
26 from tracekit.core.types import WaveformTrace
28logger = logging.getLogger("tracekit.cli.compare")
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.
69 Analyzes differences between two waveforms including timing drift,
70 amplitude changes, noise variations, and spectral differences.
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.
81 Raises:
82 Exception: If comparison fails or files cannot be loaded.
84 Examples:
86 \b
87 # Simple comparison
88 $ tracekit compare before.wfm after.wfm
90 \b
91 # Report only significant differences (>10%)
92 $ tracekit compare golden.wfm measured.wfm --threshold 10
94 \b
95 # Full comparison with alignment and HTML report
96 $ tracekit compare reference.wfm test.wfm \\
97 --align \\
98 --save-report comparison.html
100 \b
101 # JSON output for automation
102 $ tracekit compare before.wfm after.wfm --output json
103 """
104 verbose = ctx.obj.get("verbose", 0)
106 if verbose:
107 logger.info(f"Comparing: {file1} vs {file2}")
108 logger.info(f"Threshold: {threshold}%")
109 logger.info(f"Align signals: {align}")
111 try:
112 # Import here to avoid circular imports
113 from tracekit.loaders import load
115 # Load both traces
116 logger.debug(f"Loading first trace from {file1}")
117 trace1 = load(file1)
119 logger.debug(f"Loading second trace from {file2}")
120 trace2 = load(file2)
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 )
130 # Add metadata
131 results["file1"] = str(Path(file1).name)
132 results["file2"] = str(Path(file2).name)
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)
142 # Output results
143 formatted = format_output(results, output)
144 click.echo(formatted)
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)
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.
161 Args:
162 data1: Reference signal.
163 data2: Signal to align.
164 sample_rate: Sample rate in Hz.
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
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
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
183 # Compute correlation coefficient at peak
184 corr_peak = cross_corr[peak_idx] / np.sqrt(np.sum(data1**2) * np.sum(data2**2))
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]
198 # Ensure equal length
199 min_len = min(len(aligned1), len(aligned2))
200 aligned1 = aligned1[:min_len]
201 aligned2 = aligned2[:min_len]
203 # Calculate timing offset in ns
204 offset_time_ns = offset / sample_rate * 1e9
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 }
217 return aligned1, aligned2, alignment_info
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.
227 Args:
228 data1: Reference signal.
229 data2: Comparison signal.
230 sample_rate: Sample rate in Hz.
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
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]
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 }
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)
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 }
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
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
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 }
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.
294 Args:
295 data1: Reference signal.
296 data2: Comparison signal.
297 sample_rate: Sample rate in Hz.
298 threshold: Percentage threshold for significance.
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)))
309 # Apply window to reduce spectral leakage
310 window = signal.windows.hann(n)
311 windowed1 = data1 * window
312 windowed2 = data2 * window
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)
319 # Avoid division by zero
320 fft1 = np.maximum(fft1, 1e-12)
321 fft2 = np.maximum(fft2, 1e-12)
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
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))
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 )
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 }
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.
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.
378 Returns:
379 Dictionary of comparison results.
380 """
381 sample_rate1 = trace1.metadata.sample_rate
382 sample_rate2 = trace2.metadata.sample_rate
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
390 # Initialize results
391 results: dict[str, Any] = {
392 "threshold_percent": threshold,
393 "aligned": align_signals,
394 "sample_rate_mismatch": rate_mismatch,
395 }
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 }
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 }
420 # Prepare data for comparison
421 data1 = trace1.data.astype(np.float64)
422 data2 = trace2.data.astype(np.float64)
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]
434 # Timing drift analysis
435 results["timing_drift"] = _compute_timing_drift(data1, data2, sample_rate1)
437 # Amplitude difference analysis
438 diff = data2 - data1
439 abs_diff = np.abs(diff)
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()))
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
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 }
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")
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))
476 noise_change = ((noise_std2 - noise_std1) / noise_std1 * 100) if noise_std1 != 0 else 0
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 }
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 }
500 # Spectral differences
501 results["spectral_difference"] = _compute_spectral_difference(
502 data1, data2, sample_rate1, threshold
503 )
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 )
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"
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 }
539 return results
542def _generate_html_report(
543 results: dict[str, Any],
544 file1: str,
545 file2: str,
546) -> str:
547 """Generate HTML comparison report.
549 Args:
550 results: Comparison results dictionary.
551 file1: First file path.
552 file2: Second file path.
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)
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")
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>
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>
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>
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>
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>
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>
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>
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>
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