From 1f4c377799d41506f23a099398e603498e0df353 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mark=20S=C3=B8rensen?= Date: Tue, 13 Oct 2026 16:44:47 +0200 Subject: [PATCH 01/25] docs(tutorials): add maths tutorial Add docs/docs/tutorials/using-math.ipynb: a series of short examples of how maths with Parameter and DescriptorNumber works and how it is expected to behave. It covers the basic operators and the functions of es.math, together with the error cases, the limits of first-order propagation, and maths on the result of a fit. --- docs/docs/index.md | 4 +- docs/docs/tutorials/index.md | 7 + docs/docs/tutorials/using-math.ipynb | 1656 ++++++++++++++++++++++++++ docs/mkdocs.yml | 2 + 4 files changed, 1667 insertions(+), 2 deletions(-) create mode 100644 docs/docs/tutorials/using-math.ipynb diff --git a/docs/docs/index.md b/docs/docs/index.md index 5a3e9727..0d2644f0 100644 --- a/docs/docs/index.md +++ b/docs/docs/index.md @@ -14,8 +14,8 @@ Here is a brief overview of the main documentation sections: Covers core concepts, key terminology, workflow steps, and essential parameters for effective use of EasyScience. - [:material-school: Tutorials](tutorials/index.md) – Offers practical, - step-by-step examples demonstrating common workflows and data analysis - tasks. + step-by-step examples, from the basics of working with parameters and + maths functions to common workflows and data analysis tasks. - [:material-code-braces-box: API Reference](api-reference/index.md) – An auto-generated reference detailing the available functions and modules in EasyScience. diff --git a/docs/docs/tutorials/index.md b/docs/docs/tutorials/index.md index 588cfe81..a36da47c 100644 --- a/docs/docs/tutorials/index.md +++ b/docs/docs/tutorials/index.md @@ -15,6 +15,13 @@ section of the documentation. The tutorials are organized into the following categories: +## Basics + +- [Using maths](using-math.ipynb) – A series of short examples showing + how to do maths with EasyScience parameters: the basic operations, the + functions of `es.math`, and how units, uncertainties and bounds are + propagated. + ## Workshops & Schools - [Fitting QENS](fitting-qens.ipynb) – A tutorial demonstrating how to diff --git a/docs/docs/tutorials/using-math.ipynb b/docs/docs/tutorials/using-math.ipynb new file mode 100644 index 00000000..d7277caf --- /dev/null +++ b/docs/docs/tutorials/using-math.ipynb @@ -0,0 +1,1656 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "de3c4488", + "metadata": {}, + "source": [ + "# Using Maths in EasyScience" + ] + }, + { + "cell_type": "markdown", + "id": "d2d57035", + "metadata": {}, + "source": [ + "EasyScience objects are numbers that know their **unit**, their **uncertainty** and, for a\n", + "`Parameter`, the **bounds** a fit may explore. This tutorial is a series of short examples of how maths\n", + "with them works, and of how it is expected to behave:\n", + "\n", + "* the fundamental operations `+`, `-`, `*`, `/`, `**`, negation and `abs`;\n", + "* the functions of `easyscience.math` (`es.math`): exponential, logarithm, trigonometric, hyperbolic\n", + " and error functions.\n", + "\n", + "The `es.math` examples are themed on a small diffraction experiment (Bragg's law, a Debye-Waller factor,\n", + "a slit), but each one stands alone. Every result is a **new** object. Its value and unit come from the\n", + "calculation, its variance is propagated from the variances of the inputs, and its bounds are the exact\n", + "range the result can reach while the inputs stay inside their bounds.\n", + "\n", + "No data and no fit are needed until the last section, where the same functions are applied to the result\n", + "of a fit." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "f7e21632", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T14:18:15.102642Z", + "iopub.status.busy": "2026-10-13T14:18:15.102518Z", + "iopub.status.idle": "2026-10-13T14:18:16.087160Z", + "shell.execute_reply": "2026-10-13T14:18:16.086523Z" + } + }, + "outputs": [], + "source": [ + "import numpy as np\n", + "import plotly.graph_objects as go\n", + "\n", + "import easyscience as es\n", + "from easyscience import DescriptorNumber\n", + "from easyscience import Parameter" + ] + }, + { + "cell_type": "markdown", + "id": "b5e9978c", + "metadata": {}, + "source": [ + "## Numbers with an uncertainty\n", + "\n", + "A `Parameter` has a value, a unit, an uncertainty, and bounds. Here is a length of 2 m with an\n", + "uncertainty of 0.2 m, which a fit may vary between 0 and 10 m:" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "965b4011", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T14:18:16.088955Z", + "iopub.status.busy": "2026-10-13T14:18:16.088731Z", + "iopub.status.idle": "2026-10-13T14:18:16.099484Z", + "shell.execute_reply": "2026-10-13T14:18:16.099078Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "length = Parameter(name='length', value=2.0, unit='m', variance=0.04, min=0.0, max=10.0)\n", + "length" + ] + }, + { + "cell_type": "markdown", + "id": "121339f0", + "metadata": {}, + "source": [ + "The uncertainty is stored as a **variance**, and `error` is its square root:" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "6678f486", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T14:18:16.100758Z", + "iopub.status.busy": "2026-10-13T14:18:16.100635Z", + "iopub.status.idle": "2026-10-13T14:18:16.102985Z", + "shell.execute_reply": "2026-10-13T14:18:16.102548Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2.0 m\n", + "0.04 0.2\n", + "0.0 10.0\n" + ] + } + ], + "source": [ + "print(length.value, length.unit)\n", + "print(length.variance, length.error)\n", + "print(length.min, length.max)" + ] + }, + { + "cell_type": "markdown", + "id": "d135dc3f", + "metadata": {}, + "source": [ + "A `DescriptorNumber` is the same without the fitting bounds. It is the right choice for a number that\n", + "is known but is never fitted, such as a measured constant:" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "5f372ec5", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T14:18:16.103940Z", + "iopub.status.busy": "2026-10-13T14:18:16.103830Z", + "iopub.status.idle": "2026-10-13T14:18:16.106397Z", + "shell.execute_reply": "2026-10-13T14:18:16.106029Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "scale = DescriptorNumber(name='scale', value=3.0, variance=0.01)\n", + "scale" + ] + }, + { + "cell_type": "markdown", + "id": "4d09c034", + "metadata": {}, + "source": [ + "The results of `es.math` have many digits in their bounds, so we define a helper that prints one\n", + "tidy line per number: value, error, unit and, for a `Parameter`, the bounds." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "d7494dc0", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T14:18:16.107477Z", + "iopub.status.busy": "2026-10-13T14:18:16.107366Z", + "iopub.status.idle": "2026-10-13T14:18:16.110058Z", + "shell.execute_reply": "2026-10-13T14:18:16.109594Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2.0000 ± 0.2000 m in [0.0000, 10.0000]\n", + "3.0000 ± 0.1000 dimensionless\n" + ] + } + ], + "source": [ + "def show(number):\n", + " \"\"\"One line with value, error, unit and, for a Parameter, the bounds.\"\"\"\n", + " text = f'{number.value:.4f} ± {number.error:.4f} {number.unit}'\n", + " if hasattr(number, 'min'):\n", + " text += f' in [{number.min:.4f}, {number.max:.4f}]'\n", + " return text\n", + "\n", + "\n", + "print(show(length))\n", + "print(show(scale))" + ] + }, + { + "cell_type": "markdown", + "id": "d6d905ba", + "metadata": {}, + "source": [ + "## Basic arithmetic\n", + "\n", + "The operators work between `Parameter`s, `DescriptorNumber`s and plain numbers. The result is a new\n", + "object: a `Parameter` (with bounds) if any operand is a `Parameter`, and a `DescriptorNumber`\n", + "otherwise.\n", + "\n", + "For independent inputs, the variance of a result is propagated to first order,\n", + "\n", + "$$\n", + "\\sigma_f^2 = \\sum_i \\left(\\frac{\\partial f}{\\partial x_i}\\right)^2 \\sigma_i^2 .\n", + "$$" + ] + }, + { + "cell_type": "markdown", + "id": "e62e0c6a", + "metadata": {}, + "source": [ + "### Addition and subtraction\n", + "\n", + "Only numbers with the same physical dimension can be added. If the units differ, the second operand\n", + "is converted to the unit of the first, so the result is in metres here:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "e173f6bd", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T14:18:16.111181Z", + "iopub.status.busy": "2026-10-13T14:18:16.111018Z", + "iopub.status.idle": "2026-10-13T14:18:16.114238Z", + "shell.execute_reply": "2026-10-13T14:18:16.113842Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2.5000 ± 0.2010 m in [0.1000, 11.0000]\n" + ] + } + ], + "source": [ + "width = Parameter(name='width', value=50.0, unit='cm', variance=4.0, min=10.0, max=100.0)\n", + "total = length + width\n", + "print(show(total))" + ] + }, + { + "cell_type": "markdown", + "id": "d689bce5", + "metadata": {}, + "source": [ + "The variances add, $0.2^2 + 0.02^2 = 0.0404\\ \\text{m}^2$, and so do the bounds:\n", + "$[0 + 0.1,\\ 10 + 1.0]$ m. Subtraction combines the bounds the other way round: the lowest possible\n", + "difference is the minimum of the first minus the maximum of the second." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "c80005d6", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T14:18:16.115252Z", + "iopub.status.busy": "2026-10-13T14:18:16.115141Z", + "iopub.status.idle": "2026-10-13T14:18:16.117810Z", + "shell.execute_reply": "2026-10-13T14:18:16.117403Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "1.5000 ± 0.2010 m in [-1.0000, 9.9000]\n" + ] + } + ], + "source": [ + "difference = length - width\n", + "print(show(difference))" + ] + }, + { + "cell_type": "markdown", + "id": "77274922", + "metadata": {}, + "source": [ + "A plain number can only be added to a dimensionless value. Adding one to a length is an error:" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "0cc2c8a1", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T14:18:16.118903Z", + "iopub.status.busy": "2026-10-13T14:18:16.118795Z", + "iopub.status.idle": "2026-10-13T14:18:16.120937Z", + "shell.execute_reply": "2026-10-13T14:18:16.120511Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "UnitError: Numbers can only be added to dimensionless values\n" + ] + } + ], + "source": [ + "try:\n", + " length + 1.0\n", + "except Exception as error:\n", + " print(f'{type(error).__name__}: {error}')" + ] + }, + { + "cell_type": "markdown", + "id": "e8b545fe", + "metadata": {}, + "source": [ + "### Multiplication and division\n", + "\n", + "Units multiply and divide. The area of a rectangle, with the sides in the same unit:" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "b22e7b34", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T14:18:16.121926Z", + "iopub.status.busy": "2026-10-13T14:18:16.121817Z", + "iopub.status.idle": "2026-10-13T14:18:16.124727Z", + "shell.execute_reply": "2026-10-13T14:18:16.124348Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "3.0000 ± 0.3606 m^2 in [0.0000, 30.0000]\n" + ] + } + ], + "source": [ + "height = Parameter(name='height', value=1.5, unit='m', variance=0.01, min=1.0, max=3.0)\n", + "area = length * height\n", + "print(show(area))" + ] + }, + { + "cell_type": "markdown", + "id": "1a2180bb", + "metadata": {}, + "source": [ + "The bounds of a product are the lowest and highest of the four products of the bounds. A speed is a\n", + "length over a time:" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "f36e517b", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T14:18:16.125869Z", + "iopub.status.busy": "2026-10-13T14:18:16.125760Z", + "iopub.status.idle": "2026-10-13T14:18:16.128626Z", + "shell.execute_reply": "2026-10-13T14:18:16.128255Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0.5000 ± 0.0590 m/s in [0.0000, 5.0000]\n" + ] + } + ], + "source": [ + "time = Parameter(name='time', value=4.0, unit='s', variance=0.0625, min=2.0, max=8.0)\n", + "speed = length / time\n", + "print(show(speed))" + ] + }, + { + "cell_type": "markdown", + "id": "2b0102bb", + "metadata": {}, + "source": [ + "Multiplying a `DescriptorNumber` by a plain number gives a `DescriptorNumber`, so there are no\n", + "bounds:" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "4f6a4b37", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T14:18:16.129759Z", + "iopub.status.busy": "2026-10-13T14:18:16.129650Z", + "iopub.status.idle": "2026-10-13T14:18:16.132063Z", + "shell.execute_reply": "2026-10-13T14:18:16.131660Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "DescriptorNumber 6.0000 ± 0.2000 dimensionless\n" + ] + } + ], + "source": [ + "doubled = scale * 2\n", + "print(type(doubled).__name__, show(doubled))" + ] + }, + { + "cell_type": "markdown", + "id": "67901d8b", + "metadata": {}, + "source": [ + "### Powers, negation and absolute value\n", + "\n", + "The exponent of a power is a plain number. Negation flips the bounds, and `abs` folds them:" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "f8b422ea", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T14:18:16.133199Z", + "iopub.status.busy": "2026-10-13T14:18:16.133086Z", + "iopub.status.idle": "2026-10-13T14:18:16.136454Z", + "shell.execute_reply": "2026-10-13T14:18:16.136037Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "4.0000 ± 0.8000 m^2 in [0.0000, 100.0000]\n", + "-2.0000 ± 0.2000 m in [-10.0000, -0.0000]\n", + "2.0000 ± 0.2000 m in [0.0000, 10.0000]\n" + ] + } + ], + "source": [ + "print(show(length**2))\n", + "print(show(-length))\n", + "print(show(abs(-length)))" + ] + }, + { + "cell_type": "markdown", + "id": "3902407f", + "metadata": {}, + "source": [ + "## Maths functions with es.math\n", + "\n", + "`es.math` provides the functions that the operators do not cover. Each one takes a `Parameter` or a\n", + "`DescriptorNumber` and returns a new one with the value, unit, variance and bounds worked out:\n", + "\n", + "| family | functions |\n", + "|---|---|\n", + "| exponential and logarithm | `exp`, `log`, `log10`, `sqrt` |\n", + "| trigonometric | `sin`, `cos`, `tan`, `sinc` |\n", + "| inverse trigonometric | `arcsin`, `arccos`, `arctan`, `arctan2` (aliases `asin`, `acos`, `atan`, `atan2`) |\n", + "| hyperbolic | `sinh`, `cosh`, `tanh`, `arcsinh`, `arccosh`, `arctanh` (aliases `asinh`, `acosh`, `atanh`) |\n", + "| error function | `erf`, `erfc` |\n", + "\n", + "They are used as `es.math.sin(angle)`. The value and the variance come from Scipp, which also checks\n", + "the units (for `erf`, `erfc` and `arctan2`, which Scipp cannot propagate a variance through, `es.math`\n", + "supplies the derivative itself); the bounds are the exact range of the function over the bounds of the\n", + "input." + ] + }, + { + "cell_type": "markdown", + "id": "79db2ff9", + "metadata": {}, + "source": [ + "### Exponential, logarithm and square root\n", + "\n", + "These functions take a dimensionless number, except `sqrt`, which also takes a number whose unit can be\n", + "halved. The Debye-Waller factor of a crystal, $\\exp\\!\\left(-q^2\\langle u^2\\rangle/3\\right)$, combines a\n", + "scattering vector $q$ and a mean-square displacement $\\langle u^2\\rangle$; the units cancel, so `exp`\n", + "accepts it:" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "21d87989", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T14:18:16.137426Z", + "iopub.status.busy": "2026-10-13T14:18:16.137316Z", + "iopub.status.idle": "2026-10-13T14:18:16.142263Z", + "shell.execute_reply": "2026-10-13T14:18:16.141721Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0.9868 ± 0.0026 dimensionless in [0.7659, 1.0000]\n" + ] + } + ], + "source": [ + "q_par = Parameter(name='q', value=2.0, unit='1/angstrom', variance=0.02**2, min=0.5, max=4.0)\n", + "u2 = Parameter(name='u2', value=0.01, unit='angstrom**2', variance=0.002**2, min=0.0, max=0.05)\n", + "\n", + "debye_waller = es.math.exp(-(q_par**2) * u2 / 3)\n", + "print(show(debye_waller))" + ] + }, + { + "cell_type": "markdown", + "id": "0a804bf3", + "metadata": {}, + "source": [ + "The bounds are the exact range of the function over the bounds of the inputs, here $[0.77,\\ 1]$. A\n", + "relative intensity in decibels uses `log10`, and the length of a vector from two components uses `sqrt`:" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "6ac09683", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T14:18:16.143257Z", + "iopub.status.busy": "2026-10-13T14:18:16.143153Z", + "iopub.status.idle": "2026-10-13T14:18:16.149084Z", + "shell.execute_reply": "2026-10-13T14:18:16.148665Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "-6.0206 ± 0.1737 dimensionless in [-10.0000, -3.9794]\n", + "5.0000 ± 0.1000 dimensionless in [3.6056, 6.4031]\n" + ] + } + ], + "source": [ + "intensity = Parameter(name='intensity', value=250.0, variance=100.0, min=100.0, max=400.0)\n", + "reference = DescriptorNumber(name='reference', value=1000.0, variance=0.0)\n", + "decibel = 10 * es.math.log10(intensity / reference)\n", + "print(show(decibel))\n", + "\n", + "a = Parameter(name='a', value=3.0, variance=0.01, min=2.0, max=4.0)\n", + "b = Parameter(name='b', value=4.0, variance=0.01, min=3.0, max=5.0)\n", + "amplitude = es.math.sqrt(a**2 + b**2)\n", + "print(show(amplitude))" + ] + }, + { + "cell_type": "markdown", + "id": "279c93ee", + "metadata": {}, + "source": [ + "The square root of an area is a length:" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "6a0c176a", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T14:18:16.150254Z", + "iopub.status.busy": "2026-10-13T14:18:16.150139Z", + "iopub.status.idle": "2026-10-13T14:18:16.152655Z", + "shell.execute_reply": "2026-10-13T14:18:16.152258Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "1.7321 ± 0.1041 m in [0.0000, 5.4772]\n" + ] + } + ], + "source": [ + "print(show(es.math.sqrt(area)))" + ] + }, + { + "cell_type": "markdown", + "id": "3b7dd466", + "metadata": {}, + "source": [ + "**Expected behaviour: a round trip returns where it started.** The derivative of $\\exp(\\log x)$ is 1,\n", + "so the value *and* the uncertainty come back unchanged:" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "3b11d66d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T14:18:16.153785Z", + "iopub.status.busy": "2026-10-13T14:18:16.153670Z", + "iopub.status.idle": "2026-10-13T14:18:16.157075Z", + "shell.execute_reply": "2026-10-13T14:18:16.156665Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "1.2000 ± 0.0500 dimensionless in [0.5000, 3.0000]\n", + "True\n" + ] + } + ], + "source": [ + "x = Parameter(name='x', value=1.2, variance=0.0025, min=0.5, max=3.0)\n", + "round_trip = es.math.exp(es.math.log(x))\n", + "print(show(round_trip))\n", + "print(np.isclose(round_trip.variance, x.variance))" + ] + }, + { + "cell_type": "markdown", + "id": "db19bead", + "metadata": {}, + "source": [ + "### Trigonometric functions\n", + "\n", + "Bragg's law relates the spacing $d$ of the lattice planes to the scattering angle $\\theta$ and the\n", + "wavelength $\\lambda$, and the scattering vector is $q$:\n", + "\n", + "$$\n", + "d = \\frac{\\lambda}{2\\sin\\theta}, \\qquad q = \\frac{4\\pi\\sin\\theta}{\\lambda}.\n", + "$$\n", + "\n", + "The argument of `sin`, `cos` and `tan` must have an angle unit, `rad` or `deg`; the result is\n", + "dimensionless. The uncertainty is converted correctly: $\\sin 15^\\circ$ has an error of\n", + "$\\cos 15^\\circ \\times \\sigma_\\theta$ with $\\sigma_\\theta$ in radians." + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "371f8719", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T14:18:16.158123Z", + "iopub.status.busy": "2026-10-13T14:18:16.158008Z", + "iopub.status.idle": "2026-10-13T14:18:16.161571Z", + "shell.execute_reply": "2026-10-13T14:18:16.161111Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0.2588 ± 0.0017 dimensionless in [0.0872, 0.8660]\n" + ] + } + ], + "source": [ + "theta = Parameter(name='theta', value=15.0, unit='deg', variance=0.1**2, min=5.0, max=60.0)\n", + "wavelength = DescriptorNumber(name='wavelength', value=1.5406, unit='angstrom', variance=0.0005**2)\n", + "\n", + "s = es.math.sin(theta)\n", + "print(show(s))" + ] + }, + { + "cell_type": "markdown", + "id": "e0d8ecf5", + "metadata": {}, + "source": [ + "The wavelength is a measured constant, so it is a `DescriptorNumber` (no bounds); the angle may be\n", + "fitted, so it is a `Parameter`. Ordinary arithmetic then gives $d$ and $q$, with their uncertainties and\n", + "bounds:" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "daa16ff1", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T14:18:16.162695Z", + "iopub.status.busy": "2026-10-13T14:18:16.162569Z", + "iopub.status.idle": "2026-10-13T14:18:16.166680Z", + "shell.execute_reply": "2026-10-13T14:18:16.166245Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2.9762 ± 0.0194 Å in [0.8895, 8.8382]\n", + "2.1111 ± 0.0138 1/Å in [0.7109, 7.0640]\n" + ] + } + ], + "source": [ + "d = wavelength / (2 * s)\n", + "q = 4 * np.pi * s / wavelength\n", + "print(show(d))\n", + "print(show(q))" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "a4221a59", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T14:18:16.167751Z", + "iopub.status.busy": "2026-10-13T14:18:16.167629Z", + "iopub.status.idle": "2026-10-13T14:18:16.170808Z", + "shell.execute_reply": "2026-10-13T14:18:16.170353Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "cos 0.9659 ± 0.0005 dimensionless in [0.5000, 0.9962]\n", + "tan 0.2679 ± 0.0019 dimensionless in [0.0875, 1.7321]\n" + ] + } + ], + "source": [ + "for function in (es.math.cos, es.math.tan):\n", + " print(f'{function.__name__:4}', show(function(theta)))" + ] + }, + { + "cell_type": "markdown", + "id": "f15dce19", + "metadata": {}, + "source": [ + "`sinc(x)` is $\\sin(x)/x$, not the normalised $\\sin(\\pi x)/(\\pi x)$ of NumPy. It takes `rad` or a\n", + "dimensionless number. The intensity of single-slit diffraction is proportional to $\\mathrm{sinc}^2$:" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "bcc25557", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T14:18:16.171735Z", + "iopub.status.busy": "2026-10-13T14:18:16.171613Z", + "iopub.status.idle": "2026-10-13T14:18:16.174980Z", + "shell.execute_reply": "2026-10-13T14:18:16.174591Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0.6033 ± 0.0268 dimensionless in [0.0022, 0.9194]\n" + ] + } + ], + "source": [ + "x = Parameter(name='x', value=1.2, variance=0.0025, min=0.5, max=3.0)\n", + "print(show(es.math.sinc(x) ** 2))" + ] + }, + { + "cell_type": "markdown", + "id": "255f00b9", + "metadata": {}, + "source": [ + "### Inverse trigonometric functions\n", + "\n", + "The inverse functions return an angle, in radians by default; the `unit` keyword chooses `'deg'`.\n", + "**Expected behaviour:** the Bragg angle recovered from $d$ and $\\lambda$ is the angle we started with:" + ] + }, + { + "cell_type": "code", + "execution_count": 21, + "id": "fe026447", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T14:18:16.176122Z", + "iopub.status.busy": "2026-10-13T14:18:16.176007Z", + "iopub.status.idle": "2026-10-13T14:18:16.180307Z", + "shell.execute_reply": "2026-10-13T14:18:16.179879Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "15.0000 ± 0.1002 deg in [5.0000, 60.0000]\n", + "0.2618 ± 0.0017 rad in [0.0873, 1.0472]\n" + ] + } + ], + "source": [ + "recovered = es.math.arcsin(wavelength / (2 * d), unit='deg')\n", + "print(show(recovered))\n", + "print(show(es.math.arccos(es.math.cos(theta))))" + ] + }, + { + "cell_type": "markdown", + "id": "01da7bd7", + "metadata": {}, + "source": [ + "The value is the one we started with, and the error is almost the same: 0.1002 instead of 0.1000. The\n", + "small excess is explained in *Limits of first-order propagation* below.\n", + "\n", + "`arctan2(y, x)` uses the signs of both arguments to find the quadrant. The phase of a structure factor\n", + "$F = a + \\mathrm{i}\\,b$ is `arctan2(b, a)`; the two arguments must have the same unit (here none):" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "id": "b36d2d06", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T14:18:16.181355Z", + "iopub.status.busy": "2026-10-13T14:18:16.181242Z", + "iopub.status.idle": "2026-10-13T14:18:16.184143Z", + "shell.execute_reply": "2026-10-13T14:18:16.183661Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "53.1301 ± 1.1459 deg in [36.8699, 68.1986]\n" + ] + } + ], + "source": [ + "phase = es.math.arctan2(b, a, unit='deg')\n", + "print(show(phase))" + ] + }, + { + "cell_type": "markdown", + "id": "84f149c0", + "metadata": {}, + "source": [ + "The shorter names `asin`, `acos`, `atan` and `atan2` are aliases of the same functions:" + ] + }, + { + "cell_type": "code", + "execution_count": 23, + "id": "a8998852", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T14:18:16.185124Z", + "iopub.status.busy": "2026-10-13T14:18:16.184991Z", + "iopub.status.idle": "2026-10-13T14:18:16.187948Z", + "shell.execute_reply": "2026-10-13T14:18:16.187335Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 23, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "es.math.asin is es.math.arcsin" + ] + }, + { + "cell_type": "markdown", + "id": "493a177e", + "metadata": {}, + "source": [ + "### Hyperbolic functions\n", + "\n", + "`sinh`, `cosh` and `tanh` and their inverses take a dimensionless number. The reduced magnetisation of a\n", + "two-level paramagnet is $\\tanh$ of the reduced field, and `arctanh` undoes it:" + ] + }, + { + "cell_type": "code", + "execution_count": 24, + "id": "a883e7e5", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T14:18:16.189333Z", + "iopub.status.busy": "2026-10-13T14:18:16.189210Z", + "iopub.status.idle": "2026-10-13T14:18:16.193220Z", + "shell.execute_reply": "2026-10-13T14:18:16.192664Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0.4621 ± 0.0157 dimensionless in [0.0000, 0.7616]\n", + "0.5000 ± 0.0200 dimensionless in [0.0000, 1.0000]\n" + ] + } + ], + "source": [ + "reduced_field = Parameter(name='reduced_field', value=0.5, variance=0.0004, min=0.0, max=1.0)\n", + "magnetisation = es.math.tanh(reduced_field)\n", + "print(show(magnetisation))\n", + "print(show(es.math.arctanh(magnetisation)))" + ] + }, + { + "cell_type": "markdown", + "id": "15ab6f81", + "metadata": {}, + "source": [ + "The bounds of `cosh` show why the exact range matters: $\\cosh$ has its minimum at 0, so a range that\n", + "includes 0 does not start at the value of $\\cosh$ at the lower bound." + ] + }, + { + "cell_type": "code", + "execution_count": 25, + "id": "a22eec6c", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T14:18:16.194311Z", + "iopub.status.busy": "2026-10-13T14:18:16.194176Z", + "iopub.status.idle": "2026-10-13T14:18:16.198881Z", + "shell.execute_reply": "2026-10-13T14:18:16.198339Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "sinh 0.5211 ± 0.0564 dimensionless in [-1.1752, 3.6269]\n", + "cosh 1.1276 ± 0.0261 dimensionless in [1.0000, 3.7622]\n" + ] + } + ], + "source": [ + "z = Parameter(name='z', value=0.5, variance=0.0025, min=-1.0, max=2.0)\n", + "\n", + "for function in (es.math.sinh, es.math.cosh):\n", + " print(f'{function.__name__:5}', show(function(z)))" + ] + }, + { + "cell_type": "markdown", + "id": "c6954d29", + "metadata": {}, + "source": [ + "### The error function\n", + "\n", + "The fraction of a Gaussian beam of width $\\sigma$ that passes a slit of half-width $a$ is\n", + "$\\mathrm{erf}\\!\\left(a/(\\sigma\\sqrt{2})\\right)$, and `erfc` $= 1 - \\mathrm{erf}$ is the fraction that is\n", + "blocked:" + ] + }, + { + "cell_type": "code", + "execution_count": 26, + "id": "0f65cbd2", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T14:18:16.200104Z", + "iopub.status.busy": "2026-10-13T14:18:16.199924Z", + "iopub.status.idle": "2026-10-13T14:18:16.206249Z", + "shell.execute_reply": "2026-10-13T14:18:16.205755Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0.8176 ± 0.0263 dimensionless in [0.3829, 0.9973]\n", + "0.1824 ± 0.0263 dimensionless in [0.0027, 0.6171]\n", + "1.0\n" + ] + } + ], + "source": [ + "slit = Parameter(name='slit', value=2.0, unit='mm', variance=0.1**2, min=1.0, max=3.0)\n", + "beam = Parameter(name='beam', value=1.5, unit='mm', variance=0.05**2, min=1.0, max=2.0)\n", + "\n", + "transmitted = es.math.erf(slit / (beam * np.sqrt(2)))\n", + "blocked = es.math.erfc(slit / (beam * np.sqrt(2)))\n", + "print(show(transmitted))\n", + "print(show(blocked))\n", + "print(transmitted.value + blocked.value)" + ] + }, + { + "cell_type": "markdown", + "id": "506e6198", + "metadata": {}, + "source": [ + "### Parameters and DescriptorNumbers as input\n", + "\n", + "The functions follow the same rule as the operators: the result is a `Parameter` if any argument is a\n", + "`Parameter`, and a `DescriptorNumber` otherwise. A `DescriptorNumber` has no bounds, so it counts as a\n", + "single point when the bounds of a result are worked out. In the Bragg example, `wavelength` is a\n", + "`DescriptorNumber` and `theta` a `Parameter`, so `d` is a `Parameter`:" + ] + }, + { + "cell_type": "code", + "execution_count": 27, + "id": "406211c1", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T14:18:16.207362Z", + "iopub.status.busy": "2026-10-13T14:18:16.207235Z", + "iopub.status.idle": "2026-10-13T14:18:16.210632Z", + "shell.execute_reply": "2026-10-13T14:18:16.210107Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "DescriptorNumber Parameter -> Parameter\n", + "DescriptorNumber 1.6487 ± 0.0330 dimensionless\n" + ] + } + ], + "source": [ + "print(type(wavelength).__name__, type(theta).__name__, '->', type(d).__name__)\n", + "\n", + "constant = DescriptorNumber(name='constant', value=0.5, variance=0.0004)\n", + "result = es.math.exp(constant)\n", + "print(type(result).__name__, show(result))" + ] + }, + { + "cell_type": "markdown", + "id": "0544181e", + "metadata": {}, + "source": [ + "## Uncertainty and bounds of a result\n", + "\n", + "Because a result keeps its uncertainty, we can draw a function together with the band that the\n", + "uncertainty of its argument gives. `es.math` works on one number at a time, so we loop over the angles.\n", + "Here is the lattice spacing $d(\\theta)$ of the Bragg example, for an angle with an uncertainty of\n", + "$1^\\circ$ (ten times larger than above, so that the band can be seen). The chart is interactive: hover\n", + "over the curve to read the values. It loads Plotly from the internet, so it needs a connection. The charts are shown with a small helper,\n", + "`show_figure(fig)`, which is defined in a hidden cell; in your own notebook `fig.show()` does the same." + ] + }, + { + "cell_type": "code", + "execution_count": 28, + "id": "aadb9da0", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T14:18:16.211705Z", + "iopub.status.busy": "2026-10-13T14:18:16.211569Z", + "iopub.status.idle": "2026-10-13T14:18:16.214904Z", + "shell.execute_reply": "2026-10-13T14:18:16.214242Z" + }, + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "import base64\n", + "\n", + "from IPython.display import IFrame\n", + "from IPython.display import display\n", + "\n", + "\n", + "def show_figure(fig: go.Figure, height: int = 420) -> None:\n", + " \"\"\"Show a Plotly figure as an embedded iframe, so that it renders in the docs.\"\"\"\n", + " page = (\n", + " go\n", + " .Figure(fig)\n", + " .update_layout(template='plotly_white')\n", + " .to_html(\n", + " full_html=True,\n", + " include_plotlyjs='cdn',\n", + " default_width='100%',\n", + " default_height=f'{height - 20}px',\n", + " )\n", + " )\n", + " encoded = base64.b64encode(page.encode('utf-8')).decode('ascii')\n", + " display(IFrame(src=f'data:text/html;base64,{encoded}', width='100%', height=height))" + ] + }, + { + "cell_type": "code", + "execution_count": 29, + "id": "eb57ec61", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T14:18:16.216110Z", + "iopub.status.busy": "2026-10-13T14:18:16.215980Z", + "iopub.status.idle": "2026-10-13T14:18:16.472227Z", + "shell.execute_reply": "2026-10-13T14:18:16.471683Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "angles = np.linspace(5.0, 60.0, 60)\n", + "\n", + "values, errors = [], []\n", + "for value in angles:\n", + " angle = Parameter(name='theta', value=value, unit='deg', variance=1.0**2)\n", + " spacing = wavelength / (2 * es.math.sin(angle))\n", + " values.append(spacing.value)\n", + " errors.append(spacing.error)\n", + "values, errors = np.array(values), np.array(errors)\n", + "\n", + "fig = go.Figure()\n", + "fig.add_trace(\n", + " go.Scatter(x=angles, y=values - errors, mode='lines', line=dict(width=0), showlegend=False)\n", + ")\n", + "fig.add_trace(\n", + " go.Scatter(\n", + " x=angles,\n", + " y=values + errors,\n", + " mode='lines',\n", + " line=dict(width=0),\n", + " fill='tonexty',\n", + " fillcolor='rgba(99, 110, 250, 0.25)',\n", + " name='± 1 error',\n", + " )\n", + ")\n", + "fig.add_trace(\n", + " go.Scatter(\n", + " x=angles,\n", + " y=values,\n", + " mode='lines',\n", + " name='d',\n", + " line=dict(color='rgb(99, 110, 250)'),\n", + " customdata=errors,\n", + " hovertemplate='theta = %{x:.1f} deg
d = %{y:.3f} ± %{customdata:.3f} Å',\n", + " )\n", + ")\n", + "fig.update_layout(\n", + " xaxis_title='theta / deg',\n", + " yaxis_title='d / Å',\n", + " margin=dict(l=60, r=20, t=30, b=50),\n", + " legend=dict(x=0.6, y=0.95),\n", + ")\n", + "show_figure(fig)" + ] + }, + { + "cell_type": "markdown", + "id": "86805f7b", + "metadata": {}, + "source": [ + "The band is widest at small angles: the relative error of $d$ is $\\cot\\theta\\,\\sigma_\\theta$.\n", + "\n", + "The bounds of the input carry through as well. The sine of an angle that may vary between $0^\\circ$ and\n", + "$180^\\circ$ reaches 1 in the middle, so the upper bound of the result is exactly 1, not the sine of\n", + "either end:" + ] + }, + { + "cell_type": "code", + "execution_count": 30, + "id": "f75be67c", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T14:18:16.473571Z", + "iopub.status.busy": "2026-10-13T14:18:16.473449Z", + "iopub.status.idle": "2026-10-13T14:18:16.476795Z", + "shell.execute_reply": "2026-10-13T14:18:16.476391Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0.9848 ± 0.0030 dimensionless in [0.0000, 1.0000]\n" + ] + } + ], + "source": [ + "wide = Parameter(name='wide', value=80.0, unit='deg', variance=1.0, min=0.0, max=180.0)\n", + "print(show(es.math.sin(wide)))" + ] + }, + { + "cell_type": "markdown", + "id": "c9402e5b", + "metadata": {}, + "source": [ + "## Limits of first-order propagation\n", + "\n", + "The variance is correct to first order. That is excellent when the uncertainty is small compared with\n", + "how fast the function changes, and it has limits that are good to know.\n", + "\n", + "**Bad inputs are refused with an error.** A value outside the domain of a function raises an error\n", + "that names the parameter, a wrong unit raises a unit error, and a `tan` too close to a pole is refused:" + ] + }, + { + "cell_type": "code", + "execution_count": 31, + "id": "b8f9f85c", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T14:18:16.477855Z", + "iopub.status.busy": "2026-10-13T14:18:16.477745Z", + "iopub.status.idle": "2026-10-13T14:18:16.481952Z", + "shell.execute_reply": "2026-10-13T14:18:16.481524Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "log10 of zero: ValueError: The value of 'v' (0.0) is outside the domain (0.0, inf] of log10().\n", + "\n", + "arcsin of 1.2 (no Bragg angle exists): ValueError: The value of 'v' (1.2) is outside the domain [-1.0, 1.0] of arcsin().\n", + "\n", + "sqrt of a length: UnitError: Unsupported unit as result of sqrt: sqrt(m).\n", + "\n", + "tan at a pole: ValueError: The value of 'v' (1.5707963267948966 rad) is within 0.1 rad of a tan asymptote (pi/2 + n*pi); the propagated variance would explode.\n", + "\n" + ] + } + ], + "source": [ + "cases = {\n", + " 'log10 of zero': lambda: es.math.log10(Parameter(name='v', value=0.0, variance=0.01)),\n", + " 'arcsin of 1.2 (no Bragg angle exists)': lambda: es.math.arcsin(\n", + " Parameter(name='v', value=1.2, variance=0.01)\n", + " ),\n", + " 'sqrt of a length': lambda: es.math.sqrt(\n", + " Parameter(name='v', value=2.0, unit='m', variance=0.01)\n", + " ),\n", + " 'tan at a pole': lambda: es.math.tan(\n", + " Parameter(name='v', value=np.pi / 2, unit='rad', variance=0.01)\n", + " ),\n", + "}\n", + "for label, build in cases.items():\n", + " try:\n", + " build()\n", + " except Exception as error:\n", + " print(f'{label}: {type(error).__name__}: {error}\\n')" + ] + }, + { + "cell_type": "markdown", + "id": "308d3774", + "metadata": {}, + "source": [ + "`arcsin` of a ratio larger than 1 is the Bragg case $\\lambda/(2d) > 1$: no angle satisfies the law, and\n", + "the function says so instead of returning a meaningless number.\n", + "\n", + "**The same number used twice is treated as two independent numbers.** In the round trip above,\n", + "`wavelength` appears twice, in $d$ and again in the `arcsin`. Mathematically the wavelength cancels and\n", + "the variance must come back as exactly that of `theta`, but the two uses are treated as independent, so\n", + "a little extra variance is added:" + ] + }, + { + "cell_type": "code", + "execution_count": 32, + "id": "4d561018", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T14:18:16.482983Z", + "iopub.status.busy": "2026-10-13T14:18:16.482873Z", + "iopub.status.idle": "2026-10-13T14:18:16.485063Z", + "shell.execute_reply": "2026-10-13T14:18:16.484643Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "variance after the round trip: 0.010050\n", + "variance of theta: 0.010000\n" + ] + } + ], + "source": [ + "print(f'variance after the round trip: {recovered.variance:.6f}')\n", + "print(f'variance of theta: {theta.variance:.6f}')" + ] + }, + { + "cell_type": "markdown", + "id": "e453e756", + "metadata": {}, + "source": [ + "The difference is small here because the wavelength is known very precisely, but it grows with the\n", + "uncertainty of the number that is used twice.\n", + "\n", + "**Where the function is flat, first order gives zero.** At the top of the sine the derivative is 0, so\n", + "the first-order error is 0, although the argument is uncertain and the result really varies a little. A\n", + "Monte Carlo sample of the same sine shows the difference:" + ] + }, + { + "cell_type": "code", + "execution_count": 33, + "id": "f2946f38", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T14:18:16.486191Z", + "iopub.status.busy": "2026-10-13T14:18:16.486074Z", + "iopub.status.idle": "2026-10-13T14:18:16.598487Z", + "shell.execute_reply": "2026-10-13T14:18:16.597933Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "sigma = 0.3\n", + "angles = np.linspace(0.0, 2 * np.pi, 80)\n", + "\n", + "first_order = np.array([\n", + " es.math.sin(Parameter(name='angle', value=value, unit='rad', variance=sigma**2)).error\n", + " for value in angles\n", + "])\n", + "\n", + "rng = np.random.default_rng(seed=1)\n", + "samples = rng.normal(angles[:, None], sigma, size=(len(angles), 20_000))\n", + "monte_carlo = np.sin(samples).std(axis=1)\n", + "\n", + "fig = go.Figure()\n", + "fig.add_trace(go.Scatter(x=angles, y=first_order, mode='lines', name='first order (es.math)'))\n", + "fig.add_trace(\n", + " go.Scatter(x=angles, y=monte_carlo, mode='lines', line=dict(dash='dash'), name='Monte Carlo')\n", + ")\n", + "fig.update_layout(\n", + " xaxis_title='angle / rad',\n", + " yaxis_title='error of sin(angle)',\n", + " margin=dict(l=60, r=20, t=60, b=50),\n", + " legend=dict(orientation='h', x=0.5, xanchor='center', y=1.1),\n", + ")\n", + "show_figure(fig, height=380)" + ] + }, + { + "cell_type": "markdown", + "id": "0e361c8a", + "metadata": {}, + "source": [ + "The two agree to within a few per cent away from the extremes. At $\\pi/2$ and $3\\pi/2$ the first-order\n", + "error drops to (almost) zero, while the Monte Carlo error stays near $\\sigma^2/\\sqrt{2}\\approx 0.06$.\n", + "The same kind of failure happens when the uncertainty is large compared with the scale of the function\n", + "(for example `exp` of a number with an uncertainty of 1), and close to a pole of `tan`.\n", + "\n", + "**Two different inputs are treated as independent.** `a - b` and `arctan2(y, x)` combine the variances of\n", + "the two inputs as if they were unrelated. If the inputs are correlated, for example the slope and the\n", + "intercept of the same fit, the variance of a function of both can be off by a large factor." + ] + }, + { + "cell_type": "markdown", + "id": "d2adb633", + "metadata": {}, + "source": [ + "## Maths on the result of a fit\n", + "\n", + "`es.math` works on single numbers, not on arrays. Inside the model function of a fit, which is called\n", + "on a whole array of $x$ values, use NumPy on the `.value` of the parameters. After the fit, use\n", + "`es.math` on the fitted parameters: they carry the uncertainty that the fit found, so a derived\n", + "quantity comes with its own uncertainty.\n", + "\n", + "We fit a straight line to simulated data, and ask for the angle of the line:" + ] + }, + { + "cell_type": "code", + "execution_count": 34, + "id": "bfed6f42", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T14:18:16.599833Z", + "iopub.status.busy": "2026-10-13T14:18:16.599713Z", + "iopub.status.idle": "2026-10-13T14:18:16.601803Z", + "shell.execute_reply": "2026-10-13T14:18:16.601335Z" + } + }, + "outputs": [], + "source": [ + "from easyscience import Fitter\n", + "from easyscience.base_classes import ModelBase" + ] + }, + { + "cell_type": "code", + "execution_count": 35, + "id": "471f7767", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T14:18:16.602837Z", + "iopub.status.busy": "2026-10-13T14:18:16.602729Z", + "iopub.status.idle": "2026-10-13T14:18:16.605433Z", + "shell.execute_reply": "2026-10-13T14:18:16.604990Z" + } + }, + "outputs": [], + "source": [ + "class StraightLine(ModelBase):\n", + " \"\"\"y = slope * x + intercept\"\"\"\n", + "\n", + " def __init__(self, slope: float, intercept: float):\n", + " super().__init__()\n", + " self._slope = Parameter(name='slope', value=slope)\n", + " self._intercept = Parameter(name='intercept', value=intercept)\n", + "\n", + " @property\n", + " def slope(self) -> Parameter:\n", + " return self._slope\n", + "\n", + " @property\n", + " def intercept(self) -> Parameter:\n", + " return self._intercept\n", + "\n", + " def __call__(self, x):\n", + " return self._slope.value * x + self._intercept.value" + ] + }, + { + "cell_type": "code", + "execution_count": 36, + "id": "90a5d300", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T14:18:16.606464Z", + "iopub.status.busy": "2026-10-13T14:18:16.606356Z", + "iopub.status.idle": "2026-10-13T14:18:16.613316Z", + "shell.execute_reply": "2026-10-13T14:18:16.612876Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0.6009 ± 0.0136 dimensionless in [-inf, inf]\n", + "1.0138 ± 0.0791 dimensionless in [-inf, inf]\n" + ] + } + ], + "source": [ + "rng = np.random.default_rng(seed=20261013)\n", + "noise = 0.3\n", + "x = np.linspace(0.0, 10.0, 40)\n", + "y = 0.6 * x + 1.0 + rng.normal(scale=noise, size=x.size)\n", + "weights = np.full_like(x, 1.0 / noise)\n", + "\n", + "model = StraightLine(slope=0.1, intercept=0.0)\n", + "fitter = Fitter(model, model)\n", + "result = fitter.fit(x, y, weights=weights)\n", + "\n", + "print(show(model.slope))\n", + "print(show(model.intercept))" + ] + }, + { + "cell_type": "markdown", + "id": "52eaade3", + "metadata": {}, + "source": [ + "The angle of the line with the $x$ axis is the arctangent of the slope. `arctan` needs a dimensionless argument, and the slope is one; the `unit` keyword then returns the\n", + "angle in degrees:" + ] + }, + { + "cell_type": "code", + "execution_count": 37, + "id": "18237806", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T14:18:16.614525Z", + "iopub.status.busy": "2026-10-13T14:18:16.614414Z", + "iopub.status.idle": "2026-10-13T14:18:16.617173Z", + "shell.execute_reply": "2026-10-13T14:18:16.616789Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "31.0029 ± 0.5734 deg in [-90.0000, 90.0000]\n" + ] + } + ], + "source": [ + "line_angle = es.math.arctan(model.slope, unit='deg')\n", + "print(show(line_angle))" + ] + }, + { + "cell_type": "markdown", + "id": "783b9947", + "metadata": {}, + "source": [ + "The uncertainty of the angle comes from the uncertainty of the fitted slope, by the same first-order\n", + "rule as above." + ] + }, + { + "cell_type": "markdown", + "id": "96e1f2f3", + "metadata": {}, + "source": [ + "## Summary\n", + "\n", + "* `Parameter` and `DescriptorNumber` carry a value, a unit and a variance; a `Parameter` also carries\n", + " bounds. Arithmetic and `es.math` functions return **new** objects.\n", + "* The units are checked and converted, the variance is propagated to first order for independent\n", + " inputs, and the bounds are the exact range of the result.\n", + "* `es.math` covers exponential, logarithmic, trigonometric, inverse trigonometric, hyperbolic and\n", + " error functions, with a `Parameter` or a `DescriptorNumber` as input.\n", + "* A value outside the domain of a function raises an error. The first-order variance is unreliable for\n", + " large uncertainties, at a flat spot of the function, near a pole, and for inputs that are correlated\n", + " or that are the same number used twice.\n", + "* Use `es.math` on fitted parameters, after the fit, to get a derived quantity with its uncertainty.\n", + "\n", + "See the [es.math reference](../api-reference/math.md) for every function." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.6" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 47bc6fef..6d019240 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -194,6 +194,8 @@ nav: - Controlling Log Output: user-guide/controlling-log-output.md - Tutorials: - Tutorials: tutorials/index.md + - Basics: + - Using maths: tutorials/using-math.ipynb - Bayesian analysis: tutorials/fitting-bayesian.ipynb - Progress Callback: tutorials/progress-callback.ipynb - Workshops & Schools: From cf2435b00e033276c976b0ad7705cb90a8635747 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mark=20S=C3=B8rensen?= Date: Tue, 13 Oct 2026 18:24:28 +0200 Subject: [PATCH 02/25] docs(tutorials): split maths tutorial into topic notebooks (trial) Replace the single using-math tutorial with five short maths notebooks under tutorials/basics/ (arithmetic, exp/log, trigonometric, inverse trigonometric, hyperbolic) and add a small fitting introduction as a bridge to the fitting tutorials. As a trial, each notebook ends its parts with a few exercises whose hints and solutions are folded into
blocks, and charts use Plotly shown through an embedded iframe. Nothing is added to the dependencies or the shared docs configuration. Update the Tutorials nav (Basics > Maths, Fitting introduction) and the tutorials landing page. --- .../tutorials/basics/maths-arithmetic.ipynb | 999 ++++++++++++++++++ .../docs/tutorials/basics/maths-exp-log.ipynb | 943 +++++++++++++++++ .../tutorials/basics/maths-hyperbolic.ipynb | 888 ++++++++++++++++ .../basics/maths-inverse-trigonometric.ipynb | 928 ++++++++++++++++ .../basics/maths-trigonometric.ipynb | 905 ++++++++++++++++ docs/docs/tutorials/fitting-intro.ipynb | 396 +++++++ docs/docs/tutorials/index.md | 21 +- docs/mkdocs.yml | 9 +- 8 files changed, 5084 insertions(+), 5 deletions(-) create mode 100644 docs/docs/tutorials/basics/maths-arithmetic.ipynb create mode 100644 docs/docs/tutorials/basics/maths-exp-log.ipynb create mode 100644 docs/docs/tutorials/basics/maths-hyperbolic.ipynb create mode 100644 docs/docs/tutorials/basics/maths-inverse-trigonometric.ipynb create mode 100644 docs/docs/tutorials/basics/maths-trigonometric.ipynb create mode 100644 docs/docs/tutorials/fitting-intro.ipynb diff --git a/docs/docs/tutorials/basics/maths-arithmetic.ipynb b/docs/docs/tutorials/basics/maths-arithmetic.ipynb new file mode 100644 index 00000000..f970ed39 --- /dev/null +++ b/docs/docs/tutorials/basics/maths-arithmetic.ipynb @@ -0,0 +1,999 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "0a84941d", + "metadata": {}, + "source": [ + "# Arithmetic" + ] + }, + { + "cell_type": "markdown", + "id": "64d6b86b", + "metadata": {}, + "source": [ + "EasyScience numbers know their **unit**, their **uncertainty** and, for a `Parameter`, the **bounds** a fit\n", + "may explore. The operators `+`, `-`, `*`, `/` and `**`, negation and `abs` work on them and on plain\n", + "numbers, and every result is a **new** object whose value, unit, variance and bounds are worked out for you.\n", + "\n", + "This notebook shows how that works and how it is expected to behave. Each part closes with a few exercises on what was just shown. Their hints and solutions are folded away and open on a click, so the text reads straight through; a solution is a worked answer with its result.\n", + "\n", + "For independent inputs the variance of a result is propagated to first order,\n", + "\n", + "$$\n", + "\\sigma_f^2 = \\sum_i \\left(\\frac{\\partial f}{\\partial x_i}\\right)^2 \\sigma_i^2 .\n", + "$$" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "3248d597", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:48.449272Z", + "iopub.status.busy": "2026-10-13T16:18:48.449067Z", + "iopub.status.idle": "2026-10-13T16:18:49.475363Z", + "shell.execute_reply": "2026-10-13T16:18:49.474693Z" + } + }, + "outputs": [], + "source": [ + "import numpy as np\n", + "import plotly.graph_objects as go\n", + "\n", + "import easyscience as es\n", + "from easyscience import DescriptorNumber\n", + "from easyscience import Parameter" + ] + }, + { + "cell_type": "markdown", + "id": "c67a7545", + "metadata": {}, + "source": [ + "The function `show` prints a number as value, error and unit, and, for a `Parameter` with finite bounds, the bounds." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "a46d494e", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:49.477191Z", + "iopub.status.busy": "2026-10-13T16:18:49.476937Z", + "iopub.status.idle": "2026-10-13T16:18:49.479824Z", + "shell.execute_reply": "2026-10-13T16:18:49.479382Z" + } + }, + "outputs": [], + "source": [ + "def show(number):\n", + " \"\"\"One line with value, error, unit and, for a Parameter, the finite bounds.\"\"\"\n", + " text = f'{number.value:.4f} ± {number.error or 0.0:.4f} {number.unit}'\n", + " if hasattr(number, 'min') and (np.isfinite(number.min) or np.isfinite(number.max)):\n", + " text += f' in [{number.min:.4f}, {number.max:.4f}]'\n", + " return text" + ] + }, + { + "cell_type": "markdown", + "id": "e6629efa", + "metadata": {}, + "source": [ + "## Numbers with an uncertainty\n", + "\n", + "A `Parameter` has a value, a unit, an uncertainty and bounds. Here is a length of 2 m with an uncertainty of\n", + "0.2 m, which a fit may vary between 0 and 10 m:" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "0149da13", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:49.480997Z", + "iopub.status.busy": "2026-10-13T16:18:49.480887Z", + "iopub.status.idle": "2026-10-13T16:18:49.491775Z", + "shell.execute_reply": "2026-10-13T16:18:49.491114Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "length = Parameter(name='length', value=2.0, unit='m', variance=0.04, min=0.0, max=10.0)\n", + "length" + ] + }, + { + "cell_type": "markdown", + "id": "07bc3f5b", + "metadata": {}, + "source": [ + "The uncertainty is stored as a **variance**, and `error` is its square root:" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "9dd36dc6", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:49.493088Z", + "iopub.status.busy": "2026-10-13T16:18:49.492960Z", + "iopub.status.idle": "2026-10-13T16:18:49.495618Z", + "shell.execute_reply": "2026-10-13T16:18:49.495162Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2.0 m\n", + "0.04 0.2\n", + "0.0 10.0\n" + ] + } + ], + "source": [ + "print(length.value, length.unit)\n", + "print(length.variance, length.error)\n", + "print(length.min, length.max)" + ] + }, + { + "cell_type": "markdown", + "id": "5699928c", + "metadata": {}, + "source": [ + "A `DescriptorNumber` is the same without the fitting bounds. It is the right choice for a number that is\n", + "known but is never fitted, such as a measured constant:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "a4f03105", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:49.496622Z", + "iopub.status.busy": "2026-10-13T16:18:49.496505Z", + "iopub.status.idle": "2026-10-13T16:18:49.499126Z", + "shell.execute_reply": "2026-10-13T16:18:49.498715Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2.0000 ± 0.2000 m in [0.0000, 10.0000]\n", + "3.0000 ± 0.1000 dimensionless\n" + ] + } + ], + "source": [ + "scale = DescriptorNumber(name='scale', value=3.0, variance=0.01)\n", + "print(show(length))\n", + "print(show(scale))" + ] + }, + { + "cell_type": "markdown", + "id": "c4f0b28b", + "metadata": {}, + "source": [ + "## Addition and subtraction\n", + "\n", + "The operators work between `Parameter`s, `DescriptorNumber`s and plain numbers. The result is a `Parameter`\n", + "(with bounds) if any operand is a `Parameter`, and a `DescriptorNumber` otherwise.\n", + "\n", + "Only numbers with the same physical dimension can be added. If the units differ, the second operand is\n", + "converted to the unit of the first, so the result is in metres here:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "69e79f4d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:49.500196Z", + "iopub.status.busy": "2026-10-13T16:18:49.500087Z", + "iopub.status.idle": "2026-10-13T16:18:49.503146Z", + "shell.execute_reply": "2026-10-13T16:18:49.502815Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2.5000 ± 0.2010 m in [0.1000, 11.0000]\n" + ] + } + ], + "source": [ + "width = Parameter(name='width', value=50.0, unit='cm', variance=4.0, min=10.0, max=100.0)\n", + "total = length + width\n", + "print(show(total))" + ] + }, + { + "cell_type": "markdown", + "id": "b9eead61", + "metadata": {}, + "source": [ + "The variances add, $0.2^2 + 0.02^2 = 0.0404\\ \\text{m}^2$, and so do the bounds: $[0 + 0.1,\\ 10 + 1.0]$ m.\n", + "Subtraction combines the bounds the other way round: the lowest possible difference is the minimum of the\n", + "first minus the maximum of the second." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "ddd2e459", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:49.504276Z", + "iopub.status.busy": "2026-10-13T16:18:49.504169Z", + "iopub.status.idle": "2026-10-13T16:18:49.507211Z", + "shell.execute_reply": "2026-10-13T16:18:49.506596Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "1.5000 ± 0.2010 m in [-1.0000, 9.9000]\n" + ] + } + ], + "source": [ + "difference = length - width\n", + "print(show(difference))" + ] + }, + { + "cell_type": "markdown", + "id": "c9a27fc4", + "metadata": {}, + "source": [ + "A plain number can only be added to a dimensionless value. Adding one to a length is an error, while a\n", + "dimensionless `DescriptorNumber` accepts it:" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "af71c46b", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:49.508215Z", + "iopub.status.busy": "2026-10-13T16:18:49.508102Z", + "iopub.status.idle": "2026-10-13T16:18:49.511005Z", + "shell.execute_reply": "2026-10-13T16:18:49.510549Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "UnitError: Numbers can only be added to dimensionless values\n", + "4.0000 ± 0.1000 dimensionless\n" + ] + } + ], + "source": [ + "try:\n", + " length + 1.0\n", + "except Exception as error:\n", + " print(f'{type(error).__name__}: {error}')\n", + "\n", + "print(show(scale + 1.0))" + ] + }, + { + "cell_type": "markdown", + "id": "9f5f235b", + "metadata": {}, + "source": [ + "### Exercise 1: Total length\n", + "\n", + "A rod is built from two sections that were measured separately: 1.20 ± 0.02 m and 0.80 ± 0.03 m. What is the total length, with its uncertainty?" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "a88eeddf", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:49.512143Z", + "iopub.status.busy": "2026-10-13T16:18:49.512034Z", + "iopub.status.idle": "2026-10-13T16:18:49.515004Z", + "shell.execute_reply": "2026-10-13T16:18:49.514396Z" + } + }, + "outputs": [], + "source": [ + "section_1 = Parameter(name='section_1', value=1.20, variance=0.02**2, unit='m')\n", + "section_2 = Parameter(name='section_2', value=0.80, variance=0.03**2, unit='m')" + ] + }, + { + "cell_type": "markdown", + "id": "f3021cd0", + "metadata": {}, + "source": [ + "
\n", + "Hint 1\n", + "\n", + "Two lengths in the same unit can simply be added with `+`.\n", + "\n", + "
\n", + "\n", + "
\n", + "Hint 2\n", + "\n", + "The variances add: $\\sigma^2 = 0.02^2 + 0.03^2$, and `error` is the square root of the variance.\n", + "\n", + "
\n", + "\n", + "
\n", + "Solution\n", + "\n", + "```python\n", + "total = section_1 + section_2\n", + "print(show(total))\n", + "```\n", + "\n", + "Expected result:\n", + "\n", + "```text\n", + "2.0000 ± 0.0361 m\n", + "```\n", + "\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "e0497e6a", + "metadata": {}, + "source": [ + "### Exercise 2: Mixed units\n", + "\n", + "A tube is 1.20 ± 0.02 m long and a gap of 12.5 ± 0.5 cm has to be cut out of it. How long is the rest, in metres?" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "a64159f6", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:49.516009Z", + "iopub.status.busy": "2026-10-13T16:18:49.515898Z", + "iopub.status.idle": "2026-10-13T16:18:49.518681Z", + "shell.execute_reply": "2026-10-13T16:18:49.518184Z" + } + }, + "outputs": [], + "source": [ + "tube = Parameter(name='tube', value=1.20, variance=0.02**2, unit='m')\n", + "gap = Parameter(name='gap', value=12.5, variance=0.5**2, unit='cm')" + ] + }, + { + "cell_type": "markdown", + "id": "795bf0d5", + "metadata": {}, + "source": [ + "
\n", + "Hint 1\n", + "\n", + "Subtract the gap from the tube; the units do not have to match.\n", + "\n", + "
\n", + "\n", + "
\n", + "Hint 2\n", + "\n", + "The second operand is converted to the unit of the first, so the result is in the unit of `tube`.\n", + "\n", + "
\n", + "\n", + "
\n", + "Solution\n", + "\n", + "```python\n", + "remaining = tube - gap\n", + "print(show(remaining))\n", + "```\n", + "\n", + "Expected result:\n", + "\n", + "```text\n", + "1.0750 ± 0.0206 m\n", + "```\n", + "\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "313df7f1", + "metadata": {}, + "source": [ + "## Multiplication and division\n", + "\n", + "Units multiply and divide. The area of a rectangle, with the sides in the same unit:" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "bbc2cdc8", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:49.519593Z", + "iopub.status.busy": "2026-10-13T16:18:49.519487Z", + "iopub.status.idle": "2026-10-13T16:18:49.522684Z", + "shell.execute_reply": "2026-10-13T16:18:49.522213Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "3.0000 ± 0.3606 m^2 in [0.0000, 30.0000]\n" + ] + } + ], + "source": [ + "height = Parameter(name='height', value=1.5, unit='m', variance=0.01, min=1.0, max=3.0)\n", + "area = length * height\n", + "print(show(area))" + ] + }, + { + "cell_type": "markdown", + "id": "4f4fbd24", + "metadata": {}, + "source": [ + "The bounds of a product are the lowest and highest of the four products of the bounds. A speed is a length\n", + "over a time:" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "32d674ef", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:49.523849Z", + "iopub.status.busy": "2026-10-13T16:18:49.523722Z", + "iopub.status.idle": "2026-10-13T16:18:49.527687Z", + "shell.execute_reply": "2026-10-13T16:18:49.527284Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0.5000 ± 0.0590 m/s in [0.0000, 5.0000]\n" + ] + } + ], + "source": [ + "time = Parameter(name='time', value=4.0, unit='s', variance=0.0625, min=2.0, max=8.0)\n", + "speed = length / time\n", + "print(show(speed))" + ] + }, + { + "cell_type": "markdown", + "id": "63420d96", + "metadata": {}, + "source": [ + "Multiplying a `DescriptorNumber` by a plain number gives a `DescriptorNumber`, so there are no bounds:" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "1bf87c9c", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:49.528905Z", + "iopub.status.busy": "2026-10-13T16:18:49.528797Z", + "iopub.status.idle": "2026-10-13T16:18:49.531493Z", + "shell.execute_reply": "2026-10-13T16:18:49.531067Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "DescriptorNumber 6.0000 ± 0.2000 dimensionless\n" + ] + } + ], + "source": [ + "doubled = scale * 2\n", + "print(type(doubled).__name__, show(doubled))" + ] + }, + { + "cell_type": "markdown", + "id": "c6e935b4", + "metadata": {}, + "source": [ + "### Exercise 3: Speed\n", + "\n", + "A cart covers 3.0 ± 0.1 m in 1.5 ± 0.05 s. What is its speed, with its uncertainty and unit?" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "eafe8969", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:49.532501Z", + "iopub.status.busy": "2026-10-13T16:18:49.532396Z", + "iopub.status.idle": "2026-10-13T16:18:49.535070Z", + "shell.execute_reply": "2026-10-13T16:18:49.534586Z" + } + }, + "outputs": [], + "source": [ + "distance = Parameter(name='distance', value=3.0, variance=0.1**2, unit='m')\n", + "time = Parameter(name='time', value=1.5, variance=0.05**2, unit='s')" + ] + }, + { + "cell_type": "markdown", + "id": "69dfb62e", + "metadata": {}, + "source": [ + "
\n", + "Hint 1\n", + "\n", + "Speed is distance divided by time.\n", + "\n", + "
\n", + "\n", + "
\n", + "Hint 2\n", + "\n", + "Units divide as well: look at the unit of the result.\n", + "\n", + "
\n", + "\n", + "
\n", + "Solution\n", + "\n", + "```python\n", + "speed = distance / time\n", + "print(show(speed))\n", + "```\n", + "\n", + "Expected result:\n", + "\n", + "```text\n", + "2.0000 ± 0.0943 m/s\n", + "```\n", + "\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "cf261509", + "metadata": {}, + "source": [ + "## Powers, negation and absolute value\n", + "\n", + "The exponent of a power is a plain number. Negation flips the bounds, and `abs` folds them:" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "5ba32813", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:49.536034Z", + "iopub.status.busy": "2026-10-13T16:18:49.535929Z", + "iopub.status.idle": "2026-10-13T16:18:49.539513Z", + "shell.execute_reply": "2026-10-13T16:18:49.539047Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "4.0000 ± 0.8000 m^2 in [0.0000, 100.0000]\n", + "-2.0000 ± 0.2000 m in [-10.0000, -0.0000]\n", + "2.0000 ± 0.2000 m in [0.0000, 10.0000]\n" + ] + } + ], + "source": [ + "print(show(length**2))\n", + "print(show(-length))\n", + "print(show(abs(-length)))" + ] + }, + { + "cell_type": "markdown", + "id": "d19c8616", + "metadata": {}, + "source": [ + "### Exercise 4: Area of a beam spot\n", + "\n", + "The radius of a circular beam spot is 2.0 ± 0.1 cm. What is its area?" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "61387f4a", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:49.540731Z", + "iopub.status.busy": "2026-10-13T16:18:49.540577Z", + "iopub.status.idle": "2026-10-13T16:18:49.543057Z", + "shell.execute_reply": "2026-10-13T16:18:49.542660Z" + } + }, + "outputs": [], + "source": [ + "radius = Parameter(name='radius', value=2.0, variance=0.1**2, unit='cm')" + ] + }, + { + "cell_type": "markdown", + "id": "4373d991", + "metadata": {}, + "source": [ + "
\n", + "Hint 1\n", + "\n", + "The area is $\\pi r^2$. `np.pi` is a plain number, and `**` takes a plain-number exponent.\n", + "\n", + "
\n", + "\n", + "
\n", + "Hint 2\n", + "\n", + "A plain number can multiply a `Parameter`; the result is again a `Parameter`.\n", + "\n", + "
\n", + "\n", + "
\n", + "Solution\n", + "\n", + "```python\n", + "area = np.pi * radius**2\n", + "print(show(area))\n", + "```\n", + "\n", + "Expected result:\n", + "\n", + "```text\n", + "12.5664 ± 1.2566 cm^2 in [0.0000, inf]\n", + "```\n", + "\n", + "The lower bound 0 appears because a square can never be negative.\n", + "\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "364d524b", + "metadata": {}, + "source": [ + "## Uncertainty in a chart\n", + "\n", + "The uncertainty of a result depends on the inputs. A speed $v = d/t$ of an object that covered\n", + "$3.0 \\pm 0.1$ m is much less certain for short times than for long ones, even though the time has the same\n", + "uncertainty of 0.05 s each time. The chart is interactive: hover over the curve to read the values. It loads\n", + "Plotly from the internet, so it needs a connection. The charts are shown with a small helper,\n", + "`show_figure(fig)`, which is defined in a hidden cell; in your own notebook `fig.show()` does the same." + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "9ed7cb3e", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:49.544149Z", + "iopub.status.busy": "2026-10-13T16:18:49.544043Z", + "iopub.status.idle": "2026-10-13T16:18:49.547390Z", + "shell.execute_reply": "2026-10-13T16:18:49.546894Z" + }, + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "import base64\n", + "\n", + "from IPython.display import IFrame\n", + "from IPython.display import display\n", + "\n", + "\n", + "def show_figure(fig: go.Figure, height: int = 420) -> None:\n", + " \"\"\"Show a Plotly figure as an embedded iframe, so that it renders in the docs.\"\"\"\n", + " page = (\n", + " go\n", + " .Figure(fig)\n", + " .update_layout(template='plotly_white')\n", + " .to_html(\n", + " full_html=True,\n", + " include_plotlyjs='cdn',\n", + " default_width='100%',\n", + " default_height=f'{height - 20}px',\n", + " )\n", + " )\n", + " encoded = base64.b64encode(page.encode('utf-8')).decode('ascii')\n", + " display(IFrame(src=f'data:text/html;base64,{encoded}', width='100%', height=height))" + ] + }, + { + "cell_type": "code", + "execution_count": 18, + "id": "d97fb184", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:49.548371Z", + "iopub.status.busy": "2026-10-13T16:18:49.548262Z", + "iopub.status.idle": "2026-10-13T16:18:49.739472Z", + "shell.execute_reply": "2026-10-13T16:18:49.738980Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "times = np.linspace(0.5, 5.0, 46)\n", + "distance = Parameter(name='distance', value=3.0, unit='m', variance=0.1**2)\n", + "\n", + "values, errors = [], []\n", + "for value in times:\n", + " time = Parameter(name='time', value=value, unit='s', variance=0.05**2)\n", + " speed = distance / time\n", + " values.append(speed.value)\n", + " errors.append(speed.error)\n", + "values, errors = np.array(values), np.array(errors)\n", + "\n", + "fig = go.Figure()\n", + "fig.add_trace(\n", + " go.Scatter(\n", + " x=times,\n", + " y=values - errors,\n", + " mode='lines',\n", + " line=dict(width=0),\n", + " showlegend=False,\n", + " hoverinfo='skip',\n", + " )\n", + ")\n", + "fig.add_trace(\n", + " go.Scatter(\n", + " x=times,\n", + " y=values + errors,\n", + " mode='lines',\n", + " line=dict(width=0),\n", + " fill='tonexty',\n", + " fillcolor='rgba(99, 110, 250, 0.25)',\n", + " name='± 1 error',\n", + " hoverinfo='skip',\n", + " )\n", + ")\n", + "fig.add_trace(\n", + " go.Scatter(\n", + " x=times,\n", + " y=values,\n", + " mode='lines',\n", + " name='speed',\n", + " line=dict(color='rgb(99, 110, 250)'),\n", + " customdata=errors,\n", + " hovertemplate='time = %{x:.2f} s
speed = %{y:.2f} ± %{customdata:.2f} m/s',\n", + " )\n", + ")\n", + "fig.update_layout(\n", + " xaxis_title='time / s',\n", + " yaxis_title='speed / (m/s)',\n", + " margin=dict(l=60, r=20, t=30, b=50),\n", + " legend=dict(x=0.6, y=0.95),\n", + ")\n", + "show_figure(fig)" + ] + }, + { + "cell_type": "markdown", + "id": "5cdc5134", + "metadata": {}, + "source": [ + "Variances add, so the error of a **sum** of $n$ equal measurements grows like $\\sigma\\sqrt{n}$, while the\n", + "error of their **mean** shrinks like $\\sigma/\\sqrt{n}$. Here each measurement is $1.0 \\pm 0.1$:" + ] + }, + { + "cell_type": "code", + "execution_count": 19, + "id": "51adac82", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:49.740963Z", + "iopub.status.busy": "2026-10-13T16:18:49.740828Z", + "iopub.status.idle": "2026-10-13T16:18:49.950206Z", + "shell.execute_reply": "2026-10-13T16:18:49.949663Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "counts = np.arange(1, 26)\n", + "\n", + "sum_errors, mean_errors = [], []\n", + "for n in counts:\n", + " total = Parameter(name='measurement', value=1.0, variance=0.1**2)\n", + " for _ in range(int(n) - 1):\n", + " total = total + Parameter(name='measurement', value=1.0, variance=0.1**2)\n", + " sum_errors.append(total.error)\n", + " mean_errors.append((total / int(n)).error)\n", + "\n", + "fig = go.Figure()\n", + "fig.add_trace(go.Scatter(x=counts, y=sum_errors, mode='lines+markers', name='error of the sum'))\n", + "fig.add_trace(go.Scatter(x=counts, y=mean_errors, mode='lines+markers', name='error of the mean'))\n", + "fig.update_layout(\n", + " xaxis_title='number of measurements n',\n", + " yaxis_title='error',\n", + " margin=dict(l=60, r=20, t=30, b=50),\n", + " legend=dict(x=0.05, y=0.95),\n", + ")\n", + "show_figure(fig, height=380)" + ] + }, + { + "cell_type": "markdown", + "id": "f33f8247", + "metadata": {}, + "source": [ + "## Good to know\n", + "\n", + "An operator knows nothing about where its operands come from. The same object on both sides of an operator\n", + "gives the right variance: $x - x$ is exactly $0 \\pm 0$. But an intermediate result is a **new, independent\n", + "number**: in $(2x)/x$ the $x$ cancels and the exact answer is $2 \\pm 0$, yet `2 * x` no longer remembers\n", + "that it came from `x`. Bounds are combined in the same way, as if the operands were independent, so\n", + "$x - x$ of a `Parameter` that may vary between 0 and 10 has bounds $[-10, 10]$:" + ] + }, + { + "cell_type": "code", + "execution_count": 20, + "id": "3603e9d6", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:49.951618Z", + "iopub.status.busy": "2026-10-13T16:18:49.951496Z", + "iopub.status.idle": "2026-10-13T16:18:49.956071Z", + "shell.execute_reply": "2026-10-13T16:18:49.955490Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0.0000 ± 0.0000 dimensionless in [-10.0000, 10.0000]\n", + "2.0000 ± 0.0283 dimensionless in [0.0000, inf]\n" + ] + } + ], + "source": [ + "x = Parameter(name='x', value=10.0, variance=0.1**2, min=0.0, max=10.0)\n", + "print(show(x - x))\n", + "print(show((2 * x) / x))" + ] + }, + { + "cell_type": "markdown", + "id": "323986b8", + "metadata": {}, + "source": [ + "The same happens with quantities that are correlated, such as the slope and the intercept of one fit: the\n", + "operators treat them as independent, so the uncertainty of a combination of both can be off." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.6" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/docs/tutorials/basics/maths-exp-log.ipynb b/docs/docs/tutorials/basics/maths-exp-log.ipynb new file mode 100644 index 00000000..75c117ea --- /dev/null +++ b/docs/docs/tutorials/basics/maths-exp-log.ipynb @@ -0,0 +1,943 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "2662d1da", + "metadata": {}, + "source": [ + "# Exponential and logarithm" + ] + }, + { + "cell_type": "markdown", + "id": "5925e3f1", + "metadata": {}, + "source": [ + "This notebook covers `es.math.exp`, `log`, `log10`, `sqrt` and the error functions `erf` and `erfc`. They\n", + "take a `Parameter` or a `DescriptorNumber` and return a new one with the value, unit, variance and bounds\n", + "worked out: the variance to first order, the bounds as the exact range of the function over the bounds of\n", + "the input.\n", + "\n", + "The examples are taken from a diffraction experiment, but each one stands alone. Each part closes with a few exercises on what was just shown. Their hints and solutions are folded away and open on a click, so the text reads straight through; a solution is a worked answer with its result." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "f10b3445", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:51.267247Z", + "iopub.status.busy": "2026-10-13T16:18:51.267115Z", + "iopub.status.idle": "2026-10-13T16:18:52.134367Z", + "shell.execute_reply": "2026-10-13T16:18:52.133827Z" + } + }, + "outputs": [], + "source": [ + "import numpy as np\n", + "import plotly.graph_objects as go\n", + "\n", + "import easyscience as es\n", + "from easyscience import DescriptorNumber\n", + "from easyscience import Parameter" + ] + }, + { + "cell_type": "markdown", + "id": "1acc96e8", + "metadata": {}, + "source": [ + "The function `show` prints a number as value, error and unit, and, for a `Parameter` with finite bounds, the bounds." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "ed1b6606", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:52.136612Z", + "iopub.status.busy": "2026-10-13T16:18:52.136351Z", + "iopub.status.idle": "2026-10-13T16:18:52.139291Z", + "shell.execute_reply": "2026-10-13T16:18:52.138762Z" + } + }, + "outputs": [], + "source": [ + "def show(number):\n", + " \"\"\"One line with value, error, unit and, for a Parameter, the finite bounds.\"\"\"\n", + " text = f'{number.value:.4f} ± {number.error or 0.0:.4f} {number.unit}'\n", + " if hasattr(number, 'min') and (np.isfinite(number.min) or np.isfinite(number.max)):\n", + " text += f' in [{number.min:.4f}, {number.max:.4f}]'\n", + " return text" + ] + }, + { + "cell_type": "markdown", + "id": "b25a158b", + "metadata": {}, + "source": [ + "## The exponential\n", + "\n", + "`exp` takes a dimensionless number. The Debye-Waller factor of a crystal, $\\exp\\!\\left(-q^2\\langle\n", + "u^2\\rangle/3\\right)$, combines a scattering vector $q$ and a mean-square displacement $\\langle u^2\\rangle$;\n", + "the units cancel, so `exp` accepts it:" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "9752cc6f", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:52.140499Z", + "iopub.status.busy": "2026-10-13T16:18:52.140367Z", + "iopub.status.idle": "2026-10-13T16:18:52.152591Z", + "shell.execute_reply": "2026-10-13T16:18:52.152080Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0.9868 ± 0.0026 dimensionless in [0.7659, 1.0000]\n" + ] + } + ], + "source": [ + "q = Parameter(name='q', value=2.0, unit='1/angstrom', variance=0.02**2, min=0.5, max=4.0)\n", + "u2 = Parameter(name='u2', value=0.01, unit='angstrom**2', variance=0.002**2, min=0.0, max=0.05)\n", + "\n", + "debye_waller = es.math.exp(-(q**2) * u2 / 3)\n", + "print(show(debye_waller))" + ] + }, + { + "cell_type": "markdown", + "id": "55345a09", + "metadata": {}, + "source": [ + "The bounds are the exact range of the function over the bounds of the inputs: the factor can be as small as\n", + "0.77 and cannot exceed 1." + ] + }, + { + "cell_type": "markdown", + "id": "390001f6", + "metadata": {}, + "source": [ + "### Exercise 1: Transmission through a sample\n", + "\n", + "The transmission of a beam through a sample of thickness $t$ is $T = \\exp(-\\mu t)$. For $\\mu = 0.50 \\pm 0.02$ 1/cm and $t = 2.0 \\pm 0.1$ cm, what is $T$, with its uncertainty?" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "90885017", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:52.153987Z", + "iopub.status.busy": "2026-10-13T16:18:52.153856Z", + "iopub.status.idle": "2026-10-13T16:18:52.156652Z", + "shell.execute_reply": "2026-10-13T16:18:52.156139Z" + } + }, + "outputs": [], + "source": [ + "mu = Parameter(name='mu', value=0.50, variance=0.02**2, unit='1/cm')\n", + "t = Parameter(name='t', value=2.0, variance=0.1**2, unit='cm')" + ] + }, + { + "cell_type": "markdown", + "id": "a81aebb7", + "metadata": {}, + "source": [ + "
\n", + "Hint 1\n", + "\n", + "The product of `mu` and `t` has no unit: `1/cm` times `cm` cancels, which is why `exp` accepts it.\n", + "\n", + "
\n", + "\n", + "
\n", + "Hint 2\n", + "\n", + "Write the argument first, `-mu * t`, and pass it to `es.math.exp`.\n", + "\n", + "
\n", + "\n", + "
\n", + "Solution\n", + "\n", + "```python\n", + "transmission = es.math.exp(-mu * t)\n", + "print(show(transmission))\n", + "```\n", + "\n", + "Expected result:\n", + "\n", + "```text\n", + "0.3679 ± 0.0236 dimensionless in [0.0000, inf]\n", + "```\n", + "\n", + "The lower bound 0 appears because an exponential is never negative.\n", + "\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "87dbe0ab", + "metadata": {}, + "source": [ + "## Logarithms\n", + "\n", + "`log` is the natural logarithm and `log10` the decimal one. Both need a dimensionless, positive number. A\n", + "relative intensity in decibels uses `log10`; here the reference is a `DescriptorNumber` without an\n", + "uncertainty:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "f4d206cf", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:52.157811Z", + "iopub.status.busy": "2026-10-13T16:18:52.157686Z", + "iopub.status.idle": "2026-10-13T16:18:52.162057Z", + "shell.execute_reply": "2026-10-13T16:18:52.161570Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "-6.0206 ± 0.1737 dimensionless in [-10.0000, -3.9794]\n" + ] + } + ], + "source": [ + "intensity = Parameter(name='intensity', value=250.0, variance=100.0, min=100.0, max=400.0)\n", + "reference = DescriptorNumber(name='reference', value=1000.0, variance=0.0)\n", + "\n", + "decibel = 10 * es.math.log10(intensity / reference)\n", + "print(show(decibel))" + ] + }, + { + "cell_type": "markdown", + "id": "6ea20904", + "metadata": {}, + "source": [ + "A value outside the domain is refused with an error that names the parameter:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "113433ff", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:52.163328Z", + "iopub.status.busy": "2026-10-13T16:18:52.163199Z", + "iopub.status.idle": "2026-10-13T16:18:52.165960Z", + "shell.execute_reply": "2026-10-13T16:18:52.165460Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The value of 'v' (0.0) is outside the domain (0.0, inf] of log10().\n" + ] + } + ], + "source": [ + "try:\n", + " es.math.log10(Parameter(name='v', value=0.0, variance=0.01))\n", + "except ValueError as error:\n", + " print(error)" + ] + }, + { + "cell_type": "markdown", + "id": "46eebf2e", + "metadata": {}, + "source": [ + "### Exercise 2: Decibels\n", + "\n", + "A detector counts $I = 250 \\pm 10$ in a run where the reference intensity is $I_0 = 1000$, known exactly. What is the level $10 \\log_{10}(I/I_0)$ in decibels?" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "b8b46c83", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:52.167005Z", + "iopub.status.busy": "2026-10-13T16:18:52.166890Z", + "iopub.status.idle": "2026-10-13T16:18:52.169397Z", + "shell.execute_reply": "2026-10-13T16:18:52.168962Z" + } + }, + "outputs": [], + "source": [ + "intensity = Parameter(name='intensity', value=250.0, variance=10.0**2)\n", + "reference = DescriptorNumber(name='reference', value=1000.0, variance=0.0)" + ] + }, + { + "cell_type": "markdown", + "id": "203837fa", + "metadata": {}, + "source": [ + "
\n", + "Hint 1\n", + "\n", + "Divide the two intensities first.\n", + "\n", + "
\n", + "\n", + "
\n", + "Hint 2\n", + "\n", + "`log10` takes a dimensionless number; the factor 10 is a plain number that multiplies the result.\n", + "\n", + "
\n", + "\n", + "
\n", + "Solution\n", + "\n", + "```python\n", + "level = 10 * es.math.log10(intensity / reference)\n", + "print(show(level))\n", + "```\n", + "\n", + "Expected result:\n", + "\n", + "```text\n", + "-6.0206 ± 0.1737 dimensionless\n", + "```\n", + "\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "df84b668", + "metadata": {}, + "source": [ + "## The square root\n", + "\n", + "`sqrt` also accepts a number with a unit, as long as the unit can be halved. The amplitude of a vector from\n", + "its two components:" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "e5fc6001", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:52.170606Z", + "iopub.status.busy": "2026-10-13T16:18:52.170491Z", + "iopub.status.idle": "2026-10-13T16:18:52.175069Z", + "shell.execute_reply": "2026-10-13T16:18:52.174594Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "5.0000 ± 0.1000 dimensionless in [3.6056, 6.4031]\n" + ] + } + ], + "source": [ + "a = Parameter(name='a', value=3.0, variance=0.01, min=2.0, max=4.0)\n", + "b = Parameter(name='b', value=4.0, variance=0.01, min=3.0, max=5.0)\n", + "\n", + "amplitude = es.math.sqrt(a**2 + b**2)\n", + "print(show(amplitude))" + ] + }, + { + "cell_type": "markdown", + "id": "4e7ce0e4", + "metadata": {}, + "source": [ + "The square root of an area is a length. The square root of a length is not supported by the unit library, and is\n", + "refused:" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "bc75d2f8", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:52.176521Z", + "iopub.status.busy": "2026-10-13T16:18:52.176383Z", + "iopub.status.idle": "2026-10-13T16:18:52.180964Z", + "shell.execute_reply": "2026-10-13T16:18:52.180442Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "3.0000 ± 0.0500 m in [2.0000, 4.0000]\n", + "UnitError: Unsupported unit as result of sqrt: sqrt(m).\n" + ] + } + ], + "source": [ + "area = Parameter(name='area', value=9.0, unit='m**2', variance=0.09, min=4.0, max=16.0)\n", + "print(show(es.math.sqrt(area)))\n", + "\n", + "try:\n", + " es.math.sqrt(Parameter(name='v', value=2.0, unit='m', variance=0.01))\n", + "except Exception as error:\n", + " print(f'{type(error).__name__}: {error}')" + ] + }, + { + "cell_type": "markdown", + "id": "c616d8a5", + "metadata": {}, + "source": [ + "### Exercise 3: Amplitude from intensity\n", + "\n", + "The intensity of a diffraction peak, in units of a standard, is $0.64 \\pm 0.02$. Intensity is proportional to the square of the amplitude. What is the amplitude, with its uncertainty?" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "61c225b5", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:52.181909Z", + "iopub.status.busy": "2026-10-13T16:18:52.181787Z", + "iopub.status.idle": "2026-10-13T16:18:52.184065Z", + "shell.execute_reply": "2026-10-13T16:18:52.183659Z" + } + }, + "outputs": [], + "source": [ + "intensity = Parameter(name='intensity', value=0.64, variance=0.02**2)" + ] + }, + { + "cell_type": "markdown", + "id": "0824d862", + "metadata": {}, + "source": [ + "
\n", + "Hint 1\n", + "\n", + "The amplitude is the square root of the intensity.\n", + "\n", + "
\n", + "\n", + "
\n", + "Hint 2\n", + "\n", + "The relative error of a square root is half the relative error of its argument.\n", + "\n", + "
\n", + "\n", + "
\n", + "Solution\n", + "\n", + "```python\n", + "amplitude = es.math.sqrt(intensity)\n", + "print(show(amplitude))\n", + "```\n", + "\n", + "Expected result:\n", + "\n", + "```text\n", + "0.8000 ± 0.0125 dimensionless in [0.0000, inf]\n", + "```\n", + "\n", + "The lower bound 0 is the lower end of the domain of the square root.\n", + "\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "23f3bd20", + "metadata": {}, + "source": [ + "## The error functions\n", + "\n", + "The fraction of a Gaussian beam of width $\\sigma$ that passes a slit of half-width $a$ is\n", + "$\\mathrm{erf}\\!\\left(a/(\\sigma\\sqrt{2})\\right)$, and `erfc` $= 1 - \\mathrm{erf}$ is the fraction that is\n", + "blocked:" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "b2de6389", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:52.185223Z", + "iopub.status.busy": "2026-10-13T16:18:52.185102Z", + "iopub.status.idle": "2026-10-13T16:18:52.191383Z", + "shell.execute_reply": "2026-10-13T16:18:52.190924Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0.8176 ± 0.0263 dimensionless in [0.3829, 0.9973]\n", + "0.1824 ± 0.0263 dimensionless in [0.0027, 0.6171]\n", + "1.0\n" + ] + } + ], + "source": [ + "slit = Parameter(name='slit', value=2.0, unit='mm', variance=0.1**2, min=1.0, max=3.0)\n", + "beam = Parameter(name='beam', value=1.5, unit='mm', variance=0.05**2, min=1.0, max=2.0)\n", + "\n", + "transmitted = es.math.erf(slit / (beam * np.sqrt(2)))\n", + "blocked = es.math.erfc(slit / (beam * np.sqrt(2)))\n", + "print(show(transmitted))\n", + "print(show(blocked))\n", + "print(transmitted.value + blocked.value)" + ] + }, + { + "cell_type": "markdown", + "id": "0267950d", + "metadata": {}, + "source": [ + "### Exercise 4: Slit transmission\n", + "\n", + "A Gaussian beam with $\\sigma = 0.80 \\pm 0.04$ mm hits a slit with a half-width of $1.0 \\pm 0.05$ mm. What fraction of the beam passes?" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "cce37214", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:52.192399Z", + "iopub.status.busy": "2026-10-13T16:18:52.192263Z", + "iopub.status.idle": "2026-10-13T16:18:52.195093Z", + "shell.execute_reply": "2026-10-13T16:18:52.194632Z" + } + }, + "outputs": [], + "source": [ + "half_width = Parameter(name='half_width', value=1.0, variance=0.05**2, unit='mm')\n", + "beam_sigma = Parameter(name='beam_sigma', value=0.8, variance=0.04**2, unit='mm')" + ] + }, + { + "cell_type": "markdown", + "id": "0a5dc5f7", + "metadata": {}, + "source": [ + "
\n", + "Hint 1\n", + "\n", + "The fraction is $\\mathrm{erf}(a/(\\sigma\\sqrt{2}))$. The two lengths are in the same unit, so their ratio has no unit.\n", + "\n", + "
\n", + "\n", + "
\n", + "Hint 2\n", + "\n", + "`np.sqrt(2)` is a plain number; divide by `beam_sigma * np.sqrt(2)`.\n", + "\n", + "
\n", + "\n", + "
\n", + "Solution\n", + "\n", + "```python\n", + "fraction = es.math.erf(half_width / (beam_sigma * np.sqrt(2)))\n", + "print(show(fraction))\n", + "```\n", + "\n", + "Expected result:\n", + "\n", + "```text\n", + "0.7887 ± 0.0323 dimensionless in [-1.0000, 1.0000]\n", + "```\n", + "\n", + "With unbounded inputs the bounds of `erf` are its range, from -1 to 1.\n", + "\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "7f1a9e45", + "metadata": {}, + "source": [ + "## A round trip returns where it started\n", + "\n", + "The derivative of $\\exp(\\log x)$ is 1, so the value **and** the uncertainty come back unchanged:" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "ca64e157", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:52.196192Z", + "iopub.status.busy": "2026-10-13T16:18:52.196073Z", + "iopub.status.idle": "2026-10-13T16:18:52.199575Z", + "shell.execute_reply": "2026-10-13T16:18:52.199127Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "1.2000 ± 0.0500 dimensionless in [0.5000, 3.0000]\n", + "True\n" + ] + } + ], + "source": [ + "x = Parameter(name='x', value=1.2, variance=0.05**2, min=0.5, max=3.0)\n", + "round_trip = es.math.exp(es.math.log(x))\n", + "print(show(round_trip))\n", + "print(np.isclose(round_trip.variance, x.variance))" + ] + }, + { + "cell_type": "markdown", + "id": "975b30b8", + "metadata": {}, + "source": [ + "## Charts\n", + "\n", + "The Debye-Waller factor falls off with $q$, and its uncertainty comes from the uncertainty of\n", + "$\\langle u^2\\rangle = 0.010 \\pm 0.002$ Å$^2$. The chart is interactive: hover over the curve to read the\n", + "values. It loads Plotly from the internet, so it needs a connection. The charts are shown with a small\n", + "helper, `show_figure(fig)`, which is defined in a hidden cell; in your own notebook `fig.show()` does the\n", + "same." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "49a46c97", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:52.200629Z", + "iopub.status.busy": "2026-10-13T16:18:52.200510Z", + "iopub.status.idle": "2026-10-13T16:18:52.203329Z", + "shell.execute_reply": "2026-10-13T16:18:52.202844Z" + }, + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "import base64\n", + "\n", + "from IPython.display import IFrame\n", + "from IPython.display import display\n", + "\n", + "\n", + "def show_figure(fig: go.Figure, height: int = 420) -> None:\n", + " \"\"\"Show a Plotly figure as an embedded iframe, so that it renders in the docs.\"\"\"\n", + " page = (\n", + " go\n", + " .Figure(fig)\n", + " .update_layout(template='plotly_white')\n", + " .to_html(\n", + " full_html=True,\n", + " include_plotlyjs='cdn',\n", + " default_width='100%',\n", + " default_height=f'{height - 20}px',\n", + " )\n", + " )\n", + " encoded = base64.b64encode(page.encode('utf-8')).decode('ascii')\n", + " display(IFrame(src=f'data:text/html;base64,{encoded}', width='100%', height=height))" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "da2bed0a", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:52.204272Z", + "iopub.status.busy": "2026-10-13T16:18:52.204156Z", + "iopub.status.idle": "2026-10-13T16:18:52.419344Z", + "shell.execute_reply": "2026-10-13T16:18:52.418916Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "q_values = np.linspace(0.2, 4.0, 40)\n", + "u2 = Parameter(name='u2', value=0.01, unit='angstrom**2', variance=0.002**2)\n", + "\n", + "values, errors = [], []\n", + "for value in q_values:\n", + " q = DescriptorNumber(name='q', value=value, unit='1/angstrom', variance=0.0)\n", + " factor = es.math.exp(-(q**2) * u2 / 3)\n", + " values.append(factor.value)\n", + " errors.append(factor.error)\n", + "values, errors = np.array(values), np.array(errors)\n", + "\n", + "fig = go.Figure()\n", + "fig.add_trace(\n", + " go.Scatter(\n", + " x=q_values,\n", + " y=values - errors,\n", + " mode='lines',\n", + " line=dict(width=0),\n", + " showlegend=False,\n", + " hoverinfo='skip',\n", + " )\n", + ")\n", + "fig.add_trace(\n", + " go.Scatter(\n", + " x=q_values,\n", + " y=values + errors,\n", + " mode='lines',\n", + " line=dict(width=0),\n", + " fill='tonexty',\n", + " fillcolor='rgba(99, 110, 250, 0.25)',\n", + " name='± 1 error',\n", + " hoverinfo='skip',\n", + " )\n", + ")\n", + "fig.add_trace(\n", + " go.Scatter(\n", + " x=q_values,\n", + " y=values,\n", + " mode='lines',\n", + " name='Debye-Waller factor',\n", + " line=dict(color='rgb(99, 110, 250)'),\n", + " customdata=errors,\n", + " hovertemplate='q = %{x:.2f} 1/Å
factor = %{y:.4f} ± %{customdata:.4f}',\n", + " )\n", + ")\n", + "fig.update_layout(\n", + " xaxis_title='q / (1/Å)',\n", + " yaxis_title='Debye-Waller factor',\n", + " margin=dict(l=60, r=20, t=30, b=50),\n", + " legend=dict(x=0.55, y=0.95),\n", + ")\n", + "show_figure(fig)" + ] + }, + { + "cell_type": "markdown", + "id": "de7131e1", + "metadata": {}, + "source": [ + "The fraction of a Gaussian beam that passes a slit grows with the half-width $a$ and saturates at 1. The\n", + "beam width is $0.80 \\pm 0.04$ mm, and the uncertainty of the fraction comes from that width: it is largest when the half-width is close to the beam width itself, here around 0.8 mm:" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "c73439aa", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:52.420838Z", + "iopub.status.busy": "2026-10-13T16:18:52.420691Z", + "iopub.status.idle": "2026-10-13T16:18:52.518768Z", + "shell.execute_reply": "2026-10-13T16:18:52.518100Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "half_widths = np.linspace(0.1, 3.0, 59)\n", + "beam = Parameter(name='beam', value=0.8, unit='mm', variance=0.04**2)\n", + "\n", + "values, errors = [], []\n", + "for value in half_widths:\n", + " half_width = DescriptorNumber(name='half_width', value=value, unit='mm', variance=0.0)\n", + " fraction = es.math.erf(half_width / (beam * np.sqrt(2)))\n", + " values.append(fraction.value)\n", + " errors.append(fraction.error)\n", + "values, errors = np.array(values), np.array(errors)\n", + "\n", + "fig = go.Figure()\n", + "fig.add_trace(\n", + " go.Scatter(\n", + " x=half_widths,\n", + " y=values - errors,\n", + " mode='lines',\n", + " line=dict(width=0),\n", + " showlegend=False,\n", + " hoverinfo='skip',\n", + " )\n", + ")\n", + "fig.add_trace(\n", + " go.Scatter(\n", + " x=half_widths,\n", + " y=values + errors,\n", + " mode='lines',\n", + " line=dict(width=0),\n", + " fill='tonexty',\n", + " fillcolor='rgba(99, 110, 250, 0.25)',\n", + " name='± 1 error',\n", + " hoverinfo='skip',\n", + " )\n", + ")\n", + "fig.add_trace(\n", + " go.Scatter(\n", + " x=half_widths,\n", + " y=values,\n", + " mode='lines',\n", + " name='fraction that passes',\n", + " line=dict(color='rgb(99, 110, 250)'),\n", + " customdata=errors,\n", + " hovertemplate='a = %{x:.2f} mm
fraction = %{y:.4f} ± %{customdata:.4f}',\n", + " )\n", + ")\n", + "fig.update_layout(\n", + " xaxis_title='slit half-width a / mm',\n", + " yaxis_title='fraction that passes',\n", + " margin=dict(l=60, r=20, t=30, b=50),\n", + " legend=dict(x=0.55, y=0.4),\n", + ")\n", + "show_figure(fig, height=380)" + ] + }, + { + "cell_type": "markdown", + "id": "a21dece1", + "metadata": {}, + "source": [ + "## Good to know\n", + "\n", + "The first-order variance ignores the curvature of the function, so it is accurate only while the\n", + "uncertainty of the argument is small compared with the distance over which the slope changes. For $x \\sim \\mathcal{N}(1, 1)$ the first-order variance of $\\exp(x)$ is $e^2 = 7.389$, but the\n", + "exact variance of $\\exp(x)$ is $e^3(e - 1) = 34.51$:" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "d48f797a", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:52.520150Z", + "iopub.status.busy": "2026-10-13T16:18:52.520010Z", + "iopub.status.idle": "2026-10-13T16:18:52.523633Z", + "shell.execute_reply": "2026-10-13T16:18:52.523263Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "7.3890560989306495\n", + "34.51261310995657\n" + ] + } + ], + "source": [ + "wide = Parameter(name='wide', value=1.0, variance=1.0)\n", + "print(es.math.exp(wide).variance)\n", + "print(np.exp(3) * (np.e - 1))" + ] + }, + { + "cell_type": "markdown", + "id": "7c213002", + "metadata": {}, + "source": [ + "With an uncertainty of 0.05 in the argument the first-order variance of $\\exp(x)$ is within half a per cent of the exact one." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.6" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/docs/tutorials/basics/maths-hyperbolic.ipynb b/docs/docs/tutorials/basics/maths-hyperbolic.ipynb new file mode 100644 index 00000000..482dccf3 --- /dev/null +++ b/docs/docs/tutorials/basics/maths-hyperbolic.ipynb @@ -0,0 +1,888 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "37daec3c", + "metadata": {}, + "source": [ + "# Hyperbolic functions" + ] + }, + { + "cell_type": "markdown", + "id": "12c90069", + "metadata": {}, + "source": [ + "This notebook covers `es.math.sinh`, `cosh`, `tanh` and their inverses `arcsinh`, `arccosh` and `arctanh`.\n", + "They take a dimensionless `Parameter` or `DescriptorNumber` and return a new one with the value, variance\n", + "and bounds worked out: the variance to first order, the bounds as the exact range of the function over the\n", + "bounds of the input.\n", + "\n", + "The examples are taken from a diffraction experiment, but each one stands alone. Each part closes with a few exercises on what was just shown. Their hints and solutions are folded away and open on a click, so the text reads straight through; a solution is a worked answer with its result." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "95f6240e", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:53.730872Z", + "iopub.status.busy": "2026-10-13T16:18:53.730748Z", + "iopub.status.idle": "2026-10-13T16:18:54.612252Z", + "shell.execute_reply": "2026-10-13T16:18:54.611736Z" + } + }, + "outputs": [], + "source": [ + "import numpy as np\n", + "import plotly.graph_objects as go\n", + "\n", + "import easyscience as es\n", + "from easyscience import DescriptorNumber\n", + "from easyscience import Parameter" + ] + }, + { + "cell_type": "markdown", + "id": "7714045a", + "metadata": {}, + "source": [ + "The function `show` prints a number as value, error and unit, and, for a `Parameter` with finite bounds, the bounds." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "526ea56e", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:54.614254Z", + "iopub.status.busy": "2026-10-13T16:18:54.613978Z", + "iopub.status.idle": "2026-10-13T16:18:54.616978Z", + "shell.execute_reply": "2026-10-13T16:18:54.616485Z" + } + }, + "outputs": [], + "source": [ + "def show(number):\n", + " \"\"\"One line with value, error, unit and, for a Parameter, the finite bounds.\"\"\"\n", + " text = f'{number.value:.4f} ± {number.error or 0.0:.4f} {number.unit}'\n", + " if hasattr(number, 'min') and (np.isfinite(number.min) or np.isfinite(number.max)):\n", + " text += f' in [{number.min:.4f}, {number.max:.4f}]'\n", + " return text" + ] + }, + { + "cell_type": "markdown", + "id": "078a8d28", + "metadata": {}, + "source": [ + "## sinh, cosh and tanh\n", + "\n", + "The three functions are monotonic or have one minimum, so their bounds are exact:\n", + "\n", + "- `sinh` rises from $-\\infty$ to $\\infty$,\n", + "- `cosh` has its minimum 1 at 0 and is symmetric,\n", + "- `tanh` rises from $-1$ to $1$." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "0df6e42b", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:54.618292Z", + "iopub.status.busy": "2026-10-13T16:18:54.618165Z", + "iopub.status.idle": "2026-10-13T16:18:54.629162Z", + "shell.execute_reply": "2026-10-13T16:18:54.628584Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "sinh 0.8881 ± 0.0669 dimensionless in [0.0000, 2.1293]\n", + "cosh 1.3374 ± 0.0444 dimensionless in [1.0000, 2.3524]\n", + "tanh 0.6640 ± 0.0280 dimensionless in [0.0000, 0.9051]\n" + ] + } + ], + "source": [ + "x = Parameter(name='x', value=0.8, variance=0.05**2, min=0.0, max=1.5)\n", + "\n", + "for function in (es.math.sinh, es.math.cosh, es.math.tanh):\n", + " print(f'{function.__name__:5}', show(function(x)))" + ] + }, + { + "cell_type": "markdown", + "id": "29bc79fb", + "metadata": {}, + "source": [ + "The magnetisation of a paramagnet is $M/M_s = \\tanh(\\mu B/k_B T)$ for spin one half. At a given field and\n", + "temperature the argument is a dimensionless number, so the unit arithmetic is done for you:" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "ec093603", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:54.630641Z", + "iopub.status.busy": "2026-10-13T16:18:54.630501Z", + "iopub.status.idle": "2026-10-13T16:18:54.635478Z", + "shell.execute_reply": "2026-10-13T16:18:54.634981Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0.7616 ± 0.0168 dimensionless in [-1.0000, 1.0000]\n" + ] + } + ], + "source": [ + "moment = Parameter(name='moment', value=0.5, variance=0.02**2, unit='meV/T')\n", + "field = DescriptorNumber(name='field', value=4.0, variance=0.0, unit='T')\n", + "thermal = DescriptorNumber(name='thermal', value=2.0, variance=0.0, unit='meV')\n", + "\n", + "magnetisation = es.math.tanh(moment * field / thermal)\n", + "print(show(magnetisation))" + ] + }, + { + "cell_type": "markdown", + "id": "97320c46", + "metadata": {}, + "source": [ + "### Exercise 1: Saturation of a magnet\n", + "\n", + "A magnetisation follows $\\tanh(x)$ with $x = 0.80 \\pm 0.05$. The fitting range of $x$ is 0 to 3. What is the magnetisation, and between which values can it lie?" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "87b8075d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:54.636481Z", + "iopub.status.busy": "2026-10-13T16:18:54.636355Z", + "iopub.status.idle": "2026-10-13T16:18:54.638807Z", + "shell.execute_reply": "2026-10-13T16:18:54.638323Z" + } + }, + "outputs": [], + "source": [ + "x = Parameter(name='x', value=0.80, variance=0.05**2, min=0.0, max=3.0)" + ] + }, + { + "cell_type": "markdown", + "id": "ab3b8181", + "metadata": {}, + "source": [ + "
\n", + "Hint 1\n", + "\n", + "Call `es.math.tanh` on the parameter.\n", + "\n", + "
\n", + "\n", + "
\n", + "Hint 2\n", + "\n", + "`show` prints the bounds as well, because they are finite.\n", + "\n", + "
\n", + "\n", + "
\n", + "Solution\n", + "\n", + "```python\n", + "magnetisation = es.math.tanh(x)\n", + "print(show(magnetisation))\n", + "```\n", + "\n", + "Expected result:\n", + "\n", + "```text\n", + "0.6640 ± 0.0280 dimensionless in [0.0000, 0.9951]\n", + "```\n", + "\n", + "The upper bound is $\\tanh(3)$, not 1: the bounds are the range over the bounds of the input.\n", + "\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "2606a361", + "metadata": {}, + "source": [ + "## The inverse functions\n", + "\n", + "`arcsinh` accepts any number, `arccosh` needs a number of at least 1, and `arctanh` a number between -1 and\n", + "1. The inverse of `tanh` turns a measured saturation back into the argument that produced it:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "dfd08731", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:54.639754Z", + "iopub.status.busy": "2026-10-13T16:18:54.639623Z", + "iopub.status.idle": "2026-10-13T16:18:54.643540Z", + "shell.execute_reply": "2026-10-13T16:18:54.643056Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0.5493 ± 0.0267 dimensionless in [-1.4722, 1.4722]\n", + "1.3170 ± 0.0289 dimensionless in [0.0000, 2.0634]\n" + ] + } + ], + "source": [ + "saturation = Parameter(name='saturation', value=0.5, variance=0.02**2, min=-0.9, max=0.9)\n", + "print(show(es.math.arctanh(saturation)))\n", + "\n", + "y = Parameter(name='y', value=2.0, variance=0.05**2, min=1.0, max=4.0)\n", + "print(show(es.math.arccosh(y)))" + ] + }, + { + "cell_type": "markdown", + "id": "b07fdec1", + "metadata": {}, + "source": [ + "### Exercise 2: Recovering the argument\n", + "\n", + "A magnet reaches $0.50 \\pm 0.02$ of its saturation magnetisation, and the magnetisation follows $\\tanh(x)$. What is $x$?" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "f9e6f602", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:54.644616Z", + "iopub.status.busy": "2026-10-13T16:18:54.644489Z", + "iopub.status.idle": "2026-10-13T16:18:54.647036Z", + "shell.execute_reply": "2026-10-13T16:18:54.646502Z" + } + }, + "outputs": [], + "source": [ + "saturation = Parameter(name='saturation', value=0.50, variance=0.02**2)" + ] + }, + { + "cell_type": "markdown", + "id": "8b8793d3", + "metadata": {}, + "source": [ + "
\n", + "Hint 1\n", + "\n", + "The inverse of `tanh` is `arctanh`.\n", + "\n", + "
\n", + "\n", + "
\n", + "Hint 2\n", + "\n", + "The derivative of `arctanh` is $1/(1 - y^2)$, so the error is $0.02/(1 - 0.25)$.\n", + "\n", + "
\n", + "\n", + "
\n", + "Solution\n", + "\n", + "```python\n", + "x = es.math.arctanh(saturation)\n", + "print(show(x))\n", + "```\n", + "\n", + "Expected result:\n", + "\n", + "```text\n", + "0.5493 ± 0.0267 dimensionless\n", + "```\n", + "\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "3bb6195e", + "metadata": {}, + "source": [ + "## Bounds that are not at the ends\n", + "\n", + "The hyperbolic cosine has its minimum at 0, in the middle of an interval that contains 0. The lower bound\n", + "of the result is therefore 1, not the value at either end:" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "51536f23", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:54.648015Z", + "iopub.status.busy": "2026-10-13T16:18:54.647899Z", + "iopub.status.idle": "2026-10-13T16:18:54.651077Z", + "shell.execute_reply": "2026-10-13T16:18:54.650562Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "1.0000 ± 0.0000 dimensionless in [1.0000, 1.5431]\n" + ] + } + ], + "source": [ + "z = Parameter(name='z', value=0.0, variance=0.5**2, min=-1.0, max=1.0)\n", + "result = es.math.cosh(z)\n", + "print(show(result))" + ] + }, + { + "cell_type": "markdown", + "id": "47c77c9b", + "metadata": {}, + "source": [ + "### Exercise 3: The lowest value of cosh\n", + "\n", + "A parameter can vary between -1 and 1, and its current value is 0.5 ± 0.05. What is the lowest value that `cosh` of it can take? Print only that number." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "05f2f28d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:54.652079Z", + "iopub.status.busy": "2026-10-13T16:18:54.651964Z", + "iopub.status.idle": "2026-10-13T16:18:54.654204Z", + "shell.execute_reply": "2026-10-13T16:18:54.653817Z" + } + }, + "outputs": [], + "source": [ + "x = Parameter(name='x', value=0.5, variance=0.05**2, min=-1.0, max=1.0)" + ] + }, + { + "cell_type": "markdown", + "id": "437a35cf", + "metadata": {}, + "source": [ + "
\n", + "Hint 1\n", + "\n", + "The `Parameter` returned by `es.math.cosh` has the attributes `min` and `max`.\n", + "\n", + "
\n", + "\n", + "
\n", + "Hint 2\n", + "\n", + "Look at the lower bound, `result.min`, and compare it with $\\cosh(-1) = 1.5431$.\n", + "\n", + "
\n", + "\n", + "
\n", + "Solution\n", + "\n", + "```python\n", + "result = es.math.cosh(x)\n", + "print(result.min)\n", + "```\n", + "\n", + "Expected result:\n", + "\n", + "```text\n", + "1.0\n", + "```\n", + "\n", + "The lower bound is 1, the value at 0, not $\\cosh(-1)$.\n", + "\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "0bac64ab", + "metadata": {}, + "source": [ + "## Values outside the domain\n", + "\n", + "`arccosh` is only defined for numbers of at least 1. A value outside the domain is refused with an error\n", + "that names the parameter, as the next exercise shows." + ] + }, + { + "cell_type": "markdown", + "id": "b18e5703", + "metadata": {}, + "source": [ + "### Exercise 4: Outside the domain\n", + "\n", + "Predict what `es.math.arccosh` does with $x = 0.5 \\pm 0.01$, then try it. Catch the error and print its message." + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "e8012f6d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:54.655319Z", + "iopub.status.busy": "2026-10-13T16:18:54.655201Z", + "iopub.status.idle": "2026-10-13T16:18:54.657460Z", + "shell.execute_reply": "2026-10-13T16:18:54.657020Z" + } + }, + "outputs": [], + "source": [ + "x = Parameter(name='x', value=0.5, variance=0.01**2)" + ] + }, + { + "cell_type": "markdown", + "id": "17226bfb", + "metadata": {}, + "source": [ + "
\n", + "Hint 1\n", + "\n", + "The domain of `arccosh` is $[1, \\infty)$.\n", + "\n", + "
\n", + "\n", + "
\n", + "Hint 2\n", + "\n", + "Put the call in a `try` block and catch `ValueError`.\n", + "\n", + "
\n", + "\n", + "
\n", + "Solution\n", + "\n", + "```python\n", + "try:\n", + " es.math.arccosh(x)\n", + "except ValueError as error:\n", + " print(error)\n", + "```\n", + "\n", + "Expected result:\n", + "\n", + "```text\n", + "The value of 'x' (0.5) is outside the domain [1.0, inf] of arccosh().\n", + "```\n", + "\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "04d8d48f", + "metadata": {}, + "source": [ + "## A round trip\n", + "\n", + "Each function undoes its inverse, with the value and the uncertainty unchanged:" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "102d1d37", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:54.658518Z", + "iopub.status.busy": "2026-10-13T16:18:54.658402Z", + "iopub.status.idle": "2026-10-13T16:18:54.661838Z", + "shell.execute_reply": "2026-10-13T16:18:54.661403Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0.8000 ± 0.0500 dimensionless in [0.0000, 1.5000]\n" + ] + } + ], + "source": [ + "x = Parameter(name='x', value=0.8, variance=0.05**2, min=0.0, max=1.5)\n", + "back = es.math.arcsinh(es.math.sinh(x))\n", + "print(show(back))" + ] + }, + { + "cell_type": "markdown", + "id": "cc8c855d", + "metadata": {}, + "source": [ + "## Charts\n", + "\n", + "The magnetisation $\\tanh(x)$ and its uncertainty for $x$ with an uncertainty of 0.2. The band is widest\n", + "where the curve is steepest, near 0, and vanishes as the curve saturates. The chart is interactive: hover\n", + "over the curve to read the values. It loads Plotly from the internet, so it needs a connection. The charts\n", + "are shown with a small helper, `show_figure(fig)`, which is defined in a hidden cell; in your own notebook\n", + "`fig.show()` does the same." + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "9a76574e", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:54.663021Z", + "iopub.status.busy": "2026-10-13T16:18:54.662901Z", + "iopub.status.idle": "2026-10-13T16:18:54.665750Z", + "shell.execute_reply": "2026-10-13T16:18:54.665269Z" + }, + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "import base64\n", + "\n", + "from IPython.display import IFrame\n", + "from IPython.display import display\n", + "\n", + "\n", + "def show_figure(fig: go.Figure, height: int = 420) -> None:\n", + " \"\"\"Show a Plotly figure as an embedded iframe, so that it renders in the docs.\"\"\"\n", + " page = (\n", + " go\n", + " .Figure(fig)\n", + " .update_layout(template='plotly_white')\n", + " .to_html(\n", + " full_html=True,\n", + " include_plotlyjs='cdn',\n", + " default_width='100%',\n", + " default_height=f'{height - 20}px',\n", + " )\n", + " )\n", + " encoded = base64.b64encode(page.encode('utf-8')).decode('ascii')\n", + " display(IFrame(src=f'data:text/html;base64,{encoded}', width='100%', height=height))" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "6cd723ec", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:54.666768Z", + "iopub.status.busy": "2026-10-13T16:18:54.666562Z", + "iopub.status.idle": "2026-10-13T16:18:54.858429Z", + "shell.execute_reply": "2026-10-13T16:18:54.857782Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "arguments = np.linspace(-3.0, 3.0, 61)\n", + "\n", + "values, errors = [], []\n", + "for value in arguments:\n", + " argument = Parameter(name='x', value=value, variance=0.2**2)\n", + " magnetisation = es.math.tanh(argument)\n", + " values.append(magnetisation.value)\n", + " errors.append(magnetisation.error)\n", + "values, errors = np.array(values), np.array(errors)\n", + "\n", + "fig = go.Figure()\n", + "fig.add_trace(\n", + " go.Scatter(\n", + " x=arguments,\n", + " y=values - errors,\n", + " mode='lines',\n", + " line=dict(width=0),\n", + " showlegend=False,\n", + " hoverinfo='skip',\n", + " )\n", + ")\n", + "fig.add_trace(\n", + " go.Scatter(\n", + " x=arguments,\n", + " y=values + errors,\n", + " mode='lines',\n", + " line=dict(width=0),\n", + " fill='tonexty',\n", + " fillcolor='rgba(99, 110, 250, 0.25)',\n", + " name='± 1 error',\n", + " hoverinfo='skip',\n", + " )\n", + ")\n", + "\n", + "fig.add_trace(\n", + " go.Scatter(\n", + " x=arguments,\n", + " y=values,\n", + " mode='lines',\n", + " name='tanh(x)',\n", + " line=dict(color='rgb(99, 110, 250)'),\n", + " customdata=errors,\n", + " hovertemplate='x = %{x:.2f}
tanh = %{y:.3f} ± %{customdata:.3f}',\n", + " )\n", + ")\n", + "fig.update_layout(\n", + " xaxis_title='x',\n", + " yaxis_title='tanh(x)',\n", + " margin=dict(l=60, r=20, t=30, b=50),\n", + " legend=dict(x=0.05, y=0.95),\n", + ")\n", + "show_figure(fig)" + ] + }, + { + "cell_type": "markdown", + "id": "d7fbf35a", + "metadata": {}, + "source": [ + "The inverse functions are the mirror image. The error of `arccosh` grows without limit at the edge of the\n", + "domain, $y = 1$, where the derivative $1/\\sqrt{y^2 - 1}$ diverges:" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "b9a42646", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:54.859836Z", + "iopub.status.busy": "2026-10-13T16:18:54.859654Z", + "iopub.status.idle": "2026-10-13T16:18:54.925767Z", + "shell.execute_reply": "2026-10-13T16:18:54.925156Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "ys = np.linspace(1.05, 5.0, 60)\n", + "\n", + "values, errors = [], []\n", + "for value in ys:\n", + " y = Parameter(name='y', value=value, variance=0.02**2)\n", + " result = es.math.arccosh(y)\n", + " values.append(result.value)\n", + " errors.append(result.error)\n", + "values, errors = np.array(values), np.array(errors)\n", + "\n", + "fig = go.Figure()\n", + "fig.add_trace(\n", + " go.Scatter(\n", + " x=ys,\n", + " y=values - errors,\n", + " mode='lines',\n", + " line=dict(width=0),\n", + " showlegend=False,\n", + " hoverinfo='skip',\n", + " )\n", + ")\n", + "fig.add_trace(\n", + " go.Scatter(\n", + " x=ys,\n", + " y=values + errors,\n", + " mode='lines',\n", + " line=dict(width=0),\n", + " fill='tonexty',\n", + " fillcolor='rgba(99, 110, 250, 0.25)',\n", + " name='± 1 error',\n", + " hoverinfo='skip',\n", + " )\n", + ")\n", + "\n", + "fig.add_trace(\n", + " go.Scatter(\n", + " x=ys,\n", + " y=values,\n", + " mode='lines',\n", + " name='arccosh(y)',\n", + " line=dict(color='rgb(99, 110, 250)'),\n", + " customdata=errors,\n", + " hovertemplate='y = %{x:.2f}
arccosh = %{y:.3f} ± %{customdata:.3f}',\n", + " )\n", + ")\n", + "fig.update_layout(\n", + " xaxis_title='y',\n", + " yaxis_title='arccosh(y)',\n", + " margin=dict(l=60, r=20, t=30, b=50),\n", + " legend=dict(x=0.6, y=0.3),\n", + ")\n", + "show_figure(fig, height=380)" + ] + }, + { + "cell_type": "markdown", + "id": "a63db48c", + "metadata": {}, + "source": [ + "## Good to know\n", + "\n", + "At the minimum of `cosh` the derivative is 0, so the first-order variance is 0, however large the\n", + "uncertainty of the input is. For an input $0 \\pm 0.5$ the exact variance of $\\cosh(x)$, with\n", + "$x \\sim \\mathcal{N}(0, 0.5^2)$, is $\\tfrac{1}{2}(1 + e^{2\\sigma^2}) - e^{\\sigma^2} = 0.0403$:" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "dc485292", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:54.927198Z", + "iopub.status.busy": "2026-10-13T16:18:54.927035Z", + "iopub.status.idle": "2026-10-13T16:18:54.932159Z", + "shell.execute_reply": "2026-10-13T16:18:54.931715Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0.0\n", + "0.0403352186623227\n" + ] + } + ], + "source": [ + "flat = Parameter(name='flat', value=0.0, variance=0.5**2)\n", + "print(es.math.cosh(flat).variance)\n", + "\n", + "sigma2 = 0.5**2\n", + "print(0.5 * (1 + np.exp(2 * sigma2)) - np.exp(sigma2))" + ] + }, + { + "cell_type": "markdown", + "id": "e7ffdbd7", + "metadata": {}, + "source": [ + "The same applies to every function at a point where its derivative vanishes. Large arguments overflow:\n", + "`sinh` of 800 is refused with an error instead of returning infinity." + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "892ca132", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:54.933440Z", + "iopub.status.busy": "2026-10-13T16:18:54.933319Z", + "iopub.status.idle": "2026-10-13T16:18:54.936078Z", + "shell.execute_reply": "2026-10-13T16:18:54.935659Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "sinh(big) is not finite: value=inf, variance=inf. Check the value and variance of the argument(s).\n" + ] + } + ], + "source": [ + "try:\n", + " es.math.sinh(Parameter(name='big', value=800.0, variance=1.0))\n", + "except ValueError as error:\n", + " print(error)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.6" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/docs/tutorials/basics/maths-inverse-trigonometric.ipynb b/docs/docs/tutorials/basics/maths-inverse-trigonometric.ipynb new file mode 100644 index 00000000..175a31a6 --- /dev/null +++ b/docs/docs/tutorials/basics/maths-inverse-trigonometric.ipynb @@ -0,0 +1,928 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "2bdbbc22", + "metadata": {}, + "source": [ + "# Inverse trigonometric functions" + ] + }, + { + "cell_type": "markdown", + "id": "dcc545b2", + "metadata": {}, + "source": [ + "This notebook covers `es.math.arcsin`, `arccos`, `arctan` and `arctan2`. They take a `Parameter` or a\n", + "`DescriptorNumber` and return an **angle**, in radians by default; the keyword `unit='deg'` returns degrees,\n", + "with the variance and the bounds converted to match. The variance is propagated to first order, and the\n", + "bounds are the exact range of the function over the bounds of the input.\n", + "\n", + "The examples are taken from a diffraction experiment, but each one stands alone. Each part closes with a few exercises on what was just shown. Their hints and solutions are folded away and open on a click, so the text reads straight through; a solution is a worked answer with its result." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "b248846e", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:56.152252Z", + "iopub.status.busy": "2026-10-13T16:18:56.152130Z", + "iopub.status.idle": "2026-10-13T16:18:57.075168Z", + "shell.execute_reply": "2026-10-13T16:18:57.074675Z" + } + }, + "outputs": [], + "source": [ + "import numpy as np\n", + "import plotly.graph_objects as go\n", + "\n", + "import easyscience as es\n", + "from easyscience import DescriptorNumber\n", + "from easyscience import Parameter" + ] + }, + { + "cell_type": "markdown", + "id": "3f1fb192", + "metadata": {}, + "source": [ + "The function `show` prints a number as value, error and unit, and, for a `Parameter` with finite bounds, the bounds." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "b7551579", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:57.077009Z", + "iopub.status.busy": "2026-10-13T16:18:57.076732Z", + "iopub.status.idle": "2026-10-13T16:18:57.079843Z", + "shell.execute_reply": "2026-10-13T16:18:57.079164Z" + } + }, + "outputs": [], + "source": [ + "def show(number):\n", + " \"\"\"One line with value, error, unit and, for a Parameter, the finite bounds.\"\"\"\n", + " text = f'{number.value:.4f} ± {number.error or 0.0:.4f} {number.unit}'\n", + " if hasattr(number, 'min') and (np.isfinite(number.min) or np.isfinite(number.max)):\n", + " text += f' in [{number.min:.4f}, {number.max:.4f}]'\n", + " return text" + ] + }, + { + "cell_type": "markdown", + "id": "05a39696", + "metadata": {}, + "source": [ + "## The arcsine and the Bragg angle\n", + "\n", + "Bragg's law $\\lambda = 2d\\sin\\theta$ gives the scattering angle from the lattice spacing $d$ and the\n", + "wavelength $\\lambda$, with $\\theta = \\arcsin\\!\\left(\\lambda/(2d)\\right)$. The argument is dimensionless\n", + "because the two lengths cancel:" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "4146fd2b", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:57.081102Z", + "iopub.status.busy": "2026-10-13T16:18:57.080960Z", + "iopub.status.idle": "2026-10-13T16:18:57.093995Z", + "shell.execute_reply": "2026-10-13T16:18:57.093426Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0.2482 ± 0.0040 rad in [0.1287, 0.3954]\n", + "14.2210 ± 0.2315 deg in [7.3762, 22.6531]\n" + ] + } + ], + "source": [ + "d = Parameter(name='d', value=3.1356, unit='angstrom', variance=0.05**2, min=2.0, max=6.0)\n", + "wavelength = DescriptorNumber(name='wavelength', value=1.5406, unit='angstrom', variance=0.0)\n", + "\n", + "print(show(es.math.arcsin(wavelength / (2 * d))))\n", + "print(show(es.math.arcsin(wavelength / (2 * d), unit='deg')))" + ] + }, + { + "cell_type": "markdown", + "id": "acfd16ae", + "metadata": {}, + "source": [ + "The result is in radians unless `unit='deg'` says otherwise. A larger $d$ gives a smaller angle, so the\n", + "upper bound of the angle comes from the lower bound of $d$." + ] + }, + { + "cell_type": "markdown", + "id": "6d55b389", + "metadata": {}, + "source": [ + "### Exercise 1: The Bragg angle of silicon\n", + "\n", + "The (111) planes of silicon have a spacing of $3.1356 \\pm 0.0005$ Å. For X-rays of wavelength 1.5406 Å (known exactly), what is the Bragg angle $\\theta$, in degrees?" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "b41c68a2", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:57.095406Z", + "iopub.status.busy": "2026-10-13T16:18:57.095240Z", + "iopub.status.idle": "2026-10-13T16:18:57.098272Z", + "shell.execute_reply": "2026-10-13T16:18:57.097847Z" + } + }, + "outputs": [], + "source": [ + "d = Parameter(name='d', value=3.1356, variance=0.0005**2, unit='angstrom')\n", + "wavelength = DescriptorNumber(name='wavelength', value=1.5406, variance=0.0, unit='angstrom')" + ] + }, + { + "cell_type": "markdown", + "id": "74d22d9e", + "metadata": {}, + "source": [ + "
\n", + "Hint 1\n", + "\n", + "$\\theta = \\arcsin\\!\\left(\\lambda/(2d)\\right)$; form the ratio $\\lambda/(2d)$ first.\n", + "\n", + "
\n", + "\n", + "
\n", + "Hint 2\n", + "\n", + "Ask for degrees with the keyword `unit='deg'`.\n", + "\n", + "
\n", + "\n", + "
\n", + "Solution\n", + "\n", + "```python\n", + "theta = es.math.arcsin(wavelength / (2 * d), unit='deg')\n", + "print(show(theta))\n", + "```\n", + "\n", + "Expected result:\n", + "\n", + "```text\n", + "14.2210 ± 0.0023 deg in [-90.0000, 90.0000]\n", + "```\n", + "\n", + "The bounds of the arcsine are $\\pm 90^\\circ$ for an unbounded input.\n", + "\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "068f27ab", + "metadata": {}, + "source": [ + "## The arccosine and the arctangent\n", + "\n", + "`arccos` returns an angle between $0$ and $\\pi$, and `arctan` an angle between $-\\pi/2$ and $\\pi/2$.\n", + "The arccosine of a number between 0 and 1:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "45087906", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:57.099546Z", + "iopub.status.busy": "2026-10-13T16:18:57.099395Z", + "iopub.status.idle": "2026-10-13T16:18:57.102484Z", + "shell.execute_reply": "2026-10-13T16:18:57.102072Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "1.0472 ± 0.0231 rad in [0.0000, 1.5708]\n" + ] + } + ], + "source": [ + "ratio = Parameter(name='ratio', value=0.5, variance=0.02**2, min=0.0, max=1.0)\n", + "print(show(es.math.arccos(ratio)))" + ] + }, + { + "cell_type": "markdown", + "id": "26a08ce8", + "metadata": {}, + "source": [ + "The arctangent gives the angle of a slope. A ramp that rises 10 cm over a length of 1 m has a grade of 0.10:" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "817aef54", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:57.103572Z", + "iopub.status.busy": "2026-10-13T16:18:57.103458Z", + "iopub.status.idle": "2026-10-13T16:18:57.106383Z", + "shell.execute_reply": "2026-10-13T16:18:57.105989Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "5.7106 ± 0.2836 deg in [-90.0000, 90.0000]\n" + ] + } + ], + "source": [ + "grade = Parameter(name='grade', value=0.10, variance=0.005**2)\n", + "print(show(es.math.arctan(grade, unit='deg')))" + ] + }, + { + "cell_type": "markdown", + "id": "29a09b1d", + "metadata": {}, + "source": [ + "### Exercise 2: Angle of a slope\n", + "\n", + "A ramp rises $0.30 \\pm 0.01$ m over a horizontal run of $1.20 \\pm 0.02$ m. What is its angle with the horizontal, in degrees?" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "1180c5ba", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:57.107448Z", + "iopub.status.busy": "2026-10-13T16:18:57.107339Z", + "iopub.status.idle": "2026-10-13T16:18:57.110067Z", + "shell.execute_reply": "2026-10-13T16:18:57.109535Z" + } + }, + "outputs": [], + "source": [ + "rise = Parameter(name='rise', value=0.30, variance=0.01**2, unit='m')\n", + "run = Parameter(name='run', value=1.20, variance=0.02**2, unit='m')" + ] + }, + { + "cell_type": "markdown", + "id": "7ed06073", + "metadata": {}, + "source": [ + "
\n", + "Hint 1\n", + "\n", + "The tangent of the angle is the rise divided by the run.\n", + "\n", + "
\n", + "\n", + "
\n", + "Hint 2\n", + "\n", + "The ratio of two lengths in the same unit is dimensionless, which is what `arctan` needs.\n", + "\n", + "
\n", + "\n", + "
\n", + "Solution\n", + "\n", + "```python\n", + "angle = es.math.arctan(rise / run, unit='deg')\n", + "print(show(angle))\n", + "```\n", + "\n", + "Expected result:\n", + "\n", + "```text\n", + "14.0362 ± 0.5024 deg in [-90.0000, 90.0000]\n", + "```\n", + "\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "e820e211", + "metadata": {}, + "source": [ + "## The two-argument arctangent\n", + "\n", + "`arctan(b / a)` cannot tell $(a, b)$ from $(-a, -b)$. `arctan2(b, a)` uses the signs of both arguments to\n", + "find the right quadrant. The phase of a structure factor $F = a + \\mathrm{i}\\,b$ is `arctan2(b, a)`; the two\n", + "arguments must have the same unit (here none):" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "99e676f2", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:57.111246Z", + "iopub.status.busy": "2026-10-13T16:18:57.111133Z", + "iopub.status.idle": "2026-10-13T16:18:57.118304Z", + "shell.execute_reply": "2026-10-13T16:18:57.117740Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "53.1301 ± 1.1459 deg in [36.8699, 68.1986]\n", + "126.8699 ± 1.1459 deg in [111.8014, 143.1301]\n", + "-53.1301 ± 1.1459 deg in [-68.1986, -36.8699]\n" + ] + } + ], + "source": [ + "a = Parameter(name='a', value=3.0, variance=0.1**2, min=2.0, max=4.0)\n", + "b = Parameter(name='b', value=4.0, variance=0.1**2, min=3.0, max=5.0)\n", + "a_negative = Parameter(name='a_negative', value=-3.0, variance=0.1**2, min=-4.0, max=-2.0)\n", + "\n", + "print(show(es.math.arctan2(b, a, unit='deg')))\n", + "print(show(es.math.arctan2(b, a_negative, unit='deg')))\n", + "print(show(es.math.arctan(b / a_negative, unit='deg')))" + ] + }, + { + "cell_type": "markdown", + "id": "be990dff", + "metadata": {}, + "source": [ + "The second line is the angle in the second quadrant, $126.87^\\circ$. The third line, with plain `arctan`,\n", + "cannot know about the quadrant and gives $-53.13^\\circ$.\n", + "\n", + "The shorter names `asin`, `acos`, `atan` and `atan2` are aliases of the same functions:" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "cc28134c", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:57.119498Z", + "iopub.status.busy": "2026-10-13T16:18:57.119358Z", + "iopub.status.idle": "2026-10-13T16:18:57.123311Z", + "shell.execute_reply": "2026-10-13T16:18:57.122922Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "es.math.asin is es.math.arcsin" + ] + }, + { + "cell_type": "markdown", + "id": "12cca785", + "metadata": {}, + "source": [ + "### Exercise 3: Phase of a structure factor\n", + "\n", + "The real and imaginary parts of a structure factor are $a = 1.5 \\pm 0.05$ and $b = 2.0 \\pm 0.05$. What is its phase, in degrees?" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "bab72623", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:57.124436Z", + "iopub.status.busy": "2026-10-13T16:18:57.124313Z", + "iopub.status.idle": "2026-10-13T16:18:57.126910Z", + "shell.execute_reply": "2026-10-13T16:18:57.126485Z" + } + }, + "outputs": [], + "source": [ + "a = Parameter(name='a', value=1.5, variance=0.05**2)\n", + "b = Parameter(name='b', value=2.0, variance=0.05**2)" + ] + }, + { + "cell_type": "markdown", + "id": "33fcc29c", + "metadata": {}, + "source": [ + "
\n", + "Hint 1\n", + "\n", + "The phase is `arctan2` of the imaginary part over the real part: the **first** argument is `b`.\n", + "\n", + "
\n", + "\n", + "
\n", + "Hint 2\n", + "\n", + "Use the keyword `unit='deg'` for degrees.\n", + "\n", + "
\n", + "\n", + "
\n", + "Solution\n", + "\n", + "```python\n", + "phase = es.math.arctan2(b, a, unit='deg')\n", + "print(show(phase))\n", + "```\n", + "\n", + "Expected result:\n", + "\n", + "```text\n", + "53.1301 ± 1.1459 deg in [-180.0000, 180.0000]\n", + "```\n", + "\n", + "The bounds are $\\pm 180^\\circ$, the full range of the function.\n", + "\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "dfb52523", + "metadata": {}, + "source": [ + "## Values outside the domain\n", + "\n", + "`arcsin` and `arccos` accept numbers between -1 and 1 only. A ratio $\\lambda/(2d)$ larger than 1 means that\n", + "**no angle satisfies Bragg's law** for that wavelength and spacing, and the function says so instead of\n", + "returning a meaningless number. The error message names the parameter it was given." + ] + }, + { + "cell_type": "markdown", + "id": "e9cdb7bb", + "metadata": {}, + "source": [ + "### Exercise 4: No Bragg angle\n", + "\n", + "A crystal has a lattice spacing of $0.70 \\pm 0.01$ Å, and the X-rays have a wavelength of 1.5406 Å. Predict what `es.math.arcsin(wavelength / (2 * d), unit='deg')` does, then try it. Catch the error and print its message." + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "56a18498", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:57.127891Z", + "iopub.status.busy": "2026-10-13T16:18:57.127765Z", + "iopub.status.idle": "2026-10-13T16:18:57.130498Z", + "shell.execute_reply": "2026-10-13T16:18:57.129917Z" + } + }, + "outputs": [], + "source": [ + "d = Parameter(name='d', value=0.70, variance=0.01**2, unit='angstrom')\n", + "wavelength = DescriptorNumber(name='wavelength', value=1.5406, variance=0.0, unit='angstrom')" + ] + }, + { + "cell_type": "markdown", + "id": "d991a5db", + "metadata": {}, + "source": [ + "
\n", + "Hint 1\n", + "\n", + "Look at the value of `wavelength / (2 * d)`: which numbers does `arcsin` accept?\n", + "\n", + "
\n", + "\n", + "
\n", + "Hint 2\n", + "\n", + "Put the call in a `try` block and catch `ValueError`. Give the intermediate result a readable name (`ratio.name = 'ratio'`), because the error message names the parameter it was given.\n", + "\n", + "
\n", + "\n", + "
\n", + "Solution\n", + "\n", + "```python\n", + "ratio = wavelength / (2 * d)\n", + "ratio.name = 'ratio'\n", + "try:\n", + " es.math.arcsin(ratio, unit='deg')\n", + "except ValueError as error:\n", + " print(error)\n", + "```\n", + "\n", + "Expected result:\n", + "\n", + "```text\n", + "The value of 'ratio' (1.1004285714285715) is outside the domain [-1.0, 1.0] of arcsin().\n", + "```\n", + "\n", + "The ratio is larger than 1, so no angle exists. Without the line that sets the name, the message would name an automatically generated parameter instead of `ratio`.\n", + "\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "55668786", + "metadata": {}, + "source": [ + "## A round trip\n", + "\n", + "The sine and the arcsine undo each other. The Bragg angle recovered from $d$ and $\\lambda$ is the angle we\n", + "started with, and its error is almost the same:" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "33dc5b11", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:57.131908Z", + "iopub.status.busy": "2026-10-13T16:18:57.131754Z", + "iopub.status.idle": "2026-10-13T16:18:57.138108Z", + "shell.execute_reply": "2026-10-13T16:18:57.137622Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "15.0000 ± 0.1002 deg in [5.0000, 60.0000]\n", + "variance after the round trip: 0.010050\n", + "variance of theta: 0.010000\n" + ] + } + ], + "source": [ + "theta = Parameter(name='theta', value=15.0, unit='deg', variance=0.1**2, min=5.0, max=60.0)\n", + "wavelength = DescriptorNumber(name='wavelength', value=1.5406, unit='angstrom', variance=0.0005**2)\n", + "\n", + "d = wavelength / (2 * es.math.sin(theta))\n", + "recovered = es.math.arcsin(wavelength / (2 * d), unit='deg')\n", + "print(show(recovered))\n", + "print(f'variance after the round trip: {recovered.variance:.6f}')\n", + "print(f'variance of theta: {theta.variance:.6f}')" + ] + }, + { + "cell_type": "markdown", + "id": "cb6c8c84", + "metadata": {}, + "source": [ + "The excess is small and has a simple cause. The wavelength cancels mathematically, so the variance should\n", + "come back as exactly that of `theta`. But `d` is a stored intermediate result, so the second use of the\n", + "wavelength is treated as a new, independent number, just as in $(2x)/x$ in the notebook on arithmetic." + ] + }, + { + "cell_type": "markdown", + "id": "77781ebb", + "metadata": {}, + "source": [ + "## Charts\n", + "\n", + "The Bragg angle as a function of the wavelength, for a crystal with $d = 3.1356$ Å and a wavelength with an\n", + "uncertainty of 0.01 Å. The band widens as $\\lambda/(2d)$ approaches 1: the derivative of the arcsine\n", + "diverges there. The chart is interactive: hover over the curve to read the values. It loads Plotly from the\n", + "internet, so it needs a connection. The charts are shown with a small helper, `show_figure(fig)`, which is\n", + "defined in a hidden cell; in your own notebook `fig.show()` does the same." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "d0d3ee83", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:57.139174Z", + "iopub.status.busy": "2026-10-13T16:18:57.139046Z", + "iopub.status.idle": "2026-10-13T16:18:57.142016Z", + "shell.execute_reply": "2026-10-13T16:18:57.141463Z" + }, + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "import base64\n", + "\n", + "from IPython.display import IFrame\n", + "from IPython.display import display\n", + "\n", + "\n", + "def show_figure(fig: go.Figure, height: int = 420) -> None:\n", + " \"\"\"Show a Plotly figure as an embedded iframe, so that it renders in the docs.\"\"\"\n", + " page = (\n", + " go\n", + " .Figure(fig)\n", + " .update_layout(template='plotly_white')\n", + " .to_html(\n", + " full_html=True,\n", + " include_plotlyjs='cdn',\n", + " default_width='100%',\n", + " default_height=f'{height - 20}px',\n", + " )\n", + " )\n", + " encoded = base64.b64encode(page.encode('utf-8')).decode('ascii')\n", + " display(IFrame(src=f'data:text/html;base64,{encoded}', width='100%', height=height))" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "bbb8a36d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:57.143116Z", + "iopub.status.busy": "2026-10-13T16:18:57.142992Z", + "iopub.status.idle": "2026-10-13T16:18:57.365686Z", + "shell.execute_reply": "2026-10-13T16:18:57.365145Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "wavelengths = np.linspace(0.5, 6.0, 56)\n", + "spacing = DescriptorNumber(name='d', value=3.1356, unit='angstrom', variance=0.0)\n", + "\n", + "values, errors = [], []\n", + "for value in wavelengths:\n", + " wavelength = Parameter(name='wavelength', value=value, unit='angstrom', variance=0.01**2)\n", + " angle = es.math.arcsin(wavelength / (2 * spacing), unit='deg')\n", + " values.append(angle.value)\n", + " errors.append(angle.error)\n", + "values, errors = np.array(values), np.array(errors)\n", + "\n", + "fig = go.Figure()\n", + "fig.add_trace(\n", + " go.Scatter(\n", + " x=wavelengths,\n", + " y=values - errors,\n", + " mode='lines',\n", + " line=dict(width=0),\n", + " showlegend=False,\n", + " hoverinfo='skip',\n", + " )\n", + ")\n", + "fig.add_trace(\n", + " go.Scatter(\n", + " x=wavelengths,\n", + " y=values + errors,\n", + " mode='lines',\n", + " line=dict(width=0),\n", + " fill='tonexty',\n", + " fillcolor='rgba(99, 110, 250, 0.25)',\n", + " name='± 1 error',\n", + " hoverinfo='skip',\n", + " )\n", + ")\n", + "fig.add_trace(\n", + " go.Scatter(\n", + " x=wavelengths,\n", + " y=values,\n", + " mode='lines',\n", + " name='Bragg angle',\n", + " line=dict(color='rgb(99, 110, 250)'),\n", + " customdata=errors,\n", + " hovertemplate='wavelength = %{x:.2f} Å
theta = %{y:.2f} ± %{customdata:.3f} deg',\n", + " )\n", + ")\n", + "fig.update_layout(\n", + " xaxis_title='wavelength / Å',\n", + " yaxis_title='Bragg angle theta / deg',\n", + " margin=dict(l=60, r=20, t=30, b=50),\n", + " legend=dict(x=0.05, y=0.95),\n", + ")\n", + "show_figure(fig)" + ] + }, + { + "cell_type": "markdown", + "id": "c7c0b8f0", + "metadata": {}, + "source": [ + "The phase of a structure factor $a + \\mathrm{i}\\,b$ with $a = 1.5 \\pm 0.05$ as the imaginary part $b$ goes\n", + "from $-3$ to $3$ (with an uncertainty of 0.05). The phase runs from $-63^\\circ$ to $63^\\circ$ and is steepest\n", + "near $b = 0$, where the error is largest:" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "50209b28", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:57.367098Z", + "iopub.status.busy": "2026-10-13T16:18:57.366970Z", + "iopub.status.idle": "2026-10-13T16:18:57.438606Z", + "shell.execute_reply": "2026-10-13T16:18:57.437984Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "imaginary = np.linspace(-3.0, 3.0, 61)\n", + "real = Parameter(name='a', value=1.5, variance=0.05**2)\n", + "\n", + "values, errors = [], []\n", + "for value in imaginary:\n", + " b = Parameter(name='b', value=value, variance=0.05**2)\n", + " phase = es.math.arctan2(b, real, unit='deg')\n", + " values.append(phase.value)\n", + " errors.append(phase.error)\n", + "values, errors = np.array(values), np.array(errors)\n", + "\n", + "fig = go.Figure()\n", + "fig.add_trace(\n", + " go.Scatter(\n", + " x=imaginary,\n", + " y=values - errors,\n", + " mode='lines',\n", + " line=dict(width=0),\n", + " showlegend=False,\n", + " hoverinfo='skip',\n", + " )\n", + ")\n", + "fig.add_trace(\n", + " go.Scatter(\n", + " x=imaginary,\n", + " y=values + errors,\n", + " mode='lines',\n", + " line=dict(width=0),\n", + " fill='tonexty',\n", + " fillcolor='rgba(99, 110, 250, 0.25)',\n", + " name='± 1 error',\n", + " hoverinfo='skip',\n", + " )\n", + ")\n", + "fig.add_trace(\n", + " go.Scatter(\n", + " x=imaginary,\n", + " y=values,\n", + " mode='lines',\n", + " name='phase',\n", + " line=dict(color='rgb(99, 110, 250)'),\n", + " customdata=errors,\n", + " hovertemplate='b = %{x:.2f}
phase = %{y:.2f} ± %{customdata:.2f} deg',\n", + " )\n", + ")\n", + "fig.update_layout(\n", + " xaxis_title='imaginary part b',\n", + " yaxis_title='phase / deg',\n", + " margin=dict(l=60, r=20, t=30, b=50),\n", + " legend=dict(x=0.05, y=0.95),\n", + ")\n", + "show_figure(fig, height=380)" + ] + }, + { + "cell_type": "markdown", + "id": "a4b71d52", + "metadata": {}, + "source": [ + "## Good to know\n", + "\n", + "The derivative of the arcsine is $1/\\sqrt{1 - x^2}$, which diverges at $x = \\pm 1$. The same uncertainty of\n", + "0.005 gives a much larger error close to the edge of the domain:" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "d9c50356", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:57.439869Z", + "iopub.status.busy": "2026-10-13T16:18:57.439719Z", + "iopub.status.idle": "2026-10-13T16:18:57.443870Z", + "shell.execute_reply": "2026-10-13T16:18:57.443387Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0.5236 ± 0.0058 rad in [-1.5708, 1.5708]\n", + "1.4293 ± 0.0354 rad in [-1.5708, 1.5708]\n" + ] + } + ], + "source": [ + "for value in (0.5, 0.99):\n", + " print(show(es.math.arcsin(Parameter(name='x', value=value, variance=0.005**2))))" + ] + }, + { + "cell_type": "markdown", + "id": "710c53b5", + "metadata": {}, + "source": [ + "Near $x = \\pm 1$ the first-order variance becomes unreliable: the function is strongly curved there, and the\n", + "uncertainty may even reach beyond the domain." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.6" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/docs/tutorials/basics/maths-trigonometric.ipynb b/docs/docs/tutorials/basics/maths-trigonometric.ipynb new file mode 100644 index 00000000..8cd83ac0 --- /dev/null +++ b/docs/docs/tutorials/basics/maths-trigonometric.ipynb @@ -0,0 +1,905 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "c9f5addc", + "metadata": {}, + "source": [ + "# Trigonometric functions" + ] + }, + { + "cell_type": "markdown", + "id": "4b44082d", + "metadata": {}, + "source": [ + "This notebook covers `es.math.sin`, `cos`, `tan` and `sinc`. They take a `Parameter` or a\n", + "`DescriptorNumber` and return a new one with the value, unit, variance and bounds worked out: the variance\n", + "to first order, the bounds as the exact range of the function over the bounds of the input.\n", + "\n", + "The examples are taken from a diffraction experiment (Bragg's law), but each one stands alone. Each part closes with a few exercises on what was just shown. Their hints and solutions are folded away and open on a click, so the text reads straight through; a solution is a worked answer with its result." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "08d5955d", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:58.661255Z", + "iopub.status.busy": "2026-10-13T16:18:58.661106Z", + "iopub.status.idle": "2026-10-13T16:18:59.565707Z", + "shell.execute_reply": "2026-10-13T16:18:59.564834Z" + } + }, + "outputs": [], + "source": [ + "import numpy as np\n", + "import plotly.graph_objects as go\n", + "\n", + "import easyscience as es\n", + "from easyscience import DescriptorNumber\n", + "from easyscience import Parameter" + ] + }, + { + "cell_type": "markdown", + "id": "290a014d", + "metadata": {}, + "source": [ + "The function `show` prints a number as value, error and unit, and, for a `Parameter` with finite bounds, the bounds." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "aec560fa", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:59.567552Z", + "iopub.status.busy": "2026-10-13T16:18:59.567264Z", + "iopub.status.idle": "2026-10-13T16:18:59.570435Z", + "shell.execute_reply": "2026-10-13T16:18:59.569995Z" + } + }, + "outputs": [], + "source": [ + "def show(number):\n", + " \"\"\"One line with value, error, unit and, for a Parameter, the finite bounds.\"\"\"\n", + " text = f'{number.value:.4f} ± {number.error or 0.0:.4f} {number.unit}'\n", + " if hasattr(number, 'min') and (np.isfinite(number.min) or np.isfinite(number.max)):\n", + " text += f' in [{number.min:.4f}, {number.max:.4f}]'\n", + " return text" + ] + }, + { + "cell_type": "markdown", + "id": "30adc115", + "metadata": {}, + "source": [ + "## Angles need a unit\n", + "\n", + "The argument of `sin`, `cos` and `tan` must have an angle unit, `rad` or `deg`, and the result is\n", + "dimensionless. A length is refused:" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "4e68d0b1", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:59.571661Z", + "iopub.status.busy": "2026-10-13T16:18:59.571524Z", + "iopub.status.idle": "2026-10-13T16:18:59.581631Z", + "shell.execute_reply": "2026-10-13T16:18:59.581038Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "UnitError: Trigonometric function requires rad or deg unit, got m.\n" + ] + } + ], + "source": [ + "try:\n", + " es.math.sin(Parameter(name='v', value=1.0, unit='m', variance=0.01))\n", + "except Exception as error:\n", + " print(f'{type(error).__name__}: {error}')" + ] + }, + { + "cell_type": "markdown", + "id": "dd22e633", + "metadata": {}, + "source": [ + "## The sine and Bragg's law\n", + "\n", + "Bragg's law relates the spacing $d$ of the lattice planes to the scattering angle $\\theta$ and the\n", + "wavelength $\\lambda$, and the scattering vector is $q$:\n", + "\n", + "$$\n", + "d = \\frac{\\lambda}{2\\sin\\theta}, \\qquad q = \\frac{4\\pi\\sin\\theta}{\\lambda}.\n", + "$$\n", + "\n", + "The uncertainty is converted correctly: $\\sin 15^\\circ$ has an error of $\\cos 15^\\circ \\times \\sigma_\\theta$\n", + "with $\\sigma_\\theta$ in radians. The wavelength is a measured constant, so it is a `DescriptorNumber` (no\n", + "bounds); the angle may be fitted, so it is a `Parameter`:" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "9b160060", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:59.582966Z", + "iopub.status.busy": "2026-10-13T16:18:59.582814Z", + "iopub.status.idle": "2026-10-13T16:18:59.586966Z", + "shell.execute_reply": "2026-10-13T16:18:59.586512Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0.2588 ± 0.0017 dimensionless in [0.0872, 0.8660]\n" + ] + } + ], + "source": [ + "theta = Parameter(name='theta', value=15.0, unit='deg', variance=0.1**2, min=5.0, max=60.0)\n", + "wavelength = DescriptorNumber(name='wavelength', value=1.5406, unit='angstrom', variance=0.0005**2)\n", + "\n", + "s = es.math.sin(theta)\n", + "print(show(s))" + ] + }, + { + "cell_type": "markdown", + "id": "77129235", + "metadata": {}, + "source": [ + "Ordinary arithmetic then gives $d$ and $q$, with their uncertainties and bounds:" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "a1242c23", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:59.588121Z", + "iopub.status.busy": "2026-10-13T16:18:59.587964Z", + "iopub.status.idle": "2026-10-13T16:18:59.592203Z", + "shell.execute_reply": "2026-10-13T16:18:59.591642Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2.9762 ± 0.0194 Å in [0.8895, 8.8382]\n", + "2.1111 ± 0.0138 1/Å in [0.7109, 7.0640]\n" + ] + } + ], + "source": [ + "d = wavelength / (2 * s)\n", + "q = 4 * np.pi * s / wavelength\n", + "print(show(d))\n", + "print(show(q))" + ] + }, + { + "cell_type": "markdown", + "id": "54ceacc1", + "metadata": {}, + "source": [ + "### Exercise 1: Scattering vector\n", + "\n", + "X-rays of wavelength 1.54 Å (known exactly) are scattered at $\\theta = 22.5 \\pm 0.2^\\circ$. The scattering vector is $q = 4\\pi\\sin\\theta/\\lambda$. What is $q$?" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "f6c4fdf5", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:59.593190Z", + "iopub.status.busy": "2026-10-13T16:18:59.593068Z", + "iopub.status.idle": "2026-10-13T16:18:59.595497Z", + "shell.execute_reply": "2026-10-13T16:18:59.595065Z" + } + }, + "outputs": [], + "source": [ + "theta = Parameter(name='theta', value=22.5, variance=0.2**2, unit='deg')\n", + "wavelength = DescriptorNumber(name='wavelength', value=1.54, variance=0.0, unit='angstrom')" + ] + }, + { + "cell_type": "markdown", + "id": "21860200", + "metadata": {}, + "source": [ + "
\n", + "Hint 1\n", + "\n", + "The angle has the unit `deg`, which `es.math.sin` understands; the result is dimensionless.\n", + "\n", + "
\n", + "\n", + "
\n", + "Hint 2\n", + "\n", + "$4\\pi$ is a plain number, `4 * np.pi`. Divide by the wavelength last, so that the unit becomes 1/Å.\n", + "\n", + "
\n", + "\n", + "
\n", + "Solution\n", + "\n", + "```python\n", + "q = 4 * np.pi * es.math.sin(theta) / wavelength\n", + "print(show(q))\n", + "```\n", + "\n", + "Expected result:\n", + "\n", + "```text\n", + "3.1227 ± 0.0263 1/Å in [-8.1600, 8.1600]\n", + "```\n", + "\n", + "The bounds are those of the sine, $\\pm 1$, scaled by $4\\pi/\\lambda$.\n", + "\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "c57aec0d", + "metadata": {}, + "source": [ + "### Exercise 2: Lattice spacing\n", + "\n", + "For the same measurement, $\\theta = 22.5 \\pm 0.2^\\circ$ and $\\lambda = 1.54$ Å, what is the lattice spacing $d = \\lambda/(2\\sin\\theta)$?" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "b3c6c722", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:59.596736Z", + "iopub.status.busy": "2026-10-13T16:18:59.596603Z", + "iopub.status.idle": "2026-10-13T16:18:59.599359Z", + "shell.execute_reply": "2026-10-13T16:18:59.598815Z" + } + }, + "outputs": [], + "source": [ + "theta = Parameter(name='theta', value=22.5, variance=0.2**2, unit='deg')\n", + "wavelength = DescriptorNumber(name='wavelength', value=1.54, variance=0.0, unit='angstrom')" + ] + }, + { + "cell_type": "markdown", + "id": "cb1fb058", + "metadata": {}, + "source": [ + "
\n", + "Hint 1\n", + "\n", + "Compute the sine first, then divide the wavelength by twice the sine.\n", + "\n", + "
\n", + "\n", + "
\n", + "Hint 2\n", + "\n", + "A `DescriptorNumber` and a `Parameter` can be combined; the result is a `Parameter`.\n", + "\n", + "
\n", + "\n", + "
\n", + "Solution\n", + "\n", + "```python\n", + "d = wavelength / (2 * es.math.sin(theta))\n", + "print(show(d))\n", + "```\n", + "\n", + "Expected result:\n", + "\n", + "```text\n", + "2.0121 ± 0.0170 Å\n", + "```\n", + "\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "2b057253", + "metadata": {}, + "source": [ + "## The cosine and the tangent\n", + "\n", + "The same rules apply. The tangent of an angle whose bounds reach $90^\\circ$ has no upper limit:" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "3c772429", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:59.600908Z", + "iopub.status.busy": "2026-10-13T16:18:59.600785Z", + "iopub.status.idle": "2026-10-13T16:18:59.604523Z", + "shell.execute_reply": "2026-10-13T16:18:59.604118Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "cos 0.8660 ± 0.0087 dimensionless in [0.0000, 1.0000]\n", + "tan 0.5774 ± 0.0233 dimensionless in [0.0000, inf]\n" + ] + } + ], + "source": [ + "angle = Parameter(name='angle', value=30.0, unit='deg', variance=1.0, min=0.0, max=90.0)\n", + "\n", + "for function in (es.math.cos, es.math.tan):\n", + " print(f'{function.__name__:4}', show(function(angle)))" + ] + }, + { + "cell_type": "markdown", + "id": "9f32c222", + "metadata": {}, + "source": [ + "A flat detector at a distance $L$ from the sample shows a beam scattered by an angle $2\\theta$ at a height\n", + "$y = L\\tan 2\\theta$ above the direct beam." + ] + }, + { + "cell_type": "markdown", + "id": "e944bffc", + "metadata": {}, + "source": [ + "### Exercise 3: Position of a spot\n", + "\n", + "A flat detector is 1.50 ± 0.01 m from the sample. A beam scattered by $2\\theta = 30.0 \\pm 0.2^\\circ$ hits it at a height $y = L\\tan(2\\theta)$ above the direct beam. What is $y$?" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "12721052", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:59.605734Z", + "iopub.status.busy": "2026-10-13T16:18:59.605568Z", + "iopub.status.idle": "2026-10-13T16:18:59.608210Z", + "shell.execute_reply": "2026-10-13T16:18:59.607814Z" + } + }, + "outputs": [], + "source": [ + "distance = Parameter(name='distance', value=1.50, variance=0.01**2, unit='m')\n", + "two_theta = Parameter(name='two_theta', value=30.0, variance=0.2**2, unit='deg')" + ] + }, + { + "cell_type": "markdown", + "id": "cf160039", + "metadata": {}, + "source": [ + "
\n", + "Hint 1\n", + "\n", + "`es.math.tan` accepts the angle in degrees.\n", + "\n", + "
\n", + "\n", + "
\n", + "Hint 2\n", + "\n", + "A length times a dimensionless number is a length.\n", + "\n", + "
\n", + "\n", + "
\n", + "Solution\n", + "\n", + "```python\n", + "y = distance * es.math.tan(two_theta)\n", + "print(show(y))\n", + "```\n", + "\n", + "Expected result:\n", + "\n", + "```text\n", + "0.8660 ± 0.0091 m\n", + "```\n", + "\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "f326246c", + "metadata": {}, + "source": [ + "## The sinc function\n", + "\n", + "`sinc(x)` is $\\sin(x)/x$, not the normalised $\\sin(\\pi x)/(\\pi x)$ of NumPy. It takes radians or a\n", + "dimensionless number, and refuses degrees. The intensity of single-slit diffraction is proportional to\n", + "$\\mathrm{sinc}^2$:" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "3bcf8d0e", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:59.609346Z", + "iopub.status.busy": "2026-10-13T16:18:59.609224Z", + "iopub.status.idle": "2026-10-13T16:18:59.613411Z", + "shell.execute_reply": "2026-10-13T16:18:59.612943Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0.6033 ± 0.0268 dimensionless in [0.0022, 0.9194]\n", + "UnitError: sinc requires rad or dimensionless unit, got deg.\n" + ] + } + ], + "source": [ + "x = Parameter(name='x', value=1.2, variance=0.05**2, min=0.5, max=3.0)\n", + "print(show(es.math.sinc(x) ** 2))\n", + "\n", + "try:\n", + " es.math.sinc(Parameter(name='v', value=1.0, unit='deg', variance=0.01))\n", + "except Exception as error:\n", + " print(f'{type(error).__name__}: {error}')" + ] + }, + { + "cell_type": "markdown", + "id": "8e0f1a8b", + "metadata": {}, + "source": [ + "### Exercise 4: Single-slit intensity\n", + "\n", + "The intensity of single-slit diffraction, relative to the central maximum, is $\\mathrm{sinc}^2(x)$. What is it at $x = 2.0 \\pm 0.1$?" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "bf3fe263", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:59.614522Z", + "iopub.status.busy": "2026-10-13T16:18:59.614383Z", + "iopub.status.idle": "2026-10-13T16:18:59.617013Z", + "shell.execute_reply": "2026-10-13T16:18:59.616581Z" + } + }, + "outputs": [], + "source": [ + "x = Parameter(name='x', value=2.0, variance=0.1**2)" + ] + }, + { + "cell_type": "markdown", + "id": "6d988441", + "metadata": {}, + "source": [ + "
\n", + "Hint 1\n", + "\n", + "`sinc(x)` is $\\sin(x)/x$; it takes a dimensionless number or radians.\n", + "\n", + "
\n", + "\n", + "
\n", + "Hint 2\n", + "\n", + "Square the result with `**`.\n", + "\n", + "
\n", + "\n", + "
\n", + "Solution\n", + "\n", + "```python\n", + "intensity = es.math.sinc(x) ** 2\n", + "print(show(intensity))\n", + "```\n", + "\n", + "Expected result:\n", + "\n", + "```text\n", + "0.2067 ± 0.0396 dimensionless in [0.0000, 1.0000]\n", + "```\n", + "\n", + "The intensity lies between 0 and 1: $\\mathrm{sinc}$ is at most 1, and a square is never negative.\n", + "\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "33f557cd", + "metadata": {}, + "source": [ + "## Exact bounds\n", + "\n", + "The bounds of the input carry through. The sine of an angle that may vary between $0^\\circ$ and $180^\\circ$\n", + "reaches 1 in the middle, so the upper bound of the result is exactly 1, not the sine of either end:" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "18c5cc41", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:59.618173Z", + "iopub.status.busy": "2026-10-13T16:18:59.618052Z", + "iopub.status.idle": "2026-10-13T16:18:59.621140Z", + "shell.execute_reply": "2026-10-13T16:18:59.620676Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0.9848 ± 0.0030 dimensionless in [0.0000, 1.0000]\n" + ] + } + ], + "source": [ + "wide = Parameter(name='wide', value=80.0, unit='deg', variance=1.0, min=0.0, max=180.0)\n", + "print(show(es.math.sin(wide)))" + ] + }, + { + "cell_type": "markdown", + "id": "7503d274", + "metadata": {}, + "source": [ + "## Charts\n", + "\n", + "The lattice spacing $d(\\theta)$ of the Bragg example, for an angle with an uncertainty of $1^\\circ$ (ten\n", + "times larger than above, so that the band can be seen). The band is widest at small angles: the relative\n", + "error of $d$ is $\\cot\\theta\\,\\sigma_\\theta$. The chart is interactive: hover over the curve to read the\n", + "values. It loads Plotly from the internet, so it needs a connection. The charts are shown with a small\n", + "helper, `show_figure(fig)`, which is defined in a hidden cell; in your own notebook `fig.show()` does the\n", + "same." + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "c235a46f", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:59.622204Z", + "iopub.status.busy": "2026-10-13T16:18:59.622087Z", + "iopub.status.idle": "2026-10-13T16:18:59.625177Z", + "shell.execute_reply": "2026-10-13T16:18:59.624784Z" + }, + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "import base64\n", + "\n", + "from IPython.display import IFrame\n", + "from IPython.display import display\n", + "\n", + "\n", + "def show_figure(fig: go.Figure, height: int = 420) -> None:\n", + " \"\"\"Show a Plotly figure as an embedded iframe, so that it renders in the docs.\"\"\"\n", + " page = (\n", + " go\n", + " .Figure(fig)\n", + " .update_layout(template='plotly_white')\n", + " .to_html(\n", + " full_html=True,\n", + " include_plotlyjs='cdn',\n", + " default_width='100%',\n", + " default_height=f'{height - 20}px',\n", + " )\n", + " )\n", + " encoded = base64.b64encode(page.encode('utf-8')).decode('ascii')\n", + " display(IFrame(src=f'data:text/html;base64,{encoded}', width='100%', height=height))" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "cef20438", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:59.626260Z", + "iopub.status.busy": "2026-10-13T16:18:59.626150Z", + "iopub.status.idle": "2026-10-13T16:18:59.852165Z", + "shell.execute_reply": "2026-10-13T16:18:59.851629Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "angles = np.linspace(5.0, 60.0, 60)\n", + "wavelength = DescriptorNumber(name='wavelength', value=1.5406, unit='angstrom', variance=0.0005**2)\n", + "\n", + "values, errors = [], []\n", + "for value in angles:\n", + " angle = Parameter(name='theta', value=value, unit='deg', variance=1.0**2)\n", + " spacing = wavelength / (2 * es.math.sin(angle))\n", + " values.append(spacing.value)\n", + " errors.append(spacing.error)\n", + "values, errors = np.array(values), np.array(errors)\n", + "\n", + "fig = go.Figure()\n", + "fig.add_trace(\n", + " go.Scatter(\n", + " x=angles,\n", + " y=values - errors,\n", + " mode='lines',\n", + " line=dict(width=0),\n", + " showlegend=False,\n", + " hoverinfo='skip',\n", + " )\n", + ")\n", + "fig.add_trace(\n", + " go.Scatter(\n", + " x=angles,\n", + " y=values + errors,\n", + " mode='lines',\n", + " line=dict(width=0),\n", + " fill='tonexty',\n", + " fillcolor='rgba(99, 110, 250, 0.25)',\n", + " name='± 1 error',\n", + " hoverinfo='skip',\n", + " )\n", + ")\n", + "\n", + "fig.add_trace(\n", + " go.Scatter(\n", + " x=angles,\n", + " y=values,\n", + " mode='lines',\n", + " name='d',\n", + " line=dict(color='rgb(99, 110, 250)'),\n", + " customdata=errors,\n", + " hovertemplate='theta = %{x:.1f} deg
d = %{y:.3f} ± %{customdata:.3f} Å',\n", + " )\n", + ")\n", + "fig.update_layout(\n", + " xaxis_title='theta / deg',\n", + " yaxis_title='d / Å',\n", + " margin=dict(l=60, r=20, t=30, b=50),\n", + " legend=dict(x=0.6, y=0.95),\n", + ")\n", + "show_figure(fig)" + ] + }, + { + "cell_type": "markdown", + "id": "a26d6751", + "metadata": {}, + "source": [ + "The error of the sine itself is $|\\cos\\theta|\\,\\sigma_\\theta$. Here it is compared with a Monte Carlo\n", + "sample of the same sine, for an uncertainty of 0.3 rad:" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "853de0a9", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:59.853280Z", + "iopub.status.busy": "2026-10-13T16:18:59.853151Z", + "iopub.status.idle": "2026-10-13T16:18:59.972713Z", + "shell.execute_reply": "2026-10-13T16:18:59.972224Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "sigma = 0.3\n", + "angles = np.linspace(0.0, 2 * np.pi, 80)\n", + "\n", + "first_order = np.array([\n", + " es.math.sin(Parameter(name='angle', value=value, unit='rad', variance=sigma**2)).error\n", + " for value in angles\n", + "])\n", + "\n", + "rng = np.random.default_rng(seed=1)\n", + "samples = rng.normal(angles[:, None], sigma, size=(len(angles), 20_000))\n", + "monte_carlo = np.sin(samples).std(axis=1)\n", + "\n", + "fig = go.Figure()\n", + "fig.add_trace(go.Scatter(x=angles, y=first_order, mode='lines', name='first order (es.math)'))\n", + "fig.add_trace(\n", + " go.Scatter(x=angles, y=monte_carlo, mode='lines', line=dict(dash='dash'), name='Monte Carlo')\n", + ")\n", + "fig.update_layout(\n", + " xaxis_title='angle / rad',\n", + " yaxis_title='error of sin(angle)',\n", + " margin=dict(l=60, r=20, t=60, b=50),\n", + " legend=dict(orientation='h', x=0.5, xanchor='center', y=1.1),\n", + ")\n", + "show_figure(fig, height=380)" + ] + }, + { + "cell_type": "markdown", + "id": "36aa99bd", + "metadata": {}, + "source": [ + "## Good to know\n", + "\n", + "The two curves agree to within a few per cent away from the extremes. At $\\pi/2$ and $3\\pi/2$ the sine is\n", + "flat, its derivative is 0, and the first-order error drops to (almost) zero, while the Monte Carlo error\n", + "stays near $\\sigma^2/\\sqrt{2}\\approx 0.06$:" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "id": "aa92fe31", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:59.974087Z", + "iopub.status.busy": "2026-10-13T16:18:59.973905Z", + "iopub.status.idle": "2026-10-13T16:18:59.981410Z", + "shell.execute_reply": "2026-10-13T16:18:59.980782Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "1.8369701987210297e-17\n", + "0.06105801493643515\n" + ] + } + ], + "source": [ + "top = Parameter(name='top', value=np.pi / 2, unit='rad', variance=0.3**2)\n", + "print(es.math.sin(top).error)\n", + "\n", + "rng = np.random.default_rng(seed=2)\n", + "print(np.sin(rng.normal(np.pi / 2, 0.3, 200_000)).std())" + ] + }, + { + "cell_type": "markdown", + "id": "417b11b5", + "metadata": {}, + "source": [ + "Close to a pole of the tangent the first-order variance means nothing at all. An angle within one standard\n", + "deviation of a pole is refused:" + ] + }, + { + "cell_type": "code", + "execution_count": 17, + "id": "2e1c67f8", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T16:18:59.982624Z", + "iopub.status.busy": "2026-10-13T16:18:59.982474Z", + "iopub.status.idle": "2026-10-13T16:18:59.985609Z", + "shell.execute_reply": "2026-10-13T16:18:59.985202Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The value of 'v' (1.5707963267948966 rad) is within 0.1 rad of a tan asymptote (pi/2 + n*pi); the propagated variance would explode.\n" + ] + } + ], + "source": [ + "try:\n", + " es.math.tan(Parameter(name='v', value=np.pi / 2, unit='rad', variance=0.1**2))\n", + "except ValueError as error:\n", + " print(error)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.6" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/docs/tutorials/fitting-intro.ipynb b/docs/docs/tutorials/fitting-intro.ipynb new file mode 100644 index 00000000..536b7b9e --- /dev/null +++ b/docs/docs/tutorials/fitting-intro.ipynb @@ -0,0 +1,396 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "494c67e9", + "metadata": {}, + "source": [ + "# Introduction to fitting: from fitted parameters to derived quantities" + ] + }, + { + "cell_type": "markdown", + "id": "adb98df3", + "metadata": {}, + "source": [ + "A fit returns **parameters with an uncertainty**: the same `Parameter` objects used in the basics tutorials\n", + "on maths. This notebook fits a straight line to simulated data and then computes quantities that derive from\n", + "the fitted parameters, with their uncertainty, using ordinary arithmetic and `es.math`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "11f880eb", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import plotly.graph_objects as go\n", + "\n", + "import easyscience as es\n", + "from easyscience import DescriptorNumber\n", + "from easyscience import Fitter\n", + "from easyscience import Parameter\n", + "from easyscience.base_classes import ModelBase" + ] + }, + { + "cell_type": "markdown", + "id": "77df0073", + "metadata": {}, + "source": [ + "The function `show` prints a number as value, error and unit, and, for a `Parameter` with finite bounds, the bounds." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6f0e48d9", + "metadata": {}, + "outputs": [], + "source": [ + "def show(number):\n", + " \"\"\"One line with value, error, unit and, for a Parameter, the finite bounds.\"\"\"\n", + " text = f'{number.value:.4f} ± {number.error or 0.0:.4f} {number.unit}'\n", + " if hasattr(number, 'min') and (np.isfinite(number.min) or np.isfinite(number.max)):\n", + " text += f' in [{number.min:.4f}, {number.max:.4f}]'\n", + " return text" + ] + }, + { + "cell_type": "markdown", + "id": "6031ff4d", + "metadata": {}, + "source": [ + "## The model\n", + "\n", + "A model is a class that holds its fit parameters as `Parameter`s and is callable with the independent\n", + "variable. Inside the call, use plain NumPy on the `.value` of each parameter: the fitting engine calls it\n", + "many times with plain numbers. The `es.math` functions belong **after** the fit, on the fitted parameters." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c925b573", + "metadata": {}, + "outputs": [], + "source": [ + "class StraightLine(ModelBase):\n", + " \"\"\"A straight line, y = slope * x + intercept.\"\"\"\n", + "\n", + " def __init__(self, slope: float, intercept: float):\n", + " super().__init__()\n", + " self._slope = Parameter(name='slope', value=slope)\n", + " self._intercept = Parameter(name='intercept', value=intercept)\n", + "\n", + " @property\n", + " def slope(self) -> Parameter:\n", + " return self._slope\n", + "\n", + " @property\n", + " def intercept(self) -> Parameter:\n", + " return self._intercept\n", + "\n", + " def __call__(self, x):\n", + " return self._slope.value * x + self._intercept.value" + ] + }, + { + "cell_type": "markdown", + "id": "8a2d1f76", + "metadata": {}, + "source": [ + "## Simulated data and the fit\n", + "\n", + "The data follow $y = 0.6\\,x + 1.0$ with Gaussian noise of 0.3. The weights are $1/\\sigma$ of each point:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "dd686157", + "metadata": {}, + "outputs": [], + "source": [ + "rng = np.random.default_rng(seed=20261013)\n", + "noise = 0.3\n", + "x = np.linspace(0.0, 10.0, 40)\n", + "y = 0.6 * x + 1.0 + rng.normal(scale=noise, size=x.size)\n", + "weights = 1.0 / np.full_like(x, noise)" + ] + }, + { + "cell_type": "markdown", + "id": "31d84416", + "metadata": {}, + "source": [ + "The fit starts from a poor guess and finds slope and intercept. Afterwards each parameter carries its\n", + "uncertainty:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "560f4220", + "metadata": {}, + "outputs": [], + "source": [ + "model = StraightLine(slope=0.1, intercept=0.0)\n", + "fitter = Fitter(model, model)\n", + "result = fitter.fit(x, y, weights=weights)\n", + "\n", + "print(show(model.slope))\n", + "print(show(model.intercept))" + ] + }, + { + "cell_type": "markdown", + "id": "ee60decc", + "metadata": {}, + "source": [ + "The chart shows the data and the fitted line. It is interactive: hover over the points to read the values.\n", + "It loads Plotly from the internet, so it needs a connection. The charts are shown with a small helper,\n", + "`show_figure(fig)`, which is defined in a hidden cell; in your own notebook `fig.show()` does the same." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5cbeaaac", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "import base64\n", + "\n", + "from IPython.display import IFrame\n", + "from IPython.display import display\n", + "\n", + "\n", + "def show_figure(fig: go.Figure, height: int = 420) -> None:\n", + " \"\"\"Show a Plotly figure as an embedded iframe, so that it renders in the docs.\"\"\"\n", + " page = (\n", + " go\n", + " .Figure(fig)\n", + " .update_layout(template='plotly_white')\n", + " .to_html(\n", + " full_html=True,\n", + " include_plotlyjs='cdn',\n", + " default_width='100%',\n", + " default_height=f'{height - 20}px',\n", + " )\n", + " )\n", + " encoded = base64.b64encode(page.encode('utf-8')).decode('ascii')\n", + " display(IFrame(src=f'data:text/html;base64,{encoded}', width='100%', height=height))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "386d2275", + "metadata": {}, + "outputs": [], + "source": [ + "fig = go.Figure()\n", + "fig.add_trace(\n", + " go.Scatter(\n", + " x=x,\n", + " y=y,\n", + " mode='markers',\n", + " name='data',\n", + " error_y=dict(type='constant', value=noise),\n", + " )\n", + ")\n", + "fig.add_trace(go.Scatter(x=x, y=model(x), mode='lines', name='fit'))\n", + "fig.update_layout(\n", + " xaxis_title='x',\n", + " yaxis_title='y',\n", + " margin=dict(l=60, r=20, t=30, b=50),\n", + " legend=dict(x=0.05, y=0.95),\n", + ")\n", + "show_figure(fig)" + ] + }, + { + "cell_type": "markdown", + "id": "153a2d53", + "metadata": {}, + "source": [ + "## Quantities that derive from the fit\n", + "\n", + "The fitted parameters can be used like any other number with an uncertainty. A line through the origin with\n", + "the slope would reach $y = 6$ at $x = 10$:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "12d71532", + "metadata": {}, + "outputs": [], + "source": [ + "reach = model.slope * 10\n", + "print(show(reach))" + ] + }, + { + "cell_type": "markdown", + "id": "f4ee818a", + "metadata": {}, + "source": [ + "The same works with the functions of `es.math`. The angle of the line with the $x$ axis is the arctangent of\n", + "the slope:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3fa3d4e2", + "metadata": {}, + "outputs": [], + "source": [ + "print(show(es.math.arctan(model.slope, unit='deg')))" + ] + }, + { + "cell_type": "markdown", + "id": "0b8aa92c", + "metadata": {}, + "source": [ + "**One caution.** The slope and the intercept of one fit are correlated, but arithmetic treats numbers as\n", + "independent. A quantity that depends on **both**, such as the $x$ where the line crosses zero,\n", + "$-\\text{intercept}/\\text{slope}$, therefore has an error that cannot be fully trusted. A quantity that depends\n", + "on one parameter, as in the exercises below, is fine." + ] + }, + { + "cell_type": "markdown", + "id": "5151d69d", + "metadata": {}, + "source": [ + "### Exercise 1: Angle of the line\n", + "\n", + "Compute the angle of the fitted line with the $x$ axis, in **radians**, with its uncertainty." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f8fdd3a5", + "metadata": {}, + "outputs": [], + "source": [ + "slope = model.slope" + ] + }, + { + "cell_type": "markdown", + "id": "7ba4887b", + "metadata": {}, + "source": [ + "
\n", + "Hint 1\n", + "\n", + "The slope is the tangent of the angle.\n", + "\n", + "
\n", + "\n", + "
\n", + "Hint 2\n", + "\n", + "Use `es.math.arctan`; radians are the default.\n", + "\n", + "
\n", + "\n", + "
\n", + "Solution\n", + "\n", + "```python\n", + "angle = es.math.arctan(slope)\n", + "print(show(angle))\n", + "```\n", + "\n", + "Expected result:\n", + "\n", + "```text\n", + "0.5411 ± 0.0100 rad in [-1.5708, 1.5708]\n", + "```\n", + "\n", + "
" + ] + }, + { + "cell_type": "markdown", + "id": "377d6c64", + "metadata": {}, + "source": [ + "### Exercise 2: Inverse slope\n", + "\n", + "Compute $1/\\text{slope}$, the change of $x$ for a change of 1 in $y$, with its uncertainty." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8d748939", + "metadata": {}, + "outputs": [], + "source": [ + "slope = model.slope" + ] + }, + { + "cell_type": "markdown", + "id": "dd44ba8b", + "metadata": {}, + "source": [ + "
\n", + "Hint 1\n", + "\n", + "A plain number can be divided by a `Parameter`.\n", + "\n", + "
\n", + "\n", + "
\n", + "Hint 2\n", + "\n", + "Write `1 / slope`.\n", + "\n", + "
\n", + "\n", + "
\n", + "Solution\n", + "\n", + "```python\n", + "inverse = 1 / slope\n", + "print(show(inverse))\n", + "```\n", + "\n", + "Expected result:\n", + "\n", + "```text\n", + "1.6641 ± 0.0377 dimensionless\n", + "```\n", + "\n", + "
" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} \ No newline at end of file diff --git a/docs/docs/tutorials/index.md b/docs/docs/tutorials/index.md index a36da47c..a3f07612 100644 --- a/docs/docs/tutorials/index.md +++ b/docs/docs/tutorials/index.md @@ -17,13 +17,26 @@ The tutorials are organized into the following categories: ## Basics -- [Using maths](using-math.ipynb) – A series of short examples showing - how to do maths with EasyScience parameters: the basic operations, the - functions of `es.math`, and how units, uncertainties and bounds are - propagated. +Short examples of how to do maths with EasyScience parameters, with +exercises. Units, uncertainties and bounds are propagated for you. + +- [Arithmetic](basics/maths-arithmetic.ipynb) – Addition, subtraction, + multiplication, division and powers of numbers with units, + uncertainties and bounds. +- [Exponential and logarithm](basics/maths-exp-log.ipynb) – `exp`, + `log`, `log10`, `sqrt` and the error functions `erf` and `erfc`. +- [Trigonometric functions](basics/maths-trigonometric.ipynb) – `sin`, + `cos`, `tan` and `sinc`, with Bragg's law as the running example. +- [Inverse trigonometric functions](basics/maths-inverse-trigonometric.ipynb) + – `arcsin`, `arccos`, `arctan` and `arctan2`, and their domains. +- [Hyperbolic functions](basics/maths-hyperbolic.ipynb) – `sinh`, + `cosh`, `tanh` and their inverses. ## Workshops & Schools +- [Fitting introduction](fitting-intro.ipynb) – A short introduction to + fitting: fit a straight line, then compute quantities that derive from + the fitted parameters, with their uncertainty. - [Fitting QENS](fitting-qens.ipynb) – A tutorial demonstrating how to fit a quasielastic neutron scattering (QENS) data using EasyScience framework. diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 6d019240..d7e601b0 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -195,7 +195,14 @@ nav: - Tutorials: - Tutorials: tutorials/index.md - Basics: - - Using maths: tutorials/using-math.ipynb + - Maths: + - Arithmetic: tutorials/basics/maths-arithmetic.ipynb + - Exponential and logarithm: tutorials/basics/maths-exp-log.ipynb + - Trigonometric functions: tutorials/basics/maths-trigonometric.ipynb + - Inverse trigonometric functions: + tutorials/basics/maths-inverse-trigonometric.ipynb + - Hyperbolic functions: tutorials/basics/maths-hyperbolic.ipynb + - Fitting introduction: tutorials/fitting-intro.ipynb - Bayesian analysis: tutorials/fitting-bayesian.ipynb - Progress Callback: tutorials/progress-callback.ipynb - Workshops & Schools: From b7df48d19ea913725bb1e072798f4955e05937e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mark=20S=C3=B8rensen?= Date: Tue, 13 Oct 2026 18:29:59 +0200 Subject: [PATCH 03/25] chore(math): delete old tutorial notebook --- docs/docs/tutorials/using-math.ipynb | 1656 -------------------------- 1 file changed, 1656 deletions(-) delete mode 100644 docs/docs/tutorials/using-math.ipynb diff --git a/docs/docs/tutorials/using-math.ipynb b/docs/docs/tutorials/using-math.ipynb deleted file mode 100644 index d7277caf..00000000 --- a/docs/docs/tutorials/using-math.ipynb +++ /dev/null @@ -1,1656 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "id": "de3c4488", - "metadata": {}, - "source": [ - "# Using Maths in EasyScience" - ] - }, - { - "cell_type": "markdown", - "id": "d2d57035", - "metadata": {}, - "source": [ - "EasyScience objects are numbers that know their **unit**, their **uncertainty** and, for a\n", - "`Parameter`, the **bounds** a fit may explore. This tutorial is a series of short examples of how maths\n", - "with them works, and of how it is expected to behave:\n", - "\n", - "* the fundamental operations `+`, `-`, `*`, `/`, `**`, negation and `abs`;\n", - "* the functions of `easyscience.math` (`es.math`): exponential, logarithm, trigonometric, hyperbolic\n", - " and error functions.\n", - "\n", - "The `es.math` examples are themed on a small diffraction experiment (Bragg's law, a Debye-Waller factor,\n", - "a slit), but each one stands alone. Every result is a **new** object. Its value and unit come from the\n", - "calculation, its variance is propagated from the variances of the inputs, and its bounds are the exact\n", - "range the result can reach while the inputs stay inside their bounds.\n", - "\n", - "No data and no fit are needed until the last section, where the same functions are applied to the result\n", - "of a fit." - ] - }, - { - "cell_type": "code", - "execution_count": 1, - "id": "f7e21632", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T14:18:15.102642Z", - "iopub.status.busy": "2026-10-13T14:18:15.102518Z", - "iopub.status.idle": "2026-10-13T14:18:16.087160Z", - "shell.execute_reply": "2026-10-13T14:18:16.086523Z" - } - }, - "outputs": [], - "source": [ - "import numpy as np\n", - "import plotly.graph_objects as go\n", - "\n", - "import easyscience as es\n", - "from easyscience import DescriptorNumber\n", - "from easyscience import Parameter" - ] - }, - { - "cell_type": "markdown", - "id": "b5e9978c", - "metadata": {}, - "source": [ - "## Numbers with an uncertainty\n", - "\n", - "A `Parameter` has a value, a unit, an uncertainty, and bounds. Here is a length of 2 m with an\n", - "uncertainty of 0.2 m, which a fit may vary between 0 and 10 m:" - ] - }, - { - "cell_type": "code", - "execution_count": 2, - "id": "965b4011", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T14:18:16.088955Z", - "iopub.status.busy": "2026-10-13T14:18:16.088731Z", - "iopub.status.idle": "2026-10-13T14:18:16.099484Z", - "shell.execute_reply": "2026-10-13T14:18:16.099078Z" - } - }, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 2, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "length = Parameter(name='length', value=2.0, unit='m', variance=0.04, min=0.0, max=10.0)\n", - "length" - ] - }, - { - "cell_type": "markdown", - "id": "121339f0", - "metadata": {}, - "source": [ - "The uncertainty is stored as a **variance**, and `error` is its square root:" - ] - }, - { - "cell_type": "code", - "execution_count": 3, - "id": "6678f486", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T14:18:16.100758Z", - "iopub.status.busy": "2026-10-13T14:18:16.100635Z", - "iopub.status.idle": "2026-10-13T14:18:16.102985Z", - "shell.execute_reply": "2026-10-13T14:18:16.102548Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "2.0 m\n", - "0.04 0.2\n", - "0.0 10.0\n" - ] - } - ], - "source": [ - "print(length.value, length.unit)\n", - "print(length.variance, length.error)\n", - "print(length.min, length.max)" - ] - }, - { - "cell_type": "markdown", - "id": "d135dc3f", - "metadata": {}, - "source": [ - "A `DescriptorNumber` is the same without the fitting bounds. It is the right choice for a number that\n", - "is known but is never fitted, such as a measured constant:" - ] - }, - { - "cell_type": "code", - "execution_count": 4, - "id": "5f372ec5", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T14:18:16.103940Z", - "iopub.status.busy": "2026-10-13T14:18:16.103830Z", - "iopub.status.idle": "2026-10-13T14:18:16.106397Z", - "shell.execute_reply": "2026-10-13T14:18:16.106029Z" - } - }, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "scale = DescriptorNumber(name='scale', value=3.0, variance=0.01)\n", - "scale" - ] - }, - { - "cell_type": "markdown", - "id": "4d09c034", - "metadata": {}, - "source": [ - "The results of `es.math` have many digits in their bounds, so we define a helper that prints one\n", - "tidy line per number: value, error, unit and, for a `Parameter`, the bounds." - ] - }, - { - "cell_type": "code", - "execution_count": 5, - "id": "d7494dc0", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T14:18:16.107477Z", - "iopub.status.busy": "2026-10-13T14:18:16.107366Z", - "iopub.status.idle": "2026-10-13T14:18:16.110058Z", - "shell.execute_reply": "2026-10-13T14:18:16.109594Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "2.0000 ± 0.2000 m in [0.0000, 10.0000]\n", - "3.0000 ± 0.1000 dimensionless\n" - ] - } - ], - "source": [ - "def show(number):\n", - " \"\"\"One line with value, error, unit and, for a Parameter, the bounds.\"\"\"\n", - " text = f'{number.value:.4f} ± {number.error:.4f} {number.unit}'\n", - " if hasattr(number, 'min'):\n", - " text += f' in [{number.min:.4f}, {number.max:.4f}]'\n", - " return text\n", - "\n", - "\n", - "print(show(length))\n", - "print(show(scale))" - ] - }, - { - "cell_type": "markdown", - "id": "d6d905ba", - "metadata": {}, - "source": [ - "## Basic arithmetic\n", - "\n", - "The operators work between `Parameter`s, `DescriptorNumber`s and plain numbers. The result is a new\n", - "object: a `Parameter` (with bounds) if any operand is a `Parameter`, and a `DescriptorNumber`\n", - "otherwise.\n", - "\n", - "For independent inputs, the variance of a result is propagated to first order,\n", - "\n", - "$$\n", - "\\sigma_f^2 = \\sum_i \\left(\\frac{\\partial f}{\\partial x_i}\\right)^2 \\sigma_i^2 .\n", - "$$" - ] - }, - { - "cell_type": "markdown", - "id": "e62e0c6a", - "metadata": {}, - "source": [ - "### Addition and subtraction\n", - "\n", - "Only numbers with the same physical dimension can be added. If the units differ, the second operand\n", - "is converted to the unit of the first, so the result is in metres here:" - ] - }, - { - "cell_type": "code", - "execution_count": 6, - "id": "e173f6bd", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T14:18:16.111181Z", - "iopub.status.busy": "2026-10-13T14:18:16.111018Z", - "iopub.status.idle": "2026-10-13T14:18:16.114238Z", - "shell.execute_reply": "2026-10-13T14:18:16.113842Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "2.5000 ± 0.2010 m in [0.1000, 11.0000]\n" - ] - } - ], - "source": [ - "width = Parameter(name='width', value=50.0, unit='cm', variance=4.0, min=10.0, max=100.0)\n", - "total = length + width\n", - "print(show(total))" - ] - }, - { - "cell_type": "markdown", - "id": "d689bce5", - "metadata": {}, - "source": [ - "The variances add, $0.2^2 + 0.02^2 = 0.0404\\ \\text{m}^2$, and so do the bounds:\n", - "$[0 + 0.1,\\ 10 + 1.0]$ m. Subtraction combines the bounds the other way round: the lowest possible\n", - "difference is the minimum of the first minus the maximum of the second." - ] - }, - { - "cell_type": "code", - "execution_count": 7, - "id": "c80005d6", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T14:18:16.115252Z", - "iopub.status.busy": "2026-10-13T14:18:16.115141Z", - "iopub.status.idle": "2026-10-13T14:18:16.117810Z", - "shell.execute_reply": "2026-10-13T14:18:16.117403Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "1.5000 ± 0.2010 m in [-1.0000, 9.9000]\n" - ] - } - ], - "source": [ - "difference = length - width\n", - "print(show(difference))" - ] - }, - { - "cell_type": "markdown", - "id": "77274922", - "metadata": {}, - "source": [ - "A plain number can only be added to a dimensionless value. Adding one to a length is an error:" - ] - }, - { - "cell_type": "code", - "execution_count": 8, - "id": "0cc2c8a1", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T14:18:16.118903Z", - "iopub.status.busy": "2026-10-13T14:18:16.118795Z", - "iopub.status.idle": "2026-10-13T14:18:16.120937Z", - "shell.execute_reply": "2026-10-13T14:18:16.120511Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "UnitError: Numbers can only be added to dimensionless values\n" - ] - } - ], - "source": [ - "try:\n", - " length + 1.0\n", - "except Exception as error:\n", - " print(f'{type(error).__name__}: {error}')" - ] - }, - { - "cell_type": "markdown", - "id": "e8b545fe", - "metadata": {}, - "source": [ - "### Multiplication and division\n", - "\n", - "Units multiply and divide. The area of a rectangle, with the sides in the same unit:" - ] - }, - { - "cell_type": "code", - "execution_count": 9, - "id": "b22e7b34", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T14:18:16.121926Z", - "iopub.status.busy": "2026-10-13T14:18:16.121817Z", - "iopub.status.idle": "2026-10-13T14:18:16.124727Z", - "shell.execute_reply": "2026-10-13T14:18:16.124348Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "3.0000 ± 0.3606 m^2 in [0.0000, 30.0000]\n" - ] - } - ], - "source": [ - "height = Parameter(name='height', value=1.5, unit='m', variance=0.01, min=1.0, max=3.0)\n", - "area = length * height\n", - "print(show(area))" - ] - }, - { - "cell_type": "markdown", - "id": "1a2180bb", - "metadata": {}, - "source": [ - "The bounds of a product are the lowest and highest of the four products of the bounds. A speed is a\n", - "length over a time:" - ] - }, - { - "cell_type": "code", - "execution_count": 10, - "id": "f36e517b", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T14:18:16.125869Z", - "iopub.status.busy": "2026-10-13T14:18:16.125760Z", - "iopub.status.idle": "2026-10-13T14:18:16.128626Z", - "shell.execute_reply": "2026-10-13T14:18:16.128255Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "0.5000 ± 0.0590 m/s in [0.0000, 5.0000]\n" - ] - } - ], - "source": [ - "time = Parameter(name='time', value=4.0, unit='s', variance=0.0625, min=2.0, max=8.0)\n", - "speed = length / time\n", - "print(show(speed))" - ] - }, - { - "cell_type": "markdown", - "id": "2b0102bb", - "metadata": {}, - "source": [ - "Multiplying a `DescriptorNumber` by a plain number gives a `DescriptorNumber`, so there are no\n", - "bounds:" - ] - }, - { - "cell_type": "code", - "execution_count": 11, - "id": "4f6a4b37", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T14:18:16.129759Z", - "iopub.status.busy": "2026-10-13T14:18:16.129650Z", - "iopub.status.idle": "2026-10-13T14:18:16.132063Z", - "shell.execute_reply": "2026-10-13T14:18:16.131660Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "DescriptorNumber 6.0000 ± 0.2000 dimensionless\n" - ] - } - ], - "source": [ - "doubled = scale * 2\n", - "print(type(doubled).__name__, show(doubled))" - ] - }, - { - "cell_type": "markdown", - "id": "67901d8b", - "metadata": {}, - "source": [ - "### Powers, negation and absolute value\n", - "\n", - "The exponent of a power is a plain number. Negation flips the bounds, and `abs` folds them:" - ] - }, - { - "cell_type": "code", - "execution_count": 12, - "id": "f8b422ea", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T14:18:16.133199Z", - "iopub.status.busy": "2026-10-13T14:18:16.133086Z", - "iopub.status.idle": "2026-10-13T14:18:16.136454Z", - "shell.execute_reply": "2026-10-13T14:18:16.136037Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "4.0000 ± 0.8000 m^2 in [0.0000, 100.0000]\n", - "-2.0000 ± 0.2000 m in [-10.0000, -0.0000]\n", - "2.0000 ± 0.2000 m in [0.0000, 10.0000]\n" - ] - } - ], - "source": [ - "print(show(length**2))\n", - "print(show(-length))\n", - "print(show(abs(-length)))" - ] - }, - { - "cell_type": "markdown", - "id": "3902407f", - "metadata": {}, - "source": [ - "## Maths functions with es.math\n", - "\n", - "`es.math` provides the functions that the operators do not cover. Each one takes a `Parameter` or a\n", - "`DescriptorNumber` and returns a new one with the value, unit, variance and bounds worked out:\n", - "\n", - "| family | functions |\n", - "|---|---|\n", - "| exponential and logarithm | `exp`, `log`, `log10`, `sqrt` |\n", - "| trigonometric | `sin`, `cos`, `tan`, `sinc` |\n", - "| inverse trigonometric | `arcsin`, `arccos`, `arctan`, `arctan2` (aliases `asin`, `acos`, `atan`, `atan2`) |\n", - "| hyperbolic | `sinh`, `cosh`, `tanh`, `arcsinh`, `arccosh`, `arctanh` (aliases `asinh`, `acosh`, `atanh`) |\n", - "| error function | `erf`, `erfc` |\n", - "\n", - "They are used as `es.math.sin(angle)`. The value and the variance come from Scipp, which also checks\n", - "the units (for `erf`, `erfc` and `arctan2`, which Scipp cannot propagate a variance through, `es.math`\n", - "supplies the derivative itself); the bounds are the exact range of the function over the bounds of the\n", - "input." - ] - }, - { - "cell_type": "markdown", - "id": "79db2ff9", - "metadata": {}, - "source": [ - "### Exponential, logarithm and square root\n", - "\n", - "These functions take a dimensionless number, except `sqrt`, which also takes a number whose unit can be\n", - "halved. The Debye-Waller factor of a crystal, $\\exp\\!\\left(-q^2\\langle u^2\\rangle/3\\right)$, combines a\n", - "scattering vector $q$ and a mean-square displacement $\\langle u^2\\rangle$; the units cancel, so `exp`\n", - "accepts it:" - ] - }, - { - "cell_type": "code", - "execution_count": 13, - "id": "21d87989", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T14:18:16.137426Z", - "iopub.status.busy": "2026-10-13T14:18:16.137316Z", - "iopub.status.idle": "2026-10-13T14:18:16.142263Z", - "shell.execute_reply": "2026-10-13T14:18:16.141721Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "0.9868 ± 0.0026 dimensionless in [0.7659, 1.0000]\n" - ] - } - ], - "source": [ - "q_par = Parameter(name='q', value=2.0, unit='1/angstrom', variance=0.02**2, min=0.5, max=4.0)\n", - "u2 = Parameter(name='u2', value=0.01, unit='angstrom**2', variance=0.002**2, min=0.0, max=0.05)\n", - "\n", - "debye_waller = es.math.exp(-(q_par**2) * u2 / 3)\n", - "print(show(debye_waller))" - ] - }, - { - "cell_type": "markdown", - "id": "0a804bf3", - "metadata": {}, - "source": [ - "The bounds are the exact range of the function over the bounds of the inputs, here $[0.77,\\ 1]$. A\n", - "relative intensity in decibels uses `log10`, and the length of a vector from two components uses `sqrt`:" - ] - }, - { - "cell_type": "code", - "execution_count": 14, - "id": "6ac09683", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T14:18:16.143257Z", - "iopub.status.busy": "2026-10-13T14:18:16.143153Z", - "iopub.status.idle": "2026-10-13T14:18:16.149084Z", - "shell.execute_reply": "2026-10-13T14:18:16.148665Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "-6.0206 ± 0.1737 dimensionless in [-10.0000, -3.9794]\n", - "5.0000 ± 0.1000 dimensionless in [3.6056, 6.4031]\n" - ] - } - ], - "source": [ - "intensity = Parameter(name='intensity', value=250.0, variance=100.0, min=100.0, max=400.0)\n", - "reference = DescriptorNumber(name='reference', value=1000.0, variance=0.0)\n", - "decibel = 10 * es.math.log10(intensity / reference)\n", - "print(show(decibel))\n", - "\n", - "a = Parameter(name='a', value=3.0, variance=0.01, min=2.0, max=4.0)\n", - "b = Parameter(name='b', value=4.0, variance=0.01, min=3.0, max=5.0)\n", - "amplitude = es.math.sqrt(a**2 + b**2)\n", - "print(show(amplitude))" - ] - }, - { - "cell_type": "markdown", - "id": "279c93ee", - "metadata": {}, - "source": [ - "The square root of an area is a length:" - ] - }, - { - "cell_type": "code", - "execution_count": 15, - "id": "6a0c176a", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T14:18:16.150254Z", - "iopub.status.busy": "2026-10-13T14:18:16.150139Z", - "iopub.status.idle": "2026-10-13T14:18:16.152655Z", - "shell.execute_reply": "2026-10-13T14:18:16.152258Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "1.7321 ± 0.1041 m in [0.0000, 5.4772]\n" - ] - } - ], - "source": [ - "print(show(es.math.sqrt(area)))" - ] - }, - { - "cell_type": "markdown", - "id": "3b7dd466", - "metadata": {}, - "source": [ - "**Expected behaviour: a round trip returns where it started.** The derivative of $\\exp(\\log x)$ is 1,\n", - "so the value *and* the uncertainty come back unchanged:" - ] - }, - { - "cell_type": "code", - "execution_count": 16, - "id": "3b11d66d", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T14:18:16.153785Z", - "iopub.status.busy": "2026-10-13T14:18:16.153670Z", - "iopub.status.idle": "2026-10-13T14:18:16.157075Z", - "shell.execute_reply": "2026-10-13T14:18:16.156665Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "1.2000 ± 0.0500 dimensionless in [0.5000, 3.0000]\n", - "True\n" - ] - } - ], - "source": [ - "x = Parameter(name='x', value=1.2, variance=0.0025, min=0.5, max=3.0)\n", - "round_trip = es.math.exp(es.math.log(x))\n", - "print(show(round_trip))\n", - "print(np.isclose(round_trip.variance, x.variance))" - ] - }, - { - "cell_type": "markdown", - "id": "db19bead", - "metadata": {}, - "source": [ - "### Trigonometric functions\n", - "\n", - "Bragg's law relates the spacing $d$ of the lattice planes to the scattering angle $\\theta$ and the\n", - "wavelength $\\lambda$, and the scattering vector is $q$:\n", - "\n", - "$$\n", - "d = \\frac{\\lambda}{2\\sin\\theta}, \\qquad q = \\frac{4\\pi\\sin\\theta}{\\lambda}.\n", - "$$\n", - "\n", - "The argument of `sin`, `cos` and `tan` must have an angle unit, `rad` or `deg`; the result is\n", - "dimensionless. The uncertainty is converted correctly: $\\sin 15^\\circ$ has an error of\n", - "$\\cos 15^\\circ \\times \\sigma_\\theta$ with $\\sigma_\\theta$ in radians." - ] - }, - { - "cell_type": "code", - "execution_count": 17, - "id": "371f8719", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T14:18:16.158123Z", - "iopub.status.busy": "2026-10-13T14:18:16.158008Z", - "iopub.status.idle": "2026-10-13T14:18:16.161571Z", - "shell.execute_reply": "2026-10-13T14:18:16.161111Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "0.2588 ± 0.0017 dimensionless in [0.0872, 0.8660]\n" - ] - } - ], - "source": [ - "theta = Parameter(name='theta', value=15.0, unit='deg', variance=0.1**2, min=5.0, max=60.0)\n", - "wavelength = DescriptorNumber(name='wavelength', value=1.5406, unit='angstrom', variance=0.0005**2)\n", - "\n", - "s = es.math.sin(theta)\n", - "print(show(s))" - ] - }, - { - "cell_type": "markdown", - "id": "e0d8ecf5", - "metadata": {}, - "source": [ - "The wavelength is a measured constant, so it is a `DescriptorNumber` (no bounds); the angle may be\n", - "fitted, so it is a `Parameter`. Ordinary arithmetic then gives $d$ and $q$, with their uncertainties and\n", - "bounds:" - ] - }, - { - "cell_type": "code", - "execution_count": 18, - "id": "daa16ff1", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T14:18:16.162695Z", - "iopub.status.busy": "2026-10-13T14:18:16.162569Z", - "iopub.status.idle": "2026-10-13T14:18:16.166680Z", - "shell.execute_reply": "2026-10-13T14:18:16.166245Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "2.9762 ± 0.0194 Å in [0.8895, 8.8382]\n", - "2.1111 ± 0.0138 1/Å in [0.7109, 7.0640]\n" - ] - } - ], - "source": [ - "d = wavelength / (2 * s)\n", - "q = 4 * np.pi * s / wavelength\n", - "print(show(d))\n", - "print(show(q))" - ] - }, - { - "cell_type": "code", - "execution_count": 19, - "id": "a4221a59", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T14:18:16.167751Z", - "iopub.status.busy": "2026-10-13T14:18:16.167629Z", - "iopub.status.idle": "2026-10-13T14:18:16.170808Z", - "shell.execute_reply": "2026-10-13T14:18:16.170353Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "cos 0.9659 ± 0.0005 dimensionless in [0.5000, 0.9962]\n", - "tan 0.2679 ± 0.0019 dimensionless in [0.0875, 1.7321]\n" - ] - } - ], - "source": [ - "for function in (es.math.cos, es.math.tan):\n", - " print(f'{function.__name__:4}', show(function(theta)))" - ] - }, - { - "cell_type": "markdown", - "id": "f15dce19", - "metadata": {}, - "source": [ - "`sinc(x)` is $\\sin(x)/x$, not the normalised $\\sin(\\pi x)/(\\pi x)$ of NumPy. It takes `rad` or a\n", - "dimensionless number. The intensity of single-slit diffraction is proportional to $\\mathrm{sinc}^2$:" - ] - }, - { - "cell_type": "code", - "execution_count": 20, - "id": "bcc25557", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T14:18:16.171735Z", - "iopub.status.busy": "2026-10-13T14:18:16.171613Z", - "iopub.status.idle": "2026-10-13T14:18:16.174980Z", - "shell.execute_reply": "2026-10-13T14:18:16.174591Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "0.6033 ± 0.0268 dimensionless in [0.0022, 0.9194]\n" - ] - } - ], - "source": [ - "x = Parameter(name='x', value=1.2, variance=0.0025, min=0.5, max=3.0)\n", - "print(show(es.math.sinc(x) ** 2))" - ] - }, - { - "cell_type": "markdown", - "id": "255f00b9", - "metadata": {}, - "source": [ - "### Inverse trigonometric functions\n", - "\n", - "The inverse functions return an angle, in radians by default; the `unit` keyword chooses `'deg'`.\n", - "**Expected behaviour:** the Bragg angle recovered from $d$ and $\\lambda$ is the angle we started with:" - ] - }, - { - "cell_type": "code", - "execution_count": 21, - "id": "fe026447", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T14:18:16.176122Z", - "iopub.status.busy": "2026-10-13T14:18:16.176007Z", - "iopub.status.idle": "2026-10-13T14:18:16.180307Z", - "shell.execute_reply": "2026-10-13T14:18:16.179879Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "15.0000 ± 0.1002 deg in [5.0000, 60.0000]\n", - "0.2618 ± 0.0017 rad in [0.0873, 1.0472]\n" - ] - } - ], - "source": [ - "recovered = es.math.arcsin(wavelength / (2 * d), unit='deg')\n", - "print(show(recovered))\n", - "print(show(es.math.arccos(es.math.cos(theta))))" - ] - }, - { - "cell_type": "markdown", - "id": "01da7bd7", - "metadata": {}, - "source": [ - "The value is the one we started with, and the error is almost the same: 0.1002 instead of 0.1000. The\n", - "small excess is explained in *Limits of first-order propagation* below.\n", - "\n", - "`arctan2(y, x)` uses the signs of both arguments to find the quadrant. The phase of a structure factor\n", - "$F = a + \\mathrm{i}\\,b$ is `arctan2(b, a)`; the two arguments must have the same unit (here none):" - ] - }, - { - "cell_type": "code", - "execution_count": 22, - "id": "b36d2d06", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T14:18:16.181355Z", - "iopub.status.busy": "2026-10-13T14:18:16.181242Z", - "iopub.status.idle": "2026-10-13T14:18:16.184143Z", - "shell.execute_reply": "2026-10-13T14:18:16.183661Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "53.1301 ± 1.1459 deg in [36.8699, 68.1986]\n" - ] - } - ], - "source": [ - "phase = es.math.arctan2(b, a, unit='deg')\n", - "print(show(phase))" - ] - }, - { - "cell_type": "markdown", - "id": "84f149c0", - "metadata": {}, - "source": [ - "The shorter names `asin`, `acos`, `atan` and `atan2` are aliases of the same functions:" - ] - }, - { - "cell_type": "code", - "execution_count": 23, - "id": "a8998852", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T14:18:16.185124Z", - "iopub.status.busy": "2026-10-13T14:18:16.184991Z", - "iopub.status.idle": "2026-10-13T14:18:16.187948Z", - "shell.execute_reply": "2026-10-13T14:18:16.187335Z" - } - }, - "outputs": [ - { - "data": { - "text/plain": [ - "True" - ] - }, - "execution_count": 23, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "es.math.asin is es.math.arcsin" - ] - }, - { - "cell_type": "markdown", - "id": "493a177e", - "metadata": {}, - "source": [ - "### Hyperbolic functions\n", - "\n", - "`sinh`, `cosh` and `tanh` and their inverses take a dimensionless number. The reduced magnetisation of a\n", - "two-level paramagnet is $\\tanh$ of the reduced field, and `arctanh` undoes it:" - ] - }, - { - "cell_type": "code", - "execution_count": 24, - "id": "a883e7e5", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T14:18:16.189333Z", - "iopub.status.busy": "2026-10-13T14:18:16.189210Z", - "iopub.status.idle": "2026-10-13T14:18:16.193220Z", - "shell.execute_reply": "2026-10-13T14:18:16.192664Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "0.4621 ± 0.0157 dimensionless in [0.0000, 0.7616]\n", - "0.5000 ± 0.0200 dimensionless in [0.0000, 1.0000]\n" - ] - } - ], - "source": [ - "reduced_field = Parameter(name='reduced_field', value=0.5, variance=0.0004, min=0.0, max=1.0)\n", - "magnetisation = es.math.tanh(reduced_field)\n", - "print(show(magnetisation))\n", - "print(show(es.math.arctanh(magnetisation)))" - ] - }, - { - "cell_type": "markdown", - "id": "15ab6f81", - "metadata": {}, - "source": [ - "The bounds of `cosh` show why the exact range matters: $\\cosh$ has its minimum at 0, so a range that\n", - "includes 0 does not start at the value of $\\cosh$ at the lower bound." - ] - }, - { - "cell_type": "code", - "execution_count": 25, - "id": "a22eec6c", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T14:18:16.194311Z", - "iopub.status.busy": "2026-10-13T14:18:16.194176Z", - "iopub.status.idle": "2026-10-13T14:18:16.198881Z", - "shell.execute_reply": "2026-10-13T14:18:16.198339Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "sinh 0.5211 ± 0.0564 dimensionless in [-1.1752, 3.6269]\n", - "cosh 1.1276 ± 0.0261 dimensionless in [1.0000, 3.7622]\n" - ] - } - ], - "source": [ - "z = Parameter(name='z', value=0.5, variance=0.0025, min=-1.0, max=2.0)\n", - "\n", - "for function in (es.math.sinh, es.math.cosh):\n", - " print(f'{function.__name__:5}', show(function(z)))" - ] - }, - { - "cell_type": "markdown", - "id": "c6954d29", - "metadata": {}, - "source": [ - "### The error function\n", - "\n", - "The fraction of a Gaussian beam of width $\\sigma$ that passes a slit of half-width $a$ is\n", - "$\\mathrm{erf}\\!\\left(a/(\\sigma\\sqrt{2})\\right)$, and `erfc` $= 1 - \\mathrm{erf}$ is the fraction that is\n", - "blocked:" - ] - }, - { - "cell_type": "code", - "execution_count": 26, - "id": "0f65cbd2", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T14:18:16.200104Z", - "iopub.status.busy": "2026-10-13T14:18:16.199924Z", - "iopub.status.idle": "2026-10-13T14:18:16.206249Z", - "shell.execute_reply": "2026-10-13T14:18:16.205755Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "0.8176 ± 0.0263 dimensionless in [0.3829, 0.9973]\n", - "0.1824 ± 0.0263 dimensionless in [0.0027, 0.6171]\n", - "1.0\n" - ] - } - ], - "source": [ - "slit = Parameter(name='slit', value=2.0, unit='mm', variance=0.1**2, min=1.0, max=3.0)\n", - "beam = Parameter(name='beam', value=1.5, unit='mm', variance=0.05**2, min=1.0, max=2.0)\n", - "\n", - "transmitted = es.math.erf(slit / (beam * np.sqrt(2)))\n", - "blocked = es.math.erfc(slit / (beam * np.sqrt(2)))\n", - "print(show(transmitted))\n", - "print(show(blocked))\n", - "print(transmitted.value + blocked.value)" - ] - }, - { - "cell_type": "markdown", - "id": "506e6198", - "metadata": {}, - "source": [ - "### Parameters and DescriptorNumbers as input\n", - "\n", - "The functions follow the same rule as the operators: the result is a `Parameter` if any argument is a\n", - "`Parameter`, and a `DescriptorNumber` otherwise. A `DescriptorNumber` has no bounds, so it counts as a\n", - "single point when the bounds of a result are worked out. In the Bragg example, `wavelength` is a\n", - "`DescriptorNumber` and `theta` a `Parameter`, so `d` is a `Parameter`:" - ] - }, - { - "cell_type": "code", - "execution_count": 27, - "id": "406211c1", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T14:18:16.207362Z", - "iopub.status.busy": "2026-10-13T14:18:16.207235Z", - "iopub.status.idle": "2026-10-13T14:18:16.210632Z", - "shell.execute_reply": "2026-10-13T14:18:16.210107Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "DescriptorNumber Parameter -> Parameter\n", - "DescriptorNumber 1.6487 ± 0.0330 dimensionless\n" - ] - } - ], - "source": [ - "print(type(wavelength).__name__, type(theta).__name__, '->', type(d).__name__)\n", - "\n", - "constant = DescriptorNumber(name='constant', value=0.5, variance=0.0004)\n", - "result = es.math.exp(constant)\n", - "print(type(result).__name__, show(result))" - ] - }, - { - "cell_type": "markdown", - "id": "0544181e", - "metadata": {}, - "source": [ - "## Uncertainty and bounds of a result\n", - "\n", - "Because a result keeps its uncertainty, we can draw a function together with the band that the\n", - "uncertainty of its argument gives. `es.math` works on one number at a time, so we loop over the angles.\n", - "Here is the lattice spacing $d(\\theta)$ of the Bragg example, for an angle with an uncertainty of\n", - "$1^\\circ$ (ten times larger than above, so that the band can be seen). The chart is interactive: hover\n", - "over the curve to read the values. It loads Plotly from the internet, so it needs a connection. The charts are shown with a small helper,\n", - "`show_figure(fig)`, which is defined in a hidden cell; in your own notebook `fig.show()` does the same." - ] - }, - { - "cell_type": "code", - "execution_count": 28, - "id": "aadb9da0", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T14:18:16.211705Z", - "iopub.status.busy": "2026-10-13T14:18:16.211569Z", - "iopub.status.idle": "2026-10-13T14:18:16.214904Z", - "shell.execute_reply": "2026-10-13T14:18:16.214242Z" - }, - "tags": [ - "hide-in-docs" - ] - }, - "outputs": [], - "source": [ - "import base64\n", - "\n", - "from IPython.display import IFrame\n", - "from IPython.display import display\n", - "\n", - "\n", - "def show_figure(fig: go.Figure, height: int = 420) -> None:\n", - " \"\"\"Show a Plotly figure as an embedded iframe, so that it renders in the docs.\"\"\"\n", - " page = (\n", - " go\n", - " .Figure(fig)\n", - " .update_layout(template='plotly_white')\n", - " .to_html(\n", - " full_html=True,\n", - " include_plotlyjs='cdn',\n", - " default_width='100%',\n", - " default_height=f'{height - 20}px',\n", - " )\n", - " )\n", - " encoded = base64.b64encode(page.encode('utf-8')).decode('ascii')\n", - " display(IFrame(src=f'data:text/html;base64,{encoded}', width='100%', height=height))" - ] - }, - { - "cell_type": "code", - "execution_count": 29, - "id": "eb57ec61", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T14:18:16.216110Z", - "iopub.status.busy": "2026-10-13T14:18:16.215980Z", - "iopub.status.idle": "2026-10-13T14:18:16.472227Z", - "shell.execute_reply": "2026-10-13T14:18:16.471683Z" - } - }, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " \n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "angles = np.linspace(5.0, 60.0, 60)\n", - "\n", - "values, errors = [], []\n", - "for value in angles:\n", - " angle = Parameter(name='theta', value=value, unit='deg', variance=1.0**2)\n", - " spacing = wavelength / (2 * es.math.sin(angle))\n", - " values.append(spacing.value)\n", - " errors.append(spacing.error)\n", - "values, errors = np.array(values), np.array(errors)\n", - "\n", - "fig = go.Figure()\n", - "fig.add_trace(\n", - " go.Scatter(x=angles, y=values - errors, mode='lines', line=dict(width=0), showlegend=False)\n", - ")\n", - "fig.add_trace(\n", - " go.Scatter(\n", - " x=angles,\n", - " y=values + errors,\n", - " mode='lines',\n", - " line=dict(width=0),\n", - " fill='tonexty',\n", - " fillcolor='rgba(99, 110, 250, 0.25)',\n", - " name='± 1 error',\n", - " )\n", - ")\n", - "fig.add_trace(\n", - " go.Scatter(\n", - " x=angles,\n", - " y=values,\n", - " mode='lines',\n", - " name='d',\n", - " line=dict(color='rgb(99, 110, 250)'),\n", - " customdata=errors,\n", - " hovertemplate='theta = %{x:.1f} deg
d = %{y:.3f} ± %{customdata:.3f} Å',\n", - " )\n", - ")\n", - "fig.update_layout(\n", - " xaxis_title='theta / deg',\n", - " yaxis_title='d / Å',\n", - " margin=dict(l=60, r=20, t=30, b=50),\n", - " legend=dict(x=0.6, y=0.95),\n", - ")\n", - "show_figure(fig)" - ] - }, - { - "cell_type": "markdown", - "id": "86805f7b", - "metadata": {}, - "source": [ - "The band is widest at small angles: the relative error of $d$ is $\\cot\\theta\\,\\sigma_\\theta$.\n", - "\n", - "The bounds of the input carry through as well. The sine of an angle that may vary between $0^\\circ$ and\n", - "$180^\\circ$ reaches 1 in the middle, so the upper bound of the result is exactly 1, not the sine of\n", - "either end:" - ] - }, - { - "cell_type": "code", - "execution_count": 30, - "id": "f75be67c", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T14:18:16.473571Z", - "iopub.status.busy": "2026-10-13T14:18:16.473449Z", - "iopub.status.idle": "2026-10-13T14:18:16.476795Z", - "shell.execute_reply": "2026-10-13T14:18:16.476391Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "0.9848 ± 0.0030 dimensionless in [0.0000, 1.0000]\n" - ] - } - ], - "source": [ - "wide = Parameter(name='wide', value=80.0, unit='deg', variance=1.0, min=0.0, max=180.0)\n", - "print(show(es.math.sin(wide)))" - ] - }, - { - "cell_type": "markdown", - "id": "c9402e5b", - "metadata": {}, - "source": [ - "## Limits of first-order propagation\n", - "\n", - "The variance is correct to first order. That is excellent when the uncertainty is small compared with\n", - "how fast the function changes, and it has limits that are good to know.\n", - "\n", - "**Bad inputs are refused with an error.** A value outside the domain of a function raises an error\n", - "that names the parameter, a wrong unit raises a unit error, and a `tan` too close to a pole is refused:" - ] - }, - { - "cell_type": "code", - "execution_count": 31, - "id": "b8f9f85c", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T14:18:16.477855Z", - "iopub.status.busy": "2026-10-13T14:18:16.477745Z", - "iopub.status.idle": "2026-10-13T14:18:16.481952Z", - "shell.execute_reply": "2026-10-13T14:18:16.481524Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "log10 of zero: ValueError: The value of 'v' (0.0) is outside the domain (0.0, inf] of log10().\n", - "\n", - "arcsin of 1.2 (no Bragg angle exists): ValueError: The value of 'v' (1.2) is outside the domain [-1.0, 1.0] of arcsin().\n", - "\n", - "sqrt of a length: UnitError: Unsupported unit as result of sqrt: sqrt(m).\n", - "\n", - "tan at a pole: ValueError: The value of 'v' (1.5707963267948966 rad) is within 0.1 rad of a tan asymptote (pi/2 + n*pi); the propagated variance would explode.\n", - "\n" - ] - } - ], - "source": [ - "cases = {\n", - " 'log10 of zero': lambda: es.math.log10(Parameter(name='v', value=0.0, variance=0.01)),\n", - " 'arcsin of 1.2 (no Bragg angle exists)': lambda: es.math.arcsin(\n", - " Parameter(name='v', value=1.2, variance=0.01)\n", - " ),\n", - " 'sqrt of a length': lambda: es.math.sqrt(\n", - " Parameter(name='v', value=2.0, unit='m', variance=0.01)\n", - " ),\n", - " 'tan at a pole': lambda: es.math.tan(\n", - " Parameter(name='v', value=np.pi / 2, unit='rad', variance=0.01)\n", - " ),\n", - "}\n", - "for label, build in cases.items():\n", - " try:\n", - " build()\n", - " except Exception as error:\n", - " print(f'{label}: {type(error).__name__}: {error}\\n')" - ] - }, - { - "cell_type": "markdown", - "id": "308d3774", - "metadata": {}, - "source": [ - "`arcsin` of a ratio larger than 1 is the Bragg case $\\lambda/(2d) > 1$: no angle satisfies the law, and\n", - "the function says so instead of returning a meaningless number.\n", - "\n", - "**The same number used twice is treated as two independent numbers.** In the round trip above,\n", - "`wavelength` appears twice, in $d$ and again in the `arcsin`. Mathematically the wavelength cancels and\n", - "the variance must come back as exactly that of `theta`, but the two uses are treated as independent, so\n", - "a little extra variance is added:" - ] - }, - { - "cell_type": "code", - "execution_count": 32, - "id": "4d561018", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T14:18:16.482983Z", - "iopub.status.busy": "2026-10-13T14:18:16.482873Z", - "iopub.status.idle": "2026-10-13T14:18:16.485063Z", - "shell.execute_reply": "2026-10-13T14:18:16.484643Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "variance after the round trip: 0.010050\n", - "variance of theta: 0.010000\n" - ] - } - ], - "source": [ - "print(f'variance after the round trip: {recovered.variance:.6f}')\n", - "print(f'variance of theta: {theta.variance:.6f}')" - ] - }, - { - "cell_type": "markdown", - "id": "e453e756", - "metadata": {}, - "source": [ - "The difference is small here because the wavelength is known very precisely, but it grows with the\n", - "uncertainty of the number that is used twice.\n", - "\n", - "**Where the function is flat, first order gives zero.** At the top of the sine the derivative is 0, so\n", - "the first-order error is 0, although the argument is uncertain and the result really varies a little. A\n", - "Monte Carlo sample of the same sine shows the difference:" - ] - }, - { - "cell_type": "code", - "execution_count": 33, - "id": "f2946f38", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T14:18:16.486191Z", - "iopub.status.busy": "2026-10-13T14:18:16.486074Z", - "iopub.status.idle": "2026-10-13T14:18:16.598487Z", - "shell.execute_reply": "2026-10-13T14:18:16.597933Z" - } - }, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " \n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], - "source": [ - "sigma = 0.3\n", - "angles = np.linspace(0.0, 2 * np.pi, 80)\n", - "\n", - "first_order = np.array([\n", - " es.math.sin(Parameter(name='angle', value=value, unit='rad', variance=sigma**2)).error\n", - " for value in angles\n", - "])\n", - "\n", - "rng = np.random.default_rng(seed=1)\n", - "samples = rng.normal(angles[:, None], sigma, size=(len(angles), 20_000))\n", - "monte_carlo = np.sin(samples).std(axis=1)\n", - "\n", - "fig = go.Figure()\n", - "fig.add_trace(go.Scatter(x=angles, y=first_order, mode='lines', name='first order (es.math)'))\n", - "fig.add_trace(\n", - " go.Scatter(x=angles, y=monte_carlo, mode='lines', line=dict(dash='dash'), name='Monte Carlo')\n", - ")\n", - "fig.update_layout(\n", - " xaxis_title='angle / rad',\n", - " yaxis_title='error of sin(angle)',\n", - " margin=dict(l=60, r=20, t=60, b=50),\n", - " legend=dict(orientation='h', x=0.5, xanchor='center', y=1.1),\n", - ")\n", - "show_figure(fig, height=380)" - ] - }, - { - "cell_type": "markdown", - "id": "0e361c8a", - "metadata": {}, - "source": [ - "The two agree to within a few per cent away from the extremes. At $\\pi/2$ and $3\\pi/2$ the first-order\n", - "error drops to (almost) zero, while the Monte Carlo error stays near $\\sigma^2/\\sqrt{2}\\approx 0.06$.\n", - "The same kind of failure happens when the uncertainty is large compared with the scale of the function\n", - "(for example `exp` of a number with an uncertainty of 1), and close to a pole of `tan`.\n", - "\n", - "**Two different inputs are treated as independent.** `a - b` and `arctan2(y, x)` combine the variances of\n", - "the two inputs as if they were unrelated. If the inputs are correlated, for example the slope and the\n", - "intercept of the same fit, the variance of a function of both can be off by a large factor." - ] - }, - { - "cell_type": "markdown", - "id": "d2adb633", - "metadata": {}, - "source": [ - "## Maths on the result of a fit\n", - "\n", - "`es.math` works on single numbers, not on arrays. Inside the model function of a fit, which is called\n", - "on a whole array of $x$ values, use NumPy on the `.value` of the parameters. After the fit, use\n", - "`es.math` on the fitted parameters: they carry the uncertainty that the fit found, so a derived\n", - "quantity comes with its own uncertainty.\n", - "\n", - "We fit a straight line to simulated data, and ask for the angle of the line:" - ] - }, - { - "cell_type": "code", - "execution_count": 34, - "id": "bfed6f42", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T14:18:16.599833Z", - "iopub.status.busy": "2026-10-13T14:18:16.599713Z", - "iopub.status.idle": "2026-10-13T14:18:16.601803Z", - "shell.execute_reply": "2026-10-13T14:18:16.601335Z" - } - }, - "outputs": [], - "source": [ - "from easyscience import Fitter\n", - "from easyscience.base_classes import ModelBase" - ] - }, - { - "cell_type": "code", - "execution_count": 35, - "id": "471f7767", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T14:18:16.602837Z", - "iopub.status.busy": "2026-10-13T14:18:16.602729Z", - "iopub.status.idle": "2026-10-13T14:18:16.605433Z", - "shell.execute_reply": "2026-10-13T14:18:16.604990Z" - } - }, - "outputs": [], - "source": [ - "class StraightLine(ModelBase):\n", - " \"\"\"y = slope * x + intercept\"\"\"\n", - "\n", - " def __init__(self, slope: float, intercept: float):\n", - " super().__init__()\n", - " self._slope = Parameter(name='slope', value=slope)\n", - " self._intercept = Parameter(name='intercept', value=intercept)\n", - "\n", - " @property\n", - " def slope(self) -> Parameter:\n", - " return self._slope\n", - "\n", - " @property\n", - " def intercept(self) -> Parameter:\n", - " return self._intercept\n", - "\n", - " def __call__(self, x):\n", - " return self._slope.value * x + self._intercept.value" - ] - }, - { - "cell_type": "code", - "execution_count": 36, - "id": "90a5d300", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T14:18:16.606464Z", - "iopub.status.busy": "2026-10-13T14:18:16.606356Z", - "iopub.status.idle": "2026-10-13T14:18:16.613316Z", - "shell.execute_reply": "2026-10-13T14:18:16.612876Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "0.6009 ± 0.0136 dimensionless in [-inf, inf]\n", - "1.0138 ± 0.0791 dimensionless in [-inf, inf]\n" - ] - } - ], - "source": [ - "rng = np.random.default_rng(seed=20261013)\n", - "noise = 0.3\n", - "x = np.linspace(0.0, 10.0, 40)\n", - "y = 0.6 * x + 1.0 + rng.normal(scale=noise, size=x.size)\n", - "weights = np.full_like(x, 1.0 / noise)\n", - "\n", - "model = StraightLine(slope=0.1, intercept=0.0)\n", - "fitter = Fitter(model, model)\n", - "result = fitter.fit(x, y, weights=weights)\n", - "\n", - "print(show(model.slope))\n", - "print(show(model.intercept))" - ] - }, - { - "cell_type": "markdown", - "id": "52eaade3", - "metadata": {}, - "source": [ - "The angle of the line with the $x$ axis is the arctangent of the slope. `arctan` needs a dimensionless argument, and the slope is one; the `unit` keyword then returns the\n", - "angle in degrees:" - ] - }, - { - "cell_type": "code", - "execution_count": 37, - "id": "18237806", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T14:18:16.614525Z", - "iopub.status.busy": "2026-10-13T14:18:16.614414Z", - "iopub.status.idle": "2026-10-13T14:18:16.617173Z", - "shell.execute_reply": "2026-10-13T14:18:16.616789Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "31.0029 ± 0.5734 deg in [-90.0000, 90.0000]\n" - ] - } - ], - "source": [ - "line_angle = es.math.arctan(model.slope, unit='deg')\n", - "print(show(line_angle))" - ] - }, - { - "cell_type": "markdown", - "id": "783b9947", - "metadata": {}, - "source": [ - "The uncertainty of the angle comes from the uncertainty of the fitted slope, by the same first-order\n", - "rule as above." - ] - }, - { - "cell_type": "markdown", - "id": "96e1f2f3", - "metadata": {}, - "source": [ - "## Summary\n", - "\n", - "* `Parameter` and `DescriptorNumber` carry a value, a unit and a variance; a `Parameter` also carries\n", - " bounds. Arithmetic and `es.math` functions return **new** objects.\n", - "* The units are checked and converted, the variance is propagated to first order for independent\n", - " inputs, and the bounds are the exact range of the result.\n", - "* `es.math` covers exponential, logarithmic, trigonometric, inverse trigonometric, hyperbolic and\n", - " error functions, with a `Parameter` or a `DescriptorNumber` as input.\n", - "* A value outside the domain of a function raises an error. The first-order variance is unreliable for\n", - " large uncertainties, at a flat spot of the function, near a pole, and for inputs that are correlated\n", - " or that are the same number used twice.\n", - "* Use `es.math` on fitted parameters, after the fit, to get a derived quantity with its uncertainty.\n", - "\n", - "See the [es.math reference](../api-reference/math.md) for every function." - ] - } - ], - "metadata": { - "kernelspec": { - "display_name": "Python 3", - "language": "python", - "name": "python3" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.14.6" - } - }, - "nbformat": 4, - "nbformat_minor": 5 -} From 25af4b1b695558bc32aef402030ac7fa0f24828a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 17:12:58 +0000 Subject: [PATCH 04/25] docs(tutorials): run exercise solutions and show their real output Each Solution used to be a hand-pasted code block with a pasted "Expected result". It is now a small cell that runs the solution in a copy of the notebook namespace and shows the highlighted code and its output in a folded block, so the two cannot drift apart. The helper lives in the existing hidden cell, which now sits near the top so it is defined before the first exercise. Hints stay as folded markdown. Stored outputs are stripped from the six notebooks. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01PHwoT3iPrULYWjTrLPuhan --- .../tutorials/basics/maths-arithmetic.ipynb | 614 ++++++------------ .../docs/tutorials/basics/maths-exp-log.ipynb | 550 +++++----------- .../tutorials/basics/maths-hyperbolic.ipynb | 526 +++++---------- .../basics/maths-inverse-trigonometric.ipynb | 533 +++++---------- .../basics/maths-trigonometric.ipynb | 547 +++++----------- docs/docs/tutorials/fitting-intro.ipynb | 167 +++-- 6 files changed, 968 insertions(+), 1969 deletions(-) diff --git a/docs/docs/tutorials/basics/maths-arithmetic.ipynb b/docs/docs/tutorials/basics/maths-arithmetic.ipynb index f970ed39..03069b60 100644 --- a/docs/docs/tutorials/basics/maths-arithmetic.ipynb +++ b/docs/docs/tutorials/basics/maths-arithmetic.ipynb @@ -28,16 +28,9 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "id": "3248d597", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:48.449272Z", - "iopub.status.busy": "2026-10-13T16:18:48.449067Z", - "iopub.status.idle": "2026-10-13T16:18:49.475363Z", - "shell.execute_reply": "2026-10-13T16:18:49.474693Z" - } - }, + "metadata": {}, "outputs": [], "source": [ "import numpy as np\n", @@ -58,16 +51,9 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "id": "a46d494e", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:49.477191Z", - "iopub.status.busy": "2026-10-13T16:18:49.476937Z", - "iopub.status.idle": "2026-10-13T16:18:49.479824Z", - "shell.execute_reply": "2026-10-13T16:18:49.479382Z" - } - }, + "metadata": {}, "outputs": [], "source": [ "def show(number):\n", @@ -78,6 +64,73 @@ " return text" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "9ed7cb3e", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "import base64\n", + "import contextlib\n", + "import html\n", + "import io\n", + "import re\n", + "\n", + "from IPython import get_ipython\n", + "from IPython.display import HTML\n", + "from IPython.display import IFrame\n", + "from IPython.display import display\n", + "from pygments import highlight\n", + "from pygments.formatters import HtmlFormatter\n", + "from pygments.lexers import PythonLexer\n", + "\n", + "\n", + "def show_figure(fig: go.Figure, height: int = 420) -> None:\n", + " \"\"\"Show a Plotly figure as an embedded iframe, so that it renders in the docs.\"\"\"\n", + " page = (\n", + " go\n", + " .Figure(fig)\n", + " .update_layout(template='plotly_white')\n", + " .to_html(\n", + " full_html=True,\n", + " include_plotlyjs='cdn',\n", + " default_width='100%',\n", + " default_height=f'{height - 20}px',\n", + " )\n", + " )\n", + " encoded = base64.b64encode(page.encode('utf-8')).decode('ascii')\n", + " display(IFrame(src=f'data:text/html;base64,{encoded}', width='100%', height=height))\n", + "\n", + "\n", + "def solution(note: str, source: str) -> None:\n", + " \"\"\"Run a solution in a copy of the notebook namespace and show it, folded, with its output.\"\"\"\n", + " code = source.strip('\\n')\n", + " namespace = dict(get_ipython().user_ns)\n", + " stream = io.StringIO()\n", + " with contextlib.redirect_stdout(stream):\n", + " exec(code, namespace)\n", + " parts = [\n", + " '
Solution',\n", + " highlight(code, PythonLexer(), HtmlFormatter()),\n", + " ]\n", + " result = stream.getvalue().rstrip('\\n')\n", + " if result:\n", + " parts.append('

Output:

' + html.escape(result) + '
')\n", + " if note.strip():\n", + " text = re.sub('`([^`]+)`', r'\\1', html.escape(note.strip()))\n", + " parts.append('

' + text + '

')\n", + " parts.append('
')\n", + " display(HTML(''.join(parts)))\n", + "\n", + "\n", + "get_ipython().register_magic_function(solution, magic_kind='cell')" + ] + }, { "cell_type": "markdown", "id": "e6629efa", @@ -91,28 +144,10 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "id": "0149da13", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:49.480997Z", - "iopub.status.busy": "2026-10-13T16:18:49.480887Z", - "iopub.status.idle": "2026-10-13T16:18:49.491775Z", - "shell.execute_reply": "2026-10-13T16:18:49.491114Z" - } - }, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 3, - "metadata": {}, - "output_type": "execute_result" - } - ], + "metadata": {}, + "outputs": [], "source": [ "length = Parameter(name='length', value=2.0, unit='m', variance=0.04, min=0.0, max=10.0)\n", "length" @@ -128,27 +163,10 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "id": "9dd36dc6", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:49.493088Z", - "iopub.status.busy": "2026-10-13T16:18:49.492960Z", - "iopub.status.idle": "2026-10-13T16:18:49.495618Z", - "shell.execute_reply": "2026-10-13T16:18:49.495162Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "2.0 m\n", - "0.04 0.2\n", - "0.0 10.0\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ "print(length.value, length.unit)\n", "print(length.variance, length.error)\n", @@ -166,26 +184,10 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "id": "a4f03105", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:49.496622Z", - "iopub.status.busy": "2026-10-13T16:18:49.496505Z", - "iopub.status.idle": "2026-10-13T16:18:49.499126Z", - "shell.execute_reply": "2026-10-13T16:18:49.498715Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "2.0000 ± 0.2000 m in [0.0000, 10.0000]\n", - "3.0000 ± 0.1000 dimensionless\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ "scale = DescriptorNumber(name='scale', value=3.0, variance=0.01)\n", "print(show(length))\n", @@ -208,25 +210,10 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "id": "69e79f4d", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:49.500196Z", - "iopub.status.busy": "2026-10-13T16:18:49.500087Z", - "iopub.status.idle": "2026-10-13T16:18:49.503146Z", - "shell.execute_reply": "2026-10-13T16:18:49.502815Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "2.5000 ± 0.2010 m in [0.1000, 11.0000]\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ "width = Parameter(name='width', value=50.0, unit='cm', variance=4.0, min=10.0, max=100.0)\n", "total = length + width\n", @@ -245,25 +232,10 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": null, "id": "ddd2e459", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:49.504276Z", - "iopub.status.busy": "2026-10-13T16:18:49.504169Z", - "iopub.status.idle": "2026-10-13T16:18:49.507211Z", - "shell.execute_reply": "2026-10-13T16:18:49.506596Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "1.5000 ± 0.2010 m in [-1.0000, 9.9000]\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ "difference = length - width\n", "print(show(difference))" @@ -280,26 +252,10 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": null, "id": "af71c46b", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:49.508215Z", - "iopub.status.busy": "2026-10-13T16:18:49.508102Z", - "iopub.status.idle": "2026-10-13T16:18:49.511005Z", - "shell.execute_reply": "2026-10-13T16:18:49.510549Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "UnitError: Numbers can only be added to dimensionless values\n", - "4.0000 ± 0.1000 dimensionless\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ "try:\n", " length + 1.0\n", @@ -321,16 +277,9 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": null, "id": "a88eeddf", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:49.512143Z", - "iopub.status.busy": "2026-10-13T16:18:49.512034Z", - "iopub.status.idle": "2026-10-13T16:18:49.515004Z", - "shell.execute_reply": "2026-10-13T16:18:49.514396Z" - } - }, + "metadata": {}, "outputs": [], "source": [ "section_1 = Parameter(name='section_1', value=1.20, variance=0.02**2, unit='m')\n", @@ -354,25 +303,25 @@ "\n", "The variances add: $\\sigma^2 = 0.02^2 + 0.03^2$, and `error` is the square root of the variance.\n", "\n", - "
\n", - "\n", - "
\n", - "Solution\n", - "\n", - "```python\n", - "total = section_1 + section_2\n", - "print(show(total))\n", - "```\n", - "\n", - "Expected result:\n", - "\n", - "```text\n", - "2.0000 ± 0.0361 m\n", - "```\n", - "\n", "
" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "2520c343", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "%%solution\n", + "total = section_1 + section_2\n", + "print(show(total))" + ] + }, { "cell_type": "markdown", "id": "e0497e6a", @@ -385,16 +334,9 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": null, "id": "a64159f6", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:49.516009Z", - "iopub.status.busy": "2026-10-13T16:18:49.515898Z", - "iopub.status.idle": "2026-10-13T16:18:49.518681Z", - "shell.execute_reply": "2026-10-13T16:18:49.518184Z" - } - }, + "metadata": {}, "outputs": [], "source": [ "tube = Parameter(name='tube', value=1.20, variance=0.02**2, unit='m')\n", @@ -418,25 +360,25 @@ "\n", "The second operand is converted to the unit of the first, so the result is in the unit of `tube`.\n", "\n", - "\n", - "\n", - "
\n", - "Solution\n", - "\n", - "```python\n", - "remaining = tube - gap\n", - "print(show(remaining))\n", - "```\n", - "\n", - "Expected result:\n", - "\n", - "```text\n", - "1.0750 ± 0.0206 m\n", - "```\n", - "\n", "
" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "ce692abb", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "%%solution\n", + "remaining = tube - gap\n", + "print(show(remaining))" + ] + }, { "cell_type": "markdown", "id": "313df7f1", @@ -449,25 +391,10 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": null, "id": "bbc2cdc8", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:49.519593Z", - "iopub.status.busy": "2026-10-13T16:18:49.519487Z", - "iopub.status.idle": "2026-10-13T16:18:49.522684Z", - "shell.execute_reply": "2026-10-13T16:18:49.522213Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "3.0000 ± 0.3606 m^2 in [0.0000, 30.0000]\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ "height = Parameter(name='height', value=1.5, unit='m', variance=0.01, min=1.0, max=3.0)\n", "area = length * height\n", @@ -485,25 +412,10 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": null, "id": "32d674ef", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:49.523849Z", - "iopub.status.busy": "2026-10-13T16:18:49.523722Z", - "iopub.status.idle": "2026-10-13T16:18:49.527687Z", - "shell.execute_reply": "2026-10-13T16:18:49.527284Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "0.5000 ± 0.0590 m/s in [0.0000, 5.0000]\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ "time = Parameter(name='time', value=4.0, unit='s', variance=0.0625, min=2.0, max=8.0)\n", "speed = length / time\n", @@ -520,25 +432,10 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": null, "id": "1bf87c9c", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:49.528905Z", - "iopub.status.busy": "2026-10-13T16:18:49.528797Z", - "iopub.status.idle": "2026-10-13T16:18:49.531493Z", - "shell.execute_reply": "2026-10-13T16:18:49.531067Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "DescriptorNumber 6.0000 ± 0.2000 dimensionless\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ "doubled = scale * 2\n", "print(type(doubled).__name__, show(doubled))" @@ -556,16 +453,9 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": null, "id": "eafe8969", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:49.532501Z", - "iopub.status.busy": "2026-10-13T16:18:49.532396Z", - "iopub.status.idle": "2026-10-13T16:18:49.535070Z", - "shell.execute_reply": "2026-10-13T16:18:49.534586Z" - } - }, + "metadata": {}, "outputs": [], "source": [ "distance = Parameter(name='distance', value=3.0, variance=0.1**2, unit='m')\n", @@ -589,25 +479,25 @@ "\n", "Units divide as well: look at the unit of the result.\n", "\n", - "\n", - "\n", - "
\n", - "Solution\n", - "\n", - "```python\n", - "speed = distance / time\n", - "print(show(speed))\n", - "```\n", - "\n", - "Expected result:\n", - "\n", - "```text\n", - "2.0000 ± 0.0943 m/s\n", - "```\n", - "\n", "
" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "b0b64a26", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "%%solution\n", + "speed = distance / time\n", + "print(show(speed))" + ] + }, { "cell_type": "markdown", "id": "cf261509", @@ -620,27 +510,10 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": null, "id": "5ba32813", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:49.536034Z", - "iopub.status.busy": "2026-10-13T16:18:49.535929Z", - "iopub.status.idle": "2026-10-13T16:18:49.539513Z", - "shell.execute_reply": "2026-10-13T16:18:49.539047Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "4.0000 ± 0.8000 m^2 in [0.0000, 100.0000]\n", - "-2.0000 ± 0.2000 m in [-10.0000, -0.0000]\n", - "2.0000 ± 0.2000 m in [0.0000, 10.0000]\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ "print(show(length**2))\n", "print(show(-length))\n", @@ -659,16 +532,9 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": null, "id": "61387f4a", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:49.540731Z", - "iopub.status.busy": "2026-10-13T16:18:49.540577Z", - "iopub.status.idle": "2026-10-13T16:18:49.543057Z", - "shell.execute_reply": "2026-10-13T16:18:49.542660Z" - } - }, + "metadata": {}, "outputs": [], "source": [ "radius = Parameter(name='radius', value=2.0, variance=0.1**2, unit='cm')" @@ -691,27 +557,25 @@ "\n", "A plain number can multiply a `Parameter`; the result is again a `Parameter`.\n", "\n", - "\n", - "\n", - "
\n", - "Solution\n", - "\n", - "```python\n", - "area = np.pi * radius**2\n", - "print(show(area))\n", - "```\n", - "\n", - "Expected result:\n", - "\n", - "```text\n", - "12.5664 ± 1.2566 cm^2 in [0.0000, inf]\n", - "```\n", - "\n", - "The lower bound 0 appears because a square can never be negative.\n", - "\n", "
" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "8e084269", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "%%solution The lower bound 0 appears because a square can never be negative.\n", + "area = np.pi * radius**2\n", + "print(show(area))" + ] + }, { "cell_type": "markdown", "id": "364d524b", @@ -728,79 +592,10 @@ }, { "cell_type": "code", - "execution_count": 17, - "id": "9ed7cb3e", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:49.544149Z", - "iopub.status.busy": "2026-10-13T16:18:49.544043Z", - "iopub.status.idle": "2026-10-13T16:18:49.547390Z", - "shell.execute_reply": "2026-10-13T16:18:49.546894Z" - }, - "tags": [ - "hide-in-docs" - ] - }, - "outputs": [], - "source": [ - "import base64\n", - "\n", - "from IPython.display import IFrame\n", - "from IPython.display import display\n", - "\n", - "\n", - "def show_figure(fig: go.Figure, height: int = 420) -> None:\n", - " \"\"\"Show a Plotly figure as an embedded iframe, so that it renders in the docs.\"\"\"\n", - " page = (\n", - " go\n", - " .Figure(fig)\n", - " .update_layout(template='plotly_white')\n", - " .to_html(\n", - " full_html=True,\n", - " include_plotlyjs='cdn',\n", - " default_width='100%',\n", - " default_height=f'{height - 20}px',\n", - " )\n", - " )\n", - " encoded = base64.b64encode(page.encode('utf-8')).decode('ascii')\n", - " display(IFrame(src=f'data:text/html;base64,{encoded}', width='100%', height=height))" - ] - }, - { - "cell_type": "code", - "execution_count": 18, + "execution_count": null, "id": "d97fb184", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:49.548371Z", - "iopub.status.busy": "2026-10-13T16:18:49.548262Z", - "iopub.status.idle": "2026-10-13T16:18:49.739472Z", - "shell.execute_reply": "2026-10-13T16:18:49.738980Z" - } - }, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " \n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "metadata": {}, + "outputs": [], "source": [ "times = np.linspace(0.5, 5.0, 46)\n", "distance = Parameter(name='distance', value=3.0, unit='m', variance=0.1**2)\n", @@ -867,39 +662,10 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": null, "id": "51adac82", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:49.740963Z", - "iopub.status.busy": "2026-10-13T16:18:49.740828Z", - "iopub.status.idle": "2026-10-13T16:18:49.950206Z", - "shell.execute_reply": "2026-10-13T16:18:49.949663Z" - } - }, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " \n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "metadata": {}, + "outputs": [], "source": [ "counts = np.arange(1, 26)\n", "\n", @@ -939,26 +705,10 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": null, "id": "3603e9d6", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:49.951618Z", - "iopub.status.busy": "2026-10-13T16:18:49.951496Z", - "iopub.status.idle": "2026-10-13T16:18:49.956071Z", - "shell.execute_reply": "2026-10-13T16:18:49.955490Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "0.0000 ± 0.0000 dimensionless in [-10.0000, 10.0000]\n", - "2.0000 ± 0.0283 dimensionless in [0.0000, inf]\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ "x = Parameter(name='x', value=10.0, variance=0.1**2, min=0.0, max=10.0)\n", "print(show(x - x))\n", diff --git a/docs/docs/tutorials/basics/maths-exp-log.ipynb b/docs/docs/tutorials/basics/maths-exp-log.ipynb index 75c117ea..44d71a14 100644 --- a/docs/docs/tutorials/basics/maths-exp-log.ipynb +++ b/docs/docs/tutorials/basics/maths-exp-log.ipynb @@ -23,16 +23,9 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "id": "f10b3445", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:51.267247Z", - "iopub.status.busy": "2026-10-13T16:18:51.267115Z", - "iopub.status.idle": "2026-10-13T16:18:52.134367Z", - "shell.execute_reply": "2026-10-13T16:18:52.133827Z" - } - }, + "metadata": {}, "outputs": [], "source": [ "import numpy as np\n", @@ -53,16 +46,9 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "id": "ed1b6606", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:52.136612Z", - "iopub.status.busy": "2026-10-13T16:18:52.136351Z", - "iopub.status.idle": "2026-10-13T16:18:52.139291Z", - "shell.execute_reply": "2026-10-13T16:18:52.138762Z" - } - }, + "metadata": {}, "outputs": [], "source": [ "def show(number):\n", @@ -73,6 +59,73 @@ " return text" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "49a46c97", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "import base64\n", + "import contextlib\n", + "import html\n", + "import io\n", + "import re\n", + "\n", + "from IPython import get_ipython\n", + "from IPython.display import HTML\n", + "from IPython.display import IFrame\n", + "from IPython.display import display\n", + "from pygments import highlight\n", + "from pygments.formatters import HtmlFormatter\n", + "from pygments.lexers import PythonLexer\n", + "\n", + "\n", + "def show_figure(fig: go.Figure, height: int = 420) -> None:\n", + " \"\"\"Show a Plotly figure as an embedded iframe, so that it renders in the docs.\"\"\"\n", + " page = (\n", + " go\n", + " .Figure(fig)\n", + " .update_layout(template='plotly_white')\n", + " .to_html(\n", + " full_html=True,\n", + " include_plotlyjs='cdn',\n", + " default_width='100%',\n", + " default_height=f'{height - 20}px',\n", + " )\n", + " )\n", + " encoded = base64.b64encode(page.encode('utf-8')).decode('ascii')\n", + " display(IFrame(src=f'data:text/html;base64,{encoded}', width='100%', height=height))\n", + "\n", + "\n", + "def solution(note: str, source: str) -> None:\n", + " \"\"\"Run a solution in a copy of the notebook namespace and show it, folded, with its output.\"\"\"\n", + " code = source.strip('\\n')\n", + " namespace = dict(get_ipython().user_ns)\n", + " stream = io.StringIO()\n", + " with contextlib.redirect_stdout(stream):\n", + " exec(code, namespace)\n", + " parts = [\n", + " '
Solution',\n", + " highlight(code, PythonLexer(), HtmlFormatter()),\n", + " ]\n", + " result = stream.getvalue().rstrip('\\n')\n", + " if result:\n", + " parts.append('

Output:

' + html.escape(result) + '
')\n", + " if note.strip():\n", + " text = re.sub('`([^`]+)`', r'\\1', html.escape(note.strip()))\n", + " parts.append('

' + text + '

')\n", + " parts.append('
')\n", + " display(HTML(''.join(parts)))\n", + "\n", + "\n", + "get_ipython().register_magic_function(solution, magic_kind='cell')" + ] + }, { "cell_type": "markdown", "id": "b25a158b", @@ -87,25 +140,10 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "id": "9752cc6f", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:52.140499Z", - "iopub.status.busy": "2026-10-13T16:18:52.140367Z", - "iopub.status.idle": "2026-10-13T16:18:52.152591Z", - "shell.execute_reply": "2026-10-13T16:18:52.152080Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "0.9868 ± 0.0026 dimensionless in [0.7659, 1.0000]\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ "q = Parameter(name='q', value=2.0, unit='1/angstrom', variance=0.02**2, min=0.5, max=4.0)\n", "u2 = Parameter(name='u2', value=0.01, unit='angstrom**2', variance=0.002**2, min=0.0, max=0.05)\n", @@ -135,16 +173,9 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "id": "90885017", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:52.153987Z", - "iopub.status.busy": "2026-10-13T16:18:52.153856Z", - "iopub.status.idle": "2026-10-13T16:18:52.156652Z", - "shell.execute_reply": "2026-10-13T16:18:52.156139Z" - } - }, + "metadata": {}, "outputs": [], "source": [ "mu = Parameter(name='mu', value=0.50, variance=0.02**2, unit='1/cm')\n", @@ -168,27 +199,25 @@ "\n", "Write the argument first, `-mu * t`, and pass it to `es.math.exp`.\n", "\n", - "\n", - "\n", - "
\n", - "Solution\n", - "\n", - "```python\n", - "transmission = es.math.exp(-mu * t)\n", - "print(show(transmission))\n", - "```\n", - "\n", - "Expected result:\n", - "\n", - "```text\n", - "0.3679 ± 0.0236 dimensionless in [0.0000, inf]\n", - "```\n", - "\n", - "The lower bound 0 appears because an exponential is never negative.\n", - "\n", "
" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "fa6f7cd7", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "%%solution The lower bound 0 appears because an exponential is never negative.\n", + "transmission = es.math.exp(-mu * t)\n", + "print(show(transmission))" + ] + }, { "cell_type": "markdown", "id": "87dbe0ab", @@ -203,25 +232,10 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "id": "f4d206cf", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:52.157811Z", - "iopub.status.busy": "2026-10-13T16:18:52.157686Z", - "iopub.status.idle": "2026-10-13T16:18:52.162057Z", - "shell.execute_reply": "2026-10-13T16:18:52.161570Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "-6.0206 ± 0.1737 dimensionless in [-10.0000, -3.9794]\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ "intensity = Parameter(name='intensity', value=250.0, variance=100.0, min=100.0, max=400.0)\n", "reference = DescriptorNumber(name='reference', value=1000.0, variance=0.0)\n", @@ -240,25 +254,10 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "id": "113433ff", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:52.163328Z", - "iopub.status.busy": "2026-10-13T16:18:52.163199Z", - "iopub.status.idle": "2026-10-13T16:18:52.165960Z", - "shell.execute_reply": "2026-10-13T16:18:52.165460Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "The value of 'v' (0.0) is outside the domain (0.0, inf] of log10().\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ "try:\n", " es.math.log10(Parameter(name='v', value=0.0, variance=0.01))\n", @@ -278,16 +277,9 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": null, "id": "b8b46c83", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:52.167005Z", - "iopub.status.busy": "2026-10-13T16:18:52.166890Z", - "iopub.status.idle": "2026-10-13T16:18:52.169397Z", - "shell.execute_reply": "2026-10-13T16:18:52.168962Z" - } - }, + "metadata": {}, "outputs": [], "source": [ "intensity = Parameter(name='intensity', value=250.0, variance=10.0**2)\n", @@ -311,25 +303,25 @@ "\n", "`log10` takes a dimensionless number; the factor 10 is a plain number that multiplies the result.\n", "\n", - "\n", - "\n", - "
\n", - "Solution\n", - "\n", - "```python\n", - "level = 10 * es.math.log10(intensity / reference)\n", - "print(show(level))\n", - "```\n", - "\n", - "Expected result:\n", - "\n", - "```text\n", - "-6.0206 ± 0.1737 dimensionless\n", - "```\n", - "\n", "
" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "f65e993b", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "%%solution\n", + "level = 10 * es.math.log10(intensity / reference)\n", + "print(show(level))" + ] + }, { "cell_type": "markdown", "id": "df84b668", @@ -343,25 +335,10 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": null, "id": "e5fc6001", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:52.170606Z", - "iopub.status.busy": "2026-10-13T16:18:52.170491Z", - "iopub.status.idle": "2026-10-13T16:18:52.175069Z", - "shell.execute_reply": "2026-10-13T16:18:52.174594Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "5.0000 ± 0.1000 dimensionless in [3.6056, 6.4031]\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ "a = Parameter(name='a', value=3.0, variance=0.01, min=2.0, max=4.0)\n", "b = Parameter(name='b', value=4.0, variance=0.01, min=3.0, max=5.0)\n", @@ -381,26 +358,10 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": null, "id": "bc75d2f8", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:52.176521Z", - "iopub.status.busy": "2026-10-13T16:18:52.176383Z", - "iopub.status.idle": "2026-10-13T16:18:52.180964Z", - "shell.execute_reply": "2026-10-13T16:18:52.180442Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "3.0000 ± 0.0500 m in [2.0000, 4.0000]\n", - "UnitError: Unsupported unit as result of sqrt: sqrt(m).\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ "area = Parameter(name='area', value=9.0, unit='m**2', variance=0.09, min=4.0, max=16.0)\n", "print(show(es.math.sqrt(area)))\n", @@ -423,16 +384,9 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": null, "id": "61c225b5", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:52.181909Z", - "iopub.status.busy": "2026-10-13T16:18:52.181787Z", - "iopub.status.idle": "2026-10-13T16:18:52.184065Z", - "shell.execute_reply": "2026-10-13T16:18:52.183659Z" - } - }, + "metadata": {}, "outputs": [], "source": [ "intensity = Parameter(name='intensity', value=0.64, variance=0.02**2)" @@ -455,27 +409,25 @@ "\n", "The relative error of a square root is half the relative error of its argument.\n", "\n", - "\n", - "\n", - "
\n", - "Solution\n", - "\n", - "```python\n", - "amplitude = es.math.sqrt(intensity)\n", - "print(show(amplitude))\n", - "```\n", - "\n", - "Expected result:\n", - "\n", - "```text\n", - "0.8000 ± 0.0125 dimensionless in [0.0000, inf]\n", - "```\n", - "\n", - "The lower bound 0 is the lower end of the domain of the square root.\n", - "\n", "
" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "30d3a32c", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "%%solution The lower bound 0 is the lower end of the domain of the square root.\n", + "amplitude = es.math.sqrt(intensity)\n", + "print(show(amplitude))" + ] + }, { "cell_type": "markdown", "id": "23f3bd20", @@ -490,27 +442,10 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": null, "id": "b2de6389", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:52.185223Z", - "iopub.status.busy": "2026-10-13T16:18:52.185102Z", - "iopub.status.idle": "2026-10-13T16:18:52.191383Z", - "shell.execute_reply": "2026-10-13T16:18:52.190924Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "0.8176 ± 0.0263 dimensionless in [0.3829, 0.9973]\n", - "0.1824 ± 0.0263 dimensionless in [0.0027, 0.6171]\n", - "1.0\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ "slit = Parameter(name='slit', value=2.0, unit='mm', variance=0.1**2, min=1.0, max=3.0)\n", "beam = Parameter(name='beam', value=1.5, unit='mm', variance=0.05**2, min=1.0, max=2.0)\n", @@ -534,16 +469,9 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": null, "id": "cce37214", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:52.192399Z", - "iopub.status.busy": "2026-10-13T16:18:52.192263Z", - "iopub.status.idle": "2026-10-13T16:18:52.195093Z", - "shell.execute_reply": "2026-10-13T16:18:52.194632Z" - } - }, + "metadata": {}, "outputs": [], "source": [ "half_width = Parameter(name='half_width', value=1.0, variance=0.05**2, unit='mm')\n", @@ -567,27 +495,25 @@ "\n", "`np.sqrt(2)` is a plain number; divide by `beam_sigma * np.sqrt(2)`.\n", "\n", - "\n", - "\n", - "
\n", - "Solution\n", - "\n", - "```python\n", - "fraction = es.math.erf(half_width / (beam_sigma * np.sqrt(2)))\n", - "print(show(fraction))\n", - "```\n", - "\n", - "Expected result:\n", - "\n", - "```text\n", - "0.7887 ± 0.0323 dimensionless in [-1.0000, 1.0000]\n", - "```\n", - "\n", - "With unbounded inputs the bounds of `erf` are its range, from -1 to 1.\n", - "\n", "
" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "3c25236f", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "%%solution With unbounded inputs the bounds of `erf` are its range, from -1 to 1.\n", + "fraction = es.math.erf(half_width / (beam_sigma * np.sqrt(2)))\n", + "print(show(fraction))" + ] + }, { "cell_type": "markdown", "id": "7f1a9e45", @@ -600,26 +526,10 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": null, "id": "ca64e157", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:52.196192Z", - "iopub.status.busy": "2026-10-13T16:18:52.196073Z", - "iopub.status.idle": "2026-10-13T16:18:52.199575Z", - "shell.execute_reply": "2026-10-13T16:18:52.199127Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "1.2000 ± 0.0500 dimensionless in [0.5000, 3.0000]\n", - "True\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ "x = Parameter(name='x', value=1.2, variance=0.05**2, min=0.5, max=3.0)\n", "round_trip = es.math.exp(es.math.log(x))\n", @@ -643,79 +553,10 @@ }, { "cell_type": "code", - "execution_count": 14, - "id": "49a46c97", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:52.200629Z", - "iopub.status.busy": "2026-10-13T16:18:52.200510Z", - "iopub.status.idle": "2026-10-13T16:18:52.203329Z", - "shell.execute_reply": "2026-10-13T16:18:52.202844Z" - }, - "tags": [ - "hide-in-docs" - ] - }, - "outputs": [], - "source": [ - "import base64\n", - "\n", - "from IPython.display import IFrame\n", - "from IPython.display import display\n", - "\n", - "\n", - "def show_figure(fig: go.Figure, height: int = 420) -> None:\n", - " \"\"\"Show a Plotly figure as an embedded iframe, so that it renders in the docs.\"\"\"\n", - " page = (\n", - " go\n", - " .Figure(fig)\n", - " .update_layout(template='plotly_white')\n", - " .to_html(\n", - " full_html=True,\n", - " include_plotlyjs='cdn',\n", - " default_width='100%',\n", - " default_height=f'{height - 20}px',\n", - " )\n", - " )\n", - " encoded = base64.b64encode(page.encode('utf-8')).decode('ascii')\n", - " display(IFrame(src=f'data:text/html;base64,{encoded}', width='100%', height=height))" - ] - }, - { - "cell_type": "code", - "execution_count": 15, + "execution_count": null, "id": "da2bed0a", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:52.204272Z", - "iopub.status.busy": "2026-10-13T16:18:52.204156Z", - "iopub.status.idle": "2026-10-13T16:18:52.419344Z", - "shell.execute_reply": "2026-10-13T16:18:52.418916Z" - } - }, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " \n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "metadata": {}, + "outputs": [], "source": [ "q_values = np.linspace(0.2, 4.0, 40)\n", "u2 = Parameter(name='u2', value=0.01, unit='angstrom**2', variance=0.002**2)\n", @@ -782,39 +623,10 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": null, "id": "c73439aa", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:52.420838Z", - "iopub.status.busy": "2026-10-13T16:18:52.420691Z", - "iopub.status.idle": "2026-10-13T16:18:52.518768Z", - "shell.execute_reply": "2026-10-13T16:18:52.518100Z" - } - }, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " \n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "metadata": {}, + "outputs": [], "source": [ "half_widths = np.linspace(0.1, 3.0, 59)\n", "beam = Parameter(name='beam', value=0.8, unit='mm', variance=0.04**2)\n", @@ -884,26 +696,10 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": null, "id": "d48f797a", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:52.520150Z", - "iopub.status.busy": "2026-10-13T16:18:52.520010Z", - "iopub.status.idle": "2026-10-13T16:18:52.523633Z", - "shell.execute_reply": "2026-10-13T16:18:52.523263Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "7.3890560989306495\n", - "34.51261310995657\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ "wide = Parameter(name='wide', value=1.0, variance=1.0)\n", "print(es.math.exp(wide).variance)\n", diff --git a/docs/docs/tutorials/basics/maths-hyperbolic.ipynb b/docs/docs/tutorials/basics/maths-hyperbolic.ipynb index 482dccf3..9382fd32 100644 --- a/docs/docs/tutorials/basics/maths-hyperbolic.ipynb +++ b/docs/docs/tutorials/basics/maths-hyperbolic.ipynb @@ -23,16 +23,9 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "id": "95f6240e", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:53.730872Z", - "iopub.status.busy": "2026-10-13T16:18:53.730748Z", - "iopub.status.idle": "2026-10-13T16:18:54.612252Z", - "shell.execute_reply": "2026-10-13T16:18:54.611736Z" - } - }, + "metadata": {}, "outputs": [], "source": [ "import numpy as np\n", @@ -53,16 +46,9 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "id": "526ea56e", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:54.614254Z", - "iopub.status.busy": "2026-10-13T16:18:54.613978Z", - "iopub.status.idle": "2026-10-13T16:18:54.616978Z", - "shell.execute_reply": "2026-10-13T16:18:54.616485Z" - } - }, + "metadata": {}, "outputs": [], "source": [ "def show(number):\n", @@ -73,6 +59,73 @@ " return text" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "9a76574e", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "import base64\n", + "import contextlib\n", + "import html\n", + "import io\n", + "import re\n", + "\n", + "from IPython import get_ipython\n", + "from IPython.display import HTML\n", + "from IPython.display import IFrame\n", + "from IPython.display import display\n", + "from pygments import highlight\n", + "from pygments.formatters import HtmlFormatter\n", + "from pygments.lexers import PythonLexer\n", + "\n", + "\n", + "def show_figure(fig: go.Figure, height: int = 420) -> None:\n", + " \"\"\"Show a Plotly figure as an embedded iframe, so that it renders in the docs.\"\"\"\n", + " page = (\n", + " go\n", + " .Figure(fig)\n", + " .update_layout(template='plotly_white')\n", + " .to_html(\n", + " full_html=True,\n", + " include_plotlyjs='cdn',\n", + " default_width='100%',\n", + " default_height=f'{height - 20}px',\n", + " )\n", + " )\n", + " encoded = base64.b64encode(page.encode('utf-8')).decode('ascii')\n", + " display(IFrame(src=f'data:text/html;base64,{encoded}', width='100%', height=height))\n", + "\n", + "\n", + "def solution(note: str, source: str) -> None:\n", + " \"\"\"Run a solution in a copy of the notebook namespace and show it, folded, with its output.\"\"\"\n", + " code = source.strip('\\n')\n", + " namespace = dict(get_ipython().user_ns)\n", + " stream = io.StringIO()\n", + " with contextlib.redirect_stdout(stream):\n", + " exec(code, namespace)\n", + " parts = [\n", + " '
Solution',\n", + " highlight(code, PythonLexer(), HtmlFormatter()),\n", + " ]\n", + " result = stream.getvalue().rstrip('\\n')\n", + " if result:\n", + " parts.append('

Output:

' + html.escape(result) + '
')\n", + " if note.strip():\n", + " text = re.sub('`([^`]+)`', r'\\1', html.escape(note.strip()))\n", + " parts.append('

' + text + '

')\n", + " parts.append('
')\n", + " display(HTML(''.join(parts)))\n", + "\n", + "\n", + "get_ipython().register_magic_function(solution, magic_kind='cell')" + ] + }, { "cell_type": "markdown", "id": "078a8d28", @@ -89,27 +142,10 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "id": "0df6e42b", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:54.618292Z", - "iopub.status.busy": "2026-10-13T16:18:54.618165Z", - "iopub.status.idle": "2026-10-13T16:18:54.629162Z", - "shell.execute_reply": "2026-10-13T16:18:54.628584Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "sinh 0.8881 ± 0.0669 dimensionless in [0.0000, 2.1293]\n", - "cosh 1.3374 ± 0.0444 dimensionless in [1.0000, 2.3524]\n", - "tanh 0.6640 ± 0.0280 dimensionless in [0.0000, 0.9051]\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ "x = Parameter(name='x', value=0.8, variance=0.05**2, min=0.0, max=1.5)\n", "\n", @@ -128,25 +164,10 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "id": "ec093603", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:54.630641Z", - "iopub.status.busy": "2026-10-13T16:18:54.630501Z", - "iopub.status.idle": "2026-10-13T16:18:54.635478Z", - "shell.execute_reply": "2026-10-13T16:18:54.634981Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "0.7616 ± 0.0168 dimensionless in [-1.0000, 1.0000]\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ "moment = Parameter(name='moment', value=0.5, variance=0.02**2, unit='meV/T')\n", "field = DescriptorNumber(name='field', value=4.0, variance=0.0, unit='T')\n", @@ -168,16 +189,9 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "id": "87b8075d", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:54.636481Z", - "iopub.status.busy": "2026-10-13T16:18:54.636355Z", - "iopub.status.idle": "2026-10-13T16:18:54.638807Z", - "shell.execute_reply": "2026-10-13T16:18:54.638323Z" - } - }, + "metadata": {}, "outputs": [], "source": [ "x = Parameter(name='x', value=0.80, variance=0.05**2, min=0.0, max=3.0)" @@ -200,27 +214,25 @@ "\n", "`show` prints the bounds as well, because they are finite.\n", "\n", - "\n", - "\n", - "
\n", - "Solution\n", - "\n", - "```python\n", - "magnetisation = es.math.tanh(x)\n", - "print(show(magnetisation))\n", - "```\n", - "\n", - "Expected result:\n", - "\n", - "```text\n", - "0.6640 ± 0.0280 dimensionless in [0.0000, 0.9951]\n", - "```\n", - "\n", - "The upper bound is $\\tanh(3)$, not 1: the bounds are the range over the bounds of the input.\n", - "\n", "
" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "6d9c0c8a", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "%%solution The upper bound is $\\tanh(3)$, not 1: the bounds are the range over the bounds of the input.\n", + "magnetisation = es.math.tanh(x)\n", + "print(show(magnetisation))" + ] + }, { "cell_type": "markdown", "id": "2606a361", @@ -234,26 +246,10 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "id": "dfd08731", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:54.639754Z", - "iopub.status.busy": "2026-10-13T16:18:54.639623Z", - "iopub.status.idle": "2026-10-13T16:18:54.643540Z", - "shell.execute_reply": "2026-10-13T16:18:54.643056Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "0.5493 ± 0.0267 dimensionless in [-1.4722, 1.4722]\n", - "1.3170 ± 0.0289 dimensionless in [0.0000, 2.0634]\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ "saturation = Parameter(name='saturation', value=0.5, variance=0.02**2, min=-0.9, max=0.9)\n", "print(show(es.math.arctanh(saturation)))\n", @@ -274,16 +270,9 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": null, "id": "f9e6f602", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:54.644616Z", - "iopub.status.busy": "2026-10-13T16:18:54.644489Z", - "iopub.status.idle": "2026-10-13T16:18:54.647036Z", - "shell.execute_reply": "2026-10-13T16:18:54.646502Z" - } - }, + "metadata": {}, "outputs": [], "source": [ "saturation = Parameter(name='saturation', value=0.50, variance=0.02**2)" @@ -306,25 +295,25 @@ "\n", "The derivative of `arctanh` is $1/(1 - y^2)$, so the error is $0.02/(1 - 0.25)$.\n", "\n", - "\n", - "\n", - "
\n", - "Solution\n", - "\n", - "```python\n", - "x = es.math.arctanh(saturation)\n", - "print(show(x))\n", - "```\n", - "\n", - "Expected result:\n", - "\n", - "```text\n", - "0.5493 ± 0.0267 dimensionless\n", - "```\n", - "\n", "
" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "4edc2709", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "%%solution\n", + "x = es.math.arctanh(saturation)\n", + "print(show(x))" + ] + }, { "cell_type": "markdown", "id": "3bb6195e", @@ -338,25 +327,10 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": null, "id": "51536f23", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:54.648015Z", - "iopub.status.busy": "2026-10-13T16:18:54.647899Z", - "iopub.status.idle": "2026-10-13T16:18:54.651077Z", - "shell.execute_reply": "2026-10-13T16:18:54.650562Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "1.0000 ± 0.0000 dimensionless in [1.0000, 1.5431]\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ "z = Parameter(name='z', value=0.0, variance=0.5**2, min=-1.0, max=1.0)\n", "result = es.math.cosh(z)\n", @@ -375,16 +349,9 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": null, "id": "05f2f28d", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:54.652079Z", - "iopub.status.busy": "2026-10-13T16:18:54.651964Z", - "iopub.status.idle": "2026-10-13T16:18:54.654204Z", - "shell.execute_reply": "2026-10-13T16:18:54.653817Z" - } - }, + "metadata": {}, "outputs": [], "source": [ "x = Parameter(name='x', value=0.5, variance=0.05**2, min=-1.0, max=1.0)" @@ -407,27 +374,25 @@ "\n", "Look at the lower bound, `result.min`, and compare it with $\\cosh(-1) = 1.5431$.\n", "\n", - "\n", - "\n", - "
\n", - "Solution\n", - "\n", - "```python\n", - "result = es.math.cosh(x)\n", - "print(result.min)\n", - "```\n", - "\n", - "Expected result:\n", - "\n", - "```text\n", - "1.0\n", - "```\n", - "\n", - "The lower bound is 1, the value at 0, not $\\cosh(-1)$.\n", - "\n", "
" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "65bdea3a", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "%%solution The lower bound is 1, the value at 0, not $\\cosh(-1)$.\n", + "result = es.math.cosh(x)\n", + "print(result.min)" + ] + }, { "cell_type": "markdown", "id": "0bac64ab", @@ -451,16 +416,9 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": null, "id": "e8012f6d", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:54.655319Z", - "iopub.status.busy": "2026-10-13T16:18:54.655201Z", - "iopub.status.idle": "2026-10-13T16:18:54.657460Z", - "shell.execute_reply": "2026-10-13T16:18:54.657020Z" - } - }, + "metadata": {}, "outputs": [], "source": [ "x = Parameter(name='x', value=0.5, variance=0.01**2)" @@ -483,25 +441,25 @@ "\n", "Put the call in a `try` block and catch `ValueError`.\n", "\n", - "\n", - "\n", - "
\n", - "Solution\n", - "\n", - "```python\n", + "
" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fcceb1cc", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "%%solution\n", "try:\n", " es.math.arccosh(x)\n", "except ValueError as error:\n", - " print(error)\n", - "```\n", - "\n", - "Expected result:\n", - "\n", - "```text\n", - "The value of 'x' (0.5) is outside the domain [1.0, inf] of arccosh().\n", - "```\n", - "\n", - "" + " print(error)" ] }, { @@ -516,25 +474,10 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": null, "id": "102d1d37", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:54.658518Z", - "iopub.status.busy": "2026-10-13T16:18:54.658402Z", - "iopub.status.idle": "2026-10-13T16:18:54.661838Z", - "shell.execute_reply": "2026-10-13T16:18:54.661403Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "0.8000 ± 0.0500 dimensionless in [0.0000, 1.5000]\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ "x = Parameter(name='x', value=0.8, variance=0.05**2, min=0.0, max=1.5)\n", "back = es.math.arcsinh(es.math.sinh(x))\n", @@ -557,79 +500,10 @@ }, { "cell_type": "code", - "execution_count": 12, - "id": "9a76574e", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:54.663021Z", - "iopub.status.busy": "2026-10-13T16:18:54.662901Z", - "iopub.status.idle": "2026-10-13T16:18:54.665750Z", - "shell.execute_reply": "2026-10-13T16:18:54.665269Z" - }, - "tags": [ - "hide-in-docs" - ] - }, - "outputs": [], - "source": [ - "import base64\n", - "\n", - "from IPython.display import IFrame\n", - "from IPython.display import display\n", - "\n", - "\n", - "def show_figure(fig: go.Figure, height: int = 420) -> None:\n", - " \"\"\"Show a Plotly figure as an embedded iframe, so that it renders in the docs.\"\"\"\n", - " page = (\n", - " go\n", - " .Figure(fig)\n", - " .update_layout(template='plotly_white')\n", - " .to_html(\n", - " full_html=True,\n", - " include_plotlyjs='cdn',\n", - " default_width='100%',\n", - " default_height=f'{height - 20}px',\n", - " )\n", - " )\n", - " encoded = base64.b64encode(page.encode('utf-8')).decode('ascii')\n", - " display(IFrame(src=f'data:text/html;base64,{encoded}', width='100%', height=height))" - ] - }, - { - "cell_type": "code", - "execution_count": 13, + "execution_count": null, "id": "6cd723ec", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:54.666768Z", - "iopub.status.busy": "2026-10-13T16:18:54.666562Z", - "iopub.status.idle": "2026-10-13T16:18:54.858429Z", - "shell.execute_reply": "2026-10-13T16:18:54.857782Z" - } - }, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " \n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "metadata": {}, + "outputs": [], "source": [ "arguments = np.linspace(-3.0, 3.0, 61)\n", "\n", @@ -696,39 +570,10 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": null, "id": "b9a42646", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:54.859836Z", - "iopub.status.busy": "2026-10-13T16:18:54.859654Z", - "iopub.status.idle": "2026-10-13T16:18:54.925767Z", - "shell.execute_reply": "2026-10-13T16:18:54.925156Z" - } - }, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " \n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "metadata": {}, + "outputs": [], "source": [ "ys = np.linspace(1.05, 5.0, 60)\n", "\n", @@ -798,26 +643,10 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": null, "id": "dc485292", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:54.927198Z", - "iopub.status.busy": "2026-10-13T16:18:54.927035Z", - "iopub.status.idle": "2026-10-13T16:18:54.932159Z", - "shell.execute_reply": "2026-10-13T16:18:54.931715Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "0.0\n", - "0.0403352186623227\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ "flat = Parameter(name='flat', value=0.0, variance=0.5**2)\n", "print(es.math.cosh(flat).variance)\n", @@ -837,25 +666,10 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": null, "id": "892ca132", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:54.933440Z", - "iopub.status.busy": "2026-10-13T16:18:54.933319Z", - "iopub.status.idle": "2026-10-13T16:18:54.936078Z", - "shell.execute_reply": "2026-10-13T16:18:54.935659Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "sinh(big) is not finite: value=inf, variance=inf. Check the value and variance of the argument(s).\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ "try:\n", " es.math.sinh(Parameter(name='big', value=800.0, variance=1.0))\n", diff --git a/docs/docs/tutorials/basics/maths-inverse-trigonometric.ipynb b/docs/docs/tutorials/basics/maths-inverse-trigonometric.ipynb index 175a31a6..95bd00e8 100644 --- a/docs/docs/tutorials/basics/maths-inverse-trigonometric.ipynb +++ b/docs/docs/tutorials/basics/maths-inverse-trigonometric.ipynb @@ -23,16 +23,9 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "id": "b248846e", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:56.152252Z", - "iopub.status.busy": "2026-10-13T16:18:56.152130Z", - "iopub.status.idle": "2026-10-13T16:18:57.075168Z", - "shell.execute_reply": "2026-10-13T16:18:57.074675Z" - } - }, + "metadata": {}, "outputs": [], "source": [ "import numpy as np\n", @@ -53,16 +46,9 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "id": "b7551579", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:57.077009Z", - "iopub.status.busy": "2026-10-13T16:18:57.076732Z", - "iopub.status.idle": "2026-10-13T16:18:57.079843Z", - "shell.execute_reply": "2026-10-13T16:18:57.079164Z" - } - }, + "metadata": {}, "outputs": [], "source": [ "def show(number):\n", @@ -73,6 +59,73 @@ " return text" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "d0d3ee83", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "import base64\n", + "import contextlib\n", + "import html\n", + "import io\n", + "import re\n", + "\n", + "from IPython import get_ipython\n", + "from IPython.display import HTML\n", + "from IPython.display import IFrame\n", + "from IPython.display import display\n", + "from pygments import highlight\n", + "from pygments.formatters import HtmlFormatter\n", + "from pygments.lexers import PythonLexer\n", + "\n", + "\n", + "def show_figure(fig: go.Figure, height: int = 420) -> None:\n", + " \"\"\"Show a Plotly figure as an embedded iframe, so that it renders in the docs.\"\"\"\n", + " page = (\n", + " go\n", + " .Figure(fig)\n", + " .update_layout(template='plotly_white')\n", + " .to_html(\n", + " full_html=True,\n", + " include_plotlyjs='cdn',\n", + " default_width='100%',\n", + " default_height=f'{height - 20}px',\n", + " )\n", + " )\n", + " encoded = base64.b64encode(page.encode('utf-8')).decode('ascii')\n", + " display(IFrame(src=f'data:text/html;base64,{encoded}', width='100%', height=height))\n", + "\n", + "\n", + "def solution(note: str, source: str) -> None:\n", + " \"\"\"Run a solution in a copy of the notebook namespace and show it, folded, with its output.\"\"\"\n", + " code = source.strip('\\n')\n", + " namespace = dict(get_ipython().user_ns)\n", + " stream = io.StringIO()\n", + " with contextlib.redirect_stdout(stream):\n", + " exec(code, namespace)\n", + " parts = [\n", + " '
Solution',\n", + " highlight(code, PythonLexer(), HtmlFormatter()),\n", + " ]\n", + " result = stream.getvalue().rstrip('\\n')\n", + " if result:\n", + " parts.append('

Output:

' + html.escape(result) + '
')\n", + " if note.strip():\n", + " text = re.sub('`([^`]+)`', r'\\1', html.escape(note.strip()))\n", + " parts.append('

' + text + '

')\n", + " parts.append('
')\n", + " display(HTML(''.join(parts)))\n", + "\n", + "\n", + "get_ipython().register_magic_function(solution, magic_kind='cell')" + ] + }, { "cell_type": "markdown", "id": "05a39696", @@ -87,26 +140,10 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "id": "4146fd2b", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:57.081102Z", - "iopub.status.busy": "2026-10-13T16:18:57.080960Z", - "iopub.status.idle": "2026-10-13T16:18:57.093995Z", - "shell.execute_reply": "2026-10-13T16:18:57.093426Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "0.2482 ± 0.0040 rad in [0.1287, 0.3954]\n", - "14.2210 ± 0.2315 deg in [7.3762, 22.6531]\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ "d = Parameter(name='d', value=3.1356, unit='angstrom', variance=0.05**2, min=2.0, max=6.0)\n", "wavelength = DescriptorNumber(name='wavelength', value=1.5406, unit='angstrom', variance=0.0)\n", @@ -136,16 +173,9 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "id": "b41c68a2", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:57.095406Z", - "iopub.status.busy": "2026-10-13T16:18:57.095240Z", - "iopub.status.idle": "2026-10-13T16:18:57.098272Z", - "shell.execute_reply": "2026-10-13T16:18:57.097847Z" - } - }, + "metadata": {}, "outputs": [], "source": [ "d = Parameter(name='d', value=3.1356, variance=0.0005**2, unit='angstrom')\n", @@ -169,27 +199,25 @@ "\n", "Ask for degrees with the keyword `unit='deg'`.\n", "\n", - "\n", - "\n", - "
\n", - "Solution\n", - "\n", - "```python\n", - "theta = es.math.arcsin(wavelength / (2 * d), unit='deg')\n", - "print(show(theta))\n", - "```\n", - "\n", - "Expected result:\n", - "\n", - "```text\n", - "14.2210 ± 0.0023 deg in [-90.0000, 90.0000]\n", - "```\n", - "\n", - "The bounds of the arcsine are $\\pm 90^\\circ$ for an unbounded input.\n", - "\n", "
" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "75909c1a", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "%%solution The bounds of the arcsine are $\\pm 90^\\circ$ for an unbounded input.\n", + "theta = es.math.arcsin(wavelength / (2 * d), unit='deg')\n", + "print(show(theta))" + ] + }, { "cell_type": "markdown", "id": "068f27ab", @@ -203,25 +231,10 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "id": "45087906", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:57.099546Z", - "iopub.status.busy": "2026-10-13T16:18:57.099395Z", - "iopub.status.idle": "2026-10-13T16:18:57.102484Z", - "shell.execute_reply": "2026-10-13T16:18:57.102072Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "1.0472 ± 0.0231 rad in [0.0000, 1.5708]\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ "ratio = Parameter(name='ratio', value=0.5, variance=0.02**2, min=0.0, max=1.0)\n", "print(show(es.math.arccos(ratio)))" @@ -237,25 +250,10 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "id": "817aef54", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:57.103572Z", - "iopub.status.busy": "2026-10-13T16:18:57.103458Z", - "iopub.status.idle": "2026-10-13T16:18:57.106383Z", - "shell.execute_reply": "2026-10-13T16:18:57.105989Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "5.7106 ± 0.2836 deg in [-90.0000, 90.0000]\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ "grade = Parameter(name='grade', value=0.10, variance=0.005**2)\n", "print(show(es.math.arctan(grade, unit='deg')))" @@ -273,16 +271,9 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": null, "id": "1180c5ba", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:57.107448Z", - "iopub.status.busy": "2026-10-13T16:18:57.107339Z", - "iopub.status.idle": "2026-10-13T16:18:57.110067Z", - "shell.execute_reply": "2026-10-13T16:18:57.109535Z" - } - }, + "metadata": {}, "outputs": [], "source": [ "rise = Parameter(name='rise', value=0.30, variance=0.01**2, unit='m')\n", @@ -306,25 +297,25 @@ "\n", "The ratio of two lengths in the same unit is dimensionless, which is what `arctan` needs.\n", "\n", - "\n", - "\n", - "
\n", - "Solution\n", - "\n", - "```python\n", - "angle = es.math.arctan(rise / run, unit='deg')\n", - "print(show(angle))\n", - "```\n", - "\n", - "Expected result:\n", - "\n", - "```text\n", - "14.0362 ± 0.5024 deg in [-90.0000, 90.0000]\n", - "```\n", - "\n", "
" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "dcf2bf11", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "%%solution\n", + "angle = es.math.arctan(rise / run, unit='deg')\n", + "print(show(angle))" + ] + }, { "cell_type": "markdown", "id": "e820e211", @@ -339,27 +330,10 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": null, "id": "99e676f2", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:57.111246Z", - "iopub.status.busy": "2026-10-13T16:18:57.111133Z", - "iopub.status.idle": "2026-10-13T16:18:57.118304Z", - "shell.execute_reply": "2026-10-13T16:18:57.117740Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "53.1301 ± 1.1459 deg in [36.8699, 68.1986]\n", - "126.8699 ± 1.1459 deg in [111.8014, 143.1301]\n", - "-53.1301 ± 1.1459 deg in [-68.1986, -36.8699]\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ "a = Parameter(name='a', value=3.0, variance=0.1**2, min=2.0, max=4.0)\n", "b = Parameter(name='b', value=4.0, variance=0.1**2, min=3.0, max=5.0)\n", @@ -383,28 +357,10 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": null, "id": "cc28134c", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:57.119498Z", - "iopub.status.busy": "2026-10-13T16:18:57.119358Z", - "iopub.status.idle": "2026-10-13T16:18:57.123311Z", - "shell.execute_reply": "2026-10-13T16:18:57.122922Z" - } - }, - "outputs": [ - { - "data": { - "text/plain": [ - "True" - ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" - } - ], + "metadata": {}, + "outputs": [], "source": [ "es.math.asin is es.math.arcsin" ] @@ -421,16 +377,9 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": null, "id": "bab72623", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:57.124436Z", - "iopub.status.busy": "2026-10-13T16:18:57.124313Z", - "iopub.status.idle": "2026-10-13T16:18:57.126910Z", - "shell.execute_reply": "2026-10-13T16:18:57.126485Z" - } - }, + "metadata": {}, "outputs": [], "source": [ "a = Parameter(name='a', value=1.5, variance=0.05**2)\n", @@ -454,27 +403,25 @@ "\n", "Use the keyword `unit='deg'` for degrees.\n", "\n", - "\n", - "\n", - "
\n", - "Solution\n", - "\n", - "```python\n", - "phase = es.math.arctan2(b, a, unit='deg')\n", - "print(show(phase))\n", - "```\n", - "\n", - "Expected result:\n", - "\n", - "```text\n", - "53.1301 ± 1.1459 deg in [-180.0000, 180.0000]\n", - "```\n", - "\n", - "The bounds are $\\pm 180^\\circ$, the full range of the function.\n", - "\n", "
" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "d7528ae8", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "%%solution The bounds are $\\pm 180^\\circ$, the full range of the function.\n", + "phase = es.math.arctan2(b, a, unit='deg')\n", + "print(show(phase))" + ] + }, { "cell_type": "markdown", "id": "dfb52523", @@ -499,16 +446,9 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": null, "id": "56a18498", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:57.127891Z", - "iopub.status.busy": "2026-10-13T16:18:57.127765Z", - "iopub.status.idle": "2026-10-13T16:18:57.130498Z", - "shell.execute_reply": "2026-10-13T16:18:57.129917Z" - } - }, + "metadata": {}, "outputs": [], "source": [ "d = Parameter(name='d', value=0.70, variance=0.01**2, unit='angstrom')\n", @@ -532,29 +472,27 @@ "\n", "Put the call in a `try` block and catch `ValueError`. Give the intermediate result a readable name (`ratio.name = 'ratio'`), because the error message names the parameter it was given.\n", "\n", - "\n", - "\n", - "
\n", - "Solution\n", - "\n", - "```python\n", + "
" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c05bcafc", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "%%solution The ratio is larger than 1, so no angle exists. Without the line that sets the name, the message would name an automatically generated parameter instead of `ratio`.\n", "ratio = wavelength / (2 * d)\n", "ratio.name = 'ratio'\n", "try:\n", " es.math.arcsin(ratio, unit='deg')\n", "except ValueError as error:\n", - " print(error)\n", - "```\n", - "\n", - "Expected result:\n", - "\n", - "```text\n", - "The value of 'ratio' (1.1004285714285715) is outside the domain [-1.0, 1.0] of arcsin().\n", - "```\n", - "\n", - "The ratio is larger than 1, so no angle exists. Without the line that sets the name, the message would name an automatically generated parameter instead of `ratio`.\n", - "\n", - "" + " print(error)" ] }, { @@ -570,27 +508,10 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": null, "id": "33dc5b11", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:57.131908Z", - "iopub.status.busy": "2026-10-13T16:18:57.131754Z", - "iopub.status.idle": "2026-10-13T16:18:57.138108Z", - "shell.execute_reply": "2026-10-13T16:18:57.137622Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "15.0000 ± 0.1002 deg in [5.0000, 60.0000]\n", - "variance after the round trip: 0.010050\n", - "variance of theta: 0.010000\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ "theta = Parameter(name='theta', value=15.0, unit='deg', variance=0.1**2, min=5.0, max=60.0)\n", "wavelength = DescriptorNumber(name='wavelength', value=1.5406, unit='angstrom', variance=0.0005**2)\n", @@ -628,79 +549,10 @@ }, { "cell_type": "code", - "execution_count": 13, - "id": "d0d3ee83", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:57.139174Z", - "iopub.status.busy": "2026-10-13T16:18:57.139046Z", - "iopub.status.idle": "2026-10-13T16:18:57.142016Z", - "shell.execute_reply": "2026-10-13T16:18:57.141463Z" - }, - "tags": [ - "hide-in-docs" - ] - }, - "outputs": [], - "source": [ - "import base64\n", - "\n", - "from IPython.display import IFrame\n", - "from IPython.display import display\n", - "\n", - "\n", - "def show_figure(fig: go.Figure, height: int = 420) -> None:\n", - " \"\"\"Show a Plotly figure as an embedded iframe, so that it renders in the docs.\"\"\"\n", - " page = (\n", - " go\n", - " .Figure(fig)\n", - " .update_layout(template='plotly_white')\n", - " .to_html(\n", - " full_html=True,\n", - " include_plotlyjs='cdn',\n", - " default_width='100%',\n", - " default_height=f'{height - 20}px',\n", - " )\n", - " )\n", - " encoded = base64.b64encode(page.encode('utf-8')).decode('ascii')\n", - " display(IFrame(src=f'data:text/html;base64,{encoded}', width='100%', height=height))" - ] - }, - { - "cell_type": "code", - "execution_count": 14, + "execution_count": null, "id": "bbb8a36d", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:57.143116Z", - "iopub.status.busy": "2026-10-13T16:18:57.142992Z", - "iopub.status.idle": "2026-10-13T16:18:57.365686Z", - "shell.execute_reply": "2026-10-13T16:18:57.365145Z" - } - }, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " \n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "metadata": {}, + "outputs": [], "source": [ "wavelengths = np.linspace(0.5, 6.0, 56)\n", "spacing = DescriptorNumber(name='d', value=3.1356, unit='angstrom', variance=0.0)\n", @@ -768,39 +620,10 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": null, "id": "50209b28", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:57.367098Z", - "iopub.status.busy": "2026-10-13T16:18:57.366970Z", - "iopub.status.idle": "2026-10-13T16:18:57.438606Z", - "shell.execute_reply": "2026-10-13T16:18:57.437984Z" - } - }, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " \n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "metadata": {}, + "outputs": [], "source": [ "imaginary = np.linspace(-3.0, 3.0, 61)\n", "real = Parameter(name='a', value=1.5, variance=0.05**2)\n", @@ -869,26 +692,10 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": null, "id": "d9c50356", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:57.439869Z", - "iopub.status.busy": "2026-10-13T16:18:57.439719Z", - "iopub.status.idle": "2026-10-13T16:18:57.443870Z", - "shell.execute_reply": "2026-10-13T16:18:57.443387Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "0.5236 ± 0.0058 rad in [-1.5708, 1.5708]\n", - "1.4293 ± 0.0354 rad in [-1.5708, 1.5708]\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ "for value in (0.5, 0.99):\n", " print(show(es.math.arcsin(Parameter(name='x', value=value, variance=0.005**2))))" diff --git a/docs/docs/tutorials/basics/maths-trigonometric.ipynb b/docs/docs/tutorials/basics/maths-trigonometric.ipynb index 8cd83ac0..d116bf72 100644 --- a/docs/docs/tutorials/basics/maths-trigonometric.ipynb +++ b/docs/docs/tutorials/basics/maths-trigonometric.ipynb @@ -22,16 +22,9 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "id": "08d5955d", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:58.661255Z", - "iopub.status.busy": "2026-10-13T16:18:58.661106Z", - "iopub.status.idle": "2026-10-13T16:18:59.565707Z", - "shell.execute_reply": "2026-10-13T16:18:59.564834Z" - } - }, + "metadata": {}, "outputs": [], "source": [ "import numpy as np\n", @@ -52,16 +45,9 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "id": "aec560fa", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:59.567552Z", - "iopub.status.busy": "2026-10-13T16:18:59.567264Z", - "iopub.status.idle": "2026-10-13T16:18:59.570435Z", - "shell.execute_reply": "2026-10-13T16:18:59.569995Z" - } - }, + "metadata": {}, "outputs": [], "source": [ "def show(number):\n", @@ -72,6 +58,73 @@ " return text" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "c235a46f", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "import base64\n", + "import contextlib\n", + "import html\n", + "import io\n", + "import re\n", + "\n", + "from IPython import get_ipython\n", + "from IPython.display import HTML\n", + "from IPython.display import IFrame\n", + "from IPython.display import display\n", + "from pygments import highlight\n", + "from pygments.formatters import HtmlFormatter\n", + "from pygments.lexers import PythonLexer\n", + "\n", + "\n", + "def show_figure(fig: go.Figure, height: int = 420) -> None:\n", + " \"\"\"Show a Plotly figure as an embedded iframe, so that it renders in the docs.\"\"\"\n", + " page = (\n", + " go\n", + " .Figure(fig)\n", + " .update_layout(template='plotly_white')\n", + " .to_html(\n", + " full_html=True,\n", + " include_plotlyjs='cdn',\n", + " default_width='100%',\n", + " default_height=f'{height - 20}px',\n", + " )\n", + " )\n", + " encoded = base64.b64encode(page.encode('utf-8')).decode('ascii')\n", + " display(IFrame(src=f'data:text/html;base64,{encoded}', width='100%', height=height))\n", + "\n", + "\n", + "def solution(note: str, source: str) -> None:\n", + " \"\"\"Run a solution in a copy of the notebook namespace and show it, folded, with its output.\"\"\"\n", + " code = source.strip('\\n')\n", + " namespace = dict(get_ipython().user_ns)\n", + " stream = io.StringIO()\n", + " with contextlib.redirect_stdout(stream):\n", + " exec(code, namespace)\n", + " parts = [\n", + " '
Solution',\n", + " highlight(code, PythonLexer(), HtmlFormatter()),\n", + " ]\n", + " result = stream.getvalue().rstrip('\\n')\n", + " if result:\n", + " parts.append('

Output:

' + html.escape(result) + '
')\n", + " if note.strip():\n", + " text = re.sub('`([^`]+)`', r'\\1', html.escape(note.strip()))\n", + " parts.append('

' + text + '

')\n", + " parts.append('
')\n", + " display(HTML(''.join(parts)))\n", + "\n", + "\n", + "get_ipython().register_magic_function(solution, magic_kind='cell')" + ] + }, { "cell_type": "markdown", "id": "30adc115", @@ -85,25 +138,10 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "id": "4e68d0b1", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:59.571661Z", - "iopub.status.busy": "2026-10-13T16:18:59.571524Z", - "iopub.status.idle": "2026-10-13T16:18:59.581631Z", - "shell.execute_reply": "2026-10-13T16:18:59.581038Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "UnitError: Trigonometric function requires rad or deg unit, got m.\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ "try:\n", " es.math.sin(Parameter(name='v', value=1.0, unit='m', variance=0.01))\n", @@ -132,25 +170,10 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "id": "9b160060", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:59.582966Z", - "iopub.status.busy": "2026-10-13T16:18:59.582814Z", - "iopub.status.idle": "2026-10-13T16:18:59.586966Z", - "shell.execute_reply": "2026-10-13T16:18:59.586512Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "0.2588 ± 0.0017 dimensionless in [0.0872, 0.8660]\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ "theta = Parameter(name='theta', value=15.0, unit='deg', variance=0.1**2, min=5.0, max=60.0)\n", "wavelength = DescriptorNumber(name='wavelength', value=1.5406, unit='angstrom', variance=0.0005**2)\n", @@ -169,26 +192,10 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "id": "a1242c23", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:59.588121Z", - "iopub.status.busy": "2026-10-13T16:18:59.587964Z", - "iopub.status.idle": "2026-10-13T16:18:59.592203Z", - "shell.execute_reply": "2026-10-13T16:18:59.591642Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "2.9762 ± 0.0194 Å in [0.8895, 8.8382]\n", - "2.1111 ± 0.0138 1/Å in [0.7109, 7.0640]\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ "d = wavelength / (2 * s)\n", "q = 4 * np.pi * s / wavelength\n", @@ -208,16 +215,9 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "id": "f6c4fdf5", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:59.593190Z", - "iopub.status.busy": "2026-10-13T16:18:59.593068Z", - "iopub.status.idle": "2026-10-13T16:18:59.595497Z", - "shell.execute_reply": "2026-10-13T16:18:59.595065Z" - } - }, + "metadata": {}, "outputs": [], "source": [ "theta = Parameter(name='theta', value=22.5, variance=0.2**2, unit='deg')\n", @@ -241,27 +241,25 @@ "\n", "$4\\pi$ is a plain number, `4 * np.pi`. Divide by the wavelength last, so that the unit becomes 1/Å.\n", "\n", - "\n", - "\n", - "
\n", - "Solution\n", - "\n", - "```python\n", - "q = 4 * np.pi * es.math.sin(theta) / wavelength\n", - "print(show(q))\n", - "```\n", - "\n", - "Expected result:\n", - "\n", - "```text\n", - "3.1227 ± 0.0263 1/Å in [-8.1600, 8.1600]\n", - "```\n", - "\n", - "The bounds are those of the sine, $\\pm 1$, scaled by $4\\pi/\\lambda$.\n", - "\n", "
" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "c33436eb", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "%%solution The bounds are those of the sine, $\\pm 1$, scaled by $4\\pi/\\lambda$.\n", + "q = 4 * np.pi * es.math.sin(theta) / wavelength\n", + "print(show(q))" + ] + }, { "cell_type": "markdown", "id": "c57aec0d", @@ -274,16 +272,9 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": null, "id": "b3c6c722", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:59.596736Z", - "iopub.status.busy": "2026-10-13T16:18:59.596603Z", - "iopub.status.idle": "2026-10-13T16:18:59.599359Z", - "shell.execute_reply": "2026-10-13T16:18:59.598815Z" - } - }, + "metadata": {}, "outputs": [], "source": [ "theta = Parameter(name='theta', value=22.5, variance=0.2**2, unit='deg')\n", @@ -307,25 +298,25 @@ "\n", "A `DescriptorNumber` and a `Parameter` can be combined; the result is a `Parameter`.\n", "\n", - "\n", - "\n", - "
\n", - "Solution\n", - "\n", - "```python\n", - "d = wavelength / (2 * es.math.sin(theta))\n", - "print(show(d))\n", - "```\n", - "\n", - "Expected result:\n", - "\n", - "```text\n", - "2.0121 ± 0.0170 Å\n", - "```\n", - "\n", "
" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "f9064daa", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "%%solution\n", + "d = wavelength / (2 * es.math.sin(theta))\n", + "print(show(d))" + ] + }, { "cell_type": "markdown", "id": "2b057253", @@ -338,26 +329,10 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": null, "id": "3c772429", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:59.600908Z", - "iopub.status.busy": "2026-10-13T16:18:59.600785Z", - "iopub.status.idle": "2026-10-13T16:18:59.604523Z", - "shell.execute_reply": "2026-10-13T16:18:59.604118Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "cos 0.8660 ± 0.0087 dimensionless in [0.0000, 1.0000]\n", - "tan 0.5774 ± 0.0233 dimensionless in [0.0000, inf]\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ "angle = Parameter(name='angle', value=30.0, unit='deg', variance=1.0, min=0.0, max=90.0)\n", "\n", @@ -386,16 +361,9 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": null, "id": "12721052", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:59.605734Z", - "iopub.status.busy": "2026-10-13T16:18:59.605568Z", - "iopub.status.idle": "2026-10-13T16:18:59.608210Z", - "shell.execute_reply": "2026-10-13T16:18:59.607814Z" - } - }, + "metadata": {}, "outputs": [], "source": [ "distance = Parameter(name='distance', value=1.50, variance=0.01**2, unit='m')\n", @@ -419,25 +387,25 @@ "\n", "A length times a dimensionless number is a length.\n", "\n", - "\n", - "\n", - "
\n", - "Solution\n", - "\n", - "```python\n", - "y = distance * es.math.tan(two_theta)\n", - "print(show(y))\n", - "```\n", - "\n", - "Expected result:\n", - "\n", - "```text\n", - "0.8660 ± 0.0091 m\n", - "```\n", - "\n", "
" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "4b8fa4e8", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "%%solution\n", + "y = distance * es.math.tan(two_theta)\n", + "print(show(y))" + ] + }, { "cell_type": "markdown", "id": "f326246c", @@ -452,26 +420,10 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": null, "id": "3bcf8d0e", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:59.609346Z", - "iopub.status.busy": "2026-10-13T16:18:59.609224Z", - "iopub.status.idle": "2026-10-13T16:18:59.613411Z", - "shell.execute_reply": "2026-10-13T16:18:59.612943Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "0.6033 ± 0.0268 dimensionless in [0.0022, 0.9194]\n", - "UnitError: sinc requires rad or dimensionless unit, got deg.\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ "x = Parameter(name='x', value=1.2, variance=0.05**2, min=0.5, max=3.0)\n", "print(show(es.math.sinc(x) ** 2))\n", @@ -494,16 +446,9 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": null, "id": "bf3fe263", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:59.614522Z", - "iopub.status.busy": "2026-10-13T16:18:59.614383Z", - "iopub.status.idle": "2026-10-13T16:18:59.617013Z", - "shell.execute_reply": "2026-10-13T16:18:59.616581Z" - } - }, + "metadata": {}, "outputs": [], "source": [ "x = Parameter(name='x', value=2.0, variance=0.1**2)" @@ -526,27 +471,25 @@ "\n", "Square the result with `**`.\n", "\n", - "\n", - "\n", - "
\n", - "Solution\n", - "\n", - "```python\n", - "intensity = es.math.sinc(x) ** 2\n", - "print(show(intensity))\n", - "```\n", - "\n", - "Expected result:\n", - "\n", - "```text\n", - "0.2067 ± 0.0396 dimensionless in [0.0000, 1.0000]\n", - "```\n", - "\n", - "The intensity lies between 0 and 1: $\\mathrm{sinc}$ is at most 1, and a square is never negative.\n", - "\n", "
" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "55ea0189", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "%%solution The intensity lies between 0 and 1: $\\mathrm{sinc}$ is at most 1, and a square is never negative.\n", + "intensity = es.math.sinc(x) ** 2\n", + "print(show(intensity))" + ] + }, { "cell_type": "markdown", "id": "33f557cd", @@ -560,25 +503,10 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": null, "id": "18c5cc41", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:59.618173Z", - "iopub.status.busy": "2026-10-13T16:18:59.618052Z", - "iopub.status.idle": "2026-10-13T16:18:59.621140Z", - "shell.execute_reply": "2026-10-13T16:18:59.620676Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "0.9848 ± 0.0030 dimensionless in [0.0000, 1.0000]\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ "wide = Parameter(name='wide', value=80.0, unit='deg', variance=1.0, min=0.0, max=180.0)\n", "print(show(es.math.sin(wide)))" @@ -601,79 +529,10 @@ }, { "cell_type": "code", - "execution_count": 13, - "id": "c235a46f", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:59.622204Z", - "iopub.status.busy": "2026-10-13T16:18:59.622087Z", - "iopub.status.idle": "2026-10-13T16:18:59.625177Z", - "shell.execute_reply": "2026-10-13T16:18:59.624784Z" - }, - "tags": [ - "hide-in-docs" - ] - }, - "outputs": [], - "source": [ - "import base64\n", - "\n", - "from IPython.display import IFrame\n", - "from IPython.display import display\n", - "\n", - "\n", - "def show_figure(fig: go.Figure, height: int = 420) -> None:\n", - " \"\"\"Show a Plotly figure as an embedded iframe, so that it renders in the docs.\"\"\"\n", - " page = (\n", - " go\n", - " .Figure(fig)\n", - " .update_layout(template='plotly_white')\n", - " .to_html(\n", - " full_html=True,\n", - " include_plotlyjs='cdn',\n", - " default_width='100%',\n", - " default_height=f'{height - 20}px',\n", - " )\n", - " )\n", - " encoded = base64.b64encode(page.encode('utf-8')).decode('ascii')\n", - " display(IFrame(src=f'data:text/html;base64,{encoded}', width='100%', height=height))" - ] - }, - { - "cell_type": "code", - "execution_count": 14, + "execution_count": null, "id": "cef20438", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:59.626260Z", - "iopub.status.busy": "2026-10-13T16:18:59.626150Z", - "iopub.status.idle": "2026-10-13T16:18:59.852165Z", - "shell.execute_reply": "2026-10-13T16:18:59.851629Z" - } - }, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " \n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "metadata": {}, + "outputs": [], "source": [ "angles = np.linspace(5.0, 60.0, 60)\n", "wavelength = DescriptorNumber(name='wavelength', value=1.5406, unit='angstrom', variance=0.0005**2)\n", @@ -741,39 +600,10 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": null, "id": "853de0a9", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:59.853280Z", - "iopub.status.busy": "2026-10-13T16:18:59.853151Z", - "iopub.status.idle": "2026-10-13T16:18:59.972713Z", - "shell.execute_reply": "2026-10-13T16:18:59.972224Z" - } - }, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " \n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "metadata": {}, + "outputs": [], "source": [ "sigma = 0.3\n", "angles = np.linspace(0.0, 2 * np.pi, 80)\n", @@ -815,26 +645,10 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": null, "id": "aa92fe31", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:59.974087Z", - "iopub.status.busy": "2026-10-13T16:18:59.973905Z", - "iopub.status.idle": "2026-10-13T16:18:59.981410Z", - "shell.execute_reply": "2026-10-13T16:18:59.980782Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "1.8369701987210297e-17\n", - "0.06105801493643515\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ "top = Parameter(name='top', value=np.pi / 2, unit='rad', variance=0.3**2)\n", "print(es.math.sin(top).error)\n", @@ -854,25 +668,10 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": null, "id": "2e1c67f8", - "metadata": { - "execution": { - "iopub.execute_input": "2026-10-13T16:18:59.982624Z", - "iopub.status.busy": "2026-10-13T16:18:59.982474Z", - "iopub.status.idle": "2026-10-13T16:18:59.985609Z", - "shell.execute_reply": "2026-10-13T16:18:59.985202Z" - } - }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "The value of 'v' (1.5707963267948966 rad) is within 0.1 rad of a tan asymptote (pi/2 + n*pi); the propagated variance would explode.\n" - ] - } - ], + "metadata": {}, + "outputs": [], "source": [ "try:\n", " es.math.tan(Parameter(name='v', value=np.pi / 2, unit='rad', variance=0.1**2))\n", diff --git a/docs/docs/tutorials/fitting-intro.ipynb b/docs/docs/tutorials/fitting-intro.ipynb index 536b7b9e..5c9bcb0d 100644 --- a/docs/docs/tutorials/fitting-intro.ipynb +++ b/docs/docs/tutorials/fitting-intro.ipynb @@ -58,6 +58,73 @@ " return text" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "5cbeaaac", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "import base64\n", + "import contextlib\n", + "import html\n", + "import io\n", + "import re\n", + "\n", + "from IPython import get_ipython\n", + "from IPython.display import HTML\n", + "from IPython.display import IFrame\n", + "from IPython.display import display\n", + "from pygments import highlight\n", + "from pygments.formatters import HtmlFormatter\n", + "from pygments.lexers import PythonLexer\n", + "\n", + "\n", + "def show_figure(fig: go.Figure, height: int = 420) -> None:\n", + " \"\"\"Show a Plotly figure as an embedded iframe, so that it renders in the docs.\"\"\"\n", + " page = (\n", + " go\n", + " .Figure(fig)\n", + " .update_layout(template='plotly_white')\n", + " .to_html(\n", + " full_html=True,\n", + " include_plotlyjs='cdn',\n", + " default_width='100%',\n", + " default_height=f'{height - 20}px',\n", + " )\n", + " )\n", + " encoded = base64.b64encode(page.encode('utf-8')).decode('ascii')\n", + " display(IFrame(src=f'data:text/html;base64,{encoded}', width='100%', height=height))\n", + "\n", + "\n", + "def solution(note: str, source: str) -> None:\n", + " \"\"\"Run a solution in a copy of the notebook namespace and show it, folded, with its output.\"\"\"\n", + " code = source.strip('\\n')\n", + " namespace = dict(get_ipython().user_ns)\n", + " stream = io.StringIO()\n", + " with contextlib.redirect_stdout(stream):\n", + " exec(code, namespace)\n", + " parts = [\n", + " '
Solution',\n", + " highlight(code, PythonLexer(), HtmlFormatter()),\n", + " ]\n", + " result = stream.getvalue().rstrip('\\n')\n", + " if result:\n", + " parts.append('

Output:

' + html.escape(result) + '
')\n", + " if note.strip():\n", + " text = re.sub('`([^`]+)`', r'\\1', html.escape(note.strip()))\n", + " parts.append('

' + text + '

')\n", + " parts.append('
')\n", + " display(HTML(''.join(parts)))\n", + "\n", + "\n", + "get_ipython().register_magic_function(solution, magic_kind='cell')" + ] + }, { "cell_type": "markdown", "id": "6031ff4d", @@ -155,40 +222,6 @@ "`show_figure(fig)`, which is defined in a hidden cell; in your own notebook `fig.show()` does the same." ] }, - { - "cell_type": "code", - "execution_count": null, - "id": "5cbeaaac", - "metadata": { - "tags": [ - "hide-in-docs" - ] - }, - "outputs": [], - "source": [ - "import base64\n", - "\n", - "from IPython.display import IFrame\n", - "from IPython.display import display\n", - "\n", - "\n", - "def show_figure(fig: go.Figure, height: int = 420) -> None:\n", - " \"\"\"Show a Plotly figure as an embedded iframe, so that it renders in the docs.\"\"\"\n", - " page = (\n", - " go\n", - " .Figure(fig)\n", - " .update_layout(template='plotly_white')\n", - " .to_html(\n", - " full_html=True,\n", - " include_plotlyjs='cdn',\n", - " default_width='100%',\n", - " default_height=f'{height - 20}px',\n", - " )\n", - " )\n", - " encoded = base64.b64encode(page.encode('utf-8')).decode('ascii')\n", - " display(IFrame(src=f'data:text/html;base64,{encoded}', width='100%', height=height))" - ] - }, { "cell_type": "code", "execution_count": null, @@ -305,25 +338,25 @@ "\n", "Use `es.math.arctan`; radians are the default.\n", "\n", - "\n", - "\n", - "
\n", - "Solution\n", - "\n", - "```python\n", - "angle = es.math.arctan(slope)\n", - "print(show(angle))\n", - "```\n", - "\n", - "Expected result:\n", - "\n", - "```text\n", - "0.5411 ± 0.0100 rad in [-1.5708, 1.5708]\n", - "```\n", - "\n", "
" ] }, + { + "cell_type": "code", + "execution_count": null, + "id": "e84a12f7", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "%%solution\n", + "angle = es.math.arctan(slope)\n", + "print(show(angle))" + ] + }, { "cell_type": "markdown", "id": "377d6c64", @@ -361,24 +394,24 @@ "\n", "Write `1 / slope`.\n", "\n", - "\n", - "\n", - "
\n", - "Solution\n", - "\n", - "```python\n", - "inverse = 1 / slope\n", - "print(show(inverse))\n", - "```\n", - "\n", - "Expected result:\n", - "\n", - "```text\n", - "1.6641 ± 0.0377 dimensionless\n", - "```\n", - "\n", "
" ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7e60e1bc", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "%%solution\n", + "inverse = 1 / slope\n", + "print(show(inverse))" + ] } ], "metadata": { @@ -393,4 +426,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} From ff80c0d17e4ea07c40019d502da128f80a4e34be Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 18:36:29 +0000 Subject: [PATCH 05/25] test(tutorials): cover the maths and fitting tutorial notebooks Replay the six notebooks in-process and check that: - they hold no stored outputs, template delimiters or authoring leftovers, and that every exercise has givens, folded hints and a hidden solution cell; - the %%solution helper runs a solution in a copy of the namespace, escapes its output and raises its errors; - every solution prints the result of a closed-form reference (value, error, unit and bounds), including the fitted-line exercises. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01PHwoT3iPrULYWjTrLPuhan --- tests/unit/tutorials/__init__.py | 2 + tests/unit/tutorials/conftest.py | 77 +++++ tests/unit/tutorials/notebooks.py | 103 ++++++ .../test_tutorial_exercise_solutions.py | 302 ++++++++++++++++++ .../test_tutorial_notebooks_structure.py | 153 +++++++++ .../test_tutorial_solution_helper.py | 136 ++++++++ 6 files changed, 773 insertions(+) create mode 100644 tests/unit/tutorials/__init__.py create mode 100644 tests/unit/tutorials/conftest.py create mode 100644 tests/unit/tutorials/notebooks.py create mode 100644 tests/unit/tutorials/test_tutorial_exercise_solutions.py create mode 100644 tests/unit/tutorials/test_tutorial_notebooks_structure.py create mode 100644 tests/unit/tutorials/test_tutorial_solution_helper.py diff --git a/tests/unit/tutorials/__init__.py b/tests/unit/tutorials/__init__.py new file mode 100644 index 00000000..4e798e20 --- /dev/null +++ b/tests/unit/tutorials/__init__.py @@ -0,0 +1,2 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause diff --git a/tests/unit/tutorials/conftest.py b/tests/unit/tutorials/conftest.py new file mode 100644 index 00000000..e48439d8 --- /dev/null +++ b/tests/unit/tutorials/conftest.py @@ -0,0 +1,77 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +from __future__ import annotations + +import sys +import types + +import IPython +import IPython.display +import pytest + +from easyscience import global_object + +from .notebooks import SOLUTION_MAGIC +from .notebooks import FakeShell +from .notebooks import Solution +from .notebooks import is_hidden_helper +from .notebooks import is_solution_cell +from .notebooks import load_notebook +from .notebooks import parse_solution_html +from .notebooks import source_of + + +@pytest.fixture +def notebook_environment(): + """Patch IPython and plotly so that notebook cells can run without a kernel. + + Yields the fake shell and the list that collects everything the notebooks display. + """ + shown: list = [] + shell = FakeShell() + plotly = types.ModuleType('plotly') + graph_objects = types.ModuleType('plotly.graph_objects') + graph_objects.Figure = object + plotly.graph_objects = graph_objects + with pytest.MonkeyPatch.context() as patch: + patch.setattr(IPython, 'get_ipython', lambda: shell) + patch.setattr(IPython.display, 'display', shown.append) + patch.setitem(sys.modules, 'plotly', plotly) + patch.setitem(sys.modules, 'plotly.graph_objects', graph_objects) + yield shell, shown + + +@pytest.fixture +def run_notebook(notebook_environment): + """Return a function that replays a notebook and returns its solutions, in order. + + Cells that draw a chart are skipped, as no exercise depends on them. + """ + shell, shown = notebook_environment + + def run(name: str) -> list[Solution]: + global_object.map._clear() + shell.user_ns.clear() + solutions: list[Solution] = [] + exercise = '' + for cell in load_notebook(name)['cells']: + source = source_of(cell) + if cell['cell_type'] == 'markdown': + if source.startswith('### Exercise'): + exercise = source.splitlines()[0].removeprefix('### ') + continue + if is_solution_cell(cell): + header, _, code = source.partition('\n') + note = header.removeprefix(SOLUTION_MAGIC).strip() + shown.clear() + shell.magics[('cell', 'solution')](note, code) + assert len(shown) == 1, 'a solution must display exactly one object' + page = shown[0].data + shown_code, output = parse_solution_html(page) + solutions.append(Solution(name, exercise, note, shown_code, page, output)) + elif 'show_figure(' not in source or is_hidden_helper(cell): + exec(source, shell.user_ns) + return solutions + + return run diff --git a/tests/unit/tutorials/notebooks.py b/tests/unit/tutorials/notebooks.py new file mode 100644 index 00000000..30fc995a --- /dev/null +++ b/tests/unit/tutorials/notebooks.py @@ -0,0 +1,103 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +"""Helpers to read and replay the tutorial notebooks without a Jupyter kernel. + +The notebooks keep no stored outputs. Their exercise solutions are cells that start with the +``%%solution`` cell magic, which runs the solution and shows the code with its output. The +helpers here replay a notebook cell by cell, with a minimal stand-in for the IPython shell, and +hand back what every solution produced. +""" + +from __future__ import annotations + +import html +import json +import re +from dataclasses import dataclass +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[3] +TUTORIALS_DIR = PROJECT_ROOT / 'docs' / 'docs' / 'tutorials' + +MATHS_NOTEBOOKS = [ + 'basics/maths-arithmetic', + 'basics/maths-exp-log', + 'basics/maths-trigonometric', + 'basics/maths-inverse-trigonometric', + 'basics/maths-hyperbolic', +] +FITTING_NOTEBOOKS = ['fitting-intro'] +NOTEBOOKS = MATHS_NOTEBOOKS + FITTING_NOTEBOOKS + +# Number of exercises (and so of folded solutions) in each notebook. +EXERCISES = { + 'basics/maths-arithmetic': 4, + 'basics/maths-exp-log': 4, + 'basics/maths-trigonometric': 4, + 'basics/maths-inverse-trigonometric': 4, + 'basics/maths-hyperbolic': 4, + 'fitting-intro': 2, +} + +SOLUTION_MAGIC = '%%solution' +HIDDEN_TAG = 'hide-in-docs' + + +def load_notebook(name: str) -> dict: + """Read a tutorial notebook, given as its path below the tutorials folder without suffix.""" + return json.loads((TUTORIALS_DIR / f'{name}.ipynb').read_text(encoding='utf-8')) + + +def source_of(cell: dict) -> str: + """Return the source of a notebook cell as one string.""" + return ''.join(cell['source']) + + +def is_solution_cell(cell: dict) -> bool: + """Whether the cell is a code cell that runs a folded exercise solution.""" + return cell['cell_type'] == 'code' and source_of(cell).startswith(SOLUTION_MAGIC) + + +def is_hidden_helper(cell: dict) -> bool: + """Whether the cell is the hidden code cell that defines the notebook helpers.""" + tags = cell['metadata'].get('tags', []) + return cell['cell_type'] == 'code' and HIDDEN_TAG in tags and not is_solution_cell(cell) + + +class FakeShell: + """The part of an IPython shell that the notebook helpers use.""" + + def __init__(self) -> None: + self.user_ns: dict = {} + self.magics: dict = {} + + def register_magic_function(self, func, magic_kind='line', magic_name=None) -> None: + self.magics[(magic_kind, magic_name or func.__name__)] = func + + +@dataclass +class Solution: + """What one ``%%solution`` cell showed.""" + + notebook: str + exercise: str + note: str + code: str + html: str + output: str | None + + +_HIGHLIGHT = re.compile(r'
(.*?)
', re.S) +_OUTPUT = re.compile(r'

Output:

(.*?)
', re.S) +_TAGS = re.compile(r'<[^>]+>') + + +def parse_solution_html(page: str) -> tuple[str, str | None]: + """Return the code and the output text from the HTML of a folded solution.""" + code = _HIGHLIGHT.search(page) + output = _OUTPUT.search(page) + return ( + html.unescape(_TAGS.sub('', code.group(1))) if code else '', + html.unescape(output.group(1)) if output else None, + ) diff --git a/tests/unit/tutorials/test_tutorial_exercise_solutions.py b/tests/unit/tutorials/test_tutorial_exercise_solutions.py new file mode 100644 index 00000000..8b9f1700 --- /dev/null +++ b/tests/unit/tutorials/test_tutorial_exercise_solutions.py @@ -0,0 +1,302 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +"""Results shown by the exercise solutions of the tutorial notebooks. + +The solutions are run when a notebook is executed, so the documentation shows whatever they +print. These tests replay the notebooks and compare each result with a closed-form reference +(value and error propagated by hand, as in the exercise hints), so that a change to the library +cannot silently change what the tutorials teach. +""" + +import math +import re + +import numpy as np +import pytest + +from .notebooks import EXERCISES +from .notebooks import FITTING_NOTEBOOKS +from .notebooks import MATHS_NOTEBOOKS +from .notebooks import NOTEBOOKS +from .notebooks import Solution + +pytestmark = pytest.mark.fast + +# Results are printed with four decimals, so a reference can be off by half a unit of the last one. +PRINT_TOLERANCE = 5.1e-5 + +RESULT = re.compile(r'^(?P-?\d+\.\d+) ± (?P\d+\.\d+) (?P.*)$') + + +def _parse(output: str) -> tuple[float, float, str]: + """Split ``value ± error unit [in [min, max]]`` into its parts.""" + match = RESULT.match(output) + assert match, output + return float(match['value']), float(match['error']), match['rest'] + + +def _rad(degrees: float) -> float: + return math.radians(degrees) + + +def _quadrature(*terms: float) -> float: + return math.sqrt(sum(term**2 for term in terms)) + + +# Arithmetic: sums, differences, quotients and powers. +_THETA = _rad(22.5) +_SIGMA_THETA = _rad(0.2) +_RATIO_SIGMA = 0.25 * _quadrature(0.01 / 0.3, 0.02 / 1.2) +_BRAGG_RATIO = 1.5406 / (2 * 3.1356) +_ARG_ERF = 1.0 / (0.8 * math.sqrt(2)) +_SINC = math.sin(2.0) / 2.0 +_DSINC = (2.0 * math.cos(2.0) - math.sin(2.0)) / 4.0 +_TANH = math.tanh(0.8) + +# (notebook, exercise number, value, error, unit and bounds as printed) +REFERENCES = [ + ('basics/maths-arithmetic', 1, 2.0, _quadrature(0.02, 0.03), 'm'), + ('basics/maths-arithmetic', 2, 1.075, _quadrature(0.02, 0.005), 'm'), + ('basics/maths-arithmetic', 3, 2.0, 2.0 * _quadrature(0.1 / 3.0, 0.05 / 1.5), 'm/s'), + ( + 'basics/maths-arithmetic', + 4, + 4 * math.pi, + 2 * math.pi * 2.0 * 0.1, + 'cm^2 in [0.0000, inf]', + ), + ( + 'basics/maths-exp-log', + 1, + math.exp(-1.0), + math.exp(-1.0) * _quadrature(0.02 / 0.5, 0.1 / 2.0), + 'dimensionless in [0.0000, inf]', + ), + ( + 'basics/maths-exp-log', + 2, + 10 * math.log10(0.25), + 10 / math.log(10) * 10.0 / 250.0, + 'dimensionless', + ), + ( + 'basics/maths-exp-log', + 3, + 0.8, + 0.02 / (2 * 0.8), + 'dimensionless in [0.0000, inf]', + ), + ( + 'basics/maths-exp-log', + 4, + math.erf(_ARG_ERF), + 2 / math.sqrt(math.pi) * math.exp(-(_ARG_ERF**2)) * _ARG_ERF * _quadrature(0.05, 0.05), + 'dimensionless in [-1.0000, 1.0000]', + ), + ( + 'basics/maths-trigonometric', + 1, + 4 * math.pi * math.sin(_THETA) / 1.54, + 4 * math.pi * math.cos(_THETA) / 1.54 * _SIGMA_THETA, + '1/Å in [-8.1600, 8.1600]', + ), + ( + 'basics/maths-trigonometric', + 2, + 1.54 / (2 * math.sin(_THETA)), + 1.54 * math.cos(_THETA) / (2 * math.sin(_THETA) ** 2) * _SIGMA_THETA, + 'Å', + ), + ( + 'basics/maths-trigonometric', + 3, + 1.5 * math.tan(_rad(30.0)), + _quadrature( + math.tan(_rad(30.0)) * 0.01, + 1.5 / math.cos(_rad(30.0)) ** 2 * _rad(0.2), + ), + 'm', + ), + ( + 'basics/maths-trigonometric', + 4, + _SINC**2, + abs(2 * _SINC * _DSINC) * 0.1, + 'dimensionless in [0.0000, 1.0000]', + ), + ( + 'basics/maths-inverse-trigonometric', + 1, + math.degrees(math.asin(_BRAGG_RATIO)), + math.degrees( + _BRAGG_RATIO * 0.0005 / 3.1356 / math.sqrt(1 - _BRAGG_RATIO**2), + ), + 'deg in [-90.0000, 90.0000]', + ), + ( + 'basics/maths-inverse-trigonometric', + 2, + math.degrees(math.atan(0.25)), + math.degrees(_RATIO_SIGMA / (1 + 0.25**2)), + 'deg in [-90.0000, 90.0000]', + ), + ( + 'basics/maths-inverse-trigonometric', + 3, + math.degrees(math.atan2(2.0, 1.5)), + math.degrees(0.05 * math.hypot(1.5, 2.0) / (1.5**2 + 2.0**2)), + 'deg in [-180.0000, 180.0000]', + ), + ( + 'basics/maths-hyperbolic', + 1, + _TANH, + (1 - _TANH**2) * 0.05, + 'dimensionless in [0.0000, 0.9951]', + ), + ( + 'basics/maths-hyperbolic', + 2, + math.atanh(0.5), + 0.02 / (1 - 0.5**2), + 'dimensionless', + ), +] + +# Exercises that print something other than a value with its error. +MESSAGES = { + ('basics/maths-hyperbolic', 3): '1.0', + ('basics/maths-hyperbolic', 4): ( + "The value of 'x' (0.5) is outside the domain [1.0, inf] of arccosh()." + ), +} + + +@pytest.fixture +def solutions(run_notebook): + cache: dict[str, list[Solution]] = {} + + def get(name: str) -> list[Solution]: + if name not in cache: + cache[name] = run_notebook(name) + return cache[name] + + return get + + +@pytest.mark.parametrize('name', NOTEBOOKS) +def test_every_exercise_shows_a_solution_with_code_and_output(solutions, name): + shown = solutions(name) + + assert len(shown) == EXERCISES[name] + for solution in shown: + assert solution.exercise.startswith('Exercise') + assert solution.code.strip() + assert solution.output + + +@pytest.mark.parametrize('name', NOTEBOOKS) +def test_exercise_titles_are_numbered_in_order(solutions, name): + numbers = [int(re.match(r'Exercise (\d+)', s.exercise)[1]) for s in solutions(name)] + + assert numbers == list(range(1, EXERCISES[name] + 1)) + + +@pytest.mark.parametrize( + ('name', 'number', 'value', 'error', 'rest'), + REFERENCES, + ids=[f'{name.split("-", 1)[1]}-{number}' for name, number, *_ in REFERENCES], +) +def test_solution_matches_the_closed_form_reference(solutions, name, number, value, error, rest): + shown_value, shown_error, shown_rest = _parse(solutions(name)[number - 1].output) + + assert shown_value == pytest.approx(value, abs=PRINT_TOLERANCE) + assert shown_error == pytest.approx(error, abs=PRINT_TOLERANCE) + assert shown_rest == rest + + +@pytest.mark.parametrize(('name', 'number'), list(MESSAGES)) +def test_solution_prints_the_expected_message(solutions, name, number): + assert solutions(name)[number - 1].output == MESSAGES[name, number] + + +def test_arcsin_outside_its_domain_names_the_intermediate_parameter(solutions): + output = solutions('basics/maths-inverse-trigonometric')[3].output + + ratio = 1.5406 / (2 * 0.70) + match = re.fullmatch( + r"The value of 'ratio' \((?P[\d.]+)\) is outside the domain " + r'\[-1\.0, 1\.0\] of arcsin\(\)\.', + output, + ) + assert match, output + assert float(match['value']) == pytest.approx(ratio) + + +@pytest.mark.parametrize( + ('name', 'number'), + [('basics/maths-hyperbolic', 3)], +) +def test_the_lowest_value_of_cosh_is_one(solutions, name, number): + assert float(solutions(name)[number - 1].output) == 1.0 + + +@pytest.mark.parametrize( + ('name', 'number', 'text'), + [ + ('basics/maths-exp-log', 1, 'The lower bound 0 appears because an exponential is never'), + ('basics/maths-hyperbolic', 1, 'The upper bound is $\\tanh(3)$'), + ('basics/maths-inverse-trigonometric', 4, 'Without the line that sets the name'), + ], +) +def test_notes_are_shown_after_the_output(solutions, name, number, text): + page = solutions(name)[number - 1].html + + assert page.index('Output:') < page.index(text) + + +@pytest.fixture +def fitted_line(): + """Weighted straight-line fit of the simulated data of the fitting tutorial, in closed form. + + The error is the one a least-squares fit reports: the covariance of the weighted fit scaled + by the reduced chi-squared. + """ + rng = np.random.default_rng(seed=20261013) + noise = 0.3 + x = np.linspace(0.0, 10.0, 40) + y = 0.6 * x + 1.0 + rng.normal(scale=noise, size=x.size) + design = np.column_stack([x, np.ones_like(x)]) + covariance = np.linalg.inv(design.T @ design / noise**2) + slope, intercept = covariance @ (design.T @ y / noise**2) + residuals = (y - design @ [slope, intercept]) / noise + reduced_chi_squared = residuals @ residuals / (x.size - 2) + return slope, math.sqrt(covariance[0, 0] * reduced_chi_squared) + + +@pytest.mark.parametrize('name', FITTING_NOTEBOOKS) +def test_derived_quantities_follow_the_fitted_slope(solutions, fitted_line, name): + slope, slope_error = fitted_line + angle, inverse = solutions(name) + + angle_value, angle_error, angle_rest = _parse(angle.output) + inverse_value, inverse_error, inverse_rest = _parse(inverse.output) + + assert angle_value == pytest.approx(math.atan(slope), abs=2e-4) + assert angle_error == pytest.approx(slope_error / (1 + slope**2), abs=2e-4) + assert angle_rest == 'rad in [-1.5708, 1.5708]' + assert inverse_value == pytest.approx(1 / slope, abs=2e-4) + assert inverse_error == pytest.approx(slope_error / slope**2, abs=2e-4) + assert inverse_rest == 'dimensionless' + + +def test_the_references_cover_every_numeric_exercise(): + covered = {(name, number) for name, number, *_ in REFERENCES} | set(MESSAGES) + # The fitting tutorial is covered by its own test and the inverse-trigonometric message by + # a dedicated one. + expected = { + (name, number) for name in MATHS_NOTEBOOKS for number in range(1, EXERCISES[name] + 1) + } - {('basics/maths-inverse-trigonometric', 4)} + + assert covered == expected diff --git a/tests/unit/tutorials/test_tutorial_notebooks_structure.py b/tests/unit/tutorials/test_tutorial_notebooks_structure.py new file mode 100644 index 00000000..05fe8ff0 --- /dev/null +++ b/tests/unit/tutorials/test_tutorial_notebooks_structure.py @@ -0,0 +1,153 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +"""Structure of the maths and fitting tutorial notebooks. + +The notebooks are downloadable and are rendered by the documentation build, so they have to +stay free of stored outputs, of template delimiters and of authoring leftovers. +""" + +import re + +import pytest + +from .notebooks import EXERCISES +from .notebooks import NOTEBOOKS +from .notebooks import PROJECT_ROOT +from .notebooks import TUTORIALS_DIR +from .notebooks import is_hidden_helper +from .notebooks import is_solution_cell +from .notebooks import load_notebook +from .notebooks import source_of + +pytestmark = pytest.mark.fast + + +@pytest.fixture(params=NOTEBOOKS) +def name(request): + return request.param + + +@pytest.fixture +def cells(name): + return load_notebook(name)['cells'] + + +def _exercise_blocks(cells): + """Return the indices of the exercise headings.""" + return [ + i + for i, cell in enumerate(cells) + if cell['cell_type'] == 'markdown' and source_of(cell).startswith('### Exercise') + ] + + +class TestNoStoredState: + def test_notebook_is_nbformat_4(self, name): + notebook = load_notebook(name) + + assert notebook['nbformat'] == 4 + assert notebook['nbformat_minor'] >= 5 + + def test_no_outputs_are_stored(self, cells): + code_cells = [cell for cell in cells if cell['cell_type'] == 'code'] + + assert code_cells + assert all(cell['outputs'] == [] for cell in code_cells) + + def test_no_execution_state_is_stored(self, cells): + code_cells = [cell for cell in cells if cell['cell_type'] == 'code'] + + assert all(cell['execution_count'] is None for cell in code_cells) + assert all('execution' not in cell['metadata'] for cell in code_cells) + + def test_cell_ids_are_unique(self, cells): + ids = [cell['id'] for cell in cells] + + assert len(ids) == len(set(ids)) + + +class TestNoLeftovers: + def test_no_template_delimiters_in_the_file(self, name): + # The documentation build runs a template engine over the raw notebook text. + text = (TUTORIALS_DIR / f'{name}.ipynb').read_text(encoding='utf-8') + + assert not re.search(r'\{\{|\{%|\{#', text) + + def test_no_authoring_markers(self, cells): + text = '\n'.join(source_of(cell) for cell in cells) + + assert not re.search(r'\b(NOTE|TODO|FIXME|XXX)\b\(?', text) + + def test_the_old_pasted_results_are_gone(self, cells): + text = '\n'.join(source_of(cell) for cell in cells) + + assert 'Expected result' not in text + + +class TestExercises: + def test_number_of_exercises(self, name, cells): + assert len(_exercise_blocks(cells)) == EXERCISES[name] + + def test_every_exercise_has_givens_hints_and_a_solution_in_that_order(self, cells): + for start in _exercise_blocks(cells): + givens, hints, solution = cells[start + 1 : start + 4] + + assert givens['cell_type'] == 'code' + assert not is_solution_cell(givens) + assert hints['cell_type'] == 'markdown' + assert is_solution_cell(solution) + + def test_hints_are_folded_markdown_and_hold_no_solution(self, cells): + for start in _exercise_blocks(cells): + hints = source_of(cells[start + 2]) + + assert 'Hint 1' in hints + assert hints.count('
') == hints.count('
') + assert 'Solution' not in hints + assert '```' not in hints + + def test_solution_cells_are_hidden_in_the_docs_and_have_code(self, cells): + solutions = [cell for cell in cells if is_solution_cell(cell)] + + assert solutions + for cell in solutions: + assert cell['metadata'].get('tags') == ['hide-in-docs'] + header, _, code = source_of(cell).partition('\n') + assert header.startswith('%%solution') + assert code.strip() + + def test_solution_cells_keep_their_note_on_one_line(self, cells): + for cell in cells: + if is_solution_cell(cell): + assert '\n' not in source_of(cell).partition('\n')[0] + + +class TestHelperCell: + def test_there_is_exactly_one_hidden_helper_cell(self, cells): + assert sum(is_hidden_helper(cell) for cell in cells) == 1 + + def test_the_helper_is_defined_before_the_first_solution(self, cells): + helper = next(i for i, cell in enumerate(cells) if is_hidden_helper(cell)) + first = next(i for i, cell in enumerate(cells) if is_solution_cell(cell)) + + assert helper < first + + def test_the_helper_defines_and_registers_the_solution_magic(self, cells): + source = source_of(next(cell for cell in cells if is_hidden_helper(cell))) + + assert 'def solution(' in source + assert "register_magic_function(solution, magic_kind='cell')" in source + + def test_the_helper_has_no_comments(self, cells): + source = source_of(next(cell for cell in cells if is_hidden_helper(cell))) + + assert not [line for line in source.splitlines() if line.lstrip().startswith('#')] + + +class TestSite: + @pytest.mark.parametrize('name', NOTEBOOKS) + def test_notebook_is_in_the_navigation(self, name): + config = (PROJECT_ROOT / 'docs' / 'mkdocs.yml').read_text(encoding='utf-8') + + assert f'{name}.ipynb' in config diff --git a/tests/unit/tutorials/test_tutorial_solution_helper.py b/tests/unit/tutorials/test_tutorial_solution_helper.py new file mode 100644 index 00000000..b4246d87 --- /dev/null +++ b/tests/unit/tutorials/test_tutorial_solution_helper.py @@ -0,0 +1,136 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +"""The ``%%solution`` cell magic that the tutorial notebooks define in a hidden cell.""" + +from types import SimpleNamespace + +import pytest + +from .notebooks import is_hidden_helper +from .notebooks import load_notebook +from .notebooks import parse_solution_html +from .notebooks import source_of + +pytestmark = pytest.mark.fast + + +@pytest.fixture +def helper(notebook_environment): + """Run the hidden helper cell of a notebook and return the magic, the shell and the output.""" + shell, shown = notebook_environment + shell.user_ns['go'] = SimpleNamespace(Figure=object) + cell = next( + c for c in load_notebook('basics/maths-arithmetic')['cells'] if is_hidden_helper(c) + ) + exec(source_of(cell), shell.user_ns) + shown.clear() + return shell.magics[('cell', 'solution')], shell, shown + + +class TestRegistration: + def test_the_magic_is_registered_as_a_cell_magic(self, helper): + _, shell, _ = helper + + assert ('cell', 'solution') in shell.magics + + +class TestOutput: + def test_the_block_is_folded_and_titled_solution(self, helper): + solution, _, shown = helper + + solution('', 'print(1)') + + assert shown[0].data.startswith('
Solution') + assert shown[0].data.endswith('
') + + def test_the_code_is_shown_highlighted_and_unchanged(self, helper): + solution, _, shown = helper + + solution('', '\nvalue = 2 + 3\nprint(value)\n') + + page = shown[0].data + code, _ = parse_solution_html(page) + assert '
' in page + assert 'x & y')") + + assert '' not in shown[0].data + assert parse_solution_html(shown[0].data)[1] == 'x & y' + + def test_the_note_follows_the_output_and_turns_code_spans_into_code(self, helper): + solution, _, shown = helper + + solution('The `erf` is bounded by <1.', 'print(1)') + + page = shown[0].data + assert page.index('Output:') < page.index('erf') + assert '

The erf is bounded by <1.

' in page + + @pytest.mark.parametrize('note', ['', ' ']) + def test_a_missing_note_adds_nothing(self, helper, note): + solution, _, shown = helper + + solution(note, 'print(1)') + + assert shown[0].data.count('

') == 1 + + +class TestExecution: + def test_the_solution_can_use_the_names_of_the_notebook(self, helper): + solution, shell, shown = helper + shell.user_ns['given'] = 21 + + solution('', 'print(given * 2)') + + assert parse_solution_html(shown[0].data)[1] == '42' + + def test_names_made_by_the_solution_do_not_leak_into_the_notebook(self, helper): + solution, shell, _ = helper + + solution('', 'leaked = 1') + + assert 'leaked' not in shell.user_ns + + def test_names_of_the_notebook_are_not_overwritten(self, helper): + solution, shell, _ = helper + shell.user_ns['given'] = 1 + + solution('', 'given = 2') + + assert shell.user_ns['given'] == 1 + + def test_an_error_in_the_solution_is_raised(self, helper): + solution, _, shown = helper + + with pytest.raises(ZeroDivisionError): + solution('', '1 / 0') + assert shown == [] + + def test_standard_output_is_restored_after_an_error(self, helper, capsys): + solution, _, _ = helper + + with pytest.raises(ValueError, match='boom'): + solution('', "raise ValueError('boom')") + print('visible') + + assert capsys.readouterr().out == 'visible\n' From 4ab50a4b5c649a557db817d699658fd9b28031f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mark=20S=C3=B8rensen?= Date: Tue, 13 Oct 2026 22:27:34 +0200 Subject: [PATCH 06/25] docs(tutorials): make uncertainties consistent and add an error vs uncertainty note - arithmetic: give variances as squared errors so code and text agree (0.2**2 + 0.02**2 = 0.0404 m**2), with units in every term - arithmetic: print results with f-strings, including variance and bounds - arithmetic: add a note that error and uncertainty are not always the same - other maths notebooks: write remaining raw variances as squared errors Co-Authored-By: Claude Sonnet 5.5 --- .../tutorials/basics/maths-arithmetic.ipynb | 97 ++++++++++++++----- .../docs/tutorials/basics/maths-exp-log.ipynb | 14 +-- .../tutorials/basics/maths-hyperbolic.ipynb | 2 +- .../basics/maths-trigonometric.ipynb | 8 +- 4 files changed, 83 insertions(+), 38 deletions(-) diff --git a/docs/docs/tutorials/basics/maths-arithmetic.ipynb b/docs/docs/tutorials/basics/maths-arithmetic.ipynb index 03069b60..8253cf33 100644 --- a/docs/docs/tutorials/basics/maths-arithmetic.ipynb +++ b/docs/docs/tutorials/basics/maths-arithmetic.ipynb @@ -46,7 +46,7 @@ "id": "c67a7545", "metadata": {}, "source": [ - "The function `show` prints a number as value, error and unit, and, for a `Parameter` with finite bounds, the bounds." + "The examples below print numbers with f-strings, so that every value, uncertainty and unit is visible. The solutions of the exercises use a small function, `show`, that prints a number as one line: value, error and unit and, for a `Parameter` with finite bounds, the bounds." ] }, { @@ -139,7 +139,8 @@ "## Numbers with an uncertainty\n", "\n", "A `Parameter` has a value, a unit, an uncertainty and bounds. Here is a length of 2 m with an uncertainty of\n", - "0.2 m, which a fit may vary between 0 and 10 m:" + "0.2 m, which a fit may vary between 0 and 10 m. The uncertainty is given as a variance, so it is written as\n", + "the square of the error, $0.2^2 = 0.04\\ \\text{m}^2$:" ] }, { @@ -149,7 +150,7 @@ "metadata": {}, "outputs": [], "source": [ - "length = Parameter(name='length', value=2.0, unit='m', variance=0.04, min=0.0, max=10.0)\n", + "length = Parameter(name='length', value=2.0, unit='m', variance=0.2**2, min=0.0, max=10.0)\n", "length" ] }, @@ -158,7 +159,7 @@ "id": "07bc3f5b", "metadata": {}, "source": [ - "The uncertainty is stored as a **variance**, and `error` is its square root:" + "The uncertainty is stored as a **variance**, in the square of the unit, and `error` is its square root, in the unit itself:" ] }, { @@ -168,9 +169,10 @@ "metadata": {}, "outputs": [], "source": [ - "print(length.value, length.unit)\n", - "print(length.variance, length.error)\n", - "print(length.min, length.max)" + "print(f'value: {length.value} {length.unit}')\n", + "print(f'error: {length.error:.2f} {length.unit}')\n", + "print(f'variance: {length.variance:.2f} {length.unit}²')\n", + "print(f'bounds: [{length.min}, {length.max}] {length.unit}')" ] }, { @@ -189,9 +191,26 @@ "metadata": {}, "outputs": [], "source": [ - "scale = DescriptorNumber(name='scale', value=3.0, variance=0.01)\n", - "print(show(length))\n", - "print(show(scale))" + "scale = DescriptorNumber(name='scale', value=3.0, variance=0.1**2)\n", + "print(f'scale: {scale.value:.2f} ± {scale.error:.2f}')" + ] + }, + { + "cell_type": "markdown", + "id": "3897c86f", + "metadata": {}, + "source": [ + "### Error and uncertainty\n", + "\n", + "In everyday language the two words are used for the same thing, and so does EasyScience: the property that\n", + "holds the square root of the variance is called `error`. Strictly speaking they are not the same.\n", + "\n", + "- The **error** of a measurement is the difference between the measured value and the true value. The true\n", + " value is usually unknown, so the error of a real measurement can seldom be stated.\n", + "- The **uncertainty** is a quantitative estimate of the range around the measured value in which the true\n", + " value is expected to lie. It describes the precision, or the doubt, of the measurement.\n", + "\n", + "What EasyScience stores is an uncertainty, as a variance, and `error` is the standard uncertainty: one standard deviation. The standard deviation is a complete summary of the uncertainty only when the distribution of the values is symmetric and close to Gaussian. Then $\\pm 1\\sigma$ covers about 68 % of the possible values, and \"error\" and \"uncertainty\" can be used loosely. This is **not always the case**: for a skewed or heavy-tailed distribution, for a systematic effect that no repeated measurement reveals, or for a result that has gone through a strongly non-linear function, the same $\\pm$ number can be misleading. What the word means depends on the context." ] }, { @@ -215,9 +234,14 @@ "metadata": {}, "outputs": [], "source": [ - "width = Parameter(name='width', value=50.0, unit='cm', variance=4.0, min=10.0, max=100.0)\n", + "width = Parameter(name='width', value=50.0, unit='cm', variance=2.0**2, min=10.0, max=100.0)\n", "total = length + width\n", - "print(show(total))" + "\n", + "print(f'length: {length.value:.2f} ± {length.error:.2f} {length.unit}')\n", + "print(f'width: {width.value:.2f} ± {width.error:.2f} {width.unit}')\n", + "print(f'total: {total.value:.4f} ± {total.error:.4f} {total.unit}')\n", + "print(f'variance of the total: {total.variance:.4f} {total.unit}²')\n", + "print(f'bounds of the total: [{total.min:.2f}, {total.max:.2f}] {total.unit}')" ] }, { @@ -225,9 +249,15 @@ "id": "b9eead61", "metadata": {}, "source": [ - "The variances add, $0.2^2 + 0.02^2 = 0.0404\\ \\text{m}^2$, and so do the bounds: $[0 + 0.1,\\ 10 + 1.0]$ m.\n", + "The width is converted to metres first: $0.50 \\pm 0.02$ m. The variances then add, each one in square metres:\n", + "\n", + "$$\n", + "(0.2\\ \\text{m})^2 + (0.02\\ \\text{m})^2 = 0.04\\ \\text{m}^2 + 0.0004\\ \\text{m}^2 = 0.0404\\ \\text{m}^2 ,\n", + "$$\n", + "\n", + "so the error of the total is $\\sqrt{0.0404\\ \\text{m}^2} = 0.2010$ m. The bounds add as well, in metres: $[0 + 0.1,\\ 10 + 1.0]$ m.\n", "Subtraction combines the bounds the other way round: the lowest possible difference is the minimum of the\n", - "first minus the maximum of the second." + "first minus the maximum of the second, here $0 - 1.0 = -1.0$ m. The variances still add:" ] }, { @@ -238,7 +268,10 @@ "outputs": [], "source": [ "difference = length - width\n", - "print(show(difference))" + "\n", + "print(f'difference: {difference.value:.4f} ± {difference.error:.4f} {difference.unit}')\n", + "print(f'variance of the difference: {difference.variance:.4f} {difference.unit}²')\n", + "print(f'bounds of the difference: [{difference.min:.2f}, {difference.max:.2f}] {difference.unit}')" ] }, { @@ -262,7 +295,8 @@ "except Exception as error:\n", " print(f'{type(error).__name__}: {error}')\n", "\n", - "print(show(scale + 1.0))" + "shifted = scale + 1.0\n", + "print(f'scale + 1: {shifted.value:.2f} ± {shifted.error:.2f}')" ] }, { @@ -396,9 +430,11 @@ "metadata": {}, "outputs": [], "source": [ - "height = Parameter(name='height', value=1.5, unit='m', variance=0.01, min=1.0, max=3.0)\n", + "height = Parameter(name='height', value=1.5, unit='m', variance=0.1**2, min=1.0, max=3.0)\n", "area = length * height\n", - "print(show(area))" + "\n", + "print(f'area: {area.value:.4f} ± {area.error:.4f} {area.unit}')\n", + "print(f'bounds of the area: [{area.min:.2f}, {area.max:.2f}] {area.unit}')" ] }, { @@ -417,9 +453,11 @@ "metadata": {}, "outputs": [], "source": [ - "time = Parameter(name='time', value=4.0, unit='s', variance=0.0625, min=2.0, max=8.0)\n", + "time = Parameter(name='time', value=4.0, unit='s', variance=0.25**2, min=2.0, max=8.0)\n", "speed = length / time\n", - "print(show(speed))" + "\n", + "print(f'speed: {speed.value:.4f} ± {speed.error:.4f} {speed.unit}')\n", + "print(f'bounds of the speed: [{speed.min:.2f}, {speed.max:.2f}] {speed.unit}')" ] }, { @@ -438,7 +476,7 @@ "outputs": [], "source": [ "doubled = scale * 2\n", - "print(type(doubled).__name__, show(doubled))" + "print(f'{type(doubled).__name__}: {doubled.value:.2f} ± {doubled.error:.2f}')" ] }, { @@ -515,9 +553,13 @@ "metadata": {}, "outputs": [], "source": [ - "print(show(length**2))\n", - "print(show(-length))\n", - "print(show(abs(-length)))" + "squared = length**2\n", + "negated = -length\n", + "absolute = abs(-length)\n", + "\n", + "print(f'length**2: {squared.value:.2f} ± {squared.error:.2f} {squared.unit}, bounds [{squared.min:.1f}, {squared.max:.1f}]')\n", + "print(f'-length: {negated.value:.2f} ± {negated.error:.2f} {negated.unit}, bounds [{negated.min:.1f}, {negated.max:.1f}]')\n", + "print(f'abs(-length): {absolute.value:.2f} ± {absolute.error:.2f} {absolute.unit}, bounds [{absolute.min:.1f}, {absolute.max:.1f}]')" ] }, { @@ -711,8 +753,11 @@ "outputs": [], "source": [ "x = Parameter(name='x', value=10.0, variance=0.1**2, min=0.0, max=10.0)\n", - "print(show(x - x))\n", - "print(show((2 * x) / x))" + "difference = x - x\n", + "ratio = (2 * x) / x\n", + "\n", + "print(f'x - x: {difference.value:.4f} ± {difference.error:.4f}, bounds [{difference.min:.1f}, {difference.max:.1f}]')\n", + "print(f'(2x) / x: {ratio.value:.4f} ± {ratio.error:.4f}')" ] }, { diff --git a/docs/docs/tutorials/basics/maths-exp-log.ipynb b/docs/docs/tutorials/basics/maths-exp-log.ipynb index 44d71a14..6182bc77 100644 --- a/docs/docs/tutorials/basics/maths-exp-log.ipynb +++ b/docs/docs/tutorials/basics/maths-exp-log.ipynb @@ -237,7 +237,7 @@ "metadata": {}, "outputs": [], "source": [ - "intensity = Parameter(name='intensity', value=250.0, variance=100.0, min=100.0, max=400.0)\n", + "intensity = Parameter(name='intensity', value=250.0, variance=10.0**2, min=100.0, max=400.0)\n", "reference = DescriptorNumber(name='reference', value=1000.0, variance=0.0)\n", "\n", "decibel = 10 * es.math.log10(intensity / reference)\n", @@ -260,7 +260,7 @@ "outputs": [], "source": [ "try:\n", - " es.math.log10(Parameter(name='v', value=0.0, variance=0.01))\n", + " es.math.log10(Parameter(name='v', value=0.0, variance=0.1**2))\n", "except ValueError as error:\n", " print(error)" ] @@ -340,8 +340,8 @@ "metadata": {}, "outputs": [], "source": [ - "a = Parameter(name='a', value=3.0, variance=0.01, min=2.0, max=4.0)\n", - "b = Parameter(name='b', value=4.0, variance=0.01, min=3.0, max=5.0)\n", + "a = Parameter(name='a', value=3.0, variance=0.1**2, min=2.0, max=4.0)\n", + "b = Parameter(name='b', value=4.0, variance=0.1**2, min=3.0, max=5.0)\n", "\n", "amplitude = es.math.sqrt(a**2 + b**2)\n", "print(show(amplitude))" @@ -363,11 +363,11 @@ "metadata": {}, "outputs": [], "source": [ - "area = Parameter(name='area', value=9.0, unit='m**2', variance=0.09, min=4.0, max=16.0)\n", + "area = Parameter(name='area', value=9.0, unit='m**2', variance=0.3**2, min=4.0, max=16.0)\n", "print(show(es.math.sqrt(area)))\n", "\n", "try:\n", - " es.math.sqrt(Parameter(name='v', value=2.0, unit='m', variance=0.01))\n", + " es.math.sqrt(Parameter(name='v', value=2.0, unit='m', variance=0.1**2))\n", "except Exception as error:\n", " print(f'{type(error).__name__}: {error}')" ] @@ -701,7 +701,7 @@ "metadata": {}, "outputs": [], "source": [ - "wide = Parameter(name='wide', value=1.0, variance=1.0)\n", + "wide = Parameter(name='wide', value=1.0, variance=1.0**2)\n", "print(es.math.exp(wide).variance)\n", "print(np.exp(3) * (np.e - 1))" ] diff --git a/docs/docs/tutorials/basics/maths-hyperbolic.ipynb b/docs/docs/tutorials/basics/maths-hyperbolic.ipynb index 9382fd32..c88adaaa 100644 --- a/docs/docs/tutorials/basics/maths-hyperbolic.ipynb +++ b/docs/docs/tutorials/basics/maths-hyperbolic.ipynb @@ -672,7 +672,7 @@ "outputs": [], "source": [ "try:\n", - " es.math.sinh(Parameter(name='big', value=800.0, variance=1.0))\n", + " es.math.sinh(Parameter(name='big', value=800.0, variance=1.0**2))\n", "except ValueError as error:\n", " print(error)" ] diff --git a/docs/docs/tutorials/basics/maths-trigonometric.ipynb b/docs/docs/tutorials/basics/maths-trigonometric.ipynb index d116bf72..00c41087 100644 --- a/docs/docs/tutorials/basics/maths-trigonometric.ipynb +++ b/docs/docs/tutorials/basics/maths-trigonometric.ipynb @@ -144,7 +144,7 @@ "outputs": [], "source": [ "try:\n", - " es.math.sin(Parameter(name='v', value=1.0, unit='m', variance=0.01))\n", + " es.math.sin(Parameter(name='v', value=1.0, unit='m', variance=0.1**2))\n", "except Exception as error:\n", " print(f'{type(error).__name__}: {error}')" ] @@ -334,7 +334,7 @@ "metadata": {}, "outputs": [], "source": [ - "angle = Parameter(name='angle', value=30.0, unit='deg', variance=1.0, min=0.0, max=90.0)\n", + "angle = Parameter(name='angle', value=30.0, unit='deg', variance=1.0**2, min=0.0, max=90.0)\n", "\n", "for function in (es.math.cos, es.math.tan):\n", " print(f'{function.__name__:4}', show(function(angle)))" @@ -429,7 +429,7 @@ "print(show(es.math.sinc(x) ** 2))\n", "\n", "try:\n", - " es.math.sinc(Parameter(name='v', value=1.0, unit='deg', variance=0.01))\n", + " es.math.sinc(Parameter(name='v', value=1.0, unit='deg', variance=0.1**2))\n", "except Exception as error:\n", " print(f'{type(error).__name__}: {error}')" ] @@ -508,7 +508,7 @@ "metadata": {}, "outputs": [], "source": [ - "wide = Parameter(name='wide', value=80.0, unit='deg', variance=1.0, min=0.0, max=180.0)\n", + "wide = Parameter(name='wide', value=80.0, unit='deg', variance=1.0**2, min=0.0, max=180.0)\n", "print(show(es.math.sin(wide)))" ] }, From d27ca99cb5c714fa60d9d3742f4372649cb9162a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mark=20S=C3=B8rensen?= Date: Tue, 13 Oct 2026 22:43:48 +0200 Subject: [PATCH 07/25] docs(tutorials): style hints and solutions as Material admonitions Hints use the `question` type and solutions the `example` type, so the two are visually distinct. They stay collapsible HTML `

` elements, which the Material theme styles by class. Co-Authored-By: Claude Sonnet 5.5 --- .../tutorials/basics/maths-arithmetic.ipynb | 18 +++++++++--------- docs/docs/tutorials/basics/maths-exp-log.ipynb | 18 +++++++++--------- .../tutorials/basics/maths-hyperbolic.ipynb | 18 +++++++++--------- .../basics/maths-inverse-trigonometric.ipynb | 18 +++++++++--------- .../tutorials/basics/maths-trigonometric.ipynb | 18 +++++++++--------- docs/docs/tutorials/fitting-intro.ipynb | 10 +++++----- .../test_tutorial_notebooks_structure.py | 2 +- .../tutorials/test_tutorial_solution_helper.py | 2 +- 8 files changed, 52 insertions(+), 52 deletions(-) diff --git a/docs/docs/tutorials/basics/maths-arithmetic.ipynb b/docs/docs/tutorials/basics/maths-arithmetic.ipynb index 8253cf33..7aa7d22d 100644 --- a/docs/docs/tutorials/basics/maths-arithmetic.ipynb +++ b/docs/docs/tutorials/basics/maths-arithmetic.ipynb @@ -115,7 +115,7 @@ " with contextlib.redirect_stdout(stream):\n", " exec(code, namespace)\n", " parts = [\n", - " '
Solution',\n", + " '
Solution',\n", " highlight(code, PythonLexer(), HtmlFormatter()),\n", " ]\n", " result = stream.getvalue().rstrip('\\n')\n", @@ -325,14 +325,14 @@ "id": "f3021cd0", "metadata": {}, "source": [ - "
\n", + "
\n", "Hint 1\n", "\n", "Two lengths in the same unit can simply be added with `+`.\n", "\n", "
\n", "\n", - "
\n", + "
\n", "Hint 2\n", "\n", "The variances add: $\\sigma^2 = 0.02^2 + 0.03^2$, and `error` is the square root of the variance.\n", @@ -382,14 +382,14 @@ "id": "795bf0d5", "metadata": {}, "source": [ - "
\n", + "
\n", "Hint 1\n", "\n", "Subtract the gap from the tube; the units do not have to match.\n", "\n", "
\n", "\n", - "
\n", + "
\n", "Hint 2\n", "\n", "The second operand is converted to the unit of the first, so the result is in the unit of `tube`.\n", @@ -505,14 +505,14 @@ "id": "69dfb62e", "metadata": {}, "source": [ - "
\n", + "
\n", "Hint 1\n", "\n", "Speed is distance divided by time.\n", "\n", "
\n", "\n", - "
\n", + "
\n", "Hint 2\n", "\n", "Units divide as well: look at the unit of the result.\n", @@ -587,14 +587,14 @@ "id": "4373d991", "metadata": {}, "source": [ - "
\n", + "
\n", "Hint 1\n", "\n", "The area is $\\pi r^2$. `np.pi` is a plain number, and `**` takes a plain-number exponent.\n", "\n", "
\n", "\n", - "
\n", + "
\n", "Hint 2\n", "\n", "A plain number can multiply a `Parameter`; the result is again a `Parameter`.\n", diff --git a/docs/docs/tutorials/basics/maths-exp-log.ipynb b/docs/docs/tutorials/basics/maths-exp-log.ipynb index 6182bc77..ad099de1 100644 --- a/docs/docs/tutorials/basics/maths-exp-log.ipynb +++ b/docs/docs/tutorials/basics/maths-exp-log.ipynb @@ -110,7 +110,7 @@ " with contextlib.redirect_stdout(stream):\n", " exec(code, namespace)\n", " parts = [\n", - " '
Solution',\n", + " '
Solution',\n", " highlight(code, PythonLexer(), HtmlFormatter()),\n", " ]\n", " result = stream.getvalue().rstrip('\\n')\n", @@ -187,14 +187,14 @@ "id": "a81aebb7", "metadata": {}, "source": [ - "
\n", + "
\n", "Hint 1\n", "\n", "The product of `mu` and `t` has no unit: `1/cm` times `cm` cancels, which is why `exp` accepts it.\n", "\n", "
\n", "\n", - "
\n", + "
\n", "Hint 2\n", "\n", "Write the argument first, `-mu * t`, and pass it to `es.math.exp`.\n", @@ -291,14 +291,14 @@ "id": "203837fa", "metadata": {}, "source": [ - "
\n", + "
\n", "Hint 1\n", "\n", "Divide the two intensities first.\n", "\n", "
\n", "\n", - "
\n", + "
\n", "Hint 2\n", "\n", "`log10` takes a dimensionless number; the factor 10 is a plain number that multiplies the result.\n", @@ -397,14 +397,14 @@ "id": "0824d862", "metadata": {}, "source": [ - "
\n", + "
\n", "Hint 1\n", "\n", "The amplitude is the square root of the intensity.\n", "\n", "
\n", "\n", - "
\n", + "
\n", "Hint 2\n", "\n", "The relative error of a square root is half the relative error of its argument.\n", @@ -483,14 +483,14 @@ "id": "0a5dc5f7", "metadata": {}, "source": [ - "
\n", + "
\n", "Hint 1\n", "\n", "The fraction is $\\mathrm{erf}(a/(\\sigma\\sqrt{2}))$. The two lengths are in the same unit, so their ratio has no unit.\n", "\n", "
\n", "\n", - "
\n", + "
\n", "Hint 2\n", "\n", "`np.sqrt(2)` is a plain number; divide by `beam_sigma * np.sqrt(2)`.\n", diff --git a/docs/docs/tutorials/basics/maths-hyperbolic.ipynb b/docs/docs/tutorials/basics/maths-hyperbolic.ipynb index c88adaaa..d5989862 100644 --- a/docs/docs/tutorials/basics/maths-hyperbolic.ipynb +++ b/docs/docs/tutorials/basics/maths-hyperbolic.ipynb @@ -110,7 +110,7 @@ " with contextlib.redirect_stdout(stream):\n", " exec(code, namespace)\n", " parts = [\n", - " '
Solution',\n", + " '
Solution',\n", " highlight(code, PythonLexer(), HtmlFormatter()),\n", " ]\n", " result = stream.getvalue().rstrip('\\n')\n", @@ -202,14 +202,14 @@ "id": "ab3b8181", "metadata": {}, "source": [ - "
\n", + "
\n", "Hint 1\n", "\n", "Call `es.math.tanh` on the parameter.\n", "\n", "
\n", "\n", - "
\n", + "
\n", "Hint 2\n", "\n", "`show` prints the bounds as well, because they are finite.\n", @@ -283,14 +283,14 @@ "id": "8b8793d3", "metadata": {}, "source": [ - "
\n", + "
\n", "Hint 1\n", "\n", "The inverse of `tanh` is `arctanh`.\n", "\n", "
\n", "\n", - "
\n", + "
\n", "Hint 2\n", "\n", "The derivative of `arctanh` is $1/(1 - y^2)$, so the error is $0.02/(1 - 0.25)$.\n", @@ -362,14 +362,14 @@ "id": "437a35cf", "metadata": {}, "source": [ - "
\n", + "
\n", "Hint 1\n", "\n", "The `Parameter` returned by `es.math.cosh` has the attributes `min` and `max`.\n", "\n", "
\n", "\n", - "
\n", + "
\n", "Hint 2\n", "\n", "Look at the lower bound, `result.min`, and compare it with $\\cosh(-1) = 1.5431$.\n", @@ -429,14 +429,14 @@ "id": "17226bfb", "metadata": {}, "source": [ - "
\n", + "
\n", "Hint 1\n", "\n", "The domain of `arccosh` is $[1, \\infty)$.\n", "\n", "
\n", "\n", - "
\n", + "
\n", "Hint 2\n", "\n", "Put the call in a `try` block and catch `ValueError`.\n", diff --git a/docs/docs/tutorials/basics/maths-inverse-trigonometric.ipynb b/docs/docs/tutorials/basics/maths-inverse-trigonometric.ipynb index 95bd00e8..b078e043 100644 --- a/docs/docs/tutorials/basics/maths-inverse-trigonometric.ipynb +++ b/docs/docs/tutorials/basics/maths-inverse-trigonometric.ipynb @@ -110,7 +110,7 @@ " with contextlib.redirect_stdout(stream):\n", " exec(code, namespace)\n", " parts = [\n", - " '
Solution',\n", + " '
Solution',\n", " highlight(code, PythonLexer(), HtmlFormatter()),\n", " ]\n", " result = stream.getvalue().rstrip('\\n')\n", @@ -187,14 +187,14 @@ "id": "74d22d9e", "metadata": {}, "source": [ - "
\n", + "
\n", "Hint 1\n", "\n", "$\\theta = \\arcsin\\!\\left(\\lambda/(2d)\\right)$; form the ratio $\\lambda/(2d)$ first.\n", "\n", "
\n", "\n", - "
\n", + "
\n", "Hint 2\n", "\n", "Ask for degrees with the keyword `unit='deg'`.\n", @@ -285,14 +285,14 @@ "id": "7ed06073", "metadata": {}, "source": [ - "
\n", + "
\n", "Hint 1\n", "\n", "The tangent of the angle is the rise divided by the run.\n", "\n", "
\n", "\n", - "
\n", + "
\n", "Hint 2\n", "\n", "The ratio of two lengths in the same unit is dimensionless, which is what `arctan` needs.\n", @@ -391,14 +391,14 @@ "id": "33fcc29c", "metadata": {}, "source": [ - "
\n", + "
\n", "Hint 1\n", "\n", "The phase is `arctan2` of the imaginary part over the real part: the **first** argument is `b`.\n", "\n", "
\n", "\n", - "
\n", + "
\n", "Hint 2\n", "\n", "Use the keyword `unit='deg'` for degrees.\n", @@ -460,14 +460,14 @@ "id": "d991a5db", "metadata": {}, "source": [ - "
\n", + "
\n", "Hint 1\n", "\n", "Look at the value of `wavelength / (2 * d)`: which numbers does `arcsin` accept?\n", "\n", "
\n", "\n", - "
\n", + "
\n", "Hint 2\n", "\n", "Put the call in a `try` block and catch `ValueError`. Give the intermediate result a readable name (`ratio.name = 'ratio'`), because the error message names the parameter it was given.\n", diff --git a/docs/docs/tutorials/basics/maths-trigonometric.ipynb b/docs/docs/tutorials/basics/maths-trigonometric.ipynb index 00c41087..70a3984e 100644 --- a/docs/docs/tutorials/basics/maths-trigonometric.ipynb +++ b/docs/docs/tutorials/basics/maths-trigonometric.ipynb @@ -109,7 +109,7 @@ " with contextlib.redirect_stdout(stream):\n", " exec(code, namespace)\n", " parts = [\n", - " '
Solution',\n", + " '
Solution',\n", " highlight(code, PythonLexer(), HtmlFormatter()),\n", " ]\n", " result = stream.getvalue().rstrip('\\n')\n", @@ -229,14 +229,14 @@ "id": "21860200", "metadata": {}, "source": [ - "
\n", + "
\n", "Hint 1\n", "\n", "The angle has the unit `deg`, which `es.math.sin` understands; the result is dimensionless.\n", "\n", "
\n", "\n", - "
\n", + "
\n", "Hint 2\n", "\n", "$4\\pi$ is a plain number, `4 * np.pi`. Divide by the wavelength last, so that the unit becomes 1/Å.\n", @@ -286,14 +286,14 @@ "id": "cb1fb058", "metadata": {}, "source": [ - "
\n", + "
\n", "Hint 1\n", "\n", "Compute the sine first, then divide the wavelength by twice the sine.\n", "\n", "
\n", "\n", - "
\n", + "
\n", "Hint 2\n", "\n", "A `DescriptorNumber` and a `Parameter` can be combined; the result is a `Parameter`.\n", @@ -375,14 +375,14 @@ "id": "cf160039", "metadata": {}, "source": [ - "
\n", + "
\n", "Hint 1\n", "\n", "`es.math.tan` accepts the angle in degrees.\n", "\n", "
\n", "\n", - "
\n", + "
\n", "Hint 2\n", "\n", "A length times a dimensionless number is a length.\n", @@ -459,14 +459,14 @@ "id": "6d988441", "metadata": {}, "source": [ - "
\n", + "
\n", "Hint 1\n", "\n", "`sinc(x)` is $\\sin(x)/x$; it takes a dimensionless number or radians.\n", "\n", "
\n", "\n", - "
\n", + "
\n", "Hint 2\n", "\n", "Square the result with `**`.\n", diff --git a/docs/docs/tutorials/fitting-intro.ipynb b/docs/docs/tutorials/fitting-intro.ipynb index 5c9bcb0d..759879e8 100644 --- a/docs/docs/tutorials/fitting-intro.ipynb +++ b/docs/docs/tutorials/fitting-intro.ipynb @@ -109,7 +109,7 @@ " with contextlib.redirect_stdout(stream):\n", " exec(code, namespace)\n", " parts = [\n", - " '
Solution',\n", + " '
Solution',\n", " highlight(code, PythonLexer(), HtmlFormatter()),\n", " ]\n", " result = stream.getvalue().rstrip('\\n')\n", @@ -326,14 +326,14 @@ "id": "7ba4887b", "metadata": {}, "source": [ - "
\n", + "
\n", "Hint 1\n", "\n", "The slope is the tangent of the angle.\n", "\n", "
\n", "\n", - "
\n", + "
\n", "Hint 2\n", "\n", "Use `es.math.arctan`; radians are the default.\n", @@ -382,14 +382,14 @@ "id": "dd44ba8b", "metadata": {}, "source": [ - "
\n", + "
\n", "Hint 1\n", "\n", "A plain number can be divided by a `Parameter`.\n", "\n", "
\n", "\n", - "
\n", + "
\n", "Hint 2\n", "\n", "Write `1 / slope`.\n", diff --git a/tests/unit/tutorials/test_tutorial_notebooks_structure.py b/tests/unit/tutorials/test_tutorial_notebooks_structure.py index 05fe8ff0..3bacd969 100644 --- a/tests/unit/tutorials/test_tutorial_notebooks_structure.py +++ b/tests/unit/tutorials/test_tutorial_notebooks_structure.py @@ -103,7 +103,7 @@ def test_hints_are_folded_markdown_and_hold_no_solution(self, cells): hints = source_of(cells[start + 2]) assert 'Hint 1' in hints - assert hints.count('
') == hints.count('
') + assert hints.count('
') == hints.count('
') assert 'Solution' not in hints assert '```' not in hints diff --git a/tests/unit/tutorials/test_tutorial_solution_helper.py b/tests/unit/tutorials/test_tutorial_solution_helper.py index b4246d87..3e71e5d7 100644 --- a/tests/unit/tutorials/test_tutorial_solution_helper.py +++ b/tests/unit/tutorials/test_tutorial_solution_helper.py @@ -41,7 +41,7 @@ def test_the_block_is_folded_and_titled_solution(self, helper): solution('', 'print(1)') - assert shown[0].data.startswith('
Solution') + assert shown[0].data.startswith('
Solution') assert shown[0].data.endswith('
') def test_the_code_is_shown_highlighted_and_unchanged(self, helper): From 57f285395a650292160b8773f2e64920d7b84620 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mark=20S=C3=B8rensen?= Date: Wed, 14 Oct 2026 01:20:58 +0200 Subject: [PATCH 08/25] fix(docs): linting --- .../tutorials/basics/maths-arithmetic.ipynb | 517 +++++++++++++++--- .../docs/tutorials/basics/maths-exp-log.ipynb | 421 ++++++++++++-- .../tutorials/basics/maths-hyperbolic.ipynb | 401 ++++++++++++-- .../basics/maths-inverse-trigonometric.ipynb | 408 ++++++++++++-- .../basics/maths-trigonometric.ipynb | 420 ++++++++++++-- 5 files changed, 1904 insertions(+), 263 deletions(-) diff --git a/docs/docs/tutorials/basics/maths-arithmetic.ipynb b/docs/docs/tutorials/basics/maths-arithmetic.ipynb index 7aa7d22d..8973db1f 100644 --- a/docs/docs/tutorials/basics/maths-arithmetic.ipynb +++ b/docs/docs/tutorials/basics/maths-arithmetic.ipynb @@ -28,9 +28,16 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "id": "3248d597", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:08.298951Z", + "iopub.status.busy": "2026-10-13T22:59:08.298828Z", + "iopub.status.idle": "2026-10-13T22:59:09.107746Z", + "shell.execute_reply": "2026-10-13T22:59:09.106987Z" + } + }, "outputs": [], "source": [ "import numpy as np\n", @@ -51,9 +58,16 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "id": "a46d494e", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:09.109257Z", + "iopub.status.busy": "2026-10-13T22:59:09.109034Z", + "iopub.status.idle": "2026-10-13T22:59:09.111714Z", + "shell.execute_reply": "2026-10-13T22:59:09.111259Z" + } + }, "outputs": [], "source": [ "def show(number):\n", @@ -66,9 +80,15 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "id": "9ed7cb3e", "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:09.113009Z", + "iopub.status.busy": "2026-10-13T22:59:09.112896Z", + "iopub.status.idle": "2026-10-13T22:59:09.117390Z", + "shell.execute_reply": "2026-10-13T22:59:09.116842Z" + }, "tags": [ "hide-in-docs" ] @@ -145,10 +165,28 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 4, "id": "0149da13", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:09.118436Z", + "iopub.status.busy": "2026-10-13T22:59:09.118325Z", + "iopub.status.idle": "2026-10-13T22:59:09.129197Z", + "shell.execute_reply": "2026-10-13T22:59:09.128712Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "length = Parameter(name='length', value=2.0, unit='m', variance=0.2**2, min=0.0, max=10.0)\n", "length" @@ -164,10 +202,28 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 5, "id": "9dd36dc6", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:09.130163Z", + "iopub.status.busy": "2026-10-13T22:59:09.130051Z", + "iopub.status.idle": "2026-10-13T22:59:09.132594Z", + "shell.execute_reply": "2026-10-13T22:59:09.132168Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "value: 2.0 m\n", + "error: 0.20 m\n", + "variance: 0.04 m²\n", + "bounds: [0.0, 10.0] m\n" + ] + } + ], "source": [ "print(f'value: {length.value} {length.unit}')\n", "print(f'error: {length.error:.2f} {length.unit}')\n", @@ -186,10 +242,25 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 6, "id": "a4f03105", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:09.133508Z", + "iopub.status.busy": "2026-10-13T22:59:09.133400Z", + "iopub.status.idle": "2026-10-13T22:59:09.135753Z", + "shell.execute_reply": "2026-10-13T22:59:09.135354Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "scale: 3.00 ± 0.10\n" + ] + } + ], "source": [ "scale = DescriptorNumber(name='scale', value=3.0, variance=0.1**2)\n", "print(f'scale: {scale.value:.2f} ± {scale.error:.2f}')" @@ -229,10 +300,29 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 7, "id": "69e79f4d", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:09.136676Z", + "iopub.status.busy": "2026-10-13T22:59:09.136567Z", + "iopub.status.idle": "2026-10-13T22:59:09.140353Z", + "shell.execute_reply": "2026-10-13T22:59:09.139954Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "length: 2.00 ± 0.20 m\n", + "width: 50.00 ± 2.00 cm\n", + "total: 2.5000 ± 0.2010 m\n", + "variance of the total: 0.0404 m²\n", + "bounds of the total: [0.10, 11.00] m\n" + ] + } + ], "source": [ "width = Parameter(name='width', value=50.0, unit='cm', variance=2.0**2, min=10.0, max=100.0)\n", "total = length + width\n", @@ -262,16 +352,35 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 8, "id": "ddd2e459", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:09.141664Z", + "iopub.status.busy": "2026-10-13T22:59:09.141505Z", + "iopub.status.idle": "2026-10-13T22:59:09.144603Z", + "shell.execute_reply": "2026-10-13T22:59:09.144206Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "difference: 1.5000 ± 0.2010 m\n", + "variance of the difference: 0.0404 m²\n", + "bounds of the difference: [-1.00, 9.90] m\n" + ] + } + ], "source": [ "difference = length - width\n", "\n", "print(f'difference: {difference.value:.4f} ± {difference.error:.4f} {difference.unit}')\n", "print(f'variance of the difference: {difference.variance:.4f} {difference.unit}²')\n", - "print(f'bounds of the difference: [{difference.min:.2f}, {difference.max:.2f}] {difference.unit}')" + "print(\n", + " f'bounds of the difference: [{difference.min:.2f}, {difference.max:.2f}] {difference.unit}'\n", + ")" ] }, { @@ -285,10 +394,26 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 9, "id": "af71c46b", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:09.145693Z", + "iopub.status.busy": "2026-10-13T22:59:09.145582Z", + "iopub.status.idle": "2026-10-13T22:59:09.148219Z", + "shell.execute_reply": "2026-10-13T22:59:09.147825Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "UnitError: Numbers can only be added to dimensionless values\n", + "scale + 1: 4.00 ± 0.10\n" + ] + } + ], "source": [ "try:\n", " length + 1.0\n", @@ -311,9 +436,16 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 10, "id": "a88eeddf", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:09.149331Z", + "iopub.status.busy": "2026-10-13T22:59:09.149216Z", + "iopub.status.idle": "2026-10-13T22:59:09.151656Z", + "shell.execute_reply": "2026-10-13T22:59:09.151259Z" + } + }, "outputs": [], "source": [ "section_1 = Parameter(name='section_1', value=1.20, variance=0.02**2, unit='m')\n", @@ -342,14 +474,36 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 11, "id": "2520c343", "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:09.152768Z", + "iopub.status.busy": "2026-10-13T22:59:09.152661Z", + "iopub.status.idle": "2026-10-13T22:59:09.172196Z", + "shell.execute_reply": "2026-10-13T22:59:09.171725Z" + }, "tags": [ "hide-in-docs" ] }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
Solution
total = section_1 + section_2\n",
+       "print(show(total))\n",
+       "
\n", + "

Output:

2.0000 ± 0.0361 m
" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "%%solution\n", "total = section_1 + section_2\n", @@ -368,9 +522,16 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 12, "id": "a64159f6", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:09.173430Z", + "iopub.status.busy": "2026-10-13T22:59:09.173305Z", + "iopub.status.idle": "2026-10-13T22:59:09.176035Z", + "shell.execute_reply": "2026-10-13T22:59:09.175429Z" + } + }, "outputs": [], "source": [ "tube = Parameter(name='tube', value=1.20, variance=0.02**2, unit='m')\n", @@ -399,14 +560,36 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 13, "id": "ce692abb", "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:09.177106Z", + "iopub.status.busy": "2026-10-13T22:59:09.176998Z", + "iopub.status.idle": "2026-10-13T22:59:09.180716Z", + "shell.execute_reply": "2026-10-13T22:59:09.180257Z" + }, "tags": [ "hide-in-docs" ] }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
Solution
remaining = tube - gap\n",
+       "print(show(remaining))\n",
+       "
\n", + "

Output:

1.0750 ± 0.0206 m
" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "%%solution\n", "remaining = tube - gap\n", @@ -425,10 +608,26 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 14, "id": "bbc2cdc8", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:09.181740Z", + "iopub.status.busy": "2026-10-13T22:59:09.181631Z", + "iopub.status.idle": "2026-10-13T22:59:09.184861Z", + "shell.execute_reply": "2026-10-13T22:59:09.184441Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "area: 3.0000 ± 0.3606 m^2\n", + "bounds of the area: [0.00, 30.00] m^2\n" + ] + } + ], "source": [ "height = Parameter(name='height', value=1.5, unit='m', variance=0.1**2, min=1.0, max=3.0)\n", "area = length * height\n", @@ -448,10 +647,26 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 15, "id": "32d674ef", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:09.185871Z", + "iopub.status.busy": "2026-10-13T22:59:09.185765Z", + "iopub.status.idle": "2026-10-13T22:59:09.188932Z", + "shell.execute_reply": "2026-10-13T22:59:09.188488Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "speed: 0.5000 ± 0.0590 m/s\n", + "bounds of the speed: [0.00, 5.00] m/s\n" + ] + } + ], "source": [ "time = Parameter(name='time', value=4.0, unit='s', variance=0.25**2, min=2.0, max=8.0)\n", "speed = length / time\n", @@ -470,10 +685,25 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 16, "id": "1bf87c9c", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:09.189878Z", + "iopub.status.busy": "2026-10-13T22:59:09.189773Z", + "iopub.status.idle": "2026-10-13T22:59:09.192289Z", + "shell.execute_reply": "2026-10-13T22:59:09.191857Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "DescriptorNumber: 6.00 ± 0.20\n" + ] + } + ], "source": [ "doubled = scale * 2\n", "print(f'{type(doubled).__name__}: {doubled.value:.2f} ± {doubled.error:.2f}')" @@ -491,9 +721,16 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 17, "id": "eafe8969", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:09.193159Z", + "iopub.status.busy": "2026-10-13T22:59:09.193054Z", + "iopub.status.idle": "2026-10-13T22:59:09.195503Z", + "shell.execute_reply": "2026-10-13T22:59:09.195082Z" + } + }, "outputs": [], "source": [ "distance = Parameter(name='distance', value=3.0, variance=0.1**2, unit='m')\n", @@ -522,14 +759,36 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 18, "id": "b0b64a26", "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:09.196672Z", + "iopub.status.busy": "2026-10-13T22:59:09.196566Z", + "iopub.status.idle": "2026-10-13T22:59:09.200041Z", + "shell.execute_reply": "2026-10-13T22:59:09.199612Z" + }, "tags": [ "hide-in-docs" ] }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
Solution
speed = distance / time\n",
+       "print(show(speed))\n",
+       "
\n", + "

Output:

2.0000 ± 0.0943 m/s
" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "%%solution\n", "speed = distance / time\n", @@ -548,18 +807,41 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 19, "id": "5ba32813", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:09.201158Z", + "iopub.status.busy": "2026-10-13T22:59:09.201051Z", + "iopub.status.idle": "2026-10-13T22:59:09.204940Z", + "shell.execute_reply": "2026-10-13T22:59:09.204549Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "length**2: 4.00 ± 0.80 m^2, bounds [0.0, 100.0]\n", + "-length: -2.00 ± 0.20 m, bounds [-10.0, -0.0]\n", + "abs(-length): 2.00 ± 0.20 m, bounds [0.0, 10.0]\n" + ] + } + ], "source": [ "squared = length**2\n", "negated = -length\n", "absolute = abs(-length)\n", "\n", - "print(f'length**2: {squared.value:.2f} ± {squared.error:.2f} {squared.unit}, bounds [{squared.min:.1f}, {squared.max:.1f}]')\n", - "print(f'-length: {negated.value:.2f} ± {negated.error:.2f} {negated.unit}, bounds [{negated.min:.1f}, {negated.max:.1f}]')\n", - "print(f'abs(-length): {absolute.value:.2f} ± {absolute.error:.2f} {absolute.unit}, bounds [{absolute.min:.1f}, {absolute.max:.1f}]')" + "print(\n", + " f'length**2: {squared.value:.2f} ± {squared.error:.2f} {squared.unit}, bounds [{squared.min:.1f}, {squared.max:.1f}]'\n", + ")\n", + "print(\n", + " f'-length: {negated.value:.2f} ± {negated.error:.2f} {negated.unit}, bounds [{negated.min:.1f}, {negated.max:.1f}]'\n", + ")\n", + "print(\n", + " f'abs(-length): {absolute.value:.2f} ± {absolute.error:.2f} {absolute.unit}, bounds [{absolute.min:.1f}, {absolute.max:.1f}]'\n", + ")" ] }, { @@ -574,9 +856,16 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 20, "id": "61387f4a", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:09.206127Z", + "iopub.status.busy": "2026-10-13T22:59:09.206020Z", + "iopub.status.idle": "2026-10-13T22:59:09.208089Z", + "shell.execute_reply": "2026-10-13T22:59:09.207736Z" + } + }, "outputs": [], "source": [ "radius = Parameter(name='radius', value=2.0, variance=0.1**2, unit='cm')" @@ -604,14 +893,36 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 21, "id": "8e084269", "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:09.209156Z", + "iopub.status.busy": "2026-10-13T22:59:09.209050Z", + "iopub.status.idle": "2026-10-13T22:59:09.212571Z", + "shell.execute_reply": "2026-10-13T22:59:09.212163Z" + }, "tags": [ "hide-in-docs" ] }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
Solution
area = np.pi * radius**2\n",
+       "print(show(area))\n",
+       "
\n", + "

Output:

12.5664 ± 1.2566 cm^2   in [0.0000, inf]

The lower bound 0 appears because a square can never be negative.

" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "%%solution The lower bound 0 appears because a square can never be negative.\n", "area = np.pi * radius**2\n", @@ -634,10 +945,39 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 22, "id": "d97fb184", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:09.213548Z", + "iopub.status.busy": "2026-10-13T22:59:09.213443Z", + "iopub.status.idle": "2026-10-13T22:59:09.411917Z", + "shell.execute_reply": "2026-10-13T22:59:09.411368Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "times = np.linspace(0.5, 5.0, 46)\n", "distance = Parameter(name='distance', value=3.0, unit='m', variance=0.1**2)\n", @@ -704,10 +1044,39 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 23, "id": "51adac82", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:09.413052Z", + "iopub.status.busy": "2026-10-13T22:59:09.412922Z", + "iopub.status.idle": "2026-10-13T22:59:09.616480Z", + "shell.execute_reply": "2026-10-13T22:59:09.615923Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "counts = np.arange(1, 26)\n", "\n", @@ -747,16 +1116,34 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 24, "id": "3603e9d6", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:09.617986Z", + "iopub.status.busy": "2026-10-13T22:59:09.617868Z", + "iopub.status.idle": "2026-10-13T22:59:09.622545Z", + "shell.execute_reply": "2026-10-13T22:59:09.622047Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "x - x: 0.0000 ± 0.0000, bounds [-10.0, 10.0]\n", + "(2x) / x: 2.0000 ± 0.0283\n" + ] + } + ], "source": [ "x = Parameter(name='x', value=10.0, variance=0.1**2, min=0.0, max=10.0)\n", "difference = x - x\n", "ratio = (2 * x) / x\n", "\n", - "print(f'x - x: {difference.value:.4f} ± {difference.error:.4f}, bounds [{difference.min:.1f}, {difference.max:.1f}]')\n", + "print(\n", + " f'x - x: {difference.value:.4f} ± {difference.error:.4f}, bounds [{difference.min:.1f}, {difference.max:.1f}]'\n", + ")\n", "print(f'(2x) / x: {ratio.value:.4f} ± {ratio.error:.4f}')" ] }, diff --git a/docs/docs/tutorials/basics/maths-exp-log.ipynb b/docs/docs/tutorials/basics/maths-exp-log.ipynb index ad099de1..ce0f6c86 100644 --- a/docs/docs/tutorials/basics/maths-exp-log.ipynb +++ b/docs/docs/tutorials/basics/maths-exp-log.ipynb @@ -23,9 +23,16 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "id": "f10b3445", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:10.816582Z", + "iopub.status.busy": "2026-10-13T22:59:10.816454Z", + "iopub.status.idle": "2026-10-13T22:59:11.659099Z", + "shell.execute_reply": "2026-10-13T22:59:11.658435Z" + } + }, "outputs": [], "source": [ "import numpy as np\n", @@ -46,9 +53,16 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "id": "ed1b6606", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:11.660900Z", + "iopub.status.busy": "2026-10-13T22:59:11.660561Z", + "iopub.status.idle": "2026-10-13T22:59:11.663844Z", + "shell.execute_reply": "2026-10-13T22:59:11.663283Z" + } + }, "outputs": [], "source": [ "def show(number):\n", @@ -61,9 +75,15 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "id": "49a46c97", "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:11.665234Z", + "iopub.status.busy": "2026-10-13T22:59:11.665097Z", + "iopub.status.idle": "2026-10-13T22:59:11.669688Z", + "shell.execute_reply": "2026-10-13T22:59:11.669198Z" + }, "tags": [ "hide-in-docs" ] @@ -140,10 +160,25 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 4, "id": "9752cc6f", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:11.670674Z", + "iopub.status.busy": "2026-10-13T22:59:11.670558Z", + "iopub.status.idle": "2026-10-13T22:59:11.682237Z", + "shell.execute_reply": "2026-10-13T22:59:11.681690Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0.9868 ± 0.0026 dimensionless in [0.7659, 1.0000]\n" + ] + } + ], "source": [ "q = Parameter(name='q', value=2.0, unit='1/angstrom', variance=0.02**2, min=0.5, max=4.0)\n", "u2 = Parameter(name='u2', value=0.01, unit='angstrom**2', variance=0.002**2, min=0.0, max=0.05)\n", @@ -173,9 +208,16 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 5, "id": "90885017", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:11.683398Z", + "iopub.status.busy": "2026-10-13T22:59:11.683274Z", + "iopub.status.idle": "2026-10-13T22:59:11.685966Z", + "shell.execute_reply": "2026-10-13T22:59:11.685501Z" + } + }, "outputs": [], "source": [ "mu = Parameter(name='mu', value=0.50, variance=0.02**2, unit='1/cm')\n", @@ -204,14 +246,36 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 6, "id": "fa6f7cd7", "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:11.686889Z", + "iopub.status.busy": "2026-10-13T22:59:11.686772Z", + "iopub.status.idle": "2026-10-13T22:59:11.708729Z", + "shell.execute_reply": "2026-10-13T22:59:11.708223Z" + }, "tags": [ "hide-in-docs" ] }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
Solution
transmission = es.math.exp(-mu * t)\n",
+       "print(show(transmission))\n",
+       "
\n", + "

Output:

0.3679 ± 0.0236 dimensionless   in [0.0000, inf]

The lower bound 0 appears because an exponential is never negative.

" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "%%solution The lower bound 0 appears because an exponential is never negative.\n", "transmission = es.math.exp(-mu * t)\n", @@ -232,10 +296,25 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 7, "id": "f4d206cf", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:11.710264Z", + "iopub.status.busy": "2026-10-13T22:59:11.710079Z", + "iopub.status.idle": "2026-10-13T22:59:11.714915Z", + "shell.execute_reply": "2026-10-13T22:59:11.714396Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "-6.0206 ± 0.1737 dimensionless in [-10.0000, -3.9794]\n" + ] + } + ], "source": [ "intensity = Parameter(name='intensity', value=250.0, variance=10.0**2, min=100.0, max=400.0)\n", "reference = DescriptorNumber(name='reference', value=1000.0, variance=0.0)\n", @@ -254,10 +333,25 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 8, "id": "113433ff", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:11.716005Z", + "iopub.status.busy": "2026-10-13T22:59:11.715880Z", + "iopub.status.idle": "2026-10-13T22:59:11.718636Z", + "shell.execute_reply": "2026-10-13T22:59:11.718149Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The value of 'v' (0.0) is outside the domain (0.0, inf] of log10().\n" + ] + } + ], "source": [ "try:\n", " es.math.log10(Parameter(name='v', value=0.0, variance=0.1**2))\n", @@ -277,9 +371,16 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 9, "id": "b8b46c83", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:11.719648Z", + "iopub.status.busy": "2026-10-13T22:59:11.719529Z", + "iopub.status.idle": "2026-10-13T22:59:11.722010Z", + "shell.execute_reply": "2026-10-13T22:59:11.721552Z" + } + }, "outputs": [], "source": [ "intensity = Parameter(name='intensity', value=250.0, variance=10.0**2)\n", @@ -308,14 +409,36 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 10, "id": "f65e993b", "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:11.722946Z", + "iopub.status.busy": "2026-10-13T22:59:11.722832Z", + "iopub.status.idle": "2026-10-13T22:59:11.727390Z", + "shell.execute_reply": "2026-10-13T22:59:11.726875Z" + }, "tags": [ "hide-in-docs" ] }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
Solution
level = 10 * es.math.log10(intensity / reference)\n",
+       "print(show(level))\n",
+       "
\n", + "

Output:

-6.0206 ± 0.1737 dimensionless
" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "%%solution\n", "level = 10 * es.math.log10(intensity / reference)\n", @@ -335,10 +458,25 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 11, "id": "e5fc6001", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:11.728438Z", + "iopub.status.busy": "2026-10-13T22:59:11.728315Z", + "iopub.status.idle": "2026-10-13T22:59:11.739166Z", + "shell.execute_reply": "2026-10-13T22:59:11.738724Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "5.0000 ± 0.1000 dimensionless in [3.6056, 6.4031]\n" + ] + } + ], "source": [ "a = Parameter(name='a', value=3.0, variance=0.1**2, min=2.0, max=4.0)\n", "b = Parameter(name='b', value=4.0, variance=0.1**2, min=3.0, max=5.0)\n", @@ -358,10 +496,26 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 12, "id": "bc75d2f8", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:11.740381Z", + "iopub.status.busy": "2026-10-13T22:59:11.740257Z", + "iopub.status.idle": "2026-10-13T22:59:11.745769Z", + "shell.execute_reply": "2026-10-13T22:59:11.745282Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "3.0000 ± 0.0500 m in [2.0000, 4.0000]\n", + "UnitError: Unsupported unit as result of sqrt: sqrt(m).\n" + ] + } + ], "source": [ "area = Parameter(name='area', value=9.0, unit='m**2', variance=0.3**2, min=4.0, max=16.0)\n", "print(show(es.math.sqrt(area)))\n", @@ -384,9 +538,16 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 13, "id": "61c225b5", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:11.747038Z", + "iopub.status.busy": "2026-10-13T22:59:11.746911Z", + "iopub.status.idle": "2026-10-13T22:59:11.749496Z", + "shell.execute_reply": "2026-10-13T22:59:11.749033Z" + } + }, "outputs": [], "source": [ "intensity = Parameter(name='intensity', value=0.64, variance=0.02**2)" @@ -414,14 +575,36 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 14, "id": "30d3a32c", "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:11.750614Z", + "iopub.status.busy": "2026-10-13T22:59:11.750497Z", + "iopub.status.idle": "2026-10-13T22:59:11.754101Z", + "shell.execute_reply": "2026-10-13T22:59:11.753653Z" + }, "tags": [ "hide-in-docs" ] }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
Solution
amplitude = es.math.sqrt(intensity)\n",
+       "print(show(amplitude))\n",
+       "
\n", + "

Output:

0.8000 ± 0.0125 dimensionless   in [0.0000, inf]

The lower bound 0 is the lower end of the domain of the square root.

" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "%%solution The lower bound 0 is the lower end of the domain of the square root.\n", "amplitude = es.math.sqrt(intensity)\n", @@ -442,10 +625,27 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 15, "id": "b2de6389", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:11.755124Z", + "iopub.status.busy": "2026-10-13T22:59:11.755006Z", + "iopub.status.idle": "2026-10-13T22:59:11.760784Z", + "shell.execute_reply": "2026-10-13T22:59:11.760344Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0.8176 ± 0.0263 dimensionless in [0.3829, 0.9973]\n", + "0.1824 ± 0.0263 dimensionless in [0.0027, 0.6171]\n", + "1.0\n" + ] + } + ], "source": [ "slit = Parameter(name='slit', value=2.0, unit='mm', variance=0.1**2, min=1.0, max=3.0)\n", "beam = Parameter(name='beam', value=1.5, unit='mm', variance=0.05**2, min=1.0, max=2.0)\n", @@ -469,9 +669,16 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 16, "id": "cce37214", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:11.762289Z", + "iopub.status.busy": "2026-10-13T22:59:11.762133Z", + "iopub.status.idle": "2026-10-13T22:59:11.765186Z", + "shell.execute_reply": "2026-10-13T22:59:11.764681Z" + } + }, "outputs": [], "source": [ "half_width = Parameter(name='half_width', value=1.0, variance=0.05**2, unit='mm')\n", @@ -500,14 +707,36 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 17, "id": "3c25236f", "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:11.766223Z", + "iopub.status.busy": "2026-10-13T22:59:11.766105Z", + "iopub.status.idle": "2026-10-13T22:59:11.770766Z", + "shell.execute_reply": "2026-10-13T22:59:11.770327Z" + }, "tags": [ "hide-in-docs" ] }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
Solution
fraction = es.math.erf(half_width / (beam_sigma * np.sqrt(2)))\n",
+       "print(show(fraction))\n",
+       "
\n", + "

Output:

0.7887 ± 0.0323 dimensionless   in [-1.0000, 1.0000]

With unbounded inputs the bounds of erf are its range, from -1 to 1.

" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "%%solution With unbounded inputs the bounds of `erf` are its range, from -1 to 1.\n", "fraction = es.math.erf(half_width / (beam_sigma * np.sqrt(2)))\n", @@ -526,10 +755,26 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 18, "id": "ca64e157", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:11.771988Z", + "iopub.status.busy": "2026-10-13T22:59:11.771873Z", + "iopub.status.idle": "2026-10-13T22:59:11.775536Z", + "shell.execute_reply": "2026-10-13T22:59:11.775029Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "1.2000 ± 0.0500 dimensionless in [0.5000, 3.0000]\n", + "True\n" + ] + } + ], "source": [ "x = Parameter(name='x', value=1.2, variance=0.05**2, min=0.5, max=3.0)\n", "round_trip = es.math.exp(es.math.log(x))\n", @@ -553,10 +798,39 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 19, "id": "da2bed0a", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:11.776578Z", + "iopub.status.busy": "2026-10-13T22:59:11.776444Z", + "iopub.status.idle": "2026-10-13T22:59:11.992252Z", + "shell.execute_reply": "2026-10-13T22:59:11.991763Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "q_values = np.linspace(0.2, 4.0, 40)\n", "u2 = Parameter(name='u2', value=0.01, unit='angstrom**2', variance=0.002**2)\n", @@ -623,10 +897,39 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 20, "id": "c73439aa", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:11.993738Z", + "iopub.status.busy": "2026-10-13T22:59:11.993562Z", + "iopub.status.idle": "2026-10-13T22:59:12.089028Z", + "shell.execute_reply": "2026-10-13T22:59:12.088433Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "half_widths = np.linspace(0.1, 3.0, 59)\n", "beam = Parameter(name='beam', value=0.8, unit='mm', variance=0.04**2)\n", @@ -696,10 +999,26 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 21, "id": "d48f797a", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:12.090303Z", + "iopub.status.busy": "2026-10-13T22:59:12.090157Z", + "iopub.status.idle": "2026-10-13T22:59:12.093687Z", + "shell.execute_reply": "2026-10-13T22:59:12.093196Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "7.3890560989306495\n", + "34.51261310995657\n" + ] + } + ], "source": [ "wide = Parameter(name='wide', value=1.0, variance=1.0**2)\n", "print(es.math.exp(wide).variance)\n", diff --git a/docs/docs/tutorials/basics/maths-hyperbolic.ipynb b/docs/docs/tutorials/basics/maths-hyperbolic.ipynb index d5989862..10eca3ed 100644 --- a/docs/docs/tutorials/basics/maths-hyperbolic.ipynb +++ b/docs/docs/tutorials/basics/maths-hyperbolic.ipynb @@ -23,9 +23,16 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "id": "95f6240e", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:13.300214Z", + "iopub.status.busy": "2026-10-13T22:59:13.300093Z", + "iopub.status.idle": "2026-10-13T22:59:14.120894Z", + "shell.execute_reply": "2026-10-13T22:59:14.120305Z" + } + }, "outputs": [], "source": [ "import numpy as np\n", @@ -46,9 +53,16 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "id": "526ea56e", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:14.122517Z", + "iopub.status.busy": "2026-10-13T22:59:14.122294Z", + "iopub.status.idle": "2026-10-13T22:59:14.125136Z", + "shell.execute_reply": "2026-10-13T22:59:14.124622Z" + } + }, "outputs": [], "source": [ "def show(number):\n", @@ -61,9 +75,15 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "id": "9a76574e", "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:14.126129Z", + "iopub.status.busy": "2026-10-13T22:59:14.126015Z", + "iopub.status.idle": "2026-10-13T22:59:14.130610Z", + "shell.execute_reply": "2026-10-13T22:59:14.130207Z" + }, "tags": [ "hide-in-docs" ] @@ -142,10 +162,27 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 4, "id": "0df6e42b", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:14.131868Z", + "iopub.status.busy": "2026-10-13T22:59:14.131747Z", + "iopub.status.idle": "2026-10-13T22:59:14.142152Z", + "shell.execute_reply": "2026-10-13T22:59:14.141675Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "sinh 0.8881 ± 0.0669 dimensionless in [0.0000, 2.1293]\n", + "cosh 1.3374 ± 0.0444 dimensionless in [1.0000, 2.3524]\n", + "tanh 0.6640 ± 0.0280 dimensionless in [0.0000, 0.9051]\n" + ] + } + ], "source": [ "x = Parameter(name='x', value=0.8, variance=0.05**2, min=0.0, max=1.5)\n", "\n", @@ -164,10 +201,25 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 5, "id": "ec093603", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:14.143364Z", + "iopub.status.busy": "2026-10-13T22:59:14.143122Z", + "iopub.status.idle": "2026-10-13T22:59:14.147873Z", + "shell.execute_reply": "2026-10-13T22:59:14.147411Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0.7616 ± 0.0168 dimensionless in [-1.0000, 1.0000]\n" + ] + } + ], "source": [ "moment = Parameter(name='moment', value=0.5, variance=0.02**2, unit='meV/T')\n", "field = DescriptorNumber(name='field', value=4.0, variance=0.0, unit='T')\n", @@ -189,9 +241,16 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 6, "id": "87b8075d", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:14.148836Z", + "iopub.status.busy": "2026-10-13T22:59:14.148722Z", + "iopub.status.idle": "2026-10-13T22:59:14.150964Z", + "shell.execute_reply": "2026-10-13T22:59:14.150546Z" + } + }, "outputs": [], "source": [ "x = Parameter(name='x', value=0.80, variance=0.05**2, min=0.0, max=3.0)" @@ -219,14 +278,36 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 7, "id": "6d9c0c8a", "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:14.151927Z", + "iopub.status.busy": "2026-10-13T22:59:14.151812Z", + "iopub.status.idle": "2026-10-13T22:59:14.172700Z", + "shell.execute_reply": "2026-10-13T22:59:14.172324Z" + }, "tags": [ "hide-in-docs" ] }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
Solution
magnetisation = es.math.tanh(x)\n",
+       "print(show(magnetisation))\n",
+       "
\n", + "

Output:

0.6640 ± 0.0280 dimensionless   in [0.0000, 0.9951]

The upper bound is $\\tanh(3)$, not 1: the bounds are the range over the bounds of the input.

" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "%%solution The upper bound is $\\tanh(3)$, not 1: the bounds are the range over the bounds of the input.\n", "magnetisation = es.math.tanh(x)\n", @@ -246,10 +327,26 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 8, "id": "dfd08731", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:14.173990Z", + "iopub.status.busy": "2026-10-13T22:59:14.173875Z", + "iopub.status.idle": "2026-10-13T22:59:14.177948Z", + "shell.execute_reply": "2026-10-13T22:59:14.177284Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0.5493 ± 0.0267 dimensionless in [-1.4722, 1.4722]\n", + "1.3170 ± 0.0289 dimensionless in [0.0000, 2.0634]\n" + ] + } + ], "source": [ "saturation = Parameter(name='saturation', value=0.5, variance=0.02**2, min=-0.9, max=0.9)\n", "print(show(es.math.arctanh(saturation)))\n", @@ -270,9 +367,16 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 9, "id": "f9e6f602", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:14.179005Z", + "iopub.status.busy": "2026-10-13T22:59:14.178881Z", + "iopub.status.idle": "2026-10-13T22:59:14.181456Z", + "shell.execute_reply": "2026-10-13T22:59:14.180960Z" + } + }, "outputs": [], "source": [ "saturation = Parameter(name='saturation', value=0.50, variance=0.02**2)" @@ -300,14 +404,36 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 10, "id": "4edc2709", "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:14.182379Z", + "iopub.status.busy": "2026-10-13T22:59:14.182269Z", + "iopub.status.idle": "2026-10-13T22:59:14.186118Z", + "shell.execute_reply": "2026-10-13T22:59:14.185642Z" + }, "tags": [ "hide-in-docs" ] }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
Solution
x = es.math.arctanh(saturation)\n",
+       "print(show(x))\n",
+       "
\n", + "

Output:

0.5493 ± 0.0267 dimensionless
" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "%%solution\n", "x = es.math.arctanh(saturation)\n", @@ -327,10 +453,25 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 11, "id": "51536f23", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:14.187039Z", + "iopub.status.busy": "2026-10-13T22:59:14.186932Z", + "iopub.status.idle": "2026-10-13T22:59:14.189829Z", + "shell.execute_reply": "2026-10-13T22:59:14.189452Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "1.0000 ± 0.0000 dimensionless in [1.0000, 1.5431]\n" + ] + } + ], "source": [ "z = Parameter(name='z', value=0.0, variance=0.5**2, min=-1.0, max=1.0)\n", "result = es.math.cosh(z)\n", @@ -349,9 +490,16 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 12, "id": "05f2f28d", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:14.190895Z", + "iopub.status.busy": "2026-10-13T22:59:14.190789Z", + "iopub.status.idle": "2026-10-13T22:59:14.193009Z", + "shell.execute_reply": "2026-10-13T22:59:14.192568Z" + } + }, "outputs": [], "source": [ "x = Parameter(name='x', value=0.5, variance=0.05**2, min=-1.0, max=1.0)" @@ -379,14 +527,36 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 13, "id": "65bdea3a", "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:14.193979Z", + "iopub.status.busy": "2026-10-13T22:59:14.193863Z", + "iopub.status.idle": "2026-10-13T22:59:14.197464Z", + "shell.execute_reply": "2026-10-13T22:59:14.197027Z" + }, "tags": [ "hide-in-docs" ] }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
Solution
result = es.math.cosh(x)\n",
+       "print(result.min)\n",
+       "
\n", + "

Output:

1.0

The lower bound is 1, the value at 0, not $\\cosh(-1)$.

" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "%%solution The lower bound is 1, the value at 0, not $\\cosh(-1)$.\n", "result = es.math.cosh(x)\n", @@ -416,9 +586,16 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 14, "id": "e8012f6d", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:14.198435Z", + "iopub.status.busy": "2026-10-13T22:59:14.198322Z", + "iopub.status.idle": "2026-10-13T22:59:14.200464Z", + "shell.execute_reply": "2026-10-13T22:59:14.200021Z" + } + }, "outputs": [], "source": [ "x = Parameter(name='x', value=0.5, variance=0.01**2)" @@ -446,14 +623,38 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 15, "id": "fcceb1cc", "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:14.201369Z", + "iopub.status.busy": "2026-10-13T22:59:14.201264Z", + "iopub.status.idle": "2026-10-13T22:59:14.204193Z", + "shell.execute_reply": "2026-10-13T22:59:14.203776Z" + }, "tags": [ "hide-in-docs" ] }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
Solution
try:\n",
+       "    es.math.arccosh(x)\n",
+       "except ValueError as error:\n",
+       "    print(error)\n",
+       "
\n", + "

Output:

The value of 'x' (0.5) is outside the domain [1.0, inf] of arccosh().
" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "%%solution\n", "try:\n", @@ -474,10 +675,25 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 16, "id": "102d1d37", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:14.205084Z", + "iopub.status.busy": "2026-10-13T22:59:14.204978Z", + "iopub.status.idle": "2026-10-13T22:59:14.208100Z", + "shell.execute_reply": "2026-10-13T22:59:14.207687Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0.8000 ± 0.0500 dimensionless in [0.0000, 1.5000]\n" + ] + } + ], "source": [ "x = Parameter(name='x', value=0.8, variance=0.05**2, min=0.0, max=1.5)\n", "back = es.math.arcsinh(es.math.sinh(x))\n", @@ -500,10 +716,39 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 17, "id": "6cd723ec", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:14.209013Z", + "iopub.status.busy": "2026-10-13T22:59:14.208904Z", + "iopub.status.idle": "2026-10-13T22:59:14.380044Z", + "shell.execute_reply": "2026-10-13T22:59:14.379585Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "arguments = np.linspace(-3.0, 3.0, 61)\n", "\n", @@ -570,10 +815,39 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 18, "id": "b9a42646", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:14.381491Z", + "iopub.status.busy": "2026-10-13T22:59:14.381374Z", + "iopub.status.idle": "2026-10-13T22:59:14.441684Z", + "shell.execute_reply": "2026-10-13T22:59:14.441250Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "ys = np.linspace(1.05, 5.0, 60)\n", "\n", @@ -643,10 +917,26 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 19, "id": "dc485292", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:14.443049Z", + "iopub.status.busy": "2026-10-13T22:59:14.442902Z", + "iopub.status.idle": "2026-10-13T22:59:14.446821Z", + "shell.execute_reply": "2026-10-13T22:59:14.446369Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0.0\n", + "0.0403352186623227\n" + ] + } + ], "source": [ "flat = Parameter(name='flat', value=0.0, variance=0.5**2)\n", "print(es.math.cosh(flat).variance)\n", @@ -666,10 +956,25 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 20, "id": "892ca132", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:14.447775Z", + "iopub.status.busy": "2026-10-13T22:59:14.447663Z", + "iopub.status.idle": "2026-10-13T22:59:14.450229Z", + "shell.execute_reply": "2026-10-13T22:59:14.449847Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "sinh(big) is not finite: value=inf, variance=inf. Check the value and variance of the argument(s).\n" + ] + } + ], "source": [ "try:\n", " es.math.sinh(Parameter(name='big', value=800.0, variance=1.0**2))\n", diff --git a/docs/docs/tutorials/basics/maths-inverse-trigonometric.ipynb b/docs/docs/tutorials/basics/maths-inverse-trigonometric.ipynb index b078e043..d313871c 100644 --- a/docs/docs/tutorials/basics/maths-inverse-trigonometric.ipynb +++ b/docs/docs/tutorials/basics/maths-inverse-trigonometric.ipynb @@ -23,9 +23,16 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "id": "b248846e", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:15.650389Z", + "iopub.status.busy": "2026-10-13T22:59:15.650206Z", + "iopub.status.idle": "2026-10-13T22:59:16.475444Z", + "shell.execute_reply": "2026-10-13T22:59:16.474668Z" + } + }, "outputs": [], "source": [ "import numpy as np\n", @@ -46,9 +53,16 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "id": "b7551579", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:16.477082Z", + "iopub.status.busy": "2026-10-13T22:59:16.476756Z", + "iopub.status.idle": "2026-10-13T22:59:16.479825Z", + "shell.execute_reply": "2026-10-13T22:59:16.479364Z" + } + }, "outputs": [], "source": [ "def show(number):\n", @@ -61,9 +75,15 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "id": "d0d3ee83", "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:16.480979Z", + "iopub.status.busy": "2026-10-13T22:59:16.480864Z", + "iopub.status.idle": "2026-10-13T22:59:16.485561Z", + "shell.execute_reply": "2026-10-13T22:59:16.485153Z" + }, "tags": [ "hide-in-docs" ] @@ -140,10 +160,26 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 4, "id": "4146fd2b", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:16.486723Z", + "iopub.status.busy": "2026-10-13T22:59:16.486613Z", + "iopub.status.idle": "2026-10-13T22:59:16.499621Z", + "shell.execute_reply": "2026-10-13T22:59:16.499109Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0.2482 ± 0.0040 rad in [0.1287, 0.3954]\n", + "14.2210 ± 0.2315 deg in [7.3762, 22.6531]\n" + ] + } + ], "source": [ "d = Parameter(name='d', value=3.1356, unit='angstrom', variance=0.05**2, min=2.0, max=6.0)\n", "wavelength = DescriptorNumber(name='wavelength', value=1.5406, unit='angstrom', variance=0.0)\n", @@ -173,9 +209,16 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 5, "id": "b41c68a2", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:16.500916Z", + "iopub.status.busy": "2026-10-13T22:59:16.500789Z", + "iopub.status.idle": "2026-10-13T22:59:16.503440Z", + "shell.execute_reply": "2026-10-13T22:59:16.502995Z" + } + }, "outputs": [], "source": [ "d = Parameter(name='d', value=3.1356, variance=0.0005**2, unit='angstrom')\n", @@ -204,14 +247,36 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 6, "id": "75909c1a", "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:16.504684Z", + "iopub.status.busy": "2026-10-13T22:59:16.504568Z", + "iopub.status.idle": "2026-10-13T22:59:16.527078Z", + "shell.execute_reply": "2026-10-13T22:59:16.526494Z" + }, "tags": [ "hide-in-docs" ] }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
Solution
theta = es.math.arcsin(wavelength / (2 * d), unit='deg')\n",
+       "print(show(theta))\n",
+       "
\n", + "

Output:

14.2210 ± 0.0023 deg   in [-90.0000, 90.0000]

The bounds of the arcsine are $\\pm 90^\\circ$ for an unbounded input.

" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "%%solution The bounds of the arcsine are $\\pm 90^\\circ$ for an unbounded input.\n", "theta = es.math.arcsin(wavelength / (2 * d), unit='deg')\n", @@ -231,10 +296,25 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 7, "id": "45087906", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:16.528606Z", + "iopub.status.busy": "2026-10-13T22:59:16.528460Z", + "iopub.status.idle": "2026-10-13T22:59:16.531777Z", + "shell.execute_reply": "2026-10-13T22:59:16.531379Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "1.0472 ± 0.0231 rad in [0.0000, 1.5708]\n" + ] + } + ], "source": [ "ratio = Parameter(name='ratio', value=0.5, variance=0.02**2, min=0.0, max=1.0)\n", "print(show(es.math.arccos(ratio)))" @@ -250,10 +330,25 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 8, "id": "817aef54", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:16.532939Z", + "iopub.status.busy": "2026-10-13T22:59:16.532824Z", + "iopub.status.idle": "2026-10-13T22:59:16.535763Z", + "shell.execute_reply": "2026-10-13T22:59:16.535381Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "5.7106 ± 0.2836 deg in [-90.0000, 90.0000]\n" + ] + } + ], "source": [ "grade = Parameter(name='grade', value=0.10, variance=0.005**2)\n", "print(show(es.math.arctan(grade, unit='deg')))" @@ -271,9 +366,16 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 9, "id": "1180c5ba", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:16.536876Z", + "iopub.status.busy": "2026-10-13T22:59:16.536763Z", + "iopub.status.idle": "2026-10-13T22:59:16.539322Z", + "shell.execute_reply": "2026-10-13T22:59:16.538918Z" + } + }, "outputs": [], "source": [ "rise = Parameter(name='rise', value=0.30, variance=0.01**2, unit='m')\n", @@ -302,14 +404,36 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 10, "id": "dcf2bf11", "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:16.540307Z", + "iopub.status.busy": "2026-10-13T22:59:16.540173Z", + "iopub.status.idle": "2026-10-13T22:59:16.544295Z", + "shell.execute_reply": "2026-10-13T22:59:16.543929Z" + }, "tags": [ "hide-in-docs" ] }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
Solution
angle = es.math.arctan(rise / run, unit='deg')\n",
+       "print(show(angle))\n",
+       "
\n", + "

Output:

14.0362 ± 0.5024 deg   in [-90.0000, 90.0000]
" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "%%solution\n", "angle = es.math.arctan(rise / run, unit='deg')\n", @@ -330,10 +454,27 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 11, "id": "99e676f2", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:16.545507Z", + "iopub.status.busy": "2026-10-13T22:59:16.545389Z", + "iopub.status.idle": "2026-10-13T22:59:16.550726Z", + "shell.execute_reply": "2026-10-13T22:59:16.550319Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "53.1301 ± 1.1459 deg in [36.8699, 68.1986]\n", + "126.8699 ± 1.1459 deg in [111.8014, 143.1301]\n", + "-53.1301 ± 1.1459 deg in [-68.1986, -36.8699]\n" + ] + } + ], "source": [ "a = Parameter(name='a', value=3.0, variance=0.1**2, min=2.0, max=4.0)\n", "b = Parameter(name='b', value=4.0, variance=0.1**2, min=3.0, max=5.0)\n", @@ -357,10 +498,28 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 12, "id": "cc28134c", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:16.551966Z", + "iopub.status.busy": "2026-10-13T22:59:16.551856Z", + "iopub.status.idle": "2026-10-13T22:59:16.554495Z", + "shell.execute_reply": "2026-10-13T22:59:16.554013Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "True" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "es.math.asin is es.math.arcsin" ] @@ -377,9 +536,16 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 13, "id": "bab72623", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:16.555403Z", + "iopub.status.busy": "2026-10-13T22:59:16.555295Z", + "iopub.status.idle": "2026-10-13T22:59:16.557641Z", + "shell.execute_reply": "2026-10-13T22:59:16.557260Z" + } + }, "outputs": [], "source": [ "a = Parameter(name='a', value=1.5, variance=0.05**2)\n", @@ -408,14 +574,36 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 14, "id": "d7528ae8", "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:16.558600Z", + "iopub.status.busy": "2026-10-13T22:59:16.558493Z", + "iopub.status.idle": "2026-10-13T22:59:16.562081Z", + "shell.execute_reply": "2026-10-13T22:59:16.561671Z" + }, "tags": [ "hide-in-docs" ] }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
Solution
phase = es.math.arctan2(b, a, unit='deg')\n",
+       "print(show(phase))\n",
+       "
\n", + "

Output:

53.1301 ± 1.1459 deg   in [-180.0000, 180.0000]

The bounds are $\\pm 180^\\circ$, the full range of the function.

" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "%%solution The bounds are $\\pm 180^\\circ$, the full range of the function.\n", "phase = es.math.arctan2(b, a, unit='deg')\n", @@ -446,9 +634,16 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 15, "id": "56a18498", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:16.563075Z", + "iopub.status.busy": "2026-10-13T22:59:16.562964Z", + "iopub.status.idle": "2026-10-13T22:59:16.565521Z", + "shell.execute_reply": "2026-10-13T22:59:16.565055Z" + } + }, "outputs": [], "source": [ "d = Parameter(name='d', value=0.70, variance=0.01**2, unit='angstrom')\n", @@ -477,14 +672,40 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 16, "id": "c05bcafc", "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:16.566470Z", + "iopub.status.busy": "2026-10-13T22:59:16.566361Z", + "iopub.status.idle": "2026-10-13T22:59:16.570492Z", + "shell.execute_reply": "2026-10-13T22:59:16.570114Z" + }, "tags": [ "hide-in-docs" ] }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
Solution
ratio = wavelength / (2 * d)\n",
+       "ratio.name = 'ratio'\n",
+       "try:\n",
+       "    es.math.arcsin(ratio, unit='deg')\n",
+       "except ValueError as error:\n",
+       "    print(error)\n",
+       "
\n", + "

Output:

The value of 'ratio' (1.1004285714285715) is outside the domain [-1.0, 1.0] of arcsin().

The ratio is larger than 1, so no angle exists. Without the line that sets the name, the message would name an automatically generated parameter instead of ratio.

" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "%%solution The ratio is larger than 1, so no angle exists. Without the line that sets the name, the message would name an automatically generated parameter instead of `ratio`.\n", "ratio = wavelength / (2 * d)\n", @@ -508,10 +729,27 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 17, "id": "33dc5b11", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:16.571547Z", + "iopub.status.busy": "2026-10-13T22:59:16.571437Z", + "iopub.status.idle": "2026-10-13T22:59:16.576784Z", + "shell.execute_reply": "2026-10-13T22:59:16.576327Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "15.0000 ± 0.1002 deg in [5.0000, 60.0000]\n", + "variance after the round trip: 0.010050\n", + "variance of theta: 0.010000\n" + ] + } + ], "source": [ "theta = Parameter(name='theta', value=15.0, unit='deg', variance=0.1**2, min=5.0, max=60.0)\n", "wavelength = DescriptorNumber(name='wavelength', value=1.5406, unit='angstrom', variance=0.0005**2)\n", @@ -549,10 +787,39 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 18, "id": "bbb8a36d", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:16.578053Z", + "iopub.status.busy": "2026-10-13T22:59:16.577922Z", + "iopub.status.idle": "2026-10-13T22:59:16.783393Z", + "shell.execute_reply": "2026-10-13T22:59:16.782920Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "wavelengths = np.linspace(0.5, 6.0, 56)\n", "spacing = DescriptorNumber(name='d', value=3.1356, unit='angstrom', variance=0.0)\n", @@ -620,10 +887,39 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 19, "id": "50209b28", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:16.784767Z", + "iopub.status.busy": "2026-10-13T22:59:16.784642Z", + "iopub.status.idle": "2026-10-13T22:59:16.855407Z", + "shell.execute_reply": "2026-10-13T22:59:16.854813Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "imaginary = np.linspace(-3.0, 3.0, 61)\n", "real = Parameter(name='a', value=1.5, variance=0.05**2)\n", @@ -692,10 +988,26 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 20, "id": "d9c50356", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:16.856592Z", + "iopub.status.busy": "2026-10-13T22:59:16.856458Z", + "iopub.status.idle": "2026-10-13T22:59:16.860266Z", + "shell.execute_reply": "2026-10-13T22:59:16.859828Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0.5236 ± 0.0058 rad in [-1.5708, 1.5708]\n", + "1.4293 ± 0.0354 rad in [-1.5708, 1.5708]\n" + ] + } + ], "source": [ "for value in (0.5, 0.99):\n", " print(show(es.math.arcsin(Parameter(name='x', value=value, variance=0.005**2))))" diff --git a/docs/docs/tutorials/basics/maths-trigonometric.ipynb b/docs/docs/tutorials/basics/maths-trigonometric.ipynb index 70a3984e..e96ee6c7 100644 --- a/docs/docs/tutorials/basics/maths-trigonometric.ipynb +++ b/docs/docs/tutorials/basics/maths-trigonometric.ipynb @@ -22,9 +22,16 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "id": "08d5955d", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:18.161556Z", + "iopub.status.busy": "2026-10-13T22:59:18.161446Z", + "iopub.status.idle": "2026-10-13T22:59:19.021396Z", + "shell.execute_reply": "2026-10-13T22:59:19.020887Z" + } + }, "outputs": [], "source": [ "import numpy as np\n", @@ -45,9 +52,16 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "id": "aec560fa", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:19.023434Z", + "iopub.status.busy": "2026-10-13T22:59:19.023200Z", + "iopub.status.idle": "2026-10-13T22:59:19.025944Z", + "shell.execute_reply": "2026-10-13T22:59:19.025450Z" + } + }, "outputs": [], "source": [ "def show(number):\n", @@ -60,9 +74,15 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "id": "c235a46f", "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:19.027574Z", + "iopub.status.busy": "2026-10-13T22:59:19.027395Z", + "iopub.status.idle": "2026-10-13T22:59:19.032390Z", + "shell.execute_reply": "2026-10-13T22:59:19.031873Z" + }, "tags": [ "hide-in-docs" ] @@ -138,10 +158,25 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 4, "id": "4e68d0b1", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:19.033680Z", + "iopub.status.busy": "2026-10-13T22:59:19.033556Z", + "iopub.status.idle": "2026-10-13T22:59:19.049299Z", + "shell.execute_reply": "2026-10-13T22:59:19.048775Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "UnitError: Trigonometric function requires rad or deg unit, got m.\n" + ] + } + ], "source": [ "try:\n", " es.math.sin(Parameter(name='v', value=1.0, unit='m', variance=0.1**2))\n", @@ -170,10 +205,25 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 5, "id": "9b160060", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:19.050403Z", + "iopub.status.busy": "2026-10-13T22:59:19.050276Z", + "iopub.status.idle": "2026-10-13T22:59:19.053980Z", + "shell.execute_reply": "2026-10-13T22:59:19.053563Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0.2588 ± 0.0017 dimensionless in [0.0872, 0.8660]\n" + ] + } + ], "source": [ "theta = Parameter(name='theta', value=15.0, unit='deg', variance=0.1**2, min=5.0, max=60.0)\n", "wavelength = DescriptorNumber(name='wavelength', value=1.5406, unit='angstrom', variance=0.0005**2)\n", @@ -192,10 +242,26 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 6, "id": "a1242c23", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:19.055092Z", + "iopub.status.busy": "2026-10-13T22:59:19.054979Z", + "iopub.status.idle": "2026-10-13T22:59:19.058897Z", + "shell.execute_reply": "2026-10-13T22:59:19.058467Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2.9762 ± 0.0194 Å in [0.8895, 8.8382]\n", + "2.1111 ± 0.0138 1/Å in [0.7109, 7.0640]\n" + ] + } + ], "source": [ "d = wavelength / (2 * s)\n", "q = 4 * np.pi * s / wavelength\n", @@ -215,9 +281,16 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 7, "id": "f6c4fdf5", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:19.060242Z", + "iopub.status.busy": "2026-10-13T22:59:19.060121Z", + "iopub.status.idle": "2026-10-13T22:59:19.062612Z", + "shell.execute_reply": "2026-10-13T22:59:19.062251Z" + } + }, "outputs": [], "source": [ "theta = Parameter(name='theta', value=22.5, variance=0.2**2, unit='deg')\n", @@ -246,14 +319,36 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 8, "id": "c33436eb", "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:19.063920Z", + "iopub.status.busy": "2026-10-13T22:59:19.063807Z", + "iopub.status.idle": "2026-10-13T22:59:19.085776Z", + "shell.execute_reply": "2026-10-13T22:59:19.085322Z" + }, "tags": [ "hide-in-docs" ] }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
Solution
q = 4 * np.pi * es.math.sin(theta) / wavelength\n",
+       "print(show(q))\n",
+       "
\n", + "

Output:

3.1227 ± 0.0263 1/Å   in [-8.1600, 8.1600]

The bounds are those of the sine, $\\pm 1$, scaled by $4\\pi/\\lambda$.

" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "%%solution The bounds are those of the sine, $\\pm 1$, scaled by $4\\pi/\\lambda$.\n", "q = 4 * np.pi * es.math.sin(theta) / wavelength\n", @@ -272,9 +367,16 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 9, "id": "b3c6c722", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:19.087066Z", + "iopub.status.busy": "2026-10-13T22:59:19.086923Z", + "iopub.status.idle": "2026-10-13T22:59:19.089730Z", + "shell.execute_reply": "2026-10-13T22:59:19.089312Z" + } + }, "outputs": [], "source": [ "theta = Parameter(name='theta', value=22.5, variance=0.2**2, unit='deg')\n", @@ -303,14 +405,36 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 10, "id": "f9064daa", "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:19.090892Z", + "iopub.status.busy": "2026-10-13T22:59:19.090771Z", + "iopub.status.idle": "2026-10-13T22:59:19.095198Z", + "shell.execute_reply": "2026-10-13T22:59:19.094735Z" + }, "tags": [ "hide-in-docs" ] }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
Solution
d = wavelength / (2 * es.math.sin(theta))\n",
+       "print(show(d))\n",
+       "
\n", + "

Output:

2.0121 ± 0.0170 Å
" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "%%solution\n", "d = wavelength / (2 * es.math.sin(theta))\n", @@ -329,10 +453,26 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 11, "id": "3c772429", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:19.096178Z", + "iopub.status.busy": "2026-10-13T22:59:19.096056Z", + "iopub.status.idle": "2026-10-13T22:59:19.099609Z", + "shell.execute_reply": "2026-10-13T22:59:19.099230Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "cos 0.8660 ± 0.0087 dimensionless in [0.0000, 1.0000]\n", + "tan 0.5774 ± 0.0233 dimensionless in [0.0000, inf]\n" + ] + } + ], "source": [ "angle = Parameter(name='angle', value=30.0, unit='deg', variance=1.0**2, min=0.0, max=90.0)\n", "\n", @@ -361,9 +501,16 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 12, "id": "12721052", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:19.100695Z", + "iopub.status.busy": "2026-10-13T22:59:19.100582Z", + "iopub.status.idle": "2026-10-13T22:59:19.103077Z", + "shell.execute_reply": "2026-10-13T22:59:19.102632Z" + } + }, "outputs": [], "source": [ "distance = Parameter(name='distance', value=1.50, variance=0.01**2, unit='m')\n", @@ -392,14 +539,36 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 13, "id": "4b8fa4e8", "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:19.104118Z", + "iopub.status.busy": "2026-10-13T22:59:19.104000Z", + "iopub.status.idle": "2026-10-13T22:59:19.107657Z", + "shell.execute_reply": "2026-10-13T22:59:19.107259Z" + }, "tags": [ "hide-in-docs" ] }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
Solution
y = distance * es.math.tan(two_theta)\n",
+       "print(show(y))\n",
+       "
\n", + "

Output:

0.8660 ± 0.0091 m
" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "%%solution\n", "y = distance * es.math.tan(two_theta)\n", @@ -420,10 +589,26 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 14, "id": "3bcf8d0e", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:19.108681Z", + "iopub.status.busy": "2026-10-13T22:59:19.108564Z", + "iopub.status.idle": "2026-10-13T22:59:19.113808Z", + "shell.execute_reply": "2026-10-13T22:59:19.113359Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0.6033 ± 0.0268 dimensionless in [0.0022, 0.9194]\n", + "UnitError: sinc requires rad or dimensionless unit, got deg.\n" + ] + } + ], "source": [ "x = Parameter(name='x', value=1.2, variance=0.05**2, min=0.5, max=3.0)\n", "print(show(es.math.sinc(x) ** 2))\n", @@ -446,9 +631,16 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 15, "id": "bf3fe263", - "metadata": {}, + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:19.115008Z", + "iopub.status.busy": "2026-10-13T22:59:19.114885Z", + "iopub.status.idle": "2026-10-13T22:59:19.117154Z", + "shell.execute_reply": "2026-10-13T22:59:19.116735Z" + } + }, "outputs": [], "source": [ "x = Parameter(name='x', value=2.0, variance=0.1**2)" @@ -476,14 +668,36 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 16, "id": "55ea0189", "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:19.118209Z", + "iopub.status.busy": "2026-10-13T22:59:19.118096Z", + "iopub.status.idle": "2026-10-13T22:59:19.122003Z", + "shell.execute_reply": "2026-10-13T22:59:19.121612Z" + }, "tags": [ "hide-in-docs" ] }, - "outputs": [], + "outputs": [ + { + "data": { + "text/html": [ + "
Solution
intensity = es.math.sinc(x) ** 2\n",
+       "print(show(intensity))\n",
+       "
\n", + "

Output:

0.2067 ± 0.0396 dimensionless   in [0.0000, 1.0000]

The intensity lies between 0 and 1: $\\mathrm{sinc}$ is at most 1, and a square is never negative.

" + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "%%solution The intensity lies between 0 and 1: $\\mathrm{sinc}$ is at most 1, and a square is never negative.\n", "intensity = es.math.sinc(x) ** 2\n", @@ -503,10 +717,25 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 17, "id": "18c5cc41", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:19.123086Z", + "iopub.status.busy": "2026-10-13T22:59:19.122974Z", + "iopub.status.idle": "2026-10-13T22:59:19.125977Z", + "shell.execute_reply": "2026-10-13T22:59:19.125484Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "0.9848 ± 0.0030 dimensionless in [0.0000, 1.0000]\n" + ] + } + ], "source": [ "wide = Parameter(name='wide', value=80.0, unit='deg', variance=1.0**2, min=0.0, max=180.0)\n", "print(show(es.math.sin(wide)))" @@ -529,10 +758,39 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 18, "id": "cef20438", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:19.127170Z", + "iopub.status.busy": "2026-10-13T22:59:19.127030Z", + "iopub.status.idle": "2026-10-13T22:59:19.347711Z", + "shell.execute_reply": "2026-10-13T22:59:19.347234Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "angles = np.linspace(5.0, 60.0, 60)\n", "wavelength = DescriptorNumber(name='wavelength', value=1.5406, unit='angstrom', variance=0.0005**2)\n", @@ -600,10 +858,39 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 19, "id": "853de0a9", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:19.349037Z", + "iopub.status.busy": "2026-10-13T22:59:19.348916Z", + "iopub.status.idle": "2026-10-13T22:59:19.465804Z", + "shell.execute_reply": "2026-10-13T22:59:19.465326Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "\n", + " \n", + " " + ], + "text/plain": [ + "" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], "source": [ "sigma = 0.3\n", "angles = np.linspace(0.0, 2 * np.pi, 80)\n", @@ -645,10 +932,26 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 20, "id": "aa92fe31", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:19.467170Z", + "iopub.status.busy": "2026-10-13T22:59:19.467035Z", + "iopub.status.idle": "2026-10-13T22:59:19.474655Z", + "shell.execute_reply": "2026-10-13T22:59:19.474165Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "1.8369701987210297e-17\n", + "0.06105801493643515\n" + ] + } + ], "source": [ "top = Parameter(name='top', value=np.pi / 2, unit='rad', variance=0.3**2)\n", "print(es.math.sin(top).error)\n", @@ -668,10 +971,25 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 21, "id": "2e1c67f8", - "metadata": {}, - "outputs": [], + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-13T22:59:19.475669Z", + "iopub.status.busy": "2026-10-13T22:59:19.475553Z", + "iopub.status.idle": "2026-10-13T22:59:19.478611Z", + "shell.execute_reply": "2026-10-13T22:59:19.478119Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "The value of 'v' (1.5707963267948966 rad) is within 0.1 rad of a tan asymptote (pi/2 + n*pi); the propagated variance would explode.\n" + ] + } + ], "source": [ "try:\n", " es.math.tan(Parameter(name='v', value=np.pi / 2, unit='rad', variance=0.1**2))\n", From 708102f6b225d312b1f969db461a0bfd8c27922e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mark=20S=C3=B8rensen?= Date: Wed, 14 Oct 2026 01:27:46 +0200 Subject: [PATCH 09/25] fix: clear nb outputs --- .../tutorials/basics/maths-arithmetic.ipynb | 296 +++--------------- .../docs/tutorials/basics/maths-exp-log.ipynb | 247 +++------------ .../tutorials/basics/maths-hyperbolic.ipynb | 236 ++------------ .../basics/maths-inverse-trigonometric.ipynb | 243 ++------------ .../basics/maths-trigonometric.ipynb | 246 +++------------ 5 files changed, 177 insertions(+), 1091 deletions(-) diff --git a/docs/docs/tutorials/basics/maths-arithmetic.ipynb b/docs/docs/tutorials/basics/maths-arithmetic.ipynb index 8973db1f..e19bc4ca 100644 --- a/docs/docs/tutorials/basics/maths-arithmetic.ipynb +++ b/docs/docs/tutorials/basics/maths-arithmetic.ipynb @@ -28,7 +28,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "id": "3248d597", "metadata": { "execution": { @@ -58,7 +58,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "id": "a46d494e", "metadata": { "execution": { @@ -80,7 +80,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "id": "9ed7cb3e", "metadata": { "execution": { @@ -165,7 +165,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "id": "0149da13", "metadata": { "execution": { @@ -175,18 +175,7 @@ "shell.execute_reply": "2026-10-13T22:59:09.128712Z" } }, - "outputs": [ - { - "data": { - "text/plain": [ - "" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "length = Parameter(name='length', value=2.0, unit='m', variance=0.2**2, min=0.0, max=10.0)\n", "length" @@ -202,7 +191,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "id": "9dd36dc6", "metadata": { "execution": { @@ -212,18 +201,7 @@ "shell.execute_reply": "2026-10-13T22:59:09.132168Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "value: 2.0 m\n", - "error: 0.20 m\n", - "variance: 0.04 m²\n", - "bounds: [0.0, 10.0] m\n" - ] - } - ], + "outputs": [], "source": [ "print(f'value: {length.value} {length.unit}')\n", "print(f'error: {length.error:.2f} {length.unit}')\n", @@ -242,7 +220,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "id": "a4f03105", "metadata": { "execution": { @@ -252,15 +230,7 @@ "shell.execute_reply": "2026-10-13T22:59:09.135354Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "scale: 3.00 ± 0.10\n" - ] - } - ], + "outputs": [], "source": [ "scale = DescriptorNumber(name='scale', value=3.0, variance=0.1**2)\n", "print(f'scale: {scale.value:.2f} ± {scale.error:.2f}')" @@ -300,7 +270,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": null, "id": "69e79f4d", "metadata": { "execution": { @@ -310,19 +280,7 @@ "shell.execute_reply": "2026-10-13T22:59:09.139954Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "length: 2.00 ± 0.20 m\n", - "width: 50.00 ± 2.00 cm\n", - "total: 2.5000 ± 0.2010 m\n", - "variance of the total: 0.0404 m²\n", - "bounds of the total: [0.10, 11.00] m\n" - ] - } - ], + "outputs": [], "source": [ "width = Parameter(name='width', value=50.0, unit='cm', variance=2.0**2, min=10.0, max=100.0)\n", "total = length + width\n", @@ -352,7 +310,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": null, "id": "ddd2e459", "metadata": { "execution": { @@ -362,17 +320,7 @@ "shell.execute_reply": "2026-10-13T22:59:09.144206Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "difference: 1.5000 ± 0.2010 m\n", - "variance of the difference: 0.0404 m²\n", - "bounds of the difference: [-1.00, 9.90] m\n" - ] - } - ], + "outputs": [], "source": [ "difference = length - width\n", "\n", @@ -394,7 +342,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": null, "id": "af71c46b", "metadata": { "execution": { @@ -404,16 +352,7 @@ "shell.execute_reply": "2026-10-13T22:59:09.147825Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "UnitError: Numbers can only be added to dimensionless values\n", - "scale + 1: 4.00 ± 0.10\n" - ] - } - ], + "outputs": [], "source": [ "try:\n", " length + 1.0\n", @@ -436,7 +375,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": null, "id": "a88eeddf", "metadata": { "execution": { @@ -474,7 +413,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": null, "id": "2520c343", "metadata": { "execution": { @@ -487,23 +426,7 @@ "hide-in-docs" ] }, - "outputs": [ - { - "data": { - "text/html": [ - "
Solution
total = section_1 + section_2\n",
-       "print(show(total))\n",
-       "
\n", - "

Output:

2.0000 ± 0.0361 m
" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "%%solution\n", "total = section_1 + section_2\n", @@ -522,7 +445,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": null, "id": "a64159f6", "metadata": { "execution": { @@ -560,7 +483,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": null, "id": "ce692abb", "metadata": { "execution": { @@ -573,23 +496,7 @@ "hide-in-docs" ] }, - "outputs": [ - { - "data": { - "text/html": [ - "
Solution
remaining = tube - gap\n",
-       "print(show(remaining))\n",
-       "
\n", - "

Output:

1.0750 ± 0.0206 m
" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "%%solution\n", "remaining = tube - gap\n", @@ -608,7 +515,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": null, "id": "bbc2cdc8", "metadata": { "execution": { @@ -618,16 +525,7 @@ "shell.execute_reply": "2026-10-13T22:59:09.184441Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "area: 3.0000 ± 0.3606 m^2\n", - "bounds of the area: [0.00, 30.00] m^2\n" - ] - } - ], + "outputs": [], "source": [ "height = Parameter(name='height', value=1.5, unit='m', variance=0.1**2, min=1.0, max=3.0)\n", "area = length * height\n", @@ -647,7 +545,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": null, "id": "32d674ef", "metadata": { "execution": { @@ -657,16 +555,7 @@ "shell.execute_reply": "2026-10-13T22:59:09.188488Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "speed: 0.5000 ± 0.0590 m/s\n", - "bounds of the speed: [0.00, 5.00] m/s\n" - ] - } - ], + "outputs": [], "source": [ "time = Parameter(name='time', value=4.0, unit='s', variance=0.25**2, min=2.0, max=8.0)\n", "speed = length / time\n", @@ -685,7 +574,7 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": null, "id": "1bf87c9c", "metadata": { "execution": { @@ -695,15 +584,7 @@ "shell.execute_reply": "2026-10-13T22:59:09.191857Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "DescriptorNumber: 6.00 ± 0.20\n" - ] - } - ], + "outputs": [], "source": [ "doubled = scale * 2\n", "print(f'{type(doubled).__name__}: {doubled.value:.2f} ± {doubled.error:.2f}')" @@ -721,7 +602,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": null, "id": "eafe8969", "metadata": { "execution": { @@ -759,7 +640,7 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": null, "id": "b0b64a26", "metadata": { "execution": { @@ -772,23 +653,7 @@ "hide-in-docs" ] }, - "outputs": [ - { - "data": { - "text/html": [ - "
Solution
speed = distance / time\n",
-       "print(show(speed))\n",
-       "
\n", - "

Output:

2.0000 ± 0.0943 m/s
" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "%%solution\n", "speed = distance / time\n", @@ -807,7 +672,7 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": null, "id": "5ba32813", "metadata": { "execution": { @@ -817,17 +682,7 @@ "shell.execute_reply": "2026-10-13T22:59:09.204549Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "length**2: 4.00 ± 0.80 m^2, bounds [0.0, 100.0]\n", - "-length: -2.00 ± 0.20 m, bounds [-10.0, -0.0]\n", - "abs(-length): 2.00 ± 0.20 m, bounds [0.0, 10.0]\n" - ] - } - ], + "outputs": [], "source": [ "squared = length**2\n", "negated = -length\n", @@ -856,7 +711,7 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": null, "id": "61387f4a", "metadata": { "execution": { @@ -893,7 +748,7 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": null, "id": "8e084269", "metadata": { "execution": { @@ -906,23 +761,7 @@ "hide-in-docs" ] }, - "outputs": [ - { - "data": { - "text/html": [ - "
Solution
area = np.pi * radius**2\n",
-       "print(show(area))\n",
-       "
\n", - "

Output:

12.5664 ± 1.2566 cm^2   in [0.0000, inf]

The lower bound 0 appears because a square can never be negative.

" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "%%solution The lower bound 0 appears because a square can never be negative.\n", "area = np.pi * radius**2\n", @@ -945,7 +784,7 @@ }, { "cell_type": "code", - "execution_count": 22, + "execution_count": null, "id": "d97fb184", "metadata": { "execution": { @@ -955,29 +794,7 @@ "shell.execute_reply": "2026-10-13T22:59:09.411368Z" } }, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " \n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "times = np.linspace(0.5, 5.0, 46)\n", "distance = Parameter(name='distance', value=3.0, unit='m', variance=0.1**2)\n", @@ -1044,7 +861,7 @@ }, { "cell_type": "code", - "execution_count": 23, + "execution_count": null, "id": "51adac82", "metadata": { "execution": { @@ -1054,29 +871,7 @@ "shell.execute_reply": "2026-10-13T22:59:09.615923Z" } }, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " \n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "counts = np.arange(1, 26)\n", "\n", @@ -1116,7 +911,7 @@ }, { "cell_type": "code", - "execution_count": 24, + "execution_count": null, "id": "3603e9d6", "metadata": { "execution": { @@ -1126,16 +921,7 @@ "shell.execute_reply": "2026-10-13T22:59:09.622047Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "x - x: 0.0000 ± 0.0000, bounds [-10.0, 10.0]\n", - "(2x) / x: 2.0000 ± 0.0283\n" - ] - } - ], + "outputs": [], "source": [ "x = Parameter(name='x', value=10.0, variance=0.1**2, min=0.0, max=10.0)\n", "difference = x - x\n", diff --git a/docs/docs/tutorials/basics/maths-exp-log.ipynb b/docs/docs/tutorials/basics/maths-exp-log.ipynb index ce0f6c86..c0da6bce 100644 --- a/docs/docs/tutorials/basics/maths-exp-log.ipynb +++ b/docs/docs/tutorials/basics/maths-exp-log.ipynb @@ -23,7 +23,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "id": "f10b3445", "metadata": { "execution": { @@ -53,7 +53,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "id": "ed1b6606", "metadata": { "execution": { @@ -75,7 +75,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "id": "49a46c97", "metadata": { "execution": { @@ -160,7 +160,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "id": "9752cc6f", "metadata": { "execution": { @@ -170,15 +170,7 @@ "shell.execute_reply": "2026-10-13T22:59:11.681690Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "0.9868 ± 0.0026 dimensionless in [0.7659, 1.0000]\n" - ] - } - ], + "outputs": [], "source": [ "q = Parameter(name='q', value=2.0, unit='1/angstrom', variance=0.02**2, min=0.5, max=4.0)\n", "u2 = Parameter(name='u2', value=0.01, unit='angstrom**2', variance=0.002**2, min=0.0, max=0.05)\n", @@ -208,7 +200,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "id": "90885017", "metadata": { "execution": { @@ -246,7 +238,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "id": "fa6f7cd7", "metadata": { "execution": { @@ -259,23 +251,7 @@ "hide-in-docs" ] }, - "outputs": [ - { - "data": { - "text/html": [ - "
Solution
transmission = es.math.exp(-mu * t)\n",
-       "print(show(transmission))\n",
-       "
\n", - "

Output:

0.3679 ± 0.0236 dimensionless   in [0.0000, inf]

The lower bound 0 appears because an exponential is never negative.

" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "%%solution The lower bound 0 appears because an exponential is never negative.\n", "transmission = es.math.exp(-mu * t)\n", @@ -296,7 +272,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": null, "id": "f4d206cf", "metadata": { "execution": { @@ -306,15 +282,7 @@ "shell.execute_reply": "2026-10-13T22:59:11.714396Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "-6.0206 ± 0.1737 dimensionless in [-10.0000, -3.9794]\n" - ] - } - ], + "outputs": [], "source": [ "intensity = Parameter(name='intensity', value=250.0, variance=10.0**2, min=100.0, max=400.0)\n", "reference = DescriptorNumber(name='reference', value=1000.0, variance=0.0)\n", @@ -333,7 +301,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": null, "id": "113433ff", "metadata": { "execution": { @@ -343,15 +311,7 @@ "shell.execute_reply": "2026-10-13T22:59:11.718149Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "The value of 'v' (0.0) is outside the domain (0.0, inf] of log10().\n" - ] - } - ], + "outputs": [], "source": [ "try:\n", " es.math.log10(Parameter(name='v', value=0.0, variance=0.1**2))\n", @@ -371,7 +331,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": null, "id": "b8b46c83", "metadata": { "execution": { @@ -409,7 +369,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": null, "id": "f65e993b", "metadata": { "execution": { @@ -422,23 +382,7 @@ "hide-in-docs" ] }, - "outputs": [ - { - "data": { - "text/html": [ - "
Solution
level = 10 * es.math.log10(intensity / reference)\n",
-       "print(show(level))\n",
-       "
\n", - "

Output:

-6.0206 ± 0.1737 dimensionless
" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "%%solution\n", "level = 10 * es.math.log10(intensity / reference)\n", @@ -458,7 +402,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": null, "id": "e5fc6001", "metadata": { "execution": { @@ -468,15 +412,7 @@ "shell.execute_reply": "2026-10-13T22:59:11.738724Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "5.0000 ± 0.1000 dimensionless in [3.6056, 6.4031]\n" - ] - } - ], + "outputs": [], "source": [ "a = Parameter(name='a', value=3.0, variance=0.1**2, min=2.0, max=4.0)\n", "b = Parameter(name='b', value=4.0, variance=0.1**2, min=3.0, max=5.0)\n", @@ -496,7 +432,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": null, "id": "bc75d2f8", "metadata": { "execution": { @@ -506,16 +442,7 @@ "shell.execute_reply": "2026-10-13T22:59:11.745282Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "3.0000 ± 0.0500 m in [2.0000, 4.0000]\n", - "UnitError: Unsupported unit as result of sqrt: sqrt(m).\n" - ] - } - ], + "outputs": [], "source": [ "area = Parameter(name='area', value=9.0, unit='m**2', variance=0.3**2, min=4.0, max=16.0)\n", "print(show(es.math.sqrt(area)))\n", @@ -538,7 +465,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": null, "id": "61c225b5", "metadata": { "execution": { @@ -575,7 +502,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": null, "id": "30d3a32c", "metadata": { "execution": { @@ -588,23 +515,7 @@ "hide-in-docs" ] }, - "outputs": [ - { - "data": { - "text/html": [ - "
Solution
amplitude = es.math.sqrt(intensity)\n",
-       "print(show(amplitude))\n",
-       "
\n", - "

Output:

0.8000 ± 0.0125 dimensionless   in [0.0000, inf]

The lower bound 0 is the lower end of the domain of the square root.

" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "%%solution The lower bound 0 is the lower end of the domain of the square root.\n", "amplitude = es.math.sqrt(intensity)\n", @@ -625,7 +536,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": null, "id": "b2de6389", "metadata": { "execution": { @@ -635,17 +546,7 @@ "shell.execute_reply": "2026-10-13T22:59:11.760344Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "0.8176 ± 0.0263 dimensionless in [0.3829, 0.9973]\n", - "0.1824 ± 0.0263 dimensionless in [0.0027, 0.6171]\n", - "1.0\n" - ] - } - ], + "outputs": [], "source": [ "slit = Parameter(name='slit', value=2.0, unit='mm', variance=0.1**2, min=1.0, max=3.0)\n", "beam = Parameter(name='beam', value=1.5, unit='mm', variance=0.05**2, min=1.0, max=2.0)\n", @@ -669,7 +570,7 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": null, "id": "cce37214", "metadata": { "execution": { @@ -707,7 +608,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": null, "id": "3c25236f", "metadata": { "execution": { @@ -720,23 +621,7 @@ "hide-in-docs" ] }, - "outputs": [ - { - "data": { - "text/html": [ - "
Solution
fraction = es.math.erf(half_width / (beam_sigma * np.sqrt(2)))\n",
-       "print(show(fraction))\n",
-       "
\n", - "

Output:

0.7887 ± 0.0323 dimensionless   in [-1.0000, 1.0000]

With unbounded inputs the bounds of erf are its range, from -1 to 1.

" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "%%solution With unbounded inputs the bounds of `erf` are its range, from -1 to 1.\n", "fraction = es.math.erf(half_width / (beam_sigma * np.sqrt(2)))\n", @@ -755,7 +640,7 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": null, "id": "ca64e157", "metadata": { "execution": { @@ -765,16 +650,7 @@ "shell.execute_reply": "2026-10-13T22:59:11.775029Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "1.2000 ± 0.0500 dimensionless in [0.5000, 3.0000]\n", - "True\n" - ] - } - ], + "outputs": [], "source": [ "x = Parameter(name='x', value=1.2, variance=0.05**2, min=0.5, max=3.0)\n", "round_trip = es.math.exp(es.math.log(x))\n", @@ -798,7 +674,7 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": null, "id": "da2bed0a", "metadata": { "execution": { @@ -808,29 +684,7 @@ "shell.execute_reply": "2026-10-13T22:59:11.991763Z" } }, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " \n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "q_values = np.linspace(0.2, 4.0, 40)\n", "u2 = Parameter(name='u2', value=0.01, unit='angstrom**2', variance=0.002**2)\n", @@ -897,7 +751,7 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": null, "id": "c73439aa", "metadata": { "execution": { @@ -907,29 +761,7 @@ "shell.execute_reply": "2026-10-13T22:59:12.088433Z" } }, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " \n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "half_widths = np.linspace(0.1, 3.0, 59)\n", "beam = Parameter(name='beam', value=0.8, unit='mm', variance=0.04**2)\n", @@ -999,7 +831,7 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": null, "id": "d48f797a", "metadata": { "execution": { @@ -1009,16 +841,7 @@ "shell.execute_reply": "2026-10-13T22:59:12.093196Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "7.3890560989306495\n", - "34.51261310995657\n" - ] - } - ], + "outputs": [], "source": [ "wide = Parameter(name='wide', value=1.0, variance=1.0**2)\n", "print(es.math.exp(wide).variance)\n", diff --git a/docs/docs/tutorials/basics/maths-hyperbolic.ipynb b/docs/docs/tutorials/basics/maths-hyperbolic.ipynb index 10eca3ed..7246bb73 100644 --- a/docs/docs/tutorials/basics/maths-hyperbolic.ipynb +++ b/docs/docs/tutorials/basics/maths-hyperbolic.ipynb @@ -23,7 +23,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "id": "95f6240e", "metadata": { "execution": { @@ -53,7 +53,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "id": "526ea56e", "metadata": { "execution": { @@ -75,7 +75,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "id": "9a76574e", "metadata": { "execution": { @@ -162,7 +162,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "id": "0df6e42b", "metadata": { "execution": { @@ -172,17 +172,7 @@ "shell.execute_reply": "2026-10-13T22:59:14.141675Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "sinh 0.8881 ± 0.0669 dimensionless in [0.0000, 2.1293]\n", - "cosh 1.3374 ± 0.0444 dimensionless in [1.0000, 2.3524]\n", - "tanh 0.6640 ± 0.0280 dimensionless in [0.0000, 0.9051]\n" - ] - } - ], + "outputs": [], "source": [ "x = Parameter(name='x', value=0.8, variance=0.05**2, min=0.0, max=1.5)\n", "\n", @@ -201,7 +191,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "id": "ec093603", "metadata": { "execution": { @@ -211,15 +201,7 @@ "shell.execute_reply": "2026-10-13T22:59:14.147411Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "0.7616 ± 0.0168 dimensionless in [-1.0000, 1.0000]\n" - ] - } - ], + "outputs": [], "source": [ "moment = Parameter(name='moment', value=0.5, variance=0.02**2, unit='meV/T')\n", "field = DescriptorNumber(name='field', value=4.0, variance=0.0, unit='T')\n", @@ -241,7 +223,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "id": "87b8075d", "metadata": { "execution": { @@ -278,7 +260,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": null, "id": "6d9c0c8a", "metadata": { "execution": { @@ -291,23 +273,7 @@ "hide-in-docs" ] }, - "outputs": [ - { - "data": { - "text/html": [ - "
Solution
magnetisation = es.math.tanh(x)\n",
-       "print(show(magnetisation))\n",
-       "
\n", - "

Output:

0.6640 ± 0.0280 dimensionless   in [0.0000, 0.9951]

The upper bound is $\\tanh(3)$, not 1: the bounds are the range over the bounds of the input.

" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "%%solution The upper bound is $\\tanh(3)$, not 1: the bounds are the range over the bounds of the input.\n", "magnetisation = es.math.tanh(x)\n", @@ -327,7 +293,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": null, "id": "dfd08731", "metadata": { "execution": { @@ -337,16 +303,7 @@ "shell.execute_reply": "2026-10-13T22:59:14.177284Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "0.5493 ± 0.0267 dimensionless in [-1.4722, 1.4722]\n", - "1.3170 ± 0.0289 dimensionless in [0.0000, 2.0634]\n" - ] - } - ], + "outputs": [], "source": [ "saturation = Parameter(name='saturation', value=0.5, variance=0.02**2, min=-0.9, max=0.9)\n", "print(show(es.math.arctanh(saturation)))\n", @@ -367,7 +324,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": null, "id": "f9e6f602", "metadata": { "execution": { @@ -404,7 +361,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": null, "id": "4edc2709", "metadata": { "execution": { @@ -417,23 +374,7 @@ "hide-in-docs" ] }, - "outputs": [ - { - "data": { - "text/html": [ - "
Solution
x = es.math.arctanh(saturation)\n",
-       "print(show(x))\n",
-       "
\n", - "

Output:

0.5493 ± 0.0267 dimensionless
" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "%%solution\n", "x = es.math.arctanh(saturation)\n", @@ -453,7 +394,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": null, "id": "51536f23", "metadata": { "execution": { @@ -463,15 +404,7 @@ "shell.execute_reply": "2026-10-13T22:59:14.189452Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "1.0000 ± 0.0000 dimensionless in [1.0000, 1.5431]\n" - ] - } - ], + "outputs": [], "source": [ "z = Parameter(name='z', value=0.0, variance=0.5**2, min=-1.0, max=1.0)\n", "result = es.math.cosh(z)\n", @@ -490,7 +423,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": null, "id": "05f2f28d", "metadata": { "execution": { @@ -527,7 +460,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": null, "id": "65bdea3a", "metadata": { "execution": { @@ -540,23 +473,7 @@ "hide-in-docs" ] }, - "outputs": [ - { - "data": { - "text/html": [ - "
Solution
result = es.math.cosh(x)\n",
-       "print(result.min)\n",
-       "
\n", - "

Output:

1.0

The lower bound is 1, the value at 0, not $\\cosh(-1)$.

" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "%%solution The lower bound is 1, the value at 0, not $\\cosh(-1)$.\n", "result = es.math.cosh(x)\n", @@ -586,7 +503,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": null, "id": "e8012f6d", "metadata": { "execution": { @@ -623,7 +540,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": null, "id": "fcceb1cc", "metadata": { "execution": { @@ -636,25 +553,7 @@ "hide-in-docs" ] }, - "outputs": [ - { - "data": { - "text/html": [ - "
Solution
try:\n",
-       "    es.math.arccosh(x)\n",
-       "except ValueError as error:\n",
-       "    print(error)\n",
-       "
\n", - "

Output:

The value of 'x' (0.5) is outside the domain [1.0, inf] of arccosh().
" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "%%solution\n", "try:\n", @@ -675,7 +574,7 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": null, "id": "102d1d37", "metadata": { "execution": { @@ -685,15 +584,7 @@ "shell.execute_reply": "2026-10-13T22:59:14.207687Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "0.8000 ± 0.0500 dimensionless in [0.0000, 1.5000]\n" - ] - } - ], + "outputs": [], "source": [ "x = Parameter(name='x', value=0.8, variance=0.05**2, min=0.0, max=1.5)\n", "back = es.math.arcsinh(es.math.sinh(x))\n", @@ -716,7 +607,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": null, "id": "6cd723ec", "metadata": { "execution": { @@ -726,29 +617,7 @@ "shell.execute_reply": "2026-10-13T22:59:14.379585Z" } }, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " \n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "arguments = np.linspace(-3.0, 3.0, 61)\n", "\n", @@ -815,7 +684,7 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": null, "id": "b9a42646", "metadata": { "execution": { @@ -825,29 +694,7 @@ "shell.execute_reply": "2026-10-13T22:59:14.441250Z" } }, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " \n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "ys = np.linspace(1.05, 5.0, 60)\n", "\n", @@ -917,7 +764,7 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": null, "id": "dc485292", "metadata": { "execution": { @@ -927,16 +774,7 @@ "shell.execute_reply": "2026-10-13T22:59:14.446369Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "0.0\n", - "0.0403352186623227\n" - ] - } - ], + "outputs": [], "source": [ "flat = Parameter(name='flat', value=0.0, variance=0.5**2)\n", "print(es.math.cosh(flat).variance)\n", @@ -956,7 +794,7 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": null, "id": "892ca132", "metadata": { "execution": { @@ -966,15 +804,7 @@ "shell.execute_reply": "2026-10-13T22:59:14.449847Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "sinh(big) is not finite: value=inf, variance=inf. Check the value and variance of the argument(s).\n" - ] - } - ], + "outputs": [], "source": [ "try:\n", " es.math.sinh(Parameter(name='big', value=800.0, variance=1.0**2))\n", diff --git a/docs/docs/tutorials/basics/maths-inverse-trigonometric.ipynb b/docs/docs/tutorials/basics/maths-inverse-trigonometric.ipynb index d313871c..d98ad2f4 100644 --- a/docs/docs/tutorials/basics/maths-inverse-trigonometric.ipynb +++ b/docs/docs/tutorials/basics/maths-inverse-trigonometric.ipynb @@ -23,7 +23,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "id": "b248846e", "metadata": { "execution": { @@ -53,7 +53,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "id": "b7551579", "metadata": { "execution": { @@ -75,7 +75,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "id": "d0d3ee83", "metadata": { "execution": { @@ -160,7 +160,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "id": "4146fd2b", "metadata": { "execution": { @@ -170,16 +170,7 @@ "shell.execute_reply": "2026-10-13T22:59:16.499109Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "0.2482 ± 0.0040 rad in [0.1287, 0.3954]\n", - "14.2210 ± 0.2315 deg in [7.3762, 22.6531]\n" - ] - } - ], + "outputs": [], "source": [ "d = Parameter(name='d', value=3.1356, unit='angstrom', variance=0.05**2, min=2.0, max=6.0)\n", "wavelength = DescriptorNumber(name='wavelength', value=1.5406, unit='angstrom', variance=0.0)\n", @@ -209,7 +200,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "id": "b41c68a2", "metadata": { "execution": { @@ -247,7 +238,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "id": "75909c1a", "metadata": { "execution": { @@ -260,23 +251,7 @@ "hide-in-docs" ] }, - "outputs": [ - { - "data": { - "text/html": [ - "
Solution
theta = es.math.arcsin(wavelength / (2 * d), unit='deg')\n",
-       "print(show(theta))\n",
-       "
\n", - "

Output:

14.2210 ± 0.0023 deg   in [-90.0000, 90.0000]

The bounds of the arcsine are $\\pm 90^\\circ$ for an unbounded input.

" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "%%solution The bounds of the arcsine are $\\pm 90^\\circ$ for an unbounded input.\n", "theta = es.math.arcsin(wavelength / (2 * d), unit='deg')\n", @@ -296,7 +271,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": null, "id": "45087906", "metadata": { "execution": { @@ -306,15 +281,7 @@ "shell.execute_reply": "2026-10-13T22:59:16.531379Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "1.0472 ± 0.0231 rad in [0.0000, 1.5708]\n" - ] - } - ], + "outputs": [], "source": [ "ratio = Parameter(name='ratio', value=0.5, variance=0.02**2, min=0.0, max=1.0)\n", "print(show(es.math.arccos(ratio)))" @@ -330,7 +297,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": null, "id": "817aef54", "metadata": { "execution": { @@ -340,15 +307,7 @@ "shell.execute_reply": "2026-10-13T22:59:16.535381Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "5.7106 ± 0.2836 deg in [-90.0000, 90.0000]\n" - ] - } - ], + "outputs": [], "source": [ "grade = Parameter(name='grade', value=0.10, variance=0.005**2)\n", "print(show(es.math.arctan(grade, unit='deg')))" @@ -366,7 +325,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": null, "id": "1180c5ba", "metadata": { "execution": { @@ -404,7 +363,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": null, "id": "dcf2bf11", "metadata": { "execution": { @@ -417,23 +376,7 @@ "hide-in-docs" ] }, - "outputs": [ - { - "data": { - "text/html": [ - "
Solution
angle = es.math.arctan(rise / run, unit='deg')\n",
-       "print(show(angle))\n",
-       "
\n", - "

Output:

14.0362 ± 0.5024 deg   in [-90.0000, 90.0000]
" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "%%solution\n", "angle = es.math.arctan(rise / run, unit='deg')\n", @@ -454,7 +397,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": null, "id": "99e676f2", "metadata": { "execution": { @@ -464,17 +407,7 @@ "shell.execute_reply": "2026-10-13T22:59:16.550319Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "53.1301 ± 1.1459 deg in [36.8699, 68.1986]\n", - "126.8699 ± 1.1459 deg in [111.8014, 143.1301]\n", - "-53.1301 ± 1.1459 deg in [-68.1986, -36.8699]\n" - ] - } - ], + "outputs": [], "source": [ "a = Parameter(name='a', value=3.0, variance=0.1**2, min=2.0, max=4.0)\n", "b = Parameter(name='b', value=4.0, variance=0.1**2, min=3.0, max=5.0)\n", @@ -498,7 +431,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": null, "id": "cc28134c", "metadata": { "execution": { @@ -508,18 +441,7 @@ "shell.execute_reply": "2026-10-13T22:59:16.554013Z" } }, - "outputs": [ - { - "data": { - "text/plain": [ - "True" - ] - }, - "execution_count": 12, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "es.math.asin is es.math.arcsin" ] @@ -536,7 +458,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": null, "id": "bab72623", "metadata": { "execution": { @@ -574,7 +496,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": null, "id": "d7528ae8", "metadata": { "execution": { @@ -587,23 +509,7 @@ "hide-in-docs" ] }, - "outputs": [ - { - "data": { - "text/html": [ - "
Solution
phase = es.math.arctan2(b, a, unit='deg')\n",
-       "print(show(phase))\n",
-       "
\n", - "

Output:

53.1301 ± 1.1459 deg   in [-180.0000, 180.0000]

The bounds are $\\pm 180^\\circ$, the full range of the function.

" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "%%solution The bounds are $\\pm 180^\\circ$, the full range of the function.\n", "phase = es.math.arctan2(b, a, unit='deg')\n", @@ -634,7 +540,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": null, "id": "56a18498", "metadata": { "execution": { @@ -672,7 +578,7 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": null, "id": "c05bcafc", "metadata": { "execution": { @@ -685,27 +591,7 @@ "hide-in-docs" ] }, - "outputs": [ - { - "data": { - "text/html": [ - "
Solution
ratio = wavelength / (2 * d)\n",
-       "ratio.name = 'ratio'\n",
-       "try:\n",
-       "    es.math.arcsin(ratio, unit='deg')\n",
-       "except ValueError as error:\n",
-       "    print(error)\n",
-       "
\n", - "

Output:

The value of 'ratio' (1.1004285714285715) is outside the domain [-1.0, 1.0] of arcsin().

The ratio is larger than 1, so no angle exists. Without the line that sets the name, the message would name an automatically generated parameter instead of ratio.

" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "%%solution The ratio is larger than 1, so no angle exists. Without the line that sets the name, the message would name an automatically generated parameter instead of `ratio`.\n", "ratio = wavelength / (2 * d)\n", @@ -729,7 +615,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": null, "id": "33dc5b11", "metadata": { "execution": { @@ -739,17 +625,7 @@ "shell.execute_reply": "2026-10-13T22:59:16.576327Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "15.0000 ± 0.1002 deg in [5.0000, 60.0000]\n", - "variance after the round trip: 0.010050\n", - "variance of theta: 0.010000\n" - ] - } - ], + "outputs": [], "source": [ "theta = Parameter(name='theta', value=15.0, unit='deg', variance=0.1**2, min=5.0, max=60.0)\n", "wavelength = DescriptorNumber(name='wavelength', value=1.5406, unit='angstrom', variance=0.0005**2)\n", @@ -787,7 +663,7 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": null, "id": "bbb8a36d", "metadata": { "execution": { @@ -797,29 +673,7 @@ "shell.execute_reply": "2026-10-13T22:59:16.782920Z" } }, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " \n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "wavelengths = np.linspace(0.5, 6.0, 56)\n", "spacing = DescriptorNumber(name='d', value=3.1356, unit='angstrom', variance=0.0)\n", @@ -887,7 +741,7 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": null, "id": "50209b28", "metadata": { "execution": { @@ -897,29 +751,7 @@ "shell.execute_reply": "2026-10-13T22:59:16.854813Z" } }, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " \n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "imaginary = np.linspace(-3.0, 3.0, 61)\n", "real = Parameter(name='a', value=1.5, variance=0.05**2)\n", @@ -988,7 +820,7 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": null, "id": "d9c50356", "metadata": { "execution": { @@ -998,16 +830,7 @@ "shell.execute_reply": "2026-10-13T22:59:16.859828Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "0.5236 ± 0.0058 rad in [-1.5708, 1.5708]\n", - "1.4293 ± 0.0354 rad in [-1.5708, 1.5708]\n" - ] - } - ], + "outputs": [], "source": [ "for value in (0.5, 0.99):\n", " print(show(es.math.arcsin(Parameter(name='x', value=value, variance=0.005**2))))" diff --git a/docs/docs/tutorials/basics/maths-trigonometric.ipynb b/docs/docs/tutorials/basics/maths-trigonometric.ipynb index e96ee6c7..b022d97f 100644 --- a/docs/docs/tutorials/basics/maths-trigonometric.ipynb +++ b/docs/docs/tutorials/basics/maths-trigonometric.ipynb @@ -22,7 +22,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "id": "08d5955d", "metadata": { "execution": { @@ -52,7 +52,7 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "id": "aec560fa", "metadata": { "execution": { @@ -74,7 +74,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "id": "c235a46f", "metadata": { "execution": { @@ -158,7 +158,7 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "id": "4e68d0b1", "metadata": { "execution": { @@ -168,15 +168,7 @@ "shell.execute_reply": "2026-10-13T22:59:19.048775Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "UnitError: Trigonometric function requires rad or deg unit, got m.\n" - ] - } - ], + "outputs": [], "source": [ "try:\n", " es.math.sin(Parameter(name='v', value=1.0, unit='m', variance=0.1**2))\n", @@ -205,7 +197,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "id": "9b160060", "metadata": { "execution": { @@ -215,15 +207,7 @@ "shell.execute_reply": "2026-10-13T22:59:19.053563Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "0.2588 ± 0.0017 dimensionless in [0.0872, 0.8660]\n" - ] - } - ], + "outputs": [], "source": [ "theta = Parameter(name='theta', value=15.0, unit='deg', variance=0.1**2, min=5.0, max=60.0)\n", "wavelength = DescriptorNumber(name='wavelength', value=1.5406, unit='angstrom', variance=0.0005**2)\n", @@ -242,7 +226,7 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "id": "a1242c23", "metadata": { "execution": { @@ -252,16 +236,7 @@ "shell.execute_reply": "2026-10-13T22:59:19.058467Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "2.9762 ± 0.0194 Å in [0.8895, 8.8382]\n", - "2.1111 ± 0.0138 1/Å in [0.7109, 7.0640]\n" - ] - } - ], + "outputs": [], "source": [ "d = wavelength / (2 * s)\n", "q = 4 * np.pi * s / wavelength\n", @@ -281,7 +256,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": null, "id": "f6c4fdf5", "metadata": { "execution": { @@ -319,7 +294,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": null, "id": "c33436eb", "metadata": { "execution": { @@ -332,23 +307,7 @@ "hide-in-docs" ] }, - "outputs": [ - { - "data": { - "text/html": [ - "
Solution
q = 4 * np.pi * es.math.sin(theta) / wavelength\n",
-       "print(show(q))\n",
-       "
\n", - "

Output:

3.1227 ± 0.0263 1/Å   in [-8.1600, 8.1600]

The bounds are those of the sine, $\\pm 1$, scaled by $4\\pi/\\lambda$.

" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "%%solution The bounds are those of the sine, $\\pm 1$, scaled by $4\\pi/\\lambda$.\n", "q = 4 * np.pi * es.math.sin(theta) / wavelength\n", @@ -367,7 +326,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": null, "id": "b3c6c722", "metadata": { "execution": { @@ -405,7 +364,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": null, "id": "f9064daa", "metadata": { "execution": { @@ -418,23 +377,7 @@ "hide-in-docs" ] }, - "outputs": [ - { - "data": { - "text/html": [ - "
Solution
d = wavelength / (2 * es.math.sin(theta))\n",
-       "print(show(d))\n",
-       "
\n", - "

Output:

2.0121 ± 0.0170 Å
" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "%%solution\n", "d = wavelength / (2 * es.math.sin(theta))\n", @@ -453,7 +396,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": null, "id": "3c772429", "metadata": { "execution": { @@ -463,16 +406,7 @@ "shell.execute_reply": "2026-10-13T22:59:19.099230Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "cos 0.8660 ± 0.0087 dimensionless in [0.0000, 1.0000]\n", - "tan 0.5774 ± 0.0233 dimensionless in [0.0000, inf]\n" - ] - } - ], + "outputs": [], "source": [ "angle = Parameter(name='angle', value=30.0, unit='deg', variance=1.0**2, min=0.0, max=90.0)\n", "\n", @@ -501,7 +435,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": null, "id": "12721052", "metadata": { "execution": { @@ -539,7 +473,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": null, "id": "4b8fa4e8", "metadata": { "execution": { @@ -552,23 +486,7 @@ "hide-in-docs" ] }, - "outputs": [ - { - "data": { - "text/html": [ - "
Solution
y = distance * es.math.tan(two_theta)\n",
-       "print(show(y))\n",
-       "
\n", - "

Output:

0.8660 ± 0.0091 m
" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "%%solution\n", "y = distance * es.math.tan(two_theta)\n", @@ -589,7 +507,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": null, "id": "3bcf8d0e", "metadata": { "execution": { @@ -599,16 +517,7 @@ "shell.execute_reply": "2026-10-13T22:59:19.113359Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "0.6033 ± 0.0268 dimensionless in [0.0022, 0.9194]\n", - "UnitError: sinc requires rad or dimensionless unit, got deg.\n" - ] - } - ], + "outputs": [], "source": [ "x = Parameter(name='x', value=1.2, variance=0.05**2, min=0.5, max=3.0)\n", "print(show(es.math.sinc(x) ** 2))\n", @@ -631,7 +540,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": null, "id": "bf3fe263", "metadata": { "execution": { @@ -668,7 +577,7 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": null, "id": "55ea0189", "metadata": { "execution": { @@ -681,23 +590,7 @@ "hide-in-docs" ] }, - "outputs": [ - { - "data": { - "text/html": [ - "
Solution
intensity = es.math.sinc(x) ** 2\n",
-       "print(show(intensity))\n",
-       "
\n", - "

Output:

0.2067 ± 0.0396 dimensionless   in [0.0000, 1.0000]

The intensity lies between 0 and 1: $\\mathrm{sinc}$ is at most 1, and a square is never negative.

" - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "%%solution The intensity lies between 0 and 1: $\\mathrm{sinc}$ is at most 1, and a square is never negative.\n", "intensity = es.math.sinc(x) ** 2\n", @@ -717,7 +610,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": null, "id": "18c5cc41", "metadata": { "execution": { @@ -727,15 +620,7 @@ "shell.execute_reply": "2026-10-13T22:59:19.125484Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "0.9848 ± 0.0030 dimensionless in [0.0000, 1.0000]\n" - ] - } - ], + "outputs": [], "source": [ "wide = Parameter(name='wide', value=80.0, unit='deg', variance=1.0**2, min=0.0, max=180.0)\n", "print(show(es.math.sin(wide)))" @@ -758,7 +643,7 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": null, "id": "cef20438", "metadata": { "execution": { @@ -768,29 +653,7 @@ "shell.execute_reply": "2026-10-13T22:59:19.347234Z" } }, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " \n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "angles = np.linspace(5.0, 60.0, 60)\n", "wavelength = DescriptorNumber(name='wavelength', value=1.5406, unit='angstrom', variance=0.0005**2)\n", @@ -858,7 +721,7 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": null, "id": "853de0a9", "metadata": { "execution": { @@ -868,29 +731,7 @@ "shell.execute_reply": "2026-10-13T22:59:19.465326Z" } }, - "outputs": [ - { - "data": { - "text/html": [ - "\n", - " \n", - " " - ], - "text/plain": [ - "" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "sigma = 0.3\n", "angles = np.linspace(0.0, 2 * np.pi, 80)\n", @@ -932,7 +773,7 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": null, "id": "aa92fe31", "metadata": { "execution": { @@ -942,16 +783,7 @@ "shell.execute_reply": "2026-10-13T22:59:19.474165Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "1.8369701987210297e-17\n", - "0.06105801493643515\n" - ] - } - ], + "outputs": [], "source": [ "top = Parameter(name='top', value=np.pi / 2, unit='rad', variance=0.3**2)\n", "print(es.math.sin(top).error)\n", @@ -971,7 +803,7 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": null, "id": "2e1c67f8", "metadata": { "execution": { @@ -981,15 +813,7 @@ "shell.execute_reply": "2026-10-13T22:59:19.478119Z" } }, - "outputs": [ - { - "name": "stdout", - "output_type": "stream", - "text": [ - "The value of 'v' (1.5707963267948966 rad) is within 0.1 rad of a tan asymptote (pi/2 + n*pi); the propagated variance would explode.\n" - ] - } - ], + "outputs": [], "source": [ "try:\n", " es.math.tan(Parameter(name='v', value=np.pi / 2, unit='rad', variance=0.1**2))\n", From 12969c10b5b88b639153c2745a4c0fbf5a7af0e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 23:25:34 +0000 Subject: [PATCH 10/25] docs(tutorials): add error propagation and correlation notebooks Two special-edition maths notebooks: how the choice of formula changes the uncertainty of a result (the table-area example), and how correlation between measurements changes it. Includes exercises with executed solutions, tests, nav and index entries. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01PHwoT3iPrULYWjTrLPuhan --- .../tutorials/basics/maths-correlation.ipynb | 751 ++++++++++++++++++ .../basics/maths-error-propagation.ipynb | 693 ++++++++++++++++ docs/docs/tutorials/index.md | 6 + docs/mkdocs.yml | 2 + tests/unit/tutorials/conftest.py | 13 + tests/unit/tutorials/notebooks.py | 8 +- .../test_tutorial_exercise_solutions.py | 12 - .../test_tutorial_propagation_notebooks.py | 284 +++++++ 8 files changed, 1756 insertions(+), 13 deletions(-) create mode 100644 docs/docs/tutorials/basics/maths-correlation.ipynb create mode 100644 docs/docs/tutorials/basics/maths-error-propagation.ipynb create mode 100644 tests/unit/tutorials/test_tutorial_propagation_notebooks.py diff --git a/docs/docs/tutorials/basics/maths-correlation.ipynb b/docs/docs/tutorials/basics/maths-correlation.ipynb new file mode 100644 index 00000000..8c09f3af --- /dev/null +++ b/docs/docs/tutorials/basics/maths-correlation.ipynb @@ -0,0 +1,751 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "1dc159b6", + "metadata": {}, + "source": [ + "# Correlation" + ] + }, + { + "cell_type": "markdown", + "id": "a6df5560", + "metadata": {}, + "source": [ + "Two measurements are **correlated** when their uncertainties are not independent: when one reads too high, the\n", + "other tends to read too high as well (positive correlation) or too low (negative correlation). This happens\n", + "whenever they share something, such as\n", + "\n", + "* the same instrument with the same, uncertain calibration, so that every reading is a little too long or too\n", + " short in the same way,\n", + "* the same zero point or reference,\n", + "* two parameters of one fit, which compensate each other.\n", + "\n", + "The strength of a correlation is the **correlation coefficient** $\\rho = \\text{cov}(x, y) / (\\sigma_x \\sigma_y)$,\n", + "a number between $-1$ and $+1$. For $\\rho = 0$ the measurements are independent. With correlations the\n", + "first-order propagation becomes\n", + "\n", + "$$\n", + "\\sigma_f^2 = \\sum_i \\left(\\frac{\\partial f}{\\partial x_i}\\right)^2 \\sigma_i^2\n", + "+ 2 \\sum_{i < j} \\frac{\\partial f}{\\partial x_i} \\frac{\\partial f}{\\partial x_j}\\, \\rho_{ij}\\, \\sigma_i \\sigma_j .\n", + "$$\n", + "\n", + "For a sum $x + y$ this is $\\sigma^2 = \\sigma_x^2 + \\sigma_y^2 + 2\\rho\\,\\sigma_x\\sigma_y$ and for a\n", + "difference $x - y$ it is $\\sigma^2 = \\sigma_x^2 + \\sigma_y^2 - 2\\rho\\,\\sigma_x\\sigma_y$.\n", + "\n", + "A `Parameter` stores a variance and no covariance, so the operators of EasyScience always take $\\rho = 0$. This\n", + "notebook shows what that costs and how to do better where it matters." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0c4b7b8d", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import plotly.graph_objects as go\n", + "\n", + "from easyscience import Parameter" + ] + }, + { + "cell_type": "markdown", + "id": "db1ffee4", + "metadata": {}, + "source": [ + "The function `show(label, number)` prints a number as `value ± uncertainty unit` and, for a `Parameter` with finite bounds, the bounds. The uncertainty is the attribute `error`; see the note in the arithmetic notebook." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f8631932", + "metadata": {}, + "outputs": [], + "source": [ + "def show(label, number):\n", + " \"\"\"Print a labelled number as value ± uncertainty with its unit and, for a Parameter, bounds.\"\"\"\n", + " text = f'{label} = {number.value:.4f} ± {number.error or 0.0:.4f} {number.unit}'\n", + " if hasattr(number, 'min') and (np.isfinite(number.min) or np.isfinite(number.max)):\n", + " text += f' in [{number.min + 0.0:.4f}, {number.max + 0.0:.4f}]'\n", + " print(text)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0827ff4d", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "import base64\n", + "import contextlib\n", + "import html\n", + "import io\n", + "import re\n", + "\n", + "from IPython import get_ipython\n", + "from IPython.display import HTML\n", + "from IPython.display import IFrame\n", + "from IPython.display import display\n", + "from pygments import highlight\n", + "from pygments.formatters import HtmlFormatter\n", + "from pygments.lexers import PythonLexer\n", + "\n", + "\n", + "def show_figure(fig: go.Figure, height: int = 420) -> None:\n", + " \"\"\"Show a Plotly figure as an embedded iframe, so that it renders in the docs.\"\"\"\n", + " page = (\n", + " go\n", + " .Figure(fig)\n", + " .update_layout(template='plotly_white')\n", + " .to_html(\n", + " full_html=True,\n", + " include_plotlyjs='cdn',\n", + " default_width='100%',\n", + " default_height=f'{height - 20}px',\n", + " )\n", + " )\n", + " encoded = base64.b64encode(page.encode('utf-8')).decode('ascii')\n", + " display(IFrame(src=f'data:text/html;base64,{encoded}', width='100%', height=height))\n", + "\n", + "\n", + "def solution(note: str, source: str) -> None:\n", + " \"\"\"Run a solution in a copy of the notebook namespace and show it, folded, with its output.\"\"\"\n", + " code = source.strip('\\n')\n", + " namespace = dict(get_ipython().user_ns)\n", + " stream = io.StringIO()\n", + " with contextlib.redirect_stdout(stream):\n", + " exec(code, namespace)\n", + " parts = [\n", + " '
Solution',\n", + " highlight(code, PythonLexer(), HtmlFormatter()),\n", + " ]\n", + " result = stream.getvalue().rstrip('\\n')\n", + " if result:\n", + " parts.append('

Output:

' + html.escape(result) + '
')\n", + " if note.strip():\n", + " text = re.sub('`([^`]+)`', r'\\1', html.escape(note.strip()))\n", + " parts.append('

' + text + '

')\n", + " parts.append('
')\n", + " display(HTML(''.join(parts)))\n", + "\n", + "\n", + "get_ipython().register_magic_function(solution, magic_kind='cell')" + ] + }, + { + "cell_type": "markdown", + "id": "01a1ec41", + "metadata": {}, + "source": [ + "## A helper that knows about correlation\n", + "\n", + "To propagate correlated uncertainties this notebook uses the helpers `gradient` and `propagate`. They work out\n", + "the derivatives numerically and combine them with the formula above. `propagate` takes the function, the\n", + "values, the standard uncertainties and, optionally, the matrix of correlation coefficients. Without it the\n", + "inputs are independent. `report` prints a result like `show` does." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a4fd5e48", + "metadata": {}, + "outputs": [], + "source": [ + "def gradient(func, values):\n", + " \"\"\"Derivatives of func with respect to each value, by central differences.\"\"\"\n", + " values = np.asarray(values, dtype=float)\n", + " slopes = np.empty_like(values)\n", + " for i, value in enumerate(values):\n", + " step = 1e-6 * max(abs(value), 1.0)\n", + " upper, lower = values.copy(), values.copy()\n", + " upper[i] += step\n", + " lower[i] -= step\n", + " slopes[i] = (func(*upper) - func(*lower)) / (2 * step)\n", + " return slopes\n", + "\n", + "\n", + "def propagate(func, values, sigmas, correlation=None):\n", + " \"\"\"First-order propagation: the value of func and its uncertainty.\"\"\"\n", + " sigmas = np.asarray(sigmas, dtype=float)\n", + " if correlation is None:\n", + " correlation = np.eye(len(sigmas))\n", + " covariance = np.asarray(correlation, dtype=float) * np.outer(sigmas, sigmas)\n", + " slopes = gradient(func, values)\n", + " return func(*values), float(np.sqrt(slopes @ covariance @ slopes))\n", + "\n", + "\n", + "def report(label, result, unit):\n", + " \"\"\"Print a labelled (value, uncertainty) pair like show does.\"\"\"\n", + " print(f'{label} = {result[0]:.4f} ± {result[1]:.4f} {unit}')" + ] + }, + { + "cell_type": "markdown", + "id": "7c315cdd", + "metadata": {}, + "source": [ + "## The same ruler twice\n", + "\n", + "Two boards are measured one after the other with the same ruler, which is known to 0.02 m: the first is\n", + "1.20 m long and the second 0.80 m. If the ruler reads a little long, it does so for both, so their errors\n", + "share a sign. First the operators, which assume independence, then the helper for different correlations:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1ff1f641", + "metadata": {}, + "outputs": [], + "source": [ + "first = Parameter(name='first', value=1.20, unit='m', variance=0.02**2)\n", + "second = Parameter(name='second', value=0.80, unit='m', variance=0.02**2)\n", + "\n", + "total = first + second\n", + "show('total', total)\n", + "\n", + "for rho in [-1.0, 0.0, 0.5, 1.0]:\n", + " result = propagate(lambda a, b: a + b, [1.20, 0.80], [0.02, 0.02], [[1, rho], [rho, 1]])\n", + " print(f'rho = {rho:+.1f}: total = {result[0]:.4f} ± {result[1]:.4f} m')" + ] + }, + { + "cell_type": "markdown", + "id": "7122d9f1", + "metadata": {}, + "source": [ + "The operators agree with $\\rho = 0$. If the errors share a sign ($\\rho = +1$) the uncertainties simply add,\n", + "$0.02 + 0.02 = 0.04$ m, which is larger than the $\\sqrt{0.02^2 + 0.02^2} = 0.028$ m of independent\n", + "measurements. If the errors were opposite ($\\rho = -1$) they would cancel completely in the sum.\n", + "\n", + "For a **difference** the roles of the signs swap: a shared error cancels. The length of the first board\n", + "beyond the second, with the same ruler:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ea2a06e9", + "metadata": {}, + "outputs": [], + "source": [ + "difference = first - second\n", + "show('difference', difference)\n", + "\n", + "for rho in [-1.0, 0.0, 0.5, 1.0]:\n", + " result = propagate(lambda a, b: a - b, [1.20, 0.80], [0.02, 0.02], [[1, rho], [rho, 1]])\n", + " print(f'rho = {rho:+.1f}: difference = {result[0]:.4f} ± {result[1]:.4f} m')" + ] + }, + { + "cell_type": "markdown", + "id": "9494785b", + "metadata": {}, + "source": [ + "The chart shows the uncertainty of the sum and of the difference for every correlation from $-1$ to $+1$. The operators sit at $\\rho = 0$." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a53dfe35", + "metadata": {}, + "outputs": [], + "source": [ + "rhos = np.linspace(-1.0, 1.0, 41)\n", + "sums, differences = [], []\n", + "for rho in rhos:\n", + " correlation = [[1, rho], [rho, 1]]\n", + " sums.append(propagate(lambda a, b: a + b, [1.20, 0.80], [0.02, 0.02], correlation)[1])\n", + " differences.append(propagate(lambda a, b: a - b, [1.20, 0.80], [0.02, 0.02], correlation)[1])\n", + "\n", + "fig = go.Figure()\n", + "fig.add_trace(go.Scatter(x=rhos, y=sums, mode='lines', name='sum a + b'))\n", + "fig.add_trace(go.Scatter(x=rhos, y=differences, mode='lines', name='difference a - b'))\n", + "fig.add_trace(\n", + " go.Scatter(\n", + " x=[0.0],\n", + " y=[total.error],\n", + " mode='markers',\n", + " name='operators (rho = 0)',\n", + " marker=dict(size=10, color='black'),\n", + " )\n", + ")\n", + "fig.update_layout(\n", + " xaxis_title='correlation coefficient rho',\n", + " yaxis_title='uncertainty / m',\n", + " margin=dict(l=60, r=20, t=30, b=50),\n", + " legend=dict(x=0.35, y=0.1),\n", + ")\n", + "show_figure(fig)" + ] + }, + { + "cell_type": "markdown", + "id": "5723993e", + "metadata": {}, + "source": [ + "### Exercise 1: Two sections, one ruler\n", + "\n", + "A rod is made of two sections measured with the same tape: 1.20 ± 0.02 m and 0.80 ± 0.03 m. Their errors are\n", + "strongly correlated, $\\rho = 0.9$. What is the total length, with its uncertainty? Compare it with the\n", + "uncertainty that the operators give." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1208a02a", + "metadata": {}, + "outputs": [], + "source": [ + "section_1 = Parameter(name='section_1', value=1.20, unit='m', variance=0.02**2)\n", + "section_2 = Parameter(name='section_2', value=0.80, unit='m', variance=0.03**2)" + ] + }, + { + "cell_type": "markdown", + "id": "fa5e4b4f", + "metadata": {}, + "source": [ + "
\n", + "Hint 1\n", + "\n", + "The operators give `section_1 + section_2`, which takes $\\rho = 0$. For $\\rho = 0.9$ use `propagate` with a $2 \\times 2$ matrix of correlation coefficients.\n", + "\n", + "
\n", + "\n", + "
\n", + "Hint 2\n", + "\n", + "The matrix is `[[1, 0.9], [0.9, 1]]`: ones on the diagonal and the correlation coefficient off it. The uncertainties are $0.02$ m and $0.03$ m.\n", + "\n", + "
" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6f42b5a0", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "%%solution The correlated uncertainty is 0.049 m, larger than the 0.036 m of the operators, because shared errors add up in a sum.\n", + "by_operators = section_1 + section_2\n", + "show('by_operators', by_operators)\n", + "report(\n", + " 'correlated',\n", + " propagate(lambda a, b: a + b, [1.20, 0.80], [0.02, 0.03], [[1, 0.9], [0.9, 1]]),\n", + " 'm',\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "8d6de7d6", + "metadata": {}, + "source": [ + "## Shared offsets cancel\n", + "\n", + "The most useful consequence of a positive correlation is in differences: an offset that both readings share\n", + "drops out. A surveyor measures the heights of two points from the same station. The station height is uncertain,\n", + "which shifts both readings in the same way. Their difference is much better known than either height." + ] + }, + { + "cell_type": "markdown", + "id": "38695951", + "metadata": {}, + "source": [ + "### Exercise 2: A height difference\n", + "\n", + "The heights of two points above a common station are 5.42 ± 0.05 m and 3.18 ± 0.05 m, with a correlation of\n", + "$\\rho = 0.95$ because of the shared station. What is the height difference, with its uncertainty? What would\n", + "the operators say?" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6882d352", + "metadata": {}, + "outputs": [], + "source": [ + "point_a = Parameter(name='point_a', value=5.42, unit='m', variance=0.05**2)\n", + "point_b = Parameter(name='point_b', value=3.18, unit='m', variance=0.05**2)" + ] + }, + { + "cell_type": "markdown", + "id": "dc332c20", + "metadata": {}, + "source": [ + "
\n", + "Hint 1\n", + "\n", + "A difference is a function of two values: `lambda a, b: a - b`.\n", + "\n", + "
\n", + "\n", + "
\n", + "Hint 2\n", + "\n", + "For $\\rho = 0.95$ the variance is $2 \\sigma^2 (1 - \\rho) = 2 \\cdot 0.05^2 \\cdot 0.05$.\n", + "\n", + "
" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1d9f682c", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "%%solution The shared station offset cancels in the difference, so it is more than four times better known than the operators claim.\n", + "by_operators = point_a - point_b\n", + "show('by_operators', by_operators)\n", + "report(\n", + " 'correlated',\n", + " propagate(lambda a, b: a - b, [5.42, 3.18], [0.05, 0.05], [[1, 0.95], [0.95, 1]]),\n", + " 'm',\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "6f287308", + "metadata": {}, + "source": [ + "## Ratios of readings from one instrument\n", + "\n", + "If two intensities are read with the same detector, an uncertain gain makes both too large or both too small\n", + "by the same fraction. In a **ratio** that factor cancels, again because the correlation is positive and the\n", + "derivatives have opposite signs." + ] + }, + { + "cell_type": "markdown", + "id": "46b6ceaf", + "metadata": {}, + "source": [ + "### Exercise 3: An intensity ratio\n", + "\n", + "Two intensities, 120.0 ± 6.0 and 80.0 ± 4.0 counts per second, were read with the same detector and are\n", + "correlated with $\\rho = 0.9$. What is their ratio $I_1 / I_2$, with its uncertainty? Compare it with the\n", + "operators." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1210af6c", + "metadata": {}, + "outputs": [], + "source": [ + "first_intensity = Parameter(name='first_intensity', value=120.0, unit='1/s', variance=6.0**2)\n", + "second_intensity = Parameter(name='second_intensity', value=80.0, unit='1/s', variance=4.0**2)" + ] + }, + { + "cell_type": "markdown", + "id": "09040e22", + "metadata": {}, + "source": [ + "
\n", + "Hint 1\n", + "\n", + "The ratio is `lambda a, b: a / b`. The operators divide the two `Parameter`s directly.\n", + "\n", + "
\n", + "\n", + "
\n", + "Hint 2\n", + "\n", + "Both intensities have a relative uncertainty of 5 %. For $\\rho = 0.9$ the relative uncertainty of the ratio is $\\sqrt{0.05^2 + 0.05^2 - 2 \\cdot 0.9 \\cdot 0.05 \\cdot 0.05}$.\n", + "\n", + "
" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "75991d6c", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "%%solution The shared gain cancels in the ratio, so the correlated uncertainty is about three times smaller than the operators give.\n", + "by_operators = first_intensity / second_intensity\n", + "show('by_operators', by_operators)\n", + "report(\n", + " 'correlated',\n", + " propagate(lambda a, b: a / b, [120.0, 80.0], [6.0, 4.0], [[1, 0.9], [0.9, 1]]),\n", + " 'dimensionless',\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "abd73a71", + "metadata": {}, + "source": [ + "## Correlation from a fit\n", + "\n", + "The best-known source of correlated numbers is a fit. The slope and the intercept of a straight line,\n", + "$y = a x + b$, are strongly correlated whenever the $x$ values are far from zero: a steeper line must start\n", + "lower to pass through the same data. A fit program reports their covariance matrix, and `np.polyfit` can\n", + "return it too. These ten points lie close to a line:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a9c5f7d3", + "metadata": {}, + "outputs": [], + "source": [ + "x_data = np.arange(10, 20)\n", + "y_data = np.array([7.1, 7.4, 8.2, 8.6, 9.3, 9.4, 10.3, 10.6, 11.4, 11.6])\n", + "\n", + "(slope, intercept), covariance = np.polyfit(x_data, y_data, 1, cov=True)\n", + "slope_error, intercept_error = np.sqrt(np.diag(covariance))\n", + "correlation = covariance[0, 1] / (slope_error * intercept_error)\n", + "\n", + "print(f'slope = {slope:.4f} ± {slope_error:.4f}')\n", + "print(f'intercept = {intercept:.4f} ± {intercept_error:.4f}')\n", + "print(f'correlation = {correlation:.3f}')" + ] + }, + { + "cell_type": "markdown", + "id": "8bb13d80", + "metadata": {}, + "source": [ + "The correlation is close to $-1$. Now predict $y$ at $x = 20$. A `Parameter` keeps only the variance of\n", + "each, so the operators treat them as independent; the helper uses the full covariance:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "953fcd87", + "metadata": {}, + "outputs": [], + "source": [ + "fitted_slope = Parameter(name='fitted_slope', value=slope, variance=slope_error**2)\n", + "fitted_intercept = Parameter(name='fitted_intercept', value=intercept, variance=intercept_error**2)\n", + "\n", + "at_20 = fitted_slope * 20 + fitted_intercept\n", + "show('at_20', at_20)\n", + "\n", + "report(\n", + " 'at_20_correlated',\n", + " propagate(\n", + " lambda a, b: a * 20 + b,\n", + " [slope, intercept],\n", + " [slope_error, intercept_error],\n", + " [[1, correlation], [correlation, 1]],\n", + " ),\n", + " 'dimensionless',\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "8372d237", + "metadata": {}, + "source": [ + "Ignoring the correlation overestimates the uncertainty of the prediction by a factor of four. A steeper slope\n", + "comes with a lower intercept, so the two compensate each other at $x = 20$. The chart shows the uncertainty\n", + "band of the fitted line with and without the correlation. The correct band is narrowest in the middle of the\n", + "data, and the independent one is too wide for every $x$ above zero:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8c13da71", + "metadata": {}, + "outputs": [], + "source": [ + "x_line = np.linspace(0.0, 25.0, 126)\n", + "line = slope * x_line + intercept\n", + "with_correlation = np.sqrt(\n", + " slope_error**2 * x_line**2 + intercept_error**2 + 2 * x_line * covariance[0, 1]\n", + ")\n", + "without_correlation = np.sqrt(slope_error**2 * x_line**2 + intercept_error**2)\n", + "\n", + "fig = go.Figure()\n", + "fig.add_trace(\n", + " go.Scatter(x=x_line, y=line, mode='lines', name='fitted line', line=dict(color='black'))\n", + ")\n", + "fig.add_trace(\n", + " go.Scatter(\n", + " x=x_line,\n", + " y=line + without_correlation,\n", + " mode='lines',\n", + " name='± uncertainty ignoring correlation',\n", + " line=dict(color='rgb(239, 85, 59)', dash='dash'),\n", + " )\n", + ")\n", + "fig.add_trace(\n", + " go.Scatter(\n", + " x=x_line,\n", + " y=line - without_correlation,\n", + " mode='lines',\n", + " showlegend=False,\n", + " line=dict(color='rgb(239, 85, 59)', dash='dash'),\n", + " )\n", + ")\n", + "fig.add_trace(\n", + " go.Scatter(\n", + " x=x_line,\n", + " y=line + with_correlation,\n", + " mode='lines',\n", + " name='± uncertainty with correlation',\n", + " line=dict(color='rgb(99, 110, 250)'),\n", + " )\n", + ")\n", + "fig.add_trace(\n", + " go.Scatter(\n", + " x=x_line,\n", + " y=line - with_correlation,\n", + " mode='lines',\n", + " showlegend=False,\n", + " line=dict(color='rgb(99, 110, 250)'),\n", + " )\n", + ")\n", + "fig.add_trace(go.Scatter(x=x_data, y=y_data, mode='markers', name='data'))\n", + "fig.update_layout(\n", + " xaxis_title='x',\n", + " yaxis_title='y',\n", + " margin=dict(l=60, r=20, t=30, b=50),\n", + " legend=dict(x=0.02, y=0.98),\n", + ")\n", + "show_figure(fig, height=480)" + ] + }, + { + "cell_type": "markdown", + "id": "052a7906", + "metadata": {}, + "source": [ + "### Exercise 4: Predicting from a fit\n", + "\n", + "A fit gave a slope of 0.50 ± 0.02 and an intercept of 2.0 ± 0.3, with a correlation of $-0.97$. What is the\n", + "predicted $y$ at $x = 18$, with its uncertainty? What is the uncertainty if the correlation is ignored?" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "19fa310e", + "metadata": {}, + "outputs": [], + "source": [ + "fit_slope = Parameter(name='fit_slope', value=0.50, variance=0.02**2)\n", + "fit_intercept = Parameter(name='fit_intercept', value=2.0, variance=0.3**2)" + ] + }, + { + "cell_type": "markdown", + "id": "28b8c4bb", + "metadata": {}, + "source": [ + "
\n", + "Hint 1\n", + "\n", + "The prediction is `lambda a, b: a * 18 + b`. The operators `fit_slope * 18 + fit_intercept` take no correlation into account.\n", + "\n", + "
\n", + "\n", + "
\n", + "Hint 2\n", + "\n", + "The matrix of correlation coefficients is `[[1, -0.97], [-0.97, 1]]`.\n", + "\n", + "
" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f15ad6b4", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "%%solution The strong negative correlation makes the prediction almost five times better known than the operators claim.\n", + "by_operators = fit_slope * 18 + fit_intercept\n", + "show('by_operators', by_operators)\n", + "report(\n", + " 'correlated',\n", + " propagate(lambda a, b: a * 18 + b, [0.50, 2.0], [0.02, 0.3], [[1, -0.97], [-0.97, 1]]),\n", + " 'dimensionless',\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "44ca7901", + "metadata": {}, + "source": [ + "## Good to know\n", + "\n", + "* A correlation coefficient has to come from somewhere: the covariance matrix of a fit, a calibration, or an\n", + " argument about what the measurements share. It cannot be guessed from the values.\n", + "* When the correlation is unknown, $\\rho = +1$ and $\\rho = -1$ give the largest and the smallest uncertainty\n", + " that a combination can have, so they bracket the answer.\n", + "* Correlated quantities often hide inside one analysis. If a result is built from several numbers that\n", + " came from the same fit or the same instrument, ask whether the independence assumption of the operators\n", + " holds." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.6" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/docs/tutorials/basics/maths-error-propagation.ipynb b/docs/docs/tutorials/basics/maths-error-propagation.ipynb new file mode 100644 index 00000000..ca74e417 --- /dev/null +++ b/docs/docs/tutorials/basics/maths-error-propagation.ipynb @@ -0,0 +1,693 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "f0e7781e", + "metadata": {}, + "source": [ + "# Error propagation" + ] + }, + { + "cell_type": "markdown", + "id": "c11414a8", + "metadata": {}, + "source": [ + "A calculated result is only as certain as the measurements it is calculated from. Working out how the\n", + "uncertainties of the measurements carry over to the result is usually called *error propagation*. As the\n", + "note in the arithmetic notebook explains, the number it produces is an **uncertainty** (a statement about\n", + "how well a value is known), so that is the word used here.\n", + "\n", + "The result of a calculation depends on the measurements **and on the formula** that combines them. The same\n", + "quantity can often be worked out in several ways, and the ways differ in how well they use the\n", + "measurements. This notebook uses one example throughout: the area of a table top.\n", + "\n", + "For independent inputs the uncertainty of $f(x_1, x_2, \\dots)$ follows, to first order, from\n", + "\n", + "$$\n", + "\\sigma_f^2 = \\sum_i \\left(\\frac{\\partial f}{\\partial x_i}\\right)^2 \\sigma_i^2 .\n", + "$$\n", + "\n", + "The term $\\partial f / \\partial x_i \\cdot \\sigma_i$ is the **contribution** of measurement $i$: its uncertainty,\n", + "scaled by how strongly the result reacts to it. A formula that reacts weakly to uncertain measurements\n", + "gives a small uncertainty." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2ce6ad36", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import plotly.graph_objects as go\n", + "\n", + "from easyscience import Parameter" + ] + }, + { + "cell_type": "markdown", + "id": "5531b2b1", + "metadata": {}, + "source": [ + "The function `show(label, number)` prints a number as `value ± uncertainty unit` and, for a `Parameter` with finite bounds, the bounds. The uncertainty is the attribute `error`; see the note in the arithmetic notebook." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3d2b1781", + "metadata": {}, + "outputs": [], + "source": [ + "def show(label, number):\n", + " \"\"\"Print a labelled number as value ± uncertainty with its unit and, for a Parameter, bounds.\"\"\"\n", + " text = f'{label} = {number.value:.4f} ± {number.error or 0.0:.4f} {number.unit}'\n", + " if hasattr(number, 'min') and (np.isfinite(number.min) or np.isfinite(number.max)):\n", + " text += f' in [{number.min + 0.0:.4f}, {number.max + 0.0:.4f}]'\n", + " print(text)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0110158a", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "import base64\n", + "import contextlib\n", + "import html\n", + "import io\n", + "import re\n", + "\n", + "from IPython import get_ipython\n", + "from IPython.display import HTML\n", + "from IPython.display import IFrame\n", + "from IPython.display import display\n", + "from pygments import highlight\n", + "from pygments.formatters import HtmlFormatter\n", + "from pygments.lexers import PythonLexer\n", + "\n", + "\n", + "def show_figure(fig: go.Figure, height: int = 420) -> None:\n", + " \"\"\"Show a Plotly figure as an embedded iframe, so that it renders in the docs.\"\"\"\n", + " page = (\n", + " go\n", + " .Figure(fig)\n", + " .update_layout(template='plotly_white')\n", + " .to_html(\n", + " full_html=True,\n", + " include_plotlyjs='cdn',\n", + " default_width='100%',\n", + " default_height=f'{height - 20}px',\n", + " )\n", + " )\n", + " encoded = base64.b64encode(page.encode('utf-8')).decode('ascii')\n", + " display(IFrame(src=f'data:text/html;base64,{encoded}', width='100%', height=height))\n", + "\n", + "\n", + "def solution(note: str, source: str) -> None:\n", + " \"\"\"Run a solution in a copy of the notebook namespace and show it, folded, with its output.\"\"\"\n", + " code = source.strip('\\n')\n", + " namespace = dict(get_ipython().user_ns)\n", + " stream = io.StringIO()\n", + " with contextlib.redirect_stdout(stream):\n", + " exec(code, namespace)\n", + " parts = [\n", + " '
Solution',\n", + " highlight(code, PythonLexer(), HtmlFormatter()),\n", + " ]\n", + " result = stream.getvalue().rstrip('\\n')\n", + " if result:\n", + " parts.append('

Output:

' + html.escape(result) + '
')\n", + " if note.strip():\n", + " text = re.sub('`([^`]+)`', r'\\1', html.escape(note.strip()))\n", + " parts.append('

' + text + '

')\n", + " parts.append('
')\n", + " display(HTML(''.join(parts)))\n", + "\n", + "\n", + "get_ipython().register_magic_function(solution, magic_kind='cell')" + ] + }, + { + "cell_type": "markdown", + "id": "75c8445a", + "metadata": {}, + "source": [ + "## Three ways to find the area of a table\n", + "\n", + "The top of a table is a rectangle, 2.00 m long and 1.50 m wide, so its area is 3.00 m². Suppose that you\n", + "cannot measure the area directly. With a tape measure that reads to ±0.01 m you can measure the length\n", + "$L$, the width $W$, the diagonal $D$ and the perimeter $P$, and the area follows from two of them in\n", + "three different ways:\n", + "\n", + "1. from the length and the width: $A = L\\,W$,\n", + "2. from the length and the perimeter, because the width is $P/2 - L$: $A = L\\,(P/2 - L)$,\n", + "3. from the length and the diagonal, with Pythagoras: $A = L\\,\\sqrt{D^2 - L^2}$.\n", + "\n", + "All three give 3.00 m². Their uncertainties are different." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e6513da0", + "metadata": {}, + "outputs": [], + "source": [ + "length = Parameter(name='length', value=2.00, unit='m', variance=0.01**2)\n", + "width = Parameter(name='width', value=1.50, unit='m', variance=0.01**2)\n", + "diagonal = Parameter(name='diagonal', value=2.50, unit='m', variance=0.01**2)\n", + "perimeter = Parameter(name='perimeter', value=7.00, unit='m', variance=0.01**2)" + ] + }, + { + "cell_type": "markdown", + "id": "3ffcc89b", + "metadata": {}, + "source": [ + "### Method 1: length times width\n", + "\n", + "The operators do the work. Each measurement is used once, which is the case where they are exact (to first\n", + "order):" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4aea9079", + "metadata": {}, + "outputs": [], + "source": [ + "area_1 = length * width\n", + "show('area_1', area_1)" + ] + }, + { + "cell_type": "markdown", + "id": "5894a960", + "metadata": {}, + "source": [ + "By hand, $\\sigma^2 = (W\\,\\sigma_L)^2 + (L\\,\\sigma_W)^2 = (1.50 \\cdot 0.01)^2 + (2.00 \\cdot 0.01)^2\\ \\text{m}^4\n", + "= 0.000625\\ \\text{m}^4$, so $\\sigma = 0.0250\\ \\text{m}^2$.\n", + "\n", + "### A helper for any formula\n", + "\n", + "The other two formulas use a measurement twice, and the operators are not exact for those (the next\n", + "sections show why). To compare all three on equal terms, this notebook uses two short helpers. `gradient`\n", + "finds the derivatives $\\partial f / \\partial x_i$ numerically and `propagate` combines them with the\n", + "uncertainties exactly as in the formula above. Together with `report`, which prints a result like `show`\n", + "does, they are plain Python and work for any formula:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8fbd5f43", + "metadata": {}, + "outputs": [], + "source": [ + "def gradient(func, values):\n", + " \"\"\"Derivatives of func with respect to each value, by central differences.\"\"\"\n", + " values = np.asarray(values, dtype=float)\n", + " slopes = np.empty_like(values)\n", + " for i, value in enumerate(values):\n", + " step = 1e-6 * max(abs(value), 1.0)\n", + " upper, lower = values.copy(), values.copy()\n", + " upper[i] += step\n", + " lower[i] -= step\n", + " slopes[i] = (func(*upper) - func(*lower)) / (2 * step)\n", + " return slopes\n", + "\n", + "\n", + "def propagate(func, values, sigmas, correlation=None):\n", + " \"\"\"First-order propagation: the value of func and its uncertainty.\"\"\"\n", + " sigmas = np.asarray(sigmas, dtype=float)\n", + " if correlation is None:\n", + " correlation = np.eye(len(sigmas))\n", + " covariance = np.asarray(correlation, dtype=float) * np.outer(sigmas, sigmas)\n", + " slopes = gradient(func, values)\n", + " return func(*values), float(np.sqrt(slopes @ covariance @ slopes))\n", + "\n", + "\n", + "def report(label, result, unit):\n", + " \"\"\"Print a labelled (value, uncertainty) pair like show does.\"\"\"\n", + " print(f'{label} = {result[0]:.4f} ± {result[1]:.4f} {unit}')" + ] + }, + { + "cell_type": "markdown", + "id": "b19ca5d0", + "metadata": {}, + "source": [ + "For the first formula the helper must agree with the operators, which it does:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "09c71830", + "metadata": {}, + "outputs": [], + "source": [ + "report('by_helper', propagate(lambda L, W: L * W, [2.00, 1.50], [0.01, 0.01]), 'm^2')\n", + "show('area_1', area_1)" + ] + }, + { + "cell_type": "markdown", + "id": "ffff6604", + "metadata": {}, + "source": [ + "### Methods 2 and 3, and why the formula matters\n", + "\n", + "The three methods side by side, each with the same tape-measure uncertainty of 0.01 m on every\n", + "measurement. For each method the contribution of every measurement, $\\partial A / \\partial x_i \\cdot\n", + "\\sigma_i$, is printed too:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "52359517", + "metadata": {}, + "outputs": [], + "source": [ + "sigma = 0.01\n", + "methods = [\n", + " ('length and width', ['length', 'width'], lambda L, W: L * W, [2.00, 1.50]),\n", + " ('length and perimeter', ['length', 'perimeter'], lambda L, P: L * (P / 2 - L), [2.00, 7.00]),\n", + " (\n", + " 'length and diagonal',\n", + " ['length', 'diagonal'],\n", + " lambda L, D: L * np.sqrt(D**2 - L**2),\n", + " [2.00, 2.50],\n", + " ),\n", + "]\n", + "\n", + "for label, names, func, values in methods:\n", + " area, uncertainty = propagate(func, values, [sigma, sigma])\n", + " slopes = gradient(func, values)\n", + " parts = ', '.join(f'{name} {slope * sigma:+.4f}' for name, slope in zip(names, slopes))\n", + " print(f'{label:<22} area = {area:.4f} ± {uncertainty:.4f} m^2 ({parts})')" + ] + }, + { + "cell_type": "markdown", + "id": "af8bbec9", + "metadata": {}, + "source": [ + "Measuring the length and the perimeter gives the smallest uncertainty, 0.011 m², less than half of the 0.025 m²\n", + "of the obvious length-times-width. In $L\\,(P/2 - L)$ a longer length also means a shorter width, so the two\n", + "uses of $L$ almost cancel: the derivative with respect to $L$ is only $P/2 - 2L = -0.5$, and the\n", + "uncertainty of $L$ hardly matters. The perimeter, on the other hand, enters weakly (with the factor\n", + "$L/2$). The diagonal is the worst choice: $\\partial A / \\partial D = L D / \\sqrt{D^2 - L^2} = 3.33$ makes\n", + "the uncertainty of the diagonal three times as important as in a plain product." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6bfa87d1", + "metadata": {}, + "outputs": [], + "source": [ + "labels = [label for label, *_ in methods]\n", + "uncertainties = [propagate(func, values, [sigma, sigma])[1] for _, _, func, values in methods]\n", + "\n", + "fig = go.Figure(\n", + " go.Bar(\n", + " x=labels,\n", + " y=uncertainties,\n", + " marker_color='rgb(99, 110, 250)',\n", + " hovertemplate='%{x}
uncertainty = %{y:.4f} m²',\n", + " )\n", + ")\n", + "fig.update_layout(\n", + " xaxis_title='measured quantities',\n", + " yaxis_title='uncertainty of the area / m²',\n", + " margin=dict(l=60, r=20, t=30, b=50),\n", + ")\n", + "show_figure(fig)" + ] + }, + { + "cell_type": "markdown", + "id": "65ee7153", + "metadata": {}, + "source": [ + "The same measurements, with the same uncertainty each, give a result that is more or less certain depending only on the formula." + ] + }, + { + "cell_type": "markdown", + "id": "e6fc6398", + "metadata": {}, + "source": [ + "### Exercise 1: A round table\n", + "\n", + "A round table top has a diameter of 1.20 ± 0.01 m. Its circumference was also measured, 3.77 ± 0.01 m. Work\n", + "out the area both ways, from the diameter, $A = \\pi d^2 / 4$, and from the circumference,\n", + "$A = C^2 / (4\\pi)$. Each measurement is used once, so the operators are exact. Which gives the smaller\n", + "uncertainty?" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2b5a20cb", + "metadata": {}, + "outputs": [], + "source": [ + "diameter = Parameter(name='diameter', value=1.20, unit='m', variance=0.01**2)\n", + "circumference = Parameter(name='circumference', value=3.77, unit='m', variance=0.01**2)" + ] + }, + { + "cell_type": "markdown", + "id": "f6ea8d65", + "metadata": {}, + "source": [ + "
\n", + "Hint 1\n", + "\n", + "The first area is `np.pi * diameter**2 / 4`. A plain number can multiply or divide a `Parameter`.\n", + "\n", + "
\n", + "\n", + "
\n", + "Hint 2\n", + "\n", + "The relative uncertainty of $x^2$ is twice that of $x$. Compare $0.01 / 1.20$ with $0.01 / 3.77$.\n", + "\n", + "
" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f059c35f", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "%%solution The circumference gives the smaller uncertainty because 0.01 m is a smaller fraction of 3.77 m than of 1.20 m.\n", + "area_from_diameter = np.pi * diameter**2 / 4\n", + "area_from_circumference = circumference**2 / (4 * np.pi)\n", + "show('area_from_diameter', area_from_diameter)\n", + "show('area_from_circumference', area_from_circumference)" + ] + }, + { + "cell_type": "markdown", + "id": "3e2958e9", + "metadata": {}, + "source": [ + "### Exercise 2: Which measurement matters most?\n", + "\n", + "A block measures 0.50 m, 0.20 m and 0.10 m, each with an uncertainty of 0.01 m. Work out its volume with its\n", + "uncertainty, then print the contribution $|\\partial V / \\partial x_i| \\cdot \\sigma_i$ of each side, in m³.\n", + "Which side dominates?" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "85b01b55", + "metadata": {}, + "outputs": [], + "source": [ + "block_length = Parameter(name='block_length', value=0.50, unit='m', variance=0.01**2)\n", + "block_width = Parameter(name='block_width', value=0.20, unit='m', variance=0.01**2)\n", + "block_thickness = Parameter(name='block_thickness', value=0.10, unit='m', variance=0.01**2)" + ] + }, + { + "cell_type": "markdown", + "id": "d5aa1d24", + "metadata": {}, + "source": [ + "
\n", + "Hint 1\n", + "\n", + "The volume is the product of the three sides, so the operators are exact.\n", + "\n", + "
\n", + "\n", + "
\n", + "Hint 2\n", + "\n", + "The contributions need the derivatives of $abc$. `gradient(lambda a, b, c: a * b * c, [0.50, 0.20, 0.10])` returns them in the order of the sides.\n", + "\n", + "
" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0e07c1f6", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "%%solution The thinnest side dominates, although all three have the same uncertainty, because 0.01 m is 10 % of 0.10 m but only 2 % of 0.50 m.\n", + "volume = block_length * block_width * block_thickness\n", + "show('volume', volume)\n", + "slopes = gradient(lambda a, b, c: a * b * c, [0.50, 0.20, 0.10])\n", + "for name, slope in zip(['length', 'width', 'thickness'], slopes):\n", + " print(f'{name} contributes {abs(slope) * 0.01:.5f} m^3')" + ] + }, + { + "cell_type": "markdown", + "id": "f9f644c2", + "metadata": {}, + "source": [ + "## When a measurement is used twice\n", + "\n", + "An operator knows nothing about where its operands come from. In $L\\,(P/2 - L)$ the factor $P/2 - L$ is a\n", + "**new** number, and the operator that multiplies it by $L$ does not know that it contains $L$. It treats\n", + "the two as independent, which they are not. For the table:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "545c931b", + "metadata": {}, + "outputs": [], + "source": [ + "by_operators = length * (perimeter / 2 - length)\n", + "show('by_operators', by_operators)\n", + "report('by_helper', propagate(lambda L, P: L * (P / 2 - L), [2.00, 7.00], [0.01, 0.01]), 'm^2')" + ] + }, + { + "cell_type": "markdown", + "id": "e4430b85", + "metadata": {}, + "source": [ + "The operators give an uncertainty more than twice as large as the correct one. The same happens with\n", + "any formula in which a measurement appears more than once, and it can go either way. A small example with a\n", + "dimensionless $x = 3.0 \\pm 0.1$ and $f = x\\,(1 + x)$, whose derivative is $1 + 2x = 7$ and whose uncertainty\n", + "should therefore be $0.7$:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2a3fd683", + "metadata": {}, + "outputs": [], + "source": [ + "x = Parameter(name='x', value=3.0, variance=0.1**2)\n", + "show('x * (1 + x)', x * (1 + x))\n", + "report('by_helper', propagate(lambda x: x * (1 + x), [3.0], [0.1]), 'dimensionless')" + ] + }, + { + "cell_type": "markdown", + "id": "e6b3d33a", + "metadata": {}, + "source": [ + "Three things help:\n", + "\n", + "* Where a formula uses each measurement **once**, the operators are exact. Prefer such a formula if there\n", + " is one, as in the round table.\n", + "* Using the *same object* on both sides of one operator is fine: `x * x`, `x**2` and `x - x` all have the right\n", + " uncertainty.\n", + "* Otherwise work the derivatives out, by hand or with a helper such as `propagate` above." + ] + }, + { + "cell_type": "markdown", + "id": "0bbea942", + "metadata": {}, + "source": [ + "### Exercise 3: Length and perimeter\n", + "\n", + "A rectangular plate is 0.80 ± 0.01 m long and has a perimeter of 2.60 ± 0.01 m. Its area is\n", + "$A = L\\,(P/2 - L)$. Work out the area with the operators and with `propagate`. How different are the two\n", + "uncertainties?" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fedefa91", + "metadata": {}, + "outputs": [], + "source": [ + "plate_length = Parameter(name='plate_length', value=0.80, unit='m', variance=0.01**2)\n", + "plate_perimeter = Parameter(name='plate_perimeter', value=2.60, unit='m', variance=0.01**2)" + ] + }, + { + "cell_type": "markdown", + "id": "f6c9ea37", + "metadata": {}, + "source": [ + "
\n", + "Hint 1\n", + "\n", + "The length appears twice in the formula, so the operators treat the two as independent.\n", + "\n", + "
\n", + "\n", + "
\n", + "Hint 2\n", + "\n", + "`propagate(lambda L, P: L * (P / 2 - L), [0.80, 2.60], [0.01, 0.01])` returns the value and the uncertainty. `report` prints them.\n", + "\n", + "
" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2c08f124", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "%%solution The operators overestimate the uncertainty by a factor of two.\n", + "by_operators = plate_length * (plate_perimeter / 2 - plate_length)\n", + "show('by_operators', by_operators)\n", + "report('by_helper', propagate(lambda L, P: L * (P / 2 - L), [0.80, 2.60], [0.01, 0.01]), 'm^2')" + ] + }, + { + "cell_type": "markdown", + "id": "bd7b77e2", + "metadata": {}, + "source": [ + "## Choosing the procedure\n", + "\n", + "Not only the formula but also the way of measuring decides the uncertainty. The period of a pendulum of\n", + "length $\\ell$ gives the acceleration of gravity, $g = 4\\pi^2 \\ell / T^2$. Because $T$ is squared, its\n", + "relative uncertainty counts twice, and a timing uncertainty of 0.1 s on one swing of 2 s is 5 %. If the\n", + "time of ten swings is measured instead and divided by ten, the same 0.1 s of timing uncertainty is spread over\n", + "ten swings." + ] + }, + { + "cell_type": "markdown", + "id": "c1702876", + "metadata": {}, + "source": [ + "### Exercise 4: A pendulum\n", + "\n", + "A pendulum of length 1.000 ± 0.002 m is timed. One swing took 2.0 ± 0.1 s, and ten swings together took\n", + "20.0 ± 0.1 s. Work out $g$ both ways, in m/s². How much does timing ten swings help?" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "bd7e2219", + "metadata": {}, + "outputs": [], + "source": [ + "pendulum_length = Parameter(name='pendulum_length', value=1.000, unit='m', variance=0.002**2)\n", + "one_swing = Parameter(name='one_swing', value=2.0, unit='s', variance=0.1**2)\n", + "ten_swings = Parameter(name='ten_swings', value=20.0, unit='s', variance=0.1**2)" + ] + }, + { + "cell_type": "markdown", + "id": "c16e4152", + "metadata": {}, + "source": [ + "
\n", + "Hint 1\n", + "\n", + "The period of the second measurement is `ten_swings / 10`. Dividing by a plain number divides the uncertainty too.\n", + "\n", + "
\n", + "\n", + "
\n", + "Hint 2\n", + "\n", + "Each measurement is used once in $4\\pi^2 \\ell / T^2$, so the operators are exact.\n", + "\n", + "
" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a3121f90", + "metadata": { + "tags": [ + "hide-in-docs" + ] + }, + "outputs": [], + "source": [ + "%%solution Timing ten swings makes the uncertainty of g about ten times smaller.\n", + "g_one_swing = 4 * np.pi**2 * pendulum_length / one_swing**2\n", + "period_ten = ten_swings / 10\n", + "g_ten_swings = 4 * np.pi**2 * pendulum_length / period_ten**2\n", + "show('g_one_swing', g_one_swing)\n", + "show('g_ten_swings', g_ten_swings)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.14.6" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/docs/tutorials/index.md b/docs/docs/tutorials/index.md index a3f07612..5e0b9ba4 100644 --- a/docs/docs/tutorials/index.md +++ b/docs/docs/tutorials/index.md @@ -31,6 +31,12 @@ exercises. Units, uncertainties and bounds are propagated for you. – `arcsin`, `arccos`, `arctan` and `arctan2`, and their domains. - [Hyperbolic functions](basics/maths-hyperbolic.ipynb) – `sinh`, `cosh`, `tanh` and their inverses. +- [Error propagation](basics/maths-error-propagation.ipynb) – How the + choice of formula changes the uncertainty of a result, with the area of + a table as the running example. +- [Correlation](basics/maths-correlation.ipynb) – Correlated + measurements and fit parameters, and what ignoring the correlation + costs. ## Workshops & Schools diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index d7e601b0..45ffa4f4 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -202,6 +202,8 @@ nav: - Inverse trigonometric functions: tutorials/basics/maths-inverse-trigonometric.ipynb - Hyperbolic functions: tutorials/basics/maths-hyperbolic.ipynb + - Error propagation: tutorials/basics/maths-error-propagation.ipynb + - Correlation: tutorials/basics/maths-correlation.ipynb - Fitting introduction: tutorials/fitting-intro.ipynb - Bayesian analysis: tutorials/fitting-bayesian.ipynb - Progress Callback: tutorials/progress-callback.ipynb diff --git a/tests/unit/tutorials/conftest.py b/tests/unit/tutorials/conftest.py index e48439d8..97dfa687 100644 --- a/tests/unit/tutorials/conftest.py +++ b/tests/unit/tutorials/conftest.py @@ -75,3 +75,16 @@ def run(name: str) -> list[Solution]: return solutions return run + + +@pytest.fixture +def solutions(run_notebook): + """Return a function that replays a notebook once and returns its solutions.""" + cache: dict[str, list[Solution]] = {} + + def get(name: str) -> list[Solution]: + if name not in cache: + cache[name] = run_notebook(name) + return cache[name] + + return get diff --git a/tests/unit/tutorials/notebooks.py b/tests/unit/tutorials/notebooks.py index 30fc995a..3d861796 100644 --- a/tests/unit/tutorials/notebooks.py +++ b/tests/unit/tutorials/notebooks.py @@ -27,8 +27,12 @@ 'basics/maths-inverse-trigonometric', 'basics/maths-hyperbolic', ] +PROPAGATION_NOTEBOOKS = [ + 'basics/maths-error-propagation', + 'basics/maths-correlation', +] FITTING_NOTEBOOKS = ['fitting-intro'] -NOTEBOOKS = MATHS_NOTEBOOKS + FITTING_NOTEBOOKS +NOTEBOOKS = MATHS_NOTEBOOKS + PROPAGATION_NOTEBOOKS + FITTING_NOTEBOOKS # Number of exercises (and so of folded solutions) in each notebook. EXERCISES = { @@ -37,6 +41,8 @@ 'basics/maths-trigonometric': 4, 'basics/maths-inverse-trigonometric': 4, 'basics/maths-hyperbolic': 4, + 'basics/maths-error-propagation': 4, + 'basics/maths-correlation': 4, 'fitting-intro': 2, } diff --git a/tests/unit/tutorials/test_tutorial_exercise_solutions.py b/tests/unit/tutorials/test_tutorial_exercise_solutions.py index 8b9f1700..ab4168b3 100644 --- a/tests/unit/tutorials/test_tutorial_exercise_solutions.py +++ b/tests/unit/tutorials/test_tutorial_exercise_solutions.py @@ -173,18 +173,6 @@ def _quadrature(*terms: float) -> float: } -@pytest.fixture -def solutions(run_notebook): - cache: dict[str, list[Solution]] = {} - - def get(name: str) -> list[Solution]: - if name not in cache: - cache[name] = run_notebook(name) - return cache[name] - - return get - - @pytest.mark.parametrize('name', NOTEBOOKS) def test_every_exercise_shows_a_solution_with_code_and_output(solutions, name): shown = solutions(name) diff --git a/tests/unit/tutorials/test_tutorial_propagation_notebooks.py b/tests/unit/tutorials/test_tutorial_propagation_notebooks.py new file mode 100644 index 00000000..71b84688 --- /dev/null +++ b/tests/unit/tutorials/test_tutorial_propagation_notebooks.py @@ -0,0 +1,284 @@ +# SPDX-FileCopyrightText: 2026 EasyScience contributors +# SPDX-License-Identifier: BSD-3-Clause + +"""The error propagation and correlation tutorials. + +These notebooks compare formulas and correlations, so besides the results of their exercises +the tests check the numbers that the text of the notebooks quotes, against closed forms. +""" + +import math +import re + +import pytest + +from .notebooks import PROJECT_ROOT +from .notebooks import load_notebook +from .notebooks import source_of + +pytestmark = pytest.mark.fast + +PROPAGATION = 'basics/maths-error-propagation' +CORRELATION = 'basics/maths-correlation' + +# Results are printed with four decimals (five for the contributions of Exercise 2). +PRINT_TOLERANCE = 5.1e-5 + +LINE = re.compile(r'^(?P