From 359c7e810a0c374f86215013c6e57d4c3aede360 Mon Sep 17 00:00:00 2001 From: Nick Date: Mon, 29 Jun 2026 10:03:06 +0100 Subject: [PATCH 1/2] Fix beta-stage correctness bugs and packaging for V1 Audit of the whole library surfaced many bugs that the existing tests (which only exercised Pbox+Pbox) never caught. All fixes verified by running the code; adds tests/test_regression_v1.py to pin each one. Critical correctness: - pbox._arithmetic referenced undefined `other` -> Pbox+scalar/Interval (incl. N(0,1)+1) were broken - core.sum/mean: isinstance(x, [list,...]) raised TypeError - Interval.add/sub method 'p'/'o' missing return (returned None) - Interval.__ge__ was a copy-paste of __le__ (wrong results) - Interval.env([...]) isinstance list-literal bug - Pbox.exp/sqrt used wrong kwarg (function= vs func=) - beta() stray `args = list(args)` + invalid scipy support kwarg (also unbroke KN/KM) - what_I_know used `imp += pbox` (extends with values) and passed a list to imposition() - imposition() discarded p.imp() result; Pbox.imp used undefined `pbox` - min_mean off-by-one length mismatch - min_max_median built bounds from probabilities, not values High-severity logic: - always/never bare `False` (returned None) - Pbox.min/max error path called NotImplemented(...) as a function - logicaland/logicalor used builtin min/max on p-boxes; fixed all dependency methods and logicalor's wrong env() arity - core.sqrt checked "PBox" (typo); Interval.__pow__ UnboundLocalError on numpy exponents Cleanups: - __rsub__ interval branch, removed dead Pbox.mean method, uniform ignored steps, min_max_median_is_mode mean typo, removed broken t(), dropped debug prints / print->warn, removed dead pseudo-dunders, _check_steps no longer mutates operands, mixture raises instead of returning a string, invalid interpolation="outer", narrowed bare excepts, fixed backwards _check_div_by_zero guard Packaging / CI: - removed distutils setup.py (broke on 3.12) and stale MANIFEST; pyproject.toml is now authoritative (requires-python, classifiers, dev/docs extras) - CI: --cov=com -> --cov=pba, Python 3.10-3.13 matrix, install package - comprehensive .gitignore; updated release workflow V1 API: - added pba.__version__; exported Cbox/singh/min_mean/max_mean/ change_default_arithmetic_method; closed np/warnings namespace leak - added Interval.__hash__ (kept Logical-returning comparisons) Co-Authored-By: Claude Opus 4.8 --- .github/workflows/pypi_upload.yml | 1 - .github/workflows/testing.yml | 18 +-- .gitignore | 29 +++-- MANIFEST | 10 -- pba/__init__.py | 8 ++ pba/cbox.py | 2 +- pba/core.py | 13 +- pba/interval.py | 95 ++++++++------ pba/logical.py | 12 +- pba/pbox.py | 156 ++++++++++++----------- pba/pbox_constructors/distributions.py | 70 +---------- pba/pbox_constructors/non_parametric.py | 53 ++++---- pyproject.toml | 13 ++ setup.py | 23 ---- tests/test_regression_v1.py | 161 ++++++++++++++++++++++++ 15 files changed, 403 insertions(+), 261 deletions(-) delete mode 100644 MANIFEST delete mode 100644 setup.py create mode 100644 tests/test_regression_v1.py diff --git a/.github/workflows/pypi_upload.yml b/.github/workflows/pypi_upload.yml index 71da819..41b100e 100644 --- a/.github/workflows/pypi_upload.yml +++ b/.github/workflows/pypi_upload.yml @@ -62,7 +62,6 @@ jobs: file_path.write_text(new_text) update_file("pyproject.toml", r'^version = ".*"$', f'version = "{version}"') - update_file("setup.py", r"version='[^']*'", f"version='{version}'") PY git status --short diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index c049ceb..2740ada 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -5,19 +5,21 @@ on: [push, pull_request] jobs: test: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 with: - python-version: "3.10" - - name: Install dependencies + python-version: ${{ matrix.python-version }} + - name: Install package and test dependencies run: | python -m pip install --upgrade pip - pip install numpy scipy matplotlib + pip install -e .[dev] - name: Test with pytest run: | - pip install pytest pytest-cov - pytest tests/* --doctest-modules --junitxml=junit/test-results.xml --cov=com --cov-report=xml --cov-report=html - + pytest tests/ --junitxml=junit/test-results-${{ matrix.python-version }}.xml --cov=pba --cov-report=xml diff --git a/.gitignore b/.gitignore index 79e664a..18085c6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,13 +1,28 @@ +# Scratch / local test.py -pba/__pycache__ +setup.txt .DS_Store +.vscode + +# Virtual environments +.pyenv/ lib/ bin/ pyvenv.cfg -*.pyc -.vscode -.pyenv/ -dist/* -pba.egg-info/ -setup.txt +# Python byte-compiled / caches +__pycache__/ +*.py[cod] +.pytest_cache/ + +# Build / packaging artifacts +build/ +dist/ +*.egg-info/ +*.egg + +# Coverage / test reports +.coverage +coverage.xml +htmlcov/ +junit/ diff --git a/MANIFEST b/MANIFEST deleted file mode 100644 index 6e1a3f3..0000000 --- a/MANIFEST +++ /dev/null @@ -1,10 +0,0 @@ -# file GENERATED by distutils, do NOT edit -README.rst -setup.py -pba/__init__.py -pba/cbox.py -pba/copula.py -pba/core.py -pba/interval.py -pba/logical.py -pba/pbox.py diff --git a/pba/__init__.py b/pba/__init__.py index 30cabe6..20b9cff 100644 --- a/pba/__init__.py +++ b/pba/__init__.py @@ -1,5 +1,13 @@ +from importlib.metadata import version, PackageNotFoundError + +try: + __version__ = version("pba") +except PackageNotFoundError: # running from a source checkout that isn't installed + __version__ = "0.0.0+unknown" + from .interval import * from .pbox import * +from .cbox import * from .copula import * from .core import * from .logical import * diff --git a/pba/cbox.py b/pba/cbox.py index 412100b..a5decac 100644 --- a/pba/cbox.py +++ b/pba/cbox.py @@ -12,7 +12,7 @@ from matplotlib import pyplot as plt from typing import List, Tuple, Union -__all__ = ["Cbox"] +__all__ = ["Cbox", "singh"] class Cbox(Pbox): diff --git a/pba/core.py b/pba/core.py index c3ebc94..bff236d 100644 --- a/pba/core.py +++ b/pba/core.py @@ -6,6 +6,8 @@ import numpy as np import warnings +__all__ = ["envelope", "env", "sum", "mean", "mul", "sqrt"] + def envelope(*args: Union[Interval, Pbox, float]) -> Union[Interval, Pbox]: """ @@ -116,14 +118,14 @@ def sum(*args: Union[list, tuple], method="f"): """ if len(args) == 1: - if isinstance(args[0], [list, tuple, np.ndarray]): + if isinstance(args[0], (list, tuple, np.ndarray)): args = args[0] else: return args[0] s = 0 for o in args: - if isinstance(o, [Interval, Pbox, Cbox]): + if isinstance(o, (Interval, Pbox, Cbox)): s = o.add(s, method=method) else: s += o @@ -189,11 +191,8 @@ def mul(*args, method=None): def sqrt(a): if a.__class__.__name__ == "Interval": return Interval(np.sqrt(a.left), np.sqrt(a.right)) - elif a.__class__.__name__ == "PBox": - return Pbox( - left=np.sqrt(a.left), - right=np.sqrt(a.right), - ) + elif isinstance(a, Pbox): + return a.sqrt() else: return np.sqrt(a) diff --git a/pba/interval.py b/pba/interval.py index f7f54f6..aec1da6 100644 --- a/pba/interval.py +++ b/pba/interval.py @@ -1,6 +1,7 @@ r""" """ from typing import Union +from numbers import Number import numpy as np import random as r import itertools @@ -126,7 +127,7 @@ def __format__(self, format_spec: str) -> str: return ( f"[{format(self.left, format_spec)},{format(self.right, format_spec)}]" ) - except: + except Exception: raise ValueError( f"{format_spec} format specifier not understood for Interval object" ) @@ -154,16 +155,16 @@ def __sub__(self, other): try: lo = self.left - other hi = self.right - other - except: + except Exception: return NotImplemented return Interval(lo, hi) def __rsub__(self, other): if other.__class__.__name__ == "Interval": - # should be overkill - lo = other.right - self.left - hi = other.right - self.right + # should be overkill (Interval - Interval is handled by __sub__) + lo = other.left - self.right + hi = other.right - self.left elif other.__class__.__name__ == "Pbox": # shoud have be caught by Pbox.__sub__() @@ -173,7 +174,7 @@ def __rsub__(self, other): lo = other - self.right hi = other - self.left - except: + except Exception: return NotImplemented return Interval(lo, hi) @@ -203,7 +204,7 @@ def __mul__(self, other): lo = self.lo() * other hi = self.hi() * other - except: + except Exception: return NotImplemented @@ -243,7 +244,7 @@ def __truediv__(self, other): try: lo = self.lo() / other hi = self.hi() / other - except: + except Exception: return NotImplemented @@ -257,24 +258,28 @@ def __rtruediv__(self, other): try: return other * self.recip() - except: + except Exception: return NotImplemented def __pow__(self, other): - if other.__class__.__name__ == "Interval": + if isinstance(other, Interval): pow1 = self.left**other.left pow2 = self.left**other.right pow3 = self.right**other.left pow4 = self.right**other.right powUp = max(pow1, pow2, pow3, pow4) powLow = min(pow1, pow2, pow3, pow4) - elif other.__class__.__name__ in ("int", "float"): + elif isinstance(other, Number): pow1 = self.left**other pow2 = self.right**other powUp = max(pow1, pow2) powLow = min(pow1, pow2) if (self.right >= 0) and (self.left <= 0) and (other % 2 == 0): powLow = 0 + else: + raise TypeError( + f"'**' not supported between instances of Interval and {type(other)}" + ) return Interval(powLow, powUp) def __rpow__(self, left): @@ -299,7 +304,7 @@ def __lt__(self, other): if not isinstance(other, Interval): try: other = Interval(other) - except: + except Exception: raise TypeError( f"'<' not supported between instances of Interval and {type(other)}" ) @@ -311,16 +316,13 @@ def __lt__(self, other): else: return Interval(0, 1).to_logical() - def __rgt__(self, other): - return self < other - def __eq__(self, other): # == if not isinstance(other, Interval): try: other = Interval(other) - except: + except Exception: raise TypeError( f"'==' not supported between instances of Interval and {type(other)}" ) @@ -334,20 +336,17 @@ def __gt__(self, other): # > try: lt = self < other - except: + except Exception: raise TypeError( f"'>' not supported between instances of Interval and {type(other)}" ) return ~lt - def __rlt__(self, other): - return self > other - def __ne__(self, other): # != try: eq = self == other - except: + except Exception: raise TypeError( f"'!=' not supported between instances of Interval and {type(other)}" ) @@ -358,7 +357,7 @@ def __le__(self, other): if not isinstance(other, Interval): try: other = Interval(other) - except: + except Exception: raise TypeError( f"'<=' not supported between instances of Interval and {type(other)}" ) @@ -374,14 +373,14 @@ def __ge__(self, other): if not isinstance(other, Interval): try: other = Interval(other) - except: + except Exception: raise TypeError( f"'>=' not supported between instances of Interval and {type(other)}" ) - if self.right <= other.left: + if self.left >= other.right: return Interval(1, 1).to_logical() - elif self.left > other.right: + elif self.right < other.left: return Interval(0, 0).to_logical() else: return Interval(0, 1).to_logical() @@ -393,7 +392,7 @@ def __bool__(self): return True else: return False - except: + except Exception: raise ValueError("Truth value of Interval %s is ambiguous" % self) def __abs__(self): @@ -405,6 +404,23 @@ def __abs__(self): def __contains__(self, other): return self.straddles(other, endpoints=True) + def __hash__(self): + """ + Intervals are hashable based on their endpoints. + + .. note:: + + ``Interval`` deliberately overrides the comparison operators + (``==``, ``<``, ``>`` ...) to return a :class:`~pba.logical.Logical` + rather than a ``bool``, reflecting that comparisons between + uncertain numbers may be indeterminate. Because defining + ``__eq__`` otherwise makes a class unhashable, ``__hash__`` is + defined explicitly here so that intervals can still be used as + dictionary keys or set members. Use :meth:`equiv` or + :func:`~pba.logical.is_same_as` for a boolean equality test. + """ + return hash((self.left, self.right)) + def add(self, other, method=None): """ .. _interval.add: @@ -436,15 +452,15 @@ def add(self, other, method=None): return other.add(self, method=method) try: other = Interval(other) - except: + except Exception: raise TypeError( f"addition not supported between instances of Interval and {type(other)}" ) if method == "p": - Interval(self.left + other.left, self.right + other.right) + return Interval(self.left + other.left, self.right + other.right) elif method == "o": - Interval(self.left + other.right, self.right + other.left) + return Interval(self.left + other.right, self.right + other.left) else: return self.__add__(other) @@ -460,7 +476,7 @@ def __add__(self, other): try: lo = self.left + other hi = self.right + other - except: + except Exception: return NotImplemented return Interval(lo, hi) @@ -517,15 +533,15 @@ def sub(self, other, method=None): return other.rsub(self, method=method) try: other = Interval(other) - except: + except Exception: raise TypeError( f"Subtraction not supported between instances of Interval and {type(other)}" ) if method == "p": - Interval(self.left - other.left, self.right - other.right) + return Interval(self.left - other.left, self.right - other.right) elif method == "o": - Interval(self.left - other.right, self.right - other.left) + return Interval(self.left - other.right, self.right - other.left) else: return self.__sub__(other) @@ -581,7 +597,7 @@ def mul(self, other, method=None): return other.mul(self, method=method) try: other = Interval(other) - except: + except Exception: raise TypeError( f"Multiplication not supported between instances of Interval and {type(other)}" ) @@ -658,7 +674,7 @@ def div(self, other, method=None): return other.recip().mul(self, method=method) try: other = Interval(other) - except: + except Exception: raise TypeError( f"Division not supported between instances of Interval and {type(other)}" ) @@ -870,7 +886,7 @@ def env(self, other: Union[list, "Interval"]) -> "Interval": """ - if isinstance(other, [list, tuple]): + if isinstance(other, (list, tuple)): e = self for o in other: e = e.env(o) @@ -882,7 +898,7 @@ def env(self, other: Union[list, "Interval"]) -> "Interval": else: try: other = Interval(other) - except: + except Exception: raise TypeError( f"env() not supported between instances of Interval and {type(other)}" ) @@ -1002,7 +1018,10 @@ def sqrt(self): if self.left >= 0: return Interval(np.sqrt(self.left), np.sqrt(self.right)) else: - print("RuntimeWarning: invalid value encountered in sqrt") + warnings.warn( + "invalid value encountered in sqrt (interval crosses zero)", + RuntimeWarning, + ) return Interval(np.nan, np.sqrt(self.right)) # def sin(self): diff --git a/pba/logical.py b/pba/logical.py index 1f0bd2b..b6c3bb8 100644 --- a/pba/logical.py +++ b/pba/logical.py @@ -1,10 +1,13 @@ from typing import * from numbers import Number +import warnings import numpy as np from .pbox import Pbox from .interval import Interval +__all__ = ["Logical", "is_same_as", "always", "never", "sometimes", "xtimes"] + class Logical(Interval): """ @@ -29,8 +32,9 @@ def __bool__(self): if self.left == 1 and self.right == 1: return True else: - print( - "WARNING: Truth value of Logical is ambiguous, use pba.sometime or pba.always" + warnings.warn( + "Truth value of Logical is ambiguous; use pba.sometimes or pba.always", + stacklevel=2, ) return True @@ -200,7 +204,7 @@ def always(logical: Union[Logical, Interval, Number, bool]) -> bool: if logical == 1: return True else: - False + return False else: raise TypeError("Input must be a Logical, Interval or a numeric value.") @@ -265,7 +269,7 @@ def never(logical: Logical) -> bool: if logical == 0: return True else: - False + return False else: raise TypeError("Input must be a Logical, Interval or a numeric value.") diff --git a/pba/pbox.py b/pba/pbox.py index e1b7650..96ae34f 100644 --- a/pba/pbox.py +++ b/pba/pbox.py @@ -10,7 +10,14 @@ from .interval import Interval from .copula import Copula -__all__ = ["Pbox", "mixture", "truncate", "imposition", "NotIncreasingError"] +__all__ = [ + "Pbox", + "mixture", + "truncate", + "imposition", + "NotIncreasingError", + "change_default_arithmetic_method", +] ### Local functions ### @@ -56,7 +63,7 @@ class NotIncreasingError(Exception): def _check_div_by_zero(pbox): - if 0 <= min(pbox.left) and 0 >= max(pbox.right): + if min(pbox.left) <= 0 <= max(pbox.right): raise DivisionByZero("Pbox contains 0") @@ -72,14 +79,16 @@ def _interval_list_to_array(l, left=True): def _check_steps(a, b): if a.steps > b.steps: warn( - "Pboxes have different number of steps. Interpolating {b.__name__} to {a.steps} steps" + f"Pboxes have different number of steps. Interpolating second p-box " + f"from {b.steps} to {a.steps} steps" ) - b.interpolate(a.steps, inplace=True) + b = b.interpolate(a.steps, inplace=False) elif a.steps < b.steps: warn( - "Pboxes have different number of steps. Interpolating {a.__name__} to {b.steps} steps" + f"Pboxes have different number of steps. Interpolating first p-box " + f"from {a.steps} to {b.steps} steps" ) - a.interpolate(b.steps, inplace=True) + a = a.interpolate(b.steps, inplace=False) return a, b @@ -87,7 +96,7 @@ def _check_steps(a, b): def _check_moments(left, right, steps, mean, var): def nearly(x: Interval, y: Interval): - if isclose(x.left,y.left) and isclose(x.right, y.right): + if isclose(x.left, y.left) and isclose(x.right, y.right): return True else: return False @@ -101,8 +110,10 @@ def _sideVariance(w, mu): cmean = Interval(np.mean(left), np.mean(right)) if not nearly(mean, cmean) and not mean.equiv(Interval(-np.inf, np.inf)): - warn("Mean specified does not match calculated mean. Using calculated mean") - print(f"Specified mean: {mean}, calculated mean: {cmean}") + warn( + f"Mean specified ({mean}) does not match calculated mean ({cmean}). " + "Using calculated mean" + ) mean = cmean if np.any(np.isinf(left)) or np.any(np.isinf(right)): @@ -150,9 +161,9 @@ def _sideVariance(w, mu): if not nearly(var, cvar) and not var.equiv(Interval(0, np.inf)): warn( - "Variance specified does not match calculated variance. Using calculated variance" + f"Variance specified ({var}) does not match calculated variance ({cvar}). " + "Using calculated variance" ) - print(f"Specified variance: {var}, calculated variance: {cvar}") var = cvar return cmean, cvar @@ -164,9 +175,9 @@ def _arithmetic(a, b, method, op, enforce_steps=True, interpolation_method="line # * If enforce_steps is True, the number of steps in the returned p-box is the maximum of the number of steps in a and b. if isinstance(b, Interval): - other = Pbox(other, steps=a.steps) + b = Pbox(b, steps=a.steps) - if isinstance(b, Pbox) or issubclass(b.__class__,Pbox): + if isinstance(b, Pbox) or issubclass(b.__class__, Pbox): a, b = _check_steps(a, b) @@ -216,10 +227,14 @@ def _arithmetic(a, b, method, op, enforce_steps=True, interpolation_method="line s = "" return Pbox( - left=op(a.left, other), right=op(a.right, other), shape=s, steps=a.steps + left=op(a.left, b), + right=op(a.right, b), + shape=s, + steps=a.steps, + interpolation=interpolation_method, ) - except: + except Exception: return NotImplemented @@ -425,27 +440,15 @@ def __neg__(self): def __lt__(self, other): return self.lt(other, method="f") - def __rlt__(self, other): - return self.ge(other, method="f") - def __le__(self, other): return self.le(other, method="f") - def __rle__(self, other): - return self.gt(other, method="f") - def __gt__(self, other): return self.gt(other, method="f") - def __rgt__(self, other): - return self.le(other, method="f") - def __ge__(self, other): return self.ge(other, method="f") - def __rge__(self, other): - return self.lt(other, method="f") - def __and__(self, other): return self.logicaland(other, method="f") @@ -495,7 +498,7 @@ def __rtruediv__(self, other): try: return other * self.recip() - except: + except Exception: return NotImplemented def lo(self): @@ -588,8 +591,10 @@ def add( raise NotImplementedError( f"Addition of {other.__class__.__name__} to Pbox not implemented" ) - except: - raise Exception(f"Addition of {other.__class__.__name__} to Pbox failed") + except Exception as e: + raise Exception( + f"Addition of {other.__class__.__name__} to Pbox failed" + ) from e def sub(self, other, method="f"): """ @@ -612,10 +617,10 @@ def mul(self, other, method="f"): raise NotImplementedError( f"Multiplication of {other.__class__.__name__} and Pbox not implemented" ) - except: + except Exception as e: raise Exception( f"Multiplication of {other.__class__.__name__} to Pbox failed" - ) + ) from e def div(self, other, method="f"): """ @@ -641,14 +646,16 @@ def pow(self, other: Union["Pbox", Interval, float, int], method="f") -> "Pbox": raise NotImplementedError( f"Power of {other.__class__.__name__} to Pbox not implemented" ) - except: - raise Exception(f"Power of {other.__class__.__name__} to Pbox failed") + except Exception as e: + raise Exception( + f"Power of {other.__class__.__name__} to Pbox failed" + ) from e def exp(self): - return self.unary(function=lambda x: x.exp()) + return self.unary(func=lambda x: x.exp()) def sqrt(self): - return self.unary(function=lambda x: x.sqrt()) + return self.unary(func=lambda x: x.sqrt()) def recip(self): """ @@ -715,8 +722,8 @@ def min(self, other, method="f"): left=np.array([i if i < other else other for i in self.left]), right=np.array([i if i < other else other for i in self.right]), ) - except: - return NotImplemented( + except Exception: + raise NotImplementedError( f"Minimum of {other.__class__.__name__} and Pbox not implemented" ) @@ -749,9 +756,9 @@ def max(self, other, method="f"): left=np.array([i if i > other else other for i in self.left]), right=np.array([i if i > other else other for i in self.right]), ) - except: - return NotImplemented( - f"Minimum of {other.__class__.__name__} and Pbox not implemented" + except Exception: + raise NotImplementedError( + f"Maximum of {other.__class__.__name__} and Pbox not implemented" ) def truncate( @@ -789,27 +796,36 @@ def logicaland(self, other, method="f"): # conjunction elif method == "p": return self.min(other, method) # perfect min(a, b) elif method == "o": - return max(self.add(other, method) - 1, 0) # opposite max(a + b – 1, 0) + # opposite max(a + b - 1, 0) + return (self.add(other, method) - 1).max(0) elif method == "+": - return self.min(other, method) # positive env(a * b, min(a, b)) + # positive env(a * b, min(a, b)) + return self.mul(other, "i").env(self.min(other, "p")) elif method == "-": - return self.min(other, method) # negative env(max(a + b – 1, 0), a * b) + # negative env(max(a + b - 1, 0), a * b) + return ((self.add(other, "o") - 1).max(0)).env(self.mul(other, "i")) else: - return self.min(other, method).env(max(0, self.add(other, method) - 1)) + # Frechet env(max(a + b - 1, 0), min(a, b)) + return self.min(other, method).env((self.add(other, method) - 1).max(0)) def logicalor(self, other, method="f"): # disjunction if method == "i": - return 1 - (1 - self) * (1 - other) # independent 1 – (1 – a) * (1 – b) + # independent 1 - (1 - a) * (1 - b) + return 1 - (1 - self).mul(1 - other, "i") elif method == "p": return self.max(other, method) # perfect max(a, b) elif method == "o": - return min(self.add(other, method), 1) # opposite min(1, a + b) - # elif method=='+': - # return(env(,min(self.add(other,method),1)) # positive env(max(a, b), 1 – (1 – a) * (1 – b)) - # elif method=='-': - # return() # negative env(1 – (1 – a) * (1 – b), min(1, a + b)) + # opposite min(1, a + b) + return self.add(other, method).min(1) + elif method == "+": + # positive env(max(a, b), 1 - (1 - a) * (1 - b)) + return self.max(other, "p").env(1 - (1 - self).mul(1 - other, "i")) + elif method == "-": + # negative env(1 - (1 - a) * (1 - b), min(1, a + b)) + return (1 - (1 - self).mul(1 - other, "i")).env(self.add(other, "o").min(1)) else: - return self.env(self.max(other, method), min(self.add(other, method), 1)) + # Frechet env(max(a, b), min(1, a + b)) + return self.max(other, method).env(self.add(other, method).min(1)) def env(self, other): """ @@ -986,12 +1002,6 @@ def support(self) -> Interval: """ return Interval(min(self.left), max(self.right)) - def mean(self) -> Interval: - """ - Returns the mean of the pbox - """ - return self.mean - def median(self) -> Interval: """ Returns the median of the distribution @@ -1025,10 +1035,10 @@ def imp(self, other): """ if other.__class__.__name__ != "Pbox": try: - pbox = Pbox(pbox) - except: + other = Pbox(other) + except Exception: raise TypeError( - "Unable to convert %s object (%s) to Pbox" % (type(pbox), pbox) + "Unable to convert %s object (%s) to Pbox" % (type(other), other) ) u = [] @@ -1083,7 +1093,7 @@ def imposition(*args: Union[Pbox, Interval, float, int]): if pbox.__class__.__name__ != "Pbox": try: pbox = Pbox(pbox) - except: + except Exception: raise TypeError( "Unable to convert %s object (%s) to Pbox" % (type(pbox), pbox) ) @@ -1092,7 +1102,7 @@ def imposition(*args: Union[Pbox, Interval, float, int]): p = x[0] for i in range(1, len(x)): - p.imp(x[i]) + p = p.imp(x[i]) return p @@ -1122,7 +1132,7 @@ def mixture( if pbox.__class__.__name__ != "Pbox": try: pbox = Pbox(pbox) - except: + except Exception: raise TypeError( "Unable to convert %s object (%s) to Pbox" % (type(pbox), pbox) ) @@ -1138,7 +1148,7 @@ def mixture( # w = [1,1] if k != len(weights): - return "Need same number of weights as arguments for mixture" + raise ValueError("Need the same number of weights as arguments for mixture") weights = [i / sum(weights) for i in weights] # w = w / sum(w) u = [] d = [] @@ -1250,8 +1260,10 @@ def nadd( raise NotImplementedError( f"Addition of {other.__class__.__name__} to Pbox not implemented" ) - except: - raise Exception(f"Addition of {other.__class__.__name__} to Pbox failed") + except Exception as e: + raise Exception( + f"Addition of {other.__class__.__name__} to Pbox failed" + ) from e def nsub(self, other, method=method): @@ -1270,10 +1282,10 @@ def nmul(self, other, method=method): raise NotImplementedError( f"Multiplication of {other.__class__.__name__} and Pbox not implemented" ) - except: + except Exception as e: raise Exception( f"Multiplication of {other.__class__.__name__} to Pbox failed" - ) + ) from e def ndiv(self, other, method=method): @@ -1295,8 +1307,10 @@ def npow(self, other: Union["Pbox", Interval, float, int], method=method) -> "Pb raise NotImplementedError( f"Power of {other.__class__.__name__} to Pbox not implemented" ) - except: - raise Exception(f"Power of {other.__class__.__name__} to Pbox failed") + except Exception as e: + raise Exception( + f"Power of {other.__class__.__name__} to Pbox failed" + ) from e Pbox.__add__ = nadd Pbox.__sub__ = nsub diff --git a/pba/pbox_constructors/distributions.py b/pba/pbox_constructors/distributions.py index d309cbf..4d96eda 100644 --- a/pba/pbox_constructors/distributions.py +++ b/pba/pbox_constructors/distributions.py @@ -79,7 +79,6 @@ def __lognorm(mean, var): __lognorm(mean.right, var.right).ppf(x), ] ) - print(bounds) Left = np.min(bounds, axis=0) Right = np.max(bounds, axis=0) @@ -95,7 +94,6 @@ def beta(a, b, steps=200): """ Beta distribution. """ - args = list(args) if not isinstance(a, Interval): a = Interval(a) if not isinstance(b, Interval): @@ -111,7 +109,7 @@ def beta(a, b, steps=200): if b.left == 0: b.left = 1e-5 - return parametric("beta").make_pbox(a, b, steps=steps, support=Interval(0, 1)) + return parametric("beta").make_pbox(a, b, steps=steps) def foldnorm(mu, s, steps=200): @@ -171,68 +169,6 @@ def foldnorm(mu, s, steps=200): ) -def t(mean, std, df, steps=200): - - def t_from_mean_std_df(mean: float, std: float, df: float): - - scale = std * np.sqrt((df - 2.0) / df) - return stats.t(df=df, loc=mean, scale=scale) - - if mean.__class__.__name__ != "Interval": - mean = Interval(mean) - if std.__class__.__name__ != "Interval": - std = Interval(std) - - if df <= 2: - raise ValueError(f"df must be > 2 to have a finite std (got df={df}).") - - x = np.linspace(0.0001, 0.9999, steps) - - new_args = [ - [mean.lo() / std.lo()], - [mean.hi() / std.lo()], - [mean.lo() / std.hi()], - [mean.hi() / std.hi()], - ] - - bounds = [] - - mean_hi = -np.inf - mean_lo = np.inf - var_lo = np.inf - var_hi = 0 - - for mu,sigma in new_args: - - t_ = t_from_mean_std_df(mu,sigma,df) - bounds.append(t_.ppf(x)) - bmean, bvar = t_.stats(moments="mv") - - if bmean < mean_lo: - mean_lo = bmean - if bmean > mean_hi: - mean_hi = bmean - if bvar > var_hi: - var_hi = bvar - if bvar < var_lo: - var_lo = bvar - - mean = Interval(np.float64(mean_lo), np.float64(mean_hi)) - - Left = [min([b[i] for b in bounds]) for i in range(steps)] - Right = [max([b[i] for b in bounds]) for i in range(steps)] - - return Pbox( - Left, - Right, - steps=steps, - shape="foldnorm", - mean_left=mean.left, - mean_right=mean.right, - var_left=var.left, - var_right=var.right, - ) - def trapz(a, b, c, d, steps=200): if a.__class__.__name__ != "Interval": a = Interval(a) @@ -263,8 +199,8 @@ def uniform(a, b, steps=200): if b.__class__.__name__ != "Interval": b = Interval(b, b) - Left = np.linspace(a.left, b.left) - Right = np.linspace(a.right, b.right) + Left = np.linspace(a.left, b.left, steps) + Right = np.linspace(a.right, b.right, steps) mean = 0.5 * (a + b) var = ((b - a) ** 2) / 12 diff --git a/pba/pbox_constructors/non_parametric.py b/pba/pbox_constructors/non_parametric.py index 766c33c..10908e7 100644 --- a/pba/pbox_constructors/non_parametric.py +++ b/pba/pbox_constructors/non_parametric.py @@ -6,6 +6,8 @@ "what_I_know", "box", "min_max", + "min_mean", + "max_mean", "min_max_mean", "min_max_mean_std", "min_max_mean_var", @@ -82,8 +84,8 @@ def _get_pbox(func, *args, steps=steps, debug=False): _print_debug(func.__name__) try: return func(*args, steps=steps) - except: - raise Exception(f"Unable to generate {func.__name__} pbox") + except Exception as e: + raise Exception(f"Unable to generate {func.__name__} pbox") from e # if 'positive' in shape: # if minimum is None: @@ -108,28 +110,28 @@ def _get_pbox(func, *args, steps=steps, debug=False): imp = [] if minimum is not None and maximum is not None: - imp += _get_pbox(min_max, minimum, maximum, debug=debug) + imp.append(_get_pbox(min_max, minimum, maximum, debug=debug)) if minimum is not None and mean is not None: - imp += _get_pbox(min_mean, minimum, mean, debug=debug) + imp.append(_get_pbox(min_mean, minimum, mean, debug=debug)) if maximum is not None and mean is not None: - imp += _get_pbox(max_mean, maximum, mean, debug=debug) + imp.append(_get_pbox(max_mean, maximum, mean, debug=debug)) if minimum is not None and maximum is not None and mean is not None: - imp += _get_pbox(min_max_mean, minimum, maximum, mean, debug=debug) + imp.append(_get_pbox(min_max_mean, minimum, maximum, mean, debug=debug)) if minimum is not None and maximum is not None and mode is not None: - imp += _get_pbox(min_max_mode, minimum, maximum, mode, debug=debug) + imp.append(_get_pbox(min_max_mode, minimum, maximum, mode, debug=debug)) if minimum is not None and maximum is not None and median is not None: - imp += _get_pbox(min_max_median, minimum, maximum, median, debug=debug) + imp.append(_get_pbox(min_max_median, minimum, maximum, median, debug=debug)) if minimum is not None and mean is not None and std is not None: - imp += minimum + _get_pbox(pos_mean_std, mean - minimum, std, debug=debug) + imp.append(minimum + _get_pbox(pos_mean_std, mean - minimum, std, debug=debug)) if maximum is not None and mean is not None and std is not None: - imp += _get_pbox(pos_mean_std, maximum - mean, std, debug=debug) - maximum + imp.append(_get_pbox(pos_mean_std, maximum - mean, std, debug=debug) - maximum) if ( minimum is not None @@ -137,7 +139,9 @@ def _get_pbox(func, *args, steps=steps, debug=False): and mean is not None and std is not None ): - imp += _get_pbox(min_max_mean_std, minimum, maximum, mean, std, debug=debug) + imp.append( + _get_pbox(min_max_mean_std, minimum, maximum, mean, std, debug=debug) + ) if ( minimum is not None @@ -145,20 +149,22 @@ def _get_pbox(func, *args, steps=steps, debug=False): and mean is not None and var is not None ): - imp += _get_pbox(min_max_mean_var, minimum, maximum, mean, var, debug=debug) + imp.append( + _get_pbox(min_max_mean_var, minimum, maximum, mean, var, debug=debug) + ) if mean is not None and std is not None: - imp += _get_pbox(mean_std, mean, std, debug=debug) + imp.append(_get_pbox(mean_std, mean, std, debug=debug)) if mean is not None and var is not None: - imp += _get_pbox(mean_var, mean, var, debug=debug) + imp.append(_get_pbox(mean_var, mean, var, debug=debug)) if mean is not None and cv is not None: - imp += _get_pbox(mean_std, mean, cv * mean, debug=debug) + imp.append(_get_pbox(mean_std, mean, cv * mean, debug=debug)) if len(imp) == 0: raise Exception("No valid p-boxes found") - return imposition(imp) + return imposition(*imp) def box( @@ -226,7 +232,6 @@ def min_max_mean( left = [minimum if i <= mid else ((mean - maximum) / i + maximum) for i in ii] jj = [j / steps for j in range(1, steps + 1)] right = [maximum if mid <= j else (mean - minimum * j) / (1 - j) for j in jj] - print(len(left)) return Pbox(left=np.array(left), right=np.array(right), steps=steps) @@ -249,7 +254,7 @@ def min_mean( ``Pbox`` """ - jjj = np.array([j / steps for j in range(1, steps - 1)] + [1 - 1 / steps]) + jjj = np.array([j / steps for j in range(1, steps)] + [1 - 1 / steps]) right = [((mean - minimum) / (1 - j) + minimum) for j in jjj] return Pbox( @@ -451,8 +456,8 @@ def min_max_median( jj = np.array([j / steps for j in range(1, steps + 1)]) return Pbox( - left=np.array([p if p > 0.5 else minimum for p in ii]), - right=np.array([p if p <= 0.5 else minimum for p in jj]), + left=np.array([minimum if p < 0.5 else median for p in ii]), + right=np.array([median if p <= 0.5 else maximum for p in jj]), mean_left=(minimum + median) / 2, mean_right=(median + maximum) / 2, var_left=0, @@ -493,7 +498,7 @@ def min_max_median_is_mode( return Pbox( left=u, right=d, - mean_left=(minimum + 3 + m) / 4, + mean_left=(minimum + 3 * m) / 4, mean_right=(3 * m + maximum) / 4, var_left=0, var_right=(maximum - minimum) * (maximum - minimum) / 4, @@ -783,7 +788,7 @@ def from_percentiles(percentiles: dict, steps: int = Pbox.STEPS) -> Pbox: right.append(percentiles[smallest_key].right) try: - return Pbox(left, right, steps=steps, interpolation="outer") + return Pbox(left, right, steps=steps, interpolation="step") except NotIncreasingError: warn("Percentiles are not increasing. Will take intersection of percentiles.") @@ -802,8 +807,8 @@ def from_percentiles(percentiles: dict, steps: int = Pbox.STEPS) -> Pbox: left.append(percentiles[left_key].left) right.append(percentiles[right_key].right) - return Pbox(left, right, steps=steps, interpolation="outer") - except: + return Pbox(left, right, steps=steps, interpolation="step") + except Exception: raise Exception("Unable to generate p-box") diff --git a/pyproject.toml b/pyproject.toml index 04ed333..bb7d158 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "pba" version = "0.90.4" +requires-python = ">=3.10" dependencies = [ 'numpy>=1.26.2', 'scipy>=1.11.4', @@ -16,6 +17,18 @@ authors = [ description = "Probability Bounds Analysis in Python" readme = "README.md" license = {file = "LICENSE"} +classifiers = [ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Intended Audience :: Science/Research", + "Topic :: Scientific/Engineering :: Mathematics", +] + +[project.optional-dependencies] +dev = ["pytest", "pytest-cov", "black", "build", "twine"] +docs = ["sphinx", "furo", "myst-parser", "sphinx-autoapi"] + [project.urls] Documentation = "https://pba-for-python.readthedocs.io/en/latest/" Repository = "https://github.com/Institute-for-Risk-and-Uncertainty/pba-for-python" diff --git a/setup.py b/setup.py deleted file mode 100644 index 2e56484..0000000 --- a/setup.py +++ /dev/null @@ -1,23 +0,0 @@ -from distutils.core import setup - -setup( - name='pba', - version='0.90.4', - packages=['pba',], - license='MIT License', - long_description=open('README.md').read(), - install_requires=[ - 'numpy>=1.25.2', - 'scipy>=1.11.2', - 'matplotlib>=3.3.2'], - url='https://github.com/Institute-for-Risk-and-Uncertainty/pba-for-python', - author='Nick Gray', - author_email = 'ngg@liv.ac.uk' -) -# RUN THIS CODE -''' -python3 setup.py sdist -python3 -m twine upload dist/pba- - -cd docs && make html && cd .. -''' diff --git a/tests/test_regression_v1.py b/tests/test_regression_v1.py new file mode 100644 index 0000000..532d0af --- /dev/null +++ b/tests/test_regression_v1.py @@ -0,0 +1,161 @@ +""" +Regression tests for the V1 correctness fixes. + +Each test pins a bug that was present in the 0.90.x beta and broke a path that +the original test-suite never exercised (p-box arithmetic with scalars/intervals, +the distribution-free constructors, the core/logical helpers, ...). +""" + +import warnings + +import numpy as np +import pytest + +import pba +from pba import Interval, Pbox + + +# --- helpers --------------------------------------------------------------- +def _pbox(left, right, steps=3): + return Pbox(left=left, right=right, steps=steps) + + +# --- Pbox arithmetic with scalars / intervals (was: NameError `other`) ----- +def test_pbox_plus_scalar(): + a = _pbox([0, 1, 2], [1, 2, 3]) + c = a + 5 + assert isinstance(c, Pbox) + assert np.allclose(c.left, [5, 6, 7]) + assert np.allclose(c.right, [6, 7, 8]) + + +def test_scalar_plus_pbox(): + a = _pbox([0, 1, 2], [1, 2, 3]) + c = 5 + a + assert isinstance(c, Pbox) + assert np.allclose(c.left, [5, 6, 7]) + + +def test_pbox_plus_interval(): + a = _pbox([0, 1, 2], [1, 2, 3]) + c = a + Interval(0, 1) + assert isinstance(c, Pbox) + + +def test_named_distribution_plus_scalar(): + # pba.N(0, 1) + 1 used to raise TypeError + assert isinstance(pba.N(0, 1) + 1, Pbox) + + +# --- Interval.add / sub perfect & opposite (was: missing return -> None) --- +def test_interval_add_perfect_opposite(): + assert Interval(1, 2).add(Interval(3, 4), method="p").equiv(Interval(4, 6)) + assert Interval(1, 2).add(Interval(3, 4), method="o").equiv(Interval(5, 5)) + + +def test_interval_sub_perfect_opposite(): + assert Interval(1, 2).sub(Interval(3, 4), method="p").equiv(Interval(-2, -2)) + assert Interval(1, 2).sub(Interval(3, 4), method="o").equiv(Interval(-3, -1)) + + +# --- Interval.__ge__ (was: copy/paste of __le__) --------------------------- +def test_interval_ge_le(): + assert pba.always(Interval(4, 5) >= Interval(0, 1)) + assert not pba.always(Interval(4, 5) <= Interval(0, 1)) + assert pba.always(Interval(0, 1) <= Interval(4, 5)) + + +# --- always / never numeric branch (was: bare `False` -> None) ------------- +def test_always_never_numeric(): + assert pba.always(1.0) is True + assert pba.always(0.5) is False + assert pba.never(0.0) is True + assert pba.never(0.5) is False + + +# --- core.sum / core.mean (was: isinstance(x, [list, ...]) TypeError) ------ +def test_core_sum_mean(): + a = _pbox([0, 1, 2], [1, 2, 3]) + assert isinstance(pba.sum(a, a), Pbox) + assert isinstance(pba.mean(a, a), Pbox) + + +# --- Pbox.exp / sqrt and core.sqrt (was: wrong kwarg / "PBox" typo) -------- +def test_pbox_unary_and_core_sqrt(): + a = pba.box(1, 4) + assert isinstance(a.exp(), Pbox) + assert isinstance(a.sqrt(), Pbox) + assert isinstance(pba.sqrt(a), Pbox) + + +# --- Interval.env on a list (was: isinstance(other, [list, tuple])) -------- +def test_interval_env_list(): + assert Interval(0, 1).env([Interval(2, 3)]).equiv(Interval(0, 3)) + + +# --- Interval ** numpy scalar (was: UnboundLocalError) --------------------- +def test_interval_pow_numpy(): + assert (Interval(1, 2) ** np.float64(2)).equiv(Interval(1, 4)) + assert (Interval(-2, 3) ** 2).equiv(Interval(0, 9)) + with pytest.raises(TypeError): + Interval(1, 2) ** "x" + + +# --- distribution-free constructors --------------------------------------- +def test_what_i_know(): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + p = pba.what_I_know(minimum=0, maximum=10, mean=5) + assert isinstance(p, Pbox) + + +def test_min_mean_max_mean(): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + assert isinstance(pba.min_mean(0, 5), Pbox) + assert isinstance(pba.max_mean(10, 5), Pbox) + + +def test_beta_and_kn(): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + assert isinstance(pba.beta(2, 3), Pbox) + assert isinstance(pba.KN(2, 10), Pbox) + + +def test_min_max_median_increasing(): + p = pba.min_max_median(0, 10, 5) + assert np.all(np.diff(p.left) >= 0) + assert np.all(np.diff(p.right) >= 0) + assert p.lo() >= 0 and p.hi() <= 10 + + +# --- imposition (was: result discarded -> first p-box returned) ------------ +def test_imposition(): + result = pba.imposition(pba.box(0, 3), pba.box(1, 2)) + assert result.support().equiv(Interval(1, 2)) + + +# --- logical and/or for every dependency method (was: builtin min/max) ----- +@pytest.mark.parametrize("method", ["i", "p", "o", "+", "-", "f"]) +def test_logical_and_or(method): + a = pba.box(0.2, 0.4) + b = pba.box(0.1, 0.3) + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + assert isinstance(a.logicaland(b, method=method), Pbox) + assert isinstance(a.logicalor(b, method=method), Pbox) + + +# --- Interval hashability (kept Logical comparisons, added __hash__) ------- +def test_interval_hashable(): + # Defining __eq__ without __hash__ would make Interval unhashable. + assert isinstance(hash(Interval(0, 1)), int) + # Distinct intervals (distinct hashes) can live in a set without error. + s = {Interval(0, 1), Interval(2, 3)} + assert len(s) == 2 + + +# --- package metadata ------------------------------------------------------ +def test_version_attribute(): + assert isinstance(pba.__version__, str) From 541c90915129b8e0b767b321a97229af1cb95ca2 Mon Sep 17 00:00:00 2001 From: Nick Date: Mon, 29 Jun 2026 10:21:55 +0100 Subject: [PATCH 2/2] Make documentation correct and enforce docstring examples in CI The Sphinx build had broken cross-references and an invalid directive, and 22 of 86 docstring examples were wrong (incorrect outputs, removed/renamed APIs, or implementation pseudo-code mislabelled as runnable examples). Code fix surfaced by the docs: - Interval.intersection used a containment test (straddles) instead of an overlap test, so Interval(0,1).intersection(Interval(0.5,1.5)) returned None instead of [0.5, 1] (the documented result). Now uses a proper overlap test; the list form checks max(lefts) <= min(rights). Docstring example fixes: - pba.pm -> pba.PM - never/xtimes examples had outputs contradicting the (correct) comparison logic; replaced with comparisons that demonstrate the documented results - whitespace: "Interval [0,1]" -> "Interval [0, 1]" to match repr - converted implementation pseudo-code and side-effecting plotting/repr examples (>>> self.*, pm_repr/lr_repr global mutation, Pbox.show) into illustrative code-blocks so they aren't run as doctests - marked the non-deterministic Interval.sample() example +SKIP Sphinx build (now warning-free): - fixed broken refs (pba.core.envelope, pba.pbox.Pbox.env, interpolation, lr_interval_repr) using :func:/:meth: roles - replaced invalid ".. example::" directive with ".. admonition:: Example" - replaced remote-image :scale: with :width: to avoid size warning - conf.py: release now read from package metadata (was v0.16.2); copyright year Doctest enforcement: - conftest.py injects pba/np/Interval/Pbox/Logical into the doctest namespace - pyproject pytest config: testpaths=[tests, pba], --doctest-modules, NORMALIZE_WHITESPACE + ELLIPSIS - testing.yml runs the full suite so examples can't silently drift again Co-Authored-By: Claude Opus 4.8 --- .github/workflows/testing.yml | 4 +- conftest.py | 24 ++++++ docs/conf.py | 12 ++- pba/core.py | 4 +- pba/interval.py | 98 +++++++++++++------------ pba/logical.py | 6 +- pba/pbox.py | 25 +++++-- pba/pbox_constructors/non_parametric.py | 2 +- pyproject.toml | 6 ++ 9 files changed, 118 insertions(+), 63 deletions(-) create mode 100644 conftest.py diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 2740ada..530b6ec 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -20,6 +20,6 @@ jobs: run: | python -m pip install --upgrade pip pip install -e .[dev] - - name: Test with pytest + - name: Test with pytest (unit tests + docstring examples) run: | - pytest tests/ --junitxml=junit/test-results-${{ matrix.python-version }}.xml --cov=pba --cov-report=xml + pytest --junitxml=junit/test-results-${{ matrix.python-version }}.xml --cov=pba --cov-report=xml diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..934fc43 --- /dev/null +++ b/conftest.py @@ -0,0 +1,24 @@ +""" +Pytest configuration. + +Injects the common PBA names into the namespace used to run docstring +examples (``--doctest-modules``) so that examples written as they would be +typed at an interactive prompt (e.g. ``>>> pba.Interval(0, 1)``) execute +without an explicit import line. This keeps the documentation examples +runnable and verified in CI. +""" + +import pytest + + +@pytest.fixture(autouse=True) +def _add_doctest_namespace(doctest_namespace): + import numpy as np + + import pba + + doctest_namespace["pba"] = pba + doctest_namespace["np"] = np + doctest_namespace["Interval"] = pba.Interval + doctest_namespace["Pbox"] = pba.Pbox + doctest_namespace["Logical"] = pba.Logical diff --git a/docs/conf.py b/docs/conf.py index bbc150c..38e83f4 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -19,11 +19,17 @@ # -- Project information ----------------------------------------------------- project = 'PBA-for-python' -copyright = '2023, Nick Gray' +copyright = '2023-2026, Nick Gray' author = 'Nick Gray' -# The full version, including alpha/beta/rc tags -release = 'v0.16.2' +# The full version, including alpha/beta/rc tags. Sourced from the installed +# package metadata so it stays in sync with pyproject.toml. +try: + from importlib.metadata import version as _version + + release = _version("pba") +except Exception: + release = "0.0.0" # -- General configuration --------------------------------------------------- diff --git a/pba/core.py b/pba/core.py index bff236d..893254c 100644 --- a/pba/core.py +++ b/pba/core.py @@ -150,7 +150,9 @@ def mean(*args: Union[list, tuple], method="f"): Implemented as - >>> pba.sum(*args,method = method)/len(args) + .. code-block:: python + + pba.sum(*args, method=method) / len(args) """ s = sum(*args, method=method) diff --git a/pba/interval.py b/pba/interval.py index aec1da6..454eaad 100644 --- a/pba/interval.py +++ b/pba/interval.py @@ -27,9 +27,9 @@ class Interval: .. code-block:: python >>> pba.Interval(0,1) - Interval [0,1] + Interval [0, 1] >>> pba.I(2,3) - Interval [2,3] + Interval [2, 3] .. tip:: @@ -38,7 +38,7 @@ class Interval: Intervals can also be created from a single value ± half-width: >>> pba.PM(0,1) - Interval [-1,1] + Interval [-1, 1] By default intervals are displayed as ``Interval [a,b]`` where ``a`` and ``b`` are the left and right endpoints respectively. This can be changed using the `interval.pm_repr`_ and `interval.lr_repr`_ functions. @@ -655,7 +655,9 @@ def div(self, other, method=None): .. admonition:: Implementation - >>> self.add(1/other, method = method) + .. code-block:: python + + self.mul(1 / other, method=method) .. error:: @@ -819,7 +821,9 @@ def halfwidth(self) -> float: .. admonition:: Implementation - >>> self.width()/2 + .. code-block:: python + + self.width() / 2 """ return self.width() / 2 @@ -848,9 +852,11 @@ def to_logical(self): .. admonition:: Implementation - >>> left = self.left.__bool__() - >>> right = self.right.__bool__() - >>> Logical(left,right) + .. code-block:: python + + left = self.left.__bool__() + right = self.right.__bool__() + Logical(left, right) """ @@ -876,9 +882,9 @@ def env(self, other: Union[list, "Interval"]) -> "Interval": .. seealso:: - `pba.core.envelope`_ + :func:`~pba.core.envelope` - `pba.pbox.Pbox.env`_ + :meth:`~pba.pbox.Pbox.env` **Returns**: @@ -980,22 +986,24 @@ def intersection(self, other: Union["Interval", list]) -> "Interval": """ if isinstance(other, Interval): - if self.straddles(other): + # Two intervals intersect if they overlap (not only if one + # contains the other). + if self.left <= other.right and other.left <= self.right: return I( - max([x.left for x in [self, other]]), - min([x.right for x in [self, other]]), + max(self.left, other.left), + min(self.right, other.right), ) else: return None elif isinstance(other, list): - if all([self.straddles(o) for o in other]): - assert all( - [isinstance(o, Interval) for o in other] - ), "All intersected objects must be intervals" - return I( - max([x.left for x in [self] + other]), - min([x.right for x in [self] + other]), - ) + assert all( + [isinstance(o, Interval) for o in other] + ), "All intersected objects must be intervals" + intervals = [self] + other + lo = max(x.left for x in intervals) + hi = min(x.right for x in intervals) + if lo <= hi: + return I(lo, hi) else: return None else: @@ -1049,17 +1057,21 @@ def sample(self, seed=None, numpy_rng: np.random.Generator = None) -> float: If ``numpy_rng`` is given: - >>> numpy_rng.uniform(self.left, self.right) + .. code-block:: python + + numpy_rng.uniform(self.left, self.right) Otherwise the following is used: - >>> import random - >>> random.seed(seed) - >>> self.left + random.random() * self.width() + .. code-block:: python + + import random + random.seed(seed) + self.left + random.random() * self.width() **Examples**: - >>> pba.Interval(0,1).sample() + >>> pba.Interval(0,1).sample() # doctest: +SKIP 0.6160988752201705 >>> pba.I(0,1).sample(seed = 1) 0.13436424411240122 @@ -1105,7 +1117,7 @@ def PM(x, hw): ``ValueError``: If hw is less than 0. Example: - >>> pba.pm(0, 1) + >>> pba.PM(0, 1) Interval [-1, 1] """ @@ -1125,17 +1137,15 @@ def pm_repr(): .. code-block:: python - >>> import pba - >>> pba.interval.pm_repr() - >>> a = pba.Interval(0,1) # defined using left and right. This cannot be overriden. - >>> b = pba.PM(0,1) # defined using midpoint and half-width - >>> print(a) - Interval [0.5 ± 0.5] - >>> print(b) - Interval [0 ± 1] + import pba + pba.interval.pm_repr() + a = pba.Interval(0, 1) # defined using left and right; cannot be overridden + b = pba.PM(0, 1) # defined using midpoint and half-width + print(a) # Interval [0.5 ± 0.5] + print(b) # Interval [0 ± 1] .. seealso:: - :func:`~pba.lr_interval_repr` + :func:`~pba.interval.lr_repr` """ @@ -1159,15 +1169,13 @@ def lr_repr(): .. code-block:: python - >>> import pba - >>> pba.interval.pm_repr() - >>> a = pba.Interval(0,1) # defined using left and right, this cannot be overriden. - >>> print(a) - Interval [0.5±0.5] - >>> pba.interval.lr_repr() - >>> b = pba.PM(0,1) # defined using midpoint and half-width - >>> print(b) - Interval [-1,1] + import pba + pba.interval.pm_repr() + a = pba.Interval(0, 1) # defined using left and right; cannot be overridden + print(a) # Interval [0.5 ± 0.5] + pba.interval.lr_repr() + b = pba.PM(0, 1) # defined using midpoint and half-width + print(b) # Interval [-1, 1] """ diff --git a/pba/logical.py b/pba/logical.py index b6c3bb8..24bbcba 100644 --- a/pba/logical.py +++ b/pba/logical.py @@ -240,7 +240,7 @@ def never(logical: Logical) -> bool: >>> c = Interval(4, 5) >>> never(a < b) False - >>> never(a < c) + >>> never(c < a) True """ @@ -369,10 +369,10 @@ def xtimes(logical: Logical) -> bool: >>> a = pba.Interval(0, 2) >>> b = pba.Interval(2, 4) >>> c = pba.Interval(2.5,3.5) - >>> pba.xtimes(a < b) - False >>> pba.xtimes(a < c) False + >>> pba.xtimes(c < a) + False >>> pba.xtimes(c < b) True diff --git a/pba/pbox.py b/pba/pbox.py index 96ae34f..c3733a7 100644 --- a/pba/pbox.py +++ b/pba/pbox.py @@ -309,7 +309,7 @@ class Pbox: If steps is not specified, left and right must be arrays of the same length and steps is set at that value. - If steps is specified, both left and right are interpolated to the specified number of steps using the specified interpolation method (see interpolation_). In this case if steps is less than the length of left or right, a warning is raised and steps is set to the length of left or right. + If steps is specified, both left and right are interpolated to the specified number of steps using the specified interpolation method (see :meth:`interpolate`). In this case if steps is less than the length of left or right, a warning is raised and steps is set to the length of left or right. .. warning:: @@ -527,7 +527,9 @@ def unary(self, func, *args, **kwargs): The function must accept an Interval object as its first argument and return an Interval object. ``args`` and ``kwargs`` are passed to the function as additional arguments. - >>> func(Interval(l,r),*args, **kwargs) + .. code-block:: python + + func(Interval(l, r), *args, **kwargs) The function must return an Interval object. Behaviour may be unpredictable if the endpoints of the inputted interval do not correspond to the endpoints of the outputted p-box. @@ -556,7 +558,7 @@ def interpolate(self, steps: int, method: str = "linear", inplace=True) -> None: ``method = cubicspline`` uses ``scipy.interpolate.CubicSpline`` ``method = step`` uses a step interpolation method. - .. example:: + .. admonition:: Example .. image:: https://github.com/Institute-for-Risk-and-Uncertainty/pba-for-python/blob/master/docs/images/interpolation.png?raw=true @@ -775,7 +777,9 @@ def truncate( .. admonition:: Implementation - >>> self.min(a).max(b) + .. code-block:: python + + self.min(a, method=method).max(b, method=method) .. error:: @@ -889,9 +893,12 @@ def show( .. code-block:: python - >>> p = pba.N([-1,1],1) - >>> fig, ax = plt.subplots() - >>> p.show(figax = (fig,ax), now = True, title = 'Example', xlabel = 'x', ylabel = r'$\Pr(x \leq X)$',left_col = 'red',right_col = 'black') + import matplotlib.pyplot as plt + + p = pba.N([-1, 1], 1) + fig, ax = plt.subplots() + p.show(figax=(fig, ax), now=True, title="Example", xlabel="x", + ylabel=r"$\Pr(x \leq X)$", left_col="red", right_col="black") """ if figax is None: @@ -997,7 +1004,9 @@ def support(self) -> Interval: .. admonition:: Implementation - >>> Interval(self.lo(),self.hi()) + .. code-block:: python + + Interval(self.lo(), self.hi()) """ return Interval(min(self.left), max(self.right)) diff --git a/pba/pbox_constructors/non_parametric.py b/pba/pbox_constructors/non_parametric.py index 10908e7..6e93586 100644 --- a/pba/pbox_constructors/non_parametric.py +++ b/pba/pbox_constructors/non_parametric.py @@ -749,7 +749,7 @@ def from_percentiles(percentiles: dict, steps: int = Pbox.STEPS) -> Pbox: ).show() .. image:: https://github.com/Institute-for-Risk-and-Uncertainty/pba-for-python/blob/master/docs/images/from_percentiles.png?raw=true - :scale: 35 % + :width: 50 % :align: center """ diff --git a/pyproject.toml b/pyproject.toml index bb7d158..71f7b45 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,3 +35,9 @@ Repository = "https://github.com/Institute-for-Risk-and-Uncertainty/pba-for-pyth [tool.setuptools] packages = ["pba", "pba.pbox_constructors"] + +[tool.pytest.ini_options] +# Run the unit tests and verify every docstring example (doctest) in the package. +testpaths = ["tests", "pba"] +addopts = "--doctest-modules" +doctest_optionflags = "NORMALIZE_WHITESPACE ELLIPSIS"