Skip to content

Commit 240ba51

Browse files
authored
Merge pull request #97 from ISISComputingGroup/58_polarization_asymmetry
Add function which calculates Polarization & propagating uncertainties
2 parents 7219539 + 24fb6d0 commit 240ba51

File tree

5 files changed

+207
-17
lines changed

5 files changed

+207
-17
lines changed

doc/devices/dae.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,39 @@ as a list, and `units` (μs/microseconds for time of flight bounding, and angstr
295295

296296
If you don't specify either of these options, they will default to summing over the entire spectrum.
297297

298+
### Polarization/Asymmetry
299+
300+
ibex_bluesky_core provides a helper method,
301+
{py:obj}`ibex_bluesky_core.devices.simpledae.polarization`, for calculating the quantity (a-b)/(a+b). This quantity is used, for example, in neutron polarization measurements, and in calculating asymmetry for muon measurements.
302+
303+
For this expression, scipp's default uncertainty propagation rules cannot be used as the uncertainties on (a-b) are correlated with those of (a+b) in the division step - but scipp assumes uncorrelated data. This helper method calculates the uncertainties following linear error propagation theory, using the partial derivatives of the above expression.
304+
305+
The partial derivatives are:
306+
307+
$ \frac{\delta}{\delta a}\Big(\frac{a - b}{a + b}) = \frac{2b}{(a+b)^2} $
308+
309+
$ \frac{\delta}{\delta b}\Big(\frac{a - b}{a + b}) = -\frac{2a}{(a+b)^2} $
310+
311+
312+
Which then means the variances computed by this helper function are:
313+
314+
$ Variance = (\frac{\delta}{\delta a}^2 * variance_a) + (\frac{\delta}{\delta b}^2 * variance_b) $
315+
316+
317+
The polarization funtion provided will calculate the polarization between two values, A and B, which
318+
have different definitions based on the instrument context.
319+
320+
Instrument-Specific Interpretations
321+
SANS Instruments (e.g., LARMOR)
322+
A: Intensity in DAE period before switching a flipper.
323+
B: Intensity in DAE period after switching a flipper.
324+
325+
Reflectometry Instruments (e.g., POLREF)
326+
Similar to LARMOR, A and B represent intensities before and after flipper switching.
327+
328+
Muon Instruments
329+
A and B refer to Measurements from different detector banks.
330+
298331
## Waiters
299332

300333
A [`waiter`](ibex_bluesky_core.devices.simpledae.Waiter) defines an arbitrary strategy for how long to count at each point.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ dev = [
6969
"pytest-cov",
7070
"pytest-env",
7171
"pyqt6", # For dev testing with matplotlib's qt backend.
72+
"uncertainties" # For dev testing polarization, variance & uncertainties
7273
]
7374

7475
[project.urls]

src/ibex_bluesky_core/devices/simpledae/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
PeriodGoodFramesNormalizer,
2424
PeriodSpecIntegralsReducer,
2525
ScalarNormalizer,
26+
polarization,
27+
sum_spectra,
2628
tof_bounded_spectra,
2729
wavelength_bounded_spectra,
2830
)
@@ -67,6 +69,8 @@
6769
"Waiter",
6870
"check_dae_strategies",
6971
"monitor_normalising_dae",
72+
"polarization",
73+
"sum_spectra",
7074
"tof_bounded_spectra",
7175
"wavelength_bounded_spectra",
7276
]

src/ibex_bluesky_core/devices/simpledae/_reducers.py

Lines changed: 80 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,14 @@
3737
async def sum_spectra(spectra: Collection[DaeSpectra]) -> sc.Variable | sc.DataArray:
3838
"""Read and sum a number of spectra from the DAE.
3939
40-
Returns a scipp scalar, which has .value and .variance properties for accessing the sum
41-
and variance respectively of the summed counts.
40+
Args:
41+
spectra: a Collection type object of DAE spectra
42+
43+
Returns:
44+
scipp :external+scipp:py:obj:`Variable <scipp.Variable>`
45+
or :external+scipp:py:obj:`DataArray <scipp.DataArray>` describing
46+
the sum of the provided spectra.
4247
43-
More info on scipp scalars can be found here: https://scipp.github.io/generated/functions/scipp.scalar.html
4448
"""
4549
logger.info("Summing %d spectra using scipp", len(spectra))
4650
summed_counts = sc.scalar(value=0, unit=sc.units.counts, dtype="float64")
@@ -56,13 +60,14 @@ def tof_bounded_spectra(
5660
"""Sum a set of neutron spectra between the specified time of flight bounds.
5761
5862
Args:
59-
bounds: A scipp array of size 2, no variances, unit of us,
60-
where the second element must be larger than the first.
63+
bounds: A scipp :external+scipp:py:obj:`array <scipp.array>` of size 2, no variances, unit
64+
of us, where the second element must be larger than the first.
6165
62-
Returns a scipp scalar, which has .value and .variance properties for accessing the sum
63-
and variance respectively of the summed counts.
66+
:rtype:
67+
scipp :external+scipp:py:obj:`scalar <scipp.scalar>`
6468
65-
More info on scipp arrays and scalars can be found here: https://scipp.github.io/generated/functions/scipp.scalar.html
69+
Returns a scipp :external+scipp:py:obj:`scalar <scipp.scalar>`, which has .value and .variance
70+
properties for accessing the sum and variance respectively of the summed counts.
6671
6772
"""
6873
bounds_value = 2
@@ -88,17 +93,22 @@ def wavelength_bounded_spectra(
8893
"""Sum a set of neutron spectra between the specified wavelength bounds.
8994
9095
Args:
91-
bounds: A scipp array of size 2 of wavelength bounds, in units of angstrom,
92-
where the second element must be larger than the first.
93-
total_flight_path_length: A scipp scalar of Ltotal (total flight path length), the path
94-
length from neutron source to detector or monitor, in units of meters.
96+
bounds: A scipp :external+scipp:py:obj:`array <scipp.array>` of size 2 of wavelength bounds,
97+
in units of angstrom, where the second element must be larger than the first.
98+
total_flight_path_length: A scipp :external+scipp:py:obj:`scalar <scipp.scalar>` of Ltotal
99+
(total flight path length), the path length from neutron source to detector or monitor,
100+
in units of meters.
101+
102+
:rtype:
103+
scipp :external+scipp:py:obj:`scalar <scipp.scalar>`
95104
96105
Time of flight is converted to wavelength using scipp neutron's library function
97-
`wavelength_from_tof`, more info on which can be found here:
98-
https://scipp.github.io/scippneutron/generated/modules/scippneutron.conversion.tof.wavelength_from_tof.html
106+
`wavelength_from_tof`, more info on which can be found here:
107+
:external+scippneutron:py:obj:`wavelength_from_tof
108+
<scippneutron.conversion.tof.wavelength_from_tof>`
99109
100-
Returns a scipp scalar, which has .value and .variance properties for accessing the sum
101-
and variance respectively of the summed counts.
110+
Returns a scipp :external+scipp:py:obj:`scalar <scipp.scalar>`, which has .value and .variance
111+
properties for accessing the sum and variance respectively of the summed counts.
102112
103113
"""
104114
bounds_value = 2
@@ -125,6 +135,60 @@ async def sum_spectra_with_wavelength(
125135
return sum_spectra_with_wavelength
126136

127137

138+
def polarization(
139+
a: sc.Variable | sc.DataArray, b: sc.Variable | sc.DataArray
140+
) -> sc.Variable | sc.DataArray:
141+
"""Calculate polarization value and propagate uncertainties.
142+
143+
This function computes the polarization given by the formula (a-b)/(a+b)
144+
and propagates the uncertainties associated with a and b.
145+
146+
Args:
147+
a: scipp :external+scipp:py:obj:`Variable <scipp.Variable>`
148+
or :external+scipp:py:obj:`DataArray <scipp.DataArray>`
149+
b: scipp :external+scipp:py:obj:`Variable <scipp.Variable>`
150+
or :external+scipp:py:obj:`DataArray <scipp.DataArray>`
151+
152+
Returns:
153+
polarization, ``(a - b) / (a + b)``, as a scipp
154+
:external+scipp:py:obj:`Variable <scipp.Variable>`
155+
or :external+scipp:py:obj:`DataArray <scipp.DataArray>`
156+
157+
On SANS instruments e.g. LARMOR, A and B correspond to intensity in different DAE
158+
periods (before/after switching a flipper) and the output is interpreted as a neutron
159+
polarization ratio.
160+
161+
On reflectometry instruments e.g. POLREF, the situation is the same as on LARMOR.
162+
163+
On muon instruments, A and B correspond to measuring from forward/backward detector
164+
banks, and the output is interpreted as a muon asymmetry.
165+
166+
"""
167+
if a.unit != b.unit:
168+
raise ValueError("The units of a and b are not equivalent.")
169+
if a.sizes != b.sizes:
170+
raise ValueError("Dimensions/shape of a and b must match.")
171+
172+
# This line allows for dims, units, and dtype to be handled by scipp
173+
polarization_value = (a - b) / (a + b)
174+
175+
variances_a = a.variances
176+
variances_b = b.variances
177+
values_a = a.values
178+
values_b = b.values
179+
180+
# Calculate partial derivatives
181+
partial_a = 2 * values_b / (values_a + values_b) ** 2
182+
partial_b = -2 * values_a / (values_a + values_b) ** 2
183+
184+
variance_return = (partial_a**2 * variances_a) + (partial_b**2 * variances_b)
185+
186+
# Propagate uncertainties
187+
polarization_value.variances = variance_return
188+
189+
return polarization_value
190+
191+
128192
class ScalarNormalizer(Reducer, StandardReadable, ABC):
129193
"""Sum a set of user-specified spectra, then normalize by a scalar signal."""
130194

tests/devices/simpledae/test_reducers.py

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import pytest
88
import scipp as sc
99
from ophyd_async.testing import get_mock_put, set_mock_value
10+
from uncertainties import ufloat, unumpy
1011

1112
from ibex_bluesky_core.devices.simpledae import (
1213
VARIANCE_ADDITION,
@@ -19,7 +20,7 @@
1920
tof_bounded_spectra,
2021
wavelength_bounded_spectra,
2122
)
22-
from ibex_bluesky_core.devices.simpledae._reducers import DSpacingMappingReducer
23+
from ibex_bluesky_core.devices.simpledae._reducers import DSpacingMappingReducer, polarization
2324

2425

2526
@pytest.fixture
@@ -965,6 +966,93 @@ def test_wavelength_bounded_spectra_bounds_missing_or_too_many(data: list[float]
965966
)
966967

967968

969+
# Polarization
970+
@pytest.mark.parametrize(
971+
("a", "b", "variance_a", "variance_b"),
972+
[
973+
# Case 1: Symmetric case with equal uncertainties
974+
(5.0, 3.0, 0.1, 0.1),
975+
# Case 2: Asymmetric case with different uncertainties
976+
(10.0, 6.0, 0.2, 0.3),
977+
# Case 3: Case with larger values and different uncertainty magnitudes
978+
(100.0, 60.0, 1.0, 2.0),
979+
],
980+
)
981+
def test_polarization_function_calculates_accurately(a, b, variance_a, variance_b):
982+
# 'Uncertainties' library ufloat type; a nominal value and an error value
983+
a_ufloat = ufloat(a, variance_a)
984+
b_ufloat = ufloat(b, variance_b)
985+
986+
# polarization value, i.e. (a - b) / (a + b)
987+
polarization_ufloat = (a_ufloat.n - b_ufloat.n) / (a_ufloat.n + b_ufloat.n)
988+
989+
# the partial derivatives of a and b, calculated with 'uncertainties' library's ufloat type
990+
partial_a = (2 * b_ufloat.n) / ((a_ufloat.n + b_ufloat.n) ** 2)
991+
partial_b = (-2 * a_ufloat.n) / ((a_ufloat.n + b_ufloat.n) ** 2)
992+
993+
# variance calculated with 'uncertainties' library
994+
variance = (partial_a**2 * a_ufloat.s) + (partial_b**2 * b_ufloat.s)
995+
uncertainty = variance**0.5 # uncertainty is sqrt of variance
996+
997+
# Two scipp scalars, to test our polarization function
998+
var_a = sc.scalar(value=a, variance=variance_a, unit="", dtype="float64")
999+
var_b = sc.scalar(value=b, variance=variance_b, unit="", dtype="float64")
1000+
result_value = polarization(var_a, var_b)
1001+
result_uncertainy = (result_value.variance) ** 0.5 # uncertainty is sqrt of variance
1002+
1003+
assert result_value.value == pytest.approx(polarization_ufloat)
1004+
assert result_uncertainy == pytest.approx(uncertainty)
1005+
1006+
1007+
# test that arrays are supported
1008+
@pytest.mark.parametrize(
1009+
("a", "b", "variances_a", "variances_b"),
1010+
[
1011+
([5.0, 10.0, 100.0], [3.0, 6.0, 60.0], [0.1, 0.2, 1.0], [0.1, 0.3, 2.0]),
1012+
],
1013+
)
1014+
def test_polarization_2_arrays(a, b, variances_a, variances_b):
1015+
# 'Uncertainties' library ufloat type; a nominal value and an error value
1016+
1017+
a_arr = unumpy.uarray(a, [v**0.5 for v in variances_a]) # convert variances to std dev
1018+
b_arr = unumpy.uarray(b, [v**0.5 for v in variances_b])
1019+
1020+
# polarization value, i.e. (a - b) / (a + b)
1021+
polarization_ufloat = (a_arr - b_arr) / (a_arr + b_arr)
1022+
1023+
var_a = sc.array(dims="x", values=a, variances=variances_a, unit="", dtype="float64")
1024+
var_b = sc.array(dims="x", values=b, variances=variances_b, unit="", dtype="float64")
1025+
1026+
result_value = polarization(var_a, var_b)
1027+
1028+
result_uncertainties = (result_value.variances) ** 0.5
1029+
1030+
assert result_value.values == pytest.approx(unumpy.nominal_values(polarization_ufloat))
1031+
assert result_uncertainties == pytest.approx(unumpy.std_devs(polarization_ufloat))
1032+
1033+
1034+
# test that units don't match
1035+
def test_polarization_units_mismatch():
1036+
var_a = sc.scalar(value=1, variance=0.1, unit="m", dtype="float64")
1037+
var_b = sc.scalar(value=1, variance=0.1, unit="u", dtype="float64")
1038+
1039+
with pytest.raises(
1040+
expected_exception=ValueError, match=r"The units of a and b are not equivalent."
1041+
):
1042+
polarization(var_a, var_b)
1043+
1044+
1045+
# test that arrays are of unmatching sizes
1046+
def test_polarization_arrays_of_different_sizes():
1047+
var_a = sc.array(dims=["x"], values=[1, 2], variances=[0.1, 0.1], unit="m", dtype="float64")
1048+
var_b = sc.array(dims=["x"], values=[1], variances=[0.1], unit="m", dtype="float64")
1049+
1050+
with pytest.raises(
1051+
expected_exception=ValueError, match=r"Dimensions/shape of a and b must match."
1052+
):
1053+
polarization(var_a, var_b)
1054+
1055+
9681056
@pytest.mark.parametrize(
9691057
("current_period", "mon_integrals", "det_integrals"),
9701058
[

0 commit comments

Comments
 (0)