From 9f8d690887dcee7d2c6a517d1ae34301c95afa12 Mon Sep 17 00:00:00 2001 From: Jan-Lukas Wynen Date: Thu, 23 Jul 2026 11:32:18 +0200 Subject: [PATCH 1/4] Update BIFROST NeXus loader for angles --- .../src/ess/bifrost/io/mcstas.py | 33 ++++++++++++ .../src/ess/bifrost/io/nexus.py | 52 ++++++++++++++----- 2 files changed, 71 insertions(+), 14 deletions(-) diff --git a/packages/essspectroscopy/src/ess/bifrost/io/mcstas.py b/packages/essspectroscopy/src/ess/bifrost/io/mcstas.py index 2cd56d9e1..ce3ea1bae 100644 --- a/packages/essspectroscopy/src/ess/bifrost/io/mcstas.py +++ b/packages/essspectroscopy/src/ess/bifrost/io/mcstas.py @@ -7,15 +7,46 @@ import scippnexus as snx from ess.spectroscopy.types import ( EmptyDetector, + InstrumentAngle, NeXusData, + NeXusFileSpec, PulsePeriod, RawDetector, RunType, + SampleAngle, ) +from ess.reduce.nexus import open_component_group +from ess.reduce.nexus.types import NeXusLocationSpec + from ..types import McStasRawDetector +def load_sample_angle( + file_spec: NeXusFileSpec[RunType], +) -> SampleAngle[RunType]: + """Load the rotation angle of a sample from a McStas BIFROST NeXus file.""" + return SampleAngle[RunType](_load_experiment_parameter(file_spec, "a3")) + + +def load_instrument_angle( + file_spec: NeXusFileSpec[RunType], +) -> InstrumentAngle[RunType]: + """Load the rotation angle for the BIFROST detector from a McStas NeXus file.""" + return InstrumentAngle[RunType](_load_experiment_parameter(file_spec, "a4")) + + +def _load_experiment_parameter( + file_spec: NeXusFileSpec[RunType], param_name: str +) -> sc.DataArray: + with open_component_group( + NeXusLocationSpec(filename=file_spec.value), + nx_class=snx.NXparameters, + parent_class=snx.NXentry, + ) as group: + return group[param_name][()]['value'] + + def assemble_detector_data( detector: EmptyDetector[RunType], event_data: NeXusData[snx.NXdetector, RunType], @@ -74,4 +105,6 @@ def wrap_event_time_offset(event_time_offset: sc.Variable) -> sc.Variable: providers = ( assemble_detector_data, convert_simulated_time_to_event_time_offset, + load_instrument_angle, + load_sample_angle, ) diff --git a/packages/essspectroscopy/src/ess/bifrost/io/nexus.py b/packages/essspectroscopy/src/ess/bifrost/io/nexus.py index c7827b940..c793f65e9 100644 --- a/packages/essspectroscopy/src/ess/bifrost/io/nexus.py +++ b/packages/essspectroscopy/src/ess/bifrost/io/nexus.py @@ -25,33 +25,57 @@ from ess.reduce.nexus.types import NeXusLocationSpec, TransformationTimeFilter -# See https://github.com/scipp/essreduce/issues/98 -def moderator_class_for_source() -> NeXusClass[snx.NXsource]: - """Select NXmoderator as the source.""" - return NeXusClass[snx.NXsource](snx.NXmoderator) - - def load_sample_angle( file_spec: NeXusFileSpec[RunType], ) -> SampleAngle[RunType]: - return SampleAngle[RunType](_load_experiment_parameter(file_spec, "a3")) + """Load the rotation angle of a sample from a BIFROST NeXus file.""" + return SampleAngle[RunType]( + _load_rotation_angle( + file_spec, + "114_sample_stack/rotation_stage", + ) + ) def load_instrument_angle( file_spec: NeXusFileSpec[RunType], ) -> InstrumentAngle[RunType]: - return InstrumentAngle[RunType](_load_experiment_parameter(file_spec, "a4")) + """Load the rotation angle for the BIFROST detector from a NeXus file.""" + return InstrumentAngle[RunType]( + _load_rotation_angle( + file_spec, + "detector_tank_angle/transformations/detector_tank_angle_r0", + ) + ) -def _load_experiment_parameter( - file_spec: NeXusFileSpec[RunType], param_name: str -) -> sc.DataArray: +def _load_rotation_angle(file_spec: NeXusFileSpec[RunType], path: str) -> sc.DataArray: with open_component_group( NeXusLocationSpec(filename=file_spec.value), - nx_class=snx.NXparameters, + nx_class=snx.NXinstrument, parent_class=snx.NXentry, - ) as group: - return group[param_name][()]['value'] + ) as instrument: + log: snx.Group = instrument[path] # type: ignore[assignment] + if isinstance(log['value'], snx.Group): + # The NXlog is nested in this group, e.g., the group can be an NXpositioner + # with children 'value', 'target_value', etc. + transform: snx.nxtransformations.Transform = log['value'][()] # type: ignore[assignment] + else: + # `log` is the NXlog we are looking for without further nesting. + transform: snx.nxtransformations.Transform = log[()] # type: ignore[assignment] + + if transform.transformation_type != 'rotation': + raise ValueError( + "Expected the instrument angle at detector_tank_angle to be a rotation," + f" got '{transform.transformation_type}' instead.'" + ) + return transform.value # type: ignore[return-value] + + +# See https://github.com/scipp/essreduce/issues/98 +def moderator_class_for_source() -> NeXusClass[snx.NXsource]: + """Select NXmoderator as the source.""" + return NeXusClass[snx.NXsource](snx.NXmoderator) def load_analyzer_for_detector( From 20a3ffe89e813ab671bfe6665e7cb709c258b8e0 Mon Sep 17 00:00:00 2001 From: Jan-Lukas Wynen Date: Thu, 23 Jul 2026 12:54:51 +0200 Subject: [PATCH 2/4] Link to ess issue --- packages/essspectroscopy/src/ess/bifrost/io/nexus.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/essspectroscopy/src/ess/bifrost/io/nexus.py b/packages/essspectroscopy/src/ess/bifrost/io/nexus.py index c793f65e9..8b5a20753 100644 --- a/packages/essspectroscopy/src/ess/bifrost/io/nexus.py +++ b/packages/essspectroscopy/src/ess/bifrost/io/nexus.py @@ -72,7 +72,7 @@ def _load_rotation_angle(file_spec: NeXusFileSpec[RunType], path: str) -> sc.Dat return transform.value # type: ignore[return-value] -# See https://github.com/scipp/essreduce/issues/98 +# See https://github.com/scipp/ess/issues/120 def moderator_class_for_source() -> NeXusClass[snx.NXsource]: """Select NXmoderator as the source.""" return NeXusClass[snx.NXsource](snx.NXmoderator) From dee27099a10866ae84298705c4adb695efcb7505 Mon Sep 17 00:00:00 2001 From: Jan-Lukas Wynen Date: Thu, 23 Jul 2026 13:01:12 +0200 Subject: [PATCH 3/4] Use the default instrument angle loader in mcstas --- packages/essspectroscopy/src/ess/bifrost/io/mcstas.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/packages/essspectroscopy/src/ess/bifrost/io/mcstas.py b/packages/essspectroscopy/src/ess/bifrost/io/mcstas.py index ce3ea1bae..c8473e919 100644 --- a/packages/essspectroscopy/src/ess/bifrost/io/mcstas.py +++ b/packages/essspectroscopy/src/ess/bifrost/io/mcstas.py @@ -7,7 +7,6 @@ import scippnexus as snx from ess.spectroscopy.types import ( EmptyDetector, - InstrumentAngle, NeXusData, NeXusFileSpec, PulsePeriod, @@ -29,13 +28,6 @@ def load_sample_angle( return SampleAngle[RunType](_load_experiment_parameter(file_spec, "a3")) -def load_instrument_angle( - file_spec: NeXusFileSpec[RunType], -) -> InstrumentAngle[RunType]: - """Load the rotation angle for the BIFROST detector from a McStas NeXus file.""" - return InstrumentAngle[RunType](_load_experiment_parameter(file_spec, "a4")) - - def _load_experiment_parameter( file_spec: NeXusFileSpec[RunType], param_name: str ) -> sc.DataArray: @@ -105,6 +97,5 @@ def wrap_event_time_offset(event_time_offset: sc.Variable) -> sc.Variable: providers = ( assemble_detector_data, convert_simulated_time_to_event_time_offset, - load_instrument_angle, load_sample_angle, ) From db2d8bf6c81ade0504fd85b9b7fa68ae1bad9666 Mon Sep 17 00:00:00 2001 From: Jan-Lukas Wynen Date: Thu, 23 Jul 2026 13:01:30 +0200 Subject: [PATCH 4/4] Adapt workflows for simulation --- .../docs/user-guide/bifrost/bifrost-bragg-peak-monitor.ipynb | 4 ++++ packages/essspectroscopy/tests/bifrost/live_test.py | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/packages/essspectroscopy/docs/user-guide/bifrost/bifrost-bragg-peak-monitor.ipynb b/packages/essspectroscopy/docs/user-guide/bifrost/bifrost-bragg-peak-monitor.ipynb index b8eaf3d8f..cb789c8b3 100644 --- a/packages/essspectroscopy/docs/user-guide/bifrost/bifrost-bragg-peak-monitor.ipynb +++ b/packages/essspectroscopy/docs/user-guide/bifrost/bifrost-bragg-peak-monitor.ipynb @@ -25,6 +25,7 @@ " simulated_elastic_incoherent_with_phonon,\n", " lookup_table_simulation,\n", ")\n", + "from ess.bifrost.io.mcstas import load_sample_angle\n", "from ess.bifrost.single_crystal import BifrostSimulationBraggPeakMonitorWorkflow, make_q_map\n", "from ess.bifrost.single_crystal.types import *\n", "from ess.bifrost.types import McStasRawDetector\n", @@ -58,8 +59,11 @@ "outputs": [], "source": [ "workflow = BifrostSimulationBraggPeakMonitorWorkflow()\n", + "\n", "workflow[Filename[SampleRun]] = simulated_elastic_incoherent_with_phonon()\n", "workflow[LookupTableFilename] = lookup_table_simulation()\n", + "workflow.insert(load_sample_angle) # Needed for simulation data\n", + "\n", "workflow[LookupTableRelativeErrorThreshold] = {\n", " 'detector': np.inf,\n", " 'normalization_monitor': np.inf,\n", diff --git a/packages/essspectroscopy/tests/bifrost/live_test.py b/packages/essspectroscopy/tests/bifrost/live_test.py index dc2b22c52..057dd588d 100644 --- a/packages/essspectroscopy/tests/bifrost/live_test.py +++ b/packages/essspectroscopy/tests/bifrost/live_test.py @@ -10,6 +10,7 @@ lookup_table_simulation, simulated_elastic_incoherent_with_phonon, ) +from ess.bifrost.io.mcstas import load_sample_angle from ess.bifrost.live import BifrostQCutWorkflow, CutAxis, CutAxis1, CutAxis2, CutData from ess.spectroscopy.types import ( Filename, @@ -35,6 +36,10 @@ def qcut_workflow( self, simulation_detector_names: list[NeXusDetectorName] ) -> sciline.Pipeline: workflow = BifrostQCutWorkflow(simulation_detector_names) + # The test file is from a simulation and + # looks slightly different from production files. + workflow.insert(load_sample_angle) + workflow[Filename[SampleRun]] = simulated_elastic_incoherent_with_phonon() workflow[LookupTableFilename] = lookup_table_simulation() workflow[LookupTableRelativeErrorThreshold] = {