diff --git a/docs/docs/tutorials/diffusion_in_sphere.ipynb b/docs/docs/tutorials/diffusion_in_sphere.ipynb
new file mode 100644
index 00000000..6df786a5
--- /dev/null
+++ b/docs/docs/tutorials/diffusion_in_sphere.ipynb
@@ -0,0 +1,279 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "0",
+ "metadata": {},
+ "source": [
+ "# Diffusion inside a sphere\n",
+ "The model1,2 describes a particle that diffuses with diffusion coefficient $D$ inside a sphere of radius $R$ that it cannot leave, for example an atom of a side chain moving within a limited volume. The sphere is isotropic, so the scattering depends only on the magnitude of $Q$:\n",
+ "\n",
+ "$$ S(Q, E) = A_0(Q)\\,\\delta(E) + \\sum_{(l, n)} A_{nl}(Q)\\, L(E, \\Gamma_{nl}) $$\n",
+ "\n",
+ "with\n",
+ "\n",
+ "$$ A_0(Q) = \\left(\\frac{3 j_1(QR)}{QR}\\right)^2,\\quad\n",
+ "A_{nl}(Q) = (2l+1) \\frac{6 x_{nl}^2}{x_{nl}^2 - l(l+1)}\n",
+ "\\left(\\frac{QR\\, j_{l+1}(QR) - l\\, j_l(QR)}{(QR)^2 - x_{nl}^2}\\right)^2,\\quad\n",
+ "\\Gamma_{nl} = \\frac{\\hbar x_{nl}^2 D}{R^2}, $$\n",
+ "\n",
+ "where $j_l$ are the spherical Bessel functions and $x_{nl}$ the roots of $j_l'(x) = 0$, which express that no particles flow through the surface. $A_0$ is the Elastic Incoherent Structure Factor (EISF) and $A_{nl}$ the Quasi-Elastic Incoherent Structure Factor (QISF) of the root $x_{nl}$. The widths do not depend on $Q$; only the QISF do.\n",
+ "\n",
+ "The sum is infinite. The model uses the 98 roots below 26.84, the same roots as Mantid3, and by default only the lowest 10 of them, because every term adds Parameters that slow down a fit."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "id": "1",
+ "metadata": {},
+ "source": [
+ "import warnings\n",
+ "\n",
+ "import matplotlib.pyplot as plt\n",
+ "import numpy as np\n",
+ "\n",
+ "import easydynamics as edyn\n",
+ "\n",
+ "%matplotlib widget"
+ ],
+ "execution_count": null,
+ "outputs": []
+ },
+ {
+ "cell_type": "markdown",
+ "id": "2",
+ "metadata": {},
+ "source": [
+ "## The model at different Q values"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "id": "3",
+ "metadata": {},
+ "source": [
+ "# Q is in Angstrom^-1, the radius in Angstrom and D in Angstrom^2/ps.\n",
+ "# With R = 1 Angstrom, QR stays below 1.5, where 10 terms are enough.\n",
+ "Q = np.linspace(0.3, 1.5, 6)\n",
+ "\n",
+ "sphere_model = edyn.DiffusionInSphere(\n",
+ " scale=1.0,\n",
+ " radius=1.0,\n",
+ " diffusion_coefficient=0.1,\n",
+ " Q=Q,\n",
+ ")\n",
+ "print(sphere_model)"
+ ],
+ "execution_count": null,
+ "outputs": []
+ },
+ {
+ "cell_type": "code",
+ "id": "4",
+ "metadata": {},
+ "source": [
+ "# Plot the quasi-elastic part (all Lorentzians) at every Q. The delta function\n",
+ "# would be a spike at E = 0; its weight, the EISF, is plotted further down.\n",
+ "energy = np.linspace(-0.5, 0.5, 501)\n",
+ "component_collections = sphere_model.get_component_collections()\n",
+ "\n",
+ "cmap = plt.cm.jet\n",
+ "nQ = len(component_collections)\n",
+ "plt.figure()\n",
+ "for Q_index, collection in enumerate(component_collections):\n",
+ " quasielastic = sum(\n",
+ " component.evaluate(energy)\n",
+ " for component in collection\n",
+ " if isinstance(component, edyn.Lorentzian)\n",
+ " )\n",
+ " color = cmap(Q_index / (nQ - 1))\n",
+ " plt.plot(energy, quasielastic, label=f'Q={Q[Q_index]:.2f} Å^-1', color=color)\n",
+ "\n",
+ "plt.legend()\n",
+ "plt.xlabel('Energy (meV)')\n",
+ "plt.ylabel('Intensity (arb. units)')\n",
+ "plt.title('Diffusion inside a sphere, R = 1 Å')\n",
+ "plt.show()"
+ ],
+ "execution_count": null,
+ "outputs": []
+ },
+ {
+ "cell_type": "markdown",
+ "id": "5",
+ "metadata": {},
+ "source": [
+ "## Roots and widths"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "6",
+ "metadata": {},
+ "source": [
+ "The Lorentzian of order $j$ belongs to the $j$-th smallest root. `roots` gives each root as $(x_{nl}, l, n)$, and the width grows with $x_{nl}^2$."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "id": "7",
+ "metadata": {},
+ "source": [
+ "for order, (x, angular_order, n) in enumerate(sphere_model.roots, start=1):\n",
+ " width = sphere_model.calculate_width(order=order)[0]\n",
+ " print(f'order {order:2d}: x = {x:6.3f}, l = {angular_order}, n = {n}, HWHM = {width:.4f} meV')"
+ ],
+ "execution_count": null,
+ "outputs": []
+ },
+ {
+ "cell_type": "markdown",
+ "id": "8",
+ "metadata": {},
+ "source": [
+ "## EISF and QISF as a function of Q"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "9",
+ "metadata": {},
+ "source": [
+ "At small $QR$ almost all intensity is elastic. As $QR$ grows, the weight moves into the Lorentzians, first into the one with the smallest root ($l = 1$). The EISF and all QISF together sum to one, minus the weight of the terms that are left out."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "id": "10",
+ "metadata": {},
+ "source": [
+ "Q_fine = np.linspace(0.01, 1.5, 200)\n",
+ "\n",
+ "plt.figure()\n",
+ "plt.plot(Q_fine, sphere_model.calculate_EISF(Q_fine), color='k', label='EISF')\n",
+ "for order in range(1, 5):\n",
+ " _, angular_order, n = sphere_model.roots[order - 1]\n",
+ " plt.plot(\n",
+ " Q_fine,\n",
+ " sphere_model.calculate_QISF_order(Q_fine, order=order),\n",
+ " label=f'QISF of order {order} (l = {angular_order}, n = {n})',\n",
+ " )\n",
+ "plt.xlabel('Q (Å$^{-1}$)')\n",
+ "plt.ylabel('Weight')\n",
+ "plt.title('EISF and QISF for R = 1 Å')\n",
+ "plt.legend()\n",
+ "plt.show()"
+ ],
+ "execution_count": null,
+ "outputs": []
+ },
+ {
+ "cell_type": "markdown",
+ "id": "11",
+ "metadata": {},
+ "source": [
+ "## Truncation"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "12",
+ "metadata": {},
+ "source": [
+ "Leaving out terms loses intensity at large $QR$. A warning is raised when more than $10^{-3}$ of the weight is lost at any of the model's $Q$ values, and `calculate_missing_weight` returns the weight that is left out. With 10 terms this starts at $QR \\approx 1.8$, and with all 98 at $QR \\approx 9$. A model without $Q$ values builds no components, so it is quick to create for this check."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "id": "13",
+ "metadata": {},
+ "source": [
+ "QR = np.linspace(0.01, 12, 300)\n",
+ "\n",
+ "plt.figure()\n",
+ "for number_of_lorentzians in [10, 30, 98]:\n",
+ " model = edyn.DiffusionInSphere(radius=1.0, number_of_lorentzians=number_of_lorentzians)\n",
+ " plt.semilogy(\n",
+ " QR,\n",
+ " np.maximum(model.calculate_missing_weight(QR), 1e-16),\n",
+ " label=f'{number_of_lorentzians} terms',\n",
+ " )\n",
+ "plt.axhline(1e-3, color='k', linestyle='dashed', label='warning threshold')\n",
+ "plt.xlabel('QR')\n",
+ "plt.ylabel('Missing weight')\n",
+ "plt.title('Weight left out by the truncated sum')\n",
+ "plt.legend()\n",
+ "plt.show()"
+ ],
+ "execution_count": null,
+ "outputs": []
+ },
+ {
+ "cell_type": "markdown",
+ "id": "14",
+ "metadata": {},
+ "source": [
+ "A larger sphere reaches the threshold at smaller $Q$. With $R = 3$ Å and the default 10 terms, $Q$ up to 2 Å$^{-1}$ is already too far:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "id": "15",
+ "metadata": {},
+ "source": [
+ "with warnings.catch_warnings(record=True) as caught:\n",
+ " warnings.simplefilter('always')\n",
+ " large_sphere = edyn.DiffusionInSphere(radius=3.0, Q=np.linspace(0.3, 2, 4))\n",
+ "for warning in caught:\n",
+ " print(warning.message)\n",
+ "\n",
+ "print('Missing weight per Q:', np.round(large_sphere.calculate_missing_weight(), 4))"
+ ],
+ "execution_count": null,
+ "outputs": []
+ },
+ {
+ "cell_type": "markdown",
+ "id": "16",
+ "metadata": {},
+ "source": [
+ "More terms remove the warning but make every update of the model slower. The spherical Bessel functions are faster with the optional numba dependency, `pip install easydynamics[fast]`; without it SciPy gives the same values."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "17",
+ "metadata": {},
+ "source": [
+ "## References\n",
+ "\n",
+ "1. F. Volino and A. J. Dianoux, *Mol. Phys.* **41**, 271 (1980), [doi:10.1080/00268978000102761](https://doi.org/10.1080/00268978000102761).\n",
+ "\n",
+ "2. D. J. Bicout, *Phys. Rev. E* **62**, 261 (2000), [doi:10.1103/PhysRevE.62.261](https://doi.org/10.1103/PhysRevE.62.261).\n",
+ "\n",
+ "3. Mantid, [`InelasticDiffSphere`](https://github.com/mantidproject/mantid/blob/6ec66a6f524a7ec9d4258351fe3001c66c63dbe5/Framework/CurveFitting/src/Functions/InelasticDiffSphere.cpp#L53-L69): the table of the 98 roots."
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "default",
+ "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/diffusion_model.ipynb b/docs/docs/tutorials/diffusion_model.ipynb
index fa264b75..fa045302 100644
--- a/docs/docs/tutorials/diffusion_model.ipynb
+++ b/docs/docs/tutorials/diffusion_model.ipynb
@@ -6,7 +6,9 @@
"metadata": {},
"source": [
"# Diffusion Model\n",
- "We support several standard models of diffusion. Here we show examples of Brownian Translational Diffusion, Jump Translational Diffusion and Chudley-Elliott Jump Diffusion. In each model the scattering is a Lorentzian for every $Q$, and the models differ in how the width $\\Gamma(Q)$ depends on $Q$. For Brownian Translational Diffusion, the width is given by $\\Gamma = D Q^2$, where $D$ is the diffusion coefficient (in m$^2$/s) and $Q$ is the momentum transfer."
+ "We support several standard models of diffusion. Here we show examples of Brownian Translational Diffusion, Jump Translational Diffusion, Chudley-Elliott Jump Diffusion and Hall-Ross Jump Diffusion. In each model the scattering is a Lorentzian for every $Q$, and the models differ in how the width $\\Gamma(Q)$ depends on $Q$. For Brownian Translational Diffusion, the width is given by $\\Gamma = D Q^2$, where $D$ is the diffusion coefficient (in m$^2$/s) and $Q$ is the momentum transfer.\n",
+ "\n",
+ "Models with an elastic line and several Lorentzians have their own tutorials: [equivalent sites on a circle](equivalent_sites_circle.ipynb), [jump sites with a log-normal distribution](jump_sites_log_normal_distribution.ipynb), [the 3D Gaussian model](gaussian_model_3d.ipynb), [diffusion in a sphere](diffusion_in_sphere.ipynb) and [isotropic rotational diffusion](isotropic_rotational_diffusion.ipynb)."
]
},
{
@@ -204,13 +206,7 @@
"metadata": {},
"source": [
"## Chudley-Elliott Jump Diffusion\n",
- "The Chudley-Elliott model of\n",
- "\n",
- "> C. T. Chudley and R. J. Elliott, *Neutron Scattering from a Liquid on a Jump Diffusion Model*,\n",
- "> Proc. Phys. Soc. **77**, 353 (1961),\n",
- "> [doi:10.1088/0370-1328/77/2/319](https://doi.org/10.1088/0370-1328/77/2/319).\n",
- "\n",
- "describes a particle that sits on a site for a residence time $\\tau$ and then jumps a fixed distance $l$. The width is\n",
+ "The Chudley-Elliott model1 describes a particle that sits on a site for a residence time $\\tau$ and then jumps a fixed distance $l$. The width is\n",
"\n",
"$$\\Gamma(Q) = \\frac{\\hbar}{\\tau}\\left(1 - \\frac{\\sin(Ql)}{Ql}\\right),$$\n",
"\n",
@@ -295,6 +291,115 @@
"plt.title('HWHM vs Q for Chudley-Elliott Jump Diffusion')\n",
"plt.show()"
]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "17",
+ "metadata": {},
+ "source": [
+ "## Hall-Ross Jump Diffusion\n",
+ "The Hall-Ross model2 describes a particle that sits on a site for a residence time $\\tau$ and then jumps a random distance drawn from a Gaussian distribution. The width is\n",
+ "\n",
+ "$$\\Gamma(Q) = \\frac{\\hbar}{\\tau}\\left(1 - \\exp\\left(-\\frac{Q^2 l^2}{2}\\right)\\right),$$\n",
+ "\n",
+ "where $\\tau$ is the residence time (in ps) and $l$ is the standard deviation of the jump-length distribution (in Å).3 The mean-square jump length is $\\langle r^2 \\rangle = 3 l^2$. Unlike Chudley-Elliott, the width does not oscillate: it rises monotonically from $\\hbar D Q^2$, with $D = l^2 / (2\\tau)$, to $\\hbar/\\tau$."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "18",
+ "metadata": {},
+ "source": [
+ "### Model for different Q values"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "id": "19",
+ "metadata": {},
+ "source": [
+ "# Create Hall-Ross Jump Diffusion model\n",
+ "# and plot the model for different Q values.\n",
+ "# Q is in Angstrom^-1 and energy in meV.\n",
+ "\n",
+ "Q = np.linspace(0.5, 2, 7)\n",
+ "energy = np.linspace(-2, 2, 501)\n",
+ "scale = 1.0\n",
+ "residence_time = 1.0 # ps\n",
+ "jump_length = 1.5 / np.sqrt(3) # Å, the standard deviation l\n",
+ "\n",
+ "hall_ross_model = edyn.HallRossJumpDiffusion(\n",
+ " display_name='HallRossModel',\n",
+ " scale=scale,\n",
+ " residence_time=residence_time,\n",
+ " jump_length=jump_length,\n",
+ " Q=Q,\n",
+ ")\n",
+ "\n",
+ "component_collections = hall_ross_model.get_component_collections()\n",
+ "\n",
+ "cmap = plt.cm.jet\n",
+ "nQ = len(component_collections)\n",
+ "plt.figure()\n",
+ "for Q_index in range(len(component_collections)):\n",
+ " color = cmap(Q_index / (nQ - 1))\n",
+ " y = component_collections[Q_index].evaluate(energy)\n",
+ " plt.plot(energy, y, label=f'Q={Q[Q_index]} Å^-1', color=color)\n",
+ "\n",
+ "plt.legend()\n",
+ "plt.xlabel('Energy (meV)')\n",
+ "plt.ylabel('Intensity (arb. units)')\n",
+ "plt.title('Hall-Ross Jump Diffusion Model')\n",
+ "plt.show()"
+ ],
+ "execution_count": null,
+ "outputs": []
+ },
+ {
+ "cell_type": "markdown",
+ "id": "20",
+ "metadata": {},
+ "source": [
+ "### Width as a function of Q\n",
+ "The standard deviation above was chosen as $l = 1.5/\\sqrt{3}$ Å, so that the mean-square jump length, $3 l^2 = 2.25$ Å$^2$, equals that of the Chudley-Elliott model above with its fixed jump of 1.5 Å. Both models then have the same diffusion coefficient and agree at low $Q$; at high $Q$ the Hall-Ross width approaches $\\hbar/\\tau$ without oscillating."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "id": "21",
+ "metadata": {},
+ "source": [
+ "# Calculate and plot the half width at half maximum (HWHM) as function\n",
+ "# of Q, together with the Chudley-Elliott model with the same mean-square jump length\n",
+ "Q = np.linspace(0.1, 4, 201)\n",
+ "HWHM = hall_ross_model.calculate_width(Q)\n",
+ "plt.figure()\n",
+ "plt.plot(Q, HWHM, label='Hall-Ross')\n",
+ "plt.plot(Q, chudley_elliott_model.calculate_width(Q), linestyle='dashed', label='Chudley-Elliott')\n",
+ "plt.xlabel('Q (Å$^{-1}$)')\n",
+ "plt.ylabel('HWHM (meV)')\n",
+ "plt.xlim(0, 4.2)\n",
+ "plt.ylim(0, max(HWHM) * 1.2)\n",
+ "plt.title('HWHM vs Q for Hall-Ross Jump Diffusion')\n",
+ "plt.legend()\n",
+ "plt.show()"
+ ],
+ "execution_count": null,
+ "outputs": []
+ },
+ {
+ "cell_type": "markdown",
+ "id": "22",
+ "metadata": {},
+ "source": [
+ "## References\n",
+ "\n",
+ "1. C. T. Chudley and R. J. Elliott, *Neutron Scattering from a Liquid on a Jump Diffusion Model*, Proc. Phys. Soc. **77**, 353 (1961), [doi:10.1088/0370-1328/77/2/319](https://doi.org/10.1088/0370-1328/77/2/319).\n",
+ "\n",
+ "2. P. L. Hall and D. K. Ross, *Incoherent neutron scattering functions for random jump diffusion in bounded and infinite media*, Mol. Phys. **42**, 673 (1981), [doi:10.1080/00268978100100521](https://doi.org/10.1080/00268978100100521).\n",
+ "\n",
+ "3. The same convention as Mantid's [`HallRoss` fit function](https://github.com/mantidproject/mantid/blob/17c1cd96c1d46b39bee21dc75a60017b658d689c/Framework/PythonInterface/plugins/functions/HallRoss.py#L30-L39), whose `L` parameter is this $l$."
+ ]
}
],
"metadata": {
diff --git a/docs/docs/tutorials/equivalent_sites_circle.ipynb b/docs/docs/tutorials/equivalent_sites_circle.ipynb
new file mode 100644
index 00000000..ea9a6d66
--- /dev/null
+++ b/docs/docs/tutorials/equivalent_sites_circle.ipynb
@@ -0,0 +1,258 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "0",
+ "metadata": {},
+ "source": [
+ "# Equivalent sites on a circle\n",
+ "The model1,2 describes jumps between $N$ equivalent sites on a circle of radius $R$, for example a methyl group ($N = 3$) rotating about its axis. The scattering is an elastic line plus $N - 1$ Lorentzians:\n",
+ "\n",
+ "$$ S(Q, E) = A_0(Q)\\,\\delta(E) + \\sum_{l=1}^{N-1} A_l(Q)\\, L(E, \\Gamma_l) $$\n",
+ "\n",
+ "with\n",
+ "\n",
+ "$$ A_l(Q) = \\frac{1}{N} \\sum_{k=1}^{N} j_0(Q r_k) \\cos\\left(\\frac{2\\pi l k}{N}\\right),\n",
+ "\\quad r_k = 2R \\sin\\left(\\frac{\\pi k}{N}\\right),\n",
+ "\\quad \\Gamma_l = \\frac{2\\hbar}{\\tau} \\sin^2\\left(\\frac{\\pi l}{N}\\right), $$\n",
+ "\n",
+ "where $\\tau$ is the residence time on a site and $j_0(x) = \\sin(x)/x$. $A_0$ is the Elastic Incoherent Structure Factor (EISF), $A_l$ is the Quasi-Elastic Incoherent Structure Factor (QISF) of order $l$, and they sum to one. The widths do not depend on $Q$; only the weights do.\n",
+ "\n",
+ "The width is also written as $4 \\sin^2(\\pi l / N) / \\tau$, with a $\\tau$ that is twice the residence time used here.3"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "1",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import matplotlib.pyplot as plt\n",
+ "import numpy as np\n",
+ "\n",
+ "import easydynamics as edyn\n",
+ "\n",
+ "%matplotlib widget"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "2",
+ "metadata": {},
+ "source": [
+ "## The model at different Q values"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "3",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Q is in Angstrom^-1, the radius in Angstrom and the residence time in ps.\n",
+ "Q = np.linspace(0.5, 2, 7)\n",
+ "\n",
+ "circle_model = edyn.EquivalentSitesCircle(\n",
+ " scale=1.0,\n",
+ " radius=1.0,\n",
+ " residence_time=2.0,\n",
+ " number_of_sites=3,\n",
+ " Q=Q,\n",
+ ")\n",
+ "print(circle_model)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "4",
+ "metadata": {},
+ "source": [
+ "With $N = 3$ sites the model has $N - 1 = 2$ Lorentzians per $Q$. The radius is fixed by default, since it is normally known from the molecular geometry; free it with `circle_model.radius.fixed = False` to fit it."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "5",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Plot the quasi-elastic part (all Lorentzians) at every Q. The delta function\n",
+ "# would be a spike at E = 0; its weight, the EISF, is plotted further down.\n",
+ "energy = np.linspace(-2, 2, 501)\n",
+ "component_collections = circle_model.get_component_collections()\n",
+ "\n",
+ "cmap = plt.cm.jet\n",
+ "nQ = len(component_collections)\n",
+ "plt.figure()\n",
+ "for Q_index, collection in enumerate(component_collections):\n",
+ " quasielastic = sum(\n",
+ " component.evaluate(energy)\n",
+ " for component in collection\n",
+ " if isinstance(component, edyn.Lorentzian)\n",
+ " )\n",
+ " color = cmap(Q_index / (nQ - 1))\n",
+ " plt.plot(energy, quasielastic, label=f'Q={Q[Q_index]:.2f} Å^-1', color=color)\n",
+ "\n",
+ "plt.legend()\n",
+ "plt.xlabel('Energy (meV)')\n",
+ "plt.ylabel('Intensity (arb. units)')\n",
+ "plt.title('Equivalent sites on a circle, N = 3')\n",
+ "plt.show()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "6",
+ "metadata": {},
+ "source": [
+ "## The widths of the Lorentzians"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "7",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "for order in range(1, circle_model.number_of_lorentzians + 1):\n",
+ " width = circle_model.calculate_width(order=order)[0]\n",
+ " print(f'Lorentzian of order {order}: HWHM = {width:.4f} meV')"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "8",
+ "metadata": {},
+ "source": [
+ "For $N = 3$ both orders have the same width, $(2\\hbar/\\tau)\\sin^2(\\pi/3) = (2\\hbar/\\tau)\\sin^2(2\\pi/3)$, so the quasi-elastic part is a single Lorentzian. For larger $N$ the orders have different widths."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "9",
+ "metadata": {},
+ "source": [
+ "## EISF and QISF as a function of Q"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "10",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "Q_fine = np.linspace(0.01, 4, 200)\n",
+ "\n",
+ "plt.figure()\n",
+ "plt.plot(Q_fine, circle_model.calculate_EISF(Q_fine), label='EISF, $A_0$')\n",
+ "for order in range(1, circle_model.number_of_lorentzians + 1):\n",
+ " plt.plot(\n",
+ " Q_fine,\n",
+ " circle_model.calculate_QISF_order(Q_fine, order=order),\n",
+ " label=f'QISF of order {order}, $A_{order}$',\n",
+ " )\n",
+ "plt.plot(\n",
+ " Q_fine,\n",
+ " circle_model.calculate_EISF(Q_fine) + circle_model.calculate_QISF(Q_fine),\n",
+ " color='k',\n",
+ " linestyle='dashed',\n",
+ " label='EISF + QISF',\n",
+ ")\n",
+ "plt.xlabel('Q (Å$^{-1}$)')\n",
+ "plt.ylabel('Weight')\n",
+ "plt.title('Weights for N = 3')\n",
+ "plt.legend()\n",
+ "plt.show()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "11",
+ "metadata": {},
+ "source": [
+ "The EISF depends on the number of sites. For $N = 2$ this is the two-site jump model with jump distance $2R$; for large $N$ it approaches continuous rotational diffusion on the circle."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "12",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "plt.figure()\n",
+ "for number_of_sites in [2, 3, 6]:\n",
+ " model = edyn.EquivalentSitesCircle(\n",
+ " radius=1.0, residence_time=2.0, number_of_sites=number_of_sites\n",
+ " )\n",
+ " plt.plot(Q_fine, model.calculate_EISF(Q_fine), label=f'N = {number_of_sites}')\n",
+ "plt.xlabel('Q (Å$^{-1}$)')\n",
+ "plt.ylabel('EISF')\n",
+ "plt.title('EISF for different numbers of sites')\n",
+ "plt.legend()\n",
+ "plt.show()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "13",
+ "metadata": {},
+ "source": [
+ "## Components and fit targets\n",
+ "`EquivalentSitesCircle` is built on `DeltaLorentzianDiffusionModelBase`, the same base class as `JumpSitesLogNormalDistribution` and `GaussianModel3D`. Every collection holds a delta function and the Lorentzians of order $l$, named `_l`. Their areas and widths are dependent Parameters, so they follow the model parameters during a fit."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "14",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "collection = circle_model.get_component_collections()[0]\n",
+ "print([component.name for component in collection])\n",
+ "print([fit_target.name for fit_target in circle_model.get_fit_targets()])"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "15",
+ "metadata": {},
+ "source": [
+ "## References\n",
+ "\n",
+ "1. J. D. Barnes, J. Chem. Phys. **58**, 5193 (1973), [doi:10.1063/1.1679130](https://doi.org/10.1063/1.1679130).\n",
+ "\n",
+ "2. M. Bée, *Quasielastic Neutron Scattering*, Adam Hilger, Bristol (1988).\n",
+ "\n",
+ "3. Mantid, [`InelasticDiffRotDiscreteCircle`](https://github.com/mantidproject/mantid/blob/6ec66a6f524a7ec9d4258351fe3001c66c63dbe5/Framework/CurveFitting/src/Functions/InelasticDiffRotDiscreteCircle.cpp#L79-L108): its `Decay` parameter is this $2\\tau$."
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "default",
+ "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/gaussian_model_3d.ipynb b/docs/docs/tutorials/gaussian_model_3d.ipynb
new file mode 100644
index 00000000..3f6b82e9
--- /dev/null
+++ b/docs/docs/tutorials/gaussian_model_3d.ipynb
@@ -0,0 +1,250 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "0",
+ "metadata": {},
+ "source": [
+ "# Diffusion in an isotropic 3D harmonic well\n",
+ "The model1 describes a particle that diffuses with diffusion coefficient $D$ in a harmonic potential that is the same in all three directions, so its position about the centre of the well has a Gaussian distribution. $\\langle u^2 \\rangle$ is the mean square distance from the centre, summed over the three directions. Because the well is isotropic, the scattering depends only on the magnitude of $Q$:\n",
+ "\n",
+ "$$ S(Q, E) = A_0(Q)\\,\\delta(E) + \\sum_{i=1}^{n} A_i(Q)\\, L(E, \\Gamma_i) $$\n",
+ "\n",
+ "with\n",
+ "\n",
+ "$$ A_i(Q) = e^{-y} \\frac{y^i}{i!}, \\quad y = \\frac{Q^2 \\langle u^2 \\rangle}{3},\n",
+ "\\quad \\Gamma_i = \\frac{3 i \\hbar D}{\\langle u^2 \\rangle}. $$\n",
+ "\n",
+ "$A_0 = e^{-y}$ is the Elastic Incoherent Structure Factor (EISF) and $A_i$ the Quasi-Elastic Incoherent Structure Factor (QISF) of order $i$. The paper1 uses the variance along one direction, $\\langle u_x^2 \\rangle = \\langle u^2 \\rangle / 3$.\n",
+ "\n",
+ "The QISF form a Poisson distribution in $i$ with mean $y$, and together with the EISF they sum to one. The infinite sum is therefore truncated at $n$ = `number_of_lorentzians`, which must be well above $y$ at the largest $Q$."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "1",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import warnings\n",
+ "\n",
+ "import matplotlib.pyplot as plt\n",
+ "import numpy as np\n",
+ "\n",
+ "import easydynamics as edyn\n",
+ "\n",
+ "%matplotlib widget"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "2",
+ "metadata": {},
+ "source": [
+ "## The model at different Q values"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "3",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Q is in Angstrom^-1, D in m^2/s and in Angstrom^2.\n",
+ "Q = np.linspace(0.5, 2, 7)\n",
+ "\n",
+ "gaussian_model = edyn.GaussianModel3D(\n",
+ " scale=1.0,\n",
+ " diffusion_coefficient=2e-9,\n",
+ " mean_u_squared=1.5,\n",
+ " number_of_lorentzians=20,\n",
+ " Q=Q,\n",
+ ")\n",
+ "print(gaussian_model)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "4",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Plot the quasi-elastic part (all Lorentzians) at every Q. The delta function\n",
+ "# would be a spike at E = 0; its weight, the EISF, is plotted further down.\n",
+ "energy = np.linspace(-2, 2, 501)\n",
+ "component_collections = gaussian_model.get_component_collections()\n",
+ "\n",
+ "cmap = plt.cm.jet\n",
+ "nQ = len(component_collections)\n",
+ "plt.figure()\n",
+ "for Q_index, collection in enumerate(component_collections):\n",
+ " quasielastic = sum(\n",
+ " component.evaluate(energy)\n",
+ " for component in collection\n",
+ " if isinstance(component, edyn.Lorentzian)\n",
+ " )\n",
+ " color = cmap(Q_index / (nQ - 1))\n",
+ " plt.plot(energy, quasielastic, label=f'Q={Q[Q_index]:.2f} Å^-1', color=color)\n",
+ "\n",
+ "plt.legend()\n",
+ "plt.xlabel('Energy (meV)')\n",
+ "plt.ylabel('Intensity (arb. units)')\n",
+ "plt.title('3D Gaussian model')\n",
+ "plt.show()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "5",
+ "metadata": {},
+ "source": [
+ "## Widths and weights"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "6",
+ "metadata": {},
+ "source": [
+ "The widths grow linearly with the order and do not depend on $Q$. The weights do: at small $Q$ almost all weight is in the elastic line and the first Lorentzians, while at large $Q$ it moves to higher orders."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "7",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "orders = np.arange(1, gaussian_model.number_of_lorentzians + 1)\n",
+ "widths = [gaussian_model.calculate_width(order=order)[0] for order in orders]\n",
+ "print(f'HWHM of order 1: {widths[0]:.4f} meV, of order 20: {widths[-1]:.4f} meV')\n",
+ "\n",
+ "plt.figure()\n",
+ "for Q_value in [0.5, 1.5, 3.0]:\n",
+ " weights = [gaussian_model.calculate_QISF_order(Q_value, order=order)[0] for order in orders]\n",
+ " plt.plot(orders, weights, marker='o', label=f'Q={Q_value} Å^-1')\n",
+ "plt.xlabel('Order i')\n",
+ "plt.ylabel('QISF of order i, $A_i$')\n",
+ "plt.title('QISF $A_i$ of each Lorentzian (a Poisson distribution in i)')\n",
+ "plt.legend()\n",
+ "plt.show()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "8",
+ "metadata": {},
+ "source": [
+ "## EISF and QISF as a function of Q"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "9",
+ "metadata": {},
+ "source": [
+ "The EISF, $\\exp(-Q^2 \\langle u^2 \\rangle / 3)$, is the same Debye-Waller factor as in `DeltaLorentz`."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "10",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "Q_fine = np.linspace(0.01, 4, 200)\n",
+ "delta_lorentz = edyn.DeltaLorentz(mean_u_squared=1.5)\n",
+ "\n",
+ "plt.figure()\n",
+ "plt.plot(Q_fine, gaussian_model.calculate_EISF(Q_fine), label='EISF')\n",
+ "plt.plot(Q_fine, gaussian_model.calculate_QISF(Q_fine), label='QISF')\n",
+ "plt.plot(\n",
+ " Q_fine,\n",
+ " delta_lorentz.calculate_EISF(Q_fine),\n",
+ " color='k',\n",
+ " linestyle='dotted',\n",
+ " label='DeltaLorentz EISF',\n",
+ ")\n",
+ "plt.xlabel('Q (Å$^{-1}$)')\n",
+ "plt.ylabel('Weight')\n",
+ "plt.title('EISF and QISF of the 3D Gaussian model')\n",
+ "plt.legend()\n",
+ "plt.show()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "11",
+ "metadata": {},
+ "source": [
+ "## Truncation"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "12",
+ "metadata": {},
+ "source": [
+ "Truncating the sum leaves out the weight of the orders above $n$. A warning is raised when that missing weight exceeds $10^{-3}$ at any of the model's $Q$ values, and `calculate_missing_weight` returns it. As a rule, $n$ must be well above $y = Q^2 \\langle u^2 \\rangle / 3$ at the largest $Q$."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "13",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "Q_wide = np.linspace(0.5, 3, 6)\n",
+ "\n",
+ "with warnings.catch_warnings(record=True) as caught:\n",
+ " warnings.simplefilter('always')\n",
+ " truncated_model = edyn.GaussianModel3D(\n",
+ " diffusion_coefficient=2e-9, mean_u_squared=1.5, number_of_lorentzians=5, Q=Q_wide\n",
+ " )\n",
+ "for warning in caught:\n",
+ " print(warning.message)\n",
+ "\n",
+ "print('Missing weight per Q:', np.round(truncated_model.calculate_missing_weight(), 4))\n",
+ "largest = gaussian_model.calculate_missing_weight(Q_wide).max()\n",
+ "print(f'With 20 Lorentzians the largest missing weight is {largest:.1e}')"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "14",
+ "metadata": {},
+ "source": [
+ "## References\n",
+ "\n",
+ "1. F. Volino, J.-C. Perrin and S. Lyonnard, J. Phys. Chem. B **110**, 11217 (2006), [doi:10.1021/jp061103s](https://doi.org/10.1021/jp061103s)."
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "dynamics-lib:py-314-env (3.14.8)",
+ "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.9"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/docs/docs/tutorials/isotropic_rotational_diffusion.ipynb b/docs/docs/tutorials/isotropic_rotational_diffusion.ipynb
new file mode 100644
index 00000000..418d9dac
--- /dev/null
+++ b/docs/docs/tutorials/isotropic_rotational_diffusion.ipynb
@@ -0,0 +1,289 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "0",
+ "metadata": {},
+ "source": [
+ "# Isotropic rotational diffusion\n",
+ "The model1,2 describes a scatterer that stays at a fixed distance $R$ from a centre and moves over the surface of that sphere by rotational diffusion, for example a hydrogen atom in a small molecule that tumbles freely in a liquid. $D_R$ is the rotational diffusion coefficient. The motion is isotropic, so the scattering depends only on the magnitude of $Q$:\n",
+ "\n",
+ "$$ S(Q, E) = A_0(Q)\\,\\delta(E) + \\sum_{l=1}^{l_{max}} A_l(Q)\\, L(E, \\Gamma_l) $$\n",
+ "\n",
+ "with\n",
+ "\n",
+ "$$ A_0(Q) = j_0^2(QR),\\quad A_l(Q) = (2l+1)\\, j_l^2(QR),\\quad \\Gamma_l = l(l+1)\\hbar D_R, $$\n",
+ "\n",
+ "where $j_l$ are the spherical Bessel functions and $L(E, \\Gamma)$ is a Lorentzian with half width at half maximum $\\Gamma$. $A_0$ is the Elastic Incoherent Structure Factor (EISF) and $A_l$ the Quasi-Elastic Incoherent Structure Factor (QISF) of order $l$. The widths do not depend on $Q$; only the QISF do.3\n",
+ "\n",
+ "The sum is infinite. The model keeps the orders up to `l_max`, 6 by default and at most 20, because every order adds Parameters that slow down a fit."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "1",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import warnings\n",
+ "\n",
+ "import matplotlib.pyplot as plt\n",
+ "import numpy as np\n",
+ "\n",
+ "import easydynamics as edyn\n",
+ "\n",
+ "%matplotlib widget"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "2",
+ "metadata": {},
+ "source": [
+ "## The model at different Q values"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "3",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Q is in Angstrom^-1, the radius in Angstrom and D_R in 1/ps.\n",
+ "Q = np.linspace(0.3, 2, 7)\n",
+ "\n",
+ "rotation_model = edyn.IsotropicRotationalDiffusion(\n",
+ " scale=1.0,\n",
+ " radius=2.0,\n",
+ " rotational_diffusion_coefficient=0.1,\n",
+ " Q=Q,\n",
+ ")\n",
+ "print(rotation_model)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "4",
+ "metadata": {},
+ "source": [
+ "The radius is fixed by default, since it is normally known from the molecular geometry; free it with `rotation_model.radius.fixed = False` to fit it."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "5",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Plot the quasi-elastic part (all Lorentzians) at every Q. The delta function\n",
+ "# would be a spike at E = 0; its weight, the EISF, is plotted further down.\n",
+ "energy = np.linspace(-2, 2, 501)\n",
+ "component_collections = rotation_model.get_component_collections()\n",
+ "\n",
+ "cmap = plt.cm.jet\n",
+ "nQ = len(component_collections)\n",
+ "plt.figure()\n",
+ "for Q_index, collection in enumerate(component_collections):\n",
+ " quasielastic = sum(\n",
+ " component.evaluate(energy)\n",
+ " for component in collection\n",
+ " if isinstance(component, edyn.Lorentzian)\n",
+ " )\n",
+ " color = cmap(Q_index / (nQ - 1))\n",
+ " plt.plot(energy, quasielastic, label=fr'Q={Q[Q_index]:.2f} Å^{-1}', color=color)\n",
+ "\n",
+ "plt.legend()\n",
+ "plt.xlabel('Energy (meV)')\n",
+ "plt.ylabel('Intensity (arb. units)')\n",
+ "plt.title('Isotropic rotational diffusion, R = 2 Å')\n",
+ "plt.show()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "6",
+ "metadata": {},
+ "source": [
+ "## The widths of the Lorentzians"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "7",
+ "metadata": {},
+ "source": [
+ "The width of order $l$ grows as $l(l+1)$, so the higher orders are much broader than the first."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "8",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "for order in range(1, rotation_model.l_max + 1):\n",
+ " width = rotation_model.calculate_width(order=order)[0]\n",
+ " print(f'order {order}: HWHM = {width:.4f} meV')"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "9",
+ "metadata": {},
+ "source": [
+ "## EISF and QISF as a function of Q"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "10",
+ "metadata": {},
+ "source": [
+ "At small $QR$ almost all intensity is elastic. As $QR$ grows, the weight moves into the Lorentzians, one order after the other: the QISF of order $l$ is small until $QR$ approaches $l$. The EISF and the QISF of all orders sum to one."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "11",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "Q_fine = np.linspace(0.01, 2, 200)\n",
+ "\n",
+ "plt.figure()\n",
+ "plt.plot(Q_fine, rotation_model.calculate_EISF(Q_fine), color='k', label='EISF')\n",
+ "for order in range(1, 5):\n",
+ " plt.plot(\n",
+ " Q_fine,\n",
+ " rotation_model.calculate_QISF_order(Q_fine, order=order),\n",
+ " label=f'QISF of order {order}',\n",
+ " )\n",
+ "plt.plot(\n",
+ " Q_fine,\n",
+ " rotation_model.calculate_EISF(Q_fine) + rotation_model.calculate_QISF(Q_fine),\n",
+ " color='k',\n",
+ " linestyle='dashed',\n",
+ " label='EISF + QISF',\n",
+ ")\n",
+ "plt.xlabel('Q (Å$^{-1}$)')\n",
+ "plt.ylabel('Weight')\n",
+ "plt.title('EISF and QISF for R = 2 Å')\n",
+ "plt.legend()\n",
+ "plt.show()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "12",
+ "metadata": {},
+ "source": [
+ "## Truncation"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "13",
+ "metadata": {},
+ "source": [
+ "Leaving out the orders above `l_max` loses intensity at large $QR$. A warning is raised when more than $10^{-3}$ of the weight is lost at any of the model's $Q$ values, and `calculate_missing_weight` returns the weight that is left out. With the default `l_max` of 6 this starts at $QR \\approx 4.3$, and with the largest, 20, at $QR \\approx 17$. A model without $Q$ values builds no components, so it is quick to create for this check."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "14",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "QR = np.linspace(0.01, 20, 400)\n",
+ "\n",
+ "plt.figure()\n",
+ "for l_max in [2, 6, 20]:\n",
+ " model = edyn.IsotropicRotationalDiffusion(radius=1.0, l_max=l_max)\n",
+ " plt.semilogy(\n",
+ " QR,\n",
+ " np.maximum(model.calculate_missing_weight(QR), 1e-16),\n",
+ " label=f'l_max = {l_max}',\n",
+ " )\n",
+ "plt.axhline(1e-3, color='k', linestyle='dashed', label='warning threshold')\n",
+ "plt.xlabel('QR')\n",
+ "plt.ylabel('Missing weight')\n",
+ "plt.title('Weight left out by the truncated sum')\n",
+ "plt.legend()\n",
+ "plt.show()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "15",
+ "metadata": {},
+ "source": [
+ "Too small an `l_max` for the $Q$ range raises the warning:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "id": "16",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "with warnings.catch_warnings(record=True) as caught:\n",
+ " warnings.simplefilter('always')\n",
+ " short_model = edyn.IsotropicRotationalDiffusion(radius=2.0, l_max=2, Q=np.linspace(0.3, 2, 4))\n",
+ "for warning in caught:\n",
+ " print(warning.message)\n",
+ "\n",
+ "print('Missing weight per Q:', np.round(short_model.calculate_missing_weight(), 4))"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "17",
+ "metadata": {},
+ "source": [
+ "More orders remove the warning but also make every update of the model slower. The spherical Bessel functions are faster with the optional numba dependency, `pip install easydynamics[fast]`; without it SciPy gives the same values."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "18",
+ "metadata": {},
+ "source": [
+ "## References\n",
+ "\n",
+ "1. V. F. Sears, *Can. J. Phys.* **44**, 1299 (1966), [doi:10.1139/p66-109](https://doi.org/10.1139/p66-109).\n",
+ "\n",
+ "2. V. F. Sears, *Can. J. Phys.* **45**, 237 (1967), [doi:10.1139/p67-025](https://doi.org/10.1139/p67-025).\n",
+ "\n",
+ "3. M. Bée, *Quasielastic Neutron Scattering*, Adam Hilger, Bristol (1988)."
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "default",
+ "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/jump_sites_log_normal_distribution.ipynb b/docs/docs/tutorials/jump_sites_log_normal_distribution.ipynb
new file mode 100644
index 00000000..c3051ddf
--- /dev/null
+++ b/docs/docs/tutorials/jump_sites_log_normal_distribution.ipynb
@@ -0,0 +1,238 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "0",
+ "metadata": {},
+ "source": [
+ "# Jump sites with a log-normal distribution of jump rates\n",
+ "The model1 extends the [equivalent sites on a circle](equivalent_sites_circle.ipynb) model to a disordered environment, for example methyl groups in a glassy polymer, where every group has its own barrier. The jump rates then follow a log-normal distribution of standard deviation $\\sigma$, centred on the residence time $\\tau$:\n",
+ "\n",
+ "$$ S(Q, E) = A_0(Q)\\,\\delta(E) + \\sum_{l=1}^{N-1} A_l(Q) \\sum_{s=0}^{20} g_s\\, L(E, \\Gamma_l r_s), $$\n",
+ "\n",
+ "with $A_l(Q)$ and $\\Gamma_l$ as in the circle model. The distribution is sampled at 21 log-spaced widths $\\Gamma_l r_s$, with $\\ln r_s = \\sigma c (s/10 - 1)$ and $c = \\sqrt{-2 \\ln 0.1}$, and weights $g_s \\propto \\exp(-\\ln^2 r_s / (2\\sigma^2))$ that sum to one.\n",
+ "\n",
+ "$A_0$ is the Elastic Incoherent Structure Factor (EISF) and $g_s A_l$ the Quasi-Elastic Incoherent Structure Factor (QISF) of one Lorentzian. The Lorentzian of order $j = (l - 1) \\cdot 21 + s + 1$ has circle order $l$ and sample $s$, so there are $21 (N - 1)$ Lorentzians per $Q$."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "id": "1",
+ "metadata": {},
+ "source": [
+ "import matplotlib.pyplot as plt\n",
+ "import numpy as np\n",
+ "\n",
+ "import easydynamics as edyn\n",
+ "\n",
+ "%matplotlib widget"
+ ],
+ "execution_count": null,
+ "outputs": []
+ },
+ {
+ "cell_type": "markdown",
+ "id": "2",
+ "metadata": {},
+ "source": [
+ "## The model at different Q values"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "id": "3",
+ "metadata": {},
+ "source": [
+ "# Q is in Angstrom^-1, the radius in Angstrom and the residence time in ps.\n",
+ "Q = np.linspace(0.5, 2, 4)\n",
+ "\n",
+ "log_normal_model = edyn.JumpSitesLogNormalDistribution(\n",
+ " scale=1.0,\n",
+ " radius=1.0,\n",
+ " residence_time=2.0,\n",
+ " sigma=0.5,\n",
+ " number_of_sites=3,\n",
+ " Q=Q,\n",
+ ")\n",
+ "print(f'{log_normal_model.number_of_lorentzians} Lorentzians per Q')"
+ ],
+ "execution_count": null,
+ "outputs": []
+ },
+ {
+ "cell_type": "code",
+ "id": "4",
+ "metadata": {},
+ "source": [
+ "# Plot the quasi-elastic part (all Lorentzians) at every Q. The delta function\n",
+ "# would be a spike at E = 0; its weight, the EISF, is plotted further down.\n",
+ "energy = np.linspace(-2, 2, 501)\n",
+ "component_collections = log_normal_model.get_component_collections()\n",
+ "\n",
+ "cmap = plt.cm.jet\n",
+ "nQ = len(component_collections)\n",
+ "plt.figure()\n",
+ "for Q_index, collection in enumerate(component_collections):\n",
+ " quasielastic = sum(\n",
+ " component.evaluate(energy)\n",
+ " for component in collection\n",
+ " if isinstance(component, edyn.Lorentzian)\n",
+ " )\n",
+ " color = cmap(Q_index / (nQ - 1))\n",
+ " plt.plot(energy, quasielastic, label=f'Q={Q[Q_index]:.2f} Å^-1', color=color)\n",
+ "\n",
+ "plt.legend()\n",
+ "plt.xlabel('Energy (meV)')\n",
+ "plt.ylabel('Intensity (arb. units)')\n",
+ "plt.title('Jump sites with a log-normal distribution, sigma = 0.5')\n",
+ "plt.show()"
+ ],
+ "execution_count": null,
+ "outputs": []
+ },
+ {
+ "cell_type": "markdown",
+ "id": "5",
+ "metadata": {},
+ "source": [
+ "## The sampled distribution"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "6",
+ "metadata": {},
+ "source": [
+ "The grid of widths scales with $\\sigma$, so the weights $g_s$ are the same for every $\\sigma$; only the widths change."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "id": "7",
+ "metadata": {},
+ "source": [
+ "plt.figure()\n",
+ "plt.bar(range(21), log_normal_model.sample_weights())\n",
+ "plt.xlabel('Sample s')\n",
+ "plt.ylabel('Weight $g_s$')\n",
+ "plt.title('Weights of the 21 sampled widths')\n",
+ "plt.show()\n",
+ "\n",
+ "for order in [1, 11, 21]:\n",
+ " width = log_normal_model.calculate_width(order=order)[0]\n",
+ " print(f'Lorentzian of order {order}: HWHM = {width:.4f} meV')"
+ ],
+ "execution_count": null,
+ "outputs": []
+ },
+ {
+ "cell_type": "markdown",
+ "id": "8",
+ "metadata": {},
+ "source": [
+ "## The effect of sigma"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "9",
+ "metadata": {},
+ "source": [
+ "A wider distribution gives a quasi-elastic line with a sharper peak and longer wings. For $\\sigma \\to 0$ the model becomes the equivalent-sites circle model."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "id": "10",
+ "metadata": {},
+ "source": [
+ "energy = np.linspace(-2, 2, 501)\n",
+ "Q_single = 1.0\n",
+ "\n",
+ "\n",
+ "def quasielastic(\n",
+ " model: edyn.EquivalentSitesCircle | edyn.JumpSitesLogNormalDistribution,\n",
+ ") -> np.ndarray:\n",
+ " collection = model.get_component_collections()[0]\n",
+ " return sum(\n",
+ " component.evaluate(energy)\n",
+ " for component in collection\n",
+ " if isinstance(component, edyn.Lorentzian)\n",
+ " )\n",
+ "\n",
+ "\n",
+ "circle_model = edyn.EquivalentSitesCircle(\n",
+ " radius=1.0, residence_time=2.0, number_of_sites=3, Q=Q_single\n",
+ ")\n",
+ "\n",
+ "plt.figure()\n",
+ "plt.plot(energy, quasielastic(circle_model), color='k', linestyle='dashed', label='circle model')\n",
+ "for sigma in [0.01, 0.5, 1.0]:\n",
+ " model = edyn.JumpSitesLogNormalDistribution(\n",
+ " radius=1.0, residence_time=2.0, sigma=sigma, number_of_sites=3, Q=Q_single\n",
+ " )\n",
+ " plt.plot(energy, quasielastic(model), label=f'sigma = {sigma}')\n",
+ "plt.yscale('log')\n",
+ "plt.xlabel('Energy (meV)')\n",
+ "plt.ylabel('Intensity (arb. units)')\n",
+ "plt.title('Quasi-elastic part at Q = 1 Å$^{-1}$')\n",
+ "plt.legend()\n",
+ "plt.show()"
+ ],
+ "execution_count": null,
+ "outputs": []
+ },
+ {
+ "cell_type": "markdown",
+ "id": "11",
+ "metadata": {},
+ "source": [
+ "The EISF does not depend on $\\sigma$: the distribution changes how fast the jumps are, not where the sites are."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "id": "12",
+ "metadata": {},
+ "source": [
+ "Q_fine = np.linspace(0.01, 4, 200)\n",
+ "eisf = log_normal_model.calculate_EISF(Q_fine)\n",
+ "print('Same EISF as the circle model:', np.allclose(eisf, circle_model.calculate_EISF(Q_fine)))\n",
+ "print('EISF + QISF = 1:', np.allclose(eisf + log_normal_model.calculate_QISF(Q_fine), 1))"
+ ],
+ "execution_count": null,
+ "outputs": []
+ },
+ {
+ "cell_type": "markdown",
+ "id": "13",
+ "metadata": {},
+ "source": [
+ "## References\n",
+ "\n",
+ "1. A. Chahid, A. Alegría and J. Colmenero, Macromolecules **27**, 3282 (1994), [doi:10.1021/ma00090a022](https://doi.org/10.1021/ma00090a022)."
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "default",
+ "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 b8659848..5a135ea1 100644
--- a/docs/mkdocs.yml
+++ b/docs/mkdocs.yml
@@ -212,6 +212,13 @@ nav:
- Detailed balance: tutorials/detailed_balance.ipynb
- DeltaLorentz: tutorials/delta_lorentz.ipynb
- Diffusion model: tutorials/diffusion_model.ipynb
+ - Equivalent sites on a circle: tutorials/equivalent_sites_circle.ipynb
+ - Jump sites, log-normal distribution:
+ tutorials/jump_sites_log_normal_distribution.ipynb
+ - 3D Gaussian model: tutorials/gaussian_model_3d.ipynb
+ - Diffusion in a sphere: tutorials/diffusion_in_sphere.ipynb
+ - Isotropic rotational diffusion:
+ tutorials/isotropic_rotational_diffusion.ipynb
- Mittag-Leffler relaxation: tutorials/mittag_leffler.ipynb
- Sample model: tutorials/sample_model.ipynb
- Instrument model: tutorials/instrument_model.ipynb
diff --git a/pixi.lock b/pixi.lock
index 28105faf..e4fe6791 100644
--- a/pixi.lock
+++ b/pixi.lock
@@ -39,18 +39,24 @@ environments:
- conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.22.2-hbc21106_2.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.46.1-default_hbd61a6d_103.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libabseil-20260526.0-cxx17_h0dc7533_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libblas-3.11.0-11_h4a7cf45_openblas.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-h39a168f_4.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-ha411449_4.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-h018ffa1_4.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.11.0-11_h0358290_openblas.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20260512-pl5321h373387f_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-h280c20c_3.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.5-hd2095e1_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.7.0-h81df57d_1.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-16.2.0-ha9f2e26_7.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran-16.2.0-h69a702a_7.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-16.2.0-h6b99dfc_7.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.2.0-he0feb66_7.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/liblapack-3.11.0-11_h47877c9_openblas.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_1.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_2.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.68.1-h74cf4be_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenblas-0.3.34-pthreads_hf13c14d_2.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libpython-3.14.8-hd51c5da_102_cp314.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libsodium-1.0.22-hebe6cf0_3.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.54.1-h13e7031_0.conda
@@ -58,10 +64,13 @@ environments:
- conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.4-hcfc3c73_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libuv-1.52.1-h280c20c_1.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/llvmlite-0.50.0-py314h264585c_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.4-py314h93e9acf_1.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/msgspec-0.22.0-py314h89acca1_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_1.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/nodejs-26.10.0-ha5cdc1e_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/numba-0.68.0-py314hf3b331d_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/numpy-2.5.4-py314hd3a7d6b_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.5-h781a0a9_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/psutil-7.2.2-py314hfe1a184_3.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.8-hcd007b5_102_cp314.conda
@@ -188,7 +197,6 @@ environments:
- pypi: https://files.pythonhosted.org/packages/12/c9/6869a1dcf4aaf309b9543ec070be3ec3adebee7c9bec9af8c230494134b9/interrogate-1.7.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/13/95/cf3f7fe4910cf0365fa8ea0c731f4b8a624d97cd76ea777913ac8d0868e2/mkdocs_jupyter-0.26.3-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/16/78/d1caf51e136f98eef9f45fd417a21f2389bf8042e8d8f3f1ed7832a500ca/inversion-1.0.1-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/16/d5/928078d2b28f26829b138b4a6c3980045022fb409f570657a224ae60ef4e/numpy-2.5.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
- pypi: https://files.pythonhosted.org/packages/1c/59/964ecb8008722d27d8a835baea81f56a91cea8e097b3be992bc6ccde6367/versioningit-3.3.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/1d/f2/1371707ce438ba9a43f9d24089828494ec6f9396e28cd7903aa704dd6306/pillow-13.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
- pypi: https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl
@@ -415,6 +423,7 @@ environments:
- conda: https://conda.anaconda.org/conda-forge/noarch/webencodings-0.5.1-pyhd8ed1ab_3.conda
- conda: https://conda.anaconda.org/conda-forge/noarch/websocket-client-1.9.2-pyh5ded981_1.conda
- conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.1-pyh5ded981_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/_openmp_mutex-4.5-8_kmp_llvm.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/argon2-cffi-bindings-26.1.0-py314hcd3153d_2.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-python-1.2.0-py314hee34562_4.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h4e30115_10.conda
@@ -424,26 +433,37 @@ environments:
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-py310h579977c_2.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/krb5-1.22.2-h34f8a20_2.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libabseil-20260526.0-cxx17_h4c27e2a_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libblas-3.11.0-11_h51639a9_openblas.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlicommon-1.2.0-h1dcdb26_4.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlidec-1.2.0-h5295a6a_4.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlienc-1.2.0-h2ddc9cb_4.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcblas-3.11.0-11_hb0561ab_openblas.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-23.1.3-h55c6f16_0.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libedit-3.1.20260512-pl5321h26f1114_0.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libev-4.33-h1a92334_3.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.5-h6111c0a_0.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.7.0-h47dc5ef_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgcc-16.2.0-h3cf6597_7.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgfortran-16.2.0-h07b0088_7.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgfortran5-16.2.0-hdb7a957_7.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblapack-3.11.0-11_hd9741b5_openblas.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_1.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_2.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libnghttp2-1.68.1-h435687b_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenblas-0.3.34-openmp_h5e6e99c_1.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libpython-3.14.8-h281bc5c_102_cp314.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsodium-1.0.22-h74c22ad_3.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.54.1-hca69786_0.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libuv-1.52.1-h1a92334_1.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-openmp-23.1.4-hdb3d66b_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvmlite-0.50.0-py314hdb50727_0.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/markupsafe-3.0.4-py314hae45268_1.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/msgspec-0.22.0-py314hcd3153d_0.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-he64c551_1.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/nodejs-26.10.0-h8d74fab_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/numba-0.68.0-py314h16aeead_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/numpy-2.5.4-py314he06036b_0.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.5-h55eecbc_0.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/psutil-7.2.2-py314hd98292b_3.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-core-12.2.2-py314h63b12ec_1.conda
@@ -567,7 +587,6 @@ environments:
- pypi: https://files.pythonhosted.org/packages/d1/89/5d4c86da1130d9059681e5b6cd7645df5c10279a6a079c5c37dcb2cc6f3f/narwhals-2.27.1-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/d2/f0/834e479e47e499b6478e807fb57b31cc2db696c4db30557bb6f5aea4a90b/mando-0.7.1-py2.py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/d5/b2/c6ce165acffceb15a82c07b9cc77d391f86b3f379ba62911908ae5d34b91/numpy-2.5.4-cp314-cp314-macosx_14_0_arm64.whl
- pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz
- pypi: https://files.pythonhosted.org/packages/e0/1b/5e74289a1878e666bb4b65fe5805a04cafcac2aa5f519ef1ead67ef91c56/filelock-4.2.4-py3-none-any.whl
@@ -699,18 +718,32 @@ environments:
- conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.1.1-py314h5a2d7ad_3.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/charset-normalizer-3.5.2-h308a848_0.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/debugpy-1.8.22-py314hb98de8c_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/icu-78.3-h5112557_2.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/krb5-1.22.2-h719d79b_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/libblas-3.11.0-11_h8455456_mkl.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/libcblas-3.11.0-11_h2a3cdd5_mkl.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.5-hac47afa_0.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.7.0-h3d046cb_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/libhwloc-2.15.0-default_hae90fe0_1001.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/libiconv-1.18-hc1393d2_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/liblapack-3.11.0-11_hf9ab0e9_mkl.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.3-hfd05255_1.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/libmpdec-4.0.0-hfd05255_2.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/libpython-3.14.8-hc6187dd_102_cp314.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/libsodium-1.0.22-h6a83c73_3.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.54.1-hf5d6505_100.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/libwinpthread-12.0.0.r4.gg4f2fc60ca-h57928b3_10.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-16-2.15.4-h3cfd58e_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-2.15.4-he095d88_1.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.2-hfd05255_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/llvm-openmp-23.1.4-h49e36cd_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/llvmlite-0.50.0-py314h46b4103_0.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/markupsafe-3.0.4-py314h9aa99d3_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/mkl-2026.1.0-hac47afa_235.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/msgspec-0.22.0-py314h5a2d7ad_0.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/nodejs-26.10.0-h80d1838_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/numba-0.68.0-py314hb98de8c_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/numpy-2.5.4-py314h02f10f6_0.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.5-hf411b9b_0.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/psutil-7.2.2-py314hc5dbbe4_3.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/python-3.14.8-h53f6dd8_102_cp314.conda
@@ -719,6 +752,7 @@ environments:
- conda: https://conda.anaconda.org/conda-forge/win-64/pyyaml-6.0.3-py314h2359020_1.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/pyzmq-27.2.0-py312h343a6d4_2.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/rpds-py-2026.9.1-py314h9f07db2_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/tbb-2023.1.0-h272cf33_2.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h967ab96_4.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/tornado-6.5.10-py314h5a2d7ad_0.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda
@@ -775,7 +809,6 @@ environments:
- pypi: https://files.pythonhosted.org/packages/50/e2/7ad17747517e4c813dca78257d6bb593466f8231baf1384bc98d92745edb/pillow-13.0.0-cp314-cp314-win_amd64.whl
- pypi: https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/52/94/d73da0d28f16c45bb9b0a5691b91610b0275c5ef0eb5e43c87cf2dc1bf31/scipy-1.18.1-cp314-cp314-win_amd64.whl
- - pypi: https://files.pythonhosted.org/packages/52/9a/e1e512ebc948d5b9dd33b08736760f0ebbed2848fd4eda1f553088a6dcee/numpy-2.5.4-cp314-cp314-win_amd64.whl
- pypi: https://files.pythonhosted.org/packages/54/c8/12811c9b48fde162bb72b6f2e78fada9a247a09d7bb5be2050a5d099c77b/nodeenv-1.11.0-py2.py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/56/e2/480bcdb153feecde4d736b18c908c7eee4846f11d7547d2e0c71499af630/pycodestyle-2.15.0-py2.py3-none-any.whl
@@ -883,18 +916,24 @@ environments:
- conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.22.2-hbc21106_2.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.46.1-default_hbd61a6d_103.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libabseil-20260526.0-cxx17_h0dc7533_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libblas-3.11.0-11_h4a7cf45_openblas.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-h39a168f_4.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-ha411449_4.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-h018ffa1_4.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.11.0-11_h0358290_openblas.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20260512-pl5321h373387f_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-h280c20c_3.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.5-hd2095e1_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.7.0-h81df57d_1.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-16.2.0-ha9f2e26_7.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran-16.2.0-h69a702a_7.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-16.2.0-h6b99dfc_7.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.2.0-he0feb66_7.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/liblapack-3.11.0-11_h47877c9_openblas.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_1.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.68.1-h74cf4be_1.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libnsl-2.0.1-hb9d3cd8_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenblas-0.3.34-pthreads_hf13c14d_2.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libpython-3.12.15-ha57cdbe_2_cpython.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libsodium-1.0.22-hebe6cf0_3.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.54.1-h13e7031_0.conda
@@ -903,10 +942,13 @@ environments:
- conda: https://conda.anaconda.org/conda-forge/linux-64/libuv-1.52.1-h280c20c_1.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libxcrypt-4.4.38-h280c20c_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/llvmlite-0.50.0-py312hb491018_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.4-py312h8b7ec3f_1.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/msgspec-0.22.0-py312h5cc1888_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_1.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/nodejs-26.10.0-ha5cdc1e_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/numba-0.68.0-py312h80505a6_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/numpy-2.5.4-py312he827f4e_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.5-h781a0a9_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/psutil-7.2.2-py312h1b36aeb_3.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.12.15-h5f976f7_2_cpython.conda
@@ -1055,7 +1097,6 @@ environments:
- pypi: https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/3e/17/1f31d8562e6f970d64911f1abc330d233bc0c0601411cf7e19c1292be6da/spdx_headers-1.5.1-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/43/a3/c799c62e19c337e6d3770b08e475887fb30ce8477d3c09efca6b2f0228a6/numpy-2.5.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
- pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/54/c8/12811c9b48fde162bb72b6f2e78fada9a247a09d7bb5be2050a5d099c77b/nodeenv-1.11.0-py2.py3-none-any.whl
@@ -1258,6 +1299,7 @@ environments:
- conda: https://conda.anaconda.org/conda-forge/noarch/webencodings-0.5.1-pyhd8ed1ab_3.conda
- conda: https://conda.anaconda.org/conda-forge/noarch/websocket-client-1.9.2-pyh5ded981_1.conda
- conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.1-pyh5ded981_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/_openmp_mutex-4.5-8_kmp_llvm.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/argon2-cffi-bindings-26.1.0-py312ha053593_2.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/backports.zstd-1.8.0-py312h1a36842_0.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-python-1.2.0-py312ha52686f_4.conda
@@ -1268,25 +1310,36 @@ environments:
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-py310h579977c_2.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/krb5-1.22.2-h34f8a20_2.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libabseil-20260526.0-cxx17_h4c27e2a_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libblas-3.11.0-11_h51639a9_openblas.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlicommon-1.2.0-h1dcdb26_4.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlidec-1.2.0-h5295a6a_4.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlienc-1.2.0-h2ddc9cb_4.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcblas-3.11.0-11_hb0561ab_openblas.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-23.1.3-h55c6f16_0.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libedit-3.1.20260512-pl5321h26f1114_0.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libev-4.33-h1a92334_3.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.5-h6111c0a_0.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.7.0-h47dc5ef_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgcc-16.2.0-h3cf6597_7.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgfortran-16.2.0-h07b0088_7.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgfortran5-16.2.0-hdb7a957_7.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblapack-3.11.0-11_hd9741b5_openblas.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_1.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libnghttp2-1.68.1-h435687b_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenblas-0.3.34-openmp_h5e6e99c_1.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libpython-3.12.15-h0677fcb_2_cpython.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsodium-1.0.22-h74c22ad_3.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.54.1-hca69786_0.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libuv-1.52.1-h1a92334_1.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-openmp-23.1.4-hdb3d66b_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvmlite-0.50.0-py312ha6a25cf_0.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/markupsafe-3.0.4-py312hf1f8172_1.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/msgspec-0.22.0-py312ha053593_0.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-he64c551_1.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/nodejs-26.10.0-h8d74fab_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/numba-0.68.0-py312h9052ff5_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/numpy-2.5.4-py312hff34920_0.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.5-h55eecbc_0.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/psutil-7.2.2-py312hbd136b4_3.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-core-12.2.2-py312hb3d15f4_1.conda
@@ -1328,7 +1381,6 @@ environments:
- pypi: https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/2c/d7/8b9d508dda3b1ca828fb7e5ad26ca1bcb18b0b440f56c73625891ceedc7b/copier-9.19.1-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/30/81/0da8afb52a71d0a4f2bd3152357b1a441e393b286374802b9d3addab4ab5/trove_classifiers-2026.9.21.13-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/31/2f/5ea3570fcb8ccd0882bea99436a513b2c85dad8f774a2057849130a8fb99/numpy-2.5.4-cp312-cp312-macosx_14_0_arm64.whl
- pypi: https://files.pythonhosted.org/packages/33/ef/6d27fc118f58cb24886da413545a7efb0853d405fddbfd8b2d9ac09fbed4/jupyterlab_widgets-3.0.17-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/33/f0/3fe8c6e69135a845f4106f2ff8b6805638d4e85c264e70114e8126689587/tokenize_rt-6.2.0-py2.py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/34/95/40e17e20046b7bc820d29d09ae84ec157ec8dd6e6f6cd722626292c31b2e/widgetsnbextension-4.0.16-py3-none-any.whl
@@ -1542,17 +1594,31 @@ environments:
- conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.1.1-py312he06e257_3.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/charset-normalizer-3.5.2-h308a848_0.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/debugpy-1.8.22-py312ha1a9051_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/icu-78.3-h5112557_2.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/krb5-1.22.2-h719d79b_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/libblas-3.11.0-11_h8455456_mkl.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/libcblas-3.11.0-11_h2a3cdd5_mkl.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.5-hac47afa_0.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.7.0-h3d046cb_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/libhwloc-2.15.0-default_hae90fe0_1001.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/libiconv-1.18-hc1393d2_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/liblapack-3.11.0-11_hf9ab0e9_mkl.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.3-hfd05255_1.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/libpython-3.12.15-hfb4101e_2_cpython.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/libsodium-1.0.22-h6a83c73_3.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.54.1-hf5d6505_100.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/libwinpthread-12.0.0.r4.gg4f2fc60ca-h57928b3_10.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-16-2.15.4-h3cfd58e_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-2.15.4-he095d88_1.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.2-hfd05255_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/llvm-openmp-23.1.4-h49e36cd_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/llvmlite-0.50.0-py312h1534abe_0.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/markupsafe-3.0.4-py312h6fa65f5_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/mkl-2026.1.0-hac47afa_235.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/msgspec-0.22.0-py312he06e257_0.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/nodejs-26.10.0-h80d1838_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/numba-0.68.0-py312ha1a9051_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/numpy-2.5.4-py312ha3f287d_0.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.5-hf411b9b_0.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/psutil-7.2.2-py312he5662c2_3.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/python-3.12.15-hb12b558_2_cpython.conda
@@ -1561,6 +1627,7 @@ environments:
- conda: https://conda.anaconda.org/conda-forge/win-64/pyyaml-6.0.3-py312h05f76fc_1.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/pyzmq-27.2.0-py312h343a6d4_2.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/rpds-py-2026.9.1-py312hdabe01f_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/tbb-2023.1.0-h272cf33_2.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h967ab96_4.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/tornado-6.5.10-py312he06e257_0.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda
@@ -1581,7 +1648,6 @@ environments:
- pypi: https://files.pythonhosted.org/packages/07/2f/c4366b086f7c55dfbc56c2c89a1d78b5d028e69513313fffe075a27d50ae/pydoclint-0.11.1-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/0b/de/0fdee664da979fc0dd99398da63ccf228cb21de46a154dbc19fbf43e4d29/validate_pyproject-0.26-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/12/33/fefe62073dc8acfd0f2b9ed7c003af2f50aa61555e113e6db02b8f79f145/numpy-2.5.4-cp312-cp312-win_amd64.whl
- pypi: https://files.pythonhosted.org/packages/12/b3/88c0ef22878c86035f058df0ac6c171319ffd0aa52a406455ed3a3847566/ipympl-0.10.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/12/c9/6869a1dcf4aaf309b9543ec070be3ec3adebee7c9bec9af8c230494134b9/interrogate-1.7.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/13/95/cf3f7fe4910cf0365fa8ea0c731f4b8a624d97cd76ea777913ac8d0868e2/mkdocs_jupyter-0.26.3-py3-none-any.whl
@@ -1724,18 +1790,24 @@ environments:
- conda: https://conda.anaconda.org/conda-forge/linux-64/krb5-1.22.2-hbc21106_2.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/ld_impl_linux-64-2.46.1-default_hbd61a6d_103.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libabseil-20260526.0-cxx17_h0dc7533_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libblas-3.11.0-11_h4a7cf45_openblas.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-h39a168f_4.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlidec-1.2.0-ha411449_4.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlienc-1.2.0-h018ffa1_4.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.11.0-11_h0358290_openblas.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20260512-pl5321h373387f_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libev-4.33-h280c20c_3.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libexpat-2.8.5-hd2095e1_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libffi-3.7.0-h81df57d_1.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libgcc-16.2.0-ha9f2e26_7.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran-16.2.0-h69a702a_7.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-16.2.0-h6b99dfc_7.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.2.0-he0feb66_7.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/liblapack-3.11.0-11_h47877c9_openblas.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_1.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libmpdec-4.0.0-hb03c661_2.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libnghttp2-1.68.1-h74cf4be_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/libopenblas-0.3.34-pthreads_hf13c14d_2.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libpython-3.14.8-hd51c5da_102_cp314.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libsodium-1.0.22-hebe6cf0_3.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libsqlite-3.54.1-h13e7031_0.conda
@@ -1743,10 +1815,13 @@ environments:
- conda: https://conda.anaconda.org/conda-forge/linux-64/libuuid-2.42.4-hcfc3c73_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libuv-1.52.1-h280c20c_1.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/libzlib-1.3.2-h25fd6f3_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/llvmlite-0.50.0-py314h264585c_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.4-py314h93e9acf_1.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/msgspec-0.22.0-py314h89acca1_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/ncurses-6.6-hdb14827_1.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/nodejs-26.10.0-ha5cdc1e_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/numba-0.68.0-py314hf3b331d_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/linux-64/numpy-2.5.4-py314hd3a7d6b_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.5-h781a0a9_0.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/psutil-7.2.2-py314hfe1a184_3.conda
- conda: https://conda.anaconda.org/conda-forge/linux-64/python-3.14.8-hcd007b5_102_cp314.conda
@@ -1873,7 +1948,6 @@ environments:
- pypi: https://files.pythonhosted.org/packages/12/c9/6869a1dcf4aaf309b9543ec070be3ec3adebee7c9bec9af8c230494134b9/interrogate-1.7.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/13/95/cf3f7fe4910cf0365fa8ea0c731f4b8a624d97cd76ea777913ac8d0868e2/mkdocs_jupyter-0.26.3-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/16/78/d1caf51e136f98eef9f45fd417a21f2389bf8042e8d8f3f1ed7832a500ca/inversion-1.0.1-py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/16/d5/928078d2b28f26829b138b4a6c3980045022fb409f570657a224ae60ef4e/numpy-2.5.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
- pypi: https://files.pythonhosted.org/packages/1c/59/964ecb8008722d27d8a835baea81f56a91cea8e097b3be992bc6ccde6367/versioningit-3.3.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/1d/f2/1371707ce438ba9a43f9d24089828494ec6f9396e28cd7903aa704dd6306/pillow-13.0.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
- pypi: https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl
@@ -2100,6 +2174,7 @@ environments:
- conda: https://conda.anaconda.org/conda-forge/noarch/webencodings-0.5.1-pyhd8ed1ab_3.conda
- conda: https://conda.anaconda.org/conda-forge/noarch/websocket-client-1.9.2-pyh5ded981_1.conda
- conda: https://conda.anaconda.org/conda-forge/noarch/zipp-4.1.1-pyh5ded981_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/_openmp_mutex-4.5-8_kmp_llvm.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/argon2-cffi-bindings-26.1.0-py314hcd3153d_2.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/brotli-python-1.2.0-py314hee34562_4.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/bzip2-1.0.8-h4e30115_10.conda
@@ -2109,26 +2184,37 @@ environments:
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/icu-78.3-py310h579977c_2.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/krb5-1.22.2-h34f8a20_2.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libabseil-20260526.0-cxx17_h4c27e2a_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libblas-3.11.0-11_h51639a9_openblas.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlicommon-1.2.0-h1dcdb26_4.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlidec-1.2.0-h5295a6a_4.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlienc-1.2.0-h2ddc9cb_4.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcblas-3.11.0-11_hb0561ab_openblas.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-23.1.3-h55c6f16_0.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libedit-3.1.20260512-pl5321h26f1114_0.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libev-4.33-h1a92334_3.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libexpat-2.8.5-h6111c0a_0.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libffi-3.7.0-h47dc5ef_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgcc-16.2.0-h3cf6597_7.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgfortran-16.2.0-h07b0088_7.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgfortran5-16.2.0-hdb7a957_7.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblapack-3.11.0-11_hd9741b5_openblas.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_1.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libmpdec-4.0.0-h84a0fba_2.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libnghttp2-1.68.1-h435687b_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenblas-0.3.34-openmp_h5e6e99c_1.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libpython-3.14.8-h281bc5c_102_cp314.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsodium-1.0.22-h74c22ad_3.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libsqlite-3.54.1-hca69786_0.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libuv-1.52.1-h1a92334_1.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libzlib-1.3.2-h8088a28_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-openmp-23.1.4-hdb3d66b_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvmlite-0.50.0-py314hdb50727_0.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/markupsafe-3.0.4-py314hae45268_1.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/msgspec-0.22.0-py314hcd3153d_0.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/ncurses-6.6-he64c551_1.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/nodejs-26.10.0-h8d74fab_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/numba-0.68.0-py314h16aeead_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/osx-arm64/numpy-2.5.4-py314he06036b_0.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.5-h55eecbc_0.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/psutil-7.2.2-py314hd98292b_3.conda
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/pyobjc-core-12.2.2-py314h63b12ec_1.conda
@@ -2252,7 +2338,6 @@ environments:
- pypi: https://files.pythonhosted.org/packages/d1/89/5d4c86da1130d9059681e5b6cd7645df5c10279a6a079c5c37dcb2cc6f3f/narwhals-2.27.1-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/d2/f0/834e479e47e499b6478e807fb57b31cc2db696c4db30557bb6f5aea4a90b/mando-0.7.1-py2.py3-none-any.whl
- - pypi: https://files.pythonhosted.org/packages/d5/b2/c6ce165acffceb15a82c07b9cc77d391f86b3f379ba62911908ae5d34b91/numpy-2.5.4-cp314-cp314-macosx_14_0_arm64.whl
- pypi: https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz
- pypi: https://files.pythonhosted.org/packages/e0/1b/5e74289a1878e666bb4b65fe5805a04cafcac2aa5f519ef1ead67ef91c56/filelock-4.2.4-py3-none-any.whl
@@ -2384,18 +2469,32 @@ environments:
- conda: https://conda.anaconda.org/conda-forge/win-64/cffi-2.1.1-py314h5a2d7ad_3.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/charset-normalizer-3.5.2-h308a848_0.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/debugpy-1.8.22-py314hb98de8c_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/icu-78.3-h5112557_2.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/krb5-1.22.2-h719d79b_2.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/libblas-3.11.0-11_h8455456_mkl.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/libcblas-3.11.0-11_h2a3cdd5_mkl.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.5-hac47afa_0.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/libffi-3.7.0-h3d046cb_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/libhwloc-2.15.0-default_hae90fe0_1001.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/libiconv-1.18-hc1393d2_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/liblapack-3.11.0-11_hf9ab0e9_mkl.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.3-hfd05255_1.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/libmpdec-4.0.0-hfd05255_2.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/libpython-3.14.8-hc6187dd_102_cp314.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/libsodium-1.0.22-h6a83c73_3.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/libsqlite-3.54.1-hf5d6505_100.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/libwinpthread-12.0.0.r4.gg4f2fc60ca-h57928b3_10.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-16-2.15.4-h3cfd58e_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-2.15.4-he095d88_1.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.2-hfd05255_3.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/llvm-openmp-23.1.4-h49e36cd_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/llvmlite-0.50.0-py314h46b4103_0.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/markupsafe-3.0.4-py314h9aa99d3_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/mkl-2026.1.0-hac47afa_235.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/msgspec-0.22.0-py314h5a2d7ad_0.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/nodejs-26.10.0-h80d1838_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/numba-0.68.0-py314hb98de8c_1.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/numpy-2.5.4-py314h02f10f6_0.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.5-hf411b9b_0.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/psutil-7.2.2-py314hc5dbbe4_3.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/python-3.14.8-h53f6dd8_102_cp314.conda
@@ -2404,6 +2503,7 @@ environments:
- conda: https://conda.anaconda.org/conda-forge/win-64/pyyaml-6.0.3-py314h2359020_1.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/pyzmq-27.2.0-py312h343a6d4_2.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/rpds-py-2026.9.1-py314h9f07db2_0.conda
+ - conda: https://conda.anaconda.org/conda-forge/win-64/tbb-2023.1.0-h272cf33_2.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h967ab96_4.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/tornado-6.5.10-py314h5a2d7ad_0.conda
- conda: https://conda.anaconda.org/conda-forge/win-64/ucrt-10.0.26100.0-h57928b3_0.conda
@@ -2460,7 +2560,6 @@ environments:
- pypi: https://files.pythonhosted.org/packages/50/e2/7ad17747517e4c813dca78257d6bb593466f8231baf1384bc98d92745edb/pillow-13.0.0-cp314-cp314-win_amd64.whl
- pypi: https://files.pythonhosted.org/packages/52/59/0782e51887ac6b07ffd1570e0364cf901ebc36345fea669969d2084baebb/simple_websocket-1.1.0-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/52/94/d73da0d28f16c45bb9b0a5691b91610b0275c5ef0eb5e43c87cf2dc1bf31/scipy-1.18.1-cp314-cp314-win_amd64.whl
- - pypi: https://files.pythonhosted.org/packages/52/9a/e1e512ebc948d5b9dd33b08736760f0ebbed2848fd4eda1f553088a6dcee/numpy-2.5.4-cp314-cp314-win_amd64.whl
- pypi: https://files.pythonhosted.org/packages/54/c8/12811c9b48fde162bb72b6f2e78fada9a247a09d7bb5be2050a5d099c77b/nodeenv-1.11.0-py2.py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl
- pypi: https://files.pythonhosted.org/packages/56/e2/480bcdb153feecde4d736b18c908c7eee4846f11d7547d2e0c71499af630/pycodestyle-2.15.0-py2.py3-none-any.whl
@@ -3415,6 +3514,27 @@ packages:
- libabseil =*=cxx17*
size: 1440678
timestamp: 1791681043356
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libblas-3.11.0-11_h4a7cf45_openblas.conda
+ build_number: 11
+ sha256: d942e0c820d60a613a6f786d074f0820fb6343e3f1ccfb110b1d98b44fb75968
+ md5: b8cce1486f3c62f33291d6318c193d0d
+ depends:
+ - libopenblas >=0.3.34,<0.3.35.0a0
+ - libopenblas >=0.3.34,<1.0a0
+ constrains:
+ - blas 2.311 openblas
+ - libcblas 3.11.0 11*_openblas
+ - liblapack 3.11.0 11*_openblas
+ - liblapacke 3.11.0 11*_openblas
+ - mkl <2027
+ license: BSD-3-Clause
+ license_family: BSD
+ purls: []
+ run_exports:
+ weak:
+ - libblas >=3.11.0,<4.0a0
+ size: 18246
+ timestamp: 1789061062982
- conda: https://conda.anaconda.org/conda-forge/linux-64/libbrotlicommon-1.2.0-h39a168f_4.conda
sha256: c4e9854c585f3ee1785f651287ecdf8c28f30a2ea1b159a513005f8a0f6453a3
md5: df210655e30a05e3b069c91b7aaddd2d
@@ -3459,6 +3579,24 @@ packages:
- libbrotlienc >=1.2.0,<1.3.0a0
size: 298134
timestamp: 1788480383223
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libcblas-3.11.0-11_h0358290_openblas.conda
+ build_number: 11
+ sha256: f7d1bbdea61cf94ba219d17b2cb571a29830f8025385af799bff5a7ad1ef45e6
+ md5: 73a248c30811075059b5aeda766c2624
+ depends:
+ - libblas 3.11.0 11_h4a7cf45_openblas
+ constrains:
+ - blas 2.311 openblas
+ - liblapack 3.11.0 11*_openblas
+ - liblapacke 3.11.0 11*_openblas
+ license: BSD-3-Clause
+ license_family: BSD
+ purls: []
+ run_exports:
+ weak:
+ - libcblas >=3.11.0,<4.0a0
+ size: 18196
+ timestamp: 1789061068819
- conda: https://conda.anaconda.org/conda-forge/linux-64/libedit-3.1.20260512-pl5321h373387f_0.conda
sha256: 45301f14f4eb66da57cfaf2d265f3eeee73f8b07e99614ee3afc5b26eea91126
md5: 27dbd7e2984b8fe48e069379636ccfdd
@@ -3532,6 +3670,33 @@ packages:
run_exports: {}
size: 1058444
timestamp: 1790303109576
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran-16.2.0-h69a702a_7.conda
+ sha256: 9682ae4dd11487e10b904b3cb3ab6379cad25233d45fdfdf90d54349df244494
+ md5: 3a8d285f5f22138ef826ad54cf267e73
+ depends:
+ - libgfortran5 16.2.0 h6b99dfc_7
+ constrains:
+ - libgfortran-ng ==16.2.0=*_7
+ license: GPL-3.0-only WITH GCC-exception-3.1
+ license_family: GPL
+ purls: []
+ run_exports: {}
+ size: 28571
+ timestamp: 1790303139904
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libgfortran5-16.2.0-h6b99dfc_7.conda
+ sha256: 04dbab12c925b92977bb835dd2f5eda413607afa79f95a554e5a04b07a380322
+ md5: 7719ed7be38ad8c492c4bd5cc2bce0c4
+ depends:
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=16.2.0
+ constrains:
+ - libgfortran 16.2.0
+ license: GPL-3.0-only WITH GCC-exception-3.1
+ license_family: GPL
+ purls: []
+ run_exports: {}
+ size: 2528180
+ timestamp: 1790303122004
- conda: https://conda.anaconda.org/conda-forge/linux-64/libgomp-16.2.0-he0feb66_7.conda
sha256: 92a0bb350c7be6a4eedcb4e111c60aefd176018e6b1c009a6e025dbe9bbcd758
md5: d904c7ddd95e026254b4b80808ee9e12
@@ -3545,6 +3710,24 @@ packages:
- _openmp_mutex >=4.5
size: 640566
timestamp: 1790303046034
+- conda: https://conda.anaconda.org/conda-forge/linux-64/liblapack-3.11.0-11_h47877c9_openblas.conda
+ build_number: 11
+ sha256: 1ad3f43c9319ef398a8ef45989f4903a06e3965b51e77fc669a4c894168a8411
+ md5: 5622a13917855ce4b953b9e655cf9262
+ depends:
+ - libblas 3.11.0 11_h4a7cf45_openblas
+ constrains:
+ - blas 2.311 openblas
+ - libcblas 3.11.0 11*_openblas
+ - liblapacke 3.11.0 11*_openblas
+ license: BSD-3-Clause
+ license_family: BSD
+ purls: []
+ run_exports:
+ weak:
+ - liblapack >=3.11.0,<3.12.0a0
+ size: 18227
+ timestamp: 1789061073957
- conda: https://conda.anaconda.org/conda-forge/linux-64/liblzma-5.8.3-hb03c661_1.conda
sha256: 9787df8c22a59c9a70d3e5a10db9ad663485e75e9ccc3f09bd092cb7b95e0dab
md5: 1390b7c5ac0b1d8e447bc5efa6d3c8c2
@@ -3606,6 +3789,24 @@ packages:
- libnsl >=2.0.1,<2.1.0a0
size: 33731
timestamp: 1750274110928
+- conda: https://conda.anaconda.org/conda-forge/linux-64/libopenblas-0.3.34-pthreads_hf13c14d_2.conda
+ sha256: 69a722751e14eab2eb3bfdcb9acb1187afbd962b0c49b48d829341e0f6787f60
+ md5: 7e19fcd73d7f0997d3cb3326d2586a86
+ depends:
+ - libgfortran5 >=15.3.0
+ - libgfortran
+ - libgcc >=15
+ - __glibc >=2.17,<3.0.a0
+ constrains:
+ - openblas >=0.3.34,<0.3.35.0a0
+ license: BSD-3-Clause
+ license_family: BSD
+ purls: []
+ run_exports:
+ weak:
+ - libopenblas >=0.3.34,<1.0a0
+ size: 6845411
+ timestamp: 1789141490688
- conda: https://conda.anaconda.org/conda-forge/linux-64/libpython-3.12.15-ha57cdbe_2_cpython.conda
build_number: 2
sha256: 37017ac4a784c50f1fc73c7b0eb090c7f29f4541c9014c4b4e10daa275488fdd
@@ -3748,6 +3949,42 @@ packages:
- libzlib >=1.3.2,<2.0a0
size: 63713
timestamp: 1785362952714
+- conda: https://conda.anaconda.org/conda-forge/linux-64/llvmlite-0.50.0-py312hb491018_0.conda
+ sha256: fbf41bdb6920ad8c0912bb5571e1b3bd087f9e1453beed7110c34a80dc40b69b
+ md5: afdb20a709a3fb3f5c14d5849d11e67c
+ depends:
+ - python
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=15
+ - libstdcxx >=15
+ - libzlib >=1.3.2,<2.0a0
+ - zstd >=1.5.7,<1.6.0a0
+ - python_abi 3.12.* *_cp312
+ license: BSD-2-Clause
+ license_family: BSD
+ purls:
+ - pkg:pypi/llvmlite?source=compressed-mapping
+ run_exports: {}
+ size: 40830860
+ timestamp: 1790753610486
+- conda: https://conda.anaconda.org/conda-forge/linux-64/llvmlite-0.50.0-py314h264585c_0.conda
+ sha256: 9d8c5d862592dd15d0ed20703a3d053a3d208e3f16d2859ea5eb3dfa04844179
+ md5: 73a6cb12d4dd51278edf304f9436edf5
+ depends:
+ - python
+ - __glibc >=2.17,<3.0.a0
+ - libstdcxx >=15
+ - libgcc >=15
+ - zstd >=1.5.7,<1.6.0a0
+ - python_abi 3.14.* *_cp314
+ - libzlib >=1.3.2,<2.0a0
+ license: BSD-2-Clause
+ license_family: BSD
+ purls:
+ - pkg:pypi/llvmlite?source=compressed-mapping
+ run_exports: {}
+ size: 40840973
+ timestamp: 1790753612170
- conda: https://conda.anaconda.org/conda-forge/linux-64/markupsafe-3.0.4-py312h8b7ec3f_1.conda
sha256: a032b34f0c8e63ddb28714ec42f02f51cd6007d64b15a8269873574abe322931
md5: ee4135fc1ce77066d2be65475bcfbbff
@@ -3853,6 +4090,106 @@ packages:
- nodejs >=26.10.0,<27.0a0
size: 20474429
timestamp: 1790238478566
+- conda: https://conda.anaconda.org/conda-forge/linux-64/numba-0.68.0-py312h80505a6_1.conda
+ sha256: 8d56e0038318b7ddc2588f209dd1553667d8b2cf04c1e62c027e56dede448295
+ md5: e637e282879a2d6ec95189aba90e5bc1
+ depends:
+ - python
+ - llvmlite >=0.50.0,<0.51.0a0
+ - numpy >=1.22.3,<2.6
+ - _openmp_mutex >=4.5
+ - __glibc >=2.17,<3.0.a0
+ - libgcc >=15
+ - libstdcxx >=15
+ - numpy >=1.25,<3
+ - python_abi 3.12.* *_cp312
+ constrains:
+ - tbb >=2021.6.0
+ - libopenblas !=0.3.6
+ - cuda-version >=11.2
+ - cudatoolkit >=11.2
+ - scipy >=1.0
+ - cuda-python >=11.6
+ license: BSD-2-Clause
+ license_family: BSD
+ purls:
+ - pkg:pypi/numba?source=compressed-mapping
+ run_exports: {}
+ size: 6131980
+ timestamp: 1790835913658
+- conda: https://conda.anaconda.org/conda-forge/linux-64/numba-0.68.0-py314hf3b331d_1.conda
+ sha256: 79e53c9f31029ccea5e930eaf56b4e0f058a2cce84af56f5ccde3544922b1413
+ md5: 4d236b2b42dda905af545b792bd21982
+ depends:
+ - python
+ - llvmlite >=0.50.0,<0.51.0a0
+ - numpy >=1.22.3,<2.6
+ - __glibc >=2.17,<3.0.a0
+ - _openmp_mutex >=4.5
+ - libstdcxx >=15
+ - libgcc >=15
+ - numpy >=1.25,<3
+ - python_abi 3.14.* *_cp314
+ constrains:
+ - tbb >=2021.6.0
+ - libopenblas !=0.3.6
+ - cuda-version >=11.2
+ - cudatoolkit >=11.2
+ - scipy >=1.0
+ - cuda-python >=11.6
+ license: BSD-2-Clause
+ license_family: BSD
+ purls:
+ - pkg:pypi/numba?source=compressed-mapping
+ run_exports: {}
+ size: 6242403
+ timestamp: 1790835909136
+- conda: https://conda.anaconda.org/conda-forge/linux-64/numpy-2.5.4-py312he827f4e_0.conda
+ sha256: 8d562d8fcc44a93c65793a361f39a75712bb3cf2aa1d1a09bcc8ef072e54a1c3
+ md5: 759b701f4b548ff6d06794d99788e6bb
+ depends:
+ - python
+ - libstdcxx >=15
+ - libgcc >=15
+ - __glibc >=2.17,<3.0.a0
+ - liblapack >=3.9.0,<4.0a0
+ - python_abi 3.12.* *_cp312
+ - libblas >=3.9.0,<4.0a0
+ - libcblas >=3.9.0,<4.0a0
+ constrains:
+ - numpy-base <0a0
+ license: BSD-3-Clause
+ license_family: BSD
+ purls:
+ - pkg:pypi/numpy?source=compressed-mapping
+ run_exports:
+ weak:
+ - numpy >=1.25,<3
+ size: 9240540
+ timestamp: 1791675329565
+- conda: https://conda.anaconda.org/conda-forge/linux-64/numpy-2.5.4-py314hd3a7d6b_0.conda
+ sha256: 7533361a15ff1d4c1e7069b9410d32a602a6a9350d717a43731b07e3565a6689
+ md5: 533d72030f9dbe5f9a439c7f9673940b
+ depends:
+ - python
+ - libgcc >=15
+ - libstdcxx >=15
+ - __glibc >=2.17,<3.0.a0
+ - liblapack >=3.9.0,<4.0a0
+ - libcblas >=3.9.0,<4.0a0
+ - python_abi 3.14.* *_cp314
+ - libblas >=3.9.0,<4.0a0
+ constrains:
+ - numpy-base <0a0
+ license: BSD-3-Clause
+ license_family: BSD
+ purls:
+ - pkg:pypi/numpy?source=compressed-mapping
+ run_exports:
+ weak:
+ - numpy >=1.25,<3
+ size: 9406761
+ timestamp: 1791675316116
- conda: https://conda.anaconda.org/conda-forge/linux-64/openssl-3.6.5-h781a0a9_0.conda
sha256: 9c1533f1cf784f0e250919c0c9762d0b0e6bc727a79290ffb7cea1ceca1d038c
md5: 5a51a2ea653f120240b1a9bf76e0aae9
@@ -5965,6 +6302,20 @@ packages:
run_exports: {}
size: 24839
timestamp: 1791091060207
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/_openmp_mutex-4.5-8_kmp_llvm.conda
+ build_number: 8
+ sha256: c7c98ce74f6a7ff25aaa4706d06fa41d63a333add4d697b73aa4d8d2d7ad7651
+ md5: a2d706b4a7d603524133037c34f7fb76
+ depends:
+ - llvm-openmp >=9.0.1
+ license: BSD-3-Clause
+ license_family: BSD
+ purls: []
+ run_exports:
+ weak:
+ - _openmp_mutex >=4.5
+ size: 8016
+ timestamp: 1788046437162
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/argon2-cffi-bindings-26.1.0-py312ha053593_2.conda
sha256: 1aeea67028b6e4e5669bfbde76ea599d717b26757839815a42567300188dbbd8
md5: 0c522a1a5c994274dbedd6a4fd7740d0
@@ -6179,6 +6530,27 @@ packages:
- libabseil =*=cxx17*
size: 1277648
timestamp: 1791681045738
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libblas-3.11.0-11_h51639a9_openblas.conda
+ build_number: 11
+ sha256: 718c3e1a16ad946caeeba041520dc18b7af461226bd8c28faef78b181920dbb4
+ md5: 364639b771dbb4ede335d965f55a23ac
+ depends:
+ - libopenblas >=0.3.34,<0.3.35.0a0
+ - libopenblas >=0.3.34,<1.0a0
+ constrains:
+ - blas 2.311 openblas
+ - libcblas 3.11.0 11*_openblas
+ - liblapack 3.11.0 11*_openblas
+ - liblapacke 3.11.0 11*_openblas
+ - mkl <2027
+ license: BSD-3-Clause
+ license_family: BSD
+ purls: []
+ run_exports:
+ weak:
+ - libblas >=3.11.0,<4.0a0
+ size: 18300
+ timestamp: 1789061064192
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libbrotlicommon-1.2.0-h1dcdb26_4.conda
sha256: 8a6e4789a6049dabb41c6fcaf6719232245484d81dfeb199b5abdb95c84c9fc2
md5: 8f3981871d167a6cd323e636e1929dd4
@@ -6220,6 +6592,24 @@ packages:
- libbrotlienc >=1.2.0,<1.3.0a0
size: 295505
timestamp: 1788480359341
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcblas-3.11.0-11_hb0561ab_openblas.conda
+ build_number: 11
+ sha256: 3cebd672d8bd7c776a381422ea3fc48131343aeda2879974734715ac6a05c292
+ md5: 73bde7d713e790b3168707e02102e87b
+ depends:
+ - libblas 3.11.0 11_h51639a9_openblas
+ constrains:
+ - blas 2.311 openblas
+ - liblapack 3.11.0 11*_openblas
+ - liblapacke 3.11.0 11*_openblas
+ license: BSD-3-Clause
+ license_family: BSD
+ purls: []
+ run_exports:
+ weak:
+ - libcblas >=3.11.0,<4.0a0
+ size: 18324
+ timestamp: 1789061068736
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libcxx-23.1.3-h55c6f16_0.conda
sha256: 2d9d31172d7919bd276a8d1be338de567dd5afdd181e8d3d3d8c2d8695e09de2
md5: 424521e52e65ef7f6cf957c4966e6386
@@ -6285,6 +6675,64 @@ packages:
- libffi >=3.7.0,<3.8.0a0
size: 43637
timestamp: 1787753403688
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgcc-16.2.0-h3cf6597_7.conda
+ sha256: deacf62bd6cbbe05eb085dd69a2ca120053597c37066d53c8f7cf6815f26e9eb
+ md5: f100c8baeb0c816052b0d43948555bf6
+ depends:
+ - _openmp_mutex
+ constrains:
+ - libgcc-ng ==16.2.0=*_7
+ - libgomp 16.2.0 7
+ license: GPL-3.0-only WITH GCC-exception-3.1
+ license_family: GPL
+ purls: []
+ run_exports: {}
+ size: 365973
+ timestamp: 1790301995634
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgfortran-16.2.0-h07b0088_7.conda
+ sha256: a5b4b63d0ef4c7c59c37225b9f0307b5cd2a3a8499b2a40b6ddd2b9f072712a8
+ md5: 2efa9518e126c501a7c7d638b2fa3a21
+ depends:
+ - libgfortran5 16.2.0 hdb7a957_7
+ constrains:
+ - libgfortran-ng ==16.2.0=*_7
+ license: GPL-3.0-only WITH GCC-exception-3.1
+ license_family: GPL
+ purls: []
+ run_exports: {}
+ size: 99745
+ timestamp: 1790302083840
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libgfortran5-16.2.0-hdb7a957_7.conda
+ sha256: 19297e5cf39de976cdd1a46499a4e15fdf54d249d3231f3b2a9d43af3be0bc66
+ md5: c9d80dd8775b8fe76e825d63d8ccaf1b
+ depends:
+ - libgcc >=16.2.0
+ constrains:
+ - libgfortran 16.2.0
+ license: GPL-3.0-only WITH GCC-exception-3.1
+ license_family: GPL
+ purls: []
+ run_exports: {}
+ size: 554793
+ timestamp: 1790302001166
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblapack-3.11.0-11_hd9741b5_openblas.conda
+ build_number: 11
+ sha256: e7697df761eeb243272801a8c883dc1de6018a7f738746b443ef1396f0a78075
+ md5: d67ef1462e77d20cd3e0d27c876c3c9a
+ depends:
+ - libblas 3.11.0 11_h51639a9_openblas
+ constrains:
+ - blas 2.311 openblas
+ - libcblas 3.11.0 11*_openblas
+ - liblapacke 3.11.0 11*_openblas
+ license: BSD-3-Clause
+ license_family: BSD
+ purls: []
+ run_exports:
+ weak:
+ - liblapack >=3.11.0,<3.12.0a0
+ size: 18284
+ timestamp: 1789061072707
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/liblzma-5.8.3-h8088a28_1.conda
sha256: 23d0630046a3e8b164d8f80f2b74ed2605af2e7050ab9913018056402fae4311
md5: 8ab10323068b107661a4b9a4af84f3b5
@@ -6329,6 +6777,25 @@ packages:
- libnghttp2 >=1.68.1,<2.0a0
size: 567024
timestamp: 1787177422467
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libopenblas-0.3.34-openmp_h5e6e99c_1.conda
+ sha256: 18f167fdaa26f67a123c59766f4730b41866d2c0920b5c29d74a93d3a2e92a96
+ md5: 9c874eb9d4441a658ef3acf6ac56dc3e
+ depends:
+ - __osx >=11.0
+ - libgcc >=15
+ - libgfortran
+ - libgfortran5 >=15.3.0
+ - llvm-openmp >=21.1.8
+ constrains:
+ - openblas >=0.3.34,<0.3.35.0a0
+ license: BSD-3-Clause
+ license_family: BSD
+ purls: []
+ run_exports:
+ weak:
+ - libopenblas >=0.3.34,<1.0a0
+ size: 4249236
+ timestamp: 1789023664380
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/libpython-3.12.15-h0677fcb_2_cpython.conda
build_number: 2
sha256: b21efc2d2582aa1b84b96eab48229ed452b22db638960f4b5b8ebf4a50b28be4
@@ -6424,6 +6891,56 @@ packages:
- libzlib >=1.3.2,<2.0a0
size: 47822
timestamp: 1785277049190
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvm-openmp-23.1.4-hdb3d66b_0.conda
+ sha256: cc6d67df56d4ea967e17d938d3108cdf7d971531350df9e7fc5e18cb89066a70
+ md5: 9a6ec2a53da5d75951b88c51018c27fa
+ depends:
+ - __osx >=11.0
+ constrains:
+ - intel-openmp <0.0a0
+ - openmp 23.1.4|23.1.4.*
+ license: Apache-2.0 WITH LLVM-exception
+ license_family: APACHE
+ purls: []
+ run_exports:
+ strong:
+ - llvm-openmp >=23.1.4
+ size: 287495
+ timestamp: 1792567065537
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvmlite-0.50.0-py312ha6a25cf_0.conda
+ sha256: 545586bcfc6bd866b7384604f669e24959328b73a26475631f9a8ab04b0abb19
+ md5: c3c2abd754dd0a98a30e59bf133c59a4
+ depends:
+ - python
+ - __osx >=11.0
+ - libcxx >=21
+ - zstd >=1.5.7,<1.6.0a0
+ - libzlib >=1.3.2,<2.0a0
+ - python_abi 3.12.* *_cp312
+ license: BSD-2-Clause
+ license_family: BSD
+ purls:
+ - pkg:pypi/llvmlite?source=compressed-mapping
+ run_exports: {}
+ size: 30497478
+ timestamp: 1790753620077
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/llvmlite-0.50.0-py314hdb50727_0.conda
+ sha256: 8e1eafaff243728c7485b7d59aae7edee48ad2d0e4f261796a653db02280bc65
+ md5: 9d739b341d9f67d61843c40a6f5fe6c9
+ depends:
+ - python
+ - __osx >=11.0
+ - libcxx >=21
+ - python_abi 3.14.* *_cp314
+ - libzlib >=1.3.2,<2.0a0
+ - zstd >=1.5.7,<1.6.0a0
+ license: BSD-2-Clause
+ license_family: BSD
+ purls:
+ - pkg:pypi/llvmlite?source=compressed-mapping
+ run_exports: {}
+ size: 30509685
+ timestamp: 1790753618538
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/markupsafe-3.0.4-py312hf1f8172_1.conda
sha256: e8e83eee7eb752f2a46d6a61c4ed3fdfd179debab7004fe3fd499e5fe776340e
md5: 6a7b67878aae79a4a0976a3aac810f94
@@ -6523,6 +7040,102 @@ packages:
- nodejs >=26.10.0,<27.0a0
size: 18529792
timestamp: 1790238394580
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/numba-0.68.0-py312h9052ff5_1.conda
+ sha256: bedcaa7f608e7f04fcb25fba3fc9c505c665908d29aa9554e254096618046481
+ md5: 1ee7d8789312b098f86060167ec9c06c
+ depends:
+ - python
+ - llvmlite >=0.50.0,<0.51.0a0
+ - numpy >=1.22.3,<2.6
+ - libcxx >=21
+ - __osx >=11.0
+ - llvm-openmp >=21.1.8
+ - python_abi 3.12.* *_cp312
+ - numpy >=1.25,<3
+ constrains:
+ - tbb >=2021.6.0
+ - libopenblas >=0.3.18,!=0.3.20
+ - cuda-version >=11.2
+ - cudatoolkit >=11.2
+ - scipy >=1.0
+ - cuda-python >=11.6
+ license: BSD-2-Clause
+ license_family: BSD
+ purls:
+ - pkg:pypi/numba?source=compressed-mapping
+ run_exports: {}
+ size: 6112507
+ timestamp: 1790835922925
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/numba-0.68.0-py314h16aeead_1.conda
+ sha256: ca08d67da077417061a4c962495cb7a201bc217130ae50340246e1a70617c5cd
+ md5: 31c88b7e34df56222951e0af11406e8a
+ depends:
+ - python
+ - llvmlite >=0.50.0,<0.51.0a0
+ - numpy >=1.22.3,<2.6
+ - libcxx >=21
+ - llvm-openmp >=21.1.8
+ - __osx >=11.0
+ - python_abi 3.14.* *_cp314
+ - numpy >=1.25,<3
+ constrains:
+ - tbb >=2021.6.0
+ - libopenblas >=0.3.18,!=0.3.20
+ - cuda-version >=11.2
+ - cudatoolkit >=11.2
+ - scipy >=1.0
+ - cuda-python >=11.6
+ license: BSD-2-Clause
+ license_family: BSD
+ purls:
+ - pkg:pypi/numba?source=compressed-mapping
+ run_exports: {}
+ size: 6227216
+ timestamp: 1790835917808
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/numpy-2.5.4-py312hff34920_0.conda
+ sha256: 7a72f5345e7e9f2ed8385986de8d96e321301726dfd6c1a7d0a0d2106ccf204b
+ md5: f1c8bb69dfcbbdd01e7ea79c8ae31934
+ depends:
+ - python
+ - libcxx >=21
+ - __osx >=11.0
+ - python_abi 3.12.* *_cp312
+ - libcblas >=3.9.0,<4.0a0
+ - liblapack >=3.9.0,<4.0a0
+ - libblas >=3.9.0,<4.0a0
+ constrains:
+ - numpy-base <0a0
+ license: BSD-3-Clause
+ license_family: BSD
+ purls:
+ - pkg:pypi/numpy?source=compressed-mapping
+ run_exports:
+ weak:
+ - numpy >=1.25,<3
+ size: 7122982
+ timestamp: 1791675320293
+- conda: https://conda.anaconda.org/conda-forge/osx-arm64/numpy-2.5.4-py314he06036b_0.conda
+ sha256: e6a60c9c02afdf42d00c2b797916052f19eec2f5ab0a71ce976fb4d6fe4611e4
+ md5: a555611b524896921b1721e57785ed24
+ depends:
+ - python
+ - __osx >=11.0
+ - libcxx >=21
+ - libcblas >=3.9.0,<4.0a0
+ - python_abi 3.14.* *_cp314
+ - liblapack >=3.9.0,<4.0a0
+ - libblas >=3.9.0,<4.0a0
+ constrains:
+ - numpy-base <0a0
+ license: BSD-3-Clause
+ license_family: BSD
+ purls:
+ - pkg:pypi/numpy?source=compressed-mapping
+ run_exports:
+ weak:
+ - numpy >=1.25,<3
+ size: 7284786
+ timestamp: 1791675323083
- conda: https://conda.anaconda.org/conda-forge/osx-arm64/openssl-3.6.5-h55eecbc_0.conda
sha256: 44fbc526456571a9fb8fa18157f6f269d4d4bd40eef20183a93b9c9296f94497
md5: 45d9179cdf6e53d88aec034246f0dad6
@@ -7072,6 +7685,21 @@ packages:
run_exports: {}
size: 4050949
timestamp: 1789745272497
+- conda: https://conda.anaconda.org/conda-forge/win-64/icu-78.3-h5112557_2.conda
+ sha256: 75c549b55b673e15de8785a8e5dd85bca7eb612eee0ff4dc8d7bdaa15eacbdbb
+ md5: e596942e8ee6ee17fdcf1e6a77757a66
+ depends:
+ - vc >=14.3,<15
+ - vc14_runtime >=14.44.35208
+ - ucrt >=10.0.20348.0
+ license: MIT
+ license_family: MIT
+ purls: []
+ run_exports:
+ weak:
+ - icu >=78.3,<79.0a0
+ size: 16835644
+ timestamp: 1784916416303
- conda: https://conda.anaconda.org/conda-forge/win-64/krb5-1.22.2-h719d79b_2.conda
sha256: 63ff03324e903eb01a715ccf357df56d66224e61952fd6615d86490ebefb3285
md5: 93f5a01dec294a2228f757fe2f3432d4
@@ -7088,6 +7716,43 @@ packages:
- krb5 >=1.22.2,<1.23.0a0
size: 753425
timestamp: 1786762169034
+- conda: https://conda.anaconda.org/conda-forge/win-64/libblas-3.11.0-11_h8455456_mkl.conda
+ build_number: 11
+ sha256: 51a38b02bacb5a4b546d41aeb0f7921ee85c0ec1009fdcb58635bcad06f2eb1a
+ md5: a63c8b08343d276a552c395f495987fa
+ depends:
+ - mkl >=2026.1.0,<2027.0a0
+ constrains:
+ - blas 2.311 mkl
+ - libcblas 3.11.0 11*_mkl
+ - liblapack 3.11.0 11*_mkl
+ - liblapacke 3.11.0 11*_mkl
+ license: BSD-3-Clause
+ license_family: BSD
+ purls: []
+ run_exports:
+ weak:
+ - libblas >=3.11.0,<4.0a0
+ size: 67059
+ timestamp: 1789061341346
+- conda: https://conda.anaconda.org/conda-forge/win-64/libcblas-3.11.0-11_h2a3cdd5_mkl.conda
+ build_number: 11
+ sha256: 9a2d431ead1463a7a96a38bedc901705c325c5bbafd7a2bcc5a198fbfcdea29e
+ md5: c9f8a732af97d55997f9ac6d0c02e3f2
+ depends:
+ - libblas 3.11.0 11_h8455456_mkl
+ constrains:
+ - blas 2.311 mkl
+ - liblapack 3.11.0 11*_mkl
+ - liblapacke 3.11.0 11*_mkl
+ license: BSD-3-Clause
+ license_family: BSD
+ purls: []
+ run_exports:
+ weak:
+ - libcblas >=3.11.0,<4.0a0
+ size: 67465
+ timestamp: 1789061353350
- conda: https://conda.anaconda.org/conda-forge/win-64/libexpat-2.8.5-hac47afa_0.conda
sha256: 70c73d9fd24ad996f2821d1501adae2ca70984b4e2d8faed76227f348ce86078
md5: 4969128c45f9e71adbb6a1696679ce7f
@@ -7118,6 +7783,56 @@ packages:
- libffi >=3.7.0,<3.8.0a0
size: 49992
timestamp: 1787753517346
+- conda: https://conda.anaconda.org/conda-forge/win-64/libhwloc-2.15.0-default_hae90fe0_1001.conda
+ sha256: 240368b73a984a1812dab5f7964ed03304950ad97466cd7ee347df9cc707629d
+ md5: b679b0f76b152d40294fa4e43cabfd4a
+ depends:
+ - libwinpthread >=12.0.0.r4.gg4f2fc60ca
+ - libxml2
+ - libxml2-16 >=2.15.4
+ - ucrt >=10.0.20348.0
+ - vc >=14.3,<15
+ - vc14_runtime >=14.44.35208
+ license: BSD-3-Clause
+ license_family: BSD
+ purls: []
+ run_exports:
+ weak:
+ - libhwloc >=2.15.0,<2.15.1.0a0
+ size: 2417452
+ timestamp: 1790244291307
+- conda: https://conda.anaconda.org/conda-forge/win-64/libiconv-1.18-hc1393d2_3.conda
+ sha256: 35e04e3ddac7720fc7c550a1c9f998604299d6a7bd1f3ec9b2825d825061daa2
+ md5: a8a2abdf0f901bc4779d9b7be0845921
+ depends:
+ - ucrt >=10.0.20348.0
+ - vc >=14.3,<15
+ - vc14_runtime >=14.44.35208
+ license: LGPL-2.1-only
+ purls: []
+ run_exports:
+ weak:
+ - libiconv >=1.18,<2.0a0
+ size: 694899
+ timestamp: 1787033851701
+- conda: https://conda.anaconda.org/conda-forge/win-64/liblapack-3.11.0-11_hf9ab0e9_mkl.conda
+ build_number: 11
+ sha256: 460a2b9896bbee9a3cdfef26d1fdce056028291eef104564082de9e9f566bba6
+ md5: 79bdf20fd2666b8a3485a6021c935b3a
+ depends:
+ - libblas 3.11.0 11_h8455456_mkl
+ constrains:
+ - blas 2.311 mkl
+ - libcblas 3.11.0 11*_mkl
+ - liblapacke 3.11.0 11*_mkl
+ license: BSD-3-Clause
+ license_family: BSD
+ purls: []
+ run_exports:
+ weak:
+ - liblapack >=3.11.0,<3.12.0a0
+ size: 79503
+ timestamp: 1789061364082
- conda: https://conda.anaconda.org/conda-forge/win-64/liblzma-5.8.3-hfd05255_1.conda
sha256: d36c4a1e1f80fd08e18a407e03622ff2f34dfdd022da6488ad19603dea19e6d5
md5: 880a0c8549479b198af21ba5dc49b109
@@ -7201,6 +7916,59 @@ packages:
- libsqlite >=3.54.1,<4.0a0
size: 1333763
timestamp: 1792175621892
+- conda: https://conda.anaconda.org/conda-forge/win-64/libwinpthread-12.0.0.r4.gg4f2fc60ca-h57928b3_10.conda
+ sha256: 0fccf2d17026255b6e10ace1f191d0a2a18f2d65088fd02430be17c701f8ffe0
+ md5: 8a86073cf3b343b87d03f41790d8b4e5
+ depends:
+ - ucrt
+ constrains:
+ - pthreads-win32 <0.0a0
+ - msys2-conda-epoch <0.0a0
+ license: MIT AND BSD-3-Clause-Clear
+ purls: []
+ run_exports: {}
+ size: 36621
+ timestamp: 1759768399557
+- conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-16-2.15.4-h3cfd58e_1.conda
+ sha256: 819c4edd7b7bcfa9461aeccb56c0a7e9fa7c251163333e64222c142499822958
+ md5: 69b4a6c50e1cb5865648e039da8e5c12
+ depends:
+ - icu >=78.3,<79.0a0
+ - libiconv >=1.18,<2.0a0
+ - liblzma >=5.8.3,<6.0a0
+ - libzlib >=1.3.2,<2.0a0
+ - ucrt >=10.0.20348.0
+ - vc >=14.3,<15
+ - vc14_runtime >=14.44.35208
+ constrains:
+ - libxml2 2.15.4
+ license: MIT
+ license_family: MIT
+ purls: []
+ run_exports: {}
+ size: 515690
+ timestamp: 1791465669912
+- conda: https://conda.anaconda.org/conda-forge/win-64/libxml2-2.15.4-he095d88_1.conda
+ sha256: 81e8cace5c3cb62c4125882b4a6df33ab48c16349231d8ea5008bbe0ee93c10f
+ md5: babe0e64f176114f880f366eba9094af
+ depends:
+ - icu >=78.3,<79.0a0
+ - libiconv >=1.18,<2.0a0
+ - liblzma >=5.8.3,<6.0a0
+ - libxml2-16 2.15.4 h3cfd58e_1
+ - libzlib >=1.3.2,<2.0a0
+ - ucrt >=10.0.20348.0
+ - vc >=14.3,<15
+ - vc14_runtime >=14.44.35208
+ license: MIT
+ license_family: MIT
+ purls: []
+ run_exports:
+ weak:
+ - libxml2
+ - libxml2-16 >=2.15.4
+ size: 43629
+ timestamp: 1791465677280
- conda: https://conda.anaconda.org/conda-forge/win-64/libzlib-1.3.2-hfd05255_3.conda
sha256: 0629c2cc0404d3bb29d6baa7b4ba62da80797015e86de050db81ea5a07050527
md5: 5d2ff29d465097458cc3ff6569151991
@@ -7218,6 +7986,60 @@ packages:
- libzlib >=1.3.2,<2.0a0
size: 58529
timestamp: 1785276664143
+- conda: https://conda.anaconda.org/conda-forge/win-64/llvm-openmp-23.1.4-h49e36cd_0.conda
+ sha256: a3012ff7e6ec5e0a0a19ea8df76a504f3c2bea2d5b12a94f8aa5a50ac9eeab3c
+ md5: 2674733d6b58428f90f7afb0132d4202
+ depends:
+ - ucrt >=10.0.20348.0
+ - vc >=14.3,<15
+ - vc14_runtime >=14.44.35208
+ constrains:
+ - intel-openmp <0.0a0
+ - openmp 23.1.4|23.1.4.*
+ license: Apache-2.0 WITH LLVM-exception
+ license_family: APACHE
+ purls: []
+ run_exports:
+ strong:
+ - llvm-openmp >=23.1.4
+ size: 344047
+ timestamp: 1792567436843
+- conda: https://conda.anaconda.org/conda-forge/win-64/llvmlite-0.50.0-py312h1534abe_0.conda
+ sha256: c23e3ecd9f4a91349c408584ec57447afe5d85a8305f7fa3f4629dfdc6883171
+ md5: a6f0a7c583b0ba99d9a0b8c3f2e01a0a
+ depends:
+ - python
+ - vc >=14.3,<15
+ - vc14_runtime >=14.44.35208
+ - ucrt >=10.0.20348.0
+ - libzlib >=1.3.2,<2.0a0
+ - zstd >=1.5.7,<1.6.0a0
+ - python_abi 3.12.* *_cp312
+ license: BSD-2-Clause
+ license_family: BSD
+ purls:
+ - pkg:pypi/llvmlite?source=compressed-mapping
+ run_exports: {}
+ size: 28572436
+ timestamp: 1790753653114
+- conda: https://conda.anaconda.org/conda-forge/win-64/llvmlite-0.50.0-py314h46b4103_0.conda
+ sha256: cf9d273c9cfb994df78e8e18392af3981f63e1fb26ae44b319d03a1da62af0c9
+ md5: 90ee36c84fcf7ac6b40cd2e96e36adf8
+ depends:
+ - python
+ - vc >=14.3,<15
+ - vc14_runtime >=14.44.35208
+ - ucrt >=10.0.20348.0
+ - libzlib >=1.3.2,<2.0a0
+ - python_abi 3.14.* *_cp314
+ - zstd >=1.5.7,<1.6.0a0
+ license: BSD-2-Clause
+ license_family: BSD
+ purls:
+ - pkg:pypi/llvmlite?source=compressed-mapping
+ run_exports: {}
+ size: 28584343
+ timestamp: 1790753652425
- conda: https://conda.anaconda.org/conda-forge/win-64/markupsafe-3.0.4-py312h6fa65f5_1.conda
sha256: fd2cc166414630b22cae346c597162e05d786d9ace0e9932e6339953099c7b96
md5: b52ae8b54a909d9c3de8756651d175f5
@@ -7254,6 +8076,21 @@ packages:
run_exports: {}
size: 30349
timestamp: 1791548984695
+- conda: https://conda.anaconda.org/conda-forge/win-64/mkl-2026.1.0-hac47afa_235.conda
+ sha256: bb8abe071f73765446df62924031e6599577e8ac9003264dc4569e78c0fea16d
+ md5: ace316c48c178d7faa403dee51e30c7b
+ depends:
+ - llvm-openmp >=22.1.8
+ - tbb >=2023.0.0
+ - ucrt >=10.0.20348.0
+ - vc >=14.3,<15
+ - vc14_runtime >=14.44.35208
+ license: LicenseRef-IntelSimplifiedSoftwareOct2022
+ license_family: Proprietary
+ purls: []
+ run_exports: {}
+ size: 114686732
+ timestamp: 1787725739779
- conda: https://conda.anaconda.org/conda-forge/win-64/msgspec-0.22.0-py312he06e257_0.conda
sha256: baf8763cafee1a5d82e6bac1f60aeedf96427e8fbb855fd34ed922b407772db8
md5: e2c28bfd68de7e08afadf16b3a072ce3
@@ -7297,6 +8134,104 @@ packages:
- nodejs >=26.10.0,<27.0a0
size: 34521075
timestamp: 1790238384888
+- conda: https://conda.anaconda.org/conda-forge/win-64/numba-0.68.0-py312ha1a9051_1.conda
+ sha256: fbad7ed7b745de3519f7686e5cfc85a38ffac8a759af21072e576193f562ad02
+ md5: 7a72e8ced3b2b8c31650d672d5fdfdf5
+ depends:
+ - python
+ - llvmlite >=0.50.0,<0.51.0a0
+ - numpy >=1.22.3,<2.6
+ - vc >=14.3,<15
+ - vc14_runtime >=14.44.35208
+ - ucrt >=10.0.20348.0
+ - numpy >=1.25,<3
+ - python_abi 3.12.* *_cp312
+ constrains:
+ - tbb >=2021.6.0
+ - libopenblas !=0.3.6
+ - cuda-version >=11.2
+ - cudatoolkit >=11.2
+ - scipy >=1.0
+ - cuda-python >=11.6
+ license: BSD-2-Clause
+ license_family: BSD
+ purls:
+ - pkg:pypi/numba?source=compressed-mapping
+ run_exports: {}
+ size: 6122250
+ timestamp: 1790835904969
+- conda: https://conda.anaconda.org/conda-forge/win-64/numba-0.68.0-py314hb98de8c_1.conda
+ sha256: 6eee8860991f863245c15e0c0fb8778da6d9d5115a350013762df334bd5e94cf
+ md5: 574bac38bede29d3feec1a7cd331d4fe
+ depends:
+ - python
+ - llvmlite >=0.50.0,<0.51.0a0
+ - numpy >=1.22.3,<2.6
+ - vc >=14.3,<15
+ - vc14_runtime >=14.44.35208
+ - ucrt >=10.0.20348.0
+ - python_abi 3.14.* *_cp314
+ - numpy >=1.25,<3
+ constrains:
+ - tbb >=2021.6.0
+ - libopenblas !=0.3.6
+ - cuda-version >=11.2
+ - cudatoolkit >=11.2
+ - scipy >=1.0
+ - cuda-python >=11.6
+ license: BSD-2-Clause
+ license_family: BSD
+ purls:
+ - pkg:pypi/numba?source=compressed-mapping
+ run_exports: {}
+ size: 6235764
+ timestamp: 1790835894049
+- conda: https://conda.anaconda.org/conda-forge/win-64/numpy-2.5.4-py312ha3f287d_0.conda
+ sha256: 38aa0c5e5c4a7b263ea03b39f5f26fd038517571a13bcd4b9cbdef3051db5ed2
+ md5: 035814b95806c49d7649b166542678a0
+ depends:
+ - python
+ - vc >=14.3,<15
+ - vc14_runtime >=14.44.35208
+ - ucrt >=10.0.20348.0
+ - python_abi 3.12.* *_cp312
+ - libcblas >=3.9.0,<4.0a0
+ - libblas >=3.9.0,<4.0a0
+ - liblapack >=3.9.0,<4.0a0
+ constrains:
+ - numpy-base <0a0
+ license: BSD-3-Clause
+ license_family: BSD
+ purls:
+ - pkg:pypi/numpy?source=compressed-mapping
+ run_exports:
+ weak:
+ - numpy >=1.25,<3
+ size: 7377160
+ timestamp: 1791675323865
+- conda: https://conda.anaconda.org/conda-forge/win-64/numpy-2.5.4-py314h02f10f6_0.conda
+ sha256: 5cc69b49cd24fa973d87677196d41f848f7ef5c70fb7fc882bc86bb764939142
+ md5: e99f4fea78070ba4e44ab2d5e31fa7d0
+ depends:
+ - python
+ - vc >=14.3,<15
+ - vc14_runtime >=14.44.35208
+ - ucrt >=10.0.20348.0
+ - liblapack >=3.9.0,<4.0a0
+ - libcblas >=3.9.0,<4.0a0
+ - python_abi 3.14.* *_cp314
+ - libblas >=3.9.0,<4.0a0
+ constrains:
+ - numpy-base <0a0
+ license: BSD-3-Clause
+ license_family: BSD
+ purls:
+ - pkg:pypi/numpy?source=compressed-mapping
+ run_exports:
+ weak:
+ - numpy >=1.25,<3
+ size: 7525448
+ timestamp: 1791675315118
- conda: https://conda.anaconda.org/conda-forge/win-64/openssl-3.6.5-hf411b9b_0.conda
sha256: bc44625e0c6d83f3e02b5f6adc01e0e3ca7001d3d94e24959591a57f5a011b9a
md5: f002ec2237836867bfd3d1299a6005da
@@ -7556,6 +8491,20 @@ packages:
run_exports: {}
size: 221750
timestamp: 1791202478076
+- conda: https://conda.anaconda.org/conda-forge/win-64/tbb-2023.1.0-h272cf33_2.conda
+ sha256: 65f5a52eb5250ca0d627e6958e703583dca008988695284fc2181b3a0a790efd
+ md5: 83371bd3f796b3c2d3f0436692d3ea73
+ depends:
+ - libhwloc >=2.15.0,<2.15.1.0a0
+ - ucrt >=10.0.20348.0
+ - vc >=14.3,<15
+ - vc14_runtime >=14.44.35208
+ license: Apache-2.0
+ license_family: APACHE
+ purls: []
+ run_exports: {}
+ size: 157572
+ timestamp: 1790591966816
- conda: https://conda.anaconda.org/conda-forge/win-64/tk-8.6.13-h967ab96_4.conda
sha256: f19618a3a82cc483dacf1c70a30367ee7b0c7e71b90f1f80e14feff44fbd3688
md5: dd9eb33c99d352b6356f86964d6040f7
@@ -7775,6 +8724,7 @@ packages:
- nbmake ; extra == 'dev'
- nbqa ; extra == 'dev'
- nbstripout ; extra == 'dev'
+ - numba ; extra == 'dev'
- pre-commit ; extra == 'dev'
- pydoclint ; extra == 'dev'
- pytest ; extra == 'dev'
@@ -7786,6 +8736,7 @@ packages:
- spdx-headers ; extra == 'dev'
- validate-pyproject[all] ; extra == 'dev'
- versioningit ; extra == 'dev'
+ - numba ; extra == 'fast'
requires_python: '>=3.12'
- pypi: https://files.pythonhosted.org/packages/03/c1/0976b235cf29ead553e22f2fb6385a8252b533715e00d0ae52ed7b900582/h5py-3.16.0-cp312-cp312-win_amd64.whl
name: h5py
@@ -7889,11 +8840,6 @@ packages:
version: 1.9.0
sha256: ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc
requires_python: '>=3.9'
-- pypi: https://files.pythonhosted.org/packages/12/33/fefe62073dc8acfd0f2b9ed7c003af2f50aa61555e113e6db02b8f79f145/numpy-2.5.4-cp312-cp312-win_amd64.whl
- name: numpy
- version: 2.5.4
- sha256: a7b1b6353e36a7e50de2973a38d705c88ee93adcf120673cee7f45a4a3fa223a
- requires_python: '>=3.12'
- pypi: https://files.pythonhosted.org/packages/12/b3/88c0ef22878c86035f058df0ac6c171319ffd0aa52a406455ed3a3847566/ipympl-0.10.0-py3-none-any.whl
name: ipympl
version: 0.10.0
@@ -8456,11 +9402,6 @@ packages:
name: trove-classifiers
version: 2026.9.21.13
sha256: 8b1ff4f9c191b1040b71c37f1e445ab99732911e3cd91de52838453a854d7a17
-- pypi: https://files.pythonhosted.org/packages/31/2f/5ea3570fcb8ccd0882bea99436a513b2c85dad8f774a2057849130a8fb99/numpy-2.5.4-cp312-cp312-macosx_14_0_arm64.whl
- name: numpy
- version: 2.5.4
- sha256: 9cb18a327b49c5c337f972b03682f6a49855525faaf3c0d3e9c96cd0fd8880a8
- requires_python: '>=3.12'
- pypi: https://files.pythonhosted.org/packages/33/ef/6d27fc118f58cb24886da413545a7efb0853d405fddbfd8b2d9ac09fbed4/jupyterlab_widgets-3.0.17-py3-none-any.whl
name: jupyterlab-widgets
version: 3.0.17
@@ -8750,11 +9691,6 @@ packages:
version: 1.7.0
sha256: 7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec
requires_python: '>=3.10'
-- pypi: https://files.pythonhosted.org/packages/43/a3/c799c62e19c337e6d3770b08e475887fb30ce8477d3c09efca6b2f0228a6/numpy-2.5.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl
- name: numpy
- version: 2.5.4
- sha256: fbde6962867ee75b48b0ee29b2b9372ec5d617799dbaf38e82dc0596f2f7738a
- requires_python: '>=3.12'
- pypi: https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl
name: mpmath
version: 1.3.0
diff --git a/pixi.toml b/pixi.toml
index 406b2019..c4fd0670 100644
--- a/pixi.toml
+++ b/pixi.toml
@@ -48,6 +48,7 @@ nodejs = '*' # Required for Prettier (non-Python formatting)
jupyterlab = '*' # Jupyter notebooks
ipython = '*' # Interactive Python shell
pixi-kernel = '*' # Pixi Jupyter kernel
+numba = '*' # Optional fast spherical Bessel path (pip extra 'fast')
[feature.dev.pypi-dependencies]
pip = '*'
diff --git a/pyproject.toml b/pyproject.toml
index 7f1f68d8..765c3347 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -75,6 +75,10 @@ dev = [
'mkdocstrings-python', # MkDocs: Python docstring support
'pyyaml', # YAML parser
'spdx-headers', # SPDX license header validation
+ 'numba', # Tests the fast spherical Bessel path (easydynamics.utils.spherical_bessel)
+]
+fast = [
+ 'numba', # Faster spherical Bessel functions; without it scipy.special is used (same values)
]
[project.urls]
diff --git a/src/easydynamics/__init__.py b/src/easydynamics/__init__.py
index 8984f977..05df4f9d 100644
--- a/src/easydynamics/__init__.py
+++ b/src/easydynamics/__init__.py
@@ -31,10 +31,16 @@
from easydynamics.sample_model import DeltaFunction
from easydynamics.sample_model import DeltaLorentz
from easydynamics.sample_model import DiffusionDampedMittagLeffler
+from easydynamics.sample_model import DiffusionInSphere
+from easydynamics.sample_model import EquivalentSitesCircle
from easydynamics.sample_model import Exponential
from easydynamics.sample_model import ExpressionComponent
from easydynamics.sample_model import Gaussian
+from easydynamics.sample_model import GaussianModel3D
+from easydynamics.sample_model import HallRossJumpDiffusion
from easydynamics.sample_model import InstrumentModel
+from easydynamics.sample_model import IsotropicRotationalDiffusion
+from easydynamics.sample_model import JumpSitesLogNormalDistribution
from easydynamics.sample_model import JumpTranslationalDiffusion
from easydynamics.sample_model import Lorentzian
from easydynamics.sample_model import MittagLefflerDiffusion
@@ -68,14 +74,20 @@
'DeltaLorentz',
'DetailedBalanceSettings',
'DiffusionDampedMittagLeffler',
+ 'DiffusionInSphere',
'EasyDynamicsBase',
'EasyDynamicsModelBase',
+ 'EquivalentSitesCircle',
'Experiment',
'Exponential',
'ExpressionComponent',
'FitBinding',
'Gaussian',
+ 'GaussianModel3D',
+ 'HallRossJumpDiffusion',
'InstrumentModel',
+ 'IsotropicRotationalDiffusion',
+ 'JumpSitesLogNormalDistribution',
'JumpTranslationalDiffusion',
'Lorentzian',
'MittagLefflerDiffusion',
diff --git a/src/easydynamics/sample_model/__init__.py b/src/easydynamics/sample_model/__init__.py
index 384ba507..76d86805 100644
--- a/src/easydynamics/sample_model/__init__.py
+++ b/src/easydynamics/sample_model/__init__.py
@@ -22,6 +22,18 @@
BrownianTranslationalDiffusion,
)
from easydynamics.sample_model.diffusion_model.delta_lorentz import DeltaLorentz
+from easydynamics.sample_model.diffusion_model.diffusion_in_sphere import DiffusionInSphere
+from easydynamics.sample_model.diffusion_model.equivalent_sites_circle import EquivalentSitesCircle
+from easydynamics.sample_model.diffusion_model.gaussian_model_3d import GaussianModel3D
+from easydynamics.sample_model.diffusion_model.hall_ross_jump_diffusion import (
+ HallRossJumpDiffusion,
+)
+from easydynamics.sample_model.diffusion_model.isotropic_rotational_diffusion import (
+ IsotropicRotationalDiffusion,
+)
+from easydynamics.sample_model.diffusion_model.jump_sites_log_normal_distribution import (
+ JumpSitesLogNormalDistribution,
+)
from easydynamics.sample_model.diffusion_model.jump_translational_diffusion import (
JumpTranslationalDiffusion,
)
@@ -41,10 +53,16 @@
'DeltaFunction',
'DeltaLorentz',
'DiffusionDampedMittagLeffler',
+ 'DiffusionInSphere',
+ 'EquivalentSitesCircle',
'Exponential',
'ExpressionComponent',
'Gaussian',
+ 'GaussianModel3D',
+ 'HallRossJumpDiffusion',
'InstrumentModel',
+ 'IsotropicRotationalDiffusion',
+ 'JumpSitesLogNormalDistribution',
'JumpTranslationalDiffusion',
'Lorentzian',
'MittagLefflerDiffusion',
diff --git a/src/easydynamics/sample_model/diffusion_model/__init__.py b/src/easydynamics/sample_model/diffusion_model/__init__.py
index 2e92cfcd..36cd92b3 100644
--- a/src/easydynamics/sample_model/diffusion_model/__init__.py
+++ b/src/easydynamics/sample_model/diffusion_model/__init__.py
@@ -8,6 +8,18 @@
ChudleyElliottJumpDiffusion,
)
from easydynamics.sample_model.diffusion_model.delta_lorentz import DeltaLorentz
+from easydynamics.sample_model.diffusion_model.diffusion_in_sphere import DiffusionInSphere
+from easydynamics.sample_model.diffusion_model.equivalent_sites_circle import EquivalentSitesCircle
+from easydynamics.sample_model.diffusion_model.gaussian_model_3d import GaussianModel3D
+from easydynamics.sample_model.diffusion_model.hall_ross_jump_diffusion import (
+ HallRossJumpDiffusion,
+)
+from easydynamics.sample_model.diffusion_model.isotropic_rotational_diffusion import (
+ IsotropicRotationalDiffusion,
+)
+from easydynamics.sample_model.diffusion_model.jump_sites_log_normal_distribution import (
+ JumpSitesLogNormalDistribution,
+)
from easydynamics.sample_model.diffusion_model.jump_translational_diffusion import (
JumpTranslationalDiffusion,
)
@@ -19,6 +31,12 @@
'BrownianTranslationalDiffusion',
'ChudleyElliottJumpDiffusion',
'DeltaLorentz',
+ 'DiffusionInSphere',
+ 'EquivalentSitesCircle',
+ 'GaussianModel3D',
+ 'HallRossJumpDiffusion',
+ 'IsotropicRotationalDiffusion',
+ 'JumpSitesLogNormalDistribution',
'JumpTranslationalDiffusion',
'MittagLefflerDiffusion',
]
diff --git a/src/easydynamics/sample_model/diffusion_model/delta_lorentzian_diffusion_model_base.py b/src/easydynamics/sample_model/diffusion_model/delta_lorentzian_diffusion_model_base.py
new file mode 100644
index 00000000..9ba160f4
--- /dev/null
+++ b/src/easydynamics/sample_model/diffusion_model/delta_lorentzian_diffusion_model_base.py
@@ -0,0 +1,552 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+
+from abc import ABCMeta
+from abc import abstractmethod
+
+import numpy as np
+import scipp as sc
+from easyscience.variable import DescriptorNumber
+from easyscience.variable import Parameter
+
+from easydynamics.sample_model.component_collection import ComponentCollection
+from easydynamics.sample_model.components import DeltaFunction
+from easydynamics.sample_model.components import Lorentzian
+from easydynamics.sample_model.diffusion_model.diffusion_model_base import DiffusionModelBase
+from easydynamics.utils.fit_target import FitTarget
+from easydynamics.utils.utils import CANONICAL_Q_UNIT
+from easydynamics.utils.utils import Numeric
+from easydynamics.utils.utils import Q_type
+
+
+class DeltaLorentzianDiffusionModelBase(DiffusionModelBase, metaclass=ABCMeta):
+ r"""
+ Base class for diffusion models made of one delta function and n Lorentzians per Q-value.
+
+ The model is
+
+ $$ S(Q, E) = K \left[ A_0(Q)\,\delta(E) + \sum_{j=1}^{n} A_j(Q)\, L(E, \Gamma_j(Q)) \right] $$
+
+ where $K$ is the scale factor, $A_0(Q)$ is the Elastic Incoherent Structure Factor (EISF),
+ $A_j(Q)$ is the Quasi-Elastic Incoherent Structure Factor (QISF) of order $j$, and $L(E,
+ \Gamma)$ is a Lorentzian with half width at half maximum $\Gamma$. The total QISF is
+ $\sum_{j=1}^{n} A_j(Q)$, and EISF + QISF = 1 unless the model truncates an infinite sum. $Q$ is
+ assumed to have units of 1/angstrom. Creates ComponentCollections with one delta function and n
+ Lorentzian components for given Q-values. Their areas and widths are dependent Parameters, so
+ they follow the model parameters during a fit.
+
+ The base class holds no physics. A subclass supplies $\Gamma_j$ through ``calculate_width``,
+ $A_0$ through ``calculate_EISF``, $A_j$ through ``calculate_QISF_order``, the dependent
+ Parameter of $A_j$ through ``_create_weight`` (order 0 is the EISF), and the dependent width
+ through ``_write_width_dependency_expression`` and ``_write_width_dependency_map_expression``.
+ It calls ``self.create_component_collections()`` at the end of its ``__init__``, after its
+ parameters exist.
+
+ The Lorentzian of order $j$ is named ``'_'``, where the symbol is
+ the class attribute ``_order_symbol``, the conventional symbol of the subclass's order (``'l'``
+ by default). ``get_fit_targets`` provides ``'elastic_area'`` and, for every order,
+ ``'area_'`` and ``'width_'``.
+
+ See Also
+ --------
+ EquivalentSitesCircle : Jumps between N equivalent sites on a circle.
+ JumpSitesLogNormalDistribution : The same with a log-normal distribution of jump rates.
+ GaussianModel3D : Diffusion in a three-dimensional harmonic confinement.
+
+ Examples
+ --------
+ **Using a model built on this base class**
+
+ The base class is abstract. A subclass, here EquivalentSitesCircle with N = 3 sites, has N - 1
+ = 2 Lorentzians per Q-value:
+ ```python
+ import numpy as np
+ import easydynamics as edyn
+
+ model = edyn.EquivalentSitesCircle(
+ radius=1.0,
+ residence_time=2.0,
+ number_of_sites=3,
+ Q=np.linspace(0.5, 2, 7),
+ )
+ model.number_of_lorentzians # 2
+ model.lorentzian_component_name(1) # 'EquivalentSitesCircle_l1'
+ model.calculate_EISF() + model.calculate_QISF() # ones
+ ```
+ """
+
+ _order_symbol: str = 'l'
+
+ def __init__(
+ self,
+ number_of_lorentzians: int,
+ *,
+ name: str,
+ display_name: str | None,
+ scale: Numeric = 1.0,
+ Q: Q_type | None = None,
+ x_unit: str | sc.Unit = 'meV',
+ y_unit: str | sc.Unit = 'dimensionless',
+ lorentzian_name: str | None = None,
+ lorentzian_display_name: str | None = None,
+ delta_name: str = 'Delta function',
+ delta_display_name: str | None = None,
+ unique_name: str | None = None,
+ ) -> None:
+ """
+ Initialize the shared part of a delta + n Lorentzians diffusion model.
+
+ Builds no components; the subclass calls ``create_component_collections`` once its
+ parameters exist.
+
+ Parameters
+ ----------
+ number_of_lorentzians : int
+ Number n of Lorentzians per Q. Must be an integer of at least 1.
+ name : str
+ Name of the diffusion model.
+ display_name : str | None
+ Display name of the diffusion model.
+ scale : Numeric, default=1.0
+ Scale factor for the diffusion model. Must be a non-negative number.
+ Q : Q_type | None, default=None
+ Q values for the model. If None, Q is not set.
+ x_unit : str | sc.Unit, default='meV'
+ Unit of the x-axis (energy/frequency). Must be convertible to meV.
+ y_unit : str | sc.Unit, default='dimensionless'
+ Unit of the model output (intensity). Determines scale.unit = x_unit * y_unit.
+ lorentzian_name : str | None, default=None
+ Base name of the Lorentzian components. If None, it will be set to the name of the
+ diffusion model.
+ lorentzian_display_name : str | None, default=None
+ Base display name of the Lorentzian components. If None, it will be set to
+ lorentzian_name.
+ delta_name : str, default='Delta function'
+ Name of the delta function component.
+ delta_display_name : str | None, default=None
+ Display name of the delta function component. If None, it will be set to delta_name.
+ unique_name : str | None, default=None
+ Unique name of the diffusion model. If None, a unique name will be generated.
+
+ Raises
+ ------
+ TypeError
+ If number_of_lorentzians is not an integer, or if delta_name or delta_display_name is
+ not a string (or None).
+ ValueError
+ If number_of_lorentzians is smaller than 1.
+ """
+ super().__init__(
+ scale=scale,
+ Q=Q,
+ x_unit=x_unit,
+ y_unit=y_unit,
+ name=name,
+ display_name=display_name,
+ lorentzian_name=lorentzian_name,
+ lorentzian_display_name=lorentzian_display_name,
+ unique_name=unique_name,
+ )
+
+ if isinstance(number_of_lorentzians, bool) or not isinstance(
+ number_of_lorentzians, (int, np.integer)
+ ):
+ raise TypeError('number_of_lorentzians must be an integer.')
+ if number_of_lorentzians < 1:
+ raise ValueError('number_of_lorentzians must be at least 1.')
+ self._number_of_lorentzians = int(number_of_lorentzians)
+
+ if not isinstance(delta_name, str):
+ raise TypeError('delta_name must be a string.')
+ if delta_display_name is None:
+ delta_display_name = delta_name
+ if not isinstance(delta_display_name, str):
+ raise TypeError('delta_display_name must be a string or None.')
+ self._delta_name = delta_name
+ self._delta_display_name = delta_display_name
+
+ # ------------------------------------------------------------------
+ # Properties
+ # ------------------------------------------------------------------
+
+ @property
+ def number_of_lorentzians(self) -> int:
+ """
+ Get the number of Lorentzians per Q.
+
+ Returns
+ -------
+ int
+ The number n of Lorentzians. Set at construction.
+ """
+ return self._number_of_lorentzians
+
+ @property
+ def delta_name(self) -> str:
+ """
+ Get the name of the delta function component.
+
+ Returns
+ -------
+ str
+ Name of the delta function component.
+ """
+ return self._delta_name
+
+ @delta_name.setter
+ def delta_name(self, delta_name: str) -> None:
+ """
+ Set the name of the delta function component.
+
+ Parameters
+ ----------
+ delta_name : str
+ The new name for the delta function component.
+
+ Raises
+ ------
+ TypeError
+ If delta_name is not a string.
+ """
+ if not isinstance(delta_name, str):
+ raise TypeError('delta_name must be a string.')
+ self._delta_name = delta_name
+
+ @property
+ def delta_display_name(self) -> str:
+ """
+ Get the display name of the delta function component.
+
+ Returns
+ -------
+ str
+ Display name of the delta function component.
+ """
+ return self._delta_display_name
+
+ @delta_display_name.setter
+ def delta_display_name(self, delta_display_name: str | None) -> None:
+ """
+ Set the display name of the delta function component.
+
+ Parameters
+ ----------
+ delta_display_name : str | None
+ The new display name for the delta function component.
+
+ Raises
+ ------
+ TypeError
+ If delta_display_name is not a string or None.
+ """
+ if not isinstance(delta_display_name, (str, type(None))):
+ raise TypeError('delta_display_name must be a string or None.')
+ self._delta_display_name = delta_display_name
+
+ # ------------------------------------------------------------------
+ # Physics, supplied by the subclass
+ # ------------------------------------------------------------------
+
+ @abstractmethod
+ def calculate_width(self, Q: Q_type | None = None, order: int = 1) -> np.ndarray:
+ r"""
+ Calculate the half-width at half-maximum (HWHM) $\Gamma_j(Q)$ of the Lorentzian of order
+ $j$.
+
+ Parameters
+ ----------
+ Q : Q_type | None, default=None
+ Scattering vector in 1/angstrom.
+ order : int, default=1
+ The order j of the Lorentzian, between 1 and n.
+
+ Returns
+ -------
+ np.ndarray
+ HWHM values in the unit of the model (e.g., meV).
+ """
+
+ @abstractmethod
+ def calculate_EISF(self, Q: Q_type | None = None) -> np.ndarray:
+ """
+ Calculate the Elastic Incoherent Structure Factor (EISF), $A_0(Q)$, the weight of the delta
+ function.
+
+ Parameters
+ ----------
+ Q : Q_type | None, default=None
+ Scattering vector in 1/angstrom.
+
+ Returns
+ -------
+ np.ndarray
+ EISF values (dimensionless).
+ """
+
+ @abstractmethod
+ def calculate_QISF_order(self, Q: Q_type | None = None, order: int = 1) -> np.ndarray:
+ """
+ Calculate the Quasi-Elastic Incoherent Structure Factor (QISF) of one order, $A_j(Q)$, the
+ weight of the Lorentzian of order $j$.
+
+ Parameters
+ ----------
+ Q : Q_type | None, default=None
+ Scattering vector in 1/angstrom.
+ order : int, default=1
+ The order j of the Lorentzian, between 1 and n.
+
+ Returns
+ -------
+ np.ndarray
+ QISF values of that order (dimensionless).
+ """
+
+ @abstractmethod
+ def _create_weight(self, Q: float, order: int) -> Parameter:
+ """
+ Create the EISF (order 0) or the QISF of one order as a dimensionless dependent Parameter.
+
+ Parameters
+ ----------
+ Q : float
+ Scattering vector in 1/angstrom.
+ order : int
+ The order j of the Lorentzian; 0 for the delta function.
+
+ Returns
+ -------
+ Parameter
+ The EISF or QISF, which becomes the weight of the component.
+ """
+
+ @abstractmethod
+ def _write_width_dependency_expression(self, order: int) -> str:
+ """
+ Write the dependency expression for the width of the Lorentzian of a given order.
+
+ Parameters
+ ----------
+ order : int
+ The order of the Lorentzian.
+
+ Returns
+ -------
+ str
+ Dependency expression for the width.
+ """
+
+ @abstractmethod
+ def _write_width_dependency_map_expression(self) -> dict[str, DescriptorNumber]:
+ """
+ Write the dependency map for the width.
+
+ Returns
+ -------
+ dict[str, DescriptorNumber]
+ Dependency map for the width.
+ """
+
+ # ------------------------------------------------------------------
+ # Other methods
+ # ------------------------------------------------------------------
+
+ def lorentzian_component_name(self, order: int) -> str:
+ """
+ Get the name of the Lorentzian component of a given order.
+
+ Parameters
+ ----------
+ order : int
+ The order of the Lorentzian, between 1 and n.
+
+ Returns
+ -------
+ str
+ The component name, '_'.
+ """
+ self._verify_order(order)
+ return f'{self.lorentzian_name}_{self._order_symbol}{order}'
+
+ def create_component_collections(self) -> list[ComponentCollection]:
+ r"""
+ Create ComponentCollections for the model at the current Q values.
+
+ Each collection holds a delta function with area $K A_0(Q)$ (scale times EISF) and n
+ Lorentzians with widths $\Gamma_j(Q)$ from ``calculate_width`` and areas $K A_j(Q)$ (scale
+ times the QISF of order $j$). The created collections are installed on the model, so the
+ returned list is the live one.
+
+ Returns
+ -------
+ list[ComponentCollection]
+ One ComponentCollection per Q value.
+ """
+ if self.Q is None:
+ self._component_collections = []
+ return self._component_collections
+
+ Q = self.Q.values
+ component_collection_list = [None] * len(Q)
+
+ for i, Q_value in enumerate(Q):
+ component_collection_list[i] = ComponentCollection(
+ name=f'{self.name}_Q{Q_value:.2f}',
+ display_name=f'{self.display_name}_Q{Q_value:.2f}',
+ x_unit=self.x_unit,
+ y_unit=self.y_unit,
+ )
+
+ delta_component = DeltaFunction(
+ name=self.delta_name,
+ display_name=self.delta_display_name,
+ x_unit=self.x_unit,
+ y_unit=self.y_unit,
+ )
+ with np.errstate(invalid='ignore'):
+ delta_component.area.make_dependent_on(
+ dependency_expression=self._write_area_dependency_expression(),
+ dependency_map=self._write_area_dependency_map_expression(
+ self._create_weight(float(Q_value), 0)
+ ),
+ )
+ component_collection_list[i].append_component(delta_component)
+
+ for order in range(1, self.number_of_lorentzians + 1):
+ lorentzian_base_display_name = self.lorentzian_display_name or self.lorentzian_name
+ lorentzian_component = Lorentzian(
+ name=self.lorentzian_component_name(order),
+ display_name=f'{lorentzian_base_display_name}_{self._order_symbol}{order}',
+ x_unit=self.x_unit,
+ y_unit=self.y_unit,
+ )
+
+ # easyscience propagates inf bounds through arithmetic, producing inf/inf=nan
+ # as a transient intermediate. Python's min/max ignore nan so the final bounds
+ # are correct; suppress the spurious numpy RuntimeWarning.
+ with np.errstate(invalid='ignore'):
+ lorentzian_component.width.make_dependent_on(
+ dependency_expression=self._write_width_dependency_expression(order),
+ dependency_map=self._write_width_dependency_map_expression(),
+ desired_unit=self.x_unit,
+ )
+ lorentzian_component.area.make_dependent_on(
+ dependency_expression=self._write_area_dependency_expression(),
+ dependency_map=self._write_area_dependency_map_expression(
+ self._create_weight(float(Q_value), order)
+ ),
+ )
+ component_collection_list[i].append_component(lorentzian_component)
+
+ self._component_collections = component_collection_list
+ return self._component_collections
+
+ def get_fit_targets(self) -> list[FitTarget]:
+ """
+ Get the fittable predictions of the model as FitTargets.
+
+ The model predicts ``'elastic_area'`` (``scale * EISF``) and, for every order from 1 to n,
+ ``'area_'`` (``scale`` times the QISF of that order) and ``'width_'`` (its
+ HWHM). The base class implementation of DiffusionModelBase is replaced because there is no
+ single Lorentzian. Select the orders to fit with the ``targets`` of a FitBinding.
+
+ Returns
+ -------
+ list[FitTarget]
+ The fittable predictions of this model.
+ """
+ targets = [
+ FitTarget(
+ name='elastic_area',
+ dataset_key=f'{self.delta_name} area',
+ function=lambda Q, model=self, **_: model.calculate_EISF(Q) * model.scale.value,
+ label=f'{self.display_name} elastic_area',
+ x_unit=CANONICAL_Q_UNIT,
+ y_unit=str(self.scale.unit),
+ )
+ ]
+ for order in range(1, self.number_of_lorentzians + 1):
+ key = self.lorentzian_component_name(order)
+ targets.append(
+ FitTarget(
+ name=f'area_{order}',
+ dataset_key=f'{key} area',
+ function=lambda Q, model=self, order=order, **_: (
+ model.calculate_QISF_order(Q, order) * model.scale.value
+ ),
+ label=f'{self.display_name} area_{order}',
+ x_unit=CANONICAL_Q_UNIT,
+ y_unit=str(self.scale.unit),
+ )
+ )
+ targets.append(
+ FitTarget(
+ name=f'width_{order}',
+ dataset_key=f'{key} width',
+ function=lambda Q, model=self, order=order, **_: model.calculate_width(
+ Q, order
+ ),
+ label=f'{self.display_name} width_{order}',
+ x_unit=CANONICAL_Q_UNIT,
+ y_unit=str(self.x_unit),
+ )
+ )
+ return targets
+
+ # ------------------------------------------------------------------
+ # Private methods
+ # ------------------------------------------------------------------
+
+ def _write_area_dependency_expression(self) -> str:
+ """
+ Write the dependency expression for the area of a component.
+
+ Returns
+ -------
+ str
+ Dependency expression for the area, the scale times the EISF or QISF.
+ """
+ return 'scale * weight'
+
+ def _write_area_dependency_map_expression(
+ self, weight: Parameter
+ ) -> dict[str, DescriptorNumber]:
+ """
+ Write the dependency map for the areas.
+
+ Parameters
+ ----------
+ weight : Parameter
+ The EISF or the QISF of one order, from ``_create_weight`` (dimensionless).
+
+ Returns
+ -------
+ dict[str, DescriptorNumber]
+ Dependency map for the area.
+ """
+ return {'scale': self.scale, 'weight': weight}
+
+ def _verify_order(self, order: int) -> None:
+ """
+ Verify that order is a valid Lorentzian order.
+
+ Parameters
+ ----------
+ order : int
+ The order to verify.
+
+ Raises
+ ------
+ TypeError
+ If order is not an integer.
+ ValueError
+ If order is not between 1 and n.
+ """
+ if isinstance(order, bool) or not isinstance(order, (int, np.integer)):
+ raise TypeError('order must be an integer.')
+ if not 1 <= order <= self.number_of_lorentzians:
+ raise ValueError(f'order must be between 1 and {self.number_of_lorentzians}.')
+
+ def _on_Q_change(self) -> None:
+ """Rebuild the component collections when Q changes."""
+ self.create_component_collections()
diff --git a/src/easydynamics/sample_model/diffusion_model/diffusion_in_sphere.py b/src/easydynamics/sample_model/diffusion_model/diffusion_in_sphere.py
new file mode 100644
index 00000000..ece2587e
--- /dev/null
+++ b/src/easydynamics/sample_model/diffusion_model/diffusion_in_sphere.py
@@ -0,0 +1,699 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+
+import functools
+import warnings
+
+import numpy as np
+import scipp as sc
+from easyscience.variable import DescriptorNumber
+from easyscience.variable import Parameter
+from scipy.optimize import brentq
+from scipy.special import spherical_jn as scipy_spherical_jn
+
+from easydynamics.sample_model.component_collection import ComponentCollection
+from easydynamics.sample_model.diffusion_model.delta_lorentzian_diffusion_model_base import (
+ DeltaLorentzianDiffusionModelBase,
+)
+from easydynamics.sample_model.diffusion_model.spherical_bessel_expression import (
+ spherical_bessel_expression,
+)
+from easydynamics.utils.spherical_bessel import spherical_jn
+from easydynamics.utils.utils import Numeric
+from easydynamics.utils.utils import Q_type
+from easydynamics.utils.utils import angstrom
+from easydynamics.utils.utils import hbar
+
+# The roots x_nl of j_l'(x) = 0 below MAXIMUM_ROOT: the same 98 terms as Mantid's
+# InelasticDiffSphere (initXnlCoeff),
+# https://github.com/mantidproject/mantid/blob/main/Framework/CurveFitting/src/Functions/InelasticDiffSphere.cpp
+# TODO: investigate shipping the roots as a table instead of computing them, and decide. # ruff: ignore[line-contains-todo, missing-todo-author, missing-todo-link]
+MAXIMUM_ROOT = 26.84
+LIMIT_SWITCH = 1e-5 # Within this distance of a root, QR = x_nl, the 0/0 limit is used
+MINIMUM_RADIUS = 1e-10 # In angstrom; a zero radius is unphysical
+MINIMUM_DIFFUSION_COEFFICIENT = 1e-12 # In angstrom^2/ps, to keep the Lorentzian widths finite
+MISSING_WEIGHT_TOLERANCE = 1e-3 # Truncating the sum above this loss of weight raises a warning
+# Default number of terms, far fewer than Mantid's 98: each term adds two dependent Parameters per
+# Q value, and their updates are slow in EasyScience Core 2.5.1. The first 10 terms miss less than
+# 1e-3 of the weight up to QR of about 2 (4.3e-3 at QR = 3).
+# TODO: return to all 98 terms once Core 3.0 makes dependent updates cheap. # ruff: ignore[line-contains-todo, missing-todo-author, missing-todo-link]
+DEFAULT_NUMBER_OF_LORENTZIANS = 10
+
+
+@functools.cache
+def sphere_roots() -> tuple[tuple[float, int, int], ...]:
+ r"""
+ Find the roots $x_{nl}$ of $j_l'(x) = 0$ between 0 and MAXIMUM_ROOT, sorted by x.
+
+ n counts the roots of each l from 0, except for l = 0, whose root x = 0 is the elastic term:
+ there n counts from 1, as in Bicout (2000). Computed once, on first use.
+
+ Returns
+ -------
+ tuple[tuple[float, int, int], ...]
+ (x, l, n) for each root, in increasing x.
+ """
+ grid = np.linspace(1e-6, MAXIMUM_ROOT, 20001)
+ roots = []
+ angular_order = 0
+ while True:
+ derivative = scipy_spherical_jn(angular_order, grid, derivative=True)
+ changes = np.nonzero(np.sign(derivative[:-1]) * np.sign(derivative[1:]) < 0)[0]
+ if changes.size == 0:
+ break
+ first_n = 1 if angular_order == 0 else 0
+ for n, i in enumerate(changes, start=first_n):
+ x = brentq(
+ lambda z, order=angular_order: scipy_spherical_jn(order, z, derivative=True),
+ grid[i],
+ grid[i + 1],
+ xtol=1e-15,
+ rtol=1e-15,
+ )
+ roots.append((float(x), angular_order, n))
+ angular_order += 1
+ return tuple(sorted(roots))
+
+
+@functools.cache
+def _root_arrays() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
+ r"""
+ Per-root constants of the weights, computed once.
+
+ The weight of root x of order l is $(2l+1) \frac{6x^2}{x^2 - l(l+1)} J^2$ with $J(a) = \frac{a
+ j_{l+1}(a) - l j_l(a)}{a^2 - x^2} = \frac{-a j_l'(a)}{a^2 - x^2}$, a = QR. At a = x both
+ numerator and denominator vanish. From the spherical Bessel equation at $j_l'(x) = 0$: $j'' =
+ -(1 - l(l+1)/x^2) j$ and $j''' = -(2/x) j'' - 2l(l+1) j / x^3$, so with $g = -a j_l'$ and $h =
+ a^2 - x^2$: $J(x) = g'/h' = (x^2 - l(l+1)) j_l(x) / (2x^2)$ and $J'(x) = (2x g'' - 2 g') / (2
+ (2x)^2)$, with $g' = -x j''$ and $g'' = -2j'' - x j'''$.
+
+ Returns
+ -------
+ tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]
+ x, l, the coefficient (2l+1) 6x^2 / (x^2 - l(l+1)), J(x) and J'(x), one value per root.
+ """
+ x = np.array([root[0] for root in sphere_roots()])
+ angular_order = np.array([root[1] for root in sphere_roots()])
+ l_term = angular_order * (angular_order + 1)
+ coefficient = (2 * angular_order + 1) * 6 * x**2 / (x**2 - l_term)
+ j = scipy_spherical_jn(angular_order, x)
+ j2 = -(1 - l_term / x**2) * j
+ j3 = -(2 / x) * j2 - 2 * l_term * j / x**3
+ g1 = -x * j2
+ g2 = -2 * j2 - x * j3
+ J0 = g1 / (2 * x)
+ J1 = (2 * x * g2 - 2 * g1) / (2 * (2 * x) ** 2)
+ return x, angular_order, coefficient, J0, J1
+
+
+class DiffusionInSphere(DeltaLorentzianDiffusionModelBase):
+ r"""
+ Model of diffusion inside an impermeable sphere.
+
+ A particle diffuses with diffusion coefficient $D$ inside a sphere of radius $R$ that it cannot
+ leave. The sphere is isotropic, so the scattering depends only on the magnitude of $Q$:
+
+ $$ S(Q, E) = A_0(Q)\,\delta(E) + \sum_{(l, n)} A_{nl}(Q)\, L(E, \Gamma_{nl}) $$
+
+ with
+
+ $$ A_0(Q) = \left(\frac{3 j_1(QR)}{QR}\right)^2,\quad A_{nl}(Q) = (2l+1) \frac{6
+ x_{nl}^2}{x_{nl}^2 - l(l+1)} \left(\frac{QR\, j_{l+1}(QR) - l\, j_l(QR)}{(QR)^2 -
+ x_{nl}^2}\right)^2,\quad \Gamma_{nl} = \frac{\hbar x_{nl}^2 D}{R^2} $$
+
+ where $j_l$ are the spherical Bessel functions, $x_{nl}$ the roots of $j_l'(x) = 0$ (no flux
+ through the surface) and $L(E, \Gamma)$ a Lorentzian with half width at half maximum $\Gamma$.
+ $A_0$ is the EISF and $A_{nl}$ the QISF of the root $x_{nl}$. The widths do not depend on $Q$;
+ only the QISF do. At $QR = x_{nl}$ the QISF is a 0/0, and its exact limit is used there.
+
+ The sum runs over the lowest ``number_of_lorentzians`` of the 98 roots below 26.84, the same
+ roots as reference 3. A warning is raised if the truncation leaves out more than 1e-3 of the
+ weight: with the default 10 terms this happens from QR of about 1.8, with all 98 from QR of
+ about 9. Each term adds two dependent Parameters per Q value, which are slow to update in
+ EasyScience Core 2.5.1, so the default is 10 terms.
+
+ The Lorentzian of order $j$, from 1 to ``number_of_lorentzians``, belongs to the $j$-th root in
+ increasing $x$, and ``roots`` gives its $(x, l, n)$. The radius is free by default, since the
+ size of the sphere is usually what is fitted.
+
+ References
+ ----------
+ 1. F. Volino and A. J. Dianoux, Mol. Phys. 41, 271 (1980), doi:10.1080/00268978000102761.
+ 2. D. J. Bicout, Phys. Rev. E 62, 261 (2000), doi:10.1103/PhysRevE.62.261.
+ 3. Mantid, ``InelasticDiffSphere``, which uses the same 98 roots,
+ https://github.com/mantidproject/mantid/blob/6ec66a6f524a7ec9d4258351fe3001c66c63dbe5/Framework/CurveFitting/src/Functions/InelasticDiffSphere.cpp#L53-L69.
+
+ Examples
+ --------
+ **Creating a DiffusionInSphere model**
+
+ The model creates one delta function and 10 Lorentzians per Q-value by default:
+ ```python
+ import numpy as np
+ import easydynamics as edyn
+
+ model = edyn.DiffusionInSphere(
+ radius=1.0, # angstrom
+ diffusion_coefficient=0.1, # angstrom^2/ps
+ Q=np.linspace(0.3, 1.5, 7), # QR up to 1.5, below the truncation warning
+ )
+ ```
+ """
+
+ _order_symbol = 'j' # components are named '_j'
+
+ def __init__(
+ self,
+ scale: Numeric = 1.0,
+ radius: Numeric = 1.0,
+ diffusion_coefficient: Numeric = 1.0,
+ number_of_lorentzians: int = DEFAULT_NUMBER_OF_LORENTZIANS,
+ Q: Q_type | None = None,
+ x_unit: str | sc.Unit = 'meV',
+ y_unit: str | sc.Unit = 'dimensionless',
+ name: str = 'DiffusionInSphere',
+ display_name: str | None = 'DiffusionInSphere',
+ lorentzian_name: str | None = None,
+ lorentzian_display_name: str | None = None,
+ delta_name: str = 'Delta function',
+ delta_display_name: str | None = None,
+ unique_name: str | None = None,
+ ) -> None:
+ """
+ Initialize a new DiffusionInSphere model.
+
+ A radius or diffusion coefficient that is not a number raises TypeError, one below its
+ minimum raises ValueError, as do invalid delta names (from the base class). A
+ number_of_lorentzians that is not an integer raises TypeError, one below 1 or above 98
+ ValueError.
+
+ Parameters
+ ----------
+ scale : Numeric, default=1.0
+ Scale factor for the diffusion model. Must be a non-negative number.
+ radius : Numeric, default=1.0
+ Radius R of the sphere in angstrom. Must be positive. Free by default.
+ diffusion_coefficient : Numeric, default=1.0
+ Diffusion coefficient D in angstrom^2/ps. Must be positive.
+ number_of_lorentzians : int, default=DEFAULT_NUMBER_OF_LORENTZIANS
+ Number of terms in the sum, the lowest roots first; at most 98, the roots below 26.84.
+ Fewer terms make fits faster; the model warns if they omit too much weight.
+ Q : Q_type | None, default=None
+ Q values for the model. If None, Q is not set.
+ x_unit : str | sc.Unit, default='meV'
+ Unit of the x-axis (energy/frequency). Must be convertible to meV.
+ y_unit : str | sc.Unit, default='dimensionless'
+ Unit of the model output (intensity). Determines scale.unit = x_unit * y_unit.
+ name : str, default='DiffusionInSphere'
+ Name of the diffusion model.
+ display_name : str | None, default='DiffusionInSphere'
+ Display name of the diffusion model.
+ lorentzian_name : str | None, default=None
+ Base name of the Lorentzian components. The Lorentzian of order j is named
+ '_j'. If None, it will be set to the name of the diffusion model.
+ lorentzian_display_name : str | None, default=None
+ Base display name of the Lorentzian components. If None, it will be set to
+ lorentzian_name.
+ delta_name : str, default='Delta function'
+ Name of the delta function component.
+ delta_display_name : str | None, default=None
+ Display name of the delta function component. If None, it will be set to delta_name.
+ unique_name : str | None, default=None
+ Unique name of the diffusion model. If None, a unique name will be generated.
+
+ Raises
+ ------
+ ValueError
+ If number_of_lorentzians is larger than the number of sphere roots available (98).
+ """
+ self._verify_radius(radius)
+ self._verify_diffusion_coefficient(diffusion_coefficient)
+
+ super().__init__(
+ number_of_lorentzians,
+ scale=scale,
+ Q=Q,
+ x_unit=x_unit,
+ y_unit=y_unit,
+ name=name,
+ display_name=display_name,
+ lorentzian_name=lorentzian_name,
+ lorentzian_display_name=lorentzian_display_name,
+ delta_name=delta_name,
+ delta_display_name=delta_display_name,
+ unique_name=unique_name,
+ )
+ if self.number_of_lorentzians > len(sphere_roots()):
+ raise ValueError(f'number_of_lorentzians must be at most {len(sphere_roots())}.')
+
+ self._radius = Parameter(
+ name='radius',
+ value=float(radius),
+ fixed=False,
+ min=MINIMUM_RADIUS,
+ unit='angstrom',
+ )
+ self._diffusion_coefficient = Parameter(
+ name='diffusion_coefficient',
+ value=float(diffusion_coefficient),
+ fixed=False,
+ min=MINIMUM_DIFFUSION_COEFFICIENT,
+ unit='angstrom**2/ps',
+ )
+
+ self._hbar = hbar
+ # Dimensionless one, which turns the plain numbers of the weight expressions into
+ # (dimensionless) descriptors, as dependency expressions must evaluate to one.
+ self._one = DescriptorNumber(name='one', value=1.0)
+
+ # Dimensionless radius/angstrom for the dependency expressions. Deriving it through the
+ # dependency graph keeps the weights correct if the radius is converted to another unit.
+ with np.errstate(invalid='ignore'):
+ self._radius_over_angstrom = Parameter.from_dependency(
+ name='radius_over_angstrom',
+ dependency_expression='radius / angstrom',
+ dependency_map={'radius': self._radius, 'angstrom': angstrom},
+ )
+ self._radius_over_angstrom.set_desired_unit('dimensionless')
+
+ self.create_component_collections()
+
+ # ------------------------------------------------------------------
+ # Properties
+ # ------------------------------------------------------------------
+
+ @property
+ def radius(self) -> Parameter:
+ """
+ Get the radius of the sphere.
+
+ Returns
+ -------
+ Parameter
+ Radius R.
+ """
+ return self._radius
+
+ @radius.setter
+ def radius(self, radius: Numeric) -> None:
+ """
+ Set the radius of the sphere.
+
+ Parameters
+ ----------
+ radius : Numeric
+ Radius R in the unit of the radius parameter (angstrom by default).
+ """
+ self._verify_radius(radius)
+ self._radius.value = float(radius)
+
+ @property
+ def diffusion_coefficient(self) -> Parameter:
+ """
+ Get the diffusion coefficient.
+
+ Returns
+ -------
+ Parameter
+ Diffusion coefficient D.
+ """
+ return self._diffusion_coefficient
+
+ @diffusion_coefficient.setter
+ def diffusion_coefficient(self, diffusion_coefficient: Numeric) -> None:
+ """
+ Set the diffusion coefficient.
+
+ Parameters
+ ----------
+ diffusion_coefficient : Numeric
+ Diffusion coefficient D in the unit of the parameter (angstrom^2/ps by default).
+ """
+ self._verify_diffusion_coefficient(diffusion_coefficient)
+ self._diffusion_coefficient.value = float(diffusion_coefficient)
+
+ @property
+ def roots(self) -> tuple[tuple[float, int, int], ...]:
+ r"""
+ Get the roots $x_{nl}$ of the Lorentzians, in the order of the components.
+
+ Returns
+ -------
+ tuple[tuple[float, int, int], ...]
+ (x, l, n) of the Lorentzian of order j at index j - 1.
+ """
+ return sphere_roots()[: self.number_of_lorentzians]
+
+ # ------------------------------------------------------------------
+ # Other methods
+ # ------------------------------------------------------------------
+
+ def calculate_width(self, Q: Q_type | None = None, order: int = 1) -> np.ndarray:
+ r"""
+ Calculate the half-width at half-maximum (HWHM) $\Gamma_{nl}$ of the Lorentzian of order
+ $j$.
+
+ The width is $\hbar x_{nl}^2 D / R^2$ and does not depend on Q.
+
+ Parameters
+ ----------
+ Q : Q_type | None, default=None
+ Scattering vector in 1/angstrom.
+ order : int, default=1
+ The order j of the Lorentzian, between 1 and number_of_lorentzians.
+
+ Returns
+ -------
+ np.ndarray
+ HWHM values in the unit of the model (e.g., meV).
+ """
+ self._verify_order(order)
+ Q = self._ensure_Q(Q)
+ radius = sc.values(self.radius.full_value)
+ rate = self._hbar.full_value * sc.values(self.diffusion_coefficient.full_value) / radius**2
+ x = sphere_roots()[int(order) - 1][0]
+ return rate.to(unit=self.x_unit).value * x**2 * np.ones_like(Q)
+
+ def calculate_EISF(self, Q: Q_type | None = None) -> np.ndarray:
+ r"""
+ Calculate the Elastic Incoherent Structure Factor (EISF), $(3 j_1(QR) / QR)^2$.
+
+ Parameters
+ ----------
+ Q : Q_type | None, default=None
+ Scattering vector in 1/angstrom.
+
+ Returns
+ -------
+ np.ndarray
+ EISF values (dimensionless).
+ """
+ QR = self._ensure_Q(Q) * self._radius_in_angstrom()
+ with np.errstate(divide='ignore', invalid='ignore'):
+ EISF = (3 * spherical_jn(1, QR) / QR) ** 2
+ return np.where(QR == 0, 1.0, EISF)
+
+ def calculate_QISF_order(self, Q: Q_type | None = None, order: int = 1) -> np.ndarray:
+ r"""
+ Calculate the Quasi-Elastic Incoherent Structure Factor (QISF) of one order, $A_{nl}(Q)$,
+ the weight of the Lorentzian of order $j$ (root $x_{nl}$).
+
+ Parameters
+ ----------
+ Q : Q_type | None, default=None
+ Scattering vector in 1/angstrom.
+ order : int, default=1
+ The order j of the Lorentzian, between 1 and number_of_lorentzians.
+
+ Returns
+ -------
+ np.ndarray
+ QISF values of that order (dimensionless).
+ """
+ self._verify_order(order)
+ Q = self._ensure_Q(Q)
+ return self._weights(Q * self._radius_in_angstrom())[:, int(order) - 1]
+
+ def calculate_QISF(self, Q: Q_type | None = None) -> np.ndarray:
+ """
+ Calculate the total Quasi-Elastic Incoherent Structure Factor (QISF), the sum of the QISF
+ of all number_of_lorentzians orders.
+
+ Parameters
+ ----------
+ Q : Q_type | None, default=None
+ Scattering vector in 1/angstrom.
+
+ Returns
+ -------
+ np.ndarray
+ QISF values (dimensionless).
+ """
+ Q = self._ensure_Q(Q)
+ weights = self._weights(Q * self._radius_in_angstrom())
+ return np.sum(weights[:, : self.number_of_lorentzians], axis=1)
+
+ def calculate_missing_weight(self, Q: Q_type | None = None) -> np.ndarray:
+ """
+ Calculate the spectral weight lost by truncating the sum at number_of_lorentzians terms.
+
+ Parameters
+ ----------
+ Q : Q_type | None, default=None
+ Scattering vector in 1/angstrom.
+
+ Returns
+ -------
+ np.ndarray
+ 1 - EISF - QISF (dimensionless).
+ """
+ Q = self._ensure_Q(Q)
+ return 1.0 - self.calculate_EISF(Q) - self.calculate_QISF(Q)
+
+ def create_component_collections(self) -> list[ComponentCollection]:
+ r"""
+ Create ComponentCollections for the model at the current Q values.
+
+ Each collection holds a delta function with area $K A_0(Q)$ (scale times EISF) and
+ number_of_lorentzians Lorentzians with widths $\Gamma_{nl}$ and areas $K A_{nl}(Q)$ (scale
+ times the QISF of that order), built by the base class. The sum is truncated, so a warning
+ is raised if it omits more than MISSING_WEIGHT_TOLERANCE of the weight. The created
+ collections are installed on the model, so the returned list is the live one.
+
+ Returns
+ -------
+ list[ComponentCollection]
+ One ComponentCollection per Q value.
+ """
+ collections = super().create_component_collections()
+ self._warn_if_truncated()
+ return collections
+
+ def get_global_variables(self) -> list[Parameter]:
+ """
+ Get all global variables from the diffusion model.
+
+ Returns
+ -------
+ list[Parameter]
+ The scale, radius and diffusion coefficient.
+ """
+ return [self.scale, self.radius, self.diffusion_coefficient]
+
+ # ------------------------------------------------------------------
+ # Private methods
+ # ------------------------------------------------------------------
+
+ def _radius_in_angstrom(self) -> float:
+ """
+ Get the radius in angstrom.
+
+ Returns
+ -------
+ float
+ The radius value in angstrom.
+ """
+ return sc.values(self.radius.full_value).to(unit='angstrom').value
+
+ @staticmethod
+ def _weights(QR: np.ndarray) -> np.ndarray:
+ r"""
+ Calculate the QISF $A_{nl}$ of all 98 roots at once.
+
+ Parameters
+ ----------
+ QR : np.ndarray
+ Q times R, dimensionless, with dims (Q,).
+
+ Returns
+ -------
+ np.ndarray
+ QISF with shape (len(QR), 98), the Lorentzian order j - 1 along the second axis.
+ """
+ x, angular_order, coefficient, J0, J1 = _root_arrays()
+ a = np.asarray(QR, dtype=float)[:, None]
+ orders = np.stack([angular_order, angular_order + 1])[:, None, :] # j_l, j_(l+1) at once
+ j_l, j_l1 = spherical_jn(orders, np.broadcast_to(a, (2, a.shape[0], x.size)))
+ near = np.abs(a - x) < LIMIT_SWITCH
+ with np.errstate(divide='ignore', invalid='ignore'):
+ direct = (a * j_l1 - angular_order * j_l) / (a**2 - x**2)
+ J = np.where(near, J0 + J1 * (a - x), direct)
+ return coefficient * J**2
+
+ def _create_weight(self, Q: float, order: int) -> Parameter:
+ r"""
+ Create the EISF (order 0) or the QISF of order $j$ as a dimensionless dependent Parameter
+ that follows the radius.
+
+ The dependency expressions cannot call scipy, so the spherical Bessel functions are written
+ out in elementary functions (``spherical_bessel_expression``). Near the root, where the
+ weight is a 0/0, the expression uses the limit and its first-order Taylor term, computed
+ here once. Keeping the weight separate from the scale makes updates of the scale cheap.
+
+ Parameters
+ ----------
+ Q : float
+ Scattering vector in 1/angstrom.
+ order : int
+ The order j; 0 for the delta function (the EISF).
+
+ Raises
+ ------
+ TypeError
+ If Q is not a float.
+
+ Returns
+ -------
+ Parameter
+ The EISF or QISF, which becomes the weight of the component.
+ """
+ if not isinstance(Q, float):
+ raise TypeError('Q must be a float.')
+
+ # x is Q R and y = x**2, the names the Bessel expressions use
+ lines = [f'x = {Q!r} * radius_ratio.value', 'y = x**2']
+ if order == 0:
+ lines += [f'j1 = {spherical_bessel_expression(1)}', 'one * (3 * j1 / x)**2']
+ else:
+ roots, angular_orders, coefficients, limits, slopes = _root_arrays()
+ root = float(roots[order - 1])
+ angular_order = int(angular_orders[order - 1])
+ limit, slope = float(limits[order - 1]), float(slopes[order - 1])
+ direct = f'(x * jl1 - {angular_order} * jl) / (y - {root**2!r})'
+ near = f'({limit!r} + {slope!r} * (x - {root!r}))'
+ lines += [
+ f'jl = {spherical_bessel_expression(angular_order)}',
+ f'jl1 = {spherical_bessel_expression(angular_order + 1)}',
+ f'J = {near} if abs(x - {root!r}) < {LIMIT_SWITCH!r} else {direct}',
+ f'one * {float(coefficients[order - 1])!r} * J**2',
+ ]
+ with np.errstate(invalid='ignore'):
+ return Parameter.from_dependency(
+ name=f'weight_j{order}_Q{Q:.2f}',
+ dependency_expression='\n'.join(lines),
+ dependency_map={'one': self._one, 'radius_ratio': self._radius_over_angstrom},
+ )
+
+ def _write_width_dependency_expression(self, order: int) -> str:
+ """
+ Write the dependency expression for the width of the Lorentzian of a given order.
+
+ The radius enters through its dimensionless value in angstrom, which avoids Core's bound
+ arithmetic dividing two infinite upper bounds (a RuntimeWarning on every update).
+
+ Parameters
+ ----------
+ order : int
+ The order j of the Lorentzian.
+
+ Returns
+ -------
+ str
+ Dependency expression for the width.
+ """
+ x_squared = float(sphere_roots()[order - 1][0] ** 2)
+ return f'{x_squared!r} * hbar * D / angstrom**2 / radius_ratio.value**2'
+
+ def _write_width_dependency_map_expression(self) -> dict[str, DescriptorNumber]:
+ """
+ Write the dependency map for the width.
+
+ Returns
+ -------
+ dict[str, DescriptorNumber]
+ Dependency map for the width.
+ """
+ return {
+ 'hbar': self._hbar,
+ 'D': self.diffusion_coefficient,
+ 'angstrom': angstrom,
+ 'radius_ratio': self._radius_over_angstrom,
+ }
+
+ def _warn_if_truncated(self) -> None:
+ """
+ Warn if truncating the sum at number_of_lorentzians terms omits too much weight.
+
+ The warning is raised if the missing weight exceeds MISSING_WEIGHT_TOLERANCE at any of the
+ model's Q values. Without Q values there is nothing to check.
+ """
+ if self.Q is None:
+ return
+ if np.max(self.calculate_missing_weight(), initial=0.0) > MISSING_WEIGHT_TOLERANCE:
+ warnings.warn(
+ f'Truncating the sum at number_of_lorentzians={self.number_of_lorentzians} omits '
+ f'more than {MISSING_WEIGHT_TOLERANCE} of the spectral weight at some Q values. '
+ f'At most {len(sphere_roots())} terms are available (the roots below '
+ f'{MAXIMUM_ROOT}); with all of them this happens from QR of about 9.',
+ UserWarning,
+ stacklevel=3,
+ )
+
+ @staticmethod
+ def _verify_radius(radius: Numeric) -> None:
+ """
+ Verify a radius value.
+
+ Parameters
+ ----------
+ radius : Numeric
+ The radius to verify.
+
+ Raises
+ ------
+ TypeError
+ If radius is not a number.
+ ValueError
+ If radius is below MINIMUM_RADIUS.
+ """
+ if not isinstance(radius, Numeric):
+ raise TypeError('radius must be a number.')
+ if float(radius) < MINIMUM_RADIUS:
+ raise ValueError(f'radius must be at least {MINIMUM_RADIUS}.')
+
+ @staticmethod
+ def _verify_diffusion_coefficient(diffusion_coefficient: Numeric) -> None:
+ """
+ Verify a diffusion coefficient value.
+
+ Parameters
+ ----------
+ diffusion_coefficient : Numeric
+ The diffusion coefficient to verify.
+
+ Raises
+ ------
+ TypeError
+ If diffusion_coefficient is not a number.
+ ValueError
+ If diffusion_coefficient is below MINIMUM_DIFFUSION_COEFFICIENT.
+ """
+ if not isinstance(diffusion_coefficient, Numeric):
+ raise TypeError('diffusion_coefficient must be a number.')
+ if float(diffusion_coefficient) < MINIMUM_DIFFUSION_COEFFICIENT:
+ raise ValueError(
+ f'diffusion_coefficient must be at least {MINIMUM_DIFFUSION_COEFFICIENT}.'
+ )
+
+ # ------------------------------------------------------------------
+ # dunder methods
+ # ------------------------------------------------------------------
+
+ def __repr__(self) -> str:
+ """
+ String representation of the DiffusionInSphere model.
+
+ Returns
+ -------
+ str
+ String representation of the model.
+ """
+ return (
+ f'{self.__class__.__name__}('
+ f'name={self.name!r}, display_name={self.display_name!r},\n'
+ f'x_unit={self.x_unit}, y_unit={self.y_unit},\n'
+ f' radius={self.radius},\n'
+ f' diffusion_coefficient={self.diffusion_coefficient},\n'
+ f' number_of_lorentzians={self.number_of_lorentzians},\n'
+ f' scale={self.scale})'
+ )
diff --git a/src/easydynamics/sample_model/diffusion_model/equivalent_sites_circle.py b/src/easydynamics/sample_model/diffusion_model/equivalent_sites_circle.py
new file mode 100644
index 00000000..fec7348e
--- /dev/null
+++ b/src/easydynamics/sample_model/diffusion_model/equivalent_sites_circle.py
@@ -0,0 +1,530 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+
+import numpy as np
+import scipp as sc
+from easyscience.variable import DescriptorNumber
+from easyscience.variable import Parameter
+
+from easydynamics.sample_model.diffusion_model.delta_lorentzian_diffusion_model_base import (
+ DeltaLorentzianDiffusionModelBase,
+)
+from easydynamics.utils.utils import Numeric
+from easydynamics.utils.utils import Q_type
+from easydynamics.utils.utils import angstrom
+from easydynamics.utils.utils import hbar
+
+MINIMUM_RADIUS = 1e-10 # A zero radius is unphysical
+MINIMUM_WIDTH = 1e-10 # To avoid division by zero
+MAXIMUM_NUMBER_OF_SITES = 50
+
+
+class EquivalentSitesCircle(DeltaLorentzianDiffusionModelBase):
+ r"""
+ Model of jumps between N equivalent sites on a circle.
+
+ $$ S(Q, E) = A_0(Q)\,\delta(E) + \sum_{l=1}^{N-1} A_l(Q)\, L(E, \Gamma_l) $$
+
+ with
+
+ $$ A_l(Q) = \frac{1}{N} \sum_{k=1}^{N} j_0(Q r_k) \cos\left(\frac{2\pi l k}{N}\right), \quad
+ r_k = 2R \sin\left(\frac{\pi k}{N}\right), \quad \Gamma_l = \frac{2\hbar}{\tau}
+ \sin^2\left(\frac{\pi l}{N}\right) $$
+
+ where $R$ is the radius of the circle, $\tau$ the residence time on a site, $j_0(x) = \sin(x) /
+ x$, and $L(E, \Gamma)$ a Lorentzian with half width at half maximum $\Gamma$. $A_0$ is the EISF
+ and $A_l$ ($l \geq 1$) the QISF of order $l$; they sum to one. For $N = 2$ this is the two-site
+ jump model, with jump distance $2R$ and $\Gamma_1 = 2\hbar/\tau$. The width is also written as
+ $4 \sin^2(\pi l / N) / \tau$, with a $\tau$ that is twice the residence time used here
+ (reference 3).
+
+ The radius is fixed by default, since it is normally known from the molecular geometry.
+
+ References
+ ----------
+ 1. J. D. Barnes, J. Chem. Phys. 58, 5193 (1973), doi:10.1063/1.1679130.
+ 2. M. Bée, Quasielastic Neutron Scattering, Adam Hilger, Bristol (1988).
+ 3. Mantid, ``InelasticDiffRotDiscreteCircle``, whose ``Decay`` parameter is this $2\tau$,
+ https://github.com/mantidproject/mantid/blob/6ec66a6f524a7ec9d4258351fe3001c66c63dbe5/Framework/CurveFitting/src/Functions/InelasticDiffRotDiscreteCircle.cpp#L79-L108.
+
+ Examples
+ --------
+ **Creating an EquivalentSitesCircle model**
+
+ The model creates one delta function and N - 1 Lorentzians per Q-value:
+ ```python
+ import numpy as np
+ import easydynamics as edyn
+
+ Q = np.linspace(0.5, 2, 7)
+ model = edyn.EquivalentSitesCircle(
+ scale=1.0,
+ radius=1.0,
+ residence_time=2.0,
+ number_of_sites=3,
+ Q=Q,
+ )
+ component_collections = model.create_component_collections()
+ ```
+ """
+
+ _order_symbol = 'l' # components are named '_l'
+
+ def __init__(
+ self,
+ scale: Numeric = 1.0,
+ radius: Numeric = 1.0,
+ residence_time: Numeric = 1.0,
+ number_of_sites: int = 3,
+ Q: Q_type | None = None,
+ x_unit: str | sc.Unit = 'meV',
+ y_unit: str | sc.Unit = 'dimensionless',
+ name: str = 'EquivalentSitesCircle',
+ display_name: str | None = 'EquivalentSitesCircle',
+ lorentzian_name: str | None = None,
+ lorentzian_display_name: str | None = None,
+ delta_name: str = 'Delta function',
+ delta_display_name: str | None = None,
+ unique_name: str | None = None,
+ ) -> None:
+ """
+ Initialize a new EquivalentSitesCircle model.
+
+ Parameters
+ ----------
+ scale : Numeric, default=1.0
+ Scale factor for the diffusion model. Must be a non-negative number.
+ radius : Numeric, default=1.0
+ Radius of the circle in angstrom. Must be positive. Fixed by default.
+ residence_time : Numeric, default=1.0
+ Residence time tau on a site in ps.
+ number_of_sites : int, default=3
+ Number of equivalent sites N on the circle, between 2 and MAXIMUM_NUMBER_OF_SITES. Set
+ at construction; not a fit parameter.
+ Q : Q_type | None, default=None
+ Q values for the model. If None, Q is not set.
+ x_unit : str | sc.Unit, default='meV'
+ Unit of the x-axis (energy/frequency). Must be convertible to meV.
+ y_unit : str | sc.Unit, default='dimensionless'
+ Unit of the model output (intensity). Determines scale.unit = x_unit * y_unit.
+ name : str, default='EquivalentSitesCircle'
+ Name of the diffusion model.
+ display_name : str | None, default='EquivalentSitesCircle'
+ Display name of the diffusion model.
+ lorentzian_name : str | None, default=None
+ Base name of the Lorentzian components. The Lorentzian of order l is named
+ '_l'. If None, it will be set to the name of the diffusion model.
+ lorentzian_display_name : str | None, default=None
+ Base display name of the Lorentzian components. If None, it will be set to
+ lorentzian_name.
+ delta_name : str, default='Delta function'
+ Name of the delta function component.
+ delta_display_name : str | None, default=None
+ Display name of the delta function component. If None, it will be set to delta_name.
+ unique_name : str | None, default=None
+ Unique name of the diffusion model. If None, a unique name will be generated.
+
+ Raises
+ ------
+ TypeError
+ If radius or residence_time is not a number, if number_of_sites is not an integer or if
+ delta_name or delta_display_name is not a string (or None).
+ ValueError
+ If radius or residence_time is smaller than its minimum, or if number_of_sites is not
+ between 2 and MAXIMUM_NUMBER_OF_SITES.
+ """
+ self._verify_radius(radius)
+ self._verify_residence_time(residence_time)
+
+ if isinstance(number_of_sites, bool) or not isinstance(number_of_sites, (int, np.integer)):
+ raise TypeError('number_of_sites must be an integer.')
+ if not 2 <= number_of_sites <= MAXIMUM_NUMBER_OF_SITES:
+ raise ValueError(f'number_of_sites must be between 2 and {MAXIMUM_NUMBER_OF_SITES}.')
+ self._number_of_sites = int(number_of_sites)
+
+ # N sites give N - 1 Lorentzians, of orders l = 1 ... N - 1
+ super().__init__(
+ self._number_of_sites - 1,
+ scale=scale,
+ Q=Q,
+ x_unit=x_unit,
+ y_unit=y_unit,
+ name=name,
+ display_name=display_name,
+ lorentzian_name=lorentzian_name,
+ lorentzian_display_name=lorentzian_display_name,
+ delta_name=delta_name,
+ delta_display_name=delta_display_name,
+ unique_name=unique_name,
+ )
+
+ self._radius = Parameter(
+ name='radius',
+ value=float(radius),
+ fixed=True,
+ min=MINIMUM_RADIUS,
+ unit='angstrom',
+ )
+ self._residence_time = Parameter(
+ name='residence_time',
+ value=float(residence_time),
+ fixed=False,
+ min=MINIMUM_WIDTH,
+ unit='ps',
+ )
+
+ self._hbar = hbar
+ # Dimensionless one, which turns the plain numbers of the weight expressions into
+ # (dimensionless) descriptors, as dependency expressions must evaluate to one.
+ self._one = DescriptorNumber(name='one', value=1.0)
+
+ # Dimensionless radius/angstrom for the weight dependency expressions. Deriving it through
+ # the dependency graph keeps the weights correct if the radius is converted to another
+ # unit (e.g. nm).
+ with np.errstate(invalid='ignore'):
+ self._radius_over_angstrom = Parameter.from_dependency(
+ name='radius_over_angstrom',
+ dependency_expression='radius / angstrom',
+ dependency_map={'radius': self._radius, 'angstrom': angstrom},
+ )
+ self._radius_over_angstrom.set_desired_unit('dimensionless')
+
+ self.create_component_collections()
+
+ # ------------------------------------------------------------------
+ # Properties
+ # ------------------------------------------------------------------
+
+ @property
+ def radius(self) -> Parameter:
+ """
+ Get the radius of the circle.
+
+ Returns
+ -------
+ Parameter
+ Radius R.
+ """
+ return self._radius
+
+ @radius.setter
+ def radius(self, radius: Numeric) -> None:
+ """
+ Set the radius of the circle.
+
+ Parameters
+ ----------
+ radius : Numeric
+ Radius R in the unit of the radius parameter (angstrom by default).
+ """
+ self._verify_radius(radius)
+ self._radius.value = float(radius)
+
+ @property
+ def residence_time(self) -> Parameter:
+ """
+ Get the residence time on a site.
+
+ Returns
+ -------
+ Parameter
+ Residence time tau.
+ """
+ return self._residence_time
+
+ @residence_time.setter
+ def residence_time(self, residence_time: Numeric) -> None:
+ """
+ Set the residence time on a site.
+
+ Parameters
+ ----------
+ residence_time : Numeric
+ Residence time tau in ps.
+ """
+ self._verify_residence_time(residence_time)
+ self._residence_time.value = float(residence_time)
+
+ @property
+ def number_of_sites(self) -> int:
+ """
+ Get the number of equivalent sites on the circle.
+
+ Returns
+ -------
+ int
+ The number of sites N. Set at construction.
+ """
+ return self._number_of_sites
+
+ # ------------------------------------------------------------------
+ # Other methods
+ # ------------------------------------------------------------------
+
+ def calculate_width(self, Q: Q_type | None = None, order: int = 1) -> np.ndarray:
+ r"""
+ Calculate the half-width at half-maximum (HWHM) of the Lorentzian of a given order.
+
+ The width is $(2\hbar/\tau) \sin^2(\pi l / N)$ and does not depend on Q.
+
+ Parameters
+ ----------
+ Q : Q_type | None, default=None
+ Scattering vector in 1/angstrom.
+ order : int, default=1
+ The order l of the Lorentzian, between 1 and N - 1.
+
+ Returns
+ -------
+ np.ndarray
+ HWHM values in the unit of the model (e.g., meV).
+ """
+ self._verify_order(order)
+ Q = self._ensure_Q(Q)
+
+ rate = 2 * self._hbar / self.residence_time
+ rate.convert_unit(self.x_unit)
+ angle = sc.scalar(np.pi * order / self.number_of_sites, unit='rad')
+ return rate.value * sc.sin(angle).value ** 2 * np.ones_like(Q)
+
+ def calculate_EISF(self, Q: Q_type | None = None) -> np.ndarray:
+ """
+ Calculate the Elastic Incoherent Structure Factor (EISF), $A_0(Q)$.
+
+ Parameters
+ ----------
+ Q : Q_type | None, default=None
+ Scattering vector in 1/angstrom.
+
+ Returns
+ -------
+ np.ndarray
+ EISF values (dimensionless).
+ """
+ Q = self._ensure_Q(Q)
+ return self._structure_factors(Q)['order', 0].values
+
+ def calculate_QISF_order(self, Q: Q_type | None = None, order: int = 1) -> np.ndarray:
+ """
+ Calculate the Quasi-Elastic Incoherent Structure Factor (QISF) of one order, $A_l(Q)$, the
+ weight of the Lorentzian of order $l$.
+
+ Parameters
+ ----------
+ Q : Q_type | None, default=None
+ Scattering vector in 1/angstrom.
+ order : int, default=1
+ The order l of the Lorentzian, between 1 and N - 1.
+
+ Returns
+ -------
+ np.ndarray
+ QISF values of that order (dimensionless).
+ """
+ self._verify_order(order)
+ Q = self._ensure_Q(Q)
+ return self._structure_factors(Q)['order', int(order)].values
+
+ def calculate_QISF(self, Q: Q_type | None = None) -> np.ndarray:
+ """
+ Calculate the total Quasi-Elastic Incoherent Structure Factor (QISF), the sum of the QISF
+ of all N - 1 orders.
+
+ Parameters
+ ----------
+ Q : Q_type | None, default=None
+ Scattering vector in 1/angstrom.
+
+ Returns
+ -------
+ np.ndarray
+ QISF values (dimensionless).
+ """
+ Q = self._ensure_Q(Q)
+ return sc.sum(self._structure_factors(Q)['order', 1:], 'order').values
+
+ def get_global_variables(self) -> list[Parameter]:
+ """
+ Get all global variables from the diffusion model.
+
+ Returns
+ -------
+ list[Parameter]
+ The scale, radius and residence time.
+ """
+ return [self.scale, self.radius, self.residence_time]
+
+ # ------------------------------------------------------------------
+ # Private methods
+ # ------------------------------------------------------------------
+
+ def _structure_factors(self, Q: np.ndarray) -> sc.Variable:
+ r"""
+ Calculate the EISF and the QISF, $A_l(Q)$ for all orders $l = 0 \ldots N - 1$, with Scipp.
+
+ $A_l(Q) = \frac{1}{N} \sum_{k=1}^{N} j_0(Q r_k) \cos(2\pi l k / N)$ with $r_k = 2R\sin(\pi
+ k / N)$. The variance of the radius is not propagated.
+
+ Parameters
+ ----------
+ Q : np.ndarray
+ Scattering vector in 1/angstrom.
+
+ Returns
+ -------
+ sc.Variable
+ Dimensionless $A_l(Q)$ with dims ``('Q', 'order')``; order 0 is the EISF.
+ """
+ N = self.number_of_sites
+ site = sc.arange('k', 1.0, N + 1.0)
+ order = sc.arange('order', 0.0, float(N))
+ half_turn = sc.scalar(np.pi, unit='rad')
+
+ radius = sc.values(self.radius.full_value).to(unit='angstrom')
+ jump_distance = 2 * radius * sc.sin(half_turn * site / N)
+ Q_variable = sc.array(dims=['Q'], values=Q, unit='1/angstrom')
+
+ weights = sc.sum(
+ sc.sinc(Q_variable * jump_distance) * sc.cos(2 * half_turn * order * site / N), 'k'
+ )
+ return (weights / N).transpose(['Q', 'order'])
+
+ def _create_weight(self, Q: float, order: int) -> Parameter:
+ r"""
+ Create the EISF ($l = 0$) or the QISF of order $l$, $A_l(Q)$, as a dimensionless dependent
+ Parameter that follows the radius.
+
+ The dependency expressions cannot call Scipp, so the sum over sites is written out with
+ NumPy's sinc, sinc(x) = sin(pi x) / (pi x). Keeping the weight separate from the scale
+ makes updates of the scale cheap.
+
+ Parameters
+ ----------
+ Q : float
+ Scattering vector in 1/angstrom.
+ order : int
+ The order l; 0 for the delta function (the EISF).
+
+ Raises
+ ------
+ TypeError
+ If Q is not a float.
+
+ Returns
+ -------
+ Parameter
+ The weight.
+ """
+ if not isinstance(Q, float):
+ raise TypeError('Q must be a float.')
+
+ N = self.number_of_sites
+ terms = []
+ for site in range(1, N + 1):
+ # float(): NumPy 2 writes np.float64 as 'np.float64(...)', which asteval cannot parse
+ coefficient = float(np.cos(2 * np.pi * order * site / N) / N)
+ # j0(Q r_k) = sinc(Q r_k / pi) with r_k = 2 R sin(pi k / N)
+ argument = float(Q * 2 * np.sin(np.pi * site / N) / np.pi)
+ terms.append(f'{coefficient!r} * sinc({argument!r} * radius_ratio.value)')
+ expression = f'one * ({" + ".join(terms)})'
+ with np.errstate(invalid='ignore'):
+ return Parameter.from_dependency(
+ name=f'weight_l{order}_Q{Q:.2f}',
+ dependency_expression=expression,
+ dependency_map={'one': self._one, 'radius_ratio': self._radius_over_angstrom},
+ )
+
+ def _write_width_dependency_expression(self, order: int) -> str:
+ """
+ Write the dependency expression for the width of the Lorentzian of a given order.
+
+ Parameters
+ ----------
+ order : int
+ The order l of the Lorentzian.
+
+ Returns
+ -------
+ str
+ Dependency expression for the width.
+ """
+ factor = float(2 * np.sin(np.pi * order / self.number_of_sites) ** 2)
+ return f'{factor!r} * hbar / tau'
+
+ def _write_width_dependency_map_expression(self) -> dict[str, DescriptorNumber]:
+ """
+ Write the dependency map for the width.
+
+ Returns
+ -------
+ dict[str, DescriptorNumber]
+ Dependency map for the width.
+ """
+ return {'hbar': self._hbar, 'tau': self.residence_time}
+
+ @staticmethod
+ def _verify_radius(radius: Numeric) -> None:
+ """
+ Verify a radius value.
+
+ Parameters
+ ----------
+ radius : Numeric
+ The radius to verify.
+
+ Raises
+ ------
+ TypeError
+ If radius is not a number.
+ ValueError
+ If radius is below MINIMUM_RADIUS.
+ """
+ if not isinstance(radius, Numeric):
+ raise TypeError('radius must be a number.')
+ if float(radius) < MINIMUM_RADIUS:
+ raise ValueError(f'radius must be at least {MINIMUM_RADIUS}.')
+
+ @staticmethod
+ def _verify_residence_time(residence_time: Numeric) -> None:
+ """
+ Verify a residence time value.
+
+ Parameters
+ ----------
+ residence_time : Numeric
+ The residence time to verify.
+
+ Raises
+ ------
+ TypeError
+ If residence_time is not a number.
+ ValueError
+ If residence_time is below MINIMUM_WIDTH.
+ """
+ if not isinstance(residence_time, Numeric):
+ raise TypeError('residence_time must be a number.')
+ if float(residence_time) < MINIMUM_WIDTH:
+ raise ValueError(f'residence_time must be at least {MINIMUM_WIDTH}.')
+
+ # ------------------------------------------------------------------
+ # dunder methods
+ # ------------------------------------------------------------------
+
+ def __repr__(self) -> str:
+ """
+ String representation of the EquivalentSitesCircle model.
+
+ Returns
+ -------
+ str
+ String representation of the model.
+ """
+ return (
+ f'{self.__class__.__name__}('
+ f'name={self.name!r}, display_name={self.display_name!r},\n'
+ f'x_unit={self.x_unit}, y_unit={self.y_unit},\n'
+ f' number_of_sites={self.number_of_sites},\n'
+ f' radius={self.radius},\n'
+ f' residence_time={self.residence_time},\n'
+ f' scale={self.scale})'
+ )
diff --git a/src/easydynamics/sample_model/diffusion_model/gaussian_model_3d.py b/src/easydynamics/sample_model/diffusion_model/gaussian_model_3d.py
new file mode 100644
index 00000000..68e20b09
--- /dev/null
+++ b/src/easydynamics/sample_model/diffusion_model/gaussian_model_3d.py
@@ -0,0 +1,614 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+
+import math
+import warnings
+
+import numpy as np
+import scipp as sc
+from easyscience.variable import DescriptorNumber
+from easyscience.variable import Parameter
+
+from easydynamics.sample_model.component_collection import ComponentCollection
+from easydynamics.sample_model.diffusion_model.delta_lorentzian_diffusion_model_base import (
+ DeltaLorentzianDiffusionModelBase,
+)
+from easydynamics.utils.utils import Numeric
+from easydynamics.utils.utils import Q_type
+from easydynamics.utils.utils import angstrom
+from easydynamics.utils.utils import hbar
+
+MINIMUM_DIFFUSION_COEFFICIENT = 1e-20 # In m^2/s, to keep the Lorentzian widths finite
+MINIMUM_MEAN_U_SQUARED = 1e-10 # In angstrom^2; the widths scale with 1/
+MAXIMUM_NUMBER_OF_LORENTZIANS = 100 # As in QENSmodels
+MISSING_WEIGHT_TOLERANCE = 1e-3 # Truncating the sum above this loss of weight raises a warning
+
+
+class GaussianModel3D(DeltaLorentzianDiffusionModelBase):
+ r"""
+ Model of diffusion inside an isotropic three-dimensional harmonic well.
+
+ A particle diffuses with diffusion coefficient $D$ in a harmonic potential that is the same in
+ all three directions, so its position about the centre of the well has a Gaussian distribution.
+ $\langle u^2 \rangle$ is the mean square distance from the centre, summed over the three
+ directions; along any one direction it is $\langle u^2 \rangle / 3$. Because the well is
+ isotropic, the scattering depends only on the magnitude of $Q$:
+
+ $$ S(Q, E) = A_0(Q)\,\delta(E) + \sum_{i=1}^{n} A_i(Q)\, L(E, \Gamma_i) $$
+
+ with
+
+ $$ A_i(Q) = e^{-y} \frac{y^i}{i!}, \quad y = \frac{Q^2 \langle u^2 \rangle}{3}, \quad \Gamma_i
+ = \frac{3 i \hbar D}{\langle u^2 \rangle} $$
+
+ where $L(E, \Gamma)$ is a Lorentzian with half width at half maximum $\Gamma$. $A_0 = e^{-y}$
+ is the EISF, the same Debye-Waller factor as in ``DeltaLorentz``, and $A_i$ is the QISF of
+ order $i$. The widths $\Gamma_i$ grow linearly with $i$ and do not depend on $Q$; only the QISF
+ do. The paper writes the model with the variance along one direction, $\langle u_x^2 \rangle =
+ \langle u^2 \rangle / 3$.
+
+ The QISF form a Poisson distribution in $i$ with mean $y$, and together with the EISF they sum
+ to one. The infinite sum is therefore truncated at $n$ = ``number_of_lorentzians``, which must
+ be well above $y$ at the largest $Q$. A warning is raised if the truncation leaves out more
+ than 1e-3 of the total weight at any of the model's Q values, and ``calculate_missing_weight``
+ returns the weight left out.
+
+ References
+ ----------
+ F. Volino, J.-C. Perrin and S. Lyonnard, J. Phys. Chem. B 110, 11217 (2006),
+ doi:10.1021/jp061103s.
+
+ Examples
+ --------
+ **Creating a GaussianModel3D model**
+
+ The model creates one delta function and ``number_of_lorentzians`` Lorentzians per Q-value:
+ ```python
+ import numpy as np
+ import easydynamics as edyn
+
+ Q = np.linspace(0.5, 2, 7)
+ model = edyn.GaussianModel3D(
+ scale=1.0,
+ diffusion_coefficient=2e-9, # m^2/s
+ mean_u_squared=1.5, # angstrom^2
+ number_of_lorentzians=20,
+ Q=Q,
+ )
+ component_collections = model.create_component_collections()
+ ```
+ """
+
+ _order_symbol = 'i' # components are named '_i'
+
+ def __init__(
+ self,
+ scale: Numeric = 1.0,
+ diffusion_coefficient: Numeric = 1.0,
+ mean_u_squared: Numeric = 1.0,
+ number_of_lorentzians: int = 20,
+ Q: Q_type | None = None,
+ x_unit: str | sc.Unit = 'meV',
+ y_unit: str | sc.Unit = 'dimensionless',
+ name: str = 'GaussianModel3D',
+ display_name: str | None = 'GaussianModel3D',
+ lorentzian_name: str | None = None,
+ lorentzian_display_name: str | None = None,
+ delta_name: str = 'Delta function',
+ delta_display_name: str | None = None,
+ unique_name: str | None = None,
+ ) -> None:
+ """
+ Initialize a new GaussianModel3D model.
+
+ Parameters
+ ----------
+ scale : Numeric, default=1.0
+ Scale factor for the diffusion model. Must be a non-negative number.
+ diffusion_coefficient : Numeric, default=1.0
+ Diffusion coefficient D in m^2/s.
+ mean_u_squared : Numeric, default=1.0
+ Mean square displacement in angstrom^2, three times Volino's .
+ number_of_lorentzians : int, default=20
+ Number of Lorentzians n per Q-value, i.e. the order at which the sum is truncated,
+ between 1 and MAXIMUM_NUMBER_OF_LORENTZIANS. Set at construction.
+ Q : Q_type | None, default=None
+ Q values for the model. If None, Q is not set.
+ x_unit : str | sc.Unit, default='meV'
+ Unit of the x-axis (energy/frequency). Must be convertible to meV.
+ y_unit : str | sc.Unit, default='dimensionless'
+ Unit of the model output (intensity). Determines scale.unit = x_unit * y_unit.
+ name : str, default='GaussianModel3D'
+ Name of the diffusion model.
+ display_name : str | None, default='GaussianModel3D'
+ Display name of the diffusion model.
+ lorentzian_name : str | None, default=None
+ Base name of the Lorentzian components. The Lorentzian of order i is named
+ '_i'. If None, it will be set to the name of the diffusion model.
+ lorentzian_display_name : str | None, default=None
+ Base display name of the Lorentzian components. If None, it will be set to
+ lorentzian_name.
+ delta_name : str, default='Delta function'
+ Name of the delta function component.
+ delta_display_name : str | None, default=None
+ Display name of the delta function component. If None, it will be set to delta_name.
+ unique_name : str | None, default=None
+ Unique name of the diffusion model. If None, a unique name will be generated.
+
+ Raises
+ ------
+ TypeError
+ If diffusion_coefficient or mean_u_squared is not a number, if number_of_lorentzians is
+ not an integer or if delta_name or delta_display_name is not a string (or None).
+ ValueError
+ If diffusion_coefficient or mean_u_squared is smaller than its minimum, or if
+ number_of_lorentzians is not between 1 and MAXIMUM_NUMBER_OF_LORENTZIANS.
+ """
+ self._verify_diffusion_coefficient(diffusion_coefficient)
+ self._verify_mean_u_squared(mean_u_squared)
+
+ if isinstance(number_of_lorentzians, bool) or not isinstance(
+ number_of_lorentzians, (int, np.integer)
+ ):
+ raise TypeError('number_of_lorentzians must be an integer.')
+ if not 1 <= number_of_lorentzians <= MAXIMUM_NUMBER_OF_LORENTZIANS:
+ raise ValueError(
+ f'number_of_lorentzians must be between 1 and {MAXIMUM_NUMBER_OF_LORENTZIANS}.'
+ )
+
+ super().__init__(
+ number_of_lorentzians,
+ scale=scale,
+ Q=Q,
+ x_unit=x_unit,
+ y_unit=y_unit,
+ name=name,
+ display_name=display_name,
+ lorentzian_name=lorentzian_name,
+ lorentzian_display_name=lorentzian_display_name,
+ delta_name=delta_name,
+ delta_display_name=delta_display_name,
+ unique_name=unique_name,
+ )
+
+ self._diffusion_coefficient = Parameter(
+ name='diffusion_coefficient',
+ value=float(diffusion_coefficient),
+ fixed=False,
+ min=MINIMUM_DIFFUSION_COEFFICIENT,
+ unit='m**2/s',
+ )
+ self._mean_u_squared = Parameter(
+ name='mean_u_squared',
+ value=float(mean_u_squared),
+ fixed=False,
+ min=MINIMUM_MEAN_U_SQUARED,
+ unit='angstrom**2',
+ )
+
+ self._hbar = hbar
+ # Dimensionless one, which turns the plain numbers of the weight expressions into
+ # (dimensionless) descriptors, as dependency expressions must evaluate to one.
+ self._one = DescriptorNumber(name='one', value=1.0)
+
+ # Dimensionless /angstrom^2 for the weight dependency expressions. Deriving it
+ # through the dependency graph keeps the weights correct if is converted to another
+ # unit (e.g. nm^2).
+ with np.errstate(invalid='ignore'):
+ self._mean_u_squared_over_angstrom_squared = Parameter.from_dependency(
+ name='mean_u_squared_over_angstrom_squared',
+ dependency_expression='mean_u_squared / angstrom**2',
+ dependency_map={'mean_u_squared': self._mean_u_squared, 'angstrom': angstrom},
+ )
+ self._mean_u_squared_over_angstrom_squared.set_desired_unit('dimensionless')
+
+ self.create_component_collections()
+
+ # ------------------------------------------------------------------
+ # Properties
+ # ------------------------------------------------------------------
+
+ @property
+ def diffusion_coefficient(self) -> Parameter:
+ """
+ Get the diffusion coefficient.
+
+ Returns
+ -------
+ Parameter
+ Diffusion coefficient D.
+ """
+ return self._diffusion_coefficient
+
+ @diffusion_coefficient.setter
+ def diffusion_coefficient(self, diffusion_coefficient: Numeric) -> None:
+ """
+ Set the diffusion coefficient.
+
+ Parameters
+ ----------
+ diffusion_coefficient : Numeric
+ Diffusion coefficient D in m^2/s.
+ """
+ self._verify_diffusion_coefficient(diffusion_coefficient)
+ self._diffusion_coefficient.value = float(diffusion_coefficient)
+
+ @property
+ def mean_u_squared(self) -> Parameter:
+ """
+ Get the mean square displacement.
+
+ Returns
+ -------
+ Parameter
+ Mean square displacement .
+ """
+ return self._mean_u_squared
+
+ @mean_u_squared.setter
+ def mean_u_squared(self, mean_u_squared: Numeric) -> None:
+ """
+ Set the mean square displacement.
+
+ Parameters
+ ----------
+ mean_u_squared : Numeric
+ Mean square displacement in the unit of the parameter (angstrom^2 by default).
+ """
+ self._verify_mean_u_squared(mean_u_squared)
+ self._mean_u_squared.value = float(mean_u_squared)
+
+ # ------------------------------------------------------------------
+ # Other methods
+ # ------------------------------------------------------------------
+
+ def calculate_width(self, Q: Q_type | None = None, order: int = 1) -> np.ndarray:
+ r"""
+ Calculate the half-width at half-maximum (HWHM) of the Lorentzian of a given order.
+
+ The width is $3 i \hbar D / \langle u^2 \rangle$ and does not depend on Q.
+
+ Parameters
+ ----------
+ Q : Q_type | None, default=None
+ Scattering vector in 1/angstrom.
+ order : int, default=1
+ The order i of the Lorentzian, between 1 and number_of_lorentzians.
+
+ Returns
+ -------
+ np.ndarray
+ HWHM values in the unit of the model (e.g., meV).
+ """
+ self._verify_order(order)
+ Q = self._ensure_Q(Q)
+
+ width = (
+ 3
+ * sc.values(self._hbar.full_value)
+ * sc.values(self.diffusion_coefficient.full_value)
+ / sc.values(self.mean_u_squared.full_value)
+ ).to(unit=self.x_unit)
+ return order * width.value * np.ones_like(Q)
+
+ def calculate_EISF(self, Q: Q_type | None = None) -> np.ndarray:
+ r"""
+ Calculate the Elastic Incoherent Structure Factor (EISF), $\exp(-Q^2\langle u^2\rangle/3)$.
+
+ Parameters
+ ----------
+ Q : Q_type | None, default=None
+ Scattering vector in 1/angstrom.
+
+ Returns
+ -------
+ np.ndarray
+ EISF values (dimensionless).
+ """
+ Q = self._ensure_Q(Q)
+ return sc.exp(-self._y(Q)).values
+
+ def calculate_QISF_order(self, Q: Q_type | None = None, order: int = 1) -> np.ndarray:
+ """
+ Calculate the Quasi-Elastic Incoherent Structure Factor (QISF) of one order, $A_i(Q) =
+ e^{-y} y^i / i!$, the weight of the Lorentzian of order $i$.
+
+ Parameters
+ ----------
+ Q : Q_type | None, default=None
+ Scattering vector in 1/angstrom.
+ order : int, default=1
+ The order i of the Lorentzian, between 1 and number_of_lorentzians.
+
+ Returns
+ -------
+ np.ndarray
+ QISF values of that order (dimensionless).
+ """
+ self._verify_order(order)
+ Q = self._ensure_Q(Q)
+ return self._quasielastic_weights(Q)['order', int(order) - 1].values
+
+ def calculate_QISF(self, Q: Q_type | None = None) -> np.ndarray:
+ """
+ Calculate the total Quasi-Elastic Incoherent Structure Factor (QISF), the sum of the QISF
+ of all number_of_lorentzians orders.
+
+ Parameters
+ ----------
+ Q : Q_type | None, default=None
+ Scattering vector in 1/angstrom.
+
+ Returns
+ -------
+ np.ndarray
+ QISF values (dimensionless).
+ """
+ Q = self._ensure_Q(Q)
+ return sc.sum(self._quasielastic_weights(Q), 'order').values
+
+ def calculate_missing_weight(self, Q: Q_type | None = None) -> np.ndarray:
+ """
+ Calculate the spectral weight lost by truncating the sum at number_of_lorentzians.
+
+ The weights of all orders sum to one, so this is 1 - EISF - QISF. If it is not small at the
+ Q values of interest, increase number_of_lorentzians.
+
+ Parameters
+ ----------
+ Q : Q_type | None, default=None
+ Scattering vector in 1/angstrom.
+
+ Returns
+ -------
+ np.ndarray
+ The omitted weight (dimensionless).
+ """
+ Q = self._ensure_Q(Q)
+ return 1.0 - self.calculate_EISF(Q) - self.calculate_QISF(Q)
+
+ def create_component_collections(self) -> list[ComponentCollection]:
+ r"""
+ Create ComponentCollections for the model at the current Q values.
+
+ Each collection holds a delta function with area $\mathrm{scale}\, A_0(Q)$ and
+ number_of_lorentzians Lorentzians with widths $\Gamma_i$ and areas $\mathrm{scale}\,
+ A_i(Q)$ (built by the base class). The sum over i is truncated, so a warning is raised if
+ it omits more than MISSING_WEIGHT_TOLERANCE of the weight. The created collections are
+ installed on the model, so the returned list is the live one.
+
+ Returns
+ -------
+ list[ComponentCollection]
+ One ComponentCollection per Q value.
+ """
+ collections = super().create_component_collections()
+ self._warn_if_truncated()
+ return collections
+
+ def get_global_variables(self) -> list[Parameter]:
+ """
+ Get all global variables from the diffusion model.
+
+ Returns
+ -------
+ list[Parameter]
+ The scale, diffusion coefficient and mean square displacement.
+ """
+ return [self.scale, self.diffusion_coefficient, self.mean_u_squared]
+
+ # ------------------------------------------------------------------
+ # Private methods
+ # ------------------------------------------------------------------
+
+ def _y(self, Q: np.ndarray) -> sc.Variable:
+ r"""
+ Calculate $y = Q^2 \langle u^2 \rangle / 3$ with Scipp.
+
+ The variance of is not propagated.
+
+ Parameters
+ ----------
+ Q : np.ndarray
+ Scattering vector in 1/angstrom.
+
+ Returns
+ -------
+ sc.Variable
+ Dimensionless y with dims ``('Q',)``.
+ """
+ Q_variable = sc.array(dims=['Q'], values=Q, unit='1/angstrom')
+ mean_u_squared = sc.values(self.mean_u_squared.full_value)
+ return (Q_variable**2 * mean_u_squared / 3).to(unit='dimensionless')
+
+ def _quasielastic_weights(self, Q: np.ndarray) -> sc.Variable:
+ r"""
+ Calculate the QISF $A_i = e^{-y} y^i / i!$ of the orders $i = 1 \ldots n$ with Scipp.
+
+ Computed in log space, $\exp(-y + i \ln y - \ln i!)$, so that neither $y^i$ nor $i!$
+ overflows. Scipp has no factorial, so $\ln i!$ is the cumulative sum of $\ln k$. At $Q = 0$
+ the logarithm is $-\infty$ and the weights are exactly 0.
+
+ Parameters
+ ----------
+ Q : np.ndarray
+ Scattering vector in 1/angstrom.
+
+ Returns
+ -------
+ sc.Variable
+ Dimensionless QISF with dims ``('Q', 'order')``.
+ """
+ order = sc.arange('order', 1.0, self.number_of_lorentzians + 1.0)
+ log_factorial = sc.cumsum(sc.log(order), 'order')
+ y = self._y(Q)
+ weights = sc.exp(-y + order * sc.log(y) - log_factorial)
+ return weights.transpose(['Q', 'order'])
+
+ def _create_weight(self, Q: float, order: int) -> Parameter:
+ r"""
+ Create the EISF ($i = 0$) or the QISF of order $i$, $A_i(Q)$, as a dimensionless dependent
+ Parameter that follows $\langle u^2 \rangle$.
+
+ The dependency expressions cannot call Scipp, so the weight is written out in the same
+ log-space form as ``_quasielastic_weights``. Keeping the weight separate from the scale
+ makes updates of the scale cheap.
+
+ Parameters
+ ----------
+ Q : float
+ Scattering vector in 1/angstrom.
+ order : int
+ The order i; 0 for the delta function (the EISF).
+
+ Raises
+ ------
+ TypeError
+ If Q is not a float.
+
+ Returns
+ -------
+ Parameter
+ The weight.
+ """
+ if not isinstance(Q, float):
+ raise TypeError('Q must be a float.')
+
+ # float(): NumPy 2 writes np.float64 as 'np.float64(...)', which asteval cannot parse
+ y = f'{float(Q**2 / 3)!r} * u2.value'
+ if order == 0:
+ expression = f'one * exp(-{y})'
+ else:
+ log_factorial = math.lgamma(order + 1)
+ expression = f'y = {y}\none * exp(-y + {order} * log(y) - {log_factorial!r})'
+ with np.errstate(invalid='ignore'):
+ return Parameter.from_dependency(
+ name=f'weight_i{order}_Q{Q:.2f}',
+ dependency_expression=expression,
+ dependency_map={
+ 'one': self._one,
+ 'u2': self._mean_u_squared_over_angstrom_squared,
+ },
+ )
+
+ def _write_width_dependency_expression(self, order: int) -> str:
+ """
+ Write the dependency expression for the width of the Lorentzian of a given order.
+
+ Parameters
+ ----------
+ order : int
+ The order i of the Lorentzian.
+
+ Returns
+ -------
+ str
+ Dependency expression for the width.
+ """
+ # Dividing by the dimensionless value of /angstrom^2 instead of by the
+ # Parameter avoids inf/inf in Core's bound arithmetic (D and are both unbounded
+ # above), which warns on every update during a fit.
+ return f'{3 * order} * hbar * D / angstrom**2 / u2.value'
+
+ def _write_width_dependency_map_expression(self) -> dict[str, DescriptorNumber]:
+ """
+ Write the dependency map for the width.
+
+ Returns
+ -------
+ dict[str, DescriptorNumber]
+ Dependency map for the width.
+ """
+ return {
+ 'hbar': self._hbar,
+ 'D': self.diffusion_coefficient,
+ 'angstrom': angstrom,
+ 'u2': self._mean_u_squared_over_angstrom_squared,
+ }
+
+ def _warn_if_truncated(self) -> None:
+ """
+ Warn if truncating the sum at number_of_lorentzians omits too much weight.
+
+ The warning is raised if the missing weight exceeds MISSING_WEIGHT_TOLERANCE at any of the
+ model's Q values. Without Q values there is nothing to check.
+ """
+ if self.Q is None:
+ return
+ if np.max(self.calculate_missing_weight(), initial=0.0) > MISSING_WEIGHT_TOLERANCE:
+ warnings.warn(
+ f'Truncating the sum at number_of_lorentzians={self.number_of_lorentzians} omits '
+ f'more than {MISSING_WEIGHT_TOLERANCE} of the spectral weight at some Q values. '
+ 'Increase number_of_lorentzians.',
+ UserWarning,
+ stacklevel=3,
+ )
+
+ @staticmethod
+ def _verify_diffusion_coefficient(diffusion_coefficient: Numeric) -> None:
+ """
+ Verify a diffusion coefficient value.
+
+ Parameters
+ ----------
+ diffusion_coefficient : Numeric
+ The diffusion coefficient to verify.
+
+ Raises
+ ------
+ TypeError
+ If diffusion_coefficient is not a number.
+ ValueError
+ If diffusion_coefficient is below MINIMUM_DIFFUSION_COEFFICIENT.
+ """
+ if not isinstance(diffusion_coefficient, Numeric):
+ raise TypeError('diffusion_coefficient must be a number.')
+ if float(diffusion_coefficient) < MINIMUM_DIFFUSION_COEFFICIENT:
+ raise ValueError(
+ f'diffusion_coefficient must be at least {MINIMUM_DIFFUSION_COEFFICIENT}.'
+ )
+
+ @staticmethod
+ def _verify_mean_u_squared(mean_u_squared: Numeric) -> None:
+ """
+ Verify a mean square displacement value.
+
+ Parameters
+ ----------
+ mean_u_squared : Numeric
+ The mean square displacement to verify.
+
+ Raises
+ ------
+ TypeError
+ If mean_u_squared is not a number.
+ ValueError
+ If mean_u_squared is below MINIMUM_MEAN_U_SQUARED.
+ """
+ if not isinstance(mean_u_squared, Numeric):
+ raise TypeError('mean_u_squared must be a number.')
+ if float(mean_u_squared) < MINIMUM_MEAN_U_SQUARED:
+ raise ValueError(f'mean_u_squared must be at least {MINIMUM_MEAN_U_SQUARED}.')
+
+ # ------------------------------------------------------------------
+ # dunder methods
+ # ------------------------------------------------------------------
+
+ def __repr__(self) -> str:
+ """
+ String representation of the GaussianModel3D model.
+
+ Returns
+ -------
+ str
+ String representation of the model.
+ """
+ return (
+ f'{self.__class__.__name__}('
+ f'name={self.name!r}, display_name={self.display_name!r},\n'
+ f'x_unit={self.x_unit}, y_unit={self.y_unit},\n'
+ f' number_of_lorentzians={self.number_of_lorentzians},\n'
+ f' diffusion_coefficient={self.diffusion_coefficient},\n'
+ f' mean_u_squared={self.mean_u_squared},\n'
+ f' scale={self.scale})'
+ )
diff --git a/src/easydynamics/sample_model/diffusion_model/hall_ross_jump_diffusion.py b/src/easydynamics/sample_model/diffusion_model/hall_ross_jump_diffusion.py
new file mode 100644
index 00000000..a9282fda
--- /dev/null
+++ b/src/easydynamics/sample_model/diffusion_model/hall_ross_jump_diffusion.py
@@ -0,0 +1,445 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+
+
+import numpy as np
+import scipp as sc
+from easyscience.variable import DescriptorNumber
+from easyscience.variable import Parameter
+
+from easydynamics.sample_model.component_collection import ComponentCollection
+from easydynamics.sample_model.components import Lorentzian
+from easydynamics.sample_model.diffusion_model.diffusion_model_base import DiffusionModelBase
+from easydynamics.utils.utils import Numeric
+from easydynamics.utils.utils import Q_type
+from easydynamics.utils.utils import angstrom
+from easydynamics.utils.utils import hbar
+
+MINIMUM_WIDTH = 1e-10 # To avoid division by zero
+
+
+class HallRossJumpDiffusion(DiffusionModelBase):
+ r"""
+ Model of Hall-Ross jump diffusion, with a Gaussian distribution of jump lengths.
+
+ The model consists of a Lorentzian function for each Q-value, where the width is given by
+
+ $$ \Gamma(Q) = \frac{\hbar}{\tau} \left( 1 - \exp\left(-\frac{Q^2 l^2}{2}\right) \right) $$
+
+ where $\tau$ is the residence time and $l$ is the standard deviation of the jump-length
+ distribution, the convention of reference 2. The mean-square jump length is $\langle r^2
+ \rangle = 3 l^2$. At small $Q$ the width tends to $\hbar D Q^2$ with $D = l^2 / (2 \tau)$, and
+ at large $Q$ to $\hbar / \tau$. $Q$ is assumed to have units of 1/angstrom. Creates
+ ComponentCollections with Lorentzian components for given Q-values.
+
+ References
+ ----------
+ 1. P. L. Hall and D. K. Ross, *Incoherent neutron scattering functions for random jump
+ diffusion in bounded and infinite media*, Mol. Phys. **42**, 673 (1981),
+ [doi:10.1080/00268978100100521](https://doi.org/10.1080/00268978100100521).
+ 2. Mantid, ``HallRoss`` fit function, whose ``L`` parameter is this $l$,
+ https://github.com/mantidproject/mantid/blob/17c1cd96c1d46b39bee21dc75a60017b658d689c/Framework/PythonInterface/plugins/functions/HallRoss.py#L30-L39.
+
+ Examples
+ --------
+ **Creating a HallRossJumpDiffusion model**
+
+ Pass the residence time (in ps) and jump length (in angstroms) along with Q values:
+ ```python
+ import numpy as np
+ import easydynamics as edyn
+
+ Q = np.linspace(0.5, 2, 7)
+ diffusion_model = edyn.HallRossJumpDiffusion(
+ scale=1.0,
+ residence_time=1.0,
+ jump_length=1.5,
+ Q=Q,
+ )
+ component_collections = diffusion_model.create_component_collections()
+ ```
+
+ See also the tutorials.
+ """
+
+ def __init__(
+ self,
+ scale: Numeric = 1.0,
+ residence_time: Numeric = 1.0,
+ jump_length: Numeric = 1.0,
+ Q: Q_type | None = None,
+ x_unit: str | sc.Unit = 'meV',
+ y_unit: str | sc.Unit = 'dimensionless',
+ name: str = 'HallRossJumpDiffusion',
+ display_name: str | None = 'HallRossJumpDiffusion',
+ lorentzian_name: str | None = None,
+ lorentzian_display_name: str | None = None,
+ unique_name: str | None = None,
+ ) -> None:
+ """
+ Initialize a new HallRossJumpDiffusion model.
+
+ Parameters
+ ----------
+ scale : Numeric, default=1.0
+ Scale factor for the diffusion model. Must be a non-negative number.
+ residence_time : Numeric, default=1.0
+ Residence time parameter tau in ps.
+ jump_length : Numeric, default=1.0
+ Standard deviation l of the jump-length distribution in angstrom.
+ Q : Q_type | None, default=None
+ Q values for the model. If None, Q is not set.
+ x_unit : str | sc.Unit, default='meV'
+ Unit of the x-axis (energy/frequency). Must be convertible to meV.
+ y_unit : str | sc.Unit, default='dimensionless'
+ Unit of the model output (intensity). Determines scale.unit = x_unit * y_unit.
+ name : str, default='HallRossJumpDiffusion'
+ Name of the diffusion model.
+ display_name : str | None, default='HallRossJumpDiffusion'
+ Display name of the diffusion model.
+ lorentzian_name : str | None, default=None
+ Name of the Lorentzian component. If None, it will be set to the name of the diffusion
+ model. By default, None.
+ lorentzian_display_name : str | None, default=None
+ Display name of the Lorentzian component. If None, it will be set to the
+ lorentzian_name. By default, None
+ unique_name : str | None, default=None
+ Unique name of the diffusion model. If None, a unique name will be generated. By
+ default, None.
+
+ Raises
+ ------
+ TypeError
+ If ``residence_time`` or ``jump_length`` is not a number.
+ ValueError
+ If ``jump_length`` is negative or ``residence_time`` is below ``MINIMUM_WIDTH``.
+ """
+ super().__init__(
+ Q=Q,
+ x_unit=x_unit,
+ y_unit=y_unit,
+ scale=scale,
+ name=name,
+ display_name=display_name,
+ lorentzian_name=lorentzian_name,
+ lorentzian_display_name=lorentzian_display_name,
+ unique_name=unique_name,
+ )
+
+ if not isinstance(residence_time, Numeric):
+ raise TypeError('residence_time must be a number.')
+
+ if float(residence_time) < MINIMUM_WIDTH:
+ raise ValueError(f'residence_time must be at least {MINIMUM_WIDTH}.')
+
+ if not isinstance(jump_length, Numeric):
+ raise TypeError('jump_length must be a number.')
+
+ if float(jump_length) < 0:
+ raise ValueError('jump_length must be non-negative.')
+
+ residence_time = Parameter(
+ name='residence_time',
+ value=float(residence_time),
+ fixed=False,
+ unit='ps',
+ min=MINIMUM_WIDTH,
+ )
+
+ jump_length = Parameter(
+ name='jump_length',
+ value=float(jump_length),
+ fixed=False,
+ unit='angstrom',
+ min=0.0,
+ )
+
+ self._hbar = hbar
+ self._angstrom = angstrom
+ self._residence_time = residence_time
+ self._jump_length = jump_length
+
+ self._component_collections = self.create_component_collections()
+
+ ################################
+ # Properties
+ ################################
+
+ @property
+ def residence_time(self) -> Parameter:
+ """
+ Get the residence time parameter tau.
+
+ Returns
+ -------
+ Parameter
+ Residence time tau.
+ """
+ return self._residence_time
+
+ @residence_time.setter
+ def residence_time(self, residence_time: Numeric) -> None:
+ """
+ Set the residence time parameter tau.
+
+ Parameters
+ ----------
+ residence_time : Numeric
+ Residence time tau in ps.
+
+ Raises
+ ------
+ TypeError
+ If residence_time is not a number.
+ ValueError
+ If residence_time is below ``MINIMUM_WIDTH``.
+ """
+ if not isinstance(residence_time, Numeric):
+ raise TypeError('residence_time must be a number.')
+ if float(residence_time) < MINIMUM_WIDTH:
+ raise ValueError(f'residence_time must be at least {MINIMUM_WIDTH}.')
+
+ self._residence_time.value = float(residence_time)
+
+ @property
+ def jump_length(self) -> Parameter:
+ """
+ Get the jump length parameter l.
+
+ Returns
+ -------
+ Parameter
+ Standard deviation l of the jump-length distribution in angstrom.
+ """
+ return self._jump_length
+
+ @jump_length.setter
+ def jump_length(self, jump_length: Numeric) -> None:
+ """
+ Set the jump length parameter l.
+
+ Parameters
+ ----------
+ jump_length : Numeric
+ Standard deviation l of the jump-length distribution in angstrom.
+
+ Raises
+ ------
+ TypeError
+ If jump_length is not a number.
+ ValueError
+ If jump_length is negative.
+ """
+ if not isinstance(jump_length, Numeric):
+ raise TypeError('jump_length must be a number.')
+
+ if float(jump_length) < 0:
+ raise ValueError('jump_length must be non-negative.')
+ self._jump_length.value = float(jump_length)
+
+ ################################
+ # Other methods
+ ################################
+
+ def calculate_width(self, Q: Q_type | None = None) -> np.ndarray:
+ r"""
+ Calculate the half-width at half-maximum (HWHM) for the diffusion model.
+
+ $\Gamma(Q) = (\hbar / \tau) (1 - \exp(-Q^2 l^2 / 2))$, where $\tau$ is the residence time
+ and $l$ is the standard deviation of the jump-length distribution.
+
+ Parameters
+ ----------
+ Q : Q_type | None, default=None
+ Scattering vector in 1/angstrom. Can be a single value or an array of values. If None,
+ Q values stored in the model are used.
+
+ Returns
+ -------
+ np.ndarray
+ HWHM values in the unit of the model (e.g., meV).
+ """
+ Q = self._ensure_Q(Q)
+
+ conversion_factor = self._jump_length / self._angstrom
+ conversion_factor.convert_unit('dimensionless')
+
+ prefactor = self._hbar / self._residence_time
+ prefactor.convert_unit(self.x_unit)
+
+ argument = Q * conversion_factor.value
+
+ # -expm1(-x) is 1 - exp(-x) without the cancellation at small Q
+ return prefactor.value * -np.expm1(-(argument**2) / 2)
+
+ def calculate_EISF(self, Q: Q_type) -> np.ndarray:
+ """
+ Calculate the Elastic Incoherent Structure Factor (EISF), which is 0 for this model.
+
+ Parameters
+ ----------
+ Q : Q_type
+ Scattering vector in 1/angstrom. Can be a single value or an array of values.
+
+ Returns
+ -------
+ np.ndarray
+ EISF values (dimensionless).
+ """
+ Q = self._ensure_Q(Q)
+
+ return np.zeros_like(Q)
+
+ def calculate_QISF(self, Q: Q_type) -> np.ndarray:
+ """
+ Calculate the Quasi-Elastic Incoherent Structure Factor (QISF), which is 1 for this model.
+
+ Parameters
+ ----------
+ Q : Q_type
+ Scattering vector in 1/angstrom. Can be a single value or an array of values.
+
+ Returns
+ -------
+ np.ndarray
+ QISF values (dimensionless).
+ """
+ Q = self._ensure_Q(Q)
+
+ return np.ones_like(Q)
+
+ def create_component_collections(
+ self,
+ ) -> list[ComponentCollection]:
+ """
+ Create ComponentCollection components for the diffusion model at given Q values.
+
+ The created collections are installed on the model (they become the collections returned by
+ ``get_component_collections``), so the returned list is the live one.
+
+ Returns
+ -------
+ list[ComponentCollection]
+ List of ComponentCollections with Hall-Ross Lorentzian components.
+ """
+ if self.Q is None:
+ self._component_collections = []
+ return self._component_collections
+
+ Q = self.Q.values
+ component_collection_list = [None] * len(Q)
+ QISF = self.calculate_QISF(Q)
+
+ # One Lorentzian per Q-value, with the Hall-Ross width and area equal to scale. No delta
+ # function, as the EISF is 0.
+ for i, Q_value in enumerate(Q):
+ component_collection_list[i] = ComponentCollection(
+ name=f'{self.name}_Q{Q_value:.2f}',
+ display_name=f'{self.display_name}_Q{Q_value:.2f}',
+ x_unit=self.x_unit,
+ y_unit=self.y_unit,
+ )
+
+ lorentzian_component = Lorentzian(
+ name=self.lorentzian_name,
+ display_name=self.lorentzian_display_name,
+ x_unit=self.x_unit,
+ y_unit=self.y_unit,
+ )
+
+ dependency_expression = self._write_width_dependency_expression(Q[i])
+ dependency_map = self._write_width_dependency_map_expression()
+
+ # easyscience propagates inf bounds through arithmetic, producing inf/inf=nan
+ # as a transient intermediate. Python's min/max ignore nan so the final bounds
+ # are correct; suppress the spurious numpy RuntimeWarning.
+ with np.errstate(invalid='ignore'):
+ lorentzian_component.width.make_dependent_on(
+ dependency_expression=dependency_expression,
+ dependency_map=dependency_map,
+ desired_unit=self.x_unit,
+ )
+
+ area_dependency_map = self._write_area_dependency_map_expression()
+ lorentzian_component.area.make_dependent_on(
+ dependency_expression=self._write_area_dependency_expression(QISF[i]),
+ dependency_map=area_dependency_map,
+ )
+
+ component_collection_list[i].append_component(lorentzian_component)
+
+ self._component_collections = component_collection_list
+ return self._component_collections
+
+ ################################
+ # Private methods
+ ################################
+ def _on_Q_change(self) -> None:
+ """
+ Update the component collections when Q changes. This is called automatically when the Q
+ property is set. It regenerates the component collections based on the new Q values.
+ """
+ self._component_collections = self.create_component_collections()
+
+ def _write_width_dependency_expression(self, Q: float) -> str:
+ """
+ Write the dependency expression for the width as a function of Q to make dependent
+ Parameters.
+
+ Parameters
+ ----------
+ Q : float
+ Scattering vector in 1/angstrom.
+
+ Raises
+ ------
+ TypeError
+ If Q is not a float.
+
+ Returns
+ -------
+ str
+ Dependency expression for the width.
+ """
+ if not isinstance(Q, (float)):
+ raise TypeError('Q must be a float.')
+
+ # Q is in 1/angstrom and l in angstrom; -expm1(-x) is 1 - exp(-x) without cancellation
+ return f'(hbar / tau) * -expm1(-(({Q} * l.value) ** 2) / 2)'
+
+ def _write_width_dependency_map_expression(self) -> dict[str, DescriptorNumber]:
+ """
+ Write the dependency map expression to make dependent Parameters.
+
+ Returns
+ -------
+ dict[str, DescriptorNumber]
+ Dependency map for the width.
+ """
+ return {
+ 'tau': self.residence_time,
+ 'l': self.jump_length,
+ 'hbar': self._hbar,
+ }
+
+ ################################
+ # dunder methods
+ ################################
+
+ def __repr__(self) -> str:
+ """
+ String representation of the HallRossJumpDiffusion model.
+
+ Returns
+ -------
+ str
+ String representation of the HallRossJumpDiffusion model.
+ """
+ return (
+ f'{self.__class__.__name__}('
+ f'name={self.name!r}, display_name={self.display_name!r},\n'
+ f'x_unit={self.x_unit}, y_unit={self.y_unit}, \n'
+ f' residence_time={self.residence_time},\n'
+ f' jump_length={self.jump_length}),\n'
+ f' scale={self.scale})'
+ )
diff --git a/src/easydynamics/sample_model/diffusion_model/isotropic_rotational_diffusion.py b/src/easydynamics/sample_model/diffusion_model/isotropic_rotational_diffusion.py
new file mode 100644
index 00000000..c13b8ee8
--- /dev/null
+++ b/src/easydynamics/sample_model/diffusion_model/isotropic_rotational_diffusion.py
@@ -0,0 +1,586 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+
+import warnings
+
+import numpy as np
+import scipp as sc
+from easyscience.variable import DescriptorNumber
+from easyscience.variable import Parameter
+
+from easydynamics.sample_model.component_collection import ComponentCollection
+from easydynamics.sample_model.diffusion_model.delta_lorentzian_diffusion_model_base import (
+ DeltaLorentzianDiffusionModelBase,
+)
+from easydynamics.sample_model.diffusion_model.spherical_bessel_expression import (
+ spherical_bessel_expression,
+)
+from easydynamics.utils.spherical_bessel import spherical_jn
+from easydynamics.utils.utils import Numeric
+from easydynamics.utils.utils import Q_type
+from easydynamics.utils.utils import angstrom
+from easydynamics.utils.utils import hbar
+
+MINIMUM_RADIUS = 1e-10 # In angstrom; a zero radius is unphysical
+MINIMUM_ROTATIONAL_DIFFUSION_COEFFICIENT = 1e-10 # In 1/ps, to keep the Lorentzian widths finite
+# Each order adds two dependent Parameters per Q value, which are slow to update in EasyScience
+# Core 2.5.1. The Bessel expressions are tested up to order 26.
+MAXIMUM_L_MAX = 20
+DEFAULT_L_MAX = 6 # Misses less than 1e-3 of the weight up to QR of about 4.3
+MISSING_WEIGHT_TOLERANCE = 1e-3 # Truncating the sum above this loss of weight raises a warning
+
+
+class IsotropicRotationalDiffusion(DeltaLorentzianDiffusionModelBase):
+ r"""
+ Model of isotropic rotational diffusion of a scatterer on the surface of a sphere.
+
+ The scatterer stays at a fixed distance $R$ from a centre, for example a hydrogen atom in a
+ molecule that tumbles freely, and moves over the surface of that sphere by rotational diffusion
+ with rotational diffusion coefficient $D_R$. The motion is isotropic, so the scattering depends
+ only on the magnitude of $Q$:
+
+ $$ S(Q, E) = A_0(Q)\,\delta(E) + \sum_{l=1}^{l_{max}} A_l(Q)\, L(E, \Gamma_l) $$
+
+ with
+
+ $$ A_0(Q) = j_0^2(QR),\quad A_l(Q) = (2l+1)\, j_l^2(QR),\quad \Gamma_l = l(l+1)\hbar D_R $$
+
+ where $j_l$ are the spherical Bessel functions and $L(E, \Gamma)$ a Lorentzian with half width
+ at half maximum $\Gamma$. $A_0$ is the EISF and $A_l$ the QISF of order $l$. The widths do not
+ depend on $Q$; only the QISF do.
+
+ The EISF and the QISF of all orders sum to one, so cutting the infinite sum at $l_{max}$ =
+ ``l_max`` leaves out some of the weight. A warning is raised if more than 1e-3 of the weight is
+ left out at any of the model's Q values, and ``calculate_missing_weight`` returns the weight
+ left out. With the default ``l_max`` of 6 this happens from QR of about 4.3, and with the
+ largest, 20, from QR of about 17.
+
+ The radius is fixed by default, since it is normally known from the molecular geometry.
+
+ References
+ ----------
+ 1. V. F. Sears, Can. J. Phys. 44, 1299 (1966), doi:10.1139/p66-109.
+ 2. V. F. Sears, Can. J. Phys. 45, 237 (1967), doi:10.1139/p67-025.
+ 3. M. Bée, Quasielastic Neutron Scattering, Adam Hilger, Bristol (1988).
+
+ Examples
+ --------
+ **Creating an IsotropicRotationalDiffusion model**
+
+ The model creates one delta function and ``l_max`` Lorentzians per Q-value:
+ ```python
+ import numpy as np
+ import easydynamics as edyn
+
+ model = edyn.IsotropicRotationalDiffusion(
+ radius=2.0, # angstrom
+ rotational_diffusion_coefficient=0.1, # 1/ps
+ Q=np.linspace(0.3, 2, 7),
+ )
+ ```
+ """
+
+ _order_symbol = 'l' # components are named '_l'
+
+ def __init__(
+ self,
+ scale: Numeric = 1.0,
+ radius: Numeric = 1.0,
+ rotational_diffusion_coefficient: Numeric = 1.0,
+ l_max: int = DEFAULT_L_MAX,
+ Q: Q_type | None = None,
+ x_unit: str | sc.Unit = 'meV',
+ y_unit: str | sc.Unit = 'dimensionless',
+ name: str = 'IsotropicRotationalDiffusion',
+ display_name: str | None = 'IsotropicRotationalDiffusion',
+ lorentzian_name: str | None = None,
+ lorentzian_display_name: str | None = None,
+ delta_name: str = 'Delta function',
+ delta_display_name: str | None = None,
+ unique_name: str | None = None,
+ ) -> None:
+ """
+ Initialize a new IsotropicRotationalDiffusion model.
+
+ Parameters
+ ----------
+ scale : Numeric, default=1.0
+ Scale factor for the diffusion model. Must be a non-negative number.
+ radius : Numeric, default=1.0
+ Radius R of the sphere in angstrom. Must be positive. Fixed by default.
+ rotational_diffusion_coefficient : Numeric, default=1.0
+ Rotational diffusion coefficient D_R in 1/ps. Must be positive.
+ l_max : int, default=DEFAULT_L_MAX
+ Order at which the sum is cut: the model has l_max Lorentzians per Q-value, of orders 1
+ to l_max. Between 1 and MAXIMUM_L_MAX. Set at construction.
+ Q : Q_type | None, default=None
+ Q values for the model. If None, Q is not set.
+ x_unit : str | sc.Unit, default='meV'
+ Unit of the x-axis (energy/frequency). Must be convertible to meV.
+ y_unit : str | sc.Unit, default='dimensionless'
+ Unit of the model output (intensity). Determines scale.unit = x_unit * y_unit.
+ name : str, default='IsotropicRotationalDiffusion'
+ Name of the diffusion model.
+ display_name : str | None, default='IsotropicRotationalDiffusion'
+ Display name of the diffusion model.
+ lorentzian_name : str | None, default=None
+ Base name of the Lorentzian components. The Lorentzian of order l is named
+ '_l'. If None, it will be set to the name of the diffusion model.
+ lorentzian_display_name : str | None, default=None
+ Base display name of the Lorentzian components. If None, it will be set to
+ lorentzian_name.
+ delta_name : str, default='Delta function'
+ Name of the delta function component.
+ delta_display_name : str | None, default=None
+ Display name of the delta function component. If None, it will be set to delta_name.
+ unique_name : str | None, default=None
+ Unique name of the diffusion model. If None, a unique name will be generated.
+ """
+ self._verify_radius(radius)
+ self._verify_rotational_diffusion_coefficient(rotational_diffusion_coefficient)
+ self._verify_l_max(l_max)
+
+ super().__init__(
+ int(l_max),
+ scale=scale,
+ Q=Q,
+ x_unit=x_unit,
+ y_unit=y_unit,
+ name=name,
+ display_name=display_name,
+ lorentzian_name=lorentzian_name,
+ lorentzian_display_name=lorentzian_display_name,
+ delta_name=delta_name,
+ delta_display_name=delta_display_name,
+ unique_name=unique_name,
+ )
+
+ self._radius = Parameter(
+ name='radius',
+ value=float(radius),
+ fixed=True,
+ min=MINIMUM_RADIUS,
+ unit='angstrom',
+ )
+ self._rotational_diffusion_coefficient = Parameter(
+ name='rotational_diffusion_coefficient',
+ value=float(rotational_diffusion_coefficient),
+ fixed=False,
+ min=MINIMUM_ROTATIONAL_DIFFUSION_COEFFICIENT,
+ unit='1/ps',
+ )
+
+ self._hbar = hbar
+ # Dimensionless one, which turns the plain numbers of the weight expressions into
+ # (dimensionless) descriptors, as dependency expressions must evaluate to one.
+ self._one = DescriptorNumber(name='one', value=1.0)
+
+ # Dimensionless radius/angstrom for the dependency expressions. Deriving it through the
+ # dependency graph keeps the weights correct if the radius is converted to another unit.
+ with np.errstate(invalid='ignore'):
+ self._radius_over_angstrom = Parameter.from_dependency(
+ name='radius_over_angstrom',
+ dependency_expression='radius / angstrom',
+ dependency_map={'radius': self._radius, 'angstrom': angstrom},
+ )
+ self._radius_over_angstrom.set_desired_unit('dimensionless')
+
+ self.create_component_collections()
+
+ # ------------------------------------------------------------------
+ # Properties
+ # ------------------------------------------------------------------
+
+ @property
+ def radius(self) -> Parameter:
+ """
+ Get the radius of the sphere.
+
+ Returns
+ -------
+ Parameter
+ Radius R.
+ """
+ return self._radius
+
+ @radius.setter
+ def radius(self, radius: Numeric) -> None:
+ """
+ Set the radius of the sphere.
+
+ Parameters
+ ----------
+ radius : Numeric
+ Radius R in the unit of the radius parameter (angstrom by default).
+ """
+ self._verify_radius(radius)
+ self._radius.value = float(radius)
+
+ @property
+ def rotational_diffusion_coefficient(self) -> Parameter:
+ """
+ Get the rotational diffusion coefficient.
+
+ Returns
+ -------
+ Parameter
+ Rotational diffusion coefficient D_R.
+ """
+ return self._rotational_diffusion_coefficient
+
+ @rotational_diffusion_coefficient.setter
+ def rotational_diffusion_coefficient(self, rotational_diffusion_coefficient: Numeric) -> None:
+ """
+ Set the rotational diffusion coefficient.
+
+ Parameters
+ ----------
+ rotational_diffusion_coefficient : Numeric
+ Rotational diffusion coefficient D_R in the unit of the parameter (1/ps by default).
+ """
+ self._verify_rotational_diffusion_coefficient(rotational_diffusion_coefficient)
+ self._rotational_diffusion_coefficient.value = float(rotational_diffusion_coefficient)
+
+ @property
+ def l_max(self) -> int:
+ """
+ Get the order at which the sum is cut.
+
+ Returns
+ -------
+ int
+ l_max, the number of Lorentzians per Q-value. Set at construction.
+ """
+ return self.number_of_lorentzians
+
+ # ------------------------------------------------------------------
+ # Physics
+ # ------------------------------------------------------------------
+
+ def calculate_width(self, Q: Q_type | None = None, order: int = 1) -> np.ndarray:
+ r"""
+ Calculate the half-width at half-maximum (HWHM) $\Gamma_l$ of the Lorentzian of order $l$.
+
+ The width is $l(l+1)\hbar D_R$ and does not depend on Q.
+
+ Parameters
+ ----------
+ Q : Q_type | None, default=None
+ Scattering vector in 1/angstrom.
+ order : int, default=1
+ The order l of the Lorentzian, between 1 and l_max.
+
+ Returns
+ -------
+ np.ndarray
+ HWHM values in the unit of the model (e.g., meV).
+ """
+ self._verify_order(order)
+ Q = self._ensure_Q(Q)
+
+ width = self._hbar * self.rotational_diffusion_coefficient
+ width.convert_unit(self.x_unit)
+ return order * (order + 1) * width.value * np.ones_like(Q)
+
+ def calculate_EISF(self, Q: Q_type | None = None) -> np.ndarray:
+ """
+ Calculate the Elastic Incoherent Structure Factor (EISF), $j_0^2(QR)$.
+
+ Parameters
+ ----------
+ Q : Q_type | None, default=None
+ Scattering vector in 1/angstrom.
+
+ Returns
+ -------
+ np.ndarray
+ EISF values (dimensionless).
+ """
+ Q = self._ensure_Q(Q)
+ return spherical_jn(0, Q * self._radius_in_angstrom()) ** 2
+
+ def calculate_QISF_order(self, Q: Q_type | None = None, order: int = 1) -> np.ndarray:
+ r"""
+ Calculate the Quasi-Elastic Incoherent Structure Factor (QISF) of one order, $A_l(Q)$.
+
+ $A_l(Q) = (2l+1)\, j_l^2(QR)$ is the weight of the Lorentzian of order $l$.
+
+ Parameters
+ ----------
+ Q : Q_type | None, default=None
+ Scattering vector in 1/angstrom.
+ order : int, default=1
+ The order l of the Lorentzian, between 1 and l_max.
+
+ Returns
+ -------
+ np.ndarray
+ QISF values of that order (dimensionless).
+ """
+ self._verify_order(order)
+ Q = self._ensure_Q(Q)
+ return (2 * order + 1) * spherical_jn(order, Q * self._radius_in_angstrom()) ** 2
+
+ def calculate_QISF(self, Q: Q_type | None = None) -> np.ndarray:
+ """
+ Calculate the total Quasi-Elastic Incoherent Structure Factor (QISF), the sum of the QISF
+ of all l_max orders.
+
+ Parameters
+ ----------
+ Q : Q_type | None, default=None
+ Scattering vector in 1/angstrom.
+
+ Returns
+ -------
+ np.ndarray
+ QISF values (dimensionless).
+ """
+ Q = self._ensure_Q(Q)
+ orders = np.arange(1, self.l_max + 1)[:, None]
+ QR = Q * self._radius_in_angstrom()
+ return np.sum((2 * orders + 1) * spherical_jn(orders, QR) ** 2, axis=0)
+
+ def calculate_missing_weight(self, Q: Q_type | None = None) -> np.ndarray:
+ """
+ Calculate the weight left out by cutting the sum at l_max.
+
+ Parameters
+ ----------
+ Q : Q_type | None, default=None
+ Scattering vector in 1/angstrom.
+
+ Returns
+ -------
+ np.ndarray
+ 1 - EISF - QISF (dimensionless).
+ """
+ Q = self._ensure_Q(Q)
+ return 1.0 - self.calculate_EISF(Q) - self.calculate_QISF(Q)
+
+ def create_component_collections(self) -> list[ComponentCollection]:
+ r"""
+ Create ComponentCollections for the model at the current Q values.
+
+ Each collection holds a delta function with area $K A_0(Q)$ (scale times EISF) and l_max
+ Lorentzians with widths $\Gamma_l$ and areas $K A_l(Q)$ (scale times the QISF of that
+ order), built by the base class. The sum is cut at l_max, so a warning is raised if that
+ leaves out more than MISSING_WEIGHT_TOLERANCE of the weight. The created collections are
+ installed on the model, so the returned list is the live one.
+
+ Returns
+ -------
+ list[ComponentCollection]
+ One ComponentCollection per Q value.
+ """
+ collections = super().create_component_collections()
+ self._warn_if_truncated()
+ return collections
+
+ def get_global_variables(self) -> list[Parameter]:
+ """
+ Get all global variables from the diffusion model.
+
+ Returns
+ -------
+ list[Parameter]
+ The scale, radius and rotational diffusion coefficient.
+ """
+ return [self.scale, self.radius, self.rotational_diffusion_coefficient]
+
+ # ------------------------------------------------------------------
+ # Private methods
+ # ------------------------------------------------------------------
+
+ def _radius_in_angstrom(self) -> float:
+ """
+ Get the radius in angstrom.
+
+ Returns
+ -------
+ float
+ The radius value in angstrom.
+ """
+ return sc.values(self.radius.full_value).to(unit='angstrom').value
+
+ def _create_weight(self, Q: float, order: int) -> Parameter:
+ r"""
+ Create the EISF (order 0) or the QISF of order $l$ as a dimensionless dependent Parameter
+ that follows the radius.
+
+ The dependency expressions cannot call scipy, so $j_l$ is written out in elementary
+ functions (``spherical_bessel_expression``). Keeping the weight separate from the scale
+ makes updates of the scale cheap.
+
+ Parameters
+ ----------
+ Q : float
+ Scattering vector in 1/angstrom.
+ order : int
+ The order l; 0 for the delta function (the EISF).
+
+ Raises
+ ------
+ TypeError
+ If Q is not a float.
+
+ Returns
+ -------
+ Parameter
+ The EISF or QISF, which becomes the weight of the component.
+ """
+ if not isinstance(Q, float):
+ raise TypeError('Q must be a float.')
+
+ # x is Q R and y = x**2, the names the Bessel expression uses
+ lines = [
+ f'x = {Q!r} * radius_ratio.value',
+ 'y = x**2',
+ f'jl = {spherical_bessel_expression(order)}',
+ f'one * {2 * order + 1} * jl**2',
+ ]
+ with np.errstate(invalid='ignore'):
+ return Parameter.from_dependency(
+ name=f'weight_l{order}_Q{Q:.2f}',
+ dependency_expression='\n'.join(lines),
+ dependency_map={'one': self._one, 'radius_ratio': self._radius_over_angstrom},
+ )
+
+ def _write_width_dependency_expression(self, order: int) -> str:
+ """
+ Write the dependency expression for the width of the Lorentzian of a given order.
+
+ Parameters
+ ----------
+ order : int
+ The order l of the Lorentzian.
+
+ Returns
+ -------
+ str
+ Dependency expression for the width.
+ """
+ return f'{order * (order + 1)} * hbar * D_R'
+
+ def _write_width_dependency_map_expression(self) -> dict[str, DescriptorNumber]:
+ """
+ Write the dependency map for the width.
+
+ Returns
+ -------
+ dict[str, DescriptorNumber]
+ Dependency map for the width.
+ """
+ return {'hbar': self._hbar, 'D_R': self.rotational_diffusion_coefficient}
+
+ def _warn_if_truncated(self) -> None:
+ """
+ Warn if cutting the sum at l_max leaves out too much weight.
+
+ The warning is raised if the missing weight exceeds MISSING_WEIGHT_TOLERANCE at any of the
+ model's Q values. Without Q values there is nothing to check.
+ """
+ if self.Q is None:
+ return
+ if np.max(self.calculate_missing_weight(), initial=0.0) > MISSING_WEIGHT_TOLERANCE:
+ warnings.warn(
+ f'Truncating the sum at l_max={self.l_max} omits more than '
+ f'{MISSING_WEIGHT_TOLERANCE} of the spectral weight at some Q values. '
+ f'Increase l_max (at most {MAXIMUM_L_MAX}).',
+ UserWarning,
+ stacklevel=3,
+ )
+
+ @staticmethod
+ def _verify_radius(radius: Numeric) -> None:
+ """
+ Verify a radius value.
+
+ Parameters
+ ----------
+ radius : Numeric
+ The radius to verify.
+
+ Raises
+ ------
+ TypeError
+ If radius is not a number.
+ ValueError
+ If radius is below MINIMUM_RADIUS.
+ """
+ if not isinstance(radius, Numeric):
+ raise TypeError('radius must be a number.')
+ if float(radius) < MINIMUM_RADIUS:
+ raise ValueError(f'radius must be at least {MINIMUM_RADIUS}.')
+
+ @staticmethod
+ def _verify_rotational_diffusion_coefficient(
+ rotational_diffusion_coefficient: Numeric,
+ ) -> None:
+ """
+ Verify a rotational diffusion coefficient value.
+
+ Parameters
+ ----------
+ rotational_diffusion_coefficient : Numeric
+ The rotational diffusion coefficient to verify.
+
+ Raises
+ ------
+ TypeError
+ If rotational_diffusion_coefficient is not a number.
+ ValueError
+ If rotational_diffusion_coefficient is below MINIMUM_ROTATIONAL_DIFFUSION_COEFFICIENT.
+ """
+ if not isinstance(rotational_diffusion_coefficient, Numeric):
+ raise TypeError('rotational_diffusion_coefficient must be a number.')
+ if float(rotational_diffusion_coefficient) < MINIMUM_ROTATIONAL_DIFFUSION_COEFFICIENT:
+ raise ValueError(
+ 'rotational_diffusion_coefficient must be at least '
+ f'{MINIMUM_ROTATIONAL_DIFFUSION_COEFFICIENT}.'
+ )
+
+ @staticmethod
+ def _verify_l_max(l_max: int) -> None:
+ """
+ Verify an l_max value.
+
+ Parameters
+ ----------
+ l_max : int
+ The order at which to cut the sum.
+
+ Raises
+ ------
+ TypeError
+ If l_max is not an integer.
+ ValueError
+ If l_max is not between 1 and MAXIMUM_L_MAX.
+ """
+ if isinstance(l_max, bool) or not isinstance(l_max, (int, np.integer)):
+ raise TypeError('l_max must be an integer.')
+ if not 1 <= l_max <= MAXIMUM_L_MAX:
+ raise ValueError(f'l_max must be between 1 and {MAXIMUM_L_MAX}.')
+
+ # ------------------------------------------------------------------
+ # dunder methods
+ # ------------------------------------------------------------------
+
+ def __repr__(self) -> str:
+ """
+ String representation of the IsotropicRotationalDiffusion model.
+
+ Returns
+ -------
+ str
+ String representation of the model.
+ """
+ return (
+ f'{self.__class__.__name__}('
+ f'name={self.name!r}, display_name={self.display_name!r},\n'
+ f'x_unit={self.x_unit}, y_unit={self.y_unit},\n'
+ f' radius={self.radius},\n'
+ f' rotational_diffusion_coefficient={self.rotational_diffusion_coefficient},\n'
+ f' l_max={self.l_max},\n'
+ f' scale={self.scale})'
+ )
diff --git a/src/easydynamics/sample_model/diffusion_model/jump_sites_log_normal_distribution.py b/src/easydynamics/sample_model/diffusion_model/jump_sites_log_normal_distribution.py
new file mode 100644
index 00000000..61cbb887
--- /dev/null
+++ b/src/easydynamics/sample_model/diffusion_model/jump_sites_log_normal_distribution.py
@@ -0,0 +1,651 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+
+import numpy as np
+import scipp as sc
+from easyscience.variable import DescriptorNumber
+from easyscience.variable import Parameter
+
+from easydynamics.sample_model.diffusion_model.delta_lorentzian_diffusion_model_base import (
+ DeltaLorentzianDiffusionModelBase,
+)
+from easydynamics.sample_model.diffusion_model.equivalent_sites_circle import (
+ MAXIMUM_NUMBER_OF_SITES,
+)
+from easydynamics.sample_model.diffusion_model.equivalent_sites_circle import MINIMUM_RADIUS
+from easydynamics.sample_model.diffusion_model.equivalent_sites_circle import MINIMUM_WIDTH
+from easydynamics.utils.utils import Numeric
+from easydynamics.utils.utils import Q_type
+from easydynamics.utils.utils import angstrom
+from easydynamics.utils.utils import hbar
+
+# The sampling of the log-normal distribution, as in QENSmodels jump_sites_log_norm_dist.py:
+# https://github.com/QENSlibrary/QENSmodels/blob/main/QENSmodels/jump_sites_log_norm_dist.py
+NUMBER_OF_SAMPLES = 21 # 2 n_max + 1 widths per circle order, with n_max = 10
+LOWER_LIMIT = 0.1 # Smallest sampled weight relative to the largest
+MINIMUM_SIGMA = 1e-10 # A zero width of the distribution gives a 0/0 in the sampling grid
+
+# Half the number of sampling steps, and log(ratio) / sigma at the edge of the grid
+_HALF_STEPS = (NUMBER_OF_SAMPLES - 1) // 2
+_EDGE = float(np.sqrt(-2.0 * np.log(LOWER_LIMIT)))
+
+
+class JumpSitesLogNormalDistribution(DeltaLorentzianDiffusionModelBase):
+ r"""
+ Model of jumps between N equivalent sites on a circle with a log-normal distribution of jump
+ rates (Chahid, Alegría and Colmenero).
+
+ $$ S(Q, E) = A_0(Q)\,\delta(E) + \sum_{l=1}^{N-1} A_l(Q) \sum_{s=0}^{20} g_s\, L(E, \Gamma_l
+ r_s) $$
+
+ with the weights and widths of the equivalent-sites circle model,
+
+ $$ A_l(Q) = \frac{1}{N} \sum_{k=1}^{N} j_0(Q r_k) \cos\left(\frac{2\pi l k}{N}\right), \quad
+ r_k = 2R \sin\left(\frac{\pi k}{N}\right), \quad \Gamma_l = \frac{2\hbar}{\tau}
+ \sin^2\left(\frac{\pi l}{N}\right), $$
+
+ spread over 21 log-spaced widths $\Gamma_l r_s$ with $\ln r_s = \sigma c (s/10 - 1)$, $c =
+ \sqrt{-2 \ln 0.1}$, and weights $g_s \propto \exp(-\ln^2 r_s / (2\sigma^2))$ normalised to
+ $\sum_s g_s = 1$. Because the grid scales with $\sigma$, the $g_s$ do not depend on $\sigma$;
+ only the widths do. For $\sigma \to 0$ this is the equivalent-sites circle model.
+
+ $A_0$ is the EISF and $g_s A_l$ the QISF of order $j$, where the Lorentzian of order $j = (l -
+ 1) \cdot 21 + s + 1$, from 1 to $21 (N - 1)$, has circle order $l$ and sample $s$. The 21
+ widths are cut where the weight falls to 0.1 of its maximum.
+
+ The radius is fixed by default, since it is normally known from the molecular geometry.
+
+ References
+ ----------
+ 1. A. Chahid, A. Alegría and J. Colmenero, Macromolecules 27, 3282 (1994),
+ doi:10.1021/ma00090a022.
+ 2. QENSmodels, jump_sites_log_norm_dist,
+ https://github.com/QENSlibrary/QENSmodels/blob/main/QENSmodels/jump_sites_log_norm_dist.py.
+
+ Examples
+ --------
+ **Creating a JumpSitesLogNormalDistribution model**
+
+ The model creates one delta function and 21 (N - 1) Lorentzians per Q-value:
+ ```python
+ import numpy as np
+ import easydynamics as edyn
+
+ model = edyn.JumpSitesLogNormalDistribution(
+ radius=1.0,
+ residence_time=2.0,
+ sigma=0.5,
+ number_of_sites=3,
+ Q=np.linspace(0.5, 2, 7),
+ )
+ ```
+ """
+
+ _order_symbol = 'j' # components are named '_j'
+
+ def __init__(
+ self,
+ scale: Numeric = 1.0,
+ radius: Numeric = 1.0,
+ residence_time: Numeric = 1.0,
+ sigma: Numeric = 1.0,
+ number_of_sites: int = 3,
+ Q: Q_type | None = None,
+ x_unit: str | sc.Unit = 'meV',
+ y_unit: str | sc.Unit = 'dimensionless',
+ name: str = 'JumpSitesLogNormalDistribution',
+ display_name: str | None = 'JumpSitesLogNormalDistribution',
+ lorentzian_name: str | None = None,
+ lorentzian_display_name: str | None = None,
+ delta_name: str = 'Delta function',
+ delta_display_name: str | None = None,
+ unique_name: str | None = None,
+ ) -> None:
+ """
+ Initialize a new JumpSitesLogNormalDistribution model.
+
+ Parameters
+ ----------
+ scale : Numeric, default=1.0
+ Scale factor for the diffusion model. Must be a non-negative number.
+ radius : Numeric, default=1.0
+ Radius of the circle in angstrom. Must be positive. Fixed by default.
+ residence_time : Numeric, default=1.0
+ Residence time tau on a site in ps, the centre of the distribution.
+ sigma : Numeric, default=1.0
+ Standard deviation of the log-normal distribution of jump rates (dimensionless). Must
+ be at least MINIMUM_SIGMA.
+ number_of_sites : int, default=3
+ Number of equivalent sites N on the circle, between 2 and MAXIMUM_NUMBER_OF_SITES. Set
+ at construction; not a fit parameter.
+ Q : Q_type | None, default=None
+ Q values for the model. If None, Q is not set.
+ x_unit : str | sc.Unit, default='meV'
+ Unit of the x-axis (energy/frequency). Must be convertible to meV.
+ y_unit : str | sc.Unit, default='dimensionless'
+ Unit of the model output (intensity). Determines scale.unit = x_unit * y_unit.
+ name : str, default='JumpSitesLogNormalDistribution'
+ Name of the diffusion model.
+ display_name : str | None, default='JumpSitesLogNormalDistribution'
+ Display name of the diffusion model.
+ lorentzian_name : str | None, default=None
+ Base name of the Lorentzian components. The Lorentzian of order j is named
+ '_j'. If None, it will be set to the name of the diffusion model.
+ lorentzian_display_name : str | None, default=None
+ Base display name of the Lorentzian components. If None, it will be set to
+ lorentzian_name.
+ delta_name : str, default='Delta function'
+ Name of the delta function component.
+ delta_display_name : str | None, default=None
+ Display name of the delta function component. If None, it will be set to delta_name.
+ unique_name : str | None, default=None
+ Unique name of the diffusion model. If None, a unique name will be generated.
+
+ Raises
+ ------
+ TypeError
+ If radius, residence_time or sigma is not a number, if number_of_sites is not an
+ integer or if delta_name or delta_display_name is not a string (or None).
+ ValueError
+ If radius, residence_time or sigma is smaller than its minimum, or if number_of_sites
+ is not between 2 and MAXIMUM_NUMBER_OF_SITES.
+ """
+ self._verify_radius(radius)
+ self._verify_residence_time(residence_time)
+ self._verify_sigma(sigma)
+
+ if isinstance(number_of_sites, bool) or not isinstance(number_of_sites, (int, np.integer)):
+ raise TypeError('number_of_sites must be an integer.')
+ if not 2 <= number_of_sites <= MAXIMUM_NUMBER_OF_SITES:
+ raise ValueError(f'number_of_sites must be between 2 and {MAXIMUM_NUMBER_OF_SITES}.')
+ self._number_of_sites = int(number_of_sites)
+
+ super().__init__(
+ NUMBER_OF_SAMPLES * (self._number_of_sites - 1),
+ scale=scale,
+ Q=Q,
+ x_unit=x_unit,
+ y_unit=y_unit,
+ name=name,
+ display_name=display_name,
+ lorentzian_name=lorentzian_name,
+ lorentzian_display_name=lorentzian_display_name,
+ delta_name=delta_name,
+ delta_display_name=delta_display_name,
+ unique_name=unique_name,
+ )
+
+ self._radius = Parameter(
+ name='radius',
+ value=float(radius),
+ fixed=True,
+ min=MINIMUM_RADIUS,
+ unit='angstrom',
+ )
+ self._residence_time = Parameter(
+ name='residence_time',
+ value=float(residence_time),
+ fixed=False,
+ min=MINIMUM_WIDTH,
+ unit='ps',
+ )
+ self._sigma = Parameter(
+ name='sigma',
+ value=float(sigma),
+ fixed=False,
+ min=MINIMUM_SIGMA,
+ unit='dimensionless',
+ )
+
+ self._hbar = hbar
+ # Dimensionless one, which turns the plain numbers of the weight expressions into
+ # (dimensionless) descriptors, as dependency expressions must evaluate to one.
+ self._one = DescriptorNumber(name='one', value=1.0)
+
+ # Dimensionless radius/angstrom for the weight dependency expressions. Deriving it through
+ # the dependency graph keeps the weights correct if the radius is converted to another
+ # unit (e.g. nm).
+ with np.errstate(invalid='ignore'):
+ self._radius_over_angstrom = Parameter.from_dependency(
+ name='radius_over_angstrom',
+ dependency_expression='radius / angstrom',
+ dependency_map={'radius': self._radius, 'angstrom': angstrom},
+ )
+ self._radius_over_angstrom.set_desired_unit('dimensionless')
+
+ self.create_component_collections()
+
+ # ------------------------------------------------------------------
+ # Properties
+ # ------------------------------------------------------------------
+
+ @property
+ def radius(self) -> Parameter:
+ """
+ Get the radius of the circle.
+
+ Returns
+ -------
+ Parameter
+ Radius R.
+ """
+ return self._radius
+
+ @radius.setter
+ def radius(self, radius: Numeric) -> None:
+ """
+ Set the radius of the circle.
+
+ Parameters
+ ----------
+ radius : Numeric
+ Radius R in the unit of the radius parameter (angstrom by default).
+ """
+ self._verify_radius(radius)
+ self._radius.value = float(radius)
+
+ @property
+ def residence_time(self) -> Parameter:
+ """
+ Get the residence time on a site, the centre of the distribution.
+
+ Returns
+ -------
+ Parameter
+ Residence time tau.
+ """
+ return self._residence_time
+
+ @residence_time.setter
+ def residence_time(self, residence_time: Numeric) -> None:
+ """
+ Set the residence time on a site.
+
+ Parameters
+ ----------
+ residence_time : Numeric
+ Residence time tau in ps.
+ """
+ self._verify_residence_time(residence_time)
+ self._residence_time.value = float(residence_time)
+
+ @property
+ def sigma(self) -> Parameter:
+ """
+ Get the standard deviation of the log-normal distribution of jump rates.
+
+ Returns
+ -------
+ Parameter
+ Sigma (dimensionless).
+ """
+ return self._sigma
+
+ @sigma.setter
+ def sigma(self, sigma: Numeric) -> None:
+ """
+ Set the standard deviation of the log-normal distribution of jump rates.
+
+ Parameters
+ ----------
+ sigma : Numeric
+ Sigma (dimensionless).
+ """
+ self._verify_sigma(sigma)
+ self._sigma.value = float(sigma)
+
+ @property
+ def number_of_sites(self) -> int:
+ """
+ Get the number of equivalent sites on the circle.
+
+ Returns
+ -------
+ int
+ The number of sites N. Set at construction.
+ """
+ return self._number_of_sites
+
+ # ------------------------------------------------------------------
+ # Other methods
+ # ------------------------------------------------------------------
+
+ def sample_weights(self) -> np.ndarray:
+ r"""
+ Get the weights $g_s$ of the 21 sampled widths, which sum to one.
+
+ $g_s \propto \exp(-c^2 (s/10 - 1)^2 / 2)$: the log-normal weight $\exp(-\ln^2 r_s /
+ (2\sigma^2))$ with $\ln r_s = \sigma c (s/10 - 1)$, so it does not depend on $\sigma$.
+
+ Returns
+ -------
+ np.ndarray
+ The 21 weights.
+ """
+ log_ratio_per_sigma = _EDGE * (np.arange(NUMBER_OF_SAMPLES) / _HALF_STEPS - 1.0)
+ weights = np.exp(-0.5 * log_ratio_per_sigma**2)
+ return weights / np.sum(weights)
+
+ def calculate_width(self, Q: Q_type | None = None, order: int = 1) -> np.ndarray:
+ r"""
+ Calculate the half-width at half-maximum (HWHM) of the Lorentzian of a given order.
+
+ The width is $(2\hbar/\tau) \sin^2(\pi l / N) \exp(\sigma c (s/10 - 1))$ and does not
+ depend on Q.
+
+ Parameters
+ ----------
+ Q : Q_type | None, default=None
+ Scattering vector in 1/angstrom.
+ order : int, default=1
+ The order j of the Lorentzian, between 1 and 21 (N - 1).
+
+ Returns
+ -------
+ np.ndarray
+ HWHM values in the unit of the model (e.g., meV).
+ """
+ self._verify_order(order)
+ Q = self._ensure_Q(Q)
+ circle_order, s = self._split_order(order)
+
+ rate = 2 * self._hbar / self.residence_time
+ rate.convert_unit(self.x_unit)
+ angle = sc.scalar(np.pi * circle_order / self.number_of_sites, unit='rad')
+ ratio = np.exp(self.sigma.value * _EDGE * (s / _HALF_STEPS - 1.0))
+ return rate.value * sc.sin(angle).value ** 2 * ratio * np.ones_like(Q)
+
+ def calculate_EISF(self, Q: Q_type | None = None) -> np.ndarray:
+ """
+ Calculate the Elastic Incoherent Structure Factor (EISF), $A_0(Q)$.
+
+ Parameters
+ ----------
+ Q : Q_type | None, default=None
+ Scattering vector in 1/angstrom.
+
+ Returns
+ -------
+ np.ndarray
+ EISF values (dimensionless).
+ """
+ Q = self._ensure_Q(Q)
+ return self._circle_structure_factors(Q)['l', 0].values
+
+ def calculate_QISF_order(self, Q: Q_type | None = None, order: int = 1) -> np.ndarray:
+ """
+ Calculate the Quasi-Elastic Incoherent Structure Factor (QISF) of one order, $g_s A_l(Q)$,
+ the weight of the Lorentzian of order $j$ (circle order $l$, sample $s$).
+
+ Parameters
+ ----------
+ Q : Q_type | None, default=None
+ Scattering vector in 1/angstrom.
+ order : int, default=1
+ The order j of the Lorentzian, between 1 and 21 (N - 1).
+
+ Returns
+ -------
+ np.ndarray
+ QISF values of that order (dimensionless).
+ """
+ self._verify_order(order)
+ Q = self._ensure_Q(Q)
+ circle_order, s = self._split_order(order)
+ return (
+ self.sample_weights()[s] * self._circle_structure_factors(Q)['l', circle_order].values
+ )
+
+ def calculate_QISF(self, Q: Q_type | None = None) -> np.ndarray:
+ """
+ Calculate the total Quasi-Elastic Incoherent Structure Factor (QISF), the sum of the QISF
+ of all 21 (N - 1) orders.
+
+ Parameters
+ ----------
+ Q : Q_type | None, default=None
+ Scattering vector in 1/angstrom.
+
+ Returns
+ -------
+ np.ndarray
+ QISF values (dimensionless).
+ """
+ Q = self._ensure_Q(Q)
+ circle_QISF = sc.sum(self._circle_structure_factors(Q)['l', 1:], 'l').values
+ return np.sum(self.sample_weights()) * circle_QISF
+
+ def get_global_variables(self) -> list[Parameter]:
+ """
+ Get all global variables from the diffusion model.
+
+ Returns
+ -------
+ list[Parameter]
+ The scale, radius, residence time and sigma.
+ """
+ return [self.scale, self.radius, self.residence_time, self.sigma]
+
+ # ------------------------------------------------------------------
+ # Private methods
+ # ------------------------------------------------------------------
+
+ def _split_order(self, order: int) -> tuple[int, int]:
+ """
+ Split a Lorentzian order j into its circle order l and sample s.
+
+ Parameters
+ ----------
+ order : int
+ The order j, between 1 and 21 (N - 1).
+
+ Returns
+ -------
+ tuple[int, int]
+ The circle order l (1 to N - 1) and the sample s (0 to 20).
+ """
+ return (int(order) - 1) // NUMBER_OF_SAMPLES + 1, (int(order) - 1) % NUMBER_OF_SAMPLES
+
+ def _circle_structure_factors(self, Q: np.ndarray) -> sc.Variable:
+ r"""
+ Calculate the circle model's EISF and QISF, $A_l(Q)$ for all circle orders $l = 0 \ldots N
+ - 1$, with Scipp.
+
+ $A_l(Q) = \frac{1}{N} \sum_{k=1}^{N} j_0(Q r_k) \cos(2\pi l k / N)$ with $r_k = 2R\sin(\pi
+ k / N)$, as in EquivalentSitesCircle. The variance of the radius is not propagated.
+
+ Parameters
+ ----------
+ Q : np.ndarray
+ Scattering vector in 1/angstrom.
+
+ Returns
+ -------
+ sc.Variable
+ Dimensionless $A_l(Q)$ with dims ``('Q', 'l')``; l = 0 is the EISF.
+ """
+ N = self.number_of_sites
+ site = sc.arange('k', 1.0, N + 1.0)
+ circle_order = sc.arange('l', 0.0, float(N))
+ half_turn = sc.scalar(np.pi, unit='rad')
+
+ radius = sc.values(self.radius.full_value).to(unit='angstrom')
+ jump_distance = 2 * radius * sc.sin(half_turn * site / N)
+ Q_variable = sc.array(dims=['Q'], values=Q, unit='1/angstrom')
+
+ weights = sc.sum(
+ sc.sinc(Q_variable * jump_distance) * sc.cos(2 * half_turn * circle_order * site / N),
+ 'k',
+ )
+ return (weights / N).transpose(['Q', 'l'])
+
+ def _create_weight(self, Q: float, order: int) -> Parameter:
+ """
+ Create the EISF ($j = 0$) or the QISF of order $j$, $g_s A_l(Q)$, as a dimensionless
+ dependent Parameter that follows the radius.
+
+ The dependency expressions cannot call Scipp, so the circle's sum over sites is written out
+ with NumPy's sinc, sinc(x) = sin(pi x) / (pi x), and multiplied by the constant g_s.
+ Keeping the weight separate from the scale makes updates of the scale cheap.
+
+ Parameters
+ ----------
+ Q : float
+ Scattering vector in 1/angstrom.
+ order : int
+ The order j; 0 for the delta function (the EISF).
+
+ Raises
+ ------
+ TypeError
+ If Q is not a float.
+
+ Returns
+ -------
+ Parameter
+ The weight.
+ """
+ if not isinstance(Q, float):
+ raise TypeError('Q must be a float.')
+
+ if order == 0:
+ circle_order, sample_weight = 0, 1.0
+ else:
+ circle_order, s = self._split_order(order)
+ sample_weight = float(self.sample_weights()[s])
+
+ N = self.number_of_sites
+ terms = []
+ for site in range(1, N + 1):
+ # float(): NumPy 2 writes np.float64 as 'np.float64(...)', which asteval cannot parse
+ coefficient = float(sample_weight * np.cos(2 * np.pi * circle_order * site / N) / N)
+ # j0(Q r_k) = sinc(Q r_k / pi) with r_k = 2 R sin(pi k / N)
+ argument = float(Q * 2 * np.sin(np.pi * site / N) / np.pi)
+ terms.append(f'{coefficient!r} * sinc({argument!r} * radius_ratio.value)')
+ expression = f'one * ({" + ".join(terms)})'
+ with np.errstate(invalid='ignore'):
+ return Parameter.from_dependency(
+ name=f'weight_j{order}_Q{Q:.2f}',
+ dependency_expression=expression,
+ dependency_map={'one': self._one, 'radius_ratio': self._radius_over_angstrom},
+ )
+
+ def _write_width_dependency_expression(self, order: int) -> str:
+ """
+ Write the dependency expression for the width of the Lorentzian of a given order.
+
+ Parameters
+ ----------
+ order : int
+ The order j of the Lorentzian.
+
+ Returns
+ -------
+ str
+ Dependency expression for the width.
+ """
+ circle_order, s = self._split_order(order)
+ factor = float(2 * np.sin(np.pi * circle_order / self.number_of_sites) ** 2)
+ log_ratio_per_sigma = float(_EDGE * (s / _HALF_STEPS - 1.0))
+ return f'{factor!r} * exp({log_ratio_per_sigma!r} * sigma.value) * hbar / tau'
+
+ def _write_width_dependency_map_expression(self) -> dict[str, DescriptorNumber]:
+ """
+ Write the dependency map for the width.
+
+ Returns
+ -------
+ dict[str, DescriptorNumber]
+ Dependency map for the width.
+ """
+ return {'hbar': self._hbar, 'tau': self.residence_time, 'sigma': self.sigma}
+
+ @staticmethod
+ def _verify_radius(radius: Numeric) -> None:
+ """
+ Verify a radius value.
+
+ Parameters
+ ----------
+ radius : Numeric
+ The radius to verify.
+
+ Raises
+ ------
+ TypeError
+ If radius is not a number.
+ ValueError
+ If radius is below MINIMUM_RADIUS.
+ """
+ if not isinstance(radius, Numeric):
+ raise TypeError('radius must be a number.')
+ if float(radius) < MINIMUM_RADIUS:
+ raise ValueError(f'radius must be at least {MINIMUM_RADIUS}.')
+
+ @staticmethod
+ def _verify_residence_time(residence_time: Numeric) -> None:
+ """
+ Verify a residence time value.
+
+ Parameters
+ ----------
+ residence_time : Numeric
+ The residence time to verify.
+
+ Raises
+ ------
+ TypeError
+ If residence_time is not a number.
+ ValueError
+ If residence_time is below MINIMUM_WIDTH.
+ """
+ if not isinstance(residence_time, Numeric):
+ raise TypeError('residence_time must be a number.')
+ if float(residence_time) < MINIMUM_WIDTH:
+ raise ValueError(f'residence_time must be at least {MINIMUM_WIDTH}.')
+
+ @staticmethod
+ def _verify_sigma(sigma: Numeric) -> None:
+ """
+ Verify a sigma value.
+
+ Parameters
+ ----------
+ sigma : Numeric
+ The standard deviation of the distribution to verify.
+
+ Raises
+ ------
+ TypeError
+ If sigma is not a number.
+ ValueError
+ If sigma is below MINIMUM_SIGMA.
+ """
+ if not isinstance(sigma, Numeric):
+ raise TypeError('sigma must be a number.')
+ if float(sigma) < MINIMUM_SIGMA:
+ raise ValueError(f'sigma must be at least {MINIMUM_SIGMA}.')
+
+ # ------------------------------------------------------------------
+ # dunder methods
+ # ------------------------------------------------------------------
+
+ def __repr__(self) -> str:
+ """
+ String representation of the JumpSitesLogNormalDistribution model.
+
+ Returns
+ -------
+ str
+ String representation of the model.
+ """
+ return (
+ f'{self.__class__.__name__}('
+ f'name={self.name!r}, display_name={self.display_name!r},\n'
+ f'x_unit={self.x_unit}, y_unit={self.y_unit},\n'
+ f' number_of_sites={self.number_of_sites},\n'
+ f' radius={self.radius},\n'
+ f' residence_time={self.residence_time},\n'
+ f' sigma={self.sigma},\n'
+ f' scale={self.scale})'
+ )
diff --git a/src/easydynamics/sample_model/diffusion_model/spherical_bessel_expression.py b/src/easydynamics/sample_model/diffusion_model/spherical_bessel_expression.py
new file mode 100644
index 00000000..47edec33
--- /dev/null
+++ b/src/easydynamics/sample_model/diffusion_model/spherical_bessel_expression.py
@@ -0,0 +1,144 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""
+Spherical Bessel functions written as dependency expressions.
+
+EasyScience Core evaluates dependency expressions with asteval and cannot call scipy or numba
+there, so a model whose dependent Parameters need j_l(x) (for example ``DiffusionInSphere``) writes
+it out in elementary functions with ``spherical_bessel_expression``. Python-side code uses
+``easydynamics.utils.spherical_bessel.spherical_jn`` instead.
+"""
+
+import math
+
+import numpy as np
+
+SERIES_TOLERANCE = 1e-17 # Relative size of the first neglected power-series term at the switch
+
+
+def spherical_bessel_expression(order: int) -> str:
+ r"""
+ Write the spherical Bessel function j_order(x) as an expression in elementary functions.
+
+ The expression uses the names ``x`` and ``y`` (with ``y = x**2``), which must be assigned
+ before it in the dependency expression. For $x \geq \max(l, 1)$ it is the finite sum for
+ half-integer Bessel functions,
+
+ $$ j_l(x) = \frac{1}{x}\left[\sin\left(x - \frac{l\pi}{2}\right) \sum_k \frac{(-1)^k
+ (l+2k)!}{(2k)!\,(l-2k)!\,(2x)^{2k}} + \cos\left(x - \frac{l\pi}{2}\right) \sum_k \frac{(-1)^k
+ (l+2k+1)!}{(2k+1)!\,(l-2k-1)!\,(2x)^{2k+1}}\right], $$
+
+ and below it the power series
+
+ $$ j_l(x) = \frac{x^l}{(2l+1)!!} \sum_m \frac{(-y/2)^m}{m!\,(2l+3)(2l+5)\cdots(2l+2m+1)}, $$
+
+ written in nested (Horner) form, with as many terms as needed for SERIES_TOLERANCE at $x =
+ \max(l, 1)$. The finite sum loses digits to cancellation at small x and the series at large x;
+ the switch at $x = \max(l, 1)$ keeps both accurate.
+
+ Parameters
+ ----------
+ order : int
+ The order l, a non-negative integer.
+
+ Raises
+ ------
+ TypeError
+ If order is not an integer.
+ ValueError
+ If order is negative.
+
+ Returns
+ -------
+ str
+ An expression evaluating to j_order(x).
+ """
+ if isinstance(order, bool) or not isinstance(order, (int, np.integer)):
+ raise TypeError('order must be an integer.')
+ if order < 0:
+ raise ValueError('order must be non-negative.')
+ order = int(order)
+
+ switch = max(order, 1)
+ return f'(({_finite_sum(order)}) if x >= {switch} else ({_power_series(order)}))'
+
+
+def _finite_sum(order: int) -> str:
+ """
+ Write the finite sum for j_order(x), accurate for x of order and above.
+
+ Parameters
+ ----------
+ order : int
+ The order l.
+
+ Returns
+ -------
+ str
+ The expression, in the name ``x``.
+ """
+ sin_terms = []
+ for k in range(order // 2 + 1):
+ coefficient = (-1) ** k * math.factorial(order + 2 * k)
+ coefficient /= math.factorial(2 * k) * math.factorial(order - 2 * k)
+ sin_terms.append(f'{float(coefficient)!r} * (2 * x)**{-2 * k}')
+ cos_terms = []
+ for k in range((order - 1) // 2 + 1):
+ coefficient = (-1) ** k * math.factorial(order + 2 * k + 1)
+ coefficient /= math.factorial(2 * k + 1) * math.factorial(order - 2 * k - 1)
+ cos_terms.append(f'{float(coefficient)!r} * (2 * x)**{-2 * k - 1}')
+
+ phase = float(order * np.pi / 2)
+ expression = f'sin(x - {phase!r}) * ({" + ".join(sin_terms)})'
+ if cos_terms:
+ expression += f' + cos(x - {phase!r}) * ({" + ".join(cos_terms)})'
+ return f'({expression}) / x'
+
+
+def _power_series(order: int) -> str:
+ """
+ Write the power series for j_order(x), accurate for x up to about order.
+
+ Parameters
+ ----------
+ order : int
+ The order l.
+
+ Returns
+ -------
+ str
+ The expression, in the names ``x`` and ``y = x**2``.
+ """
+ series = '1'
+ for m in range(_series_terms(order) - 1, 0, -1):
+ # ratio of term m to term m - 1: -y / (2 m (2l + 2m + 1))
+ series = f'(1 - y / {float(2 * m * (2 * order + 2 * m + 1))!r} * {series})'
+ double_factorial = float(math.prod(range(2 * order + 1, 0, -2)))
+ return f'x**{order} / {double_factorial!r} * {series}'
+
+
+def _series_terms(order: int) -> int:
+ """
+ Count the power-series terms needed at the switch point x = max(order, 1).
+
+ Terms are added until the next one is smaller than SERIES_TOLERANCE times the first, and at
+ least order + 1 terms are used, past the largest term.
+
+ Parameters
+ ----------
+ order : int
+ The order l.
+
+ Returns
+ -------
+ int
+ The number of terms, including the leading one.
+ """
+ y = float(max(order, 1)) ** 2
+ term = 1.0
+ m = 0
+ while True:
+ m += 1
+ term *= y / (2 * m * (2 * order + 2 * m + 1))
+ if m > order and term < SERIES_TOLERANCE:
+ return m + 1
diff --git a/src/easydynamics/utils/spherical_bessel.py b/src/easydynamics/utils/spherical_bessel.py
new file mode 100644
index 00000000..e1b459ee
--- /dev/null
+++ b/src/easydynamics/utils/spherical_bessel.py
@@ -0,0 +1,89 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+"""
+Spherical Bessel functions of the first kind, j_n(x), computed by scipy's compiled code called from
+numba.
+
+``scipy.special.spherical_jn`` has a high cost per call in scipy 1.18. The numba kernel calls the
+same compiled C function directly (through ``scipy.special.cython_special``), so its results are
+bit-for-bit those of scipy, about ten times faster for small arrays. The name of that C function is
+a scipy implementation detail; if it cannot be found, or numba is not installed (it is optional:
+``pip install easydynamics[fast]``), scipy's Python function is used instead, with the same values.
+"""
+
+import ctypes
+import functools
+from collections.abc import Callable
+
+import numpy as np
+import scipy.special
+
+# The float64 version of spherical_jn in scipy.special.cython_special (__pyx_fuse_0 is complex)
+_CYTHON_MODULE = 'scipy.special.cython_special'
+_CYTHON_FUNCTION = '__pyx_fuse_1spherical_jn'
+
+
+def spherical_jn(order: int | np.ndarray, x: float | np.ndarray) -> np.ndarray:
+ """
+ Calculate the spherical Bessel function of the first kind, j_order(x).
+
+ ``order`` and ``x`` broadcast against each other, as in ``scipy.special.spherical_jn``, so all
+ orders can be computed in one call, e.g. ``spherical_jn(np.arange(5)[:, None], x)``.
+
+ Parameters
+ ----------
+ order : int | np.ndarray
+ Non-negative integer order(s).
+ x : float | np.ndarray
+ Real argument(s).
+
+ Returns
+ -------
+ np.ndarray
+ The values, float64, with the broadcast shape of ``order`` and ``x``.
+ """
+ kernel = _load_kernel()
+ if kernel is None:
+ return np.asarray(scipy.special.spherical_jn(order, x), dtype=float)
+
+ orders, arguments = np.broadcast_arrays(np.asarray(order), np.asarray(x, dtype=float))
+ out = np.empty(arguments.shape, dtype=float)
+ kernel(
+ np.ascontiguousarray(orders, dtype=np.int64).ravel(),
+ np.ascontiguousarray(arguments).ravel(),
+ out.reshape(-1),
+ )
+ return out
+
+
+@functools.cache
+def _load_kernel() -> Callable | None:
+ """
+ Build the numba kernel that calls scipy's compiled spherical_jn, once.
+
+ Returns
+ -------
+ Callable | None
+ ``kernel(orders, xs, out)`` filling ``out`` with j_orders(xs), or None if numba is not
+ installed or scipy's compiled function cannot be found.
+ """
+ try:
+ import numba
+ from numba.extending import get_cython_function_address
+
+ address = get_cython_function_address(_CYTHON_MODULE, _CYTHON_FUNCTION)
+ except (ImportError, ValueError, KeyError, AttributeError):
+ return None
+
+ # C signature: double spherical_jn(Py_ssize_t n, double z, int skip_dispatch, opt_args *).
+ # The opt_args pointer is declared as a pointer-sized integer so numba can pass 0 (NULL).
+ compiled_spherical_jn = ctypes.CFUNCTYPE(
+ ctypes.c_double, ctypes.c_ssize_t, ctypes.c_double, ctypes.c_int, ctypes.c_ssize_t
+ )(address)
+
+ @numba.njit(cache=False)
+ def kernel(orders: np.ndarray, xs: np.ndarray, out: np.ndarray) -> None:
+ for i in range(xs.size):
+ out[i] = compiled_spherical_jn(orders[i], xs[i], np.int32(0), 0)
+
+ return kernel
diff --git a/tests/integration/fitting/test_fitting_with_multi_lorentzian_models.py b/tests/integration/fitting/test_fitting_with_multi_lorentzian_models.py
new file mode 100644
index 00000000..d74d7ccf
--- /dev/null
+++ b/tests/integration/fitting/test_fitting_with_multi_lorentzian_models.py
@@ -0,0 +1,106 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+
+# Round trip for the delta-plus-Lorentzians diffusion models: synthetic data from the model,
+# convolved with a Gaussian resolution, then a simultaneous fit over all Q values. The unit tests
+# fit one Q value at a time without resolution; this exercises the shared Parameters across Q and
+# the convolution through Experiment, Analysis and the EasyScience fitter.
+
+import numpy as np
+import pytest
+import scipp as sc
+
+import easydynamics as edyn
+
+Q = sc.array(dims=['Q'], values=[0.5, 1.0, 1.5], unit='1/angstrom')
+ENERGY = sc.linspace('energy', -2.0, 2.0, 401, unit='meV')
+SHAPE = (Q.shape[0], ENERGY.shape[0])
+NOISE = 0.003
+
+# (model class, fixed arguments, true values of the fitted parameters, start values)
+CASES = {
+ 'HallRossJumpDiffusion': (
+ edyn.HallRossJumpDiffusion,
+ {},
+ {'scale': 1.0, 'residence_time': 2.0, 'jump_length': 1.5},
+ {'scale': 0.8, 'residence_time': 1.5, 'jump_length': 1.2},
+ ),
+ 'EquivalentSitesCircle': (
+ edyn.EquivalentSitesCircle,
+ {'radius': 1.2, 'number_of_sites': 3},
+ {'scale': 1.0, 'residence_time': 2.0},
+ {'scale': 0.8, 'residence_time': 1.5},
+ ),
+ 'GaussianModel3D': (
+ edyn.GaussianModel3D,
+ {},
+ {'scale': 1.0, 'diffusion_coefficient': 2e-9, 'mean_u_squared': 1.5},
+ {'scale': 0.8, 'diffusion_coefficient': 1.5e-9, 'mean_u_squared': 1.2},
+ ),
+ 'JumpSitesLogNormalDistribution': (
+ edyn.JumpSitesLogNormalDistribution,
+ {'radius': 1.2, 'number_of_sites': 3},
+ {'scale': 1.0, 'residence_time': 2.0, 'sigma': 0.5},
+ {'scale': 0.8, 'residence_time': 1.5, 'sigma': 0.7},
+ ),
+ # QR <= 1.5 keeps the default 10 terms below the truncation warning
+ 'DiffusionInSphere': (
+ edyn.DiffusionInSphere,
+ {},
+ {'scale': 1.0, 'radius': 1.0, 'diffusion_coefficient': 0.1},
+ {'scale': 0.8, 'radius': 0.8, 'diffusion_coefficient': 0.07},
+ ),
+ # QR <= 1.8 is well inside the default l_max = 6; the radius is fixed by default
+ 'IsotropicRotationalDiffusion': (
+ edyn.IsotropicRotationalDiffusion,
+ {'radius': 1.2},
+ {'scale': 1.0, 'rotational_diffusion_coefficient': 0.1},
+ {'scale': 0.8, 'rotational_diffusion_coefficient': 0.07},
+ ),
+}
+
+
+def as_data(values: np.ndarray, variances: np.ndarray) -> sc.DataArray:
+ return sc.DataArray(
+ sc.array(dims=['Q', 'energy'], values=values, variances=variances),
+ coords={'Q': Q, 'energy': ENERGY},
+ )
+
+
+def make_analysis(experiment: edyn.Experiment, model: object) -> edyn.Analysis:
+ resolution = edyn.ResolutionModel(components=edyn.Gaussian(width=0.05, area=1.0))
+ resolution.fix_all_parameters()
+ analysis = edyn.Analysis(
+ experiment=experiment,
+ sample_model=edyn.SampleModel(diffusion_models=model),
+ instrument_model=edyn.InstrumentModel(resolution_model=resolution),
+ )
+ analysis.fix_energy_offset()
+ return analysis
+
+
+@pytest.mark.parametrize('case', CASES.values(), ids=CASES.keys())
+def test_simultaneous_fit_recovers_parameters(case):
+ # WHEN
+ model_class, fixed, true_values, start_values = case
+ true_model = model_class(**fixed, **true_values)
+ experiment = edyn.Experiment('synthetic', data=as_data(np.ones(SHAPE), np.ones(SHAPE)))
+ clean = np.array(make_analysis(experiment, true_model).calculate())
+ rng = np.random.default_rng(seed=1)
+ experiment.data = as_data(clean + rng.normal(0.0, NOISE, SHAPE), np.full(SHAPE, NOISE**2))
+ fit_model = model_class(**fixed, **start_values)
+ analysis = make_analysis(experiment, fit_model)
+
+ # THEN
+ results = analysis.fit(fit_method='simultaneous')
+
+ # EXPECT
+ assert all(result.success for result in results)
+ residuals = (experiment.data.values - np.array(analysis.calculate())) / NOISE
+ reduced_chi2 = float(np.sum(residuals**2) / residuals.size)
+ assert 0.85 < reduced_chi2 < 1.15
+ for name in true_values:
+ fitted = getattr(fit_model, name)
+ true_value = getattr(true_model, name).value
+ assert fitted.variance > 0
+ assert abs(fitted.value - true_value) < 5 * fitted.error, name
diff --git a/tests/manual/diffusion_in_sphere_manual.ipynb b/tests/manual/diffusion_in_sphere_manual.ipynb
new file mode 100644
index 00000000..f7dc7cf1
--- /dev/null
+++ b/tests/manual/diffusion_in_sphere_manual.ipynb
@@ -0,0 +1,17472 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "e0283624",
+ "metadata": {},
+ "source": [
+ "# Diffusion inside a sphere: manual test\n",
+ "Loads `DiffusionInSphere`, plots its weights, compares it with the Mantid formula and the Bicout integral, fits synthetic and tutorial data, tests the fit loop and fits the EISF against Q.\n",
+ "\n",
+ "$$A_0 = \\left(\\frac{3 j_1(QR)}{QR}\\right)^2,\\quad A_{nl} = (2l+1)\\frac{6x_{nl}^2}{x_{nl}^2 - l(l+1)}\\left(\\frac{QR\\,j_{l+1}(QR) - l\\,j_l(QR)}{(QR)^2 - x_{nl}^2}\\right)^2,\\quad \\Gamma_{nl} = \\frac{\\hbar x_{nl}^2 D}{R^2}$$"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "8704a745",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-10-24T14:59:22.355486Z",
+ "iopub.status.busy": "2026-10-24T14:59:22.355273Z",
+ "iopub.status.idle": "2026-10-24T14:59:24.639414Z",
+ "shell.execute_reply": "2026-10-24T14:59:24.638787Z"
+ }
+ },
+ "outputs": [],
+ "source": [
+ "import numpy as np\n",
+ "import plopp as pp\n",
+ "import scipp as sc\n",
+ "from scipp.constants import hbar\n",
+ "\n",
+ "import easydynamics as edyn"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "a437bc92",
+ "metadata": {},
+ "source": [
+ "## Load\n",
+ "The model with the true parameters, at six Q values."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "b93369b4",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-10-24T14:59:24.641092Z",
+ "iopub.status.busy": "2026-10-24T14:59:24.640785Z",
+ "iopub.status.idle": "2026-10-24T14:59:24.644444Z",
+ "shell.execute_reply": "2026-10-24T14:59:24.643998Z"
+ }
+ },
+ "outputs": [],
+ "source": [
+ "Q = sc.linspace('Q', 0.4, 2.0, 6, unit='1/angstrom')\n",
+ "energy = sc.linspace('energy', -2.0, 2.0, 401, unit='meV')\n",
+ "Q_fine = sc.linspace('Q', 0.02, 3.0, 300, unit='1/angstrom')\n",
+ "hbar_meV_ps = hbar.to(unit='meV*ps').value\n",
+ "\n",
+ "\n",
+ "def versus_Q(values, unit='dimensionless'):\n",
+ " \"\"\"A model quantity on Q_fine as a DataArray, for plopp.\"\"\"\n",
+ " return sc.DataArray(sc.array(dims=['Q'], values=values, unit=unit), coords={'Q': Q_fine})"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "7e4b2524",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-10-24T14:59:24.645559Z",
+ "iopub.status.busy": "2026-10-24T14:59:24.645444Z",
+ "iopub.status.idle": "2026-10-24T14:59:25.856694Z",
+ "shell.execute_reply": "2026-10-24T14:59:25.856191Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "/home/mark/Projects/dynamics-lib/src/easydynamics/sample_model/diffusion_model/diffusion_in_sphere.py:273: UserWarning: Truncating the sum at number_of_lorentzians=10 omits more than 0.001 of the spectral weight at some Q values. At most 98 terms are available (the roots below 26.84, the same limit as Mantid); with all of them this happens from QR of about 10.\n",
+ " self.create_component_collections()\n"
+ ]
+ },
+ {
+ "data": {
+ "text/plain": [
+ "{'build, 6 Q (s)': 1.2,\n",
+ " 'terms': 10,\n",
+ " 'D as stored': \"\"}"
+ ]
+ },
+ "execution_count": 3,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "import time\n",
+ "\n",
+ "true_parameters = {'scale': 1.0, 'radius': 3.0, 'diffusion_coefficient': 0.1} # D in Ų/ps\n",
+ "\n",
+ "start = time.perf_counter()\n",
+ "model = edyn.DiffusionInSphere(Q=Q.values, **true_parameters)\n",
+ "{\n",
+ " 'build, 6 Q (s)': round(time.perf_counter() - start, 1),\n",
+ " 'terms': model.number_of_lorentzians,\n",
+ " 'D as stored': str(model.diffusion_coefficient),\n",
+ "}"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "9ff51949",
+ "metadata": {},
+ "source": [
+ "## Plot\n",
+ "Model quantities against Q, each with its limiting case."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "2f8e213a",
+ "metadata": {},
+ "source": [
+ "EISF for several radii, and the weights of the first terms."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "d9fe7769",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-10-24T14:59:25.858076Z",
+ "iopub.status.busy": "2026-10-24T14:59:25.857914Z",
+ "iopub.status.idle": "2026-10-24T14:59:25.997877Z",
+ "shell.execute_reply": "2026-10-24T14:59:25.997369Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "image/svg+xml": [
+ "\n",
+ "\n",
+ "\n"
+ ],
+ "text/plain": [
+ "Figure(600x400, 3 artists)"
+ ]
+ },
+ "execution_count": 4,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "pp.plot(\n",
+ " {\n",
+ " f'R = {r} Å': versus_Q(edyn.DiffusionInSphere(radius=r).calculate_EISF(Q_fine.values))\n",
+ " for r in (1.0, 3.0, 6.0)\n",
+ " },\n",
+ " title='EISF',\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "056f84f2",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-10-24T14:59:25.999594Z",
+ "iopub.status.busy": "2026-10-24T14:59:25.999451Z",
+ "iopub.status.idle": "2026-10-24T14:59:26.181036Z",
+ "shell.execute_reply": "2026-10-24T14:59:26.180521Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "image/svg+xml": [
+ "\n",
+ "\n",
+ "\n"
+ ],
+ "text/plain": [
+ "Figure(600x400, 5 artists)"
+ ]
+ },
+ "execution_count": 5,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "curves = {}\n",
+ "for j in (1, 2, 3, 6):\n",
+ " x, angular_order, n = model.roots[j - 1]\n",
+ " curves[f'j = {j} (l = {angular_order}, n = {n})'] = versus_Q(\n",
+ " model.calculate_QISF_order(Q_fine.values, j)\n",
+ " )\n",
+ "curves['missing'] = versus_Q(model.calculate_missing_weight(Q_fine.values))\n",
+ "pp.plot(curves, title='weights, R = 3 Å')"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "b6092116",
+ "metadata": {},
+ "source": [
+ "## Compare with the references\n",
+ "QENSmodels has no sphere model. The spectrum is compared with Mantid's `InelasticDiffSphere` formula, written out here with scipy, and the weights with Bicout's overlap integral (Eq. 2.12)."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "id": "e7794a31",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-10-24T14:59:26.183314Z",
+ "iopub.status.busy": "2026-10-24T14:59:26.183141Z",
+ "iopub.status.idle": "2026-10-24T14:59:26.279436Z",
+ "shell.execute_reply": "2026-10-24T14:59:26.278968Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "{'max relative difference to Mantid formula': 4.935486566250747e-15,\n",
+ " 'max relative difference to Bicout integral': np.float64(6.194820869695222e-15)}"
+ ]
+ },
+ "execution_count": 6,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "from scipy.integrate import quad\n",
+ "from scipy.special import spherical_jn as scipy_jn\n",
+ "\n",
+ "# Mantid InelasticDiffSphere's formula, re-implemented independently over the model's\n",
+ "# number_of_lorentzians lowest roots (Mantid sums all 98): weights\n",
+ "# (2l+1) 6x^2/(x^2 - l(l+1)) J^2 with the direct J, which is accurate unless QR is within about\n",
+ "# 1e-3 of a root (the 0/0 point; Mantid itself interpolates within 0.1 of each root instead).\n",
+ "x_nl = np.array([root[0] for root in model.roots])\n",
+ "l_nl = np.array([root[1] for root in model.roots])\n",
+ "R = true_parameters['radius']\n",
+ "spectrum_differences = []\n",
+ "for i, q in enumerate(Q.values):\n",
+ " a = q * R\n",
+ " assert np.min(np.abs(a - x_nl)) > 1e-3, (q, a)\n",
+ " J = (a * scipy_jn(l_nl + 1, a) - l_nl * scipy_jn(l_nl, a)) / (a**2 - x_nl**2)\n",
+ " weights = (2 * l_nl + 1) * 6 * x_nl**2 / (x_nl**2 - l_nl * (l_nl + 1)) * J**2\n",
+ " widths = (\n",
+ " hbar\n",
+ " * sc.scalar(true_parameters['diffusion_coefficient'], unit='angstrom**2/ps')\n",
+ " / sc.scalar(R, unit='angstrom') ** 2\n",
+ " ).to(unit='meV').value * x_nl**2\n",
+ " E = energy.values[energy.values != 0]\n",
+ " mantid = np.sum(\n",
+ " weights[:, None] * widths[:, None] / np.pi / (widths[:, None] ** 2 + E**2), axis=0\n",
+ " )\n",
+ " ours = sum(component.evaluate(E) for component in model.get_component_collections()[i][1:])\n",
+ " spectrum_differences.append(float(np.max(np.abs(ours - mantid)) / np.max(mantid)))\n",
+ "\n",
+ "# Bicout, PRE 62, 261 (2000), Eq. 2.12, single sphere of radius 1\n",
+ "bicout_differences = []\n",
+ "for j, (x, order, _) in enumerate(model.roots[:5], start=1):\n",
+ " Z = quad(\n",
+ " lambda r, x=x, order=order: r * r * scipy_jn(order, x * r) ** 2,\n",
+ " 0,\n",
+ " 1,\n",
+ " epsabs=1e-15,\n",
+ " epsrel=1e-12,\n",
+ " )[0]\n",
+ " for QR in (0.3, 1.7, 4.2):\n",
+ " overlap = quad(\n",
+ " lambda r, x=x, order=order, QR=QR: (\n",
+ " 3**0.5 * r * r * scipy_jn(order, QR * r) * scipy_jn(order, x * r)\n",
+ " ),\n",
+ " 0,\n",
+ " 1,\n",
+ " epsabs=1e-14,\n",
+ " epsrel=1e-12,\n",
+ " limit=200,\n",
+ " )[0]\n",
+ " unit_sphere = edyn.DiffusionInSphere(radius=1.0)\n",
+ " expected = (2 * order + 1) * overlap**2 / Z\n",
+ " bicout_differences.append(\n",
+ " abs(unit_sphere.calculate_QISF_order(np.array([QR]), j)[0] - expected) / expected\n",
+ " )\n",
+ "reference_difference = max(spectrum_differences)\n",
+ "assert reference_difference < 1e-9, spectrum_differences\n",
+ "assert max(bicout_differences) < 1e-10, bicout_differences\n",
+ "{\n",
+ " 'max relative difference to Mantid formula': reference_difference,\n",
+ " 'max relative difference to Bicout integral': max(bicout_differences),\n",
+ "}"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "75649438",
+ "metadata": {},
+ "source": [
+ "## Synthetic data\n",
+ "The model with the true parameters is evaluated on a (Q, energy) grid through an `Analysis`,\n",
+ "plus Gaussian noise with a fixed $\\sigma$. A first `Experiment` holds placeholder data with the\n",
+ "right coordinates, so that `Analysis.calculate` knows the grid."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "id": "a14d8b9d",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-10-24T14:59:26.280876Z",
+ "iopub.status.busy": "2026-10-24T14:59:26.280745Z",
+ "iopub.status.idle": "2026-10-24T14:59:27.052144Z",
+ "shell.execute_reply": "2026-10-24T14:59:27.051687Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "/home/mark/Projects/dynamics-lib/src/easydynamics/sample_model/diffusion_model/delta_lorentzian_diffusion_model_base.py:509: UserWarning: Truncating the sum at number_of_lorentzians=10 omits more than 0.001 of the spectral weight at some Q values. At most 98 terms are available (the roots below 26.84, the same limit as Mantid); with all of them this happens from QR of about 10.\n",
+ " self.create_component_collections()\n"
+ ]
+ },
+ {
+ "data": {
+ "image/svg+xml": [
+ "\n",
+ "\n",
+ "\n"
+ ],
+ "text/plain": [
+ "Figure(600x400, 1 artists)"
+ ]
+ },
+ "execution_count": 7,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "sigma = 0.003\n",
+ "shape = (Q.shape[0], energy.shape[0])\n",
+ "\n",
+ "\n",
+ "def as_data(values, variances):\n",
+ " return sc.DataArray(\n",
+ " sc.array(dims=['Q', 'energy'], values=values, variances=variances),\n",
+ " coords={'Q': Q, 'energy': energy},\n",
+ " )\n",
+ "\n",
+ "\n",
+ "experiment = edyn.Experiment('synthetic', data=as_data(np.ones(shape), np.ones(shape)))\n",
+ "true_analysis = edyn.Analysis(\n",
+ " experiment=experiment, sample_model=edyn.SampleModel(diffusion_models=model)\n",
+ ")\n",
+ "clean = np.array(true_analysis.calculate())\n",
+ "rng = np.random.default_rng(seed=1)\n",
+ "experiment.data = as_data(clean + rng.normal(0.0, sigma, shape), np.full(shape, sigma**2))\n",
+ "experiment.plot_data(slicer=False)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "cfc6a240",
+ "metadata": {},
+ "source": [
+ "$S(Q, E)$ without noise, one curve per Q."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 8,
+ "id": "eac9c2e5",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-10-24T14:59:27.053710Z",
+ "iopub.status.busy": "2026-10-24T14:59:27.053579Z",
+ "iopub.status.idle": "2026-10-24T14:59:27.443677Z",
+ "shell.execute_reply": "2026-10-24T14:59:27.443192Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "image/svg+xml": [
+ "\n",
+ "\n",
+ "\n"
+ ],
+ "text/plain": [
+ "Figure(600x400, 6 artists)"
+ ]
+ },
+ "execution_count": 8,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "sqw = sc.DataArray(\n",
+ " sc.array(dims=['Q', 'energy'], values=clean, unit=model.y_unit),\n",
+ " coords={'Q': Q, 'energy': energy},\n",
+ ")\n",
+ "pp.plot({f'Q = {q:.2f} 1/Å': sqw['Q', i] for i, q in enumerate(Q.values)}, norm='log')"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "d99fd3de",
+ "metadata": {},
+ "source": [
+ "## Fit to the synthetic data\n",
+ "A new model with wrong start values, fitted simultaneously over all Q. The synthetic data has no\n",
+ "energy offset, so it is fixed: with a delta function on the energy grid, a free offset can stop\n",
+ "the minimizer early while it still reports success (see the last cell)."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 9,
+ "id": "bddfde7e",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-10-24T14:59:27.447219Z",
+ "iopub.status.busy": "2026-10-24T14:59:27.447066Z",
+ "iopub.status.idle": "2026-10-24T14:59:36.736404Z",
+ "shell.execute_reply": "2026-10-24T14:59:36.735878Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "/home/mark/Projects/dynamics-lib/src/easydynamics/sample_model/diffusion_model/delta_lorentzian_diffusion_model_base.py:509: UserWarning: Truncating the sum at number_of_lorentzians=10 omits more than 0.001 of the spectral weight at some Q values. At most 98 terms are available (the roots below 26.84, the same limit as Mantid); with all of them this happens from QR of about 10.\n",
+ " self.create_component_collections()\n",
+ "CollectionBase is deprecated and will be removed in a future version. Please migrate to ModelBase or EasyList.\n"
+ ]
+ },
+ {
+ "data": {
+ "text/plain": [
+ "DiffusionInSphere(name='DiffusionInSphere', display_name='DiffusionInSphere',\n",
+ "x_unit=meV, y_unit=dimensionless,\n",
+ " radius=,\n",
+ " diffusion_coefficient=,\n",
+ " number_of_lorentzians=10,\n",
+ " scale=)"
+ ]
+ },
+ "execution_count": 9,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "fitted_parameters = {\n",
+ " 'scale': 1.0,\n",
+ " 'radius': 3.0,\n",
+ " 'diffusion_coefficient': 1e-9,\n",
+ "} # D as stored: 0.1 Ų/ps = 1e-9 m²/s\n",
+ "fit_model = edyn.DiffusionInSphere(scale=0.7, radius=2.5, diffusion_coefficient=0.07)\n",
+ "fit_analysis = edyn.Analysis(\n",
+ " experiment=experiment, sample_model=edyn.SampleModel(diffusion_models=fit_model)\n",
+ ")\n",
+ "fit_analysis.fix_energy_offset()\n",
+ "results = fit_analysis.fit(fit_method='simultaneous')\n",
+ "fit_model"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "f4fe8bb4",
+ "metadata": {},
+ "source": [
+ "Data and fitted model at the lowest and highest Q. In Jupyter with `%matplotlib widget`, `fit_analysis.plot_data_and_model()` shows all Q with a slider."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 10,
+ "id": "ca3ae6bd",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-10-24T14:59:36.737623Z",
+ "iopub.status.busy": "2026-10-24T14:59:36.737490Z",
+ "iopub.status.idle": "2026-10-24T14:59:36.937489Z",
+ "shell.execute_reply": "2026-10-24T14:59:36.936957Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "image/svg+xml": [
+ "\n",
+ "\n",
+ "\n"
+ ],
+ "text/plain": [
+ "Figure(600x400, 13 artists)"
+ ]
+ },
+ "execution_count": 10,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "fit_analysis.plot_data_and_model(Q_index=0)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 11,
+ "id": "d3944059",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-10-24T14:59:36.939540Z",
+ "iopub.status.busy": "2026-10-24T14:59:36.939368Z",
+ "iopub.status.idle": "2026-10-24T14:59:37.161126Z",
+ "shell.execute_reply": "2026-10-24T14:59:37.160609Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "image/svg+xml": [
+ "\n",
+ "\n",
+ "\n"
+ ],
+ "text/plain": [
+ "Figure(600x400, 13 artists)"
+ ]
+ },
+ "execution_count": 11,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "fit_analysis.plot_data_and_model(Q_index=len(Q) - 1)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "00e54b6d",
+ "metadata": {},
+ "source": [
+ "### Check\n",
+ "The fitted values must lie within 5 standard errors of the true values, and the reduced\n",
+ "$\\chi^2$ must be close to 1. This cell raises if they do not."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 12,
+ "id": "b37546f6",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-10-24T14:59:37.163198Z",
+ "iopub.status.busy": "2026-10-24T14:59:37.163016Z",
+ "iopub.status.idle": "2026-10-24T14:59:37.171289Z",
+ "shell.execute_reply": "2026-10-24T14:59:37.170752Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "{'reduced chi2': 1.0077710349131443,\n",
+ " 'scale: (fit - true) / error': np.float64(1.3566960528002816),\n",
+ " 'radius: (fit - true) / error': np.float64(0.3969229527804407),\n",
+ " 'diffusion_coefficient: (fit - true) / error': np.float64(-0.09015742962917775)}"
+ ]
+ },
+ "execution_count": 12,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "model_values = np.array(fit_analysis.calculate())\n",
+ "residuals = (experiment.data.values - model_values) / sigma\n",
+ "reduced_chi2 = float(np.sum(residuals**2) / model_values.size)\n",
+ "deviations = {\n",
+ " name: (getattr(fit_model, name).value - true_value) / getattr(fit_model, name).error\n",
+ " for name, true_value in fitted_parameters.items()\n",
+ "}\n",
+ "assert all(abs(d) < 5 for d in deviations.values()), 'a fitted value is more than 5 sigma off'\n",
+ "assert 0.9 < reduced_chi2 < 1.1, 'reduced chi2 is far from 1'\n",
+ "{'reduced chi2': reduced_chi2} | {\n",
+ " f'{name}: (fit - true) / error': d for name, d in deviations.items()\n",
+ "}"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "0b0298b1",
+ "metadata": {},
+ "source": [
+ "The tutorial fit, the fit loop and the EISF fit are in `diffusion_in_sphere_manual_part_2.ipynb`."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "01772494",
+ "metadata": {},
+ "source": [
+ "## Bugs found\n",
+ "Found while building and testing this model, each reproduced in this session (2026-10-23) on\n",
+ "easyscience 2.5.1, scipp 26.9.0 and NumPy 2.5.4 unless stated otherwise; to be reported.\n",
+ "\n",
+ "**EasyScience Core**\n",
+ "1. `(1/Ų) × Å²` gets the unit `0.999999999999999889` instead of `dimensionless`;\n",
+ " `convert_unit('dimensionless')` does not fix it, and adding a plain number then raises\n",
+ " `UnitError: Numbers can only be added to dimensionless values`. Hits $Q^2\\langle u^2\\rangle$.\n",
+ "2. `Parameter` bound arithmetic (`parameter.py:1280`, `self.max / other.max`) divides two\n",
+ " NumPy `float64` infinities when a dependency divides two parameters without upper bound after a\n",
+ " product with a `DescriptorNumber` (e.g. `3 * hbar * D / u2`): every update warns\n",
+ " `RuntimeWarning: invalid value encountered in scalar divide`, and with warnings as errors the\n",
+ " dependent value becomes `None` (`AttributeError` in `_update`). Plain Python bounds do not warn.\n",
+ " `GaussianModel3D` avoids it by dividing by the dimensionless value of $\\langle u^2\\rangle$.\n",
+ "3. Core `add-maths-module` (local, with Core `develop` merged in): 189 of 2266 EasyDynamics unit\n",
+ " tests fail; the tracebacks inspected end in `ValueError: Object name Parameter_2 already exists\n",
+ " in the graph`, raised by `Parameter.from_dict` in the new `easyscience/io` serializers when\n",
+ " `ComponentCollection.__copy__` copies a collection.\n",
+ "\n",
+ "**EasyDynamics**\n",
+ "\n",
+ "4. A simultaneous fit with a delta function and the default free energy offset per Q stopped at\n",
+ " reduced $\\chi^2$ 1.47 (the true parameters give 1.008) and reported `success=True`; with the\n",
+ " offset fixed it converged (`EquivalentSitesCircle`, synthetic data as above).\n",
+ "5. `Analysis.plot_data_and_model()` without `Q_index` raises `RuntimeError` on a non-interactive\n",
+ " backend (from plopp's slicer inside Jupyter; outside Jupyter the message names `plot_data()`\n",
+ " instead of `plot_data_and_model()`).\n",
+ "6. A jump length of 0 is accepted but breaks evaluation: `ChudleyElliottJumpDiffusion` gives a NaN\n",
+ " width ($\\sin 0/0$) and all-NaN spectra; `HallRossJumpDiffusion` gives width 0 and a division by\n",
+ " zero in the Lorentzian.\n",
+ "\n",
+ "**Pitfall (not a bug)**: NumPy 2 writes `np.float64(0.5)` as `'np.float64(0.5)'`, which dependency\n",
+ "expressions cannot parse (`NameError: name 'np' is not defined`); convert to `float` first.\n",
+ "\n",
+ "**EasyScience Core 2.5.1**\n",
+ "\n",
+ "7. Every `ParameterAnalysis` or simultaneous fit warns `CollectionBase is deprecated and\n",
+ " will be removed in a future version. Please migrate to ModelBase or EasyList.`: Core's own\n",
+ " `MultiFitter` builds a `CollectionBase` (`easyscience/fitting/multi_fitter.py:31`); EasyDynamics\n",
+ " calls it in `analysis/parameter_analysis.py:348` and `analysis/analysis.py:845`. Core\n",
+ " `release-3.0` uses `EasyList` there instead. Nothing to change in EasyDynamics.\n",
+ "8. DFO-LS uncertainties are about 4 times too small: `_error_from_jacobian`\n",
+ " (`easyscience/fitting/minimizers/minimizer_base.py`, lines 137-138 on `release-3.0`) scales them\n",
+ " by `stats.norm.pdf(0.975)` = 0.248 (a density; the 95 % quantile would be `norm.ppf(0.975)` =\n",
+ " 1.96, and a standard error would need no factor), and takes the element-wise `np.sqrt` of the\n",
+ " whole covariance matrix, so negative off-diagonal terms give `RuntimeWarning: invalid value\n",
+ " encountered in sqrt`. Measured here: the same fitted values as LMFit and Bumps\n",
+ " (tau 1.854, sigma 0.7354) with errors 0.048 and 0.049 instead of 0.19 (ratio 0.25).\n",
+ "\n",
+ "**Pitfalls and conventions (not bugs)**\n",
+ "\n",
+ "9. Scipp has no named unit for Ų/ps, so the diffusion coefficient given as 0.1 (Ų/ps) is stored\n",
+ " and shown as 1e-9 m²/s (the same value); compare fitted values in the Parameter's unit.\n",
+ "10. Mantid interpolates the weights linearly within ±0.1 of each root $x_{nl}$, where the formula is\n",
+ " a 0/0; this model uses the exact limit and its first-order Taylor term within 1e-5 instead.\n",
+ "11. The sum is truncated: by default at 10 terms (Mantid uses all 98 roots below 26.84, the\n",
+ " most `number_of_lorentzians` allows), because dependent Parameters are slow in Core 2.5.1.\n",
+ " With 10 terms more than 1e-3 of the weight is missing from QR of about 2 (with 98, from about\n",
+ " 10), and the model warns; this notebook uses the default, so the warning shows at QR up to 6.\n",
+ "\n",
+ "**EasyScience Core 2.5.1 (performance)**\n",
+ "\n",
+ "12. Every update of a dependent Parameter gets slower as the global object map grows (Core's\n",
+ " `generate_unique_name` scans it). Measured with 98 terms, on one radius change of a 1-Q sphere\n",
+ " model (0.036 s with the default 10 terms on a fresh map): 0.419 s\n",
+ " with 505 map entries, 2.783 s with four more 6-Q sphere models alive (12453 entries); deleting\n",
+ " two models and running `gc.collect()` left 4067 of 6489 entries and 1.189 s. In one kernel, the\n",
+ " tutorial fit timed out at 600 s after the 6-Q fits, while the same fit alone took 164 s, so\n",
+ " this test was split into two notebooks.\n",
+ "13. DFO-LS stops after its first trust-region step when a free parameter is of order 1e-9, as\n",
+ " D is when stored in m²/s: on the part-2 fit-loop data it returned R = 2.75000 (start 2.5)\n",
+ " with error 1.9e-08, while LMFit found R = 2.99790 ± 0.004. With D converted to nm²/ns\n",
+ " (value about 1) DFO found R = 2.99790 ± 0.00088, so part 2 converts D before fitting.\n",
+ "\n",
+ "**Unit-suite warnings (not from these models; found 2026-10-24)**\n",
+ "\n",
+ "The full unit suite (`pytest tests/unit`, 2538 passed) prints two warnings, both from tests this\n",
+ "branch does not touch:\n",
+ "\n",
+ "- `test_polynomial.py:537` (`test_remove_coefficient_evaluate`): `UserWarning: The Polynomial\n",
+ " with unique_name Polynomial_3 has negative values, which may not be physically meaningful.` The\n",
+ " test does not expect or silence the warning.\n",
+ "- `test_plotting.py` (`test_slicerplot_with_residuals_aligns_data_and_residuals_axes`):\n",
+ " `DeprecationWarning` from traitlets, `Passing unrecognized arguments to super(Toolbar).__init__()`,\n",
+ " `NavigationToolbar2WebAgg.__init__() missing 1 required positional argument: 'canvas'`;\n",
+ " traitlets says this will become an error in a future release.\n",
+ "\n",
+ "**Upstream items for EasyScience Core 2.5.1 (found 2026-10-24, planning time-domain fits)**\n",
+ "\n",
+ "Read from the installed source; none blocks fitting in energy, but they limit fits of $F(Q,t)$:\n",
+ "\n",
+ "- Weights are a 1-D array and the residual is `(y - f(x)) * weights` (`minimizer_dfo.py:237`,\n",
+ " `fitter.py:390`): there is no covariance matrix, so correlated data points, such as a Fourier\n",
+ " transform of $S(Q,\\omega)$, cannot be weighted correctly. Workaround without Core changes:\n",
+ " whiten the data ($C = LL^T$, fit $L^{-1}y$ against $L^{-1}f$ with unit weights).\n",
+ "- Residuals are real (`dtype=float`, `minimizer_dfo.py:273`): complex data must be symmetrised\n",
+ " first, or its real and imaginary parts stacked.\n",
+ "- Updates of dependent Parameters are slow, and slower as the global object map grows; waiting for\n",
+ " Core 3.0.\n",
+ "- DFO stalls when a free parameter is about 1e-9 in magnitude (as D in m$^2$/s); times stored in\n",
+ " seconds (about 1e-12) would stall it too, so they must be stored in ps.\n",
+ "- Not yet checked: whether `MultiFitter` accepts datasets of different lengths and kinds (energy\n",
+ " and time together), and whether products such as $\\Gamma t$ get odd units as in bug 1."
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3 (ipykernel)",
+ "language": "python",
+ "name": "python3"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/tests/manual/diffusion_in_sphere_manual_part_2.ipynb b/tests/manual/diffusion_in_sphere_manual_part_2.ipynb
new file mode 100644
index 00000000..b36028cf
--- /dev/null
+++ b/tests/manual/diffusion_in_sphere_manual_part_2.ipynb
@@ -0,0 +1,417 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "0d646b56",
+ "metadata": {},
+ "source": [
+ "# Diffusion inside a sphere: manual test, part 2\n",
+ "The tutorial-data fit, the fit loop (minimizers, a fixed parameter, sampling) and the EISF fit, in a fresh kernel: dependent-Parameter updates slow down as the global object map grows (bug 12 in part 1), so the 6-Q models of part 1 would make these fits several times slower."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "8704a745",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-10-24T11:46:13.697986Z",
+ "iopub.status.busy": "2026-10-24T11:46:13.697792Z",
+ "iopub.status.idle": "2026-10-24T11:46:16.267111Z",
+ "shell.execute_reply": "2026-10-24T11:46:16.266465Z"
+ }
+ },
+ "outputs": [],
+ "source": [
+ "import numpy as np\n",
+ "import scipp as sc\n",
+ "from scipp.constants import hbar\n",
+ "\n",
+ "import easydynamics as edyn"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "b93369b4",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-10-24T11:46:16.268936Z",
+ "iopub.status.busy": "2026-10-24T11:46:16.268646Z",
+ "iopub.status.idle": "2026-10-24T11:46:16.272316Z",
+ "shell.execute_reply": "2026-10-24T11:46:16.271774Z"
+ }
+ },
+ "outputs": [],
+ "source": [
+ "Q = sc.linspace('Q', 0.4, 2.0, 6, unit='1/angstrom')\n",
+ "energy = sc.linspace('energy', -2.0, 2.0, 401, unit='meV')\n",
+ "Q_fine = sc.linspace('Q', 0.02, 3.0, 300, unit='1/angstrom')\n",
+ "hbar_meV_ps = hbar.to(unit='meV*ps').value\n",
+ "\n",
+ "\n",
+ "def versus_Q(values, unit='dimensionless'):\n",
+ " \"\"\"A model quantity on Q_fine as a DataArray, for plopp.\"\"\"\n",
+ " return sc.DataArray(sc.array(dims=['Q'], values=values, unit=unit), coords={'Q': Q_fine})"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "df528538",
+ "metadata": {},
+ "source": [
+ "## Basic fit to the tutorial data\n",
+ "`fake_advanced_data.hdf5` from the tutorials, rebinned to 2 Q values. That data was made from a\n",
+ "Gaussian, a Lorentzian, a damped harmonic oscillator and a sloped background, not from this model,\n",
+ "so this only checks that loading and fitting run on a real file; there is no background or\n",
+ "resolution in this model."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "60d5d3a5",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-10-24T11:46:16.273435Z",
+ "iopub.status.busy": "2026-10-24T11:46:16.273311Z",
+ "iopub.status.idle": "2026-10-24T11:46:29.843826Z",
+ "shell.execute_reply": "2026-10-24T11:46:29.843209Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "/home/mark/Projects/dynamics-lib/src/easydynamics/sample_model/diffusion_model/delta_lorentzian_diffusion_model_base.py:509: UserWarning: Truncating the sum at number_of_lorentzians=10 omits more than 0.001 of the spectral weight at some Q values. At most 98 terms are available (the roots below 26.84, the same limit as Mantid); with all of them this happens from QR of about 10.\n",
+ " self.create_component_collections()\n",
+ "CollectionBase is deprecated and will be removed in a future version. Please migrate to ModelBase or EasyList.\n"
+ ]
+ },
+ {
+ "data": {
+ "text/plain": [
+ "{'success': True,\n",
+ " 'scale': '21.89 ± 1.7 meV',\n",
+ " 'radius': '5.695 ± 0.13 Å',\n",
+ " 'diffusion_coefficient': '1.817e-08 ± 2.3e-09 m^2/s'}"
+ ]
+ },
+ "execution_count": 3,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "tutorial = edyn.Experiment(display_name='Tutorial')\n",
+ "tutorial.load_hdf5(filename='../../docs/docs/tutorials/data/fake_advanced_data.hdf5')\n",
+ "tutorial.rebin({'Q': 2, 'energy': 256})\n",
+ "tutorial_model = edyn.DiffusionInSphere(scale=1.0, radius=2.0, diffusion_coefficient=0.1)\n",
+ "tutorial_analysis = edyn.Analysis(\n",
+ " experiment=tutorial, sample_model=edyn.SampleModel(diffusion_models=tutorial_model)\n",
+ ")\n",
+ "tutorial_analysis.fix_energy_offset()\n",
+ "tutorial_results = tutorial_analysis.fit(fit_method='simultaneous')\n",
+ "{'success': all(result.success for result in tutorial_results)} | {\n",
+ " parameter.name: f'{parameter.value:.4g} ± {parameter.error:.2g} {parameter.unit}'\n",
+ " for parameter in tutorial_model.get_global_variables()\n",
+ "}"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "8cd321ad",
+ "metadata": {},
+ "source": [
+ "## Fit loop: minimizers, fixed parameters, sampling and timings\n",
+ "One Q value (10 Lorentzians, the default), so that the dependent Parameters keep it quick. Every\n",
+ "minimizer must recover $\\tau$ and $\\sigma$ (within 5 standard errors where it reports errors,\n",
+ "else within 5 %); the fixed radius must not move; a short Bumps sampling run must work and label\n",
+ "its columns with the parameters. The times are measured, not asserted: the component areas and\n",
+ "widths are dependent Parameters, whose updates are slow in EasyScience Core 2.5.1."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "1602195e",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-10-24T11:46:29.845222Z",
+ "iopub.status.busy": "2026-10-24T11:46:29.845038Z",
+ "iopub.status.idle": "2026-10-24T11:46:49.355452Z",
+ "shell.execute_reply": "2026-10-24T11:46:49.354887Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "{'build, 1 Q (s)': 0.1,\n",
+ " 'one change of the radius (s)': 0.038,\n",
+ " 'LMFit': {'fit (s)': 0.94,\n",
+ " 'radius': '2.998 ± 0.004',\n",
+ " 'diffusion_coefficient': '1.006 ± 0.0097'},\n",
+ " 'Bumps': {'fit (s)': 14.62,\n",
+ " 'radius': '2.998 ± 0.0039',\n",
+ " 'diffusion_coefficient': '1.006 ± 0.0096'},\n",
+ " 'DFO': {'fit (s)': 3.46,\n",
+ " 'radius': '2.998 ± 0.00088',\n",
+ " 'diffusion_coefficient': '1.006 ± 0.0025'}}"
+ ]
+ },
+ "execution_count": 4,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "import time\n",
+ "\n",
+ "from easyscience.fitting import AvailableMinimizers\n",
+ "\n",
+ "loop_Q = sc.array(dims=['Q'], values=[1.0], unit='1/angstrom')\n",
+ "loop_shape = (1, energy.shape[0])\n",
+ "\n",
+ "\n",
+ "def loop_data(values, variances):\n",
+ " return sc.DataArray(\n",
+ " sc.array(dims=['Q', 'energy'], values=values, variances=variances),\n",
+ " coords={'Q': loop_Q, 'energy': energy},\n",
+ " )\n",
+ "\n",
+ "\n",
+ "loop_truth = {'radius': 3.0, 'diffusion_coefficient': 0.1} # D in angstrom^2/ps\n",
+ "loop_experiment = edyn.Experiment('loop', data=loop_data(np.ones(loop_shape), np.ones(loop_shape)))\n",
+ "start = time.perf_counter()\n",
+ "loop_true_model = edyn.DiffusionInSphere(**loop_truth)\n",
+ "loop_true_analysis = edyn.Analysis(\n",
+ " experiment=loop_experiment, sample_model=edyn.SampleModel(diffusion_models=loop_true_model)\n",
+ ")\n",
+ "loop_clean = np.array(loop_true_analysis.calculate())\n",
+ "build_seconds = time.perf_counter() - start\n",
+ "start = time.perf_counter()\n",
+ "loop_true_model.radius = 3.1\n",
+ "change_seconds = time.perf_counter() - start\n",
+ "loop_true_model.radius = 3.0\n",
+ "loop_sigma = 0.003 * loop_clean.max()\n",
+ "loop_experiment.data = loop_data(\n",
+ " loop_clean + np.random.default_rng(seed=2).normal(0.0, loop_sigma, loop_shape),\n",
+ " np.full(loop_shape, loop_sigma**2),\n",
+ ")\n",
+ "\n",
+ "\n",
+ "def loop_fit(minimizer):\n",
+ " loop_model = edyn.DiffusionInSphere(radius=2.5, diffusion_coefficient=0.07)\n",
+ " # D is stored in m^2/s (about 1e-9), where DFO stalls after its first step (bug 13 in part 1);\n",
+ " # nm^2/ns (shown by Scipp as nGy*s, the same dimension) gives it a value of order 1\n",
+ " loop_model.diffusion_coefficient.convert_unit('nm**2/ns')\n",
+ " analysis = edyn.Analysis1d(\n",
+ " experiment=loop_experiment,\n",
+ " sample_model=edyn.SampleModel(diffusion_models=loop_model),\n",
+ " Q_index=0,\n",
+ " )\n",
+ " analysis.fix_energy_offset()\n",
+ " analysis.fitter.switch_minimizer(minimizer)\n",
+ " start = time.perf_counter()\n",
+ " analysis.fit()\n",
+ " seconds = time.perf_counter() - start\n",
+ " for name, true_value in loop_truth.items():\n",
+ " parameter = getattr(loop_model, name)\n",
+ " unit = 'angstrom' if name == 'radius' else 'angstrom**2/ps'\n",
+ " true_value = sc.scalar(true_value, unit=unit).to(unit=parameter.unit).value\n",
+ " if parameter.error:\n",
+ " assert abs(parameter.value - true_value) < 5 * parameter.error, (minimizer, name)\n",
+ " else:\n",
+ " assert abs(parameter.value - true_value) < 0.05 * true_value, (minimizer, name)\n",
+ " return loop_model, analysis, seconds\n",
+ "\n",
+ "\n",
+ "loop_results = {}\n",
+ "for minimizer in (AvailableMinimizers.LMFit, AvailableMinimizers.Bumps, AvailableMinimizers.DFO):\n",
+ " loop_model, loop_analysis, seconds = loop_fit(minimizer)\n",
+ " loop_results[minimizer.name] = {\n",
+ " 'fit (s)': round(seconds, 2),\n",
+ " **{\n",
+ " name: f'{getattr(loop_model, name).value:.4g} ± {getattr(loop_model, name).error:.2g}'\n",
+ " for name in loop_truth\n",
+ " },\n",
+ " }\n",
+ "{\n",
+ " 'build, 1 Q (s)': round(build_seconds, 2),\n",
+ " 'one change of the radius (s)': round(change_seconds, 3),\n",
+ "} | loop_results"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "ee371b8f",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-10-24T11:46:49.357389Z",
+ "iopub.status.busy": "2026-10-24T11:46:49.357081Z",
+ "iopub.status.idle": "2026-10-24T11:46:50.354108Z",
+ "shell.execute_reply": "2026-10-24T11:46:50.353706Z"
+ }
+ },
+ "outputs": [
+ {
+ "data": {
+ "text/plain": [
+ "{'radius (fixed)': np.float64(2.5),\n",
+ " 'D': \"\"}"
+ ]
+ },
+ "execution_count": 5,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "# A fixed parameter must stay where it is during a fit\n",
+ "fixed_model = edyn.DiffusionInSphere(radius=2.5, diffusion_coefficient=0.07)\n",
+ "fixed_model.radius.fixed = True\n",
+ "fixed_analysis = edyn.Analysis1d(\n",
+ " experiment=loop_experiment,\n",
+ " sample_model=edyn.SampleModel(diffusion_models=fixed_model),\n",
+ " Q_index=0,\n",
+ ")\n",
+ "fixed_analysis.fix_energy_offset()\n",
+ "fixed_analysis.fit()\n",
+ "# Exact on purpose: a fixed parameter must not move at all.\n",
+ "assert fixed_model.radius.value == 2.5, 'the fixed radius moved' # ruff: ignore[float-equality-comparison]\n",
+ "{'radius (fixed)': fixed_model.radius.value, 'D': str(fixed_model.diffusion_coefficient)}"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "id": "9a01f4a4",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-10-24T11:46:50.355753Z",
+ "iopub.status.busy": "2026-10-24T11:46:50.355607Z",
+ "iopub.status.idle": "2026-10-24T11:48:03.446699Z",
+ "shell.execute_reply": "2026-10-24T11:48:03.446296Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "/home/mark/Projects/dynamics-lib/.pixi/envs/default/lib/python3.15/site-packages/bumps/dream/convergence.py:186: UserWarning: Did not converge!\n",
+ " warnings.warn(\"Did not converge!\")\n"
+ ]
+ },
+ {
+ "data": {
+ "text/plain": [
+ "{'sampling (s)': 72.0,\n",
+ " 'draws': (87, 3),\n",
+ " 'chain columns': ['Parameter_11112', 'Parameter_11113', 'Parameter_11114'],\n",
+ " 'labels': ['scale', 'radius', 'diffusion_coefficient']}"
+ ]
+ },
+ "execution_count": 6,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "loop_model, loop_analysis, _ = loop_fit(AvailableMinimizers.LMFit)\n",
+ "# The bounds are the prior of the sampler, so every free parameter needs finite ones\n",
+ "loop_analysis.bayesian.suggest_bounds().apply()\n",
+ "start = time.perf_counter()\n",
+ "sampling = loop_analysis.bayesian.sample(samples=200, burn=20, thin=2)\n",
+ "sampling_seconds = time.perf_counter() - start\n",
+ "# Core's chain columns are unique names (Parameter_123); EasyDynamics labels them by parameter name\n",
+ "labels = [entry.name for entry in loop_analysis.bayesian.summary().entries]\n",
+ "assert any('radius' in label for label in labels), labels\n",
+ "assert any('diffusion_coefficient' in label for label in labels), labels\n",
+ "{\n",
+ " 'sampling (s)': round(sampling_seconds, 1),\n",
+ " 'draws': sampling.draws.shape,\n",
+ " 'chain columns': sampling.param_names,\n",
+ " 'labels': labels,\n",
+ "}"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "c258663f",
+ "metadata": {},
+ "source": [
+ "## EISF against Q with ParameterAnalysis\n",
+ "The equivalent of Mantid's `EISFDiffSphere`: synthetic elastic areas against Q, fitted with the\n",
+ "`elastic_area` target of the model. The EISF does not depend on D, so D is fixed."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "id": "bfc6d861",
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-10-24T11:48:03.448310Z",
+ "iopub.status.busy": "2026-10-24T11:48:03.448165Z",
+ "iopub.status.idle": "2026-10-24T11:48:03.477310Z",
+ "shell.execute_reply": "2026-10-24T11:48:03.476788Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stderr",
+ "output_type": "stream",
+ "text": [
+ "CollectionBase is deprecated and will be removed in a future version. Please migrate to ModelBase or EasyList.\n"
+ ]
+ },
+ {
+ "data": {
+ "text/plain": [
+ "{'radius': '3.0372 ± 0.033 Å'}"
+ ]
+ },
+ "execution_count": 7,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "Q_eisf = sc.linspace('Q', 0.2, 2.0, 15, unit='1/angstrom')\n",
+ "eisf_noise = 0.01\n",
+ "true_eisf = edyn.DiffusionInSphere(radius=3.0).calculate_EISF(Q_eisf.values)\n",
+ "eisf_data = sc.Dataset({\n",
+ " 'Delta function area': sc.DataArray(\n",
+ " sc.array(\n",
+ " dims=['Q'],\n",
+ " values=true_eisf + np.random.default_rng(seed=3).normal(0.0, eisf_noise, 15),\n",
+ " variances=np.full(15, eisf_noise**2),\n",
+ " unit='meV',\n",
+ " ),\n",
+ " coords={'Q': Q_eisf},\n",
+ " )\n",
+ "})\n",
+ "eisf_model = edyn.DiffusionInSphere(radius=2.5)\n",
+ "eisf_model.diffusion_coefficient.fixed = True\n",
+ "binding = edyn.FitBinding(model=eisf_model, targets={'elastic_area': 'Delta function area'})\n",
+ "eisf_analysis = edyn.ParameterAnalysis(parameters=eisf_data, bindings=binding)\n",
+ "eisf_analysis.fit()\n",
+ "assert abs(eisf_model.radius.value - 3.0) < 5 * eisf_model.radius.error\n",
+ "{'radius': f'{eisf_model.radius.value:.4f} ± {eisf_model.radius.error:.2g} Å'}"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "Python 3 (ipykernel)",
+ "language": "python",
+ "name": "python3"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_delta_lorentzian_diffusion_model_base.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_delta_lorentzian_diffusion_model_base.py
new file mode 100644
index 00000000..c5ec5d93
--- /dev/null
+++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_delta_lorentzian_diffusion_model_base.py
@@ -0,0 +1,201 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+
+import numpy as np
+import pytest
+from easyscience.variable import DescriptorNumber
+from easyscience.variable import Parameter
+
+from easydynamics.sample_model.diffusion_model.delta_lorentzian_diffusion_model_base import (
+ DeltaLorentzianDiffusionModelBase,
+)
+from easydynamics.utils.utils import Q_type
+
+# Weights of orders 0 (delta function) to 3; they sum to 1.
+WEIGHTS = [0.5, 0.3, 0.15, 0.05]
+
+
+class DummyDiffusionModel(DeltaLorentzianDiffusionModelBase):
+ """Test-only model with constant weights and widths order * w."""
+
+ _order_symbol = 'k'
+
+ def __init__(self, number_of_lorentzians: int = 3, **kwargs) -> None:
+ kwargs.setdefault('name', 'Dummy')
+ kwargs.setdefault('display_name', 'Dummy')
+ super().__init__(number_of_lorentzians, **kwargs)
+ self.w = Parameter(name='w', value=0.1, unit='meV')
+ self.create_component_collections()
+
+ def calculate_width(self, Q: Q_type | None = None, order: int = 1) -> np.ndarray:
+ self._verify_order(order)
+ return order * self.w.value * np.ones_like(self._ensure_Q(Q))
+
+ def calculate_EISF(self, Q: Q_type | None = None) -> np.ndarray:
+ return WEIGHTS[0] * np.ones_like(self._ensure_Q(Q))
+
+ def calculate_QISF_order(self, Q: Q_type | None = None, order: int = 1) -> np.ndarray:
+ self._verify_order(order)
+ return WEIGHTS[order] * np.ones_like(self._ensure_Q(Q))
+
+ def _create_weight(self, Q: float, order: int) -> Parameter:
+ return Parameter(name=f'weight_k{order}_Q{Q:.2f}', value=WEIGHTS[order], fixed=True)
+
+ def _write_width_dependency_expression(self, order: int) -> str:
+ return f'{order} * w'
+
+ def _write_width_dependency_map_expression(self) -> dict[str, DescriptorNumber]:
+ return {'w': self.w}
+
+
+@pytest.fixture
+def dummy():
+ return DummyDiffusionModel(scale=2.0, Q=np.array([0.5, 1.0]))
+
+
+# ----------------------------------------------------------------------
+# Components
+# ----------------------------------------------------------------------
+
+
+def test_component_names_use_symbol(dummy):
+ # WHEN
+ components = dummy.get_component_collections()[0][:]
+
+ # THEN EXPECT
+ assert dummy.lorentzian_component_name(2) == 'Dummy_k2'
+ assert [c.name for c in components] == ['Delta function', 'Dummy_k1', 'Dummy_k2', 'Dummy_k3']
+ assert components[1].display_name == 'Dummy_k1'
+
+
+def test_components_have_weights_and_widths(dummy):
+ # WHEN THEN EXPECT
+ for collection in dummy.get_component_collections():
+ delta, *lorentzians = collection[:]
+ assert delta.area.value == pytest.approx(1.0)
+ assert [c.area.value for c in lorentzians] == pytest.approx([0.6, 0.3, 0.1])
+ assert [c.width.value for c in lorentzians] == pytest.approx([0.1, 0.2, 0.3])
+
+
+def test_areas_follow_scale(dummy):
+ # WHEN
+ dummy.scale.value = 4.0
+
+ # THEN EXPECT
+ assert dummy.get_component_collections()[0][1].area.value == pytest.approx(1.2)
+
+
+def test_setting_Q_builds_collections():
+ # WHEN
+ dummy = DummyDiffusionModel()
+
+ # THEN EXPECT
+ assert dummy.get_component_collections() == []
+ dummy.Q = [0.5]
+ assert len(dummy.get_component_collections()[0][:]) == 4
+
+
+# ----------------------------------------------------------------------
+# Orders and validation
+# ----------------------------------------------------------------------
+
+
+@pytest.mark.parametrize('order', [0, 4, 1.5, True])
+def test_order_out_of_range_raises(dummy, order):
+ # WHEN THEN EXPECT
+ with pytest.raises((TypeError, ValueError), match='order must be'):
+ dummy.lorentzian_component_name(order)
+
+
+@pytest.mark.parametrize('n, error', [(0, ValueError), (1.0, TypeError), (True, TypeError)])
+def test_number_of_lorentzians_validated(n, error):
+ # WHEN THEN EXPECT
+ with pytest.raises(error, match='number_of_lorentzians must be'):
+ DummyDiffusionModel(number_of_lorentzians=n)
+
+
+def test_number_of_lorentzians_read_only(dummy):
+ # WHEN THEN EXPECT
+ assert dummy.number_of_lorentzians == 3
+ with pytest.raises(AttributeError):
+ dummy.number_of_lorentzians = 2
+
+
+@pytest.mark.parametrize('kwargs', [{'delta_name': 1}, {'delta_display_name': 1}])
+def test_delta_names_validated(kwargs):
+ # WHEN THEN EXPECT
+ with pytest.raises(TypeError):
+ DummyDiffusionModel(**kwargs)
+
+
+@pytest.mark.parametrize('display_name', ['Elastic line', None])
+def test_delta_name_setters(dummy, display_name):
+ # WHEN
+ dummy.delta_name = 'Elastic'
+ dummy.delta_display_name = display_name
+
+ # THEN EXPECT
+ assert dummy.delta_name == 'Elastic'
+ assert dummy.delta_display_name == display_name
+
+
+@pytest.mark.parametrize(
+ 'attribute, message',
+ [
+ ('delta_name', 'delta_name must be a string'),
+ ('delta_display_name', 'delta_display_name must be a string or None'),
+ ],
+)
+def test_delta_name_setters_reject_non_strings(dummy, attribute, message):
+ # WHEN THEN EXPECT
+ with pytest.raises(TypeError, match=message):
+ setattr(dummy, attribute, 1)
+
+
+def test_incomplete_subclass_cannot_be_created():
+ # WHEN
+ class NoEISF(DummyDiffusionModel):
+ calculate_EISF = DeltaLorentzianDiffusionModelBase.calculate_EISF
+
+ # THEN EXPECT
+ with pytest.raises(TypeError):
+ NoEISF()
+
+
+# ----------------------------------------------------------------------
+# Fit targets
+# ----------------------------------------------------------------------
+
+
+def test_fit_target_names(dummy):
+ # WHEN THEN EXPECT
+ assert [t.name for t in dummy.get_fit_targets()] == [
+ 'elastic_area',
+ 'area_1',
+ 'width_1',
+ 'area_2',
+ 'width_2',
+ 'area_3',
+ 'width_3',
+ ]
+
+
+def test_fit_target_values(dummy):
+ # WHEN
+ targets = {t.name: t for t in dummy.get_fit_targets()}
+ Q = np.array([0.5])
+
+ # THEN EXPECT
+ np.testing.assert_allclose(targets['elastic_area'].function(Q), [1.0])
+ np.testing.assert_allclose(targets['area_2'].function(Q), [0.3])
+ np.testing.assert_allclose(targets['width_3'].function(Q), [0.3])
+
+
+def test_fit_target_keys_follow_names(dummy):
+ # WHEN
+ dummy.delta_name = 'Elastic'
+ targets = dummy.get_fit_targets()
+
+ # THEN EXPECT
+ assert targets[0].dataset_key == 'Elastic area'
+ assert targets[1].dataset_key == 'Dummy_k1 area'
diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_in_sphere.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_in_sphere.py
new file mode 100644
index 00000000..f2b768c0
--- /dev/null
+++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_diffusion_in_sphere.py
@@ -0,0 +1,502 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+
+import warnings
+
+import numpy as np
+import pytest
+import scipp as sc
+from scipp.constants import hbar as scipp_hbar
+from scipy.integrate import quad
+from scipy.special import spherical_jn
+
+import easydynamics as edyn
+from easydynamics.sample_model.components import DeltaFunction
+from easydynamics.sample_model.components import Lorentzian
+from easydynamics.sample_model.diffusion_model.diffusion_in_sphere import MAXIMUM_ROOT
+from easydynamics.sample_model.diffusion_model.diffusion_in_sphere import DiffusionInSphere
+from easydynamics.sample_model.diffusion_model.diffusion_in_sphere import sphere_roots
+
+# Mantid InelasticDiffSphere.cpp (initXnlCoeff): the first 12 roots x_nl of j_l'(x) = 0, with
+# their (l, n), and the last of its 98 roots. Mantid prints them with 6 decimals.
+MANTID_FIRST_ROOTS = [
+ 2.081576, 3.342094, 4.493409, 4.514100, 5.646704, 5.940370,
+ 6.756456, 7.289932, 7.725252, 7.851078, 8.583755, 8.934839,
+] # fmt: skip
+MANTID_FIRST_ORDERS = [
+ (1, 0), (2, 0), (0, 1), (3, 0), (4, 0), (1, 1),
+ (5, 0), (2, 1), (0, 2), (6, 0), (3, 1), (7, 0),
+] # fmt: skip
+MANTID_LAST_ROOT = 26.837518
+
+# Weights A near the 0/0 points QR = x_nl, computed once with mpmath at 50 digits from the root
+# found to 50 digits (script make_sphere_limit_references.py in the 2026-10-23 session scratchpad).
+LIMIT_REFERENCES = [ # (l, n, QR - x, weight A)
+ (1, 0, 0.0, 0.46096729855722723),
+ (1, 0, 1e-08, 0.4609673001282459),
+ (1, 0, -1e-08, 0.46096729696343897),
+ (1, 0, 1e-06, 0.460967456786031),
+ (1, 0, -1e-06, 0.46096714030533814),
+ (1, 0, 0.0001, 0.4609831210020438),
+ (1, 0, -0.0001, 0.46095147293282407),
+ (0, 1, 0.0, 0.07078567383644102),
+ (0, 1, 1e-08, 0.0707856736811847),
+ (0, 1, -1e-08, 0.07078567399624913),
+ (0, 1, 1e-06, 0.07078565808547693),
+ (0, 1, -1e-06, 0.07078568959191965),
+ (0, 1, 0.0001, 0.070784098330299),
+ (0, 1, -0.0001, 0.07078724897456277),
+ (7, 2, 0.0, 0.06864322107763328),
+ (7, 2, 1e-08, 0.0686432210551458),
+ (7, 2, -1e-08, 0.06864322109744807),
+ (7, 2, 1e-06, 0.06864321896116536),
+ (7, 2, -1e-06, 0.06864322319139181),
+ (7, 2, 0.0001, 0.06864300938149931),
+ (7, 2, -0.0001, 0.0686434324041429),
+ (12, 3, 0.0, 0.04595736490553809),
+ (12, 3, 1e-08, 0.045957364897496314),
+ (12, 3, -1e-08, 0.04595736491280735),
+ (12, 3, 1e-06, 0.04595736413958797),
+ (12, 3, -1e-06, 0.04595736567069177),
+ (12, 3, 0.0001, 0.0459572882303148),
+ (12, 3, -0.0001, 0.04595744134069539),
+]
+
+
+def order_of(model: DiffusionInSphere, angular_order: int, n: int) -> tuple[int, float]:
+ """The Lorentzian order j and root x of the (l, n) term."""
+ for j, (x, root_l, root_n) in enumerate(model.roots, start=1):
+ if (root_l, root_n) == (angular_order, n):
+ return j, x
+ raise KeyError((angular_order, n))
+
+
+class TestDiffusionInSphere:
+ @pytest.fixture
+ def model(self):
+ # The physics tests check the full sum, all 98 terms
+ return DiffusionInSphere(number_of_lorentzians=98)
+
+ def test_create_weight_requires_float_Q(self, model):
+ # WHEN THEN EXPECT
+ with pytest.raises(TypeError, match='Q must be a float'):
+ model._create_weight(1, 1)
+
+ # ------------------------------------------------------------------
+ # Roots
+ # ------------------------------------------------------------------
+
+ def test_roots_match_mantid(self):
+ # WHEN
+ roots = sphere_roots()
+
+ # THEN EXPECT
+ assert len(roots) == 98
+ assert all(0 < x < MAXIMUM_ROOT for x, _, _ in roots)
+ assert [x for x, _, _ in roots] == sorted(x for x, _, _ in roots)
+ np.testing.assert_allclose(
+ [x for x, _, _ in roots[:12]], MANTID_FIRST_ROOTS, rtol=0, atol=1e-6
+ )
+ assert [(order, n) for _, order, n in roots[:12]] == MANTID_FIRST_ORDERS
+ assert roots[-1][0] == pytest.approx(MANTID_LAST_ROOT, abs=1e-6)
+
+ def test_roots_are_zeros_of_the_derivative(self):
+ # WHEN THEN EXPECT
+ for x, angular_order, _ in sphere_roots():
+ assert abs(spherical_jn(angular_order, x, derivative=True)) < 1e-12
+
+ # ------------------------------------------------------------------
+ # Physics
+ # ------------------------------------------------------------------
+
+ def test_EISF(self, model):
+ # WHEN
+ Q = np.array([0.05, 0.7, 2.3, 4.0])
+
+ # THEN EXPECT
+ np.testing.assert_allclose(
+ model.calculate_EISF(Q), (3 * spherical_jn(1, Q) / Q) ** 2, rtol=1e-13
+ )
+
+ def test_weights_match_bicout_integral(self, model):
+ """
+ Bicout, PRE 62, 261 (2000), Eq. 2.12, single sphere of radius 1: A = (2l+1) |integral of
+ r^2 sqrt(p_eq) j_l(Q r) psi(r) dr|^2 with p_eq = 3 and psi(r) = r j_l(x r) / sqrt(Z).
+ """
+ # WHEN THEN EXPECT
+ for j, (x, order, _) in enumerate(model.roots[:10], start=1):
+ Z = quad(
+ lambda r, x=x, order=order: r * r * spherical_jn(order, x * r) ** 2,
+ 0,
+ 1,
+ epsabs=1e-15,
+ epsrel=1e-12,
+ )[0]
+ for QR in (0.3, 1.7, 4.2):
+ overlap = quad(
+ lambda r, x=x, order=order, QR=QR: (
+ r
+ * np.sqrt(3.0)
+ * spherical_jn(order, QR * r)
+ * r
+ * spherical_jn(order, x * r)
+ ),
+ 0,
+ 1,
+ epsabs=1e-14, # reachable for small overlaps (l = 0 at QR = 0.3: 5.6e-4)
+ epsrel=1e-12,
+ limit=200,
+ )[0]
+ expected = (2 * order + 1) * overlap**2 / Z
+ assert model.calculate_QISF_order(np.array([QR]), j)[0] == pytest.approx(
+ expected, rel=1e-10, abs=1e-15
+ )
+
+ @pytest.mark.parametrize(
+ 'angular_order, n, distance, expected',
+ LIMIT_REFERENCES,
+ ids=[f'l{order}_n{n}_{d:+.0e}' for order, n, d, _ in LIMIT_REFERENCES],
+ )
+ def test_limit_region_matches_mpmath(self, model, angular_order, n, distance, expected):
+ # WHEN radius 1, so QR = Q
+ j, x = order_of(model, angular_order, n)
+
+ # THEN
+ weight = model.calculate_QISF_order(np.array([x + distance]), j)[0]
+
+ # EXPECT
+ assert weight == pytest.approx(expected, rel=1e-9, abs=0)
+
+ def test_weights_sum_to_one_up_to_the_missing_weight(self, model):
+ # WHEN
+ Q = np.array([0.1, 1.0, 3.0, 5.0])
+
+ # THEN
+ missing = model.calculate_missing_weight(Q)
+
+ # EXPECT
+ np.testing.assert_allclose(
+ model.calculate_EISF(Q) + model.calculate_QISF(Q) + missing, 1.0, rtol=1e-14
+ )
+ assert np.all(missing >= 0)
+ assert np.all(missing < 3e-4)
+
+ def test_small_QR_is_finite_and_elastic(self, model):
+ # WHEN
+ Q = np.array([1e-6])
+
+ # THEN
+ weights = [model.calculate_QISF_order(Q, j)[0] for j in range(1, 99)]
+
+ # EXPECT
+ assert model.calculate_EISF(Q)[0] == pytest.approx(1.0, abs=1e-10)
+ assert all(np.isfinite(weights))
+ assert all(w >= 0 for w in weights)
+ assert sum(weights) < 1e-10
+
+ def test_width(self):
+ # WHEN radius 2 angstrom, D 0.1 angstrom^2/ps
+ model = DiffusionInSphere(radius=2.0, diffusion_coefficient=0.1, number_of_lorentzians=98)
+ hbar_meV_ps = scipp_hbar.to(unit='meV*ps').value
+
+ # THEN
+ widths = [model.calculate_width(np.array([1.0]), j)[0] for j in (1, 3, 98)]
+
+ # EXPECT
+ expected = [hbar_meV_ps * model.roots[j - 1][0] ** 2 * 0.1 / 4.0 for j in (1, 3, 98)]
+ np.testing.assert_allclose(widths, expected, rtol=1e-12)
+
+ def test_width_follows_diffusion_coefficient_unit(self):
+ # WHEN
+ model = DiffusionInSphere(radius=2.0, diffusion_coefficient=0.1)
+ width_angstrom = model.calculate_width(np.array([1.0]), 5)
+
+ # THEN 0.1 angstrom^2/ps = 1e-9 m^2/s
+ model.diffusion_coefficient.convert_unit('m**2/s')
+
+ # EXPECT
+ assert model.diffusion_coefficient.value == pytest.approx(1e-9, rel=1e-12)
+ np.testing.assert_allclose(
+ model.calculate_width(np.array([1.0]), 5), width_angstrom, rtol=1e-12
+ )
+
+ def test_weights_follow_radius_unit(self):
+ # WHEN
+ model = DiffusionInSphere(radius=3.0)
+ Q = np.array([0.5, 1.5])
+ weights_angstrom = model.calculate_QISF_order(Q, 4)
+
+ # THEN
+ model.radius.convert_unit('nm')
+
+ # EXPECT the same physical radius gives the same weights
+ np.testing.assert_allclose(model.calculate_QISF_order(Q, 4), weights_angstrom, rtol=1e-12)
+ np.testing.assert_allclose(
+ model.calculate_EISF(Q), (3 * spherical_jn(1, 3.0 * Q) / (3.0 * Q)) ** 2, rtol=1e-13
+ )
+
+ def test_init_default(self):
+ # WHEN
+ model = DiffusionInSphere()
+
+ # THEN EXPECT a short sum by default, as dependent-Parameter updates are slow in Core 2.5.1
+ assert model.display_name == 'DiffusionInSphere'
+ assert model.number_of_lorentzians == 10
+ assert model.radius.unit == 'Å'
+ assert model.radius.fixed is False
+ # Scipp has no named unit for angstrom^2/ps; it is stored as the equivalent 1e-8 m^2/s
+ assert model.diffusion_coefficient.full_value.to(unit='angstrom**2/ps').value == (
+ pytest.approx(1.0, rel=1e-12)
+ )
+ assert model.diffusion_coefficient.fixed is False
+
+
+# The dependency strings write j_l in elementary functions, accurate to about 3e-13 absolute;
+# the weight multiplies that by up to (2l+1) 6x^2 / (x^2 - l(l+1)) and divides by a^2 - x^2.
+STRING_ATOL = 1e-10
+
+
+class TestDiffusionInSphereComponents:
+ def test_components_per_Q(self):
+ # WHEN
+ model = DiffusionInSphere(Q=np.array([0.5, 1.0]))
+
+ # THEN
+ collections = model.get_component_collections()
+
+ # EXPECT
+ assert len(collections) == 2
+ for collection in collections:
+ assert len(collection) == 11
+ assert isinstance(collection[0], DeltaFunction)
+ assert all(isinstance(component, Lorentzian) for component in collection[1:])
+ assert collections[0][10].name == 'DiffusionInSphere_j10'
+
+ @pytest.mark.parametrize('radius', [0.5, 2.0, 4.5])
+ def test_dependent_weights_follow_radius(self, radius):
+ # WHEN
+ Q = np.array([0.4, 1.3])
+ model = DiffusionInSphere(scale=2.0, radius=1.0, Q=Q)
+
+ # THEN
+ model.radius = radius
+
+ # EXPECT
+ collections = model.get_component_collections()
+ EISF = model.calculate_EISF(Q)
+ for i, collection in enumerate(collections):
+ assert collection[0].area.value == pytest.approx(2.0 * EISF[i], rel=0, abs=STRING_ATOL)
+ for j in range(1, model.number_of_lorentzians + 1):
+ assert collection[j].area.value == pytest.approx(
+ 2.0 * model.calculate_QISF_order(Q, j)[i], rel=0, abs=STRING_ATOL
+ )
+
+ @pytest.mark.parametrize('distance', [0.0, 1e-7, -1e-7, 1e-3, -1e-3])
+ def test_dependent_weights_at_and_near_a_root(self, distance):
+ """A fit step can move QR onto a root, where the weight is a 0/0."""
+ # WHEN
+ model = DiffusionInSphere(Q=np.array([1.0]))
+ j, x = order_of(model, 1, 0)
+
+ # THEN radius = x + distance, so QR = x + distance at Q = 1
+ model.radius = x + distance
+
+ # EXPECT
+ dependent = model.get_component_collections()[0][j].area.value
+ assert dependent == pytest.approx(
+ model.calculate_QISF_order(np.array([1.0]), j)[0], rel=1e-8, abs=0
+ )
+
+ def test_component_widths(self):
+ # WHEN
+ Q = np.array([0.7])
+ model = DiffusionInSphere(radius=2.0, diffusion_coefficient=0.1, Q=Q)
+
+ # THEN
+ model.diffusion_coefficient = 0.3
+ model.radius = 2.5
+
+ # EXPECT
+ collection = model.get_component_collections()[0]
+ for j in (1, 5, 10):
+ assert collection[j].width.value == pytest.approx(
+ model.calculate_width(Q, j)[0], rel=1e-12
+ )
+ assert collection[j].width.unit == 'meV'
+
+ def test_component_parameters_are_dependent(self):
+ # WHEN
+ model = DiffusionInSphere(Q=np.array([1.0]))
+ collection = model.get_component_collections()[0]
+
+ # THEN
+ fit_parameters = model.get_fit_parameters()
+
+ # EXPECT
+ for component in collection:
+ assert component.area.independent is False
+ assert all(component.area is not p for p in fit_parameters)
+ for component in collection[1:]:
+ assert component.width.independent is False
+
+ def test_weights_follow_radius_unit(self):
+ # WHEN
+ Q = np.array([0.8])
+ # QR = 2.4: 30 terms keep the truncation below the warning threshold
+ model = DiffusionInSphere(radius=3.0, Q=Q, number_of_lorentzians=30)
+ areas_angstrom = [c.area.value for c in model.get_component_collections()[0]]
+ widths_angstrom = [c.width.value for c in model.get_component_collections()[0][1:]]
+
+ # THEN
+ model.radius.convert_unit('nm')
+
+ # EXPECT
+ areas_nm = [c.area.value for c in model.get_component_collections()[0]]
+ widths_nm = [c.width.value for c in model.get_component_collections()[0][1:]]
+ np.testing.assert_allclose(areas_nm, areas_angstrom, rtol=0, atol=STRING_ATOL)
+ np.testing.assert_allclose(widths_nm, widths_angstrom, rtol=1e-12)
+
+ def test_large_QR_warns_and_stays_finite(self):
+ # WHEN THEN QR = 30, beyond the 98 roots below 26.84
+ with pytest.warns(UserWarning, match='number_of_lorentzians'):
+ model = DiffusionInSphere(radius=10.0, Q=np.array([3.0]))
+
+ # EXPECT
+ collection = model.get_component_collections()[0]
+ assert all(np.isfinite(component.area.value) for component in collection)
+ assert all(np.isfinite(component.width.value) for component in collection[1:])
+
+ @pytest.mark.parametrize('number_of_lorentzians', [30, 98])
+ def test_number_of_lorentzians_keeps_the_lowest_roots(self, number_of_lorentzians):
+ # WHEN
+ Q = np.array([0.6, 1.1])
+ full = DiffusionInSphere(radius=3.0, number_of_lorentzians=98)
+
+ # THEN
+ model = DiffusionInSphere(radius=3.0, Q=Q, number_of_lorentzians=number_of_lorentzians)
+
+ # EXPECT the first number_of_lorentzians terms of the full sum, unchanged
+ assert model.number_of_lorentzians == number_of_lorentzians
+ assert model.roots == full.roots[:number_of_lorentzians]
+ kept = sum(full.calculate_QISF_order(Q, j) for j in range(1, number_of_lorentzians + 1))
+ np.testing.assert_allclose(model.calculate_QISF(Q), kept, rtol=1e-13)
+ # 1 minus nearly 1: compare absolutely, at the rounding of 1
+ np.testing.assert_allclose(
+ model.calculate_missing_weight(Q),
+ 1.0 - full.calculate_EISF(Q) - kept,
+ rtol=0,
+ atol=1e-15,
+ )
+ for i, collection in enumerate(model.get_component_collections()):
+ assert len(collection) == number_of_lorentzians + 1
+ for j in range(1, number_of_lorentzians + 1):
+ assert collection[j].area.value == pytest.approx(
+ full.calculate_QISF_order(Q, j)[i], rel=0, abs=STRING_ATOL
+ )
+ assert collection[j].width.value == pytest.approx(
+ full.calculate_width(Q, j)[i], rel=1e-12
+ )
+
+ def test_short_sum_warns_when_truncation_is_large(self):
+ # WHEN THEN EXPECT QR = 3: the first 10 terms miss 4.3e-3 of the weight
+ with pytest.warns(UserWarning, match='number_of_lorentzians=10'):
+ DiffusionInSphere(radius=3.0, Q=np.array([1.0]), number_of_lorentzians=10)
+
+ def test_no_warning_when_truncation_is_negligible(self):
+ # WHEN THEN EXPECT QR = 1: the default 10 terms miss 3.7e-4 of the weight
+ with warnings.catch_warnings():
+ warnings.simplefilter('error')
+ DiffusionInSphere(radius=1.0, Q=np.array([1.0]))
+
+ def test_parameter_updates_raise_no_runtime_warning(self):
+ """A fit changes R and D on every step; the dependent values must not warn."""
+ # WHEN
+ model = DiffusionInSphere(Q=np.array([0.5]))
+
+ # THEN EXPECT
+ with warnings.catch_warnings():
+ warnings.simplefilter('error', RuntimeWarning)
+ model.radius = 1.7
+ model.diffusion_coefficient = 0.4
+
+ @pytest.mark.parametrize(
+ 'kwargs, expected_exception, expected_message',
+ [
+ ({'radius': 'invalid'}, TypeError, 'radius must be a number'),
+ ({'radius': 0.0}, ValueError, 'radius must be at least'),
+ ({'diffusion_coefficient': 'x'}, TypeError, 'diffusion_coefficient must be a number'),
+ ({'diffusion_coefficient': 0.0}, ValueError, 'diffusion_coefficient must be at least'),
+ ({'delta_name': 1}, TypeError, 'delta_name must be a string'),
+ ({'number_of_lorentzians': 2.5}, TypeError, 'number_of_lorentzians must be an'),
+ ({'number_of_lorentzians': 0}, ValueError, 'number_of_lorentzians must be at least'),
+ (
+ {'number_of_lorentzians': 99},
+ ValueError,
+ 'number_of_lorentzians must be at most 98',
+ ),
+ ],
+ )
+ def test_init_rejects_invalid_input(self, kwargs, expected_exception, expected_message):
+ # WHEN THEN EXPECT
+ with pytest.raises(expected_exception, match=expected_message):
+ DiffusionInSphere(**kwargs)
+
+ def test_get_global_variables_and_repr(self):
+ # WHEN
+ model = DiffusionInSphere()
+
+ # THEN EXPECT
+ assert model.get_global_variables() == [
+ model.scale,
+ model.radius,
+ model.diffusion_coefficient,
+ ]
+ assert 'diffusion_coefficient=' in repr(model)
+ assert 'number_of_lorentzians=10' in repr(model)
+
+ def test_fit_recovers_parameters(self):
+ """One Q, synthetic data from the model; R and D are fitted, as in a real fit."""
+ # WHEN
+ Q = sc.array(dims=['Q'], values=[1.0], unit='1/angstrom')
+ energy = sc.linspace('energy', -2.0, 2.0, 201, unit='meV')
+ shape = (1, energy.shape[0])
+
+ def as_data(values, variances):
+ return sc.DataArray(
+ sc.array(dims=['Q', 'energy'], values=values, variances=variances),
+ coords={'Q': Q, 'energy': energy},
+ )
+
+ # QR = 3: 30 terms keep the truncation below the warning threshold
+ true_model = DiffusionInSphere(
+ radius=3.0, diffusion_coefficient=0.1, number_of_lorentzians=30
+ )
+ experiment = edyn.Experiment('synthetic', data=as_data(np.ones(shape), np.ones(shape)))
+ clean = np.array(
+ edyn.Analysis(
+ experiment=experiment, sample_model=edyn.SampleModel(diffusion_models=true_model)
+ ).calculate()
+ )
+ noise = 0.003 * clean.max()
+ rng = np.random.default_rng(seed=1)
+ experiment.data = as_data(clean + rng.normal(0.0, noise, shape), np.full(shape, noise**2))
+ fit_model = DiffusionInSphere(
+ radius=2.5, diffusion_coefficient=0.07, number_of_lorentzians=30
+ )
+ analysis = edyn.Analysis(
+ experiment=experiment, sample_model=edyn.SampleModel(diffusion_models=fit_model)
+ )
+ analysis.fix_energy_offset()
+
+ # THEN
+ analysis.fit(fit_method='independent')
+
+ # EXPECT
+ for name, true_value in {'radius': 3.0, 'diffusion_coefficient': 0.1}.items():
+ parameter = getattr(fit_model, name)
+ true_in_unit = sc.scalar(
+ true_value, unit='angstrom' if name == 'radius' else 'angstrom**2/ps'
+ )
+ true_value = true_in_unit.to(unit=parameter.unit).value
+ assert parameter.variance > 0
+ assert abs(parameter.value - true_value) < 5 * parameter.error
diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_equivalent_sites_circle.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_equivalent_sites_circle.py
new file mode 100644
index 00000000..00e2a2d6
--- /dev/null
+++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_equivalent_sites_circle.py
@@ -0,0 +1,404 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+
+import numpy as np
+import pytest
+import scipp as sc
+from scipp.constants import hbar as scipp_hbar
+
+from easydynamics.sample_model.components import DeltaFunction
+from easydynamics.sample_model.components import Lorentzian
+from easydynamics.sample_model.diffusion_model.equivalent_sites_circle import (
+ MAXIMUM_NUMBER_OF_SITES,
+)
+from easydynamics.sample_model.diffusion_model.equivalent_sites_circle import MINIMUM_RADIUS
+from easydynamics.sample_model.diffusion_model.equivalent_sites_circle import MINIMUM_WIDTH
+from easydynamics.sample_model.diffusion_model.equivalent_sites_circle import EquivalentSitesCircle
+
+# A weight is a sum of N terms of size up to 1/N that can nearly cancel, so its rounding error is
+# absolute, a few N * machine epsilon, not relative to the (possibly tiny) result.
+WEIGHT_ATOL = 1e-14
+
+
+def hbar_over(residence_time_ps: float) -> float:
+ """hbar / tau in meV, computed with Scipp's constants."""
+ return (scipp_hbar / sc.scalar(residence_time_ps, unit='ps')).to(unit='meV').value
+
+
+class TestEquivalentSitesCircle:
+ @pytest.fixture
+ def model(self):
+ return EquivalentSitesCircle()
+
+ def test_create_weight_requires_float_Q(self, model):
+ # WHEN THEN EXPECT
+ with pytest.raises(TypeError, match='Q must be a float'):
+ model._create_weight(1, 1)
+
+ # ------------------------------------------------------------------
+ # Construction and parameters
+ # ------------------------------------------------------------------
+
+ def test_init_default(self, model):
+ # WHEN THEN EXPECT
+ assert model.display_name == 'EquivalentSitesCircle'
+ assert model.x_unit == 'meV'
+ assert model.y_unit == 'dimensionless'
+ assert model.scale.value == pytest.approx(1.0)
+ assert model.number_of_sites == 3
+ assert model.radius.value == pytest.approx(1.0)
+ assert model.radius.unit == 'Å'
+ assert model.radius.fixed is True
+ assert model.radius.min == MINIMUM_RADIUS
+ assert model.residence_time.value == pytest.approx(1.0)
+ assert model.residence_time.unit == 'ps'
+ assert model.residence_time.fixed is False
+ assert model.residence_time.min == MINIMUM_WIDTH
+
+ @pytest.mark.parametrize(
+ 'kwargs, expected_exception, expected_message',
+ [
+ ({'radius': 'invalid'}, TypeError, 'radius must be a number'),
+ ({'radius': 0.0}, ValueError, 'radius must be at least'),
+ ({'residence_time': 'invalid'}, TypeError, 'residence_time must be a number'),
+ ({'residence_time': 0.0}, ValueError, 'residence_time must be at least'),
+ ({'number_of_sites': 2.5}, TypeError, 'number_of_sites must be an integer'),
+ ({'number_of_sites': True}, TypeError, 'number_of_sites must be an integer'),
+ ({'number_of_sites': 1}, ValueError, 'number_of_sites must be between 2 and'),
+ (
+ {'number_of_sites': MAXIMUM_NUMBER_OF_SITES + 1},
+ ValueError,
+ 'number_of_sites must be between 2 and',
+ ),
+ ({'delta_name': 1}, TypeError, 'delta_name must be a string'),
+ ],
+ ids=[
+ 'radius_type',
+ 'radius_zero',
+ 'residence_time_type',
+ 'residence_time_zero',
+ 'sites_float',
+ 'sites_bool',
+ 'sites_too_few',
+ 'sites_too_many',
+ 'delta_name_type',
+ ],
+ )
+ def test_init_rejects_invalid_input(self, kwargs, expected_exception, expected_message):
+ with pytest.raises(expected_exception, match=expected_message):
+ EquivalentSitesCircle(**kwargs)
+
+ def test_number_of_sites_accepts_numpy_integer(self):
+ # WHEN THEN EXPECT
+ assert EquivalentSitesCircle(number_of_sites=np.int64(4)).number_of_sites == 4
+
+ def test_number_of_sites_is_read_only(self, model):
+ with pytest.raises(AttributeError):
+ model.number_of_sites = 4
+
+ def test_radius_setter(self, model):
+ # WHEN
+ model.radius = 2.5
+
+ # THEN EXPECT
+ assert model.radius.value == pytest.approx(2.5)
+
+ @pytest.mark.parametrize(
+ 'value, expected_exception',
+ [('invalid', TypeError), (0.0, ValueError)],
+ ids=['type', 'zero'],
+ )
+ def test_radius_setter_raises(self, model, value, expected_exception):
+ with pytest.raises(expected_exception, match='radius must be'):
+ model.radius = value
+
+ def test_residence_time_setter(self, model):
+ # WHEN
+ model.residence_time = 3.0
+
+ # THEN EXPECT
+ assert model.residence_time.value == pytest.approx(3.0)
+
+ @pytest.mark.parametrize(
+ 'value, expected_exception',
+ [('invalid', TypeError), (0.0, ValueError), (MINIMUM_WIDTH / 10, ValueError)],
+ ids=['type', 'zero', 'below_minimum'],
+ )
+ def test_residence_time_setter_raises(self, model, value, expected_exception):
+ with pytest.raises(expected_exception, match='residence_time must be'):
+ model.residence_time = value
+
+ # ------------------------------------------------------------------
+ # Physics of the structure factors and widths
+ # ------------------------------------------------------------------
+
+ @pytest.mark.parametrize('number_of_sites', [2, 3, 6, 11])
+ def test_weights_sum_to_one(self, number_of_sites):
+ """EISF + sum of all quasi-elastic weights is exactly 1 (no truncation)."""
+ # WHEN
+ model = EquivalentSitesCircle(number_of_sites=number_of_sites, radius=1.3)
+ Q = np.array([1e-3, 0.3, 1.0, 2.5, 7.0])
+
+ # THEN
+ total = model.calculate_EISF(Q) + model.calculate_QISF(Q)
+
+ # EXPECT
+ np.testing.assert_allclose(total, 1.0, rtol=0, atol=1e-12)
+
+ def test_QISF_is_sum_of_orders(self):
+ # WHEN
+ model = EquivalentSitesCircle(number_of_sites=5, radius=1.3)
+ Q = np.array([0.5, 1.5])
+
+ # THEN
+ orders = [model.calculate_QISF_order(Q, order) for order in range(1, 5)]
+
+ # EXPECT
+ np.testing.assert_allclose(
+ model.calculate_QISF(Q), np.sum(orders, axis=0), rtol=0, atol=WEIGHT_ATOL
+ )
+
+ def test_two_sites_is_the_two_site_jump_model(self):
+ """N = 2: EISF = (1 + j0(2QR)) / 2 and Gamma = 2 hbar / tau (Bee)."""
+ # WHEN
+ radius = 1.2
+ model = EquivalentSitesCircle(number_of_sites=2, radius=radius, residence_time=1.5)
+ Q = np.array([0.3, 1.0, 2.0])
+
+ # THEN
+ EISF = model.calculate_EISF(Q)
+ width = model.calculate_width(Q, order=1)
+
+ # EXPECT
+ jump_distance = 2 * radius
+ expected_EISF = (1 + np.sin(Q * jump_distance) / (Q * jump_distance)) / 2
+ np.testing.assert_allclose(EISF, expected_EISF, rtol=0, atol=WEIGHT_ATOL)
+ np.testing.assert_allclose(
+ model.calculate_QISF_order(Q, 1), 1 - expected_EISF, rtol=0, atol=WEIGHT_ATOL
+ )
+ np.testing.assert_allclose(width, 2 * hbar_over(1.5), rtol=1e-12)
+
+ def test_EISF_tends_to_one_at_small_Q(self):
+ # WHEN
+ model = EquivalentSitesCircle(number_of_sites=4, radius=2.0)
+
+ # THEN EXPECT
+ assert model.calculate_EISF(np.array([1e-8]))[0] == pytest.approx(1.0, rel=1e-12)
+
+ def test_orders_l_and_N_minus_l_are_equal(self):
+ # WHEN
+ model = EquivalentSitesCircle(number_of_sites=6, radius=1.1, residence_time=2.0)
+ Q = np.array([0.4, 1.7])
+
+ # THEN EXPECT
+ for order in range(1, 6):
+ np.testing.assert_allclose(
+ model.calculate_QISF_order(Q, order),
+ model.calculate_QISF_order(Q, 6 - order),
+ rtol=0,
+ atol=WEIGHT_ATOL,
+ )
+ np.testing.assert_allclose(
+ model.calculate_width(Q, order), model.calculate_width(Q, 6 - order), rtol=1e-12
+ )
+
+ def test_width_follows_sine_squared(self):
+ """Gamma_l = (2 hbar / tau) sin^2(pi l / N), the residence-time convention."""
+ # WHEN
+ model = EquivalentSitesCircle(number_of_sites=5, residence_time=2.0)
+ Q = np.array([0.5, 1.5])
+
+ # THEN EXPECT
+ for order in range(1, 5):
+ expected = 2 * hbar_over(2.0) * np.sin(np.pi * order / 5) ** 2
+ np.testing.assert_allclose(model.calculate_width(Q, order), expected, rtol=1e-12)
+
+ def test_matches_QENSmodels_reference_values(self):
+ """
+ Doctest values of QENSmodels hwhmEquivalentSitesCircle([1., 2.], 6, 0.5, 1.5), rounded
+ there to the digits given. Its widths are in 1/ps, so they are multiplied by hbar here.
+ """
+ # WHEN
+ model = EquivalentSitesCircle(number_of_sites=6, radius=0.5, residence_time=1.5)
+ Q = np.array([1.0, 2.0])
+
+ # THEN
+ EISF = model.calculate_EISF(Q)
+
+ # EXPECT
+ np.testing.assert_allclose(EISF, [0.92, 0.713], atol=5e-4)
+ assert model.calculate_QISF_order(Q, 2)[0] == pytest.approx(0.000503, abs=5e-7)
+ assert model.calculate_QISF_order(Q, 5)[1] == pytest.approx(0.13616, abs=5e-6)
+ hbar_per_ps = hbar_over(1.0)
+ assert model.calculate_width(Q, 1)[0] / hbar_per_ps == pytest.approx(0.333, abs=5e-4)
+ assert model.calculate_width(Q, 3)[0] / hbar_per_ps == pytest.approx(1.333, abs=5e-4)
+
+ def test_weights_follow_radius_unit(self):
+ # WHEN
+ model = EquivalentSitesCircle(number_of_sites=4, radius=1.5)
+ Q = np.array([0.8, 1.6])
+ EISF_angstrom = model.calculate_EISF(Q)
+
+ # THEN
+ model.radius.convert_unit('nm')
+
+ # EXPECT the same physical radius gives the same weights
+ np.testing.assert_allclose(
+ model.calculate_EISF(Q), EISF_angstrom, rtol=0, atol=WEIGHT_ATOL
+ )
+
+ @pytest.mark.parametrize('order', [0, 3, 1.0, True], ids=['zero', 'N', 'float', 'bool'])
+ def test_order_validation(self, model, order):
+ with pytest.raises((TypeError, ValueError), match='order must be'):
+ model.calculate_QISF_order(np.array([1.0]), order)
+ with pytest.raises((TypeError, ValueError), match='order must be'):
+ model.calculate_width(np.array([1.0]), order)
+
+ def test_calculate_EISF_type_error(self, model):
+ with pytest.raises(TypeError, match='Q must be '):
+ model.calculate_EISF(Q='invalid')
+
+ # ------------------------------------------------------------------
+ # Components
+ # ------------------------------------------------------------------
+
+ def test_no_Q_gives_no_collections(self, model):
+ # WHEN THEN EXPECT
+ assert model.get_component_collections() == []
+
+ def test_component_names_follow_the_base_convention(self):
+ # WHEN
+ model = EquivalentSitesCircle(number_of_sites=3, Q=np.array([1.0]))
+
+ # THEN EXPECT
+ assert model.number_of_lorentzians == 2
+ assert model.lorentzian_component_name(2) == 'EquivalentSitesCircle_l2'
+
+ def test_components_per_Q(self):
+ # WHEN
+ model = EquivalentSitesCircle(number_of_sites=4, Q=np.array([0.5, 1.0]))
+
+ # THEN
+ collections = model.get_component_collections()
+
+ # EXPECT one delta function and N - 1 Lorentzians per Q
+ assert len(collections) == 2
+ for collection in collections:
+ assert len(collection) == 4
+ assert isinstance(collection[0], DeltaFunction)
+ assert all(isinstance(component, Lorentzian) for component in collection[1:])
+ assert [component.name for component in collection[1:]] == [
+ model.lorentzian_component_name(order) for order in range(1, 4)
+ ]
+
+ def test_component_areas_and_widths_match_calculations(self):
+ """The dependency strings must give what the Scipp calculations give."""
+ # WHEN
+ Q = np.array([0.4, 1.1, 2.3])
+ model = EquivalentSitesCircle(
+ scale=2.5, radius=1.4, residence_time=0.8, number_of_sites=5, Q=Q
+ )
+
+ # THEN
+ collections = model.get_component_collections()
+
+ # EXPECT
+ EISF = model.calculate_EISF(Q)
+ for i, collection in enumerate(collections):
+ assert collection[0].area.value == pytest.approx(2.5 * EISF[i], rel=0, abs=WEIGHT_ATOL)
+ for order in range(1, 5):
+ lorentzian = collection[order]
+ weight = model.calculate_QISF_order(Q, order)[i]
+ width = model.calculate_width(Q, order)[i]
+ assert lorentzian.area.value == pytest.approx(2.5 * weight, rel=0, abs=WEIGHT_ATOL)
+ assert lorentzian.width.value == pytest.approx(width, rel=1e-12)
+ assert lorentzian.width.unit == 'meV'
+ assert lorentzian.area.independent is False
+
+ def test_components_follow_parameter_changes(self):
+ # WHEN
+ Q = np.array([0.6, 1.8])
+ model = EquivalentSitesCircle(number_of_sites=3, Q=Q)
+
+ # THEN
+ model.radius = 2.2
+ model.residence_time = 3.5
+ model.scale = 4.0
+
+ # EXPECT
+ collections = model.get_component_collections()
+ EISF = model.calculate_EISF(Q)
+ for i, collection in enumerate(collections):
+ assert collection[0].area.value == pytest.approx(4.0 * EISF[i], rel=0, abs=WEIGHT_ATOL)
+ assert collection[1].area.value == pytest.approx(
+ 4.0 * model.calculate_QISF_order(Q, 1)[i], rel=0, abs=WEIGHT_ATOL
+ )
+ assert collection[1].width.value == pytest.approx(
+ model.calculate_width(Q, 1)[i], rel=1e-12
+ )
+
+ def test_component_weights_follow_radius_unit(self):
+ # WHEN
+ Q = np.array([0.8, 1.6])
+ model = EquivalentSitesCircle(number_of_sites=4, radius=1.5, Q=Q)
+ areas_angstrom = [c[1].area.value for c in model.get_component_collections()]
+
+ # THEN
+ model.radius.convert_unit('nm')
+
+ # EXPECT
+ areas_nm = [c[1].area.value for c in model.get_component_collections()]
+ np.testing.assert_allclose(areas_nm, areas_angstrom, rtol=0, atol=WEIGHT_ATOL)
+
+ def test_create_component_collections_installs_collections(self):
+ # WHEN
+ model = EquivalentSitesCircle(Q=np.array([1.0, 2.0]))
+
+ # THEN
+ collections = model.create_component_collections()
+
+ # EXPECT
+ assert collections is model.get_component_collections()
+
+ # ------------------------------------------------------------------
+ # Fit targets, variables and representation
+ # ------------------------------------------------------------------
+
+ def test_fit_targets(self):
+ # WHEN
+ Q = np.array([0.5, 1.5])
+ model = EquivalentSitesCircle(scale=2.0, number_of_sites=3, Q=Q)
+
+ # THEN
+ targets = {target.name: target for target in model.get_fit_targets()}
+
+ # EXPECT
+ assert set(targets) == {'elastic_area', 'area_1', 'width_1', 'area_2', 'width_2'}
+ np.testing.assert_allclose(
+ targets['elastic_area'].function(Q), 2.0 * model.calculate_EISF(Q), rtol=1e-12
+ )
+ np.testing.assert_allclose(
+ targets['area_2'].function(Q), 2.0 * model.calculate_QISF_order(Q, 2), rtol=1e-12
+ )
+ np.testing.assert_allclose(
+ targets['width_1'].function(Q), model.calculate_width(Q, 1), rtol=1e-12
+ )
+
+ def test_get_global_variables(self, model):
+ # WHEN THEN EXPECT
+ assert model.get_global_variables() == [
+ model.scale,
+ model.radius,
+ model.residence_time,
+ ]
+
+ def test_repr(self, model):
+ # WHEN THEN
+ repr_str = repr(model)
+
+ # EXPECT
+ assert 'EquivalentSitesCircle' in repr_str
+ assert 'number_of_sites=3' in repr_str
+ assert 'radius' in repr_str
+ assert 'residence_time' in repr_str
+ assert 'x_unit=meV, y_unit=dimensionless' in repr_str
diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_gaussian_model_3d.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_gaussian_model_3d.py
new file mode 100644
index 00000000..88e86743
--- /dev/null
+++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_gaussian_model_3d.py
@@ -0,0 +1,452 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+
+import warnings
+
+import numpy as np
+import pytest
+import scipp as sc
+from scipp.constants import hbar as scipp_hbar
+from scipy.stats import poisson
+
+from easydynamics.sample_model.components import DeltaFunction
+from easydynamics.sample_model.components import Lorentzian
+from easydynamics.sample_model.diffusion_model.delta_lorentz import DeltaLorentz
+from easydynamics.sample_model.diffusion_model.gaussian_model_3d import (
+ MAXIMUM_NUMBER_OF_LORENTZIANS,
+)
+from easydynamics.sample_model.diffusion_model.gaussian_model_3d import (
+ MINIMUM_DIFFUSION_COEFFICIENT,
+)
+from easydynamics.sample_model.diffusion_model.gaussian_model_3d import MINIMUM_MEAN_U_SQUARED
+from easydynamics.sample_model.diffusion_model.gaussian_model_3d import GaussianModel3D
+
+# Volino's example below in this model's units: D = 0.5 angstrom^2/ps = 5e-9 m^2/s, and
+# = 3 = 4.5 angstrom^2.
+VOLINO_D = 5e-9
+VOLINO_MEAN_U_SQUARED = 4.5
+
+
+def hbar_rate(rate_per_ps: float) -> float:
+ """hbar * rate in meV, for a rate in 1/ps, computed with Scipp's constants."""
+ return (scipp_hbar * sc.scalar(rate_per_ps, unit='1/ps')).to(unit='meV').value
+
+
+def model_without_warning(**kwargs) -> GaussianModel3D:
+ """Create a model and fail if it warns about truncation."""
+ with warnings.catch_warnings():
+ warnings.simplefilter('error')
+ return GaussianModel3D(**kwargs)
+
+
+class TestGaussianModel3D:
+ @pytest.fixture
+ def model(self):
+ return GaussianModel3D()
+
+ def test_create_weight_requires_float_Q(self, model):
+ # WHEN THEN EXPECT
+ with pytest.raises(TypeError, match='Q must be a float'):
+ model._create_weight(1, 1)
+
+ # ------------------------------------------------------------------
+ # Construction and parameters
+ # ------------------------------------------------------------------
+
+ def test_init_default(self, model):
+ # WHEN THEN EXPECT
+ assert model.display_name == 'GaussianModel3D'
+ assert model.x_unit == 'meV'
+ assert model.y_unit == 'dimensionless'
+ assert model.scale.value == pytest.approx(1.0)
+ assert model.number_of_lorentzians == 20
+ assert model.diffusion_coefficient.value == pytest.approx(1.0)
+ assert model.diffusion_coefficient.unit == 'm^2/s'
+ assert model.diffusion_coefficient.min == MINIMUM_DIFFUSION_COEFFICIENT
+ assert model.mean_u_squared.value == pytest.approx(1.0)
+ assert model.mean_u_squared.unit == 'Å^2'
+ assert model.mean_u_squared.min == MINIMUM_MEAN_U_SQUARED
+
+ @pytest.mark.parametrize(
+ 'kwargs, expected_exception, expected_message',
+ [
+ ({'diffusion_coefficient': 'x'}, TypeError, 'diffusion_coefficient must be a number'),
+ ({'diffusion_coefficient': 0.0}, ValueError, 'diffusion_coefficient must be at least'),
+ ({'mean_u_squared': 'x'}, TypeError, 'mean_u_squared must be a number'),
+ ({'mean_u_squared': 0.0}, ValueError, 'mean_u_squared must be at least'),
+ ({'number_of_lorentzians': 2.0}, TypeError, 'number_of_lorentzians must be an'),
+ ({'number_of_lorentzians': True}, TypeError, 'number_of_lorentzians must be an'),
+ ({'number_of_lorentzians': 0}, ValueError, 'number_of_lorentzians must be between'),
+ (
+ {'number_of_lorentzians': MAXIMUM_NUMBER_OF_LORENTZIANS + 1},
+ ValueError,
+ 'number_of_lorentzians must be between',
+ ),
+ ({'delta_name': 1}, TypeError, 'delta_name must be a string'),
+ ],
+ ids=[
+ 'D_type',
+ 'D_zero',
+ 'u2_type',
+ 'u2_zero',
+ 'n_float',
+ 'n_bool',
+ 'n_zero',
+ 'n_too_many',
+ 'delta_name_type',
+ ],
+ )
+ def test_init_rejects_invalid_input(self, kwargs, expected_exception, expected_message):
+ with pytest.raises(expected_exception, match=expected_message):
+ GaussianModel3D(**kwargs)
+
+ def test_number_of_lorentzians_is_read_only(self, model):
+ with pytest.raises(AttributeError):
+ model.number_of_lorentzians = 5
+
+ def test_setters(self, model):
+ # WHEN
+ model.diffusion_coefficient = 2e-9
+ model.mean_u_squared = 3.0
+
+ # THEN EXPECT
+ assert model.diffusion_coefficient.value == pytest.approx(2e-9)
+ assert model.mean_u_squared.value == pytest.approx(3.0)
+
+ @pytest.mark.parametrize(
+ 'name, value, expected_exception',
+ [
+ ('diffusion_coefficient', 'x', TypeError),
+ ('diffusion_coefficient', 0.0, ValueError),
+ ('mean_u_squared', 'x', TypeError),
+ ('mean_u_squared', 0.0, ValueError),
+ ],
+ ids=['D_type', 'D_zero', 'u2_type', 'u2_zero'],
+ )
+ def test_setters_raise(self, model, name, value, expected_exception):
+ with pytest.raises(expected_exception, match=f'{name} must be'):
+ setattr(model, name, value)
+
+ # ------------------------------------------------------------------
+ # Physics of the structure factors and widths
+ # ------------------------------------------------------------------
+
+ def test_weights_are_poisson(self):
+ """A_i(Q) = exp(-y) y^i / i! with y = Q^2 / 3."""
+ # WHEN
+ model = GaussianModel3D(mean_u_squared=2.4, number_of_lorentzians=30)
+ Q = np.array([0.3, 1.0, 2.2])
+ y = Q**2 * 2.4 / 3
+
+ # THEN EXPECT
+ np.testing.assert_allclose(model.calculate_EISF(Q), poisson.pmf(0, y), rtol=1e-12)
+ for order in (1, 2, 7, 30):
+ np.testing.assert_allclose(
+ model.calculate_QISF_order(Q, order), poisson.pmf(order, y), rtol=1e-12
+ )
+
+ def test_EISF_equals_DeltaLorentz_debye_waller_factor(self):
+ """The same mean_u_squared gives the same exp(- Q^2 / 3) as DeltaLorentz."""
+ # WHEN
+ Q = np.array([0.5, 1.0, 1.5])
+ model = GaussianModel3D(mean_u_squared=1.7)
+ delta_lorentz = DeltaLorentz(mean_u_squared=1.7, A_0=1.0)
+
+ # THEN EXPECT
+ np.testing.assert_allclose(
+ model.calculate_EISF(Q), delta_lorentz.calculate_EISF(Q), rtol=1e-12
+ )
+
+ def test_weights_and_missing_weight_sum_to_one(self):
+ # WHEN
+ model = GaussianModel3D(mean_u_squared=3.0, number_of_lorentzians=5)
+ Q = np.array([0.5, 1.5, 3.0])
+
+ # THEN
+ total = (
+ model.calculate_EISF(Q) + model.calculate_QISF(Q) + model.calculate_missing_weight(Q)
+ )
+
+ # EXPECT
+ np.testing.assert_allclose(total, 1.0, rtol=0, atol=1e-14)
+ np.testing.assert_allclose(
+ model.calculate_missing_weight(Q), poisson.sf(5, Q**2), rtol=1e-9
+ )
+
+ def test_width_is_order_times_3_hbar_D_over_u2(self):
+ # WHEN
+ model = GaussianModel3D(diffusion_coefficient=2e-9, mean_u_squared=1.2)
+ Q = np.array([0.5, 2.0])
+
+ # THEN EXPECT Gamma_i = 3 i hbar D / , with D / = 2e-9 m^2/s / 1.2 angstrom^2
+ rate_per_ps = 3 * 2e-9 * 1e20 / 1e12 / 1.2
+ for order in (1, 4, 20):
+ np.testing.assert_allclose(
+ model.calculate_width(Q, order), order * hbar_rate(rate_per_ps), rtol=1e-12
+ )
+
+ def test_matches_QENSmodels_reference_values(self):
+ """
+ Doctest values of QENSmodels hwhmGaussianModel3D([1., 2.], 0.5, 1.5), with D in
+ angstrom^2/ps and in angstrom^2, rounded there to the digits given.
+ """
+ # WHEN
+ model = GaussianModel3D(
+ diffusion_coefficient=VOLINO_D,
+ mean_u_squared=VOLINO_MEAN_U_SQUARED,
+ number_of_lorentzians=99,
+ )
+ Q = np.array([1.0, 2.0])
+
+ # THEN EXPECT
+ np.testing.assert_allclose(model.calculate_EISF(Q), [0.223, 0.002], atol=5e-4)
+ np.testing.assert_allclose(model.calculate_QISF_order(Q, 1), [0.3347, 0.0149], atol=5e-5)
+ width_10 = model.calculate_width(Q, 10) / hbar_rate(1.0)
+ width_99 = model.calculate_width(Q, 99) / hbar_rate(1.0)
+ np.testing.assert_allclose(width_10, 3.333, atol=5e-4)
+ np.testing.assert_allclose(width_99, 33.0, atol=5e-2)
+
+ def test_Q_zero_is_purely_elastic(self):
+ # WHEN
+ model = GaussianModel3D()
+
+ # THEN
+ Q = np.array([0.0])
+
+ # EXPECT exactly: exp(0) = 1 and exp(-inf) = 0, with no NaN from 0 * log(0)
+ np.testing.assert_array_equal(model.calculate_EISF(Q), [1.0])
+ np.testing.assert_array_equal(model.calculate_QISF(Q), [0.0])
+ np.testing.assert_array_equal(model.calculate_QISF_order(Q, 1), [0.0])
+
+ def test_large_Q_gives_no_nan(self):
+ """y = Q^2 / 3 up to about 1000: the weights underflow to 0, never NaN."""
+ # WHEN
+ model = GaussianModel3D(mean_u_squared=30.0, number_of_lorentzians=100)
+ Q = np.array([5.0, 10.0])
+
+ # THEN
+ weights = [model.calculate_QISF_order(Q, order) for order in (1, 50, 100)]
+
+ # EXPECT
+ assert np.all(np.isfinite(weights))
+ assert np.all(np.isfinite(model.calculate_EISF(Q)))
+
+ def test_weights_follow_mean_u_squared_unit(self):
+ # WHEN
+ model = GaussianModel3D(mean_u_squared=1.5)
+ Q = np.array([0.8, 1.6])
+ EISF_angstrom = model.calculate_EISF(Q)
+ width_angstrom = model.calculate_width(Q, 1)
+
+ # THEN
+ model.mean_u_squared.convert_unit('nm**2')
+
+ # EXPECT
+ np.testing.assert_allclose(model.calculate_EISF(Q), EISF_angstrom, rtol=1e-12)
+ np.testing.assert_allclose(model.calculate_width(Q, 1), width_angstrom, rtol=1e-12)
+
+ @pytest.mark.parametrize(
+ 'order', [0, 21, 1.0, True], ids=['zero', 'n_plus_1', 'float', 'bool']
+ )
+ def test_order_validation(self, model, order):
+ with pytest.raises((TypeError, ValueError), match='order must be'):
+ model.calculate_QISF_order(np.array([1.0]), order)
+ with pytest.raises((TypeError, ValueError), match='order must be'):
+ model.calculate_width(np.array([1.0]), order)
+
+ def test_calculate_EISF_type_error(self, model):
+ with pytest.raises(TypeError, match='Q must be '):
+ model.calculate_EISF(Q='invalid')
+
+ # ------------------------------------------------------------------
+ # Truncation warning
+ # ------------------------------------------------------------------
+
+ def test_warns_when_truncation_drops_weight(self):
+ # WHEN THEN EXPECT y = 3 at Q = 3 needs more than 3 Lorentzians
+ with pytest.warns(UserWarning, match='number_of_lorentzians'):
+ GaussianModel3D(mean_u_squared=1.0, number_of_lorentzians=3, Q=np.array([3.0]))
+
+ def test_no_warning_when_truncation_is_negligible(self):
+ # WHEN THEN EXPECT y = 0.03 at Q = 0.3: 3 Lorentzians leave about 3e-8
+ model_without_warning(mean_u_squared=1.0, number_of_lorentzians=3, Q=np.array([0.3]))
+
+ def test_warns_again_when_Q_changes(self):
+ """Guards the refactor: new Q values rebuild the components and check the truncation."""
+ # WHEN
+ model = model_without_warning(
+ mean_u_squared=1.0, number_of_lorentzians=3, Q=np.array([0.3])
+ )
+ model.clear_Q(confirm=True)
+
+ # THEN EXPECT y = 25/3 at Q = 5: 3 Lorentzians leave about 0.97 of the weight
+ with pytest.warns(UserWarning, match='number_of_lorentzians'):
+ model.Q = np.array([5.0])
+
+ def test_component_names_follow_the_base_convention(self):
+ # WHEN
+ model = model_without_warning(number_of_lorentzians=3, Q=np.array([0.5]))
+
+ # THEN EXPECT
+ assert model.lorentzian_component_name(3) == 'GaussianModel3D_i3'
+
+ # ------------------------------------------------------------------
+ # Components
+ # ------------------------------------------------------------------
+
+ def test_no_Q_gives_no_collections(self, model):
+ # WHEN THEN EXPECT
+ assert model.get_component_collections() == []
+
+ def test_components_per_Q(self):
+ # WHEN
+ model = model_without_warning(number_of_lorentzians=4, Q=np.array([0.5, 1.0]))
+
+ # THEN
+ collections = model.get_component_collections()
+
+ # EXPECT one delta function and n Lorentzians per Q
+ assert len(collections) == 2
+ for collection in collections:
+ assert len(collection) == 5
+ assert isinstance(collection[0], DeltaFunction)
+ assert all(isinstance(component, Lorentzian) for component in collection[1:])
+ assert [component.name for component in collection[1:]] == [
+ model.lorentzian_component_name(order) for order in range(1, 5)
+ ]
+
+ def test_component_areas_and_widths_match_calculations(self):
+ """The dependency strings must give what the Scipp calculations give."""
+ # WHEN
+ Q = np.array([0.4, 1.1, 2.3])
+ model = model_without_warning(
+ scale=2.5,
+ diffusion_coefficient=3e-9,
+ mean_u_squared=0.9,
+ number_of_lorentzians=12,
+ Q=Q,
+ )
+
+ # THEN
+ collections = model.get_component_collections()
+
+ # EXPECT
+ EISF = model.calculate_EISF(Q)
+ for i, collection in enumerate(collections):
+ assert collection[0].area.value == pytest.approx(2.5 * EISF[i], rel=1e-12)
+ for order in range(1, 13):
+ lorentzian = collection[order]
+ weight = model.calculate_QISF_order(Q, order)[i]
+ width = model.calculate_width(Q, order)[i]
+ assert lorentzian.area.value == pytest.approx(2.5 * weight, rel=1e-12, abs=0)
+ assert lorentzian.width.value == pytest.approx(width, rel=1e-12)
+ assert lorentzian.width.unit == 'meV'
+ assert lorentzian.area.independent is False
+
+ def test_components_follow_parameter_changes(self):
+ # WHEN
+ Q = np.array([0.6, 1.8])
+ model = model_without_warning(number_of_lorentzians=15, mean_u_squared=0.5, Q=Q)
+
+ # THEN
+ model.mean_u_squared = 0.8
+ model.diffusion_coefficient = 4e-9
+ model.scale = 4.0
+
+ # EXPECT
+ collections = model.get_component_collections()
+ EISF = model.calculate_EISF(Q)
+ for i, collection in enumerate(collections):
+ assert collection[0].area.value == pytest.approx(4.0 * EISF[i], rel=1e-12)
+ assert collection[3].area.value == pytest.approx(
+ 4.0 * model.calculate_QISF_order(Q, 3)[i], rel=1e-12
+ )
+ assert collection[3].width.value == pytest.approx(
+ model.calculate_width(Q, 3)[i], rel=1e-12
+ )
+
+ def test_parameter_updates_raise_no_runtime_warning(self):
+ """A fit changes D and on every step; the dependent widths must not warn."""
+ # WHEN
+ model = model_without_warning(number_of_lorentzians=3, Q=np.array([0.5, 1.0]))
+
+ # THEN EXPECT
+ with warnings.catch_warnings():
+ warnings.simplefilter('error', RuntimeWarning)
+ model.diffusion_coefficient = 3e-9
+ model.mean_u_squared = 0.7
+
+ def test_component_weights_follow_mean_u_squared_unit(self):
+ # WHEN
+ Q = np.array([0.8, 1.6])
+ model = model_without_warning(mean_u_squared=1.5, Q=Q)
+ areas_angstrom = [c[2].area.value for c in model.get_component_collections()]
+ widths_angstrom = [c[2].width.value for c in model.get_component_collections()]
+
+ # THEN
+ model.mean_u_squared.convert_unit('nm**2')
+
+ # EXPECT
+ collections = model.get_component_collections()
+ np.testing.assert_allclose(
+ [c[2].area.value for c in collections], areas_angstrom, rtol=1e-12
+ )
+ np.testing.assert_allclose(
+ [c[2].width.value for c in collections], widths_angstrom, rtol=1e-12
+ )
+
+ def test_create_component_collections_installs_collections(self):
+ # WHEN
+ model = model_without_warning(Q=np.array([1.0, 2.0]))
+
+ # THEN
+ collections = model.create_component_collections()
+
+ # EXPECT
+ assert collections is model.get_component_collections()
+
+ # ------------------------------------------------------------------
+ # Fit targets, variables and representation
+ # ------------------------------------------------------------------
+
+ def test_fit_targets(self):
+ # WHEN
+ Q = np.array([0.5, 1.5])
+ model = model_without_warning(scale=2.0, number_of_lorentzians=6, Q=Q)
+
+ # THEN
+ targets = {target.name: target for target in model.get_fit_targets()}
+
+ # EXPECT
+ expected_names = {'elastic_area'} | {
+ f'{kind}_{order}' for kind in ('area', 'width') for order in range(1, 7)
+ }
+ assert set(targets) == expected_names
+ np.testing.assert_allclose(
+ targets['elastic_area'].function(Q), 2.0 * model.calculate_EISF(Q), rtol=1e-12
+ )
+ np.testing.assert_allclose(
+ targets['area_2'].function(Q), 2.0 * model.calculate_QISF_order(Q, 2), rtol=1e-12
+ )
+ np.testing.assert_allclose(
+ targets['width_3'].function(Q), model.calculate_width(Q, 3), rtol=1e-12
+ )
+
+ def test_get_global_variables(self, model):
+ # WHEN THEN EXPECT
+ assert model.get_global_variables() == [
+ model.scale,
+ model.diffusion_coefficient,
+ model.mean_u_squared,
+ ]
+
+ def test_repr(self, model):
+ # WHEN THEN
+ repr_str = repr(model)
+
+ # EXPECT
+ assert 'GaussianModel3D' in repr_str
+ assert 'number_of_lorentzians=20' in repr_str
+ assert 'diffusion_coefficient' in repr_str
+ assert 'mean_u_squared' in repr_str
+ assert 'x_unit=meV, y_unit=dimensionless' in repr_str
diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_hall_ross_jump_diffusion.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_hall_ross_jump_diffusion.py
new file mode 100644
index 00000000..d22e7b80
--- /dev/null
+++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_hall_ross_jump_diffusion.py
@@ -0,0 +1,364 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+
+import numpy as np
+import pytest
+import scipp as sc
+from scipp import UnitError
+from scipp.constants import hbar as scipp_hbar
+
+from easydynamics.sample_model.diffusion_model.hall_ross_jump_diffusion import MINIMUM_WIDTH
+from easydynamics.sample_model.diffusion_model.hall_ross_jump_diffusion import (
+ HallRossJumpDiffusion,
+)
+
+
+class TestHallRossJumpDiffusion:
+ @pytest.fixture
+ def hall_ross_model(self):
+ return HallRossJumpDiffusion()
+
+ def test_init_default(self, hall_ross_model):
+ # WHEN THEN EXPECT
+ assert hall_ross_model.display_name == 'HallRossJumpDiffusion'
+ assert hall_ross_model.x_unit == 'meV'
+ assert hall_ross_model.y_unit == 'dimensionless'
+ assert hall_ross_model.scale.value == pytest.approx(1.0)
+ assert hall_ross_model.scale.unit == 'meV'
+ assert hall_ross_model.residence_time.value == pytest.approx(1.0)
+ assert hall_ross_model.jump_length.value == pytest.approx(1.0)
+
+ @pytest.mark.parametrize(
+ 'kwargs,expected_exception, expected_message',
+ [
+ (
+ {
+ 'x_unit': 123,
+ 'scale': 1.0,
+ 'residence_time': 1.0,
+ 'jump_length': 1.0,
+ },
+ UnitError,
+ 'Invalid unit',
+ ),
+ (
+ {
+ 'y_unit': 123,
+ 'scale': 1.0,
+ 'residence_time': 1.0,
+ 'jump_length': 1.0,
+ },
+ UnitError,
+ 'unit must be None, a string',
+ ),
+ (
+ {
+ 'x_unit': 'meV',
+ 'scale': 'invalid',
+ 'residence_time': 1.0,
+ 'jump_length': 1.0,
+ },
+ TypeError,
+ 'scale must be a number',
+ ),
+ (
+ {
+ 'x_unit': 'meV',
+ 'scale': 1.0,
+ 'residence_time': 'invalid',
+ 'jump_length': 1.0,
+ },
+ TypeError,
+ 'residence_time must be a number',
+ ),
+ (
+ {
+ 'x_unit': 'meV',
+ 'scale': 1.0,
+ 'residence_time': -1.0,
+ 'jump_length': 1.0,
+ },
+ ValueError,
+ 'residence_time must be at least',
+ ),
+ (
+ {
+ 'x_unit': 'meV',
+ 'scale': 1.0,
+ 'residence_time': 0.0,
+ 'jump_length': 1.0,
+ },
+ ValueError,
+ 'residence_time must be at least',
+ ),
+ (
+ {
+ 'x_unit': 'meV',
+ 'scale': 1.0,
+ 'residence_time': 1.0,
+ 'jump_length': 'invalid',
+ },
+ TypeError,
+ 'jump_length must be a number',
+ ),
+ (
+ {
+ 'x_unit': 'meV',
+ 'scale': 1.0,
+ 'residence_time': 1.0,
+ 'jump_length': -1.0,
+ },
+ ValueError,
+ 'jump_length must be non-negative',
+ ),
+ ],
+ ids=[
+ 'invalid_x_unit',
+ 'invalid_y_unit',
+ 'invalid_scale_type',
+ 'invalid_residence_time_type',
+ 'invalid_residence_time_negative',
+ 'invalid_residence_time_zero',
+ 'invalid_jump_length_type',
+ 'invalid_jump_length_negative',
+ ],
+ )
+ def test_input_type_validation_raises(self, kwargs, expected_exception, expected_message):
+ with pytest.raises(expected_exception, match=expected_message):
+ HallRossJumpDiffusion(display_name='HallRossJumpDiffusion', **kwargs)
+
+ def test_residence_time_setter(self, hall_ross_model):
+ # WHEN THEN
+ hall_ross_model.residence_time = 3.0
+
+ # EXPECT
+ assert hall_ross_model.residence_time.value == pytest.approx(3.0)
+
+ def test_residence_time_setter_raises(self, hall_ross_model):
+ # WHEN THEN EXPECT
+ with pytest.raises(TypeError, match=r'residence_time must be a number.'):
+ hall_ross_model.residence_time = 'invalid'
+
+ @pytest.mark.parametrize('invalid_value', [-1.0, 0.0, MINIMUM_WIDTH / 10])
+ def test_residence_time_setter_below_minimum_raises(self, hall_ross_model, invalid_value):
+ # WHEN THEN EXPECT
+ with pytest.raises(ValueError, match=r'residence_time must be at least'):
+ hall_ross_model.residence_time = invalid_value
+
+ def test_residence_time_has_minimum_width(self, hall_ross_model):
+ # WHEN THEN EXPECT
+ assert hall_ross_model.residence_time.min == MINIMUM_WIDTH
+
+ def test_jump_length_setter(self, hall_ross_model):
+ # WHEN THEN
+ hall_ross_model.jump_length = 2.5
+
+ # EXPECT
+ assert hall_ross_model.jump_length.value == pytest.approx(2.5)
+
+ def test_jump_length_setter_raises(self, hall_ross_model):
+ # WHEN THEN EXPECT
+ with pytest.raises(TypeError, match=r'jump_length must be a number.'):
+ hall_ross_model.jump_length = 'invalid'
+
+ def test_jump_length_setter_negative_raises(self, hall_ross_model):
+ # WHEN THEN EXPECT
+ with pytest.raises(ValueError, match=r'jump_length must be non-negative.'):
+ hall_ross_model.jump_length = -1.0
+
+ def test_calculate_width_type_error(self, hall_ross_model):
+ # WHEN THEN EXPECT
+ with pytest.raises(TypeError, match='Q must be '):
+ hall_ross_model.calculate_width(Q='invalid')
+
+ def test_calculate_width(self, hall_ross_model):
+ "Test the calculation against a scipp implementation instead of our Parameters"
+ # WHEN
+ hall_ross_model.residence_time = 2.0
+ hall_ross_model.jump_length = 1.5
+ Q_values = sc.linspace('Q', 0.5, 1.5, num=6, unit='1/angstrom')
+ # sc.values drops the (zero) variance, which scipp cannot broadcast against Q_values
+ residence_time_sc = sc.values(hall_ross_model.residence_time.full_value)
+ jump_length_sc = sc.values(hall_ross_model.jump_length.full_value)
+
+ # THEN
+ widths = hall_ross_model.calculate_width(Q_values)
+
+ # EXPECT
+ argument = (Q_values * jump_length_sc).to(unit='dimensionless')
+ expected_widths = scipp_hbar / residence_time_sc * (1 - sc.exp(-(argument**2) / 2))
+ expected_widths = expected_widths.to(unit=hall_ross_model.x_unit)
+ np.testing.assert_allclose(widths, expected_widths.values, rtol=1e-5)
+
+ def test_calculate_width_small_Q_limit_is_brownian(self, hall_ross_model):
+ """
+ At small Q the width is hbar * D * Q**2 with D = l**2 / (2 tau). Q = 1e-6 also checks
+ that the width keeps its precision where 1 - exp(-x) would cancel.
+ """
+ # WHEN
+ hall_ross_model.residence_time = 2.0
+ hall_ross_model.jump_length = 1.5
+ Q_values = np.array([1e-6, 1e-4])
+
+ # THEN
+ widths = hall_ross_model.calculate_width(Q_values)
+
+ # EXPECT
+ diffusion_coefficient = sc.scalar(1.5**2 / (2 * 2.0), unit='angstrom**2/ps')
+ Q_sc = sc.array(dims=['Q'], values=Q_values, unit='1/angstrom')
+ expected_widths = (scipp_hbar * diffusion_coefficient * Q_sc**2).to(unit='meV')
+ np.testing.assert_allclose(widths, expected_widths.values, rtol=1e-8)
+
+ def test_calculate_width_large_Q_limit(self, hall_ross_model):
+ # WHEN
+ hall_ross_model.residence_time = 2.0
+ Q_values = np.array([50.0, 100.0])
+
+ # THEN
+ widths = hall_ross_model.calculate_width(Q_values)
+
+ # EXPECT width tends to hbar / tau
+ expected_width = (scipp_hbar / sc.scalar(2.0, unit='ps')).to(unit='meV')
+ np.testing.assert_allclose(widths, expected_width.value, rtol=1e-12)
+
+ def test_calculate_EISF(self, hall_ross_model):
+ # WHEN
+ Q_values = np.array([0.1, 0.2, 0.3])
+
+ # THEN
+ EISF = hall_ross_model.calculate_EISF(Q_values)
+
+ # EXPECT
+ np.testing.assert_array_equal(EISF, np.zeros_like(Q_values))
+
+ def test_calculate_EISF_type_error(self, hall_ross_model):
+ # WHEN THEN EXPECT
+ with pytest.raises(TypeError, match='Q must be '):
+ hall_ross_model.calculate_EISF(Q='invalid')
+
+ def test_calculate_QISF(self, hall_ross_model):
+ # WHEN
+ Q_values = np.array([0.1, 0.2, 0.3])
+
+ # THEN
+ QISF = hall_ross_model.calculate_QISF(Q_values)
+
+ # EXPECT
+ np.testing.assert_array_equal(QISF, np.ones_like(Q_values))
+
+ def test_calculate_QISF_type_error(self, hall_ross_model):
+ # WHEN THEN EXPECT
+ with pytest.raises(TypeError, match='Q must be '):
+ hall_ross_model.calculate_QISF(Q='invalid')
+
+ @pytest.mark.parametrize(
+ 'Q',
+ [
+ (0.5),
+ ([1.0, 2.0, 3.0]),
+ (np.array([1.0, 2.0, 3.0])),
+ ],
+ ids=[
+ 'python_scalar',
+ 'python_list',
+ 'numpy_array',
+ ],
+ )
+ def test_create_component_collections(self, hall_ross_model, Q):
+ # WHEN
+ hall_ross_model.Q = Q
+
+ # THEN
+ component_collections = hall_ross_model.create_component_collections()
+
+ # EXPECT
+ expected_widths = hall_ross_model.calculate_width(Q)
+ for model_index in range(len(component_collections)):
+ model = component_collections[model_index]
+ assert len(model) == 1
+ component = model[0]
+ assert component.width.unit == hall_ross_model.x_unit
+ assert np.isclose(component.width.value, expected_widths[model_index])
+ assert component.width.independent is False
+ # area.unit = area_unit = x_unit * y_unit
+ assert component.area.unit == 'meV'
+
+ def test_component_widths_follow_parameters(self):
+ # WHEN
+ model = HallRossJumpDiffusion(Q=np.array([0.5, 1.0]))
+
+ # THEN
+ model.residence_time = 4.0
+ model.jump_length = 2.0
+
+ # EXPECT the dependent widths update with the parameters
+ widths = [collection[0].width.value for collection in model.get_component_collections()]
+ np.testing.assert_allclose(widths, model.calculate_width(np.array([0.5, 1.0])))
+
+ def test_component_width_keeps_precision_at_small_Q(self):
+ """The dependent widths a fit uses must not lose digits where 1 - exp(-x) cancels."""
+ # WHEN
+ model = HallRossJumpDiffusion(Q=np.array([1e-6]), residence_time=2.0, jump_length=1.5)
+
+ # THEN
+ width = model.get_component_collections()[0][0].width.value
+
+ # EXPECT hbar * D * Q**2 with D = l**2 / (2 tau)
+ diffusion_coefficient = sc.scalar(1.5**2 / (2 * 2.0), unit='angstrom**2/ps')
+ Q_sc = sc.scalar(1e-6, unit='1/angstrom')
+ expected_width = (scipp_hbar * diffusion_coefficient * Q_sc**2).to(unit='meV')
+ # abs=0: pytest.approx's default absolute tolerance of 1e-12 exceeds this width (~4e-13)
+ assert width == pytest.approx(expected_width.value, rel=1e-8, abs=0)
+
+ def test_create_component_collections_installs_collections(self):
+ # WHEN
+ model = HallRossJumpDiffusion(Q=np.array([1.0, 2.0]))
+
+ # THEN
+ collections = model.create_component_collections()
+
+ # EXPECT the returned collections are the installed (live) ones
+ assert collections is model.get_component_collections()
+
+ def test_write_width_dependency_expression(self, hall_ross_model):
+ # WHEN THEN
+ expression = hall_ross_model._write_width_dependency_expression(0.5)
+
+ # EXPECT
+ assert expression == '(hbar / tau) * -expm1(-((0.5 * l.value) ** 2) / 2)'
+
+ def test_write_width_dependency_map_expression(self, hall_ross_model):
+ # WHEN THEN
+ expression_map = hall_ross_model._write_width_dependency_map_expression()
+
+ # EXPECT
+ expected_map = {
+ 'tau': hall_ross_model.residence_time,
+ 'l': hall_ross_model.jump_length,
+ 'hbar': hall_ross_model._hbar,
+ }
+ assert expression_map == expected_map
+
+ def test_write_width_dependency_expression_raises(self, hall_ross_model):
+ with pytest.raises(TypeError, match='Q must be a float'):
+ hall_ross_model._write_width_dependency_expression('invalid')
+
+ def test_write_area_dependency_expression_raises(self, hall_ross_model):
+ with pytest.raises(TypeError, match='QISF must be a float'):
+ hall_ross_model._write_area_dependency_expression('invalid')
+
+ def test_y_unit_setter_raises(self, hall_ross_model):
+ # WHEN THEN EXPECT
+ with pytest.raises(AttributeError, match=r'read-only'):
+ hall_ross_model.y_unit = '1/meV'
+
+ def test_repr(self, hall_ross_model):
+ # WHEN THEN
+ repr_str = repr(hall_ross_model)
+
+ # EXPECT
+ assert 'HallRossJumpDiffusion' in repr_str
+ assert 'residence_time' in repr_str
+ assert 'jump_length' in repr_str
+ assert 'scale=' in repr_str
+ assert 'x_unit=meV, y_unit=dimensionless' in repr_str
diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_isotropic_rotational_diffusion.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_isotropic_rotational_diffusion.py
new file mode 100644
index 00000000..8c1ce940
--- /dev/null
+++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_isotropic_rotational_diffusion.py
@@ -0,0 +1,362 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+
+import warnings
+
+import numpy as np
+import pytest
+import scipp as sc
+from scipp.constants import hbar as scipp_hbar
+from scipy.special import spherical_jn
+
+from easydynamics.sample_model.components import DeltaFunction
+from easydynamics.sample_model.components import Lorentzian
+from easydynamics.sample_model.diffusion_model.isotropic_rotational_diffusion import MAXIMUM_L_MAX
+from easydynamics.sample_model.diffusion_model.isotropic_rotational_diffusion import MINIMUM_RADIUS
+from easydynamics.sample_model.diffusion_model.isotropic_rotational_diffusion import (
+ MINIMUM_ROTATIONAL_DIFFUSION_COEFFICIENT,
+)
+from easydynamics.sample_model.diffusion_model.isotropic_rotational_diffusion import (
+ IsotropicRotationalDiffusion,
+)
+
+# Rows of QENSmodels' reference data for isotropic_rotational_diffusion (tests/reference_data):
+# energy transfer in meV and S(q, w) in 1/meV, for q = float32(0.7), scale = 1, center = 0,
+# radius = 2 and rotational_diffusion_coefficient = 0.05 meV, with 5 Lorentzians (l = 1 to 5).
+REFERENCE_ROWS = [
+ (-1.0, 0.01993768579029),
+ (-0.5, 0.07269305377548),
+ (0.5, 0.07269305377548),
+ (1.0, 0.01993768579029),
+ (1.5, 0.009060005990534),
+]
+
+
+def hbar_rate(rate_per_ps: float) -> float:
+ """hbar * rate in meV, for a rate in 1/ps, computed with Scipp's constants."""
+ return (scipp_hbar * sc.scalar(rate_per_ps, unit='1/ps')).to(unit='meV').value
+
+
+def model_without_warning(**kwargs) -> IsotropicRotationalDiffusion:
+ """Create a model and fail if it warns about truncation."""
+ with warnings.catch_warnings():
+ warnings.simplefilter('error')
+ return IsotropicRotationalDiffusion(**kwargs)
+
+
+class TestIsotropicRotationalDiffusion:
+ @pytest.fixture
+ def model(self):
+ return IsotropicRotationalDiffusion()
+
+ def test_create_weight_requires_float_Q(self, model):
+ # WHEN THEN EXPECT
+ with pytest.raises(TypeError, match='Q must be a float'):
+ model._create_weight(1, 1)
+
+ # ------------------------------------------------------------------
+ # Construction and parameters
+ # ------------------------------------------------------------------
+
+ def test_init_default(self, model):
+ # WHEN THEN EXPECT
+ assert model.display_name == 'IsotropicRotationalDiffusion'
+ assert model.x_unit == 'meV'
+ assert model.y_unit == 'dimensionless'
+ assert model.scale.value == pytest.approx(1.0)
+ assert model.l_max == 6
+ assert model.number_of_lorentzians == 6
+ assert model.radius.value == pytest.approx(1.0)
+ assert model.radius.unit == 'Å'
+ assert model.radius.fixed is True
+ assert model.radius.min == MINIMUM_RADIUS
+ assert model.rotational_diffusion_coefficient.value == pytest.approx(1.0)
+ assert sc.Unit(model.rotational_diffusion_coefficient.unit) == sc.Unit('1/ps')
+ assert model.rotational_diffusion_coefficient.fixed is False
+ assert (
+ model.rotational_diffusion_coefficient.min == MINIMUM_ROTATIONAL_DIFFUSION_COEFFICIENT
+ )
+
+ @pytest.mark.parametrize(
+ 'kwargs, expected_exception, expected_message',
+ [
+ ({'radius': 'x'}, TypeError, 'radius must be a number'),
+ ({'radius': 0.0}, ValueError, 'radius must be at least'),
+ (
+ {'rotational_diffusion_coefficient': 'x'},
+ TypeError,
+ 'rotational_diffusion_coefficient must be a number',
+ ),
+ (
+ {'rotational_diffusion_coefficient': 0.0},
+ ValueError,
+ 'rotational_diffusion_coefficient must be at least',
+ ),
+ ({'l_max': 2.0}, TypeError, 'l_max must be an integer'),
+ ({'l_max': True}, TypeError, 'l_max must be an integer'),
+ ({'l_max': 0}, ValueError, 'l_max must be between'),
+ ({'l_max': MAXIMUM_L_MAX + 1}, ValueError, 'l_max must be between'),
+ ({'delta_name': 1}, TypeError, 'delta_name must be a string'),
+ ],
+ ids=[
+ 'radius_type',
+ 'radius_zero',
+ 'D_R_type',
+ 'D_R_zero',
+ 'l_max_float',
+ 'l_max_bool',
+ 'l_max_zero',
+ 'l_max_too_large',
+ 'delta_name_type',
+ ],
+ )
+ def test_init_rejects_invalid_input(self, kwargs, expected_exception, expected_message):
+ with pytest.raises(expected_exception, match=expected_message):
+ IsotropicRotationalDiffusion(**kwargs)
+
+ def test_l_max_is_read_only(self, model):
+ with pytest.raises(AttributeError):
+ model.l_max = 5
+
+ def test_setters(self, model):
+ # WHEN
+ model.radius = 2.5
+ model.rotational_diffusion_coefficient = 0.3
+
+ # THEN EXPECT
+ assert model.radius.value == pytest.approx(2.5)
+ assert model.rotational_diffusion_coefficient.value == pytest.approx(0.3)
+
+ @pytest.mark.parametrize(
+ 'name, value, expected_exception',
+ [
+ ('radius', 'x', TypeError),
+ ('radius', 0.0, ValueError),
+ ('rotational_diffusion_coefficient', 'x', TypeError),
+ ('rotational_diffusion_coefficient', 0.0, ValueError),
+ ],
+ ids=['radius_type', 'radius_zero', 'D_R_type', 'D_R_zero'],
+ )
+ def test_setters_raise(self, model, name, value, expected_exception):
+ with pytest.raises(expected_exception, match=f'{name} must be'):
+ setattr(model, name, value)
+
+ # ------------------------------------------------------------------
+ # Physics of the structure factors and widths
+ # ------------------------------------------------------------------
+
+ def test_EISF_is_j0_squared(self):
+ # WHEN
+ model = IsotropicRotationalDiffusion(radius=1.7)
+ Q = np.array([0.2, 1.0, 2.5])
+
+ # THEN EXPECT
+ np.testing.assert_allclose(
+ model.calculate_EISF(Q), spherical_jn(0, Q * 1.7) ** 2, rtol=1e-13
+ )
+
+ @pytest.mark.parametrize('order', [1, 2, 6])
+ def test_QISF_order_is_2l_plus_1_times_jl_squared(self, order):
+ # WHEN
+ model = IsotropicRotationalDiffusion(radius=1.7)
+ Q = np.array([0.2, 1.0, 2.5])
+
+ # THEN EXPECT
+ np.testing.assert_allclose(
+ model.calculate_QISF_order(Q, order=order),
+ (2 * order + 1) * spherical_jn(order, Q * 1.7) ** 2,
+ rtol=1e-12,
+ atol=1e-300,
+ )
+
+ def test_full_sum_has_no_missing_weight(self):
+ """sum_l (2l+1) j_l(x)^2 = 1, so with enough orders nothing is missing."""
+ # WHEN
+ model = IsotropicRotationalDiffusion(radius=1.0, l_max=MAXIMUM_L_MAX)
+ Q = np.array([0.1, 1.0, 3.0])
+
+ # THEN EXPECT
+ np.testing.assert_allclose(model.calculate_missing_weight(Q), 0.0, atol=1e-12)
+ np.testing.assert_allclose(
+ model.calculate_EISF(Q) + model.calculate_QISF(Q), 1.0, rtol=1e-12
+ )
+
+ @pytest.mark.parametrize('order', [1, 2, 5])
+ def test_width_is_l_times_l_plus_1_hbar_D_R(self, order):
+ # WHEN
+ model = IsotropicRotationalDiffusion(rotational_diffusion_coefficient=0.4)
+
+ # THEN EXPECT
+ np.testing.assert_allclose(
+ model.calculate_width(Q=np.array([0.5, 2.0]), order=order),
+ order * (order + 1) * hbar_rate(0.4),
+ rtol=1e-12,
+ )
+
+ def test_matches_QENSmodels_reference_spectrum(self):
+ """QENSmodels takes D_R in meV, so D_R = 0.05 meV / hbar here."""
+ # WHEN
+ model = IsotropicRotationalDiffusion(
+ scale=1.0,
+ radius=2.0,
+ rotational_diffusion_coefficient=0.05 / hbar_rate(1.0),
+ l_max=5,
+ Q=np.array([float(np.float32(0.7))]),
+ )
+ energy = np.array([row[0] for row in REFERENCE_ROWS])
+
+ # THEN
+ # Lorentzians only: the reference puts the delta function in the bin at w = 0, not listed.
+ lorentzians = model.get_component_collections()[0][1:]
+ spectrum = sum(component.evaluate(energy) for component in lorentzians)
+
+ # EXPECT
+ np.testing.assert_allclose(spectrum, [row[1] for row in REFERENCE_ROWS], rtol=1e-9)
+
+ def test_Q_zero_is_purely_elastic(self, model):
+ # WHEN
+ Q = np.array([0.0])
+
+ # THEN EXPECT
+ np.testing.assert_allclose(model.calculate_EISF(Q), 1.0)
+ np.testing.assert_allclose(model.calculate_QISF(Q), 0.0)
+
+ def test_weights_follow_radius_unit(self):
+ # WHEN
+ model = IsotropicRotationalDiffusion(radius=2.0)
+ Q = np.array([0.4, 1.3])
+ before = model.calculate_QISF_order(Q, order=2)
+ model.radius.convert_unit('nm')
+
+ # THEN EXPECT
+ np.testing.assert_allclose(model.calculate_QISF_order(Q, order=2), before, rtol=1e-12)
+
+ @pytest.mark.parametrize('order', [0, 7, 1.5, True])
+ def test_order_validation(self, model, order):
+ with pytest.raises((TypeError, ValueError), match='order must be'):
+ model.calculate_width(order=order)
+
+ # ------------------------------------------------------------------
+ # Truncation warning
+ # ------------------------------------------------------------------
+
+ def test_warns_when_truncation_drops_weight(self):
+ with pytest.warns(UserWarning, match='l_max=2'):
+ IsotropicRotationalDiffusion(radius=2.0, l_max=2, Q=np.array([0.5, 2.0]))
+
+ def test_no_warning_when_truncation_is_negligible(self):
+ model = model_without_warning(radius=1.0, Q=np.array([0.3, 1.0]))
+ assert np.max(model.calculate_missing_weight()) < 1e-3
+
+ def test_warns_again_when_Q_changes(self):
+ # WHEN
+ model = model_without_warning(radius=1.0, Q=np.array([0.5]))
+ model.clear_Q(confirm=True)
+
+ # THEN EXPECT QR = 6 is beyond what the default l_max = 6 covers
+ with pytest.warns(UserWarning, match='omits more than'):
+ model.Q = np.array([6.0])
+
+ # ------------------------------------------------------------------
+ # Components
+ # ------------------------------------------------------------------
+
+ def test_no_Q_gives_no_collections(self, model):
+ assert model.get_component_collections() == []
+
+ def test_components_per_Q(self):
+ # WHEN
+ model = IsotropicRotationalDiffusion(l_max=3, Q=np.array([0.5, 1.0]))
+ collections = model.get_component_collections()
+
+ # THEN EXPECT
+ assert len(collections) == 2
+ components = collections[0][:]
+ assert isinstance(components[0], DeltaFunction)
+ assert all(isinstance(component, Lorentzian) for component in components[1:])
+ assert [component.name for component in components] == [
+ 'Delta function',
+ 'IsotropicRotationalDiffusion_l1',
+ 'IsotropicRotationalDiffusion_l2',
+ 'IsotropicRotationalDiffusion_l3',
+ ]
+
+ def test_component_areas_and_widths_match_calculations(self):
+ # WHEN
+ Q = np.array([0.6, 1.4])
+ model = IsotropicRotationalDiffusion(
+ scale=2.0, radius=1.5, rotational_diffusion_coefficient=0.2, l_max=4, Q=Q
+ )
+
+ # THEN EXPECT
+ for i, collection in enumerate(model.get_component_collections()):
+ components = collection[:]
+ assert components[0].area.value == pytest.approx(
+ 2.0 * model.calculate_EISF(Q[i])[0], rel=1e-10
+ )
+ for order in range(1, 5):
+ assert components[order].area.value == pytest.approx(
+ 2.0 * model.calculate_QISF_order(Q[i], order=order)[0], rel=1e-10
+ )
+ assert components[order].width.value == pytest.approx(
+ model.calculate_width(Q[i], order=order)[0], rel=1e-12
+ )
+
+ def test_components_follow_parameter_changes(self):
+ # WHEN
+ model = IsotropicRotationalDiffusion(l_max=3, Q=np.array([1.0]))
+ model.radius = 1.6
+ model.rotational_diffusion_coefficient = 0.7
+
+ # THEN EXPECT
+ components = model.get_component_collections()[0][:]
+ assert components[0].area.value == pytest.approx(model.calculate_EISF(1.0)[0], rel=1e-10)
+ assert components[2].area.value == pytest.approx(
+ model.calculate_QISF_order(1.0, order=2)[0], rel=1e-10
+ )
+ assert components[2].width.value == pytest.approx(6 * hbar_rate(0.7), rel=1e-12)
+
+ def test_parameter_updates_raise_no_runtime_warning(self):
+ # WHEN
+ model = IsotropicRotationalDiffusion(l_max=3, Q=np.array([1.0]))
+
+ # THEN EXPECT
+ with warnings.catch_warnings():
+ warnings.simplefilter('error', RuntimeWarning)
+ model.radius = 1.2
+ model.rotational_diffusion_coefficient = 0.5
+ model.scale = 3.0
+
+ # ------------------------------------------------------------------
+ # Fit targets, variables and representation
+ # ------------------------------------------------------------------
+
+ def test_fit_targets(self):
+ # WHEN
+ model = IsotropicRotationalDiffusion(l_max=2)
+
+ # THEN EXPECT
+ assert [target.name for target in model.get_fit_targets()] == [
+ 'elastic_area',
+ 'area_1',
+ 'width_1',
+ 'area_2',
+ 'width_2',
+ ]
+
+ def test_get_global_variables(self, model):
+ assert model.get_global_variables() == [
+ model.scale,
+ model.radius,
+ model.rotational_diffusion_coefficient,
+ ]
+
+ def test_repr(self, model):
+ # WHEN
+ text = repr(model)
+
+ # THEN EXPECT
+ assert text.startswith('IsotropicRotationalDiffusion(')
+ assert 'radius=' in text
+ assert 'rotational_diffusion_coefficient=' in text
+ assert 'l_max=6' in text
diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_jump_sites_log_normal_distribution.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_jump_sites_log_normal_distribution.py
new file mode 100644
index 00000000..b7548064
--- /dev/null
+++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_jump_sites_log_normal_distribution.py
@@ -0,0 +1,398 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+
+import warnings
+
+import numpy as np
+import pytest
+import scipp as sc
+from scipp.constants import hbar as scipp_hbar
+
+import easydynamics as edyn
+from easydynamics.sample_model.components import DeltaFunction
+from easydynamics.sample_model.components import Lorentzian
+from easydynamics.sample_model.diffusion_model.equivalent_sites_circle import EquivalentSitesCircle
+from easydynamics.sample_model.diffusion_model.jump_sites_log_normal_distribution import (
+ MINIMUM_SIGMA,
+)
+from easydynamics.sample_model.diffusion_model.jump_sites_log_normal_distribution import (
+ NUMBER_OF_SAMPLES,
+)
+from easydynamics.sample_model.diffusion_model.jump_sites_log_normal_distribution import (
+ JumpSitesLogNormalDistribution,
+)
+
+# A weight is a sum of N terms of size up to 1/N that can nearly cancel, so its rounding error is
+# absolute, a few N * machine epsilon, not relative to the (possibly tiny) result.
+WEIGHT_ATOL = 1e-14
+
+# Rows of tests/manual/jump_sites_log_norm_dist_ref_data.dat (QENSmodels tests/reference_data):
+# energy transfer w in 1/ps and S(q, w), for Nsites = 7, radius = 5, resTime = 2, sigma = 0.6,
+# scale = 2, center = 0.8 and q = float32(0.7).
+REFERENCE_ROWS = [
+ (-1.0, 0.08158287388105),
+ (-0.5, 0.1336353341603),
+ (0.5, 0.6730922522463),
+ (1.0, 0.8950268953231),
+ (1.5, 0.2983075456912),
+]
+
+
+def hbar_over(residence_time_ps: float) -> float:
+ """hbar / tau in meV, computed with Scipp's constants."""
+ return (scipp_hbar / sc.scalar(residence_time_ps, unit='ps')).to(unit='meV').value
+
+
+def order_of(circle_order: int, s: int) -> int:
+ """The Lorentzian order j of circle order l and sample s."""
+ return (circle_order - 1) * NUMBER_OF_SAMPLES + s + 1
+
+
+class TestJumpSitesLogNormalDistribution:
+ @pytest.fixture
+ def model(self):
+ return JumpSitesLogNormalDistribution()
+
+ def test_create_weight_requires_float_Q(self, model):
+ # WHEN THEN EXPECT
+ with pytest.raises(TypeError, match='Q must be a float'):
+ model._create_weight(1, 1)
+
+ # ------------------------------------------------------------------
+ # Construction and parameters
+ # ------------------------------------------------------------------
+
+ def test_init_default(self, model):
+ # WHEN THEN EXPECT
+ assert model.display_name == 'JumpSitesLogNormalDistribution'
+ assert model.number_of_sites == 3
+ assert model.number_of_lorentzians == 2 * NUMBER_OF_SAMPLES
+ assert model.radius.value == pytest.approx(1.0)
+ assert model.radius.unit == 'Å'
+ assert model.radius.fixed is True
+ assert model.residence_time.unit == 'ps'
+ assert model.residence_time.fixed is False
+ assert model.sigma.value == pytest.approx(1.0)
+ assert model.sigma.unit == 'dimensionless'
+ assert model.sigma.fixed is False
+ assert model.sigma.min == MINIMUM_SIGMA
+
+ @pytest.mark.parametrize(
+ 'kwargs, expected_exception, expected_message',
+ [
+ ({'radius': 'invalid'}, TypeError, 'radius must be a number'),
+ ({'radius': 0.0}, ValueError, 'radius must be at least'),
+ ({'residence_time': 'invalid'}, TypeError, 'residence_time must be a number'),
+ ({'residence_time': 0.0}, ValueError, 'residence_time must be at least'),
+ ({'sigma': 'invalid'}, TypeError, 'sigma must be a number'),
+ ({'sigma': 0.0}, ValueError, 'sigma must be at least'),
+ ({'number_of_sites': 2.5}, TypeError, 'number_of_sites must be an integer'),
+ ({'number_of_sites': 1}, ValueError, 'number_of_sites must be between 2 and'),
+ ({'delta_name': 1}, TypeError, 'delta_name must be a string'),
+ ],
+ )
+ def test_init_rejects_invalid_input(self, kwargs, expected_exception, expected_message):
+ # WHEN THEN EXPECT
+ with pytest.raises(expected_exception, match=expected_message):
+ JumpSitesLogNormalDistribution(**kwargs)
+
+ def test_sigma_setter(self, model):
+ # WHEN
+ model.sigma = 0.4
+
+ # THEN EXPECT
+ assert model.sigma.value == pytest.approx(0.4)
+ with pytest.raises(ValueError, match='sigma must be at least'):
+ model.sigma = 0.0
+
+ # ------------------------------------------------------------------
+ # Physics
+ # ------------------------------------------------------------------
+
+ def test_matches_QENSmodels_doctest_values(self):
+ """
+ Doctest values of QENSmodels hwhmJumpSitesLogNormDist([1., 2.], 4, 0.5, 1.5, 1.0), rounded
+ there to the digits given. Its widths are in 1/ps, so they are divided by hbar here.
+ """
+ # WHEN
+ model = JumpSitesLogNormalDistribution(
+ number_of_sites=4, radius=0.5, residence_time=1.5, sigma=1.0
+ )
+ Q = np.array([1.0, 2.0])
+ hbar_per_ps = hbar_over(1.0)
+
+ # THEN EXPECT
+ np.testing.assert_allclose(model.calculate_EISF(Q), [0.92, 0.713], atol=5e-4)
+ assert model.calculate_width(Q, order_of(3, 20))[1] / hbar_per_ps == pytest.approx(
+ 5.7, abs=5e-2
+ )
+ assert model.calculate_width(Q, order_of(1, 5))[1] / hbar_per_ps == pytest.approx(
+ 0.228, abs=5e-4
+ )
+ assert model.calculate_QISF_order(Q, order_of(3, 19))[0] == pytest.approx(
+ 0.000538, abs=5e-7
+ )
+ assert model.calculate_QISF_order(Q, order_of(2, 5))[1] == pytest.approx(
+ 0.000712, abs=5e-7
+ )
+
+ def test_matches_QENSmodels_reference_spectrum(self):
+ """The QENSmodels reference spectrum, with w in 1/ps and hbar = 1 there."""
+ # WHEN
+ model = JumpSitesLogNormalDistribution(
+ scale=2.0,
+ radius=5.0,
+ residence_time=2.0,
+ sigma=0.6,
+ number_of_sites=7,
+ Q=np.array([float(np.float32(0.7))]),
+ )
+ hbar_meV_ps = scipp_hbar.to(unit='meV*ps').value
+ w = np.array([row[0] for row in REFERENCE_ROWS])
+
+ # THEN
+ # Lorentzians only: on these few points the delta function would land in the point
+ # nearest its centre (w = 1.0), while the reference has it at w = 0.8, not listed here.
+ x = hbar_meV_ps * (w - 0.8)
+ lorentzians = model.get_component_collections()[0][1:]
+ spectrum = hbar_meV_ps * sum(component.evaluate(x) for component in lorentzians)
+
+ # EXPECT
+ np.testing.assert_allclose(spectrum, [row[1] for row in REFERENCE_ROWS], rtol=1e-9)
+
+ @pytest.mark.parametrize('number_of_sites', [2, 3, 7])
+ def test_weights_sum_to_one(self, number_of_sites):
+ # WHEN
+ model = JumpSitesLogNormalDistribution(number_of_sites=number_of_sites, radius=1.3)
+ Q = np.array([0.1, 0.9, 2.5])
+
+ # THEN EXPECT
+ np.testing.assert_allclose(
+ model.calculate_EISF(Q) + model.calculate_QISF(Q), 1.0, rtol=0, atol=WEIGHT_ATOL
+ )
+
+ def test_sample_weights_do_not_depend_on_sigma(self):
+ # WHEN
+ narrow = JumpSitesLogNormalDistribution(sigma=0.2).sample_weights()
+ wide = JumpSitesLogNormalDistribution(sigma=1.5).sample_weights()
+
+ # THEN EXPECT
+ assert narrow.shape == (NUMBER_OF_SAMPLES,)
+ np.testing.assert_allclose(narrow, wide, rtol=1e-15, atol=0)
+ assert narrow.sum() == pytest.approx(1.0, rel=1e-15)
+ # gaussian in log(ratio) with the half: g_0 / g_10 = exp(-0.5 * 2 ln 10) = 1/10
+ assert narrow[0] / narrow[10] == pytest.approx(0.1, rel=1e-12)
+
+ def test_minimum_sigma_is_the_circle(self):
+ # WHEN
+ Q = np.array([0.5, 1.7])
+ model = JumpSitesLogNormalDistribution(
+ number_of_sites=5, radius=1.2, residence_time=0.7, sigma=MINIMUM_SIGMA
+ )
+ circle = EquivalentSitesCircle(number_of_sites=5, radius=1.2, residence_time=0.7)
+
+ # THEN EXPECT
+ for circle_order in range(1, 5):
+ for s in range(NUMBER_OF_SAMPLES):
+ np.testing.assert_allclose(
+ model.calculate_width(Q, order_of(circle_order, s)),
+ circle.calculate_width(Q, circle_order),
+ rtol=1e-9,
+ )
+ summed = sum(
+ model.calculate_QISF_order(Q, order_of(circle_order, s))
+ for s in range(NUMBER_OF_SAMPLES)
+ )
+ np.testing.assert_allclose(
+ summed, circle.calculate_QISF_order(Q, circle_order), rtol=0, atol=WEIGHT_ATOL
+ )
+ np.testing.assert_allclose(
+ model.calculate_EISF(Q), circle.calculate_EISF(Q), rtol=0, atol=WEIGHT_ATOL
+ )
+
+ def test_weights_follow_radius_unit(self):
+ # WHEN
+ Q = np.array([0.8, 1.6])
+ model = JumpSitesLogNormalDistribution(number_of_sites=4, radius=1.5, Q=Q)
+ EISF_angstrom = model.calculate_EISF(Q)
+ areas_angstrom = [c[3].area.value for c in model.get_component_collections()]
+
+ # THEN
+ model.radius.convert_unit('nm')
+
+ # EXPECT the same physical radius gives the same weights
+ np.testing.assert_allclose(model.calculate_EISF(Q), EISF_angstrom, rtol=1e-12)
+ areas_nm = [c[3].area.value for c in model.get_component_collections()]
+ np.testing.assert_allclose(areas_nm, areas_angstrom, rtol=0, atol=WEIGHT_ATOL)
+
+ @pytest.mark.parametrize('order', [0, 2 * NUMBER_OF_SAMPLES + 1, 1.5])
+ def test_order_validation(self, model, order):
+ # WHEN THEN EXPECT
+ with pytest.raises((TypeError, ValueError), match='order must be'):
+ model.calculate_QISF_order(np.array([1.0]), order)
+
+ # ------------------------------------------------------------------
+ # Components
+ # ------------------------------------------------------------------
+
+ @pytest.mark.parametrize('number_of_sites', [3, 7])
+ def test_components_per_Q(self, number_of_sites):
+ # WHEN
+ model = JumpSitesLogNormalDistribution(
+ number_of_sites=number_of_sites, Q=np.array([0.5, 1.0])
+ )
+ n = NUMBER_OF_SAMPLES * (number_of_sites - 1)
+
+ # THEN
+ collections = model.get_component_collections()
+
+ # EXPECT
+ assert len(collections) == 2
+ for collection in collections:
+ assert len(collection) == n + 1
+ assert isinstance(collection[0], DeltaFunction)
+ assert all(isinstance(component, Lorentzian) for component in collection[1:])
+ assert collection[n].name == model.lorentzian_component_name(n)
+ assert model.lorentzian_component_name(5) == 'JumpSitesLogNormalDistribution_j5'
+
+ def test_component_areas_and_widths_match_calculations(self):
+ """The dependency strings must give what the Scipp calculations give."""
+ # WHEN
+ Q = np.array([0.4, 2.3])
+ model = JumpSitesLogNormalDistribution(
+ scale=2.5, radius=1.4, residence_time=0.8, sigma=0.7, number_of_sites=4, Q=Q
+ )
+
+ # THEN
+ collections = model.get_component_collections()
+
+ # EXPECT
+ EISF = model.calculate_EISF(Q)
+ for i, collection in enumerate(collections):
+ assert collection[0].area.value == pytest.approx(2.5 * EISF[i], rel=0, abs=WEIGHT_ATOL)
+ for order in range(1, model.number_of_lorentzians + 1):
+ lorentzian = collection[order]
+ weight = model.calculate_QISF_order(Q, order)[i]
+ assert lorentzian.area.value == pytest.approx(2.5 * weight, rel=0, abs=WEIGHT_ATOL)
+ assert lorentzian.width.value == pytest.approx(
+ model.calculate_width(Q, order)[i], rel=1e-12
+ )
+ assert lorentzian.width.unit == 'meV'
+
+ def test_components_follow_parameter_changes(self):
+ # WHEN
+ Q = np.array([0.6, 1.8])
+ model = JumpSitesLogNormalDistribution(number_of_sites=3, Q=Q)
+
+ # THEN
+ model.radius = 2.2
+ model.residence_time = 3.5
+ model.sigma = 0.3
+ model.scale = 4.0
+
+ # EXPECT
+ collections = model.get_component_collections()
+ for order in (1, 7, 21, 30, 42):
+ for i, collection in enumerate(collections):
+ assert collection[order].area.value == pytest.approx(
+ 4.0 * model.calculate_QISF_order(Q, order)[i], rel=0, abs=WEIGHT_ATOL
+ )
+ assert collection[order].width.value == pytest.approx(
+ model.calculate_width(Q, order)[i], rel=1e-12
+ )
+
+ def test_component_parameters_are_dependent(self):
+ # WHEN
+ model = JumpSitesLogNormalDistribution(Q=np.array([1.0]))
+ collection = model.get_component_collections()[0]
+
+ # THEN
+ fit_parameters = model.get_fit_parameters()
+
+ # EXPECT
+ for component in collection:
+ assert component.area.independent is False
+ assert all(component.area is not p for p in fit_parameters)
+ for component in collection[1:]:
+ assert component.width.independent is False
+ assert all(component.width is not p for p in fit_parameters)
+
+ def test_parameter_updates_raise_no_runtime_warning(self):
+ """A fit changes tau and sigma on every step; the dependent values must not warn."""
+ # WHEN
+ model = JumpSitesLogNormalDistribution(Q=np.array([0.5, 1.0]))
+
+ # THEN EXPECT
+ with warnings.catch_warnings():
+ warnings.simplefilter('error', RuntimeWarning)
+ model.residence_time = 3.0
+ model.sigma = 0.4
+
+ def test_fit_targets(self, model):
+ # WHEN THEN
+ names = [target.name for target in model.get_fit_targets()]
+
+ # EXPECT
+ assert names[0] == 'elastic_area'
+ assert len(names) == 1 + 2 * model.number_of_lorentzians
+
+ def test_get_global_variables(self, model):
+ # WHEN THEN EXPECT
+ assert model.get_global_variables() == [
+ model.scale,
+ model.radius,
+ model.residence_time,
+ model.sigma,
+ ]
+
+ def test_repr(self, model):
+ # WHEN THEN EXPECT
+ assert 'sigma=' in repr(model)
+ assert 'number_of_sites=3' in repr(model)
+
+ # ------------------------------------------------------------------
+ # Fit loop
+ # ------------------------------------------------------------------
+
+ def test_fit_recovers_parameters(self):
+ """One Q, synthetic data from the model; tau and sigma are fitted, as in a real fit."""
+ # WHEN
+ Q = sc.array(dims=['Q'], values=[1.2], unit='1/angstrom')
+ energy = sc.linspace('energy', -2.0, 2.0, 201, unit='meV')
+ shape = (1, energy.shape[0])
+
+ def as_data(values, variances):
+ return sc.DataArray(
+ sc.array(dims=['Q', 'energy'], values=values, variances=variances),
+ coords={'Q': Q, 'energy': energy},
+ )
+
+ true_model = JumpSitesLogNormalDistribution(
+ radius=1.5, residence_time=2.0, sigma=0.5, number_of_sites=2
+ )
+ experiment = edyn.Experiment('synthetic', data=as_data(np.ones(shape), np.ones(shape)))
+ clean = np.array(
+ edyn.Analysis(
+ experiment=experiment, sample_model=edyn.SampleModel(diffusion_models=true_model)
+ ).calculate()
+ )
+ noise = 0.003 * clean.max()
+ rng = np.random.default_rng(seed=1)
+ experiment.data = as_data(clean + rng.normal(0.0, noise, shape), np.full(shape, noise**2))
+ fit_model = JumpSitesLogNormalDistribution(
+ radius=1.5, residence_time=1.5, sigma=0.8, number_of_sites=2
+ )
+ analysis = edyn.Analysis(
+ experiment=experiment, sample_model=edyn.SampleModel(diffusion_models=fit_model)
+ )
+ analysis.fix_energy_offset()
+
+ # THEN
+ analysis.fit(fit_method='independent')
+
+ # EXPECT
+ for name, true_value in {'residence_time': 2.0, 'sigma': 0.5}.items():
+ parameter = getattr(fit_model, name)
+ assert parameter.variance > 0
+ assert abs(parameter.value - true_value) < 5 * parameter.error
+ assert fit_model.radius.value == pytest.approx(1.5) # fixed, so unchanged
diff --git a/tests/unit/easydynamics/sample_model/diffusion_model/test_spherical_bessel_expression.py b/tests/unit/easydynamics/sample_model/diffusion_model/test_spherical_bessel_expression.py
new file mode 100644
index 00000000..48c626ba
--- /dev/null
+++ b/tests/unit/easydynamics/sample_model/diffusion_model/test_spherical_bessel_expression.py
@@ -0,0 +1,74 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+
+import math
+
+import numpy as np
+import pytest
+from asteval import Interpreter
+
+from easydynamics.sample_model.diffusion_model.spherical_bessel_expression import (
+ spherical_bessel_expression,
+)
+from easydynamics.utils.spherical_bessel import spherical_jn
+
+ORDERS = range(27) # DiffusionInSphere needs j_l and j_(l+1) for l up to 25
+
+
+def evaluate(expression: str, x: np.ndarray) -> np.ndarray:
+ """Evaluate the expression point by point with asteval, as EasyScience Core does."""
+ interpreter = Interpreter()
+ values = np.empty_like(x)
+ for i, value in enumerate(x):
+ interpreter.symtable['x'] = float(value)
+ interpreter.symtable['y'] = float(value) ** 2
+ values[i] = interpreter.eval(expression, raise_errors=True)
+ return values
+
+
+@pytest.mark.parametrize('order', ORDERS)
+def test_matches_spherical_jn(order):
+ # WHEN
+ x = np.concatenate([np.linspace(1e-3, 1.0, 200), np.linspace(1.0, 40.0, 1800)])
+
+ # THEN
+ values = evaluate(spherical_bessel_expression(order), x)
+
+ # EXPECT
+ np.testing.assert_allclose(values, spherical_jn(order, x), rtol=0, atol=1e-12)
+
+
+@pytest.mark.parametrize('order', range(1, 27))
+def test_continuous_at_the_switch(order):
+ # WHEN the expression switches from the power series to the finite sum at x = order
+ x = np.array([order - 1e-9, order, order + 1e-9])
+
+ # THEN
+ values = evaluate(spherical_bessel_expression(order), x)
+
+ # EXPECT
+ np.testing.assert_allclose(values, spherical_jn(order, x), rtol=0, atol=1e-12)
+
+
+@pytest.mark.parametrize('order', [0, 1, 5, 26])
+def test_small_x(order):
+ # WHEN
+ x = np.array([1e-8])
+
+ # THEN
+ values = evaluate(spherical_bessel_expression(order), x)
+
+ # EXPECT the leading term x^l / (2l + 1)!!
+ double_factorial = math.prod(range(2 * order + 1, 0, -2))
+ assert np.isfinite(values[0])
+ assert values[0] == pytest.approx(1e-8**order / double_factorial, rel=1e-12, abs=0)
+
+
+@pytest.mark.parametrize(
+ 'order, expected_exception',
+ [(1.5, TypeError), (True, TypeError), ('2', TypeError), (-1, ValueError)],
+)
+def test_order_must_be_a_non_negative_integer(order, expected_exception):
+ # WHEN THEN EXPECT
+ with pytest.raises(expected_exception, match='order must be'):
+ spherical_bessel_expression(order)
diff --git a/tests/unit/easydynamics/utils/test_spherical_bessel.py b/tests/unit/easydynamics/utils/test_spherical_bessel.py
new file mode 100644
index 00000000..31f646f7
--- /dev/null
+++ b/tests/unit/easydynamics/utils/test_spherical_bessel.py
@@ -0,0 +1,61 @@
+# SPDX-FileCopyrightText: 2026 EasyScience contributors
+# SPDX-License-Identifier: BSD-3-Clause
+
+import numpy as np
+import pytest
+import scipy.special
+
+from easydynamics.utils import spherical_bessel
+from easydynamics.utils.spherical_bessel import spherical_jn
+
+
+def test_matches_scipy_exactly():
+ # WHEN
+ order = np.arange(27)[:, None]
+ x = np.linspace(0.0, 40.0, 2001)
+
+ # THEN
+ values = spherical_jn(order, x)
+
+ # EXPECT bit for bit: the kernel calls scipy's own compiled function
+ np.testing.assert_array_equal(values, scipy.special.spherical_jn(order, x))
+
+
+@pytest.mark.parametrize(
+ 'order, x, shape',
+ [
+ (3, np.linspace(0.1, 5.0, 7), (7,)),
+ (np.arange(4), 2.0, (4,)),
+ (2, 1.5, ()),
+ (np.arange(3)[:, None], np.linspace(0.1, 1.0, 5), (3, 5)),
+ ],
+ ids=['array_x', 'array_order', 'scalars', 'table'],
+)
+def test_broadcasts_order_and_x(order, x, shape):
+ # WHEN
+ values = spherical_jn(order, x)
+
+ # THEN EXPECT
+ assert np.shape(values) == shape
+ np.testing.assert_array_equal(values, scipy.special.spherical_jn(order, x))
+
+
+def test_falls_back_to_scipy(monkeypatch):
+ # WHEN scipy's compiled function cannot be found (its name is a scipy implementation detail)
+ monkeypatch.setattr(spherical_bessel, '_load_kernel', lambda: None)
+ order = np.arange(5)[:, None]
+ x = np.linspace(0.0, 20.0, 101)
+
+ # THEN
+ values = spherical_jn(order, x)
+
+ # EXPECT
+ np.testing.assert_array_equal(values, scipy.special.spherical_jn(order, x))
+
+
+def test_uses_the_numba_kernel():
+ # WHEN numba (an optional dependency) is installed
+ pytest.importorskip('numba')
+
+ # THEN EXPECT the fast path is available
+ assert spherical_bessel._load_kernel() is not None