essreduce 25.2.6__tar.gz → 25.3.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 (137) hide show
  1. {essreduce-25.2.6/src/essreduce.egg-info → essreduce-25.3.1}/PKG-INFO +1 -1
  2. {essreduce-25.2.6 → essreduce-25.3.1}/src/ess/reduce/nexus/_nexus_loader.py +5 -5
  3. {essreduce-25.2.6 → essreduce-25.3.1}/src/ess/reduce/nexus/workflow.py +3 -1
  4. {essreduce-25.2.6 → essreduce-25.3.1}/src/ess/reduce/streaming.py +91 -8
  5. {essreduce-25.2.6 → essreduce-25.3.1}/src/ess/reduce/time_of_flight/__init__.py +1 -1
  6. essreduce-25.2.6/src/ess/reduce/time_of_flight/toa_to_tof.py → essreduce-25.3.1/src/ess/reduce/time_of_flight/eto_to_tof.py +106 -58
  7. {essreduce-25.2.6 → essreduce-25.3.1/src/essreduce.egg-info}/PKG-INFO +1 -1
  8. {essreduce-25.2.6 → essreduce-25.3.1}/src/essreduce.egg-info/SOURCES.txt +1 -1
  9. {essreduce-25.2.6 → essreduce-25.3.1}/tests/nexus/nexus_loader_test.py +9 -9
  10. {essreduce-25.2.6 → essreduce-25.3.1}/tests/nexus/workflow_test.py +4 -1
  11. {essreduce-25.2.6 → essreduce-25.3.1}/tests/streaming_test.py +514 -0
  12. {essreduce-25.2.6 → essreduce-25.3.1}/tests/time_of_flight/unwrap_test.py +140 -25
  13. {essreduce-25.2.6 → essreduce-25.3.1}/.copier-answers.ess.yml +0 -0
  14. {essreduce-25.2.6 → essreduce-25.3.1}/.copier-answers.yml +0 -0
  15. {essreduce-25.2.6 → essreduce-25.3.1}/.github/ISSUE_TEMPLATE/blank.md +0 -0
  16. {essreduce-25.2.6 → essreduce-25.3.1}/.github/ISSUE_TEMPLATE/high-level-requirement.yml +0 -0
  17. {essreduce-25.2.6 → essreduce-25.3.1}/.github/dependabot.yml +0 -0
  18. {essreduce-25.2.6 → essreduce-25.3.1}/.github/workflows/ci.yml +0 -0
  19. {essreduce-25.2.6 → essreduce-25.3.1}/.github/workflows/docs.yml +0 -0
  20. {essreduce-25.2.6 → essreduce-25.3.1}/.github/workflows/nightly_at_main.yml +0 -0
  21. {essreduce-25.2.6 → essreduce-25.3.1}/.github/workflows/nightly_at_release.yml +0 -0
  22. {essreduce-25.2.6 → essreduce-25.3.1}/.github/workflows/python-version-ci +0 -0
  23. {essreduce-25.2.6 → essreduce-25.3.1}/.github/workflows/release.yml +0 -0
  24. {essreduce-25.2.6 → essreduce-25.3.1}/.github/workflows/test.yml +0 -0
  25. {essreduce-25.2.6 → essreduce-25.3.1}/.github/workflows/unpinned.yml +0 -0
  26. {essreduce-25.2.6 → essreduce-25.3.1}/.github/workflows/weekly_windows_macos.yml +0 -0
  27. {essreduce-25.2.6 → essreduce-25.3.1}/.gitignore +0 -0
  28. {essreduce-25.2.6 → essreduce-25.3.1}/.pre-commit-config.yaml +0 -0
  29. {essreduce-25.2.6 → essreduce-25.3.1}/.python-version +0 -0
  30. {essreduce-25.2.6 → essreduce-25.3.1}/CODE_OF_CONDUCT.md +0 -0
  31. {essreduce-25.2.6 → essreduce-25.3.1}/CONTRIBUTING.md +0 -0
  32. {essreduce-25.2.6 → essreduce-25.3.1}/LICENSE +0 -0
  33. {essreduce-25.2.6 → essreduce-25.3.1}/MANIFEST.in +0 -0
  34. {essreduce-25.2.6 → essreduce-25.3.1}/README.md +0 -0
  35. {essreduce-25.2.6 → essreduce-25.3.1}/conda/meta.yaml +0 -0
  36. {essreduce-25.2.6 → essreduce-25.3.1}/docs/_static/anaconda-icon.js +0 -0
  37. {essreduce-25.2.6 → essreduce-25.3.1}/docs/_static/favicon.svg +0 -0
  38. {essreduce-25.2.6 → essreduce-25.3.1}/docs/_static/logo-dark.svg +0 -0
  39. {essreduce-25.2.6 → essreduce-25.3.1}/docs/_static/logo.svg +0 -0
  40. {essreduce-25.2.6 → essreduce-25.3.1}/docs/_templates/class-template.rst +0 -0
  41. {essreduce-25.2.6 → essreduce-25.3.1}/docs/_templates/doc_version.html +0 -0
  42. {essreduce-25.2.6 → essreduce-25.3.1}/docs/_templates/module-template.rst +0 -0
  43. {essreduce-25.2.6 → essreduce-25.3.1}/docs/about/index.md +0 -0
  44. {essreduce-25.2.6 → essreduce-25.3.1}/docs/api-reference/index.md +0 -0
  45. {essreduce-25.2.6 → essreduce-25.3.1}/docs/conf.py +0 -0
  46. {essreduce-25.2.6 → essreduce-25.3.1}/docs/developer/coding-conventions.md +0 -0
  47. {essreduce-25.2.6 → essreduce-25.3.1}/docs/developer/dependency-management.md +0 -0
  48. {essreduce-25.2.6 → essreduce-25.3.1}/docs/developer/getting-started.md +0 -0
  49. {essreduce-25.2.6 → essreduce-25.3.1}/docs/developer/gui.ipynb +0 -0
  50. {essreduce-25.2.6 → essreduce-25.3.1}/docs/developer/index.md +0 -0
  51. {essreduce-25.2.6 → essreduce-25.3.1}/docs/index.md +0 -0
  52. {essreduce-25.2.6 → essreduce-25.3.1}/docs/user-guide/index.md +0 -0
  53. {essreduce-25.2.6 → essreduce-25.3.1}/docs/user-guide/reduction-workflow-guidelines.md +0 -0
  54. {essreduce-25.2.6 → essreduce-25.3.1}/docs/user-guide/tof/dream.ipynb +0 -0
  55. {essreduce-25.2.6 → essreduce-25.3.1}/docs/user-guide/tof/frame-unwrapping.ipynb +0 -0
  56. {essreduce-25.2.6 → essreduce-25.3.1}/docs/user-guide/tof/index.md +0 -0
  57. {essreduce-25.2.6 → essreduce-25.3.1}/docs/user-guide/tof/wfm.ipynb +0 -0
  58. {essreduce-25.2.6 → essreduce-25.3.1}/docs/user-guide/widget.md +0 -0
  59. {essreduce-25.2.6 → essreduce-25.3.1}/pyproject.toml +0 -0
  60. {essreduce-25.2.6 → essreduce-25.3.1}/requirements/base.in +0 -0
  61. {essreduce-25.2.6 → essreduce-25.3.1}/requirements/base.txt +0 -0
  62. {essreduce-25.2.6 → essreduce-25.3.1}/requirements/basetest.in +0 -0
  63. {essreduce-25.2.6 → essreduce-25.3.1}/requirements/basetest.txt +0 -0
  64. {essreduce-25.2.6 → essreduce-25.3.1}/requirements/ci.in +0 -0
  65. {essreduce-25.2.6 → essreduce-25.3.1}/requirements/ci.txt +0 -0
  66. {essreduce-25.2.6 → essreduce-25.3.1}/requirements/dev.in +0 -0
  67. {essreduce-25.2.6 → essreduce-25.3.1}/requirements/dev.txt +0 -0
  68. {essreduce-25.2.6 → essreduce-25.3.1}/requirements/docs.in +0 -0
  69. {essreduce-25.2.6 → essreduce-25.3.1}/requirements/docs.txt +0 -0
  70. {essreduce-25.2.6 → essreduce-25.3.1}/requirements/make_base.py +0 -0
  71. {essreduce-25.2.6 → essreduce-25.3.1}/requirements/mypy.in +0 -0
  72. {essreduce-25.2.6 → essreduce-25.3.1}/requirements/mypy.txt +0 -0
  73. {essreduce-25.2.6 → essreduce-25.3.1}/requirements/nightly.in +0 -0
  74. {essreduce-25.2.6 → essreduce-25.3.1}/requirements/nightly.txt +0 -0
  75. {essreduce-25.2.6 → essreduce-25.3.1}/requirements/static.in +0 -0
  76. {essreduce-25.2.6 → essreduce-25.3.1}/requirements/static.txt +0 -0
  77. {essreduce-25.2.6 → essreduce-25.3.1}/requirements/test.in +0 -0
  78. {essreduce-25.2.6 → essreduce-25.3.1}/requirements/test.txt +0 -0
  79. {essreduce-25.2.6 → essreduce-25.3.1}/requirements/wheels.in +0 -0
  80. {essreduce-25.2.6 → essreduce-25.3.1}/requirements/wheels.txt +0 -0
  81. {essreduce-25.2.6 → essreduce-25.3.1}/resources/logo.svg +0 -0
  82. {essreduce-25.2.6 → essreduce-25.3.1}/setup.cfg +0 -0
  83. {essreduce-25.2.6 → essreduce-25.3.1}/src/ess/reduce/__init__.py +0 -0
  84. {essreduce-25.2.6 → essreduce-25.3.1}/src/ess/reduce/data.py +0 -0
  85. {essreduce-25.2.6 → essreduce-25.3.1}/src/ess/reduce/live/__init__.py +0 -0
  86. {essreduce-25.2.6 → essreduce-25.3.1}/src/ess/reduce/live/raw.py +0 -0
  87. {essreduce-25.2.6 → essreduce-25.3.1}/src/ess/reduce/live/roi.py +0 -0
  88. {essreduce-25.2.6 → essreduce-25.3.1}/src/ess/reduce/live/workflow.py +0 -0
  89. {essreduce-25.2.6 → essreduce-25.3.1}/src/ess/reduce/logging.py +0 -0
  90. {essreduce-25.2.6 → essreduce-25.3.1}/src/ess/reduce/nexus/__init__.py +0 -0
  91. {essreduce-25.2.6 → essreduce-25.3.1}/src/ess/reduce/nexus/json_generator.py +0 -0
  92. {essreduce-25.2.6 → essreduce-25.3.1}/src/ess/reduce/nexus/json_nexus.py +0 -0
  93. {essreduce-25.2.6 → essreduce-25.3.1}/src/ess/reduce/nexus/types.py +0 -0
  94. {essreduce-25.2.6 → essreduce-25.3.1}/src/ess/reduce/parameter.py +0 -0
  95. {essreduce-25.2.6 → essreduce-25.3.1}/src/ess/reduce/py.typed +0 -0
  96. {essreduce-25.2.6 → essreduce-25.3.1}/src/ess/reduce/scripts/grow_nexus.py +0 -0
  97. {essreduce-25.2.6 → essreduce-25.3.1}/src/ess/reduce/time_of_flight/fakes.py +0 -0
  98. {essreduce-25.2.6 → essreduce-25.3.1}/src/ess/reduce/time_of_flight/simulation.py +0 -0
  99. {essreduce-25.2.6 → essreduce-25.3.1}/src/ess/reduce/time_of_flight/to_events.py +0 -0
  100. {essreduce-25.2.6 → essreduce-25.3.1}/src/ess/reduce/time_of_flight/types.py +0 -0
  101. {essreduce-25.2.6 → essreduce-25.3.1}/src/ess/reduce/ui.py +0 -0
  102. {essreduce-25.2.6 → essreduce-25.3.1}/src/ess/reduce/uncertainty.py +0 -0
  103. {essreduce-25.2.6 → essreduce-25.3.1}/src/ess/reduce/widgets/__init__.py +0 -0
  104. {essreduce-25.2.6 → essreduce-25.3.1}/src/ess/reduce/widgets/_base.py +0 -0
  105. {essreduce-25.2.6 → essreduce-25.3.1}/src/ess/reduce/widgets/_binedges_widget.py +0 -0
  106. {essreduce-25.2.6 → essreduce-25.3.1}/src/ess/reduce/widgets/_bounds_widget.py +0 -0
  107. {essreduce-25.2.6 → essreduce-25.3.1}/src/ess/reduce/widgets/_config.py +0 -0
  108. {essreduce-25.2.6 → essreduce-25.3.1}/src/ess/reduce/widgets/_filename_widget.py +0 -0
  109. {essreduce-25.2.6 → essreduce-25.3.1}/src/ess/reduce/widgets/_linspace_widget.py +0 -0
  110. {essreduce-25.2.6 → essreduce-25.3.1}/src/ess/reduce/widgets/_optional_widget.py +0 -0
  111. {essreduce-25.2.6 → essreduce-25.3.1}/src/ess/reduce/widgets/_spinner.py +0 -0
  112. {essreduce-25.2.6 → essreduce-25.3.1}/src/ess/reduce/widgets/_string_widget.py +0 -0
  113. {essreduce-25.2.6 → essreduce-25.3.1}/src/ess/reduce/widgets/_switchable_widget.py +0 -0
  114. {essreduce-25.2.6 → essreduce-25.3.1}/src/ess/reduce/widgets/_vector_widget.py +0 -0
  115. {essreduce-25.2.6 → essreduce-25.3.1}/src/ess/reduce/workflow.py +0 -0
  116. {essreduce-25.2.6 → essreduce-25.3.1}/src/essreduce.egg-info/dependency_links.txt +0 -0
  117. {essreduce-25.2.6 → essreduce-25.3.1}/src/essreduce.egg-info/entry_points.txt +0 -0
  118. {essreduce-25.2.6 → essreduce-25.3.1}/src/essreduce.egg-info/requires.txt +0 -0
  119. {essreduce-25.2.6 → essreduce-25.3.1}/src/essreduce.egg-info/top_level.txt +0 -0
  120. {essreduce-25.2.6 → essreduce-25.3.1}/tests/live/raw_test.py +0 -0
  121. {essreduce-25.2.6 → essreduce-25.3.1}/tests/live/roi_test.py +0 -0
  122. {essreduce-25.2.6 → essreduce-25.3.1}/tests/nexus/json_generator_test.py +0 -0
  123. {essreduce-25.2.6 → essreduce-25.3.1}/tests/nexus/json_nexus_examples/array_dataset.json +0 -0
  124. {essreduce-25.2.6 → essreduce-25.3.1}/tests/nexus/json_nexus_examples/dataset.json +0 -0
  125. {essreduce-25.2.6 → essreduce-25.3.1}/tests/nexus/json_nexus_examples/detector.json +0 -0
  126. {essreduce-25.2.6 → essreduce-25.3.1}/tests/nexus/json_nexus_examples/entry.json +0 -0
  127. {essreduce-25.2.6 → essreduce-25.3.1}/tests/nexus/json_nexus_examples/event_data.json +0 -0
  128. {essreduce-25.2.6 → essreduce-25.3.1}/tests/nexus/json_nexus_examples/instrument.json +0 -0
  129. {essreduce-25.2.6 → essreduce-25.3.1}/tests/nexus/json_nexus_examples/log.json +0 -0
  130. {essreduce-25.2.6 → essreduce-25.3.1}/tests/nexus/json_nexus_test.py +0 -0
  131. {essreduce-25.2.6 → essreduce-25.3.1}/tests/package_test.py +0 -0
  132. {essreduce-25.2.6 → essreduce-25.3.1}/tests/scripts/test_grow_nexus.py +0 -0
  133. {essreduce-25.2.6 → essreduce-25.3.1}/tests/time_of_flight/to_events_test.py +0 -0
  134. {essreduce-25.2.6 → essreduce-25.3.1}/tests/time_of_flight/wfm_test.py +0 -0
  135. {essreduce-25.2.6 → essreduce-25.3.1}/tests/uncertainty_test.py +0 -0
  136. {essreduce-25.2.6 → essreduce-25.3.1}/tests/widget_test.py +0 -0
  137. {essreduce-25.2.6 → essreduce-25.3.1}/tox.ini +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: essreduce
3
- Version: 25.2.6
3
+ Version: 25.3.1
4
4
  Summary: Common data reduction tools for the ESS facility
5
5
  Author: Scipp contributors
6
6
  License: BSD 3-Clause License
@@ -85,7 +85,7 @@ def load_metadata(
85
85
  entry_name: NeXusEntryName | None = None,
86
86
  definitions: Mapping | NoNewDefinitionsType = NoNewDefinitions,
87
87
  ) -> _Model:
88
- with _open_nexus_file(file_path, definitions=definitions) as f:
88
+ with open_nexus_file(file_path, definitions=definitions) as f:
89
89
  entry = _unique_child_group(f, snx.NXentry, entry_name)
90
90
  return model.from_nexus_entry(entry)
91
91
 
@@ -113,7 +113,7 @@ def compute_component_position(dg: sc.DataGroup) -> sc.DataGroup:
113
113
  )
114
114
 
115
115
 
116
- def _open_nexus_file(
116
+ def open_nexus_file(
117
117
  file_path: FilePath | NeXusFile | NeXusGroup,
118
118
  definitions: Mapping | None | NoNewDefinitionsType = NoNewDefinitions,
119
119
  *,
@@ -212,7 +212,7 @@ def _open_component_parent(
212
212
  """Locate the parent group of a NeXus component."""
213
213
  file_path = location.filename
214
214
  entry_name = location.entry_name
215
- with _open_nexus_file(file_path, definitions=definitions) as f:
215
+ with open_nexus_file(file_path, definitions=definitions) as f:
216
216
  entry = _unique_child_group(f, snx.NXentry, entry_name)
217
217
  if nx_class is snx.NXsample:
218
218
  yield entry
@@ -357,7 +357,7 @@ def load_data(
357
357
  :
358
358
  Data array with events or a histogram.
359
359
  """
360
- with _open_nexus_file(file_path, definitions=definitions) as f:
360
+ with open_nexus_file(file_path, definitions=definitions) as f:
361
361
  entry = _unique_child_group(f, snx.NXentry, entry_name)
362
362
  instrument = _unique_child_group(entry, snx.NXinstrument, None)
363
363
  component = instrument[component_name]
@@ -554,7 +554,7 @@ def _parse_monitor(group: snx.Group) -> NeXusMonitorInfo:
554
554
 
555
555
  def read_nexus_file_info(file_path: FilePath | NeXusFile | NeXusGroup) -> NeXusFileInfo:
556
556
  """Opens and inspects a NeXus file, returning a summary of its contents."""
557
- with _open_nexus_file(file_path) as f:
557
+ with open_nexus_file(file_path) as f:
558
558
  entry = _unique_child_group(f, snx.NXentry, None)
559
559
  instrument = _unique_child_group(entry, snx.NXinstrument, None)
560
560
  detectors = {}
@@ -62,7 +62,9 @@ def file_path_to_file_spec(
62
62
  filename: Filename[RunType], preopen: PreopenNeXusFile
63
63
  ) -> NeXusFileSpec[RunType]:
64
64
  return NeXusFileSpec[RunType](
65
- snx.File(filename, definitions=definitions) if preopen else filename
65
+ nexus.open_nexus_file(filename, definitions=definitions)
66
+ if preopen
67
+ else filename
66
68
  )
67
69
 
68
70
 
@@ -240,6 +240,22 @@ class StreamProcessor:
240
240
  processing based on the input keys. In particular, it is the responsibility of the
241
241
  user to ensure that the workflow is "linear" with respect to the dynamic keys up to
242
242
  the accumulation keys.
243
+
244
+ Similarly, the stream processor cannot determine from the workflow structure whether
245
+ context updates are compatible with the accumulated data. Accumulators are not
246
+ cleared automatically. This is best illustrated with an example:
247
+
248
+ - If the context is the detector rotation angle, and we accumulate I(Q) (or a
249
+ prerequisite of I(Q)), then updating the detector angle context is compatible with
250
+ previous data, assuming Q for each new chunk is computed based on the angle.
251
+ - If the context is the sample temperature, and we accumulate I(Q), then updating
252
+ the temperature context is not compatible with previous data. Accumulating I(Q, T)
253
+ could be compatible in this case.
254
+
255
+ Since the correctness cannot be determined from the workflow structure, we recommend
256
+ implementing processing steps in a way to catch such problems. For example, adding
257
+ the temperature as a coordinate to the I(Q) data array should allow for
258
+ automatically raising in the accumulator if the temperature changes.
243
259
  """
244
260
 
245
261
  def __init__(
@@ -247,6 +263,7 @@ class StreamProcessor:
247
263
  base_workflow: sciline.Pipeline,
248
264
  *,
249
265
  dynamic_keys: tuple[sciline.typing.Key, ...],
266
+ context_keys: tuple[sciline.typing.Key, ...] = (),
250
267
  target_keys: tuple[sciline.typing.Key, ...],
251
268
  accumulators: dict[sciline.typing.Key, Accumulator | Callable[..., Accumulator]]
252
269
  | tuple[sciline.typing.Key, ...],
@@ -260,7 +277,12 @@ class StreamProcessor:
260
277
  base_workflow:
261
278
  Workflow to be used for processing chunks.
262
279
  dynamic_keys:
263
- Keys that are expected to be updated with each chunk.
280
+ Keys that are expected to be updated with each chunk. These keys cannot
281
+ depend on each other or on context_keys.
282
+ context_keys:
283
+ Keys that define context for processing chunks and may change occasionally.
284
+ These keys cannot overlap with dynamic_keys or depend on each other or on
285
+ dynamic_keys.
264
286
  target_keys:
265
287
  Keys to be computed and returned.
266
288
  accumulators:
@@ -275,21 +297,59 @@ class StreamProcessor:
275
297
  unless the values for these keys are valid for all chunks comprised in the
276
298
  final accumulators at the point where :py:meth:`finalize` is called.
277
299
  """
300
+ self._dynamic_keys = set(dynamic_keys)
301
+ self._context_keys = set(context_keys)
302
+
303
+ # Validate that dynamic and context keys do not overlap
304
+ overlap = self._dynamic_keys & self._context_keys
305
+ if overlap:
306
+ raise ValueError(f"Keys cannot be both dynamic and context: {overlap}")
307
+
308
+ # Check dynamic/context keys don't depend on other dynamic/context keys
309
+ graph = base_workflow.underlying_graph
310
+ special_keys = self._dynamic_keys | self._context_keys
311
+ for key in special_keys:
312
+ if key not in graph:
313
+ continue
314
+ ancestors = nx.ancestors(graph, key)
315
+ special_ancestors = ancestors & special_keys
316
+ downstream = 'Dynamic' if key in self._dynamic_keys else 'Context'
317
+ if special_ancestors:
318
+ raise ValueError(
319
+ f"{downstream} key '{key}' depends on other dynamic/context keys: "
320
+ f"{special_ancestors}. This is not supported."
321
+ )
322
+
278
323
  workflow = sciline.Pipeline()
279
324
  for key in target_keys:
280
325
  workflow[key] = base_workflow[key]
281
326
  for key in dynamic_keys:
282
327
  workflow[key] = None # hack to prune branches
283
-
284
- self._dynamic_keys = set(dynamic_keys)
328
+ for key in context_keys:
329
+ workflow[key] = None
285
330
 
286
331
  # Find and pre-compute static nodes as far down the graph as possible
287
- # See also https://github.com/scipp/sciline/issues/148.
288
- nodes = _find_descendants(workflow, dynamic_keys)
289
- parents = _find_parents(workflow, nodes) - nodes
290
- for key, value in base_workflow.compute(parents).items():
332
+ nodes = _find_descendants(workflow, dynamic_keys + context_keys)
333
+ last_static = _find_parents(workflow, nodes) - nodes
334
+ for key, value in base_workflow.compute(last_static).items():
291
335
  workflow[key] = value
292
336
 
337
+ # Nodes that may need updating on context change but should be cached otherwise.
338
+ dynamic_nodes = _find_descendants(workflow, dynamic_keys)
339
+ # Nodes as far "down" in the graph as possible, right before the dynamic nodes.
340
+ # This also includes target keys that are not dynamic but context-dependent.
341
+ context_to_cache = (
342
+ (_find_parents(workflow, dynamic_nodes) | set(target_keys)) - dynamic_nodes
343
+ ) & _find_descendants(workflow, context_keys)
344
+ graph = workflow.underlying_graph
345
+ self._context_key_to_cached_context_nodes_map = {
346
+ context_key: ({context_key} | nx.descendants(graph, context_key))
347
+ & context_to_cache
348
+ for context_key in self._context_keys
349
+ if context_key in graph
350
+ }
351
+
352
+ self._context_workflow = workflow.copy()
293
353
  self._process_chunk_workflow = workflow.copy()
294
354
  self._finalize_workflow = workflow.copy()
295
355
  self._accumulators = (
@@ -299,7 +359,6 @@ class StreamProcessor:
299
359
  )
300
360
 
301
361
  # Map each accumulator to its dependent dynamic keys
302
- graph = workflow.underlying_graph
303
362
  self._accumulator_dependencies = {
304
363
  acc_key: nx.ancestors(graph, acc_key) & self._dynamic_keys
305
364
  for acc_key in self._accumulators
@@ -323,6 +382,30 @@ class StreamProcessor:
323
382
  self._target_keys = target_keys
324
383
  self._allow_bypass = allow_bypass
325
384
 
385
+ def set_context(self, context: dict[sciline.typing.Key, Any]) -> None:
386
+ """
387
+ Set the context for processing chunks.
388
+
389
+ Parameters
390
+ ----------
391
+ context:
392
+ Context to be set.
393
+ """
394
+ needs_recompute = set()
395
+ for key in context:
396
+ if key not in self._context_keys:
397
+ raise ValueError(f"Key '{key}' is not a context key")
398
+ needs_recompute |= self._context_key_to_cached_context_nodes_map[key]
399
+ for key, value in context.items():
400
+ self._context_workflow[key] = value
401
+ results = self._context_workflow.compute(needs_recompute)
402
+ for key, value in results.items():
403
+ if key in self._target_keys:
404
+ # Context-dependent key is direct target, independent of dynamic nodes.
405
+ self._finalize_workflow[key] = value
406
+ else:
407
+ self._process_chunk_workflow[key] = value
408
+
326
409
  def add_chunk(
327
410
  self, chunks: dict[sciline.typing.Key, Any]
328
411
  ) -> dict[sciline.typing.Key, Any]:
@@ -6,9 +6,9 @@ Utilities for computing real neutron time-of-flight from chopper settings and
6
6
  neutron time-of-arrival at the detectors.
7
7
  """
8
8
 
9
+ from .eto_to_tof import default_parameters, providers, resample_tof_data
9
10
  from .simulation import simulate_beamline
10
11
  from .to_events import to_events
11
- from .toa_to_tof import default_parameters, providers, resample_tof_data
12
12
  from .types import (
13
13
  DistanceResolution,
14
14
  LookupTableRelativeErrorThreshold,
@@ -284,38 +284,90 @@ def compute_tof_lookup_table(
284
284
  )
285
285
 
286
286
 
287
- def _make_tof_interpolator(
288
- lookup: sc.DataArray, distance_unit: str, time_unit: str
289
- ) -> Callable:
290
- from scipy.interpolate import RegularGridInterpolator
291
-
292
- # TODO: to make use of multi-threading, we could write our own interpolator.
293
- # This should be simple enough as we are making the bins linspace, so computing
294
- # bin indices is fast.
295
-
296
- # In the pulse dimension, it could be that for a given event_time_offset and
297
- # distance, a tof value is finite in one pulse and NaN in the other.
298
- # When using the bilinear interpolation, even if the value of the requested point is
299
- # exactly 0 or 1 (in the case of pulse_stride=2), the interpolator will still
300
- # use all 4 corners surrounding the point. This means that if one of the corners
301
- # is NaN, the result will be NaN.
302
- # Here, we use a trick where we duplicate the lookup values in the 'pulse' dimension
303
- # so that the interpolator has values on bin edges for that dimension.
304
- # The interpolator raises an error if axes coordinates are not strictly monotonic,
305
- # so we cannot use e.g. [-0.5, 0.5, 0.5, 1.5] in the case of pulse_stride=2.
306
- # Instead we use [-0.25, 0.25, 0.75, 1.25].
307
- base_grid = np.arange(float(lookup.sizes["pulse"]))
308
- return RegularGridInterpolator(
309
- (
310
- np.sort(np.concatenate([base_grid - 0.25, base_grid + 0.25])),
311
- lookup.coords["distance"].to(unit=distance_unit, copy=False).values,
312
- lookup.coords["event_time_offset"].to(unit=time_unit, copy=False).values,
313
- ),
314
- np.repeat(lookup.data.to(unit=time_unit, copy=False).values, 2, axis=0),
315
- method="linear",
316
- bounds_error=False,
317
- fill_value=np.nan,
318
- )
287
+ class TofInterpolator:
288
+ def __init__(self, lookup: sc.DataArray, distance_unit: str, time_unit: str):
289
+ from scipy.interpolate import RegularGridInterpolator
290
+
291
+ # TODO: to make use of multi-threading, we could write our own interpolator.
292
+ # This should be simple enough as we are making the bins linspace, so computing
293
+ # bin indices is fast.
294
+
295
+ self._distance_unit = distance_unit
296
+ self._time_unit = time_unit
297
+
298
+ # In the pulse dimension, it could be that for a given event_time_offset and
299
+ # distance, a tof value is finite in one pulse and NaN in the other.
300
+ # When using the bilinear interpolation, even if the value of the requested
301
+ # point is exactly 0 or 1 (in the case of pulse_stride=2), the interpolator
302
+ # will still use all 4 corners surrounding the point. This means that if one of
303
+ # the corners is NaN, the result will be NaN.
304
+ # Here, we use a trick where we duplicate the lookup values in the 'pulse'
305
+ # dimension so that the interpolator has values on bin edges for that dimension.
306
+ # The interpolator raises an error if axes coordinates are not strictly
307
+ # monotonic, so we cannot use e.g. [-0.5, 0.5, 0.5, 1.5] in the case of
308
+ # pulse_stride=2. Instead we use [-0.25, 0.25, 0.75, 1.25].
309
+ base_grid = np.arange(float(lookup.sizes["pulse"]))
310
+ self._interpolator = RegularGridInterpolator(
311
+ (
312
+ np.sort(np.concatenate([base_grid - 0.25, base_grid + 0.25])),
313
+ lookup.coords["distance"].to(unit=distance_unit, copy=False).values,
314
+ lookup.coords["event_time_offset"]
315
+ .to(unit=self._time_unit, copy=False)
316
+ .values,
317
+ ),
318
+ np.repeat(
319
+ lookup.data.to(unit=self._time_unit, copy=False).values, 2, axis=0
320
+ ),
321
+ method="linear",
322
+ bounds_error=False,
323
+ fill_value=np.nan,
324
+ )
325
+
326
+ def __call__(
327
+ self,
328
+ pulse_index: sc.Variable,
329
+ ltotal: sc.Variable,
330
+ event_time_offset: sc.Variable,
331
+ ) -> sc.Variable:
332
+ if pulse_index.unit not in ("", None):
333
+ raise sc.UnitError(
334
+ "pulse_index must have unit dimensionless or None, "
335
+ f"but got unit: {pulse_index.unit}."
336
+ )
337
+ if ltotal.unit != self._distance_unit:
338
+ raise sc.UnitError(
339
+ f"ltotal must have unit: {self._distance_unit}, "
340
+ f"but got unit: {ltotal.unit}."
341
+ )
342
+ if event_time_offset.unit != self._time_unit:
343
+ raise sc.UnitError(
344
+ f"event_time_offset must have unit: {self._time_unit}, "
345
+ f"but got unit: {event_time_offset.unit}."
346
+ )
347
+ out_dims = event_time_offset.dims
348
+ pulse_index = pulse_index.values
349
+ ltotal = ltotal.values
350
+ event_time_offset = event_time_offset.values
351
+ # Check bounds for pulse_index and ltotal.
352
+ # We do not check the event_time_offset dimension because histogrammed monitors
353
+ # often have binning which can be anything (does not necessarily stop at 71ms).
354
+ # Raising an error here would be too restrictive, and warnings would add noise
355
+ # to the workflows.
356
+ for i, (name, values) in enumerate(
357
+ {'pulse_index': pulse_index, 'ltotal': ltotal}.items()
358
+ ):
359
+ vmin = self._interpolator.grid[i][0]
360
+ vmax = self._interpolator.grid[i][-1]
361
+ if np.any(values < vmin) or np.any(values > vmax):
362
+ raise ValueError(
363
+ "Some requested values are outside of lookup table bounds for "
364
+ f"axis {i}: {name}, min: {vmin}, max: {vmax}."
365
+ )
366
+ return sc.array(
367
+ dims=out_dims,
368
+ values=self._interpolator((pulse_index, ltotal, event_time_offset)),
369
+ unit=self._time_unit,
370
+ )
319
371
 
320
372
 
321
373
  def _time_of_flight_data_histogram(
@@ -360,17 +412,11 @@ def _time_of_flight_data_histogram(
360
412
  )
361
413
  pulse_index = sc.zeros(sizes=etos.sizes)
362
414
 
363
- # Create 2D interpolator
364
- interp = _make_tof_interpolator(
365
- lookup, distance_unit=ltotal.unit, time_unit=eto_unit
366
- )
415
+ # Create linear interpolator
416
+ interp = TofInterpolator(lookup, distance_unit=ltotal.unit, time_unit=eto_unit)
367
417
 
368
418
  # Compute time-of-flight of the bin edges using the interpolator
369
- tofs = sc.array(
370
- dims=etos.dims,
371
- values=interp((pulse_index.values, ltotal.values, etos.values)),
372
- unit=eto_unit,
373
- )
419
+ tofs = interp(pulse_index=pulse_index, ltotal=ltotal, event_time_offset=etos)
374
420
 
375
421
  return rebinned.assign_coords(tof=tofs)
376
422
 
@@ -380,7 +426,7 @@ def _guess_pulse_stride_offset(
380
426
  ltotal: sc.Variable,
381
427
  event_time_offset: sc.Variable,
382
428
  pulse_stride: int,
383
- interp: Callable,
429
+ interp: TofInterpolator,
384
430
  ) -> int:
385
431
  """
386
432
  Using the minimum ``event_time_zero`` to calculate a reference time when computing
@@ -408,21 +454,29 @@ def _guess_pulse_stride_offset(
408
454
  pulse_stride:
409
455
  Stride of used pulses.
410
456
  interp:
411
- 2D interpolator for the lookup table.
457
+ Interpolator for the lookup table.
412
458
  """
413
459
  tofs = {}
414
460
  # Choose a few random events to compute the time-of-flight
415
461
  inds = np.random.choice(
416
462
  len(event_time_offset), min(5000, len(event_time_offset)), replace=False
417
463
  )
418
- pulse_index_values = pulse_index.values[inds]
419
- ltotal_values = ltotal.values[inds]
420
- etos_values = event_time_offset.values[inds]
464
+ pulse_index = sc.array(
465
+ dims=pulse_index.dims,
466
+ values=pulse_index.values[inds],
467
+ unit=pulse_index.unit,
468
+ )
469
+ ltotal = sc.array(dims=ltotal.dims, values=ltotal.values[inds], unit=ltotal.unit)
470
+ etos = sc.array(
471
+ dims=event_time_offset.dims,
472
+ values=event_time_offset.values[inds],
473
+ unit=event_time_offset.unit,
474
+ )
421
475
  for i in range(pulse_stride):
422
- pulse_inds = (pulse_index_values + i) % pulse_stride
423
- tofs[i] = interp((pulse_inds, ltotal_values, etos_values))
476
+ pulse_inds = (pulse_index + i) % pulse_stride
477
+ tofs[i] = interp(pulse_index=pulse_inds, ltotal=ltotal, event_time_offset=etos)
424
478
  # Find the entry in the list with the least number of nan values
425
- return sorted(tofs, key=lambda x: np.isnan(tofs[x]).sum())[0]
479
+ return sorted(tofs, key=lambda x: sc.isnan(tofs[x]).sum())[0]
426
480
 
427
481
 
428
482
  def _time_of_flight_data_events(
@@ -436,10 +490,8 @@ def _time_of_flight_data_events(
436
490
  etos = da.bins.coords["event_time_offset"]
437
491
  eto_unit = elem_unit(etos)
438
492
 
439
- # Create 2D interpolator
440
- interp = _make_tof_interpolator(
441
- lookup, distance_unit=ltotal.unit, time_unit=eto_unit
442
- )
493
+ # Create linear interpolator
494
+ interp = TofInterpolator(lookup, distance_unit=ltotal.unit, time_unit=eto_unit)
443
495
 
444
496
  # Operate on events (broadcast distances to all events)
445
497
  ltotal = sc.bins_like(etos, ltotal).bins.constituents["data"]
@@ -491,11 +543,7 @@ def _time_of_flight_data_events(
491
543
  pulse_index %= pulse_stride
492
544
 
493
545
  # Compute time-of-flight for all neutrons using the interpolator
494
- tofs = sc.array(
495
- dims=etos.dims,
496
- values=interp((pulse_index.values, ltotal.values, etos.values)),
497
- unit=eto_unit,
498
- )
546
+ tofs = interp(pulse_index=pulse_index, ltotal=ltotal, event_time_offset=etos)
499
547
 
500
548
  parts = da.bins.constituents
501
549
  parts["data"] = tofs
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: essreduce
3
- Version: 25.2.6
3
+ Version: 25.3.1
4
4
  Summary: Common data reduction tools for the ESS facility
5
5
  Author: Scipp contributors
6
6
  License: BSD 3-Clause License
@@ -89,10 +89,10 @@ src/ess/reduce/nexus/types.py
89
89
  src/ess/reduce/nexus/workflow.py
90
90
  src/ess/reduce/scripts/grow_nexus.py
91
91
  src/ess/reduce/time_of_flight/__init__.py
92
+ src/ess/reduce/time_of_flight/eto_to_tof.py
92
93
  src/ess/reduce/time_of_flight/fakes.py
93
94
  src/ess/reduce/time_of_flight/simulation.py
94
95
  src/ess/reduce/time_of_flight/to_events.py
95
- src/ess/reduce/time_of_flight/toa_to_tof.py
96
96
  src/ess/reduce/time_of_flight/types.py
97
97
  src/ess/reduce/widgets/__init__.py
98
98
  src/ess/reduce/widgets/_base.py
@@ -633,7 +633,7 @@ def compute_component_position_returns_input_if_no_depends_on() -> None:
633
633
  # h5py cannot open files on these systems with file locks.
634
634
  # We cannot reasonably emulate this within Python tests.
635
635
  # So the following tests only check the behaviour on a basic level.
636
- # The tests use the private `_open_nexus_file` directly to focus on what matters.
636
+ # The tests use the private `open_nexus_file` directly to focus on what matters.
637
637
  #
638
638
  # A file may already be open in this or another process.
639
639
  # We should still be able to open it
@@ -652,13 +652,13 @@ def compute_component_position_returns_input_if_no_depends_on() -> None:
652
652
  ],
653
653
  )
654
654
  def test_open_nexus_file_multiple_times(tmp_path: Path, locks: tuple[Any, Any]) -> None:
655
- from ess.reduce.nexus._nexus_loader import _open_nexus_file
655
+ from ess.reduce.nexus._nexus_loader import open_nexus_file
656
656
 
657
657
  path = FilePath(tmp_path / "file.nxs")
658
658
  with snx.File(path, "w"):
659
659
  pass
660
- with _open_nexus_file(path, locking=locks[0]) as f1:
661
- with _open_nexus_file(path, locking=locks[1]) as f2:
660
+ with open_nexus_file(path, locking=locks[0]) as f1:
661
+ with open_nexus_file(path, locking=locks[1]) as f2:
662
662
  assert f1.name == f2.name
663
663
 
664
664
 
@@ -669,15 +669,15 @@ def _in_conda_env():
669
669
  def _test_open_nexus_file_with_mismatched_locking(
670
670
  tmp_path: Path, locks: tuple[Any, Any]
671
671
  ) -> None:
672
- from ess.reduce.nexus._nexus_loader import _open_nexus_file
672
+ from ess.reduce.nexus._nexus_loader import open_nexus_file
673
673
 
674
674
  path = FilePath(tmp_path / "file.nxs")
675
675
  with snx.File(path, "w"):
676
676
  pass
677
677
 
678
- with _open_nexus_file(path, locking=locks[0]):
678
+ with open_nexus_file(path, locking=locks[0]):
679
679
  with pytest.raises(OSError, match="flag values don't match"):
680
- _ = _open_nexus_file(path, locking=locks[1])
680
+ _ = open_nexus_file(path, locking=locks[1])
681
681
 
682
682
 
683
683
  @pytest.mark.skipif(
@@ -721,7 +721,7 @@ def test_open_nexus_file_with_mismatched_locking_all(
721
721
 
722
722
 
723
723
  def test_open_nonexisting_file_raises_filenotfounderror():
724
- from ess.reduce.nexus._nexus_loader import _open_nexus_file
724
+ from ess.reduce.nexus._nexus_loader import open_nexus_file
725
725
 
726
726
  with pytest.raises(FileNotFoundError):
727
- _open_nexus_file(nexus.types.FilePath(Path("doesnotexist.hdf")))
727
+ open_nexus_file(nexus.types.FilePath(Path("doesnotexist.hdf")))
@@ -26,6 +26,7 @@ from ess.reduce.nexus.types import (
26
26
  NeXusComponentLocationSpec,
27
27
  NeXusName,
28
28
  NeXusTransformation,
29
+ PreopenNeXusFile,
29
30
  RunType,
30
31
  SampleRun,
31
32
  TimeInterval,
@@ -533,11 +534,13 @@ def test_load_detector_workflow() -> None:
533
534
  assert da.dims == ('detector_number',)
534
535
 
535
536
 
536
- def test_generic_nexus_workflow() -> None:
537
+ @pytest.mark.parametrize('preopen', [True, False])
538
+ def test_generic_nexus_workflow(preopen: bool) -> None:
537
539
  wf = GenericNeXusWorkflow()
538
540
  wf[Filename[SampleRun]] = data.loki_tutorial_sample_run_60250()
539
541
  wf[NeXusName[Monitor1]] = 'monitor_1'
540
542
  wf[NeXusName[snx.NXdetector]] = 'larmor_detector'
543
+ wf[PreopenNeXusFile] = preopen
541
544
  da = wf.compute(DetectorData[SampleRun])
542
545
  assert 'position' in da.coords
543
546
  assert 'sample_position' in da.coords