diff --git a/examples/component_example.ipynb b/examples/components.ipynb similarity index 93% rename from examples/component_example.ipynb rename to examples/components.ipynb index bdda8cf..9183e30 100644 --- a/examples/component_example.ipynb +++ b/examples/components.ipynb @@ -74,18 +74,16 @@ "source": [ "delta = DeltaFunction(name='Delta', center=0.0, area=1.0)\n", "x1=np.linspace(-2, 2, 100)\n", - "y=delta.evaluate(x1)\n", + "y1=delta.evaluate(x1)\n", "x2=np.linspace(-2,2,51)\n", "y2=delta.evaluate(x2)\n", "plt.figure()\n", - "plt.plot(x1, y, label='Delta Function')\n", + "plt.plot(x1, y1, label='Delta Function')\n", "plt.plot(x2, y2, label='Delta Function (coarser)')\n", "plt.legend()\n", "plt.show()\n", "# The area under the Delta function is indeed equal to the area parameter.\n", - "xx=np.linspace(-2, 2, 10000)\n", - "yy=delta.evaluate(xx)\n", - "area= np.trapezoid(y, x1)\n", + "area= np.trapezoid(y1, x1)\n", "print(area)\n" ] }, @@ -114,7 +112,7 @@ ], "metadata": { "kernelspec": { - "display_name": "newdynamics", + "display_name": "easydynamics_newbase", "language": "python", "name": "python3" }, @@ -128,7 +126,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.11.13" + "version": "3.12.12" } }, "nbformat": 4, diff --git a/examples/convolution.ipynb b/examples/convolution.ipynb new file mode 100644 index 0000000..2d7cac6 --- /dev/null +++ b/examples/convolution.ipynb @@ -0,0 +1,154 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "f42e34d0", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "from easydynamics.sample_model import DeltaFunction, Gaussian, Lorentzian, SampleModel, DampedHarmonicOscillator\n", + "from easydynamics.convolution import Convolution \n", + "from easydynamics.utils import _detailed_balance_factor as detailed_balance_factor\n", + "\n", + "import matplotlib.pyplot as plt\n", + "%matplotlib widget\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "600c0850", + "metadata": {}, + "outputs": [], + "source": [ + "# Standard example of convolution of a sample model with a resolution model\n", + "sample_model=SampleModel(name='sample_model')\n", + "gaussian=Gaussian(name='Gaussian',width=0.5,area=1)\n", + "dho = DampedHarmonicOscillator(name='DHO',center=1.0,width=0.3,area=2.0)\n", + "lorentzian = Lorentzian(name='Lorentzian',center=-1.0,width=0.2,area=1.0)\n", + "delta = DeltaFunction(name='Delta',center=0.4,area=0.5)\n", + "sample_model.add_component(gaussian)\n", + "# sample_model.add_component(dho)\n", + "sample_model.add_component(lorentzian)\n", + "sample_model.add_component(delta)\n", + "\n", + "resolution_model = SampleModel(name='resolution_model')\n", + "resolution_gaussian=Gaussian(name='Resolution Gaussian',width=0.015,area=0.8)\n", + "resolution_lorentzian = Lorentzian(name='Resolution Lorentzian',width=0.025,area=0.2)\n", + "resolution_model.add_component(resolution_gaussian)\n", + "resolution_model.add_component(resolution_lorentzian)\n", + "\n", + "energy=np.linspace(-2, 2, 100)\n", + "\n", + "convolver = Convolution(sample_model=sample_model, resolution_model=resolution_model, energy=energy)\n", + "y = convolver.convolution()\n", + "plt.figure()\n", + "plt.plot(energy, y, label='Convoluted Model')\n", + "plt.xlabel('Energy (meV)')\n", + "plt.ylabel('Intensity (arb. units)')\n", + "plt.title('Convolution of Sample Model with Resolution Model')\n", + "\n", + "plt.plot(energy, sample_model.evaluate(energy), label='Sample Model', linestyle='--')\n", + "plt.plot(energy, resolution_model.evaluate(energy), label='Resolution Model', linestyle=':')\n", + "\n", + "\n", + "plt.legend()\n", + "# set the limit on the y axis\n", + "plt.ylim(0,6)\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7d49acae", + "metadata": {}, + "outputs": [], + "source": [ + "convolver.upsample_factor" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "fede1a58", + "metadata": {}, + "outputs": [], + "source": [ + "\n", + "# Use some of the extra settings for the numerical convolution\n", + "sample_model=SampleModel(name='sample_model')\n", + "gaussian=Gaussian(name='Gaussian',width=0.3,area=1)\n", + "dho = DampedHarmonicOscillator(name='DHO',center=1.0,width=0.3,area=2.0)\n", + "lorentzian = Lorentzian(name='Lorentzian',center=-1.0,width=0.2,area=1.0)\n", + "delta = DeltaFunction(name='Delta',center=0.4,area=0.5)\n", + "sample_model.add_component(gaussian)\n", + "sample_model.add_component(dho)\n", + "sample_model.add_component(lorentzian)\n", + "\n", + "resolution_model = SampleModel(name='resolution_model')\n", + "resolution_gaussian=Gaussian(name='Resolution Gaussian',width=0.15,area=0.8)\n", + "resolution_lorentzian = Lorentzian(name='Resolution Lorentzian',width=0.25,area=0.2)\n", + "resolution_model.add_component(resolution_gaussian)\n", + "resolution_model.add_component(resolution_lorentzian)\n", + "\n", + "energy=np.linspace(-2, 2, 100)\n", + "\n", + "\n", + "temperature = 10.0 # Temperature in Kelvin\n", + "offset = 0.5\n", + "upsample_factor = 5\n", + "extension_factor = 0.5\n", + "plt.figure()\n", + "plt.xlabel('Energy (meV)')\n", + "plt.ylabel('Intensity (arb. units)')\n", + "\n", + "convolver = Convolution(\n", + " sample_model=sample_model, \n", + " resolution_model=resolution_model, \n", + " energy=energy-offset, \n", + " upsample_factor=upsample_factor,\n", + " extension_factor=extension_factor,\n", + " temperature=temperature,\n", + " normalize_detailed_balance=True,)\n", + "y = convolver.convolution()\n", + "\n", + "\n", + "plt.plot(energy, y, label='Convoluted Model')\n", + "\n", + "plt.plot(energy, sample_model.evaluate(energy-offset)*detailed_balance_factor(energy-offset, temperature), label='Sample Model with DB', linestyle='--')\n", + "\n", + "plt.plot(energy, resolution_model.evaluate(energy ), label='Resolution Model', linestyle=':')\n", + "plt.title('Convolution of Sample Model with Resolution Model with detailed balancing')\n", + "\n", + "plt.legend()\n", + "plt.ylim(0,2.5)\n", + "plt.show()\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "newdynamics", + "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.11.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/examples/detailed_balance.ipynb b/examples/detailed_balance.ipynb index 172422f..b4ca072 100644 --- a/examples/detailed_balance.ipynb +++ b/examples/detailed_balance.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "id": "97050b3e", "metadata": {}, "outputs": [], @@ -17,36 +17,10 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "id": "c1654720", "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "7cfd67c54e984f0bbf333f80d81e1929", - "version_major": 2, - "version_minor": 0 - }, - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAoAAAAHgCAYAAAA10dzkAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjYsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvq6yFwwAAAAlwSFlzAAAPYQAAD2EBqD+naQAAfQBJREFUeJzt3Qd8E+X/B/Bv96KlzLLKLLKnCKIMUWQpwwGoKIgD9QcOBBkqw4GooPJXURQVF6iIDCeIyFLZe++9V/du7v/6PO2FJE1L2qa9jM8bz14ul8tzI3ffe9b5aJqmCRERERF5DV+jE0BEREREJYsBIBEREZGXYQBIRERE5GUYABIRERF5GQaARERERF6GASARERGRl2EASERERORlGAASEREReRkGgERERERehgEgERERkZdhAEhERETkZRgAEhEREXkZBoBEREREXoYBIBEREZGXYQBIRERE5GUYABIRERF5GQaARERERF6GASARERGRl2EASERERORlGAASEREReRkGgERERERehgEgERERkZdhAEhERETkZRgAEhEREXkZBoBEREREXoYBIBEREZGXYQBIRERE5GUYABIRERF5GQaARERERF6GASARERGRl2EASERERORlGAASEREReRkGgERERERehgEgERERkZdxSgC4YsUK8fHxUX/zM3HiRDXfxYsXxVluueUWNbi7zMxMGTVqlERHR4uvr6/06dNHPEFiYqI89thjUqlSJbXvn3vuOUPSge/G8af78ssv1bSjR49azTdlyhSpXbu2+Pn5SfPmzT163xB5AnvXn4cfflhq1qxZounAuQTpwLmFyB0wB9BFfPHFFyr4uPfee+Wrr76S4cOHO/07PvrooxI/Ob3xxhvqO5966in55ptv5KGHHhJX9eeff6pA7+abb5ZZs2aptJfUvnGW33//3SrQdcVjwh0kJyer7Xitm1qigtq9e7c6tmxvPp1xsz1hwgTp1q2blC1bttiC0f/++0+lPzY21unLppLlX8LfR3n4+++/pWrVqvLee+8V23fgYl++fHl1d1yS63XjjTeqE5MrQSB63333SVBQkFVakcP3+eefS2BgYInuG2cGgNOnT3c4CDTimHCXAPCVV15R455QwuBtZs6cKSaTSVw1AMSxhePKmbmUKFl79dVXpXr16tKsWbNiu3lBAIj045wRGRlZLN9B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TFB/PRHXz/15+jpy/dyfp68j14+Kgo1AiIiIiLwMcwCJiIiIvAwDQCIiIiIvwwCQiIiIyMswACQiIiLyMgwADTJp0iS56aabJDQ0VCIjIx36DNrrjB8/XipXriwhISHSuXNnOXDggNU8ly9flgEDBqhOM7HcRx99VBITE6WkFTQdR48eVU9TsTf8+OOP5vnsvf/999+LEQqzrW+55ZZc6X/yySet5jl+/Ljccccd6tioWLGivPDCC5KZmSmuvn6Y/+mnn5Z69eqp47N69eryzDPPSFxcnNV8Ru7D6dOnS82aNSU4OFjatGkj69evz3d+HHv169dX8zdp0kR+//33Av8mS1JB1m/mzJnSvn17KVOmjBqQdtv5H3744Vz7qlu3buIO6/fll1/mSjs+58r7r6DraO98ggHnD1fch6tWrZKePXuqp28gHQsXLrzmZ1asWCEtW7ZULYFjYmLUfi3q75pyoBUwlbzx48dr7777rvb8889rpUuXdugzb775ppp34cKF2rZt27RevXpptWrV0lJSUszzdOvWTWvWrJm2du1abfXq1VpMTIx2//33ayWtoOnIzMzUzpw5YzW88sorWqlSpbSEhATzfDhkZ82aZTWf5fqXpMJs644dO2qPP/64Vfrj4uKstkPjxo21zp07a1u2bNF+//13rXz58trYsWM1V1+/HTt2aHfffbf2888/awcPHtSWLVum1a1bV7vnnnus5jNqH37//fdaYGCg9sUXX2i7du1S+yEyMlI7d+6c3fn//fdfzc/PT3v77be13bt3ay+//LIWEBCg1rMgv8mSUtD1e+CBB7Tp06er42zPnj3aww8/rNbl5MmT5nkGDRqkjgPLfXX58mXNCAVdPxxjERERVmk/e/as1TyutP8Ks46XLl2yWr+dO3eqYxbr7or7EOezl156SZs/f746DyxYsCDf+Q8fPqyFhoaq6yR+gx988IFav8WLFxd6m9FVDAANhh+qIwGgyWTSKlWqpE2ZMsU8LTY2VgsKCtK+++479Ro/EPyoNmzYYJ7njz/+0Hx8fLRTp05pJcVZ6WjevLn2yCOPWE1z5KThyuuIAPDZZ5/N9wTp6+trdaH6+OOP1YUsLS1Nc7d9OHfuXHVyzsjIMHwftm7dWhs6dKj5dVZWllalShVt8uTJdufv16+fdscdd1hNa9OmjfbEE084/Jt05fWz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", - "text/html": [ - "\n", - "
\n", - "
\n", - " Figure\n", - "
\n", - " \n", - "
\n", - " " - ], - "text/plain": [ - "Canvas(toolbar=Toolbar(toolitems=[('Home', 'Reset original view', 'home', 'home'), ('Back', 'Back to previous …" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "\n", "temperatures=[1, 10, 100]\n", @@ -68,36 +42,10 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "id": "a64fbe7c", "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "16184f6dae4a40ea85c0c8ca1c716fd3", - "version_major": 2, - "version_minor": 0 - }, - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAoAAAAHgCAYAAAA10dzkAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjYsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvq6yFwwAAAAlwSFlzAAAPYQAAD2EBqD+naQAAZQVJREFUeJzt3Qd8FGX+x/EnPSH0DoIU8ayo2PXsDUQBy9k9sZyeimJX8BRsWLGdp2IvJ6JYsPw9Uey9IlhBRBSUXgPpZf6v75PMMrvZJLvJJlvm8369Jrs7O5l9pv/maZPmOI5jAAAA4Bvp8U4AAAAAWhYBIAAAgM8QAAIAAPgMASAAAIDPEAACAAD4DAEgAACAzxAAAgAA+AwBIAAAgM8QAAIAAPgMASAAAIDPEAACAAD4DAEgAACAzxAAAgAA+AwBIAAAgM8QAAIAAPgMASAAAIDPEAACAAD4DAEgAACAzxAAAgAA+AwBIAAAgM8QAAIAAPgMASAAAIDPEAACAAD4DAEgAACAzxAAAgAA+AwBIAAAgM8QAAIAAPgMASAAAIDPEAACAAD4DAEgAACAzxAAAgAA+AwBIAAAgM8QAAIAAPgMASAAAIDPEADGwamnnmr69u0bNC4tLc1cc801MfuN9957z85Tr/XRb2q6lStXxuy399tvPzsku4qKCnP55Zeb3r17m/T0dHPEEUeYVLBhwwbzj3/8w3Tv3t1u+wsvvDAu6Qjd5x9//HE77rfffgua7rbbbjP9+/c3GRkZZocddkjpbQMgNnSN1bU22mtirCXy9TAlA0D3QuIOubm5pmfPnmbw4MHm3//+t1m/fn2j5/3jjz/ai1boRQqp59FHH7XBx9/+9jfzxBNPmIsuuijmv3HffffZ/bUl3XjjjfY3zznnHPPf//7X/P3vfzeJ6s0337SB3l//+lfz2GOP2bS31LaJlf/9739R3dzFY59IBkVFRXY9tvQFPNlwjUKkMk0Ku+6660y/fv1MeXm5Wbp0qT1xKLfjjjvuMK+88orZbrvtGnVwXXvttTaiD83Fi9RDDz1kqqqqGvW/aDnvvPOO2WSTTcydd97ZbL+hi33nzp2D7lRbYrl23313M378eJNIFIgef/zxJicnJyityuF75JFHTHZ2dotum1gGgPfee2/EQWA89olkCQB17pVEzVFJBLG4RqWiffbZxxQXFwedR/wupQPAQw891Oy8886Bz2PHjrUXjsMPP9wMHz7c/PTTTyYvL6/F05WVldXiv4noLV++3LRv394km5KSEnuSU+BU13JtvfXWMfs9FcfqhqapJ1YV8WoITauO0dB5x3rbOI5j11s8zgd+Fav9JlXSkcq0fsvKymxpXLzofBjP309EKVkEXJ8DDjjAXH311eb33383Tz31VNB3c+bMsUVKHTt2tDuKgkflFLpULHPMMcfY9/vvv3+giNktknj55ZfNYYcdZoublYux2Wabmeuvv95UVlY2WAcwnD///NOcfvrpplu3bnZ+22yzjS36CvXHH3/YOlD5+fmma9eutjistLQ0qvWiOoDHHnusadu2renUqZO54IIL7AXRS0VwWn/6DaVHQcT999/f4Lx14I8bN87stNNOpl27djade++9t3n33XeDplORhdbnxIkTzYMPPmjXn35nl112MV9++WWt+Wp7Kc1dunSxF+4tttjC/Otf/2rUOgyXDqXvhx9+qLWdlb4999zTrif9rpbr+eefDzsv7WO77rqradWqlenQoYO9C1Wxpmgf0Pzff//9wG94czZ+/fVXu79pf9T/K9futddeC5q/W6/lmWeeMVdddZXNFdO0BQUFtdLiTrtgwQI7H/c33aIiBVVnnHGGXVfa/7fffntbvFrXNrrrrrsC20i5DnXRvqh9UtupTZs29uZL+2yo0DqAeq99rrCwMJBWd5q6to0uNEqXtrOWQcvyz3/+06xZsybot7TudSP4xhtv2ONc2/GBBx6w361du9aWFKh+oZZtwIAB5pZbbgnKtY90X9Wxrtw/d3ncoS4N7RPRpk2/rfqT2icOOeQQs2jRIhvs6rzUq1cvu9wjRowwq1evDrt+tK+q3qXWpY73F198sVaao01T6H4TyflB/6/9R5S75a4bN1e1rnpWoefahvbfhq4BohIlpWHzzTe30+g8sNdee5kZM2aYxlB6zjvvPPPSSy+ZbbfdNnCemj59eq1pv/nmG5uxofN069atzYEHHmg+++yziK9R4WgdaV46V+o6ovda15deemmta5eOxUsuuSSwrXXO1brUPhVumSZPnmyXRdNqedzj96OPPjKjR4+2v6MbOR2j2g+0L51yyin2XKlB1T9C5x3N+dcrtA7g4yFVxbxD6L6k87h+R7+nfUMlFTqWQrnnAk2n8/6HH35oEllK5wDWV9R05ZVX2pPbmWeeacfppKt6RrqAjhkzxp6Epk6dag+IF154wRx55JH24q2dVvUI9f9bbbWV/V/3VTuUDp6LL77Yviq3USc2XYxVXykay5Ytsxd890DSgfL666/bC7Tm51bcV5a2TgILFy60aVPwqXpd+u1oKJDSifKmm26yJxQtoy6aTz75ZGAaBXs6mHUBz8zMNK+++qo599xz7Yl+1KhRdc5b6X344YfNCSecYNe36mCqSE91Mr/44otAxX7X008/bafRSUHLf+utt5qjjjrKBkRu7um3335rLxL6fNZZZ9m0z58/36ZpwoQJUa3DUJpO61DzUYMJrRPvdr777rvtOjjppJPsSUvBl066//d//2dvAFy6SOgCpZOVqiMoh+Hzzz+320YXY12Azj//fLuvuIGrAhY37fo/FXtpu+pkp2BMv6uTnfZHL13QNX+dtBVwhcvNUPq1XArGdPHXidxdXu1HOun98ssvdl2p6sRzzz1nLw46KeuGwEuBmW4QtO51ctdJsS5qcKIT6IknnmiXScvvXU91UVp1QtU+ov1HBg0aVO+20T6j4/C0006z603B7n/+8x974fz444+Dct/nzp1r90n9j/ZLXcy0vvfdd197MdT4TTfd1HzyySe29GDJkiV2m0Wzr2r84sWLbXCgdDekvn0i2rTp4qv9U/NTgKe06TjXTZwugldccYXd3vfcc4/db0JvjObNm2eOO+44c/bZZ5uRI0faba79XBfygw8+uFFpCrffRHJ+0D6q84/qrWrf1zqWxlTjqSsdkVwDRMe09jvt17rIK/1fffWVmTlzZmC9REsBkYJrnU91k6Tz79FHH23P6zr2RenTOU/BnwIj7V+6adFxqxuG3XbbrcFrVF0U6Gl9ax4KsN566y1z++2322BG61wUiOn8o8Bc51BtF91AXXbZZXb7h1bH0HGu9afziao06Bw9a9Ys+532STVC0zlS1xsd5woEte9oH1JdX1Wd0HVTQbGCQlek59+G7LPPPrWOSWUM6UZamRwunWuUaaRjR9t8xYoV9pjR/+u84pZEaJ/VMaBznK4tOgcondq3FDAnJCcFPfbYY7plcL788ss6p2nXrp0zaNCgwOcDDzzQGThwoFNSUhIYV1VV5ey5557O5ptvHhj33HPP2Xm/++67teZZVFRUa9w///lPp1WrVkHzHTlypNOnT5+g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AOXmm282l112mfn+++/jnRQAAICklOaoXDWJdOjQwRQVFZmKigqTnZ1t8vLygr5fvXp1i6WloKDAtGvXzqxbt860bdu2xX4XAAA0XgHXb5Npksxdd90V7yQAAAAktaQLAEeOHBnvJAAAACS1pAsApbKy0rz00kvmp59+sp+32WYbM3z4cJORkRHvpAEAACS8pAsAf/nlFzN06FDz559/mi222MKOu+mmm0zv3r3Na6+9ZjbbbLN4JxEAACChJV0r4NGjR9sgT30Bzpw50w7qHLpfv372OwAAAKRYDuD7779vPvvsM9OxY8fAuE6dOtnuYf7617/GNW0AAADJIOlyAHNycsz69etrjd+wYYPtFgYAAAApFgAefvjh5qyzzjKff/65fTScBuUInn322bYhCAAAAFIsAPz3v/9t6wDuscceJjc31w4q+tUzgukjEAAAIAXrALZv3968/PLLtjWw2w3MVlttZQNAAAAApGAO4HXXXWcfBaeAb9iwYXbQ++LiYvsdAAAAUuxZwOrsecmSJaZr165B41etWmXHqZPolsKzBAEASD4FXL+TLwdQ8WpaWlqt8bNnzw7qGgYAAABJXgewQ4cONvDT8Je//CUoCFSun7qBUUtgAAAApEgAqBa+yv07/fTTzbXXXmuzbl3q/69v3762ZTAAAABSJAAcOXKkfdUj39TtS2Zm0iQdAAAgoSRdHcDCwkLz9ttv1xr/xhtvmNdffz0uaQIAAEgmSRcAjhkzJmxLXxUP6zsAAACkWAA4b948s/XWW9cav+WWW9rOoQEAAJBiAaAaf/z666+1xiv4y8/Pj2pef/75pzn55JNNp06dTF5enhk4cKD56quvYphaAACAxJN0AeCIESPMhRdeaObPnx8U/F1yySVm+PDhEc9nzZo1tjFJVlaWrTv4448/mttvv912NwMAAJDKku5JIOq1e8iQITanrlevXnbcH3/8Yfbee2/z4osv2mcFR0L1BT/++GPz4YcfNjot9CQOAEDyKeD6nXwBoCjJM2bMsE//UNHtdtttZ/bZZ5+o5qF6hIMHD7bB4/vvv2822WQTc+6555ozzzwz4nmwAwEAkHwKuH4nZwAYC7m5ufb14osvNsccc4z58ssvzQUXXGAmTZoU6HMwVGlpqR28O1Dv3r19vQMBAJBsCggAkzMAVF+AyrVbuHChKSsrC/pu9OjR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hgTmvckmmzTLMvlVmpNk3WvrLuKKK64wU6dONatWrar1vVoftZSCggK7k65bt47AEwBSlDIbFixYYPr162efbIHk324FXL+T70kgl19+uXnnnXfM/fffb3JycszDDz9s+wNU9vCTTz4Z7+QBAAAkvKQrAn711VdtoKd6AapPsPfee9t6B3369DGTJ0+m7yAAAIBUywFUNy9uZ9DKtnW7fdlrr73MBx98EOfUAQAAJL6kCwAV/KlMX7bccktbF9DNGVTLIQAAAKRYAKhi39mzZ9v3eqi0nhuoyp0XXXSRueyyy+KdPAAAgISXdHUAFei59HBpPTtw5syZth7gdtttF9e0AQAAJIOkCwBD9e3b1w4AAABI0SJg0SNlDj/8cLPZZpvZQe/16BgAAACkYAB433332Z7B9dDoCy64wA5qDTx06FBbHxAAAAApVgR84403mjvvvDPoodKjR482f/3rX+13o0aNimv6AAAAEl3S5QCuXbvW5gCGOuSQQ+wjXQAAgDFpaWn1Dtdcc03U8/zhhx/M0Ucfbeveax533XVX2OlUIqdp1EvHbrvtZr744otaj2lThk2nTp1M69at7TyXLVtW72/rARAXXnhh0Li7777bPhXsmWeeiXpZ/C7pAsDhw4ebadOm1Rr/8ssv27qAAADAmCVLlgQGBWqqLuUdd+mll0Y9z6KiItsf780332y6d+8edppnn33WXHzxxWb8+PG2l47tt9/eDB482CxfvjyoRw/13/vcc8+Z999/3yxevNgcddRRUaVF87/yyivt9f/444+Peln8LimKgP/9738H3m+99dZmwoQJ5r333jN77LGHHffZZ5+Zjz/+2FxyySWN/g3tzGPHjrV1Cuu6owEAIFl4A7R27drZHLu6grZI7bLLLnZw++IN54477jBnnnmm7bdXJk2aZF577TXz6KOP2v9Rad0jjzxinn76aXPAAQfYaR577DGz1VZb2ev57rvvXm8aHMexVb+eeuopM2PGDLPnnns2aZn8KikCQNX58+rQoYP58ccf7eDSU0C0c1111VVRz//LL780DzzwAP0IAgAioiCkuKI4Lr+dl5lng7lYURFsfU4++WQbxEWirKzMfP311zZDxZWenm777f3000/tZ31fXl5ux7n0ZK9NN93UTlNfAFhRUWHT884779icQ67bKR4Auo9+aw4bNmwwJ510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zeuvv+6kpaU5f/75p9NSYpWOHXbYwTn99NODxkVy0kjkZVQAeMEFF9R7gkxPTw+6UN1///32QlZaWuok2zacOnWqPTmXl5fHfRvuuuuuzqhRowKfKysrnZ49ezo33XRT2OmPPfZY57DDDgsat9tuuzn//Oc/Iz4mE3n5Qunmo02bNs4TTzwRFDyMGDHCSQTRLl9D59ZE236x2IZ33nmn3YYbNmxIyG3oFcl54PLLL3e22WaboHHHHXecM3jw4JitMz+jCDhJLFiwwCxdutQWUXifY6js7k8//dR+1quK6nbeeefANJpezyz+/PPPWyytsUjH119/bWbNmmWLHUONGjXKdO7c2ey6667m0UcftcU4La0pyzh58mSb/m233daMHTvWFBUVBc1XRY3dunULjBs8eLB9KPoPP/xgWkqs9iUV/6oIOTMzM67bsKyszO5T3uNHy6LP7vETSuO907vbwp0+kmOypTRm+UJpPywvLzcdO3asVQSnqggq2j/nnHPMqlWrTEtr7PKpykKfPn1M7969zYgRI4KOoUTafrHaho888og5/vjjTX5+fsJtw8Zo6BiMxTrzs+CzMhKWTlTiDQzcz+53etVB7qULr07o7jQtldampkMnsq222srWk/S67rrrzAEHHGDrx7355pvm3HPPtSd51TVrSY1dxhNPPNFekFQH5ttvvzVXXHGFmTt3rnnxxRcD8w23jd3vkmkbrly50lx//fXmrLPOivs2VFoqKyvDrts5c+aE/Z+6toX3eHPH1TVNS2nM8oXSvqj90nsxVV2xo446yvTr18/Mnz/fXHnllebQQw+1F9eMjAyTyMunYEc3F9ttt529EZk4caI9nygI7NWrV0Jtv1hsQ9V7+/777+250ytRtmFj1HUM6oa4uLjYrFmzpsn7vZ8RAMbQmDFjzC233FLvND/99JOtVJ7Ky9dUOrCffvppc/XVV9f6zjtu0KBBprCw0Nx2220xCx6aexm9wZBy+lT5/MADD7Qn5s0228ykyjbUCVoV0bfeemtzzTXXtOg2RPRuvvlm2xBHOUXehhLKTfLurwqmtJ9qOu23iWyPPfawg0vBn24qH3jgAXtjkmoU+GkbKVfdK5m3IZoXAWAMXXLJJbbFVX369+/fqHl3797dvi5btswGDS593mGHHQLTLF++POj/1HpUrTPd/2+J5WtqOp5//nlbHHXKKac0OK2Ka3QyLy0tjcnzIltqGb3pl19++cWelPW/oS3YtI0lWbbh+vXrba5DmzZtzLRp00xWVlaLbsNwVNys3A53Xbr0ua7l0fj6po/kmGwpjVk+l3LGFAC+9dZbNjhoaN/Qb2l/bcngoSnL59J+qBsOpT3Rtl9Tl1E3UQrglbvekHhtw8ao6xhUtRK12tb6aup+4WvxroTod9E2Apk4cWJgnFqPhmsE8tVXXwWmeeONN+LWCKSx6VBDidCWo3W54YYbnA4dOjgtLVbr+qOPPrLzUQtEbyMQbwu2Bx54wDYCKSkpcRJ9+bRP7r777nYbFhYWJtQ2VGXx8847L6iy+CabbFJvI5DDDz88aNwee+xRqxFIfcdkS4p2+eSWW26x+9ann34a0W8sWrTI7gMvv/yykwzLF9rIZYsttnAuuuiihNx+TVlGXUeU7pUrVyb0NmxMIxD1iuClnghCG4E0Zb/wMwLAOPn9999t9wtuVyd6r8Hb5YlOVmou7+2yQM3bdeB+++23tmVXuG5gBg0a5Hz++ec2uFA3HPHqBqa+dKirCS2fvveaN2+ePTmpxWkodS/y0EMP2W44NN19991nuwhQlzrxEO0yqmuU6667zgZVCxYssNuxf//+zj777FOrG5hDDjnEmTVrlu3uoEuXLnHrBiaa5dPFU61kBw4caJfV2+2Elive21DdRegi+fjjj9sA96yzzrLHk9vi+u9//7szZsyYoG5gMjMzbYCgblLGjx8fthuYho7JlhLt8intaqH9/PPPB20r9xyk10svvdQGh9pf33rrLWfHHXe0+0FL3ow0dvl0btVNy/z5852vv/7aOf74453c3FzbVUgibr/GLKNrr732sq1jQyXaNlR63GudAkB1hab3uh6Klk3LGNoNzGWXXWaPQXVbFK4bmPrWGepGABgnapqvAyB0ePfdd2v1l+bSHevVV1/tdOvWze7wBx54oDN37txa/ULpIq2gUnf2p512WlBQ2VIaSodORqHLKwp0evfube/iQikoVNcwmmd+fr7to27SpElhp03EZVy4cKEN9jp27Gi3n/rV04nN2w+g/Pbbb86hhx7q5OXl2T4AL7nkkqBuVBJ1+fQabp/WoGkTYRuqH7FNN93UBj7KOVAfhy7lWuq4DO3G5i9/+YudXt1RvPbaa0HfR3JMtqRolq9Pnz5ht5UCXSkqKrI3IroBUeCr6dXHWjwvrNEs34UXXhiYVttn6NChzsyZMxN6+zVmH50zZ47dbm+++WateSXaNqzrHOEuk161jKH/o3OG1odumL3XxEjWGeqWpj/xLoYGAABAy6EfQAAAAJ8hAAQAAPAZAkAAAACfIQAEAADwGQJAAAAAnyEABAAA8BkCQAAAAJ8hAASAGFq6dKk5+OCDTX5+vmnfvn2z/Mbf//53c+ONN5pEMH36dPvs3KqqqngnBUAUCAABHzn11FNNWlparWHIkCEmWe23337mwgsvNInizjvvNEuWLDGzZs0yP//8c8znP3v2bPO///3PjB492jSngQMHmrPPPjvsd//9739NTk6OWblypd13srKyzOTJk5s1PQBiiwAQ8BldsBWgeIcpU6Y062+WlZWZeNIDjyoqKlrkt+bPn2922mkns/nmm5uuXbvGfH3dc8895phjjjGtW7c2zemMM84wzzzzjCkuLq713WOPPWaGDx9uOnfuHLix+Pe//92s6QEQWwSAgM8o56Z79+5BQ4cOHQLfK0fw4YcfNkceeaRp1aqVDWReeeWVoHl8//335tBDD7VBSLdu3WyRpHKDvLly5513ns2ZU5AwePBgO17z0fxyc3PN/vvvb5544gn7e2vXrjWFhYWmbdu25vnnnw/6rZdeeskWp65fv77WsijweP/9983dd98dyM387bffzHvvvWffv/766zYY0zJ/9NFHNjgbMWKETbPSvssuu5i33noraJ59+/a1xaunn366adOmjdl0003Ngw8+GBScadl69Ohhl6NPnz7mpptuCvzvCy+8YJ588kn7+0qfaPn+8Y9/mC5duthlPOCAA2xOnuuaa66xxaha7/369bPzDaeystKun2HDhtVK8w033GBOOeUUu1xKk9b1ihUr7PJq3HbbbWe++uqroP/TOtl7771NXl6e6d27t81V1HaQk08+2QZ/Wh6vBQsW2PWrANGl9GjeWr8AkkQ9zwkGkGL0sPURI0bUO41OC7169XKefvppZ968ec7o0aOd1q1bO6tWrbLfr1mzxj5cfuzYsc5PP/3kzJw50zn44IOd/fffPzAPPdBd/3PZZZfZh9Vr+PXXX+0D6S+99FL7ecqUKc4mm2xif0/zFD2ofujQoUHpGT58uHPKKaeETevatWudPfbYw/7fkiVL7FBRURF46Px2223nvPnmm84vv/xi0z9r1ixn0qRJznfffef8/PPPzlVXXeXk5uY6v//+e2Ceffr0cTp27Ojce++9dvlvuukmJz093aZZbrvtNqd3797OBx984Pz222/Ohx9+aNeVLF++3BkyZIhz7LHH2rQofXLQQQc5w4YNc7788kv7u5dcconTqVOnwDodP368k5+fb/9X63P27Nlhl1ffabmWLl0aNN5Ns5ZN8z/nnHOctm3b2vlNnTrVmTt3rnPEEUc4W221lVNVVWX/R+tEv3nnnXfa//n444+dQYMGOaeeempgvsccc0zQdpVx48bZ5a+srAwa361bN+exxx6rY68CkGgIAAGfBYAZGRn2wu8dJkyYEJhGAYYCI9eGDRvsuNdff91+vv76651DDjkkaL6LFi2y0yjQcANABRNeV1xxhbPtttsGjfvXv/4VFAB+/vnnNn2LFy+2n5ctW+ZkZmY67733Xp3LpN+64IILgsa5AeBLL73U4DrZZpttnHvuuScomDr55JMDnxUwde3a1bn//vvt5/PPP9854IADAoFUKAXYWs8uBYgKxkpKSoKm22yzzZwHHnggEAAqOFYAWZ9p06bZ9RP626FpVvCp5b/66qsD4z799FM7Tt/JGWec4Zx11llB81FaFewWFxfbz9OnT3fS0tJs8O6uC/2Wd/9waXtfc8019aYfQOKgCBjwGRW9qoGCdwit7K/iQpeKX1VsuXz5cvtZRZfvvvuuLVZ0hy233NJ+5y0CVNGr19y5c22Rq9euu+5a6/M222xji4blqaeessWZ++yzT6OWdeeddw76vGHDBnPppZearbbayrbQVdp/+ukns3DhwjqXX0W5KiZ3l1/FulpnW2yxhS0yffPNN+tNg9aXfrdTp05B60xFqd71peVUEXF9VCSr4mylKZQ3zSridhtyhI7zbsfHH388KE0qqldrXqVN1Jq5V69ets6fvP3223ZdnXbaabV+X8XIRUVF9aYfQOLIjHcCALQsBXQDBgyodxq16vRSwOF286FgRnW+brnlllr/p3px3t9pDNWVu/fee82YMWNs4KFgI1zAE4nQNCj4mzFjhpk4caJdBwpa/va3v9VqdFHf8u+44442QFL9QtUfPPbYY81BBx1Uq+6iS+tL60X15kJ5u4mJZH2pPqWCLKU3Ozu7zjS76yvcOO92/Oc//xm2NbHqPUp6eroNeBWQq56itoduIPr371/rf1avXt1gAAsgcRAAAoiKAiA1DFDDg8zMyE8hyjFT9yVeX375Za3p1Pjg8ssvt61Kf/zxRzNy5Mh656tASI0jIvHxxx/bgEYNXNwgSI1GoqUc0eOOO84OCiDVsloBUMeOHcOuL/UNqHWlddYUaigiWi/u+8ZSujSfhm4GFICrgcmLL75opk2bZhuqhCopKbG5mYMGDWpSmgC0HIqAAZ8pLS21AYl38LbgbcioUaNssHPCCSfYAE4X/jfeeMMGCvUFYsptmjNnjrniiits/3hTp061RZDizeFTi+SjjjrKXHbZZeaQQw6xRZD1UVD1+eef20BOy1Ffh8RqgaxARkW4KgI98cQTo+7A+I477rDd5mhZtBzPPfecLSKuq9Nn5Q7uscce5ogjjrDFxUrnJ598Yv71r3/VapXbEOWwKXBT692m0nZQOtSiWetj3rx55uWXX7afvdQqWa2WzzrrLFv8rG0T6rPPPrPfaTkBJAcCQMBn9OQGFUl6h7322ivi/+/Zs6fNSVOwpwBN9czU3YsCIBUZ1kWBhIpJFYCpvtr9999vgyBR8OClLkZUzKmuWBqiYt2MjAyz9dZb2wAptD5faPCmAHPPPfe0xdiq86aAKhrqGubWW2+19QtVp1EBnXI261p2Bbf6XvUYFST/5S9/Mccff7z5/fffA/Xyoi0ij0Wny9oG6kJHQay6glHu3bhx4+z2DaXtsWbNGhswh+uiRgHxSSedZLsNApAc0tQSJN6JAOBPEyZMMJMmTTKLFi2q9aSJiy66yCxevLhWXTe/U0MQFac/++yzCZHjplxXpUe5mQryASQH6gACaDH33XefzTVTi1jlIt52221BRY5q4KAnk9x88822yJjgrzY1XFFH09EU2zcn5YBquxL8AcmFHEAALUa5esq5Uh1CtTTVE0TGjh0baEyilqbKFVRxqeqjNffjzgDArwgAAQAAfIZGIAAAAD5DAAgAAOAzBIAAAAA+QwAIAADgMwSAAAAAPkMACAAA4DMEgAAAAD5DAAgAAOAzBIAAAADGX/4fCJ0j4Wf4CTIAAAAASUVORK5CYII=", - "text/html": [ - "\n", - "
\n", - "
\n", - " Figure\n", - "
\n", - " \n", - "
\n", - " " - ], - "text/plain": [ - "Canvas(toolbar=Toolbar(toolitems=[('Home', 'Reset original view', 'home', 'home'), ('Back', 'Back to previous …" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "\n", "temperatures=[1, 10, 100]\n", @@ -119,36 +67,10 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "id": "ea1f36ac", "metadata": {}, - "outputs": [ - { - "data": { - "application/vnd.jupyter.widget-view+json": { - "model_id": "309863fb77bf4e798eecf4ceb72a9e96", - "version_major": 2, - "version_minor": 0 - }, - "image/png": "iVBORw0KGgoAAAANSUhEUgAAAoAAAAHgCAYAAAA10dzkAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjYsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvq6yFwwAAAAlwSFlzAAAPYQAAD2EBqD+naQAAZQVJREFUeJzt3Qd8FGX+x/EnPSH0DoIU8ayo2PXsDUQBy9k9sZyeimJX8BRsWLGdp2IvJ6JYsPw9Uey9IlhBRBSUXgPpZf6v75PMMrvZJLvJJlvm8369Jrs7O5l9pv/maZPmOI5jAAAA4Bvp8U4AAAAAWhYBIAAAgM8QAAIAAPgMASAAAIDPEAACAAD4DAEgAACAzxAAAgAA+AwBIAAAgM8QAAIAAPgMASAAAIDPEAACAAD4DAEgAACAzxAAAgAA+AwBIAAAgM8QAAIAAPgMASAAAIDPEAACAAD4DAEgAACAzxAAAgAA+AwBIAAAgM8QAAIAAPgMASAAAIDPEAACAAD4DAEgAACAzxAAAgAA+AwBIAAAgM8QAAIAAPgMASAAAIDPEAACAAD4DAEgAACAzxAAAgAA+AwBIAAAgM8QAAIAAPgMASAAAIDPEADGwamnnmr69u0bNC4tLc1cc801MfuN9957z85Tr/XRb2q6lStXxuy399tvPzsku4qKCnP55Zeb3r17m/T0dHPEEUeYVLBhwwbzj3/8w3Tv3t1u+wsvvDAu6Qjd5x9//HE77rfffgua7rbbbjP9+/c3GRkZZocddkjpbQMgNnSN1bU22mtirCXy9TAlA0D3QuIOubm5pmfPnmbw4MHm3//+t1m/fn2j5/3jjz/ai1boRQqp59FHH7XBx9/+9jfzxBNPmIsuuijmv3HffffZ/bUl3XjjjfY3zznnHPPf//7X/P3vfzeJ6s0337SB3l//+lfz2GOP2bS31LaJlf/9739R3dzFY59IBkVFRXY9tvQFPNlwjUKkMk0Ku+6660y/fv1MeXm5Wbp0qT1xKLfjjjvuMK+88orZbrvtGnVwXXvttTaiD83Fi9RDDz1kqqqqGvW/aDnvvPOO2WSTTcydd97ZbL+hi33nzp2D7lRbYrl23313M378eJNIFIgef/zxJicnJyityuF75JFHTHZ2dotum1gGgPfee2/EQWA89olkCQB17pVEzVFJBLG4RqWiffbZxxQXFwedR/wupQPAQw891Oy8886Bz2PHjrUXjsMPP9wMHz7c/PTTTyYvL6/F05WVldXiv4noLV++3LRv394km5KSEnuSU+BU13JtvfXWMfs9FcfqhqapJ1YV8WoITauO0dB5x3rbOI5j11s8zgd+Fav9JlXSkcq0fsvKymxpXLzofBjP309EKVkEXJ8DDjjAXH311eb33383Tz31VNB3c+bMsUVKHTt2tDuKgkflFLpULHPMMcfY9/vvv3+giNktknj55ZfNYYcdZoublYux2Wabmeuvv95UVlY2WAcwnD///NOcfvrpplu3bnZ+22yzjS36CvXHH3/YOlD5+fmma9eutjistLQ0qvWiOoDHHnusadu2renUqZO54IIL7AXRS0VwWn/6DaVHQcT999/f4Lx14I8bN87stNNOpl27djade++9t3n33XeDplORhdbnxIkTzYMPPmjXn35nl112MV9++WWt+Wp7Kc1dunSxF+4tttjC/Otf/2rUOgyXDqXvhx9+qLWdlb4999zTrif9rpbr+eefDzsv7WO77rqradWqlenQoYO9C1Wxpmgf0Pzff//9wG94czZ+/fVXu79pf9T/K9futddeC5q/W6/lmWeeMVdddZXNFdO0BQUFtdLiTrtgwQI7H/c33aIiBVVnnHGGXVfa/7fffntbvFrXNrrrrrsC20i5DnXRvqh9UtupTZs29uZL+2yo0DqAeq99rrCwMJBWd5q6to0uNEqXtrOWQcvyz3/+06xZsybot7TudSP4xhtv2ONc2/GBBx6w361du9aWFKh+oZZtwIAB5pZbbgnKtY90X9Wxrtw/d3ncoS4N7RPRpk2/rfqT2icOOeQQs2jRIhvs6rzUq1cvu9wjRowwq1evDrt+tK+q3qXWpY73F198sVaao01T6H4TyflB/6/9R5S75a4bN1e1rnpWoefahvbfhq4BohIlpWHzzTe30+g8sNdee5kZM2aYxlB6zjvvPPPSSy+ZbbfdNnCemj59eq1pv/nmG5uxofN069atzYEHHmg+++yziK9R4WgdaV46V+o6ovda15deemmta5eOxUsuuSSwrXXO1brUPhVumSZPnmyXRdNqedzj96OPPjKjR4+2v6MbOR2j2g+0L51yyin2XKlB1T9C5x3N+dcrtA7g4yFVxbxD6L6k87h+R7+nfUMlFTqWQrnnAk2n8/6HH35oEllK5wDWV9R05ZVX2pPbmWeeacfppKt6RrqAjhkzxp6Epk6dag+IF154wRx55JH24q2dVvUI9f9bbbWV/V/3VTuUDp6LL77Yviq3USc2XYxVXykay5Ytsxd890DSgfL666/bC7Tm51bcV5a2TgILFy60aVPwqXpd+u1oKJDSifKmm26yJxQtoy6aTz75ZGAaBXs6mHUBz8zMNK+++qo599xz7Yl+1KhRdc5b6X344YfNCSecYNe36mCqSE91Mr/44otAxX7X008/bafRSUHLf+utt5qjjjrKBkRu7um3335rLxL6fNZZZ9m0z58/36ZpwoQJUa3DUJpO61DzUYMJrRPvdr777rvtOjjppJPsSUvBl066//d//2dvAFy6SOgCpZOVqiMoh+Hzzz+320YXY12Azj//fLuvuIGrAhY37fo/FXtpu+pkp2BMv6uTnfZHL13QNX+dtBVwhcvNUPq1XArGdPHXidxdXu1HOun98ssvdl2p6sRzzz1nLw46KeuGwEuBmW4QtO51ctdJsS5qcKIT6IknnmiXScvvXU91UVp1QtU+ov1HBg0aVO+20T6j4/C0006z603B7n/+8x974fz444+Dct/nzp1r90n9j/ZLXcy0vvfdd197MdT4TTfd1HzyySe29GDJkiV2m0Wzr2r84sWLbXCgdDekvn0i2rTp4qv9U/NTgKe06TjXTZwugldccYXd3vfcc4/db0JvjObNm2eOO+44c/bZZ5uRI0faba79XBfygw8+uFFpCrffRHJ+0D6q84/qrWrf1zqWxlTjqSsdkVwDRMe09jvt17rIK/1fffWVmTlzZmC9REsBkYJrnU91k6Tz79FHH23P6zr2RenTOU/BnwIj7V+6adFxqxuG3XbbrcFrVF0U6Gl9ax4KsN566y1z++2322BG61wUiOn8o8Bc51BtF91AXXbZZXb7h1bH0HGu9afziao06Bw9a9Ys+532STVC0zlS1xsd5woEte9oH1JdX1Wd0HVTQbGCQlek59+G7LPPPrWOSWUM6UZamRwunWuUaaRjR9t8xYoV9pjR/+u84pZEaJ/VMaBznK4tOgcondq3FDAnJCcFPfbYY7plcL788ss6p2nXrp0zaNCgwOcDDzzQGThwoFNSUhIYV1VV5ey5557O5ptvHhj33HPP2Xm/++67teZZVFRUa9w///lPp1WrVkHzHTlypNOnT5+g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AOXmm282l112mfn+++/jnRQAAICklOaoXDWJdOjQwRQVFZmKigqTnZ1t8vLygr5fvXp1i6WloKDAtGvXzqxbt860bdu2xX4XAAA0XgHXb5Npksxdd90V7yQAAAAktaQLAEeOHBnvJAAAACS1pAsApbKy0rz00kvmp59+sp+32WYbM3z4cJORkRHvpAEAACS8pAsAf/nlFzN06FDz559/mi222MKOu+mmm0zv3r3Na6+9ZjbbbLN4JxEAACChJV0r4NGjR9sgT30Bzpw50w7qHLpfv372OwAAAKRYDuD7779vPvvsM9OxY8fAuE6dOtnuYf7617/GNW0AAADJIOlyAHNycsz69etrjd+wYYPtFgYAAAApFgAefvjh5qyzzjKff/65fTScBuUInn322bYhCAAAAFIsAPz3v/9t6wDuscceJjc31w4q+tUzgukjEAAAIAXrALZv3968/PLLtjWw2w3MVlttZQNAAAAApGAO4HXXXWcfBaeAb9iwYXbQ++LiYvsdAAAAUuxZwOrsecmSJaZr165B41etWmXHqZPolsKzBAEASD4FXL+TLwdQ8WpaWlqt8bNnzw7qGgYAAABJXgewQ4cONvDT8Je//CUoCFSun7qBUUtgAAAApEgAqBa+yv07/fTTzbXXXmuzbl3q/69v3762ZTAAAABSJAAcOXKkfdUj39TtS2Zm0iQdAAAgoSRdHcDCwkLz9ttv1xr/xhtvmNdffz0uaQIAAEgmSRcAjhkzJmxLXxUP6zsAAACkWAA4b948s/XWW9cav+WWW9rOoQEAAJBiAaAaf/z666+1xiv4y8/Pj2pef/75pzn55JNNp06dTF5enhk4cKD56quvYphaAACAxJN0AeCIESPMhRdeaObPnx8U/F1yySVm+PDhEc9nzZo1tjFJVlaWrTv4448/mttvv912NwMAAJDKku5JIOq1e8iQITanrlevXnbcH3/8Yfbee2/z4osv2mcFR0L1BT/++GPz4YcfNjot9CQOAEDyKeD6nXwBoCjJM2bMsE//UNHtdtttZ/bZZ5+o5qF6hIMHD7bB4/vvv2822WQTc+6555ozzzwz4nmwAwEAkHwKuH4nZwAYC7m5ufb14osvNsccc4z58ssvzQUXXGAmTZoU6HMwVGlpqR28O1Dv3r19vQMBAJBsCggAkzMAVF+AyrVbuHChKSsrC/pu9OjR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hgTmvckmmzTLMvlVmpNk3WvrLuKKK64wU6dONatWrar1vVoftZSCggK7k65bt47AEwBSlDIbFixYYPr162efbIHk324FXL+T70kgl19+uXnnnXfM/fffb3JycszDDz9s+wNU9vCTTz4Z7+QBAAAkvKQrAn711VdtoKd6AapPsPfee9t6B3369DGTJ0+m7yAAAIBUywFUNy9uZ9DKtnW7fdlrr73MBx98EOfUAQAAJL6kCwAV/KlMX7bccktbF9DNGVTLIQAAAKRYAKhi39mzZ9v3eqi0nhuoyp0XXXSRueyyy+KdPAAAgISXdHUAFei59HBpPTtw5syZth7gdtttF9e0AQAAJIOkCwBD9e3b1w4AAABI0SJg0SNlDj/8cLPZZpvZQe/16BgAAACkYAB433332Z7B9dDoCy64wA5qDTx06FBbHxAAAAApVgR84403mjvvvDPoodKjR482f/3rX+13o0aNimv6AAAAEl3S5QCuXbvW5gCGOuSQQ+wjXQAAgDFpaWn1Dtdcc03U8/zhhx/M0Ucfbeveax533XVX2OlUIqdp1EvHbrvtZr744otaj2lThk2nTp1M69at7TyXLVtW72/rARAXXnhh0Li7777bPhXsmWeeiXpZ/C7pAsDhw4ebadOm1Rr/8ssv27qAAADAmCVLlgQGBWqqLuUdd+mll0Y9z6KiItsf780332y6d+8edppnn33WXHzxxWb8+PG2l47tt9/eDB482CxfvjyoRw/13/vcc8+Z999/3yxevNgcddRRUaVF87/yyivt9f/444+Peln8LimKgP/9738H3m+99dZmwoQJ5r333jN77LGHHffZZ5+Zjz/+2FxyySWN/g3tzGPHjrV1Cuu6owEAIFl4A7R27drZHLu6grZI7bLLLnZw++IN54477jBnnnmm7bdXJk2aZF577TXz6KOP2v9Rad0jjzxinn76aXPAAQfYaR577DGz1VZb2ev57rvvXm8aHMexVb+eeuopM2PGDLPnnns2aZn8KikCQNX58+rQoYP58ccf7eDSU0C0c1111VVRz//LL780DzzwAP0IAgAioiCkuKI4Lr+dl5lng7lYURFsfU4++WQbxEWirKzMfP311zZDxZWenm777f3000/tZ31fXl5ux7n0ZK9NN93UTlNfAFhRUWHT884779icQ67bKR4Auo9+aw4bNmwwJ510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zeuvv+6kpaU5f/75p9NSYpWOHXbYwTn99NODxkVy0kjkZVQAeMEFF9R7gkxPTw+6UN1///32QlZaWuok2zacOnWqPTmXl5fHfRvuuuuuzqhRowKfKysrnZ49ezo33XRT2OmPPfZY57DDDgsat9tuuzn//Oc/Iz4mE3n5Qunmo02bNs4TTzwRFDyMGDHCSQTRLl9D59ZE236x2IZ33nmn3YYbNmxIyG3oFcl54PLLL3e22WaboHHHHXecM3jw4JitMz+jCDhJLFiwwCxdutQWUXifY6js7k8//dR+1quK6nbeeefANJpezyz+/PPPWyytsUjH119/bWbNmmWLHUONGjXKdO7c2ey6667m0UcftcU4La0pyzh58mSb/m233daMHTvWFBUVBc1XRY3dunULjBs8eLB9KPoPP/xgWkqs9iUV/6oIOTMzM67bsKyszO5T3uNHy6LP7vETSuO907vbwp0+kmOypTRm+UJpPywvLzcdO3asVQSnqggq2j/nnHPMqlWrTEtr7PKpykKfPn1M7969zYgRI4KOoUTafrHaho888og5/vjjTX5+fsJtw8Zo6BiMxTrzs+CzMhKWTlTiDQzcz+53etVB7qULr07o7jQtldampkMnsq222srWk/S67rrrzAEHHGDrx7355pvm3HPPtSd51TVrSY1dxhNPPNFekFQH5ttvvzVXXHGFmTt3rnnxxRcD8w23jd3vkmkbrly50lx//fXmrLPOivs2VFoqKyvDrts5c+aE/Z+6toX3eHPH1TVNS2nM8oXSvqj90nsxVV2xo446yvTr18/Mnz/fXHnllebQQw+1F9eMjAyTyMunYEc3F9ttt529EZk4caI9nygI7NWrV0Jtv1hsQ9V7+/777+250ytRtmFj1HUM6oa4uLjYrFmzpsn7vZ8RAMbQmDFjzC233FLvND/99JOtVJ7Ky9dUOrCffvppc/XVV9f6zjtu0KBBprCw0Nx2220xCx6aexm9wZBy+lT5/MADD7Qn5s0228ykyjbUCVoV0bfeemtzzTXXtOg2RPRuvvlm2xBHOUXehhLKTfLurwqmtJ9qOu23iWyPPfawg0vBn24qH3jgAXtjkmoU+GkbKVfdK5m3IZoXAWAMXXLJJbbFVX369+/fqHl3797dvi5btswGDS593mGHHQLTLF++POj/1HpUrTPd/2+J5WtqOp5//nlbHHXKKac0OK2Ka3QyLy0tjcnzIltqGb3pl19++cWelPW/oS3YtI0lWbbh+vXrba5DmzZtzLRp00xWVlaLbsNwVNys3A53Xbr0ua7l0fj6po/kmGwpjVk+l3LGFAC+9dZbNjhoaN/Qb2l/bcngoSnL59J+qBsOpT3Rtl9Tl1E3UQrglbvekHhtw8ao6xhUtRK12tb6aup+4WvxroTod9E2Apk4cWJgnFqPhmsE8tVXXwWmeeONN+LWCKSx6VBDidCWo3W54YYbnA4dOjgtLVbr+qOPPrLzUQtEbyMQbwu2Bx54wDYCKSkpcRJ9+bRP7r777nYbFhYWJtQ2VGXx8847L6iy+CabbFJvI5DDDz88aNwee+xRqxFIfcdkS4p2+eSWW26x+9ann34a0W8sWrTI7gMvv/yykwzLF9rIZYsttnAuuuiihNx+TVlGXUeU7pUrVyb0NmxMIxD1iuClnghCG4E0Zb/wMwLAOPn9999t9wtuVyd6r8Hb5YlOVmou7+2yQM3bdeB+++23tmVXuG5gBg0a5Hz++ec2uFA3HPHqBqa+dKirCS2fvveaN2+ePTmpxWkodS/y0EMP2W44NN19991nuwhQlzrxEO0yqmuU6667zgZVCxYssNuxf//+zj777FOrG5hDDjnEmTVrlu3uoEuXLnHrBiaa5dPFU61kBw4caJfV2+2Elive21DdRegi+fjjj9sA96yzzrLHk9vi+u9//7szZsyYoG5gMjMzbYCgblLGjx8fthuYho7JlhLt8intaqH9/PPPB20r9xyk10svvdQGh9pf33rrLWfHHXe0+0FL3ow0dvl0btVNy/z5852vv/7aOf74453c3FzbVUgibr/GLKNrr732sq1jQyXaNlR63GudAkB1hab3uh6Klk3LGNoNzGWXXWaPQXVbFK4bmPrWGepGABgnapqvAyB0ePfdd2v1l+bSHevVV1/tdOvWze7wBx54oDN37txa/ULpIq2gUnf2p512WlBQ2VIaSodORqHLKwp0evfube/iQikoVNcwmmd+fr7to27SpElhp03EZVy4cKEN9jp27Gi3n/rV04nN2w+g/Pbbb86hhx7q5OXl2T4AL7nkkqBuVBJ1+fQabp/WoGkTYRuqH7FNN93UBj7KOVAfhy7lWuq4DO3G5i9/+YudXt1RvPbaa0HfR3JMtqRolq9Pnz5ht5UCXSkqKrI3IroBUeCr6dXHWjwvrNEs34UXXhiYVttn6NChzsyZMxN6+zVmH50zZ47dbm+++WateSXaNqzrHOEuk161jKH/o3OG1odumL3XxEjWGeqWpj/xLoYGAABAy6EfQAAAAJ8hAAQAAPAZAkAAAACfIQAEAADwGQJAAAAAnyEABAAA8BkCQAAAAJ8hAASAGFq6dKk5+OCDTX5+vmnfvn2z/Mbf//53c+ONN5pEMH36dPvs3KqqqngnBUAUCAABHzn11FNNWlparWHIkCEmWe23337mwgsvNInizjvvNEuWLDGzZs0yP//8c8znP3v2bPO///3PjB492jSngQMHmrPPPjvsd//9739NTk6OWblypd13srKyzOTJk5s1PQBiiwAQ8BldsBWgeIcpU6Y062+WlZWZeNIDjyoqKlrkt+bPn2922mkns/nmm5uuXbvGfH3dc8895phjjjGtW7c2zemMM84wzzzzjCkuLq713WOPPWaGDx9uOnfuHLix+Pe//92s6QEQWwSAgM8o56Z79+5BQ4cOHQLfK0fw4YcfNkceeaRp1aqVDWReeeWVoHl8//335tBDD7VBSLdu3WyRpHKDvLly5513ns2ZU5AwePBgO17z0fxyc3PN/vvvb5544gn7e2vXrjWFhYWmbdu25vnnnw/6rZdeeskWp65fv77WsijweP/9983dd98dyM387bffzHvvvWffv/766zYY0zJ/9NFHNjgbMWKETbPSvssuu5i33noraJ59+/a1xaunn366adOmjdl0003Ngw8+GBScadl69Ohhl6NPnz7mpptuCvzvCy+8YJ588kn7+0qfaPn+8Y9/mC5duthlPOCAA2xOnuuaa66xxaha7/369bPzDaeystKun2HDhtVK8w033GBOOeUUu1xKk9b1ihUr7PJq3HbbbWe++uqroP/TOtl7771NXl6e6d27t81V1HaQk08+2QZ/Wh6vBQsW2PWrANGl9GjeWr8AkkQ9zwkGkGL0sPURI0bUO41OC7169XKefvppZ968ec7o0aOd1q1bO6tWrbLfr1mzxj5cfuzYsc5PP/3kzJw50zn44IOd/fffPzAPPdBd/3PZZZfZh9Vr+PXXX+0D6S+99FL7ecqUKc4mm2xif0/zFD2ofujQoUHpGT58uHPKKaeETevatWudPfbYw/7fkiVL7FBRURF46Px2223nvPnmm84vv/xi0z9r1ixn0qRJznfffef8/PPPzlVXXeXk5uY6v//+e2Ceffr0cTp27Ojce++9dvlvuukmJz093aZZbrvtNqd3797OBx984Pz222/Ohx9+aNeVLF++3BkyZIhz7LHH2rQofXLQQQc5w4YNc7788kv7u5dcconTqVOnwDodP368k5+fb/9X63P27Nlhl1ffabmWLl0aNN5Ns5ZN8z/nnHOctm3b2vlNnTrVmTt3rnPEEUc4W221lVNVVWX/R+tEv3nnnXfa//n444+dQYMGOaeeempgvsccc0zQdpVx48bZ5a+srAwa361bN+exxx6rY68CkGgIAAGfBYAZGRn2wu8dJkyYEJhGAYYCI9eGDRvsuNdff91+vv76651DDjkkaL6LFi2y0yjQcANABRNeV1xxhbPtttsGjfvXv/4VFAB+/vnnNn2LFy+2n5ctW+ZkZmY67733Xp3LpN+64IILgsa5AeBLL73U4DrZZpttnHvuuScomDr55JMDnxUwde3a1bn//vvt5/PPP9854IADAoFUKAXYWs8uBYgKxkpKSoKm22yzzZwHHnggEAAqOFYAWZ9p06bZ9RP626FpVvCp5b/66qsD4z799FM7Tt/JGWec4Zx11llB81FaFewWFxfbz9OnT3fS0tJs8O6uC/2Wd/9waXtfc8019aYfQOKgCBjwGRW9qoGCdwit7K/iQpeKX1VsuXz5cvtZRZfvvvuuLVZ0hy233NJ+5y0CVNGr19y5c22Rq9euu+5a6/M222xji4blqaeessWZ++yzT6OWdeeddw76vGHDBnPppZearbbayrbQVdp/+ukns3DhwjqXX0W5KiZ3l1/FulpnW2yxhS0yffPNN+tNg9aXfrdTp05B60xFqd71peVUEXF9VCSr4mylKZQ3zSridhtyhI7zbsfHH388KE0qqldrXqVN1Jq5V69ets6fvP3223ZdnXbaabV+X8XIRUVF9aYfQOLIjHcCALQsBXQDBgyodxq16vRSwOF286FgRnW+brnlllr/p3px3t9pDNWVu/fee82YMWNs4KFgI1zAE4nQNCj4mzFjhpk4caJdBwpa/va3v9VqdFHf8u+44442QFL9QtUfPPbYY81BBx1Uq+6iS+tL60X15kJ5u4mJZH2pPqWCLKU3Ozu7zjS76yvcOO92/Oc//xm2NbHqPUp6eroNeBWQq56itoduIPr371/rf1avXt1gAAsgcRAAAoiKAiA1DFDDg8zMyE8hyjFT9yVeX375Za3p1Pjg8ssvt61Kf/zxRzNy5Mh656tASI0jIvHxxx/bgEYNXNwgSI1GoqUc0eOOO84OCiDVsloBUMeOHcOuL/UNqHWlddYUaigiWi/u+8ZSujSfhm4GFICrgcmLL75opk2bZhuqhCopKbG5mYMGDWpSmgC0HIqAAZ8pLS21AYl38LbgbcioUaNssHPCCSfYAE4X/jfeeMMGCvUFYsptmjNnjrniiits/3hTp061RZDizeFTi+SjjjrKXHbZZeaQQw6xRZD1UVD1+eef20BOy1Ffh8RqgaxARkW4KgI98cQTo+7A+I477rDd5mhZtBzPPfecLSKuq9Nn5Q7uscce5ogjjrDFxUrnJ598Yv71r3/VapXbEOWwKXBT692m0nZQOtSiWetj3rx55uWXX7afvdQqWa2WzzrrLFv8rG0T6rPPPrPfaTkBJAcCQMBn9OQGFUl6h7322ivi/+/Zs6fNSVOwpwBN9czU3YsCIBUZ1kWBhIpJFYCpvtr9999vgyBR8OClLkZUzKmuWBqiYt2MjAyz9dZb2wAptD5faPCmAHPPPfe0xdiq86aAKhrqGubWW2+19QtVp1EBnXI261p2Bbf6XvUYFST/5S9/Mccff7z5/fffA/Xyoi0ij0Wny9oG6kJHQay6glHu3bhx4+z2DaXtsWbNGhswh+uiRgHxSSedZLsNApAc0tQSJN6JAOBPEyZMMJMmTTKLFi2q9aSJiy66yCxevLhWXTe/U0MQFac/++yzCZHjplxXpUe5mQryASQH6gACaDH33XefzTVTi1jlIt52221BRY5q4KAnk9x88822yJjgrzY1XFFH09EU2zcn5YBquxL8AcmFHEAALUa5esq5Uh1CtTTVE0TGjh0baEyilqbKFVRxqeqjNffjzgDArwgAAQAAfIZGIAAAAD5DAAgAAOAzBIAAAAA+QwAIAADgMwSAAAAAPkMACAAA4DMEgAAAAD5DAAgAAOAzBIAAAADGX/4fCJ0j4Wf4CTIAAAAASUVORK5CYII=", - "text/html": [ - "\n", - "
\n", - "
\n", - " Figure\n", - "
\n", - " \n", - "
\n", - " " - ], - "text/plain": [ - "Canvas(toolbar=Toolbar(toolitems=[('Home', 'Reset original view', 'home', 'home'), ('Back', 'Back to previous …" - ] - }, - "metadata": {}, - "output_type": "display_data" - } - ], + "outputs": [], "source": [ "import scipp as sc\n", "temperatures=[1, 10, 100]\n", diff --git a/examples/sample_model.ipynb b/examples/sample_model.ipynb new file mode 100644 index 0000000..1e7e9d9 --- /dev/null +++ b/examples/sample_model.ipynb @@ -0,0 +1,94 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "64deaa41", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "from easydynamics.sample_model import Gaussian\n", + "from easydynamics.sample_model import Lorentzian\n", + "from easydynamics.sample_model import DampedHarmonicOscillator\n", + "from easydynamics.sample_model import Polynomial\n", + "\n", + "from easydynamics.sample_model import SampleModel\n", + "\n", + "\n", + "import matplotlib.pyplot as plt\n", + "\n", + "\n", + "%matplotlib widget" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "784d9e82", + "metadata": {}, + "outputs": [], + "source": [ + "sample_model=SampleModel(name='sample_model')\n", + "\n", + "# Creating components\n", + "gaussian=Gaussian(name='Gaussian',width=0.5,area=1)\n", + "dho = DampedHarmonicOscillator(name='DHO',center=1.0,width=0.3,area=2.0)\n", + "lorentzian = Lorentzian(name='Lorentzian',center=-1.0,width=0.2,area=1.0)\n", + "polynomial = Polynomial(name='Polynomial',coefficients=[0.1, 0, 0.5]) # y=0.1+0.5*x^2\n", + "\n", + "sample_model.add_component(gaussian)\n", + "sample_model.add_component(dho)\n", + "sample_model.add_component(lorentzian)\n", + "sample_model.add_component(polynomial)\n", + "\n", + "\n", + "x=np.linspace(-2, 2, 100)\n", + "\n", + "plt.figure()\n", + "y=sample_model.evaluate(x)\n", + "plt.plot(x, y, label='Sample Model')\n", + "\n", + "for component in list(sample_model):\n", + " y = component.evaluate(x)\n", + " plt.plot(x, y, label=component.name)\n", + "\n", + "plt.legend()\n", + "plt.show()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d35179d8", + "metadata": {}, + "outputs": [], + "source": [ + "# sample_model=SampleModel(name='sample_model')\n", + "sample_model.components" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "newdynamics", + "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.11.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/easydynamics/convolution/__init__.py b/src/easydynamics/convolution/__init__.py new file mode 100644 index 0000000..4158f51 --- /dev/null +++ b/src/easydynamics/convolution/__init__.py @@ -0,0 +1,3 @@ +from .convolution import Convolution + +__all__ = ["Convolution"] diff --git a/src/easydynamics/convolution/analytical_convolution.py b/src/easydynamics/convolution/analytical_convolution.py new file mode 100644 index 0000000..f05913f --- /dev/null +++ b/src/easydynamics/convolution/analytical_convolution.py @@ -0,0 +1,460 @@ +from typing import Optional + +import numpy as np +import scipp as sc +from scipy.special import voigt_profile + +from easydynamics.convolution.convolution_base import ConvolutionBase +from easydynamics.sample_model import ( + DeltaFunction, + Gaussian, + Lorentzian, + SampleModel, + Voigt, +) +from easydynamics.sample_model.components.model_component import ModelComponent + +Numerical = float | int + + +class AnalyticalConvolution(ConvolutionBase): + """Analytical convolution of a SampleModel with a ResolutionModel. + Possible analytical convolutions are any combination of delta functions, Gaussians, Lorentzians and Voigt profiles. + Args: + energy : np.ndarray or scipp.Variable + 1D array of energy values where the convolution is evaluated. + sample_model : SampleModel or ModelComponent + The sample model to be convolved. + resolution_model : SampleModel or ModelComponent + The resolution model to convolve with. + """ + + # Mapping of supported component type pairs to convolution methods. + # Delta functions are handled separately. + _CONVOLUTIONS = { + ("Gaussian", "Gaussian"): "_convolute_gaussian_gaussian", + ("Gaussian", "Lorentzian"): "_convolute_gaussian_lorentzian", + ("Gaussian", "Voigt"): "_convolute_gaussian_voigt", + ("Lorentzian", "Lorentzian"): "_convolute_lorentzian_lorentzian", + ("Lorentzian", "Voigt"): "_convolute_lorentzian_voigt", + ("Voigt", "Voigt"): "_convolute_voigt_voigt", + } + + def __init__( + self, + energy: np.ndarray | sc.Variable, + energy_unit: str | sc.Unit = "meV", + sample_model: Optional[SampleModel] = None, + resolution_model: Optional[SampleModel] = None, + ): + super().__init__( + energy=energy, + sample_model=sample_model, + resolution_model=resolution_model, + energy_unit=energy_unit, + ) + + def convolution( + self, + ) -> np.ndarray: + """ + Convolve sample with resolution analytically if possible. Accepts SampleModel or single ModelComponent for each. + Possible analytical convolutions are any combination of delta functions, Gaussians, Lorentzians and Voigt profiles. + + Returns: + np.ndarray + The convolution of the sample_model and resolution_model values evaluated at energy. + + Raises: + ValueError + If resolution_model contains delta functions. + ValueError + If component pair cannot be handled analytically. + + """ + + # prepare list of components + if isinstance(self.sample_model, SampleModel): + sample_components = self.sample_model.components + else: + sample_components = [self.sample_model] + + if isinstance(self.resolution_model, SampleModel): + resolution_components = self.resolution_model.components + else: + resolution_components = [self.resolution_model] + + total = np.zeros_like(self.energy.values, dtype=float) + + for sample_component in sample_components: + # Go through resolution components, adding analytical contributions + for resolution_component in resolution_components: + contrib = self._convolute_analytic_pair( + sample_component=sample_component, + resolution_component=resolution_component, + ) + total += contrib + + return total + + def _convolute_analytic_pair( + self, + sample_component: ModelComponent, + resolution_component: ModelComponent, + ) -> np.ndarray: + """ + Analytic convolution for component pair (sample_component, resolution_component). + The convolution of two gaussian components results in another gaussian component with width sqrt(w1^2 + w2^2). + The convolution of two lorentzian components results in another lorentzian component with width w1 + w2. + The convolution of a gaussian and a lorentzian results in a voigt profile. + The convolution of a gaussian and a voigt profile results in another voigt profile, with the lorentzian width unchanged and the gaussian widths summed in quadrature. + The convolution of a lorentzian and a voigt profile results in another voigt profile, with the gaussian width unchanged and the lorentzian widths summed. + The convolution of two voigt profiles results in another voigt profile, with the gaussian widths summed in quadrature and the lorentzian widths summed. + The convolution of a delta function with any component or SampleModel results in the same component or SampleModel shifted by the delta center. + All areas are multiplied. + + + Args: + sample_component : ModelComponent + The sample component to be convolved. + resolution_component : ModelComponent + The resolution component to convolve with. + + Returns: + np.ndarray: The convolution result + + Raises: + ValueError: If the component pair cannot be handled analytically. + """ + + if isinstance(resolution_component, DeltaFunction): + raise ValueError( + "Analytical convolution with a delta function in the resolution model is not supported." + ) + + # Delta function + anything --> anything, shifted by delta center with area A1 * A2 + if isinstance(sample_component, DeltaFunction): + return self._convolute_delta_any( + sample_component, + resolution_component, + ) + + pair = (type(sample_component).__name__, type(resolution_component).__name__) + swapped = False + + if pair not in self._CONVOLUTIONS: + # Try reversing the pair + pair = ( + type(resolution_component).__name__, + type(sample_component).__name__, + ) + swapped = True + + func_name = self._CONVOLUTIONS.get(pair) + + if func_name is None: + raise ValueError( + f"Analytical convolution not supported for component pair: " + f"{type(sample_component).__name__}, {type(resolution_component).__name__}" + ) + + # Call the corresponding method + if swapped: + return getattr(self, func_name)(resolution_component, sample_component) + else: + return getattr(self, func_name)(sample_component, resolution_component) + + def _convolute_delta_any( + self, + sample_component: DeltaFunction, + resolution_model: SampleModel | ModelComponent, + ): + """ + Convolution of delta function with any component or SampleModel results in the same component or SampleModel shifted by the delta center. + The areas are multiplied. + + Args: + sample_component : DeltaFunction + The sample component to be convolved. + resolution_model : SampleModel | ModelComponent + The resolution model to convolve with. + Returns: + np.ndarray + The evaluated convolution values at self.energy. + """ + return sample_component.area.value * resolution_model.evaluate( + self.energy.values - sample_component.center.value + ) + + def _convolute_gaussian_gaussian( + self, + sample_component: Gaussian, + resolution_component: Gaussian, + ) -> np.ndarray: + """ + Convolution of two gaussian components results in another gaussian component with width sqrt(w1^2 + w2^2). + The areas are multiplied. + + Args: + sample_component : Gaussian + The sample Gaussian component to be convolved. + resolution_component : Gaussian + The resolution Gaussian component to convolve with. + + Returns: + np.ndarray + + The evaluated convolution values at self.energy. + """ + + width = np.sqrt( + sample_component.width.value**2 + resolution_component.width.value**2 + ) + + area = sample_component.area.value * resolution_component.area.value + + center = sample_component.center.value + resolution_component.center.value + + return self._gaussian_eval(area=area, center=center, width=width) + + def _convolute_gaussian_lorentzian( + self, + sample_component: Gaussian, + resolution_component: Lorentzian, + ) -> np.ndarray: + """ + Convolution of a Gaussian and a Lorentzian results in a Voigt profile. + The areas are multiplied. + + Args: + sample_component : Gaussian + The sample Gaussian component to be convolved. + resolution_component : Lorentzian + The resolution Lorentzian component to convolve with. + + Returns: + np.ndarray + The evaluated convolution values at self.energy. + """ + center = sample_component.center.value + resolution_component.center.value + area = sample_component.area.value * resolution_component.area.value + + return self._voigt_eval( + area=area, + center=center, + gaussian_width=sample_component.width.value, + lorentzian_width=resolution_component.width.value, + ) + + def _convolute_gaussian_voigt( + self, + sample_component: Gaussian, + resolution_component: Voigt, + ) -> np.ndarray: + """ + Convolution of a Gaussian and a Voigt profile results in another Voigt profile. + The Lorentzian width remains unchanged, while the Gaussian widths are summed in quadrature. + The areas are multiplied. + + Args: + sample_component : Gaussian + The sample Gaussian component to be convolved. + resolution_component : Voigt + The resolution Voigt component to convolve with. + + Returns: + np.ndarray + The evaluated convolution values at self.energy. + """ + area = sample_component.area.value * resolution_component.area.value + + center = sample_component.center.value + resolution_component.center.value + + gaussian_width = np.sqrt( + sample_component.width.value**2 + + resolution_component.gaussian_width.value**2 + ) + + lorentzian_width = resolution_component.lorentzian_width.value + + return self._voigt_eval( + area=area, + center=center, + gaussian_width=gaussian_width, + lorentzian_width=lorentzian_width, + ) + + def _convolute_lorentzian_lorentzian( + self, + sample_component: Lorentzian, + resolution_component: Lorentzian, + ) -> np.ndarray: + """ + Convolution of two Lorentzian components results in another Lorentzian component with width w1 + w2. + The areas are multiplied. + + Args: + sample_component : Lorentzian + The sample Lorentzian component to be convolved. + resolution_component : Lorentzian + The resolution Lorentzian component to convolve with. + Returns: + np.ndarray + The evaluated convolution values at self.energy. + """ + area = sample_component.area.value * resolution_component.area.value + + center = sample_component.center.value + resolution_component.center.value + + width = sample_component.width.value + resolution_component.width.value + + return self._lorentzian_eval(area=area, center=center, width=width) + + def _convolute_lorentzian_voigt( + self, + sample_component: Lorentzian, + resolution_component: Voigt, + ) -> np.ndarray: + """ + Convolution of a Lorentzian and a Voigt profile results in another Voigt profile. + The Gaussian width remains unchanged, while the Lorentzian widths are summed. + The areas are multiplied. + Args: + sample_component : Lorentzian + The sample Lorentzian component to be convolved. + resolution_component : Voigt + The resolution Voigt component to convolve with. + Returns: + np.ndarray + The evaluated convolution values at self.energy. + """ + area = sample_component.area.value * resolution_component.area.value + + center = sample_component.center.value + resolution_component.center.value + + gaussian_width = resolution_component.gaussian_width.value + + lorentzian_width = ( + sample_component.width.value + resolution_component.lorentzian_width.value + ) + + return self._voigt_eval( + area=area, + center=center, + gaussian_width=gaussian_width, + lorentzian_width=lorentzian_width, + ) + + def _convolute_voigt_voigt( + self, + sample_component: Voigt, + resolution_component: Voigt, + ) -> np.ndarray: + """ + Convolution of two Voigt profiles results in another Voigt profile. + The Gaussian widths are summed in quadrature, while the Lorentzian widths are summed. + The areas are multiplied. + Args: + sample_component : Voigt + The sample Voigt component to be convolved. + resolution_component : Voigt + The resolution Voigt component to convolve with. + Returns: + np.ndarray + The evaluated convolution values at self.energy. + """ + area = sample_component.area.value * resolution_component.area.value + + center = sample_component.center.value + resolution_component.center.value + + gaussian_width = np.sqrt( + sample_component.gaussian_width.value**2 + + resolution_component.gaussian_width.value**2 + ) + + lorentzian_width = ( + sample_component.lorentzian_width.value + + resolution_component.lorentzian_width.value + ) + return self._voigt_eval( + area=area, + center=center, + gaussian_width=gaussian_width, + lorentzian_width=lorentzian_width, + ) + + def _gaussian_eval( + self, + area: float, + center: float, + width: float, + ) -> np.ndarray: + """ + Evaluate a Gaussian function. y = (area / (sqrt(2pi) * width)) * exp(-0.5 * ((x - center) / width)^2) + All checks are handled in the calling function. + + Args: + area : float + The area under the Gaussian curve. + center : float + The center of the Gaussian. + width : float + The width (sigma) of the Gaussian. + Returns: + np.ndarray + The evaluated Gaussian values at self.energy. + """ + + normalization = 1 / (np.sqrt(2 * np.pi) * width) + exponent = -0.5 * ((self.energy.values - center) / width) ** 2 + + return area * normalization * np.exp(exponent) + + def _lorentzian_eval(self, area: float, center: float, width: float) -> np.ndarray: + """ + Evaluate a Lorentzian function. y = (area * width / pi) / ((x - center)^2 + width^2). + All checks are handled in the calling function. + + Args: + area : float + The area under the Lorentzian. + center : float + The center of the Lorentzian. + width : float + The width (HWHM) of the Lorentzian. + Returns: + np.ndarray + The evaluated Lorentzian values at self.energy. + """ + + normalization = width / np.pi + denominator = (self.energy.values - center) ** 2 + width**2 + + return area * normalization / denominator + + def _voigt_eval( + self, + area: float, + center: float, + gaussian_width: float, + lorentzian_width: float, + ) -> np.ndarray: + """ + Evaluate a Voigt profile function using scipy's voigt_profile. + Args: + area : float + The area under the Voigt profile. + center : float + The center of the Voigt profile. + gaussian_width : float + The Gaussian width (sigma) of the Voigt profile. + lorentzian_width : float + The Lorentzian width (HWHM) of the Voigt profile. + Returns: + np.ndarray + The evaluated Voigt profile values at self.energy. + """ + + return area * voigt_profile( + self.energy.values - center, gaussian_width, lorentzian_width + ) diff --git a/src/easydynamics/convolution/convolution.py b/src/easydynamics/convolution/convolution.py new file mode 100644 index 0000000..d3b35a4 --- /dev/null +++ b/src/easydynamics/convolution/convolution.py @@ -0,0 +1,254 @@ +import numpy as np +import scipp as sc +from easyscience.variable import Parameter + +from easydynamics.convolution.analytical_convolution import AnalyticalConvolution +from easydynamics.convolution.numerical_convolution import NumericalConvolution +from easydynamics.convolution.numerical_convolution_base import NumericalConvolutionBase +from easydynamics.sample_model import ( + DeltaFunction, + Gaussian, + Lorentzian, + SampleModel, + Voigt, +) +from easydynamics.sample_model.components.model_component import ModelComponent + +Numerical = float | int + + +class Convolution(NumericalConvolutionBase): + """ + Convolution class that combines analytical and numerical convolution methods to efficiently perform convolutions + of SampleModels with ResolutionModels. + Supports analytical convolution for pairs of analytical model components (DeltaFunction, Gaussian, Lorentzian, Voigt), + while using numerical convolution for other components. + If temperature is provided, detailed balance correction is applied to the sample model. In this case, all convolutions + are handled numerically. + Includes optional upsampling and extended range to improve accuracy of the numerical convolutions. Also warns about + numerical instabilities if peaks are very wide or very narrow. + + Args: + energy : np.ndarray or scipp.Variable + 1D array of energy values where the convolution is evaluated. + sample_model : SampleModel or ModelComponent + The sample model to be convolved. + resolution_model : SampleModel or ModelComponent + The resolution model to convolve with. + upsample_factor : int, optional + The factor by which to upsample the input data before convolution. Default is 5. + extension_factor : float, optional + The factor by which to extend the input data range before convolution. Default is 0.2. + temperature : Parameter, float, or None, optional + The temperature to use for detailed balance correction. Default is None. + temperature_unit : str or sc.Unit, optional + The unit of the temperature parameter. Default is 'K'. + energy_unit : str or sc.Unit, optional + The unit of the energy. Default is 'meV'. + normalize_detailed_balance : bool, optional + Whether to normalize the detailed balance correction. Default is True. + """ + + # When these attributes are changed, the convolution plan needs to be rebuilt + _invalidate_plan_on_change = { + "energy", + "_energy", + "_energy_grid", + "_sample_model", + "_resolution_model", + "_temperature", + "_upsample_factor", + "_extension_factor", + "_energy_unit", + "_normalize_detailed_balance", + } + + def __init__( + self, + energy: np.ndarray | sc.Variable, + sample_model: SampleModel | ModelComponent, + resolution_model: SampleModel | ModelComponent, + upsample_factor: Numerical = 5, + extension_factor: Numerical = 0.2, + temperature: Parameter | Numerical | None = None, + temperature_unit: str | sc.Unit = "K", + energy_unit: str | sc.Unit = "meV", + normalize_detailed_balance: bool = True, + ): + self._convolution_plan_is_valid = False + self._reactions_enabled = False + super().__init__( + energy=energy, + sample_model=sample_model, + resolution_model=resolution_model, + upsample_factor=upsample_factor, + extension_factor=extension_factor, + temperature=temperature, + temperature_unit=temperature_unit, + energy_unit=energy_unit, + normalize_detailed_balance=normalize_detailed_balance, + ) + + self._reactions_enabled = True + # Separate sample model components into pairs that can be handled analytically, delta functions, and the rest + # Also initialize analytical and numerical convolvers based on sample model component + self._build_convolution_plan() + + def convolution( + self, + ) -> np.ndarray: + """ + Perform convolution using analytical convolutions where possible, and numerical convolutions for the remaining components. + Returns: + np.ndarray + The convolved values evaluated at energy. + """ + if not self._convolution_plan_is_valid: + self._build_convolution_plan() + total = np.zeros_like(self.energy.values, dtype=float) + + # Analytical convolution + if self._analytical_convolver is not None: + total += self._analytical_convolver.convolution() + + # Numerical convolution + if self._numerical_convolver is not None: + total += self._numerical_convolver.convolution() + + # Delta function components + if self._delta_sample_model.components: + total += self._convolve_delta_functions() + + return total + + def _convolve_delta_functions(self) -> np.ndarray: + "Convolve delta function components of the sample model with the resolution model." + "No detailed balance correction is applied to delta functions." + return sum( + delta.area.value + * self._resolution_model.evaluate(self.energy.values - delta.center.value) + for delta in self._delta_sample_model.components + ) + + def _check_if_pair_is_analytic( + self, + sample_component: ModelComponent, + resolution_component: ModelComponent, + ) -> bool: + """ + Check if the convolution of the given component pair can be handled analytically. + + Args: + sample_component : ModelComponent + The sample component to be convolved. + resolution_component : ModelComponent + The resolution component to convolve with. + Returns: + bool + True if the component pair can be handled analytically, False otherwise. + """ + + if not isinstance(sample_component, ModelComponent): + raise TypeError( + f"`sample_component` is an instance of {type(sample_component).__name__}, but must be a ModelComponent." + ) + + if not isinstance(resolution_component, ModelComponent): + raise TypeError( + f"`resolution_component` is an instance of {type(resolution_component).__name__}, but must be a ModelComponent." + ) + + if isinstance(resolution_component, DeltaFunction): + raise TypeError( + "Resolution model contains delta functions. This is not supported." + ) + + analytical_types = (Gaussian, Lorentzian, Voigt) + if isinstance(sample_component, analytical_types) and isinstance( + resolution_component, analytical_types + ): + return True + + return False + + def _build_convolution_plan(self) -> None: + """Separate sample model components into analytical pairs, delta functions, and the rest.""" + + analytical_sample_model = SampleModel() + delta_sample_model = SampleModel() + numerical_sample_model = SampleModel() + + for sample_component in self._sample_model.components: + # If delta function, put in delta sample model and go to the next component + if isinstance(sample_component, DeltaFunction): + delta_sample_model.add_component(sample_component) + continue + + # If temperature is set, all other components go to numerical sample model + if self.temperature is not None: + numerical_sample_model.add_component(sample_component) + continue + + # If temperature is not set, check if all resolution components can be convolved analytically with this sample component + pair_is_analytic = [] + for resolution_component in self._resolution_model.components: + pair_is_analytic.append( + self._check_if_pair_is_analytic( + sample_component, resolution_component + ) + ) + # If all resolution components can be convolved analytically with this sample component, add it to analytical sample model. If not, it goes to numerical sample model. + if all(pair_is_analytic): + analytical_sample_model.add_component(sample_component) + else: + numerical_sample_model.add_component(sample_component) + + self._analytical_sample_model = analytical_sample_model + self._delta_sample_model = delta_sample_model + self._numerical_sample_model = numerical_sample_model + self._convolution_plan_is_valid = True + + # Update convolvers + self._set_convolvers() + + def _set_convolvers(self) -> None: + """Initialize analytical and numerical convolvers based on sample model components. + There is no delta function convolver, as delta functions are handled directly in the convolution method.""" + + if self._analytical_sample_model.components: + self._analytical_convolver = AnalyticalConvolution( + energy=self.energy, + sample_model=self._analytical_sample_model, + resolution_model=self._resolution_model, + ) + else: + self._analytical_convolver = None + + if self._numerical_sample_model.components: + self._numerical_convolver = NumericalConvolution( + energy=self.energy, + sample_model=self._numerical_sample_model, + resolution_model=self._resolution_model, + upsample_factor=self.upsample_factor, + extension_factor=self.extension_factor, + temperature=self.temperature, + temperature_unit=self._temperature_unit, + normalize_detailed_balance=self.normalize_detailed_balance, + ) + else: + self._numerical_convolver = None + + # Update some setters so the internal sample models are updated accordingly + def __setattr__(self, name, value): + """Custom setattr to invalidate convolution plan on relevant attribute changes, and build a new plan. + The new plan is only built after initialization (when _reactions_enabled is True) to avoid issues during __init__.""" + super().__setattr__(name, value) + + if name in self._invalidate_plan_on_change: + self._convolution_plan_is_valid = False + + if ( + getattr(self, "_reactions_enabled", False) + and name in self._invalidate_plan_on_change + ): + self._build_convolution_plan() diff --git a/src/easydynamics/convolution/convolution_base.py b/src/easydynamics/convolution/convolution_base.py new file mode 100644 index 0000000..2af5d30 --- /dev/null +++ b/src/easydynamics/convolution/convolution_base.py @@ -0,0 +1,168 @@ +import numpy as np +import scipp as sc + +from easydynamics.sample_model import SampleModel +from easydynamics.sample_model.components.model_component import ModelComponent + +Numerical = float | int + + +class ConvolutionBase: + """ + Base class for convolutions of sample and resolution models. + This base class has no convolution functionality. + + Args: + energy : np.ndarray or scipp.Variable + 1D array of energy values where the convolution is evaluated. + sample_model : SampleModel or ModelComponent + The sample model to be convolved. + resolution_model : SampleModel or ModelComponent + The resolution model to convolve with. + energy_unit : str or sc.Unit, optional + The unit of the energy. Default is 'meV'. + """ + + def __init__( + self, + energy: np.ndarray | sc.Variable, + sample_model: SampleModel | ModelComponent = None, + resolution_model: SampleModel | ModelComponent = None, + energy_unit: str | sc.Unit = "meV", + ): + if isinstance(energy, Numerical): + energy = np.array([float(energy)]) + + if not isinstance(energy, (np.ndarray, sc.Variable)): + raise TypeError("Energy must be a numpy ndarray or a scipp Variable.") + + if not isinstance(energy_unit, (str, sc.Unit)): + raise TypeError("Energy_unit must be a string or sc.Unit.") + + if isinstance(energy, np.ndarray): + energy = sc.array(dims=["energy"], values=energy, unit=energy_unit) + + self._energy = energy + self._energy_unit = energy_unit + + if sample_model is not None and not ( + isinstance(sample_model, SampleModel) + or isinstance(sample_model, ModelComponent) + ): + raise TypeError( + f"`sample_model` is an instance of {type(sample_model).__name__}, but must be a SampleModel or ModelComponent." + ) + self._sample_model = sample_model + + if resolution_model is not None and not isinstance( + resolution_model, SampleModel + ): + raise TypeError( + f"`resolution_model` is an instance of {type(resolution_model).__name__}, but must be a SampleModel or ModelComponent." + ) + self._resolution_model = resolution_model + + @property + def energy(self) -> sc.Variable: + """Get the energy""" + + return self._energy + + @energy.setter + def energy(self, energy: np.ndarray) -> None: + """Set the energy. + Args: + energy : np.ndarray or scipp.Variable + 1D array of energy values where the convolution is evaluated. + + Raises: + TypeError: If energy is not a numpy ndarray or a scipp Variable. + """ + + if isinstance(energy, Numerical): + energy = np.array([float(energy)]) + + if not isinstance(energy, (np.ndarray, sc.Variable)): + raise TypeError( + "Energy must be a Number, a numpy ndarray or a scipp Variable." + ) + + if isinstance(energy, np.ndarray): + self._energy = sc.array( + dims=["energy"], values=energy, unit=self._energy.unit + ) + + if isinstance(energy, sc.Variable): + self._energy = energy + self._energy_unit = energy.unit + + @property + def energy_unit(self) -> str: + """Get the energy unit""" + return self._energy_unit + + @energy_unit.setter + def energy_unit(self, unit_str: str) -> None: + raise AttributeError( + ( + f"Unit is read-only. Use convert_unit to change the unit between allowed types " + f"or create a new {self.__class__.__name__} with the desired unit." + ) + ) # noqa: E501 + + def convert_energy_unit(self, energy_unit: str | sc.Unit) -> None: + """Convert the energy to the specified unit + Args: + energy_unit : str or sc.Unit + The unit of the energy. + + Raises: + TypeError: If energy_unit is not a string or scipp unit. + """ + if not isinstance(energy_unit, (str, sc.Unit)): + raise TypeError("Energy unit must be a string or scipp unit.") + + self.energy = sc.to_unit(self.energy, energy_unit) + self._energy_unit = energy_unit + + @property + def sample_model(self) -> SampleModel | ModelComponent: + """Get the sample model""" + return self._sample_model + + @sample_model.setter + def sample_model(self, sample_model: SampleModel | ModelComponent) -> None: + """Set the sample model. + Args: + sample_model : SampleModel or ModelComponent + The sample model to be convolved. + + Raises: + TypeError: If sample_model is not a SampleModel or ModelComponent. + """ + if not isinstance(sample_model, (SampleModel, ModelComponent)): + raise TypeError( + f"`sample_model` is an instance of {type(sample_model).__name__}, but must be a SampleModel or ModelComponent." + ) + self._sample_model = sample_model + + @property + def resolution_model(self) -> SampleModel | ModelComponent: + """Get the resolution model""" + return self._resolution_model + + @resolution_model.setter + def resolution_model(self, resolution_model: SampleModel | ModelComponent) -> None: + """Set the resolution model. + Args: + resolution_model : SampleModel or ModelComponent + The resolution model to convolve with. + + Raises: + TypeError: If resolution_model is not a SampleModel or ModelComponent. + """ + if not isinstance(resolution_model, (SampleModel, ModelComponent)): + raise TypeError( + f"`resolution_model` is an instance of {type(resolution_model).__name__}, but must be a SampleModel or ModelComponent." + ) + self._resolution_model = resolution_model diff --git a/src/easydynamics/convolution/energy_grid.py b/src/easydynamics/convolution/energy_grid.py new file mode 100644 index 0000000..b2a2e0b --- /dev/null +++ b/src/easydynamics/convolution/energy_grid.py @@ -0,0 +1,27 @@ +from dataclasses import dataclass + +import numpy as np + + +@dataclass(frozen=True) +class EnergyGrid: + """Container for the dense energy grid and related metadata. + + Attributes: + energy_dense : np.ndarray + The upsampled and extended energy array. + energy_dense_centered : np.ndarray + The centered version of energy_dense (used for resolution evaluation). + energy_dense_step : float + The spacing of energy_dense (used for width checks and normalization). + energy_span_dense : float + The total span of energy_dense. (used for width checks). + energy_even_length_offset : float + The offset to apply if energy_dense has even length (used for convolution alignment). + """ + + energy_dense: np.ndarray + energy_dense_centered: np.ndarray + energy_dense_step: float + energy_span_dense: float + energy_even_length_offset: float diff --git a/src/easydynamics/convolution/numerical_convolution.py b/src/easydynamics/convolution/numerical_convolution.py new file mode 100644 index 0000000..532e5df --- /dev/null +++ b/src/easydynamics/convolution/numerical_convolution.py @@ -0,0 +1,126 @@ +import numpy as np +import scipp as sc +from easyscience.variable import Parameter +from scipy.signal import fftconvolve + +from easydynamics.convolution.numerical_convolution_base import NumericalConvolutionBase +from easydynamics.sample_model import ( + SampleModel, +) +from easydynamics.sample_model.components.model_component import ModelComponent +from easydynamics.utils.detailed_balance import ( + _detailed_balance_factor as detailed_balance_factor, +) + +Numerical = float | int + + +class NumericalConvolution(NumericalConvolutionBase): + """Numerical convolution of a SampleModel with a ResolutionModel using FFT. + Includes optional upsampling and extended range to improve accuracy. + Warns about very wide or very narrow peaks in the models. + If temperature is provided, detailed balance correction is applied to the sample model. + + Args: + energy : np.ndarray or scipp.Variable + 1D array of energy values where the convolution is evaluated. + sample_model : SampleModel or ModelComponent + The sample model to be convolved. + resolution_model : SampleModel or ModelComponent + The resolution model to convolve with. + upsample_factor : int, optional + The factor by which to upsample the input data before convolution. Default is 5. + extension_factor : float, optional + The factor by which to extend the input data range before convolution. Default is 0.2. + temperature : Parameter, float, or None, optional + The temperature to use for detailed balance correction. Default is None. + temperature_unit : str or sc.Unit, optional + The unit of the temperature parameter. Default is 'K'. + energy_unit : str or sc.Unit, optional + The unit of the energy. Default is 'meV'. + normalize_detailed_balance : bool, optional + Whether to normalize the detailed balance correction. Default is True. + """ + + def __init__( + self, + energy: np.ndarray | sc.Variable, + sample_model: SampleModel | ModelComponent, + resolution_model: SampleModel | ModelComponent, + upsample_factor: Numerical = 5, + extension_factor: float = 0.2, + temperature: Parameter | float | None = None, + temperature_unit: str | sc.Unit = "K", + energy_unit: str | sc.Unit = "meV", + normalize_detailed_balance: bool = True, + ): + super().__init__( + energy=energy, + sample_model=sample_model, + resolution_model=resolution_model, + upsample_factor=upsample_factor, + extension_factor=extension_factor, + temperature=temperature, + temperature_unit=temperature_unit, + energy_unit=energy_unit, + normalize_detailed_balance=normalize_detailed_balance, + ) + + def convolution( + self, + ) -> np.ndarray: + """ + Calculate the convolution of the sample and resolution models at the values + given in energy. + Includes detailed balance correction if temperature is provided. + + Returns: + np.ndarray + The convolved values evaluated at energy. + """ + + # Give warnings if peaks are very wide or very narrow + self._check_width_thresholds( + model=self.sample_model, + model_name="sample model", + ) + self._check_width_thresholds( + model=self.resolution_model, + model_name="resolution model", + ) + + # Evaluate sample model. If called via the Convolution class, delta functions are already filtered out. + sample_vals = self.sample_model.evaluate( + self._energy_grid.energy_dense - self._energy_grid.energy_even_length_offset + ) + + # Detailed balance correction + if self.temperature is not None: + detailed_balance_factor_correction = detailed_balance_factor( + energy=self._energy_grid.energy_dense, + temperature=self.temperature, + energy_unit=self.energy.unit, + divide_by_temperature=self.normalize_detailed_balance, + ) + sample_vals *= detailed_balance_factor_correction + + # Evaluate resolution model + resolution_vals = self.resolution_model.evaluate( + self._energy_grid.energy_dense_centered + ) + + # Convolution + convolved = fftconvolve(sample_vals, resolution_vals, mode="same") + convolved *= self._energy_grid.energy_dense_step # normalize + + if self.upsample_factor is not None: + # interpolate back to original energy grid + convolved = np.interp( + self.energy.values, + self._energy_grid.energy_dense, + convolved, + left=0.0, + right=0.0, + ) + + return convolved diff --git a/src/easydynamics/convolution/numerical_convolution_base.py b/src/easydynamics/convolution/numerical_convolution_base.py new file mode 100644 index 0000000..a1cd45b --- /dev/null +++ b/src/easydynamics/convolution/numerical_convolution_base.py @@ -0,0 +1,356 @@ +import warnings + +# from dataclasses import dataclass +from typing import Optional + +import numpy as np +import scipp as sc +from easyscience.variable import Parameter + +from easydynamics.convolution.convolution_base import ConvolutionBase +from easydynamics.convolution.energy_grid import EnergyGrid +from easydynamics.sample_model import ( + SampleModel, +) +from easydynamics.sample_model.components.model_component import ModelComponent + +Numerical = float | int + +# The thresholds are illustrated in performance_tests/convolution/convolution_width_thresholds.ipynb +LARGE_WIDTH_THRESHOLD = ( + 0.1 # Threshold for large widths compared to span - warn if width > 10% of span +) +SMALL_WIDTH_THRESHOLD = ( + 1.0 # Threshold for small widths compared to bin spacing - warn if width < dx +) + + +class NumericalConvolutionBase(ConvolutionBase): + """ + Base class for numerical convolutions of sample and resolution models. + Provides methods to handle upsampling, extension, and detailed balance correction. + This base class has no convolution functionality. + + Args: + energy : np.ndarray or scipp.Variable + 1D array of energy values where the convolution is evaluated. + sample_model : SampleModel or ModelComponent + The sample model to be convolved. + resolution_model : SampleModel or ModelComponent + The resolution model to convolve with. + upsample_factor : int, optional + The factor by which to upsample the input data before convolution. Default is 5. + extension_factor : float, optional + The factor by which to extend the input data range before convolution. Default is 0.2. + temperature : Parameter, float, or None, optional + The temperature to use for detailed balance correction. Default is None. + temperature_unit : str or sc.Unit, optional + The unit of the temperature parameter. Default is 'K'. + energy_unit : str or sc.Unit, optional + The unit of the energy. Default is 'meV'. + normalize_detailed_balance : bool, optional + Whether to normalize the detailed balance correction. Default is True. + """ + + def __init__( + self, + energy: np.ndarray | sc.Variable, + sample_model: SampleModel | ModelComponent, + resolution_model: SampleModel | ModelComponent, + upsample_factor: Numerical = 5, + extension_factor: float = 0.2, + temperature: Parameter | float | None = None, + temperature_unit: str | sc.Unit = "K", + energy_unit: str | sc.Unit = "meV", + normalize_detailed_balance: bool = True, + ): + super().__init__( + energy=energy, + sample_model=sample_model, + resolution_model=resolution_model, + energy_unit=energy_unit, + ) + + if temperature is not None and not isinstance( + temperature, (Numerical, Parameter) + ): + raise TypeError("Temperature must be None, a number or a Parameter.") + + if not isinstance(temperature_unit, (str, sc.Unit)): + raise TypeError("Temperature_unit must be a string or sc.Unit.") + self._temperature_unit = temperature_unit + self._temperature = None + self.temperature = temperature + + self._normalize_detailed_balance = normalize_detailed_balance + + self._upsample_factor = upsample_factor + self._extension_factor = extension_factor + + # Create a dense grid to improve accuracy. When upsample_factor>1, we evaluate on this grid and interpolate back to the original values at the end + self._energy_grid = self._create_energy_grid() + + @ConvolutionBase.energy.setter + def energy(self, energy: np.ndarray) -> None: + ConvolutionBase.energy.fset(self, energy) + # Recreate dense grid when energy is updated + self._energy_grid = self._create_energy_grid() + + @property + def upsample_factor(self) -> Numerical: + """ + Get the upsample factor. + """ + + return self._upsample_factor + + @upsample_factor.setter + def upsample_factor(self, factor: Numerical) -> None: + """ + Set the upsample factor and recreate the dense grid.""" + if factor is None: + self._upsample_factor = factor + self._energy_grid = self._create_energy_grid() + return + + if not isinstance(factor, Numerical): + raise TypeError("Upsample factor must be a numerical value or None.") + factor = float(factor) + if factor <= 1.0: + raise ValueError("Upsample factor must be greater than 1.") + + self._upsample_factor = factor + + # Recreate dense grid when upsample factor is updated + self._energy_grid = self._create_energy_grid() + + @property + def extension_factor(self) -> float: + """ + Get the extension factor. + The extension factor determines how much the energy range is extended on both sides before convolution. + 0.2 means extending by 20% of the original energy span on each side + """ + + return self._extension_factor + + @extension_factor.setter + def extension_factor(self, factor: Numerical) -> None: + """ + Set the extension factor and recreate the dense grid. + The extension factor determines how much the energy range is extended on both sides before convolution. + 0.2 means extending by 20% of the original energy span on each side. + + Args: + factor : float + The new extension factor. + + Raises: + TypeError: If factor is not a number. + """ + if not isinstance(factor, Numerical): + raise TypeError("Extension factor must be a number.") + if factor < 0.0: + raise ValueError("Extension factor must be non-negative.") + + self._extension_factor = factor + # Recreate dense grid when extension factor is updated + self._energy_grid = self._create_energy_grid() + + @property + def temperature(self) -> Optional[Parameter]: + """ + Get the temperature. + """ + + return self._temperature + + @temperature.setter + def temperature(self, temp: Parameter | float | None) -> None: + """ + Set the temperature. If None, disables detailed balance correction and removes the temperature parameter. + Args: + temp : Parameter, float, or None + The temperature to set. The unit will be the same as the existing temperature parameter if it exists, otherwise 'K'. + Raises: + TypeError: If temp is not a float, Parameter, or None. + """ + + if temp is None: + self._temperature = None + elif isinstance(temp, Numerical): + if self._temperature is not None: + self._temperature.value = float(temp) + else: + self._temperature = Parameter( + name="temperature", + value=float(temp), + unit=self._temperature_unit, + fixed=True, + ) + elif isinstance(temp, Parameter): + self._temperature = temp + else: + raise TypeError("Temperature must be None, a float or a Parameter.") + + @property + def normalize_detailed_balance(self) -> bool: + """ + Get whether to normalize the detailed balance factor. + """ + + return self._normalize_detailed_balance + + @normalize_detailed_balance.setter + def normalize_detailed_balance(self, normalize: bool) -> None: + """ + Set whether to normalize the detailed balance factor. + If True, the detailed balance factor is divided by temperature. + Args: + normalize : bool + Whether to normalize the detailed balance factor. + Raises: + TypeError: If normalize is not a bool. + """ + + if not isinstance(normalize, bool): + raise TypeError("normalize_detailed_balance must be True or False.") + + self._normalize_detailed_balance = normalize + + def _create_energy_grid( + self, + ) -> EnergyGrid: + """ + Create a dense grid by upsampling and extending the energy array. If upsample_factor is None, no upsampling or extension is performed. + This dense grid is used for convolution to improve accuracy. + Returns: + EnergyGrid + The dense grid created by upsampling and extending energy. + The EnergyGrid has the following attributes: + energy_dense : np.ndarray + The upsampled and extended energy array. + energy_dense_centered : np.ndarray + The centered version of energy_dense (used for resolution evaluation). + energy_dense_step : float + The spacing of energy_dense (used for width checks and normalization). + energy_span_dense : float + The total span of energy_dense. (used for width checks). + energy_even_length_offset : float + The offset to apply if energy_dense has even length (used for convolution alignment). + """ + if self.upsample_factor is None: + # Check if the array is uniformly spaced. + energy_diff = np.diff(self.energy.values) + is_uniform = np.allclose(energy_diff, energy_diff[0]) + if not is_uniform: + raise ValueError( + "Input array `energy` must be uniformly spaced if upsample_factor is not given." + ) + energy_dense = self.energy.values + + energy_span_dense = self.energy.values.max() - self.energy.values.min() + else: + # Create an extended and upsampled energy grid + energy_min, energy_max = self.energy.values.min(), self.energy.values.max() + energy_span_original = energy_max - energy_min + extra = self.extension_factor / 2 * energy_span_original + extended_min = energy_min - extra + extended_max = energy_max + extra + num_points = round(len(self.energy.values) * self.upsample_factor) + energy_dense = np.linspace(extended_min, extended_max, num_points) + energy_span_dense = extended_max - extended_min + + if len(energy_dense) < 2: + raise ValueError("Energy array must have at least two points.") + energy_dense_step = energy_dense[1] - energy_dense[0] + + # Handle offset for even length of energy_dense in convolution. + # The convolution of two arrays of length N is of length 2N-1. When using 'same' mode, only the central N points are kept, + # so the output has the same length as the input. + # However, if N is even, the center falls between two points, leading to a half-bin offset. + # For example, if N=4, the convolution has length 7, and when we select the 4 central points we either get + # indices [2,3,4,5] or [1,2,3,4], both of which are offset by 0.5*dx from the true center at index 3.5. + if len(energy_dense) % 2 == 0: + energy_even_length_offset = -0.5 * energy_dense_step + else: + energy_even_length_offset = 0.0 + + # Handle the case when energy_dense is not symmetric around zero. The resolution is still centered around zero (or close to it), so it needs to be evaluated there. + if not np.isclose(energy_dense.mean(), 0.0): + energy_dense_centered = np.linspace( + -0.5 * energy_span_dense, 0.5 * energy_span_dense, len(energy_dense) + ) + else: + energy_dense_centered = energy_dense + + energy_grid = EnergyGrid( + energy_dense=energy_dense, + energy_dense_centered=energy_dense_centered, + energy_dense_step=energy_dense_step, + energy_span_dense=energy_span_dense, + energy_even_length_offset=energy_even_length_offset, + ) + + return energy_grid + + def _check_width_thresholds( + self, + model: SampleModel | ModelComponent, + model_name: str, + ) -> None: + """ + Helper function to check and warn if components are wide compared to the span of the data, or narrow compared to the spacing. + In both cases, the convolution accuracy may be compromised. + Args: + model : SampleModel or ModelComponent + The model to check. + model_name : str + A string indicating whether the model is a 'sample model' or 'resolution model' for warning messages. + returns: + None + warns: + UserWarning + If the component widths are not appropriate for the data span or bin spacing. + + """ + + # Handle SampleModel or ModelComponent + if isinstance(model, SampleModel): + components = model.components + else: + components = [model] # Treat single ModelComponent as a list + + for comp in components: + if hasattr(comp, "width"): + if ( + comp.width.value + > LARGE_WIDTH_THRESHOLD * self._energy_grid.energy_span_dense + ): + warnings.warn( + f"The width of the {model_name} component '{comp.name}' ({comp.width.value}) is large compared to the span of the input " + f"array ({self._energy_grid.energy_span_dense}). This may lead to inaccuracies in the convolution. Increase extension_factor to improve accuracy.", + UserWarning, + ) + if ( + comp.width.value + < SMALL_WIDTH_THRESHOLD * self._energy_grid.energy_dense_step + ): + warnings.warn( + f"The width of the {model_name} component '{comp.name}' ({comp.width.value}) is small compared to the spacing of the input " + f"array ({self._energy_grid.energy_dense_step}). This may lead to inaccuracies in the convolution. Increase upsample_factor to improve accuracy.", + UserWarning, + ) + + def __repr__(self) -> str: + return ( + f"{self.__class__.__name__}(" + f"energy=array of shape {self.energy.values.shape},\n " + f"sample_model={repr(self.sample_model)}, \n" + f"resolution_model={repr(self.resolution_model)},\n " + f"energy_unit={self._energy_unit}, " + f"upsample_factor={self.upsample_factor}, " + f"extension_factor={self.extension_factor}, " + f"temperature={self.temperature}, " + f"normalize_detailed_balance={self.normalize_detailed_balance})" + ) diff --git a/src/easydynamics/sample_model/__init__.py b/src/easydynamics/sample_model/__init__.py index a64ffd2..2b1274f 100644 --- a/src/easydynamics/sample_model/__init__.py +++ b/src/easydynamics/sample_model/__init__.py @@ -6,6 +6,7 @@ Polynomial, Voigt, ) +from .sample_model import SampleModel __all__ = [ "SampleModel", diff --git a/src/easydynamics/sample_model/sample_model.py b/src/easydynamics/sample_model/sample_model.py new file mode 100644 index 0000000..5dece61 --- /dev/null +++ b/src/easydynamics/sample_model/sample_model.py @@ -0,0 +1,277 @@ +import warnings +from typing import List, Optional, Union + +import numpy as np +import scipp as sc +from easyscience.base_classes import CollectionBase +from easyscience.global_object.undo_redo import NotarizedDict +from easyscience.job.theoreticalmodel import TheoreticalModelBase + +from .components.model_component import ModelComponent + +Numeric = Union[float, int] + + +class SampleModel(CollectionBase, TheoreticalModelBase): + """ + A model of the scattering from a sample, combining multiple model components. + + Attributes + ---------- + name : str + Name of the SampleModel. + unit : str or sc.Unit + Unit of the SampleModel. + components : List[ModelComponent] + List of model components in the SampleModel. + + """ + + def __init__( + self, + name: str = "MySampleModel", + unit: Optional[Union[str, sc.Unit]] = "meV", + data: Optional[List] = None, + ): + """ + Initialize a new SampleModel. + + Parameters + ---------- + name : str + Name of the sample model. + unit : str or sc.Unit, optional + Unit of the sample model. Defaults to "meV". + data : List[ModelComponent], optional + Initial list of model components to include in the sample model. + """ + + CollectionBase.__init__(self, name=name) + TheoreticalModelBase.__init__(self, name=name) + if not isinstance(self._kwargs, NotarizedDict): + self._kwargs = NotarizedDict() + + self._unit = unit + + # Add initial components if provided. Mostly used for serialization. + if data: + # Just to be safe + self.clear_components() + for item in data: + # ensure item is a ModelComponent + if not isinstance(item, ModelComponent): + raise TypeError("Data items must be instances of ModelComponent.") + self.insert(index=len(self), value=item) + + def add_component( + self, component: ModelComponent, name: Optional[str] = None + ) -> None: + """ + Add a model component to the SampleModel. Component names must be unique. + Parameters + ---------- + component : ModelComponent + The model component to add. + name : str, optional + Name to assign to the component. If None, uses the component's own name. + """ + if not isinstance(component, ModelComponent): + raise TypeError("component must be an instance of ModelComponent.") + + if name is None: + name = component.name + if name in self.list_component_names(): + raise ValueError(f"Component with name '{name}' already exists.") + + component.name = name + + self.insert(index=len(self), value=component) + + def remove_component(self, name: str): + """ + Remove a model component by name. + """ + # Find index where item.name == name + indices = [i for i, item in enumerate(list(self)) if item.name == name] + if not indices: + raise KeyError(f"No component named '{name}' exists in the model.") + del self[indices[0]] + + def list_component_names(self) -> List[str]: + """ + List the names of all components in the model. + + Returns + ------- + List[str] + Component names. + """ + + return [item.name for item in list(self)] + + def clear_components(self): + """ + Remove all components from the model. + """ + + for _ in range(len(self)): + del self[0] + + def normalize_area(self) -> None: + # Useful for convolutions. + """ + Normalize the areas of all components so they sum to 1. + """ + if not self.components: + raise ValueError("No components in the model to normalize.") + + area_params = [] + total_area = 0.0 + + for component in list(self): + if hasattr(component, "area"): + area_params.append(component.area) + total_area += component.area.value + else: + warnings.warn( + f"Component '{component.name}' does not have an 'area' attribute and will be skipped in normalization." + ) + + if total_area == 0: + raise ValueError("Total area is zero; cannot normalize.") + + if not np.isfinite(total_area): + raise ValueError("Total area is not finite; cannot normalize.") + + for param in area_params: + param.value /= total_area + + @property + def components(self) -> List[ModelComponent]: + """ + Get the list of components in the SampleModel. + + Returns + ------- + List[ModelComponent] + """ + return list(self) + + @property + def unit(self) -> Optional[Union[str, sc.Unit]]: + """ + Get the unit of the SampleModel. + + Returns + ------- + str or sc.Unit or None + """ + return self._unit + + @unit.setter + def unit(self, unit_str: str) -> None: + raise AttributeError( + ( + f"Unit is read-only. Use convert_unit to change the unit between allowed types " + f"or create a new {self.__class__.__name__} with the desired unit." + ) + ) # noqa: E501 + + def convert_unit(self, unit: Union[str, sc.Unit]) -> None: + """ + Convert the unit of the SampleModel and all its components. + """ + self._unit = unit + # for component in self.components.values(): + for component in list(self): + component.convert_unit(unit) + + def evaluate( + self, x: Union[Numeric, list, np.ndarray, sc.Variable, sc.DataArray] + ) -> np.ndarray: + """ + Evaluate the sum of all components. + + Parameters + ---------- + x : Number, list, np.ndarray, sc.Variable, or sc.DataArray + Energy axis. + + Returns + ------- + np.ndarray + Evaluated model values. + """ + + if not self.components: + raise ValueError("No components in the model to evaluate.") + result = None + for component in list(self): + value = component.evaluate(x) + result = value if result is None else result + value + + return result + + def evaluate_component( + self, + x: Union[Numeric, list, np.ndarray, sc.Variable, sc.DataArray], + name: str, + ) -> np.ndarray: + """ + Evaluate a single component by name. + + Parameters + ---------- + x : Number, list, np.ndarray, sc.Variable, or sc.DataArray + Energy axis. + name : str + Component name. + + Returns + ------- + np.ndarray + Evaluated values for the specified component. + """ + if not self.components: + raise ValueError("No components in the model to evaluate.") + + if not isinstance(name, str): + raise TypeError( + (f"Component name must be a string, got {type(name)} instead.") + ) + + matches = [comp for comp in list(self) if comp.name == name] + if not matches: + raise KeyError(f"No component named '{name}' exists.") + + component = matches[0] + + result = component.evaluate(x) + + return result + + def fix_all_parameters(self) -> None: + """ + Fix all free parameters in the model. + """ + for param in self.get_parameters(): + param.fixed = True + + def free_all_parameters(self) -> None: + """ + Free all fixed parameters in the model. + """ + for param in self.get_parameters(): + param.fixed = False + + def __repr__(self) -> str: + """ + Return a string representation of the SampleModel. + + Returns + ------- + str + """ + comp_names = ", ".join(c.name for c in self) or "No components" + + return f"" diff --git a/tests/performance_tests/convolution/convolution_width_thresholds.ipynb b/tests/performance_tests/convolution/convolution_width_thresholds.ipynb new file mode 100644 index 0000000..a158762 --- /dev/null +++ b/tests/performance_tests/convolution/convolution_width_thresholds.ipynb @@ -0,0 +1,136 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "id": "018fa173", + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "\n", + "from easydynamics.sample_model import Gaussian, SampleModel\n", + "from easydynamics.convolution.analytical_convolution import AnalyticalConvolution \n", + "from easydynamics.convolution.numerical_convolution import NumericalConvolution \n", + "\n", + "import matplotlib.pyplot as plt" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cf69a4b9", + "metadata": {}, + "outputs": [], + "source": [ + "# When the width of the Gaussian is >~20% of the span, numerical issues arise. We set the limit to 10% to be safe.\n", + "gaussian_widths=[30, 20, 10, 5]\n", + "sample_model=SampleModel(name='sample_model')\n", + "gaussian=Gaussian(name='Gaussian',width=30,area=1)\n", + "sample_model.add_component(gaussian)\n", + "\n", + "resolution_model = SampleModel(name='resolution_model')\n", + "resolution_gaussian=Gaussian(name='Resolution Gaussian',width=5,area=1.0)\n", + "resolution_model.add_component(resolution_gaussian)\n", + "x=np.linspace(-50, 50, 101)\n", + "\n", + "analytical_convolver = AnalyticalConvolution(\n", + " sample_model=sample_model,\n", + " resolution_model=resolution_model,\n", + " energy=x,\n", + ")\n", + "\n", + "numerical_convolver = NumericalConvolution(\n", + " sample_model=sample_model,\n", + " resolution_model=resolution_model,\n", + " energy=x,\n", + " upsample_factor=None\n", + ")\n", + "\n", + "for gwidth in gaussian_widths:\n", + " sample_model['Gaussian'].width=gwidth\n", + " y_analytical = analytical_convolver.convolution()\n", + "\n", + " y_numerical = numerical_convolver.convolution()\n", + "\n", + " plt.plot(x, y_analytical, label='Analytical, width = {}'.format(gwidth), color = plt.cm.viridis(gaussian_widths.index(gwidth)/len(gaussian_widths)))\n", + " plt.plot(x, y_numerical, label='Numerical, width = {}'.format(gwidth), color = plt.cm.viridis(gaussian_widths.index(gwidth)/len(gaussian_widths)), linestyle='--')\n", + " plt.xlabel('Energy (meV)')\n", + " plt.ylabel('Intensity (arb. units)')\n", + " plt.title('Convolution of Sample Model with Resolution Model with various widths')\n", + "\n", + "plt.legend()\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "0cb777e0", + "metadata": {}, + "outputs": [], + "source": [ + "# When the width of the Gaussian is <~50% of the bin spacing, numerical issues arise. We set the limit to 100% to be safe.\n", + "gaussian_widths=[5, 2, 1, 0.5, 0.25]\n", + "gaussian_centers=[-50, -25, 0, 25, 50]\n", + "sample_model=SampleModel(name='sample_model')\n", + "gaussian=Gaussian(name='Gaussian',width=0.5,area=1)\n", + "sample_model.add_component(gaussian)\n", + "\n", + "resolution_model = SampleModel(name='resolution_model')\n", + "resolution_gaussian=Gaussian(name='Resolution Gaussian',width=10,area=1.0)\n", + "resolution_model.add_component(resolution_gaussian)\n", + "x=np.linspace(-100, 100, 201)\n", + "\n", + "analytical_convolver = AnalyticalConvolution(\n", + " sample_model=sample_model,\n", + " resolution_model=resolution_model,\n", + " energy=x,\n", + ")\n", + "\n", + "numerical_convolver = NumericalConvolution(\n", + " sample_model=sample_model,\n", + " resolution_model=resolution_model,\n", + " energy=x,\n", + " upsample_factor=None\n", + ")\n", + "\n", + "for gwidth, gcenter in zip(gaussian_widths, gaussian_centers):\n", + " sample_model['Gaussian'].width=gwidth\n", + " sample_model['Gaussian'].center=gcenter\n", + " y_analytical = analytical_convolver.convolution()\n", + "\n", + " y_numerical = numerical_convolver.convolution()\n", + "\n", + "\n", + " plt.plot(x, y_analytical, label='Analytical, width = {}'.format(gwidth), color = plt.cm.viridis(gaussian_widths.index(gwidth)/len(gaussian_widths)))\n", + " plt.plot(x, y_numerical, label='Numerical, width = {}'.format(gwidth), color = plt.cm.viridis(gaussian_widths.index(gwidth)/len(gaussian_widths)), linestyle='--')\n", + " plt.xlabel('Energy (meV)')\n", + " plt.ylabel('Intensity (arb. units)')\n", + " plt.title('Convolution of Sample Model with Resolution Model with various widths')\n", + "\n", + "plt.legend()\n" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "newdynamics", + "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.11.13" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/tests/unit_tests/convolution/test_analytical_convolution.py b/tests/unit_tests/convolution/test_analytical_convolution.py new file mode 100644 index 0000000..e762048 --- /dev/null +++ b/tests/unit_tests/convolution/test_analytical_convolution.py @@ -0,0 +1,478 @@ +from unittest.mock import patch + +import numpy as np +import pytest +import scipp as sc +from scipy.signal import fftconvolve + +# from easyscience.variable import Parameter +from easydynamics.convolution.analytical_convolution import ( + AnalyticalConvolution, +) +from easydynamics.sample_model import ( + DampedHarmonicOscillator, + DeltaFunction, + Gaussian, + Lorentzian, + SampleModel, + Voigt, +) + +NUMERICAL_CONVOLUTION_RELATIVE_TOLERANCE = 1e-5 +NUMERICAL_CONVOLUTION_ABSOLUTE_TOLERANCE = 1e-6 + + +@pytest.fixture +def function1(request): + # request.param will be e.g. "gaussian1" + return request.getfixturevalue(request.param) + + +@pytest.fixture +def function2(request): + return request.getfixturevalue(request.param) + + +class TestAnalyticalConvolution: + @pytest.fixture + def default_analytical_convolution(self): + # Energy needs to be odd to avoid issues with shifts in fftconvolve. + energy = np.linspace(-100, 100, 2**15 + 1) + sample_model = SampleModel(name="SampleModel") + resolution_model = SampleModel(name="ResolutionModel") + + return AnalyticalConvolution( + energy=energy, + sample_model=sample_model, + resolution_model=resolution_model, + ) + + @pytest.fixture + def gaussian1(self): + return Gaussian(area=2.0, center=1.0, width=0.5) + + @pytest.fixture + def gaussian2(self): + return Gaussian(area=3.0, center=-1.0, width=0.4) + + @pytest.fixture + def lorentzian1(self): + return Lorentzian(area=2.0, center=1.0, width=0.55) + + @pytest.fixture + def lorentzian2(self): + return Lorentzian(area=3.0, center=-1.0, width=0.45) + + @pytest.fixture + def voigt1(self): + return Voigt(area=2.0, center=1.0, gaussian_width=0.33, lorentzian_width=0.28) + + @pytest.fixture + def voigt2(self): + return Voigt(area=3.0, center=-1.0, gaussian_width=0.36, lorentzian_width=0.42) + + @pytest.fixture + def dho1(self): + return DampedHarmonicOscillator(area=2.0, center=2.0, width=0.2) + + def test_init(self, default_analytical_convolution): + # WHEN THEN EXPECT + assert isinstance(default_analytical_convolution, AnalyticalConvolution) + assert isinstance(default_analytical_convolution.energy, sc.Variable) + assert np.allclose( + default_analytical_convolution.energy.values, + np.linspace(-100, 100, 2**15 + 1), + ) + assert isinstance(default_analytical_convolution._sample_model, SampleModel) + assert isinstance(default_analytical_convolution._resolution_model, SampleModel) + + def test_convolution( + self, + default_analytical_convolution, + gaussian1, + gaussian2, + lorentzian1, + lorentzian2, + ): + """Test that the convolute method calls _convolute_analytic_pair for all component pairs.""" + + # WHEN + sample_model = SampleModel(name="SampleModel") + sample_model.add_component(gaussian1) + sample_model.add_component(lorentzian1) + + resolution_model = SampleModel(name="ResolutionModel") + resolution_model.add_component(gaussian2) + resolution_model.add_component(lorentzian2) + default_analytical_convolution.sample_model = sample_model + default_analytical_convolution.resolution_model = resolution_model + + # THEN + # Mock _convolute_analytic_pair to return 1.0 for any input pair + def mock_convolute_analytic_pair(sample_component, resolution_component): + return np.full_like( + default_analytical_convolution.energy.values, fill_value=1.0 + ) + + with patch.object( + default_analytical_convolution, + "_convolute_analytic_pair", + side_effect=mock_convolute_analytic_pair, + ) as mocked_pair: + result = default_analytical_convolution.convolution() + + # EXPECT + # 2 sample components x 2 resolution components + assert np.all(result == 4.0) + assert mocked_pair.call_count == 4 + + # Gather the actual calls to verify correct pairs + calls = [] + for c in mocked_pair.call_args_list: + kwargs = c.kwargs + + # Ensure no accidental extra arguments + assert set(kwargs.keys()) == { + "sample_component", + "resolution_component", + } + + calls.append( + (kwargs["sample_component"], kwargs["resolution_component"]) + ) + + expected_calls = [ + (gaussian1, gaussian2), + (gaussian1, lorentzian2), + (lorentzian1, gaussian2), + (lorentzian1, lorentzian2), + ] + assert calls == expected_calls + + def test_convolution_components( + self, + default_analytical_convolution, + gaussian1, + lorentzian1, + ): + """Test that the convolute method also works for components.""" + + # WHEN + default_analytical_convolution.sample_model = gaussian1 + default_analytical_convolution.resolution_model = lorentzian1 + + # THEN + # Mock _convolute_analytic_pair to return 1.0 for any input pair + def mock_convolute_analytic_pair(sample_component, resolution_component): + return np.full_like( + default_analytical_convolution.energy.values, fill_value=1.0 + ) + + with patch.object( + default_analytical_convolution, + "_convolute_analytic_pair", + side_effect=mock_convolute_analytic_pair, + ) as mocked_pair: + result = default_analytical_convolution.convolution() + + # EXPECT + # 1 sample component x 1 resolution component + assert np.all(result == 1.0) + assert mocked_pair.call_count == 1 + + # Gather the actual calls to verify correct pairs + calls = [] + for c in mocked_pair.call_args_list: + kwargs = c.kwargs + + # Ensure no accidental extra arguments + assert set(kwargs.keys()) == { + "sample_component", + "resolution_component", + } + + calls.append( + (kwargs["sample_component"], kwargs["resolution_component"]) + ) + + expected_calls = [ + (gaussian1, lorentzian1), + ] + assert calls == expected_calls + + @pytest.mark.parametrize( + "function1, function2, expected_method, swapped", + [ + # Normal cases + ( + "gaussian1", + "gaussian2", + "_convolute_gaussian_gaussian", + False, + ), + ( + "gaussian1", + "lorentzian1", + "_convolute_gaussian_lorentzian", + False, + ), + ( + "gaussian1", + "voigt1", + "_convolute_gaussian_voigt", + False, + ), + ( + "lorentzian1", + "lorentzian2", + "_convolute_lorentzian_lorentzian", + False, + ), + ( + "lorentzian1", + "voigt1", + "_convolute_lorentzian_voigt", + False, + ), + ("voigt1", "voigt2", "_convolute_voigt_voigt", False), + # Swapped cases + ("lorentzian1", "gaussian1", "_convolute_gaussian_lorentzian", True), + ("voigt1", "gaussian1", "_convolute_gaussian_voigt", True), + ("voigt1", "lorentzian1", "_convolute_lorentzian_voigt", True), + ], + indirect=["function1", "function2"], + ids=[ + "gauss-gauss", + "gauss-lorentz", + "gauss-voigt", + "lorentz-lorentz", + "lorentz-voigt", + "voigt-voigt", + "lorentz-gauss", + "voigt-gauss", + "voigt-lorentz", + ], + ) + def test_convolute_analytic_pair_calls_correct_helper( + self, + default_analytical_convolution, + function1, + function2, + expected_method, + swapped, + ): + """Test that _convolute_analytic_pair calls the correct internal helper with correct order.""" + + with patch.object( + default_analytical_convolution, + expected_method, + return_value="mocked_result", + ) as mocked_func: + result = default_analytical_convolution._convolute_analytic_pair( + function1, function2 + ) + if swapped: + expected_args = (function2, function1) + else: + expected_args = (function1, function2) + + mocked_func.assert_called_once_with(*expected_args) + assert result == "mocked_result" + + @pytest.mark.parametrize( + "function1", + ["gaussian1", "lorentzian1", "voigt1", "dho1"], + indirect=True, + ids=["gaussian", "lorentzian", "voigt", "dho"], + ) + def test_convolute_analytic_pair_delta( + self, default_analytical_convolution, function1 + ): + """Test that convolution with delta function returns the other function.""" + # WHEN THEN + delta_function = DeltaFunction(area=2.0, center=0.5) + function1 = Gaussian(area=3.0, center=-1.0, width=1.0) + + convoluted = default_analytical_convolution._convolute_analytic_pair( + delta_function, function1 + ) + + # EXPECT + expected = 2.0 * function1.evaluate( + default_analytical_convolution.energy.values - 0.5 + ) + + assert np.allclose( + convoluted, + expected, + rtol=NUMERICAL_CONVOLUTION_RELATIVE_TOLERANCE, + atol=NUMERICAL_CONVOLUTION_ABSOLUTE_TOLERANCE, + ) + + def test_convolute_analytic_pair_resolution_delta_raises( + self, default_analytical_convolution + ): + """Test that an error is raised if the resolution function is a delta function.""" + # WHEN + sample_function = Gaussian(area=2.0, center=0.0, width=1.0) + resolution_function = DeltaFunction(area=1.0, center=0.0) + + # THEN EXPECT + with pytest.raises( + ValueError, + match="not supported", + ): + default_analytical_convolution._convolute_analytic_pair( + sample_function, resolution_function + ) + + def test_convolute_analytic_pair_non_analytical_pair_raises( + self, default_analytical_convolution, gaussian1, dho1 + ): + """Test that an error is raised if the function pair is not supported for analytical convolution.""" + # WHEN + sample_function = dho1 + resolution_function = gaussian1 + + # THEN EXPECT + with pytest.raises( + ValueError, + match="not supported", + ): + default_analytical_convolution._convolute_analytic_pair( + sample_function, resolution_function + ) + + @pytest.mark.parametrize( + "method_name, function1, function2", + [ + ("_convolute_gaussian_gaussian", "gaussian1", "gaussian2"), + ("_convolute_gaussian_lorentzian", "gaussian1", "lorentzian1"), + ("_convolute_gaussian_voigt", "gaussian1", "voigt1"), + ("_convolute_lorentzian_lorentzian", "lorentzian1", "lorentzian2"), + ("_convolute_lorentzian_voigt", "lorentzian1", "voigt1"), + ("_convolute_voigt_voigt", "voigt1", "voigt2"), + ], + indirect=["function1", "function2"], + ids=[ + "gauss-gauss", + "gauss-lorentz", + "gauss-voigt", + "lorentz-lorentz", + "lorentz-voigt", + "voigt-voigt", + ], + ) + def test_convolute_function1_function2( + self, default_analytical_convolution, function1, function2, method_name + ): + # This test is perhaps superfluous since the methods get tested indirectly above. + """Test that the analytical convolution methods are correct by comparing to numerical convolution.""" + # WHEN THEN + convoluted = getattr(default_analytical_convolution, method_name)( + function1, function2 + ) + + # EXPECT + expected = fftconvolve( + function1.evaluate(default_analytical_convolution.energy.values), + function2.evaluate(default_analytical_convolution.energy.values), + mode="same", + ) * ( + default_analytical_convolution.energy.values[1] + - default_analytical_convolution.energy.values[0] + ) + + # Numerical convolution can be inaccurate at the edges, so only compare the central part + N = len(convoluted) + start = N // 4 + end = 3 * N // 4 + + assert np.allclose( + convoluted[start:end], + expected[start:end], + rtol=NUMERICAL_CONVOLUTION_RELATIVE_TOLERANCE, + atol=NUMERICAL_CONVOLUTION_ABSOLUTE_TOLERANCE, + ) + + @pytest.mark.parametrize( + "function1", + ["gaussian1", "lorentzian1", "voigt1", "dho1"], + indirect=True, + ids=["gaussian", "lorentzian", "voigt", "dho"], + ) + def test_convolute_delta_any(self, default_analytical_convolution, function1): + """Test that convolution with delta function returns the other function.""" + # WHEN THEN + delta_function = DeltaFunction(area=2.0, center=0.5) + convoluted = default_analytical_convolution._convolute_delta_any( + delta_function, function1 + ) + + # EXPECT + expected = 2.0 * function1.evaluate( + default_analytical_convolution.energy.values - 0.5 + ) + + assert np.allclose( + convoluted, + expected, + rtol=NUMERICAL_CONVOLUTION_RELATIVE_TOLERANCE, + atol=NUMERICAL_CONVOLUTION_ABSOLUTE_TOLERANCE, + ) + + def test_gaussian_eval(self, default_analytical_convolution): + # WHEN + area = 2.0 + center = 0.0 + width = 1.0 + + # THEN + gaussian_eval = default_analytical_convolution._gaussian_eval( + area, center, width + ) + + # EXPECT + expected = Gaussian(area=area, center=center, width=width).evaluate( + default_analytical_convolution.energy.values + ) + assert np.allclose(gaussian_eval, expected) + + def test_lorentzian_eval(self, default_analytical_convolution): + # WHEN + area = 2.0 + center = 0.0 + width = 1.0 + + # THEN + lorentzian_eval = default_analytical_convolution._lorentzian_eval( + area, center, width + ) + + # EXPECT + expected = Lorentzian(area=area, center=center, width=width).evaluate( + default_analytical_convolution.energy.values + ) + assert np.allclose(lorentzian_eval, expected) + + def test_voigt_eval(self, default_analytical_convolution): + # WHEN + area = 2.0 + center = 0.0 + gaussian_width = 1.0 + lorentzian_width = 1.0 + + # THEN + voigt_eval = default_analytical_convolution._voigt_eval( + area, center, gaussian_width, lorentzian_width + ) + + # EXPECT + expected = Voigt( + area=area, + center=center, + gaussian_width=gaussian_width, + lorentzian_width=lorentzian_width, + ).evaluate(default_analytical_convolution.energy.values) + + assert np.allclose(voigt_eval, expected) diff --git a/tests/unit_tests/convolution/test_convolution.py b/tests/unit_tests/convolution/test_convolution.py new file mode 100644 index 0000000..c3c4c77 --- /dev/null +++ b/tests/unit_tests/convolution/test_convolution.py @@ -0,0 +1,489 @@ +from contextlib import nullcontext +from unittest.mock import patch + +import numpy as np +import pytest +import scipp as sc + +from easydynamics.convolution.analytical_convolution import AnalyticalConvolution +from easydynamics.convolution.convolution import ( + Convolution, +) +from easydynamics.convolution.energy_grid import EnergyGrid +from easydynamics.convolution.numerical_convolution import NumericalConvolution +from easydynamics.sample_model import ( + DampedHarmonicOscillator, + DeltaFunction, + Gaussian, + Lorentzian, + Polynomial, + SampleModel, + Voigt, +) + + +class TestConvolution: + @pytest.fixture + def default_convolution(self): + energy = np.linspace(-10, 10, 5001) + sample_model = SampleModel(name="SampleModel") + + sample_model.add_component( + Gaussian(name="Gaussian1", area=2.0, center=0.1, width=0.4) + ) + + sample_model.add_component( + DampedHarmonicOscillator(name="DHO1", area=2.0, center=1.0, width=0.1) + ) + + sample_model.add_component(DeltaFunction(name="Delta1", area=2.0, center=0.3)) + + resolution_model = SampleModel(name="ResolutionModel") + resolution_model.add_component( + Gaussian(name="GaussianRes", area=3.0, center=0.2, width=0.5) + ) + + conv = Convolution( + energy=energy, + sample_model=sample_model, + resolution_model=resolution_model, + ) + return conv + + def test_init(self, default_convolution): + "Test initialization of Convolution with default parameters." + # WHEN THEN EXPECT + assert isinstance(default_convolution, Convolution) + assert isinstance(default_convolution.energy, sc.Variable) + assert np.allclose( + default_convolution.energy.values, np.linspace(-10, 10, 5001) + ) + assert isinstance(default_convolution._sample_model, SampleModel) + assert isinstance(default_convolution._resolution_model, SampleModel) + assert default_convolution.upsample_factor == 5 + assert default_convolution.extension_factor == 0.2 + assert default_convolution.temperature is None + assert default_convolution.energy_unit == "meV" + assert default_convolution.normalize_detailed_balance is True + assert isinstance(default_convolution._energy_grid, EnergyGrid) + + assert isinstance(default_convolution._analytical_sample_model, SampleModel) + assert ( + default_convolution._analytical_sample_model.components[0] + is default_convolution.sample_model.components[0] + ) + assert isinstance(default_convolution._numerical_sample_model, SampleModel) + assert ( + default_convolution._numerical_sample_model.components[0] + is default_convolution.sample_model.components[1] + ) + + assert isinstance(default_convolution._delta_sample_model, SampleModel) + assert ( + default_convolution._delta_sample_model.components[0] + is default_convolution.sample_model.components[2] + ) + assert default_convolution._convolution_plan_is_valid is True + assert default_convolution._reactions_enabled is True + + def test_convolution_plan_is_built_when_invalid(self, default_convolution): + "Test that convolution plan is built when invalid." + # WHEN + conv = default_convolution + conv._convolution_plan_is_valid = False + + # THEN EXPECT + with patch.object(conv, "_build_convolution_plan") as build_plan: + conv.convolution() + build_plan.assert_called_once() + + def test_convolution_calls_analytical_convolver(self, default_convolution): + "Test that convolution calls analytical convolver when analytical components are present." + # WHEN + conv = default_convolution + + # THEN EXPECT + with patch.object( + conv._analytical_convolver, "convolution", return_value=np.array([1.0]) + ) as analytical_conv: + conv.convolution() + analytical_conv.assert_called_once() + + def test_convolution_calls_numerical_convolver(self, default_convolution): + "Test that convolution calls numerical convolver when numerical components are present." + # WHEN + conv = default_convolution + + # THEN EXPECT + with patch.object( + conv._numerical_convolver, "convolution", return_value=np.array([1.0]) + ) as numerical_conv: + conv.convolution() + numerical_conv.assert_called_once() + + def test_convolution_calls_convolve_delta_functions(self, default_convolution): + "Test that convolution calls _convolve_delta_functions when delta components are present." + # WHEN + conv = default_convolution + + # THEN EXPECT + with patch.object( + conv, + "_convolve_delta_functions", + return_value=np.array([1.0]), + ) as delta_eval: + conv.convolution() + delta_eval.assert_called_once() + + @pytest.mark.parametrize( + "analytical_component", + [True, False], + ids=["with_analytical", "without_analytical"], + ) + @pytest.mark.parametrize( + "numerical_component", + [True, False], + ids=["with_numerical", "without_numerical"], + ) + @pytest.mark.parametrize( + "delta_component", [True, False], ids=["with_delta", "without_delta"] + ) + def test_convolution_calls_correct_methods( + self, + default_convolution, + analytical_component, + numerical_component, + delta_component, + ): + """ + Tests that convolution calls the correct methods depending on which component types are present. + """ + + # WHEN + conv = default_convolution + sample_model = SampleModel() + + if analytical_component: + sample_model.add_component( + Gaussian(name="Gaussian", area=1.0, center=0.0, width=0.1) + ) + + if numerical_component: + sample_model.add_component( + DampedHarmonicOscillator( + name="DampedHarmonicOscillator", area=1.0, center=1.0, width=0.1 + ) + ) + + if delta_component: + sample_model.add_component( + DeltaFunction(name="DeltaFunction", area=1.0, center=0.0) + ) + + conv.sample_model = sample_model # This updates the internal sample models + + # THEN + # Mock the methods to be tested. Use nullcontext if the component type is not present. + if analytical_component: + patch_analytical = patch.object( + conv._analytical_convolver, "convolution", return_value=np.array([1.0]) + ) + else: + patch_analytical = nullcontext() + + if numerical_component: + patch_numerical = patch.object( + conv._numerical_convolver, "convolution", return_value=np.array([1.0]) + ) + else: + patch_numerical = nullcontext() + + patch_delta = patch.object( + conv, + "_convolve_delta_functions", + return_value=np.array([1.0]), + ) + + # EXPECT + # Each method is called only if the corresponding component type is present. + with ( + patch_analytical as mock_analytical_method, + patch_numerical as mock_numerical_method, + patch_delta as mock_delta_method, + ): + conv._convolution_plan_is_valid = True + conv.convolution() + + if analytical_component: + mock_analytical_method.assert_called_once() + else: + assert conv._analytical_convolver is None + + if numerical_component: + mock_numerical_method.assert_called_once() + else: + assert conv._numerical_convolver is None + + if delta_component: + mock_delta_method.assert_called_once() + else: + mock_delta_method.assert_not_called() + + def test_convolve_delta_functions(self, default_convolution): + "Test that _convolve_delta_functions returns expected values." + # WHEN + conv = default_convolution + + # THEN + result = conv._convolve_delta_functions() + + # EXPECT + expected_area = 2.0 * 3.0 # Delta area * Resolution area + expected_center = 0.3 + 0.2 # Delta center + Resolution center + expected_width = 0.5 # Resolution width + expected_values = Gaussian( + name="ExpectedGaussian", + area=expected_area, + center=expected_center, + width=expected_width, + ).evaluate(conv.energy.values) + + assert np.allclose(result, expected_values) + + # List of analytic functions + analytic_functions = [ + Gaussian(name="G", area=1.0, center=0.0, width=0.1), + Lorentzian(name="L", area=1.0, center=0.0, width=0.1), + Voigt(name="V", area=1.0, center=0.0, gaussian_width=0.1, lorentzian_width=0.1), + ] + + # List of non-analytic functions + non_analytic_functions = [ + DampedHarmonicOscillator(name="DHO", area=1.0, center=1.0, width=0.1), + Polynomial(name="P", coefficients=[1.0, 0.0, 0.0]), + ] + + all_functions_except_delta = analytic_functions + non_analytic_functions + all_functions = all_functions_except_delta + [ + DeltaFunction(name="Delta", area=1.0, center=0.0) + ] + + @pytest.mark.parametrize( + "function1", all_functions, ids=lambda f: f.__class__.__name__ + ) + @pytest.mark.parametrize( + "function2", all_functions_except_delta, ids=lambda f: f.__class__.__name__ + ) + def test_check_if_pair_is_analytic(self, default_convolution, function1, function2): + """ + Test _check_if_pair_is_analytic for all combinations. + Analytic functions combined → True, otherwise False. + """ + # WHEN + conv = default_convolution + + # THEN + # skip delta function in resolution + result = conv._check_if_pair_is_analytic( + sample_component=function1, resolution_component=function2 + ) + + # EXPECT + # Determine expected result + is_analytic1 = isinstance(function1, (Gaussian, Lorentzian, Voigt)) + is_analytic2 = isinstance(function2, (Gaussian, Lorentzian, Voigt)) + expected = is_analytic1 and is_analytic2 + assert result == expected + + def test_check_if_pair_is_analytic_raises_with_delta_in_resolution( + self, default_convolution + ): + """ + Test that _check_if_pair_is_analytic raises TypeError when resolution component is DeltaFunction. + """ + # WHEN + conv = default_convolution + sample_component = Gaussian(name="G", area=1.0, center=0.0, width=0.1) + resolution_component = DeltaFunction(name="Delta", area=1.0, center=0.0) + + # THEN EXPECT + with pytest.raises( + TypeError, + match="This is not supported", + ): + conv._check_if_pair_is_analytic( + sample_component=sample_component, + resolution_component=resolution_component, + ) + + @pytest.mark.parametrize( + "sample_component,resolution_component", + [ + ("NotAModelComponent", Gaussian(name="G", area=1.0, center=0.0, width=0.1)), + (Gaussian(name="G", area=1.0, center=0.0, width=0.1), "NotAModelComponent"), + ], + ids=["invalid_sample_component", "invalid_resolution_component"], + ) + def test_check_if_pair_is_analytic_raises_with_invalid_types( + self, default_convolution, sample_component, resolution_component + ): + """ + Test that _check_if_pair_is_analytic raises TypeError when given invalid component types. + """ + # WHEN + conv = default_convolution + + # THEN EXPECT + with pytest.raises( + TypeError, + match="must be a ModelComponent", + ): + conv._check_if_pair_is_analytic( + sample_component=sample_component, + resolution_component=resolution_component, + ) + + @pytest.mark.parametrize( + "analytical_component", + [True, False], + ids=["with_analytical", "without_analytical"], + ) + @pytest.mark.parametrize( + "numerical_component", + [True, False], + ids=["with_numerical", "without_numerical"], + ) + @pytest.mark.parametrize( + "delta_component", [True, False], ids=["with_delta", "without_delta"] + ) + @pytest.mark.parametrize( + "temperature", [None, 100], ids=["with_temperature", "without_temperature"] + ) + def test_build_convolution_plan( + self, + default_convolution, + analytical_component, + numerical_component, + delta_component, + temperature, + ): + """ + Tests that convolution calls the correct methods depending on which component types are present. + """ + + # WHEN + conv = default_convolution + sample_model = SampleModel() + + if analytical_component: + sample_model.add_component( + Gaussian(name="Gaussian", area=1.0, center=0.0, width=0.1) + ) + + if numerical_component: + sample_model.add_component( + DampedHarmonicOscillator( + name="DampedHarmonicOscillator", area=1.0, center=1.0, width=0.1 + ) + ) + + if delta_component: + sample_model.add_component( + DeltaFunction(name="DeltaFunction", area=1.0, center=0.0) + ) + + # THEN + conv.sample_model = sample_model # This updates the internal sample models + if temperature is not None: + conv.temperature = temperature + conv._build_convolution_plan() # It is already called by sample_model setter, but we now call it explicitly + + # EXPECT + assert isinstance(conv._analytical_sample_model, SampleModel) + if analytical_component and not temperature: + assert len(conv._analytical_sample_model.components) == 1 + assert conv._analytical_sample_model.components[0].name == "Gaussian" + else: + assert len(conv._analytical_sample_model.components) == 0 + + assert isinstance(conv._delta_sample_model, SampleModel) + if delta_component: + assert len(conv._delta_sample_model.components) == 1 + assert conv._delta_sample_model.components[0].name == "DeltaFunction" + else: + assert len(conv._delta_sample_model.components) == 0 + + assert isinstance(conv._numerical_sample_model, SampleModel) + + if not temperature: + if numerical_component: + assert len(conv._numerical_sample_model.components) == 1 + assert ( + conv._numerical_sample_model.components[0].name + == "DampedHarmonicOscillator" + ) + else: + assert len(conv._numerical_sample_model.components) == 0 + else: + # analytical and numerical components go to numerical when temperature is set + expected_numerical_count = 0 + if numerical_component: + expected_numerical_count += 1 + if analytical_component: + expected_numerical_count += 1 + assert ( + len(conv._numerical_sample_model.components) == expected_numerical_count + ) + + assert conv._convolution_plan_is_valid is True + + @pytest.mark.parametrize( + "analytical_component", + [True, False], + ids=["with_analytical", "without_analytical"], + ) + @pytest.mark.parametrize( + "numerical_component", + [True, False], + ids=["with_numerical", "without_numerical"], + ) + def test_set_convolvers( + self, + default_convolution, + analytical_component, + numerical_component, + ): + """ + Tests that convolution sets the correct methods depending on which component types are present. + """ + + # WHEN + conv = default_convolution + sample_model = SampleModel() + + if analytical_component: + sample_model.add_component( + Gaussian(name="Gaussian", area=1.0, center=0.0, width=0.1) + ) + + if numerical_component: + sample_model.add_component( + DampedHarmonicOscillator( + name="DampedHarmonicOscillator", area=1.0, center=1.0, width=0.1 + ) + ) + + # THEN + conv.sample_model = sample_model # This updates the internal sample models + conv._set_convolvers() # Should already have been called by sample_model setter, but we now call it explicitly + + # EXPECT + if analytical_component: + assert isinstance(conv._analytical_convolver, AnalyticalConvolution) + else: + assert conv._analytical_convolver is None + + if numerical_component: + assert isinstance(conv._numerical_convolver, NumericalConvolution) + else: + assert conv._numerical_convolver is None diff --git a/tests/unit_tests/convolution/test_convolution_base.py b/tests/unit_tests/convolution/test_convolution_base.py new file mode 100644 index 0000000..ca1348f --- /dev/null +++ b/tests/unit_tests/convolution/test_convolution_base.py @@ -0,0 +1,204 @@ +import numpy as np +import pytest +import scipp as sc + +from easydynamics.convolution.convolution_base import ( + ConvolutionBase, +) +from easydynamics.sample_model import SampleModel + + +class TestConvolutionBase: + @pytest.fixture + def convolution_base(self): + energy = np.linspace(-10, 10, 100) + sample_model = SampleModel(name="SampleModel") + resolution_model = SampleModel(name="ResolutionModel") + + return ConvolutionBase( + energy=energy, + sample_model=sample_model, + resolution_model=resolution_model, + ) + + def test_init(self, convolution_base): + # WHEN THEN EXPECT + assert isinstance(convolution_base, ConvolutionBase) + assert isinstance(convolution_base.energy, sc.Variable) + assert np.allclose(convolution_base.energy.values, np.linspace(-10, 10, 100)) + assert isinstance(convolution_base._sample_model, SampleModel) + assert isinstance(convolution_base._resolution_model, SampleModel) + + def test_init_energy_numerical_none_offset(self): + # WHEN + energy = 1 + + convolution_base = ConvolutionBase( + energy=energy, + ) + + # THEN EXPECT + assert isinstance(convolution_base, ConvolutionBase) + assert isinstance(convolution_base.energy, sc.Variable) + assert convolution_base.energy.values == np.array([1.0]) + assert convolution_base.energy.unit == "meV" + assert convolution_base._sample_model is None + assert convolution_base._resolution_model is None + + @pytest.mark.parametrize( + "kwargs, expected_message", + [ + ( + { + "energy": "invalid", + "sample_model": SampleModel(), + "resolution_model": SampleModel(), + "energy_unit": "meV", + }, + "Energy must be", + ), + ( + { + "energy": np.linspace(-10, 10, 100), + "sample_model": "invalid", + "resolution_model": SampleModel(), + "energy_unit": "meV", + }, + "`sample_model` is an instance of str, but must be a SampleModel or ModelComponent.", + ), + ( + { + "energy": np.linspace(-10, 10, 100), + "sample_model": SampleModel(), + "resolution_model": "invalid", + "energy_unit": "meV", + }, + "`resolution_model` is an instance of str, but must be a SampleModel or ModelComponent.", + ), + ( + { + "energy": np.linspace(-10, 10, 100), + "sample_model": SampleModel(), + "resolution_model": SampleModel(), + "energy_unit": 123, + }, + "Energy_unit must be ", + ), + ], + ) + def test_input_type_validation_raises(self, kwargs, expected_message): + # WHEN THEN EXPECT + with pytest.raises(TypeError, match=expected_message): + ConvolutionBase(**kwargs) + + @pytest.mark.parametrize( + "energy, expected_energy", + [ + ( + 1, + sc.array(dims=["energy"], values=[1.0], unit="meV"), + ), + ( + 1.0, + sc.array(dims=["energy"], values=[1.0], unit="meV"), + ), + ( + np.linspace(-5, 5, 50), + sc.array(dims=["energy"], values=np.linspace(-5, 5, 50), unit="meV"), + ), + ( + sc.array(dims=["energy"], values=np.linspace(-5, 5, 50), unit="meV"), + sc.array(dims=["energy"], values=np.linspace(-5, 5, 50), unit="meV"), + ), + ], + ids=["int", "float", "np.ndarray", "scipp.Variable"], + ) + def test_energy_setter(self, convolution_base, energy, expected_energy): + # WHEN + convolution_base.energy = energy + + # THEN + assert sc.identical(convolution_base.energy, expected_energy) + + def test_energy_setter_invalid_type_raises(self, convolution_base): + # WHEN THEN EXPECT + with pytest.raises( + TypeError, + match="Energy must be a Number, a numpy ndarray or a scipp Variable.", + ): + convolution_base.energy = "invalid" + + def test_energy_unit_property(self, convolution_base): + # WHEN THEN EXPECT + assert convolution_base.energy.unit == "meV" + + def test_energy_unit_setter_raises(self, convolution_base): + # WHEN THEN EXPECT + with pytest.raises( + AttributeError, + match="Use convert_unit to change the unit between allowed types ", + ): + convolution_base.energy_unit = "K" + + def test_convert_energy_unit(self, convolution_base): + # WHEN THEN + convolution_base.convert_energy_unit("eV") + + # EXPECT + assert convolution_base.energy.unit == "eV" + assert convolution_base.energy_unit == "eV" + assert np.allclose( + convolution_base.energy.values, np.linspace(-0.01, 0.01, 100) + ) + + def test_convert_energy_unit_invalid_type_raises(self, convolution_base): + # WHEN THEN EXPECT + with pytest.raises( + TypeError, + match="Energy unit must be a string or scipp unit.", + ): + convolution_base.convert_energy_unit(123) + + def test_sample_model_property(self, convolution_base): + # WHEN THEN EXPECT + assert isinstance(convolution_base.sample_model, SampleModel) + + def test_sample_model_setter(self, convolution_base): + # WHEN + new_sample_model = SampleModel(name="NewSampleModel") + + # THEN + convolution_base.sample_model = new_sample_model + + # EXPECT + assert convolution_base.sample_model == new_sample_model + + def test_sample_model_setter_invalid_type_raises(self, convolution_base): + # WHEN THEN EXPECT + with pytest.raises( + TypeError, + match="`sample_model` is an instance of str, but must be a SampleModel or ModelComponent.", + ): + convolution_base.sample_model = "invalid" + + def test_resolution_model_property(self, convolution_base): + # WHEN THEN EXPECT + assert isinstance(convolution_base.resolution_model, SampleModel) + + def test_resolution_model_setter(self, convolution_base): + # WHEN + new_resolution_model = SampleModel(name="NewResolutionModel") + + # THEN + convolution_base.resolution_model = new_resolution_model + + # EXPECT + assert convolution_base.resolution_model == new_resolution_model + + def test_resolution_model_setter_invalid_type_raises(self, convolution_base): + # WHEN THEN EXPECT + with pytest.raises( + TypeError, + match="`resolution_model` is an instance of str, but must be a SampleModel or ModelComponent.", + ): + convolution_base.resolution_model = "invalid" diff --git a/tests/unit_tests/convolution/test_energy_grid.py b/tests/unit_tests/convolution/test_energy_grid.py new file mode 100644 index 0000000..a66b714 --- /dev/null +++ b/tests/unit_tests/convolution/test_energy_grid.py @@ -0,0 +1,26 @@ +import numpy as np + +from easydynamics.convolution.energy_grid import EnergyGrid + + +class TestEnergyGrid: + def test_energy_grid_attributes(self): + energy_dense = np.array([-1.0, 0.0, 1.0]) + energy_dense_centered = np.array([-1.0, 0.0, 1.0]) + energy_dense_step = 1.0 + energy_span_dense = 2.0 + energy_even_length_offset = 0.0 + + energy_grid = EnergyGrid( + energy_dense=energy_dense, + energy_span_dense=energy_span_dense, + energy_even_length_offset=energy_even_length_offset, + energy_dense_centered=energy_dense_centered, + energy_dense_step=energy_dense_step, + ) + + assert np.array_equal(energy_grid.energy_dense, energy_dense) + assert np.array_equal(energy_grid.energy_dense_centered, energy_dense_centered) + assert energy_grid.energy_dense_step == energy_dense_step + assert energy_grid.energy_span_dense == energy_span_dense + assert energy_grid.energy_even_length_offset == energy_even_length_offset diff --git a/tests/unit_tests/convolution/test_numerical_convolution.py b/tests/unit_tests/convolution/test_numerical_convolution.py new file mode 100644 index 0000000..eb98ca8 --- /dev/null +++ b/tests/unit_tests/convolution/test_numerical_convolution.py @@ -0,0 +1,102 @@ +import numpy as np +import pytest +import scipp as sc +from scipy.signal import fftconvolve + +from easydynamics.convolution.energy_grid import EnergyGrid +from easydynamics.convolution.numerical_convolution import ( + NumericalConvolution, +) +from easydynamics.sample_model import Gaussian, SampleModel +from easydynamics.utils.detailed_balance import ( + _detailed_balance_factor as detailed_balance_factor, +) + + +class TestNumericalConvolution: + @pytest.fixture + def default_numerical_convolution(self): + energy = np.linspace(-10, 10, 5001) + sample_model = SampleModel(name="SampleModel") + sample_model.add_component( + Gaussian(name="Gaussian1", area=2.0, center=0.1, width=0.4) + ) + resolution_model = SampleModel(name="ResolutionModel") + resolution_model.add_component( + Gaussian(name="GaussianRes", area=3.0, center=0.2, width=0.5) + ) + + return NumericalConvolution( + energy=energy, + sample_model=sample_model, + resolution_model=resolution_model, + ) + + def test_init(self, default_numerical_convolution): + "Test initialization of NumericalConvolution with default parameters." + # WHEN THEN EXPECT + assert isinstance(default_numerical_convolution, NumericalConvolution) + assert isinstance(default_numerical_convolution.energy, sc.Variable) + assert np.allclose( + default_numerical_convolution.energy.values, np.linspace(-10, 10, 5001) + ) + assert isinstance(default_numerical_convolution._sample_model, SampleModel) + assert isinstance(default_numerical_convolution._resolution_model, SampleModel) + assert default_numerical_convolution.upsample_factor == 5 + assert default_numerical_convolution.extension_factor == 0.2 + assert default_numerical_convolution.temperature is None + assert default_numerical_convolution.energy_unit == "meV" + assert default_numerical_convolution.normalize_detailed_balance is True + assert isinstance(default_numerical_convolution._energy_grid, EnergyGrid) + + @pytest.mark.parametrize("upsample_factor", [None, 5]) + def test_convolution(self, default_numerical_convolution, upsample_factor): + "Test that convolution of two Gaussians produces the expected result." + # WHEN THEN + default_numerical_convolution.upsample_factor = upsample_factor + result = default_numerical_convolution.convolution() + + # EXPECT + expected_area = 2.0 * 3.0 # area of sample_model * area of resolution_model + expected_center = ( + 0.1 + 0.2 + ) # center of sample_model + center of resolution_model + expected_width = np.sqrt(0.4**2 + 0.5**2) # sqrt(width_sample^2 + width_res^2) + expected_result = Gaussian( + name="ExpectedConvolution", + area=expected_area, + center=expected_center, + width=expected_width, + ).evaluate(default_numerical_convolution.energy) + assert np.allclose(result, expected_result, rtol=1e-4) + + def test_convolution_with_temperature( + self, + default_numerical_convolution, + ): + "Test that convolution includes detailed balance correction when temperature is provided." + + # WHEN + default_numerical_convolution.temperature = 5.0 # Kelvin + + # THEN + result = default_numerical_convolution.convolution() + + # EXPECT + sample_valds = default_numerical_convolution._sample_model.evaluate( + default_numerical_convolution.energy.values + ) + resolution_vals = default_numerical_convolution._resolution_model.evaluate( + default_numerical_convolution.energy.values + ) + DBF = detailed_balance_factor( + energy=default_numerical_convolution.energy, temperature=5.0 + ) + expected_result = fftconvolve( + sample_valds * DBF, resolution_vals, mode="same" + ) * ( + default_numerical_convolution.energy.values[1] + - default_numerical_convolution.energy.values[0] + ) + + assert np.allclose(result, expected_result, rtol=1e-4) diff --git a/tests/unit_tests/convolution/test_numerical_convolution_base.py b/tests/unit_tests/convolution/test_numerical_convolution_base.py new file mode 100644 index 0000000..71ec13a --- /dev/null +++ b/tests/unit_tests/convolution/test_numerical_convolution_base.py @@ -0,0 +1,446 @@ +import numpy as np +import pytest +import scipp as sc +from easyscience.variable import Parameter + +from easydynamics.convolution.energy_grid import EnergyGrid +from easydynamics.convolution.numerical_convolution_base import ( + NumericalConvolutionBase, +) +from easydynamics.sample_model import Gaussian, SampleModel + + +class TestNumericalConvolutionBase: + @pytest.fixture + def default_numerical_convolution_base(self): + energy = np.linspace(-10, 10, 101) + sample_model = SampleModel(name="SampleModel") + resolution_model = SampleModel(name="ResolutionModel") + + return NumericalConvolutionBase( + energy=energy, + sample_model=sample_model, + resolution_model=resolution_model, + ) + + def test_init(self, default_numerical_convolution_base): + "Test initialization of NumericalConvolutionBase with default parameters." + # WHEN THEN EXPECT + assert isinstance(default_numerical_convolution_base, NumericalConvolutionBase) + assert isinstance(default_numerical_convolution_base.energy, sc.Variable) + assert np.allclose( + default_numerical_convolution_base.energy.values, np.linspace(-10, 10, 101) + ) + assert isinstance(default_numerical_convolution_base._sample_model, SampleModel) + assert isinstance( + default_numerical_convolution_base._resolution_model, SampleModel + ) + assert default_numerical_convolution_base.upsample_factor == 5 + assert default_numerical_convolution_base.extension_factor == 0.2 + assert default_numerical_convolution_base.temperature is None + assert default_numerical_convolution_base.energy_unit == "meV" + assert default_numerical_convolution_base.normalize_detailed_balance is True + assert isinstance(default_numerical_convolution_base._energy_grid, EnergyGrid) + + def test_init_with_custom_parameters(self): + "Test initialization of NumericalConvolutionBase with custom parameters." + # WHEN + energy = np.linspace(-5, 5, 50) + sample_model = SampleModel(name="SampleModel") + resolution_model = SampleModel(name="ResolutionModel") + upsample_factor = 10 + extension_factor = 0.5 + temperature = 300.0 + temperature_unit = "K" + energy_unit = "meV" + normalize_detailed_balance = False + + # THEN + numerical_convolution_base = NumericalConvolutionBase( + energy=energy, + sample_model=sample_model, + resolution_model=resolution_model, + upsample_factor=upsample_factor, + extension_factor=extension_factor, + temperature=temperature, + temperature_unit=temperature_unit, + energy_unit=energy_unit, + normalize_detailed_balance=normalize_detailed_balance, + ) + + # EXPECT + assert numerical_convolution_base.upsample_factor == upsample_factor + assert numerical_convolution_base.extension_factor == extension_factor + assert numerical_convolution_base.temperature.value == temperature + assert numerical_convolution_base.temperature.unit == temperature_unit + assert numerical_convolution_base.energy_unit == energy_unit + assert ( + numerical_convolution_base.normalize_detailed_balance + == normalize_detailed_balance + ) + assert isinstance(numerical_convolution_base._energy_grid, EnergyGrid) + + def test_init_raises_type_error_for_invalid_temperature(self): + "Test that initialization raises TypeError for invalid temperature." + # WHEN + energy = np.linspace(-5, 5, 50) + sample_model = SampleModel(name="SampleModel") + resolution_model = SampleModel(name="ResolutionModel") + invalid_temperature = "invalid_temperature" + + # THEN EXPECT + with pytest.raises( + TypeError, match="Temperature must be None, a number or a Parameter." + ): + NumericalConvolutionBase( + energy=energy, + sample_model=sample_model, + resolution_model=resolution_model, + temperature=invalid_temperature, + ) + + def test_init_raises_type_error_for_invalid_temperature_unit(self): + "Test that initialization raises TypeError for invalid temperature_unit." + # WHEN + energy = np.linspace(-5, 5, 50) + sample_model = SampleModel(name="SampleModel") + resolution_model = SampleModel(name="ResolutionModel") + invalid_temperature_unit = 123 # Not a string or sc.Unit + + # THEN EXPECT + with pytest.raises( + TypeError, match="Temperature_unit must be a string or sc.Unit." + ): + NumericalConvolutionBase( + energy=energy, + sample_model=sample_model, + resolution_model=resolution_model, + temperature_unit=invalid_temperature_unit, + ) + + def test_energy_setter(self, default_numerical_convolution_base): + "Test setting a new energy array updates the energy grid accordingly." + # WHEN + new_energy = np.linspace(-20, 20, 201) + default_numerical_convolution_base.energy = new_energy + + # THEN EXPECT + assert isinstance(default_numerical_convolution_base.energy, sc.Variable) + assert np.allclose(default_numerical_convolution_base.energy.values, new_energy) + assert default_numerical_convolution_base._energy_grid.energy_dense.shape[ + 0 + ] == round(201 * default_numerical_convolution_base.upsample_factor) + + def test_upsample_factor_setter(self, default_numerical_convolution_base): + "Test setting a new upsample factor updates the energy grid accordingly." + # WHEN + new_upsample_factor = 10 + default_numerical_convolution_base.upsample_factor = new_upsample_factor + + # THEN EXPECT + assert default_numerical_convolution_base.upsample_factor == new_upsample_factor + assert default_numerical_convolution_base._energy_grid.energy_dense.shape[ + 0 + ] == round(101 * new_upsample_factor) + + def test_upsample_factor_setter_none(self, default_numerical_convolution_base): + "Test setting upsample factor to None disables upsampling." + # WHEN + new_upsample_factor = None + default_numerical_convolution_base.upsample_factor = new_upsample_factor + + # THEN EXPECT + assert default_numerical_convolution_base.upsample_factor == new_upsample_factor + assert ( + default_numerical_convolution_base._energy_grid.energy_dense.shape[0] == 101 + ) + + @pytest.mark.parametrize( + "invalid_upsample_factor, expected_exception", + [ + (-1, ValueError), # numeric < 1 → ValueError + (0, ValueError), # numeric < 1 → ValueError + (1.0, ValueError), # numeric = 1 → ValueError + ("invalid", TypeError), # non-numeric → TypeError + ], + ids=["negative", "zero", "one", "string"], + ) + def test_upsample_setter_raises( + self, + default_numerical_convolution_base, + invalid_upsample_factor, + expected_exception, + ): + "Test that setting invalid upsample factors raises appropriate exceptions." + # WHEN THEN EXPECT + with pytest.raises( + expected_exception, + ): + default_numerical_convolution_base.upsample_factor = invalid_upsample_factor + + def test_extension_factor_setter(self, default_numerical_convolution_base): + "Test setting a new extension factor updates the energy grid accordingly." + # WHEN + new_extension_factor = 0.5 + default_numerical_convolution_base.extension_factor = new_extension_factor + + # THEN EXPECT + assert ( + default_numerical_convolution_base.extension_factor == new_extension_factor + ) + expected_span = 20 + (0.5 * 20) # original span + extension + assert np.isclose( + default_numerical_convolution_base._energy_grid.energy_span_dense, + expected_span, + ) + + @pytest.mark.parametrize( + "invalid_extension_factor, expected_exception", + [ + (-0.1, ValueError), # negative → ValueError + ("invalid", TypeError), # non-numeric → TypeError + ], + ids=["negative", "string"], + ) + def test_extension_factor_setter_raises( + self, + default_numerical_convolution_base, + invalid_extension_factor, + expected_exception, + ): + "Test that setting invalid extension factors raises appropriate exceptions." + + # WHEN THEN EXPECT + with pytest.raises( + expected_exception, + ): + default_numerical_convolution_base.extension_factor = ( + invalid_extension_factor + ) + + @pytest.mark.parametrize( + "temperature_input, expected_value", + [ + (1, 1.0), + (100.0, 100.0), + (Parameter(name="TempParam", value=250.0, unit="K"), 250.0), + ], + ids=["int", "float", "parameter"], + ) + def test_temperature_setter( + self, default_numerical_convolution_base, temperature_input, expected_value + ): + "Test setting various valid temperature inputs." + # WHEN + default_numerical_convolution_base.temperature = temperature_input + + # THEN EXPECT + assert default_numerical_convolution_base.temperature.value == expected_value + assert default_numerical_convolution_base.temperature.unit == "K" + + def test_temperature_setter_none(self, default_numerical_convolution_base): + "Test setting temperature to None." + # WHEN + default_numerical_convolution_base.temperature = None + + # THEN EXPECT + assert default_numerical_convolution_base.temperature is None + + def test_temperature_setter_does_not_replace_parameter( + self, default_numerical_convolution_base + ): + "Test that if setting the temperature to a value when it already exists does not create a new Parameter" + # WHEN + temp_param = Parameter(name="TempParam", value=300.0, unit="K") + default_numerical_convolution_base.temperature = temp_param + + # THEN + default_numerical_convolution_base.temperature = 350.0 + + # EXPECT + assert default_numerical_convolution_base.temperature is temp_param + assert default_numerical_convolution_base.temperature.value == 350.0 + + def test_temperature_setter_raises(self, default_numerical_convolution_base): + "Test that setting an invalid temperature raises TypeError." + # WHEN THEN EXPECT + with pytest.raises(TypeError, match="Temperature must be"): + default_numerical_convolution_base.temperature = "invalid_temperature" + + def test_normalize_detailed_balance_setter( + self, default_numerical_convolution_base + ): + "Test setting normalize_detailed_balance to False." + # WHEN + default_numerical_convolution_base.normalize_detailed_balance = False + + # THEN EXPECT + assert default_numerical_convolution_base.normalize_detailed_balance is False + + def test_normalize_detailed_balance_setter_raises( + self, default_numerical_convolution_base + ): + "Test that setting an invalid normalize_detailed_balance raises TypeError." + # WHEN THEN EXPECT + with pytest.raises(TypeError, match="normalize_detailed_balance must be"): + default_numerical_convolution_base.normalize_detailed_balance = "invalid" + + def test_create_energy_grid_upsample_none(self, default_numerical_convolution_base): + "Test creating energy grid with upsample_factor set to None (no upsampling). In this case, the energy grid is equal to the input energy" + # WHEN + default_numerical_convolution_base.upsample_factor = None + energy_grid = default_numerical_convolution_base._create_energy_grid() + + # THEN EXPECT + assert isinstance(energy_grid, EnergyGrid) + assert np.allclose( + energy_grid.energy_dense, + np.linspace(-10, 10, 101), # no extension + ) + assert np.allclose(energy_grid.energy_dense_centered, np.linspace(-10, 10, 101)) + assert np.isclose(energy_grid.energy_dense_step, 0.2) + assert np.isclose(energy_grid.energy_span_dense, 20.0) + assert np.isclose(energy_grid.energy_even_length_offset, 0.0) + + def test_create_energy_grid_upsample_none_non_uniform_raises( + self, default_numerical_convolution_base + ): + "Test that creating energy grid with upsample_factor set to None and non-uniform energy raises ValueError (the energy grid must always be uniform)." + # WHEN + default_numerical_convolution_base.energy = np.array([0, 1, 3, 6, 10]) + with pytest.raises( + ValueError, + match="Input array `energy` must be uniformly spaced if upsample_factor is not given", + ): + default_numerical_convolution_base.upsample_factor = None + + @pytest.mark.parametrize("num_points", [100, 101], ids=["even", "odd"]) + def test_create_energy_grid_upsample_and_extension( + self, default_numerical_convolution_base, num_points + ): + "Test creating energy grid with upsampling and extension. TThe even_length_offset is tested for both even and odd number of input energy points." + # WHEN + default_numerical_convolution_base.energy = np.linspace(-10, 10, num_points) + + # THEN + energy_grid = default_numerical_convolution_base._create_energy_grid() + + # EXPECT + assert isinstance(energy_grid, EnergyGrid) + + expected_extended_energy = np.linspace( + -12, + 12, + round(num_points * default_numerical_convolution_base.upsample_factor), + ) + assert np.allclose(energy_grid.energy_dense, expected_extended_energy) + + expected_centered_energy = expected_extended_energy + assert np.allclose(energy_grid.energy_dense_centered, expected_centered_energy) + + expected_step = expected_extended_energy[1] - expected_extended_energy[0] + assert np.isclose(energy_grid.energy_dense_step, expected_step) + + expected_span = 24.0 # original span + extension + assert np.isclose(energy_grid.energy_span_dense, expected_span) + + if num_points % 2 == 0: + expected_offset = -expected_step / 2 + assert np.isclose(energy_grid.energy_even_length_offset, expected_offset) + else: + assert np.isclose(energy_grid.energy_even_length_offset, 0.0) + + def test_create_energy_grid_non_centered_energy( + self, default_numerical_convolution_base + ): + "Test creating energy grid when input energy is not centered around zero. The centered energy grid should be shifted accordingly." + # WHEN + default_numerical_convolution_base.energy = np.linspace(5, 25, 101) + energy_grid = default_numerical_convolution_base._create_energy_grid() + + # THEN EXPECT + assert isinstance(energy_grid, EnergyGrid) + + expected_extended_energy = np.linspace( + 3, + 27, + round(101 * default_numerical_convolution_base.upsample_factor), + ) + assert np.allclose(energy_grid.energy_dense, expected_extended_energy) + + expected_centered_energy = expected_extended_energy - 15.0 + assert np.allclose(energy_grid.energy_dense_centered, expected_centered_energy) + + expected_step = expected_extended_energy[1] - expected_extended_energy[0] + assert np.isclose(energy_grid.energy_dense_step, expected_step) + + expected_span = 24.0 # original span + extension + assert np.isclose(energy_grid.energy_span_dense, expected_span) + + assert np.isclose(energy_grid.energy_even_length_offset, 0.0) + + def test_check_width_large_threshold(self, default_numerical_convolution_base): + "Test that _check_width_thresholds warns when model widths are too large compared to energy grid span." + # WHEN + wide_gaussian = Gaussian(name="SampleModel", area=1.0, center=0.0, width=15.0) + + # THEN EXPECT + with pytest.warns( + UserWarning, + match="Increase extension_factor to improve", + ): + default_numerical_convolution_base._check_width_thresholds( + model=wide_gaussian, + model_name="SampleModel", + ) + + def test_check_width_small_threshold(self, default_numerical_convolution_base): + "Test that _check_width_thresholds warns when model widths are too small compared to energy grid step." + # WHEN + narrow_gaussian = Gaussian(name="SampleModel", area=1.0, center=0.0, width=0.01) + + # THEN EXPECT + with pytest.warns( + UserWarning, + match="Increase upsample_factor to improve", + ): + default_numerical_convolution_base._check_width_thresholds( + model=narrow_gaussian, + model_name="SampleModel", + ) + + def test_check_width_no_warnings(self, default_numerical_convolution_base): + "Test that _check_width_thresholds does not warn when model widths are within acceptable range. Also tests that SampleModel components are checked correctly." + # WHEN + good_gaussian = Gaussian(name="SampleModel", area=1.0, center=0.0, width=1.0) + sample_model = SampleModel(name="SampleModel") + sample_model.add_component(good_gaussian) + + # THEN EXPECT + default_numerical_convolution_base._check_width_thresholds( + model=sample_model, + model_name="SampleModel", + ) + + def test_repr(self, default_numerical_convolution_base): + "Test the __repr__ method of NumericalConvolutionBase." + # WHEN + repr_str = repr(default_numerical_convolution_base) + + # THEN EXPECT + assert "NumericalConvolutionBase(" in repr_str + assert "energy=array of shape" in repr_str + assert "(101," in repr_str # correct shape + + # Sample and resolution models + assert "SampleModel" in repr_str + assert "Components: No components" in repr_str + assert "sample_model=" in repr_str + assert "resolution_model=" in repr_str + + # Important parameters + assert "energy_unit=meV" in repr_str + assert "upsample_factor=5" in repr_str + assert "extension_factor=0.2" in repr_str + assert "temperature=None" in repr_str + assert "normalize_detailed_balance=True" in repr_str diff --git a/tests/unit_tests/sample_model/test_sample_model.py b/tests/unit_tests/sample_model/test_sample_model.py new file mode 100644 index 0000000..19d1042 --- /dev/null +++ b/tests/unit_tests/sample_model/test_sample_model.py @@ -0,0 +1,318 @@ +from copy import copy + +import numpy as np +import pytest +from easyscience.variable import Parameter +from scipy.integrate import simpson + +from easydynamics.sample_model import Gaussian, Lorentzian, Polynomial, SampleModel + + +class TestSampleModel: + @pytest.fixture + def sample_model(self): + model = SampleModel(name="TestSampleModel") + component1 = Gaussian( + name="TestGaussian1", area=1.0, center=0.0, width=1.0, unit="meV" + ) + component2 = Lorentzian( + name="TestLorentzian1", area=2.0, center=1.0, width=0.5, unit="meV" + ) + model.add_component(component1) + model.add_component(component2) + return model + + def test_init(self): + # WHEN THEN + sample_model = SampleModel(name="InitModel") + + # EXPECT + assert sample_model.name == "InitModel" + assert len(sample_model.components) == 0 + + def test_initialization_with_components(self): + # WHEN THEN + component1 = Gaussian( + name="InitGaussian", area=1.0, center=0.0, width=1.0, unit="meV" + ) + component2 = Lorentzian( + name="InitLorentzian", area=2.0, center=1.0, width=0.5, unit="meV" + ) + sample_model = SampleModel( + name="InitModelWithComponents", data=[component1, component2] + ) + + # EXPECT + assert sample_model.name == "InitModelWithComponents" + assert len(sample_model.components) == 2 + assert sample_model["InitGaussian"] is component1 + assert sample_model["InitLorentzian"] is component2 + + # ───── Component Management ───── + + def test_add_component(self, sample_model): + # WHEN + component = Gaussian( + name="TestComponent", area=1.0, center=0.0, width=1.0, unit="meV" + ) + # THEN + sample_model.add_component(component) + # EXPECT + assert sample_model["TestComponent"] is component + + def test_add_duplicate_component_raises(self, sample_model): + # WHEN THEN + component = Gaussian( + name="TestGaussian1", area=1.0, center=0.0, width=1.0, unit="meV" + ) + # EXPECT + with pytest.raises(ValueError, match="already exists"): + sample_model.add_component(component) + + def test_add_invalid_component_raises(self, sample_model): + # WHEN THEN EXPECT + with pytest.raises( + TypeError, match="component must be an instance of ModelComponent." + ): + sample_model.add_component("NotAComponent") + + def test_remove_component(self, sample_model): + # WHEN THEN + sample_model.remove_component("TestGaussian1") + # EXPECT + assert "TestGaussian1" not in sample_model.components + + def test_remove_nonexistent_component_raises(self, sample_model): + # WHEN THEN EXPECT + with pytest.raises( + KeyError, match="No component named 'NonExistentComponent' exists" + ): + sample_model.remove_component("NonExistentComponent") + + def test_getitem(self, sample_model): + # WHEN + component = Gaussian( + name="TestComponent", area=1.0, center=0.0, width=1.0, unit="meV" + ) + # THEN + sample_model.add_component(component) + # EXPECT + assert sample_model["TestComponent"] is component + + def test_list_component_names(self, sample_model): + # WHEN THEN + components = sample_model.list_component_names() + # EXPECT + assert len(components) == 2 + assert components[0] == "TestGaussian1" + assert components[1] == "TestLorentzian1" + + def test_clear_components(self, sample_model): + # WHEN THEN + sample_model.clear_components() + # EXPECT + assert len(sample_model.components) == 0 + + def test_convert_unit(self, sample_model): + # WHEN THEN + sample_model.convert_unit("eV") + # EXPECT + for component in list(sample_model): + assert component.unit == "eV" + + def test_evaluate(self, sample_model): + # WHEN + x = np.linspace(-5, 5, 100) + result = sample_model.evaluate(x) + # EXPECT + expected_result = sample_model["TestGaussian1"].evaluate(x) + sample_model[ + "TestLorentzian1" + ].evaluate(x) + np.testing.assert_allclose(result, expected_result, rtol=1e-5) + + def test_evaluate_no_components_raises(self): + # WHEN THEN + sample_model = SampleModel(name="EmptyModel") + x = np.linspace(-5, 5, 100) + # EXPECT + with pytest.raises(ValueError, match="No components in the model to evaluate."): + sample_model.evaluate(x) + + def test_evaluate_component(self, sample_model): + # WHEN THEN + x = np.linspace(-5, 5, 100) + result1 = sample_model.evaluate_component(x, "TestGaussian1") + result2 = sample_model.evaluate_component(x, "TestLorentzian1") + + # EXPECT + expected_result1 = sample_model["TestGaussian1"].evaluate(x) + expected_result2 = sample_model["TestLorentzian1"].evaluate(x) + np.testing.assert_allclose(result1, expected_result1, rtol=1e-5) + np.testing.assert_allclose(result2, expected_result2, rtol=1e-5) + + def test_evaluate_nonexistent_component_raises(self, sample_model): + # WHEN + x = np.linspace(-5, 5, 100) + + # THEN EXPECT + with pytest.raises( + KeyError, match="No component named 'NonExistentComponent' exists" + ): + sample_model.evaluate_component(x, "NonExistentComponent") + + def test_evaluate_component_no_components_raises(self): + # WHEN THEN + sample_model = SampleModel(name="EmptyModel") + x = np.linspace(-5, 5, 100) + # EXPECT + with pytest.raises(ValueError, match="No components in the model to evaluate."): + sample_model.evaluate_component(x, "AnyComponent") + + def test_evaluate_component_invalid_name_type_raises(self, sample_model): + # WHEN + x = np.linspace(-5, 5, 100) + + # THEN EXPECT + with pytest.raises( + TypeError, + match="Component name must be a string, got instead.", + ): + sample_model.evaluate_component(x, 123) + + # ───── Utilities ───── + + def test_normalize_area(self, sample_model): + # WHEN THEN + sample_model.normalize_area() + # EXPECT + x = np.linspace(-10000, 10000, 1000000) # Lorentzians have long tails + result = sample_model.evaluate(x) + numerical_area = simpson(result, x) + assert np.isclose(numerical_area, 1.0, rtol=1e-4) + + def test_normalize_area_no_components_raises(self): + # WHEN THEN + sample_model = SampleModel(name="EmptyModel") + # EXPECT + with pytest.raises( + ValueError, match="No components in the model to normalize." + ): + sample_model.normalize_area() + + @pytest.mark.parametrize( + "area_value", + [np.nan, 0.0, np.inf], + ids=["NaN area", "Zero area", "Infinite area"], + ) + def test_normalize_area_not_finite_area_raises(self, sample_model, area_value): + # WHEN THEN + sample_model["TestGaussian1"].area = area_value + sample_model["TestLorentzian1"].area = area_value + + # EXPECT + with pytest.raises(ValueError, match="cannot normalize."): + sample_model.normalize_area() + + def test_normalize_area_non_area_component_warns(self, sample_model): + # WHEN + component1 = Polynomial( + name="TestPolynomial", coefficients=[1, 2, 3], unit="meV" + ) + sample_model.add_component(component1) + + # THEN EXPECT + with pytest.warns(UserWarning, match="does not have an 'area' "): + sample_model.normalize_area() + + def test_get_parameters(self, sample_model): + # WHEN THEN + parameters = sample_model.get_parameters() + # EXPECT + assert len(parameters) == 6 + + expected_names = { + "TestGaussian1 area", + "TestGaussian1 center", + "TestGaussian1 width", + "TestLorentzian1 area", + "TestLorentzian1 center", + "TestLorentzian1 width", + } + actual_names = {param.name for param in parameters} + assert actual_names == expected_names + assert all(isinstance(param, Parameter) for param in parameters) + + def test_get_parameters_no_components(self): + sample_model = SampleModel(name="EmptyModel") + # WHEN THEN + parameters = sample_model.get_parameters() + # EXPECT + assert len(parameters) == 0 + + def test_get_fit_parameters(self, sample_model): + # WHEN + + # Fix one parameter and make another dependent + sample_model["TestGaussian1"].area.fixed = True + sample_model["TestLorentzian1"].width.make_dependent_on( + "comp1_width", + {"comp1_width": sample_model["TestGaussian1"].width}, + ) + + # THEN + fit_parameters = sample_model.get_fit_parameters() + + # EXPECT + assert len(fit_parameters) == 4 + + expected_names = { + "TestGaussian1 center", + "TestGaussian1 width", + "TestLorentzian1 area", + "TestLorentzian1 center", + } + actual_names = {param.name for param in fit_parameters} + assert actual_names == expected_names + assert all(isinstance(param, Parameter) for param in fit_parameters) + + def test_fix_and_free_all_parameters(self, sample_model): + # WHEN THEN + sample_model.fix_all_parameters() + + # EXPECT + for param in sample_model.get_parameters(): + assert param.fixed is True + + # WHEN + sample_model.free_all_parameters() + + # THEN + for param in sample_model.get_parameters(): + assert param.fixed is False + + def test_repr_contains_name_and_components(self, sample_model): + # WHEN THEN + rep = repr(sample_model) + # EXPECT + assert "SampleModel" in rep + assert "TestGaussian" in rep + + def test_copy(self, sample_model): + # WHEN THEN + sample_model.temperature = 300 + model_copy = copy(sample_model) + # EXPECT + assert model_copy is not sample_model + assert model_copy.name == sample_model.name + assert len(list(model_copy)) == len(list(sample_model)) + for comp in list(sample_model): + copied_comp = model_copy[comp.name] + assert copied_comp is not comp + assert copied_comp.name == comp.name + for param_orig, param_copy in zip( + comp.get_parameters(), copied_comp.get_parameters() + ): + assert param_copy is not param_orig + assert param_copy.name == param_orig.name + assert param_copy.value == param_orig.value + assert param_copy.fixed == param_orig.fixed