diff --git a/src/easyscience/variable/parameter.py b/src/easyscience/variable/parameter.py index 55787ad..d7ad9e6 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, + unit: str | sc.Unit | None = None, + **kwargs, ) -> Parameter: # noqa: E501 """ Create a dependent Parameter directly from a dependency expression. @@ -129,15 +134,16 @@ 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 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, unit=unit) return parameter def _update(self) -> None: @@ -158,11 +164,17 @@ 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, 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 +195,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 unit: + The desired unit of the dependent parameter. If None, the 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.') @@ -219,6 +234,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 unit is not None and not (isinstance(unit, str) or isinstance(unit, sc.Unit)): + raise TypeError('`unit` must be a string representing a valid unit.') + self._desired_unit = unit # List of allowed python constructs for the asteval interpreter asteval_config = { 'import': False, @@ -289,6 +307,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 +335,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 +500,21 @@ 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.') + + self._desired_unit = unit_str + self._update() + @property def min(self) -> numbers.Number: """ diff --git a/tests/unit_tests/variable/test_parameter.py b/tests/unit_tests/variable/test_parameter.py index 0ca8f85..b0b9083 100644 --- a/tests/unit_tests/variable/test_parameter.py +++ b/tests/unit_tests/variable/test_parameter.py @@ -136,6 +136,85 @@ def test_make_dependent_on(self, normal_parameter: Parameter): normal_parameter.value == 4 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}, 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}, 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): + normal_parameter.make_dependent_on(dependency_expression='2*a', dependency_map={'a': independent_parameter}, 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): + normal_parameter.make_dependent_on(dependency_expression='2*a', dependency_map={'a': independent_parameter}, unit=123) + + def test_parameter_from_dependency(self, normal_parameter: Parameter): # When Then dependent_parameter = Parameter.from_dependency( @@ -159,6 +238,57 @@ def test_parameter_from_dependency(self, normal_parameter: Parameter): # Expect self.compare_parameters(dependent_parameter, 2*normal_parameter) + + def test_parameter_from_dependency_with_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', + 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', + unit = "s", + ) + + def test_dependent_parameter_with_unique_name(self, clear, normal_parameter: Parameter): # When Then dependent_parameter = Parameter.from_dependency( @@ -471,6 +601,7 @@ def test_dependent_parameter_dependency_map_setter(self, normal_parameter: Param with pytest.raises(AttributeError): dependent_parameter.dependency_map = {'a': normal_parameter} + def test_min(self, parameter: Parameter): # When Then Expect assert parameter.min == 0 @@ -535,6 +666,72 @@ 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): + dependent_parameter.set_desired_unit("s") + + 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 parameter.fixed = True