From 3fd0c84cccd9037c97d092875208cf14b14ec5b4 Mon Sep 17 00:00:00 2001 From: Simon Heybrock Date: Fri, 27 Feb 2026 09:54:34 +0000 Subject: [PATCH 1/2] fix: break false dependency of OutputCalibrationData on CorrectedDetector assemble_output_calibration only uses Ltotal and two_theta, which are purely geometric quantities derived from detector pixel positions. By depending on CorrectedDetector[SampleRun], it unnecessarily pulled in the full event processing chain, blocking use in streaming contexts where event data is not available during finalization. Introduce DetectorTwoTheta sciline node type (mirroring the existing DetectorLtotal from ess.reduce) and a provider that computes it from EmptyDetector + beamline geometry. Change assemble_output_calibration to depend on DetectorLtotal[SampleRun] and DetectorTwoTheta[SampleRun] instead. Closes #249 Prompt: Please use a new branch and address #249. We concluded that using EmptyDetector should work, but hopefully tests will tell (check we have tests that are sensitive to this). Follow-up: Find out how the ess.reduce GenericTofWorkflow handles Ltotal. Is it already a node in the graph, or is it always embedded as a coord? If a node, we should consider doing the same for two_theta. Co-Authored-By: Claude Opus 4.6 --- src/ess/powder/calibration.py | 54 +++++++++++++++++++++++++++++++---- src/ess/powder/types.py | 5 ++++ 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/src/ess/powder/calibration.py b/src/ess/powder/calibration.py index c110b8cc..e3093e60 100644 --- a/src/ess/powder/calibration.py +++ b/src/ess/powder/calibration.py @@ -8,8 +8,18 @@ import scipp as sc import scipp.constants +import scippneutron as scn +import scippnexus as snx -from .types import CorrectedDetector, SampleRun +from .types import ( + DetectorLtotal, + DetectorTwoTheta, + EmptyDetector, + GravityVector, + Position, + RunType, + SampleRun, +) class OutputCalibrationData(Mapping[int, sc.Variable]): @@ -93,13 +103,45 @@ def to_cif_format(self) -> sc.DataArray: ) +def detector_two_theta( + detector: EmptyDetector[RunType], + source_position: Position[snx.NXsource, RunType], + sample_position: Position[snx.NXsample, RunType], + gravity: GravityVector, +) -> DetectorTwoTheta[RunType]: + """Compute the scattering angle (two-theta) for each detector pixel. + + Parameters + ---------- + detector: + Data array with detector positions. + source_position: + Position of the neutron source. + sample_position: + Position of the sample. + gravity: + Gravity vector. + """ + graph = { + **scn.conversion.graph.beamline.beamline(scatter=True), + 'source_position': lambda: source_position, + 'sample_position': lambda: sample_position, + 'gravity': lambda: gravity, + } + return DetectorTwoTheta[RunType]( + detector.transform_coords( + "two_theta", graph=graph, keep_intermediate=False + ).coords["two_theta"] + ) + + def assemble_output_calibration( - data: CorrectedDetector[SampleRun], + ltotal: DetectorLtotal[SampleRun], + two_theta: DetectorTwoTheta[SampleRun], ) -> OutputCalibrationData: """Construct output calibration data from average pixel positions.""" - # Use nanmean because pixels without events have position=NaN. - average_l = sc.nanmean(data.coords["Ltotal"]) - average_two_theta = sc.nanmean(data.coords["two_theta"]) + average_l = sc.nanmean(ltotal) + average_two_theta = sc.nanmean(two_theta) difc = sc.to_unit( 2 * sc.constants.m_n @@ -111,4 +153,4 @@ def assemble_output_calibration( return OutputCalibrationData({1: difc}) -providers = (assemble_output_calibration,) +providers = (detector_two_theta, assemble_output_calibration) diff --git a/src/ess/powder/types.py b/src/ess/powder/types.py index 238270c2..6b377c03 100644 --- a/src/ess/powder/types.py +++ b/src/ess/powder/types.py @@ -36,6 +36,7 @@ DetectorBankSizes = reduce_t.DetectorBankSizes +DetectorLtotal = tof_t.DetectorLtotal TofDetector = tof_t.TofDetector TofMonitor = tof_t.TofMonitor PulseStrideOffset = tof_t.PulseStrideOffset @@ -103,6 +104,10 @@ class WavelengthDetector(sciline.Scope[RunType, sc.DataArray], sc.DataArray): """Histogrammed intensity vs d-spacing.""" +class DetectorTwoTheta(sciline.Scope[RunType, sc.Variable], sc.Variable): + """Scattering angle (two-theta) for each detector pixel.""" + + class ElasticCoordTransformGraph(sciline.Scope[RunType, dict], dict): """Graph for transforming coordinates in elastic scattering.""" From 9f38b04298cac2098132d7cec3a9bf6a6da80f06 Mon Sep 17 00:00:00 2001 From: Simon Heybrock Date: Fri, 27 Feb 2026 13:54:27 +0000 Subject: [PATCH 2/2] fix: address review comments on PR #250 - Use ElasticCoordTransformGraph in detector_two_theta instead of constructing a custom graph, providing a single customization point - Restore nanmean comment in assemble_output_calibration Prompt: We need to address review comments on PR #250. --- src/ess/powder/calibration.py | 24 +++++------------------- 1 file changed, 5 insertions(+), 19 deletions(-) diff --git a/src/ess/powder/calibration.py b/src/ess/powder/calibration.py index e3093e60..b9c0a371 100644 --- a/src/ess/powder/calibration.py +++ b/src/ess/powder/calibration.py @@ -8,15 +8,12 @@ import scipp as sc import scipp.constants -import scippneutron as scn -import scippnexus as snx from .types import ( DetectorLtotal, DetectorTwoTheta, + ElasticCoordTransformGraph, EmptyDetector, - GravityVector, - Position, RunType, SampleRun, ) @@ -105,9 +102,7 @@ def to_cif_format(self) -> sc.DataArray: def detector_two_theta( detector: EmptyDetector[RunType], - source_position: Position[snx.NXsource, RunType], - sample_position: Position[snx.NXsample, RunType], - gravity: GravityVector, + graph: ElasticCoordTransformGraph[RunType], ) -> DetectorTwoTheta[RunType]: """Compute the scattering angle (two-theta) for each detector pixel. @@ -115,19 +110,9 @@ def detector_two_theta( ---------- detector: Data array with detector positions. - source_position: - Position of the neutron source. - sample_position: - Position of the sample. - gravity: - Gravity vector. + graph: + Coordinate transformation graph for elastic scattering. """ - graph = { - **scn.conversion.graph.beamline.beamline(scatter=True), - 'source_position': lambda: source_position, - 'sample_position': lambda: sample_position, - 'gravity': lambda: gravity, - } return DetectorTwoTheta[RunType]( detector.transform_coords( "two_theta", graph=graph, keep_intermediate=False @@ -140,6 +125,7 @@ def assemble_output_calibration( two_theta: DetectorTwoTheta[SampleRun], ) -> OutputCalibrationData: """Construct output calibration data from average pixel positions.""" + # Use nanmean because pixels without events have position=NaN. average_l = sc.nanmean(ltotal) average_two_theta = sc.nanmean(two_theta) difc = sc.to_unit(