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
« prev ^ index » next coverage.py v7.13.1, created at 2026-01-11 23:04 +0000
1"""Limit testing for TraceKit.
3This module provides specification limit testing including upper/lower
4bounds, pass/fail determination, and margin analysis.
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"""
13from __future__ import annotations
15from dataclasses import dataclass
16from typing import TYPE_CHECKING, Any, Literal
18import numpy as np
20from tracekit.core.exceptions import AnalysisError
21from tracekit.core.types import WaveformTrace
23if TYPE_CHECKING:
24 from numpy.typing import NDArray
27@dataclass
28class LimitSpec:
29 """Specification limit definition.
31 Defines upper and lower limits for a measurement with optional
32 guardbands and absolute/relative modes.
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 """
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"
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})")
60@dataclass
61class LimitTestResult:
62 """Result of a limit test.
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 """
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
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.
104 Creates a LimitSpec from various input formats including
105 center +/- tolerance notation.
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.
117 Returns:
118 LimitSpec instance.
120 Raises:
121 ValueError: If center requires tolerance or tolerance_pct, or if no limits specified.
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")
138 if upper is None and lower is None:
139 raise ValueError("Must specify limits (upper/lower or center+tolerance)")
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
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 )
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.
170 Tests all samples against upper and lower limits and returns
171 detailed violation information.
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.
180 Returns:
181 LimitTestResult with pass/fail status and violation details.
183 Raises:
184 ValueError: If no limits or bounds specified.
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)
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)
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
212 # Find violations
213 upper_viol = np.array([], dtype=np.int64)
214 lower_viol = np.array([], dtype=np.int64)
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]
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
226 # Compute statistics
227 max_val = float(np.max(data))
228 min_val = float(np.min(data))
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)
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
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
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 )
275@dataclass
276class MarginAnalysis:
277 """Margin analysis result.
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 """
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"]
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.
306 Calculates how much margin exists between the data and the
307 specification limits.
309 Args:
310 trace: Input trace or data array.
311 limits: LimitSpec defining the limits.
312 warning_threshold_pct: Threshold for margin warning (percent).
314 Returns:
315 MarginAnalysis with margin details.
317 Raises:
318 AnalysisError: If no limits defined for margin analysis.
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)
330 max_val = float(np.max(data))
331 min_val = float(np.min(data))
333 # Compute margins
334 upper_margin = None
335 lower_margin = None
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
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))
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")
352 # Find minimum margin
353 min_margin_tuple = min(margins, key=lambda x: x[1])
354 min_margin = min_margin_tuple[1]
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]
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
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"
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 )