diff --git a/src/easyscience/variable/parameter.py b/src/easyscience/variable/parameter.py index ca007327..6ea8d1a6 100644 --- a/src/easyscience/variable/parameter.py +++ b/src/easyscience/variable/parameter.py @@ -121,7 +121,12 @@ def __init__( @classmethod def from_dependency( - cls, name: str, dependency_expression: str, dependency_map: Optional[dict] = None, **kwargs + cls, + name: str, + dependency_expression: str, + dependency_map: Optional[dict] = None, + desired_unit: str | sc.Unit | None = None, + **kwargs, ) -> Parameter: # noqa: E501 """ Create a dependent Parameter directly from a dependency expression. @@ -129,15 +134,20 @@ def from_dependency( :param name: The name of the parameter :param dependency_expression: The dependency expression to evaluate. This should be a string which can be evaluated by the ASTEval interpreter. :param dependency_map: A dictionary of dependency expression symbol name and dependency object pairs. This is inserted into the asteval interpreter to resolve dependencies. + :param desired_unit: The desired unit of the dependent parameter. :param kwargs: Additional keyword arguments to pass to the Parameter constructor. :return: A new dependent Parameter object. """ # noqa: E501 # Set default values for required parameters for the constructor, they get overwritten by the dependency anyways - default_kwargs = {'value': 0.0, 'unit': '', 'variance': 0.0, 'min': -np.inf, 'max': np.inf} + default_kwargs = {'value': 0.0, 'variance': 0.0, 'min': -np.inf, 'max': np.inf} # Update with user-provided kwargs, to avoid errors. default_kwargs.update(kwargs) parameter = cls(name=name, **default_kwargs) - parameter.make_dependent_on(dependency_expression=dependency_expression, dependency_map=dependency_map) + parameter.make_dependent_on( + dependency_expression=dependency_expression, + dependency_map=dependency_map, + desired_unit=desired_unit, + ) return parameter def _update(self) -> None: @@ -158,11 +168,20 @@ def _update(self) -> None: ) # noqa: E501 self._min.unit = temporary_parameter.unit self._max.unit = temporary_parameter.unit + + if self._desired_unit is not None: + self._convert_unit(self._desired_unit) + self._notify_observers() else: warnings.warn('This parameter is not dependent. It cannot be updated.') - def make_dependent_on(self, dependency_expression: str, dependency_map: Optional[dict] = None) -> None: + def make_dependent_on( + self, + dependency_expression: str, + dependency_map: Optional[dict] = None, + desired_unit: str | sc.Unit | None = None, + ) -> None: """ Make this parameter dependent on another parameter. This will overwrite the current value, unit, variance, min and max. @@ -183,6 +202,9 @@ def make_dependent_on(self, dependency_expression: str, dependency_map: Optional A dictionary of dependency expression symbol name and dependency object pairs. This is inserted into the asteval interpreter to resolve dependencies. + :param desired_unit: + The desired unit of the dependent parameter. If None, the default unit of the dependency expression result is used. + """ # noqa: E501 if not isinstance(dependency_expression, str): raise TypeError('`dependency_expression` must be a string representing a valid dependency expression.') @@ -212,6 +234,7 @@ def make_dependent_on(self, dependency_expression: str, dependency_map: Optional '_dependency_map': self._dependency_map, '_dependency_interpreter': self._dependency_interpreter, '_clean_dependency_string': self._clean_dependency_string, + '_desired_unit': self._desired_unit, } for dependency in self._dependency_map.values(): dependency._detach_observer(self) @@ -219,6 +242,9 @@ def make_dependent_on(self, dependency_expression: str, dependency_map: Optional self._independent = False self._dependency_string = dependency_expression self._dependency_map = dependency_map if dependency_map is not None else {} + if desired_unit is not None and not (isinstance(desired_unit, str) or isinstance(desired_unit, sc.Unit)): + raise TypeError('`desired_unit` must be a string representing a valid unit.') + self._desired_unit = desired_unit # List of allowed python constructs for the asteval interpreter asteval_config = { 'import': False, @@ -289,6 +315,17 @@ def make_dependent_on(self, dependency_expression: str, dependency_map: Optional raise error # Update the parameter with the dependency result self._fixed = False + + if self._desired_unit is not None: + try: + dependency_result._convert_unit(self._desired_unit) + except Exception as e: + desired_unit_for_error_message = self._desired_unit + self._revert_dependency() # also deletes self._desired_unit + raise UnitError( + f'Failed to convert unit from {dependency_result.unit} to {desired_unit_for_error_message}: {e}' + ) + self._update() def make_independent(self) -> None: @@ -306,6 +343,7 @@ def make_independent(self) -> None: del self._dependency_interpreter del self._dependency_string del self._clean_dependency_string + del self._desired_unit else: raise AttributeError('This parameter is already independent.') @@ -470,6 +508,28 @@ def convert_unit(self, unit_str: str) -> None: """ self._convert_unit(unit_str) + def set_desired_unit(self, unit_str: str | sc.Unit | None) -> None: + """ + Set the desired unit for a dependent Parameter. This will convert the parameter to the desired unit. + + :param unit_str: The desired unit as a string. + """ + + if self._independent: + raise AttributeError('This is an independent parameter, desired unit can only be set for dependent parameters.') + if not (isinstance(unit_str, str) or isinstance(unit_str, sc.Unit) or unit_str is None): + raise TypeError('`unit_str` must be a string representing a valid unit.') + + if unit_str is not None: + try: + old_unit_for_message = self.unit + self._convert_unit(unit_str) + except Exception as e: + raise UnitError(f'Failed to convert unit from {old_unit_for_message} to {unit_str}: {e}') + + self._desired_unit = unit_str + self._update() + @property def min(self) -> numbers.Number: """ @@ -580,6 +640,9 @@ def as_dict(self, skip: Optional[List[str]] = None) -> Dict[str, Any]: # Save the dependency expression raw_dict['_dependency_string'] = self._clean_dependency_string + if self._desired_unit is not None: + raw_dict['_desired_unit'] = self._desired_unit + # Mark that this parameter is dependent raw_dict['_independent'] = self._independent @@ -648,6 +711,7 @@ def from_dict(cls, obj_dict: dict) -> 'Parameter': dependency_string = raw_dict.pop('_dependency_string', None) dependency_map_serializer_ids = raw_dict.pop('_dependency_map_serializer_ids', None) is_independent = raw_dict.pop('_independent', True) + desired_unit = raw_dict.pop('_desired_unit', None) # Note: Keep _serializer_id in the dict so it gets passed to __init__ # Create the parameter using the base class method (serializer_id is now handled in __init__) @@ -659,6 +723,7 @@ def from_dict(cls, obj_dict: dict) -> 'Parameter': param._pending_dependency_map_serializer_ids = dependency_map_serializer_ids # Keep parameter as independent initially - will be made dependent after all objects are loaded param._independent = True + param._pending_desired_unit = desired_unit return param @@ -874,7 +939,12 @@ def __truediv__(self, other: Union[DescriptorNumber, Parameter, numbers.Number]) elif self.max <= 0: combinations = [self.max / other.min, np.inf] else: - combinations = [self.min / other.min, self.max / other.max, self.min / other.max, self.max / other.min] + combinations = [ + self.min / other.min, + self.max / other.max, + self.min / other.max, + self.max / other.min, + ] else: combinations = [self.min / other.value, self.max / other.value] else: @@ -1017,13 +1087,18 @@ def resolve_pending_dependencies(self) -> None: # Establish the dependency relationship try: - self.make_dependent_on(dependency_expression=dependency_string, dependency_map=dependency_map) + self.make_dependent_on( + dependency_expression=dependency_string, + dependency_map=dependency_map, + desired_unit=self._pending_desired_unit, + ) except Exception as e: raise ValueError(f"Error establishing dependency '{dependency_string}': {e}") # Clean up temporary attributes delattr(self, '_pending_dependency_string') delattr(self, '_pending_dependency_map_serializer_ids') + delattr(self, '_pending_desired_unit') def _find_parameter_by_serializer_id(self, serializer_id: str) -> Optional['DescriptorNumber']: """Find a parameter by its serializer_id from all parameters in the global map.""" diff --git a/tests/unit_tests/variable/test_parameter.py b/tests/unit_tests/variable/test_parameter.py index d3f28ea3..f2b756eb 100644 --- a/tests/unit_tests/variable/test_parameter.py +++ b/tests/unit_tests/variable/test_parameter.py @@ -10,6 +10,7 @@ from easyscience import global_object from easyscience import ObjBase + class TestParameter: @pytest.fixture def parameter(self) -> Parameter: @@ -28,7 +29,7 @@ def parameter(self) -> Parameter: parent=None, ) return parameter - + @pytest.fixture def normal_parameter(self) -> Parameter: parameter = Parameter( @@ -75,7 +76,7 @@ def test_init(self, parameter: Parameter): assert parameter._observers == [] def test_init_value_min_exception(self): - # When + # When mock_callback = MagicMock() value = -1 @@ -96,7 +97,7 @@ def test_init_value_min_exception(self): ) def test_init_value_max_exception(self): - # When + # When mock_callback = MagicMock() value = 100 @@ -118,162 +119,421 @@ def test_init_value_max_exception(self): def test_make_dependent_on(self, normal_parameter: Parameter): # When - independent_parameter = Parameter(name="independent", value=1, unit="m", variance=0.01, min=0, max=10) - + independent_parameter = Parameter( + name="independent", value=1, unit="m", variance=0.01, min=0, max=10 + ) + # Then - normal_parameter.make_dependent_on(dependency_expression='2*a', dependency_map={'a': independent_parameter}) + normal_parameter.make_dependent_on( + dependency_expression="2*a", dependency_map={"a": independent_parameter} + ) # Expect assert normal_parameter._independent == False - assert normal_parameter.dependency_expression == '2*a' - assert normal_parameter.dependency_map == {'a': independent_parameter} - self.compare_parameters(normal_parameter, 2*independent_parameter) + assert normal_parameter.dependency_expression == "2*a" + assert normal_parameter.dependency_map == {"a": independent_parameter} + self.compare_parameters(normal_parameter, 2 * independent_parameter) # Then independent_parameter.value = 2 # Expect normal_parameter.value == 4 - self.compare_parameters(normal_parameter, 2*independent_parameter) + self.compare_parameters(normal_parameter, 2 * independent_parameter) + + def test_dependent_parameter_make_dependent_on_with_desired_unit( + self, normal_parameter: Parameter + ): + # When + independent_parameter = Parameter( + name="independent", value=1, unit="m", variance=0.01, min=0, max=10 + ) + + # Then + normal_parameter.make_dependent_on( + dependency_expression="2*a", + dependency_map={"a": independent_parameter}, + desired_unit="cm", + ) + + # Expect + assert normal_parameter._independent == False + assert normal_parameter.dependency_expression == "2*a" + assert normal_parameter.dependency_map == {"a": independent_parameter} + + assert normal_parameter.value == 200 * independent_parameter.value + assert normal_parameter.unit == "cm" + assert ( + normal_parameter.variance == independent_parameter.variance * 4 * 10000 + ) # unit conversion from m to cm squared + assert normal_parameter.min == 200 * independent_parameter.min + assert normal_parameter.max == 200 * independent_parameter.max + assert normal_parameter._min.unit == "cm" + assert normal_parameter._max.unit == "cm" + + # Then + independent_parameter.value = 2 + + # Expect + assert normal_parameter.value == 200 * independent_parameter.value + assert normal_parameter.unit == "cm" + assert ( + normal_parameter.variance == independent_parameter.variance * 4 * 10000 + ) # unit conversion from m to cm squared + assert normal_parameter.min == 200 * independent_parameter.min + assert normal_parameter.max == 200 * independent_parameter.max + assert normal_parameter._min.unit == "cm" + assert normal_parameter._max.unit == "cm" + + # Then # Change the dependency expression and unit again + normal_parameter.make_dependent_on( + dependency_expression="3*a", + dependency_map={"a": independent_parameter}, + desired_unit="mm", + ) + + # Expect + assert normal_parameter._independent == False + assert normal_parameter.dependency_expression == "3*a" + assert normal_parameter.dependency_map == {"a": independent_parameter} + + assert normal_parameter.value == 3000 * independent_parameter.value + assert normal_parameter.unit == "mm" + assert ( + normal_parameter.variance == independent_parameter.variance * 9 * 1000000 + ) # unit conversion from m to mm squared + assert normal_parameter.min == 3000 * independent_parameter.min + assert normal_parameter.max == 3000 * independent_parameter.max + assert normal_parameter._min.unit == "mm" + assert normal_parameter._max.unit == "mm" + + # Then + independent_parameter.value = 2 + + # Expect + assert normal_parameter.value == 3000 * independent_parameter.value + assert normal_parameter.unit == "mm" + assert ( + normal_parameter.variance == independent_parameter.variance * 9 * 1000000 + ) # unit conversion from m to mm squared + assert normal_parameter.min == 3000 * independent_parameter.min + assert normal_parameter.max == 3000 * independent_parameter.max + assert normal_parameter._min.unit == "mm" + assert normal_parameter._max.unit == "mm" + + def test_dependent_parameter_make_dependent_on_with_desired_unit_incompatible_unit_raises( + self, normal_parameter: Parameter + ): + # When + independent_parameter = Parameter( + name="independent", value=1, unit="m", variance=0.01, min=0, max=10 + ) + + # Then Expect + with pytest.raises(UnitError, match="Failed to convert unit from m to s."): + normal_parameter.make_dependent_on( + dependency_expression="2*a", + dependency_map={"a": independent_parameter}, + desired_unit="s", + ) + + def test_dependent_parameter_make_dependent_on_with_incorrect_unit_raises( + self, normal_parameter: Parameter + ): + # When + independent_parameter = Parameter( + name="independent", value=1, unit="m", variance=0.01, min=0, max=10 + ) + + # Then Expect + with pytest.raises( + TypeError, match="`desired_unit` must be a string representing a valid unit" + ): + normal_parameter.make_dependent_on( + dependency_expression="2*a", + dependency_map={"a": independent_parameter}, + desired_unit=123, + ) def test_parameter_from_dependency(self, normal_parameter: Parameter): # When Then dependent_parameter = Parameter.from_dependency( - name = 'dependent', - dependency_expression='2*a', - dependency_map={'a': normal_parameter}, - display_name='display_name', + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, + display_name="display_name", ) # Expect assert dependent_parameter._independent == False - assert dependent_parameter.dependency_expression == '2*a' - assert dependent_parameter.dependency_map == {'a': normal_parameter} - assert dependent_parameter.name == 'dependent' - assert dependent_parameter.display_name == 'display_name' - self.compare_parameters(dependent_parameter, 2*normal_parameter) + assert dependent_parameter.dependency_expression == "2*a" + assert dependent_parameter.dependency_map == {"a": normal_parameter} + assert dependent_parameter.name == "dependent" + assert dependent_parameter.display_name == "display_name" + self.compare_parameters(dependent_parameter, 2 * normal_parameter) # Then normal_parameter.value = 2 # Expect - self.compare_parameters(dependent_parameter, 2*normal_parameter) + self.compare_parameters(dependent_parameter, 2 * normal_parameter) - def test_dependent_parameter_with_unique_name(self, clear, normal_parameter: Parameter): + def test_parameter_from_dependency_with_desired_unit( + self, normal_parameter: Parameter + ): # When Then dependent_parameter = Parameter.from_dependency( - name = 'dependent', + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, + display_name="display_name", + desired_unit="cm", + ) + + # Expect + assert dependent_parameter._independent == False + assert dependent_parameter.dependency_expression == "2*a" + assert dependent_parameter.dependency_map == {"a": normal_parameter} + assert dependent_parameter.name == "dependent" + assert dependent_parameter.display_name == "display_name" + + assert dependent_parameter.value == 200 * normal_parameter.value + assert dependent_parameter.unit == "cm" + assert ( + dependent_parameter.variance == normal_parameter.variance * 4 * 10000 + ) # unit conversion from m to cm squared + assert dependent_parameter.min == 200 * normal_parameter.min + assert dependent_parameter.max == 200 * normal_parameter.max + assert dependent_parameter._min.unit == "cm" + assert dependent_parameter._max.unit == "cm" + + # Then + normal_parameter.value = 2 + + # Expect + assert dependent_parameter.value == 200 * normal_parameter.value + assert dependent_parameter.unit == "cm" + assert ( + dependent_parameter.variance == normal_parameter.variance * 4 * 10000 + ) # unit conversion from m to cm squared + assert dependent_parameter.min == 200 * normal_parameter.min + assert dependent_parameter.max == 200 * normal_parameter.max + assert dependent_parameter._min.unit == "cm" + assert dependent_parameter._max.unit == "cm" + + def test_parameter_from_dependency_with_desired_unit_incompatible_unit_raises( + self, normal_parameter: Parameter + ): + # When Then Expect + with pytest.raises(UnitError): + dependent_parameter = Parameter.from_dependency( + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, + display_name="display_name", + desired_unit="s", + ) + + def test_dependent_parameter_with_unique_name( + self, clear, normal_parameter: Parameter + ): + # When Then + dependent_parameter = Parameter.from_dependency( + name="dependent", dependency_expression='2*"Parameter_0"', ) # Expect assert dependent_parameter.dependency_expression == '2*"Parameter_0"' - assert dependent_parameter.dependency_map == {'__Parameter_0__': normal_parameter} - self.compare_parameters(dependent_parameter, 2*normal_parameter) + assert dependent_parameter.dependency_map == { + "__Parameter_0__": normal_parameter + } + self.compare_parameters(dependent_parameter, 2 * normal_parameter) # Then normal_parameter.value = 2 # Expect - self.compare_parameters(dependent_parameter, 2*normal_parameter) + self.compare_parameters(dependent_parameter, 2 * normal_parameter) - def test_process_dependency_unique_names_double_quotes(self, clear, normal_parameter: Parameter): + def test_process_dependency_unique_names_double_quotes( + self, clear, normal_parameter: Parameter + ): # When - independent_parameter = Parameter(name="independent", value=1, unit="m", variance=0.01, min=0, max=10, unique_name='Special_name') + independent_parameter = Parameter( + name="independent", + value=1, + unit="m", + variance=0.01, + min=0, + max=10, + unique_name="Special_name", + ) normal_parameter._dependency_map = {} # Then - normal_parameter._process_dependency_unique_names(dependency_expression='2*"Special_name"') + normal_parameter._process_dependency_unique_names( + dependency_expression='2*"Special_name"' + ) # Expect - assert normal_parameter._dependency_map == {'__Special_name__': independent_parameter} - assert normal_parameter._clean_dependency_string == '2*__Special_name__' + assert normal_parameter._dependency_map == { + "__Special_name__": independent_parameter + } + assert normal_parameter._clean_dependency_string == "2*__Special_name__" - def test_process_dependency_unique_names_single_quotes(self, clear, normal_parameter: Parameter): + def test_process_dependency_unique_names_single_quotes( + self, clear, normal_parameter: Parameter + ): # When - independent_parameter = Parameter(name="independent", value=1, unit="m", variance=0.01, min=0, max=10, unique_name='Special_name') - independent_parameter_2 = Parameter(name="independent_2", value=1, unit="m", variance=0.01, min=0, max=10, unique_name='Special_name_2') + independent_parameter = Parameter( + name="independent", + value=1, + unit="m", + variance=0.01, + min=0, + max=10, + unique_name="Special_name", + ) + independent_parameter_2 = Parameter( + name="independent_2", + value=1, + unit="m", + variance=0.01, + min=0, + max=10, + unique_name="Special_name_2", + ) normal_parameter._dependency_map = {} # Then - normal_parameter._process_dependency_unique_names(dependency_expression="'Special_name' + 'Special_name_2'") + normal_parameter._process_dependency_unique_names( + dependency_expression="'Special_name' + 'Special_name_2'" + ) # Expect - assert normal_parameter._dependency_map == {'__Special_name__': independent_parameter, - '__Special_name_2__': independent_parameter_2} - assert normal_parameter._clean_dependency_string == '__Special_name__ + __Special_name_2__' + assert normal_parameter._dependency_map == { + "__Special_name__": independent_parameter, + "__Special_name_2__": independent_parameter_2, + } + assert ( + normal_parameter._clean_dependency_string + == "__Special_name__ + __Special_name_2__" + ) - def test_process_dependency_unique_names_exception_unique_name_does_not_exist(self, clear, normal_parameter: Parameter): + def test_process_dependency_unique_names_exception_unique_name_does_not_exist( + self, clear, normal_parameter: Parameter + ): # When normal_parameter._dependency_map = {} # Then Expect - with pytest.raises(ValueError, match='A Parameter with unique_name Special_name does not exist. Please check your dependency expression.'): - normal_parameter._process_dependency_unique_names(dependency_expression='2*"Special_name"') + with pytest.raises( + ValueError, + match="A Parameter with unique_name Special_name does not exist. Please check your dependency expression.", + ): + normal_parameter._process_dependency_unique_names( + dependency_expression='2*"Special_name"' + ) - def test_process_dependency_unique_names_exception_not_a_descriptorNumber(self, clear, normal_parameter: Parameter): + def test_process_dependency_unique_names_exception_not_a_descriptorNumber( + self, clear, normal_parameter: Parameter + ): # When normal_parameter._dependency_map = {} - base_obj = ObjBase(name='ObjBase', unique_name='base_obj') + base_obj = ObjBase(name="ObjBase", unique_name="base_obj") # Then Expect - with pytest.raises(ValueError, match='The object with unique_name base_obj is not a Parameter or DescriptorNumber. Please check your dependency expression.'): - normal_parameter._process_dependency_unique_names(dependency_expression='2*"base_obj"') - - @pytest.mark.parametrize("dependency_expression, dependency_map", [ - (2, {'a': Parameter(name='a', value=1)}), - ('2*a', ['a', Parameter(name='a', value=1)]), - ('2*a', {4: Parameter(name='a', value=1)}), - ('2*a', {'a': ObjBase(name='a')}), - ], ids=["dependency_expression_not_a_string", "dependency_map_not_a_dict", "dependency_map_keys_not_strings", "dependency_map_values_not_descriptor_number"]) - def test_parameter_from_dependency_input_exceptions(self, dependency_expression, dependency_map): + with pytest.raises( + ValueError, + match="The object with unique_name base_obj is not a Parameter or DescriptorNumber. Please check your dependency expression.", + ): + normal_parameter._process_dependency_unique_names( + dependency_expression='2*"base_obj"' + ) + + @pytest.mark.parametrize( + "dependency_expression, dependency_map", + [ + (2, {"a": Parameter(name="a", value=1)}), + ("2*a", ["a", Parameter(name="a", value=1)]), + ("2*a", {4: Parameter(name="a", value=1)}), + ("2*a", {"a": ObjBase(name="a")}), + ], + ids=[ + "dependency_expression_not_a_string", + "dependency_map_not_a_dict", + "dependency_map_keys_not_strings", + "dependency_map_values_not_descriptor_number", + ], + ) + def test_parameter_from_dependency_input_exceptions( + self, dependency_expression, dependency_map + ): # When Then Expect with pytest.raises(TypeError): Parameter.from_dependency( - name = 'dependent', - dependency_expression=dependency_expression, + name="dependent", + dependency_expression=dependency_expression, dependency_map=dependency_map, ) - @pytest.mark.parametrize("dependency_expression, error", [ - ('2*a + b', NameError), - ('2*a + 3*', SyntaxError), - ('2 + 2', TypeError), - ('2*"special_name"', ValueError), - ], ids=["parameter_not_in_map", "invalid_dependency_expression", "result_not_a_descriptor_number", "unique_name_does_not_exist"]) - def test_parameter_make_dependent_on_exceptions_cleanup_previously_dependent(self, normal_parameter, dependency_expression, error): - # When - independent_parameter = Parameter(name='independent', value=10, unit='s', variance=0.02) + @pytest.mark.parametrize( + "dependency_expression, error", + [ + ("2*a + b", NameError), + ("2*a + 3*", SyntaxError), + ("2 + 2", TypeError), + ('2*"special_name"', ValueError), + ], + ids=[ + "parameter_not_in_map", + "invalid_dependency_expression", + "result_not_a_descriptor_number", + "unique_name_does_not_exist", + ], + ) + def test_parameter_make_dependent_on_exceptions_cleanup_previously_dependent( + self, normal_parameter, dependency_expression, error + ): + # When + independent_parameter = Parameter( + name="independent", value=10, unit="s", variance=0.02 + ) dependent_parameter = Parameter.from_dependency( - name= 'dependent', - dependency_expression='best', - dependency_map={'best': independent_parameter} - ) + name="dependent", + dependency_expression="best", + dependency_map={"best": independent_parameter}, + ) # Then Expect # Check that the correct error is raised with pytest.raises(error): dependent_parameter.make_dependent_on( - dependency_expression=dependency_expression, - dependency_map={'a': normal_parameter}, - ) + dependency_expression=dependency_expression, + dependency_map={"a": normal_parameter}, + ) # Check that everything is properly cleaned up assert normal_parameter._observers == [] assert dependent_parameter.independent == False - assert dependent_parameter.dependency_expression == 'best' - assert dependent_parameter.dependency_map == {'best': independent_parameter} + assert dependent_parameter.dependency_expression == "best" + assert dependent_parameter.dependency_map == {"best": independent_parameter} independent_parameter.value = 50 self.compare_parameters(dependent_parameter, independent_parameter) - def test_parameter_make_dependent_on_exceptions_cleanup_previously_independent(self, normal_parameter): - # When - independent_parameter = Parameter(name='independent', value=10, unit='s', variance=0.02) + def test_parameter_make_dependent_on_exceptions_cleanup_previously_independent( + self, normal_parameter + ): + # When + independent_parameter = Parameter( + name="independent", value=10, unit="s", variance=0.02 + ) # Then Expect # Check that the correct error is raised with pytest.raises(NameError): independent_parameter.make_dependent_on( - dependency_expression='2*a + b', - dependency_map={'a': normal_parameter}, - ) + dependency_expression="2*a + b", + dependency_map={"a": normal_parameter}, + ) # Check that everything is properly cleaned up assert normal_parameter._observers == [] assert independent_parameter.independent == True @@ -283,84 +543,88 @@ def test_parameter_make_dependent_on_exceptions_cleanup_previously_independent(s def test_dependent_parameter_updates(self, normal_parameter: Parameter): # When dependent_parameter = Parameter.from_dependency( - name = 'dependent', - dependency_expression='2*a', - dependency_map={'a': normal_parameter}, + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, ) # Then Expect normal_parameter.value = 2 - self.compare_parameters(dependent_parameter, 2*normal_parameter) + self.compare_parameters(dependent_parameter, 2 * normal_parameter) normal_parameter.variance = 0.02 - self.compare_parameters(dependent_parameter, 2*normal_parameter) + self.compare_parameters(dependent_parameter, 2 * normal_parameter) normal_parameter.error = 0.2 - self.compare_parameters(dependent_parameter, 2*normal_parameter) + self.compare_parameters(dependent_parameter, 2 * normal_parameter) normal_parameter.convert_unit("cm") - self.compare_parameters(dependent_parameter, 2*normal_parameter) + self.compare_parameters(dependent_parameter, 2 * normal_parameter) normal_parameter.min = 1 - self.compare_parameters(dependent_parameter, 2*normal_parameter) + self.compare_parameters(dependent_parameter, 2 * normal_parameter) normal_parameter.max = 300 - self.compare_parameters(dependent_parameter, 2*normal_parameter) + self.compare_parameters(dependent_parameter, 2 * normal_parameter) def test_dependent_parameter_indirect_updates(self, normal_parameter: Parameter): # When dependent_parameter = Parameter.from_dependency( - name = 'dependent', - dependency_expression='2*a', - dependency_map={'a': normal_parameter}, + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, ) dependent_parameter_2 = Parameter.from_dependency( - name = 'dependent_2', - dependency_expression='10*a', - dependency_map={'a': normal_parameter}, + name="dependent_2", + dependency_expression="10*a", + dependency_map={"a": normal_parameter}, ) dependent_parameter_3 = Parameter.from_dependency( - name = 'dependent_3', - dependency_expression='b+c', - dependency_map={'b': dependent_parameter, 'c': dependent_parameter_2}, + name="dependent_3", + dependency_expression="b+c", + dependency_map={"b": dependent_parameter, "c": dependent_parameter_2}, ) # Then normal_parameter.value = 2 # Expect - self.compare_parameters(dependent_parameter, 2*normal_parameter) - self.compare_parameters(dependent_parameter_2, 10*normal_parameter) - self.compare_parameters(dependent_parameter_3, 2*normal_parameter + 10*normal_parameter) + self.compare_parameters(dependent_parameter, 2 * normal_parameter) + self.compare_parameters(dependent_parameter_2, 10 * normal_parameter) + self.compare_parameters( + dependent_parameter_3, 2 * normal_parameter + 10 * normal_parameter + ) def test_dependent_parameter_cyclic_dependencies(self, normal_parameter: Parameter): # When dependent_parameter = Parameter.from_dependency( - name = 'dependent', - dependency_expression='2*a', - dependency_map={'a': normal_parameter}, + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, ) dependent_parameter_2 = Parameter.from_dependency( - name = 'dependent_2', - dependency_expression='2*b', - dependency_map={'b': dependent_parameter}, + name="dependent_2", + dependency_expression="2*b", + dependency_map={"b": dependent_parameter}, ) # Then Expect with pytest.raises(RuntimeError): - normal_parameter.make_dependent_on(dependency_expression='2*c', dependency_map={'c': dependent_parameter_2}) + normal_parameter.make_dependent_on( + dependency_expression="2*c", dependency_map={"c": dependent_parameter_2} + ) # Check that everything is properly cleaned up assert dependent_parameter_2._observers == [] assert normal_parameter.independent == True assert normal_parameter.value == 1 normal_parameter.value = 50 - self.compare_parameters(dependent_parameter_2, 4*normal_parameter) + self.compare_parameters(dependent_parameter_2, 4 * normal_parameter) def test_dependent_parameter_logical_dependency(self, normal_parameter: Parameter): # When dependent_parameter = Parameter.from_dependency( - name = 'dependent', - dependency_expression='a if a.value > 0 else -a', - dependency_map={'a': normal_parameter}, + name="dependent", + dependency_expression="a if a.value > 0 else -a", + dependency_map={"a": normal_parameter}, ) self.compare_parameters(dependent_parameter, normal_parameter) @@ -372,21 +636,23 @@ def test_dependent_parameter_logical_dependency(self, normal_parameter: Paramete def test_dependent_parameter_return_is_descriptor_number(self): # When - descriptor_number = DescriptorNumber(name='descriptor', value=1, unit='m', variance=0.01) - + descriptor_number = DescriptorNumber( + name="descriptor", value=1, unit="m", variance=0.01 + ) + # Then dependent_parameter = Parameter.from_dependency( - name = 'dependent', - dependency_expression='2*descriptor', - dependency_map={'descriptor': descriptor_number}, + name="dependent", + dependency_expression="2*descriptor", + dependency_map={"descriptor": descriptor_number}, ) # Expect - assert dependent_parameter.value == 2*descriptor_number.value + assert dependent_parameter.value == 2 * descriptor_number.value assert dependent_parameter.unit == descriptor_number.unit assert dependent_parameter.variance == 0.04 - assert dependent_parameter.min == 2*descriptor_number.value - assert dependent_parameter.max == 2*descriptor_number.value + assert dependent_parameter.min == 2 * descriptor_number.value + assert dependent_parameter.max == 2 * descriptor_number.value def test_dependent_parameter_division_expression_order(self): """Test that division expressions work regardless of operand order. @@ -436,32 +702,36 @@ def test_dependent_parameter_division_expression_order(self): def test_dependent_parameter_overwrite_dependency(self, normal_parameter: Parameter): # When dependent_parameter = Parameter.from_dependency( - name = 'dependent', - dependency_expression='2*a', - dependency_map={'a': normal_parameter}, + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, ) - self.compare_parameters(dependent_parameter, 2*normal_parameter) + self.compare_parameters(dependent_parameter, 2 * normal_parameter) # Then - normal_parameter_2 = Parameter(name='a2', value=-2, unit='m', variance=0.01, min=-10, max=0) - dependent_parameter.make_dependent_on(dependency_expression='3*a2', dependency_map={'a2': normal_parameter_2}) + normal_parameter_2 = Parameter( + name="a2", value=-2, unit="m", variance=0.01, min=-10, max=0 + ) + dependent_parameter.make_dependent_on( + dependency_expression="3*a2", dependency_map={"a2": normal_parameter_2} + ) normal_parameter.value = 3 # Expect - self.compare_parameters(dependent_parameter, 3*normal_parameter_2) - assert dependent_parameter.dependency_expression == '3*a2' - assert dependent_parameter.dependency_map == {'a2': normal_parameter_2} + self.compare_parameters(dependent_parameter, 3 * normal_parameter_2) + assert dependent_parameter.dependency_expression == "3*a2" + assert dependent_parameter.dependency_map == {"a2": normal_parameter_2} assert normal_parameter._observers == [] - + def test_make_independent(self, normal_parameter: Parameter): # When dependent_parameter = Parameter.from_dependency( - name = 'dependent', - dependency_expression='2*a', - dependency_map={'a': normal_parameter}, + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, ) assert dependent_parameter.independent == False - self.compare_parameters(dependent_parameter, 2*normal_parameter) + self.compare_parameters(dependent_parameter, 2 * normal_parameter) # Then dependent_parameter.make_independent() @@ -482,46 +752,52 @@ def test_independent_setter(self, normal_parameter: Parameter): with pytest.raises(AttributeError): normal_parameter.independent = False - def test_independent_parameter_dependency_expression(self, normal_parameter: Parameter): + def test_independent_parameter_dependency_expression( + self, normal_parameter: Parameter + ): # When Then Expect with pytest.raises(AttributeError): normal_parameter.dependency_expression - def test_dependent_parameter_dependency_expression_setter(self, normal_parameter: Parameter): + def test_dependent_parameter_dependency_expression_setter( + self, normal_parameter: Parameter + ): # When dependent_parameter = Parameter.from_dependency( - name = 'dependent', - dependency_expression='2*a', - dependency_map={'a': normal_parameter}, + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, ) # Then Expect with pytest.raises(AttributeError): - dependent_parameter.dependency_expression = '3*a' + dependent_parameter.dependency_expression = "3*a" def test_independent_parameter_dependency_map(self, normal_parameter: Parameter): # When Then Expect with pytest.raises(AttributeError): normal_parameter.dependency_map - def test_dependent_parameter_dependency_map_setter(self, normal_parameter: Parameter): + def test_dependent_parameter_dependency_map_setter( + self, normal_parameter: Parameter + ): # When dependent_parameter = Parameter.from_dependency( - name = 'dependent', - dependency_expression='2*a', - dependency_map={'a': normal_parameter}, + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, ) # Then Expect with pytest.raises(AttributeError): - dependent_parameter.dependency_map = {'a': normal_parameter} + dependent_parameter.dependency_map = {"a": normal_parameter} def test_min(self, parameter: Parameter): # When Then Expect assert parameter.min == 0 def test_set_min(self, parameter: Parameter): - # When Then + # When Then self.mock_callback.fget.return_value = 1.0 # Ensure fget returns a scalar value parameter.min = 0.1 @@ -532,9 +808,9 @@ def test_set_min(self, parameter: Parameter): def test_set_min_dependent_parameter(self, normal_parameter: Parameter): # When dependent_parameter = Parameter.from_dependency( - name = 'dependent', - dependency_expression='2*a', - dependency_map={'a': normal_parameter}, + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, ) # Then Expect @@ -547,7 +823,7 @@ def test_set_min_exception(self, parameter: Parameter): parameter.min = 10 def test_set_max(self, parameter: Parameter): - # When Then + # When Then parameter.max = 10 # Expect @@ -556,9 +832,9 @@ def test_set_max(self, parameter: Parameter): def test_set_max_dependent_parameter(self, normal_parameter: Parameter): # When dependent_parameter = Parameter.from_dependency( - name = 'dependent', - dependency_expression='2*a', - dependency_map={'a': normal_parameter}, + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, ) # Then Expect @@ -580,8 +856,109 @@ def test_convert_unit(self, parameter: Parameter): assert parameter._max.value == 10000 assert parameter._max.unit == "mm" + def test_set_desired_unit(self, normal_parameter: Parameter): + # When Then + dependent_parameter = Parameter.from_dependency( + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, + display_name="display_name", + ) + + # Then + dependent_parameter.set_desired_unit("cm") + + # Expect + + assert dependent_parameter.value == 200 * normal_parameter.value + assert dependent_parameter.unit == "cm" + assert ( + dependent_parameter.variance == normal_parameter.variance * 4 * 10000 + ) # unit conversion from m to cm squared + assert dependent_parameter.min == 200 * normal_parameter.min + assert dependent_parameter.max == 200 * normal_parameter.max + assert dependent_parameter._min.unit == "cm" + assert dependent_parameter._max.unit == "cm" + + # Then + normal_parameter.value = 2 + + # Expect + assert dependent_parameter.value == 200 * normal_parameter.value + assert dependent_parameter.unit == "cm" + assert ( + dependent_parameter.variance == normal_parameter.variance * 4 * 10000 + ) # unit conversion from m to cm squared + assert dependent_parameter.min == 200 * normal_parameter.min + assert dependent_parameter.max == 200 * normal_parameter.max + assert dependent_parameter._min.unit == "cm" + assert dependent_parameter._max.unit == "cm" + + def test_set_desired_unit_incompatible_units_raises( + self, normal_parameter: Parameter + ): + # When Then + dependent_parameter = Parameter.from_dependency( + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, + display_name="display_name", + ) + + # Then Expect + with pytest.raises( + UnitError, + match="Failed to convert unit from m to s.", + ): + dependent_parameter.set_desired_unit("s") + + def test_set_desired_unit_None(self, normal_parameter: Parameter): + # When Then + dependent_parameter = Parameter.from_dependency( + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, + display_name="display_name", + desired_unit="cm", + ) + + # EXPECT + assert dependent_parameter.unit == "cm" + + # Then + dependent_parameter.set_desired_unit(None) + + # EXPECT + assert dependent_parameter.value == 2 * normal_parameter.value + assert dependent_parameter.unit == "m" + + def test_set_desired_unit_independent_parameter_raises( + self, normal_parameter: Parameter + ): + # When Then Expect + with pytest.raises( + AttributeError, + match="This is an independent parameter, desired unit can only be set for dependent parameters.", + ): + normal_parameter.set_desired_unit("cm") + + def test_set_desired_unit_incorrect_unit_type_raises( + self, normal_parameter: Parameter + ): + # When Then + dependent_parameter = Parameter.from_dependency( + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, + display_name="display_name", + ) + + # Then Expect + with pytest.raises(TypeError, match="must be a string"): + dependent_parameter.set_desired_unit(5) + def test_set_fixed(self, parameter: Parameter): - # When Then + # When Then parameter.fixed = True # Expect @@ -590,9 +967,9 @@ def test_set_fixed(self, parameter: Parameter): def test_set_fixed_dependent_parameter(self, normal_parameter: Parameter): # When dependent_parameter = Parameter.from_dependency( - name = 'dependent', - dependency_expression='2*a', - dependency_map={'a': normal_parameter}, + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, ) # Then Expect @@ -608,9 +985,9 @@ def test_set_fixed_exception(self, parameter: Parameter, fixed): def test_error(self, parameter: Parameter): # When Then Expect assert parameter.error == 0.1 - + def test_set_error(self, parameter: Parameter): - # When + # When parameter.error = 10 # Then Expect @@ -629,14 +1006,20 @@ def test_set_error_exception(self, parameter: Parameter): def test_repr(self, parameter: Parameter): # When Then Expect - assert repr(parameter) == "" + assert ( + repr(parameter) + == "" + ) def test_repr_fixed(self, parameter: Parameter): - # When + # When parameter.fixed = True # Then Expect - assert repr(parameter) == "" + assert ( + repr(parameter) + == "" + ) def test_value_match_callback(self, parameter: Parameter): # When @@ -645,7 +1028,7 @@ def test_value_match_callback(self, parameter: Parameter): # Then Expect assert parameter.value == 1.0 assert parameter._callback.fget.call_count == 1 - + def test_value_no_match_callback(self, parameter: Parameter): # When self.mock_callback.fget.return_value = 2.0 @@ -666,14 +1049,14 @@ def test_set_value(self, parameter: Parameter): # Expect parameter._callback.fset.assert_called_with(2) assert parameter._callback.fset.call_count == 1 - assert parameter._scalar == sc.scalar(2, unit='m') + assert parameter._scalar == sc.scalar(2, unit="m") def test_set_value_dependent_parameter(self, normal_parameter: Parameter): # When dependent_parameter = Parameter.from_dependency( - name = 'dependent', - dependency_expression='2*a', - dependency_map={'a': normal_parameter}, + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, ) # Then Expect @@ -683,14 +1066,14 @@ def test_set_value_dependent_parameter(self, normal_parameter: Parameter): def test_set_full_value(self, parameter: Parameter): # When Then Expect with pytest.raises(AttributeError): - parameter.full_value = sc.scalar(2, unit='s') + parameter.full_value = sc.scalar(2, unit="s") def test_set_variance_dependent_parameter(self, normal_parameter: Parameter): # When dependent_parameter = Parameter.from_dependency( - name = 'dependent', - dependency_expression='2*a', - dependency_map={'a': normal_parameter}, + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, ) # Then Expect @@ -700,9 +1083,9 @@ def test_set_variance_dependent_parameter(self, normal_parameter: Parameter): def test_set_error_dependent_parameter(self, normal_parameter: Parameter): # When dependent_parameter = Parameter.from_dependency( - name = 'dependent', - dependency_expression='2*a', - dependency_map={'a': normal_parameter}, + name="dependent", + dependency_expression="2*a", + dependency_map={"a": normal_parameter}, ) # Then Expect @@ -729,13 +1112,35 @@ def test_copy(self, parameter: Parameter): assert parameter_copy._display_name == parameter._display_name assert parameter_copy._independent == parameter._independent - @pytest.mark.parametrize("test, expected, expected_reverse", [ - (Parameter("test", 2, "m", 0.01, -10, 20), Parameter("name + test", 3, "m", 0.02, -10, 30), Parameter("test + name", 3, "m", 0.02, -10, 30)), - (Parameter("test", 2, "m", 0.01), Parameter("name + test", 3, "m", 0.02, min=-np.inf, max=np.inf),Parameter("test + name", 3, "m", 0.02, min=-np.inf, max=np.inf)), - (Parameter("test", 2, "cm", 0.01, -10, 10), Parameter("name + test", 1.02, "m", 0.010001, -0.1, 10.1), Parameter("test + name", 102, "cm", 100.01, -10, 1010))], - ids=["regular", "no_bounds", "unit_conversion"]) - def test_addition_with_parameter(self, parameter : Parameter, test : Parameter, expected : Parameter, expected_reverse : Parameter): - # When + @pytest.mark.parametrize( + "test, expected, expected_reverse", + [ + ( + Parameter("test", 2, "m", 0.01, -10, 20), + Parameter("name + test", 3, "m", 0.02, -10, 30), + Parameter("test + name", 3, "m", 0.02, -10, 30), + ), + ( + Parameter("test", 2, "m", 0.01), + Parameter("name + test", 3, "m", 0.02, min=-np.inf, max=np.inf), + Parameter("test + name", 3, "m", 0.02, min=-np.inf, max=np.inf), + ), + ( + Parameter("test", 2, "cm", 0.01, -10, 10), + Parameter("name + test", 1.02, "m", 0.010001, -0.1, 10.1), + Parameter("test + name", 102, "cm", 100.01, -10, 1010), + ), + ], + ids=["regular", "no_bounds", "unit_conversion"], + ) + def test_addition_with_parameter( + self, + parameter: Parameter, + test: Parameter, + expected: Parameter, + expected_reverse: Parameter, + ): + # When parameter._callback = property() # Then @@ -750,7 +1155,7 @@ def test_addition_with_parameter(self, parameter : Parameter, test : Parameter, assert result.min == expected.min assert result.max == expected.max - assert result_reverse.name == result_reverse.unique_name + assert result_reverse.name == result_reverse.unique_name assert result_reverse.value == expected_reverse.value assert result_reverse.unit == expected_reverse.unit assert result_reverse.variance == expected_reverse.variance @@ -782,10 +1187,12 @@ def test_addition_with_scalar(self): assert result_reverse.min == 1.0 assert result_reverse.max == 11.0 - def test_addition_with_descriptor_number(self, parameter : Parameter): - # When + def test_addition_with_descriptor_number(self, parameter: Parameter): + # When parameter._callback = property() - descriptor_number = DescriptorNumber(name="test", value=1, variance=0.1, unit="cm") + descriptor_number = DescriptorNumber( + name="test", value=1, variance=0.1, unit="cm" + ) # Then result = parameter + descriptor_number @@ -811,21 +1218,54 @@ def test_addition_with_descriptor_number(self, parameter : Parameter): assert parameter.unit == "m" assert descriptor_number.unit == "cm" - @pytest.mark.parametrize("test", [1.0, Parameter("test", 2, "s",)], ids=["add_scalar_to_unit", "incompatible_units"]) - def test_addition_exception(self, parameter : Parameter, test): + @pytest.mark.parametrize( + "test", + [ + 1.0, + Parameter( + "test", + 2, + "s", + ), + ], + ids=["add_scalar_to_unit", "incompatible_units"], + ) + def test_addition_exception(self, parameter: Parameter, test): # When Then Expect with pytest.raises(UnitError): result = parameter + test with pytest.raises(UnitError): result_reverse = test + parameter - - @pytest.mark.parametrize("test, expected, expected_reverse", [ - (Parameter("test", 2, "m", 0.01, -20, 20), Parameter("name - test", -1, "m", 0.02, -20, 30), Parameter("test - name", 1, "m", 0.02, -30, 20)), - (Parameter("test", 2, "m", 0.01), Parameter("name - test", -1, "m", 0.02, min=-np.inf, max=np.inf),Parameter("test - name", 1, "m", 0.02, min=-np.inf, max=np.inf)), - (Parameter("test", 2, "cm", 0.01, -10, 10), Parameter("name - test", 0.98, "m", 0.010001, -0.1, 10.1), Parameter("test - name", -98, "cm", 100.01, -1010, 10))], - ids=["regular", "no_bounds", "unit_conversion"]) - def test_subtraction_with_parameter(self, parameter : Parameter, test : Parameter, expected : Parameter, expected_reverse : Parameter): - # When + + @pytest.mark.parametrize( + "test, expected, expected_reverse", + [ + ( + Parameter("test", 2, "m", 0.01, -20, 20), + Parameter("name - test", -1, "m", 0.02, -20, 30), + Parameter("test - name", 1, "m", 0.02, -30, 20), + ), + ( + Parameter("test", 2, "m", 0.01), + Parameter("name - test", -1, "m", 0.02, min=-np.inf, max=np.inf), + Parameter("test - name", 1, "m", 0.02, min=-np.inf, max=np.inf), + ), + ( + Parameter("test", 2, "cm", 0.01, -10, 10), + Parameter("name - test", 0.98, "m", 0.010001, -0.1, 10.1), + Parameter("test - name", -98, "cm", 100.01, -1010, 10), + ), + ], + ids=["regular", "no_bounds", "unit_conversion"], + ) + def test_subtraction_with_parameter( + self, + parameter: Parameter, + test: Parameter, + expected: Parameter, + expected_reverse: Parameter, + ): + # When parameter._callback = property() # Then @@ -851,7 +1291,9 @@ def test_subtraction_with_parameter(self, parameter : Parameter, test : Paramete def test_subtraction_with_parameter_nan_cases(self): # When - parameter = Parameter(name="name", value=1, variance=0.01, min=-np.inf, max=np.inf) + parameter = Parameter( + name="name", value=1, variance=0.01, min=-np.inf, max=np.inf + ) test = Parameter(name="test", value=2, variance=0.01, min=-np.inf, max=np.inf) # Then @@ -896,10 +1338,12 @@ def test_subtraction_with_scalar(self): assert result_reverse.min == -9.0 assert result_reverse.max == 1.0 - def test_subtraction_with_descriptor_number(self, parameter : Parameter): - # When + def test_subtraction_with_descriptor_number(self, parameter: Parameter): + # When parameter._callback = property() - descriptor_number = DescriptorNumber(name="test", value=1, variance=0.1, unit="cm") + descriptor_number = DescriptorNumber( + name="test", value=1, variance=0.1, unit="cm" + ) # Then result = parameter - descriptor_number @@ -925,21 +1369,54 @@ def test_subtraction_with_descriptor_number(self, parameter : Parameter): assert parameter.unit == "m" assert descriptor_number.unit == "cm" - @pytest.mark.parametrize("test", [1.0, Parameter("test", 2, "s",)], ids=["sub_scalar_to_unit", "incompatible_units"]) - def test_subtraction_exception(self, parameter : Parameter, test): + @pytest.mark.parametrize( + "test", + [ + 1.0, + Parameter( + "test", + 2, + "s", + ), + ], + ids=["sub_scalar_to_unit", "incompatible_units"], + ) + def test_subtraction_exception(self, parameter: Parameter, test): # When Then Expect with pytest.raises(UnitError): result = parameter - test with pytest.raises(UnitError): result_reverse = test - parameter - @pytest.mark.parametrize("test, expected, expected_reverse", [ - (Parameter("test", 2, "m", 0.01, -10, 20), Parameter("name * test", 2, "m^2", 0.05, -100, 200), Parameter("test * name", 2, "m^2", 0.05, -100, 200)), - (Parameter("test", 2, "m", 0.01), Parameter("name * test", 2, "m^2", 0.05, min=-np.inf, max=np.inf), Parameter("test * name", 2, "m^2", 0.05, min=-np.inf, max=np.inf)), - (Parameter("test", 2, "dm", 0.01, -10, 20), Parameter("name * test", 0.2, "m^2", 0.0005, -10, 20), Parameter("test * name", 0.2, "m^2", 0.0005, -10, 20))], - ids=["regular", "no_bounds", "base_unit_conversion"]) - def test_multiplication_with_parameter(self, parameter : Parameter, test : Parameter, expected : Parameter, expected_reverse : Parameter): - # When + @pytest.mark.parametrize( + "test, expected, expected_reverse", + [ + ( + Parameter("test", 2, "m", 0.01, -10, 20), + Parameter("name * test", 2, "m^2", 0.05, -100, 200), + Parameter("test * name", 2, "m^2", 0.05, -100, 200), + ), + ( + Parameter("test", 2, "m", 0.01), + Parameter("name * test", 2, "m^2", 0.05, min=-np.inf, max=np.inf), + Parameter("test * name", 2, "m^2", 0.05, min=-np.inf, max=np.inf), + ), + ( + Parameter("test", 2, "dm", 0.01, -10, 20), + Parameter("name * test", 0.2, "m^2", 0.0005, -10, 20), + Parameter("test * name", 0.2, "m^2", 0.0005, -10, 20), + ), + ], + ids=["regular", "no_bounds", "base_unit_conversion"], + ) + def test_multiplication_with_parameter( + self, + parameter: Parameter, + test: Parameter, + expected: Parameter, + expected_reverse: Parameter, + ): + # When parameter._callback = property() # Then @@ -961,11 +1438,25 @@ def test_multiplication_with_parameter(self, parameter : Parameter, test : Param assert result_reverse.min == expected_reverse.min assert result_reverse.max == expected_reverse.max - @pytest.mark.parametrize("test, expected, expected_reverse", [ - (Parameter("test", 0, "", 0.01, -10, 0), Parameter("name * test", 0.0, "dimensionless", 0.01, -np.inf, 0), Parameter("test * name", 0, "dimensionless", 0.01, -np.inf, 0)), - (Parameter("test", 0, "", 0.01, 0, 10), Parameter("name * test", 0.0, "dimensionless", 0.01, 0, np.inf), Parameter("test * name", 0, "dimensionless", 0.01, 0, np.inf))], - ids=["zero_min", "zero_max"]) - def test_multiplication_with_parameter_nan_cases(self, test, expected, expected_reverse): + @pytest.mark.parametrize( + "test, expected, expected_reverse", + [ + ( + Parameter("test", 0, "", 0.01, -10, 0), + Parameter("name * test", 0.0, "dimensionless", 0.01, -np.inf, 0), + Parameter("test * name", 0, "dimensionless", 0.01, -np.inf, 0), + ), + ( + Parameter("test", 0, "", 0.01, 0, 10), + Parameter("name * test", 0.0, "dimensionless", 0.01, 0, np.inf), + Parameter("test * name", 0, "dimensionless", 0.01, 0, np.inf), + ), + ], + ids=["zero_min", "zero_max"], + ) + def test_multiplication_with_parameter_nan_cases( + self, test, expected, expected_reverse + ): # When parameter = Parameter(name="name", value=1, variance=0.01, min=1, max=np.inf) @@ -988,12 +1479,26 @@ def test_multiplication_with_parameter_nan_cases(self, test, expected, expected_ assert result_reverse.min == expected_reverse.min assert result_reverse.max == expected_reverse.max - @pytest.mark.parametrize("test, expected, expected_reverse", [ - (DescriptorNumber(name="test", value=2, variance=0.1, unit="cm"), Parameter("name * test", 2, "dm^2", 0.14, 0, 20), Parameter("test * name", 2, "dm^2", 0.14, 0, 20)), - (DescriptorNumber(name="test", value=0, variance=0.1, unit="cm"), DescriptorNumber("name * test", 0, "dm^2", 0.1), DescriptorNumber("test * name", 0, "dm^2", 0.1))], - ids=["regular", "zero_value"]) - def test_multiplication_with_descriptor_number(self, parameter : Parameter, test, expected, expected_reverse): - # When + @pytest.mark.parametrize( + "test, expected, expected_reverse", + [ + ( + DescriptorNumber(name="test", value=2, variance=0.1, unit="cm"), + Parameter("name * test", 2, "dm^2", 0.14, 0, 20), + Parameter("test * name", 2, "dm^2", 0.14, 0, 20), + ), + ( + DescriptorNumber(name="test", value=0, variance=0.1, unit="cm"), + DescriptorNumber("name * test", 0, "dm^2", 0.1), + DescriptorNumber("test * name", 0, "dm^2", 0.1), + ), + ], + ids=["regular", "zero_value"], + ) + def test_multiplication_with_descriptor_number( + self, parameter: Parameter, test, expected, expected_reverse + ): + # When parameter._callback = property() # Then @@ -1019,12 +1524,26 @@ def test_multiplication_with_descriptor_number(self, parameter : Parameter, test assert result_reverse.min == expected_reverse.min assert result_reverse.max == expected_reverse.max - @pytest.mark.parametrize("test, expected, expected_reverse", [ - (2, Parameter("name * 2", 2, "m", 0.04, 0, 20), Parameter("2 * name", 2, "m", 0.04, 0, 20)), - (0, DescriptorNumber("name * 0", 0, "m", 0), DescriptorNumber("0 * name", 0, "m", 0))], - ids=["regular", "zero_value"]) - def test_multiplication_with_scalar(self, parameter : Parameter, test, expected, expected_reverse): - # When + @pytest.mark.parametrize( + "test, expected, expected_reverse", + [ + ( + 2, + Parameter("name * 2", 2, "m", 0.04, 0, 20), + Parameter("2 * name", 2, "m", 0.04, 0, 20), + ), + ( + 0, + DescriptorNumber("name * 0", 0, "m", 0), + DescriptorNumber("0 * name", 0, "m", 0), + ), + ], + ids=["regular", "zero_value"], + ) + def test_multiplication_with_scalar( + self, parameter: Parameter, test, expected, expected_reverse + ): + # When parameter._callback = property() # Then @@ -1049,13 +1568,31 @@ def test_multiplication_with_scalar(self, parameter : Parameter, test, expected, assert result_reverse.min == expected_reverse.min assert result_reverse.max == expected_reverse.max - @pytest.mark.parametrize("test, expected, expected_reverse", [ - (Parameter("test", 2, "s", 0.01, -10, 20), Parameter("name / test", 0.5, "m/s", 0.003125, -np.inf, np.inf), Parameter("test / name", 2, "s/m", 0.05, -np.inf, np.inf)), - (Parameter("test", 2, "s", 0.01, 0, 20), Parameter("name / test", 0.5, "m/s", 0.003125, 0.0, np.inf), Parameter("test / name", 2, "s/m", 0.05, 0.0, np.inf)), - (Parameter("test", -2, "s", 0.01, -10, 0), Parameter("name / test", -0.5, "m/s", 0.003125, -np.inf, 0.0), Parameter("test / name", -2, "s/m", 0.05, -np.inf, 0.0))], - ids=["crossing_zero", "only_positive", "only_negative"]) - def test_division_with_parameter(self, parameter : Parameter, test, expected, expected_reverse): - # When + @pytest.mark.parametrize( + "test, expected, expected_reverse", + [ + ( + Parameter("test", 2, "s", 0.01, -10, 20), + Parameter("name / test", 0.5, "m/s", 0.003125, -np.inf, np.inf), + Parameter("test / name", 2, "s/m", 0.05, -np.inf, np.inf), + ), + ( + Parameter("test", 2, "s", 0.01, 0, 20), + Parameter("name / test", 0.5, "m/s", 0.003125, 0.0, np.inf), + Parameter("test / name", 2, "s/m", 0.05, 0.0, np.inf), + ), + ( + Parameter("test", -2, "s", 0.01, -10, 0), + Parameter("name / test", -0.5, "m/s", 0.003125, -np.inf, 0.0), + Parameter("test / name", -2, "s/m", 0.05, -np.inf, 0.0), + ), + ], + ids=["crossing_zero", "only_positive", "only_negative"], + ) + def test_division_with_parameter( + self, parameter: Parameter, test, expected, expected_reverse + ): + # When parameter._callback = property() # Then @@ -1079,11 +1616,31 @@ def test_division_with_parameter(self, parameter : Parameter, test, expected, ex assert result_reverse.min == expected_reverse.min assert result_reverse.max == expected_reverse.max - @pytest.mark.parametrize("first, second, expected", [ - (Parameter("name", 1, "m", 0.01, -10, 20), Parameter("test", -2, "s", 0.01, -10, 0), Parameter("name / test", -0.5, "m/s", 0.003125, -np.inf, np.inf)), - (Parameter("name", -10, "m", 0.01, -20, -10), Parameter("test", -2, "s", 0.01, -10, 0), Parameter("name / test", 5.0, "m/s", 0.065, 1, np.inf)), - (Parameter("name", 10, "m", 0.01, 10, 20), Parameter("test", -20, "s", 0.01, -20, -10), Parameter("name / test", -0.5, "m/s", 3.125e-5, -2, -0.5))], - ids=["first_crossing_zero_second_negative_0", "both_negative_second_negative_0", "finite_limits"]) + @pytest.mark.parametrize( + "first, second, expected", + [ + ( + Parameter("name", 1, "m", 0.01, -10, 20), + Parameter("test", -2, "s", 0.01, -10, 0), + Parameter("name / test", -0.5, "m/s", 0.003125, -np.inf, np.inf), + ), + ( + Parameter("name", -10, "m", 0.01, -20, -10), + Parameter("test", -2, "s", 0.01, -10, 0), + Parameter("name / test", 5.0, "m/s", 0.065, 1, np.inf), + ), + ( + Parameter("name", 10, "m", 0.01, 10, 20), + Parameter("test", -20, "s", 0.01, -20, -10), + Parameter("name / test", -0.5, "m/s", 3.125e-5, -2, -0.5), + ), + ], + ids=[ + "first_crossing_zero_second_negative_0", + "both_negative_second_negative_0", + "finite_limits", + ], + ) def test_division_with_parameter_remaining_cases(self, first, second, expected): # When Then result = first / second @@ -1096,12 +1653,26 @@ def test_division_with_parameter_remaining_cases(self, first, second, expected): assert result.min == expected.min assert result.max == expected.max - @pytest.mark.parametrize("test, expected, expected_reverse", [ - (DescriptorNumber(name="test", value=2, variance=0.1, unit="s"), Parameter("name / test", 0.5, "m/s", 0.00875, 0, 5), Parameter("test / name", 2, "s/m", 0.14, 0.2, np.inf)), - (2, Parameter("name / 2", 0.5, "m", 0.0025, 0, 5), Parameter("2 / name", 2, "m**-1", 0.04, 0.2, np.inf))], - ids=["descriptor_number", "number"]) - def test_division_with_descriptor_number_and_number(self, parameter : Parameter, test, expected, expected_reverse): - # When + @pytest.mark.parametrize( + "test, expected, expected_reverse", + [ + ( + DescriptorNumber(name="test", value=2, variance=0.1, unit="s"), + Parameter("name / test", 0.5, "m/s", 0.00875, 0, 5), + Parameter("test / name", 2, "s/m", 0.14, 0.2, np.inf), + ), + ( + 2, + Parameter("name / 2", 0.5, "m", 0.0025, 0, 5), + Parameter("2 / name", 2, "m**-1", 0.04, 0.2, np.inf), + ), + ], + ids=["descriptor_number", "number"], + ) + def test_division_with_descriptor_number_and_number( + self, parameter: Parameter, test, expected, expected_reverse + ): + # When parameter._callback = property() # Then @@ -1125,12 +1696,21 @@ def test_division_with_descriptor_number_and_number(self, parameter : Parameter, assert result_reverse.min == expected_reverse.min assert result_reverse.max == expected_reverse.max - @pytest.mark.parametrize("test, expected", [ - (DescriptorNumber(name="test", value=0, variance=0.1, unit="s"), DescriptorNumber("test / name", 0.0, "s/m", 0.1)), - (0, DescriptorNumber("0 / name", 0.0, "1/m", 0.0))], - ids=["descriptor_number", "number"]) - def test_zero_value_divided_by_parameter(self, parameter : Parameter, test, expected): - # When + @pytest.mark.parametrize( + "test, expected", + [ + ( + DescriptorNumber(name="test", value=0, variance=0.1, unit="s"), + DescriptorNumber("test / name", 0.0, "s/m", 0.1), + ), + (0, DescriptorNumber("0 / name", 0.0, "1/m", 0.0)), + ], + ids=["descriptor_number", "number"], + ) + def test_zero_value_divided_by_parameter( + self, parameter: Parameter, test, expected + ): + # When parameter._callback = property() # Then @@ -1143,14 +1723,46 @@ def test_zero_value_divided_by_parameter(self, parameter : Parameter, test, expe assert result.unit == expected.unit assert result.variance == expected.variance - @pytest.mark.parametrize("first, second, expected", [ - (DescriptorNumber("name", 1, "m", 0.01), Parameter("test", 2, "s", 0.1, -10, 10), Parameter("name / test", 0.5, "m/s", 0.00875, -np.inf, np.inf)), - (DescriptorNumber("name", -1, "m", 0.01), Parameter("test", 2, "s", 0.1, 0, 10), Parameter("name / test", -0.5, "m/s", 0.00875, -np.inf, -0.1)), - (DescriptorNumber("name", 1, "m", 0.01), Parameter("test", -2, "s", 0.1, -10, 0), Parameter("name / test", -0.5, "m/s", 0.00875, -np.inf, -0.1)), - (DescriptorNumber("name", -1, "m", 0.01), Parameter("test", -2, "s", 0.1, -10, 0), Parameter("name / test", 0.5, "m/s", 0.00875, 0.1, np.inf)), - (DescriptorNumber("name", 1, "m", 0.01), Parameter("test", 2, "s", 0.1, 1, 10), Parameter("name / test", 0.5, "m/s", 0.00875, 0.1, 1))], - ids=["crossing_zero", "positive_0_with_negative", "negative_0_with_positive", "negative_0_with_negative", "finite_limits"]) - def test_division_with_descriptor_number_missing_cases(self, first, second, expected): + @pytest.mark.parametrize( + "first, second, expected", + [ + ( + DescriptorNumber("name", 1, "m", 0.01), + Parameter("test", 2, "s", 0.1, -10, 10), + Parameter("name / test", 0.5, "m/s", 0.00875, -np.inf, np.inf), + ), + ( + DescriptorNumber("name", -1, "m", 0.01), + Parameter("test", 2, "s", 0.1, 0, 10), + Parameter("name / test", -0.5, "m/s", 0.00875, -np.inf, -0.1), + ), + ( + DescriptorNumber("name", 1, "m", 0.01), + Parameter("test", -2, "s", 0.1, -10, 0), + Parameter("name / test", -0.5, "m/s", 0.00875, -np.inf, -0.1), + ), + ( + DescriptorNumber("name", -1, "m", 0.01), + Parameter("test", -2, "s", 0.1, -10, 0), + Parameter("name / test", 0.5, "m/s", 0.00875, 0.1, np.inf), + ), + ( + DescriptorNumber("name", 1, "m", 0.01), + Parameter("test", 2, "s", 0.1, 1, 10), + Parameter("name / test", 0.5, "m/s", 0.00875, 0.1, 1), + ), + ], + ids=[ + "crossing_zero", + "positive_0_with_negative", + "negative_0_with_positive", + "negative_0_with_negative", + "finite_limits", + ], + ) + def test_division_with_descriptor_number_missing_cases( + self, first, second, expected + ): # When Then result = first / second @@ -1162,9 +1774,13 @@ def test_division_with_descriptor_number_missing_cases(self, first, second, expe assert result.min == expected.min assert result.max == expected.max - @pytest.mark.parametrize("test", [0, DescriptorNumber("test", 0, "s", 0.1)], ids=["number", "descriptor_number"]) - def test_divide_parameter_by_zero(self, parameter : Parameter, test): - # When + @pytest.mark.parametrize( + "test", + [0, DescriptorNumber("test", 0, "s", 0.1)], + ids=["number", "descriptor_number"], + ) + def test_divide_parameter_by_zero(self, parameter: Parameter, test): + # When parameter._callback = property() # Then Expect @@ -1180,20 +1796,34 @@ def test_divide_by_zero_value_parameter(self): with pytest.raises(ZeroDivisionError): result = descriptor / parameter - @pytest.mark.parametrize("test, expected", [ - (3, Parameter("name ** 3", 125, "m^3", 281.25, -125, 1000)), - (2, Parameter("name ** 2", 25, "m^2", 5.0, 0, 100)), - (-1, Parameter("name ** -1", 0.2, "1/m", 8e-5, -np.inf, np.inf)), - (-2, Parameter("name ** -2", 0.04, "1/m^2", 1.28e-5, 0, np.inf)), - (0, DescriptorNumber("name ** 0", 1, "dimensionless", 0)), - (DescriptorNumber("test", 2), Parameter("name ** test", 25, "m^2", 5.0, 0, 100))], - ids=["power_3", "power_2", "power_-1", "power_-2", "power_0", "power_descriptor_number"]) + @pytest.mark.parametrize( + "test, expected", + [ + (3, Parameter("name ** 3", 125, "m^3", 281.25, -125, 1000)), + (2, Parameter("name ** 2", 25, "m^2", 5.0, 0, 100)), + (-1, Parameter("name ** -1", 0.2, "1/m", 8e-5, -np.inf, np.inf)), + (-2, Parameter("name ** -2", 0.04, "1/m^2", 1.28e-5, 0, np.inf)), + (0, DescriptorNumber("name ** 0", 1, "dimensionless", 0)), + ( + DescriptorNumber("test", 2), + Parameter("name ** test", 25, "m^2", 5.0, 0, 100), + ), + ], + ids=[ + "power_3", + "power_2", + "power_-1", + "power_-2", + "power_0", + "power_descriptor_number", + ], + ) def test_power_of_parameter(self, test, expected): - # When + # When parameter = Parameter("name", 5, "m", 0.05, -5, 10) # Then - result = parameter ** test + result = parameter**test # Expect assert type(result) == type(expected) @@ -1205,18 +1835,65 @@ def test_power_of_parameter(self, test, expected): assert result.min == expected.min assert result.max == expected.max - @pytest.mark.parametrize("test, exponent, expected", [ - (Parameter("name", 5, "m", 0.05, 0, 10), -1, Parameter("name ** -1", 0.2, "1/m", 8e-5, 0.1, np.inf)), - (Parameter("name", -5, "m", 0.05, -5, 0), -1, Parameter("name ** -1", -0.2, "1/m", 8e-5, -np.inf, -0.2)), - (Parameter("name", 5, "m", 0.05, 5, 10), -1, Parameter("name ** -1", 0.2, "1/m", 8e-5, 0.1, 0.2)), - (Parameter("name", -5, "m", 0.05, -10, -5), -1, Parameter("name ** -1", -0.2, "1/m", 8e-5, -0.2, -0.1)), - (Parameter("name", -5, "m", 0.05, -10, -5), -2, Parameter("name ** -2", 0.04, "1/m^2", 1.28e-5, 0.01, 0.04)), - (Parameter("name", 5, "", 0.1, 1, 10), 0.3, Parameter("name ** 0.3", 1.6206565966927624, "", 0.0009455500095853564, 1, 1.9952623149688795)), - (Parameter("name", 5, "", 0.1), 0.5, Parameter("name ** 0.5", 2.23606797749979, "", 0.005, 0, np.inf))], - ids=["0_positive", "negative_0", "both_positive", "both_negative_invert", "both_negative_invert_square", "fractional", "fractional_negative_limit"]) + @pytest.mark.parametrize( + "test, exponent, expected", + [ + ( + Parameter("name", 5, "m", 0.05, 0, 10), + -1, + Parameter("name ** -1", 0.2, "1/m", 8e-5, 0.1, np.inf), + ), + ( + Parameter("name", -5, "m", 0.05, -5, 0), + -1, + Parameter("name ** -1", -0.2, "1/m", 8e-5, -np.inf, -0.2), + ), + ( + Parameter("name", 5, "m", 0.05, 5, 10), + -1, + Parameter("name ** -1", 0.2, "1/m", 8e-5, 0.1, 0.2), + ), + ( + Parameter("name", -5, "m", 0.05, -10, -5), + -1, + Parameter("name ** -1", -0.2, "1/m", 8e-5, -0.2, -0.1), + ), + ( + Parameter("name", -5, "m", 0.05, -10, -5), + -2, + Parameter("name ** -2", 0.04, "1/m^2", 1.28e-5, 0.01, 0.04), + ), + ( + Parameter("name", 5, "", 0.1, 1, 10), + 0.3, + Parameter( + "name ** 0.3", + 1.6206565966927624, + "", + 0.0009455500095853564, + 1, + 1.9952623149688795, + ), + ), + ( + Parameter("name", 5, "", 0.1), + 0.5, + Parameter("name ** 0.5", 2.23606797749979, "", 0.005, 0, np.inf), + ), + ], + ids=[ + "0_positive", + "negative_0", + "both_positive", + "both_negative_invert", + "both_negative_invert_square", + "fractional", + "fractional_negative_limit", + ], + ) def test_power_of_diffent_parameters(self, test, exponent, expected): # When Then - result = test ** exponent + result = test**exponent # Expect assert result.name == result.unique_name @@ -1226,16 +1903,33 @@ def test_power_of_diffent_parameters(self, test, exponent, expected): assert result.min == expected.min assert result.max == expected.max - @pytest.mark.parametrize("parameter, exponent, expected", [ - (Parameter("name", 5, "m"), DescriptorNumber("test", 2, unit="s"), UnitError), - (Parameter("name", 5, "m"), DescriptorNumber("test", 2, variance=0.01), ValueError), - (Parameter("name", 5, "m"), 0.5, UnitError), - (Parameter("name", -5, ""), 0.5, ValueError),], - ids=["exponent_unit", "exponent_variance", "exponent_fractional", "negative_base_fractional"]) + @pytest.mark.parametrize( + "parameter, exponent, expected", + [ + ( + Parameter("name", 5, "m"), + DescriptorNumber("test", 2, unit="s"), + UnitError, + ), + ( + Parameter("name", 5, "m"), + DescriptorNumber("test", 2, variance=0.01), + ValueError, + ), + (Parameter("name", 5, "m"), 0.5, UnitError), + (Parameter("name", -5, ""), 0.5, ValueError), + ], + ids=[ + "exponent_unit", + "exponent_variance", + "exponent_fractional", + "negative_base_fractional", + ], + ) def test_power_exceptions(self, parameter, exponent, expected): # When Then Expect with pytest.raises(expected): - result = parameter ** exponent + result = parameter**exponent def test_negation(self): # When @@ -1252,18 +1946,28 @@ def test_negation(self): assert result.min == -10 assert result.max == 5 - @pytest.mark.parametrize("test, expected", [ - (Parameter("name", -5, "m", 0.05, -10, -5), Parameter("abs(name)", 5, "m", 0.05, 5, 10)), - (Parameter("name", 5, "m", 0.05, -10, 10), Parameter("abs(name)", 5, "m", 0.05, 0, 10))], - ids=["pure_negative", "crossing_zero"]) + @pytest.mark.parametrize( + "test, expected", + [ + ( + Parameter("name", -5, "m", 0.05, -10, -5), + Parameter("abs(name)", 5, "m", 0.05, 5, 10), + ), + ( + Parameter("name", 5, "m", 0.05, -10, 10), + Parameter("abs(name)", 5, "m", 0.05, 0, 10), + ), + ], + ids=["pure_negative", "crossing_zero"], + ) def test_abs(self, test, expected): # When Then result = abs(test) # Expect - assert result.name == result.unique_name + assert result.name == result.unique_name assert result.value == expected.value assert result.unit == expected.unit assert result.variance == expected.variance assert result.min == expected.min - assert result.max == expected.max \ No newline at end of file + assert result.max == expected.max diff --git a/tests/unit_tests/variable/test_parameter_dependency_serialization.py b/tests/unit_tests/variable/test_parameter_dependency_serialization.py index 8cf7abc0..574e2497 100644 --- a/tests/unit_tests/variable/test_parameter_dependency_serialization.py +++ b/tests/unit_tests/variable/test_parameter_dependency_serialization.py @@ -4,13 +4,19 @@ from unittest.mock import Mock from easyscience import Parameter, global_object -from easyscience.variable.parameter_dependency_resolver import resolve_all_parameter_dependencies -from easyscience.variable.parameter_dependency_resolver import get_parameters_with_pending_dependencies -from easyscience.variable.parameter_dependency_resolver import deserialize_and_resolve_parameters +from easyscience.variable.parameter_dependency_resolver import ( + resolve_all_parameter_dependencies, +) +from easyscience.variable.parameter_dependency_resolver import ( + get_parameters_with_pending_dependencies, +) +from easyscience.variable.parameter_dependency_resolver import ( + deserialize_and_resolve_parameters, +) class TestParameterDependencySerialization: - + @pytest.fixture def clear_global_map(self): """This fixture pattern: @@ -34,19 +40,19 @@ def clear_global_map(self): def test_independent_parameter_serialization(self, clear_global_map): """Test that independent parameters serialize normally without dependency info.""" param = Parameter(name="test", value=5.0, unit="m", min=0, max=10) - + # Serialize serialized = param.as_dict() - + # Should not contain dependency fields - assert '_dependency_string' not in serialized - assert '_dependency_map_serializer_ids' not in serialized - assert '_independent' not in serialized - + assert "_dependency_string" not in serialized + assert "_dependency_map_serializer_ids" not in serialized + assert "_independent" not in serialized + # Deserialize global_object.map._clear() new_param = Parameter.from_dict(serialized) - + # Should be identical assert new_param.name == param.name assert new_param.value == param.value @@ -57,108 +63,161 @@ def test_dependent_parameter_serialization(self, clear_global_map): """Test serialization of parameters with dependencies.""" # Create independent parameter a = Parameter(name="a", value=2.0, unit="m", min=0, max=10) - + # Create dependent parameter b = Parameter.from_dependency( - name="b", - dependency_expression="2 * a", - dependency_map={"a": a}, - unit="m" + name="b", dependency_expression="2 * a", dependency_map={"a": a}, unit="m" ) - + # Serialize dependent parameter serialized = b.as_dict() - + # Should contain dependency information - assert serialized['_dependency_string'] == "2 * a" - assert serialized['_dependency_map_serializer_ids'] == {"a": a._DescriptorNumber__serializer_id} - assert serialized['_independent'] is False - + assert serialized["_dependency_string"] == "2 * a" + assert serialized["_dependency_map_serializer_ids"] == { + "a": a._DescriptorNumber__serializer_id + } + assert serialized["_independent"] is False + # Deserialize global_object.map._clear() new_b = Parameter.from_dict(serialized) - + # Should have pending dependency info - assert hasattr(new_b, '_pending_dependency_string') + assert hasattr(new_b, "_pending_dependency_string") assert new_b._pending_dependency_string == "2 * a" - assert new_b._pending_dependency_map_serializer_ids == {"a": a._DescriptorNumber__serializer_id} - assert new_b.independent is True # Initially independent until dependencies resolved + assert new_b._pending_dependency_map_serializer_ids == { + "a": a._DescriptorNumber__serializer_id + } + assert ( + new_b.independent is True + ) # Initially independent until dependencies resolved def test_dependency_resolution_after_deserialization(self, clear_global_map): """Test that dependencies are properly resolved after deserialization.""" # Create test parameters with dependencies a = Parameter(name="a", value=2.0, unit="m", min=0, max=10) b = Parameter(name="b", value=3.0, unit="m", min=0, max=10) - + c = Parameter.from_dependency( name="c", - dependency_expression="a + b", + dependency_expression="a + b", dependency_map={"a": a, "b": b}, - unit="m" + unit="m", ) - + # Verify original dependency works assert c.value == 5.0 # 2 + 3 - + # Serialize all parameters - params_data = { - "a": a.as_dict(), - "b": b.as_dict(), - "c": c.as_dict() - } - + params_data = {"a": a.as_dict(), "b": b.as_dict(), "c": c.as_dict()} + # Clear and deserialize (manual approach) global_object.map._clear() new_params = {} for name, data in params_data.items(): new_params[name] = Parameter.from_dict(data) - + # Before resolution, c should be independent with pending dependency assert new_params["c"].independent is True - assert hasattr(new_params["c"], '_pending_dependency_string') - + assert hasattr(new_params["c"], "_pending_dependency_string") + # Resolve dependencies resolve_all_parameter_dependencies(new_params) - + # Alternative simplified approach using the helper function: # global_object.map._clear() # new_params = deserialize_and_resolve_parameters(params_data) - + # After resolution, c should be dependent and functional assert new_params["c"].independent is False assert new_params["c"].value == 5.0 # Still 2 + 3 - + # Test that dependency still works new_params["a"].value = 10.0 assert new_params["c"].value == 13.0 # 10 + 3 + def test_dependency_resolution_after_deserialization_desired_unit( + self, clear_global_map + ): + """Test that dependencies are properly resolved after deserialization.""" + # Create test parameters with dependencies + a = Parameter(name="a", value=2.0, unit="m", min=0, max=10) + b = Parameter(name="b", value=3.0, unit="m", min=0, max=10) + + c = Parameter.from_dependency( + name="c", + dependency_expression="a + b", + dependency_map={"a": a, "b": b}, + desired_unit="cm", + ) + + # Verify original dependency works + assert c.value == 5.0 * 100 # 2 + 3 + assert c.unit == "cm" + + # Serialize all parameters + params_data = {"a": a.as_dict(), "b": b.as_dict(), "c": c.as_dict()} + + # Clear and deserialize (manual approach) + global_object.map._clear() + new_params = {} + for name, data in params_data.items(): + new_params[name] = Parameter.from_dict(data) + + # Before resolution, c should be independent with pending dependency + assert new_params["c"].independent is True + assert hasattr(new_params["c"], "_pending_dependency_string") + + # Resolve dependencies + resolve_all_parameter_dependencies(new_params) + + # Alternative simplified approach using the helper function: + # global_object.map._clear() + # new_params = deserialize_and_resolve_parameters(params_data) + + # After resolution, c should be dependent and functional + assert new_params["c"].independent is False + assert new_params["c"]._desired_unit == "cm" # Desired unit should be preserved + assert new_params["c"].value == 5.0 * 100 # Still 2 + 3, converted to cm + + # Test that dependency still works + new_params["a"].value = 10.0 + assert new_params["c"].value == 13.0 * 100 # 10 + 3 + assert new_params["c"].unit == "cm" + def test_unique_name_dependency_serialization(self, clear_global_map): """Test serialization of dependencies using unique names.""" a = Parameter(name="a", value=3.0, unit="m", min=0, max=10) - + # Create dependent parameter using unique name b = Parameter.from_dependency( - name="b", + name="b", dependency_expression='2 * "Parameter_0"', # Using unique name - unit="m" + unit="m", ) - + # Serialize both parameters a_serialized = a.as_dict() b_serialized = b.as_dict() - + # Should contain unique name mapping - assert b_serialized['_dependency_string'] == '2 * __Parameter_0__' - assert "__Parameter_0__" in b_serialized['_dependency_map_serializer_ids'] - assert b_serialized['_dependency_map_serializer_ids']["__Parameter_0__"] == a._DescriptorNumber__serializer_id - + assert b_serialized["_dependency_string"] == "2 * __Parameter_0__" + assert "__Parameter_0__" in b_serialized["_dependency_map_serializer_ids"] + assert ( + b_serialized["_dependency_map_serializer_ids"]["__Parameter_0__"] + == a._DescriptorNumber__serializer_id + ) + # Deserialize both and resolve global_object.map._clear() - c = Parameter(name='c', value=0.0) # Dummy to occupy unique name, to force new unique_names + c = Parameter( + name="c", value=0.0 + ) # Dummy to occupy unique name, to force new unique_names # Remove unique_name from serialized data to force generation of new unique names - a_serialized.pop('unique_name', None) - b_serialized.pop('unique_name', None) + a_serialized.pop("unique_name", None) + b_serialized.pop("unique_name", None) new_b = Parameter.from_dict(b_serialized) new_a = Parameter.from_dict(a_serialized) @@ -174,35 +233,35 @@ def test_json_serialization_roundtrip(self, clear_global_map): # Create parameters with dependencies length = Parameter(name="length", value=10.0, unit="m", min=0, max=100) width = Parameter(name="width", value=5.0, unit="m", min=0, max=50) - + area = Parameter.from_dependency( name="area", dependency_expression="length * width", dependency_map={"length": length, "width": width}, - unit="m^2" + unit="m^2", ) - + # Serialize to JSON params_data = { "length": length.as_dict(), "width": width.as_dict(), - "area": area.as_dict() + "area": area.as_dict(), } json_str = json.dumps(params_data, default=str) - + # Deserialize from JSON global_object.map._clear() loaded_data = json.loads(json_str) new_params = {} for name, data in loaded_data.items(): new_params[name] = Parameter.from_dict(data) - + # Resolve dependencies resolve_all_parameter_dependencies(new_params) - + # Test functionality assert new_params["area"].value == 50.0 # 10 * 5 - + # Test dependency updates new_params["length"].value = 20.0 assert new_params["area"].value == 100.0 # 20 * 5 @@ -211,44 +270,37 @@ def test_multiple_dependent_parameters(self, clear_global_map): """Test serialization with multiple dependent parameters.""" # Create a chain of dependencies x = Parameter(name="x", value=2.0, unit="m", min=0, max=10) - + y = Parameter.from_dependency( - name="y", - dependency_expression="2 * x", - dependency_map={"x": x}, - unit="m" + name="y", dependency_expression="2 * x", dependency_map={"x": x}, unit="m" ) - + z = Parameter.from_dependency( - name="z", + name="z", dependency_expression="y + x", dependency_map={"y": y, "x": x}, - unit="m" + unit="m", ) - + # Verify original chain works assert y.value == 4.0 # 2 * 2 assert z.value == 6.0 # 4 + 2 - + # Serialize all - params_data = { - "x": x.as_dict(), - "y": y.as_dict(), - "z": z.as_dict() - } - + params_data = {"x": x.as_dict(), "y": y.as_dict(), "z": z.as_dict()} + # Deserialize and resolve global_object.map._clear() new_params = {} for name, data in params_data.items(): new_params[name] = Parameter.from_dict(data) - + resolve_all_parameter_dependencies(new_params) - + # Test chain still works assert new_params["y"].value == 4.0 assert new_params["z"].value == 6.0 - + # Test cascade updates new_params["x"].value = 5.0 assert new_params["y"].value == 10.0 # 2 * 5 @@ -257,6 +309,7 @@ def test_multiple_dependent_parameters(self, clear_global_map): def test_dependency_with_descriptor_number(self, clear_global_map): """Test that dependencies involving DescriptorNumber serialize correctly.""" from easyscience.variable import DescriptorNumber + # When x = DescriptorNumber(name="x", value=3.0, unit="m") @@ -272,11 +325,7 @@ def test_dependency_with_descriptor_number(self, clear_global_map): # Then # Serialize all - params_data = { - "x": x.as_dict(), - "y": y.as_dict(), - "z": z.as_dict() - } + params_data = {"x": x.as_dict(), "y": y.as_dict(), "z": z.as_dict()} # Deserialize and resolve global_object.map._clear() new_params = {} @@ -303,26 +352,23 @@ def test_get_parameters_with_pending_dependencies(self, clear_global_map): # Create parameters a = Parameter(name="a", value=1.0, unit="m") b = Parameter.from_dependency( - name="b", - dependency_expression="2 * a", - dependency_map={"a": a}, - unit="m" + name="b", dependency_expression="2 * a", dependency_map={"a": a}, unit="m" ) - + # Serialize and deserialize params_data = {"a": a.as_dict(), "b": b.as_dict()} global_object.map._clear() new_params = {} for name, data in params_data.items(): new_params[name] = Parameter.from_dict(data) - + # Find pending dependencies pending = get_parameters_with_pending_dependencies(new_params) - + assert len(pending) == 1 assert pending[0].name == "b" - assert hasattr(pending[0], '_pending_dependency_string') - + assert hasattr(pending[0], "_pending_dependency_string") + # After resolution, should be empty resolve_all_parameter_dependencies(new_params) pending_after = get_parameters_with_pending_dependencies(new_params) @@ -332,54 +378,54 @@ def test_error_handling_missing_dependency(self, clear_global_map): """Test error handling when dependency cannot be resolved.""" a = Parameter(name="a", value=1.0, unit="m") b = Parameter.from_dependency( - name="b", - dependency_expression="2 * a", - dependency_map={"a": a}, - unit="m" + name="b", dependency_expression="2 * a", dependency_map={"a": a}, unit="m" ) - + # Serialize b but not a b_data = b.as_dict() - + # Deserialize without a in the global map global_object.map._clear() new_b = Parameter.from_dict(b_data) - + # Should raise error when trying to resolve - with pytest.raises(ValueError, match="Cannot find parameter with serializer_id"): + with pytest.raises( + ValueError, match="Cannot find parameter with serializer_id" + ): new_b.resolve_pending_dependencies() def test_backward_compatibility_base_deserializer(self, clear_global_map): """Test that the base deserializer path still works for dependent parameters.""" from easyscience.io.serializer_dict import SerializerDict - + # Create dependent parameter a = Parameter(name="a", value=2.0, unit="m") b = Parameter.from_dependency( - name="b", - dependency_expression="3 * a", - dependency_map={"a": a}, - unit="m" + name="b", dependency_expression="3 * a", dependency_map={"a": a}, unit="m" ) - + # Use base serializer path (SerializerDict.decode) serialized = b.encode(encoder=SerializerDict) global_object.map._clear() - + # This should not raise the "_independent" error anymore deserialized = SerializerDict.decode(serialized) - + # Should be a valid Parameter (but without dependency resolution) assert isinstance(deserialized, Parameter) assert deserialized.name == "b" assert deserialized.independent is True # Base path doesn't handle dependencies - @pytest.mark.parametrize("order", [ - ["x", "y", "z"], - ["z", "x", "y"], - ["y", "z", "x"], - ["z", "y", "x"] - ], ids=['normal_order', 'dependent_first', 'mixed_order', 'dependent_first_reverse']) + @pytest.mark.parametrize( + "order", + [["x", "y", "z"], ["z", "x", "y"], ["y", "z", "x"], ["z", "y", "x"]], + ids=[ + "normal_order", + "dependent_first", + "mixed_order", + "dependent_first_reverse", + ], + ) def test_serializer_id_system_order_independence(self, clear_global_map, order): """Test that dependency IDs allow parameters to be loaded in any order.""" # WHEN @@ -391,7 +437,7 @@ def test_serializer_id_system_order_independence(self, clear_global_map, order): name="z", dependency_expression="x * y", dependency_map={"x": x, "y": y}, - unit="m^2" + unit="m^2", ) # Verify original functionality @@ -402,11 +448,7 @@ def test_serializer_id_system_order_independence(self, clear_global_map, order): y_dep_id = y._DescriptorNumber__serializer_id # Serialize all parameters - params_data = { - "x": x.as_dict(), - "y": y.as_dict(), - "z": z.as_dict() - } + params_data = {"x": x.as_dict(), "y": y.as_dict(), "z": z.as_dict()} # Verify dependency IDs are in serialized data assert params_data["x"]["__serializer_id"] == x_dep_id @@ -451,18 +493,14 @@ def test_deserialize_and_resolve_parameters_helper(self, clear_global_map): name="c", dependency_expression="a + b", dependency_map={"a": a, "b": b}, - unit="m" + unit="m", ) # Verify original dependency works assert c.value == 5.0 # 2 + 3 # Serialize all parameters - params_data = { - "a": a.as_dict(), - "b": b.as_dict(), - "c": c.as_dict() - } + params_data = {"a": a.as_dict(), "b": b.as_dict(), "c": c.as_dict()} # Clear global map global_object.map._clear() @@ -497,4 +535,3 @@ def test_deserialize_and_resolve_parameters_helper(self, clear_global_map): # Verify no pending dependencies remain pending = get_parameters_with_pending_dependencies(new_params) assert len(pending) == 0 -