diff --git a/.codecov.yml b/.codecov.yml index 9bf66ff7..bfdc9877 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -1,15 +1,8 @@ -codecov: - token: c2f0ce36-17ad-4668-bb1b-f1b72dedf3fc - comment: - after_n_builds: 9 - coverage: - precision: 2 - round: down - range: "90...100" status: project: default: - target: auto - threshold: 2.0% + informational: true + patch: + default: informational: true diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 86e363d9..ef1f5969 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -34,10 +34,11 @@ jobs: cd tests python -m pytest --cov=uncertainties --cov=. --cov-report=xml --cov-report=term - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v4.0.1 + uses: codecov/codecov-action@v4.6.0 with: - token: ${{ secrets.CODECOV_TOKEN }} - slug: lmfit/uncertainties + flags: ${{ matrix.os }}-${{ matrix.python-version }} + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} test_without_numpy: name: Test without numpy runs-on: ubuntu-latest @@ -46,7 +47,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python-version }} + python-version: 3.12 - name: Install dependencies run: | python -m pip install --upgrade pip @@ -57,10 +58,11 @@ jobs: python -m pytest --ignore=test_unumpy.py --ignore=test_ulinalg.py -k "not test_monte_carlo_comparison" --cov=uncertainties --cov=. --cov-report=xml --cov-report=term - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v4.0.1 + uses: codecov/codecov-action@v4.6.0 with: - token: ${{ secrets.CODECOV_TOKEN }} - slug: lmfit/uncertainties + flags: no-numpy + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} results: # This step aggregates the results from all the tests and allows us to # require only this single job to pass for a PR to be merged rather than diff --git a/CHANGES.rst b/CHANGES.rst index c45ef266..c9263ab6 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,9 +4,21 @@ Change Log Unreleased ---------- +Changes + +- Changed how `numpy` is handled as an optional dependency. Previously, + importing a `numpy`-dependent function, like `correlated_values`, + without `numpy` installed would result in an `ImportError` at import + time. Now such a function can be imported but if the user attempts to + execute it, a `NotImplementedError` is raised indicating that the + function can't be used because `numpy` couldn't be imported. + Fixes: - fix `readthedocs` configuration so that the build passes (#254) +- adjust `codecov.io` configuration so that minor code coverage changes will not result + in indications that tests are failing. Rather code coverage reports will be purely + informational for code reviewers. Also fix other minor configuration issues. (#270) 3.2.2 2024-July-08 ----------------------- diff --git a/tests/test_uncertainties.py b/tests/test_uncertainties.py index 10af2791..0cb81807 100644 --- a/tests/test_uncertainties.py +++ b/tests/test_uncertainties.py @@ -2,9 +2,16 @@ import math import random # noqa +import pytest + import uncertainties.core as uncert_core from uncertainties.core import ufloat, AffineScalarFunc, ufloat_fromstr -from uncertainties import umath +from uncertainties import ( + umath, + correlated_values, + correlated_values_norm, + correlation_matrix, +) from helpers import ( power_special_cases, power_all_cases, @@ -15,6 +22,12 @@ ) +try: + import numpy as np +except ImportError: + np = None + + def test_value_construction(): """ Tests the various means of constructing a constant number with @@ -1313,3 +1326,42 @@ def test_correlated_values_correlation_mat(): numpy.array(cov_mat), numpy.array(uncert_core.covariance_matrix([x2, y2, z2])), ) + + +@pytest.mark.skipif( + np is not None, + reason="This test is only run when numpy is not installed.", +) +def test_no_numpy(): + nom_values = [1, 2, 3] + std_devs = [0.1, 0.2, 0.3] + cov = [ + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], + ] + + with pytest.raises( + NotImplementedError, + match="not able to import numpy", + ): + _ = correlated_values(nom_values, cov) + + with pytest.raises( + NotImplementedError, + match="not able to import numpy", + ): + _ = correlated_values_norm( + list(zip(nom_values, std_devs)), + cov, + ) + + x = ufloat(1, 0.1) + y = ufloat(2, 0.2) + z = ufloat(3, 0.3) + + with pytest.raises( + NotImplementedError, + match="not able to import numpy", + ): + _ = correlation_matrix([x, y, z]) diff --git a/uncertainties/core.py b/uncertainties/core.py index 6759ba6d..c521a87f 100644 --- a/uncertainties/core.py +++ b/uncertainties/core.py @@ -57,6 +57,9 @@ "nan_if_exception", "modified_operators", "modified_ops_with_reflection", + "correlated_values", + "correlated_values_norm", + "correlation_matrix", ] ############################################################################### @@ -66,138 +69,168 @@ try: import numpy except ImportError: - pass -else: - # Entering variables as a block of correlated values. Only available - # if NumPy is installed. + numpy = None - #! It would be possible to dispense with NumPy, but a routine should be - # written for obtaining the eigenvectors of a symmetric matrix. See - # for instance Numerical Recipes: (1) reduction to tri-diagonal - # [Givens or Householder]; (2) QR / QL decomposition. - def correlated_values(nom_values, covariance_mat, tags=None): - """ - Return numbers with uncertainties (AffineScalarFunc objects) - that correctly reproduce the given covariance matrix, and have - the given (float) values as their nominal value. +def correlated_values(nom_values, covariance_mat, tags=None): + """ + Return numbers with uncertainties (AffineScalarFunc objects) + that correctly reproduce the given covariance matrix, and have + the given (float) values as their nominal value. - The correlated_values_norm() function returns the same result, - but takes a correlation matrix instead of a covariance matrix. + The correlated_values_norm() function returns the same result, + but takes a correlation matrix instead of a covariance matrix. - The list of values and the covariance matrix must have the - same length, and the matrix must be a square (symmetric) one. + The list of values and the covariance matrix must have the + same length, and the matrix must be a square (symmetric) one. - The numbers with uncertainties returned depend on newly - created, independent variables (Variable objects). + The numbers with uncertainties returned depend on newly + created, independent variables (Variable objects). - nom_values -- sequence with the nominal (real) values of the - numbers with uncertainties to be returned. + nom_values -- sequence with the nominal (real) values of the + numbers with uncertainties to be returned. - covariance_mat -- full covariance matrix of the returned numbers with - uncertainties. For example, the first element of this matrix is the - variance of the first number with uncertainty. This matrix must be a - NumPy array-like (list of lists, NumPy array, etc.). + covariance_mat -- full covariance matrix of the returned numbers with + uncertainties. For example, the first element of this matrix is the + variance of the first number with uncertainty. This matrix must be a + NumPy array-like (list of lists, NumPy array, etc.). - tags -- if 'tags' is not None, it must list the tag of each new - independent variable. - """ + tags -- if 'tags' is not None, it must list the tag of each new + independent variable. - # !!! It would in principle be possible to handle 0 variance - # variables by first selecting the sub-matrix that does not contain - # such variables (with the help of numpy.ix_()), and creating - # them separately. - - std_devs = numpy.sqrt(numpy.diag(covariance_mat)) - - # For numerical stability reasons, we go through the correlation - # matrix, because it is insensitive to any change of scale in the - # quantities returned. However, care must be taken with 0 variance - # variables: calculating the correlation matrix cannot be simply done - # by dividing by standard deviations. We thus use specific - # normalization values, with no null value: - norm_vector = std_devs.copy() - norm_vector[norm_vector == 0] = 1 - - return correlated_values_norm( - # !! The following zip() is a bit suboptimal: correlated_values() - # separates back the nominal values and the standard deviations: - list(zip(nom_values, std_devs)), - covariance_mat / norm_vector / norm_vector[:, numpy.newaxis], - tags, + This function raises NotImplementedError if numpy cannot be + imported. + """ + if numpy is None: + msg = ( + "uncertainties was not able to import numpy so " + "correlated_values is unavailable." ) + raise NotImplementedError(msg) + # !!! It would in principle be possible to handle 0 variance + # variables by first selecting the sub-matrix that does not contain + # such variables (with the help of numpy.ix_()), and creating + # them separately. + + std_devs = numpy.sqrt(numpy.diag(covariance_mat)) + + # For numerical stability reasons, we go through the correlation + # matrix, because it is insensitive to any change of scale in the + # quantities returned. However, care must be taken with 0 variance + # variables: calculating the correlation matrix cannot be simply done + # by dividing by standard deviations. We thus use specific + # normalization values, with no null value: + norm_vector = std_devs.copy() + norm_vector[norm_vector == 0] = 1 + + return correlated_values_norm( + # !! The following zip() is a bit suboptimal: correlated_values() + # separates back the nominal values and the standard deviations: + list(zip(nom_values, std_devs)), + covariance_mat / norm_vector / norm_vector[:, numpy.newaxis], + tags, + ) - __all__.append("correlated_values") - def correlated_values_norm(values_with_std_dev, correlation_mat, tags=None): - """ - Return correlated values like correlated_values(), but takes - instead as input: - - - nominal (float) values along with their standard deviation, and - - a correlation matrix (i.e. a normalized covariance matrix). - - values_with_std_dev -- sequence of (nominal value, standard - deviation) pairs. The returned, correlated values have these - nominal values and standard deviations. - - correlation_mat -- correlation matrix between the given values, except - that any value with a 0 standard deviation must have its correlations - set to 0, with a diagonal element set to an arbitrary value (something - close to 0-1 is recommended, for a better numerical precision). When - no value has a 0 variance, this is the covariance matrix normalized by - standard deviations, and thus a symmetric matrix with ones on its - diagonal. This matrix must be an NumPy array-like (list of lists, - NumPy array, etc.). - - tags -- like for correlated_values(). - """ +def correlated_values_norm(values_with_std_dev, correlation_mat, tags=None): + """ + Return correlated values like correlated_values(), but takes + instead as input: - # If no tags were given, we prepare tags for the newly created - # variables: - if tags is None: - tags = (None,) * len(values_with_std_dev) + - nominal (float) values along with their standard deviation, and + - a correlation matrix (i.e. a normalized covariance matrix). - (nominal_values, std_devs) = numpy.transpose(values_with_std_dev) + values_with_std_dev -- sequence of (nominal value, standard + deviation) pairs. The returned, correlated values have these + nominal values and standard deviations. - # We diagonalize the correlation matrix instead of the - # covariance matrix, because this is generally more stable - # numerically. In fact, the covariance matrix can have - # coefficients with arbitrary values, through changes of units - # of its input variables. This creates numerical instabilities. - # - # The covariance matrix is diagonalized in order to define - # the independent variables that model the given values: - (variances, transform) = numpy.linalg.eigh(correlation_mat) + correlation_mat -- correlation matrix between the given values, except + that any value with a 0 standard deviation must have its correlations + set to 0, with a diagonal element set to an arbitrary value (something + close to 0-1 is recommended, for a better numerical precision). When + no value has a 0 variance, this is the covariance matrix normalized by + standard deviations, and thus a symmetric matrix with ones on its + diagonal. This matrix must be an NumPy array-like (list of lists, + NumPy array, etc.). - # Numerical errors might make some variances negative: we set - # them to zero: - variances[variances < 0] = 0.0 + tags -- like for correlated_values(). - # Creation of new, independent variables: + This function raises NotImplementedError if numpy cannot be + imported. + """ + if numpy is None: + msg = ( + "uncertainties was not able to import numpy so " + "correlated_values_norm is unavailable." + ) + raise NotImplementedError(msg) + + # If no tags were given, we prepare tags for the newly created + # variables: + if tags is None: + tags = (None,) * len(values_with_std_dev) + + (nominal_values, std_devs) = numpy.transpose(values_with_std_dev) + + # We diagonalize the correlation matrix instead of the + # covariance matrix, because this is generally more stable + # numerically. In fact, the covariance matrix can have + # coefficients with arbitrary values, through changes of units + # of its input variables. This creates numerical instabilities. + # + # The covariance matrix is diagonalized in order to define + # the independent variables that model the given values: + (variances, transform) = numpy.linalg.eigh(correlation_mat) + + # Numerical errors might make some variances negative: we set + # them to zero: + variances[variances < 0] = 0.0 + + # Creation of new, independent variables: + + # We use the fact that the eigenvectors in 'transform' are + # special: 'transform' is unitary: its inverse is its transpose: + + variables = tuple( + # The variables represent "pure" uncertainties: + Variable(0, sqrt(variance), tag) + for (variance, tag) in zip(variances, tags) + ) - # We use the fact that the eigenvectors in 'transform' are - # special: 'transform' is unitary: its inverse is its transpose: + # The coordinates of each new uncertainty as a function of the + # new variables must include the variable scale (standard deviation): + transform *= std_devs[:, numpy.newaxis] - variables = tuple( - # The variables represent "pure" uncertainties: - Variable(0, sqrt(variance), tag) - for (variance, tag) in zip(variances, tags) - ) + # Representation of the initial correlated values: + values_funcs = tuple( + AffineScalarFunc(value, LinearCombination(dict(zip(variables, coords)))) + for (coords, value) in zip(transform, nominal_values) + ) + + return values_funcs - # The coordinates of each new uncertainty as a function of the - # new variables must include the variable scale (standard deviation): - transform *= std_devs[:, numpy.newaxis] - # Representation of the initial correlated values: - values_funcs = tuple( - AffineScalarFunc(value, LinearCombination(dict(zip(variables, coords)))) - for (coords, value) in zip(transform, nominal_values) +def correlation_matrix(nums_with_uncert): + """ + Return the correlation matrix of the given sequence of + numbers with uncertainties, as a NumPy array of floats. + + This function raises NotImplementedError if numpy cannot be + imported. + """ + if numpy is None: + msg = ( + "uncertainties was not able to import numpy so " + "correlation_matrix is unavailable." ) + raise NotImplementedError(msg) + + cov_mat = numpy.array(covariance_matrix(nums_with_uncert)) - return values_funcs + std_devs = numpy.sqrt(cov_mat.diagonal()) + + return cov_mat / std_devs / std_devs[numpy.newaxis].T - __all__.append("correlated_values_norm") ############################################################################### @@ -927,27 +960,6 @@ def covariance_matrix(nums_with_uncert): return covariance_matrix -try: - import numpy -except ImportError: - pass -else: - - def correlation_matrix(nums_with_uncert): - """ - Return the correlation matrix of the given sequence of - numbers with uncertainties, as a NumPy array of floats. - """ - - cov_mat = numpy.array(covariance_matrix(nums_with_uncert)) - - std_devs = numpy.sqrt(cov_mat.diagonal()) - - return cov_mat / std_devs / std_devs[numpy.newaxis].T - - __all__.append("correlation_matrix") - - def ufloat_fromstr(representation, tag=None): """ Create an uncertainties Variable from a string representation.