Coverage for src / tracekit / jupyter / display.py: 68%

111 statements  

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

1"""Rich display integration for Jupyter notebooks. 

2 

3This module provides rich HTML display for TraceKit objects including 

4traces, measurements, and spectral data. 

5 

6 - HTML tables for measurements 

7 - Inline plot rendering 

8 - Interactive result display 

9 

10Example: 

11 In [1]: from tracekit.jupyter.display import display_trace 

12 In [2]: display_trace(trace) # Shows rich HTML summary 

13""" 

14 

15from __future__ import annotations 

16 

17from typing import Any 

18 

19try: 

20 from IPython.display import HTML, SVG, display 

21 

22 IPYTHON_AVAILABLE = True 

23except ImportError: 

24 IPYTHON_AVAILABLE = False 

25 

26 class HTML: # type: ignore[no-redef] 

27 """Fallback HTML class when IPython not available.""" 

28 

29 def __init__(self, data: str) -> None: 

30 self.data = data 

31 

32 class SVG: # type: ignore[no-redef] 

33 """Fallback SVG class when IPython not available.""" 

34 

35 def __init__(self, data: str) -> None: 

36 self.data = data 

37 

38 def display(*args: Any, **kwargs: Any) -> None: # type: ignore[no-redef,misc] 

39 """Fallback display when IPython not available.""" 

40 for arg in args: 

41 print(arg) 

42 

43 

44class TraceDisplay: 

45 """Rich display wrapper for trace objects. 

46 

47 Provides _repr_html_ for Jupyter notebook rendering. 

48 """ 

49 

50 def __init__(self, trace: Any, title: str = "Trace") -> None: 

51 """Initialize trace display. 

52 

53 Args: 

54 trace: WaveformTrace or DigitalTrace object 

55 title: Display title 

56 """ 

57 self.trace = trace 

58 self.title = title 

59 

60 def _repr_html_(self) -> str: 

61 """Generate HTML representation for Jupyter.""" 

62 trace = self.trace 

63 

64 # Build info rows 

65 rows = [] 

66 

67 if hasattr(trace, "data"): 67 ↛ 70line 67 didn't jump to line 70 because the condition on line 67 was always true

68 rows.append(("Samples", f"{len(trace.data):,}")) 

69 

70 if hasattr(trace, "metadata"): 70 ↛ 89line 70 didn't jump to line 89 because the condition on line 70 was always true

71 meta = trace.metadata 

72 if hasattr(meta, "sample_rate") and meta.sample_rate: 72 ↛ 82line 72 didn't jump to line 82 because the condition on line 72 was always true

73 rate = meta.sample_rate 

74 if rate >= 1e9: 74 ↛ 75line 74 didn't jump to line 75 because the condition on line 74 was never true

75 rate_str = f"{rate / 1e9:.3f} GSa/s" 

76 elif rate >= 1e6: 76 ↛ 79line 76 didn't jump to line 79 because the condition on line 76 was always true

77 rate_str = f"{rate / 1e6:.3f} MSa/s" 

78 else: 

79 rate_str = f"{rate / 1e3:.3f} kSa/s" 

80 rows.append(("Sample Rate", rate_str)) 

81 

82 if hasattr(meta, "channel_name") and meta.channel_name: 

83 rows.append(("Channel", meta.channel_name)) 

84 

85 if hasattr(meta, "source_file") and meta.source_file: 85 ↛ 86line 85 didn't jump to line 86 because the condition on line 85 was never true

86 rows.append(("Source", meta.source_file)) 

87 

88 # Calculate duration if possible 

89 if hasattr(trace, "data") and hasattr(trace, "metadata"): 89 ↛ 103line 89 didn't jump to line 103 because the condition on line 89 was always true

90 if hasattr(trace.metadata, "sample_rate") and trace.metadata.sample_rate: 90 ↛ 103line 90 didn't jump to line 103 because the condition on line 90 was always true

91 duration = len(trace.data) / trace.metadata.sample_rate 

92 if duration >= 1: 92 ↛ 93line 92 didn't jump to line 93 because the condition on line 92 was never true

93 dur_str = f"{duration:.3f} s" 

94 elif duration >= 1e-3: 

95 dur_str = f"{duration * 1e3:.3f} ms" 

96 elif duration >= 1e-6: 96 ↛ 99line 96 didn't jump to line 99 because the condition on line 96 was always true

97 dur_str = f"{duration * 1e6:.3f} us" 

98 else: 

99 dur_str = f"{duration * 1e9:.3f} ns" 

100 rows.append(("Duration", dur_str)) 

101 

102 # Data statistics 

103 if hasattr(trace, "data"): 103 ↛ 113line 103 didn't jump to line 113 because the condition on line 103 was always true

104 import numpy as np 

105 

106 data = trace.data 

107 rows.append(("Min", f"{np.min(data):.4g}")) 

108 rows.append(("Max", f"{np.max(data):.4g}")) 

109 rows.append(("Mean", f"{np.mean(data):.4g}")) 

110 rows.append(("Std Dev", f"{np.std(data):.4g}")) 

111 

112 # Build HTML table 

113 html = f""" 

114<div style="border: 1px solid #ccc; border-radius: 4px; padding: 10px; max-width: 400px;"> 

115 <h4 style="margin: 0 0 10px 0; color: #333;">{self.title}</h4> 

116 <table style="width: 100%; border-collapse: collapse;"> 

117""" 

118 for label, value in rows: 

119 html += f""" 

120 <tr> 

121 <td style="padding: 4px 8px; border-bottom: 1px solid #eee; font-weight: bold; color: #666;">{label}</td> 

122 <td style="padding: 4px 8px; border-bottom: 1px solid #eee;">{value}</td> 

123 </tr> 

124""" 

125 html += """ 

126 </table> 

127</div> 

128""" 

129 return html 

130 

131 

132class MeasurementDisplay: 

133 """Rich display wrapper for measurement results. 

134 

135 Provides _repr_html_ for Jupyter notebook rendering. 

136 """ 

137 

138 def __init__(self, measurements: dict[str, Any], title: str = "Measurements") -> None: 

139 """Initialize measurement display. 

140 

141 Args: 

142 measurements: Dictionary of measurement name -> value 

143 title: Display title 

144 """ 

145 self.measurements = measurements 

146 self.title = title 

147 

148 def _format_value(self, value: Any) -> str: 

149 """Format a measurement value with appropriate units.""" 

150 if isinstance(value, float): 

151 # Determine scale and units for common measurements 

152 abs_val = abs(value) 

153 if abs_val == 0: 153 ↛ 154line 153 didn't jump to line 154 because the condition on line 153 was never true

154 return "0" 

155 elif abs_val >= 1e9: 155 ↛ 156line 155 didn't jump to line 156 because the condition on line 155 was never true

156 return f"{value / 1e9:.3f} G" 

157 elif abs_val >= 1e6: 

158 return f"{value / 1e6:.3f} M" 

159 elif abs_val >= 1e3: 159 ↛ 160line 159 didn't jump to line 160 because the condition on line 159 was never true

160 return f"{value / 1e3:.3f} k" 

161 elif abs_val >= 1: 

162 return f"{value:.4f}" 

163 elif abs_val >= 1e-3: 163 ↛ 164line 163 didn't jump to line 164 because the condition on line 163 was never true

164 return f"{value * 1e3:.3f} m" 

165 elif abs_val >= 1e-6: 165 ↛ 166line 165 didn't jump to line 166 because the condition on line 165 was never true

166 return f"{value * 1e6:.3f} u" 

167 elif abs_val >= 1e-9: 167 ↛ 169line 167 didn't jump to line 169 because the condition on line 167 was always true

168 return f"{value * 1e9:.3f} n" 

169 elif abs_val >= 1e-12: 

170 return f"{value * 1e12:.3f} p" 

171 else: 

172 return f"{value:.3e}" 

173 else: 

174 return str(value) 

175 

176 def _repr_html_(self) -> str: 

177 """Generate HTML representation for Jupyter.""" 

178 html = f""" 

179<div style="border: 1px solid #ccc; border-radius: 4px; padding: 10px; max-width: 500px;"> 

180 <h4 style="margin: 0 0 10px 0; color: #333;">{self.title}</h4> 

181 <table style="width: 100%; border-collapse: collapse;"> 

182 <tr style="background-color: #f5f5f5;"> 

183 <th style="padding: 8px; text-align: left; border-bottom: 2px solid #ddd;">Measurement</th> 

184 <th style="padding: 8px; text-align: right; border-bottom: 2px solid #ddd;">Value</th> 

185 </tr> 

186""" 

187 for name, value in self.measurements.items(): 

188 formatted = self._format_value(value) 

189 html += f""" 

190 <tr> 

191 <td style="padding: 6px 8px; border-bottom: 1px solid #eee;">{name}</td> 

192 <td style="padding: 6px 8px; border-bottom: 1px solid #eee; text-align: right; font-family: monospace;">{formatted}</td> 

193 </tr> 

194""" 

195 html += """ 

196 </table> 

197</div> 

198""" 

199 return html 

200 

201 

202def display_trace(trace: Any, title: str = "Trace") -> None: 

203 """Display a trace with rich HTML formatting. 

204 

205 Args: 

206 trace: WaveformTrace or DigitalTrace object 

207 title: Display title 

208 """ 

209 wrapper = TraceDisplay(trace, title) 

210 if IPYTHON_AVAILABLE: 210 ↛ 211line 210 didn't jump to line 211 because the condition on line 210 was never true

211 display(HTML(wrapper._repr_html_())) # type: ignore[no-untyped-call] 

212 else: 

213 print(wrapper._repr_html_()) 

214 

215 

216def display_measurements(measurements: dict[str, Any], title: str = "Measurements") -> None: 

217 """Display measurements with rich HTML formatting. 

218 

219 Args: 

220 measurements: Dictionary of measurement name -> value 

221 title: Display title 

222 """ 

223 wrapper = MeasurementDisplay(measurements, title) 

224 if IPYTHON_AVAILABLE: 224 ↛ 225line 224 didn't jump to line 225 because the condition on line 224 was never true

225 display(HTML(wrapper._repr_html_())) # type: ignore[no-untyped-call] 

226 else: 

227 for name, value in measurements.items(): 

228 print(f"{name}: {value}") 

229 

230 

231def display_spectrum( 

232 frequencies: Any, 

233 magnitudes: Any, 

234 title: str = "Spectrum", 

235 log_scale: bool = True, 

236 figsize: tuple[int, int] = (10, 4), 

237) -> None: 

238 """Display a spectrum plot inline in Jupyter. 

239 

240 Args: 

241 frequencies: Frequency array (Hz) 

242 magnitudes: Magnitude array (dB or linear) 

243 title: Plot title 

244 log_scale: Use log scale for x-axis 

245 figsize: Figure size tuple 

246 """ 

247 import matplotlib.pyplot as plt 

248 import numpy as np 

249 

250 _fig, ax = plt.subplots(figsize=figsize) 

251 

252 if log_scale and np.min(frequencies[frequencies > 0]) > 0: 

253 ax.semilogx(frequencies, magnitudes) 

254 else: 

255 ax.plot(frequencies, magnitudes) 

256 

257 ax.set_xlabel("Frequency (Hz)") 

258 ax.set_ylabel("Magnitude (dB)") 

259 ax.set_title(title) 

260 ax.grid(True, alpha=0.3) 

261 

262 plt.tight_layout() 

263 

264 if IPYTHON_AVAILABLE: 

265 # Display inline 

266 plt.show() 

267 else: 

268 plt.show()