essreduce 25.12.0__tar.gz → 25.12.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. {essreduce-25.12.0 → essreduce-25.12.1}/PKG-INFO +1 -1
  2. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/nexus/workflow.py +8 -6
  3. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/normalization.py +154 -26
  4. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/time_of_flight/eto_to_tof.py +18 -2
  5. {essreduce-25.12.0 → essreduce-25.12.1}/src/essreduce.egg-info/PKG-INFO +1 -1
  6. {essreduce-25.12.0 → essreduce-25.12.1}/tests/nexus/workflow_test.py +119 -6
  7. {essreduce-25.12.0 → essreduce-25.12.1}/tests/normalization_test.py +43 -0
  8. {essreduce-25.12.0 → essreduce-25.12.1}/tox.ini +3 -3
  9. {essreduce-25.12.0 → essreduce-25.12.1}/.copier-answers.ess.yml +0 -0
  10. {essreduce-25.12.0 → essreduce-25.12.1}/.copier-answers.yml +0 -0
  11. {essreduce-25.12.0 → essreduce-25.12.1}/.github/ISSUE_TEMPLATE/high-level-requirement.yml +0 -0
  12. {essreduce-25.12.0 → essreduce-25.12.1}/.github/dependabot.yml +0 -0
  13. {essreduce-25.12.0 → essreduce-25.12.1}/.github/workflows/ci.yml +0 -0
  14. {essreduce-25.12.0 → essreduce-25.12.1}/.github/workflows/docs.yml +0 -0
  15. {essreduce-25.12.0 → essreduce-25.12.1}/.github/workflows/nightly_at_main.yml +0 -0
  16. {essreduce-25.12.0 → essreduce-25.12.1}/.github/workflows/nightly_at_main_lower_bound.yml +0 -0
  17. {essreduce-25.12.0 → essreduce-25.12.1}/.github/workflows/nightly_at_release.yml +0 -0
  18. {essreduce-25.12.0 → essreduce-25.12.1}/.github/workflows/python-version-ci +0 -0
  19. {essreduce-25.12.0 → essreduce-25.12.1}/.github/workflows/release.yml +0 -0
  20. {essreduce-25.12.0 → essreduce-25.12.1}/.github/workflows/test.yml +0 -0
  21. {essreduce-25.12.0 → essreduce-25.12.1}/.github/workflows/unpinned.yml +0 -0
  22. {essreduce-25.12.0 → essreduce-25.12.1}/.github/workflows/weekly_windows_macos.yml +0 -0
  23. {essreduce-25.12.0 → essreduce-25.12.1}/.gitignore +0 -0
  24. {essreduce-25.12.0 → essreduce-25.12.1}/.pre-commit-config.yaml +0 -0
  25. {essreduce-25.12.0 → essreduce-25.12.1}/.python-version +0 -0
  26. {essreduce-25.12.0 → essreduce-25.12.1}/CODE_OF_CONDUCT.md +0 -0
  27. {essreduce-25.12.0 → essreduce-25.12.1}/CONTRIBUTING.md +0 -0
  28. {essreduce-25.12.0 → essreduce-25.12.1}/LICENSE +0 -0
  29. {essreduce-25.12.0 → essreduce-25.12.1}/MANIFEST.in +0 -0
  30. {essreduce-25.12.0 → essreduce-25.12.1}/README.md +0 -0
  31. {essreduce-25.12.0 → essreduce-25.12.1}/docs/_static/anaconda-icon.js +0 -0
  32. {essreduce-25.12.0 → essreduce-25.12.1}/docs/_static/favicon.svg +0 -0
  33. {essreduce-25.12.0 → essreduce-25.12.1}/docs/_static/logo-dark.svg +0 -0
  34. {essreduce-25.12.0 → essreduce-25.12.1}/docs/_static/logo.svg +0 -0
  35. {essreduce-25.12.0 → essreduce-25.12.1}/docs/_templates/class-template.rst +0 -0
  36. {essreduce-25.12.0 → essreduce-25.12.1}/docs/_templates/doc_version.html +0 -0
  37. {essreduce-25.12.0 → essreduce-25.12.1}/docs/_templates/module-template.rst +0 -0
  38. {essreduce-25.12.0 → essreduce-25.12.1}/docs/about/index.md +0 -0
  39. {essreduce-25.12.0 → essreduce-25.12.1}/docs/api-reference/index.md +0 -0
  40. {essreduce-25.12.0 → essreduce-25.12.1}/docs/conf.py +0 -0
  41. {essreduce-25.12.0 → essreduce-25.12.1}/docs/developer/coding-conventions.md +0 -0
  42. {essreduce-25.12.0 → essreduce-25.12.1}/docs/developer/dependency-management.md +0 -0
  43. {essreduce-25.12.0 → essreduce-25.12.1}/docs/developer/getting-started.md +0 -0
  44. {essreduce-25.12.0 → essreduce-25.12.1}/docs/developer/gui.ipynb +0 -0
  45. {essreduce-25.12.0 → essreduce-25.12.1}/docs/developer/index.md +0 -0
  46. {essreduce-25.12.0 → essreduce-25.12.1}/docs/index.md +0 -0
  47. {essreduce-25.12.0 → essreduce-25.12.1}/docs/user-guide/index.md +0 -0
  48. {essreduce-25.12.0 → essreduce-25.12.1}/docs/user-guide/installation.md +0 -0
  49. {essreduce-25.12.0 → essreduce-25.12.1}/docs/user-guide/reduction-workflow-guidelines.md +0 -0
  50. {essreduce-25.12.0 → essreduce-25.12.1}/docs/user-guide/tof/dream.ipynb +0 -0
  51. {essreduce-25.12.0 → essreduce-25.12.1}/docs/user-guide/tof/frame-unwrapping.ipynb +0 -0
  52. {essreduce-25.12.0 → essreduce-25.12.1}/docs/user-guide/tof/index.md +0 -0
  53. {essreduce-25.12.0 → essreduce-25.12.1}/docs/user-guide/tof/wfm.ipynb +0 -0
  54. {essreduce-25.12.0 → essreduce-25.12.1}/docs/user-guide/widget.md +0 -0
  55. {essreduce-25.12.0 → essreduce-25.12.1}/pyproject.toml +0 -0
  56. {essreduce-25.12.0 → essreduce-25.12.1}/requirements/base.in +0 -0
  57. {essreduce-25.12.0 → essreduce-25.12.1}/requirements/base.txt +0 -0
  58. {essreduce-25.12.0 → essreduce-25.12.1}/requirements/basetest.in +0 -0
  59. {essreduce-25.12.0 → essreduce-25.12.1}/requirements/basetest.txt +0 -0
  60. {essreduce-25.12.0 → essreduce-25.12.1}/requirements/ci.in +0 -0
  61. {essreduce-25.12.0 → essreduce-25.12.1}/requirements/ci.txt +0 -0
  62. {essreduce-25.12.0 → essreduce-25.12.1}/requirements/dev.in +0 -0
  63. {essreduce-25.12.0 → essreduce-25.12.1}/requirements/dev.txt +0 -0
  64. {essreduce-25.12.0 → essreduce-25.12.1}/requirements/docs.in +0 -0
  65. {essreduce-25.12.0 → essreduce-25.12.1}/requirements/docs.txt +0 -0
  66. {essreduce-25.12.0 → essreduce-25.12.1}/requirements/make_base.py +0 -0
  67. {essreduce-25.12.0 → essreduce-25.12.1}/requirements/mypy.in +0 -0
  68. {essreduce-25.12.0 → essreduce-25.12.1}/requirements/mypy.txt +0 -0
  69. {essreduce-25.12.0 → essreduce-25.12.1}/requirements/nightly.in +0 -0
  70. {essreduce-25.12.0 → essreduce-25.12.1}/requirements/nightly.txt +0 -0
  71. {essreduce-25.12.0 → essreduce-25.12.1}/requirements/static.in +0 -0
  72. {essreduce-25.12.0 → essreduce-25.12.1}/requirements/static.txt +0 -0
  73. {essreduce-25.12.0 → essreduce-25.12.1}/requirements/test.in +0 -0
  74. {essreduce-25.12.0 → essreduce-25.12.1}/requirements/test.txt +0 -0
  75. {essreduce-25.12.0 → essreduce-25.12.1}/requirements/wheels.in +0 -0
  76. {essreduce-25.12.0 → essreduce-25.12.1}/requirements/wheels.txt +0 -0
  77. {essreduce-25.12.0 → essreduce-25.12.1}/resources/logo.svg +0 -0
  78. {essreduce-25.12.0 → essreduce-25.12.1}/setup.cfg +0 -0
  79. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/__init__.py +0 -0
  80. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/data/__init__.py +0 -0
  81. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/data/_registry.py +0 -0
  82. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/live/__init__.py +0 -0
  83. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/live/raw.py +0 -0
  84. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/live/roi.py +0 -0
  85. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/live/workflow.py +0 -0
  86. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/logging.py +0 -0
  87. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/nexus/__init__.py +0 -0
  88. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/nexus/_nexus_loader.py +0 -0
  89. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/nexus/json_generator.py +0 -0
  90. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/nexus/json_nexus.py +0 -0
  91. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/nexus/types.py +0 -0
  92. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/parameter.py +0 -0
  93. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/py.typed +0 -0
  94. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/scripts/grow_nexus.py +0 -0
  95. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/streaming.py +0 -0
  96. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/time_of_flight/__init__.py +0 -0
  97. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/time_of_flight/fakes.py +0 -0
  98. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/time_of_flight/interpolator_numba.py +0 -0
  99. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/time_of_flight/interpolator_scipy.py +0 -0
  100. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/time_of_flight/lut.py +0 -0
  101. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/time_of_flight/resample.py +0 -0
  102. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/time_of_flight/types.py +0 -0
  103. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/time_of_flight/workflow.py +0 -0
  104. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/ui.py +0 -0
  105. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/uncertainty.py +0 -0
  106. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/widgets/__init__.py +0 -0
  107. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/widgets/_base.py +0 -0
  108. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/widgets/_binedges_widget.py +0 -0
  109. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/widgets/_bounds_widget.py +0 -0
  110. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/widgets/_config.py +0 -0
  111. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/widgets/_filename_widget.py +0 -0
  112. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/widgets/_linspace_widget.py +0 -0
  113. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/widgets/_optional_widget.py +0 -0
  114. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/widgets/_spinner.py +0 -0
  115. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/widgets/_string_widget.py +0 -0
  116. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/widgets/_switchable_widget.py +0 -0
  117. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/widgets/_vector_widget.py +0 -0
  118. {essreduce-25.12.0 → essreduce-25.12.1}/src/ess/reduce/workflow.py +0 -0
  119. {essreduce-25.12.0 → essreduce-25.12.1}/src/essreduce.egg-info/SOURCES.txt +0 -0
  120. {essreduce-25.12.0 → essreduce-25.12.1}/src/essreduce.egg-info/dependency_links.txt +0 -0
  121. {essreduce-25.12.0 → essreduce-25.12.1}/src/essreduce.egg-info/entry_points.txt +0 -0
  122. {essreduce-25.12.0 → essreduce-25.12.1}/src/essreduce.egg-info/requires.txt +0 -0
  123. {essreduce-25.12.0 → essreduce-25.12.1}/src/essreduce.egg-info/top_level.txt +0 -0
  124. {essreduce-25.12.0 → essreduce-25.12.1}/tests/accumulators_test.py +0 -0
  125. {essreduce-25.12.0 → essreduce-25.12.1}/tests/conftest.py +0 -0
  126. {essreduce-25.12.0 → essreduce-25.12.1}/tests/live/raw_test.py +0 -0
  127. {essreduce-25.12.0 → essreduce-25.12.1}/tests/live/roi_test.py +0 -0
  128. {essreduce-25.12.0 → essreduce-25.12.1}/tests/nexus/json_generator_test.py +0 -0
  129. {essreduce-25.12.0 → essreduce-25.12.1}/tests/nexus/json_nexus_examples/array_dataset.json +0 -0
  130. {essreduce-25.12.0 → essreduce-25.12.1}/tests/nexus/json_nexus_examples/dataset.json +0 -0
  131. {essreduce-25.12.0 → essreduce-25.12.1}/tests/nexus/json_nexus_examples/detector.json +0 -0
  132. {essreduce-25.12.0 → essreduce-25.12.1}/tests/nexus/json_nexus_examples/entry.json +0 -0
  133. {essreduce-25.12.0 → essreduce-25.12.1}/tests/nexus/json_nexus_examples/event_data.json +0 -0
  134. {essreduce-25.12.0 → essreduce-25.12.1}/tests/nexus/json_nexus_examples/instrument.json +0 -0
  135. {essreduce-25.12.0 → essreduce-25.12.1}/tests/nexus/json_nexus_examples/log.json +0 -0
  136. {essreduce-25.12.0 → essreduce-25.12.1}/tests/nexus/json_nexus_test.py +0 -0
  137. {essreduce-25.12.0 → essreduce-25.12.1}/tests/nexus/nexus_loader_test.py +0 -0
  138. {essreduce-25.12.0 → essreduce-25.12.1}/tests/package_test.py +0 -0
  139. {essreduce-25.12.0 → essreduce-25.12.1}/tests/scripts/test_grow_nexus.py +0 -0
  140. {essreduce-25.12.0 → essreduce-25.12.1}/tests/streaming_test.py +0 -0
  141. {essreduce-25.12.0 → essreduce-25.12.1}/tests/time_of_flight/interpolator_test.py +0 -0
  142. {essreduce-25.12.0 → essreduce-25.12.1}/tests/time_of_flight/lut_test.py +0 -0
  143. {essreduce-25.12.0 → essreduce-25.12.1}/tests/time_of_flight/resample_tests.py +0 -0
  144. {essreduce-25.12.0 → essreduce-25.12.1}/tests/time_of_flight/unwrap_test.py +0 -0
  145. {essreduce-25.12.0 → essreduce-25.12.1}/tests/time_of_flight/wfm_test.py +0 -0
  146. {essreduce-25.12.0 → essreduce-25.12.1}/tests/time_of_flight/workflow_test.py +0 -0
  147. {essreduce-25.12.0 → essreduce-25.12.1}/tests/uncertainty_test.py +0 -0
  148. {essreduce-25.12.0 → essreduce-25.12.1}/tests/widget_test.py +0 -0
  149. {essreduce-25.12.0 → essreduce-25.12.1}/tools/shrink_nexus.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: essreduce
3
- Version: 25.12.0
3
+ Version: 25.12.1
4
4
  Summary: Common data reduction tools for the ESS facility
5
5
  Author: Scipp contributors
6
6
  License-Expression: BSD-3-Clause
@@ -370,6 +370,8 @@ def get_calibrated_detector(
370
370
  ----------
371
371
  detector:
372
372
  NeXus detector group.
373
+ transform:
374
+ Transformation matrix for the detector.
373
375
  offset:
374
376
  Offset to add to the detector position.
375
377
  bank_sizes:
@@ -430,8 +432,8 @@ def assemble_detector_data(
430
432
 
431
433
  def get_calibrated_monitor(
432
434
  monitor: NeXusComponent[MonitorType, RunType],
435
+ transform: NeXusTransformation[MonitorType, RunType],
433
436
  offset: MonitorPositionOffset[RunType, MonitorType],
434
- source_position: Position[snx.NXsource, RunType],
435
437
  ) -> EmptyMonitor[RunType, MonitorType]:
436
438
  """
437
439
  Extract the data array corresponding to a monitor's signal field.
@@ -443,16 +445,16 @@ def get_calibrated_monitor(
443
445
  ----------
444
446
  monitor:
445
447
  NeXus monitor group.
448
+ transform:
449
+ Transformation matrix for the monitor.
446
450
  offset:
447
451
  Offset to add to the monitor position.
448
- source_position:
449
- Position of the neutron source.
450
452
  """
451
- monitor = nexus.compute_component_position(monitor)
453
+ transform_unit = transform.value.unit
452
454
  return EmptyMonitor[RunType, MonitorType](
453
455
  nexus.extract_signal_data_array(monitor).assign_coords(
454
- position=monitor['position'] + offset.to(unit=monitor['position'].unit),
455
- source_position=source_position,
456
+ position=transform.value * sc.vector([0, 0, 0], unit=transform_unit)
457
+ + offset.to(unit=transform_unit),
456
458
  )
457
459
  )
458
460
 
@@ -2,7 +2,11 @@
2
2
  # Copyright (c) 2025 Scipp contributors (https://github.com/scipp)
3
3
  """Normalization routines for neutron data reduction."""
4
4
 
5
+ from __future__ import annotations
6
+
7
+ import enum
5
8
  import functools
9
+ import warnings
6
10
 
7
11
  import scipp as sc
8
12
 
@@ -14,6 +18,7 @@ def normalize_by_monitor_histogram(
14
18
  *,
15
19
  monitor: sc.DataArray,
16
20
  uncertainty_broadcast_mode: UncertaintyBroadcastMode,
21
+ skip_range_check: bool = False,
17
22
  ) -> sc.DataArray:
18
23
  """Normalize detector data by a normalized histogrammed monitor.
19
24
 
@@ -23,10 +28,13 @@ def normalize_by_monitor_histogram(
23
28
  - For *event* detectors, the monitor values are mapped to the detector
24
29
  using :func:`scipp.lookup`. That is, for detector event :math:`d_i`,
25
30
  :math:`m_i` is the monitor bin value at the same coordinate.
26
- - For *histogram* detectors, the monitor is rebinned using to the detector
31
+ - For *histogram* detectors, the monitor is generally rebinned using the detector
27
32
  binning using :func:`scipp.rebin`. Thus, detector value :math:`d_i` and
28
33
  monitor value :math:`m_i` correspond to the same bin.
29
34
 
35
+ - In case the detector coordinate does not have a dimension in common with the
36
+ monitor, :func:`scipp.lookup` is used as in the event case.
37
+
30
38
  In both cases, let :math:`x_i` be the lower bound of monitor bin :math:`i`
31
39
  and let :math:`\\Delta x_i = x_{i+1} - x_i` be the width of that bin.
32
40
 
@@ -47,6 +55,16 @@ def normalize_by_monitor_histogram(
47
55
  Must be one-dimensional and have a dimension coordinate, typically "wavelength".
48
56
  uncertainty_broadcast_mode:
49
57
  Choose how uncertainties of the monitor are broadcast to the sample data.
58
+ skip_range_check:
59
+ If false (default), the detector data must be within the range of the monitor
60
+ coordinate. Set this to true to disable the check.
61
+ The value of out-of-range bins / events is undefined in that case.
62
+
63
+ This is useful when the detector contains data outside the monitor range, and it
64
+ is difficult or impossible to slice the detector without also removing in-range
65
+ data. In this case, the caller can mask those data points and skip the range
66
+ check. ``normalize_by_monitor_histogram`` does not take masks into account when
67
+ checking ranges as that is expensive to implement in a general case.
50
68
 
51
69
  Returns
52
70
  -------
@@ -60,22 +78,45 @@ def normalize_by_monitor_histogram(
60
78
  normalize_by_monitor_integrated:
61
79
  Normalize by an integrated monitor.
62
80
  """
63
- _check_monitor_range_contains_detector(monitor=monitor, detector=detector)
81
+ if not skip_range_check:
82
+ _check_monitor_range_contains_detector(monitor=monitor, detector=detector)
64
83
 
65
84
  dim = monitor.dim
66
85
 
67
- if detector.bins is None:
68
- monitor = monitor.rebin({dim: detector.coords[dim]})
69
- detector = _mask_detector_for_norm(detector=detector, monitor=monitor)
70
- coord = monitor.coords[dim]
71
- delta_w = sc.DataArray(coord[1:] - coord[:-1], masks=monitor.masks)
72
- norm = broadcast_uncertainties(
73
- monitor / delta_w, prototype=detector, mode=uncertainty_broadcast_mode
74
- )
75
-
76
- if detector.bins is None:
77
- return detector / norm.rebin({dim: detector.coords[dim]})
78
- return detector.bins / sc.lookup(norm, dim=dim)
86
+ match _HistogramNormalizationMode.deduce(detector, dim):
87
+ case _HistogramNormalizationMode.Events:
88
+ detector = _mask_detector_for_norm(detector=detector, monitor=monitor)
89
+ norm = _histogram_monitor_term(
90
+ monitor,
91
+ dim,
92
+ broadcast_to=detector,
93
+ uncertainty_broadcast_mode=uncertainty_broadcast_mode,
94
+ )
95
+ if dim in detector.bins.coords:
96
+ return detector.bins / sc.lookup(norm, dim=dim)
97
+ else:
98
+ return detector / norm
99
+
100
+ case _HistogramNormalizationMode.BinsCommonDim:
101
+ monitor = monitor.rebin({dim: detector.coords[dim]})
102
+ detector = _mask_detector_for_norm(detector=detector, monitor=monitor)
103
+ norm = _histogram_monitor_term(
104
+ monitor,
105
+ dim,
106
+ broadcast_to=detector,
107
+ uncertainty_broadcast_mode=uncertainty_broadcast_mode,
108
+ )
109
+ return detector / norm
110
+
111
+ case _HistogramNormalizationMode.BinsDifferentDim:
112
+ detector = _mask_detector_for_norm(detector=detector, monitor=monitor)
113
+ # No broadcast here because there are no common dims, use lookup instead.
114
+ norm = _histogram_monitor_term(
115
+ monitor,
116
+ dim,
117
+ uncertainty_broadcast_mode=uncertainty_broadcast_mode,
118
+ )
119
+ return detector / sc.lookup(norm)[_compute_bin_centers(detector, dim)]
79
120
 
80
121
 
81
122
  def normalize_by_monitor_integrated(
@@ -83,6 +124,7 @@ def normalize_by_monitor_integrated(
83
124
  *,
84
125
  monitor: sc.DataArray,
85
126
  uncertainty_broadcast_mode: UncertaintyBroadcastMode,
127
+ skip_range_check: bool = False,
86
128
  ) -> sc.DataArray:
87
129
  """Normalize detector data by an integrated monitor.
88
130
 
@@ -113,6 +155,16 @@ def normalize_by_monitor_integrated(
113
155
  Must be one-dimensional and have a dimension coordinate, typically "wavelength".
114
156
  uncertainty_broadcast_mode:
115
157
  Choose how uncertainties of the monitor are broadcast to the sample data.
158
+ skip_range_check:
159
+ If false (default), the detector data must be within the range of the monitor
160
+ coordinate. Set this to true to disable the check.
161
+ The value of out-of-range bins / events is undefined in that case.
162
+
163
+ This is useful when the detector contains data outside the monitor range, and it
164
+ is difficult or impossible to slice the detector without also removing in-range
165
+ data. In this case, the caller can mask those data points and skip the range
166
+ check. ``normalize_by_monitor_histogram`` does not take masks into account when
167
+ checking ranges as that is expensive to implement in a general case.
116
168
 
117
169
  Returns
118
170
  -------
@@ -126,7 +178,8 @@ def normalize_by_monitor_integrated(
126
178
  normalize_by_monitor_histogram:
127
179
  Normalize by a monitor histogram.
128
180
  """
129
- _check_monitor_range_contains_detector(monitor=monitor, detector=detector)
181
+ if not skip_range_check:
182
+ _check_monitor_range_contains_detector(monitor=monitor, detector=detector)
130
183
  detector = _mask_detector_for_norm(detector=detector, monitor=monitor)
131
184
  norm = monitor.nansum().data
132
185
  norm = broadcast_uncertainties(
@@ -149,15 +202,15 @@ def _check_monitor_range_contains_detector(
149
202
  # monitor range that is less than the detector bins which is fine for the events,
150
203
  # but would be wrong if the detector was subsequently histogrammed.
151
204
  if (det_coord := detector.coords.get(dim)) is not None:
152
- lo = det_coord[dim, :-1].nanmin()
153
- hi = det_coord[dim, 1:].nanmax()
205
+ ...
154
206
  elif (det_coord := detector.bins.coords.get(dim)) is not None:
155
- lo = det_coord.nanmin()
156
- hi = det_coord.nanmax()
207
+ ...
157
208
  else:
158
209
  raise sc.CoordError(
159
210
  f"Missing '{dim}' coordinate in detector for monitor normalization."
160
211
  )
212
+ lo = det_coord.nanmin()
213
+ hi = det_coord.nanmax()
161
214
 
162
215
  if monitor.coords[dim].min() > lo or monitor.coords[dim].max() < hi:
163
216
  raise ValueError(
@@ -181,13 +234,17 @@ def _mask_detector_for_norm(
181
234
  if (monitor_mask := _monitor_mask(monitor)) is None:
182
235
  return detector
183
236
 
184
- if (detector_coord := detector.coords.get(monitor.dim)) is not None:
237
+ if (detector_coord := detector.coords.get(dim)) is not None:
185
238
  # Apply the mask to the bins or a dense detector.
186
- # Use rebin to reshape the mask to the detector.
187
- mask = sc.DataArray(monitor_mask, coords={dim: monitor.coords[dim]}).rebin(
188
- {dim: detector_coord}
189
- ).data != sc.scalar(0, unit=None)
190
- return detector.assign_masks({"_monitor_mask": mask})
239
+ mask_da = sc.DataArray(monitor_mask, coords={dim: monitor.coords[dim]})
240
+ if dim in detector_coord.dims:
241
+ # Use rebin to reshape the mask to the detector.
242
+ mask = mask_da.rebin({dim: detector_coord}).data != sc.scalar(0, unit=None)
243
+ return detector.assign_masks(_monitor_mask=mask)
244
+ # else: need to use lookup to apply mask at matching coord elements
245
+ return detector.assign_masks(
246
+ _monitor_mask=sc.lookup(mask_da)[_compute_bin_centers(detector, dim)]
247
+ )
191
248
 
192
249
  # else: Apply the mask to the events.
193
250
  if dim not in detector.bins.coords:
@@ -197,7 +254,7 @@ def _mask_detector_for_norm(
197
254
  event_mask = sc.lookup(
198
255
  sc.DataArray(monitor_mask, coords={dim: monitor.coords[dim]})
199
256
  )[detector.bins.coords[dim]]
200
- return detector.bins.assign_masks({"_monitor_mask": event_mask})
257
+ return detector.bins.assign_masks(_monitor_mask=event_mask)
201
258
 
202
259
 
203
260
  def _monitor_mask(monitor: sc.DataArray) -> sc.Variable | None:
@@ -213,3 +270,74 @@ def _monitor_mask(monitor: sc.DataArray) -> sc.Variable | None:
213
270
  if not masks:
214
271
  return None
215
272
  return functools.reduce(sc.logical_or, masks)
273
+
274
+
275
+ def _histogram_monitor_term(
276
+ monitor: sc.DataArray,
277
+ dim: str,
278
+ *,
279
+ broadcast_to: sc.DataArray | None = None,
280
+ uncertainty_broadcast_mode: UncertaintyBroadcastMode,
281
+ ) -> sc.DataArray:
282
+ if not monitor.coords.is_edges(dim, dim):
283
+ raise sc.CoordError(
284
+ f"Monitor coordinage {dim} must be bin-edges for normalization."
285
+ )
286
+ coord = monitor.coords[dim]
287
+ delta_w = sc.DataArray(coord[1:] - coord[:-1], masks=monitor.masks)
288
+ norm = monitor / delta_w
289
+
290
+ if broadcast_to is not None:
291
+ return broadcast_uncertainties(
292
+ norm, prototype=broadcast_to, mode=uncertainty_broadcast_mode
293
+ )
294
+
295
+ match uncertainty_broadcast_mode:
296
+ case UncertaintyBroadcastMode.fail:
297
+ return norm
298
+ case UncertaintyBroadcastMode.drop:
299
+ return sc.values(norm)
300
+ case _:
301
+ warnings.warn(
302
+ "Cannot broadcast uncertainties in this case.",
303
+ UserWarning,
304
+ stacklevel=3,
305
+ )
306
+ return norm
307
+
308
+
309
+ class _HistogramNormalizationMode(enum.Enum):
310
+ Events = enum.auto()
311
+ """Use an event coordinate to lookup monitor values."""
312
+ BinsCommonDim = enum.auto()
313
+ """Use a bin coordinate which contains the monitor dimension.
314
+
315
+ The coordinate may be multi-dimensional but one dimension matches
316
+ the dimension of the monitor.
317
+ """
318
+ BinsDifferentDim = enum.auto()
319
+ """Use a bin coordinate which does not contain the monitor dimension.
320
+
321
+ The coordinate may be multi-dimensions, e.g., in the DREAM powder workflow
322
+ where it has dims (two_theta, dspacing [bin-edges]).
323
+ """
324
+
325
+ @classmethod
326
+ def deduce(cls, detector: sc.DataArray, dim: str) -> _HistogramNormalizationMode:
327
+ # Use an event-coord when available:
328
+ if detector.bins is not None and dim in detector.bins.coords:
329
+ return _HistogramNormalizationMode.Events
330
+ # else: use a bin-coord.
331
+
332
+ det_coord = detector.coords[dim]
333
+ if dim in det_coord.dims:
334
+ return _HistogramNormalizationMode.BinsCommonDim
335
+ return _HistogramNormalizationMode.BinsDifferentDim
336
+
337
+
338
+ def _compute_bin_centers(da: sc.DataArray, name: str) -> sc.Variable:
339
+ coord = da.coords[name]
340
+ for dim in coord.dims:
341
+ if da.coords.is_edges(name, dim):
342
+ coord = sc.midpoints(coord, dim=dim)
343
+ return coord
@@ -317,7 +317,19 @@ def _time_of_flight_data_events(
317
317
  parts = da.bins.constituents
318
318
  parts["data"] = tofs
319
319
  result = da.bins.assign_coords(tof=sc.bins(**parts, validate_indices=False))
320
- return result.bins.drop_coords("event_time_offset")
320
+ out = result.bins.drop_coords("event_time_offset")
321
+
322
+ # The result may still have an 'event_time_zero' dimension (in the case of an
323
+ # event monitor where events were not grouped by pixel).
324
+ if "event_time_zero" in out.dims:
325
+ if ("event_time_zero" in out.coords) and (
326
+ "event_time_zero" not in out.bins.coords
327
+ ):
328
+ out.bins.coords["event_time_zero"] = sc.bins_like(
329
+ out, out.coords["event_time_zero"]
330
+ )
331
+ out = out.bins.concat("event_time_zero")
332
+ return out
321
333
 
322
334
 
323
335
  def detector_ltotal_from_straight_line_approximation(
@@ -357,6 +369,7 @@ def detector_ltotal_from_straight_line_approximation(
357
369
 
358
370
  def monitor_ltotal_from_straight_line_approximation(
359
371
  monitor_beamline: EmptyMonitor[RunType, MonitorType],
372
+ source_position: Position[snx.NXsource, RunType],
360
373
  ) -> MonitorLtotal[RunType, MonitorType]:
361
374
  """
362
375
  Compute Ltotal for the monitor.
@@ -369,7 +382,10 @@ def monitor_ltotal_from_straight_line_approximation(
369
382
  Beamline data for the monitor that contains the positions necessary to compute
370
383
  the straight-line approximation to Ltotal (source and monitor positions).
371
384
  """
372
- graph = scn.conversion.graph.beamline.beamline(scatter=False)
385
+ graph = {
386
+ **scn.conversion.graph.beamline.beamline(scatter=False),
387
+ 'source_position': lambda: source_position,
388
+ }
373
389
  return MonitorLtotal[RunType, MonitorType](
374
390
  monitor_beamline.transform_coords(
375
391
  "Ltotal", graph=graph, keep_intermediate=False
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: essreduce
3
- Version: 25.12.0
3
+ Version: 25.12.1
4
4
  Summary: Common data reduction tools for the ESS facility
5
5
  Author: Scipp contributors
6
6
  License-Expression: BSD-3-Clause
@@ -336,6 +336,79 @@ def test_get_calibrated_detector_forwards_masks(
336
336
  assert 'mymask' in detector.masks
337
337
 
338
338
 
339
+ def test_get_calibrated_detector_with_time_dependent_transformation(
340
+ time_dependent_depends_on: snx.TransformationChain,
341
+ ) -> None:
342
+ """Test that get_calibrated_detector works with time-dependent transformations.
343
+
344
+ This test verifies that when a transformation is time-dependent and resolved
345
+ to a specific time interval, the detector positions are correctly computed
346
+ using the transformation value at that time.
347
+ """
348
+ # Create a simple detector with pixel offsets
349
+ detector_number = sc.arange('detector_number', 4, unit=None)
350
+ data = sc.DataArray(
351
+ sc.empty_like(detector_number),
352
+ coords={
353
+ 'detector_number': detector_number,
354
+ 'x_pixel_offset': sc.array(
355
+ dims=['detector_number'], values=[0.0, 0.1, 0.2, 0.3], unit='m'
356
+ ),
357
+ 'y_pixel_offset': sc.zeros(dims=['detector_number'], shape=[4], unit='m'),
358
+ 'z_pixel_offset': sc.zeros(dims=['detector_number'], shape=[4], unit='m'),
359
+ },
360
+ )
361
+ nexus_detector = workflow.NeXusComponent[snx.NXdetector, SampleRun](
362
+ sc.DataGroup(
363
+ data=data,
364
+ depends_on=time_dependent_depends_on,
365
+ nexus_component_name='detector1',
366
+ )
367
+ )
368
+
369
+ # Resolve the time-dependent transformation to a specific time interval
370
+ # Using the middle time bin (time index 1): translation of (1, 2, 0) m
371
+ transform = workflow.to_transformation(
372
+ time_dependent_depends_on,
373
+ TimeInterval(slice(1, 2)),
374
+ )
375
+
376
+ # Get the calibrated detector
377
+ detector = workflow.get_calibrated_detector(
378
+ nexus_detector, offset=workflow.no_offset, bank_sizes={}, transform=transform
379
+ )
380
+
381
+ # Verify the detector has the correct structure
382
+ assert detector.sizes == {'detector_number': 4}
383
+ assert 'position' in detector.coords
384
+ assert detector.coords['position'].sizes == {'detector_number': 4}
385
+
386
+ # Verify the positions are correctly computed
387
+ # The transformation: translate (1, 0, 0) then translate (0, 2, 0)
388
+ # Combined with pixel offsets in x: [0.0, 0.1, 0.2, 0.3]
389
+ # Expected positions:
390
+ # [(1.0, 2.0, 0.0), (1.1, 2.0, 0.0), (1.2, 2.0, 0.0), (1.3, 2.0, 0.0)]
391
+ expected_x = sc.array(
392
+ dims=['detector_number'], values=[1.0, 1.1, 1.2, 1.3], unit='m'
393
+ )
394
+ expected_y = sc.array(
395
+ dims=['detector_number'], values=[2.0, 2.0, 2.0, 2.0], unit='m'
396
+ )
397
+ expected_z = sc.array(
398
+ dims=['detector_number'], values=[0.0, 0.0, 0.0, 0.0], unit='m'
399
+ )
400
+
401
+ assert sc.allclose(
402
+ detector.coords['position'].fields.x, expected_x, rtol=sc.scalar(1e-10)
403
+ )
404
+ assert sc.allclose(
405
+ detector.coords['position'].fields.y, expected_y, rtol=sc.scalar(1e-10)
406
+ )
407
+ assert sc.allclose(
408
+ detector.coords['position'].fields.z, expected_z, rtol=sc.scalar(1e-10)
409
+ )
410
+
411
+
339
412
  @pytest.fixture
340
413
  def calibrated_detector() -> workflow.EmptyDetector[SampleRun]:
341
414
  detector_number = sc.arange('detector_number', 6, unit=None)
@@ -429,10 +502,12 @@ def test_get_calibrated_monitor_extracts_data_field_from_nexus_monitor(
429
502
  monitor = workflow.get_calibrated_monitor(
430
503
  nexus_monitor,
431
504
  offset=workflow.no_offset,
432
- source_position=sc.vector([0.0, 0.0, -10.0], unit='m'),
505
+ transform=NeXusTransformation.from_chain(
506
+ workflow.get_transformation_chain(nexus_monitor),
507
+ ),
433
508
  )
434
509
  assert_identical(
435
- monitor.drop_coords(('position', 'source_position')),
510
+ monitor.drop_coords('position'),
436
511
  compute_component_position(nexus_monitor)['data'],
437
512
  )
438
513
 
@@ -444,11 +519,52 @@ def test_get_calibrated_monitor_subtracts_offset_from_position(
444
519
  monitor = workflow.get_calibrated_monitor(
445
520
  nexus_monitor,
446
521
  offset=offset,
447
- source_position=sc.vector([0.0, 0.0, -10.0], unit='m'),
522
+ transform=NeXusTransformation.from_chain(
523
+ workflow.get_transformation_chain(nexus_monitor),
524
+ ),
448
525
  )
449
526
  assert_identical(monitor.coords['position'], sc.vector([1.1, 2.2, 3.3], unit='m'))
450
527
 
451
528
 
529
+ def test_get_calibrated_monitor_with_time_dependent_transformation(
530
+ time_dependent_depends_on: snx.TransformationChain,
531
+ ) -> None:
532
+ """Test that get_calibrated_monitor works with time-dependent transformations.
533
+
534
+ This test verifies that when a transformation is time-dependent and resolved
535
+ to a specific time interval, the monitor position is correctly computed using
536
+ the transformation value at that time.
537
+ """
538
+ # Create a simple monitor
539
+ data = sc.DataArray(sc.scalar(1.2), coords={'something': sc.scalar(13)})
540
+ nexus_monitor = workflow.NeXusComponent[FrameMonitor1, SampleRun](
541
+ sc.DataGroup(data=data, depends_on=time_dependent_depends_on)
542
+ )
543
+
544
+ # Resolve the time-dependent transformation to a specific time interval
545
+ # Using the middle time bin (time index 1): translation of (1, 2, 0) m
546
+ transform = workflow.to_transformation(
547
+ time_dependent_depends_on,
548
+ TimeInterval(slice(1, 2)),
549
+ )
550
+
551
+ # Get the calibrated monitor
552
+ monitor = workflow.get_calibrated_monitor(
553
+ nexus_monitor, transform=transform, offset=workflow.no_offset
554
+ )
555
+
556
+ # Verify the monitor has the correct structure
557
+ assert 'position' in monitor.coords
558
+
559
+ # Verify the position is correctly computed
560
+ # The transformation: translate (1, 0, 0) then translate (0, 2, 0)
561
+ # Applied to origin gives position (1, 2, 0)
562
+ expected_position = sc.vector([1.0, 2.0, 0.0], unit='m')
563
+ assert sc.allclose(
564
+ monitor.coords['position'], expected_position, rtol=sc.scalar(1e-10)
565
+ )
566
+
567
+
452
568
  @pytest.fixture
453
569
  def calibrated_monitor() -> workflow.EmptyMonitor[SampleRun, FrameMonitor1]:
454
570
  return workflow.EmptyMonitor[SampleRun, FrameMonitor1](
@@ -559,7 +675,6 @@ def test_load_event_monitor_workflow(loki_tutorial_sample_run_60250: Path) -> No
559
675
  wf[NeXusName[FrameMonitor1]] = 'monitor_1'
560
676
  da = wf.compute(RawMonitor[SampleRun, FrameMonitor1])
561
677
  assert 'position' in da.coords
562
- assert 'source_position' in da.coords
563
678
  assert da.bins is not None
564
679
  assert da.dims == ('event_time_zero',)
565
680
  assert da.bins.constituents['data'].variances is not None
@@ -571,7 +686,6 @@ def test_load_histogram_monitor_workflow(dream_coda_test_file: Path) -> None:
571
686
  wf[NeXusName[FrameMonitor1]] = 'monitor_bunker'
572
687
  da = wf.compute(RawMonitor[SampleRun, FrameMonitor1])
573
688
  assert 'position' in da.coords
574
- assert 'source_position' in da.coords
575
689
  assert da.bins is None
576
690
  assert set(da.dims) == {'time', 'frame_time'}
577
691
  assert 'time' in da.coords.keys()
@@ -629,7 +743,6 @@ def test_generic_nexus_workflow(
629
743
  assert da.dims == ('detector_number',)
630
744
  da = wf.compute(RawMonitor[SampleRun, FrameMonitor1])
631
745
  assert 'position' in da.coords
632
- assert 'source_position' in da.coords
633
746
  assert da.bins is not None
634
747
  assert da.dims == ('event_time_zero',)
635
748
 
@@ -41,6 +41,7 @@ class TestNormalizeByMonitorHistogram:
41
41
  sc.array(dims=['w'], values=[0, 10, 30], unit='counts'),
42
42
  coords={'w': sc.arange('w', 3.0, unit='Å')},
43
43
  ).bin(w=sc.array(dims=['w'], values=[0.0, 2, 3], unit='Å'))
44
+ del detector.bins.coords['w']
44
45
  monitor = sc.DataArray(
45
46
  sc.array(dims=['w'], values=[5.0, 6.0], unit='counts'),
46
47
  coords={'w': sc.array(dims=['w'], values=[0.0, 2, 3], unit='Å')},
@@ -55,6 +56,7 @@ class TestNormalizeByMonitorHistogram:
55
56
  sc.array(dims=['w'], values=[0.0, 4, 5], unit='counts'),
56
57
  coords={'w': sc.arange('w', 3.0, unit='Å')},
57
58
  ).bin(w=sc.array(dims=['w'], values=[0.0, 2, 3], unit='Å'))
59
+ del expected.bins.coords['w']
58
60
 
59
61
  sc.testing.assert_identical(normalized, expected)
60
62
 
@@ -565,6 +567,47 @@ class TestNormalizeByMonitorHistogram:
565
567
 
566
568
  sc.testing.assert_allclose(normalized, expected)
567
569
 
570
+ def test_different_dims_dense(self) -> None:
571
+ detector = sc.DataArray(
572
+ sc.array( # sizes={'x': 3, 'y': 2}
573
+ dims=['x', 'y'], values=[[11.0, 10], [9, 8], [7, 6]], unit='counts'
574
+ ),
575
+ coords={
576
+ 'x': sc.array(dims=['x'], values=[0.0, 1, 2, 3], unit='m'),
577
+ 'y': sc.array(dims=['y'], values=[-5, -1, 4], unit='kg'),
578
+ 'w': sc.array( # bin edges in x
579
+ dims=['x', 'y'], values=[[1.0, 2], [2, 3], [4, 5], [5, 3]], unit='Å'
580
+ ),
581
+ },
582
+ )
583
+ monitor = sc.DataArray(
584
+ sc.array(dims=['w'], values=[3.0, 5, 7], unit='counts'),
585
+ coords={'w': sc.array(dims=['w'], values=[0.0, 2, 4, 6], unit='Å')},
586
+ masks={'M': sc.array(dims=['w'], values=[False, True, False])},
587
+ )
588
+ normalized = normalize_by_monitor_histogram(
589
+ detector,
590
+ monitor=monitor,
591
+ uncertainty_broadcast_mode=UncertaintyBroadcastMode.fail,
592
+ )
593
+
594
+ expected = sc.DataArray(
595
+ sc.array(
596
+ dims=['x', 'y'],
597
+ values=[[22 / 3, np.nan], [np.nan, 16 / 7], [2.0, 12 / 7]],
598
+ unit='counts',
599
+ ),
600
+ coords=detector.coords,
601
+ masks={
602
+ '_monitor_mask': sc.array(
603
+ dims=['x', 'y'],
604
+ values=[[False, True], [True, False], [False, False]],
605
+ ),
606
+ },
607
+ )
608
+
609
+ sc.testing.assert_allclose(normalized, expected)
610
+
568
611
  def test_independent_of_monitor_binning_bin(self) -> None:
569
612
  detector = sc.DataArray(
570
613
  sc.array(dims=['w'], values=[3, 10, 20, 30], unit='counts'),
@@ -26,8 +26,8 @@ commands = pytest {posargs}
26
26
  description = invoke sphinx-build to build the HTML docs
27
27
  deps = -r requirements/docs.txt
28
28
  allowlist_externals=find
29
- commands = python -m sphinx -j2 -v -b html -d {toxworkdir}/docs_doctrees docs html
30
- python -m sphinx -j2 -v -b doctest -d {toxworkdir}/docs_doctrees docs html
29
+ commands = python -m sphinx -v -b html -d {toxworkdir}/docs_doctrees docs html
30
+ python -m sphinx -v -b doctest -d {toxworkdir}/docs_doctrees docs html
31
31
  find html -type f -name "*.ipynb" -not -path "html/_sources/*" -delete
32
32
 
33
33
  [testenv:releasedocs]
@@ -42,7 +42,7 @@ commands = {[testenv:docs]commands}
42
42
  [testenv:linkcheck]
43
43
  description = Run Sphinx linkcheck
44
44
  deps = -r requirements/docs.txt
45
- commands = python -m sphinx -j2 -v -b linkcheck -d {toxworkdir}/docs_doctrees docs html
45
+ commands = python -m sphinx -v -b linkcheck -d {toxworkdir}/docs_doctrees docs html
46
46
 
47
47
  [testenv:static]
48
48
  description = Code formatting and static analysis
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes