Skip to content

Commit 28682ad

Browse files
committed
Initial docs
1 parent ac756f2 commit 28682ad

File tree

5 files changed

+182
-2
lines changed

5 files changed

+182
-2
lines changed
32 KB
Loading
40.5 KB
Loading
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Psi scanning
2+
3+
On a reflectometer, the Psi parameter is a rotation of the sample, side-to-side as viewed from a neutron's perspective.
4+
The Psi parameter causes the reflected beam profile, which can be approximated as an elongated ellipse, to be rotated
5+
when viewed at the detector. Reflectometers want to align the beam such that the beam profile is parallel to detector
6+
pixels (or, in other words, a horizontal line as seen on the detector).
7+
8+
Psi does not get _redefined_ to zero when the beam is level, but reflectometers do need to know what value of Psi
9+
_causes_ the beam to be level. The value of Psi which causes the beam to be level will be different for each sample,
10+
because of differences in how each sample is mounted.
11+
12+
## Linear detectors
13+
14+
This section applies to instruments like OFFSPEC and POLREF, which have a 1-D bank of detectors. These pixels are much
15+
wider than they are tall; representative pixel sizes are `0.5 x 50mm` on OFFSPEC or `0.5 x 30mm` on POLREF.
16+
17+
A linear pixel configuration means that as Psi is varied, the beam profile reflected off the sample(dark blue) hits a
18+
different number of detector pixels (light orange):
19+
20+
![Psi not equal to zero - broad peak](psi_scan_linear/psi_neq_0.png)
21+
22+
![Psi equal to zero - narrow peak](psi_scan_linear/psi_eq_0.png)
23+
24+
For a linear detector, the optimum value of Psi occurs when the beam profile is focused onto the smallest number of
25+
pixels (when the beam profile is parallel to the pixels).
26+
27+
A Psi scan on a linear detector is therefore implemented using the following steps:
28+
- Physically scan over Psi
29+
- At each scan point (implemented by {py:obj}`~ibex_bluesky_core.devices.reflectometry.AngleMappingReducer`:
30+
- Count & acquire a spectrum-data map describing the counts in all detector pixels
31+
- Filter to a range of interesting pixels (for example using {py:obj}`~ibex_bluesky_core.utils.centred_pixel`)
32+
- Map those pixels to relative angle, to account for pixel spacing which may not be exactly even between all pixels
33+
- Optionally divide counts in each pixel by a flood map, to account for different pixel efficiencies
34+
- Fit angle against counts using a Gaussian curve
35+
- Return the 'width' parameter of the Gaussian fit as the data from each scan point
36+
- Plotting the returned 'width' parameter at each scan point against the scanned variable, Psi, the optimum value of Psi
37+
occurs when the width is minimised
38+
- This is performed using an 'outer' {py:obj}`~ibex_bluesky_core.fitting.Gaussian` fit, using standard
39+
[callbacks and fitting](/callbacks/fitting/fitting) infrastructure. This is expected to give a negative
40+
{py:obj}`~ibex_bluesky_core.fitting.Gaussian` curve, where the `x0` parameter describes the optimum value of Psi

src/ibex_bluesky_core/devices/reflectometry.py

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,23 +3,30 @@
33
import asyncio
44
import logging
55

6+
import numpy as np
7+
import numpy.typing as npt
68
from bluesky.protocols import NamedMovable
79
from ophyd_async.core import (
810
AsyncStatus,
11+
Device,
912
SignalR,
1013
SignalW,
1114
StandardReadable,
1215
StandardReadableFormat,
1316
observe_value,
17+
soft_signal_r_and_setter,
1418
)
1519
from ophyd_async.epics.core import epics_signal_r, epics_signal_rw, epics_signal_w
1620

1721
from ibex_bluesky_core.devices import NoYesChoice
22+
from ibex_bluesky_core.devices.dae import Dae
23+
from ibex_bluesky_core.devices.simpledae import Reducer
24+
from ibex_bluesky_core.fitting import Gaussian
1825
from ibex_bluesky_core.utils import get_pv_prefix
1926

2027
logger = logging.getLogger(__name__)
2128

22-
__all__ = ["ReflParameter", "ReflParameterRedefine", "refl_parameter"]
29+
__all__ = ["AngleMappingReducer", "ReflParameter", "ReflParameterRedefine", "refl_parameter"]
2330

2431

2532
class ReflParameter(StandardReadable, NamedMovable[float]):
@@ -125,3 +132,94 @@ def refl_parameter(
125132
return ReflParameter(
126133
prefix=prefix, name=name, changing_timeout_s=changing_timeout_s, has_redefine=has_redefine
127134
)
135+
136+
137+
class AngleMappingReducer(Reducer, StandardReadable):
138+
"""Reflectometry angle-mapping reducer."""
139+
140+
def __init__(
141+
self,
142+
*,
143+
detectors: npt.NDArray[np.int32],
144+
angle_map: npt.NDArray[np.float64],
145+
) -> None:
146+
"""Angle-mapping :py:obj:`Reducer` for use by reflectometers.
147+
148+
This :py:obj:`Reducer` fits the counts on each pixel of a detector,
149+
against the relative angular positions of those pixels. It then exposes
150+
fitted quantities, and their standard deviations, as signals from this
151+
reducer.
152+
153+
Args:
154+
detectors: numpy array of detector spectra to include.
155+
angle_map: numpy array of relative pixel angles for each
156+
selected detector
157+
158+
"""
159+
self.amp, self._amp_setter = soft_signal_r_and_setter(float, 0.0)
160+
"""Amplitude of fitted Gaussian"""
161+
self.amp_err, self._amp_err_setter = soft_signal_r_and_setter(float, 0.0)
162+
"""Amplitude standard deviation of fitted Gaussian"""
163+
self.sigma, self._sigma_setter = soft_signal_r_and_setter(float, 0.0)
164+
"""Width (sigma) of fitted Gaussian"""
165+
self.sigma_err, self._sigma_err_setter = soft_signal_r_and_setter(float, 0.0)
166+
"""Width (sigma) standard deviation of fitted Gaussian"""
167+
self.x0, self._x0_setter = soft_signal_r_and_setter(float, 0.0)
168+
"""Centre (x0) of fitted Gaussian"""
169+
self.x0_err, self._x0_err_setter = soft_signal_r_and_setter(float, 0.0)
170+
"""Centre (x0) standard deviation of fitted Gaussian"""
171+
self.background, self._background_setter = soft_signal_r_and_setter(float, 0.0)
172+
"""Background of fitted Gaussian"""
173+
self.background_err, self._background_err_setter = soft_signal_r_and_setter(float, 0.0)
174+
"""Background standard deviation of fitted Gaussian"""
175+
176+
super().__init__()
177+
self._detectors = detectors
178+
self._angle_map = angle_map
179+
self._fit_method = Gaussian()
180+
181+
def additional_readable_signals(self, dae: Dae) -> list[Device]:
182+
"""Expose fit parameters as readable signals.
183+
184+
:meta private:
185+
"""
186+
return [
187+
self.amp,
188+
self.amp_err,
189+
self.sigma,
190+
self.sigma_err,
191+
self.x0,
192+
self.x0_err,
193+
self.background,
194+
self.background_err,
195+
]
196+
197+
async def reduce_data(self, dae: Dae) -> None:
198+
"""Perform the 'reduction'.
199+
200+
:meta private:
201+
"""
202+
data = await dae.trigger_and_get_specdata()
203+
204+
# Filter to relevant detectors
205+
data = data[self._detectors]
206+
207+
# Sum in ToF
208+
data = data.sum(axis=1)
209+
210+
print(data)
211+
212+
fit_method = Gaussian()
213+
214+
# Generate initial guesses and fit
215+
guess = fit_method.guess()(self._angle_map, data)
216+
result = fit_method.model().fit(data, x=self._angle_map, **guess)
217+
218+
self._amp_setter(result.params["amp"].value)
219+
self._amp_err_setter(result.params["amp"].stderr)
220+
self._x0_setter(result.params["x0"].value)
221+
self._x0_err_setter(result.params["x0"].stderr)
222+
self._sigma_setter(result.params["sigma"].value)
223+
self._sigma_err_setter(result.params["sigma"].stderr)
224+
self._background_setter(result.params["background"].value)
225+
self._background_err_setter(result.params["background"].stderr)

tests/devices/test_reflectometry.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
# pyright: reportMissingParameterType=false
22
import asyncio
3-
from unittest.mock import patch
3+
from unittest.mock import AsyncMock, MagicMock, patch
44

55
import bluesky.plan_stubs as bps
6+
import numpy as np
67
import pytest
78
from ophyd_async.plan_stubs import ensure_connected
89
from ophyd_async.testing import callback_on_mock_put, get_mock_put, set_mock_value
910

1011
from ibex_bluesky_core.devices import NoYesChoice
12+
from ibex_bluesky_core.devices.dae import Dae
1113
from ibex_bluesky_core.devices.reflectometry import (
14+
AngleMappingReducer,
1215
ReflParameter,
1316
ReflParameterRedefine,
1417
refl_parameter,
@@ -59,3 +62,42 @@ async def test_fails_to_redefine_and_raises_if_not_in_manager_mode(RE):
5962
r" as not in manager mode.",
6063
):
6164
await param.set(new_value)
65+
66+
67+
async def test_angle_mapping_reducer(dae):
68+
reducer = AngleMappingReducer(
69+
detectors=np.array([0, 1, 2, 3, 4], dtype=np.int32),
70+
angle_map=np.array([10, 11, 12, 13, 14], dtype=np.float64),
71+
)
72+
73+
await reducer.connect(mock=True)
74+
75+
fakedae = Dae(prefix="unittest:")
76+
await fakedae.connect(mock=True)
77+
fakedae.trigger_and_get_specdata = AsyncMock(
78+
return_value=np.array([[0, 0], [0, 1], [0, 10], [0, 1], [0, 0]], dtype=np.float64)
79+
)
80+
81+
await reducer.reduce_data(fakedae)
82+
83+
assert await reducer.x0.get_value() == pytest.approx(12.0)
84+
assert await reducer.amp.get_value() == pytest.approx(10.0, abs=0.01)
85+
assert await reducer.background.get_value() == pytest.approx(0.0, abs=0.01)
86+
87+
88+
def test_angle_mapping_reducer_interesting_signals():
89+
reducer = AngleMappingReducer(
90+
detectors=np.array([], dtype=np.int32),
91+
angle_map=np.array([], dtype=np.float64),
92+
)
93+
94+
assert reducer.additional_readable_signals(MagicMock()) == [
95+
reducer.amp,
96+
reducer.amp_err,
97+
reducer.sigma,
98+
reducer.sigma_err,
99+
reducer.x0,
100+
reducer.x0_err,
101+
reducer.background,
102+
reducer.background_err,
103+
]

0 commit comments

Comments
 (0)