Coverage for src / tracekit / visualization / axis_scaling.py: 98%
94 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"""Intelligent axis scaling and range optimization.
3This module provides enhanced Y-axis scaling with nice number rounding,
4outlier exclusion, and per-channel scaling for multi-channel plots.
7Example:
8 >>> from tracekit.visualization.axis_scaling import calculate_axis_limits
9 >>> y_min, y_max = calculate_axis_limits(signal, nice_numbers=True)
11References:
12 - Wilkinson's tick placement algorithm
13 - Percentile-based outlier detection
14 - IEEE publication best practices
15"""
17from __future__ import annotations
19from typing import TYPE_CHECKING, Literal
21import numpy as np
23if TYPE_CHECKING:
24 from numpy.typing import NDArray
27def calculate_axis_limits(
28 data: NDArray[np.float64],
29 *,
30 nice_numbers: bool = True,
31 outlier_percentile: float = 1.0,
32 margin_percent: float = 5.0,
33 symmetric: bool = False,
34 zero_centered: bool = False,
35) -> tuple[float, float]:
36 """Calculate optimal axis limits with nice number rounding.
38 Enhanced version with nice number rounding for publication-quality plots.
40 Args:
41 data: Signal data array.
42 nice_numbers: Round limits to nice numbers (1, 2, 5 × 10^n). # noqa: RUF002
43 outlier_percentile: Percentile for outlier exclusion (default 1% each side).
44 margin_percent: Margin as percentage of data range (default 5%).
45 symmetric: Use symmetric range ±max for bipolar signals.
46 zero_centered: Force zero to be centered in range.
48 Returns:
49 Tuple of (y_min, y_max) with nice rounded values.
51 Raises:
52 ValueError: If data is empty or all NaN.
54 Example:
55 >>> signal = np.array([1.234, 5.678, 9.012])
56 >>> y_min, y_max = calculate_axis_limits(signal, nice_numbers=True)
57 >>> # Returns nice values like (0.0, 10.0) instead of (1.234, 9.012)
59 References:
60 VIS-013: Auto Y-Axis Range Optimization
61 Wilkinson (1999): The Grammar of Graphics
62 """
63 if len(data) == 0:
64 raise ValueError("Data array is empty")
66 # Remove NaN values
67 clean_data = data[~np.isnan(data)]
69 if len(clean_data) == 0:
70 raise ValueError("Data contains only NaN values")
72 # Exclude outliers using percentiles
73 lower_pct = outlier_percentile
74 upper_pct = 100.0 - outlier_percentile
76 data_min = np.percentile(clean_data, lower_pct)
77 data_max = np.percentile(clean_data, upper_pct)
78 data_range = data_max - data_min
80 # Apply margin
81 margin = margin_percent / 100.0
82 margin_value = data_range * margin
84 if symmetric:
85 # Symmetric range: ±max
86 max_abs = max(abs(data_min), abs(data_max))
87 y_min = -(max_abs + margin_value)
88 y_max = max_abs + margin_value
89 elif zero_centered:
90 # Force zero to be centered
91 max_extent = max(abs(data_min), abs(data_max)) + margin_value
92 y_min = -max_extent
93 y_max = max_extent
94 else:
95 # Asymmetric range
96 y_min = data_min - margin_value
97 y_max = data_max + margin_value
99 # Round to nice numbers if requested
100 if nice_numbers:
101 y_min = _round_to_nice_number(y_min, direction="down")
102 y_max = _round_to_nice_number(y_max, direction="up")
104 return (float(y_min), float(y_max))
107def calculate_multi_channel_limits( # type: ignore[no-untyped-def]
108 channels: list[NDArray[np.float64]],
109 *,
110 mode: Literal["per_channel", "common", "grouped"] = "per_channel",
111 nice_numbers: bool = True,
112 **kwargs,
113) -> list[tuple[float, float]]:
114 """Calculate axis limits for multiple channels.
116 Args:
117 channels: List of channel data arrays.
118 mode: Scaling mode:
119 - "per_channel": Independent ranges per channel
120 - "common": Single range for all channels
121 - "grouped": Group similar ranges
122 nice_numbers: Round to nice numbers.
123 **kwargs: Additional arguments passed to calculate_axis_limits.
125 Returns:
126 List of (y_min, y_max) tuples, one per channel.
128 Raises:
129 ValueError: If unknown mode specified.
131 Example:
132 >>> ch1 = np.array([0, 1, 2])
133 >>> ch2 = np.array([0, 10, 20])
134 >>> limits = calculate_multi_channel_limits([ch1, ch2], mode="per_channel")
136 References:
137 VIS-013: Auto Y-Axis Range Optimization (per-channel scaling)
138 VIS-015: Multi-Channel Stack Optimization
139 """
140 if len(channels) == 0:
141 return []
143 if mode == "per_channel":
144 # Independent ranges
145 return [calculate_axis_limits(ch, nice_numbers=nice_numbers, **kwargs) for ch in channels]
147 elif mode == "common":
148 # Single range for all channels
149 all_data = np.concatenate([ch for ch in channels if len(ch) > 0])
150 if len(all_data) == 0: 150 ↛ 151line 150 didn't jump to line 151 because the condition on line 150 was never true
151 return [(0.0, 1.0)] * len(channels)
153 common_limits = calculate_axis_limits(all_data, nice_numbers=nice_numbers, **kwargs)
154 return [common_limits] * len(channels)
156 elif mode == "grouped":
157 # Group channels with similar ranges
158 # First calculate individual ranges
159 individual_limits = [
160 calculate_axis_limits(ch, nice_numbers=False, **kwargs) for ch in channels
161 ]
163 # Simple grouping: group by order of magnitude
164 grouped_limits = []
165 for y_min, y_max in individual_limits:
166 range_mag = np.log10(max(abs(y_max - y_min), 1e-10))
167 # Round to nearest integer magnitude
168 group_mag = int(np.round(range_mag))
170 # Use 10^group_mag as the range scale
171 scale = 10.0**group_mag
173 # Round to this scale
174 grouped_min = np.floor(y_min / scale) * scale
175 grouped_max = np.ceil(y_max / scale) * scale
177 if nice_numbers:
178 grouped_min = _round_to_nice_number(grouped_min, direction="down")
179 grouped_max = _round_to_nice_number(grouped_max, direction="up")
181 grouped_limits.append((float(grouped_min), float(grouped_max)))
183 return grouped_limits
185 else:
186 raise ValueError(f"Unknown mode: {mode}")
189def _round_to_nice_number(
190 value: float,
191 *,
192 direction: Literal["up", "down", "nearest"] = "nearest",
193) -> float:
194 """Round value to nice number (1, 2, 5 × 10^n). # noqa: RUF002
196 Args:
197 value: Value to round.
198 direction: Rounding direction ("up", "down", "nearest").
200 Returns:
201 Rounded nice number.
203 Example:
204 >>> _round_to_nice_number(3.7, direction="up")
205 5.0
206 >>> _round_to_nice_number(3.7, direction="down")
207 2.0
208 >>> _round_to_nice_number(0.037, direction="up")
209 0.05
210 """
211 if value == 0:
212 return 0.0
214 # Determine sign
215 sign = 1 if value >= 0 else -1
216 abs_value = abs(value)
218 # Find exponent
219 exponent = np.floor(np.log10(abs_value))
220 mantissa = abs_value / (10**exponent)
222 # Round mantissa to nice fraction (1, 2, 5)
223 nice_fractions = [1.0, 2.0, 5.0, 10.0]
225 if direction == "up":
226 # Find smallest nice fraction >= mantissa
227 nice_mantissa = next((f for f in nice_fractions if f >= mantissa), 10.0)
228 elif direction == "down":
229 # Find largest nice fraction <= mantissa
230 nice_mantissa = 1.0
231 for f in nice_fractions: 231 ↛ 243line 231 didn't jump to line 243 because the loop on line 231 didn't complete
232 if f <= mantissa:
233 nice_mantissa = f
234 else:
235 break
236 else: # nearest
237 # Find closest nice fraction
238 distances = [abs(f - mantissa) for f in nice_fractions]
239 min_idx = np.argmin(distances)
240 nice_mantissa = nice_fractions[min_idx]
242 # Handle mantissa = 10 case (move to next exponent)
243 if nice_mantissa >= 10.0:
244 nice_mantissa = 1.0
245 exponent += 1
247 return sign * nice_mantissa * (10**exponent) # type: ignore[no-any-return]
250def suggest_tick_spacing(
251 y_min: float,
252 y_max: float,
253 *,
254 target_ticks: int = 5,
255 minor_ticks: bool = True,
256) -> tuple[float, float]:
257 """Suggest tick spacing for axis.
259 Args:
260 y_min: Minimum axis value.
261 y_max: Maximum axis value.
262 target_ticks: Target number of major ticks.
263 minor_ticks: Generate minor tick spacing.
265 Returns:
266 Tuple of (major_spacing, minor_spacing).
268 Example:
269 >>> major, minor = suggest_tick_spacing(0, 10, target_ticks=5)
270 >>> # Returns (2.0, 0.5) for nice tick marks at 0, 2, 4, 6, 8, 10
272 References:
273 VIS-019: Grid Auto-Spacing
274 """
275 axis_range = y_max - y_min
277 if axis_range <= 0:
278 return (1.0, 0.2)
280 # Calculate rough spacing
281 rough_spacing = axis_range / target_ticks
283 # Round to nice number
284 major_spacing = _round_to_nice_number(rough_spacing, direction="nearest")
286 # Minor spacing: 1/5 of major for most cases
287 if minor_ticks:
288 # Use 1/5 for multiples of 5, 1/4 for multiples of 2, 1/2 otherwise
289 if major_spacing % 5 == 0:
290 minor_spacing = major_spacing / 5
291 elif major_spacing % 2 == 0:
292 minor_spacing = major_spacing / 4
293 else:
294 minor_spacing = major_spacing / 2
295 else:
296 minor_spacing = major_spacing
298 return (float(major_spacing), float(minor_spacing))
301__all__ = [
302 "calculate_axis_limits",
303 "calculate_multi_channel_limits",
304 "suggest_tick_spacing",
305]