From 71eec3be72a424555ea51999cc4ccdce3ffe8ff2 Mon Sep 17 00:00:00 2001 From: Justin Gerber Date: Sun, 14 Jul 2024 09:16:10 -0600 Subject: [PATCH 01/18] WIP on bugfix/avoid_test_case_overwrites --- tests/test_uncertainties.py | 6 +- uncertainties/core_new.py | 411 ++++++++++++++++++++++++++++++++++++ 2 files changed, 414 insertions(+), 3 deletions(-) create mode 100644 uncertainties/core_new.py diff --git a/tests/test_uncertainties.py b/tests/test_uncertainties.py index 4738371e..57ba1bb6 100644 --- a/tests/test_uncertainties.py +++ b/tests/test_uncertainties.py @@ -5,7 +5,7 @@ from math import isnan import uncertainties.core as uncert_core -from uncertainties.core import ufloat, AffineScalarFunc, ufloat_fromstr +from uncertainties.core_new import ufloat, UFloat as AffineScalarFunc, ufloat_fromstr from uncertainties import formatting from uncertainties import umath from helpers import ( @@ -108,8 +108,8 @@ def test_ufloat_fromstr(): # NaN value: "nan+/-3.14e2": (float("nan"), 314), # "Double-floats" - "(-3.1415 +/- 1e-4)e+200": (-3.1415e200, 1e196), - "(-3.1415e-10 +/- 1e-4)e+200": (-3.1415e190, 1e196), + # "(-3.1415 +/- 1e-4)e+200": (-3.1415e200, 1e196), + # "(-3.1415e-10 +/- 1e-4)e+200": (-3.1415e190, 1e196), # Special float representation: "-3(0.)": (-3, 0), } diff --git a/uncertainties/core_new.py b/uncertainties/core_new.py new file mode 100644 index 00000000..1cb91347 --- /dev/null +++ b/uncertainties/core_new.py @@ -0,0 +1,411 @@ +from __future__ import annotations + +from collections import defaultdict +from dataclasses import dataclass, field +from functools import lru_cache, wraps +import inspect +from math import sqrt +import sys +from typing import Callable, Collection, Dict, Optional, Tuple, Union, TYPE_CHECKING +import uuid + +from uncertainties.parsing import str_to_number_with_uncert + +if TYPE_CHECKING: + from inspect import Signature + + +@dataclass(frozen=True) +class UncertaintyAtom: + """ + Custom class to keep track of "atoms" of uncertainty. Note e.g. that + UncertaintyAtom(3) is UncertaintyAtom(3) + returns False. + """ + unc: float + uuid: uuid.UUID = field(default_factory=uuid.uuid4, init=False) + + +UncertaintyCombo = Tuple[ + Tuple[ + Union[UncertaintyAtom, "UncertaintyCombo"], + float + ], + ... +] +UncertaintyComboExpanded = Tuple[ + Tuple[ + UncertaintyAtom, + float + ], + ... +] + + +@lru_cache +def get_expanded_combo(combo: UncertaintyCombo) -> UncertaintyComboExpanded: + """ + Recursively expand a linear combination of uncertainties out into the base Atoms. + It is a performance optimization to sometimes store unexpanded linear combinations. + For example, there may be a long calculation involving many layers of UFloat + manipulations. We need not expand the linear combination until the end when a + calculation of a standard deviation on a UFloat is requested. + """ + expanded_dict = defaultdict(float) + for unc_combo_1, weight_1 in combo: + if isinstance(unc_combo_1, UncertaintyAtom): + expanded_dict[unc_combo_1] += weight_1 + else: + expanded_sub_combo = get_expanded_combo(unc_combo_1) + for unc_atom, weight_2 in expanded_sub_combo: + expanded_dict[unc_atom] += weight_2 * weight_1 + + return tuple((unc_atom, weight) for unc_atom, weight in expanded_dict.items()) + + +Value = Union["UValue", float] + + +class UFloat: + """ + Core class. Stores a mean value (val, nominal_value, n) and an uncertainty stored + as a (possibly unexpanded) linear combination of uncertainty atoms. Two UFloat's + which share non-zero weight for a certain uncertainty atom are correlated. + + UFloats can be combined using arithmetic and more sophisticated mathematical + operations. The uncertainty is propagation using rules of linear uncertainty + propagation. + """ + def __init__( + self, + /, + val: float, + unc: Union[UncertaintyCombo, float] = (), + tag: Optional[str] = None + ): + self._val = float(val) + if isinstance(unc, (float, int)): + unc_atom = UncertaintyAtom(float(unc)) + unc_combo = ((unc_atom, 1.0),) + self.unc_linear_combo = unc_combo + else: + self.unc_linear_combo = unc + self.tag = tag + + @property + def val(self: "UFloat") -> float: + return self._val + + @property + def unc(self: "UFloat") -> float: + expanded_combo = get_expanded_combo(self.unc_linear_combo) + return float(sqrt(sum([(weight * unc_atom.unc)**2 for unc_atom, weight in expanded_combo]))) + + @property + def nominal_value(self: "UFloat") -> float: + return self.val + + @property + def n(self: "UFloat") -> float: + return self.val + + @property + def std_dev(self: "UFloat") -> float: + return self.unc + + @property + def s(self: "UFloat") -> float: + return self.unc + + def __repr__(self) -> str: + return f'{self.__class__.__name__}({self.val}, {self.unc})' + + +SQRT_EPS = sqrt(sys.float_info.epsilon) + + +def get_param_name(sig: Signature, param: Union[int, str]): + if isinstance(param, int): + param_name = list(sig.parameters.keys())[param] + else: + param_name = param + return param_name + + +def partial_derivative( + f: Callable[..., float], + target_param: Union[str, int], + *args, + **kwargs +) -> float: + """ + Numerically calculate the partial derivative of a function f with respect to the + target_param (string name or position number of the float parameter to f to be + varied) holding all other arguments, *args and **kwargs, constant. + """ + sig = inspect.signature(f) + lower_bound_sig = sig.bind(*args, **kwargs) + upper_bound_sig = sig.bind(*args, **kwargs) + + for arg, val in lower_bound_sig.arguments.items(): + if isinstance(val, UFloat): + lower_bound_sig.arguments[arg] = val.val + upper_bound_sig.arguments[arg] = val.val + + target_param_name = get_param_name(sig, target_param) + + x = lower_bound_sig.arguments[target_param_name] + dx = abs(x) * SQRT_EPS # Numerical Recipes 3rd Edition, eq. 5.7.5 + + # Inject x - dx into target_param and evaluate f + lower_bound_sig.arguments[target_param_name] = x - dx + lower_y = f(*lower_bound_sig.args, **lower_bound_sig.kwargs) + + # Inject x + dx into target_param and evaluate f + upper_bound_sig.arguments[target_param_name] = x + dx + upper_y = f(*upper_bound_sig.args, **upper_bound_sig.kwargs) + + derivative = (upper_y - lower_y) / (2 * dx) + return derivative + + +ParamSpecifier = Union[str, int] +DerivFuncDict = Optional[Dict[ParamSpecifier, Optional[Callable[..., float]]]] + + +class ToUFunc: + """ + Decorator to convert a function which typically accepts float inputs into a function + which accepts UFloat inputs. + + >>> @ToUFunc(('x', 'y')) + >>> def multiply(x, y, print_str='print this string!', do_print=False): + ... if do_print: + ... print(print_str) + ... return x * y + + Pass in a list of parameter names which correspond to float inputs that should now + accept UFloat inputs. + + To calculate the output nominal value the decorator replaces all float inputs with + their respective nominal values and evaluates the function directly. + + To calculate the output uncertainty linear combination the decorator calculates the + partial derivative of the function with respect to each UFloat entry and appends the + uncertainty linear combination corresponding to that UFloat, weighted by the + corresponding partial derivative. + + The partial derivative is evaluated numerically by default using the + partial_derivative() function. However, the user can optionaly pass in + deriv_func_dict which maps each u_float parameter to a function that will calculate + the partial derivative given *args and **kwargs supplied to the converted function. + This latter approach may provide performance optimizations when it is faster to + use an analytic formula to evaluate the partial derivative than the numerical + calculation. + """ + def __init__( + self, + ufloat_params: Collection[ParamSpecifier], + deriv_func_dict: DerivFuncDict = None, + ): + self.ufloat_params = ufloat_params + if deriv_func_dict is None: + deriv_func_dict = { + ufloat_param: None for ufloat_param in self.ufloat_params + } + self.deriv_func_dict: DerivFuncDict = deriv_func_dict + + def __call__(self, f: Callable[..., float]): + sig = inspect.signature(f) + + @wraps(f) + def wrapped(*args, **kwargs): + """ + Calculate the + """ + unc_linear_combo = [] + bound = sig.bind(*args, **kwargs) + float_bound = sig.bind(*args, **kwargs) + + return_u_val = False + for param, param_val in float_bound.arguments.items(): + if isinstance(param_val, UFloat): + float_bound.arguments[param] = param_val.val + return_u_val = True + elif isinstance(param_val, int): + float_bound.arguments[param] = float(param_val) + + new_val = f(*float_bound.args, **float_bound.kwargs) + if not return_u_val: + return new_val + + for u_float_param in self.ufloat_params: + u_float_param_name = get_param_name(sig, u_float_param) + arg = bound.arguments[u_float_param_name] + if isinstance(arg, UFloat): + sub_unc_linear_combo = arg.unc_linear_combo + deriv_func = self.deriv_func_dict[u_float_param] + if deriv_func is None: + derivative = partial_derivative( + f, + u_float_param_name, + *args, + **kwargs, + ) + else: + derivative = deriv_func(*float_bound.args, **float_bound.kwargs) + + unc_linear_combo.append((sub_unc_linear_combo, derivative)) + + unc_linear_combo = tuple(unc_linear_combo) + return UFloat(new_val, unc_linear_combo) + + return wrapped + + +def func_str_to_positional_func(func_str, nargs): + if nargs == 1: + def pos_func(x): + return eval(func_str) + elif nargs == 2: + def pos_func(x, y): + return eval(func_str) + else: + raise ValueError(f'Only nargs=1 or nargs=2 is supported, not {nargs=}.') + return pos_func + + +def deriv_func_dict_positional_helper(deriv_funcs): + if not isinstance(deriv_funcs, tuple): + raise ValueError(f'deriv_funcs must be a tuple, not \"{deriv_funcs}\".') + + nargs = len(deriv_funcs) + deriv_func_dict = {} + + for arg_num, deriv_func in enumerate(deriv_funcs): + if isinstance(deriv_func, str): + deriv_func = func_str_to_positional_func(deriv_func, nargs) + elif deriv_func is None: + pass + else: + if not callable(deriv_func): + raise ValueError( + f'Derivative functions must be callable or strings. Not ' + f'{deriv_func}.' + ) + deriv_func_dict[arg_num] = deriv_func + return deriv_func_dict + + +class ToUFuncPositional(ToUFunc): + """ + Helper decorator for decorating a function to be UFloat compatible when only + positional arguments are being converted. Instead of passing a list of parameter + specifiers (names or number of parameters) and a dict of + parameter specifiers : derivative functions + we just pass a list of derivative functions. Each derivative function can either be + a callable of a function string like '-x/y**2'. + """ + def __init__(self, deriv_funcs: tuple[Callable[..., float]]): + ufloat_params = tuple(range(len(deriv_funcs))) + deriv_func_dict = deriv_func_dict_positional_helper(deriv_funcs) + super().__init__(ufloat_params, deriv_func_dict) + + +def add_float_funcs_to_uvalue(): + """ + Monkey-patch common float instance methods over to UFloat + + Here I use a notation involving x and y which is parsed by + resolve_deriv_func_dict_from_func_str_list. This is a compact way to specify the + formulas to calculate the partial derivatives of binary and unary functions. + + # TODO: There's a bit of complexity added by allowing analytic derivative function + # in addition to the default numerical derivative function. It would be + # interesting to see performance differences between the two methods. Is the + # added complexity *actually* buying performance? + """ + float_funcs_dict = { + '__abs__': ('abs(x)/x',), + '__pos__': ('1',), + '__neg__': ('-1',), + '__trunc__': ('0',), + '__add__': ('1', '1'), + '__radd__': ('1', '1'), + '__sub__': ('1', '-1'), + '__rsub__': ('-1', '1'), # Note reversed order + '__mul__': ('y', 'x'), + '__rmul__': ('x', 'y'), # Note reversed order + '__truediv__': ('1/y', '-x/y**2'), + '__rtruediv__': ('-x/y**2', '1/y'), # Note reversed order + '__floordiv__': ('0', '0'), # ? + '__rfloordiv__': ('0', '0'), # ? + '__pow__': (None, None), # TODO: add these, see `uncertainties` source + '__rpow__': (None, None), + '__mod__': (None, None), + '__rmod__': (None, None), + } + + for func_name, deriv_funcs in float_funcs_dict.items(): + float_func = getattr(float, func_name) + ufloat_ufunc = ToUFuncPositional(deriv_funcs)(float_func) + setattr(UFloat, func_name, ufloat_ufunc) + + +add_float_funcs_to_uvalue() + + +def ufloat(val, unc, tag=None): + return UFloat(val, unc, tag) + +def ufloat_fromstr(string, tag=None): + (nom, std) = str_to_number_with_uncert(string.strip()) + return ufloat(nom, std, tag) + +""" +^^^ +End libary code +____ +Begin sample test code +vvvv +""" +from math import sin + +usin = ToUFunc((0,))(sin) + +x = UFloat(10, 1) + +y = UFloat(10, 1) + +z = UFloat(20, 2) + +print(f'{x=}') +print(f'{-x=}') +print(f'{3*x=}') +print(f'{x-x=} # A UFloat is correlated with itself') + +print(f'{y=}') +print(f'{x-y=} # Two distinct UFloats are not correlated unless they have the same Uncertainty Atoms') + +print(f'{z=}') + +print(f'{x*z=}') +print(f'{x/z=}') +print(f'{x**z=}') + +print(f'{usin(x)=} # We can UFloat-ify complex functions') + +# x=UFloat(10.0, 1.0) +# -x=UFloat(-10.0, 1.0) +# 3*x=UFloat(30.0, 3.0) +# x-x=UFloat(0.0, 0.0) # A UFloat is correlated with itself +# y=UFloat(10.0, 1.0) +# x-y=UFloat(0.0, 1.4142135623730951) # Two distinct UFloats are not correlated unless they have the same Uncertainty Atoms +# z=UFloat(20.0, 2.0) +# x*z=UFloat(200.0, 28.284271247461902) +# x/z=UFloat(0.5, 0.07071067811865477) +# x**z=UFloat(1e+20, 5.0207163276303525e+20) +# usin(x)=UFloat(-0.5440211108893698, 0.8390715289860964) # We can UFloat-ify complex functions + + + From 651542086d905d10827b20b8fa598974c6ff9e9a Mon Sep 17 00:00:00 2001 From: Justin Gerber Date: Sun, 14 Jul 2024 22:24:37 -0600 Subject: [PATCH 02/18] some updates --- uncertainties/core_new.py | 99 ++++++++++++++++++++++++--------------- 1 file changed, 60 insertions(+), 39 deletions(-) diff --git a/uncertainties/core_new.py b/uncertainties/core_new.py index 1cb91347..154d8dad 100644 --- a/uncertainties/core_new.py +++ b/uncertainties/core_new.py @@ -5,6 +5,7 @@ from functools import lru_cache, wraps import inspect from math import sqrt +from numbers import Real import sys from typing import Callable, Collection, Dict, Optional, Tuple, Union, TYPE_CHECKING import uuid @@ -18,11 +19,10 @@ @dataclass(frozen=True) class UncertaintyAtom: """ - Custom class to keep track of "atoms" of uncertainty. Note e.g. that - UncertaintyAtom(3) is UncertaintyAtom(3) - returns False. + Custom class to keep track of "atoms" of uncertainty. Two UncertaintyAtoms are + always uncorrelated. """ - unc: float + std_dev: float uuid: uuid.UUID = field(default_factory=uuid.uuid4, init=False) @@ -45,25 +45,22 @@ class UncertaintyAtom: @lru_cache def get_expanded_combo(combo: UncertaintyCombo) -> UncertaintyComboExpanded: """ - Recursively expand a linear combination of uncertainties out into the base Atoms. + Recursively expand a linear combination of uncertainties out into the base atoms. It is a performance optimization to sometimes store unexpanded linear combinations. For example, there may be a long calculation involving many layers of UFloat manipulations. We need not expand the linear combination until the end when a calculation of a standard deviation on a UFloat is requested. """ expanded_dict = defaultdict(float) - for unc_combo_1, weight_1 in combo: - if isinstance(unc_combo_1, UncertaintyAtom): - expanded_dict[unc_combo_1] += weight_1 + for combo, combo_weight in combo: + if isinstance(combo, UncertaintyAtom): + expanded_dict[combo] += combo_weight else: - expanded_sub_combo = get_expanded_combo(unc_combo_1) - for unc_atom, weight_2 in expanded_sub_combo: - expanded_dict[unc_atom] += weight_2 * weight_1 + expanded_combo = get_expanded_combo(combo) + for atom, atom_weight in expanded_combo: + expanded_dict[atom] += atom_weight * combo_weight - return tuple((unc_atom, weight) for unc_atom, weight in expanded_dict.items()) - - -Value = Union["UValue", float] + return tuple((atom, weight) for atom, weight in expanded_dict.items()) class UFloat: @@ -79,28 +76,57 @@ class UFloat: def __init__( self, /, - val: float, - unc: Union[UncertaintyCombo, float] = (), + value: Real, + uncertainty: Union[UncertaintyCombo, Real] = (), tag: Optional[str] = None ): - self._val = float(val) - if isinstance(unc, (float, int)): - unc_atom = UncertaintyAtom(float(unc)) - unc_combo = ((unc_atom, 1.0),) - self.unc_linear_combo = unc_combo + self._val = float(value) + if isinstance(uncertainty, Real): + atom = UncertaintyAtom(float(uncertainty)) + uncertainty_combo = ((atom, 1.0),) + self.uncertainty_lin_combo = uncertainty_combo else: - self.unc_linear_combo = unc + self.uncertainty_lin_combo = uncertainty self.tag = tag + class dtype(object): + type = staticmethod(lambda value: value) + @property def val(self: "UFloat") -> float: return self._val @property - def unc(self: "UFloat") -> float: - expanded_combo = get_expanded_combo(self.unc_linear_combo) - return float(sqrt(sum([(weight * unc_atom.unc)**2 for unc_atom, weight in expanded_combo]))) + def std_dev(self: "UFloat") -> float: + # TODO: It would be interesting to memoize/cache this result. However, if we + # stored this result as an instance attribute that would qualify as a mutation + # of the object and have implications for hashability. For example, two UFloat + # objects might have different uncertainty_lin_combo, but when expanded + # they're the same so that the std_dev and even correlations with other UFloat + # are the same. Should these two have the same hash? My opinion is no. + # I think a good path forward could be to cache this as an instance attribute + # nonetheless, but to not include the std_dev in the hash. Also equality would + # be based on equality of uncertainty_lin_combo, not equality of std_dev. + expanded_lin_combo = get_expanded_combo(self.uncertainty_lin_combo) + list_of_squares = [ + (weight * atom.std_dev)**2 for atom, weight in expanded_lin_combo + ] + std_dev = sqrt(sum(list_of_squares)) + return std_dev + + def __eq__(self: "UFloat", other: "UFloat") -> bool: + if not isinstance(other, UFloat): + return False + val_eq = self.val == other.val + uncertainty_eq = self.uncertainty_lin_combo == other.uncertainty_lin_combo + return val_eq and uncertainty_eq + + # def __gt__(self, other): + + def __repr__(self) -> str: + return f'{self.__class__.__name__}({self.val}, {self.std_dev})' + # Aliases @property def nominal_value(self: "UFloat") -> float: return self.val @@ -109,17 +135,9 @@ def nominal_value(self: "UFloat") -> float: def n(self: "UFloat") -> float: return self.val - @property - def std_dev(self: "UFloat") -> float: - return self.unc - @property def s(self: "UFloat") -> float: - return self.unc - - def __repr__(self) -> str: - return f'{self.__class__.__name__}({self.val}, {self.unc})' - + return self.std_dev SQRT_EPS = sqrt(sys.float_info.epsilon) @@ -132,7 +150,7 @@ def get_param_name(sig: Signature, param: Union[int, str]): return param_name -def partial_derivative( +def numerical_partial_derivative( f: Callable[..., float], target_param: Union[str, int], *args, @@ -232,7 +250,7 @@ def wrapped(*args, **kwargs): if isinstance(param_val, UFloat): float_bound.arguments[param] = param_val.val return_u_val = True - elif isinstance(param_val, int): + elif isinstance(param_val, Real): float_bound.arguments[param] = float(param_val) new_val = f(*float_bound.args, **float_bound.kwargs) @@ -243,10 +261,10 @@ def wrapped(*args, **kwargs): u_float_param_name = get_param_name(sig, u_float_param) arg = bound.arguments[u_float_param_name] if isinstance(arg, UFloat): - sub_unc_linear_combo = arg.unc_linear_combo + sub_unc_linear_combo = arg.uncertainty_lin_combo deriv_func = self.deriv_func_dict[u_float_param] if deriv_func is None: - derivative = partial_derivative( + derivative = numerical_partial_derivative( f, u_float_param_name, *args, @@ -409,3 +427,6 @@ def ufloat_fromstr(string, tag=None): +import numpy as np +arr = np.array([x, y, z]) +print(np.mean(arr)) From 8299f58cdc9d575aa148b7fafd8a7951649a3510 Mon Sep 17 00:00:00 2001 From: Justin Gerber Date: Sun, 14 Jul 2024 23:44:10 -0600 Subject: [PATCH 03/18] cleanup --- uncertainties/core_new.py | 65 ++++++++++++++++++++------------------- 1 file changed, 34 insertions(+), 31 deletions(-) diff --git a/uncertainties/core_new.py b/uncertainties/core_new.py index 154d8dad..337160fc 100644 --- a/uncertainties/core_new.py +++ b/uncertainties/core_new.py @@ -89,9 +89,6 @@ def __init__( self.uncertainty_lin_combo = uncertainty self.tag = tag - class dtype(object): - type = staticmethod(lambda value: value) - @property def val(self: "UFloat") -> float: return self._val @@ -139,6 +136,7 @@ def n(self: "UFloat") -> float: def s(self: "UFloat") -> float: return self.std_dev + SQRT_EPS = sqrt(sys.float_info.epsilon) @@ -165,10 +163,10 @@ def numerical_partial_derivative( lower_bound_sig = sig.bind(*args, **kwargs) upper_bound_sig = sig.bind(*args, **kwargs) - for arg, val in lower_bound_sig.arguments.items(): - if isinstance(val, UFloat): - lower_bound_sig.arguments[arg] = val.val - upper_bound_sig.arguments[arg] = val.val + for param, arg in lower_bound_sig.arguments.items(): + if isinstance(arg, UFloat): + lower_bound_sig.arguments[param] = arg.val + upper_bound_sig.arguments[param] = arg.val target_param_name = get_param_name(sig, target_param) @@ -227,10 +225,12 @@ def __init__( deriv_func_dict: DerivFuncDict = None, ): self.ufloat_params = ufloat_params + if deriv_func_dict is None: - deriv_func_dict = { - ufloat_param: None for ufloat_param in self.ufloat_params - } + deriv_func_dict = {} + for ufloat_param in ufloat_params: + if ufloat_param not in deriv_func_dict: + deriv_func_dict[ufloat_param] = None self.deriv_func_dict: DerivFuncDict = deriv_func_dict def __call__(self, f: Callable[..., float]): @@ -238,11 +238,6 @@ def __call__(self, f: Callable[..., float]): @wraps(f) def wrapped(*args, **kwargs): - """ - Calculate the - """ - unc_linear_combo = [] - bound = sig.bind(*args, **kwargs) float_bound = sig.bind(*args, **kwargs) return_u_val = False @@ -257,11 +252,12 @@ def wrapped(*args, **kwargs): if not return_u_val: return new_val + ufloat_bound = sig.bind(*args, **kwargs) + new_uncertainty_lin_combo = [] for u_float_param in self.ufloat_params: u_float_param_name = get_param_name(sig, u_float_param) - arg = bound.arguments[u_float_param_name] + arg = ufloat_bound.arguments[u_float_param_name] if isinstance(arg, UFloat): - sub_unc_linear_combo = arg.uncertainty_lin_combo deriv_func = self.deriv_func_dict[u_float_param] if deriv_func is None: derivative = numerical_partial_derivative( @@ -273,9 +269,11 @@ def wrapped(*args, **kwargs): else: derivative = deriv_func(*float_bound.args, **float_bound.kwargs) - unc_linear_combo.append((sub_unc_linear_combo, derivative)) + new_uncertainty_lin_combo.append( + (arg.uncertainty_lin_combo, derivative) + ) - unc_linear_combo = tuple(unc_linear_combo) + unc_linear_combo = tuple(new_uncertainty_lin_combo) return UFloat(new_val, unc_linear_combo) return wrapped @@ -293,24 +291,27 @@ def pos_func(x, y): return pos_func -def deriv_func_dict_positional_helper(deriv_funcs): - if not isinstance(deriv_funcs, tuple): - raise ValueError(f'deriv_funcs must be a tuple, not \"{deriv_funcs}\".') +PositionalDerivFunc = Union[Callable[..., float], str] + +def deriv_func_dict_positional_helper( + deriv_funcs: Tuple[Optional[PositionalDerivFunc]], +): nargs = len(deriv_funcs) deriv_func_dict = {} for arg_num, deriv_func in enumerate(deriv_funcs): - if isinstance(deriv_func, str): - deriv_func = func_str_to_positional_func(deriv_func, nargs) - elif deriv_func is None: + if deriv_func is None: pass + elif callable(deriv_func): + pass + elif isinstance(deriv_func, str): + deriv_func = func_str_to_positional_func(deriv_func, nargs) else: - if not callable(deriv_func): - raise ValueError( - f'Derivative functions must be callable or strings. Not ' - f'{deriv_func}.' - ) + raise ValueError( + f'Invalid deriv_func: {deriv_func}. Must be None, callable, or a ' + f'string.' + ) deriv_func_dict[arg_num] = deriv_func return deriv_func_dict @@ -324,7 +325,7 @@ class ToUFuncPositional(ToUFunc): we just pass a list of derivative functions. Each derivative function can either be a callable of a function string like '-x/y**2'. """ - def __init__(self, deriv_funcs: tuple[Callable[..., float]]): + def __init__(self, deriv_funcs: Tuple[Optional[PositionalDerivFunc]]): ufloat_params = tuple(range(len(deriv_funcs))) deriv_func_dict = deriv_func_dict_positional_helper(deriv_funcs) super().__init__(ufloat_params, deriv_func_dict) @@ -376,10 +377,12 @@ def add_float_funcs_to_uvalue(): def ufloat(val, unc, tag=None): return UFloat(val, unc, tag) + def ufloat_fromstr(string, tag=None): (nom, std) = str_to_number_with_uncert(string.strip()) return ufloat(nom, std, tag) + """ ^^^ End libary code From f24ea0f9d3b3426ec2e0dc3992731785198d523a Mon Sep 17 00:00:00 2001 From: Justin Gerber Date: Mon, 15 Jul 2024 07:20:26 -0600 Subject: [PATCH 04/18] tests --- tests/test_core_new.py | 150 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 tests/test_core_new.py diff --git a/tests/test_core_new.py b/tests/test_core_new.py new file mode 100644 index 00000000..ec56e118 --- /dev/null +++ b/tests/test_core_new.py @@ -0,0 +1,150 @@ +from math import sqrt, sin, cos + +import pytest + +from uncertainties.core_new import UFloat, ToUFuncPositional + +repr_cases = cases = [ + (UFloat(10, 1), 'UFloat(10.0, 1.0)'), + (UFloat(20, 2), 'UFloat(20.0, 2.0)'), + (UFloat(30, 3), 'UFloat(30.0, 3.0)'), + (UFloat(-30, 3), 'UFloat(-30.0, 3.0)'), + (UFloat(-30, float('nan')), 'UFloat(-30.0, nan)'), + ] + + +@pytest.mark.parametrize("unum, expected_repr_str", repr_cases) +def test_repr(unum: UFloat, expected_repr_str: str): + assert repr(unum) == expected_repr_str + + +x = UFloat(10, 1) +unary_cases = [ + (-x, -10, 1), + (+x, 10, 1), + (abs(x), 10, 1), + (abs(-x), 10, 1), +] + + +@pytest.mark.parametrize( + "unum, expected_val, expected_std_dev", + unary_cases, +) +def test_unary( + unum: UFloat, + expected_val: float, + expected_std_dev: float, +): + assert unum.val == expected_val + assert unum.std_dev == expected_std_dev + + +x = UFloat(10, 1) +y = UFloat(20, 2) +binary_cases = [ + (x + 20, 30, 1), + (x - 20, -10, 1), + (x * 20, 200, 20), + (x / 20, 0.5, 0.05), + (20 + x, 30, 1), + (-20 + x, -10, 1), + (20 * x, 200, 20), + (x + y, 30, sqrt(2**2 + 1**2)), + (x * y, 200, sqrt(20**2 + 20**2)), + (x / y, 0.5, sqrt((1/20)**2 + (2*10/(20**2))**2)), +] + + +@pytest.mark.parametrize( + "unum, expected_val, expected_std_dev", + binary_cases, +) +def test_binary( + unum: UFloat, + expected_val: float, + expected_std_dev: float, +): + assert unum.val == expected_val + assert unum.std_dev == expected_std_dev + + +u_zero = UFloat(0, 0) +x = UFloat(10, 1) +y = UFloat(10, 1) +equals_cases = [ + (x, x), + (x-x, u_zero), + (2*x - x, x), + (x*0, u_zero), + (x*0, y*0), +] + + +@pytest.mark.parametrize( + "first, second", + equals_cases, +) +def test_equals(first, second): + assert first == second + + +u_zero = UFloat(0, 0) +x = UFloat(10, 1) +y = UFloat(10, 1) +not_equals_cases = [ + (x, y), + (x-y, u_zero), + (x, 10), +] + + +@pytest.mark.parametrize( + "first, second", + not_equals_cases, +) +def test_not_equals(first, second): + assert first != second + + +usin = ToUFuncPositional((lambda x: cos(x),))(sin) +x = UFloat(10, 2) +sin_cases = [ + (usin(x), sin(10), 2 * cos(10)) +] + + +@pytest.mark.parametrize( + "unum, expected_val, expected_std_dev", + binary_cases, +) +def test_sin( + unum: UFloat, + expected_val: float, + expected_std_dev: float, +): + assert unum.val == expected_val + assert unum.std_dev == expected_std_dev + + +u_zero = UFloat(0, 0) +x = UFloat(10, 2) +y = UFloat(10, 2) +bool_val_cases = [ + (u_zero, False), + (x, True), + (y, True), + (x-y, True), + (x-x, False), + (y-y, False), + (0*x, False), + (0*y, False), +] + + +@pytest.mark.parametrize( + "unum, bool_val", + bool_val_cases, +) +def test_bool(unum: UFloat, bool_val: bool): + assert bool(unum) is bool_val From 6946067abe5255df663ed74b935864dbad5a1ef0 Mon Sep 17 00:00:00 2001 From: Justin Gerber Date: Mon, 15 Jul 2024 07:20:46 -0600 Subject: [PATCH 05/18] some nan handling --- uncertainties/core_new.py | 120 ++++++++++++++++++++------------------ 1 file changed, 63 insertions(+), 57 deletions(-) diff --git a/uncertainties/core_new.py b/uncertainties/core_new.py index 337160fc..4e90df9e 100644 --- a/uncertainties/core_new.py +++ b/uncertainties/core_new.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field from functools import lru_cache, wraps import inspect -from math import sqrt +from math import sqrt, isnan from numbers import Real import sys from typing import Callable, Collection, Dict, Optional, Tuple, Union, TYPE_CHECKING @@ -60,7 +60,33 @@ def get_expanded_combo(combo: UncertaintyCombo) -> UncertaintyComboExpanded: for atom, atom_weight in expanded_combo: expanded_dict[atom] += atom_weight * combo_weight - return tuple((atom, weight) for atom, weight in expanded_dict.items()) + pruned_expanded_dict = {} + for atom, weight in expanded_dict.items(): + if atom.std_dev == 0 or (weight == 0 and not isnan(atom.std_dev)): + continue + pruned_expanded_dict[atom] = weight + + return tuple((atom, weight) for atom, weight in pruned_expanded_dict.items()) + + +# class UFloatBinOp: +# def __init__(self, bin_op_str): +# self.bin_op_str = bin_op_str +# +# def __call__(self, bin_op): +# @wraps(bin_op) +# def ufloat_bin_op(first, second): +# if isinstance(second, UFloat): +# return bin_op(first.val, second.val) +# elif isinstance(second, Real): +# return bin_op(first.val, float(second)) +# else: +# pass +# # raise TypeError( +# # f'\'{self.bin_op_str}\' not supported between instances of ' +# # f'\'UFloat\' and \'{type(second)}\'' +# # ) +# return ufloat_bin_op class UFloat: @@ -115,14 +141,46 @@ def __eq__(self: "UFloat", other: "UFloat") -> bool: if not isinstance(other, UFloat): return False val_eq = self.val == other.val - uncertainty_eq = self.uncertainty_lin_combo == other.uncertainty_lin_combo - return val_eq and uncertainty_eq + self_expanded_linear_combo = get_expanded_combo(self.uncertainty_lin_combo) + other_expanded_linear_combo = get_expanded_combo(other.uncertainty_lin_combo) + uncertainty_eq = self_expanded_linear_combo == other_expanded_linear_combo + return val_eq and uncertainty_eq + # + # TODO: UFloat shouldn't implement binary comparison operators. The easy way to do + # it would be to have the operators [==, !=, >, >=, <, <=] all do direct + # comparisons on UFloat.val. But then we would have + # ufloat(1, 1) - ufloat(1, 1) == ufloat(0, 0) + # which I don't think is totally appropriate since the lefthand side has + # non-zero uncertainty due to the lack of correlations. But if __eq__ depends on + # both val and uncertainty_lin_combo it's impossible to define a total order on + # UFloat. We could define [>, <] to depend only on UFloat.val, but it would be + # impossible to define [>=, <=] in a way that respects both [>, <] that depend + # only on val AND respects [==, !=] which depend on val and uncertainty_lin_combo. + + # + # @UFloatBinOp('>') # def __gt__(self, other): + # return self > other + # + # @UFloatBinOp('>=') + # def __ge__(self, other): + # return self >= other + # + # @UFloatBinOp('<') + # def __lt__(self, other): + # return self < other + # + # @UFloatBinOp('<=') + # def __le__(self, other): + # return self <= other def __repr__(self) -> str: return f'{self.__class__.__name__}({self.val}, {self.std_dev})' + def __bool__(self): + return self != UFloat(0, 0) + # Aliases @property def nominal_value(self: "UFloat") -> float: @@ -354,7 +412,7 @@ def add_float_funcs_to_uvalue(): '__sub__': ('1', '-1'), '__rsub__': ('-1', '1'), # Note reversed order '__mul__': ('y', 'x'), - '__rmul__': ('x', 'y'), # Note reversed order + '__rmul__': ('y', 'x'), # Note reversed order '__truediv__': ('1/y', '-x/y**2'), '__rtruediv__': ('-x/y**2', '1/y'), # Note reversed order '__floordiv__': ('0', '0'), # ? @@ -381,55 +439,3 @@ def ufloat(val, unc, tag=None): def ufloat_fromstr(string, tag=None): (nom, std) = str_to_number_with_uncert(string.strip()) return ufloat(nom, std, tag) - - -""" -^^^ -End libary code -____ -Begin sample test code -vvvv -""" -from math import sin - -usin = ToUFunc((0,))(sin) - -x = UFloat(10, 1) - -y = UFloat(10, 1) - -z = UFloat(20, 2) - -print(f'{x=}') -print(f'{-x=}') -print(f'{3*x=}') -print(f'{x-x=} # A UFloat is correlated with itself') - -print(f'{y=}') -print(f'{x-y=} # Two distinct UFloats are not correlated unless they have the same Uncertainty Atoms') - -print(f'{z=}') - -print(f'{x*z=}') -print(f'{x/z=}') -print(f'{x**z=}') - -print(f'{usin(x)=} # We can UFloat-ify complex functions') - -# x=UFloat(10.0, 1.0) -# -x=UFloat(-10.0, 1.0) -# 3*x=UFloat(30.0, 3.0) -# x-x=UFloat(0.0, 0.0) # A UFloat is correlated with itself -# y=UFloat(10.0, 1.0) -# x-y=UFloat(0.0, 1.4142135623730951) # Two distinct UFloats are not correlated unless they have the same Uncertainty Atoms -# z=UFloat(20.0, 2.0) -# x*z=UFloat(200.0, 28.284271247461902) -# x/z=UFloat(0.5, 0.07071067811865477) -# x**z=UFloat(1e+20, 5.0207163276303525e+20) -# usin(x)=UFloat(-0.5440211108893698, 0.8390715289860964) # We can UFloat-ify complex functions - - - -import numpy as np -arr = np.array([x, y, z]) -print(np.mean(arr)) From 7d3ec324990bafa92a1228610030d3448bee6068 Mon Sep 17 00:00:00 2001 From: Justin Gerber Date: Mon, 15 Jul 2024 07:21:13 -0600 Subject: [PATCH 06/18] comment --- uncertainties/core_new.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/uncertainties/core_new.py b/uncertainties/core_new.py index 4e90df9e..ab319c8c 100644 --- a/uncertainties/core_new.py +++ b/uncertainties/core_new.py @@ -412,7 +412,7 @@ def add_float_funcs_to_uvalue(): '__sub__': ('1', '-1'), '__rsub__': ('-1', '1'), # Note reversed order '__mul__': ('y', 'x'), - '__rmul__': ('y', 'x'), # Note reversed order + '__rmul__': ('y', 'x'), '__truediv__': ('1/y', '-x/y**2'), '__rtruediv__': ('-x/y**2', '1/y'), # Note reversed order '__floordiv__': ('0', '0'), # ? From c3dc427c94bf1263f2f8dfe5ff9affcd1be35310 Mon Sep 17 00:00:00 2001 From: Justin Gerber Date: Mon, 15 Jul 2024 07:44:16 -0600 Subject: [PATCH 07/18] revert test_uncertainties changes --- tests/test_uncertainties.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_uncertainties.py b/tests/test_uncertainties.py index 57ba1bb6..4738371e 100644 --- a/tests/test_uncertainties.py +++ b/tests/test_uncertainties.py @@ -5,7 +5,7 @@ from math import isnan import uncertainties.core as uncert_core -from uncertainties.core_new import ufloat, UFloat as AffineScalarFunc, ufloat_fromstr +from uncertainties.core import ufloat, AffineScalarFunc, ufloat_fromstr from uncertainties import formatting from uncertainties import umath from helpers import ( @@ -108,8 +108,8 @@ def test_ufloat_fromstr(): # NaN value: "nan+/-3.14e2": (float("nan"), 314), # "Double-floats" - # "(-3.1415 +/- 1e-4)e+200": (-3.1415e200, 1e196), - # "(-3.1415e-10 +/- 1e-4)e+200": (-3.1415e190, 1e196), + "(-3.1415 +/- 1e-4)e+200": (-3.1415e200, 1e196), + "(-3.1415e-10 +/- 1e-4)e+200": (-3.1415e190, 1e196), # Special float representation: "-3(0.)": (-3, 0), } From d2388c200b9915d090aeee8aaed07844f8490f40 Mon Sep 17 00:00:00 2001 From: Justin Gerber Date: Mon, 15 Jul 2024 22:25:50 -0600 Subject: [PATCH 08/18] operation type hints and cleanup --- uncertainties/core_new.py | 100 ++++++++++++++++---------------------- 1 file changed, 42 insertions(+), 58 deletions(-) diff --git a/uncertainties/core_new.py b/uncertainties/core_new.py index ab319c8c..9644e180 100644 --- a/uncertainties/core_new.py +++ b/uncertainties/core_new.py @@ -69,26 +69,6 @@ def get_expanded_combo(combo: UncertaintyCombo) -> UncertaintyComboExpanded: return tuple((atom, weight) for atom, weight in pruned_expanded_dict.items()) -# class UFloatBinOp: -# def __init__(self, bin_op_str): -# self.bin_op_str = bin_op_str -# -# def __call__(self, bin_op): -# @wraps(bin_op) -# def ufloat_bin_op(first, second): -# if isinstance(second, UFloat): -# return bin_op(first.val, second.val) -# elif isinstance(second, Real): -# return bin_op(first.val, float(second)) -# else: -# pass -# # raise TypeError( -# # f'\'{self.bin_op_str}\' not supported between instances of ' -# # f'\'UFloat\' and \'{type(second)}\'' -# # ) -# return ufloat_bin_op - - class UFloat: """ Core class. Stores a mean value (val, nominal_value, n) and an uncertainty stored @@ -137,44 +117,6 @@ def std_dev(self: "UFloat") -> float: std_dev = sqrt(sum(list_of_squares)) return std_dev - def __eq__(self: "UFloat", other: "UFloat") -> bool: - if not isinstance(other, UFloat): - return False - val_eq = self.val == other.val - - self_expanded_linear_combo = get_expanded_combo(self.uncertainty_lin_combo) - other_expanded_linear_combo = get_expanded_combo(other.uncertainty_lin_combo) - uncertainty_eq = self_expanded_linear_combo == other_expanded_linear_combo - return val_eq and uncertainty_eq - # - # TODO: UFloat shouldn't implement binary comparison operators. The easy way to do - # it would be to have the operators [==, !=, >, >=, <, <=] all do direct - # comparisons on UFloat.val. But then we would have - # ufloat(1, 1) - ufloat(1, 1) == ufloat(0, 0) - # which I don't think is totally appropriate since the lefthand side has - # non-zero uncertainty due to the lack of correlations. But if __eq__ depends on - # both val and uncertainty_lin_combo it's impossible to define a total order on - # UFloat. We could define [>, <] to depend only on UFloat.val, but it would be - # impossible to define [>=, <=] in a way that respects both [>, <] that depend - # only on val AND respects [==, !=] which depend on val and uncertainty_lin_combo. - - # - # @UFloatBinOp('>') - # def __gt__(self, other): - # return self > other - # - # @UFloatBinOp('>=') - # def __ge__(self, other): - # return self >= other - # - # @UFloatBinOp('<') - # def __lt__(self, other): - # return self < other - # - # @UFloatBinOp('<=') - # def __le__(self, other): - # return self <= other - def __repr__(self) -> str: return f'{self.__class__.__name__}({self.val}, {self.std_dev})' @@ -194,6 +136,48 @@ def n(self: "UFloat") -> float: def s(self: "UFloat") -> float: return self.std_dev + def __eq__(self: "UFloat", other: "UFloat") -> bool: + if not isinstance(other, UFloat): + return False + val_eq = self.val == other.val + + self_expanded_linear_combo = get_expanded_combo(self.uncertainty_lin_combo) + other_expanded_linear_combo = get_expanded_combo(other.uncertainty_lin_combo) + uncertainty_eq = self_expanded_linear_combo == other_expanded_linear_combo + return val_eq and uncertainty_eq + + def __pos__(self: "UFloat") -> "UFloat": ... + + def __neg__(self: "UFloat") -> "UFloat": ... + + def __abs__(self: "UFloat") -> "UFloat": ... + + def __trunc__(self: "UFloat") -> "UFloat": ... + + def __add__(self: "UFloat", other: Union["UFloat", Real]) -> "UFloat": ... + + def __radd__(self: "UFloat", other: Union["UFloat", Real]) -> "UFloat": ... + + def __sub__(self: "UFloat", other: Union["UFloat", Real]) -> "UFloat": ... + + def __rsub__(self: "UFloat", other: Union["UFloat", Real]) -> "UFloat": ... + + def __mul__(self: "UFloat", other: Union["UFloat", Real]) -> "UFloat": ... + + def __rmul__(self: "UFloat", other: Union["UFloat", Real]) -> "UFloat": ... + + def __truediv__(self: "UFloat", other: Union["UFloat", Real]) -> "UFloat": ... + + def __rtruediv__(self: "UFloat", other: Union["UFloat", Real]) -> "UFloat": ... + + def __pow__(self: "UFloat", other: Union["UFloat", Real]) -> "UFloat": ... + + def __rpow__(self: "UFloat", other: Union["UFloat", Real]) -> "UFloat": ... + + def __mod__(self: "UFloat", other: Union["UFloat", Real]) -> "UFloat": ... + + def __rmod__(self: "UFloat", other: Union["UFloat", Real]) -> "UFloat": ... + SQRT_EPS = sqrt(sys.float_info.epsilon) From 08b22dda3ab18c9171a0391550010054728633bd Mon Sep 17 00:00:00 2001 From: Justin Gerber Date: Mon, 15 Jul 2024 22:58:14 -0600 Subject: [PATCH 09/18] docstring --- uncertainties/core_new.py | 44 +++++++++++++++------------------------ 1 file changed, 17 insertions(+), 27 deletions(-) diff --git a/uncertainties/core_new.py b/uncertainties/core_new.py index 9644e180..933942b3 100644 --- a/uncertainties/core_new.py +++ b/uncertainties/core_new.py @@ -233,33 +233,22 @@ def numerical_partial_derivative( class ToUFunc: """ - Decorator to convert a function which typically accepts float inputs into a function - which accepts UFloat inputs. - - >>> @ToUFunc(('x', 'y')) - >>> def multiply(x, y, print_str='print this string!', do_print=False): - ... if do_print: - ... print(print_str) - ... return x * y - - Pass in a list of parameter names which correspond to float inputs that should now - accept UFloat inputs. - - To calculate the output nominal value the decorator replaces all float inputs with - their respective nominal values and evaluates the function directly. - - To calculate the output uncertainty linear combination the decorator calculates the - partial derivative of the function with respect to each UFloat entry and appends the - uncertainty linear combination corresponding to that UFloat, weighted by the - corresponding partial derivative. - - The partial derivative is evaluated numerically by default using the - partial_derivative() function. However, the user can optionaly pass in - deriv_func_dict which maps each u_float parameter to a function that will calculate - the partial derivative given *args and **kwargs supplied to the converted function. - This latter approach may provide performance optimizations when it is faster to - use an analytic formula to evaluate the partial derivative than the numerical - calculation. + Decorator which converts a function which accepts real numbers and returns a real + number into a function which accepts UFloats and returns a UFloat. The returned + UFloat will have the same value as if the original function had been called using + the values of the input UFloats. But, additionally, it will have an uncertainty + corresponding to the square root of the sum of squares of the uncertainties of the + input UFloats weighted by the partial derivatives of the original function with + respect to the corresponding input parameters. + + :param ufloat_params: Collection of strings or integers indicating the name or + position index of the parameters which will be made to accept UFloat. + :param deriv_func_dict: Dictionary mapping parameters specified in ufloat_params to + functions that return the partial derivatives of the decorated function with + respect to the corresponding parameter. The partial derivative functions should + have the same signature as the decorated function. If any ufloat param is absent + or is mapped to ``None`` then the partial derivatives will be evaluated + numerically. """ def __init__( self, @@ -321,6 +310,7 @@ def wrapped(*args, **kwargs): return wrapped +# noinspection PyUnusedLocal def func_str_to_positional_func(func_str, nargs): if nargs == 1: def pos_func(x): From b1b534a02f130c41aefe1ca6f33df88cb72d52ef Mon Sep 17 00:00:00 2001 From: Justin Gerber Date: Mon, 15 Jul 2024 23:10:48 -0600 Subject: [PATCH 10/18] documentation --- uncertainties/core_new.py | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/uncertainties/core_new.py b/uncertainties/core_new.py index 933942b3..ee046685 100644 --- a/uncertainties/core_new.py +++ b/uncertainties/core_new.py @@ -26,6 +26,24 @@ class UncertaintyAtom: uuid: uuid.UUID = field(default_factory=uuid.uuid4, init=False) +""" +UncertaintyCombo represents a (possibly nested) linear superposition of +UncertaintyAtoms. The UncertaintyCombo is an n-tuple of terms in the linear +superposition and each term is represented by a 2-tuple. The second element of the +2-tuple is the weight of that term. The first element is either an UncertaintyAtom or +another UncertaintyCombo. In the latter case the original UncertaintyCombo is nested. + +By passing the weights through the linear combinations and collecting like terms, any +UncertaintyCombo can be expanded into a form where each term is an UncertaintyAtom. This +would be an ExpandedUncertaintyCombo. + +Nested UncertaintyCombos are supported as a performance optimization. There is a +cost to expanding linear combinations during uncertainty propagation calculations. +Supporting nested UncertaintyCombos allows expansion to be deferred through intermediate +calculations until a standard deviation or correlation must be calculated at the end of +an error propagation calculation. +""" +# TODO: How much does this optimization quantitatively improve performance? UncertaintyCombo = Tuple[ Tuple[ Union[UncertaintyAtom, "UncertaintyCombo"], @@ -33,7 +51,7 @@ class UncertaintyAtom: ], ... ] -UncertaintyComboExpanded = Tuple[ +ExpandedUncertaintyCombo = Tuple[ Tuple[ UncertaintyAtom, float @@ -43,13 +61,10 @@ class UncertaintyAtom: @lru_cache -def get_expanded_combo(combo: UncertaintyCombo) -> UncertaintyComboExpanded: +def get_expanded_combo(combo: UncertaintyCombo) -> ExpandedUncertaintyCombo: """ - Recursively expand a linear combination of uncertainties out into the base atoms. - It is a performance optimization to sometimes store unexpanded linear combinations. - For example, there may be a long calculation involving many layers of UFloat - manipulations. We need not expand the linear combination until the end when a - calculation of a standard deviation on a UFloat is requested. + Recursively expand a nested UncertaintyCombo into an ExpandedUncertaintyCombo whose + terms all represent weighted UncertaintyAtoms. """ expanded_dict = defaultdict(float) for combo, combo_weight in combo: @@ -71,12 +86,12 @@ def get_expanded_combo(combo: UncertaintyCombo) -> UncertaintyComboExpanded: class UFloat: """ - Core class. Stores a mean value (val, nominal_value, n) and an uncertainty stored + Core class. Stores a mean value (value, nominal_value, n) and an uncertainty stored as a (possibly unexpanded) linear combination of uncertainty atoms. Two UFloat's which share non-zero weight for a certain uncertainty atom are correlated. UFloats can be combined using arithmetic and more sophisticated mathematical - operations. The uncertainty is propagation using rules of linear uncertainty + operations. The uncertainty is propagtaed using the rules of linear uncertainty propagation. """ def __init__( From deaf4f9b42406cd56baa75e03c35bdd894331fcb Mon Sep 17 00:00:00 2001 From: Justin Gerber Date: Mon, 15 Jul 2024 23:14:16 -0600 Subject: [PATCH 11/18] Cache standard deviation calculation --- uncertainties/core_new.py | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/uncertainties/core_new.py b/uncertainties/core_new.py index ee046685..35a7bf9f 100644 --- a/uncertainties/core_new.py +++ b/uncertainties/core_new.py @@ -60,7 +60,7 @@ class UncertaintyAtom: ] -@lru_cache +@lru_cache(maxsize=None) def get_expanded_combo(combo: UncertaintyCombo) -> ExpandedUncertaintyCombo: """ Recursively expand a nested UncertaintyCombo into an ExpandedUncertaintyCombo whose @@ -84,6 +84,20 @@ def get_expanded_combo(combo: UncertaintyCombo) -> ExpandedUncertaintyCombo: return tuple((atom, weight) for atom, weight in pruned_expanded_dict.items()) +@lru_cache(maxsize=None) +def get_std_dev(combo: UncertaintyCombo) -> float: + """ + Get the standard deviation corresponding to an UncertaintyCombo. The UncertainyCombo + is expanded and the weighted UncertaintyAtoms are added in quadrature. + """ + expanded_combo = get_expanded_combo(combo) + list_of_squares = [ + (weight*atom.std_dev)**2 for atom, weight in expanded_combo + ] + std_dev = sqrt(sum(list_of_squares)) + return std_dev + + class UFloat: """ Core class. Stores a mean value (value, nominal_value, n) and an uncertainty stored @@ -116,21 +130,7 @@ def val(self: "UFloat") -> float: @property def std_dev(self: "UFloat") -> float: - # TODO: It would be interesting to memoize/cache this result. However, if we - # stored this result as an instance attribute that would qualify as a mutation - # of the object and have implications for hashability. For example, two UFloat - # objects might have different uncertainty_lin_combo, but when expanded - # they're the same so that the std_dev and even correlations with other UFloat - # are the same. Should these two have the same hash? My opinion is no. - # I think a good path forward could be to cache this as an instance attribute - # nonetheless, but to not include the std_dev in the hash. Also equality would - # be based on equality of uncertainty_lin_combo, not equality of std_dev. - expanded_lin_combo = get_expanded_combo(self.uncertainty_lin_combo) - list_of_squares = [ - (weight * atom.std_dev)**2 for atom, weight in expanded_lin_combo - ] - std_dev = sqrt(sum(list_of_squares)) - return std_dev + return get_std_dev(self.uncertainty_lin_combo) def __repr__(self) -> str: return f'{self.__class__.__name__}({self.val}, {self.std_dev})' From 2ceb966ebb9f4da634be639505cbc5e9788b1ef9 Mon Sep 17 00:00:00 2001 From: Justin Gerber Date: Tue, 16 Jul 2024 07:01:36 -0600 Subject: [PATCH 12/18] Cleanup and documentation --- uncertainties/core_new.py | 58 ++++++++++++++++----------------------- 1 file changed, 23 insertions(+), 35 deletions(-) diff --git a/uncertainties/core_new.py b/uncertainties/core_new.py index 35a7bf9f..ae031267 100644 --- a/uncertainties/core_new.py +++ b/uncertainties/core_new.py @@ -10,8 +10,6 @@ from typing import Callable, Collection, Dict, Optional, Tuple, Union, TYPE_CHECKING import uuid -from uncertainties.parsing import str_to_number_with_uncert - if TYPE_CHECKING: from inspect import Signature @@ -220,11 +218,6 @@ def numerical_partial_derivative( lower_bound_sig = sig.bind(*args, **kwargs) upper_bound_sig = sig.bind(*args, **kwargs) - for param, arg in lower_bound_sig.arguments.items(): - if isinstance(arg, UFloat): - lower_bound_sig.arguments[param] = arg.val - upper_bound_sig.arguments[param] = arg.val - target_param_name = get_param_name(sig, target_param) x = lower_bound_sig.arguments[target_param_name] @@ -309,8 +302,8 @@ def wrapped(*args, **kwargs): derivative = numerical_partial_derivative( f, u_float_param_name, - *args, - **kwargs, + *float_bound.args, + **float_bound.kwargs, ) else: derivative = deriv_func(*float_bound.args, **float_bound.kwargs) @@ -319,19 +312,18 @@ def wrapped(*args, **kwargs): (arg.uncertainty_lin_combo, derivative) ) - unc_linear_combo = tuple(new_uncertainty_lin_combo) - return UFloat(new_val, unc_linear_combo) + new_uncertainty_lin_combo = tuple(new_uncertainty_lin_combo) + return UFloat(new_val, new_uncertainty_lin_combo) return wrapped -# noinspection PyUnusedLocal def func_str_to_positional_func(func_str, nargs): if nargs == 1: - def pos_func(x): + def pos_func(x): # noqa return eval(func_str) elif nargs == 2: - def pos_func(x, y): + def pos_func(x, y): # noqa return eval(func_str) else: raise ValueError(f'Only nargs=1 or nargs=2 is supported, not {nargs=}.') @@ -365,12 +357,17 @@ def deriv_func_dict_positional_helper( class ToUFuncPositional(ToUFunc): """ - Helper decorator for decorating a function to be UFloat compatible when only - positional arguments are being converted. Instead of passing a list of parameter - specifiers (names or number of parameters) and a dict of - parameter specifiers : derivative functions - we just pass a list of derivative functions. Each derivative function can either be - a callable of a function string like '-x/y**2'. + Helper decorator for ToUFunc for functions which accept one or two floats as + positional input parameters and return a float. + + :param deriv_funcs: List of functions or strings specifying a custom partial + derivative function for each parameter of the wrapped function. There must be an + element in the list for every parameter of the wrapped function. Elements of the + list can be callable functions with the same number of positional arguments + as the wrapped function. They can also be string representations of functions such + as 'x', 'y', '1/y', '-x/y**2' etc. Unary functions should use 'x' as the parameter + and binary functions should use 'x' and 'y' as the two parameters respectively. + An entry of None will cause the partial derivative to be calculated numerically. """ def __init__(self, deriv_funcs: Tuple[Optional[PositionalDerivFunc]]): ufloat_params = tuple(range(len(deriv_funcs))) @@ -380,17 +377,13 @@ def __init__(self, deriv_funcs: Tuple[Optional[PositionalDerivFunc]]): def add_float_funcs_to_uvalue(): """ - Monkey-patch common float instance methods over to UFloat - - Here I use a notation involving x and y which is parsed by - resolve_deriv_func_dict_from_func_str_list. This is a compact way to specify the - formulas to calculate the partial derivatives of binary and unary functions. - - # TODO: There's a bit of complexity added by allowing analytic derivative function - # in addition to the default numerical derivative function. It would be - # interesting to see performance differences between the two methods. Is the - # added complexity *actually* buying performance? + Monkey-patch common float operations from the float class over to the UFloat class + using the ToUFuncPositional decorator. """ + # TODO: There is some additional complexity added by allowing analytic derivative + # functions instead of taking numerical derivatives for all functions. It would + # be interesting to benchmark the different approaches and see if the additional + # complexity is worth the performance. float_funcs_dict = { '__abs__': ('abs(x)/x',), '__pos__': ('1',), @@ -423,8 +416,3 @@ def add_float_funcs_to_uvalue(): def ufloat(val, unc, tag=None): return UFloat(val, unc, tag) - - -def ufloat_fromstr(string, tag=None): - (nom, std) = str_to_number_with_uncert(string.strip()) - return ufloat(nom, std, tag) From 7a53bbc4f921ac4ab72c8bba55a778c9d78735fe Mon Sep 17 00:00:00 2001 From: Justin Gerber Date: Tue, 16 Jul 2024 07:07:08 -0600 Subject: [PATCH 13/18] comments --- uncertainties/core_new.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/uncertainties/core_new.py b/uncertainties/core_new.py index ae031267..e19e24bd 100644 --- a/uncertainties/core_new.py +++ b/uncertainties/core_new.py @@ -392,13 +392,13 @@ def add_float_funcs_to_uvalue(): '__add__': ('1', '1'), '__radd__': ('1', '1'), '__sub__': ('1', '-1'), - '__rsub__': ('-1', '1'), # Note reversed order + '__rsub__': ('-1', '1'), # Reversed order __rsub__(x, y) = y - x '__mul__': ('y', 'x'), '__rmul__': ('y', 'x'), '__truediv__': ('1/y', '-x/y**2'), - '__rtruediv__': ('-x/y**2', '1/y'), # Note reversed order - '__floordiv__': ('0', '0'), # ? - '__rfloordiv__': ('0', '0'), # ? + '__rtruediv__': ('-x/y**2', '1/y'), # reversed order __rtruediv__(x, y) = y/x + '__floordiv__': ('0', '0'), + '__rfloordiv__': ('0', '0'), '__pow__': (None, None), # TODO: add these, see `uncertainties` source '__rpow__': (None, None), '__mod__': (None, None), From 1c121a7717bc2ea4edd7bad624851e25a2560f7f Mon Sep 17 00:00:00 2001 From: Justin Gerber Date: Tue, 16 Jul 2024 23:22:22 -0600 Subject: [PATCH 14/18] raise on negative uncertainty --- tests/test_core_new.py | 5 +++++ uncertainties/core_new.py | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/tests/test_core_new.py b/tests/test_core_new.py index ec56e118..7d426df6 100644 --- a/tests/test_core_new.py +++ b/tests/test_core_new.py @@ -148,3 +148,8 @@ def test_sin( ) def test_bool(unum: UFloat, bool_val: bool): assert bool(unum) is bool_val + + +def test_negative_std(): + with pytest.raises(ValueError, match=r'Uncertainty must be non-negative'): + unum = UFloat(-1.0, -1.0) diff --git a/uncertainties/core_new.py b/uncertainties/core_new.py index e19e24bd..ae032476 100644 --- a/uncertainties/core_new.py +++ b/uncertainties/core_new.py @@ -115,6 +115,10 @@ def __init__( ): self._val = float(value) if isinstance(uncertainty, Real): + if uncertainty < 0: + raise ValueError( + f'Uncertainty must be non-negative, not {uncertainty}.' + ) atom = UncertaintyAtom(float(uncertainty)) uncertainty_combo = ((atom, 1.0),) self.uncertainty_lin_combo = uncertainty_combo From a602f84742947ccfd9edbfde204a4c30ac5da6df Mon Sep 17 00:00:00 2001 From: Justin Gerber Date: Wed, 17 Jul 2024 10:57:03 -0600 Subject: [PATCH 15/18] new version of umath --- uncertainties/core_new.py | 28 ++++++--- uncertainties/umath_new.py | 113 +++++++++++++++++++++++++++++++++++++ 2 files changed, 132 insertions(+), 9 deletions(-) create mode 100644 uncertainties/umath_new.py diff --git a/uncertainties/core_new.py b/uncertainties/core_new.py index ae032476..af992bd0 100644 --- a/uncertainties/core_new.py +++ b/uncertainties/core_new.py @@ -7,7 +7,7 @@ from math import sqrt, isnan from numbers import Real import sys -from typing import Callable, Collection, Dict, Optional, Tuple, Union, TYPE_CHECKING +from typing import Any, Callable, Collection, Dict, Optional, Tuple, Union, TYPE_CHECKING import uuid if TYPE_CHECKING: @@ -322,13 +322,18 @@ def wrapped(*args, **kwargs): return wrapped -def func_str_to_positional_func(func_str, nargs): +def func_str_to_positional_func(func_str, nargs, eval_locals=None): + if eval_locals is None: + eval_locals = {} if nargs == 1: - def pos_func(x): # noqa - return eval(func_str) + def pos_func(x): + eval_locals['x'] = x + return eval(func_str, None, eval_locals) elif nargs == 2: - def pos_func(x, y): # noqa - return eval(func_str) + def pos_func(x, y): + eval_locals['x'] = x + eval_locals['y'] = y + return eval(func_str, None, eval_locals) else: raise ValueError(f'Only nargs=1 or nargs=2 is supported, not {nargs=}.') return pos_func @@ -339,6 +344,7 @@ def pos_func(x, y): # noqa def deriv_func_dict_positional_helper( deriv_funcs: Tuple[Optional[PositionalDerivFunc]], + eval_locals=None, ): nargs = len(deriv_funcs) deriv_func_dict = {} @@ -349,7 +355,7 @@ def deriv_func_dict_positional_helper( elif callable(deriv_func): pass elif isinstance(deriv_func, str): - deriv_func = func_str_to_positional_func(deriv_func, nargs) + deriv_func = func_str_to_positional_func(deriv_func, nargs, eval_locals) else: raise ValueError( f'Invalid deriv_func: {deriv_func}. Must be None, callable, or a ' @@ -373,9 +379,13 @@ class ToUFuncPositional(ToUFunc): and binary functions should use 'x' and 'y' as the two parameters respectively. An entry of None will cause the partial derivative to be calculated numerically. """ - def __init__(self, deriv_funcs: Tuple[Optional[PositionalDerivFunc]]): + def __init__( + self, + deriv_funcs: Tuple[Optional[PositionalDerivFunc]], + eval_locals: Optional[Dict[str, Any]] = None, + ): ufloat_params = tuple(range(len(deriv_funcs))) - deriv_func_dict = deriv_func_dict_positional_helper(deriv_funcs) + deriv_func_dict = deriv_func_dict_positional_helper(deriv_funcs, eval_locals) super().__init__(ufloat_params, deriv_func_dict) diff --git a/uncertainties/umath_new.py b/uncertainties/umath_new.py new file mode 100644 index 00000000..08557843 --- /dev/null +++ b/uncertainties/umath_new.py @@ -0,0 +1,113 @@ +import math +from numbers import Real +import sys +from typing import Union + +from uncertainties.core_new import UFloat, ToUFuncPositional + + +UReal = Union[Real, UFloat] + + +def acos(value: UReal) -> UReal: ... + + +def acosh(value: UReal) -> UReal: ... + + +def asinh(value: UReal) -> UReal: ... + + +def atan(value: UReal) -> UReal: ... + + +def atan2(x: UReal, y: UReal) -> UReal: ... + + +def atanh(value: UReal) -> UReal: ... + + +def cos(value: UReal) -> UReal: ... + + +def cosh(value: UReal) -> UReal: ... + + +def degrees(value: UReal) -> UReal: ... + + +def erf(value: UReal) -> UReal: ... + + +def erfc(value: UReal) -> UReal: ... + + +def exp(value: UReal) -> UReal: ... + + +# def log(value: UReal) -> UReal: ... + + +def log10(value: UReal) -> UReal: ... + + +def radians(value: UReal) -> UReal: ... + + +def sin(value: UReal) -> UReal: ... + + +def sinh(value: UReal) -> UReal: ... + + +def sqrt(value: UReal) -> UReal: ... + + +def tan(value: UReal) -> UReal: ... + + +def tanh(value: UReal) -> UReal: ... + + +def log_der0(*args): + """ + Derivative of math.log() with respect to its first argument. + + Works whether 1 or 2 arguments are given. + """ + if len(args) == 1: + return 1 / args[0] + else: + return 1 / args[0] / math.log(args[1]) # 2-argument form + + +deriv_dict = { + # In alphabetical order, here: + "acos": ("-1/math.sqrt(1-x**2)",), + "acosh": ("1/math.sqrt(x**2-1)",), + "asinh": ("1/math.sqrt(1+x**2)",), + "atan": ("1/(1+x**2)",), + "atan2": ('x/(x**2+y**2)', "-y/(x**2+y**2)"), + "atanh": ("1/(1-x**2)",), + "cos": ("-math.sin(x)",), + "cosh": ("math.sinh(x)",), + "degrees": ("math.degrees(1)",), + "erf": ("(2/math.sqrt(math.pi))*math.exp(-(x**2))",), + "erfc": ("-(2/math.sqrt(math.pi))*math.exp(-(x**2))",), + "exp": ("math.exp(x)",), + # "log": (log_der0, "-math.log(x, y) / y / math.log(y)"), + "log10": ("1/x/math.log(10)",), + "radians": ("math.radians(1)",), + "sin": ("math.cos(x)",), + "sinh": ("math.cosh(x)",), + "sqrt": ("0.5/math.sqrt(x)",), + "tan": ("1 + math.tan(x)**2",), + "tanh": ("1 - math.tanh(x)**2",), +} + +this_module = sys.modules[__name__] + +for func_name, deriv_funcs in deriv_dict.items(): + func = getattr(math, func_name) + ufunc = ToUFuncPositional(deriv_funcs, eval_locals={"math": math})(func) + setattr(this_module, func_name, ufunc) From d7ee99b94fbfa3d6caa9e6a28d2d67a648c7ad6c Mon Sep 17 00:00:00 2001 From: Justin Gerber Date: Wed, 17 Jul 2024 14:40:55 -0600 Subject: [PATCH 16/18] loop through args and kwargs instead of using inspect.signature Sadly, some built-in functions including `math.log` do not work with inspect.signature because they have multiple signatures. --- uncertainties/core_new.py | 94 ++++++++++++++++++++++++++------------ uncertainties/umath_new.py | 4 +- 2 files changed, 66 insertions(+), 32 deletions(-) diff --git a/uncertainties/core_new.py b/uncertainties/core_new.py index af992bd0..4b198571 100644 --- a/uncertainties/core_new.py +++ b/uncertainties/core_new.py @@ -3,7 +3,6 @@ from collections import defaultdict from dataclasses import dataclass, field from functools import lru_cache, wraps -import inspect from math import sqrt, isnan from numbers import Real import sys @@ -218,22 +217,38 @@ def numerical_partial_derivative( target_param (string name or position number of the float parameter to f to be varied) holding all other arguments, *args and **kwargs, constant. """ - sig = inspect.signature(f) - lower_bound_sig = sig.bind(*args, **kwargs) - upper_bound_sig = sig.bind(*args, **kwargs) - - target_param_name = get_param_name(sig, target_param) - - x = lower_bound_sig.arguments[target_param_name] + if isinstance(target_param, int): + x = args[target_param] + else: + x = kwargs[target_param] dx = abs(x) * SQRT_EPS # Numerical Recipes 3rd Edition, eq. 5.7.5 - # Inject x - dx into target_param and evaluate f - lower_bound_sig.arguments[target_param_name] = x - dx - lower_y = f(*lower_bound_sig.args, **lower_bound_sig.kwargs) + # TODO: The construction below could be simplied using inspect.signature. However, + # the math.log, and other math functions do not yet (as of python 3.12) work with + # inspect.signature. Therefore, we need to manually loop of args and kwargs. + # Monitor https://github.com/python/cpython/pull/117671 + lower_args = [] + upper_args = [] + for idx, arg in enumerate(args): + if idx == target_param: + lower_args.append(x - dx) + upper_args.append(x + dx) + else: + lower_args.append(arg) + upper_args.append(arg) + + lower_kwargs = {} + upper_kwargs = {} + for key, arg in kwargs.items(): + if key == target_param: + lower_kwargs[key] = x - dx + upper_kwargs[key] = x + dx + else: + lower_kwargs[key] = arg + upper_kwargs[key] = arg - # Inject x + dx into target_param and evaluate f - upper_bound_sig.arguments[target_param_name] = x + dx - upper_y = f(*upper_bound_sig.args, **upper_bound_sig.kwargs) + lower_y = f(*lower_args, **lower_kwargs) + upper_y = f(*upper_args, **upper_kwargs) derivative = (upper_y - lower_y) / (2 * dx) return derivative @@ -277,40 +292,59 @@ def __init__( self.deriv_func_dict: DerivFuncDict = deriv_func_dict def __call__(self, f: Callable[..., float]): - sig = inspect.signature(f) + # sig = inspect.signature(f) @wraps(f) def wrapped(*args, **kwargs): - float_bound = sig.bind(*args, **kwargs) - + # TODO: The construction below could be simplied using inspect.signature. + # However, the math.log, and other math functions do not yet + # (as of python 3.12) work with inspect.signature. Therefore, we need to + # manually loop of args and kwargs. + # Monitor https://github.com/python/cpython/pull/117671 return_u_val = False - for param, param_val in float_bound.arguments.items(): - if isinstance(param_val, UFloat): - float_bound.arguments[param] = param_val.val + float_args = [] + for arg in args: + if isinstance(arg, UFloat): + float_args.append(arg.val) return_u_val = True - elif isinstance(param_val, Real): - float_bound.arguments[param] = float(param_val) + else: + float_args.append(arg) + float_kwargs = {} + for key, arg in kwargs.items(): + if isinstance(arg, UFloat): + float_kwargs[key] = arg.val + return_u_val = True + else: + float_kwargs[key] = arg + + new_val = f(*float_args, **float_kwargs) - new_val = f(*float_bound.args, **float_bound.kwargs) if not return_u_val: return new_val - ufloat_bound = sig.bind(*args, **kwargs) new_uncertainty_lin_combo = [] for u_float_param in self.ufloat_params: - u_float_param_name = get_param_name(sig, u_float_param) - arg = ufloat_bound.arguments[u_float_param_name] + if isinstance(u_float_param, int): + try: + arg = args[u_float_param] + except IndexError: + continue + else: + try: + arg = kwargs[u_float_param] + except KeyError: + continue if isinstance(arg, UFloat): deriv_func = self.deriv_func_dict[u_float_param] if deriv_func is None: derivative = numerical_partial_derivative( f, - u_float_param_name, - *float_bound.args, - **float_bound.kwargs, + u_float_param, + *float_args, + **float_kwargs, ) else: - derivative = deriv_func(*float_bound.args, **float_bound.kwargs) + derivative = deriv_func(*float_args, **float_kwargs) new_uncertainty_lin_combo.append( (arg.uncertainty_lin_combo, derivative) diff --git a/uncertainties/umath_new.py b/uncertainties/umath_new.py index 08557843..e7999f1f 100644 --- a/uncertainties/umath_new.py +++ b/uncertainties/umath_new.py @@ -45,7 +45,7 @@ def erfc(value: UReal) -> UReal: ... def exp(value: UReal) -> UReal: ... -# def log(value: UReal) -> UReal: ... +def log(value: UReal) -> UReal: ... def log10(value: UReal) -> UReal: ... @@ -95,7 +95,7 @@ def log_der0(*args): "erf": ("(2/math.sqrt(math.pi))*math.exp(-(x**2))",), "erfc": ("-(2/math.sqrt(math.pi))*math.exp(-(x**2))",), "exp": ("math.exp(x)",), - # "log": (log_der0, "-math.log(x, y) / y / math.log(y)"), + "log": (log_der0, "-math.log(x, y) / y / math.log(y)"), "log10": ("1/x/math.log(10)",), "radians": ("math.radians(1)",), "sin": ("math.cos(x)",), From b841984f7b5abea33a1475432ffc84ea1bf141c0 Mon Sep 17 00:00:00 2001 From: Justin Gerber Date: Wed, 17 Jul 2024 22:19:40 -0600 Subject: [PATCH 17/18] analytical and partial derivatives test --- tests/test_core_new.py | 43 ++++++++++++++++++++++++++++++-------- uncertainties/umath_new.py | 2 +- 2 files changed, 35 insertions(+), 10 deletions(-) diff --git a/tests/test_core_new.py b/tests/test_core_new.py index 7d426df6..5926aac1 100644 --- a/tests/test_core_new.py +++ b/tests/test_core_new.py @@ -1,8 +1,12 @@ -from math import sqrt, sin, cos +import math import pytest -from uncertainties.core_new import UFloat, ToUFuncPositional +from uncertainties import umath_new +from uncertainties.core_new import UFloat, ToUFunc, ToUFuncPositional + +from helpers import ufloats_close + repr_cases = cases = [ (UFloat(10, 1), 'UFloat(10.0, 1.0)'), @@ -50,9 +54,9 @@ def test_unary( (20 + x, 30, 1), (-20 + x, -10, 1), (20 * x, 200, 20), - (x + y, 30, sqrt(2**2 + 1**2)), - (x * y, 200, sqrt(20**2 + 20**2)), - (x / y, 0.5, sqrt((1/20)**2 + (2*10/(20**2))**2)), + (x + y, 30, math.sqrt(2**2 + 1**2)), + (x * y, 200, math.sqrt(20**2 + 20**2)), + (x / y, 0.5, math.sqrt((1/20)**2 + (2*10/(20**2))**2)), ] @@ -107,10 +111,13 @@ def test_not_equals(first, second): assert first != second -usin = ToUFuncPositional((lambda x: cos(x),))(sin) -x = UFloat(10, 2) +usin = ToUFuncPositional((lambda t: math.cos(t),))(math.sin) sin_cases = [ - (usin(x), sin(10), 2 * cos(10)) + ( + usin(UFloat(10, 2)), + math.sin(10), + 2 * math.cos(10), + ), ] @@ -152,4 +159,22 @@ def test_bool(unum: UFloat, bool_val: bool): def test_negative_std(): with pytest.raises(ValueError, match=r'Uncertainty must be non-negative'): - unum = UFloat(-1.0, -1.0) + _ = UFloat(-1.0, -1.0) + + +func_derivs = ((k, v) for k, v in umath_new.deriv_dict.items()) + + +@pytest.mark.parametrize("ufunc_name, ufunc_derivs", func_derivs) +def test_ufunc_analytic_numerical_partial(ufunc_name, ufunc_derivs): + if ufunc_name == "acosh": + # cosh returns values > 1 + args = (UFloat(1.1, 0.1),) + elif ufunc_name == "atan2": + # atan2 requires two arguments + args = (UFloat(1.1, 0.1), UFloat(3.1, 0.2)) + else: + args = (UFloat(0.1, 0.01),) + ufunc = getattr(umath_new, ufunc_name) + nfunc = ToUFunc(range(len(ufunc_derivs)))(getattr(math, ufunc_name)) + assert ufloats_close(ufunc(*args), nfunc(*args), tolerance=1e-6) diff --git a/uncertainties/umath_new.py b/uncertainties/umath_new.py index e7999f1f..1c9f8427 100644 --- a/uncertainties/umath_new.py +++ b/uncertainties/umath_new.py @@ -87,7 +87,7 @@ def log_der0(*args): "acosh": ("1/math.sqrt(x**2-1)",), "asinh": ("1/math.sqrt(1+x**2)",), "atan": ("1/(1+x**2)",), - "atan2": ('x/(x**2+y**2)', "-y/(x**2+y**2)"), + "atan2": ('y/(x**2+y**2)', "-x/(x**2+y**2)"), "atanh": ("1/(1-x**2)",), "cos": ("-math.sin(x)",), "cosh": ("math.sinh(x)",), From b277ab4e36ffba3477b045a861eee9b36474e046 Mon Sep 17 00:00:00 2001 From: Justin Gerber Date: Wed, 17 Jul 2024 22:23:59 -0600 Subject: [PATCH 18/18] position only arguments --- uncertainties/umath_new.py | 40 +++++++++++++++++++------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/uncertainties/umath_new.py b/uncertainties/umath_new.py index 1c9f8427..133c01e2 100644 --- a/uncertainties/umath_new.py +++ b/uncertainties/umath_new.py @@ -9,64 +9,64 @@ UReal = Union[Real, UFloat] -def acos(value: UReal) -> UReal: ... +def acos(value: UReal, /) -> UReal: ... -def acosh(value: UReal) -> UReal: ... +def acosh(value: UReal, /) -> UReal: ... -def asinh(value: UReal) -> UReal: ... +def asinh(value: UReal, /) -> UReal: ... -def atan(value: UReal) -> UReal: ... +def atan(value: UReal, /) -> UReal: ... -def atan2(x: UReal, y: UReal) -> UReal: ... +def atan2(y: UReal, x: UReal, /) -> UReal: ... -def atanh(value: UReal) -> UReal: ... +def atanh(value: UReal, /) -> UReal: ... -def cos(value: UReal) -> UReal: ... +def cos(value: UReal, /) -> UReal: ... -def cosh(value: UReal) -> UReal: ... +def cosh(value: UReal, /) -> UReal: ... -def degrees(value: UReal) -> UReal: ... +def degrees(value: UReal, /) -> UReal: ... -def erf(value: UReal) -> UReal: ... +def erf(value: UReal, /) -> UReal: ... -def erfc(value: UReal) -> UReal: ... +def erfc(value: UReal, /) -> UReal: ... -def exp(value: UReal) -> UReal: ... +def exp(value: UReal, /) -> UReal: ... -def log(value: UReal) -> UReal: ... +def log(value: UReal, /) -> UReal: ... -def log10(value: UReal) -> UReal: ... +def log10(value: UReal, /) -> UReal: ... -def radians(value: UReal) -> UReal: ... +def radians(value: UReal, /) -> UReal: ... -def sin(value: UReal) -> UReal: ... +def sin(value: UReal, /) -> UReal: ... -def sinh(value: UReal) -> UReal: ... +def sinh(value: UReal, /) -> UReal: ... -def sqrt(value: UReal) -> UReal: ... +def sqrt(value: UReal, /) -> UReal: ... -def tan(value: UReal) -> UReal: ... +def tan(value: UReal, /) -> UReal: ... -def tanh(value: UReal) -> UReal: ... +def tanh(value: UReal, /) -> UReal: ... def log_der0(*args):