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
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
1"""Time-aware X-axis formatting and optimization.
3This module provides intelligent time axis formatting with automatic unit
4selection, relative time offsets, and cursor readout with full precision.
7Example:
8 >>> from tracekit.visualization.time_axis import format_time_axis
9 >>> labels = format_time_axis(time_values, unit="auto")
11References:
12 - SI prefixes for time units
13 - IEEE publication time axis standards
14 - Matplotlib formatter customization
15"""
17from __future__ import annotations
19from typing import TYPE_CHECKING, Literal
21import numpy as np
23if TYPE_CHECKING:
24 from numpy.typing import NDArray
26TimeUnit = Literal["s", "ms", "us", "ns", "ps", "auto"]
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.
36 Args:
37 time_range: Time range in seconds.
38 prefer_larger: Prefer larger units when ambiguous.
40 Returns:
41 Selected time unit ("s", "ms", "us", "ns", "ps").
43 Example:
44 >>> select_time_unit(0.001) # 1 ms
45 'ms'
46 >>> select_time_unit(1e-6) # 1 us
47 'us'
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"
64def convert_time_values(
65 time: NDArray[np.float64],
66 unit: TimeUnit,
67) -> NDArray[np.float64]:
68 """Convert time values to specified unit.
70 Args:
71 time: Time array in seconds.
72 unit: Target time unit.
74 Returns:
75 Time array in target unit.
77 Raises:
78 ValueError: If unit is invalid.
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]
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 }
96 if unit == "auto":
97 time_range = float(np.ptp(time))
98 unit = select_time_unit(time_range)
100 if unit not in multipliers:
101 raise ValueError(f"Invalid time unit: {unit}")
103 return time * multipliers[unit]
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.
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.
121 Returns:
122 List of formatted time labels.
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']
129 References:
130 VIS-014: Adaptive X-Axis Time Window
131 """
132 # Convert to target unit
133 time_converted = convert_time_values(time, unit)
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)))
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("."))
155 return labels
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.
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).
171 Returns:
172 Relative time array.
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]
179 References:
180 VIS-014: Adaptive X-Axis Time Window
181 """
182 if len(time) == 0:
183 return time
185 if reference_time is None:
186 reference_time = time[0] if start_at_zero else 0.0
188 return time - reference_time
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.
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.
206 Returns:
207 Array of major tick positions in seconds.
209 Example:
210 >>> ticks = calculate_major_ticks(0, 0.01, target_count=5, unit="ms")
212 References:
213 VIS-014: Adaptive X-Axis Time Window
214 VIS-019: Grid Auto-Spacing
215 """
216 time_range = time_max - time_min
218 if time_range <= 0:
219 return np.array([time_min])
221 # Select unit if auto
222 if unit == "auto":
223 unit = select_time_unit(time_range)
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]
235 time_min_unit = time_min * multiplier
236 time_max_unit = time_max * multiplier
237 range_unit = time_max_unit - time_min_unit
239 # Calculate rough spacing
240 rough_spacing = range_unit / target_count
242 # Round to nice number
243 nice_spacing = _round_to_nice_time(rough_spacing)
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
249 ticks_unit = first_tick + np.arange(n_ticks) * nice_spacing
251 # Convert back to seconds
252 ticks = ticks_unit / multiplier
254 # Filter to range
255 filtered_ticks: NDArray[np.float64] = ticks[(ticks >= time_min) & (ticks <= time_max)]
257 return filtered_ticks
260def _round_to_nice_time(value: float) -> float:
261 """Round to nice time value (1, 2, 5, 10, 20, 50 × 10^n). # noqa: RUF002
263 Args:
264 value: Value to round.
266 Returns:
267 Nice rounded value.
268 """
269 if value <= 0:
270 return 1.0
272 exponent = np.floor(np.log10(value))
273 mantissa = value / (10**exponent)
275 # Nice fractions for time
276 nice_fractions = [1.0, 2.0, 5.0, 10.0]
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]
283 # Handle overflow
284 if nice_mantissa >= 10.0:
285 nice_mantissa = 1.0
286 exponent += 1
288 return nice_mantissa * (10**exponent) # type: ignore[no-any-return]
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.
299 Args:
300 time_value: Time value in seconds.
301 unit: Display unit.
302 full_precision: Show full floating-point precision.
304 Returns:
305 Formatted time string.
307 Example:
308 >>> readout = format_cursor_readout(1.23456789e-6, unit="us")
309 >>> # Returns "1.23456789 μs"
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))
318 # Convert to unit
319 time_converted = convert_time_values(np.array([time_value]), unit)[0]
321 # Unit symbols
322 unit_symbols = {
323 "s": "s",
324 "ms": "ms",
325 "us": "μs",
326 "ns": "ns",
327 "ps": "ps",
328 }
330 symbol = unit_symbols.get(unit, unit)
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}"
340 return f"{formatted} {symbol}"
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]