diff --git a/pyproject.toml b/pyproject.toml index 913555c..1818bda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,6 +84,7 @@ dev-pytest = [ "pytest-mock == 3.14.0", "pytest-asyncio == 0.23.7", "async-solipsism == 0.6", + "hypothesis == 6.103.2", ] dev = [ "frequenz-core[dev-mkdocs,dev-flake8,dev-formatting,dev-mkdocs,dev-mypy,dev-noxfile,dev-pylint,dev-pytest]", diff --git a/src/frequenz/core/math.py b/src/frequenz/core/math.py new file mode 100644 index 0000000..e1b51a5 --- /dev/null +++ b/src/frequenz/core/math.py @@ -0,0 +1,25 @@ +# License: MIT +# Copyright © 2023 Frequenz Energy-as-a-Service GmbH + +"""Internal math tools.""" + +import math + + +def is_close_to_zero(value: float, abs_tol: float = 1e-9) -> bool: + """Check if a floating point value is close to zero. + + A value of 1e-9 is a commonly used absolute tolerance to balance precision + and robustness for floating-point numbers comparisons close to zero. Note + that this is also the default value for the relative tolerance. + For more technical details, see https://peps.python.org/pep-0485/#behavior-near-zero + + Args: + value: the floating point value to compare to. + abs_tol: the minimum absolute tolerance. Defaults to 1e-9. + + Returns: + whether the floating point value is close to zero. + """ + zero: float = 0.0 + return math.isclose(a=value, b=zero, abs_tol=abs_tol) diff --git a/tests/test_math.py b/tests/test_math.py new file mode 100644 index 0000000..ba7d934 --- /dev/null +++ b/tests/test_math.py @@ -0,0 +1,55 @@ +# License: MIT +# Copyright © 2024 Frequenz Energy-as-a-Service GmbH + +"""Tests for the math module.""" + +from hypothesis import given +from hypothesis import strategies as st + +from frequenz.core.math import is_close_to_zero + +# We first do some regular test cases to avoid mistakes using hypothesis and having +# basic cases not working. + + +def test_is_close_to_zero_default_tolerance() -> None: + """Test the is_close_to_zero function with the default tolerance.""" + assert is_close_to_zero(0.0) + assert is_close_to_zero(1e-10) + assert not is_close_to_zero(1e-8) + + +def test_is_close_to_zero_custom_tolerance() -> None: + """Test the is_close_to_zero function with a custom tolerance.""" + assert is_close_to_zero(0.0, abs_tol=1e-8) + assert is_close_to_zero(1e-8, abs_tol=1e-8) + assert not is_close_to_zero(1e-7, abs_tol=1e-8) + + +def test_is_close_to_zero_negative_values() -> None: + """Test the is_close_to_zero function with negative values.""" + assert is_close_to_zero(-1e-10) + assert not is_close_to_zero(-1e-8) + + +@given(st.floats(allow_nan=False, allow_infinity=False)) +def test_is_close_to_zero_default_tolerance_hypothesis(value: float) -> None: + """Test the is_close_to_zero function with the default tolerance for many values.""" + if -1e-9 <= value <= 1e-9: + assert is_close_to_zero(value) + else: + assert not is_close_to_zero(value) + + +@given( + st.floats(allow_nan=False, allow_infinity=False), + st.floats(allow_nan=False, allow_infinity=False, min_value=0.0, max_value=2.0), +) +def test_is_close_to_zero_custom_tolerance_hypothesis( + value: float, abs_tol: float +) -> None: + """Test the is_close_to_zero function with a custom tolerance with many values/tolerance.""" + if -abs_tol <= value <= abs_tol: + assert is_close_to_zero(value, abs_tol=abs_tol) + else: + assert not is_close_to_zero(value, abs_tol=abs_tol)