⚠ This page is served via a proxy. Original site: https://github.com
This service does not collect credentials or authentication data.
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 49 additions & 4 deletions src/easyscience/variable/parameter.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,23 +121,29 @@ 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.

: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:
Expand All @@ -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)

Comment on lines +169 to +170
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_convert_unit doesn't catch any exceptions and we aren't checking anything here. Maybe add some exception handling so it doesn't bubble up potentially leaving the parameter in a weird state?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is on purpose. To keep the _update method as fast as possible, all the checks are made in the make_dependent_on method. If this method succeeds, then the _update will also always succeed.
This is also how we did it for the other updates :)

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.
Comment on lines +176 to 179
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no validation on unit type. A user can pass unit=1234 and this will get accepted.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added validation.


Expand All @@ -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.')
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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.')

Expand Down Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is missing a check on the type of unit_str

self._update()

@property
def min(self) -> numbers.Number:
"""
Expand Down
197 changes: 197 additions & 0 deletions tests/unit_tests/variable/test_parameter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)


Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no test verifying that _desired_unit is properly handled when calling make_dependent_on on an already-dependent parameter (i.e., overwriting an existing dependency).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point! Expanded the test_dependent_parameter_make_dependent_on_with_desired_unit test

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(
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading