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

1"""Intelligent axis scaling and range optimization. 

2 

3This module provides enhanced Y-axis scaling with nice number rounding, 

4outlier exclusion, and per-channel scaling for multi-channel plots. 

5 

6 

7Example: 

8 >>> from tracekit.visualization.axis_scaling import calculate_axis_limits 

9 >>> y_min, y_max = calculate_axis_limits(signal, nice_numbers=True) 

10 

11References: 

12 - Wilkinson's tick placement algorithm 

13 - Percentile-based outlier detection 

14 - IEEE publication best practices 

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 

26 

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. 

37 

38 Enhanced version with nice number rounding for publication-quality plots. 

39 

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. 

47 

48 Returns: 

49 Tuple of (y_min, y_max) with nice rounded values. 

50 

51 Raises: 

52 ValueError: If data is empty or all NaN. 

53 

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) 

58 

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") 

65 

66 # Remove NaN values 

67 clean_data = data[~np.isnan(data)] 

68 

69 if len(clean_data) == 0: 

70 raise ValueError("Data contains only NaN values") 

71 

72 # Exclude outliers using percentiles 

73 lower_pct = outlier_percentile 

74 upper_pct = 100.0 - outlier_percentile 

75 

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 

79 

80 # Apply margin 

81 margin = margin_percent / 100.0 

82 margin_value = data_range * margin 

83 

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 

98 

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") 

103 

104 return (float(y_min), float(y_max)) 

105 

106 

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. 

115 

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. 

124 

125 Returns: 

126 List of (y_min, y_max) tuples, one per channel. 

127 

128 Raises: 

129 ValueError: If unknown mode specified. 

130 

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") 

135 

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 [] 

142 

143 if mode == "per_channel": 

144 # Independent ranges 

145 return [calculate_axis_limits(ch, nice_numbers=nice_numbers, **kwargs) for ch in channels] 

146 

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) 

152 

153 common_limits = calculate_axis_limits(all_data, nice_numbers=nice_numbers, **kwargs) 

154 return [common_limits] * len(channels) 

155 

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 ] 

162 

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)) 

169 

170 # Use 10^group_mag as the range scale 

171 scale = 10.0**group_mag 

172 

173 # Round to this scale 

174 grouped_min = np.floor(y_min / scale) * scale 

175 grouped_max = np.ceil(y_max / scale) * scale 

176 

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") 

180 

181 grouped_limits.append((float(grouped_min), float(grouped_max))) 

182 

183 return grouped_limits 

184 

185 else: 

186 raise ValueError(f"Unknown mode: {mode}") 

187 

188 

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 

195 

196 Args: 

197 value: Value to round. 

198 direction: Rounding direction ("up", "down", "nearest"). 

199 

200 Returns: 

201 Rounded nice number. 

202 

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 

213 

214 # Determine sign 

215 sign = 1 if value >= 0 else -1 

216 abs_value = abs(value) 

217 

218 # Find exponent 

219 exponent = np.floor(np.log10(abs_value)) 

220 mantissa = abs_value / (10**exponent) 

221 

222 # Round mantissa to nice fraction (1, 2, 5) 

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

224 

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] 

241 

242 # Handle mantissa = 10 case (move to next exponent) 

243 if nice_mantissa >= 10.0: 

244 nice_mantissa = 1.0 

245 exponent += 1 

246 

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

248 

249 

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. 

258 

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. 

264 

265 Returns: 

266 Tuple of (major_spacing, minor_spacing). 

267 

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 

271 

272 References: 

273 VIS-019: Grid Auto-Spacing 

274 """ 

275 axis_range = y_max - y_min 

276 

277 if axis_range <= 0: 

278 return (1.0, 0.2) 

279 

280 # Calculate rough spacing 

281 rough_spacing = axis_range / target_ticks 

282 

283 # Round to nice number 

284 major_spacing = _round_to_nice_number(rough_spacing, direction="nearest") 

285 

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 

297 

298 return (float(major_spacing), float(minor_spacing)) 

299 

300 

301__all__ = [ 

302 "calculate_axis_limits", 

303 "calculate_multi_channel_limits", 

304 "suggest_tick_spacing", 

305]