from abc import abstractmethod
import copy
import math
import operator
import numpy as np
from .dataStructure import Descriptor
[docs]
class ParameterBase(Descriptor):
"""
Base class for model parameters with potential constraints or type-casting.
Unlike mere data members, parameters can have default values, support type
constraints, and cast from certain types to their target type.
Attributes
----------
default : Any
Default value of the parameter.
unit : str
Unit of the parameter.
description : str
Description or context of the parameter.
Notes
-----
1. Supports deep copying of default values, allowing mutable defaults without side effects.
2. Subclasses can further specify type constraints (like `Typed`).
3. They can also define immutable parameters (like `Constant`) and options-based parameters (`Switch`).
See Also
--------
Descriptor
Structure
Constant
Switch
Typed
Bool
Integer
Tuple
Float
String
Dictionary
"""
def __init__(
self,
*args,
default=None,
is_optional=False,
unit=None,
description=None,
**kwargs):
"""
Initialize a Parameter instance.
Parameters
----------
*args : Any
Variable length argument list.
default : Any, optional
Default value for the parameter. Defaults to None.
is_optional : bool, optional
If True, parameter is not added to list of required parameters.
Defaults to False
unit : str, optional
Unit of the parameter. Defaults to None.
description : str, optional
Description of the parameter. Defaults to None.
**kwargs : Any
Arbitrary keyword arguments.
"""
self.default = default
self.is_optional = is_optional
self.unit = unit
self.description = description
super().__init__(*args, **kwargs)
@property
def default(self):
"""Any: Get or set the default value of the parameter."""
return copy.deepcopy(self._default)
@default.setter
def default(self, value):
"""
Set the default value of the parameter.
Parameters
----------
value : Any
Value to set as default.
"""
if value is not None:
val = self._prepare(None, value, recursive=True)
self._check(None, val, recursive=True)
self._default = value
[docs]
def get_default_value(self, instance):
"""
Return default values if necessary.
Override this method if type-casting for default values is necessary.
Parameters
----------
value : Any
Value to cast.
Returns
-------
Any
Default value.
"""
default = self.default
if default is not None:
default = self._prepare(instance, default, recursive=True)
self._check(instance, default, recursive=True)
return default
def __get__(self, instance, cls):
"""
Retrieve the descriptor value for the given instance.
Parameters
----------
instance : Any
Instance to retrieve the descriptor value for.
cls : Type[Any]
Class to which the descriptor belongs.
Returns
-------
Any
Descriptor value or the default value if the descriptor value isn't set.
"""
if instance is None:
return self
try:
value = Descriptor.__get__(self, instance, cls)
except KeyError:
value = self.get_default_value(instance)
if value is not None:
self._check(instance, value, recursive=True)
return value
def __set__(self, instance, value):
"""
Set the descriptor value for the given instance.
Parameters
----------
instance : Any
Instance to set the descriptor value for.
value : Any
Value to set.
"""
if value is None:
value = self.get_default_value(instance)
if value is not None:
value = self._prepare(instance, value, recursive=True)
self._check(instance, value, recursive=True)
try:
if self.name in instance._parameters:
instance._parameters_dict[self.name] = value
except AttributeError:
pass
super().__set__(instance, value)
def _prepare(self, instance, value, recursive=False):
"""
Prepare value for setting if necessary.
Override this method if type-casting or other operations are necessary.
Parameters
----------
instance : Any
Instance to retrieve the descriptor value for.
value : Any
Value to cast.
Returns
-------
Any
Prepared value.
"""
return value
def _check(self, instance, value, recursive=False):
"""
Check the given value.
Override this method for specific checks.
Parameters
----------
instance : Any
Instance to retrieve the descriptor value for.
value : Any
Value to check.
recursive : bool, optional
If True, perform the check recursively. Defaults to False.
"""
return
# %% Constant Parameters
[docs]
class Constant(ParameterBase):
"""
Parameter that is immutable once set.
Attributes
----------
value : Any
The immutable value of the parameter.
Notes
-----
Once set, the value of a Constant parameter cannot be modified.
"""
def __init__(self, value, *args, **kwargs):
"""
Initialize a Constant instance.
Parameters
----------
value : Any
Constant value for the parameter.
*args : Any
Variable length argument list.
**kwargs : Any
Arbitrary keyword arguments.
"""
super().__init__(*args, default=value, **kwargs)
def __set__(self, instance, value):
"""
Disallow modification of the value of a Constant parameter.
Raises
------
ValueError
If trying to modify the constant parameter.
"""
raise ValueError("Cannot modify constant parameter.")
[docs]
class Switch(ParameterBase):
"""
Parameter that can be set to one of several predefined options.
Attributes
----------
valid : list
List of valid options for the parameter.
Notes
-----
Assign a value to this parameter from the `valid` list.
"""
def __init__(self, *args, valid, **kwargs):
"""
Initialize a Switch instance.
Parameters
----------
*args : Any
Variable length argument list.
valid : list
List of valid options for the parameter.
**kwargs : Any
Arbitrary keyword arguments.
Raises
------
TypeError
If `valid` is not a list.
ValueError
If the `valid` list is empty.
"""
if not isinstance(valid, list):
raise TypeError("Expected a list for valid entries")
if not valid:
raise ValueError("The valid options list cannot be empty.")
self.valid = valid
super().__init__(*args, **kwargs)
def _check(self, instance, value, recursive=False):
"""
Verify if the value belongs to the valid options.
Parameters
----------
instance : Any
Instance to retrieve the descriptor value for.
value : Any
The value to check.
recursive : bool, optional
If True, perform the check recursively. Defaults to False.
Returns
-------
bool
True if the value is among the valid options, otherwise raises an exception.
Raises
------
ValueError
If the value isn't one of the valid options.
"""
if value not in self.valid:
raise ValueError(f"Value must be one of {self.valid}")
if recursive:
super()._check(instance, value, recursive)
# %% Typed Parameters
[docs]
class Typed(ParameterBase):
"""
Mixin for parameters constrained to a specific type.
`Typed` extends the base `Parameter` class with type constraints. When instantiated
with a specific type (`ty`), it ensures values are of that type or can be cast
to that type. If `ty` is not specified during instantiation and is not predefined
by a subclass, an error is raised.
Attributes
----------
ty : type
Desired type for the parameter. Defaults to accepting any type. Subclasses
can directly set this attribute.
Methods
-------
cast_value(value) -> Any:
Attempts to cast the value to the target type. By default, returns the value
as is. Subclasses can override for specific casting behavior.
_prepare(instance, value, recursive=False) -> Any:
Prepares and optionally type-casts the value before checking its type. This
method uses `cast_value` to attempt to cast the value to the required type.
_check(instance, value, recursive=False) -> bool:
Validates if the value matches the desired type (`ty`). Raises a TypeError if
validation fails.
Notes
-----
- If `ty` is specified during instantiation, any value assigned to this parameter
undergoes validation against this type.
- Override `cast_value` in subclasses for custom casting logic.
- Assigning `None` to the parameter removes its current value from the instance.
- An error is raised during instantiation if `ty` is neither provided nor predefined
by a subclass.
See Also
--------
Parameter
Bool
Integer
Tuple
Float
String
Dictionary
"""
def __init__(self, *args, ty=None, **kwargs):
"""
Initialize a Typed instance.
Parameters
----------
*args : Any
Variable length argument list.
ty : type, optional
The desired type for the parameter.
**kwargs : Any
Arbitrary keyword arguments.
"""
if ty is not None:
self.ty = ty
elif not hasattr(self, 'ty'):
raise ValueError(
"Type must be provided either in a subclass or during instantiation."
)
super().__init__(*args, **kwargs)
[docs]
def cast_value(self, value):
"""
Cast the type of the given value.
This method is a placeholder. Override it if type-casting is necessary.
Parameters
----------
value : Any
Value to cast.
Returns
-------
Any
The unmodified value.
"""
return value
def _prepare(self, instance, value, recursive=False):
"""
Prepare and optionally type-cast the value before type validation.
This method is called before `_check` to provide an opportunity to
process or type-cast the value. By default, it uses the `cast_value`
method to attempt casting the value to the desired type.
Parameters
----------
instance : Any
The instance to which this descriptor belongs.
value : Any
The value to be prepared or type-cast.
recursive : bool, optional
If True, the preparation is done recursively by calling the parent's
_prepare method. This can be useful if `Typed` is combined with other
descriptor classes that might also need to process the value.
Defaults to False.
Returns
-------
Any
The potentially type-cast value, ready for type validation.
"""
value = self.cast_value(value)
if recursive:
value = super()._prepare(instance, value, recursive)
return value
def _check(self, instance, value, recursive=False):
"""
Validate the value's type against `ty`.
Parameters
----------
instance : Any
Instance to retrieve the descriptor value for.
value : Any
Value to check.
recursive : bool, optional
If True, perform the check recursively. Defaults to False.
Returns
-------
bool
True if value's type matches, else raises an exception.
Raises
------
TypeError
If the value's type doesn't match `ty`.
"""
if not isinstance(value, self.ty):
raise TypeError(f"Expected type {self.ty}, got {type(value)}")
if recursive:
super()._check(instance, value, recursive)
[docs]
class Bool(Typed):
"""
Parameter descriptor constrained to boolean values.
Notes
-----
This class also supports casting integers 0 and 1 to their boolean equivalents.
"""
ty = bool
[docs]
def cast_value(self, value):
"""
Convert integers 0 and 1 to their respective boolean values.
Parameters
----------
value : Any
Value to be cast.
Returns
-------
Union[bool, Any]
Boolean equivalent if value is 0 or 1; otherwise, the original value.
"""
if isinstance(value, int) and value in [0, 1]:
value = bool(value)
return value
[docs]
class Integer(Typed):
"""Parameter descriptor constrained to integer values."""
ty = int
[docs]
class Float(Typed):
"""
Parameter descriptor constrained to float values.
Notes
-----
This class also supports casting integers and numpy numbers to floats.
"""
ty = float
[docs]
def cast_value(self, value):
"""
Convert integers and numpy numbers to float.
Parameters
----------
value : Any
Value to be cast.
Returns
-------
Union[float, Any]
Float equivalent if value is an integer or numpy number;
otherwise, the original value.
"""
if isinstance(value, (int, np.number)):
value = float(value)
return value
[docs]
class String(Typed):
"""Parameter descriptor constrained to string values."""
ty = str
[docs]
class Tuple(Typed):
"""Parameter descriptor constrained to tuple values."""
ty = tuple
[docs]
class List(Typed):
"""Parameter descriptor constrained to list values."""
ty = list
[docs]
class Dictionary(Typed):
"""Parameter descriptor constrained to dictionary (`dict`) values."""
ty = dict
[docs]
class NdArray(Typed):
"""
Parameter descriptor constrained to np.ndarray values.
Notes
-----
The `cast_value` method automatically converts lists to numpy arrays and
wraps scalars (int or float) into single-element numpy arrays.
"""
ty = np.ndarray
[docs]
def cast_value(self, value):
"""
Cast lists or scalars (int or float) to numpy arrays.
Parameters
----------
value : Any
The value to be casted.
Returns
-------
np.ndarray or Any
If the value is a list or scalar (int or float),
it returns its numpy array equivalent.
Otherwise, it returns the value unchanged.
"""
if isinstance(value, list):
value = np.array(value)
elif isinstance(value, (int, float)):
value = np.array((value,))
return value
[docs]
class Callable(ParameterBase):
"""
Parameter descriptor constrained to callable objects.
Designed to ensure a given parameter is callable. This is distinct from using a type
constraint since built-in functions (e.g., those implemented in C) won't be captured
by `types.FunctionTypes`.
Examples
--------
Here's how you might use the `Callable` class:
>>> class MyModel:
... func = Callable()
...
>>> model = MyModel()
>>> model.func = print # This is fine as print is callable
>>> model.func = "not_callable" # This will raise a TypeError
See Also
--------
Parameter
Typed
"""
def _check(self, instance, value, recursive=False):
"""
Check if the given value is callable.
Parameters
----------
instance : Any
Instance to retrieve the descriptor value for.
value : Any
Value to check.
recursive : bool, optional
If True, perform the check recursively. Defaults to False.
Raises
------
TypeError
If the value is not callable.
"""
if not callable(value):
raise TypeError("Expected object to be callable")
if recursive:
super()._check(instance, value, recursive)
# %% Ranged Parameters
[docs]
class Ranged(ParameterBase):
"""Descriptor for parameters within specified bounds.
Allows setting values constrained by provided lower and upper bounds. The actual comparisons
against the bounds can be customized using the `lb_op` and `ub_op` comparison functions.
Attributes
----------
lb : numeric
The lower bound of the parameter. Default is negative infinity.
lb_op : callable
A callable that defines the comparison operation against the lower bound.
Default is less than (<).
ub : numeric
The upper bound of the parameter. Default is positive infinity.
ub_op : callable
A callable that defines the comparison operation against the upper bound.
Default is greater than (>).
Examples
--------
Constraining a parameter between 0 and 10:
>>> class MyClass:
... value = Ranged(lb=0, ub=10)
...
>>> obj = MyClass()
>>> obj.value = 5 # This is valid
>>> obj.value = -5 # Raises an error
Notes
-----
- By default, values are checked to be strictly within the bounds (exclusive).
To change this behavior, use the `lb_op` and `ub_op` parameters.
- The `check_range` method can be overridden for custom range validation logic,
especially if the data structure isn't a simple scalar value
(e.g. for np.ndarrays).
See Also
--------
ParameterBase
"""
def __init__(
self, *args,
lb=-math.inf, lb_op=operator.lt,
ub=math.inf, ub_op=operator.gt,
**kwargs):
"""
Initialize the Ranged descriptor.
Parameters
----------
lb : numeric, optional
Lower bound. Defaults to negative infinity.
lb_op : callable, optional
Comparison for the lower bound. Defaults to less than.
ub : numeric, optional
Upper bound. Defaults to positive infinity.
ub_op : callable, optional
Comparison for the upper bound. Defaults to greater than.
"""
self.lb = lb
self.lb_op = lb_op
self.ub = ub
self.ub_op = ub_op
super().__init__(*args, **kwargs)
[docs]
def check_range(self, value):
"""
Validate if the value is within the defined range.
Override this method if other methods for checking ranges are required
(e.g. for np.ndarrays).
Parameters
----------
value : Any
Value to check against the range.
Raises
------
ValueError
If the value is outside the specified bounds.
"""
if self.lb_op(value, self.lb):
raise ValueError(f"Value {value} is below the lower bound of {self.lb}")
elif self.ub_op(value, self.ub):
raise ValueError(f"Value {value} is above the upper bound of {self.ub}")
def _check(self, instance, value, recursive=False):
"""
Validate the value against the range.
Parameters
----------
instance : Any
Instance to retrieve the descriptor value for.
value : Any
Value to check.
recursive : bool, optional
If True, perform the check recursively. Defaults to False.
"""
self.check_range(value)
if recursive:
super()._check(instance, value, recursive)
# Ranged scalar parameters
[docs]
class RangedInteger(Integer, Ranged):
"""Parameter descriptor for integers parameters constrained within bounds."""
pass
[docs]
class RangedFloat(Float, Ranged):
"""Parameter descriptor for float parameters constrained within bounds."""
pass
# Ranged list / array parameters
[docs]
class RangedArray(Ranged):
"""Parameter descriptor for arrays with elements constrained within some bounds.
This class extends the Ranged descriptor to support array-like structures
(lists, numpy arrays, etc.). Each element in the array is individually checked
against the specified bounds.
Examples
--------
Constraining elements of an array parameter between 0 and 10:
>>> class MyClass:
... values = RangedArray(lb=0, ub=10)
...
>>> obj = MyClass()
>>> obj.values = [5, 7, 2] # This is valid
>>> obj.values = [5, -1, 2] # Raises an error indicating the second element is
below the lower bound.
Notes
-----
- The class uses numpy for efficient element-wise comparison.
- In case of out-of-bound values, the raised exception specifies the index/indices
of such values.
See Also
--------
Ranged
"""
[docs]
def check_range(self, value):
"""Check each element of an array-like structure against specified bounds.
Parameters
----------
value : array-like
The array whose elements need to be checked against the range.
Raises
------
ValueError
If any element(s) of the array are outside the specified bounds. The raised exception
indicates the index/indices of out-of-bound values.
"""
value_array = np.array(value)
if np.any(self.lb_op(value_array, self.lb)):
idx = np.where(self.lb_op(value_array, self.lb))[0]
raise ValueError(
f"Element(s) at index/indices {idx} below the lower bound of {self.lb}"
)
elif np.any(self.ub_op(value_array, self.ub)):
idx = np.where(self.ub_op(value_array, self.ub))[0]
raise ValueError(
f"Element(s) at index/indices {idx} above the upper bound of {self.ub}"
)
[docs]
class RangedList(List, RangedArray):
"""Parameter descriptor for lists with elements constrained within bounds."""
pass
[docs]
class RangedNdArray(NdArray, RangedArray):
"""Parameter descriptor for numpy arrays with elements constrained within bounds."""
pass
# Unsigned scalar parameters
[docs]
class Unsigned(Ranged):
"""Parameter descriptor for non-negative parameters."""
def __init__(self, *args, **kwargs):
super().__init__(*args, lb=0, lb_op=operator.lt, **kwargs)
[docs]
class UnsignedInteger(Integer, Unsigned):
"""Parameter descriptor for unsigned integer parameters."""
pass
[docs]
class UnsignedFloat(Float, Unsigned):
"""Parameter descriptor for unsigned floating-point parameters."""
pass
# Unsigned list / array parameters
[docs]
class UnsignedArray(Unsigned, RangedArray):
"""Parameter descriptor for arrays with non-negative elements."""
pass
[docs]
class UnsignedList(List, UnsignedArray):
"""Parameter descriptor for lists with non-negative elements."""
pass
[docs]
class UnsignedNdArray(NdArray, UnsignedArray):
"""Parameter descriptor for numpy arrays with non-negative elements."""
pass
# %% Sized Parameters
[docs]
class Sized(ParameterBase):
"""
Descriptor for parameters with size that potentially depends on instance attributes.
Attributes
----------
size : tuple
Expected size or dimensions of the parameter. Individual elements can be
either integers or strings indicating other instance parameters that influence
the size.
Methods
-------
is_independent : bool
Determines whether the size is independent of other parameters.
get_size(value) -> int
Calculates the size of the given value. Override for custom behavior.
get_expected_size(instance) -> int
Computes the expected size based on the instance's other attributes.
check_size(instance, value)
Validates that the provided value's size matches the expected size.
"""
def __init__(self, *args, size, **kwargs):
"""
Initialize the Sized descriptor.
Parameters
----------
size : int or tuple
The expected size or dimensions of the parameter. Individual elements
can be either integers or strings (indicating other instance parameters).
"""
if not isinstance(size, tuple):
size = (size,)
self.size = size
super().__init__(*args, **kwargs)
@property
def is_independent(self):
"""
Determine whether the size is independent of other parameters.
Returns
-------
bool
True if the size is independent,
False if it depends on other instance attributes.
"""
flag = True
if any(isinstance(i, str) for i in self.size):
flag = False
return flag
[docs]
def get_size(self, value):
"""
Determines the size of the provided value.
Parameters
----------
value : Any
The value for which the size needs to be calculated.
Returns
-------
Union[int, Tuple[int, ...]]
Size of the value.
"""
return len(value)
[docs]
def get_expected_size(self, instance):
"""
Compute the expected size based on the instance's other attributes.
Parameters
----------
instance : object
The instance whose attributes determine the expected size.
Returns
-------
int
Computed expected size based on instance attributes.
Raises
------
ValueError
If an attribute, on which the size depends, is not set.
"""
if not self.is_independent and instance is None:
raise ValueError(
"Parameter is not independent, need instance get expected size!"
)
size = []
for i in self.size:
if isinstance(i, int):
size.append(i)
continue
i_value = getattr(instance, i)
if i_value is None:
raise ValueError(f"Value for {i} not set.")
size.append(i_value)
return np.prod(size)
[docs]
def check_size(self, instance, value):
"""
Validate that the provided value's size matches the expected size.
Parameters
----------
instance : object
The instance associated with the parameter.
value : Any
The value whose size needs to be validated.
Raises
------
ValueError
If the value's size does not match the expected size.
"""
if np.array(value).size == 1:
return
size = self.get_size(value)
expected_size = self.get_expected_size(instance)
if size != expected_size:
raise ValueError(f"Expected size {expected_size}")
def _prepare(self, instance, value, recursive=False):
"""
Prepare the value by typecasting and adjusting its size.
This method handles typecasting of the provided value to the expected type
(`self.ty`) and potentially adjusts its size. If the size of the parameter
depends on other instance attributes, it will retrieve that expected size and
adjust the value accordingly.
Parameters
----------
instance : object
The instance associated with the parameter.
value : Any
The value to be prepared.
recursive : bool, optional
If True, recursively prepares the value using the superclass's method.
Defaults to False.
Returns
-------
Any
The prepared value, which has been potentially typecasted and resized.
Raises
------
ValueError
If the value cannot be cast to the expected type.
"""
if not isinstance(value, self.ty):
try:
expected_size = self.get_expected_size(instance)
except ValueError:
expected_size = None
if isinstance(value, (int, float)):
value = self.ty((value, ))
else:
raise ValueError("Cannot cast value from given value.")
if expected_size is not None:
value = np.prod(expected_size) * value
if recursive:
value = super()._prepare(instance, value, recursive)
return value
def _check(self, instance, value, recursive=False):
"""
Validate the provided value's size.
Parameters
----------
instance : Any
Instance to retrieve the descriptor value for.
value : Any
Value to check.
recursive : bool, optional
If True, perform the check recursively. Defaults to False.
"""
self.check_size(instance, value)
if recursive:
super()._check(instance, value, recursive)
[docs]
class SizedList(List, Sized):
"""Descriptor for lists whose size may depend on other instance attributes."""
pass
[docs]
class SizedTuple(Tuple, Sized):
"""Descriptor for tuples whose size may depend on other instance attributes."""
pass
[docs]
class SizedNdArray(NdArray, Sized):
"""Descriptor for NumPy arrays whose size may depend on other instance attributes."""
[docs]
def get_size(self, value):
return value.shape
[docs]
def get_expected_size(self, instance):
"""
Calculate the expected size of a numpy array based on the instance's other attributes.
Returns
-------
tuple
The expected shape of the array.
"""
if not self.is_independent and instance is None:
raise ValueError("Parameter is not independent, need instance!")
expected_size = []
for i in self.size:
if isinstance(i, int):
expected_size += [i]
continue
dim = getattr(instance, i)
if dim is None:
raise ValueError(f"Value for {i} not set.")
if isinstance(dim, np.ndarray):
dim = list(dim.shape)
elif isinstance(dim, int):
dim = [dim]
expected_size += dim
expected_size = tuple(expected_size)
return expected_size
def _prepare(self, instance, value, recursive=False):
"""
Prepare the value for a NumPy array, ensuring it has the expected shape.
If the value is a scalar (int or float), this method will transform it into a NumPy
array with the expected shape, filled with the scalar value. If the value is
already an array, this method ensures its shape matches the expected shape.
Parameters
----------
instance : object
The instance associated with the parameter.
value : Any
The value to be prepared.
recursive : bool, optional
If True, recursively prepares the value using the superclass's method.
Defaults to False.
Returns
-------
np.ndarray
The prepared NumPy array with the correct shape.
Raises
------
ValueError
If the value cannot be cast to a NumPy array with the expected shape.
"""
# if not isinstance(value, self.ty):
if isinstance(value, (int, float)):
try:
expected_size = self.get_expected_size(instance)
except ValueError:
expected_size = None
if expected_size is not None:
value = value * np.ones(expected_size)
else:
try:
self.check_size(instance, np.array(value))
except ValueError:
raise ValueError("Cannot cast value from given value.")
if recursive:
value = super()._prepare(instance, value, recursive)
return value
[docs]
class SizedRangedList(RangedList, Sized):
"""Descriptor for ranged lists whose size depends on other instance attributes."""
pass
[docs]
class SizedUnsignedList(UnsignedList, SizedList):
"""Descriptor for unsigned lists whose size depends on other instance attributes."""
pass
[docs]
class SizedUnsignedNdArray(UnsignedNdArray, SizedNdArray):
"""Descriptor for unsigned numpy arrays whose size depends on other instance attributes."""
pass
# Also check content type
[docs]
class SizedRangedIntegerList(RangedList, Integer, Sized):
"""Descriptor for ranged lists of integers whose size depends on other instance attributes."""
pass
[docs]
class SizedUnsignedIntegerList(UnsignedList, Integer, Sized):
"""Descriptor for unsigned lists of integers whose size depends on other instance attributes."""
pass
# %% Dimensionalized Parameters
[docs]
class DimensionalizedArray(NdArray):
"""
Parameter descriptor constrained to np.arrays with a specific dimensionality.
The descriptor ensures that the ndarray assigned matches the specified
number of dimensions (n_dim).
Attributes
----------
n_dim : int
The number of dimensions the array must have.
Examples
--------
To create a descriptor for 2-dimensional arrays:
>>> class MyClass:
... arr = DimensionalizedArray(n_dim=2)
...
>>> obj = MyClass()
>>> obj.arr = np.array([[1, 2], [3, 4]]) # This is valid
>>> obj.arr = np.array([1, 2, 3, 4]) # Raises a ValueError
Notes
-----
The n_dim attribute can be set during initialization.
"""
n_dim = None
def __init__(self, n_dim=None, *args, **kwargs):
"""
Initialize the DimensionalizedArray descriptor.
Parameters
----------
n_dim : int, optional
The number of dimensions the array must have. If not specified,
it must be set before usage.
Raises
------
ValueError
If n_dim is not an integer.
"""
super().__init__(*args, **kwargs)
if n_dim is not None:
if not isinstance(n_dim, int):
raise ValueError('Dimensionality (n_dim) must be an integer.')
self.n_dim = n_dim
# Ensure the dimension is set and valid
if self.n_dim is None:
raise ValueError(
'Dimensionality (n_dim) must be set during initialization.'
)
def _check(self, instance, value, recursive=False):
"""
Check the dimensionality of the given value.
Parameters
----------
instance : Any
Instance to retrieve the descriptor value for.
value : Any
Value to check.
recursive : bool, optional
If True, perform the check recursively. Defaults to False.
Raises
------
ValueError
If the number of dimensions of the ndarray doesn't match n_dim.
"""
if self.n_dim != np.array(value).ndim:
raise ValueError(
f"Expected {self.n_dim} dimensions, got {np.array(value).ndim}."
)
if recursive:
super()._check(instance, value, recursive)
[docs]
class Vector(DimensionalizedArray):
"""
Parameter descriptor for one-dimensional numpy arrays (vectors).
Attributes
----------
n_dim : int
Dimensionality of the numpy array, set to 1 for vectors.
Examples
--------
>>> class MyModel:
... coordinates = Vector()
...
>>> model.coordinates = np.array([1, 2, 3]) # Valid
>>> model.coordinates = np.array([[1, 2], [3, 4]]) # Raises ValueError
See Also
--------
Dimensionalized
NdArray
"""
n_dim = 1
[docs]
class Matrix(DimensionalizedArray):
"""
Parameter descriptor for two-dimensional numpy arrays (matrices).
This descriptor ensures that the ndarray assigned is two-dimensional.
Attributes
----------
n_dim : int
Dimensionality of the numpy array, set to 2 for matrices.
Examples
--------
>>> class MyModel:
... data = Matrix()
...
>>> model = MyModel()
>>> model.data = np.array([[1, 2], [3, 4]]) # Valid
>>> model.data = np.array([1, 2, 3, 4]) # Raises ValueError
See Also
--------
DimensionalizedArray
NdArray
"""
n_dim = 2
# %% Polynomial Parameters
[docs]
class NdPolynomial(SizedNdArray):
"""
Dependently sized polynomial for n entries.
This descriptor represents a polynomial whose size or dimensions may depend on other
instance attributes. The polynomial can also be thought of as a 2D array, where each
row represents a polynomial of a certain degree.
Important: Use [entries x n_coeff] for dependencies.
Parameters
----------
n_entries : int, optional
Number of polynomials or rows. Default is None.
n_coeff : int, optional
Number of coefficients for each polynomial or columns. Default is None.
Attributes
----------
size : tuple
The shape of the polynomial array, determined from n_entries and n_coeff.
Notes
-----
Currently, NdPolynomial is implemented as `SizedNdArray`.
Consequently, no default values can be set since their size would depend on the
dependent variables.
In theory, this could be split into `NdPolynomial` and `SizedNdPolynomial`,
but there is currently no use for this distinction.
Methods
-------
fill_values(dims, value) -> np.ndarray:
Fills values to generate the polynomial matrix of the desired size.
_prepare(instance, value, recursive=False) -> np.ndarray:
Prepare the given polynomial matrix s.t. it adheres to the expected size.
"""
def __init__(self, *args, n_entries=None, n_coeff=None, **kwargs):
"""
Initialize an NdPolynomial descriptor with specific entries and coefficients.
Parameters
----------
n_entries : int, optional
Set the number of polynomials or rows. If not provided, it must be inferred
from 'size'.
n_coeff : int, optional
Define the number of coefficients for each polynomial or columns. If not
provided, it must be inferred from 'size'.
*args :
Variable length argument list.
**kwargs :
Arbitrary keyword arguments, can include 'size' which denotes the shape of
the polynomial array.
Raises
------
ValueError
If both 'n_entries' and 'n_coeff' are missing and 'size' is not provided or
is incomplete.
If there's a mismatch or redundancy in provided dimensions.
Notes
-----
The shape of the polynomial array is determined from 'n_entries' and 'n_coeff'
or from the 'size' keyword argument.
"""
if 'default' in kwargs and kwargs['default'] != 0:
raise ValueError("Default value for NdPolynomial must always be 0.")
try:
size = kwargs['size']
if not isinstance(size, tuple):
size = (size,)
except KeyError:
size = tuple()
if n_entries is None and n_coeff is None and len(size) < 2:
raise ValueError("Missing value for n_coeff for shape")
if n_entries is not None:
if len(size) > 1:
raise ValueError("Found duplicate n_entries for shape")
_n_entries = n_entries
if n_coeff is not None:
if len(size) > 0:
raise ValueError("Found duplicate n_entries for shape")
else:
_n_entries = size[0]
if n_coeff is not None:
if len(size) > 1:
raise ValueError("Found duplicate n_entries for shape")
_n_coeff = n_coeff
if n_entries is not None:
if len(size) > 0:
raise ValueError("Found duplicate n_entries for shape")
else:
if n_entries is not None:
_n_coeff = size[0]
else:
_n_coeff = size[1]
size = (_n_entries, _n_coeff)
kwargs['size'] = size
super().__init__(*args, **kwargs)
[docs]
def fill_values(self, dims, value):
"""
Fill values to generate the polynomial matrix of the desired size.
Parameters
----------
dims : tuple of int
Dimensions (n_entries, n_coeff) of the polynomial matrix.
value : Union[int, float, np.ndarray, list]
Value(s) to be filled in the polynomial matrix.
Returns
-------
np.ndarray
Polynomial matrix of the desired size filled with given values.
Raises
------
ValueError
If there's a mismatch between the provided values and expected dimensions.
"""
if len(dims) == 1:
n_entries = 1
n_coeff = dims[0]
single_entry = True
value = np.array(value, ndmin=2)
else:
n_entries = dims[0]
n_coeff = dims[1]
single_entry = False
if single_entry and n_entries > 1:
raise ValueError("Can only set single entry if n_entries == 1.")
if isinstance(value, np.ndarray) and value.size == 1:
value = float(value.squeeze())
if isinstance(value, (int, float)):
value = n_entries * [value]
elif isinstance(value, np.ndarray):
value = value.tolist()
if len(value) != n_entries:
raise ValueError("Number of entries does not match")
_value = np.zeros((n_entries, n_coeff))
for i, v in enumerate(value):
if isinstance(v, (int, float, np.number)):
v = [v]
if isinstance(v, (list, tuple)):
missing = n_coeff - len(v)
v += missing*(0,)
_value[i, :] = np.array(v)
if single_entry:
_value = _value[0]
return _value
def _prepare(self, instance, value, recursive=False):
"""
Prepare the given polynomial matrix s.t. it adheres to the expected size.
Parameters
----------
instance : object
The instance whose attributes might influence the size.
value : Union[int, float, np.ndarray, list]
Value(s) to be filled in the polynomial matrix.
recursive : bool, optional
If True, perform the operation recursively. Defaults to False.
Returns
-------
np.ndarray
Polynomial matrix of the desired size prepared as per requirements.
"""
if instance is not None:
dims = self.get_expected_size(instance)
value = self.fill_values(dims, value)
if recursive:
value = super()._prepare(instance, value, recursive)
return value
[docs]
class Polynomial(NdPolynomial):
"""
Represent a single polynomial using coefficients.
This class serves as a simplified version of NdPolynomial, specifically tailored for
single polynomials. It is always defined by its coefficients, removing the need for
an 'n_entries' parameter.
Use this class when only a single polynomial representation is required.
Attributes
----------
size : tuple
Size of the coefficients for the polynomial. Derived from NdPolynomial but omits
'n_entries'.
"""
def __init__(self, *args, **kwargs):
"""
Initialize a Polynomial descriptor with a specific coefficient length.
Parameters
----------
*args :
Variable length argument list.
**kwargs :
Arbitrary keyword arguments. Typically used for any parameters inherited
from NdPolynomial, except 'n_entries'.
Notes
-----
The Polynomial descriptor is a special case of NdPolynomial with 'n_entries'
always set to 1. Therefore, it's only defined by its coefficients.
"""
super().__init__(*args, n_entries=1, **kwargs)
self.size = self.size[1:]
# %% Modulated Parameters
[docs]
class DependentlyModulated(Sized):
"""
Mixin for checking parameter shapes based on other instance attributes.
This mixin ensures that the size of a parameter is modulo an expected size.
If this condition is not met, a ValueError is raised.
"""
[docs]
def check_mod_value(self, instance, value):
"""
Check if the size of the parameter modulo its expected size is zero.
By default, this method checks the modulo condition, but can be overridden
by subclasses to incorporate custom behaviors.
Parameters
----------
instance : object
The instance associated with the parameter.
value : Any
The value whose size needs to be validated.
Raises
------
ValueError
If the modulo condition of the size does not meet the expected criteria.
"""
size = self.get_size(value)
expected_size = self.get_expected_size(instance)
size %= expected_size
if size != 0:
raise ValueError(
f"The size of the value modulo the expected size is not zero. "
f"Size: {size}, Expected Size: {expected_size}"
)
check_size = check_mod_value
[docs]
class DependentlyModulatedUnsignedList(UnsignedList, SizedList, DependentlyModulated):
"""List of unsigned values whose size is dependent on other attributes."""
pass