Coverage for src / tracekit / visualization / time_axis.py: 100%

85 statements  

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

1"""Time-aware X-axis formatting and optimization. 

2 

3This module provides intelligent time axis formatting with automatic unit 

4selection, relative time offsets, and cursor readout with full precision. 

5 

6 

7Example: 

8 >>> from tracekit.visualization.time_axis import format_time_axis 

9 >>> labels = format_time_axis(time_values, unit="auto") 

10 

11References: 

12 - SI prefixes for time units 

13 - IEEE publication time axis standards 

14 - Matplotlib formatter customization 

15""" 

16 

17from __future__ import annotations 

18 

19from typing import TYPE_CHECKING, Literal 

20 

21import numpy as np 

22 

23if TYPE_CHECKING: 

24 from numpy.typing import NDArray 

25 

26TimeUnit = Literal["s", "ms", "us", "ns", "ps", "auto"] 

27 

28 

29def select_time_unit( 

30 time_range: float, 

31 *, 

32 prefer_larger: bool = False, 

33) -> TimeUnit: 

34 """Automatically select appropriate time unit based on range. 

35 

36 Args: 

37 time_range: Time range in seconds. 

38 prefer_larger: Prefer larger units when ambiguous. 

39 

40 Returns: 

41 Selected time unit ("s", "ms", "us", "ns", "ps"). 

42 

43 Example: 

44 >>> select_time_unit(0.001) # 1 ms 

45 'ms' 

46 >>> select_time_unit(1e-6) # 1 us 

47 'us' 

48 

49 References: 

50 VIS-014: Adaptive X-Axis Time Window 

51 """ 

52 if time_range >= 1.0: 

53 return "s" 

54 elif time_range >= 1e-3: 

55 return "ms" if not prefer_larger else "s" 

56 elif time_range >= 1e-6: 

57 return "us" if not prefer_larger else "ms" 

58 elif time_range >= 1e-9: 

59 return "ns" if not prefer_larger else "us" 

60 else: 

61 return "ps" if not prefer_larger else "ns" 

62 

63 

64def convert_time_values( 

65 time: NDArray[np.float64], 

66 unit: TimeUnit, 

67) -> NDArray[np.float64]: 

68 """Convert time values to specified unit. 

69 

70 Args: 

71 time: Time array in seconds. 

72 unit: Target time unit. 

73 

74 Returns: 

75 Time array in target unit. 

76 

77 Raises: 

78 ValueError: If unit is invalid. 

79 

80 Example: 

81 >>> time_s = np.array([0.001, 0.002, 0.003]) 

82 >>> time_ms = convert_time_values(time_s, "ms") 

83 >>> # Returns [1.0, 2.0, 3.0] 

84 

85 References: 

86 VIS-014: Adaptive X-Axis Time Window 

87 """ 

88 multipliers = { 

89 "s": 1.0, 

90 "ms": 1e3, 

91 "us": 1e6, 

92 "ns": 1e9, 

93 "ps": 1e12, 

94 } 

95 

96 if unit == "auto": 

97 time_range = float(np.ptp(time)) 

98 unit = select_time_unit(time_range) 

99 

100 if unit not in multipliers: 

101 raise ValueError(f"Invalid time unit: {unit}") 

102 

103 return time * multipliers[unit] 

104 

105 

106def format_time_labels( 

107 time: NDArray[np.float64], 

108 unit: TimeUnit = "auto", 

109 *, 

110 precision: int | None = None, 

111 scientific_threshold: float = 1e6, 

112) -> list[str]: 

113 """Format time values as labels with appropriate precision. 

114 

115 Args: 

116 time: Time array in seconds. 

117 unit: Time unit ("s", "ms", "us", "ns", "ps", "auto"). 

118 precision: Number of decimal places (auto if None). 

119 scientific_threshold: Use scientific notation above this value. 

120 

121 Returns: 

122 List of formatted time labels. 

123 

124 Example: 

125 >>> time = np.array([0.0, 0.001, 0.002]) 

126 >>> labels = format_time_labels(time, unit="ms") 

127 >>> # Returns ['0', '1', '2'] 

128 

129 References: 

130 VIS-014: Adaptive X-Axis Time Window 

131 """ 

132 # Convert to target unit 

133 time_converted = convert_time_values(time, unit) 

134 

135 # Auto-select precision based on value range 

136 if precision is None: 

137 value_range = np.ptp(time_converted) 

138 if value_range == 0: 

139 precision = 1 

140 else: 

141 # Use enough precision to show differences 

142 magnitude = np.log10(value_range) 

143 precision = max(0, int(np.ceil(2 - magnitude))) 

144 

145 # Format labels 

146 labels = [] 

147 for val in time_converted: 

148 if abs(val) >= scientific_threshold: 

149 # Scientific notation 

150 labels.append(f"{val:.{precision}e}") 

151 else: 

152 # Fixed point 

153 labels.append(f"{val:.{precision}f}".rstrip("0").rstrip(".")) 

154 

155 return labels 

156 

157 

158def create_relative_time( 

159 time: NDArray[np.float64], 

160 *, 

161 start_at_zero: bool = True, 

162 reference_time: float | None = None, 

163) -> NDArray[np.float64]: 

164 """Create relative time axis starting at zero or reference. 

165 

166 Args: 

167 time: Absolute time array in seconds. 

168 start_at_zero: Start time axis at t=0. 

169 reference_time: Reference time (uses first sample if None). 

170 

171 Returns: 

172 Relative time array. 

173 

174 Example: 

175 >>> time_abs = np.array([1000.5, 1000.6, 1000.7]) 

176 >>> time_rel = create_relative_time(time_abs) 

177 >>> # Returns [0.0, 0.1, 0.2] 

178 

179 References: 

180 VIS-014: Adaptive X-Axis Time Window 

181 """ 

182 if len(time) == 0: 

183 return time 

184 

185 if reference_time is None: 

186 reference_time = time[0] if start_at_zero else 0.0 

187 

188 return time - reference_time 

189 

190 

191def calculate_major_ticks( 

192 time_min: float, 

193 time_max: float, 

194 *, 

195 target_count: int = 7, 

196 unit: TimeUnit = "auto", 

197) -> NDArray[np.float64]: 

198 """Calculate major tick positions for time axis. 

199 

200 Args: 

201 time_min: Minimum time value in seconds. 

202 time_max: Maximum time value in seconds. 

203 target_count: Target number of major ticks. 

204 unit: Time unit for tick alignment. 

205 

206 Returns: 

207 Array of major tick positions in seconds. 

208 

209 Example: 

210 >>> ticks = calculate_major_ticks(0, 0.01, target_count=5, unit="ms") 

211 

212 References: 

213 VIS-014: Adaptive X-Axis Time Window 

214 VIS-019: Grid Auto-Spacing 

215 """ 

216 time_range = time_max - time_min 

217 

218 if time_range <= 0: 

219 return np.array([time_min]) 

220 

221 # Select unit if auto 

222 if unit == "auto": 

223 unit = select_time_unit(time_range) 

224 

225 # Convert to selected unit 

226 multipliers = { 

227 "s": 1.0, 

228 "ms": 1e3, 

229 "us": 1e6, 

230 "ns": 1e9, 

231 "ps": 1e12, 

232 } 

233 multiplier = multipliers[unit] 

234 

235 time_min_unit = time_min * multiplier 

236 time_max_unit = time_max * multiplier 

237 range_unit = time_max_unit - time_min_unit 

238 

239 # Calculate rough spacing 

240 rough_spacing = range_unit / target_count 

241 

242 # Round to nice number 

243 nice_spacing = _round_to_nice_time(rough_spacing) 

244 

245 # Generate ticks 

246 first_tick = np.ceil(time_min_unit / nice_spacing) * nice_spacing 

247 n_ticks = int((time_max_unit - first_tick) / nice_spacing) + 1 

248 

249 ticks_unit = first_tick + np.arange(n_ticks) * nice_spacing 

250 

251 # Convert back to seconds 

252 ticks = ticks_unit / multiplier 

253 

254 # Filter to range 

255 filtered_ticks: NDArray[np.float64] = ticks[(ticks >= time_min) & (ticks <= time_max)] 

256 

257 return filtered_ticks 

258 

259 

260def _round_to_nice_time(value: float) -> float: 

261 """Round to nice time value (1, 2, 5, 10, 20, 50 × 10^n). # noqa: RUF002 

262 

263 Args: 

264 value: Value to round. 

265 

266 Returns: 

267 Nice rounded value. 

268 """ 

269 if value <= 0: 

270 return 1.0 

271 

272 exponent = np.floor(np.log10(value)) 

273 mantissa = value / (10**exponent) 

274 

275 # Nice fractions for time 

276 nice_fractions = [1.0, 2.0, 5.0, 10.0] 

277 

278 # Find closest 

279 distances = [abs(f - mantissa) for f in nice_fractions] 

280 min_idx = np.argmin(distances) 

281 nice_mantissa = nice_fractions[min_idx] 

282 

283 # Handle overflow 

284 if nice_mantissa >= 10.0: 

285 nice_mantissa = 1.0 

286 exponent += 1 

287 

288 return nice_mantissa * (10**exponent) # type: ignore[no-any-return] 

289 

290 

291def format_cursor_readout( 

292 time_value: float, 

293 *, 

294 unit: TimeUnit = "auto", 

295 full_precision: bool = True, 

296) -> str: 

297 """Format time value for cursor readout with full precision. 

298 

299 Args: 

300 time_value: Time value in seconds. 

301 unit: Display unit. 

302 full_precision: Show full floating-point precision. 

303 

304 Returns: 

305 Formatted time string. 

306 

307 Example: 

308 >>> readout = format_cursor_readout(1.23456789e-6, unit="us") 

309 >>> # Returns "1.23456789 μs" 

310 

311 References: 

312 VIS-014: Adaptive X-Axis Time Window (cursor readout) 

313 """ 

314 # Select unit if auto 

315 if unit == "auto": 

316 unit = select_time_unit(abs(time_value)) 

317 

318 # Convert to unit 

319 time_converted = convert_time_values(np.array([time_value]), unit)[0] 

320 

321 # Unit symbols 

322 unit_symbols = { 

323 "s": "s", 

324 "ms": "ms", 

325 "us": "μs", 

326 "ns": "ns", 

327 "ps": "ps", 

328 } 

329 

330 symbol = unit_symbols.get(unit, unit) 

331 

332 # Format with appropriate precision 

333 if full_precision: 

334 # Maximum useful precision (avoid floating point noise) 

335 formatted = f"{time_converted:.12g}" 

336 else: 

337 # Standard precision 

338 formatted = f"{time_converted:.6g}" 

339 

340 return f"{formatted} {symbol}" 

341 

342 

343__all__ = [ 

344 "TimeUnit", 

345 "calculate_major_ticks", 

346 "convert_time_values", 

347 "create_relative_time", 

348 "format_cursor_readout", 

349 "format_time_labels", 

350 "select_time_unit", 

351]