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
39 changes: 35 additions & 4 deletions src/easyscience/variable/parameter.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,23 +121,29 @@

@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,19 @@
) # 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}')
Copy link
Member

Choose a reason for hiding this comment

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

Should we completely bail out on bad unit conversion? Maybe return the original value with the original unit and warn users of the failure of the unit conversion.

Copy link
Member Author

Choose a reason for hiding this comment

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

Hmm, good question. I tend to think yes, since something must have gone wrong if the user expects a unit that is incompatible with the calculated 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.
Expand All @@ -183,6 +197,9 @@
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 +236,7 @@
self._independent = False
self._dependency_string = dependency_expression
self._dependency_map = dependency_map if dependency_map is not None else {}
self._desired_unit = unit
# List of allowed python constructs for the asteval interpreter
asteval_config = {
'import': False,
Expand Down Expand Up @@ -306,6 +324,7 @@
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 +489,18 @@
"""
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.')

Check warning on line 500 in src/easyscience/variable/parameter.py

View check run for this annotation

Codecov / codecov/patch

src/easyscience/variable/parameter.py#L500

Added line #L500 was not covered by tests
self._desired_unit = unit_str
self._update()

@property
def min(self) -> numbers.Number:
"""
Expand Down
142 changes: 142 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,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(
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading