Skip to content

Commit dca90b3

Browse files
RastislavTuranyiRastislav Turanyiajjackson
authored
Implement kernels and convolutions (#39)
* Implement get_kernel * Rewrite get_characteristics interface * Position kernels at energy transfer * Implement convolve method * Move the mesh argument to last * Add missing docstrings * Change __call__ to call convolve * Update the input class variable docs * Add corresponding documentation page * Update InstrumentModel docstring * Update README * Fix convolve * Use two different get kernel methods * Fix integration tests: compare with "sigma" characteristic * Add mandatory methods to InstrumentModel mock class * Tweak pychop test for more convenient formatting Was getting some false negatives from error message formatting depending if the float was displayed as a bare float or as np.float64. That seems unimportant to the test, so cast to float() * Fix convolve to use get_peak instead of get_kernel SimpleConvolve expects the mesh to span the full energy transfer range and so does the get_peak method (get_kernel does not) * Add unit tests for mixins * Update quickstart.rst * Update README.md with get_peak changes * Update how-to guides * Fix test_mixins convolve test data generation * Add convolve result plot to docs * Fix test_convolve and add test data * Switch convolve to use einsum * Rename convolve into broaden * Clarify mixin output array shapes in docs * Update InstrumentModel docstring according to review Co-authored-by: Adam J. Jackson <a.j.jackson@physics.org> * Update InstrumentModel method docstrings to clarify array shapes * Refactor meshes to be a list of arrays * Use reshape for obtaining a 1D array of energies * Add a nice exception text * Revert to slicing * Apply suggestions from code review Co-authored-by: Adam J. Jackson <a.j.jackson@physics.org> --------- Co-authored-by: Rastislav Turanyi <rastislav.turanyi@stfc.ac.uk> Co-authored-by: Adam J. Jackson <a.j.jackson@physics.org>
1 parent 2295385 commit dca90b3

25 files changed

+634
-255
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Documentation build files
22
docs/include/auto-instruments
3+
tests/unit_tests/data/*.png
34

45
# Byte-compiled / optimized / DLL files
56
__pycache__/

README.md

Lines changed: 40 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ literature. The main purposes are:
99

1010
1. Provide one, central place implementing various models for INS instruments (resolution functions)
1111
2. Provide a simple way to obtain the broadening at a given frequency, for a given instrument and settings
12-
3. (in progress) Provide a way to apply broadening to a spectrum
12+
3. Provide a way to apply broadening to a spectrum
1313

1414
## Quick Start
1515

@@ -18,31 +18,53 @@ class and get the instrument object of your choice:
1818

1919
```
2020
>>> from resolution_functions import Instrument
21-
>>> tosca = Instrument.from_default('TOSCA')
22-
>>> tosca
23-
Instrument(name='TOSCA', version='TOSCA', models=['AbINS', 'book', 'vision'])
21+
>>> maps = Instrument.from_default('MAPS', 'MAPS')
22+
>>> print(maps)
23+
Instrument(name=MAPS, version=MAPS)
2424
```
2525

26-
To get the resolution function, call the `get_resolution_function` method, which returns a callable
27-
that can be called to get the broadening at specified frequencies. However, you will need to know
28-
which model you want to use, as well as any model-specific parameters.
26+
To get the resolution function, call the `get_resolution_function` method (providing all your
27+
choices for the required settings and configurations), which returns a callable that can be called
28+
to broaden the data.
2929

3030
```
3131
>>> # The available models for a given instrument can be queried:
32-
>>> tosca.available_models
33-
['AbINS', 'book', 'vision']
32+
>>> maps.available_models
33+
['PyChop_fit']
3434
>>> # There are multiple ways of querying the model-specific parameters, but the most comprehensive is
35-
>>> tosca.get_model_signature('book')
36-
<Signature (model_name: Optional[str] = 'book', *, detector_bank: Literal['Backward', 'Forward'] = 'Backward', _) -> resolution_functions.models.tosca_book.ToscaBookModel>
35+
>>> maps.get_model_signature('PyChop_fit')
36+
<Signature (model_name: Optional[str] = 'PyChop_fit_v1', *, chopper_package: Literal['A', 'B', 'S'] = 'A', e_init: Annotated[ForwardRef('Optional[float]'), 'restriction=[0, 2000]'] = 500, chopper_frequency: Annotated[ForwardRef('Optional[int]'), 'restriction=[50, 601, 50]'] = 400, fitting_order: 'int' = 4, _) -> resolution_functions.models.pychop.PyChopModelFermi>
3737
>>> # Now we can get the resolution function
38-
>>> book = tosca.get_resolution_function('book', detector_bank='Forward')
39-
>>> print(book)
40-
ToscaBookModel(citation=[''])
41-
>>> book(100)
42-
0.81802604002035
38+
>>> pychop = maps.get_resolution_function('PyChop_fit', chopper_package='B', e_init=500, chopper_frequency=300)
39+
>>> print(pychop)
40+
PyChopModelFermi(citation=[''])
41+
```
42+
43+
Calling the model (like a function) broadens the data at the provided combinations of energy
44+
transfer and momentum ([w, Q]), using a mesh and the corresponding data:
45+
46+
```
4347
>>> import numpy as np
44-
>>> book(np.array([100, 200, 300]))
45-
array([0.81802604, 1.34222267, 1.88255039])
48+
>>> energy_transfer = np.array([100, 200, 300])[:, np.newaxis]
49+
>>> data = np.array([0.6, 1.5, 0.9])
50+
>>> mesh = np.linspace(0, 500, 1000)
51+
>>> pychop(energy_transfer, data, mesh)
52+
array([3.43947518e-028, ... 5.99877942e-002, ... 7.31766110e-249])
53+
```
54+
55+
However, the model also provides methods that go lower;
56+
57+
- `get_kernel` computes the broadening kernel at each [w, Q] (centered on 0)
58+
- `get_peak` computes the broadening peak at each [w, Q] (centered on the [w, Q])
59+
- `get_characteristics` returns only the characteristic parameters of the kernel
60+
at each [w, Q] (such as the standard deviation of the normal distribution)
61+
62+
```
63+
>>> peaks = pychop.get_peak(energy_transfer, mesh)
64+
>>> mesh_centered_on_0 = np.linspace(-100, 100, 1000)
65+
>>> kernels = pychop.get_kernel(energy_transfer, mesh_centered_on_0)
66+
>>> pychop.get_characteristics(energy_transfer)
67+
{'sigma': array([9.15987016, 7.38868127, 5.93104319])}
4668
```
4769

4870
## Installation

docs/source/api/models/mixins.rst

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
resolution\_functions.models.mixins
2+
-------------------------------------------
3+
4+
.. automodule:: resolution_functions.models.mixins
5+
:members:
6+
:show-inheritance:
28.5 KB
Loading
32.3 KB
Loading
28.2 KB
Loading

docs/source/howtos/optimise_experiment.rst

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ which can be used as a starting point to generate some resolution data:
3939
>>> import numpy as np
4040
>>> model = maps.get_resolution_function('PyChop_fit', chopper_package='B')
4141
>>> energies = np.arange(0, data.defaults['e_init'], 0.5)
42-
>>> resolution = model(energies)
42+
>>> resolution = model.get_characteristics(energies)['sigma']
4343

4444
Plotting with matplotlib:
4545

@@ -91,7 +91,7 @@ All the data can then be generated using by loopiing over these variables:
9191
>>> for i, chopper_frequency in enumerate(test_choppers):
9292
... for j, e_init in enumerate(test_e_init):
9393
... model = maps.get_resolution_function('PyChop_fit', chopper_package='B', e_init=e_init, chopper_frequency=chopper_frequency)
94-
... results[i, j, :] = model(energy_transfer)
94+
... results[i, j, :] = model.get_characteristics(energy_transfer)['sigma']
9595

9696
This can then be plotted in various ways. For example, we can check the effect
9797
of ``e_init`` by plotting the resolution for its different values at a constant

docs/source/quickstart.rst

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ With the package :doc:`installed<installation>`, the first step is to create an
55
instance of a particular :term:`version` of a particular :term:`instrument`:
66

77
>>> from resolution_functions import Instrument
8-
>>> tosca = Instrument('TOSCA')
9-
>>> print(tosca)
10-
Instrument(name='TOSCA', version='TOSCA', models=['AbINS', 'book', 'vision'])
8+
>>> maps = Instrument.from_default('MAPS', 'MAPS')
9+
>>> print(maps)
10+
Instrument(name=MAPS, version=MAPS)
1111

1212
To get the :term:`resolution function`, a couple choices might be necessary:
1313

@@ -20,29 +20,55 @@ If we don't know what the possibilities are for the chosen instrument, the
2020
information can be found either in the :doc:`documentation<instruments>` or
2121
programmatically:
2222

23-
>>> tosca.available_models
24-
['AbINS', 'book', 'vision']
25-
>>> tosca.get_model_signature('book')
26-
<Signature (model_name: Optional[str] = 'book', *, detector_bank: Literal['Backward', 'Forward'] = 'Backward', _) -> resolution_functions.models.tosca_book.ToscaBookModel>
23+
>>> maps.available_models
24+
['PyChop_fit']
25+
>>> maps.get_model_signature('PyChop_fit')
26+
<Signature (model_name: Optional[str] = 'PyChop_fit_v1', *, chopper_package: Literal['A', 'B', 'S'] = 'A', e_init: Annotated[ForwardRef('Optional[float]'), 'restriction=[0, 2000]'] = 500, chopper_frequency: Annotated[ForwardRef('Optional[int]'), 'restriction=[50, 601, 50]'] = 400, fitting_order: 'int' = 4, _) -> resolution_functions.models.pychop.PyChopModelFermi>
2727

2828
With this, it is possible to make the choices and obtain the resolution function
2929
via the
3030
:py:meth:`~resolution_functions.instrument.Instrument.get_resolution_function`
3131
method:
3232

33-
>>> book = tosca.get_resolution_function('book', detector_bank='Forward')
33+
>>> pychop = maps.get_resolution_function('PyChop_fit', chopper_package='B', e_init=500, chopper_frequency=300)
3434
>>> print(book)
35-
ToscaBookModel(citation=[''])
35+
PyChopModelFermi(citation=[''])
3636

3737
.. note::
3838

39-
The settings *must* be passed in as keyword arguments.
39+
The settings and configurations *must* be passed in as keyword arguments.
4040

41-
The resolution function is a callable object that computes the resolution at the
42-
given energy transfer (in meV) and/or momentum:
41+
The obtained model can be called (like a function) to broaden data at the
42+
provided combinations of energy transfer and momentum ([w, Q]), using a mesh and
43+
the corresponding data:
4344

44-
>>> book(100)
45-
0.81802604002035
4645
>>> import numpy as np
47-
>>> book(np.array([100, 200, 300]))
48-
array([0.81802604, 1.34222267, 1.88255039])
46+
>>> energy_transfer = np.array([100, 200, 300])[:, np.newaxis]
47+
>>> data = np.array([0.6, 1.5, 0.9])
48+
>>> mesh = np.linspace(0, 500, 1000)
49+
>>> result = pychop(energy_transfer, data, mesh)
50+
51+
which can be plotted as:
52+
53+
.. image:: /figures/example_convolve.png
54+
55+
However, the model also provides methods for more fundamental operations: `get_kernel` computes
56+
the broadening kernel at each [w, Q] (centered on 0), `get_peak` computes the
57+
broadening peak at each [w, Q] (centered on the [w, Q]), and
58+
`get_characteristics` returns only the characteristic parameters of the kernel
59+
at each [w, Q] (such as the standard deviation of the normal distribution):
60+
61+
>>> pychop.get_characteristics(energy_transfer)
62+
{'sigma': array([9.15987016, 7.38868127, 5.93104319])}
63+
>>> peaks = pychop.get_peak(energy_transfer, mesh)
64+
65+
Can be plotted as:
66+
67+
.. image:: /figures/example_get_peak.png
68+
69+
>>> mesh_centered_on_0 = np.linspace(-100, 100, 1000)
70+
>>> kernels = pychop.get_kernel(energy_transfer, mesh_centered_on_0)
71+
72+
Can be plotted as:
73+
74+
.. image:: /figures/example_get_kernel.png
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
"""
2+
Mixins providing generic implementations for
3+
`~resolution_functions.models.model_base.InstrumentModel` methods.
4+
5+
The classes defined here are mixins to be used by specific models via multiple inheritance, allowing
6+
common code to be shared between models. Please note, however, that when doing this, the mixin
7+
**must** be the first base class (i.e. ``class Foo(Mixin, InstrumentModel)``) so that its
8+
implementation of a method overrides the abstract declaration in ``InstrumentModel``.
9+
"""
10+
from __future__ import annotations
11+
12+
from typing import TYPE_CHECKING
13+
14+
import numpy as np
15+
from scipy.stats import norm
16+
17+
if TYPE_CHECKING:
18+
from jaxtyping import Float
19+
from .model_base import InstrumentModel
20+
21+
22+
class GaussianKernel1DMixin:
23+
"""
24+
A mixin providing the implementation for the Gaussian kernel ``get_kernel`` method.
25+
26+
Implements `resolution_functions.models.model_base.InstrumentModel.get_kernel` method of models
27+
whose broadening can be represented by a 1D Gaussian distribution. Any model that satisfies this
28+
condition should inherit the ``get_kernel`` method from this mixin instead of writing its own
29+
implementation.
30+
31+
Technically, any model that implements the
32+
`resolution_functions.models.model_base.InstrumentModel.get_characteristics` method and which
33+
returns the ``sigma`` parameter in its dictionary can use this mixin to inherit the Gaussian
34+
``get_kernel`` method. However, it is recommended that only models that actually model a
35+
Gaussian kernel should use this mixin.
36+
"""
37+
def get_kernel(self,
38+
points: Float[np.ndarray, 'sample dimension=1'],
39+
mesh: Float[np.ndarray, 'mesh'],
40+
) -> Float[np.ndarray, 'sample mesh']:
41+
"""
42+
Computes the Gaussian kernel centered on zero on the provided `mesh` at each (energy
43+
transfer or momentum scalar) point in `points`.
44+
45+
Parameters
46+
----------
47+
points
48+
The energy transfer or momentum scalar for which to compute the kernel. This *must* be
49+
a Nx1 2D array where N is the number of w/Q values.
50+
mesh
51+
The mesh on which to evaluate the kernel. A 1D array which must encompass 0.
52+
53+
Returns
54+
-------
55+
kernels
56+
The Gaussian kernel at w/Q point as given by this model, computed on the
57+
`mesh` and centered on zero. This is a 2D N x M array where N is the number of w/Q
58+
values and M is the length of the `mesh` array.
59+
"""
60+
return self._get_kernel(points, mesh, 0.)
61+
62+
def get_peak(self,
63+
points: Float[np.ndarray, 'sample dimension=1'],
64+
mesh: Float[np.ndarray, 'mesh'],
65+
) -> Float[np.ndarray, 'sample mesh']:
66+
"""
67+
Computes the Gaussian broadening peak on the provided `mesh` at each (energy transfer or
68+
momentum scalar) point in `points`.
69+
70+
Parameters
71+
----------
72+
points
73+
The energy transfer or momentum scalar for which to compute the peak. This *must* be a
74+
Nx1 2D array where N is the number of w/Q values.
75+
mesh
76+
The mesh on which to evaluate the kernel. This is a 1D array which *must* span the
77+
`points` w\Q space of interest.
78+
79+
Returns
80+
-------
81+
peaks
82+
The Gaussian peak at each w/Q point in `points` as given by this model, computed on the
83+
`mesh` and centered on the corresponding w/Q. This is a 2D N x M array where
84+
N is the number of w/Q values and M is the length of the `mesh` array.
85+
"""
86+
return self._get_kernel(points, mesh, points)
87+
88+
def _get_kernel(self: InstrumentModel,
89+
points: Float[np.ndarray, 'sample dimension=1'],
90+
mesh: Float[np.ndarray, 'mesh'],
91+
displacement: float | Float[np.ndarray, 'sample'] = 0.
92+
) -> Float[np.ndarray, 'sample mesh']:
93+
"""Computes the kernel using the specified `displacement`."""
94+
new_mesh = np.zeros((len(points), len(mesh)))
95+
new_mesh[:, :] = mesh
96+
97+
sigma = self.get_characteristics(points)['sigma']
98+
return norm.pdf(new_mesh, loc=displacement, scale=sigma[:, np.newaxis])
99+
100+
101+
class SimpleBroaden1DMixin:
102+
"""
103+
A mixin providing the most simple implementation for the ``convolve`` method.
104+
105+
Implements `resolution_functions.models.model_base.InstrumentModel.convolve` method in the
106+
most simple and basic way - the dot product between the matrix of kernels (obtained from the
107+
``get_kernel`` method) and the intensities.
108+
109+
This implementation should be mostly used as a reference method given that it is correct but
110+
inefficient. It should be able to work with any model, so it may be used when other
111+
implementations are unavailable.
112+
"""
113+
def broaden(self: InstrumentModel,
114+
points: Float[np.ndarray, 'sample dimension=1'],
115+
data: Float[np.ndarray, 'data'],
116+
mesh: Float[np.ndarray, 'mesh'],
117+
) -> Float[np.ndarray, 'spectrum']:
118+
"""
119+
Broadens the `data` on the full `mesh` using the straightforward scheme.
120+
121+
Parameters
122+
----------
123+
points
124+
The independent variable (energy transfer or momentum scalar) whose `data` to broaden.
125+
This *must* be a ``sample`` x 1 2D array where ``sample`` is the number of w/Q values
126+
for which there is `data`. Therefore, the ``sample`` dimension *must* match the length
127+
of the `data` array.
128+
data
129+
The intensities at the w/Q `points`.
130+
mesh
131+
The mesh to use for the broadening. This is a 1D array which *must* span the entire
132+
`points` space of interest.
133+
134+
Returns
135+
-------
136+
spectrum
137+
The broadened spectrum. This is a 1D array of the same length as `mesh`.
138+
"""
139+
kernels = self.get_peak(points, mesh)
140+
return np.einsum('i,ij...->j...', data, kernels)

0 commit comments

Comments
 (0)