diff --git a/packages/essreduce/src/ess/reduce/unwrap/lut.py b/packages/essreduce/src/ess/reduce/unwrap/lut.py index 64aa15d02..ce42d94f7 100644 --- a/packages/essreduce/src/ess/reduce/unwrap/lut.py +++ b/packages/essreduce/src/ess/reduce/unwrap/lut.py @@ -429,6 +429,34 @@ def _to_component_reading(component) -> BeamlineComponentReading: ) +def chopper_distance_along_beam( + axle_position: sc.Variable, + source_position: sc.Variable, +) -> sc.Variable: + """Flight distance from the source to a chopper along the incident beam. + + A disk chopper's axle is offset from the beam (the disk sits above, below, or + to the side of it), so the straight-line distance + ``norm(axle_position - source_position)`` overestimates the flight distance, + and for closely-spaced choppers can even place them in the wrong order along + the cascade. The neutron crosses the disk where the incident beam intersects + it, so the flight distance is the projection of the axle position onto the + incident-beam direction. Assuming the incident beam runs along the z axis, + this is the z-component of the offset from the source. This matches the + straight-line ``Ltotal`` used for detectors and monitors, where the component + lies on the beam and the projection reduces to the norm. + + Parameters + ---------- + axle_position: + Position of the chopper's rotation axle. + source_position: + Position of the neutron source. + """ + source_position = source_position.to(unit=axle_position.unit) + return (axle_position - source_position).fields.z + + def simulate_chopper_cascade_using_tof( choppers: DiskChoppers[RunType], source_position: Position[snx.NXsource, RunType], @@ -471,9 +499,7 @@ def simulate_chopper_cascade_using_tof( tof_choppers = [] for name, ch in choppers.items(): chop = tof.Chopper.from_diskchopper(ch, name=name) - chop.distance = sc.norm( - ch.axle_position - source_position.to(unit=ch.axle_position.unit) - ) + chop.distance = chopper_distance_along_beam(ch.axle_position, source_position) tof_choppers.append(chop) source = tof.Source( @@ -657,9 +683,7 @@ def compute_frame_sequence( chops = { key: chopper_cascade.Chopper( - distance=sc.norm( - ch.axle_position - source_position.to(unit=ch.axle_position.unit) - ), + distance=chopper_distance_along_beam(ch.axle_position, source_position), time_open=ch.time_offset_open( pulse_frequency=frequency_for_chopper_rotation ), diff --git a/packages/essreduce/tests/unwrap/lut_test.py b/packages/essreduce/tests/unwrap/lut_test.py index 6c87c55d8..323c45974 100644 --- a/packages/essreduce/tests/unwrap/lut_test.py +++ b/packages/essreduce/tests/unwrap/lut_test.py @@ -8,6 +8,7 @@ from ess.reduce import unwrap from ess.reduce.nexus.types import AnyRun, FrameMonitor0, Position from ess.reduce.unwrap import GenericUnwrapWorkflow, LookupTableWorkflow, SourceBounds +from ess.reduce.unwrap.lut import chopper_distance_along_beam sl = pytest.importorskip("sciline") @@ -167,6 +168,49 @@ def _make_choppers(): } +def _single_slit_chopper(axle_position: sc.Variable) -> DiskChopper: + return DiskChopper( + frequency=sc.scalar(-14.0, unit='Hz'), + beam_position=sc.scalar(0.0, unit='deg'), + phase=sc.scalar(0.0, unit='deg'), + axle_position=axle_position, + slit_begin=sc.array(dims=['cutout'], values=[0.0], unit='deg'), + slit_end=sc.array(dims=['cutout'], values=[10.0], unit='deg'), + slit_height=sc.scalar(10.0, unit='cm'), + radius=sc.scalar(30.0, unit='cm'), + ) + + +def test_chopper_distance_along_beam_projects_offset_axle_onto_beam(): + source = sc.vector([0, 0, 0], unit='m') + # Axle sits 3 m above the beam and 10 m downstream of the source. + axle = sc.vector([0, 3, 10], unit='m') + distance = chopper_distance_along_beam(axle, source) + assert sc.isclose(distance, sc.scalar(10.0, unit='m')) + # The straight-line distance to the offset axle would overestimate this. + assert sc.norm(axle - source) > sc.scalar(10.0, unit='m') + + +def test_chopper_cascade_orders_offset_axle_by_beam_projection(): + # Two closely-spaced choppers; the upstream one's axle is offset far enough + # from the beam that ``norm(axle - source)`` (6.185 m) exceeds the downstream + # chopper's distance (6.155 m). Ordering must follow the beam-projected + # distance, otherwise the upstream chopper is wrongly applied last (or, for a + # narrow distance range, never at all). + wf = _make_workflow("analytical") + wf[Position[snx.NXsource, AnyRun]] = sc.vector([0, 0, 0], unit='m') + wf[unwrap.PulseStride[AnyRun]] = 1 + wf[unwrap.DiskChoppers[AnyRun]] = { + 'near': _single_slit_chopper(sc.vector([0, 0.7, 6.145], unit='m')), + 'far': _single_slit_chopper(sc.vector([0, 0, 6.155], unit='m')), + } + + frames = wf.compute(unwrap.ChopperFrameSequence[AnyRun]) + + distances = sorted(f.distance.to(unit='m').value for f in frames) + assert distances == pytest.approx([0.0, 6.145, 6.155]) + + @pytest.mark.parametrize("detector_or_monitor", ["detector", "monitor"]) @pytest.mark.parametrize("wavelength_from", ["analytical", "simulation"]) def test_lut_workflow_computes_table_with_choppers(