From 888f4af64d890c00c48719fcd323bf99bcfc6084 Mon Sep 17 00:00:00 2001 From: Eric Lindgren Date: Mon, 3 Mar 2025 16:21:17 +0100 Subject: [PATCH 01/14] create a new branch based on develop --- src/easyscience/Objects/variable/__init__.py | 4 +- .../Objects/variable/descriptor_array.py | 760 ++++++++++++++ .../Objects/variable/test_descriptor_array.py | 947 ++++++++++++++++++ 3 files changed, 1709 insertions(+), 2 deletions(-) create mode 100644 src/easyscience/Objects/variable/descriptor_array.py create mode 100644 tests/unit_tests/Objects/variable/test_descriptor_array.py diff --git a/src/easyscience/Objects/variable/__init__.py b/src/easyscience/Objects/variable/__init__.py index e2b6663..04a9f6c 100644 --- a/src/easyscience/Objects/variable/__init__.py +++ b/src/easyscience/Objects/variable/__init__.py @@ -1,11 +1,11 @@ -from .descriptor_any_type import DescriptorAnyType +from .descriptor_array import DescriptorArray from .descriptor_bool import DescriptorBool from .descriptor_number import DescriptorNumber from .descriptor_str import DescriptorStr from .parameter import Parameter __all__ = [ - DescriptorAnyType, + DescriptorArray, DescriptorBool, DescriptorNumber, DescriptorStr, diff --git a/src/easyscience/Objects/variable/descriptor_array.py b/src/easyscience/Objects/variable/descriptor_array.py new file mode 100644 index 0000000..4a6f112 --- /dev/null +++ b/src/easyscience/Objects/variable/descriptor_array.py @@ -0,0 +1,760 @@ +from __future__ import annotations + +import numbers +import operator as op +from typing import Any +from typing import Dict +from typing import List +from typing import Optional +from typing import Union +from warnings import warn + +import numpy as np +import scipp as sc +from scipp import UnitError +from scipp import Variable + +from easyscience.global_object.undo_redo import PropertyStack +from easyscience.global_object.undo_redo import property_stack_deco + +from .descriptor_base import DescriptorBase +from .descriptor_number import DescriptorNumber + + +class DescriptorArray(DescriptorBase): + """ + A `Descriptor` for Array values with units. The internal representation is a scipp array. + """ + + def __init__( + self, + name: str, + value: Union[list, np.ndarray], + unit: Optional[Union[str, sc.Unit]] = '', + variance: Optional[numbers.Number] = None, + unique_name: Optional[str] = None, + description: Optional[str] = None, + url: Optional[str] = None, + display_name: Optional[str] = None, + parent: Optional[Any] = None, + dims: Optional[list] = None + ): + """Constructor for the DescriptorArray class + + param name: Name of the descriptor + param value: List containing the values of the descriptor + param unit: Unit of the descriptor + param variance: Variances of the descriptor + param description: Description of the descriptor + param url: URL of the descriptor + param display_name: Display name of the descriptor + param parent: Parent of the descriptor + param dims: List of dimensions to pass to scipp. Will be autogenerated if not supplied. + .. note:: Undo/Redo functionality is implemented for the attributes `variance`, `error`, `unit` and `value`. + """ + + if not isinstance(value, (list, np.ndarray)): + raise TypeError(f"{value=} must be a list or numpy array.") + if isinstance(value, list): + value = np.array(value) # Convert to numpy array for consistent handling. + + if variance is not None: + if not isinstance(variance, (list, np.ndarray)): + raise TypeError(f"{variance=} must be a list or numpy array if provided.") + if isinstance(variance, list): + variance = np.array(variance) # Convert to numpy array for consistent handling. + if variance.shape != value.shape: + raise ValueError(f"{variance=} must have the same shape as {value=}.") + if not np.all(variance >= 0): + raise ValueError(f"{variance=} must only contain non-negative values.") + + if not isinstance(unit, sc.Unit) and not isinstance(unit, str): + raise TypeError(f'{unit=} must be a scipp unit or a string representing a valid scipp unit') + + + if dims is None: + # Autogenerate dimensions if not supplied + dims = ['dim'+str(i) for i in range(len(value.shape))] + if not len(dims) == len(value.shape): + raise ValueError(f"Length of dims ({dims=}) does not match length of value {value=}.") + self._dims = dims + + try: + self._array = sc.array(dims=dims, values=value, unit=unit, variances=variance) + except Exception as message: + raise UnitError(message) + # TODO: handle 1xn and nx1 arrays + self._array = sc.array(dims=dims, values=value, unit=unit, variances=variance) + + super().__init__( + name=name, + unique_name=unique_name, + description=description, + url=url, + display_name=display_name, + parent=parent, + ) + + # Call convert_unit during initialization to ensure that the unit has no numbers in it, and to ensure unit consistency. + if self.unit is not None: + self.convert_unit(self._base_unit()) + + @classmethod + def from_scipp(cls, name: str, full_value: Variable, **kwargs) -> DescriptorArray: + """ + Create a DescriptorArray from a scipp array. + + :param name: Name of the descriptor + :param full_value: Value of the descriptor as a scipp variable + :param kwargs: Additional parameters for the descriptor + :return: DescriptorArray + """ + if not isinstance(full_value, Variable): + raise TypeError(f'{full_value=} must be a scipp array') + return cls(name=name, value=full_value.values, unit=full_value.unit, variance=full_value.variances, **kwargs) + + @property + def full_value(self) -> Variable: + """ + Get the value of self as a scipp array. This is should be usable for most cases. + + :return: Value of self with unit. + """ + return self._array + + @full_value.setter + def full_value(self, full_value: Variable) -> None: + raise AttributeError( + f'Full_value is read-only. Change the value and variance seperately. Or create a new {self.__class__.__name__}.' + ) + + @property + def value(self) -> numbers.Number: + """ + Get the value. This should be usable for most cases. The full value can be obtained from `obj.full_value`. + + :return: Value of self with unit. + """ + return self._array.values + + @value.setter + @property_stack_deco + def value(self, value: Union[list, np.ndarray]) -> None: + """ + Set the value of self. Ensures the input is an array and matches the shape of the existing array. + The full value can be obtained from `obj.full_value`. + + :param value: New value for the DescriptorArray, must be a list or numpy array. + """ + if not isinstance(value, (list, np.ndarray)): + raise TypeError(f"{value=} must be a list or numpy array.") + if isinstance(value, list): + value = np.array(value) # Convert lists to numpy arrays for consistent handling. + + if value.shape != self._array.values.shape: + raise ValueError(f"{value=} must have the same shape as the existing array values.") + + self._array.values = value + + @property + def dims(self) -> list: + """ + Get the dims used for the underlying scipp array. + + :return: dims of self. + """ + return self._dims + + @dims.setter + def dims(self, dims: Union[list, np.ndarray]) -> None: + """ + Set the dims of self. Ensures that the input has a shape compatible with self.full_value. + + :param value: list of dims. + """ + if not isinstance(dims, (list, np.ndarray)): + raise TypeError(f"{dims=} must be a list or numpy array.") + + if len(dims) != len(self._dims): + raise ValueError(f"{dims=} must have the same shape as the existing dims") + + self._dims = dims + + @property + def unit(self) -> str: + """ + Get the unit. + + :return: Unit as a string. + """ + return str(self._array.unit) + + @unit.setter + def unit(self, unit_str: str) -> None: + raise AttributeError( + ( + f'Unit is read-only. Use convert_unit to change the unit between allowed types ' + f'or create a new {self.__class__.__name__} with the desired unit.' + ) + ) # noqa: E501 + + @property + def variance(self) -> float: + """ + Get the variance. + + :return: variance. + """ + return self._array.variances + + @variance.setter + @property_stack_deco + def variance(self, variance: Union[list, np.ndarray]) -> None: + """ + Set the variance of self. Ensures the input is an array and matches the shape of the existing values. + + :param variance: New variance for the DescriptorArray, must be a list or numpy array. + """ + if variance is not None: + if not isinstance(variance, (list, np.ndarray)): + raise TypeError(f"{variance=} must be a list or numpy array.") + if isinstance(variance, list): + variance = np.array(variance) # Convert lists to numpy arrays for consistent handling. + + if variance.shape != self._array.values.shape: + raise ValueError(f"{variance=} must have the same shape as the array values.") + + if not np.all(variance >= 0): + raise ValueError(f"{variance=} must only contain non-negative values.") + + self._array.variances = variance + + @property + def error(self) -> Optional[np.ndarray]: + """ + The standard deviations, calculated as the square root of variances. + + :return: A numpy array of standard deviations, or None if variances are not set. + """ + if self._array.variances is None: + return None + return np.sqrt(self._array.variances) + + @error.setter + @property_stack_deco + def error(self, error: Union[list, np.ndarray]) -> None: + """ + Set the standard deviation for the parameter, which updates the variances. + + :param error: A list or numpy array of standard deviations. + """ + if error is not None: + if not isinstance(error, (list, np.ndarray)): + raise TypeError(f"{error=} must be a list or numpy array.") + if isinstance(error, list): + error = np.array(error) # Convert lists to numpy arrays for consistent handling. + + if error.shape != self._array.values.shape: + raise ValueError(f"{error=} must have the same shape as the array values.") + + if not np.all(error >= 0): + raise ValueError(f"{error=} must only contain non-negative values.") + + # Update variances as the square of the errors + self._array.variances = error**2 + else: + self._array.variances = None + + def convert_unit(self, unit_str: str) -> None: + """ + Convert the value from one unit system to another. + + :param unit_str: New unit in string form + """ + if not isinstance(unit_str, str): + raise TypeError(f'{unit_str=} must be a string representing a valid scipp unit') + try: + new_unit = sc.Unit(unit_str) + except UnitError as message: + raise UnitError(message) from None + + # Save the current state for undo/redo + old_array = self._array + + # Perform the unit conversion + try: + new_array = self._array.to(unit=new_unit) + except Exception as e: + raise UnitError(f"Failed to convert unit: {e}") from e + + # Define the setter function for the undo stack + def set_array(obj, scalar): + obj._array = scalar + + # Push to undo stack + self._global_object.stack.push( + PropertyStack(self, set_array, old_array, new_array, text=f"Convert unit to {unit_str}") + ) + + # Update the array + self._array = new_array + + def __copy__(self) -> DescriptorArray: + """ + Return a copy of the current DescriptorArray. + """ + return super().__copy__() + + def __repr__(self) -> str: + """ + Return a string representation of the DescriptorArray, showing its name, value, variance, and unit. + Large arrays are summarized for brevity. + """ + # Base string with name + string = f"<{self.__class__.__name__} '{self._name}': " + + # Summarize array values + values_summary = np.array2string( + self._array.values, + precision=4, + threshold=10, # Show full array if <=10 elements, else summarize + edgeitems=3, # Show first and last 3 elements for large arrays + ) + string += f"values={values_summary}" + + # Add errors if they exists + if self._array.variances is not None: + errors_summary = np.array2string( + self.error, + precision=4, + threshold=10, + edgeitems=3, + ) + string += f", errors={errors_summary}" + + # Add unit + obj_unit = str(self._array.unit) + if obj_unit and obj_unit != "dimensionless": + string += f", unit={obj_unit}" + + string += ">" + string=string.replace('\n', ',') + return string + + + def as_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]: + """ + Dict representation of the current DescriptorArray. + The dict contains the value, unit and variances, in addition + to the properties of DescriptorBase. + """ + raw_dict = super().as_dict(skip=skip) + raw_dict['value'] = self._array.values + raw_dict['unit'] = str(self._array.unit) + raw_dict['variance'] = self._array.variances + return raw_dict + + def _smooth_operator(self, + other: Union[DescriptorArray, DescriptorNumber, list, numbers.Number], + operator: str, + units_must_match: bool = True) -> DescriptorArray: + """ + Perform element-wise operations with another DescriptorNumber, DescriptorArray, list, or number. + + :param other: The object to operate on. Must be a DescriptorArray or DescriptorNumber with compatible units, + or a list with the same shape if the DescriptorArray is dimensionless. + :param operator: The operation to perform + :return: A new DescriptorArray representing the result of the operation. + """ + if isinstance(other, numbers.Number): + # Does not need to be dimensionless for multiplication and division + if self.unit not in [None, "dimensionless"] and units_must_match: + raise UnitError("Numbers can only be used together with dimensionless values") + new_full_value = operator(self.full_value, other) + + elif isinstance(other, list): + if self.unit not in [None, "dimensionless"] and units_must_match: + raise UnitError("Operations with lists are only allowed for dimensionless values") + + # Ensure dimensions match + if np.shape(other) != self._array.values.shape: + raise ValueError(f"Shape of {other=} must match the shape of DescriptorArray values") + + other = sc.array(dims=self._array.dims, values=other) + new_full_value = operator(self._array, other) # Let scipp handle operation for uncertainty propagation + + elif isinstance(other, DescriptorNumber): + try: + other_converted = other.__copy__() + other_converted.convert_unit(self.unit) + except UnitError: + if units_must_match: + raise UnitError(f"Values with units {self.unit} and {other.unit} are not compatible") from None + # Operations with a DescriptorNumber that has a variance WILL introduce + # correlations between the elements of the DescriptorArray. + # See, https://content.iospress.com/articles/journal-of-neutron-research/jnr220049 + # However, DescriptorArray does not consider the covariance between + # elements of the array. Hence, the broadcasting is "manually" + # performed to work around `scipp` and a warning raised to the end user. + if (self._array.variances is not None or other.variance is not None): + warn('Correlations introduced by this operation will not be considered.\ + See https://content.iospress.com/articles/journal-of-neutron-research/jnr220049\ + for further detailes', UserWarning) + # Cheeky copy() of broadcasted scipp array to force scipp to perform the broadcast here + broadcasted = sc.broadcast(other_converted.full_value, + dims=self._array.dims, + shape=self._array.shape).copy() + new_full_value = operator(self.full_value, broadcasted) + + elif isinstance(other, DescriptorArray): + try: + other_converted = other.__copy__() + other_converted.convert_unit(self.unit) + except UnitError: + if units_must_match: + raise UnitError(f"Values with units {self.unit} and {other.unit} are incompatible") from None + + # Ensure dimensions match + if self.full_value.dims != other_converted.full_value.dims: + raise ValueError(f"Dimensions of the DescriptorArrays do not match: " + f"{self.full_value.dims} vs {other_converted.full_value.dims}") + + new_full_value = operator(self.full_value, other_converted.full_value) + + else: + return NotImplemented + + descriptor_array = DescriptorArray.from_scipp(name=self.name, full_value=new_full_value) + descriptor_array.name = descriptor_array.unique_name + return descriptor_array + + def _rsmooth_operator(self, + other: Union[DescriptorArray, DescriptorNumber, list, numbers.Number], + operator: str, + units_must_match: bool = True) -> DescriptorArray: + """ + Handle reverse operations for DescriptorArrays, DescriptorNumbers, lists, and scalars. + Ensures unit compatibility when `other` is a DescriptorNumber. + """ + def reversed_operator(a, b): + return operator(b, a) + if isinstance(other, DescriptorArray): + # This is probably never called + return operator(other, self) + elif isinstance(other, DescriptorNumber): + # Ensure unit compatibility for DescriptorNumber + original_unit = self.unit + try: + self.convert_unit(other.unit) # Convert `self` to `other`'s unit + except UnitError: + # Only allowed operations with different units are + # multiplication and division. We try to convert + # the units for mul/div, but if the conversion + # fails it's no big deal. + if units_must_match: + raise UnitError(f"Values with units {self.unit} and {other.unit} are incompatible") from None + result = self._smooth_operator(other, reversed_operator, units_must_match) + # Revert `self` to its original unit + self.convert_unit(original_unit) + return result + else: + # Delegate to operation to __self__ for other types (e.g., list, scalar) + return self._smooth_operator(other, reversed_operator, units_must_match) + + def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): + """ + DescriptorArray does not generally support Numpy array functions. + For example, `np.argwhere(descriptorArray: DescriptorArray)` should fail. + Modify this function if you want to add such functionality. + """ + return NotImplemented + + def __array_function__(self, func, types, args, kwargs): + """ + DescriptorArray does not generally support Numpy array functions. + For example, `np.argwhere(descriptorArray: DescriptorArray)` should fail. + Modify this function if you want to add such functionality. + """ + return NotImplemented + + def __add__(self, other: Union[DescriptorArray, DescriptorNumber, list, numbers.Number]) -> DescriptorArray: + """ + Perform element-wise addition with another DescriptorNumber, DescriptorArray, list, or number. + + :param other: The object to add. Must be a DescriptorArray or DescriptorNumber with compatible units, + or a list with the same shape if the DescriptorArray is dimensionless. + :return: A new DescriptorArray representing the result of the addition. + """ + return self._smooth_operator(other, op.add) + + def __radd__(self, other: Union[DescriptorArray, DescriptorNumber, list, numbers.Number]) -> DescriptorArray: + """ + Handle reverse addition for DescriptorArrays, DescriptorNumbers, lists, and scalars. + Ensures unit compatibility when `other` is a DescriptorNumber. + """ + return self._rsmooth_operator(other, op.add) + + def __sub__(self, other: Union[DescriptorArray, list, np.ndarray, numbers.Number]) -> DescriptorArray: + """ + Perform element-wise subtraction with another DescriptorArray, list, or number. + + :param other: The object to subtract. Must be a DescriptorArray with compatible units, + or a list with the same shape if the DescriptorArray is dimensionless. + :return: A new DescriptorArray representing the result of the subtraction. + """ + if isinstance(other, (DescriptorArray, DescriptorNumber, list, numbers.Number)): + # Leverage __neg__ and __add__ for subtraction + if isinstance(other, list): + # Use numpy to negate all elements of the list + value = (-np.array(other)).tolist() + else: + value = -other + return self.__add__(value) + else: + return NotImplemented + + def __rsub__(self, other: Union[DescriptorArray, list, numbers.Number]) -> DescriptorArray: + """ + Perform element-wise subtraction with another DescriptorArray, list, or number. + + :param other: The object to subtract. Must be a DescriptorArray with compatible units, + or a list with the same shape if the DescriptorArray is dimensionless. + :return: A new DescriptorArray representing the result of the subtraction. + """ + if isinstance(other, (DescriptorArray, DescriptorNumber, list, numbers.Number)): + if isinstance(other, list): + # Use numpy to negate all elements of the list + value = (-np.array(other)).tolist() + else: + value = -other + return -(self.__radd__(value)) + else: + return NotImplemented + + def __mul__(self, other: Union[DescriptorArray, DescriptorNumber, list, numbers.Number]) -> DescriptorArray: + """ + Perform element-wise multiplication with another DescriptorNumber, DescriptorArray, list, or number. + + :param other: The object to multiply. Must be a DescriptorArray or DescriptorNumber with compatible units, + or a list with the same shape if the DescriptorArray is dimensionless. + :return: A new DescriptorArray representing the result of the addition. + """ + if not isinstance(other, (DescriptorArray, DescriptorNumber, list, numbers.Number)): + return NotImplemented + return self._smooth_operator(other, op.mul, units_must_match=False) + + def __rmul__(self, other: Union[DescriptorArray, DescriptorNumber, list, numbers.Number]) -> DescriptorArray: + """ + Handle reverse multiplication for DescriptorArrays, DescriptorNumbers, lists, and scalars. + Ensures unit compatibility when `other` is a DescriptorNumber. + """ + if not isinstance(other, (DescriptorArray, DescriptorNumber, list, numbers.Number)): + return NotImplemented + return self._rsmooth_operator(other, op.mul, units_must_match=False) + + def __truediv__(self, other: Union[DescriptorArray, DescriptorNumber, list, numbers.Number]) -> DescriptorArray: + """ + Perform element-wise division with another DescriptorNumber, DescriptorArray, list, or number. + + :param other: The object to use as a denominator. Must be a DescriptorArray or DescriptorNumber with compatible units, + or a list with the same shape if the DescriptorArray is dimensionless. + :return: A new DescriptorArray representing the result of the addition. + """ + if not isinstance(other, (DescriptorArray, DescriptorNumber, list, numbers.Number)): + return NotImplemented + + if isinstance(other, numbers.Number): + original_other = other + elif isinstance(other, (numbers.Number, list)): + original_other = np.array(other) + elif isinstance(other, DescriptorNumber): + original_other = other.value + elif isinstance(other, DescriptorArray): + original_other = other.full_value.values + + if np.any(original_other == 0): + raise ZeroDivisionError('Cannot divide by zero') + return self._smooth_operator(other, op.truediv, units_must_match=False) + + def __rtruediv__(self, other: Union[DescriptorArray, DescriptorNumber, list, numbers.Number]) -> DescriptorArray: + """ + Handle reverse division for DescriptorArrays, DescriptorNumbers, lists, and scalars. + Ensures unit compatibility when `other` is a DescriptorNumber. + """ + if not isinstance(other, (DescriptorArray, DescriptorNumber, list, numbers.Number)): + return NotImplemented + + if np.any(self.full_value.values == 0): + raise ZeroDivisionError('Cannot divide by zero') + + # First use __div__ to compute `self / other` + # but first converting to the units of other + inverse_result = self._rsmooth_operator(other, op.truediv, units_must_match=False) + return inverse_result + + def __pow__(self, other: Union[DescriptorNumber, numbers.Number]) -> DescriptorArray: + """ + Perform element-wise exponentiation with another DescriptorNumber or number. + + :param other: The object to use as a denominator. Must be a number or DescriptorNumber with + no unit or variance. + :return: A new DescriptorArray representing the result of the addition. + """ + if not isinstance(other, (numbers.Number, DescriptorNumber)): + return NotImplemented + + if isinstance(other, numbers.Number): + exponent = other + elif type(other) is DescriptorNumber: + if other.unit != 'dimensionless': + raise UnitError('Exponents must be dimensionless') + if other.variance is not None: + raise ValueError('Exponents must not have variance') + exponent = other.value + else: + return NotImplemented + try: + new_value = self.full_value**exponent + except Exception as message: + raise message from None + if np.any(np.isnan(new_value.values)): + raise ValueError('The result of the exponentiation is not a number') + descriptor_number = DescriptorArray.from_scipp(name=self.name, full_value=new_value) + descriptor_number.name = descriptor_number.unique_name + return descriptor_number + + def __rpow__(self, other: numbers.Number) -> numbers.Number: + """ + Defers reverse pow with a descriptor array, `a ** array`. + Exponentiation with regards to an array does not make sense, + and is not implemented. + """ + return NotImplemented + + def __neg__(self) -> DescriptorArray: + """ + Negate all values in the DescriptorArray. + """ + new_value = -self.full_value + descriptor_array = DescriptorArray.from_scipp(name=self.name, full_value=new_value) + descriptor_array.name = descriptor_array.unique_name + return descriptor_array + + def __abs__(self) -> DescriptorArray: + """ + Replace all elements in the DescriptorArray with their + absolute values. Note that this is different from the + norm of the DescriptorArray. + """ + new_value = abs(self.full_value) + descriptor_array = DescriptorArray.from_scipp(name=self.name, full_value=new_value) + descriptor_array.name = descriptor_array.unique_name + return descriptor_array + + def __getitem__(self, a) -> Union[DescriptorArray]: + """ + Slice using scipp syntax. + Defer slicing to scipp. + """ + descriptor = DescriptorArray.from_scipp(name=self.name, full_value=self.full_value.__getitem__(a)) + descriptor.name = descriptor.unique_name + return descriptor + + def __delitem__(self, a): + """ + Defer slicing to scipp. + This should fail, since scipp does not support __delitem__. + """ + return self.full_value.__delitem__(a) + + def __setitem__(self, a, b: Union[numbers.Number, list, DescriptorNumber, DescriptorArray]): + """ + __setitem via slice is not allowed, since we currently do not give back a + view to the DescriptorArray upon calling __getitem__. + """ + raise AttributeError( + f'{self.__class__.__name__} cannot be edited via slicing. Edit the underlyinf scipp\ + array via the `full_value` property, or create a\ + new {self.__class__.__name__}.' + ) + + def trace(self) -> DescriptorNumber: + """ + Computes the trace over the descriptor array. + Only works for matrices where all dimensions are equal. + """ + shape = np.array(self.full_value.shape) + N = shape[0] + if not np.all(shape == N): + raise ValueError('\ + Trace can only be taken over arrays where all dimensions are of equal length') + + trace = sc.scalar(0.0, unit=self.unit, variance=None) + for i in range(N): + # Index through all the dims to get + # the value i on the diagonal + diagonal_element = self.full_value + for dim in self.full_value.dims: + diagonal_element = diagonal_element[dim, i] + trace = trace + diagonal_element + + descriptor = DescriptorNumber.from_scipp(name=self.name, full_value=trace) + descriptor.name = descriptor.unique_name + return descriptor + + def sum(self, dim: Optional[Union[str, list]] = None) -> DescriptorNumber: + """ + Uses scipp to sum over the requested dims. + :param dim: The dim(s) in the scipp array to sum over. If `None`, will sum over all dims. + """ + new_full_value = self.full_value.sum(dim=dim) + + # If fully reduced the result will be a DescriptorNumber, + # otherwise a DescriptorArray + if dim is None: + constructor = DescriptorNumber.from_scipp + else: + constructor = DescriptorArray.from_scipp + + descriptor = constructor(name=self.name, full_value=new_full_value) + descriptor.name = descriptor.unique_name + return descriptor + + # This is to be implemented at a later time + # def __matmul__(self, other: [DescriptorArray, list]) -> DescriptorArray: + # """ + # Perform matrix multiplication with with another DesciptorArray or list. + + # :param other: The object to use as a denominator. Must be a DescriptorArray + # or a list, of compatible shape. + # :return: A new DescriptorArray representing the result of the addition. + # """ + # if not isinstance(other, (DescriptorArray, list)): + # return NotImplemented + + # if isinstance(other, DescriptorArray): + # shape = other.full_value.shape + # elif isinstance(other, list): + # shape = np.shape(other) + + # # Dimensions must match for matrix multiplication + # if shape[0] != self._array.values.shape[-1]: + # raise ValueError(f"Last dimension of {other=} must match the first dimension of DescriptorArray values") + # + # other = sc.array(dims=self._array.dims, values=other) + # new_full_value = operator(self._array, other) # Let scipp handle operation for uncertainty propagation + + + def _base_unit(self) -> str: + """ + Returns the base unit of the current array. + For example, if the unit is `100m`, returns `m`. + """ + string = str(self._array.unit) + for i, letter in enumerate(string): + if letter == 'e': + if string[i : i + 2] not in ['e+', 'e-']: + return string[i:] + elif letter not in ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '+', '-']: + return string[i:] + return '' diff --git a/tests/unit_tests/Objects/variable/test_descriptor_array.py b/tests/unit_tests/Objects/variable/test_descriptor_array.py new file mode 100644 index 0000000..7be5dd1 --- /dev/null +++ b/tests/unit_tests/Objects/variable/test_descriptor_array.py @@ -0,0 +1,947 @@ +import pytest +from unittest.mock import MagicMock +import scipp as sc +from scipp import UnitError +from scipp.testing import assert_identical + +import numpy as np + +from easyscience.Objects.variable.descriptor_array import DescriptorArray +from easyscience.Objects.variable.descriptor_number import DescriptorNumber +from easyscience import global_object + +class TestDescriptorArray: + @pytest.fixture + def descriptor(self): + descriptor = DescriptorArray( + name="name", + value=[[1., 2.], [3., 4.]], + unit="m", + variance=[[0.1, 0.2], [0.3, 0.4]], + description="description", + url="url", + display_name="display_name", + parent=None, + ) + return descriptor + + @pytest.fixture + def descriptor_dimensionless(self): + descriptor = DescriptorArray( + name="name", + value=[[1., 2.], [3., 4.], [5., 6.]], + unit="dimensionless", + variance=[[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]], + description="description", + url="url", + display_name="display_name", + parent=None, + ) + return descriptor + + @pytest.fixture + def clear(self): + global_object.map._clear() + + def test_init(self, descriptor: DescriptorArray): + # When Then Expect + assert np.array_equal(descriptor._array.values,np.array([[1., 2.], [3., 4.]])) + assert descriptor._array.unit == "m" + assert np.array_equal(descriptor._array.variances, np.array([[0.1, 0.2], [0.3, 0.4]])) + + # From super + assert descriptor._name == "name" + assert descriptor._description == "description" + assert descriptor._url == "url" + assert descriptor._display_name == "display_name" + + def test_init_sc_unit(self): + # When Then + descriptor = DescriptorArray( + name="name", + value=[[1., 2.], [3., 4.]], + unit=sc.units.Unit("m"), + variance=[[0.1, 0.2], [0.3, 0.4]], + description="description", + url="url", + display_name="display_name", + parent=None, + ) + + # Expect + assert np.array_equal(descriptor._array.values,np.array([[1., 2.], [3., 4.]])) + assert descriptor._array.unit == "m" + assert np.array_equal(descriptor._array.variances, np.array([[0.1, 0.2], [0.3, 0.4]])) + + def test_init_sc_unit_unknown(self): + # When Then Expect + with pytest.raises(UnitError): + DescriptorArray( + name="name", + value=[[1., 2.], [3., 4.]], + unit="unknown", + variance=[[0.1, 0.2], [0.3, 0.4]], + description="description", + url="url", + display_name="display_name", + parent=None, + ) + + @pytest.mark.parametrize("value", [True, "string"]) + def test_init_value_type_exception(self, value): + # When + + # Then Expect + with pytest.raises(TypeError): + DescriptorArray( + name="name", + value=value, + unit="m", + variance=[[0.1, 0.2], [0.3, 0.4]], + description="description", + url="url", + display_name="display_name", + parent=None, + ) + + def test_init_variance_exception(self): + # When + variance=[[-0.1, -0.2], [-0.3, -0.4]] + # Then Expect + with pytest.raises(ValueError): + DescriptorArray( + name="name", + value=[[1., 2.], [3., 4.]], + unit="m", + variance=variance, + description="description", + url="url", + display_name="display_name", + parent=None, + ) + + # test from_scipp + def test_from_scipp(self): + # When + full_value = sc.array(dims=['row','column'],values=[[1,2],[3,4]], unit='m') + # Then + descriptor = DescriptorArray.from_scipp(name="name", full_value=full_value) + + # Expect + assert np.array_equal(descriptor._array.values,[[1,2],[3,4]]) + assert descriptor._array.unit == "m" + assert descriptor._array.variances == None + + # @pytest.mark.parametrize("full_value", [sc.array(values=[1,2], dims=["x"]), sc.array(values=[[1], [2]], dims=["x","y"]), object(), 1, "string"], ids=["1D", "2D", "object", "int", "string"]) + # def test_from_scipp_type_exception(self, full_value): + # # When Then Expect + # with pytest.raises(TypeError): + # DescriptorArray.from_scipp(name="name", full_value=full_value) + + def tvigateDownest_full_value(self, descriptor: DescriptorArray): + # When Then Expect + other = sc.array(dims=('dim0','dim1'), + values=[[1.0, 2.0], [3.0, 4.0]], + unit='m', + variances=[[0.1, 0.2], [0.3, 0.4]]) + assert_identical(descriptor.full_value, other) + + def test_set_full_value(self, descriptor: DescriptorArray): + with pytest.raises(AttributeError): + descriptor.full_value = sc.array(dims=['row','column'],values=[[1,2],[3,4]], unit='s') + + def test_unit(self, descriptor: DescriptorArray): + # When Then Expect + assert descriptor.unit == 'm' + + def test_set_unit(self, descriptor: DescriptorArray): + with pytest.raises(AttributeError): + descriptor.unit = 's' + + def test_convert_unit(self, descriptor: DescriptorArray): + # When Then + descriptor.convert_unit('mm') + + # Expect + assert descriptor._array.unit == 'mm' + assert np.array_equal(descriptor._array.values,[[1000,2000],[3000,4000]]) + assert np.array_equal(descriptor._array.variances,[[100000,200000],[300000,400000]]) + + def test_variance(self, descriptor: DescriptorArray): + # When Then Expect + assert np.array_equal(descriptor._array.variances, np.array([[0.1, 0.2], [0.3, 0.4]])) + + + def test_set_variance(self, descriptor: DescriptorArray): + # When Then + descriptor.variance = [[0.2, 0.3], [0.4, 0.5]] + + # Expect + assert np.array_equal(descriptor.variance, np.array([[0.2, 0.3], [0.4, 0.5]])) + assert np.array_equal(descriptor.error, np.sqrt(np.array([[0.2, 0.3], [0.4, 0.5]]))) + + def test_error(self, descriptor: DescriptorArray): + # When Then Expect + assert np.array_equal(descriptor.error, np.sqrt(np.array([[0.1, 0.2], [0.3, 0.4]]))) + + + def test_set_error(self, descriptor: DescriptorArray): + # When Then + descriptor.error = np.sqrt(np.array([[0.2, 0.3], [0.4, 0.5]])) + # Expect + assert np.allclose(descriptor.error, np.sqrt(np.array([[0.2, 0.3], [0.4, 0.5]]))) + assert np.allclose(descriptor.variance, np.array([[0.2, 0.3], [0.4, 0.5]])) + + + def test_value(self, descriptor: DescriptorArray): + # When Then Expect + assert np.array_equal(descriptor.value, np.array([[1, 2], [3, 4]])) + + def test_set_value(self, descriptor: DescriptorArray): + # When Then + descriptor.value = ([[0.2, 0.3], [0.4, 0.5]]) + # Expect + assert np.array_equal(descriptor._array.values, np.array([[0.2, 0.3], [0.4, 0.5]])) + + def test_repr(self, descriptor: DescriptorArray): + # When Then + repr_str = str(descriptor) + + # Expect + assert repr_str == "" + + def test_copy(self, descriptor: DescriptorArray): + # When Then + descriptor_copy = descriptor.__copy__() + + # Expect + assert type(descriptor_copy) == DescriptorArray + assert np.array_equal(descriptor_copy._array.values, descriptor._array.values) + assert descriptor_copy._array.unit == descriptor._array.unit + + def test_as_data_dict(self, clear, descriptor: DescriptorArray): + # When + descriptor_dict = descriptor.as_data_dict() + + # Expected dictionary + expected_dict = { + "name": "name", + "value": np.array([[1.0, 2.0], [3.0, 4.0]]), # Use numpy array for comparison + "unit": "m", + "variance": np.array([[0.1, 0.2], [0.3, 0.4]]), # Use numpy array for comparison + "description": "description", + "url": "url", + "display_name": "display_name", + "unique_name": "DescriptorArray_0", + } + + # Then: Compare dictionaries key by key + for key, expected_value in expected_dict.items(): + if isinstance(expected_value, np.ndarray): + # Compare numpy arrays + assert np.array_equal(descriptor_dict[key], expected_value), f"Mismatch for key: {key}" + else: + # Compare other values directly + assert descriptor_dict[key] == expected_value, f"Mismatch for key: {key}" + + @pytest.mark.parametrize("unit_string, expected", [ + ("1e+9", "dimensionless"), + ("1000", "dimensionless"), + ("10dm^2", "m^2")], + ids=["scientific_notation", "numbers", "unit_prefix"]) + def test_base_unit(self, unit_string, expected): + # When + descriptor = DescriptorArray(name="name", value=[[1.0, 2.0], [3.0, 4.0]], unit=unit_string) + + # Then + base_unit = descriptor._base_unit() + + # Expect + assert base_unit == expected + + @pytest.mark.parametrize("test, expected, raises_warning", [ + (DescriptorNumber("test", 2, "m", 0.01), + DescriptorArray("test + name", + [[3.0, 4.0], [5.0, 6.0]], + "m", + [[0.11, 0.21], [0.31, 0.41]]), + True), + (DescriptorNumber("test", 1, "cm", 10), + DescriptorArray("test + name", + [[101.0, 201.0], [301.0, 401.0]], + "cm", + [[1010.0, 2010.0], [3010.0, 4010.0]]), + True), + (DescriptorArray("test", + [[2.0, 3.0], [4.0, -5.0]], + "cm", + [[1.0, 2.0], [3.0, 4.0]]), + DescriptorArray("test + name", + [[102.0, 203.0], [304.0, 395.0]], + "cm", + [[1001.0, 2002.0], [3003.0, 4004.0]]), + False)], + ids=["descriptor_number_regular", "descriptor_number_unit_conversion", "array_conversion"]) + def test_addition(self, descriptor: DescriptorArray, test, expected, raises_warning): + # When Then + if raises_warning: + with pytest.warns(UserWarning) as record: + result = test + descriptor + result_reverse = descriptor + test + assert len(record) == 2 + assert 'Correlations introduced' in record[0].message.args[0] + else: + result = test + descriptor + result_reverse = descriptor + test + # Expect + assert type(result) == DescriptorArray + assert result.name == result.unique_name + assert np.array_equal(result.value, expected.value) + assert result.unit == expected.unit + assert result_reverse.unit == descriptor.unit + assert np.allclose(result.variance, expected.variance) + assert descriptor.unit == 'm' + + + @pytest.mark.parametrize("test, expected", [ + ([[2.0, 3.0], [4.0, -5.0], [6.0, -8.0]], + DescriptorArray("test", + [[3.0, 5.0], [7.0, -1.0], [11.0, -2.0]], + "dimensionless", + [[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]])), + (1, + DescriptorArray("test", + [[2.0, 3.0], [4.0, 5.0], [6.0, 7.0]], + "dimensionless", + [[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]])) + ], + ids=["list", "number"]) + def test_addition_dimensionless(self, descriptor_dimensionless: DescriptorArray, test, expected): + # When Then + result = test + descriptor_dimensionless + result_reverse = descriptor_dimensionless + test + # Expect + assert type(result) == DescriptorArray + assert type(result_reverse) == DescriptorArray + assert np.array_equal(result.value, expected.value) + assert np.allclose(result.variance, expected.variance) + assert descriptor_dimensionless.unit == 'dimensionless' + + @pytest.mark.parametrize("test, expected, raises_warning", [ + (DescriptorNumber("test", 2, "m", 0.01), + DescriptorArray("test + name", + [[1.0, 0.0], [-1.0, -2.0]], + "m", + [[0.11, 0.21], [0.31, 0.41]]), + True), + (DescriptorNumber("test", 1, "cm", 10), + DescriptorArray("test + name", + [[-99.0, -199.0], [-299.0, -399.0]], + "cm", + [[1010.0, 2010.0], [3010.0, 4010.0]]), + True), + (DescriptorArray("test", + [[2.0, 3.0], [4.0, -5.0]], + "cm", + [[1.0, 2.0], [3.0, 4.0]]), + DescriptorArray("test + name", + [[-98.0, -197.0], [-296.0, -405.0]], + "cm", + [[1001.0, 2002.0], [3003.0, 4004.0]]), + False)], + ids=["descriptor_number_regular", "descriptor_number_unit_conversion", "array_conversion"]) + def test_subtraction(self, descriptor: DescriptorArray, test, expected, raises_warning): + # When Then + if raises_warning: + with pytest.warns(UserWarning) as record: + result = test - descriptor + result_reverse = descriptor - test + assert len(record) == 2 + assert 'Correlations introduced' in record[0].message.args[0] + else: + result = test - descriptor + result_reverse = descriptor - test + # Expect + assert type(result) == DescriptorArray + assert result.name == result.unique_name + assert np.array_equal(result.value, expected.value) + assert result.unit == expected.unit + assert result_reverse.unit == descriptor.unit + assert np.allclose(result.variance, expected.variance) + assert descriptor.unit == 'm' + # Convert units and check that reverse result is the same + result_reverse.convert_unit(result.unit) + assert np.array_equal(result.value, -result_reverse.value) + + @pytest.mark.parametrize("test, expected", [ + ([[2.0, 3.0], [4.0, -5.0], [6.0, -8.0]], + DescriptorArray("test", + [[1.0, 1.0], [1.0, -9.0], [1.0, -14.0]], + "dimensionless", + [[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]])), + (1, + DescriptorArray("test", + [[0.0, -1.0], [-2.0, -3.0], [-4.0, -5.0]], + "dimensionless", + [[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]])) + ], + ids=["list", "number"]) + def test_subtraction_dimensionless(self, descriptor_dimensionless: DescriptorArray, test, expected): + # When Then + result = test - descriptor_dimensionless + result_reverse = descriptor_dimensionless - test + # Expect + assert type(result) == DescriptorArray + assert type(result_reverse) == DescriptorArray + assert np.array_equal(result.value, expected.value) + assert np.array_equal(result.value, -result_reverse.value) + assert np.allclose(result.variance, expected.variance) + assert descriptor_dimensionless.unit == 'dimensionless' + + + @pytest.mark.parametrize("test, expected, raises_warning", [ + (DescriptorNumber("test", 2, "m", 0.01), + DescriptorArray("test * name", + [[2.0, 4.0], [6.0, 8.0]], + "m^2", + [[0.41, 0.84], [1.29, 1.76]]), + True), + (DescriptorNumber("test", 1, "cm", 10), + DescriptorArray("test * name", + [[100.0, 200.0], [300.0, 400.0]], + "cm^2", + [[101000.0, 402000.0], [903000.0, 1604000.0]]), + True), + (DescriptorNumber("test", 1, "kg", 10), + DescriptorArray("test * name", + [[1.0, 2.0], [3.0, 4.0]], + "kg*m", + [[10.1, 40.2], [90.3, 160.4]]), + True), + (DescriptorArray("test", + [[2.0, 3.0], [4.0, -5.0]], + "cm", + [[1.0, 2.0], [3.0, 4.0]]), + DescriptorArray("test * name", + [[200.0, 600.0], [1200.0, -2000.0]], + "cm^2", + [[14000.0, 98000.0], [318000.0, 740000.0]]), + False), + ([[2.0, 3.0], [4.0, -5.0]], + DescriptorArray("test * name", + [[2.0, 6.0], [12.0, -20.0]], + "m", + [[0.1 * 2**2, 0.2 * 3**2], + [0.3 * 4**2, 0.4 * 5**2]]), + False), + (2.0, + DescriptorArray("test * name", + [[2.0, 4.0], [6.0, 8.0]], + "m", + [[0.1 * 2**2, 0.2 * 2**2], + [0.3 * 2**2, 0.4 * 2**2]]), + False) + + ], + ids=["descriptor_number_regular", + "descriptor_number_unit_conversion", + "descriptor_number_different_units", + "array_conversion", + "list", + "number"]) + def test_multiplication(self, descriptor: DescriptorArray, test, expected, raises_warning): + # When Then + if raises_warning: + with pytest.warns(UserWarning) as record: + result = test * descriptor + result_reverse = descriptor * test + assert len(record) == 2 + assert 'Correlations introduced' in record[0].message.args[0] + else: + result = test * descriptor + result_reverse = descriptor * test + # Expect + assert type(result) == DescriptorArray + assert result.name == result.unique_name + assert np.array_equal(result.value, expected.value) + assert result.unit == expected.unit + assert np.allclose(result.variance, expected.variance) + assert descriptor.unit == 'm' + + + @pytest.mark.parametrize("test, expected", [ + ([[2.0, 3.0], [4.0, -5.0], [6.0, -8.0]], + DescriptorArray("test", + [[2.0, 6.0], [12.0, -20.0], [30.0, -48.0]], + "dimensionless", + [[0.4, 1.8], [4.8, 10.0], [18.0, 38.4]])), + (1.5, + DescriptorArray("test", + [[1.5, 3.0], [4.5, 6.0], [7.5, 9.0]], + "dimensionless", + [[0.225, 0.45], [0.675, 0.9], [1.125, 1.35]])) + ], + ids=["list", "number"]) + def test_multiplication_dimensionless(self, descriptor_dimensionless: DescriptorArray, test, expected): + # When Then + result = test * descriptor_dimensionless + result_reverse = descriptor_dimensionless * test + # Expect + assert type(result) == DescriptorArray + assert type(result_reverse) == DescriptorArray + assert np.array_equal(result.value, expected.value) + assert np.allclose(result.variance, expected.variance) + assert descriptor_dimensionless.unit == 'dimensionless' + + @pytest.mark.parametrize("test, expected, raises_warning", [ + (DescriptorNumber("test", 2, "m", 0.01), + DescriptorArray("test / name", + [[2.0, 1.0], [2.0/3.0, 0.5]], + "dimensionless", + [[0.41, 0.0525], + [(0.01 + 0.3 * 2**2 / 3.0**2) / 3.0**2, + (0.01 + 0.4 * 2**2 / 4.0**2) / 4.0**2]]), + True), + (DescriptorNumber("test", 1, "cm", 10), + DescriptorArray("test / name", + [[1.0/100.0, 1.0/200.0], [1.0/300.0, 1.0/400.0]], + "dimensionless", + [[1.01e-3, (1e-3 + 0.2 * 0.01**2/2**2) / 2**2], + [(1e-3 + 0.3 * 0.01**2/3**2) / 3**2,(1e-3 + 0.4 * 0.01**2 / 4**2) / 4**2]]), + True), + (DescriptorNumber("test", 1, "kg", 10), + DescriptorArray("test / name", + [[1.0, 0.5], [1.0/3.0, 0.25]], + "kg/m", + [[10.1, ( 10 + 0.2 * 1/2**2 ) / 2**2], + [( 10 + 0.3 * 1/3**2 ) / 3**2, ( 10 + 0.4 * 1/4**2 ) / 4**2 ]]), + True), + (DescriptorArray("test", + [[2.0, 3.0], [4.0, -5.0]], + "cm^2", + [[1.0, 2.0], [3.0, 4.0]]), + DescriptorArray("test / name", + [[2e-4, 1.5e-4], [4.0/3.0*1e-4, -1.25e-4]], + "m", + [[1.4e-8, 6.125e-9], + [( 3.0e-8 + 0.3 * (0.0004)**2 / 3**2 ) / 3**2, + ( 4.0e-8 + 0.4 * (0.0005)**2 / 4**2 ) / 4**2]]), + False), + ([[2.0, 3.0], [4.0, -5.0]], + DescriptorArray("test / name", + [[2, 1.5], [4.0/3.0, -1.25]], + "1/m", + [[0.1 * 2**2 / 1**4, 0.2 * 3.0**2 / 2.0**4], + [0.3 * 4**2 / 3**4, 0.4 * 5.0**2 / 4**4]]), + False), + (2.0, + DescriptorArray("test / name", + [[2, 1.0], [2.0/3.0, 0.5]], + "1/m", + [[0.1 * 2**2 / 1**4, 0.2 * 2.0**2 / 2.0**4], + [0.3 * 2**2 / 3**4, 0.4 * 2.0**2 / 4.0**4]]), + False) + ], + ids=["descriptor_number_regular", + "descriptor_number_unit_conversion", + "descriptor_number_different_units", + "array_conversion", + "list", + "number"]) + def test_division(self, descriptor: DescriptorArray, test, expected, raises_warning): + # When Then + if raises_warning: + with pytest.warns(UserWarning) as record: + result = test / descriptor + result_reverse = descriptor / test + assert len(record) == 2 + assert 'Correlations introduced' in record[0].message.args[0] + else: + result = test / descriptor + result_reverse = descriptor / test + # Expect + assert type(result) == DescriptorArray + assert result.name == result.unique_name + assert np.allclose(result.value, expected.value) + assert result.unit == expected.unit + assert np.allclose(result.variance, expected.variance) + assert descriptor.unit == 'm' + + @pytest.mark.parametrize("test, expected", [ + ([[2.0, 3.0], [4.0, -5.0], [6.0, -8.0]], + DescriptorArray("test", + [[2.0/1.0, 3.0/2.0], [4.0/3.0, -5.0/4.0], [6.0/5.0, -8.0/6.0]], + "dimensionless", + [[0.1 * 2.0**2, 0.2 * 3.0**2 / 2**4], + [0.3 * 4.0**2 / 3.0**4, 0.4 * 5.0**2 / 4**4], + [0.5 * 6.0**2 / 5**4, 0.6 * 8.0**2 / 6**4]])), + (2, + DescriptorArray("test", + [[2.0, 1.0], [2.0/3.0, 0.5], [2.0/5.0, 1.0/3.0]], + "dimensionless", + [[0.1 * 2.0**2, 0.2 / 2**2], + [0.3 * 2**2 / 3**4, 0.4 * 2**2 / 4**4], + [0.5 * 2**2 / 5**4, 0.6 * 2**2 / 6**4]])) + ], + ids=["list", "number"]) + def test_division_dimensionless(self, descriptor_dimensionless: DescriptorArray, test, expected): + # When Then + result = test / descriptor_dimensionless + result_reverse = descriptor_dimensionless / test + # Expect + assert type(result) == DescriptorArray + assert type(result_reverse) == DescriptorArray + assert np.allclose(result.value, expected.value) + assert np.allclose(result.value, 1 / result_reverse.value) + assert np.allclose(result.variance, expected.variance) + + assert descriptor_dimensionless.unit == 'dimensionless' + + @pytest.mark.parametrize("test", [ + [[2.0, 3.0], [4.0, -5.0], [6.0, 0.0]], + 0.0, + DescriptorNumber("test", 0, "cm", 10), + DescriptorArray("test", + [[1.5, 0.0], [4.5, 6.0], [7.5, 9.0]], + "dimensionless", + [[0.225, 0.45], [0.675, 0.9], [1.125, 1.35]])], + ids=["list", "number", "DescriptorNumber", "DescriptorArray"]) + def test_division_exception(self, descriptor_dimensionless: DescriptorArray, test): + # When Then + with pytest.raises(ZeroDivisionError): + descriptor_dimensionless / test + + # Also test reverse division where `self` is a DescriptorArray with a zero + zero_descriptor = DescriptorArray("test", + [[1.5, 0.0], [4.5, 6.0], [7.5, 0.0]], + "dimensionless", + [[0.225, 0.45], [0.675, 0.9], [1.125, 1.35]]) + with pytest.raises(ZeroDivisionError): + test / zero_descriptor + + @pytest.mark.parametrize("test, expected", [ + (DescriptorNumber("test", 2, "dimensionless"), + DescriptorArray("test ** name", + [[1.0, 4.0], [9.0, 16.0]], + "m^2", + [[4 * 0.1 * 1, 4 * 0.2 * 2**2], + [4 * 0.3 * 3**2, 4 * 0.4 * 4**2]])), + (DescriptorNumber("test", 3, "dimensionless"), + DescriptorArray("test ** name", + [[1.0, 8.0], [27, 64.0]], + "m^3", + [[9 * 0.1, 9 * 0.2 * 2**4], + [9 * 0.3 * 3**4, 9 * 0.4 * 4**4]])), + (DescriptorNumber("test", 0.0, "dimensionless"), + DescriptorArray("test ** name", + [[1.0, 1.0], [1.0, 1.0]], + "dimensionless", + [[0.0, 0.0], [0.0, 0.0]])), + (0.0, + DescriptorArray("test ** name", + [[1.0, 1.0], [1.0, 1.0]], + "dimensionless", + [[0.0, 0.0], [0.0, 0.0]])) + ], + ids=["descriptor_number_squared", + "descriptor_number_cubed", + "descriptor_number_zero", + "number_zero"]) + def test_power(self, descriptor: DescriptorArray, test, expected): + # When Then + result = descriptor ** test + # Expect + assert type(result) == DescriptorArray + assert result.name == result.unique_name + assert np.array_equal(result.value, expected.value) + assert result.unit == expected.unit + assert np.allclose(result.variance, expected.variance) + assert descriptor.unit == 'm' + + @pytest.mark.parametrize("test, expected", [ + (DescriptorNumber("test", 0.1, "dimensionless"), + DescriptorArray("test ** name", + [[1, 2**0.1], [3**0.1, 4**0.1], [5**0.1, 6**0.1]], + "dimensionless", + [[0.1**2 * 0.1 * 1, 0.1**2 * 0.2 * 2**(-1.8)], + [0.1**2 * 0.3 * 3**(-1.8), 0.1**2 * 0.4 * 4**(-1.8)], + [0.1**2 * 0.5 * 5**(-1.8), 0.1**2 * 0.6 * 6**(-1.8)]])), + (DescriptorNumber("test", 2.0, "dimensionless"), + DescriptorArray("test ** name", + [[1.0, 4.0], [9.0, 16.0], [25.0, 36.0]], + "dimensionless", + [[0.4, 3.2], [10.8, 25.6], [50., 86.4]])), + ], + ids=["descriptor_number_fractional", "descriptor_number_integer"]) + def test_power_dimensionless(self, descriptor_dimensionless: DescriptorArray, test, expected): + # When Then + result = descriptor_dimensionless ** test + # Expect + assert type(result) == DescriptorArray + assert result.name == result.unique_name + assert np.allclose(result.value, expected.value) + assert result.unit == expected.unit + assert np.allclose(result.variance, expected.variance) + assert descriptor_dimensionless.unit == 'dimensionless' + + + @pytest.mark.parametrize("test, exception", [ + (DescriptorNumber("test", 2, "m"), UnitError), + (DescriptorNumber("test", 2, "dimensionless", 10), ValueError), + (DescriptorNumber("test", np.nan, "dimensionless"), UnitError), + (DescriptorNumber("test", np.nan, "dimensionless"), UnitError), + (DescriptorNumber("test", 1.5, "dimensionless"), UnitError), + (DescriptorNumber("test", 0.5, "dimensionless"), UnitError) # Square roots are not legal + ], + ids=["units", + "variance", + "scipp_nan", + "nan_result", + "non_integer_exponent_on_units", + "square_root_on_units" + ]) + def test_power_exception(self, descriptor: DescriptorArray, test, exception): + # When Then + with pytest.raises(exception): + result = descriptor ** 2 ** test + with pytest.raises(TypeError): + # Exponentiation with an array does not make sense + test ** descriptor + + @pytest.mark.parametrize("test", [ + DescriptorNumber("test", 2, "s"), + DescriptorArray("test", [[1, 2], [3, 4]], "s")], ids=["add_array_to_unit", "incompatible_units"]) + def test_operation_exception(self, descriptor: DescriptorArray, test): + # When Then Expect + import operator + operators = [operator.add, + operator.sub] + for operator in operators: # This can probably be done better w. fixture + with pytest.raises(UnitError): + result = operator(descriptor, test) + with pytest.raises(UnitError): + result_reverse = operator(test, descriptor) + + @pytest.mark.parametrize("function", [ + np.sin, + np.cos, + np.exp, + np.add, + np.multiply + ], + ids=["sin", "cos", "exp", "add", "multiply"]) + def test_numpy_ufuncs_exception(self, descriptor_dimensionless, function): + (np.add,np.array([[2.0, 3.0], [4.0, -5.0], [6.0, -8.0]])), + """ + Not implemented ufuncs should return NotImplemented. + """ + test = np.array([[1, 2], [3, 4]]) + with pytest.raises(TypeError) as e: + function(descriptor_dimensionless, test) + assert 'returned NotImplemented from' in str(e) + + def test_negation(self, descriptor): + # When + # Then + result = -descriptor + + # Expect + expected = DescriptorArray( + name="name", + value=[[-1., -2.], [-3., -4.]], + unit="m", + variance=[[0.1, 0.2], [0.3, 0.4]], + description="description", + url="url", + display_name="display_name", + parent=None, + ) + assert type(result) == DescriptorArray + assert result.name == result.unique_name + assert np.array_equal(result.value, expected.value) + assert result.unit == expected.unit + assert np.allclose(result.variance, expected.variance) + assert descriptor.unit == 'm' + + def test_abs(self, descriptor): + # When + negated = DescriptorArray( + name="name", + value=[[-1., -2.], [-3., -4.]], + unit="m", + variance=[[0.1, 0.2], [0.3, 0.4]], + description="description", + url="url", + display_name="display_name", + parent=None, + ) + + # Then + result = abs(negated) + + # Expect + assert type(result) == DescriptorArray + assert result.name == result.unique_name + assert np.array_equal(result.value, descriptor.value) + assert result.unit == descriptor.unit + assert np.allclose(result.variance, descriptor.variance) + assert descriptor.unit == 'm' + + @pytest.mark.parametrize("test, expected", [ + (DescriptorArray("test + name", + [[3.0, 4.0], [5.0, 6.0]], + "m", + [[0.11, 0.21], [0.31, 0.41]]), + DescriptorNumber("test", 9, "m", 0.52)), + (DescriptorArray("test + name", + [[101.0, 201.0], [301.0, 401.0]], + "dimensionless", + [[1010.0, 2010.0], [3010.0, 4010.0]]), + DescriptorNumber("test", 502.0, "dimensionless", 5020.0)), + (DescriptorArray("test", np.ones((9, 9)), "dimensionless", np.ones((9, 9))), + DescriptorNumber("test", 9.0, "dimensionless", 9.0)), + (DescriptorArray("test", np.ones((3, 3, 3)), "dimensionless", np.ones((3, 3, 3))), + DescriptorNumber("test", 3.0, "dimensionless", 3.0)), + (DescriptorArray("test", [[2.0]], "dimensionless"), + DescriptorNumber("test", 2.0, "dimensionless")) + ], + ids=["2d_unit", "2d_dimensionless", "2d_large", "3d_dimensionless", "1d_dimensionless"]) + def test_trace(self, test: DescriptorArray, expected: DescriptorNumber): + result = test.trace() + assert type(result) == DescriptorNumber + assert result.name == result.unique_name + assert np.array_equal(result.value, expected.value) + assert result.unit == expected.unit + if test.variance is not None: + assert np.allclose(result.variance, expected.variance) + + @pytest.mark.parametrize("test", [ + DescriptorArray("test + name", + [[3.0, 4.0]], + "m", + [[0.11, 0.21]]), + + DescriptorArray("test + name", + [[3.0, 4.0], [1.0, 1.0], [1.0, 1.0]], + "dimensionless", + [[0.11, 0.21], [1., 1.], [1., 1.]]) + ], + ids=["2x1_unit", "3x2_dimensionless"]) + def test_trace_exception(self, test: DescriptorArray): + with pytest.raises(ValueError) as e: + test.trace() + assert "Trace can only be taken" in str(e) + + def test_slicing(self, descriptor: DescriptorArray): + # When + first_value = descriptor['dim0', 0] + last_value = descriptor['dim0', -1] + second_array = descriptor['dim1', :] + + # Then + assert type(first_value) == DescriptorArray + assert type(last_value) == DescriptorArray + assert type(second_array) == DescriptorArray + + assert first_value.name != descriptor.unique_name + assert last_value.name != descriptor.unique_name + assert second_array.name != descriptor.unique_name + + assert np.array_equal(first_value.full_value.values, descriptor.full_value['dim0', 0].values) + assert np.array_equal(last_value.full_value.values, descriptor.full_value['dim0', -1].values) + assert np.array_equal(second_array.full_value.values, descriptor.full_value['dim1', :].values) + + assert np.array_equal(first_value.full_value.variances, descriptor.full_value['dim0', 0].variances) + assert np.array_equal(last_value.full_value.variances, descriptor.full_value['dim0', -1].variances) + assert np.array_equal(second_array.full_value.variances, descriptor.full_value['dim1', :].variances) + + assert np.array_equal(first_value.full_value.unit, descriptor.unit) + assert np.array_equal(last_value.full_value.unit, descriptor.unit) + assert np.array_equal(second_array.full_value.unit, descriptor.unit) + + def test_slice_deletion(self, descriptor: DescriptorArray): + with pytest.raises(AttributeError) as e: + del descriptor['dim0', 0] + assert 'has no attribute' in str(e) + + @pytest.mark.parametrize("test", [ + 1.0, + [3.0, 4.0, 5.0] + ], + ids=["number", "list"]) + def test_slice_assignment_exception(self, descriptor_dimensionless: DescriptorArray, test): + # When + with pytest.raises(AttributeError) as e: + descriptor_dimensionless['dim0', :] = test + assert "cannot be edited via slicing" in str(e) + + @pytest.mark.parametrize("test, expected", [ + (DescriptorArray("test + name", + [[3.0, 4.0], [5.0, 6.0]], + "m", + [[0.11, 0.21], [0.31, 0.41]]), + DescriptorNumber("test", 18, "m", 1.04)), + (DescriptorArray("test + name", + [[101.0, 201.0], [301.0, 401.0]], + "cm", + [[1010.0, 2010.0], [3010.0, 4010.0]]), + DescriptorNumber("test", 1004.0, "cm", 10040.)), + (DescriptorArray("test", + [[2.0, 3.0]], + "dimensionless", + [[1.0, 2.0]]), + DescriptorNumber("test", 5.0, "dimensionless", 3.0)), + (DescriptorArray("test", + [[2.0, 3.0]], + "dimensionless"), + DescriptorNumber("test", 5.0, "dimensionless")), + ], + ids=["descriptor_array_m", "d=descriptor_array_cm", "descriptor_array_dimensionless", "descriptor_array_dim_varless"]) + def test_sum(self, test, expected): + result = test.sum() + assert type(result) == DescriptorNumber + assert result.name == result.unique_name + assert np.array_equal(result.value, expected.value) + assert result.unit == expected.unit + if test.variance is not None: + assert np.allclose(result.variance, expected.variance) + + @pytest.mark.parametrize("expected, dim", [ + (DescriptorArray("test", + [4.0, 6.0], + "m", + [0.4, 0.6]), + 'dim0'), + (DescriptorArray("test", + [3.0, 7.0], + "m", + [0.3, 0.7]), + 'dim1'), + ], + ids=["descriptor_array_dim0", "descriptor_array_dim1"]) + def test_sum_over_subset(self, descriptor, expected, dim): + result = descriptor.sum(dim) + assert type(result) == type(expected) + assert result.name == result.unique_name + assert np.array_equal(result.value, expected.value) + assert result.unit == expected.unit + assert np.allclose(result.variance, expected.variance) + + @pytest.mark.parametrize("test, dims", [ + (DescriptorArray("test", [1.], "dimensionless", [1.]), ['dim0']), + (DescriptorArray("test", [[1., 1.]], "dimensionless", [[1., 1.]]), ['dim0', 'dim1']), + (DescriptorArray("test", [[1.], [1.]], "dimensionless", [[1.], [1.]]), ['dim0', 'dim1']), + (DescriptorArray("test", [[[1., 1., 1.]]], "dimensionless", [[[1., 1., 1.]]]), ['dim0', 'dim1', 'dim2']), + (DescriptorArray("test", [[[1.]], [[1.]], [[1.]]], "dimensionless", [[[1.]], [[1.]], [[1.]]]), ['dim0', 'dim1', 'dim2']), + ], + ids=["1x1", "1x2", "2x1", "1x3", "3x1"]) + def test_array_generate_dims(self, test, dims): + assert test.dims == dims + + def test_array_set_dims_exception(self, descriptor): + with pytest.raises(ValueError) as e: + descriptor.dims = ['too_few'] + assert "must have the same shape" + with pytest.raises(ValueError) as e: + DescriptorArray("test", [[1.]], "m", [[1.]], dims=['dim']) + assert "Length of dims" in str(e) From d93eadc55468337087333aea22961622342e3967 Mon Sep 17 00:00:00 2001 From: Eric Lindgren Date: Thu, 6 Mar 2025 09:12:21 +0100 Subject: [PATCH 02/14] implement reviewer suggested changes --- .../Objects/variable/descriptor_array.py | 154 +++++++++--------- .../Objects/variable/test_descriptor_array.py | 44 ++--- 2 files changed, 99 insertions(+), 99 deletions(-) diff --git a/src/easyscience/Objects/variable/descriptor_array.py b/src/easyscience/Objects/variable/descriptor_array.py index 4a6f112..4ddce9e 100644 --- a/src/easyscience/Objects/variable/descriptor_array.py +++ b/src/easyscience/Objects/variable/descriptor_array.py @@ -1,8 +1,9 @@ from __future__ import annotations import numbers -import operator as op +import operator from typing import Any +from typing import Callable from typing import Dict from typing import List from typing import Optional @@ -37,7 +38,7 @@ def __init__( url: Optional[str] = None, display_name: Optional[str] = None, parent: Optional[Any] = None, - dims: Optional[list] = None + dimensions: Optional[list] = None ): """Constructor for the DescriptorArray class @@ -49,7 +50,7 @@ def __init__( param url: URL of the descriptor param display_name: Display name of the descriptor param parent: Parent of the descriptor - param dims: List of dimensions to pass to scipp. Will be autogenerated if not supplied. + param dimensions: List of dimensions to pass to scipp. Will be autogenerated if not supplied. .. note:: Undo/Redo functionality is implemented for the attributes `variance`, `error`, `unit` and `value`. """ @@ -71,20 +72,18 @@ def __init__( if not isinstance(unit, sc.Unit) and not isinstance(unit, str): raise TypeError(f'{unit=} must be a scipp unit or a string representing a valid scipp unit') - - if dims is None: + if dimensions is None: # Autogenerate dimensions if not supplied - dims = ['dim'+str(i) for i in range(len(value.shape))] - if not len(dims) == len(value.shape): - raise ValueError(f"Length of dims ({dims=}) does not match length of value {value=}.") - self._dims = dims + dimensions = ['dim'+str(i) for i in range(len(value.shape))] + if not len(dimensions) == len(value.shape): + raise ValueError(f"Length of dimensions ({dimensions=}) does not match length of value {value=}.") + self._dimensions = dimensions try: - self._array = sc.array(dims=dims, values=value, unit=unit, variances=variance) + self._array = sc.array(dims=dimensions, values=value, unit=unit, variances=variance) except Exception as message: raise UnitError(message) # TODO: handle 1xn and nx1 arrays - self._array = sc.array(dims=dims, values=value, unit=unit, variances=variance) super().__init__( name=name, @@ -116,7 +115,7 @@ def from_scipp(cls, name: str, full_value: Variable, **kwargs) -> DescriptorArra @property def full_value(self) -> Variable: """ - Get the value of self as a scipp array. This is should be usable for most cases. + Get the value of self as a scipp array. This should be usable for most cases. :return: Value of self with unit. """ @@ -125,7 +124,7 @@ def full_value(self) -> Variable: @full_value.setter def full_value(self, full_value: Variable) -> None: raise AttributeError( - f'Full_value is read-only. Change the value and variance seperately. Or create a new {self.__class__.__name__}.' + f'Full_value is read-only. Change the value and variance separately. Or create a new {self.__class__.__name__}.' ) @property @@ -157,28 +156,28 @@ def value(self, value: Union[list, np.ndarray]) -> None: self._array.values = value @property - def dims(self) -> list: + def dimensions(self) -> list: """ - Get the dims used for the underlying scipp array. + Get the dimensions used for the underlying scipp array. - :return: dims of self. + :return: dimensions of self. """ - return self._dims + return self._dimensions - @dims.setter - def dims(self, dims: Union[list, np.ndarray]) -> None: + @dimensions.setter + def dimensions(self, dimensions: Union[list, np.ndarray]) -> None: """ - Set the dims of self. Ensures that the input has a shape compatible with self.full_value. + Set the dimensions of self. Ensures that the input has a shape compatible with self.full_value. - :param value: list of dims. + :param value: list of dimensions. """ - if not isinstance(dims, (list, np.ndarray)): - raise TypeError(f"{dims=} must be a list or numpy array.") + if not isinstance(dimensions, (list, np.ndarray)): + raise TypeError(f"{dimensions=} must be a list or numpy array.") - if len(dims) != len(self._dims): - raise ValueError(f"{dims=} must have the same shape as the existing dims") + if len(dimensions) != len(self._dimensions): + raise ValueError(f"{dimensions=} must have the same shape as the existing dims") - self._dims = dims + self._dimensions = dimensions @property def unit(self) -> str: @@ -276,7 +275,7 @@ def convert_unit(self, unit_str: str) -> None: try: new_unit = sc.Unit(unit_str) except UnitError as message: - raise UnitError(message) from None + raise UnitError(message) # Save the current state for undo/redo old_array = self._array @@ -344,9 +343,8 @@ def __repr__(self) -> str: def as_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]: """ - Dict representation of the current DescriptorArray. - The dict contains the value, unit and variances, in addition - to the properties of DescriptorBase. + Dict representation of the current DescriptorArray. The dict contains the value, unit and variances, + in addition to the properties of DescriptorBase. """ raw_dict = super().as_dict(skip=skip) raw_dict['value'] = self._array.values @@ -354,23 +352,23 @@ def as_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]: raw_dict['variance'] = self._array.variances return raw_dict - def _smooth_operator(self, + def _apply_operation(self, other: Union[DescriptorArray, DescriptorNumber, list, numbers.Number], - operator: str, + operation: Callable, units_must_match: bool = True) -> DescriptorArray: """ Perform element-wise operations with another DescriptorNumber, DescriptorArray, list, or number. :param other: The object to operate on. Must be a DescriptorArray or DescriptorNumber with compatible units, or a list with the same shape if the DescriptorArray is dimensionless. - :param operator: The operation to perform + :param operation: The operation to perform :return: A new DescriptorArray representing the result of the operation. """ if isinstance(other, numbers.Number): # Does not need to be dimensionless for multiplication and division if self.unit not in [None, "dimensionless"] and units_must_match: raise UnitError("Numbers can only be used together with dimensionless values") - new_full_value = operator(self.full_value, other) + new_full_value = operation(self.full_value, other) elif isinstance(other, list): if self.unit not in [None, "dimensionless"] and units_must_match: @@ -381,7 +379,7 @@ def _smooth_operator(self, raise ValueError(f"Shape of {other=} must match the shape of DescriptorArray values") other = sc.array(dims=self._array.dims, values=other) - new_full_value = operator(self._array, other) # Let scipp handle operation for uncertainty propagation + new_full_value = operation(self._array, other) # Let scipp handle operation for uncertainty propagation elif isinstance(other, DescriptorNumber): try: @@ -399,12 +397,12 @@ def _smooth_operator(self, if (self._array.variances is not None or other.variance is not None): warn('Correlations introduced by this operation will not be considered.\ See https://content.iospress.com/articles/journal-of-neutron-research/jnr220049\ - for further detailes', UserWarning) + for further details', UserWarning) # Cheeky copy() of broadcasted scipp array to force scipp to perform the broadcast here broadcasted = sc.broadcast(other_converted.full_value, dims=self._array.dims, - shape=self._array.shape).copy() - new_full_value = operator(self.full_value, broadcasted) + shape=self._array.shape).copy() + new_full_value = operation(self.full_value, broadcasted) elif isinstance(other, DescriptorArray): try: @@ -419,7 +417,7 @@ def _smooth_operator(self, raise ValueError(f"Dimensions of the DescriptorArrays do not match: " f"{self.full_value.dims} vs {other_converted.full_value.dims}") - new_full_value = operator(self.full_value, other_converted.full_value) + new_full_value = operation(self.full_value, other_converted.full_value) else: return NotImplemented @@ -428,20 +426,18 @@ def _smooth_operator(self, descriptor_array.name = descriptor_array.unique_name return descriptor_array - def _rsmooth_operator(self, + def _rapply_operation(self, other: Union[DescriptorArray, DescriptorNumber, list, numbers.Number], - operator: str, + operation: Callable, units_must_match: bool = True) -> DescriptorArray: """ Handle reverse operations for DescriptorArrays, DescriptorNumbers, lists, and scalars. Ensures unit compatibility when `other` is a DescriptorNumber. """ - def reversed_operator(a, b): - return operator(b, a) - if isinstance(other, DescriptorArray): - # This is probably never called - return operator(other, self) - elif isinstance(other, DescriptorNumber): + def reversed_operation(a, b): + return operation(b, a) + + if isinstance(other, DescriptorNumber): # Ensure unit compatibility for DescriptorNumber original_unit = self.unit try: @@ -453,13 +449,13 @@ def reversed_operator(a, b): # fails it's no big deal. if units_must_match: raise UnitError(f"Values with units {self.unit} and {other.unit} are incompatible") from None - result = self._smooth_operator(other, reversed_operator, units_must_match) + result = self._apply_operation(other, reversed_operation, units_must_match) # Revert `self` to its original unit self.convert_unit(original_unit) return result else: # Delegate to operation to __self__ for other types (e.g., list, scalar) - return self._smooth_operator(other, reversed_operator, units_must_match) + return self._apply_operation(other, reversed_operation, units_must_match) def __array_ufunc__(self, ufunc, method, *inputs, **kwargs): """ @@ -482,17 +478,17 @@ def __add__(self, other: Union[DescriptorArray, DescriptorNumber, list, numbers. Perform element-wise addition with another DescriptorNumber, DescriptorArray, list, or number. :param other: The object to add. Must be a DescriptorArray or DescriptorNumber with compatible units, - or a list with the same shape if the DescriptorArray is dimensionless. + or a list with the same shape if the DescriptorArray is dimensionless, or a number. :return: A new DescriptorArray representing the result of the addition. """ - return self._smooth_operator(other, op.add) + return self._apply_operation(other, operator.add) - def __radd__(self, other: Union[DescriptorArray, DescriptorNumber, list, numbers.Number]) -> DescriptorArray: + def __radd__(self, other: Union[DescriptorNumber, list, numbers.Number]) -> DescriptorArray: """ Handle reverse addition for DescriptorArrays, DescriptorNumbers, lists, and scalars. Ensures unit compatibility when `other` is a DescriptorNumber. """ - return self._rsmooth_operator(other, op.add) + return self._rapply_operation(other, operator.add) def __sub__(self, other: Union[DescriptorArray, list, np.ndarray, numbers.Number]) -> DescriptorArray: """ @@ -513,15 +509,15 @@ def __sub__(self, other: Union[DescriptorArray, list, np.ndarray, numbers.Number else: return NotImplemented - def __rsub__(self, other: Union[DescriptorArray, list, numbers.Number]) -> DescriptorArray: + def __rsub__(self, other: Union[DescriptorNumber, list, numbers.Number]) -> DescriptorArray: """ - Perform element-wise subtraction with another DescriptorArray, list, or number. + Perform element-wise subtraction with another DescriptorNumber, list, or number. :param other: The object to subtract. Must be a DescriptorArray with compatible units, or a list with the same shape if the DescriptorArray is dimensionless. :return: A new DescriptorArray representing the result of the subtraction. """ - if isinstance(other, (DescriptorArray, DescriptorNumber, list, numbers.Number)): + if isinstance(other, (DescriptorNumber, list, numbers.Number)): if isinstance(other, list): # Use numpy to negate all elements of the list value = (-np.array(other)).tolist() @@ -541,16 +537,16 @@ def __mul__(self, other: Union[DescriptorArray, DescriptorNumber, list, numbers. """ if not isinstance(other, (DescriptorArray, DescriptorNumber, list, numbers.Number)): return NotImplemented - return self._smooth_operator(other, op.mul, units_must_match=False) + return self._apply_operation(other, operator.mul, units_must_match=False) - def __rmul__(self, other: Union[DescriptorArray, DescriptorNumber, list, numbers.Number]) -> DescriptorArray: + def __rmul__(self, other: Union[DescriptorNumber, list, numbers.Number]) -> DescriptorArray: """ - Handle reverse multiplication for DescriptorArrays, DescriptorNumbers, lists, and scalars. + Handle reverse multiplication for DescriptorNumbers, lists, and scalars. Ensures unit compatibility when `other` is a DescriptorNumber. """ - if not isinstance(other, (DescriptorArray, DescriptorNumber, list, numbers.Number)): + if not isinstance(other, (DescriptorNumber, list, numbers.Number)): return NotImplemented - return self._rsmooth_operator(other, op.mul, units_must_match=False) + return self._rapply_operation(other, operator.mul, units_must_match=False) def __truediv__(self, other: Union[DescriptorArray, DescriptorNumber, list, numbers.Number]) -> DescriptorArray: """ @@ -565,23 +561,21 @@ def __truediv__(self, other: Union[DescriptorArray, DescriptorNumber, list, numb if isinstance(other, numbers.Number): original_other = other - elif isinstance(other, (numbers.Number, list)): + elif isinstance(other, list): original_other = np.array(other) - elif isinstance(other, DescriptorNumber): + elif isinstance(other, (DescriptorArray, DescriptorNumber)): original_other = other.value - elif isinstance(other, DescriptorArray): - original_other = other.full_value.values if np.any(original_other == 0): raise ZeroDivisionError('Cannot divide by zero') - return self._smooth_operator(other, op.truediv, units_must_match=False) + return self._apply_operation(other, operator.truediv, units_must_match=False) - def __rtruediv__(self, other: Union[DescriptorArray, DescriptorNumber, list, numbers.Number]) -> DescriptorArray: + def __rtruediv__(self, other: Union[DescriptorNumber, list, numbers.Number]) -> DescriptorArray: """ - Handle reverse division for DescriptorArrays, DescriptorNumbers, lists, and scalars. + Handle reverse division for DescriptorNumbers, lists, and scalars. Ensures unit compatibility when `other` is a DescriptorNumber. """ - if not isinstance(other, (DescriptorArray, DescriptorNumber, list, numbers.Number)): + if not isinstance(other, (DescriptorNumber, list, numbers.Number)): return NotImplemented if np.any(self.full_value.values == 0): @@ -589,7 +583,7 @@ def __rtruediv__(self, other: Union[DescriptorArray, DescriptorNumber, list, num # First use __div__ to compute `self / other` # but first converting to the units of other - inverse_result = self._rsmooth_operator(other, op.truediv, units_must_match=False) + inverse_result = self._rapply_operation(other, operator.truediv, units_must_match=False) return inverse_result def __pow__(self, other: Union[DescriptorNumber, numbers.Number]) -> DescriptorArray: @@ -605,7 +599,7 @@ def __pow__(self, other: Union[DescriptorNumber, numbers.Number]) -> DescriptorA if isinstance(other, numbers.Number): exponent = other - elif type(other) is DescriptorNumber: + elif isinstance(other, DescriptorNumber): if other.unit != 'dimensionless': raise UnitError('Exponents must be dimensionless') if other.variance is not None: @@ -623,13 +617,13 @@ def __pow__(self, other: Union[DescriptorNumber, numbers.Number]) -> DescriptorA descriptor_number.name = descriptor_number.unique_name return descriptor_number - def __rpow__(self, other: numbers.Number) -> numbers.Number: + def __rpow__(self, other: numbers.Number): """ Defers reverse pow with a descriptor array, `a ** array`. Exponentiation with regards to an array does not make sense, and is not implemented. """ - return NotImplemented + raise ValueError('Raising a value to the power of an array does not make sense.') def __neg__(self) -> DescriptorArray: """ @@ -651,7 +645,7 @@ def __abs__(self) -> DescriptorArray: descriptor_array.name = descriptor_array.unique_name return descriptor_array - def __getitem__(self, a) -> Union[DescriptorArray]: + def __getitem__(self, a) -> DescriptorArray: """ Slice using scipp syntax. Defer slicing to scipp. @@ -673,7 +667,7 @@ def __setitem__(self, a, b: Union[numbers.Number, list, DescriptorNumber, Descri view to the DescriptorArray upon calling __getitem__. """ raise AttributeError( - f'{self.__class__.__name__} cannot be edited via slicing. Edit the underlyinf scipp\ + f'{self.__class__.__name__} cannot be edited via slicing. Edit the underlying scipp\ array via the `full_value` property, or create a\ new {self.__class__.__name__}.' ) @@ -698,11 +692,11 @@ def trace(self) -> DescriptorNumber: diagonal_element = diagonal_element[dim, i] trace = trace + diagonal_element - descriptor = DescriptorNumber.from_scipp(name=self.name, full_value=trace) - descriptor.name = descriptor.unique_name - return descriptor + descriptor_number = DescriptorNumber.from_scipp(name=self.name, full_value=trace) + descriptor_number.name = descriptor_number.unique_name + return descriptor_number - def sum(self, dim: Optional[Union[str, list]] = None) -> DescriptorNumber: + def sum(self, dim: Optional[Union[str, list]] = None) -> Union[DescriptorArray, DescriptorNumber]: """ Uses scipp to sum over the requested dims. :param dim: The dim(s) in the scipp array to sum over. If `None`, will sum over all dims. @@ -742,7 +736,7 @@ def sum(self, dim: Optional[Union[str, list]] = None) -> DescriptorNumber: # raise ValueError(f"Last dimension of {other=} must match the first dimension of DescriptorArray values") # # other = sc.array(dims=self._array.dims, values=other) - # new_full_value = operator(self._array, other) # Let scipp handle operation for uncertainty propagation + # new_full_value = operation(self._array, other) # Let scipp handle operation for uncertainty propagation def _base_unit(self) -> str: diff --git a/tests/unit_tests/Objects/variable/test_descriptor_array.py b/tests/unit_tests/Objects/variable/test_descriptor_array.py index 7be5dd1..1499878 100644 --- a/tests/unit_tests/Objects/variable/test_descriptor_array.py +++ b/tests/unit_tests/Objects/variable/test_descriptor_array.py @@ -132,13 +132,13 @@ def test_from_scipp(self): assert descriptor._array.unit == "m" assert descriptor._array.variances == None - # @pytest.mark.parametrize("full_value", [sc.array(values=[1,2], dims=["x"]), sc.array(values=[[1], [2]], dims=["x","y"]), object(), 1, "string"], ids=["1D", "2D", "object", "int", "string"]) + # @pytest.mark.parametrize("full_value", [sc.array(values=[1,2], dimensions=["x"]), sc.array(values=[[1], [2]], dims=["x","y"]), object(), 1, "string"], ids=["1D", "2D", "object", "int", "string"]) # def test_from_scipp_type_exception(self, full_value): # # When Then Expect # with pytest.raises(TypeError): # DescriptorArray.from_scipp(name="name", full_value=full_value) - def tvigateDownest_full_value(self, descriptor: DescriptorArray): + def test_get_full_value(self, descriptor: DescriptorArray): # When Then Expect other = sc.array(dims=('dim0','dim1'), values=[[1.0, 2.0], [3.0, 4.0]], @@ -704,23 +704,29 @@ def test_power_exception(self, descriptor: DescriptorArray, test, exception): # When Then with pytest.raises(exception): result = descriptor ** 2 ** test - with pytest.raises(TypeError): + with pytest.raises(ValueError): # Exponentiation with an array does not make sense test ** descriptor @pytest.mark.parametrize("test", [ DescriptorNumber("test", 2, "s"), DescriptorArray("test", [[1, 2], [3, 4]], "s")], ids=["add_array_to_unit", "incompatible_units"]) - def test_operation_exception(self, descriptor: DescriptorArray, test): + def test_addition_exception(self, descriptor: DescriptorArray, test): # When Then Expect - import operator - operators = [operator.add, - operator.sub] - for operator in operators: # This can probably be done better w. fixture - with pytest.raises(UnitError): - result = operator(descriptor, test) - with pytest.raises(UnitError): - result_reverse = operator(test, descriptor) + with pytest.raises(UnitError): + result = descriptor + test + with pytest.raises(UnitError): + result_reverse = test + descriptor + + @pytest.mark.parametrize("test", [ + DescriptorNumber("test", 2, "s"), + DescriptorArray("test", [[1, 2], [3, 4]], "s")], ids=["add_array_to_unit", "incompatible_units"]) + def test_sub_exception(self, descriptor: DescriptorArray, test): + # When Then Expect + with pytest.raises(UnitError): + result = descriptor - test + with pytest.raises(UnitError): + result_reverse = test - descriptor @pytest.mark.parametrize("function", [ np.sin, @@ -927,7 +933,7 @@ def test_sum_over_subset(self, descriptor, expected, dim): assert result.unit == expected.unit assert np.allclose(result.variance, expected.variance) - @pytest.mark.parametrize("test, dims", [ + @pytest.mark.parametrize("test, dimensions", [ (DescriptorArray("test", [1.], "dimensionless", [1.]), ['dim0']), (DescriptorArray("test", [[1., 1.]], "dimensionless", [[1., 1.]]), ['dim0', 'dim1']), (DescriptorArray("test", [[1.], [1.]], "dimensionless", [[1.], [1.]]), ['dim0', 'dim1']), @@ -935,13 +941,13 @@ def test_sum_over_subset(self, descriptor, expected, dim): (DescriptorArray("test", [[[1.]], [[1.]], [[1.]]], "dimensionless", [[[1.]], [[1.]], [[1.]]]), ['dim0', 'dim1', 'dim2']), ], ids=["1x1", "1x2", "2x1", "1x3", "3x1"]) - def test_array_generate_dims(self, test, dims): - assert test.dims == dims + def test_array_generate_dimensions(self, test, dimensions): + assert test.dimensions == dimensions - def test_array_set_dims_exception(self, descriptor): + def test_array_set_dimensions_exception(self, descriptor): with pytest.raises(ValueError) as e: - descriptor.dims = ['too_few'] + descriptor.dimensions = ['too_few'] assert "must have the same shape" with pytest.raises(ValueError) as e: - DescriptorArray("test", [[1.]], "m", [[1.]], dims=['dim']) - assert "Length of dims" in str(e) + DescriptorArray("test", [[1.]], "m", [[1.]], dimensions=['dim']) + assert "Length of dimensions" in str(e) From b512fe9ebb83720681df606d2ce470917f888aba Mon Sep 17 00:00:00 2001 From: Eric Lindgren Date: Thu, 6 Mar 2025 09:23:18 +0100 Subject: [PATCH 03/14] add back DescriptorAnyType --- src/easyscience/Objects/variable/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/easyscience/Objects/variable/__init__.py b/src/easyscience/Objects/variable/__init__.py index 04a9f6c..8af6ee6 100644 --- a/src/easyscience/Objects/variable/__init__.py +++ b/src/easyscience/Objects/variable/__init__.py @@ -1,3 +1,4 @@ +from .descriptor_any_type import DescriptorAnyType from .descriptor_array import DescriptorArray from .descriptor_bool import DescriptorBool from .descriptor_number import DescriptorNumber @@ -5,6 +6,7 @@ from .parameter import Parameter __all__ = [ + DescriptorAnyType, DescriptorArray, DescriptorBool, DescriptorNumber, From 5ec887b44b2de5f08d7745df78d7b4bee51c2841 Mon Sep 17 00:00:00 2001 From: Eric Lindgren Date: Thu, 6 Mar 2025 10:28:45 +0100 Subject: [PATCH 04/14] split addition and reverse addition --- .../Objects/variable/test_descriptor_array.py | 74 +++++++++++++++++-- 1 file changed, 66 insertions(+), 8 deletions(-) diff --git a/tests/unit_tests/Objects/variable/test_descriptor_array.py b/tests/unit_tests/Objects/variable/test_descriptor_array.py index 1499878..af0ab69 100644 --- a/tests/unit_tests/Objects/variable/test_descriptor_array.py +++ b/tests/unit_tests/Objects/variable/test_descriptor_array.py @@ -282,23 +282,20 @@ def test_base_unit(self, unit_string, expected): [[1001.0, 2002.0], [3003.0, 4004.0]]), False)], ids=["descriptor_number_regular", "descriptor_number_unit_conversion", "array_conversion"]) - def test_addition(self, descriptor: DescriptorArray, test, expected, raises_warning): + def test_reverse_addition(self, descriptor: DescriptorArray, test, expected, raises_warning): # When Then if raises_warning: with pytest.warns(UserWarning) as record: result = test + descriptor - result_reverse = descriptor + test - assert len(record) == 2 + assert len(record) == 1 assert 'Correlations introduced' in record[0].message.args[0] else: result = test + descriptor - result_reverse = descriptor + test # Expect assert type(result) == DescriptorArray assert result.name == result.unique_name assert np.array_equal(result.value, expected.value) assert result.unit == expected.unit - assert result_reverse.unit == descriptor.unit assert np.allclose(result.variance, expected.variance) assert descriptor.unit == 'm' @@ -316,13 +313,74 @@ def test_addition(self, descriptor: DescriptorArray, test, expected, raises_warn [[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]])) ], ids=["list", "number"]) - def test_addition_dimensionless(self, descriptor_dimensionless: DescriptorArray, test, expected): + def test_reverse_addition_dimensionless(self, descriptor_dimensionless: DescriptorArray, test, expected): # When Then result = test + descriptor_dimensionless - result_reverse = descriptor_dimensionless + test # Expect assert type(result) == DescriptorArray - assert type(result_reverse) == DescriptorArray + assert np.array_equal(result.value, expected.value) + assert np.allclose(result.variance, expected.variance) + assert descriptor_dimensionless.unit == 'dimensionless' + + @pytest.mark.parametrize("test, expected, raises_warning", [ + (DescriptorNumber("test", 2, "m", 0.01), + DescriptorArray("test + name", + [[3.0, 4.0], [5.0, 6.0]], + "m", + [[0.11, 0.21], [0.31, 0.41]]), + True), + (DescriptorNumber("test", 1, "cm", 10), + DescriptorArray("test + name", + [[1.01, 2.01], [3.01, 4.01]], + "m", + [[0.1010, 0.2010], [0.3010, 0.4010]]), + True), + (DescriptorArray("test", + [[2.0, 3.0], [4.0, -5.0]], + "cm", + [[1.0, 2.0], [3.0, 4.0]]), + DescriptorArray("test + name", + [[1.02, 2.03], [3.04, 3.95]], + "m", + [[0.1001, 0.2002], [0.3003, 0.4004]]), + False)], + ids=["descriptor_number_regular", "descriptor_number_unit_conversion", "array_conversion"]) + def test_addition(self, descriptor: DescriptorArray, test, expected, raises_warning): + # When Then + if raises_warning: + with pytest.warns(UserWarning) as record: + result = descriptor + test + assert len(record) == 1 + assert 'Correlations introduced' in record[0].message.args[0] + else: + result = descriptor + test + # Expect + assert type(result) == DescriptorArray + assert result.name == result.unique_name + assert np.array_equal(result.value, expected.value) + assert result.unit == expected.unit + assert np.allclose(result.variance, expected.variance) + assert descriptor.unit == 'm' + + + @pytest.mark.parametrize("test, expected", [ + ([[2.0, 3.0], [4.0, -5.0], [6.0, -8.0]], + DescriptorArray("test", + [[3.0, 5.0], [7.0, -1.0], [11.0, -2.0]], + "dimensionless", + [[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]])), + (1, + DescriptorArray("test", + [[2.0, 3.0], [4.0, 5.0], [6.0, 7.0]], + "dimensionless", + [[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]])) + ], + ids=["list", "number"]) + def test_addition_dimensionless(self, descriptor_dimensionless: DescriptorArray, test, expected): + # When Then + result = descriptor_dimensionless + test + # Expect + assert type(result) == DescriptorArray assert np.array_equal(result.value, expected.value) assert np.allclose(result.variance, expected.variance) assert descriptor_dimensionless.unit == 'dimensionless' From 71537c52d3e4a61ff755d043894e9b620ed76ddd Mon Sep 17 00:00:00 2001 From: Eric Lindgren Date: Thu, 6 Mar 2025 10:36:48 +0100 Subject: [PATCH 05/14] split subtraction tests --- .../Objects/variable/test_descriptor_array.py | 100 +++++++++++++----- 1 file changed, 75 insertions(+), 25 deletions(-) diff --git a/tests/unit_tests/Objects/variable/test_descriptor_array.py b/tests/unit_tests/Objects/variable/test_descriptor_array.py index af0ab69..1c0713c 100644 --- a/tests/unit_tests/Objects/variable/test_descriptor_array.py +++ b/tests/unit_tests/Objects/variable/test_descriptor_array.py @@ -258,6 +258,68 @@ def test_base_unit(self, unit_string, expected): # Expect assert base_unit == expected + + @pytest.mark.parametrize("test, expected, raises_warning", [ + (DescriptorNumber("test", 2, "m", 0.01), + DescriptorArray("test + name", + [[3.0, 4.0], [5.0, 6.0]], + "m", + [[0.11, 0.21], [0.31, 0.41]]), + True), + (DescriptorNumber("test", 1, "cm", 10), + DescriptorArray("test + name", + [[1.01, 2.01], [3.01, 4.01]], + "m", + [[0.1010, 0.2010], [0.3010, 0.4010]]), + True), + (DescriptorArray("test", + [[2.0, 3.0], [4.0, -5.0]], + "cm", + [[1.0, 2.0], [3.0, 4.0]]), + DescriptorArray("test + name", + [[1.02, 2.03], [3.04, 3.95]], + "m", + [[0.1001, 0.2002], [0.3003, 0.4004]]), + False)], + ids=["descriptor_number_regular", "descriptor_number_unit_conversion", "array_conversion"]) + def test_addition(self, descriptor: DescriptorArray, test, expected, raises_warning): + # When Then + if raises_warning: + with pytest.warns(UserWarning) as record: + result = descriptor + test + assert len(record) == 1 + assert 'Correlations introduced' in record[0].message.args[0] + else: + result = descriptor + test + # Expect + assert type(result) == DescriptorArray + assert result.name == result.unique_name + assert np.array_equal(result.value, expected.value) + assert result.unit == expected.unit + assert np.allclose(result.variance, expected.variance) + assert descriptor.unit == 'm' + + @pytest.mark.parametrize("test, expected", [ + ([[2.0, 3.0], [4.0, -5.0], [6.0, -8.0]], + DescriptorArray("test", + [[3.0, 5.0], [7.0, -1.0], [11.0, -2.0]], + "dimensionless", + [[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]])), + (1, + DescriptorArray("test", + [[2.0, 3.0], [4.0, 5.0], [6.0, 7.0]], + "dimensionless", + [[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]])) + ], + ids=["list", "number"]) + def test_addition_dimensionless(self, descriptor_dimensionless: DescriptorArray, test, expected): + # When Then + result = descriptor_dimensionless + test + # Expect + assert type(result) == DescriptorArray + assert np.array_equal(result.value, expected.value) + assert np.allclose(result.variance, expected.variance) + assert descriptor_dimensionless.unit == 'dimensionless' @pytest.mark.parametrize("test, expected, raises_warning", [ (DescriptorNumber("test", 2, "m", 0.01), @@ -299,7 +361,6 @@ def test_reverse_addition(self, descriptor: DescriptorArray, test, expected, rai assert np.allclose(result.variance, expected.variance) assert descriptor.unit == 'm' - @pytest.mark.parametrize("test, expected", [ ([[2.0, 3.0], [4.0, -5.0], [6.0, -8.0]], DescriptorArray("test", @@ -325,13 +386,13 @@ def test_reverse_addition_dimensionless(self, descriptor_dimensionless: Descript @pytest.mark.parametrize("test, expected, raises_warning", [ (DescriptorNumber("test", 2, "m", 0.01), DescriptorArray("test + name", - [[3.0, 4.0], [5.0, 6.0]], + [[-1.0, 0.0], [1.0, 2.0]], "m", [[0.11, 0.21], [0.31, 0.41]]), True), (DescriptorNumber("test", 1, "cm", 10), DescriptorArray("test + name", - [[1.01, 2.01], [3.01, 4.01]], + [[0.99, 1.99], [2.99, 3.99]], "m", [[0.1010, 0.2010], [0.3010, 0.4010]]), True), @@ -340,20 +401,20 @@ def test_reverse_addition_dimensionless(self, descriptor_dimensionless: Descript "cm", [[1.0, 2.0], [3.0, 4.0]]), DescriptorArray("test + name", - [[1.02, 2.03], [3.04, 3.95]], + [[0.98, 1.97], [2.96, 4.05]], "m", [[0.1001, 0.2002], [0.3003, 0.4004]]), False)], ids=["descriptor_number_regular", "descriptor_number_unit_conversion", "array_conversion"]) - def test_addition(self, descriptor: DescriptorArray, test, expected, raises_warning): + def test_subtraction(self, descriptor: DescriptorArray, test, expected, raises_warning): # When Then if raises_warning: with pytest.warns(UserWarning) as record: - result = descriptor + test + result = descriptor - test assert len(record) == 1 assert 'Correlations introduced' in record[0].message.args[0] else: - result = descriptor + test + result = descriptor - test # Expect assert type(result) == DescriptorArray assert result.name == result.unique_name @@ -362,23 +423,22 @@ def test_addition(self, descriptor: DescriptorArray, test, expected, raises_warn assert np.allclose(result.variance, expected.variance) assert descriptor.unit == 'm' - @pytest.mark.parametrize("test, expected", [ ([[2.0, 3.0], [4.0, -5.0], [6.0, -8.0]], DescriptorArray("test", - [[3.0, 5.0], [7.0, -1.0], [11.0, -2.0]], + [[-1.0, -1.0], [-1.0, 9.0], [-1, 14.0]], "dimensionless", [[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]])), (1, DescriptorArray("test", - [[2.0, 3.0], [4.0, 5.0], [6.0, 7.0]], + [[0.0, 1.0], [2.0, 3.0], [4.0, 5.0]], "dimensionless", [[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]])) ], ids=["list", "number"]) - def test_addition_dimensionless(self, descriptor_dimensionless: DescriptorArray, test, expected): + def test_subtraction_dimensionless(self, descriptor_dimensionless: DescriptorArray, test, expected): # When Then - result = descriptor_dimensionless + test + result = descriptor_dimensionless - test # Expect assert type(result) == DescriptorArray assert np.array_equal(result.value, expected.value) @@ -408,28 +468,22 @@ def test_addition_dimensionless(self, descriptor_dimensionless: DescriptorArray, [[1001.0, 2002.0], [3003.0, 4004.0]]), False)], ids=["descriptor_number_regular", "descriptor_number_unit_conversion", "array_conversion"]) - def test_subtraction(self, descriptor: DescriptorArray, test, expected, raises_warning): + def test_reverse_subtraction(self, descriptor: DescriptorArray, test, expected, raises_warning): # When Then if raises_warning: with pytest.warns(UserWarning) as record: result = test - descriptor - result_reverse = descriptor - test - assert len(record) == 2 + assert len(record) == 1 assert 'Correlations introduced' in record[0].message.args[0] else: result = test - descriptor - result_reverse = descriptor - test # Expect assert type(result) == DescriptorArray assert result.name == result.unique_name assert np.array_equal(result.value, expected.value) assert result.unit == expected.unit - assert result_reverse.unit == descriptor.unit assert np.allclose(result.variance, expected.variance) assert descriptor.unit == 'm' - # Convert units and check that reverse result is the same - result_reverse.convert_unit(result.unit) - assert np.array_equal(result.value, -result_reverse.value) @pytest.mark.parametrize("test, expected", [ ([[2.0, 3.0], [4.0, -5.0], [6.0, -8.0]], @@ -444,18 +498,14 @@ def test_subtraction(self, descriptor: DescriptorArray, test, expected, raises_w [[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]])) ], ids=["list", "number"]) - def test_subtraction_dimensionless(self, descriptor_dimensionless: DescriptorArray, test, expected): + def test_reverse_subtraction_dimensionless(self, descriptor_dimensionless: DescriptorArray, test, expected): # When Then result = test - descriptor_dimensionless - result_reverse = descriptor_dimensionless - test # Expect assert type(result) == DescriptorArray - assert type(result_reverse) == DescriptorArray assert np.array_equal(result.value, expected.value) - assert np.array_equal(result.value, -result_reverse.value) assert np.allclose(result.variance, expected.variance) assert descriptor_dimensionless.unit == 'dimensionless' - @pytest.mark.parametrize("test, expected, raises_warning", [ (DescriptorNumber("test", 2, "m", 0.01), From 18f6ce22600926651a483c0717fa62037f9b3910 Mon Sep 17 00:00:00 2001 From: Eric Lindgren Date: Thu, 6 Mar 2025 15:32:05 +0100 Subject: [PATCH 06/14] split regular add reverse tests --- .../Objects/variable/test_descriptor_array.py | 223 ++++++++++++++++-- 1 file changed, 205 insertions(+), 18 deletions(-) diff --git a/tests/unit_tests/Objects/variable/test_descriptor_array.py b/tests/unit_tests/Objects/variable/test_descriptor_array.py index 1c0713c..098e415 100644 --- a/tests/unit_tests/Objects/variable/test_descriptor_array.py +++ b/tests/unit_tests/Objects/variable/test_descriptor_array.py @@ -507,6 +507,95 @@ def test_reverse_subtraction_dimensionless(self, descriptor_dimensionless: Descr assert np.allclose(result.variance, expected.variance) assert descriptor_dimensionless.unit == 'dimensionless' + @pytest.mark.parametrize("test, expected, raises_warning", [ + (DescriptorNumber("test", 2, "m", 0.01), + DescriptorArray("test * name", + [[2.0, 4.0], [6.0, 8.0]], + "m^2", + [[0.41, 0.84], [1.29, 1.76]]), + True), + (DescriptorNumber("test", 1, "cm", 10), + DescriptorArray("test * name", + [[0.01, 0.02], [0.03, 0.04]], + "m^2", + [[0.00101, 0.00402], [0.00903, 0.01604]]), + True), + (DescriptorNumber("test", 1, "kg", 10), + DescriptorArray("test * name", + [[1.0, 2.0], [3.0, 4.0]], + "kg*m", + [[10.1, 40.2], [90.3, 160.4]]), + True), + (DescriptorArray("test", + [[2.0, 3.0], [4.0, -5.0]], + "cm", + [[1.0, 2.0], [3.0, 4.0]]), + DescriptorArray("test * name", + [[0.02, 0.06], [0.12, -0.2]], + "m^2", + [[0.00014, 0.00098], [0.00318, 0.0074]]), + False), + ([[2.0, 3.0], [4.0, -5.0]], + DescriptorArray("test * name", + [[2.0, 6.0], [12.0, -20.0]], + "m", + [[0.1 * 2**2, 0.2 * 3**2], + [0.3 * 4**2, 0.4 * 5**2]]), + False), + (2.0, + DescriptorArray("test * name", + [[2.0, 4.0], [6.0, 8.0]], + "m", + [[0.1 * 2**2, 0.2 * 2**2], + [0.3 * 2**2, 0.4 * 2**2]]), + False) + + ], + ids=["descriptor_number_regular", + "descriptor_number_unit_conversion", + "descriptor_number_different_units", + "array_conversion", + "list", + "number"]) + def test_multiplication(self, descriptor: DescriptorArray, test, expected, raises_warning): + # When Then + if raises_warning: + with pytest.warns(UserWarning) as record: + result = descriptor * test + assert len(record) == 1 + assert 'Correlations introduced' in record[0].message.args[0] + else: + result = descriptor * test + # Expect + assert type(result) == DescriptorArray + assert result.name == result.unique_name + assert np.array_equal(result.value, expected.value) + assert result.unit == expected.unit + assert np.allclose(result.variance, expected.variance) + assert descriptor.unit == 'm' + + @pytest.mark.parametrize("test, expected", [ + ([[2.0, 3.0], [4.0, -5.0], [6.0, -8.0]], + DescriptorArray("test", + [[2.0, 6.0], [12.0, -20.0], [30.0, -48.0]], + "dimensionless", + [[0.4, 1.8], [4.8, 10.0], [18.0, 38.4]])), + (1.5, + DescriptorArray("test", + [[1.5, 3.0], [4.5, 6.0], [7.5, 9.0]], + "dimensionless", + [[0.225, 0.45], [0.675, 0.9], [1.125, 1.35]])) + ], + ids=["list", "number"]) + def test_multiplication_dimensionless(self, descriptor_dimensionless: DescriptorArray, test, expected): + # When Then + result = descriptor_dimensionless * test + # Expect + assert type(result) == DescriptorArray + assert np.array_equal(result.value, expected.value) + assert np.allclose(result.variance, expected.variance) + assert descriptor_dimensionless.unit == 'dimensionless' + @pytest.mark.parametrize("test, expected, raises_warning", [ (DescriptorNumber("test", 2, "m", 0.01), DescriptorArray("test * name", @@ -557,17 +646,15 @@ def test_reverse_subtraction_dimensionless(self, descriptor_dimensionless: Descr "array_conversion", "list", "number"]) - def test_multiplication(self, descriptor: DescriptorArray, test, expected, raises_warning): + def test_reverse_multiplication(self, descriptor: DescriptorArray, test, expected, raises_warning): # When Then if raises_warning: with pytest.warns(UserWarning) as record: result = test * descriptor - result_reverse = descriptor * test - assert len(record) == 2 + assert len(record) == 1 assert 'Correlations introduced' in record[0].message.args[0] else: result = test * descriptor - result_reverse = descriptor * test # Expect assert type(result) == DescriptorArray assert result.name == result.unique_name @@ -576,7 +663,6 @@ def test_multiplication(self, descriptor: DescriptorArray, test, expected, raise assert np.allclose(result.variance, expected.variance) assert descriptor.unit == 'm' - @pytest.mark.parametrize("test, expected", [ ([[2.0, 3.0], [4.0, -5.0], [6.0, -8.0]], DescriptorArray("test", @@ -590,17 +676,125 @@ def test_multiplication(self, descriptor: DescriptorArray, test, expected, raise [[0.225, 0.45], [0.675, 0.9], [1.125, 1.35]])) ], ids=["list", "number"]) - def test_multiplication_dimensionless(self, descriptor_dimensionless: DescriptorArray, test, expected): + def test_reverse_multiplication_dimensionless(self, descriptor_dimensionless: DescriptorArray, test, expected): # When Then result = test * descriptor_dimensionless - result_reverse = descriptor_dimensionless * test # Expect assert type(result) == DescriptorArray - assert type(result_reverse) == DescriptorArray assert np.array_equal(result.value, expected.value) assert np.allclose(result.variance, expected.variance) assert descriptor_dimensionless.unit == 'dimensionless' + + @pytest.mark.parametrize("test, expected, raises_warning", [ + (DescriptorNumber("test", 2, "m", 0.01), + DescriptorArray("name / test", + [[1.0/2.0, 2.0/2.0], [3.0/2.0, 4.0/2.0]], + "dimensionless", + [[(0.1 + 0.01 * 1.0**2 / 2.0**2) / 2.0**2, + (0.2 + 0.01 * 2.0**2 / 2.0**2) / 2.0**2], + [(0.3 + 0.01 * 3.0**2 / 2.0**2) / 2.0**2, + (0.4 + 0.01 * 4.0**2 / 2.0**2) / 2.0**2]]), + True), + (DescriptorNumber("test", 1, "cm", 10), + DescriptorArray("name / test", + [[100.0, 200.0], [300.0, 400.0]], + "dimensionless", + [[(0.1 + 10 * 1.0**2 / 1.0**2) / 1.0**2 * 1e4, + (0.2 + 10 * 2.0**2 / 1.0**2) / 1.0**2 * 1e4], + [(0.3 + 10 * 3.0**2 / 1.0**2) / 1.0**2 * 1e4, + (0.4 + 10 * 4.0**2 / 1.0**2) / 1.0**2 * 1e4]]), + True), + (DescriptorNumber("test", 1, "kg", 10), + DescriptorArray("name / test", + [[1.0, 2.0], [3.0, 4.0]], + "m/kg", + [[(0.1 + 10 * 1.0**2 / 1.0**2) / 1.0**2, + (0.2 + 10 * 2.0**2 / 1.0**2) / 1.0**2], + [(0.3 + 10 * 3.0**2 / 1.0**2) / 1.0**2, + (0.4 + 10 * 4.0**2 / 1.0**2) / 1.0**2]]), + True), + (DescriptorArray("test", + [[2.0, 3.0], [4.0, -5.0]], + "cm^2", + [[1.0, 2.0], [3.0, 4.0]]), + DescriptorArray("name / test", + [[1/2 * 1e4, 2/3 * 1e4], [3.0/4.0*1e4, -4.0/5.0 * 1e4]], + "1/m", + [[(0.1 + 1.0 * 1.0**2 / 2.0**2) / 2.0**2 * 1e8, + (0.2 + 2.0 * 2.0**2 / 3.0**2) / 3.0**2 * 1e8], + [(0.3 + 3.0 * 3.0**2 / 4.0**2) / 4.0**2 * 1e8, + (0.4 + 4.0 * 4.0**2 / 5.0**2) / 5.0**2 * 1e8]]), + False), + ([[2.0, 3.0], [4.0, -5.0]], + DescriptorArray("name / name", + [[0.5, 2.0/3.0], [3.0/4.0, -4/5]], + "m", + [[0.1 / 2**2, 0.2 / 3.0**2], + [0.3 / 4**2, 0.4 / 5.0**2]]), + False), + (2.0, + DescriptorArray("name / test", + [[0.5, 1.0], [3.0/2.0, 2.0]], + "m", + [[0.1 / 2.0**2, 0.2 / 2.0**2], + [0.3 / 2.0**2, 0.4 / 2.0**2]]), + False) + ], + ids=["descriptor_number_regular", + "descriptor_number_unit_conversion", + "descriptor_number_different_units", + "array_conversion", + "list", + "number"]) + def test_division(self, descriptor: DescriptorArray, test, expected, raises_warning): + # When Then + if raises_warning: + with pytest.warns(UserWarning) as record: + result = descriptor / test + assert len(record) == 1 + assert 'Correlations introduced' in record[0].message.args[0] + else: + result = descriptor / test + # Expect + assert type(result) == DescriptorArray + assert result.name == result.unique_name + assert np.allclose(result.value, expected.value) + assert result.unit == expected.unit + assert np.allclose(result.variance, expected.variance) + assert descriptor.unit == 'm' + @pytest.mark.parametrize("test, expected", [ + ([[2.0, 3.0], [4.0, -5.0], [6.0, -8.0]], + DescriptorArray("test", + [[1.0/2.0, 2.0/3.0], [3.0/4.0, -4.0/5.0], [5.0/6.0, -6.0/8.0]], + "dimensionless", + [[0.1 / 2.0**2, + 0.2 / 3.0**2], + [0.3 / 4.0**2, + 0.4 / 5.0**2], + [0.5 / 6.0**2, + 0.6 / 8.0**2]])), + (2, + DescriptorArray("test", + [[1.0/2.0, 2.0/2.0], [3.0/2.0, 4.0/2.0], [5.0/2.0, 6.0/2.0]], + "dimensionless", + [[0.1 / 2.0**2, + 0.2 / 2.0**2], + [0.3 / 2.0**2, + 0.4 / 2.0**2], + [0.5 / 2.0**2, + 0.6 / 2.0**2]])) + ], + ids=["list", "number"]) + def test_division_dimensionless(self, descriptor_dimensionless: DescriptorArray, test, expected): + # When Then + result = descriptor_dimensionless / test + # Expect + assert type(result) == DescriptorArray + assert np.allclose(result.value, expected.value) + assert np.allclose(result.variance, expected.variance) + assert descriptor_dimensionless.unit == 'dimensionless' + @pytest.mark.parametrize("test, expected, raises_warning", [ (DescriptorNumber("test", 2, "m", 0.01), DescriptorArray("test / name", @@ -656,17 +850,15 @@ def test_multiplication_dimensionless(self, descriptor_dimensionless: Descriptor "array_conversion", "list", "number"]) - def test_division(self, descriptor: DescriptorArray, test, expected, raises_warning): + def test_reverse_division(self, descriptor: DescriptorArray, test, expected, raises_warning): # When Then if raises_warning: with pytest.warns(UserWarning) as record: result = test / descriptor - result_reverse = descriptor / test - assert len(record) == 2 + assert len(record) == 1 assert 'Correlations introduced' in record[0].message.args[0] else: result = test / descriptor - result_reverse = descriptor / test # Expect assert type(result) == DescriptorArray assert result.name == result.unique_name @@ -692,17 +884,13 @@ def test_division(self, descriptor: DescriptorArray, test, expected, raises_warn [0.5 * 2**2 / 5**4, 0.6 * 2**2 / 6**4]])) ], ids=["list", "number"]) - def test_division_dimensionless(self, descriptor_dimensionless: DescriptorArray, test, expected): + def test_reverse_division_dimensionless(self, descriptor_dimensionless: DescriptorArray, test, expected): # When Then result = test / descriptor_dimensionless - result_reverse = descriptor_dimensionless / test # Expect assert type(result) == DescriptorArray - assert type(result_reverse) == DescriptorArray assert np.allclose(result.value, expected.value) - assert np.allclose(result.value, 1 / result_reverse.value) assert np.allclose(result.variance, expected.variance) - assert descriptor_dimensionless.unit == 'dimensionless' @pytest.mark.parametrize("test", [ @@ -791,7 +979,6 @@ def test_power_dimensionless(self, descriptor_dimensionless: DescriptorArray, te assert result.unit == expected.unit assert np.allclose(result.variance, expected.variance) assert descriptor_dimensionless.unit == 'dimensionless' - @pytest.mark.parametrize("test, exception", [ (DescriptorNumber("test", 2, "m"), UnitError), From 2f05549a11cb65e2d889e39a60d9419ad0c9f7b4 Mon Sep 17 00:00:00 2001 From: Eric Lindgren Date: Thu, 6 Mar 2025 15:34:21 +0100 Subject: [PATCH 07/14] remove try catch, and let scipp raise the error instead --- src/easyscience/Objects/variable/descriptor_array.py | 5 +---- src/easyscience/Objects/variable/descriptor_number.py | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/src/easyscience/Objects/variable/descriptor_array.py b/src/easyscience/Objects/variable/descriptor_array.py index 4ddce9e..b7c2dad 100644 --- a/src/easyscience/Objects/variable/descriptor_array.py +++ b/src/easyscience/Objects/variable/descriptor_array.py @@ -272,10 +272,7 @@ def convert_unit(self, unit_str: str) -> None: """ if not isinstance(unit_str, str): raise TypeError(f'{unit_str=} must be a string representing a valid scipp unit') - try: - new_unit = sc.Unit(unit_str) - except UnitError as message: - raise UnitError(message) + new_unit = sc.Unit(unit_str) # Save the current state for undo/redo old_array = self._array diff --git a/src/easyscience/Objects/variable/descriptor_number.py b/src/easyscience/Objects/variable/descriptor_number.py index 91f7154..cfba4a4 100644 --- a/src/easyscience/Objects/variable/descriptor_number.py +++ b/src/easyscience/Objects/variable/descriptor_number.py @@ -206,10 +206,7 @@ def convert_unit(self, unit_str: str) -> None: """ if not isinstance(unit_str, str): raise TypeError(f'{unit_str=} must be a string representing a valid scipp unit') - try: - new_unit = sc.Unit(unit_str) - except UnitError as message: - raise UnitError(message) from None + new_unit = sc.Unit(unit_str) # Save the current state for undo/redo old_scalar = self._scalar From 5d3df5b0aed2a1f35cfe343ebf0b59dcbd731d2f Mon Sep 17 00:00:00 2001 From: Eric Lindgren Date: Thu, 6 Mar 2025 16:14:21 +0100 Subject: [PATCH 08/14] convert value and variance to float to avoid int/float operations --- .../Objects/variable/descriptor_array.py | 17 ++- .../Objects/variable/test_descriptor_array.py | 113 ++++++++++++++++-- 2 files changed, 115 insertions(+), 15 deletions(-) diff --git a/src/easyscience/Objects/variable/descriptor_array.py b/src/easyscience/Objects/variable/descriptor_array.py index b7c2dad..5d9dc92 100644 --- a/src/easyscience/Objects/variable/descriptor_array.py +++ b/src/easyscience/Objects/variable/descriptor_array.py @@ -68,7 +68,7 @@ def __init__( raise ValueError(f"{variance=} must have the same shape as {value=}.") if not np.all(variance >= 0): raise ValueError(f"{variance=} must only contain non-negative values.") - + if not isinstance(unit, sc.Unit) and not isinstance(unit, str): raise TypeError(f'{unit=} must be a scipp unit or a string representing a valid scipp unit') @@ -79,8 +79,11 @@ def __init__( raise ValueError(f"Length of dimensions ({dimensions=}) does not match length of value {value=}.") self._dimensions = dimensions + try: - self._array = sc.array(dims=dimensions, values=value, unit=unit, variances=variance) + # Convert value and variance to floats + # for optimization everything must be floats + self._array = sc.array(dims=dimensions, values=value, unit=unit, variances=variance).astype('float') except Exception as message: raise UnitError(message) # TODO: handle 1xn and nx1 arrays @@ -152,8 +155,9 @@ def value(self, value: Union[list, np.ndarray]) -> None: if value.shape != self._array.values.shape: raise ValueError(f"{value=} must have the same shape as the existing array values.") - - self._array.values = value + + # Values must be floats for optimization + self._array.values = value.astype('float') @property def dimensions(self) -> list: @@ -220,13 +224,14 @@ def variance(self, variance: Union[list, np.ndarray]) -> None: if isinstance(variance, list): variance = np.array(variance) # Convert lists to numpy arrays for consistent handling. - if variance.shape != self._array.values.shape: + if variance.shape != self._array.shape: raise ValueError(f"{variance=} must have the same shape as the array values.") if not np.all(variance >= 0): raise ValueError(f"{variance=} must only contain non-negative values.") - self._array.variances = variance + # Values must be floats for optimization + self._array.variances = variance.astype('float') @property def error(self) -> Optional[np.ndarray]: diff --git a/tests/unit_tests/Objects/variable/test_descriptor_array.py b/tests/unit_tests/Objects/variable/test_descriptor_array.py index 098e415..caca0c3 100644 --- a/tests/unit_tests/Objects/variable/test_descriptor_array.py +++ b/tests/unit_tests/Objects/variable/test_descriptor_array.py @@ -280,8 +280,17 @@ def test_base_unit(self, unit_string, expected): [[1.02, 2.03], [3.04, 3.95]], "m", [[0.1001, 0.2002], [0.3003, 0.4004]]), - False)], - ids=["descriptor_number_regular", "descriptor_number_unit_conversion", "array_conversion"]) + False), + (DescriptorArray("test", + [[2, 3], [4, -5]], + "cm"), + DescriptorArray("test + name", + [[1.02, 2.03], [3.04, 3.95]], + "m", + [[0.1, 0.2], [0.3, 0.4]]), + False), + ], + ids=["descriptor_number_regular", "descriptor_number_unit_conversion", "array_conversion", "array_conversion_integer"]) def test_addition(self, descriptor: DescriptorArray, test, expected, raises_warning): # When Then if raises_warning: @@ -342,8 +351,17 @@ def test_addition_dimensionless(self, descriptor_dimensionless: DescriptorArray, [[102.0, 203.0], [304.0, 395.0]], "cm", [[1001.0, 2002.0], [3003.0, 4004.0]]), - False)], - ids=["descriptor_number_regular", "descriptor_number_unit_conversion", "array_conversion"]) + False), + (DescriptorArray("test", + [[2, 3], [4, -5]], + "cm"), + DescriptorArray("test + name", + [[102.0, 203.0], [304.0, 395.0]], + "cm", + [[1000.0, 2000.0], [3000.0, 4000.0]]), + False), + ], + ids=["descriptor_number_regular", "descriptor_number_unit_conversion", "array_conversion", "array_conversion_integer"]) def test_reverse_addition(self, descriptor: DescriptorArray, test, expected, raises_warning): # When Then if raises_warning: @@ -404,8 +422,17 @@ def test_reverse_addition_dimensionless(self, descriptor_dimensionless: Descript [[0.98, 1.97], [2.96, 4.05]], "m", [[0.1001, 0.2002], [0.3003, 0.4004]]), - False)], - ids=["descriptor_number_regular", "descriptor_number_unit_conversion", "array_conversion"]) + False), + (DescriptorArray("test", + [[2, 3], [4, -5]], + "cm"), + DescriptorArray("test + name", + [[0.98, 1.97], [2.96, 4.05]], + "m", + [[0.100, 0.200], [0.300, 0.400]]), + False) + ], + ids=["descriptor_number_regular", "descriptor_number_unit_conversion", "array_conversion", "array_conversion_integer"]) def test_subtraction(self, descriptor: DescriptorArray, test, expected, raises_warning): # When Then if raises_warning: @@ -466,8 +493,17 @@ def test_subtraction_dimensionless(self, descriptor_dimensionless: DescriptorArr [[-98.0, -197.0], [-296.0, -405.0]], "cm", [[1001.0, 2002.0], [3003.0, 4004.0]]), - False)], - ids=["descriptor_number_regular", "descriptor_number_unit_conversion", "array_conversion"]) + False), + (DescriptorArray("test", + [[2, 3], [4, -5]], + "cm"), + DescriptorArray("test + name", + [[-98.0, -197.0], [-296.0, -405.0]], + "cm", + [[1000.0, 2000.0], [3000.0, 4000.0]]), + False) + ], + ids=["descriptor_number_regular", "descriptor_number_unit_conversion", "array_conversion", "array_conversion_integer"]) def test_reverse_subtraction(self, descriptor: DescriptorArray, test, expected, raises_warning): # When Then if raises_warning: @@ -535,6 +571,15 @@ def test_reverse_subtraction_dimensionless(self, descriptor_dimensionless: Descr "m^2", [[0.00014, 0.00098], [0.00318, 0.0074]]), False), + (DescriptorArray("test", + [[2, 3], [4, -5]], + "cm"), + DescriptorArray("test * name", + [[0.02, 0.06], [0.12, -0.2]], + "m^2", + [[0.1 * 2**2 * 1e-4, 0.2 * 3**2 * 1e-4], + [0.3 * 4**2 * 1e-4, 0.4 * 5**2 * 1e-4]]), + False), ([[2.0, 3.0], [4.0, -5.0]], DescriptorArray("test * name", [[2.0, 6.0], [12.0, -20.0]], @@ -555,6 +600,7 @@ def test_reverse_subtraction_dimensionless(self, descriptor_dimensionless: Descr "descriptor_number_unit_conversion", "descriptor_number_different_units", "array_conversion", + "array_conversion_integer", "list", "number"]) def test_multiplication(self, descriptor: DescriptorArray, test, expected, raises_warning): @@ -624,6 +670,15 @@ def test_multiplication_dimensionless(self, descriptor_dimensionless: Descriptor "cm^2", [[14000.0, 98000.0], [318000.0, 740000.0]]), False), + (DescriptorArray("test", + [[2, 3], [4, -5]], + "cm"), + DescriptorArray("test * name", + [[200.0, 600.0], [1200.0, -2000.0]], + "cm^2", + [[0.1 * 2**2 * 1e4, 0.2 * 3**2 * 1e4], + [0.3 * 4**2 * 1e4, 0.4 * 5**2 * 1e4]]), + False), ([[2.0, 3.0], [4.0, -5.0]], DescriptorArray("test * name", [[2.0, 6.0], [12.0, -20.0]], @@ -644,6 +699,7 @@ def test_multiplication_dimensionless(self, descriptor_dimensionless: Descriptor "descriptor_number_unit_conversion", "descriptor_number_different_units", "array_conversion", + "array_conversion_integer", "list", "number"]) def test_reverse_multiplication(self, descriptor: DescriptorArray, test, expected, raises_warning): @@ -725,6 +781,17 @@ def test_reverse_multiplication_dimensionless(self, descriptor_dimensionless: De [(0.3 + 3.0 * 3.0**2 / 4.0**2) / 4.0**2 * 1e8, (0.4 + 4.0 * 4.0**2 / 5.0**2) / 5.0**2 * 1e8]]), False), + (DescriptorArray("test", + [[2, 3], [4, -5]], + "cm^2"), + DescriptorArray("name / test", + [[1/2 * 1e4, 2/3 * 1e4], [3.0/4.0*1e4, -4.0/5.0 * 1e4]], + "1/m", + [[(0.1) / 2.0**2 * 1e8, + (0.2) / 3.0**2 * 1e8], + [(0.3) / 4.0**2 * 1e8, + (0.4) / 5.0**2 * 1e8]]), + False), ([[2.0, 3.0], [4.0, -5.0]], DescriptorArray("name / name", [[0.5, 2.0/3.0], [3.0/4.0, -4/5]], @@ -744,6 +811,7 @@ def test_reverse_multiplication_dimensionless(self, descriptor_dimensionless: De "descriptor_number_unit_conversion", "descriptor_number_different_units", "array_conversion", + "array_conversion_integer", "list", "number"]) def test_division(self, descriptor: DescriptorArray, test, expected, raises_warning): @@ -829,12 +897,23 @@ def test_division_dimensionless(self, descriptor_dimensionless: DescriptorArray, [( 3.0e-8 + 0.3 * (0.0004)**2 / 3**2 ) / 3**2, ( 4.0e-8 + 0.4 * (0.0005)**2 / 4**2 ) / 4**2]]), False), + (DescriptorArray("test", + [[2, 3], [4, -5]], + "cm^2"), + DescriptorArray("test / name", + [[2e-4, 1.5e-4], [4.0/3.0*1e-4, -1.25e-4]], + "m", + [[(0.1 * 2.0**2 / 1.0**2) / 1.0**2 * 1e-8, + (0.2 * 3.0**2 / 2.0**2) / 2.0**2 * 1e-8], + [(0.3 * 4.0**2 / 3.0**2) / 3.0**2 * 1e-8, + (0.4 * 5.0**2 / 4.0**2) / 4.0**2 * 1e-8]]), + False), ([[2.0, 3.0], [4.0, -5.0]], DescriptorArray("test / name", [[2, 1.5], [4.0/3.0, -1.25]], "1/m", [[0.1 * 2**2 / 1**4, 0.2 * 3.0**2 / 2.0**4], - [0.3 * 4**2 / 3**4, 0.4 * 5.0**2 / 4**4]]), + [0.3 * 4**2 / 3**4, 0.4 * 5.0**2 / 4.0**4]]), False), (2.0, DescriptorArray("test / name", @@ -848,6 +927,7 @@ def test_division_dimensionless(self, descriptor_dimensionless: DescriptorArray, "descriptor_number_unit_conversion", "descriptor_number_different_units", "array_conversion", + "array_conversion_integer", "list", "number"]) def test_reverse_division(self, descriptor: DescriptorArray, test, expected, raises_warning): @@ -1246,3 +1326,18 @@ def test_array_set_dimensions_exception(self, descriptor): with pytest.raises(ValueError) as e: DescriptorArray("test", [[1.]], "m", [[1.]], dimensions=['dim']) assert "Length of dimensions" in str(e) + + def test_array_set_integer_value(self, descriptor): + """ + Scipp does not convert ints to floats, but values need to be floats for optimization. + """ + # When + descriptor.value = [[1, 2], [3, 4]] + # Then Expect + assert isinstance(descriptor.value[0][0], float) + + def test_array_set_integer_variance(self, descriptor): + # When + descriptor.variance = [[1, 2], [3, 4]] + # Then Expect + assert isinstance(descriptor.variance[0][0], float) From ab79e16625b9451a4f917e1742e3a02d6566ec7a Mon Sep 17 00:00:00 2001 From: Eric Lindgren Date: Thu, 6 Mar 2025 16:38:34 +0100 Subject: [PATCH 09/14] add dimensions to dict --- .../Objects/variable/descriptor_array.py | 17 +++++++++++------ .../Objects/variable/test_descriptor_array.py | 10 ++++++++++ 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/easyscience/Objects/variable/descriptor_array.py b/src/easyscience/Objects/variable/descriptor_array.py index 5d9dc92..4338191 100644 --- a/src/easyscience/Objects/variable/descriptor_array.py +++ b/src/easyscience/Objects/variable/descriptor_array.py @@ -32,7 +32,7 @@ def __init__( name: str, value: Union[list, np.ndarray], unit: Optional[Union[str, sc.Unit]] = '', - variance: Optional[numbers.Number] = None, + variance: Optional[Union[list, np.ndarray]] = None, unique_name: Optional[str] = None, description: Optional[str] = None, url: Optional[str] = None, @@ -133,9 +133,9 @@ def full_value(self, full_value: Variable) -> None: @property def value(self) -> numbers.Number: """ - Get the value. This should be usable for most cases. The full value can be obtained from `obj.full_value`. + Get the value without units. The Scipp array can be obtained from `obj.full_value`. - :return: Value of self with unit. + :return: Value of self without unit. """ return self._array.values @@ -169,7 +169,7 @@ def dimensions(self) -> list: return self._dimensions @dimensions.setter - def dimensions(self, dimensions: Union[list, np.ndarray]) -> None: + def dimensions(self, dimensions: Union[list]) -> None: """ Set the dimensions of self. Ensures that the input has a shape compatible with self.full_value. @@ -182,6 +182,10 @@ def dimensions(self, dimensions: Union[list, np.ndarray]) -> None: raise ValueError(f"{dimensions=} must have the same shape as the existing dims") self._dimensions = dimensions + # Also rename the dims of the scipp array + rename_dict = { old_dim: new_dim for (old_dim, new_dim) in zip(self.full_value.dims, dimensions) } + renamed_array = self._array.rename_dims(rename_dict) + self._array = renamed_array @property def unit(self) -> str: @@ -202,9 +206,9 @@ def unit(self, unit_str: str) -> None: ) # noqa: E501 @property - def variance(self) -> float: + def variance(self) -> np.ndarray: """ - Get the variance. + Get the variance as a Numpy ndarray. :return: variance. """ @@ -352,6 +356,7 @@ def as_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]: raw_dict['value'] = self._array.values raw_dict['unit'] = str(self._array.unit) raw_dict['variance'] = self._array.variances + raw_dict['dimensions'] = self._array.dims return raw_dict def _apply_operation(self, diff --git a/tests/unit_tests/Objects/variable/test_descriptor_array.py b/tests/unit_tests/Objects/variable/test_descriptor_array.py index caca0c3..3eeb6cb 100644 --- a/tests/unit_tests/Objects/variable/test_descriptor_array.py +++ b/tests/unit_tests/Objects/variable/test_descriptor_array.py @@ -233,6 +233,7 @@ def test_as_data_dict(self, clear, descriptor: DescriptorArray): "url": "url", "display_name": "display_name", "unique_name": "DescriptorArray_0", + "dimensions": np.array(['dim0', 'dim1']), # Use numpy array for comparison } # Then: Compare dictionaries key by key @@ -1341,3 +1342,12 @@ def test_array_set_integer_variance(self, descriptor): descriptor.variance = [[1, 2], [3, 4]] # Then Expect assert isinstance(descriptor.variance[0][0], float) + + def test_array_set_dims(self, descriptor): + # When + descriptor.dimensions = ['x', 'y'] + # Then Expect + assert descriptor.dimensions[0] == 'x' + assert descriptor.dimensions[1] == 'y' + assert descriptor.full_value.dims[0] == 'x' + assert descriptor.full_value.dims[1] == 'y' From 553c3f23a3d841a753544a4e281714be9cc5ba1a Mon Sep 17 00:00:00 2001 From: Eric Lindgren Date: Thu, 6 Mar 2025 17:29:50 +0100 Subject: [PATCH 10/14] use numpy for trace operation, and handle tensors properly --- .../Objects/variable/descriptor_array.py | 41 +++++++++++++------ .../Objects/variable/test_descriptor_array.py | 6 ++- 2 files changed, 32 insertions(+), 15 deletions(-) diff --git a/src/easyscience/Objects/variable/descriptor_array.py b/src/easyscience/Objects/variable/descriptor_array.py index 4338191..bef57bd 100644 --- a/src/easyscience/Objects/variable/descriptor_array.py +++ b/src/easyscience/Objects/variable/descriptor_array.py @@ -113,7 +113,12 @@ def from_scipp(cls, name: str, full_value: Variable, **kwargs) -> DescriptorArra """ if not isinstance(full_value, Variable): raise TypeError(f'{full_value=} must be a scipp array') - return cls(name=name, value=full_value.values, unit=full_value.unit, variance=full_value.variances, **kwargs) + return cls(name=name, + value=full_value.values, + unit=full_value.unit, + variance=full_value.variances, + dimensions=full_value.dims, + **kwargs) @property def full_value(self) -> Variable: @@ -679,10 +684,12 @@ def __setitem__(self, a, b: Union[numbers.Number, list, DescriptorNumber, Descri new {self.__class__.__name__}.' ) - def trace(self) -> DescriptorNumber: + def trace(self) -> Union[DescriptorArray, DescriptorNumber]: """ Computes the trace over the descriptor array. Only works for matrices where all dimensions are equal. + For a rank `k` tensor, the trace will run over the firs two dimensions, + resulting in a rank `k-2` tensor. """ shape = np.array(self.full_value.shape) N = shape[0] @@ -690,18 +697,26 @@ def trace(self) -> DescriptorNumber: raise ValueError('\ Trace can only be taken over arrays where all dimensions are of equal length') - trace = sc.scalar(0.0, unit=self.unit, variance=None) - for i in range(N): - # Index through all the dims to get - # the value i on the diagonal - diagonal_element = self.full_value - for dim in self.full_value.dims: - diagonal_element = diagonal_element[dim, i] - trace = trace + diagonal_element + trace_value = np.trace(self.value) + trace_variance = np.trace(self.variance) if self.variance is not None else None - descriptor_number = DescriptorNumber.from_scipp(name=self.name, full_value=trace) - descriptor_number.name = descriptor_number.unique_name - return descriptor_number + # The trace reduces a rank k tensor to a k-2. + # Pick out the remaining dims + remaining_dimensions = self.dimensions[2:] + print(remaining_dimensions) + if remaining_dimensions == []: + # No remaining dimensions; the trace is a scalar + trace = sc.scalar(value=trace_value, unit=self.unit, variance=trace_variance) + constructor = DescriptorNumber.from_scipp + else: + # Else, the result is some array + trace = sc.array(dims=remaining_dimensions, values=trace_value, unit=self.unit, variances=trace_variance) + print(trace.dims) + constructor = DescriptorArray.from_scipp + + descriptor = constructor(name=self.name, full_value=trace) + descriptor.name = descriptor.unique_name + return descriptor def sum(self, dim: Optional[Union[str, list]] = None) -> Union[DescriptorArray, DescriptorNumber]: """ diff --git a/tests/unit_tests/Objects/variable/test_descriptor_array.py b/tests/unit_tests/Objects/variable/test_descriptor_array.py index 3eeb6cb..cdec07d 100644 --- a/tests/unit_tests/Objects/variable/test_descriptor_array.py +++ b/tests/unit_tests/Objects/variable/test_descriptor_array.py @@ -1183,19 +1183,21 @@ def test_abs(self, descriptor): (DescriptorArray("test", np.ones((9, 9)), "dimensionless", np.ones((9, 9))), DescriptorNumber("test", 9.0, "dimensionless", 9.0)), (DescriptorArray("test", np.ones((3, 3, 3)), "dimensionless", np.ones((3, 3, 3))), - DescriptorNumber("test", 3.0, "dimensionless", 3.0)), + DescriptorArray("test", [3., 3., 3.], "dimensionless", [3., 3., 3.,], dimensions=['dim2'])), (DescriptorArray("test", [[2.0]], "dimensionless"), DescriptorNumber("test", 2.0, "dimensionless")) ], ids=["2d_unit", "2d_dimensionless", "2d_large", "3d_dimensionless", "1d_dimensionless"]) def test_trace(self, test: DescriptorArray, expected: DescriptorNumber): result = test.trace() - assert type(result) == DescriptorNumber + assert type(result) == type(expected) assert result.name == result.unique_name assert np.array_equal(result.value, expected.value) assert result.unit == expected.unit if test.variance is not None: assert np.allclose(result.variance, expected.variance) + if isinstance(expected, DescriptorArray): + assert np.all(result.full_value.dims == expected.full_value.dims) @pytest.mark.parametrize("test", [ DescriptorArray("test + name", From f6fe4af66bc36d59da978b0ad73aebe416055d86 Mon Sep 17 00:00:00 2001 From: Eric Lindgren Date: Thu, 6 Mar 2025 17:30:25 +0100 Subject: [PATCH 11/14] fixup! remove prints --- src/easyscience/Objects/variable/descriptor_array.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/easyscience/Objects/variable/descriptor_array.py b/src/easyscience/Objects/variable/descriptor_array.py index bef57bd..4223e65 100644 --- a/src/easyscience/Objects/variable/descriptor_array.py +++ b/src/easyscience/Objects/variable/descriptor_array.py @@ -703,7 +703,6 @@ def trace(self) -> Union[DescriptorArray, DescriptorNumber]: # The trace reduces a rank k tensor to a k-2. # Pick out the remaining dims remaining_dimensions = self.dimensions[2:] - print(remaining_dimensions) if remaining_dimensions == []: # No remaining dimensions; the trace is a scalar trace = sc.scalar(value=trace_value, unit=self.unit, variance=trace_variance) @@ -711,7 +710,6 @@ def trace(self) -> Union[DescriptorArray, DescriptorNumber]: else: # Else, the result is some array trace = sc.array(dims=remaining_dimensions, values=trace_value, unit=self.unit, variances=trace_variance) - print(trace.dims) constructor = DescriptorArray.from_scipp descriptor = constructor(name=self.name, full_value=trace) From 14e7e1ef23af75bd1c7f1aa82283c24e5ea79cab Mon Sep 17 00:00:00 2001 From: Eric Lindgren Date: Fri, 7 Mar 2025 10:39:00 +0100 Subject: [PATCH 12/14] convert input to floats --- src/easyscience/Objects/variable/descriptor_array.py | 7 ++++++- .../unit_tests/Objects/variable/test_descriptor_array.py | 9 +++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/easyscience/Objects/variable/descriptor_array.py b/src/easyscience/Objects/variable/descriptor_array.py index 4223e65..477318a 100644 --- a/src/easyscience/Objects/variable/descriptor_array.py +++ b/src/easyscience/Objects/variable/descriptor_array.py @@ -58,6 +58,7 @@ def __init__( raise TypeError(f"{value=} must be a list or numpy array.") if isinstance(value, list): value = np.array(value) # Convert to numpy array for consistent handling. + value = np.astype(value, 'float') if variance is not None: if not isinstance(variance, (list, np.ndarray)): @@ -68,6 +69,7 @@ def __init__( raise ValueError(f"{variance=} must have the same shape as {value=}.") if not np.all(variance >= 0): raise ValueError(f"{variance=} must only contain non-negative values.") + variance = np.astype(variance, 'float') if not isinstance(unit, sc.Unit) and not isinstance(unit, str): raise TypeError(f'{unit=} must be a scipp unit or a string representing a valid scipp unit') @@ -83,7 +85,10 @@ def __init__( try: # Convert value and variance to floats # for optimization everything must be floats - self._array = sc.array(dims=dimensions, values=value, unit=unit, variances=variance).astype('float') + self._array = sc.array(dims=dimensions, + values=value, + unit=unit, + variances=variance) except Exception as message: raise UnitError(message) # TODO: handle 1xn and nx1 arrays diff --git a/tests/unit_tests/Objects/variable/test_descriptor_array.py b/tests/unit_tests/Objects/variable/test_descriptor_array.py index cdec07d..d64d6da 100644 --- a/tests/unit_tests/Objects/variable/test_descriptor_array.py +++ b/tests/unit_tests/Objects/variable/test_descriptor_array.py @@ -1345,6 +1345,15 @@ def test_array_set_integer_variance(self, descriptor): # Then Expect assert isinstance(descriptor.variance[0][0], float) + def test_array_create_with_mixed_integers_and_floats(self): + # When + value = [[1, 2], [3, 4]] + variance = [[0.1, 0.2], [0.3, 0.4]] + # Then Expect + descriptor = DescriptorArray('test', value, 'dimensionless', variance) # Should not raise + assert isinstance(descriptor.value[0][0], float) + assert isinstance(descriptor.variance[0][0], float) + def test_array_set_dims(self, descriptor): # When descriptor.dimensions = ['x', 'y'] From 2145935245f9d0bdff320d6503c68f1741ac5796 Mon Sep 17 00:00:00 2001 From: Eric Lindgren Date: Fri, 7 Mar 2025 11:06:36 +0100 Subject: [PATCH 13/14] allow trace to take dimensions --- .../Objects/variable/descriptor_array.py | 44 ++++++++++++------ .../Objects/variable/test_descriptor_array.py | 46 +++++++++++++------ 2 files changed, 61 insertions(+), 29 deletions(-) diff --git a/src/easyscience/Objects/variable/descriptor_array.py b/src/easyscience/Objects/variable/descriptor_array.py index 477318a..c5aa297 100644 --- a/src/easyscience/Objects/variable/descriptor_array.py +++ b/src/easyscience/Objects/variable/descriptor_array.py @@ -689,25 +689,39 @@ def __setitem__(self, a, b: Union[numbers.Number, list, DescriptorNumber, Descri new {self.__class__.__name__}.' ) - def trace(self) -> Union[DescriptorArray, DescriptorNumber]: + def trace(self, + dimension1: Optional[str] = None, + dimension2: Optional[str]= None) -> Union[DescriptorArray, DescriptorNumber]: """ - Computes the trace over the descriptor array. - Only works for matrices where all dimensions are equal. - For a rank `k` tensor, the trace will run over the firs two dimensions, - resulting in a rank `k-2` tensor. - """ - shape = np.array(self.full_value.shape) - N = shape[0] - if not np.all(shape == N): - raise ValueError('\ - Trace can only be taken over arrays where all dimensions are of equal length') + Computes the trace over the descriptor array. The submatrix defined `dimension1` and `dimension2` must be square. + For a rank `k` tensor, the trace will run over the firs two dimensions, resulting in a rank `k-2` tensor. - trace_value = np.trace(self.value) - trace_variance = np.trace(self.variance) if self.variance is not None else None + :param dimension1, dimension2: First and second dimension to perform trace over. Must be in `self.dimensions`. + If not defined, the trace will be taken over the first two dimensions. + """ + if (dimension1 is not None and dimension2 is None) or (dimension1 is None and dimension2 is not None): + raise ValueError('Either both or none of `dimension1` and `dimension2` must be set.') + + if dimension1 is not None and dimension2 is not None: + if dimension1 == dimension2: + raise ValueError(f'`{dimension1=}` and `{dimension2=}` must be different.') + + axes = [] + for dim in (dimension1, dimension2): + if dim not in self.dimensions: + raise ValueError(f'Dimension {dim=} does not exist in the ') + index = self.dimensions.index(dim) + axes.append(index) + remaining_dimensions = [dim for dim in self.dimensions if dim not in (dimension1, dimension2)] + else: + # Take the first two dimensions + axes = (0, 1) + # Pick out the remaining dims + remaining_dimensions = self.dimensions[2:] + trace_value = np.trace(self.value, axis1=axes[0], axis2=axes[1]) + trace_variance = np.trace(self.variance, axis1=axes[0], axis2=axes[1]) if self.variance is not None else None # The trace reduces a rank k tensor to a k-2. - # Pick out the remaining dims - remaining_dimensions = self.dimensions[2:] if remaining_dimensions == []: # No remaining dimensions; the trace is a scalar trace = sc.scalar(value=trace_value, unit=self.unit, variance=trace_variance) diff --git a/tests/unit_tests/Objects/variable/test_descriptor_array.py b/tests/unit_tests/Objects/variable/test_descriptor_array.py index d64d6da..2708f4e 100644 --- a/tests/unit_tests/Objects/variable/test_descriptor_array.py +++ b/tests/unit_tests/Objects/variable/test_descriptor_array.py @@ -1199,22 +1199,40 @@ def test_trace(self, test: DescriptorArray, expected: DescriptorNumber): if isinstance(expected, DescriptorArray): assert np.all(result.full_value.dims == expected.full_value.dims) - @pytest.mark.parametrize("test", [ - DescriptorArray("test + name", - [[3.0, 4.0]], - "m", - [[0.11, 0.21]]), - - DescriptorArray("test + name", - [[3.0, 4.0], [1.0, 1.0], [1.0, 1.0]], - "dimensionless", - [[0.11, 0.21], [1., 1.], [1., 1.]]) + @pytest.mark.parametrize("test, expected, dimensions", [ + (DescriptorArray("test", np.ones((3, 3, 4, 5)), "dimensionless", np.ones((3, 3, 4, 5))), + DescriptorArray("test", 3*np.ones((3, 4)), "dimensionless", 3*np.ones((3, 4)), dimensions=['dim0', 'dim2']), + ('dim1', 'dim3')) + ], + ids=["4d"]) + def test_trace_select_dimensions(self, test: DescriptorArray, expected: DescriptorNumber, dimensions): + result = test.trace(dimension1=dimensions[0], dimension2=dimensions[1]) + assert type(result) == type(expected) + assert result.name == result.unique_name + assert np.array_equal(result.value.shape, expected.value.shape) + assert np.array_equal(result.value, expected.value) + assert result.unit == expected.unit + assert np.all(result.full_value.dims == expected.full_value.dims) + + @pytest.mark.parametrize("test,dimensions,message", [ + (DescriptorArray("test", np.ones((3, 3, 3)), "dimensionless", np.ones((3, 3, 3))), + ('dim0', None), + "Either both or none" + ), + (DescriptorArray("test", np.ones((3, 3, 3)), "dimensionless", np.ones((3, 3, 3))), + ('dim0', 'dim0'), + "must be different" + ), + (DescriptorArray("test", np.ones((3, 3, 3)), "dimensionless", np.ones((3, 3, 3))), + ('dim0', 'dim1337'), + "does not exist" + ), ], - ids=["2x1_unit", "3x2_dimensionless"]) - def test_trace_exception(self, test: DescriptorArray): + ids=["one_defined_dimension", "same_dimension", "invalid_dimension"]) + def test_trace_exception(self, test: DescriptorArray, dimensions, message): with pytest.raises(ValueError) as e: - test.trace() - assert "Trace can only be taken" in str(e) + test.trace(dimension1=dimensions[0], dimension2=dimensions[1]) + assert message in str(e) def test_slicing(self, descriptor: DescriptorArray): # When From 4201565197eddfc5e631a713ce5471862ecc7a3d Mon Sep 17 00:00:00 2001 From: Eric Lindgren Date: Fri, 7 Mar 2025 13:49:37 +0100 Subject: [PATCH 14/14] finish string --- src/easyscience/Objects/variable/descriptor_array.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/easyscience/Objects/variable/descriptor_array.py b/src/easyscience/Objects/variable/descriptor_array.py index c5aa297..c9b154e 100644 --- a/src/easyscience/Objects/variable/descriptor_array.py +++ b/src/easyscience/Objects/variable/descriptor_array.py @@ -709,7 +709,7 @@ def trace(self, axes = [] for dim in (dimension1, dimension2): if dim not in self.dimensions: - raise ValueError(f'Dimension {dim=} does not exist in the ') + raise ValueError(f'Dimension {dim=} does not exist in `self.dimensions`.') index = self.dimensions.index(dim) axes.append(index) remaining_dimensions = [dim for dim in self.dimensions if dim not in (dimension1, dimension2)]