From de923eab0598c211d97eb462cc7f6d6b5624b4bf Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Fri, 9 Jan 2026 14:58:58 +0100 Subject: [PATCH 1/2] Added desired units and tests --- src/easyscience/variable/parameter.py | 40 +++++- tests/unit_tests/variable/test_parameter.py | 142 ++++++++++++++++++++ 2 files changed, 178 insertions(+), 4 deletions(-) diff --git a/src/easyscience/variable/parameter.py b/src/easyscience/variable/parameter.py index 55787ad4..eadf6086 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,19 @@ 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: + try: + self._convert_unit(self._desired_unit) + except Exception as e: + raise UnitError(f'Failed to convert unit from {temporary_parameter.unit} to {self._desired_unit}: {e}') 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 +197,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 +236,8 @@ 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 {} + # TODO: checks + self._desired_unit = unit # List of allowed python constructs for the asteval interpreter asteval_config = { 'import': False, @@ -306,6 +325,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 +490,18 @@ def convert_unit(self, unit_str: str) -> None: """ self._convert_unit(unit_str) + def set_desired_unit(self, unit_str: str) -> 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.') + 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 0ca8f85e..42527803 100644 --- a/tests/unit_tests/variable/test_parameter.py +++ b/tests/unit_tests/variable/test_parameter.py @@ -136,6 +136,49 @@ 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" + + + 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_parameter_from_dependency(self, normal_parameter: Parameter): # When Then dependent_parameter = Parameter.from_dependency( @@ -159,6 +202,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 +565,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 +630,53 @@ 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_fixed(self, parameter: Parameter): # When Then parameter.fixed = True From 585305e298a1f8362b92dbd1f05d12de879ef02e Mon Sep 17 00:00:00 2001 From: henrikjacobsenfys Date: Fri, 9 Jan 2026 15:01:08 +0100 Subject: [PATCH 2/2] small update --- src/easyscience/variable/parameter.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/easyscience/variable/parameter.py b/src/easyscience/variable/parameter.py index eadf6086..893f25c9 100644 --- a/src/easyscience/variable/parameter.py +++ b/src/easyscience/variable/parameter.py @@ -236,7 +236,6 @@ def make_dependent_on( self._independent = False self._dependency_string = dependency_expression self._dependency_map = dependency_map if dependency_map is not None else {} - # TODO: checks self._desired_unit = unit # List of allowed python constructs for the asteval interpreter asteval_config = {