Source code for openlifu.plan.param_constraint

from __future__ import annotations

from dataclasses import dataclass
from typing import Annotated, Literal, Tuple

import pandas as pd

from openlifu.util.annotations import OpenLIFUFieldData
from openlifu.util.dict_conversion import DictMixin

PARAM_STATUS_SYMBOLS = {
    "ok": "✅",
    "warning": "❗",
    "error": "❌"
}

[docs] @dataclass class ParameterConstraint(DictMixin): operator: Annotated[Literal['<', '<=', '>', '>=', 'within', 'inside', 'outside', 'outside_inclusive'], OpenLIFUFieldData("Constraint operator", "Constraint operator used to evaluate parameter values")] """Constraint operator used to evaluate parameter values""" warning_value: Annotated[float | int | Tuple[float | int, float | int] | None, OpenLIFUFieldData("Warning value", "Threshold or range that triggers a warning")] = None """Threshold or range that triggers a warning""" error_value: Annotated[float | int | Tuple[float | int, float | int] | None, OpenLIFUFieldData("Error value", "Threshold or range that triggers an error")] = None """Threshold or range that triggers an error""" def __post_init__(self): if self.warning_value is None and self.error_value is None: raise ValueError("At least one of warning_value or error_value must be set") if self.operator in ['within', 'inside', 'outside', 'outside_inclusive']: if self.warning_value and (not isinstance(self.warning_value, tuple) or len(self.warning_value) != 2 or self.warning_value[0] >= self.warning_value[1]): raise ValueError("Warning value must be a sorted tuple of two numbers") if self.error_value and (not isinstance(self.error_value, tuple) or len(self.error_value) != 2 or self.error_value[0] >= self.error_value[1]): raise ValueError("Error value must be a sorted tuple of two numbers") elif self.operator in ['<', '<=', '>', '>=']: if self.warning_value is not None and not isinstance(self.warning_value, (int, float)): raise ValueError("Warning value must be a single value") if self.error_value is not None and not isinstance(self.error_value, (int, float)): raise ValueError("Error value must be a single value") @staticmethod def compare(value, operator, threshold) -> bool: if operator == '<': return value < threshold elif operator == '<=': return value <= threshold elif operator == '>': return value > threshold elif operator == '>=': return value >= threshold elif operator == 'within': return threshold[0] < value < threshold[1] elif operator == 'inside': return threshold[0] <= value <= threshold[1] elif operator == 'outside': return value < threshold[0] or value > threshold[1] elif operator == 'outside_inclusive': return value <= threshold[0] or value >= threshold[1] else: raise ValueError(f"Unsupported operator: {operator}") def is_warning(self, value: float | int) -> bool: if self.warning_value is not None: return not self.compare(value, self.operator, self.warning_value) return False def is_error(self, value: float | int) -> bool: if self.error_value is not None: return not self.compare(value, self.operator, self.error_value) return False def get_status(self, value: float) -> str: if self.is_error(value): return "error" elif self.is_warning(value): return "warning" else: return "ok" def get_status_symbol(self, value: float) -> str: return PARAM_STATUS_SYMBOLS[self.get_status(value)]
[docs] def to_table(self) -> pd.DataFrame: """Convert the parameter constraint to a table format.""" records = [] if self.operator in ['<', '<=', '>', '>='] or self.operator in ['within', 'inside', 'outside', 'outside_inclusive']: value_template = f"value {self.operator} {{value}}" else: raise ValueError(f"Unsupported operator: {self.operator}") if self.warning_value is not None: records.append({"Name": "Warn if not", "Value": value_template.format(value=self.warning_value), "Unit": ""}) if self.error_value is not None: records.append({"Name": "Error if not", "Value": value_template.format(value=self.error_value), "Unit": ""}) return pd.DataFrame.from_records(records)