From f53bda31e3fdc703023bac94aa40c10df0c6e0f9 Mon Sep 17 00:00:00 2001 From: saudzahirr Date: Sat, 15 Aug 2026 16:40:00 +0500 Subject: [PATCH] Raise test coverage to 99% and fix a constant-value regression Coverage had fallen to 76% because most of the library was never exercised: umath was at 54% with nearly every trigonometric, hyperbolic and special function untested, and the operator overloads, the distribution constructors and the printed reports were largely untouched. The new tests take the package to 99%. - test_umath_functions.py parametrizes all 39 unary functions plus the stepwise, power and binary ones, checking each twice: once with a plain float and once with an uncertain input. Reference values come from the standard library, so a wrong derivative shows up as a mismatch instead of agreeing with itself. umath goes from 54% to 100%. - test_uncertain_function.py covers every operator, the derivative accessors, and all four combinations of error_components. - test_method_of_moments.py covers the standardization helpers, the argument checks, the printed report and raw2central. - test_distributions.py covers all eleven constructors against their closed-form mean and variance, plus every parameter-validation path. - test_uncertain_variable.py gains the setters and the plotting paths. Writing them surfaced a real regression from the FORTRAN port: a constant UncertainFunction has no input variables, so the kernel was handed zero-length arrays and f2py raised ValueError: unexpected array size: new_size=1, got array with arr_size=0 The Python loops it replaced simply iterated zero times, so str(UncertainFunction(4.0)) used to work and had stopped. rawmoment, variance_components and soerp_numeric now short-circuit when there are no variables, which is also what to_uncertain_func needs for a plain number. Coverage is now reported against src/soerp rather than site-packages. The package carries a compiled extension so it cannot be imported from the source tree, and without [tool.coverage.paths] the results do not line up with the repository. matplotlib joins the test group so the optional plotting path is exercised rather than skipped. Two small things left uncovered deliberately: the PackageNotFoundError fallback in __init__, which needs the package reloaded to reach, and the defensive else branches in the error_components report, which cannot be reached because the dictionaries are built from the same variable list they are then looked up in. Co-authored-by: Claude Opus 5 Co-authored-by: codex <267193182+codex@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: gemini-code-assist Co-authored-by: Junie Co-authored-by: Qwen-Coder Co-authored-by: Cursor Agent Co-authored-by: kilocode-bot --- pyproject.toml | 22 +++ src/soerp/method_of_moments.py | 23 ++- tests/test_distributions.py | 168 ++++++++++++++++++ tests/test_method_of_moments.py | 241 +++++++++++++++++++++++++ tests/test_umath_functions.py | 194 ++++++++++++++++++++ tests/test_uncertain_function.py | 295 +++++++++++++++++++++++++++++++ tests/test_uncertain_variable.py | 96 +++++++++- 7 files changed, 1034 insertions(+), 5 deletions(-) create mode 100644 tests/test_distributions.py create mode 100644 tests/test_method_of_moments.py create mode 100644 tests/test_umath_functions.py create mode 100644 tests/test_uncertain_function.py diff --git a/pyproject.toml b/pyproject.toml index 1940c73..0665f4d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -90,6 +90,7 @@ test = [ "pytest>=8.3.5", "pytest-cov>=7.0.0", "pytest-xdist>=3.6.1", + "matplotlib", ] [project.optional-dependencies] @@ -158,8 +159,14 @@ ignore = [ "missing-type-function-argument", # [flake8-annotations](https://docs.astral.sh/ruff/rules/missing-type-function-argument/) "missing-return-type-undocumented-public-function", # [flake8-annotations](https://docs.astral.sh/ruff/rules/missing-return-type-undocumented-public-function/) "missing-return-type-private-function", # [flake8-annotations](https://docs.astral.sh/ruff/rules/missing-return-type-private-function/) + "missing-return-type-static-method", # [flake8-annotations](https://docs.astral.sh/ruff/rules/missing-return-type-static-method/) - fixture helpers "assert", # [flake8-bandit](https://docs.astral.sh/ruff/rules/assert/) "no-self-use", # [pylint](https://docs.astral.sh/ruff/rules/no-self-use/) + # Setters and structural zeros are stored verbatim, so exact equality is + # the property under test rather than a floating-point hazard. + "float-equality-comparison", # [pylint](https://docs.astral.sh/ruff/rules/float-equality-comparison/) + # The matplotlib backend has to be selected before pyplot is imported. + "import-outside-top-level", # [pylint](https://docs.astral.sh/ruff/rules/import-outside-top-level/) ] [tool.ruff.format] @@ -170,3 +177,18 @@ skip-magic-trailing-comma = true addopts = "--doctest-modules" testpaths = ["tests"] python_files = ["test_*.py"] + +[tool.coverage.run] +source = ["soerp"] + +# The package is imported from site-packages (it carries a compiled +# extension, so it cannot be imported from the source tree), but coverage +# must be reported against src/soerp for the results to line up with the +# repository. +[tool.coverage.paths] +source = ["src/soerp", "*/site-packages/soerp"] + +[tool.coverage.report] +exclude_also = [ + "if TYPE_CHECKING:", +] diff --git a/src/soerp/method_of_moments.py b/src/soerp/method_of_moments.py index 142e5b4..e427e9a 100644 --- a/src/soerp/method_of_moments.py +++ b/src/soerp/method_of_moments.py @@ -216,6 +216,9 @@ def rawmoment( f"Can only calculate raw moments k = 0 to {MAX_MOMENT}" ) lc, qc, cp, moments = _kernel_inputs(slc, sqc, scp, vm) + if lc.size == 0: + # A constant carries no variables, so y is identically zero. + return 1.0 if k == 0 else 0.0 return float(_soerp.rawmom(lc, qc, cp, moments, k)) @@ -288,6 +291,8 @@ def variance_components( The actual variance components from the cross-product 2nd-order terms """ lc, qc, cp, moments = _kernel_inputs(slc, sqc, scp, var_moments) + if lc.size == 0: + return np.zeros(0), np.zeros(0), np.zeros((0, 0)) return _soerp.varcmp(lc, qc, cp, moments, vz[2]) @@ -345,7 +350,7 @@ def variance_contrib( ############################################################################### -def soerp_numeric( # ruff: ignore[too-many-arguments, too-many-positional-arguments, too-many-branches, too-many-locals] +def soerp_numeric( # ruff: ignore[too-many-arguments, too-many-positional-arguments, too-many-branches, too-many-locals, too-many-statements] slc: NDArray, sqc: NDArray, scp: NDArray, @@ -444,9 +449,19 @@ def soerp_numeric( # ruff: ignore[too-many-arguments, too-many-positional-argum lc, qc, cp, moments = _kernel_inputs(slc, sqc, scp, var_moments) - # A single trip across the language boundary: the raw moments, the - # central moments and every variance component come back from one call. - vy, vz, vc_lc, vc_qc, vc_cp = _soerp.soerpm(lc, qc, cp, moments) + if lc.size == 0: + # A constant: y is identically zero, so only the 0th moment is 1 + # and there are no variance components to report. + vy = np.zeros(MAX_MOMENT + 1) + vz = np.zeros(MAX_MOMENT + 1) + vy[0] = vz[0] = 1.0 + vc_lc = np.zeros(0) + vc_qc = np.zeros(0) + vc_cp = np.zeros((0, 0)) + else: + # A single trip across the language boundary: the raw moments, the + # central moments and every variance component come back at once. + vy, vz, vc_lc, vc_qc, vc_cp = _soerp.soerpm(lc, qc, cp, moments) if debug and not silent: print("*" * 80) diff --git a/tests/test_distributions.py b/tests/test_distributions.py new file mode 100644 index 0000000..a3f27c9 --- /dev/null +++ b/tests/test_distributions.py @@ -0,0 +1,168 @@ +""" +Coverage of the convenience distribution constructors: each one must +produce the mean and variance of the distribution it names, and each one +must reject parameters outside its valid domain. +""" + +import math + +import pytest + +from soerp import distributions as dist + + +class TestConstructors: + """Mean and variance are checked against the closed-form values.""" + + def test_normal(self): + x = dist.normal(10.0, 2.0) + assert x.mean == pytest.approx(10.0) + assert x.var == pytest.approx(4.0) + + def test_uniform(self): + x = dist.uniform(2.0, 6.0) + assert x.mean == pytest.approx(4.0) + assert x.var == pytest.approx((6.0 - 2.0) ** 2 / 12) + + def test_exponential(self): + x = dist.exponential(2.0) # rate lambda = 2 -> mean 1/2 + assert x.mean == pytest.approx(0.5) + assert x.var == pytest.approx(0.25) + + def test_gamma(self): + k, theta = 3.0, 2.0 + x = dist.gamma(k, theta) + assert x.mean == pytest.approx(k * theta) + assert x.var == pytest.approx(k * theta**2) + + def test_beta(self): + a, b = 2.0, 3.0 + x = dist.beta(a, b) + assert x.mean == pytest.approx(a / (a + b)) + assert x.var == pytest.approx(a * b / ((a + b) ** 2 * (a + b + 1))) + + def test_beta_on_a_shifted_support(self): + x = dist.beta(2.0, 3.0, 10.0, 20.0) + assert x.mean == pytest.approx(10.0 + 10.0 * (2.0 / 5.0)) + + def test_log_normal(self): + mu, sigma = 0.0, 0.25 + x = dist.log_normal(mu, sigma) + assert x.mean == pytest.approx(math.exp(mu + sigma**2 / 2)) + + def test_chi_squared(self): + x = dist.chi_squared(9) + assert x.mean == pytest.approx(9.0) + assert x.var == pytest.approx(18.0) + + def test_f_distribution(self): + d1, d2 = 10, 20 # d2 > 16 so the 8th moment converges + x = dist.f_distribution(d1, d2) + assert x.mean == pytest.approx(d2 / (d2 - 2)) + + def test_triangular(self): + a, b, c = 0.0, 4.0, 1.0 + x = dist.triangular(a, b, c) + assert x.mean == pytest.approx((a + b + c) / 3) + + def test_student_t(self): + x = dist.student_t(12) + assert x.mean == pytest.approx(0.0, abs=1e-9) + assert x.var == pytest.approx(12 / (12 - 2)) + + def test_weibull(self): + lamda, k = 1.0, 3.0 + x = dist.weibull(lamda, k) + assert x.mean == pytest.approx(lamda * math.gamma(1 + 1 / k)) + + +class TestAliases: + @pytest.mark.parametrize( + ("alias", "full"), + [ + (dist.N, dist.normal), + (dist.U, dist.uniform), + (dist.Exp, dist.exponential), + (dist.Gamma, dist.gamma), + (dist.Beta, dist.beta), + (dist.LogN, dist.log_normal), + (dist.Chi2, dist.chi_squared), + (dist.F, dist.f_distribution), + (dist.Tri, dist.triangular), + (dist.T, dist.student_t), + (dist.Weib, dist.weibull), + ], + ) + def test_short_names_point_at_the_full_constructors(self, alias, full): + assert alias is full + + +class TestParameterValidation: + @pytest.mark.parametrize("sigma", [0.0, -1.0]) + def test_normal_rejects_non_positive_sigma(self, sigma): + with pytest.raises(ValueError, match="Sigma must be positive"): + dist.normal(0.0, sigma) + + @pytest.mark.parametrize(("a", "b"), [(5.0, 5.0), (6.0, 2.0)]) + def test_uniform_rejects_an_empty_support(self, a, b): + with pytest.raises(ValueError, match="Lower bound"): + dist.uniform(a, b) + + @pytest.mark.parametrize( + ("k", "theta"), [(0.0, 1.0), (1.0, 0.0), (-1.0, 1.0)] + ) + def test_gamma_rejects_non_positive_parameters(self, k, theta): + with pytest.raises(ValueError, match="greater than zero"): + dist.gamma(k, theta) + + @pytest.mark.parametrize(("a", "b"), [(0.0, 1.0), (1.0, 0.0), (-2.0, 1.0)]) + def test_beta_rejects_non_positive_shapes(self, a, b): + with pytest.raises(ValueError, match="greater than zero"): + dist.beta(a, b) + + @pytest.mark.parametrize("sigma", [0.0, -0.5]) + def test_log_normal_rejects_non_positive_sigma(self, sigma): + with pytest.raises(ValueError, match="Sigma must be positive"): + dist.log_normal(0.0, sigma) + + @pytest.mark.parametrize("df", [1, 0, -3, 2.5]) + def test_chi_squared_rejects_bad_degrees_of_freedom(self, df): + with pytest.raises(ValueError, match="DF must be an int"): + dist.chi_squared(df) + + def test_f_distribution_rejects_bad_numerator_df(self): + with pytest.raises(ValueError, match="d1 must be an int"): + dist.f_distribution(1, 12) + + def test_f_distribution_rejects_bad_denominator_df(self): + with pytest.raises(ValueError, match="d2 must be an int"): + dist.f_distribution(20, 1) + + @pytest.mark.parametrize( + ("a", "b", "c"), [(0.0, 4.0, 5.0), (0.0, 4.0, -1.0)] + ) + def test_triangular_rejects_a_peak_outside_the_support(self, a, b, c): + with pytest.raises(ValueError, match="peak must lie"): + dist.triangular(a, b, c) + + @pytest.mark.parametrize("v", [1, 0, 2.5]) + def test_student_t_rejects_bad_degrees_of_freedom(self, v): + with pytest.raises(ValueError, match="v must be an int"): + dist.student_t(v) + + @pytest.mark.parametrize( + ("lamda", "k"), [(0.0, 1.0), (1.0, 0.0), (-1.0, 2.0)] + ) + def test_weibull_rejects_non_positive_parameters(self, lamda, k): + with pytest.raises(ValueError, match="greater than zero"): + dist.weibull(lamda, k) + + +class TestTagging: + def test_tag_is_used_as_the_representation(self): + x = dist.normal(1.0, 0.1, tag="width") + assert repr(x) == "width" + + def test_without_a_tag_the_moments_are_shown(self): + x = dist.normal(1.0, 0.1) + assert repr(x).startswith("uv(") diff --git a/tests/test_method_of_moments.py b/tests/test_method_of_moments.py new file mode 100644 index 0000000..6abc936 --- /dev/null +++ b/tests/test_method_of_moments.py @@ -0,0 +1,241 @@ +""" +Coverage of the Python layer in ``soerp.method_of_moments``: the +standardization helpers, the argument checks around the FORTRAN kernel, +the printed report and the raw-to-central conversion. +""" + +from typing import ClassVar + +import numpy as np +import pytest + +import soerp.method_of_moments as mm +from soerp import raw2central +from soerp.method_of_moments import ( + centralmoment, + rawmoment, + soerp_numeric, + standard_cp, + standard_lc, + standard_qc, + standardize, + variance_components, + variance_contrib, +) + + +NORM = [1, 0, 1, 0, 3, 0, 15, 0, 105] + +LC = np.array([-802.65, -430.5]) +QC = np.array([205.54, 78.66]) +CP = np.array([[0.0, -216.5], [-216.5, 0.0]]) +VM = np.array([NORM, NORM], dtype=float) + + +class TestStandardization: + STDEVS = np.array([2.0, 3.0]) + + def test_standard_lc_scales_by_sigma(self): + got = standard_lc(np.array([1.0, 2.0]), self.STDEVS) + assert got == pytest.approx([2.0, 6.0]) + + def test_standard_qc_scales_by_variance(self): + got = standard_qc(np.array([1.0, 2.0]), self.STDEVS) + assert got == pytest.approx([4.0, 18.0]) + + def test_standard_cp_scales_by_both_sigmas(self): + cp = np.array([[0.0, 5.0], [5.0, 0.0]]) + got = standard_cp(cp, self.STDEVS) + assert got[0, 1] == pytest.approx(30.0) + assert got[1, 0] == pytest.approx(30.0) + + def test_standard_cp_single_variable_is_untouched(self): + cp = np.zeros((1, 1)) + assert standard_cp(cp, np.array([2.0])).shape == (1, 1) + + def test_standardize_returns_all_three(self): + cp = np.array([[0.0, 5.0], [5.0, 0.0]]) + slc, sqc, scp = standardize( + np.array([1.0, 2.0]), np.array([1.0, 2.0]), cp, self.STDEVS + ) + assert slc == pytest.approx([2.0, 6.0]) + assert sqc == pytest.approx([4.0, 18.0]) + assert scp[0, 1] == pytest.approx(30.0) + + +class TestMomentArgumentChecks: + @pytest.mark.parametrize("k", [-1, 5, 100]) + def test_rawmoment_rejects_unsupported_order(self, k): + with pytest.raises(ValueError, match="raw moments"): + rawmoment(LC, QC, CP, VM, k) + + @pytest.mark.parametrize("k", [-1, 5, 100]) + def test_centralmoment_rejects_unsupported_order(self, k): + with pytest.raises(ValueError, match="central moments"): + centralmoment([1.0, 0.0, 1.0, 0.0, 3.0], k) + + def test_rawmoment_accepts_plain_lists(self): + from_lists = rawmoment(list(LC), list(QC), [list(r) for r in CP], VM, 2) + assert from_lists == pytest.approx(rawmoment(LC, QC, CP, VM, 2)) + + def test_zeroth_moment_is_one(self): + assert rawmoment(LC, QC, CP, VM, 0) == pytest.approx(1.0) + + def test_centralmoment_accepts_short_input(self): + # Only the first three raw moments are supplied. + assert centralmoment([1.0, 2.0, 5.0], 2) == pytest.approx(1.0) + + +class TestCentralMoments: + """mu2 = 1, mu3 = 0 and mu4 = 6 for these raw moments.""" + + VY: ClassVar[list[float]] = [1.0, 2.0, 5.0, 14.0, 46.0] + + @pytest.mark.parametrize( + ("k", "want"), [(0, 1.0), (1, 0.0), (2, 1.0), (3, 0.0), (4, 6.0)] + ) + def test_known_values(self, k, want): + assert centralmoment(self.VY, k) == pytest.approx(want) + + +class TestAssumeLinear: + def test_second_order_terms_are_dropped(self, monkeypatch): + """With assume_linear set, only the linear coefficients survive.""" + monkeypatch.setattr(mm, "assume_linear", True) + got = rawmoment(LC, QC, CP, VM, 2) + expected = LC[0] ** 2 + LC[1] ** 2 # both inputs standardized + assert got == pytest.approx(expected) + + def test_first_moment_vanishes_without_quadratic_terms(self, monkeypatch): + monkeypatch.setattr(mm, "assume_linear", True) + assert rawmoment(LC, QC, CP, VM, 1) == pytest.approx(0.0) + + +class TestVarianceReporting: + def test_components_and_contributions(self): + vy = [rawmoment(LC, QC, CP, VM, k) for k in range(5)] + vz = [centralmoment(vy, k) for k in range(5)] + vc_lc, vc_qc, vc_cp = variance_components(LC, QC, CP, VM, vz) + + assert vc_lc.shape == (2,) + assert vc_qc.shape == (2,) + assert vc_cp.shape == (2, 2) + + clc, cqc, ccp = variance_contrib(vc_lc, vc_qc, vc_cp, vz) + assert np.all(clc >= 0) + assert np.all(cqc >= 0) + assert np.all(ccp >= 0) + + def test_contributions_are_zero_when_there_is_no_variance(self): + zeros = np.zeros(2) + clc, cqc, ccp = variance_contrib( + zeros, zeros, np.zeros((2, 2)), [1.0, 0.0, 0.0, 0.0, 0.0] + ) + assert np.all(clc == 0.0) + assert np.all(cqc == 0.0) + assert np.all(ccp == 0.0) + + +class TestSoerpNumeric: + """The published example from the original SOERP user guide.""" + + F0 = 4152 + EXPECTED: ClassVar[list[float]] = [4436.2, 973317.70, 0.61068742, 4.1121529] + + def test_silent_returns_the_four_moments(self): + got = soerp_numeric(LC, QC, CP, VM, self.F0, silent=True) + assert got[0] == pytest.approx(self.EXPECTED[0], rel=1e-6) + assert got[1] == pytest.approx(self.EXPECTED[1], rel=1e-6) + assert got[2] == pytest.approx(self.EXPECTED[2], rel=1e-6) + assert got[3] == pytest.approx(self.EXPECTED[3], rel=1e-6) + + def test_silent_prints_nothing(self, capsys): + soerp_numeric(LC, QC, CP, VM, self.F0, silent=True) + assert not capsys.readouterr().out + + def test_report_is_printed(self, capsys): + soerp_numeric(LC, QC, CP, VM, self.F0, title="EXAMPLE") + out = capsys.readouterr().out + assert "SOERP: EXAMPLE" in out + assert "MEAN-INTERCEPT (EDEL1)" in out + assert "COEFFICIENT OF KURTOSIS (BETA2)" in out + assert "Variance Contribution of lc[x0]" in out + assert "Variance Contribution of cp[x0, x1]" in out + + def test_report_without_a_title(self, capsys): + soerp_numeric(LC, QC, CP, VM, self.F0) + assert "VARIANCE (VARDL)" in capsys.readouterr().out + + def test_debug_reports_every_moment(self, capsys): + soerp_numeric(LC, QC, CP, VM, self.F0, debug=True) + out = capsys.readouterr().out + for k in range(5): + assert f"Raw Moment {k}:" in out + assert f"Central Moment {k}:" in out + + def test_debug_is_overridden_by_silent(self, capsys): + soerp_numeric(LC, QC, CP, VM, self.F0, debug=True, silent=True) + assert not capsys.readouterr().out + + def test_degenerate_input_has_no_skewness_or_kurtosis(self): + zeros = np.zeros(2) + got = soerp_numeric( + zeros, zeros, np.zeros((2, 2)), VM, 1.0, silent=True + ) + assert got[1] == pytest.approx(0.0) + assert got[2] == pytest.approx(0.0) + assert got[3] == pytest.approx(0.0) + + +class TestRaw2Central: + def test_matches_the_textbook_relations(self): + raw = [2.0, 5.0, 14.0, 46.0] + got = raw2central(raw) + assert got[0] == pytest.approx(0.0) + assert got[1] == pytest.approx(1.0) + assert got[2] == pytest.approx(0.0) + assert got[3] == pytest.approx(6.0) + + def test_length_is_preserved(self): + assert len(raw2central([1.0, 2.0, 3.0, 4.0, 5.0])) == 5 + + +class TestNoInputVariables: + """A constant has no input variables, so the kernel would be handed + zero-length arrays. The wrappers must short-circuit instead.""" + + EMPTY_LC = np.zeros(0) + EMPTY_CP = np.zeros((0, 0)) + EMPTY_VM = np.zeros((0, 9)) + + @pytest.mark.parametrize( + ("k", "want"), [(0, 1.0), (1, 0.0), (2, 0.0), (3, 0.0), (4, 0.0)] + ) + def test_rawmoment(self, k, want): + got = rawmoment( + self.EMPTY_LC, self.EMPTY_LC, self.EMPTY_CP, self.EMPTY_VM, k + ) + assert got == pytest.approx(want) + + def test_variance_components_are_empty(self): + vc_lc, vc_qc, vc_cp = variance_components( + self.EMPTY_LC, + self.EMPTY_LC, + self.EMPTY_CP, + self.EMPTY_VM, + [1.0, 0.0, 0.0, 0.0, 0.0], + ) + assert vc_lc.size == 0 + assert vc_qc.size == 0 + assert vc_cp.size == 0 + + def test_soerp_numeric_returns_the_intercept(self): + got = soerp_numeric( + self.EMPTY_LC, + self.EMPTY_LC, + self.EMPTY_CP, + self.EMPTY_VM, + 7.0, + silent=True, + ) + assert got == pytest.approx([7.0, 0.0, 0.0, 0.0]) diff --git a/tests/test_umath_functions.py b/tests/test_umath_functions.py new file mode 100644 index 0000000..cf28c56 --- /dev/null +++ b/tests/test_umath_functions.py @@ -0,0 +1,194 @@ +""" +Coverage of every function exported by ``soerp.umath``. + +Each one is exercised twice: once with a plain float, which must pass +straight through to the underlying math function, and once with an +uncertain input, which must propagate derivatives. The reference values +come from the standard library rather than from soerp, so a wrong formula +shows up as a mismatch rather than agreeing with itself. +""" + +import math + +import pytest + +from soerp import N, umath + + +def _sec(x): + return 1.0 / math.cos(x) + + +def _csc(x): + return 1.0 / math.sin(x) + + +def _cot(x): + return 1.0 / math.tan(x) + + +def _sech(x): + return 1.0 / math.cosh(x) + + +def _csch(x): + return 1.0 / math.sinh(x) + + +def _coth(x): + return 1.0 / math.tanh(x) + + +# (name, evaluation point, reference implementation) +UNARY = [ + ("sin", 0.7, math.sin), + ("cos", 0.7, math.cos), + ("tan", 0.7, math.tan), + ("sec", 0.7, _sec), + ("csc", 0.7, _csc), + ("cot", 0.7, _cot), + ("asin", 0.4, math.asin), + ("acos", 0.4, math.acos), + ("atan", 0.7, math.atan), + ("acot", 1.7, lambda x: math.atan(1.0 / x)), + ("asec", 1.7, lambda x: math.acos(1.0 / x)), + ("acsc", 1.7, lambda x: math.asin(1.0 / x)), + ("sinh", 0.7, math.sinh), + ("cosh", 0.7, math.cosh), + ("tanh", 0.7, math.tanh), + ("sech", 0.7, _sech), + ("csch", 0.7, _csch), + ("coth", 0.7, _coth), + ("asinh", 0.7, math.asinh), + ("acosh", 1.7, math.acosh), + ("atanh", 0.4, math.atanh), + ("acoth", 1.7, lambda x: math.atanh(1.0 / x)), + ("asech", 0.4, lambda x: math.acosh(1.0 / x)), + ("acsch", 1.7, lambda x: math.asinh(1.0 / x)), + ("exp", 0.7, math.exp), + ("expm1", 0.7, math.expm1), + ("ln", 1.7, math.log), + ("log", 1.7, math.log), + ("log10", 1.7, math.log10), + ("log1p", 0.7, math.log1p), + ("sqrt", 1.7, math.sqrt), + ("erf", 0.7, math.erf), + ("erfc", 0.7, math.erfc), + ("gamma", 1.7, math.gamma), + ("lgamma", 1.7, math.lgamma), + ("degrees", 0.7, math.degrees), + ("radians", 0.7, math.radians), + ("abs_", -1.7, abs), + ("fabs", -1.7, math.fabs), +] + +# Piecewise-constant functions: value only, derivatives are zero. +STEPWISE = [("ceil", 3.2, 4.0), ("floor", 3.7, 3.0), ("trunc", 3.7, 3.0)] + + +@pytest.mark.parametrize( + ("name", "x0", "ref"), UNARY, ids=[c[0] for c in UNARY] +) +class TestUnaryFunctions: + def test_scalar_passthrough(self, name, x0, ref): + assert getattr(umath, name)(x0) == pytest.approx(ref(x0)) + + def test_uncertain_value_at_the_mean(self, name, x0, ref): + x = N(x0, 1e-4) + r = getattr(umath, name)(x) + assert r.x == pytest.approx(ref(x0)) + + def test_uncertain_propagates_a_derivative(self, name, x0, ref): + x = N(x0, 1e-4) + r = getattr(umath, name)(x) + assert math.isfinite(r.d(x)) + assert r.d(x) != 0.0 + assert r.var > 0 + + +@pytest.mark.parametrize( + ("name", "x0", "want"), STEPWISE, ids=[c[0] for c in STEPWISE] +) +class TestStepwiseFunctions: + def test_scalar(self, name, x0, want): + assert getattr(umath, name)(x0) == pytest.approx(want) + + def test_uncertain_has_no_spread(self, name, x0, want): + r = getattr(umath, name)(N(x0, 0.01)) + assert r.x == pytest.approx(want) + assert r.var == pytest.approx(0.0) + + +class TestPow: + def test_scalar(self): + assert umath.pow_(1.7, 2.0) == pytest.approx(1.7**2) + + def test_uncertain(self): + x = N(1.7, 0.01) + r = umath.pow_(x, 3.0) + assert r.x == pytest.approx(1.7**3) + assert r.d(x) == pytest.approx(3 * 1.7**2) + assert r.d2(x) == pytest.approx(6 * 1.7) + + def test_near_zero_base_is_finite(self): + r = umath.pow_(N(0.0, 1e-3), 3.0) + assert math.isfinite(r.x) + + +class TestFactorial: + def test_scalar(self): + assert umath.factorial(5) == pytest.approx(120.0) + + def test_uncertain_collapses_to_a_constant(self): + assert umath.factorial(N(5.0, 0.01)) == pytest.approx(120.0) + + +class TestBinaryFunctions: + """Covers every mix of uncertain and plain arguments.""" + + def test_atan2_both_scalar(self): + assert umath.atan2(1.0, 1.0) == pytest.approx(math.pi / 4) + + def test_atan2_both_uncertain(self): + y, x = N(1.0, 0.01), N(2.0, 0.01) + r = umath.atan2(y, x) + assert r.x == pytest.approx(math.atan2(1.0, 2.0)) + assert r.d(y) == pytest.approx(2.0 / 5.0) + assert r.d(x) == pytest.approx(-1.0 / 5.0) + + def test_atan2_uncertain_numerator(self): + y = N(1.0, 0.01) + r = umath.atan2(y, 2.0) + assert r.x == pytest.approx(math.atan2(1.0, 2.0)) + assert r.var > 0 + + def test_atan2_uncertain_denominator(self): + x = N(2.0, 0.01) + r = umath.atan2(1.0, x) + assert r.x == pytest.approx(math.atan2(1.0, 2.0)) + assert r.var > 0 + + def test_hypot_both_scalar(self): + assert umath.hypot(3.0, 4.0) == pytest.approx(5.0) + + def test_hypot_both_uncertain(self): + x, y = N(3.0, 0.01), N(4.0, 0.01) + r = umath.hypot(x, y) + assert r.x == pytest.approx(5.0) + assert r.d(x) == pytest.approx(3.0 / 5.0) + assert r.d(y) == pytest.approx(4.0 / 5.0) + + def test_hypot_mixed(self): + x = N(3.0, 0.01) + assert umath.hypot(x, 4.0).x == pytest.approx(5.0) + assert umath.hypot(3.0, N(4.0, 0.01)).x == pytest.approx(5.0) + + +class TestExports: + def test_every_exported_name_is_present(self): + for name in umath.__all__: + assert hasattr(umath, name), name + + def test_constants(self): + assert umath.pi == pytest.approx(math.pi) + assert umath.e == pytest.approx(math.e) diff --git a/tests/test_uncertain_function.py b/tests/test_uncertain_function.py new file mode 100644 index 0000000..0f97936 --- /dev/null +++ b/tests/test_uncertain_function.py @@ -0,0 +1,295 @@ +""" +Coverage of the UncertainFunction arithmetic, comparison and reporting +surface: the operator overloads, the derivative accessors and the +error-component reports. +""" + +import math + +import pytest + +from soerp import N, UncertainFunction, to_uncertain_func, uv +from soerp.uncertain_function import make_uf_compatible_object + + +class TestToUncertainFunc: + def test_passes_through_an_uncertain_function(self): + x = N(1.0, 0.1) + assert to_uncertain_func(x) is x + + @pytest.mark.parametrize("value", [3, 3.0]) + def test_wraps_plain_constants(self, value): + wrapped = to_uncertain_func(value) + assert isinstance(wrapped, UncertainFunction) + assert wrapped.d() == {} + + def test_returns_none_for_unknown_types(self): + assert to_uncertain_func("not a number") is None + + def test_compatibility_shim_is_identity(self): + x = N(1.0, 0.1) + assert make_uf_compatible_object(x) is x + + +class TestArithmetic: + """Each operator is checked against the value it must produce and the + first derivative it must carry.""" + + def test_add(self): + x, y = N(3.0, 0.1), N(4.0, 0.1) + r = x + y + assert r.x == pytest.approx(7.0) + assert r.d(x) == pytest.approx(1.0) + assert r.d(y) == pytest.approx(1.0) + + def test_radd(self): + x = N(3.0, 0.1) + assert (2 + x).x == pytest.approx(5.0) + + def test_sub(self): + x, y = N(3.0, 0.1), N(4.0, 0.1) + r = x - y + assert r.x == pytest.approx(-1.0) + assert r.d(y) == pytest.approx(-1.0) + + def test_rsub(self): + x = N(3.0, 0.1) + assert (10 - x).x == pytest.approx(7.0) + + def test_mul(self): + x, y = N(3.0, 0.1), N(4.0, 0.1) + r = x * y + assert r.x == pytest.approx(12.0) + assert r.d(x) == pytest.approx(4.0) + assert r.d2c(x, y) == pytest.approx(1.0) + + def test_rmul(self): + x = N(3.0, 0.1) + assert (2 * x).x == pytest.approx(6.0) + + def test_mul_by_a_plain_number(self): + x = N(3.0, 0.1) + r = x * 2 + assert r.x == pytest.approx(6.0) + assert r.d(x) == pytest.approx(2.0) + + def test_truediv(self): + x, y = N(6.0, 0.1), N(3.0, 0.1) + r = x / y + assert r.x == pytest.approx(2.0) + assert r.d(x) == pytest.approx(1 / 3) + assert r.d(y) == pytest.approx(-6 / 9) + + def test_rtruediv(self): + x = N(4.0, 0.1) + assert (8 / x).x == pytest.approx(2.0) + + def test_pow_constant_exponent(self): + x = N(3.0, 0.1) + r = x**2 + assert r.x == pytest.approx(9.0) + assert r.d(x) == pytest.approx(6.0) + assert r.d2(x) == pytest.approx(2.0) + + def test_pow_uncertain_exponent(self): + x, y = N(3.0, 0.01), N(2.0, 0.01) + r = x**y + assert r.x == pytest.approx(9.0) + assert r.d(x) == pytest.approx(2 * 3.0) + assert r.d(y) == pytest.approx(9.0 * math.log(3.0)) + + def test_rpow(self): + x = N(2.0, 0.01) + assert (3**x).x == pytest.approx(9.0) + + @pytest.mark.parametrize("exponent", [0.5, 1.0, 2.0, 3.0]) + def test_pow_near_zero_base_stays_finite(self, exponent): + r = N(0.0, 1e-3) ** exponent + assert math.isfinite(r.x) + + def test_neg(self): + x = N(3.0, 0.1) + r = -x + assert r.x == pytest.approx(-3.0) + assert r.d(x) == pytest.approx(-1.0) + + def test_pos(self): + x = N(3.0, 0.1) + r = +x + assert r.x == pytest.approx(3.0) + assert r.d(x) == pytest.approx(1.0) + + def test_abs_of_negative(self): + x = N(-3.0, 0.1) + r = abs(x) + assert r.x == pytest.approx(3.0) + assert r.d(x) == pytest.approx(-1.0) + + def test_abs_of_positive(self): + x = N(3.0, 0.1) + assert abs(x).d(x) == pytest.approx(1.0) + + +class TestComparisons: + def test_equal_and_not_equal(self): + x = N(3.0, 0.1) + assert x == x # ruff: ignore[comparison-with-itself] + assert x - x == 0 + assert x != N(4.0, 0.1) + + def test_ordering(self): + small, large = N(1.0, 0.1), N(5.0, 0.1) + assert small < large + assert large > small + assert small <= large + assert large >= small + assert small <= small # ruff: ignore[comparison-with-itself] + assert large >= large # ruff: ignore[comparison-with-itself] + + def test_bool(self): + x = N(3.0, 0.1) + assert bool(x) is True + # x - x is identically zero; two independent variables that merely + # share a distribution are not. + assert bool(x - x) is False + assert bool(N(3.0, 0.1) - N(3.0, 0.1)) is True + + def test_hash_is_identity_based(self): + x = N(3.0, 0.1) + assert hash(x) == hash(x) + assert hash(x) != hash(N(3.0, 0.1)) + + def test_derived_functions_are_hashable(self): + """UncertainFunction defines its own __hash__, separate from the + one UncertainVariable overrides.""" + x = N(3.0, 0.1) + derived = x * 2 + assert hash(derived) == hash(derived) + assert hash(derived) != hash(x) + assert len({derived, x}) == 2 + + +class TestDerivativeAccessors: + @staticmethod + def _model(): + x1 = uv([24, 1, 0, 3, 0, 15, 0, 105]) + x2 = uv([37, 16, 0, 3, 0, 15, 0, 105]) + x3 = uv([0.5, 0.25, 2, 9, 44, 265, 1854, 14833]) + return x1, x2, x3, (x1 * x2**2) / (15 * (1.5 + x3)) + + def test_first_derivatives(self): + x1, x2, x3, z = self._model() + assert z.d(x1) == pytest.approx(1369.0 / 30.0) + assert z.d(x2) == pytest.approx(59.2) + assert z.d(x3) == pytest.approx(-547.6) + + def test_second_and_cross_derivatives(self): + x1, x2, x3, z = self._model() + assert z.d2(x2) == pytest.approx(1.6) + assert z.d2c(x1, x3) == pytest.approx(-1369.0 / 60.0) + # Order must not matter. + assert z.d2c(x3, x1) == pytest.approx(z.d2c(x1, x3)) + + def test_dict_forms(self): + x1, _, _, z = self._model() + assert isinstance(z.d(), dict) + assert isinstance(z.d2(), dict) + assert isinstance(z.d2c(), dict) + assert x1 in z.d() + + def test_unknown_variable_has_zero_derivative(self): + _, _, _, z = self._model() + other = N(1.0, 0.1) + assert z.d(other) == 0.0 + assert z.d2(other) == 0.0 + assert z.d2c(other, other) == 0.0 + + def test_gradient_and_hessian(self): + x1, x2, x3, z = self._model() + grad = z.gradient([x1, x2, x3]) + assert grad == pytest.approx([1369.0 / 30.0, 59.2, -547.6]) + + hess = z.hessian([x1, x2, x3]) + assert len(hess) == 3 + assert all(len(row) == 3 for row in hess) + assert hess[1][1] == pytest.approx(1.6) + assert hess[0][2] == pytest.approx(hess[2][0]) + + +class TestReporting: + @staticmethod + def _model(): + x1 = uv([24, 1, 0, 3, 0, 15, 0, 105]) + x2 = uv([37, 16, 0, 3, 0, 15, 0, 105]) + x3 = uv([0.5, 0.25, 2, 9, 44, 265, 1854, 14833]) + return x1, x2, x3, (x1 * x2**2) / (15 * (1.5 + x3)) + + def test_moments_list_and_index(self): + *_, z = self._model() + assert z.moments() == pytest.approx( + [z.mean, z.var, z.skew, z.kurt], rel=1e-12 + ) + for i in range(4): + assert z.moments(i) == pytest.approx(z.moments()[i]) + + @pytest.mark.parametrize("idx", [-1, 4, 99]) + def test_moments_rejects_bad_index(self, idx): + *_, z = self._model() + with pytest.raises(ValueError, match="idx must be"): + z.moments(idx) + + def test_std_is_root_variance(self): + *_, z = self._model() + assert z.std == pytest.approx(math.sqrt(z.var)) + + def test_str_and_repr(self): + *_, z = self._model() + assert str(z).startswith("uv(") + assert repr(z) == str(z) + + def test_constant_renders_as_a_bare_number(self): + """A constant has no input variables at all; the moment kernel must + still cope rather than being handed zero-length arrays.""" + constant = UncertainFunction(4.0) + assert "uv(" not in str(constant) + assert constant.mean == pytest.approx(4.0) + assert constant.var == pytest.approx(0.0) + assert constant.skew == pytest.approx(0.0) + assert constant.kurt == pytest.approx(0.0) + + def test_constant_from_to_uncertain_func_is_usable(self): + assert str(to_uncertain_func(7)) == str(7.0) + + def test_describe(self, capsys): + *_, z = self._model() + z.describe() + out = capsys.readouterr().out + assert "SOERP Uncertain Value" in out + assert "Skewness Coefficient" in out + + def test_error_components_by_variable(self): + x1, x2, x3, z = self._model() + comps = z.error_components() + assert set(comps) == {x1, x2, x3} + assert all(math.isfinite(v) for v in comps.values()) + + def test_error_components_as_equation_terms(self): + x1, _, _, z = self._model() + lc, qc, cp = z.error_components(as_eq_terms=True) + assert x1 in lc + assert x1 in qc + # Cross-product keys are stored in both orders. + assert any(pair[::-1] in cp for pair in cp) + + def test_error_components_pprint_by_variable(self, capsys): + *_, z = self._model() + assert z.error_components(pprint=True) is None + assert "COMPOSITE VARIABLE ERROR COMPONENTS" in capsys.readouterr().out + + def test_error_components_pprint_as_equation_terms(self, capsys): + *_, z = self._model() + assert z.error_components(pprint=True, as_eq_terms=True) is None + out = capsys.readouterr().out + assert "LINEAR ERROR COMPONENTS" in out + assert "QUADRATIC ERROR COMPONENTS" in out + assert "CROSS-PRODUCT ERROR COMPONENTS" in out diff --git a/tests/test_uncertain_variable.py b/tests/test_uncertain_variable.py index dbccb91..4b0ac1a 100644 --- a/tests/test_uncertain_variable.py +++ b/tests/test_uncertain_variable.py @@ -6,7 +6,8 @@ import pytest import scipy.stats as ss -from soerp import U, uv +from soerp import N, U, uv +from soerp.uncertain_variable import matplotlib_installed class TestConstructionErrors: @@ -70,3 +71,96 @@ def test_exponential_moments(self): assert standardized == pytest.approx( [2.0, 9.0, 44.0, 265.0, 1854.0, 14833.0], rel=1e-6 ) + + +class TestMomentAccessors: + def test_moments_returns_all_eight(self): + x = uv([10, 4, 0, 3, 0, 15, 0, 105]) + assert x.moments() == [10, 4, 0, 3, 0, 15, 0, 105] + + @pytest.mark.parametrize( + ("idx", "want"), [(0, 10), (1, 4), (2, 0), (3, 3), (7, 105)] + ) + def test_moments_by_index(self, idx, want): + x = uv([10, 4, 0, 3, 0, 15, 0, 105]) + assert x.moments(idx) == want + + def test_out_of_range_index_returns_the_whole_list(self): + x = uv([10, 4, 0, 3, 0, 15, 0, 105]) + assert x.moments(99) == x.moments() + + def test_named_properties(self): + x = uv([10, 4, 0.5, 3.2, 0, 15, 0, 105]) + assert x.mean == 10 + assert x.var == 4 + assert x.std == pytest.approx(2.0) + assert x.skew == 0.5 + assert x.kurt == 3.2 + + def test_hash_is_identity_based(self): + x = N(1.0, 0.1) + assert hash(x) == hash(x) + assert hash(x) != hash(N(1.0, 0.1)) + + +class TestSetters: + @staticmethod + def _variable(): + return uv([10, 4, 0, 3, 0, 15, 0, 105]) + + def test_set_mean(self): + x = self._variable() + x.set_mean(42.0) + assert x.mean == 42.0 + + def test_set_std(self): + x = self._variable() + x.set_std(3.0) + assert x.var == pytest.approx(9.0) + + def test_set_var(self): + x = self._variable() + x.set_var(25.0) + assert x.var == 25.0 + assert x.std == pytest.approx(5.0) + + def test_set_skew(self): + x = self._variable() + x.set_skew(1.5) + assert x.skew == 1.5 + + def test_set_kurt(self): + x = self._variable() + x.set_kurt(4.5) + assert x.kurt == 4.5 + + def test_set_moments(self): + x = self._variable() + x.set_moments([1, 2, 3, 4, 5, 6, 7, 8]) + assert x.moments() == [1, 2, 3, 4, 5, 6, 7, 8] + + @pytest.mark.parametrize("bad", [[1, 2, 3], [1] * 9, []]) + def test_set_moments_requires_exactly_eight(self, bad): + x = self._variable() + with pytest.raises(ValueError, match="eight values"): + x.set_moments(bad) + + +@pytest.mark.skipif(not matplotlib_installed, reason="matplotlib not installed") +class TestPlot: + def test_plotting_needs_a_scipy_distribution(self): + x = uv([10, 4, 0, 3, 0, 15, 0, 105]) + with pytest.raises(NotImplementedError, match="Cannot determine"): + x.plot() + + def test_plot_from_a_scipy_distribution(self, monkeypatch): + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + monkeypatch.setattr(plt, "show", lambda *a, **k: None) + x = uv(rv=ss.norm(loc=0, scale=1)) + x.plot() + x.plot(vals=[-2.0, 2.0]) + plt.close("all")