Skip to content

Commit 9859e52

Browse files
authored
Merge pull request #218 from scipp/robust-vana-norm
Rebin vanadium to data
2 parents 75ea26d + 7b2a9b0 commit 9859e52

3 files changed

Lines changed: 248 additions & 11 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ dependencies = [
3737
"plopp>=25.07.0",
3838
"pythreejs>=2.4.1",
3939
"sciline>=25.04.1",
40-
"scipp>=25.05.1",
40+
"scipp>=25.11.0",
4141
"scippneutron>=25.02.0",
4242
"scippnexus>=23.12.0",
4343
"tof>=25.11.1",

src/ess/powder/correction.py

Lines changed: 64 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""Correction algorithms for powder diffraction."""
44

55
import enum
6+
from collections.abc import Mapping, Sequence
67
from typing import TypeVar
78

89
import sciline
@@ -31,6 +32,8 @@
3132
WavelengthMonitor,
3233
)
3334

35+
_T = TypeVar("_T")
36+
3437

3538
def normalize_by_monitor_histogram(
3639
detector: CorrectedDspacing[RunType],
@@ -146,19 +149,41 @@ def _mask_out_of_monitor_range_data(
146149
return detector, False
147150

148151

152+
def _filter_keys(mapping: Mapping[str, _T], keys: Sequence[str]) -> dict[str, _T]:
153+
return {key: value for key, value in mapping.items() if key in keys}
154+
155+
156+
def _histogram_vanadium(vanadium: sc.DataArray, sample: sc.DataArray) -> sc.DataArray:
157+
target_coords = _filter_keys(sample.coords, ("dspacing", "two_theta"))
158+
if "two_theta" in target_coords and "two_theta" not in vanadium.bins.coords:
159+
# Need to provide a two_theta event coord so we can histogram.
160+
if sc.identical(vanadium.coords["two_theta"], target_coords["two_theta"]):
161+
# Skip the expensive event coord if possible:
162+
return vanadium.hist(dspacing=sample.coords["dspacing"])
163+
return vanadium.bins.assign_coords(
164+
two_theta=sc.bins_like(vanadium, vanadium.coords["two_theta"])
165+
).hist(target_coords)
166+
return vanadium.hist(target_coords)
167+
168+
149169
def _normalize_by_vanadium(
150170
data: sc.DataArray,
151171
vanadium: sc.DataArray,
152172
uncertainty_broadcast_mode: UncertaintyBroadcastMode,
153173
) -> sc.DataArray:
154-
norm = vanadium.hist() if vanadium.bins is not None else vanadium
174+
norm = (
175+
_histogram_vanadium(vanadium, sample=data)
176+
if vanadium.is_binned
177+
else vanadium.rebin(_filter_keys(data.coords, ("dspacing", "two_theta")))
178+
)
155179
norm = broadcast_uncertainties(
156180
norm, prototype=data, mode=uncertainty_broadcast_mode
157181
)
158-
# Converting to unit 'one' because the division might produce a unit
159-
# with a large scale if the proton charges in data and vanadium were
160-
# measured with different units.
161-
normed = (data / norm).to(unit="one", copy=False)
182+
# Convert the unit such that the end-result has unit 'one' because the division
183+
# might otherwise produce a unit with a large scale if the proton charges in data
184+
# and vanadium were measured with different units.
185+
norm = norm.to(unit=data.unit, copy=False)
186+
normed = data / norm
162187
mask = norm.data == sc.scalar(0.0, unit=norm.unit)
163188
if mask.any():
164189
normed.masks['zero_vanadium'] = mask
@@ -173,8 +198,18 @@ def normalize_by_vanadium_dspacing(
173198
vanadium: FocussedDataDspacing[VanadiumRun],
174199
uncertainty_broadcast_mode: UncertaintyBroadcastMode,
175200
) -> IntensityDspacing[_RunTypeNoVanadium]:
176-
"""
177-
Normalize sample data by a vanadium measurement and return intensity vs. d-spacing.
201+
"""Normalize sample data binned in d-spacing by a vanadium measurement.
202+
203+
If the vanadium data is binned, it gets :func:`histogrammed <scipp.hist>` to the
204+
same bins as ``data``. If it is not binned, it gets :func:`rebinned <scipp.rebin>`
205+
to the same coordinates as ``data``. Then, the result is computed as
206+
207+
.. code-block:: python
208+
209+
data / vanadium
210+
211+
And any bins where vanadium is zero are masked out
212+
with a mask called "zero_vanadium".
178213
179214
Parameters
180215
----------
@@ -192,6 +227,11 @@ def normalize_by_vanadium_dspacing(
192227
``data / vanadium``.
193228
May contain a mask "zero_vanadium" which is ``True``
194229
for bins where vanadium is zero.
230+
231+
See Also
232+
--------
233+
normalize_by_vanadium_dspacing_and_two_theta:
234+
Normalization for 2d data binned in d-spacing and :math`2\\theta`.
195235
"""
196236
return IntensityDspacing(
197237
_normalize_by_vanadium(data, vanadium, uncertainty_broadcast_mode)
@@ -203,9 +243,18 @@ def normalize_by_vanadium_dspacing_and_two_theta(
203243
vanadium: FocussedDataDspacingTwoTheta[VanadiumRun],
204244
uncertainty_broadcast_mode: UncertaintyBroadcastMode,
205245
) -> IntensityDspacingTwoTheta[_RunTypeNoVanadium]:
206-
"""
207-
Normalize sample data by a vanadium measurement and return intensity vs.
208-
(d-spacing, 2theta).
246+
"""Normalize sample data binned in (d-spacing, 2theta) by a vanadium measurement.
247+
248+
If the vanadium data is binned, it gets :func:`histogrammed <scipp.hist>` to the
249+
same bins as ``data``. If it is not binned, it gets :func:`rebinned <scipp.rebin>`
250+
to the same coordinates as ``data``. Then, the result is computed as
251+
252+
.. code-block:: python
253+
254+
data / vanadium
255+
256+
And any bins where vanadium is zero are masked out
257+
with a mask called "zero_vanadium".
209258
210259
Parameters
211260
----------
@@ -223,6 +272,11 @@ def normalize_by_vanadium_dspacing_and_two_theta(
223272
``data / vanadium``.
224273
May contain a mask "zero_vanadium" which is ``True``
225274
for bins where vanadium is zero.
275+
276+
See Also
277+
--------
278+
normalize_by_vanadium_dspacing:
279+
Normalization for 1d data binned in d-spacing.
226280
"""
227281
return IntensityDspacingTwoTheta(
228282
_normalize_by_vanadium(data, vanadium, uncertainty_broadcast_mode)

tests/powder/correction_test.py

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,18 @@
1010
merge_calibration,
1111
normalize_by_monitor_histogram,
1212
normalize_by_monitor_integrated,
13+
normalize_by_vanadium_dspacing,
14+
normalize_by_vanadium_dspacing_and_two_theta,
1315
)
1416
from ess.powder.types import (
1517
CaveMonitor,
1618
CorrectedDspacing,
19+
FocussedDataDspacing,
20+
FocussedDataDspacingTwoTheta,
1721
NormalizedDspacing,
1822
SampleRun,
1923
UncertaintyBroadcastMode,
24+
VanadiumRun,
2025
WavelengthMonitor,
2126
)
2227

@@ -454,3 +459,181 @@ def test_normalize_by_monitor_integrated_assigns_mask_if_monitor_range_too_narro
454459
monitor=WavelengthMonitor[SampleRun, CaveMonitor](monitor),
455460
uncertainty_broadcast_mode=UncertaintyBroadcastMode.fail,
456461
)
462+
463+
464+
class TestNormalizeByVanadium:
465+
def random_variable(
466+
self,
467+
rng: np.random.Generator,
468+
dim: str,
469+
n: int,
470+
unit: str,
471+
with_variances: bool = False,
472+
) -> sc.Variable:
473+
values = rng.uniform(0.1, 2.0, n)
474+
variances = values * rng.uniform(0.1, 0.5, n) if with_variances else None
475+
return sc.array(dims=[dim], values=values, variances=variances, unit=unit)
476+
477+
def random_binned_data(
478+
self,
479+
rng: np.random.Generator,
480+
n_events: int,
481+
unit: str,
482+
with_variances: bool = False,
483+
*coords: tuple[str, int, str],
484+
) -> sc.DataArray:
485+
return sc.DataArray(
486+
self.random_variable(
487+
rng, 'event', n_events, unit, with_variances=with_variances
488+
),
489+
coords={
490+
dim: self.random_variable(rng, 'event', n_events, coord_unit)
491+
for (dim, _, coord_unit) in coords
492+
},
493+
).bin({dim: n_bins for (dim, n_bins, _) in coords})
494+
495+
def make_sample_and_vanadium_1d(self) -> tuple[sc.DataArray, sc.DataArray]:
496+
rng = np.random.default_rng(seed=495)
497+
sample = self.random_binned_data(rng, 84, 'count', True, ('dspacing', 35, 'Å'))
498+
vanadium = self.random_binned_data(
499+
rng, 146, 'count', True, ('dspacing', 79, 'Å')
500+
)
501+
return sample, vanadium
502+
503+
def make_sample_and_vanadium_2d(self) -> tuple[sc.DataArray, sc.DataArray]:
504+
rng = np.random.default_rng(seed=3193)
505+
sample = self.random_binned_data(
506+
rng, 138, 'count', True, ('dspacing', 35, 'Å'), ('two_theta', 13, 'rad')
507+
)
508+
vanadium = self.random_binned_data(
509+
rng, 170, 'count', True, ('dspacing', 79, 'Å'), ('two_theta', 14, 'rad')
510+
)
511+
return sample, vanadium
512+
513+
def test_1d_binned_vanadium(self) -> None:
514+
sample, vanadium = self.make_sample_and_vanadium_1d()
515+
normed = normalize_by_vanadium_dspacing(
516+
FocussedDataDspacing[SampleRun](sample),
517+
FocussedDataDspacing[VanadiumRun](vanadium),
518+
UncertaintyBroadcastMode.drop,
519+
)
520+
# we test masks separately
521+
normed = normed.drop_masks(list(normed.masks.keys()))
522+
523+
norm = vanadium.hist(dspacing=sample.coords['dspacing'])
524+
expected = sample / sc.values(norm)
525+
sc.testing.assert_allclose(normed, expected)
526+
527+
def test_1d_histogrammed_vanadium(self) -> None:
528+
sample, vanadium = self.make_sample_and_vanadium_1d()
529+
vanadium = vanadium.hist()
530+
normed = normalize_by_vanadium_dspacing(
531+
FocussedDataDspacing[SampleRun](sample),
532+
FocussedDataDspacing[VanadiumRun](vanadium),
533+
UncertaintyBroadcastMode.drop,
534+
)
535+
# we test masks separately
536+
normed = normed.drop_masks(list(normed.masks.keys()))
537+
538+
norm = vanadium.rebin(dspacing=sample.coords['dspacing'])
539+
expected = sample / sc.values(norm)
540+
sc.testing.assert_allclose(normed, expected)
541+
542+
def test_1d_binned_vanadium_binning_has_no_effect(self) -> None:
543+
sample, vanadium = self.make_sample_and_vanadium_1d()
544+
vana_binned_like_sample = vanadium.bin(dspacing=sample.coords['dspacing'])
545+
normed_a = normalize_by_vanadium_dspacing(
546+
FocussedDataDspacing[SampleRun](sample),
547+
FocussedDataDspacing[VanadiumRun](vanadium),
548+
UncertaintyBroadcastMode.drop,
549+
)
550+
normed_b = normalize_by_vanadium_dspacing(
551+
FocussedDataDspacing[SampleRun](sample),
552+
FocussedDataDspacing[VanadiumRun](vana_binned_like_sample),
553+
UncertaintyBroadcastMode.drop,
554+
)
555+
sc.testing.assert_allclose(normed_a, normed_b)
556+
557+
def test_1d_masks_zero_vanadium_bins(self) -> None:
558+
sample, vanadium = self.make_sample_and_vanadium_1d()
559+
vanadium['dspacing', 5] = sc.scalar(0.0, variance=0.0, unit='counts')
560+
normed = normalize_by_vanadium_dspacing(
561+
FocussedDataDspacing[SampleRun](sample),
562+
FocussedDataDspacing[VanadiumRun](vanadium),
563+
UncertaintyBroadcastMode.drop,
564+
)
565+
566+
norm = vanadium.hist(dspacing=sample.coords['dspacing'])
567+
568+
sc.testing.assert_allclose(
569+
normed.masks['zero_vanadium'], norm.data == sc.scalar(0.0, unit='counts')
570+
)
571+
572+
def test_2d_binned_vanadium(self) -> None:
573+
sample, vanadium = self.make_sample_and_vanadium_2d()
574+
normed = normalize_by_vanadium_dspacing_and_two_theta(
575+
FocussedDataDspacingTwoTheta[SampleRun](sample),
576+
FocussedDataDspacingTwoTheta[VanadiumRun](vanadium),
577+
UncertaintyBroadcastMode.drop,
578+
)
579+
# we test masks separately
580+
normed = normed.drop_masks(list(normed.masks.keys()))
581+
582+
norm = vanadium.hist(
583+
dspacing=sample.coords['dspacing'], two_theta=sample.coords['two_theta']
584+
)
585+
expected = sample / sc.values(norm)
586+
sc.testing.assert_allclose(normed, expected)
587+
588+
def test_2d_histogrammed_vanadium(self) -> None:
589+
sample, vanadium = self.make_sample_and_vanadium_2d()
590+
vanadium = vanadium.hist()
591+
normed = normalize_by_vanadium_dspacing_and_two_theta(
592+
FocussedDataDspacingTwoTheta[SampleRun](sample),
593+
FocussedDataDspacingTwoTheta[VanadiumRun](vanadium),
594+
UncertaintyBroadcastMode.drop,
595+
)
596+
# we test masks separately
597+
normed = normed.drop_masks(list(normed.masks.keys()))
598+
599+
norm = vanadium.rebin(
600+
dspacing=sample.coords['dspacing'], two_theta=sample.coords['two_theta']
601+
)
602+
expected = sample / sc.values(norm)
603+
sc.testing.assert_allclose(normed, expected)
604+
605+
def test_2d_binned_vanadium_binning_has_no_effect(self) -> None:
606+
sample, vanadium = self.make_sample_and_vanadium_2d()
607+
vana_binned_like_sample = vanadium.bin(
608+
dspacing=sample.coords['dspacing'], two_theta=sample.coords['two_theta']
609+
)
610+
normed_a = normalize_by_vanadium_dspacing_and_two_theta(
611+
FocussedDataDspacingTwoTheta[SampleRun](sample),
612+
FocussedDataDspacingTwoTheta[VanadiumRun](vanadium),
613+
UncertaintyBroadcastMode.drop,
614+
)
615+
normed_b = normalize_by_vanadium_dspacing_and_two_theta(
616+
FocussedDataDspacingTwoTheta[SampleRun](sample),
617+
FocussedDataDspacingTwoTheta[VanadiumRun](vana_binned_like_sample),
618+
UncertaintyBroadcastMode.drop,
619+
)
620+
sc.testing.assert_allclose(normed_a, normed_b)
621+
622+
def test_2d_masks_zero_vanadium_bins(self) -> None:
623+
sample, vanadium = self.make_sample_and_vanadium_2d()
624+
vanadium['dspacing', 5]['two_theta', 7] = sc.scalar(
625+
0.0, variance=0.0, unit='counts'
626+
)
627+
normed = normalize_by_vanadium_dspacing_and_two_theta(
628+
FocussedDataDspacingTwoTheta[SampleRun](sample),
629+
FocussedDataDspacingTwoTheta[VanadiumRun](vanadium),
630+
UncertaintyBroadcastMode.drop,
631+
)
632+
633+
norm = vanadium.hist(
634+
dspacing=sample.coords['dspacing'], two_theta=sample.coords['two_theta']
635+
)
636+
637+
sc.testing.assert_allclose(
638+
normed.masks['zero_vanadium'], norm.data == sc.scalar(0.0, unit='counts')
639+
)

0 commit comments

Comments
 (0)