From 7dee6775973cc5506cda170780d80be0a9524cf8 Mon Sep 17 00:00:00 2001 From: Kai Chappell Date: Tue, 2 Dec 2025 23:54:15 +0000 Subject: [PATCH] Implement limit checker --- src/py_dvt_ate/framework/__init__.py | 12 +- src/py_dvt_ate/framework/limits.py | 238 +++++++++++++++++++++++++++ 2 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 src/py_dvt_ate/framework/limits.py diff --git a/src/py_dvt_ate/framework/__init__.py b/src/py_dvt_ate/framework/__init__.py index 0861d66..8f03f95 100644 --- a/src/py_dvt_ate/framework/__init__.py +++ b/src/py_dvt_ate/framework/__init__.py @@ -5,6 +5,16 @@ and runtime context management for DVT characterisation tests. """ from py_dvt_ate.framework.context import ITest, TestContext +from py_dvt_ate.framework.limits import Limit, LimitSet, check_value, evaluate_results from py_dvt_ate.framework.logger import ITestLogger, TestLogger -__all__ = ["ITest", "ITestLogger", "TestContext", "TestLogger"] +__all__ = [ + "ITest", + "ITestLogger", + "Limit", + "LimitSet", + "TestContext", + "TestLogger", + "check_value", + "evaluate_results", +] diff --git a/src/py_dvt_ate/framework/limits.py b/src/py_dvt_ate/framework/limits.py new file mode 100644 index 0000000..e31d4f2 --- /dev/null +++ b/src/py_dvt_ate/framework/limits.py @@ -0,0 +1,238 @@ +"""Limit checking utilities for test result evaluation. + +This module provides utilities for evaluating measurements against specification +limits and determining pass/fail status. Used by tests to check if results meet +requirements and by the test runner to determine overall test status. +""" + +from dataclasses import dataclass +from typing import Any + +from py_dvt_ate.data.models import TestResult, TestStatus + + +@dataclass(frozen=True) +class Limit: + """Specification limit for a parameter. + + Represents a single limit specification with optional lower and upper bounds. + Used to define test specifications and evaluate pass/fail. + + Attributes: + parameter: Parameter name this limit applies to. + lower: Optional lower limit (inclusive). None means no lower limit. + upper: Optional upper limit (inclusive). None means no upper limit. + unit: Unit of measurement for the limits. + + Example: + temp_co_limit = Limit("temp_co", lower=-50.0, upper=50.0, unit="ppm/°C") + """ + + parameter: str + lower: float | None = None + upper: float | None = None + unit: str = "" + + def check(self, value: float) -> bool | None: + """Check if a value is within this limit. + + Args: + value: Value to check against limits. + + Returns: + True if value is within limits, False if outside limits. + None if no limits are defined (informational parameter). + + Example: + limit = Limit("v_out", lower=3.25, upper=3.35, unit="V") + limit.check(3.30) # Returns True + limit.check(3.40) # Returns False + """ + if self.lower is None and self.upper is None: + return None + + lower_ok = self.lower is None or value >= self.lower + upper_ok = self.upper is None or value <= self.upper + return lower_ok and upper_ok + + +@dataclass(frozen=True) +class LimitSet: + """Collection of limits for a test. + + Groups multiple parameter limits together as a test specification. + Can be loaded from configuration or defined programmatically. + + Attributes: + name: Name of this limit set (e.g., "nominal", "extended"). + limits: Dictionary mapping parameter names to Limit objects. + + Example: + limits = LimitSet( + name="nominal", + limits={ + "temp_co": Limit("temp_co", -50.0, 50.0, "ppm/°C"), + "v_out": Limit("v_out", 3.25, 3.35, "V"), + } + ) + """ + + name: str + limits: dict[str, Limit] + + def get_limit(self, parameter: str) -> Limit | None: + """Get the limit for a specific parameter. + + Args: + parameter: Parameter name to look up. + + Returns: + Limit object if found, None if parameter has no limit defined. + """ + return self.limits.get(parameter) + + def check(self, parameter: str, value: float) -> bool | None: + """Check if a value is within limits for a parameter. + + Args: + parameter: Parameter name. + value: Value to check. + + Returns: + True if within limits, False if outside limits. + None if parameter has no limit defined. + """ + limit = self.get_limit(parameter) + if limit is None: + return None + return limit.check(value) + + @classmethod + def from_dict(cls, name: str, limits_dict: dict[str, Any]) -> "LimitSet": + """Create a LimitSet from a dictionary. + + Useful for loading limit sets from YAML configuration files. + + Args: + name: Name for this limit set. + limits_dict: Dictionary with parameter names as keys and limit + specifications as values. Each limit spec should have: + - "lower": Optional lower limit + - "upper": Optional upper limit + - "unit": Unit of measurement + + Returns: + LimitSet instance. + + Example: + config = { + "temp_co": {"lower": -50.0, "upper": 50.0, "unit": "ppm/°C"}, + "v_out": {"lower": 3.25, "upper": 3.35, "unit": "V"}, + } + limits = LimitSet.from_dict("nominal", config) + """ + limits = {} + for param, spec in limits_dict.items(): + limits[param] = Limit( + parameter=param, + lower=spec.get("lower"), + upper=spec.get("upper"), + unit=spec.get("unit", ""), + ) + return cls(name=name, limits=limits) + + +def check_value( + value: float, + lower: float | None = None, + upper: float | None = None, +) -> bool | None: + """Check if a value is within specified limits. + + Utility function for quick limit checking without creating Limit objects. + + Args: + value: Value to check. + lower: Optional lower limit (inclusive). + upper: Optional upper limit (inclusive). + + Returns: + True if value is within limits, False if outside limits. + None if no limits are specified. + + Example: + check_value(3.30, lower=3.25, upper=3.35) # Returns True + check_value(3.40, lower=3.25, upper=3.35) # Returns False + check_value(3.30) # Returns None (no limits) + """ + if lower is None and upper is None: + return None + + lower_ok = lower is None or value >= lower + upper_ok = upper is None or value <= upper + return lower_ok and upper_ok + + +def evaluate_results(results: list[TestResult]) -> TestStatus: + """Evaluate a list of test results to determine overall status. + + Aggregates multiple test results into a single pass/fail determination. + If any result fails its limits, the overall status is FAILED. + If all results pass (or have no limits), the overall status is PASSED. + + Args: + results: List of TestResult objects to evaluate. + + Returns: + TestStatus.PASSED if all results pass their limits. + TestStatus.FAILED if any result fails its limits. + TestStatus.PASSED if no results have limits defined (informational only). + + Example: + results = [ + TestResult(..., value=25.0, lower_limit=-50.0, upper_limit=50.0), + TestResult(..., value=3.30, lower_limit=3.25, upper_limit=3.35), + ] + status = evaluate_results(results) # Returns TestStatus.PASSED + """ + if not results: + return TestStatus.PASSED + + # Check if any result failed + for result in results: + if result.passed is False: + return TestStatus.FAILED + + # All results passed (or had no limits) + return TestStatus.PASSED + + +def format_limit_violation(result: TestResult) -> str: + """Format a limit violation message for a failed result. + + Creates a human-readable message describing why a result failed. + Useful for logging and reporting. + + Args: + result: TestResult that failed its limits. + + Returns: + Formatted violation message. + + Example: + result = TestResult(..., parameter="v_out", value=3.40, + lower_limit=3.25, upper_limit=3.35, unit="V") + message = format_limit_violation(result) + # Returns: "v_out: 3.400 V [FAIL] (limits: 3.250 to 3.350 V)" + """ + status = "PASS" if result.passed else "FAIL" + limits_str = "" + + if result.lower_limit is not None and result.upper_limit is not None: + limits_str = f" (limits: {result.lower_limit:.3f} to {result.upper_limit:.3f} {result.unit})" + elif result.lower_limit is not None: + limits_str = f" (minimum: {result.lower_limit:.3f} {result.unit})" + elif result.upper_limit is not None: + limits_str = f" (maximum: {result.upper_limit:.3f} {result.unit})" + + return f"{result.parameter}: {result.value:.3f} {result.unit} [{status}]{limits_str}"