from __future__ import annotations
__all__ = ["Operand", "Number", "ParamBase", "ParamDef"]
import abc
from operator import add, sub, mul, truediv, neg
from collections.abc import Callable
from typing import Any, TypeAlias
# Define a type variable for numeric types
Number: TypeAlias = int | float
def _nop(value: Any) -> Any:
"""No-operation function that returns its input unchanged.
This function is used as a default conversion function in parameter classes.
"""
return value
class _Evaluator(abc.ABC):
"""Abstract base class for evaluators.
An evaluator is a callable object that returns a scalar value.
"""
@staticmethod
def _convert_to_evaluator(value):
"""Convert a value to an evaluator."""
if isinstance(value, (int, float)):
return _Constant(value)
elif isinstance(value, Operand):
return value
msg = f"Parameter operation not defined for type {type(value)}"
raise TypeError(msg)
@abc.abstractmethod
def __call__(self) -> Number:
"""Evaluate and return the value.
Returns:
The evaluated value
"""
...
class _Constant(_Evaluator):
"""An evaluator that always returns a constant value."""
__slots__ = ["value"]
def __init__(self, value: Number):
"""Initialise a constant evaluator.
Args:
value: The constant value to return
Raises:
TypeError: If the value is not a scalar (int or float)
"""
if not isinstance(value, Number):
msg = "The parameter value must be a scalar"
raise TypeError(msg)
self.value: Number = value
def __call__(self) -> Number:
return self.value
class _BinaryOperator(_Evaluator):
__slots__ = ["left_operand", "operator", "right_operand"]
def __init__(self, operator, left, right) -> None:
"""Initialise a binary operator.
Args:
operator: The operator function to apply
left: The left operand of the operator
right: The right operand of the operator
"""
self.operator = operator
self.right_operand = self._convert_to_evaluator(right)
self.left_operand = self._convert_to_evaluator(left)
def __call__(self) -> Number:
return self.operator(self.left_operand.value, self.right_operand.value)
class _UnaryOperator(_Evaluator):
__slots__ = ["operand", "operator"]
def __init__(self, operator, operand) -> None:
"""Initialise a unary operator.
Args:
operator: The operator function to apply
operand: The operand to apply the operator to
"""
self.operator = operator
self.operand = self._convert_to_evaluator(operand)
def __call__(self) -> Number:
return self.operator(self.operand.value)
[docs]
class Operand(abc.ABC):
"""Abstract base class for arithmetic combinations of parameters."""
name: str #: Operand name
def __init__(self, name: str, **kwargs):
self.name = name
super().__init__(**kwargs)
@staticmethod
def _nm(obj, priority: int):
"""Return the parenthesised name of the object."""
if isinstance(obj, ParamBase):
return obj.name if obj._priority >= priority else f"({obj.name})"
else:
return str(obj)
@property
@abc.abstractmethod
def value(self): ...
def __str__(self):
return self.name
def __repr__(self):
return repr(self.value)
def __add__(self, other):
try:
op = _BinaryOperator(add, self, other)
except TypeError:
return NotImplemented
else:
name = "+".join((self._nm(self, 10), self._nm(other, 10)))
return ParamBase(evaluator=op, name=name, priority=10)
__radd__ = __add__
def __pos__(self):
return self
def __neg__(self):
name = "-" + self._nm(self, 20)
op = _UnaryOperator(neg, self)
return ParamBase(evaluator=op, name=name, priority=0)
def __abs__(self):
name = f"abs({self._nm(self, 0)})"
op = _UnaryOperator(abs, self)
return ParamBase(evaluator=op, name=name, priority=20)
def __sub__(self, other):
try:
op = _BinaryOperator(sub, self, other)
except TypeError:
return NotImplemented
else:
name = "-".join((self._nm(self, 10), self._nm(other, 10)))
return ParamBase(evaluator=op, name=name, priority=10)
def __rsub__(self, other):
try:
op = _BinaryOperator(sub, other, self)
except TypeError:
return NotImplemented
else:
name = "-".join((self._nm(other, 10), self._nm(self, 10)))
return ParamBase(evaluator=op, name=name, priority=10)
def __mul__(self, other):
try:
op = _BinaryOperator(mul, self, other)
except TypeError:
return NotImplemented
else:
name = "*".join((self._nm(self, 20), self._nm(other, 20)))
return ParamBase(evaluator=op, name=name, priority=20)
__rmul__ = __mul__
def __truediv__(self, other):
try:
op = _BinaryOperator(truediv, self, other)
except TypeError:
return NotImplemented
else:
name = "/".join((self._nm(self, 20), self._nm(other, 20)))
return ParamBase(evaluator=op, name=name, priority=20)
def __rtruediv__(self, other):
try:
op = _BinaryOperator(truediv, other, self)
except TypeError:
return NotImplemented
else:
name = "/".join((self._nm(other, 20), self._nm(self, 20)))
return ParamBase(evaluator=op, name=name, priority=20)
def __pow__(self, other):
try:
op = _BinaryOperator(pow, self, other)
except TypeError:
return NotImplemented
else:
name = "**".join((self._nm(self, 20), self._nm(other, 20)))
return ParamBase(evaluator=op, name=name, priority=20)
def __rpow__(self, other):
try:
op = _BinaryOperator(pow, other, self)
except TypeError:
return NotImplemented
else:
name = "**".join((self._nm(other, 20), self._nm(self, 20)))
return ParamBase(evaluator=op, name=name, priority=20)
def __gt__(self, other):
return float(self.value) > other
def __lt__(self, other):
return float(self.value) < other
def __ge__(self, other):
return float(self.value) >= other
def __le__(self, other):
return float(self.value) <= other
def __float__(self):
return float(self.value)
def __int__(self):
return int(self.value)
[docs]
class ParamDef(abc.ABC):
"""Abstract base class for parameter definitions.
This class defines the interface for parameter objects that can be used
as element attributes. It provides a *value* property and a method for converting
values to the appropriate type.
"""
def __init__(self, *, conversion: Callable[[Any], Any] | None = _nop, **kwargs):
"""
Args:
conversion: Function to convert values to the appropriate type.
"""
self._conversion = _nop if conversion is None else conversion
super().__init__(**kwargs)
def __copy__(self):
# Parameters are not copied
return self
def __deepcopy__(self, memo):
# Parameters are not deep-copied
return self
[docs]
def set_conversion(self, conversion: Callable[[Any], Any]) -> None:
"""Set the data type conversion function.
This method is called when a parameter is assigned to an
:py:class:`.Element` attribute. It can only be set once.
Args:
conversion: Function to convert values to the appropriate type
Raises:
ValueError: If attempting to change an already set conversion function
"""
if conversion is not self._conversion:
if self._conversion is _nop:
self._conversion = conversion
else:
msg = "Cannot change the data type of the parameter"
raise ValueError(msg)
[docs]
@abc.abstractmethod
def fast_value(self) -> Any:
"""Return the value of the parameter."""
# This method is called by the __getattr__ method of Element
...
@property
def value(self) -> Any:
"""Current value of the parameter."""
return self.fast_value()
[docs]
class ParamBase(ParamDef, Operand):
"""Read-only base class for parameters.
It is used for computed parameters and should not be instantiated
otherwise.
"""
_evaluator: _Evaluator
_priority: int
def __init__(self, evaluator: _Evaluator, *, priority: int = 20, **kwargs) -> None:
"""
Args:
evaluator: Evaluator function
name: Name of the parameter
conversion: data conversion function
priority: priority of the operator.
"""
if not isinstance(evaluator, _Evaluator):
msg = "'Evaluate' must be an _Evaluate object"
raise TypeError(msg)
self._evaluator = evaluator
self._priority = priority
super().__init__(**kwargs)
[docs]
def fast_value(self):
return self._conversion(self._evaluator())