Skip to content

Add es.math maths module with uncertainty and bounds - #329

Open
markbujehein wants to merge 141 commits into
developfrom
add-maths-module
Open

markbujehein wants to merge 141 commits into
developfrom
add-maths-module

Conversation

@markbujehein

@markbujehein markbujehein commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Adds es.math, so maths functions can be applied to Parameters and keep their units, uncertainties and fit bounds, e.g.

>>> angle = es.Parameter(30.0, unit='deg', variance=1.0, min=0.0, max=90.0)
>>> es.math.sin(angle)
<Parameter 'sin(angle)': 0.5000 ± 0.0151, bounds=[0.0:1.0]>

The functions are sin, cos, tan, sinc, arcsin, arccos, arctan, arctan2, sinh, cosh, tanh, arcsinh, arccosh, arctanh, exp, log, log10, sqrt, erf and erfc, plus the common aliases asin, acos, atan, atan2, asinh, acosh and atanh (all found in Scipp).

They work on Parameter, DescriptorNumber and DescriptorArray; output takes same type as the input.
Scipp now handles almost everything; this module adds bounds, but features less guardrails compared to earlier (allowing Scipp-related issues to show instead of trying to patch them).

The PR now also features tutorials (previously #331)

Motivated by

Solves #201.

How it works

  • The bounds of the result are exact. For example, sin over [0, 90] deg gives [0, 1].
  • A result that is nan or infinite raises ValueError, for example log of a negative number.
  • Inverse trig functions return radians.
  • Uncertainties are propagated to first order, assuming the inputs are uncorrelated.

Other changes

  • DescriptorNumber.from_scipp returns Self, so it type-checks on subclasses.
  • New API page docs/api-reference/math.md. Every function has a tested example.
  • In docstring code examples, pydocs-style (Python Standard REPL) >>> primary prompts are used. I was urged to try out some custom styling, so I've done that. Docstring examples can also be done in Markdown, but then please give me a standard.

Tests

  • tests/unit/variable/test_math_examples.py: verifies the maths inside the docstring examples.

Known limitations

  • erf, erfc and arctan2 fail on inputs with an uncertainty, because Scipp doesn't support it for them. That includes all fitted parameters. The tutorials show this and work around it.
  • tan near π/2 returns a huge number instead of an error. The trigonometry tutorial warns about it.
  • TODO: the 1e-9 tolerance for equal bounds is hard-coded in parameter.py and should perhaps be a shared constant.

rozyczko added 30 commits August 6, 2026 15:18
fixed two minor issues in the LMFit wrapper. #301 and #302
rozyczko and others added 6 commits October 29, 2026 11:54
…odule

Files as of origin/add-maths-tutorials (71ea6a2), unchanged. The shared
files keep both sides: the module branch's notebooks, nav entries and
user-guide pages, plus the tutorial navigation, the Concepts page and the
recursive notebook globs. Updates for the current API follow separately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ease-3.0 API

- display_name instead of name, global_object.registry instead of map, and
  Sampler.from_fitter instead of passing a fitter to Sampler.
- Inverse trig results are converted with convert_unit('deg') instead of the
  removed unit= keyword.
- erf, erfc and arctan2 refuse numbers with an uncertainty: the notebooks show
  the VariancesError, use exact inputs, and propagate the erf error by hand.
  The arctan2 exercise and chart use arctan(b / a) for a positive real part.
- tan near a pole is no longer refused: the notebook shows the huge value.
- Expected error messages follow the new "is not finite" wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@markbujehein
markbujehein removed this pull request from stack #351 October 29, 2026 22:08
@markbujehein markbujehein changed the title Add maths module for Parameters & DescriptorNumbers with variance and bounds propagation Add es.math maths module with uncertainty and bounds Oct 29, 2026
@markbujehein
markbujehein marked this pull request as ready for review October 29, 2026 22:43
@markbujehein
markbujehein changed the base branch from release-3.0 to develop November 2, 2026 12:11
Comment thread src/easyscience/math.py Outdated
Comment on lines +1293 to +1299
asin = arcsin
acos = arccos
atan = arctan
atan2 = arctan2
asinh = arcsinh
acosh = arccosh
atanh = arctanh

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need these? Just because the functions are spelled a certain way in Scipp does not mean we have to have the same spelling in EasyScience.

Comment on lines +134 to +135
Self
A new instance of the class this is called on.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You never fixed this . . .

Comment on lines +19 to +21
# math must come after base_classes, which has an import cycle with variable
from . import math # noqa: E402 # isort: skip

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is redundant, it's already in this module

Comment thread src/easyscience/math.py
Comment on lines +1028 to +1032
Unlike ``arctan(y / x)``, this uses the signs of both coordinates,
so the angle lands in the right quadrant. ``y`` and ``x`` must have
the same unit, and the result is in ``rad``. Scipp cannot propagate
an uncertainty through this function yet, so neither input may have
a variance.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would argue we shouldn't have this function yet then. It is confusing that some of the functions in the module propagates variance and others don't. You can make an issue on it and link to the Scipp issue, (assuming there is one?)

Comment thread src/easyscience/math.py
Comment on lines +1369 to +1372
>>> import easyscience as es
>>> x = es.Parameter(1.0, display_name='x', variance=0.01, min=0.0, max=2.0)
>>> print(es.math.tanh(x))
<Parameter 'tanh(x)': 0.7616 ± 0.0420, bounds=[0.0:0.9640275800758169]>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, please use the properly formatted python markdown when writing examples . . .
```python
Your code snippet goes here, without the >>>
```

Comment thread src/easyscience/math.py
Comment on lines +1795 to +1806
def _monotonic_bounds(function_values: Callable) -> _ResultBoundsFunction:
"""
Result bounds of a monotonic function: its values at the argument
bounds.
"""

def bounds(argument_bounds: _Bounds) -> tuple[float, float]:
lower, upper = argument_bounds
at_lower, at_upper = float(function_values(lower)), float(function_values(upper))
return min(at_lower, at_upper), max(at_lower, at_upper)

return bounds

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why make this so complicated? You're essentially just saying that the output should have:

temp_param = func(param.value)
temp_param.min = min(func(param.min), func(param.max))
temp_param.max = max(func(param.min), func(param.max))

But you're hiding it away behind a dataclass storing a function which is computed from a function which takes the callable as input . . . .
This is a good example of where code becomes too DRY. Better to write those two lines I wrote above for every function, than to abstract it away to this degree, it is much harder to find out what actually happens in each function this way . . .

Comment thread src/easyscience/math.py
Comment on lines +1675 to +1680
for arg in args:
if not isinstance(arg, (DescriptorNumber, DescriptorArray)):
raise TypeError(
'EasyScience math functions support `Parameter`, `DescriptorNumber` and '
f'`DescriptorArray` objects, not {type(arg)}.'
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do here accept multiple inputs? You haven't even typehinted that you accept multiple inputs . . .
These functions should only take one input for now. YAGNI: You Are Not Gonna Need It.
We can implement multiple input WHEN and IF we need it, for now Keep It Simple.

Comment thread src/easyscience/math.py
Comment on lines +1741 to +1753
def _scipp_value(descriptor: DescriptorNumber | DescriptorArray) -> sc.Variable:
"""
Scipp value of a descriptor, without variances if they are all zero.

A ``Parameter`` stores a variance of 0.0 by default. Scipp would
propagate it to ``nan`` where the derivative is infinite, and its
``erf``, ``erfc`` and ``atan2`` refuse any variance, so a zero
variance is dropped.
"""
value = descriptor.full_value
if value.variances is not None and not np.any(value.variances):
return sc.values(value)
return value

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm surprised you haven't mentioned anything. It seems obvious that we should consider changing the default variance to None rather than 0.0 on Parameter. If we did, you would also avoid this issue.

Comment thread src/easyscience/math.py
Comment on lines +1599 to +1641
@dataclass(frozen=True)
class _InputRange:
"""The argument values a function is defined for."""

lower_limit: float = -np.inf
upper_limit: float = np.inf

def clip(self, lower: float, upper: float) -> _Bounds:
"""Restrict the bounds ``[lower, upper]`` to this range."""
return max(lower, self.lower_limit), min(upper, self.upper_limit)


@dataclass(frozen=True)
class _MathFunction:
"""
What a public maths function evaluates and how its result bounds are
found.

Attributes
----------
name : str
Public name, used in error messages and in the name of the
result.
scipp_function : Callable
Scipp function that evaluates the value and the variance and
checks the units.
result_bounds : _ResultBoundsFunction
Takes the bounds of each argument and returns the bounds of the
result.
input_range : _InputRange, default=_InputRange()
Argument values the function is defined for. Argument bounds are
clipped to it before ``result_bounds`` runs.
bounds_unit : str | None, default=None
Unit that ``result_bounds`` expects the argument bounds in:
``'rad'`` for the trigonometric functions, whose NumPy versions
take radians.
"""

name: str
scipp_function: Callable
result_bounds: _ResultBoundsFunction
input_range: _InputRange = _InputRange()
bounds_unit: str | None = None

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't make the math functions dataclasses. Dataclasses are meant to be for small classes which just holds data. Yours holds functions and logic.

It'd be simpler to just use functions. Right now, your actual math functions have no logic in them, they're just a docstring and a call to the dataclass, which makes the code more difficult to read. Have code where it is used unless you ABSOLUTELY have to move it out. And always start with the simplest solution (i.e. regular functions and classes), only move on to more advanced solutions when you have a good REASON to do so.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

[priority] medium Normal/default priority [scope] enhancement Adds/improves features (major.MINOR.patch)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support more functions for Parameters such as sin and cos

3 participants