Coverage for src / tracekit / comparison / limits.py: 94%

150 statements  

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

1"""Limit testing for TraceKit. 

2 

3This module provides specification limit testing including upper/lower 

4bounds, pass/fail determination, and margin analysis. 

5 

6 

7Example: 

8 >>> from tracekit.comparison import check_limits, margin_analysis 

9 >>> result = check_limits(trace, upper=1.5, lower=-0.5) 

10 >>> margins = margin_analysis(trace, limits) 

11""" 

12 

13from __future__ import annotations 

14 

15from dataclasses import dataclass 

16from typing import TYPE_CHECKING, Any, Literal 

17 

18import numpy as np 

19 

20from tracekit.core.exceptions import AnalysisError 

21from tracekit.core.types import WaveformTrace 

22 

23if TYPE_CHECKING: 

24 from numpy.typing import NDArray 

25 

26 

27@dataclass 

28class LimitSpec: 

29 """Specification limit definition. 

30 

31 Defines upper and lower limits for a measurement with optional 

32 guardbands and absolute/relative modes. 

33 

34 Attributes: 

35 upper: Upper limit value. 

36 lower: Lower limit value. 

37 upper_guardband: Guardband below upper limit (margin). 

38 lower_guardband: Guardband above lower limit (margin). 

39 name: Name of the specification. 

40 unit: Unit of measurement. 

41 mode: Limit mode ("absolute" or "relative"). 

42 """ 

43 

44 upper: float | None = None 

45 lower: float | None = None 

46 upper_guardband: float = 0.0 

47 lower_guardband: float = 0.0 

48 name: str = "spec" 

49 unit: str = "" 

50 mode: Literal["absolute", "relative"] = "absolute" 

51 

52 def __post_init__(self) -> None: 

53 """Validate limit specification.""" 

54 if self.upper is None and self.lower is None: 

55 raise ValueError("At least one of upper or lower limit must be specified") 

56 if self.upper is not None and self.lower is not None and self.upper < self.lower: 

57 raise ValueError(f"Upper limit ({self.upper}) must be >= lower limit ({self.lower})") 

58 

59 

60@dataclass 

61class LimitTestResult: 

62 """Result of a limit test. 

63 

64 Attributes: 

65 passed: True if all samples are within limits. 

66 num_violations: Number of samples violating limits. 

67 violation_rate: Fraction of samples violating limits. 

68 upper_violations: Indices of samples exceeding upper limit. 

69 lower_violations: Indices of samples below lower limit. 

70 max_value: Maximum value in data. 

71 min_value: Minimum value in data. 

72 upper_margin: Margin to upper limit (positive = within, negative = exceeded). 

73 lower_margin: Margin to lower limit (positive = within, negative = exceeded). 

74 margin_percentage: Smallest margin as percentage of limit range. 

75 within_guardband: True if within guardband but outside tight limits. 

76 """ 

77 

78 passed: bool 

79 num_violations: int 

80 violation_rate: float 

81 upper_violations: NDArray[np.int64] | None = None 

82 lower_violations: NDArray[np.int64] | None = None 

83 max_value: float = 0.0 

84 min_value: float = 0.0 

85 upper_margin: float | None = None 

86 lower_margin: float | None = None 

87 margin_percentage: float | None = None 

88 within_guardband: bool = False 

89 

90 

91def create_limit_spec( 

92 *, 

93 upper: float | None = None, 

94 lower: float | None = None, 

95 center: float | None = None, 

96 tolerance: float | None = None, 

97 tolerance_pct: float | None = None, 

98 guardband_pct: float = 0.0, 

99 name: str = "spec", 

100 unit: str = "", 

101) -> LimitSpec: 

102 """Create a limit specification. 

103 

104 Creates a LimitSpec from various input formats including 

105 center +/- tolerance notation. 

106 

107 Args: 

108 upper: Upper limit value. 

109 lower: Lower limit value. 

110 center: Center value (used with tolerance). 

111 tolerance: Absolute tolerance (+/- from center). 

112 tolerance_pct: Percentage tolerance (+/- % of center). 

113 guardband_pct: Guardband as percentage of limit range. 

114 name: Specification name. 

115 unit: Unit of measurement. 

116 

117 Returns: 

118 LimitSpec instance. 

119 

120 Raises: 

121 ValueError: If center requires tolerance or tolerance_pct, or if no limits specified. 

122 

123 Example: 

124 >>> spec = create_limit_spec(center=1.0, tolerance_pct=5) # 1.0 +/- 5% 

125 >>> spec = create_limit_spec(upper=1.5, lower=0.5, guardband_pct=10) 

126 """ 

127 if center is not None: 

128 if tolerance is not None: 

129 upper = center + tolerance 

130 lower = center - tolerance 

131 elif tolerance_pct is not None: 

132 abs_tol = abs(center) * tolerance_pct / 100.0 

133 upper = center + abs_tol 

134 lower = center - abs_tol 

135 else: 

136 raise ValueError("center requires tolerance or tolerance_pct") 

137 

138 if upper is None and lower is None: 

139 raise ValueError("Must specify limits (upper/lower or center+tolerance)") 

140 

141 # Calculate guardbands 

142 upper_gb = 0.0 

143 lower_gb = 0.0 

144 if guardband_pct > 0 and upper is not None and lower is not None: 

145 range_val = upper - lower 

146 guardband = range_val * guardband_pct / 100.0 

147 upper_gb = guardband 

148 lower_gb = guardband 

149 

150 return LimitSpec( 

151 upper=upper, 

152 lower=lower, 

153 upper_guardband=upper_gb, 

154 lower_guardband=lower_gb, 

155 name=name, 

156 unit=unit, 

157 ) 

158 

159 

160def check_limits( 

161 trace: WaveformTrace | NDArray[np.floating[Any]], 

162 limits: LimitSpec | None = None, 

163 *, 

164 upper: float | None = None, 

165 lower: float | None = None, 

166 reference: float | None = None, 

167) -> LimitTestResult: 

168 """Check if trace data is within specification limits. 

169 

170 Tests all samples against upper and lower limits and returns 

171 detailed violation information. 

172 

173 Args: 

174 trace: Input trace or data array. 

175 limits: LimitSpec defining the limits. 

176 upper: Upper limit (alternative to LimitSpec). 

177 lower: Lower limit (alternative to LimitSpec). 

178 reference: Reference value for relative limits. 

179 

180 Returns: 

181 LimitTestResult with pass/fail status and violation details. 

182 

183 Raises: 

184 ValueError: If no limits or bounds specified. 

185 

186 Example: 

187 >>> result = check_limits(trace, upper=1.5, lower=-0.5) 

188 >>> if not result.passed: 

189 ... print(f"{result.num_violations} violations found") 

190 """ 

191 # Get data 

192 if isinstance(trace, WaveformTrace): 

193 data = trace.data.astype(np.float64) 

194 else: 

195 data = np.asarray(trace, dtype=np.float64) 

196 

197 # Create or use limits 

198 if limits is None: 

199 if upper is None and lower is None: 199 ↛ 200line 199 didn't jump to line 200 because the condition on line 199 was never true

200 raise ValueError("Must specify limits or upper/lower bounds") 

201 limits = LimitSpec(upper=upper, lower=lower) 

202 

203 # Handle relative limits 

204 actual_upper = limits.upper 

205 actual_lower = limits.lower 

206 if limits.mode == "relative" and reference is not None: 

207 if actual_upper is not None: 207 ↛ 209line 207 didn't jump to line 209 because the condition on line 207 was always true

208 actual_upper = reference + actual_upper 

209 if actual_lower is not None: 209 ↛ 213line 209 didn't jump to line 213 because the condition on line 209 was always true

210 actual_lower = reference + actual_lower 

211 

212 # Find violations 

213 upper_viol = np.array([], dtype=np.int64) 

214 lower_viol = np.array([], dtype=np.int64) 

215 

216 if actual_upper is not None: 

217 upper_viol = np.where(data > actual_upper)[0] 

218 if actual_lower is not None: 

219 lower_viol = np.where(data < actual_lower)[0] 

220 

221 # Combine violations 

222 all_violations = np.union1d(upper_viol, lower_viol) 

223 num_violations = len(all_violations) 

224 violation_rate = num_violations / len(data) if len(data) > 0 else 0.0 

225 

226 # Compute statistics 

227 max_val = float(np.max(data)) 

228 min_val = float(np.min(data)) 

229 

230 # Compute margins 

231 upper_margin = None 

232 lower_margin = None 

233 if actual_upper is not None: 

234 upper_margin = float(actual_upper - max_val) 

235 if actual_lower is not None: 

236 lower_margin = float(min_val - actual_lower) 

237 

238 # Compute margin percentage 

239 margin_pct = None 

240 if actual_upper is not None and actual_lower is not None: 

241 limit_range = actual_upper - actual_lower 

242 if limit_range > 0: 

243 min_margin = min( 

244 upper_margin if upper_margin is not None else float("inf"), 

245 lower_margin if lower_margin is not None else float("inf"), 

246 ) 

247 margin_pct = (min_margin / limit_range) * 100.0 

248 

249 # Check guardband 

250 within_guardband = False 

251 if num_violations == 0: 

252 # Check if within guardband 

253 if limits.upper_guardband > 0 and upper_margin is not None: 

254 if upper_margin < limits.upper_guardband: 254 ↛ 256line 254 didn't jump to line 256 because the condition on line 254 was always true

255 within_guardband = True 

256 if limits.lower_guardband > 0 and lower_margin is not None: 256 ↛ 257line 256 didn't jump to line 257 because the condition on line 256 was never true

257 if lower_margin < limits.lower_guardband: 

258 within_guardband = True 

259 

260 return LimitTestResult( 

261 passed=num_violations == 0, 

262 num_violations=num_violations, 

263 violation_rate=violation_rate, 

264 upper_violations=upper_viol if len(upper_viol) > 0 else None, 

265 lower_violations=lower_viol if len(lower_viol) > 0 else None, 

266 max_value=max_val, 

267 min_value=min_val, 

268 upper_margin=upper_margin, 

269 lower_margin=lower_margin, 

270 margin_percentage=margin_pct, 

271 within_guardband=within_guardband, 

272 ) 

273 

274 

275@dataclass 

276class MarginAnalysis: 

277 """Margin analysis result. 

278 

279 Attributes: 

280 upper_margin: Margin to upper limit. 

281 lower_margin: Margin to lower limit. 

282 min_margin: Smallest margin (most critical). 

283 margin_percentage: Margin as percentage of limit range. 

284 critical_limit: Which limit has the smallest margin. 

285 warning: True if margin is below warning threshold. 

286 margin_status: "pass", "warning", or "fail". 

287 """ 

288 

289 upper_margin: float | None 

290 lower_margin: float | None 

291 min_margin: float 

292 margin_percentage: float 

293 critical_limit: Literal["upper", "lower", "both", "none"] 

294 warning: bool 

295 margin_status: Literal["pass", "warning", "fail"] 

296 

297 

298def margin_analysis( 

299 trace: WaveformTrace | NDArray[np.floating[Any]], 

300 limits: LimitSpec, 

301 *, 

302 warning_threshold_pct: float = 20.0, 

303) -> MarginAnalysis: 

304 """Analyze margins to specification limits. 

305 

306 Calculates how much margin exists between the data and the 

307 specification limits. 

308 

309 Args: 

310 trace: Input trace or data array. 

311 limits: LimitSpec defining the limits. 

312 warning_threshold_pct: Threshold for margin warning (percent). 

313 

314 Returns: 

315 MarginAnalysis with margin details. 

316 

317 Raises: 

318 AnalysisError: If no limits defined for margin analysis. 

319 

320 Example: 

321 >>> margins = margin_analysis(trace, limits) 

322 >>> print(f"Margin: {margins.margin_percentage:.1f}%") 

323 """ 

324 # Get data 

325 if isinstance(trace, WaveformTrace): 

326 data = trace.data.astype(np.float64) 

327 else: 

328 data = np.asarray(trace, dtype=np.float64) 

329 

330 max_val = float(np.max(data)) 

331 min_val = float(np.min(data)) 

332 

333 # Compute margins 

334 upper_margin = None 

335 lower_margin = None 

336 

337 if limits.upper is not None: 

338 upper_margin = limits.upper - max_val 

339 if limits.lower is not None: 

340 lower_margin = min_val - limits.lower 

341 

342 # Determine minimum margin and critical limit 

343 margins = [] 

344 if upper_margin is not None: 

345 margins.append(("upper", upper_margin)) 

346 if lower_margin is not None: 

347 margins.append(("lower", lower_margin)) 

348 

349 if not margins: 349 ↛ 350line 349 didn't jump to line 350 because the condition on line 349 was never true

350 raise AnalysisError("No limits defined for margin analysis") 

351 

352 # Find minimum margin 

353 min_margin_tuple = min(margins, key=lambda x: x[1]) 

354 min_margin = min_margin_tuple[1] 

355 

356 # Determine critical limit 

357 if len(margins) == 2 and abs(margins[0][1] - margins[1][1]) < 1e-10: 

358 critical_limit: Literal["upper", "lower", "both", "none"] = "both" 

359 else: 

360 critical_limit = min_margin_tuple[0] # type: ignore[assignment] 

361 

362 # Compute margin percentage 

363 margin_pct = 0.0 

364 if limits.upper is not None and limits.lower is not None: 

365 limit_range = limits.upper - limits.lower 

366 if limit_range > 0: 366 ↛ 374line 366 didn't jump to line 374 because the condition on line 366 was always true

367 margin_pct = (min_margin / limit_range) * 100.0 

368 elif limits.upper is not None and upper_margin is not None: 

369 margin_pct = (upper_margin / abs(limits.upper)) * 100.0 if limits.upper != 0 else 0 

370 elif limits.lower is not None and lower_margin is not None: 370 ↛ 374line 370 didn't jump to line 374 because the condition on line 370 was always true

371 margin_pct = (lower_margin / abs(limits.lower)) * 100.0 if limits.lower != 0 else 0 

372 

373 # Determine status 

374 warning = False 

375 if min_margin < 0: 

376 margin_status: Literal["pass", "warning", "fail"] = "fail" 

377 elif margin_pct < warning_threshold_pct: 

378 margin_status = "warning" 

379 warning = True 

380 else: 

381 margin_status = "pass" 

382 

383 return MarginAnalysis( 

384 upper_margin=upper_margin, 

385 lower_margin=lower_margin, 

386 min_margin=min_margin, 

387 margin_percentage=margin_pct, 

388 critical_limit=critical_limit, 

389 warning=warning, 

390 margin_status=margin_status, 

391 )