From 207f62a7b431a77565c6580ef04228b0a63cd80c Mon Sep 17 00:00:00 2001 From: Simon Heybrock Date: Mon, 24 Nov 2025 12:42:14 +0000 Subject: [PATCH 1/8] Implement LogicalDownsampler with transform-based design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces a cleaner design for LogicalDownsampler that separates the "grouping" logic (transform) from the "aggregation" logic (sum over reduction dimensions). This is more flexible and conceptually clearer than the previous histogram-based approach. Key changes: - LogicalDownsampler now takes a `transform` callable (e.g., fold operations) and a `reduction_dim` parameter - __call__() applies transform then sums over reduction_dim - input_indices() applies transform to detector indices and creates binned mapping for ROI filtering - Supports both single and multiple reduction dimensions - Add comprehensive unit tests using TDD approach The new design follows the same pattern as regular logical views: - transform defines how to group pixels (e.g., via fold operations) - reduction explicitly sums the groups - More flexible than hardcoded histogramming with uniform bins Example usage: downsampler = LogicalDownsampler( transform=lambda da: da.fold('x_pixel_offset', {'x_pixel_offset': 512, 'x_bin': 2}), reduction_dim='x_bin', detector_number=detector_number, ) Tests added: - Single-dimension downsampling (1D with 2x binning) - Multi-dimension downsampling (2D with 2x2 binning) - input_indices() correctness for 1D and 2D cases - Varying input values are correctly summed All 36 tests in raw_test.py pass. Original prompt: "Please consider the latest commit. I don't think it makes much sense as-is. Would it be more useful and conceptually cleaner if we defined LogicalDownsampler based on a \`transform\` callable (just like an regular logical view), but specified a dim to "sum" over via and extra \`reduction_dim\` argument? Then \`__call__\` could simple \`transform(data).sum(self._reduction_dim)\` and \`input_indices\` would use \`transform(detector_number)\` and turn it to binned data along \`_reduction_dim\`?" Follow-up: "Yes, \`sum\` supports multiple dims (or we could flatten beforehand). Please start a new branch off main and implement LogicalDownsampler there as we discussed. Start by writing tests, use TDD." 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/ess/reduce/live/raw.py | 170 +++++++++++++++++++++++++++++++++++++ tests/live/raw_test.py | 165 +++++++++++++++++++++++++++++++++++ 2 files changed, 335 insertions(+) diff --git a/src/ess/reduce/live/raw.py b/src/ess/reduce/live/raw.py index ef3c0624..31083579 100644 --- a/src/ess/reduce/live/raw.py +++ b/src/ess/reduce/live/raw.py @@ -138,6 +138,176 @@ def apply_full(self, var: sc.Variable) -> sc.DataArray: return self._hist(replicated, coords=self._coords) / self.replicas +class LogicalDownsampler: + """ + Downsampler for logical detector views. + + Implements downsampling by applying a user-defined transform (e.g., fold operations) + followed by reduction (summing) over specified dimensions. This provides a clean + separation between "how to group pixels" (transform) and "how to aggregate them" + (sum over reduction dimensions). + + This class provides both data transformation (__call__) and index mapping + (input_indices) using the same transform, ensuring consistency for ROI filtering. + """ + + def __init__( + self, + transform: Callable[[sc.DataArray], sc.DataArray], + reduction_dim: str | list[str], + detector_number: sc.Variable | None = None, + ): + """ + Create a logical downsampler. + + Parameters + ---------- + transform: + Callable that transforms input data by grouping pixels. + Example: lambda da: da.fold( + 'x_pixel_offset', {'x_pixel_offset': 512, 'x_bin': 2} + ) + reduction_dim: + Dimension(s) to sum over after applying transform. + Example: 'x_bin' or ['x_bin', 'y_bin'] + detector_number: + Detector number array defining the input shape. + Required for input_indices(). + If not provided, input_indices() will raise an error. + """ + self._transform = transform + self._reduction_dim = ( + [reduction_dim] if isinstance(reduction_dim, str) else reduction_dim + ) + self._detector_number = detector_number + + def __call__(self, da: sc.DataArray) -> sc.DataArray: + """ + Downsample data by applying transform and summing over reduction dimensions. + + Parameters + ---------- + da: + Data to downsample. + + Returns + ------- + : + Downsampled data array. + """ + transformed = self._transform(da) + return transformed.sum(self._reduction_dim) + + def input_indices(self) -> sc.DataArray: + """ + Create index mapping for ROI filtering. + + Returns a binned DataArray where each output pixel contains + a list of contributing detector numbers (as indices into the + flattened detector_number array). + + Returns + ------- + : + Binned DataArray mapping output pixels to input detector indices. + + Raises + ------ + ValueError: + If detector_number was not provided during initialization. + """ + if self._detector_number is None: + raise ValueError( + "detector_number is required for input_indices(). " + "Provide it during LogicalDownsampler initialization." + ) + + # Create sequential indices (0, 1, 2, ...) matching detector_number shape + indices = sc.arange( + 'detector_number', + self._detector_number.size, + dtype='int64', + unit=None, + ) + indices = indices.fold( + dim='detector_number', + sizes=dict(self._detector_number.sizes), + ) + + # Create DataArray to apply transform + indices_da = sc.DataArray( + data=indices, + coords={'detector_number': self._detector_number}, + ) + + # Apply transform to get the grouping structure + transformed = self._transform(indices_da) + + # Flatten the reduction dimensions and convert to binned structure + # Each output pixel will contain indices from the reduction dimensions + flat_dim = 'detector_number' + if len(self._reduction_dim) > 1: + # Multiple reduction dims: need to make them contiguous first + # Get all dimensions and move reduction dims to the end + all_dims = list(transformed.dims) + output_dims = [d for d in all_dims if d not in self._reduction_dim] + # Transpose to put reduction dims at the end, in order + new_order = output_dims + self._reduction_dim + transformed_transposed = transformed.transpose(new_order) + # Now flatten the (now contiguous) reduction dims + transformed_flat = transformed_transposed.flatten( + dims=self._reduction_dim, to=flat_dim + ) + else: + # Single reduction dim: rename it + transformed_flat = transformed.rename_dims( + {self._reduction_dim[0]: flat_dim} + ) + + # Convert the dense array to binned data by creating bins manually + # Get the remaining dimensions (output dims) and create binned structure + output_dims = [d for d in transformed_flat.dims if d != flat_dim] + + # Calculate bin structure: each output pixel has data from reduction dims + bin_size = transformed_flat.sizes[flat_dim] + + # Create bin boundaries + begin_values = np.arange( + 0, transformed_flat.data.size, bin_size, dtype=np.int64 + ) + end_values = begin_values + bin_size + + # Get output shape + output_shape = [transformed_flat.sizes[d] for d in output_dims] + + # Flatten output dimensions to create 1D bins + data_flat = transformed_flat.data.flatten(to=flat_dim + '_flat') + + # Create binned data constituents + # Ensure all components have matching units (no unit for indices) + begin_var = sc.array( + dims=output_dims, + values=begin_values.reshape(output_shape), + dtype='int64', + ) + end_var = sc.array( + dims=output_dims, + values=end_values.reshape(output_shape), + dtype='int64', + ) # Remove units from begin/end to match data (which has no unit from arange) + begin_var.unit = data_flat.unit + end_var.unit = data_flat.unit + + binned_var = sc.bins( + begin=begin_var, + end=end_var, + dim=flat_dim + '_flat', + data=data_flat, + ) + + return sc.DataArray(binned_var) + + class Detector: def __init__(self, detector_number: sc.Variable): self._data = sc.DataArray( diff --git a/tests/live/raw_test.py b/tests/live/raw_test.py index 90773c1c..d12587b4 100644 --- a/tests/live/raw_test.py +++ b/tests/live/raw_test.py @@ -547,3 +547,168 @@ def test_transform_weights_raises_given_DataArray_with_bad_det_num() -> None: ) with pytest.raises(sc.CoordError): view.transform_weights(weights) + + +def test_LogicalDownsampler_single_dim_downsampling() -> None: + """Test basic 1D downsampling with transform + reduction.""" + # Create a simple 1D detector: 8 pixels -> downsample to 4 pixels (2x2 binning) + detector_number = sc.arange('x_pixel_offset', 8, unit=None) + + # Transform: fold into 4 groups of 2 + def transform(da: sc.DataArray) -> sc.DataArray: + return da.fold(dim='x_pixel_offset', sizes={'x_pixel_offset': 4, 'x_bin': 2}) + + downsampler = raw.LogicalDownsampler( + transform=transform, + reduction_dim='x_bin', + detector_number=detector_number, + ) + + # Create test data: each pixel has value equal to its index + data = sc.DataArray( + data=sc.arange('x_pixel_offset', 8, dtype='float64', unit='counts'), + coords={'detector_number': detector_number}, + ) + + # Apply downsampling + result = downsampler(data) + + # Should sum pairs: [0+1, 2+3, 4+5, 6+7] = [1, 5, 9, 13] + expected = sc.array( + dims=['x_pixel_offset'], + values=[1.0, 5.0, 9.0, 13.0], + unit='counts', + ) + assert sc.allclose(result.data, expected) + assert result.sizes == {'x_pixel_offset': 4} + + +def test_LogicalDownsampler_multi_dim_downsampling() -> None: + """Test 2D downsampling similar to _resize_image example.""" + # Create 8x8 detector -> downsample to 4x4 (2x2 binning in each dimension) + detector_number = sc.zeros(sizes={'x_pixel_offset': 8, 'y_pixel_offset': 8}) + + # Transform: fold both dimensions + def transform(da: sc.DataArray) -> sc.DataArray: + da = da.fold(dim='x_pixel_offset', sizes={'x_pixel_offset': 4, 'x_bin': 2}) + da = da.fold(dim='y_pixel_offset', sizes={'y_pixel_offset': 4, 'y_bin': 2}) + return da + + downsampler = raw.LogicalDownsampler( + transform=transform, + reduction_dim=['x_bin', 'y_bin'], + detector_number=detector_number, + ) + + # Create test data: constant value of 1 everywhere + data = sc.DataArray( + data=sc.ones(sizes={'x_pixel_offset': 8, 'y_pixel_offset': 8}, unit='counts'), + coords={'detector_number': detector_number}, + ) + + # Apply downsampling + result = downsampler(data) + + # Each output pixel should be sum of 2x2=4 input pixels + expected = sc.full( + dims=['x_pixel_offset', 'y_pixel_offset'], + shape=[4, 4], + value=4.0, + unit='counts', + ) + assert sc.allclose(result.data, expected) + assert result.sizes == {'x_pixel_offset': 4, 'y_pixel_offset': 4} + + +def test_LogicalDownsampler_input_indices_single_dim() -> None: + """Test that input_indices creates correct binned mapping for 1D.""" + detector_number = sc.arange('x_pixel_offset', 8, unit=None) + + def transform(da: sc.DataArray) -> sc.DataArray: + return da.fold(dim='x_pixel_offset', sizes={'x_pixel_offset': 4, 'x_bin': 2}) + + downsampler = raw.LogicalDownsampler( + transform=transform, + reduction_dim='x_bin', + detector_number=detector_number, + ) + + # Get index mapping + indices = downsampler.input_indices() + + # Should be binned data with 4 bins, each containing 2 indices + assert indices.sizes == {'x_pixel_offset': 4} + assert indices.bins is not None + + # Check each bin contains the correct indices + # Bin 0: [0, 1], Bin 1: [2, 3], Bin 2: [4, 5], Bin 3: [6, 7] + # Extract all bin contents using the bins accessor + bin_sizes = indices.bins.size() + assert all(bin_sizes.values == 2) # Each bin should have 2 indices + + # Check total count + assert indices.bins.size().sum().value == 8 + + +def test_LogicalDownsampler_input_indices_multi_dim() -> None: + """Test that input_indices creates correct binned mapping for 2D.""" + detector_number = sc.zeros(sizes={'x_pixel_offset': 4, 'y_pixel_offset': 4}) + + def transform(da: sc.DataArray) -> sc.DataArray: + da = da.fold(dim='x_pixel_offset', sizes={'x_pixel_offset': 2, 'x_bin': 2}) + da = da.fold(dim='y_pixel_offset', sizes={'y_pixel_offset': 2, 'y_bin': 2}) + return da + + downsampler = raw.LogicalDownsampler( + transform=transform, + reduction_dim=['x_bin', 'y_bin'], + detector_number=detector_number, + ) + + # Get index mapping + indices = downsampler.input_indices() + + # Should be binned data with 2x2 output bins + assert indices.sizes == {'x_pixel_offset': 2, 'y_pixel_offset': 2} + assert indices.bins is not None + + # Each bin should contain 2x2=4 indices from the flattened input + bin_sizes = indices.bins.size() + assert all(bin_sizes.values.ravel() == 4) # Each bin should have 4 indices + + # Check total count: 4x4 input pixels -> 2x2 output bins + assert indices.bins.size().sum().value == 16 + + +def test_LogicalDownsampler_with_varying_input_values() -> None: + """Test that downsampling correctly sums varying input values.""" + detector_number = sc.arange('x_pixel_offset', 6, unit=None) + + def transform(da: sc.DataArray) -> sc.DataArray: + return da.fold(dim='x_pixel_offset', sizes={'x_pixel_offset': 3, 'x_bin': 2}) + + downsampler = raw.LogicalDownsampler( + transform=transform, + reduction_dim='x_bin', + detector_number=detector_number, + ) + + # Create test data with specific values: [10, 20, 30, 40, 50, 60] + data = sc.DataArray( + data=sc.array( + dims=['x_pixel_offset'], + values=[10.0, 20.0, 30.0, 40.0, 50.0, 60.0], + unit='counts', + ), + coords={'detector_number': detector_number}, + ) + + result = downsampler(data) + + # Should sum pairs: [10+20, 30+40, 50+60] = [30, 70, 110] + expected = sc.array( + dims=['x_pixel_offset'], + values=[30.0, 70.0, 110.0], + unit='counts', + ) + assert sc.allclose(result.data, expected) From 793ca7e501ea04797975f95d8eb86b2470f95650 Mon Sep 17 00:00:00 2001 From: Simon Heybrock Date: Mon, 24 Nov 2025 12:52:25 +0000 Subject: [PATCH 2/8] Integrate LogicalDownsampler with RollingDetectorView using duck typing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updates RollingDetectorView to support LogicalDownsampler as a projection by replacing isinstance checks with duck typing. This makes the code more flexible and follows best practices. Key changes: - Add replicas property to LogicalDownsampler (always returns 1) - Update make_roi_filter() to use hasattr for input_indices method - Update transform_weights() to use hasattr for apply_full method - Both methods now work with Histogrammer, LogicalDownsampler, or custom callables Integration tests added: - RollingDetectorView with LogicalDownsampler as projection - make_roi_filter() works correctly with LogicalDownsampler - transform_weights() works correctly with LogicalDownsampler All 39 tests in raw_test.py pass, including 8 LogicalDownsampler tests. This completes the LogicalDownsampler implementation with full RollingDetectorView integration and ROI filtering support. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/ess/reduce/live/raw.py | 16 +++++-- tests/live/raw_test.py | 90 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 4 deletions(-) diff --git a/src/ess/reduce/live/raw.py b/src/ess/reduce/live/raw.py index 31083579..61eab2c8 100644 --- a/src/ess/reduce/live/raw.py +++ b/src/ess/reduce/live/raw.py @@ -181,6 +181,11 @@ def __init__( ) self._detector_number = detector_number + @property + def replicas(self) -> int: + """Number of replicas. Always 1 for LogicalDownsampler.""" + return 1 + def __call__(self, da: sc.DataArray) -> sc.DataArray: """ Downsample data by applying transform and summing over reduction dimensions. @@ -419,9 +424,11 @@ def clear_counts(self) -> None: def make_roi_filter(self) -> roi.ROIFilter: """Return a ROI filter operating via the projection plane of the view.""" norm = 1.0 - if isinstance(self._projection, Histogrammer): + # Use duck typing: check if projection has input_indices method + if hasattr(self._projection, 'input_indices'): indices = self._projection.input_indices() - norm = self._projection.replicas + # Get replicas property if it exists (Histogrammer has it, default to 1.0) + norm = getattr(self._projection, 'replicas', 1.0) else: indices = sc.ones(sizes=self.data.sizes, dtype='int32', unit=None) indices = sc.cumsum(indices, mode='exclusive') @@ -462,10 +469,11 @@ def transform_weights( if not sc.identical(det_num, self.detector_number): raise sc.CoordError("Mismatching detector numbers in weights.") weights = weights.data - if isinstance(self._projection, Histogrammer): + # Use duck typing: check for apply_full method (Histogrammer) + if hasattr(self._projection, 'apply_full'): xs = self._projection.apply_full(weights) # Use all replicas elif self._projection is not None: - xs = self._projection(weights) + xs = self._projection(weights) # LogicalDownsampler or callable else: xs = weights.copy() nonempty = xs.values[xs.values > 0] diff --git a/tests/live/raw_test.py b/tests/live/raw_test.py index d12587b4..bd021b98 100644 --- a/tests/live/raw_test.py +++ b/tests/live/raw_test.py @@ -712,3 +712,93 @@ def transform(da: sc.DataArray) -> sc.DataArray: unit='counts', ) assert sc.allclose(result.data, expected) + + +def test_RollingDetectorView_with_LogicalDownsampler_projection() -> None: + """Test that RollingDetectorView works with LogicalDownsampler as projection.""" + # Create a 1D detector with 8 pixels + detector_number = sc.arange('x_pixel_offset', 1, 9, unit=None) + + # Define downsampling transform: 8 -> 4 pixels (2x binning) + def transform(da: sc.DataArray) -> sc.DataArray: + return da.fold(dim='x_pixel_offset', sizes={'x_pixel_offset': 4, 'x_bin': 2}) + + downsampler = raw.LogicalDownsampler( + transform=transform, + reduction_dim='x_bin', + detector_number=detector_number, + ) + + # Create RollingDetectorView with downsampler as projection + view = raw.RollingDetectorView( + detector_number=detector_number, window=2, projection=downsampler + ) + + # Add some counts: pixels 1, 2, 3, 4 -> downsampled bins [0, 1] + view.add_counts([1, 2, 3, 4]) + result = view.get() + + # After downsampling: [1+2, 3+4] = [2, 2] for first two bins + assert result.sizes == {'x_pixel_offset': 4} + assert result['x_pixel_offset', 0].value == 2 + assert result['x_pixel_offset', 1].value == 2 + assert result['x_pixel_offset', 2].value == 0 + assert result['x_pixel_offset', 3].value == 0 + + +def test_RollingDetectorView_make_roi_filter_with_LogicalDownsampler() -> None: + """Test that make_roi_filter() works with LogicalDownsampler.""" + detector_number = sc.arange('x_pixel_offset', 1, 9, unit=None) + + def transform(da: sc.DataArray) -> sc.DataArray: + return da.fold(dim='x_pixel_offset', sizes={'x_pixel_offset': 4, 'x_bin': 2}) + + downsampler = raw.LogicalDownsampler( + transform=transform, + reduction_dim='x_bin', + detector_number=detector_number, + ) + + view = raw.RollingDetectorView( + detector_number=detector_number, window=1, projection=downsampler + ) + + # Should not raise - LogicalDownsampler has input_indices() + roi_filter = view.make_roi_filter() + + # The indices should be binned data (check via private attribute for now) + assert roi_filter._indices.bins is not None + assert roi_filter._indices.sizes == {'x_pixel_offset': 4} + # Each bin should contain 2 indices + assert all(roi_filter._indices.bins.size().values == 2) + + +def test_RollingDetectorView_transform_weights_with_LogicalDownsampler() -> None: + """Test that transform_weights() works with LogicalDownsampler.""" + detector_number = sc.arange('x_pixel_offset', 1, 9, unit=None) + + def transform(da: sc.DataArray) -> sc.DataArray: + return da.fold(dim='x_pixel_offset', sizes={'x_pixel_offset': 4, 'x_bin': 2}) + + downsampler = raw.LogicalDownsampler( + transform=transform, + reduction_dim='x_bin', + detector_number=detector_number, + ) + + view = raw.RollingDetectorView( + detector_number=detector_number, window=1, projection=downsampler + ) + + # Create weights: all pixels have weight 1.0 + weights = sc.ones(sizes={'x_pixel_offset': 8}, dtype='float32', unit='') + + # Transform weights through the downsampler + transformed = view.transform_weights(weights) + + # After downsampling: each output bin sums 2 input weights = 2.0 + assert transformed.sizes == {'x_pixel_offset': 4} + expected = sc.full( + dims=['x_pixel_offset'], shape=[4], value=2.0, dtype='float32', unit='' + ) + assert sc.allclose(transformed.data, expected) From 9693e0b2542c7bdd08ab5b59ffbb73516d18621e Mon Sep 17 00:00:00 2001 From: Simon Heybrock Date: Mon, 24 Nov 2025 13:05:04 +0000 Subject: [PATCH 3/8] Organize LogicalDownsampler tests into test classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Groups the LogicalDownsampler tests added in this branch into organized test classes with cleaner, shorter test names: - TestLogicalDownsampler: Tests for core LogicalDownsampler functionality - test_single_dim_downsampling - test_multi_dim_downsampling - test_input_indices_single_dim - test_input_indices_multi_dim - test_with_varying_input_values - TestRollingDetectorViewWithLogicalDownsampler: Integration tests - test_as_projection - test_make_roi_filter - test_transform_weights All 39 tests pass. No changes to tests added before this branch. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- tests/live/raw_test.py | 436 +++++++++++++++++++++-------------------- 1 file changed, 225 insertions(+), 211 deletions(-) diff --git a/tests/live/raw_test.py b/tests/live/raw_test.py index bd021b98..76d0cef1 100644 --- a/tests/live/raw_test.py +++ b/tests/live/raw_test.py @@ -549,256 +549,270 @@ def test_transform_weights_raises_given_DataArray_with_bad_det_num() -> None: view.transform_weights(weights) -def test_LogicalDownsampler_single_dim_downsampling() -> None: - """Test basic 1D downsampling with transform + reduction.""" - # Create a simple 1D detector: 8 pixels -> downsample to 4 pixels (2x2 binning) - detector_number = sc.arange('x_pixel_offset', 8, unit=None) - - # Transform: fold into 4 groups of 2 - def transform(da: sc.DataArray) -> sc.DataArray: - return da.fold(dim='x_pixel_offset', sizes={'x_pixel_offset': 4, 'x_bin': 2}) - - downsampler = raw.LogicalDownsampler( - transform=transform, - reduction_dim='x_bin', - detector_number=detector_number, - ) - - # Create test data: each pixel has value equal to its index - data = sc.DataArray( - data=sc.arange('x_pixel_offset', 8, dtype='float64', unit='counts'), - coords={'detector_number': detector_number}, - ) - - # Apply downsampling - result = downsampler(data) - - # Should sum pairs: [0+1, 2+3, 4+5, 6+7] = [1, 5, 9, 13] - expected = sc.array( - dims=['x_pixel_offset'], - values=[1.0, 5.0, 9.0, 13.0], - unit='counts', - ) - assert sc.allclose(result.data, expected) - assert result.sizes == {'x_pixel_offset': 4} - - -def test_LogicalDownsampler_multi_dim_downsampling() -> None: - """Test 2D downsampling similar to _resize_image example.""" - # Create 8x8 detector -> downsample to 4x4 (2x2 binning in each dimension) - detector_number = sc.zeros(sizes={'x_pixel_offset': 8, 'y_pixel_offset': 8}) - - # Transform: fold both dimensions - def transform(da: sc.DataArray) -> sc.DataArray: - da = da.fold(dim='x_pixel_offset', sizes={'x_pixel_offset': 4, 'x_bin': 2}) - da = da.fold(dim='y_pixel_offset', sizes={'y_pixel_offset': 4, 'y_bin': 2}) - return da - - downsampler = raw.LogicalDownsampler( - transform=transform, - reduction_dim=['x_bin', 'y_bin'], - detector_number=detector_number, - ) +class TestLogicalDownsampler: + """Tests for LogicalDownsampler class.""" + + def test_single_dim_downsampling(self) -> None: + """Test basic 1D downsampling with transform + reduction.""" + # Create a simple 1D detector: 8 pixels -> downsample to 4 pixels (2x2 binning) + detector_number = sc.arange('x_pixel_offset', 8, unit=None) + + # Transform: fold into 4 groups of 2 + def transform(da: sc.DataArray) -> sc.DataArray: + return da.fold( + dim='x_pixel_offset', sizes={'x_pixel_offset': 4, 'x_bin': 2} + ) + + downsampler = raw.LogicalDownsampler( + transform=transform, + reduction_dim='x_bin', + detector_number=detector_number, + ) - # Create test data: constant value of 1 everywhere - data = sc.DataArray( - data=sc.ones(sizes={'x_pixel_offset': 8, 'y_pixel_offset': 8}, unit='counts'), - coords={'detector_number': detector_number}, - ) + # Create test data: each pixel has value equal to its index + data = sc.DataArray( + data=sc.arange('x_pixel_offset', 8, dtype='float64', unit='counts'), + coords={'detector_number': detector_number}, + ) - # Apply downsampling - result = downsampler(data) + # Apply downsampling + result = downsampler(data) - # Each output pixel should be sum of 2x2=4 input pixels - expected = sc.full( - dims=['x_pixel_offset', 'y_pixel_offset'], - shape=[4, 4], - value=4.0, - unit='counts', - ) - assert sc.allclose(result.data, expected) - assert result.sizes == {'x_pixel_offset': 4, 'y_pixel_offset': 4} + # Should sum pairs: [0+1, 2+3, 4+5, 6+7] = [1, 5, 9, 13] + expected = sc.array( + dims=['x_pixel_offset'], + values=[1.0, 5.0, 9.0, 13.0], + unit='counts', + ) + assert sc.allclose(result.data, expected) + assert result.sizes == {'x_pixel_offset': 4} + + def test_multi_dim_downsampling(self) -> None: + """Test 2D downsampling similar to _resize_image example.""" + # Create 8x8 detector -> downsample to 4x4 (2x2 binning in each dimension) + detector_number = sc.zeros(sizes={'x_pixel_offset': 8, 'y_pixel_offset': 8}) + + # Transform: fold both dimensions + def transform(da: sc.DataArray) -> sc.DataArray: + da = da.fold(dim='x_pixel_offset', sizes={'x_pixel_offset': 4, 'x_bin': 2}) + da = da.fold(dim='y_pixel_offset', sizes={'y_pixel_offset': 4, 'y_bin': 2}) + return da + + downsampler = raw.LogicalDownsampler( + transform=transform, + reduction_dim=['x_bin', 'y_bin'], + detector_number=detector_number, + ) + # Create test data: constant value of 1 everywhere + data = sc.DataArray( + data=sc.ones( + sizes={'x_pixel_offset': 8, 'y_pixel_offset': 8}, unit='counts' + ), + coords={'detector_number': detector_number}, + ) -def test_LogicalDownsampler_input_indices_single_dim() -> None: - """Test that input_indices creates correct binned mapping for 1D.""" - detector_number = sc.arange('x_pixel_offset', 8, unit=None) + # Apply downsampling + result = downsampler(data) - def transform(da: sc.DataArray) -> sc.DataArray: - return da.fold(dim='x_pixel_offset', sizes={'x_pixel_offset': 4, 'x_bin': 2}) + # Each output pixel should be sum of 2x2=4 input pixels + expected = sc.full( + dims=['x_pixel_offset', 'y_pixel_offset'], + shape=[4, 4], + value=4.0, + unit='counts', + ) + assert sc.allclose(result.data, expected) + assert result.sizes == {'x_pixel_offset': 4, 'y_pixel_offset': 4} + + def test_input_indices_single_dim(self) -> None: + """Test that input_indices creates correct binned mapping for 1D.""" + detector_number = sc.arange('x_pixel_offset', 8, unit=None) + + def transform(da: sc.DataArray) -> sc.DataArray: + return da.fold( + dim='x_pixel_offset', sizes={'x_pixel_offset': 4, 'x_bin': 2} + ) + + downsampler = raw.LogicalDownsampler( + transform=transform, + reduction_dim='x_bin', + detector_number=detector_number, + ) - downsampler = raw.LogicalDownsampler( - transform=transform, - reduction_dim='x_bin', - detector_number=detector_number, - ) + # Get index mapping + indices = downsampler.input_indices() - # Get index mapping - indices = downsampler.input_indices() + # Should be binned data with 4 bins, each containing 2 indices + assert indices.sizes == {'x_pixel_offset': 4} + assert indices.bins is not None - # Should be binned data with 4 bins, each containing 2 indices - assert indices.sizes == {'x_pixel_offset': 4} - assert indices.bins is not None + # Check each bin contains the correct indices + # Bin 0: [0, 1], Bin 1: [2, 3], Bin 2: [4, 5], Bin 3: [6, 7] + # Extract all bin contents using the bins accessor + bin_sizes = indices.bins.size() + assert all(bin_sizes.values == 2) # Each bin should have 2 indices - # Check each bin contains the correct indices - # Bin 0: [0, 1], Bin 1: [2, 3], Bin 2: [4, 5], Bin 3: [6, 7] - # Extract all bin contents using the bins accessor - bin_sizes = indices.bins.size() - assert all(bin_sizes.values == 2) # Each bin should have 2 indices + # Check total count + assert indices.bins.size().sum().value == 8 - # Check total count - assert indices.bins.size().sum().value == 8 + def test_input_indices_multi_dim(self) -> None: + """Test that input_indices creates correct binned mapping for 2D.""" + detector_number = sc.zeros(sizes={'x_pixel_offset': 4, 'y_pixel_offset': 4}) + def transform(da: sc.DataArray) -> sc.DataArray: + da = da.fold(dim='x_pixel_offset', sizes={'x_pixel_offset': 2, 'x_bin': 2}) + da = da.fold(dim='y_pixel_offset', sizes={'y_pixel_offset': 2, 'y_bin': 2}) + return da -def test_LogicalDownsampler_input_indices_multi_dim() -> None: - """Test that input_indices creates correct binned mapping for 2D.""" - detector_number = sc.zeros(sizes={'x_pixel_offset': 4, 'y_pixel_offset': 4}) + downsampler = raw.LogicalDownsampler( + transform=transform, + reduction_dim=['x_bin', 'y_bin'], + detector_number=detector_number, + ) - def transform(da: sc.DataArray) -> sc.DataArray: - da = da.fold(dim='x_pixel_offset', sizes={'x_pixel_offset': 2, 'x_bin': 2}) - da = da.fold(dim='y_pixel_offset', sizes={'y_pixel_offset': 2, 'y_bin': 2}) - return da + # Get index mapping + indices = downsampler.input_indices() - downsampler = raw.LogicalDownsampler( - transform=transform, - reduction_dim=['x_bin', 'y_bin'], - detector_number=detector_number, - ) + # Should be binned data with 2x2 output bins + assert indices.sizes == {'x_pixel_offset': 2, 'y_pixel_offset': 2} + assert indices.bins is not None - # Get index mapping - indices = downsampler.input_indices() + # Each bin should contain 2x2=4 indices from the flattened input + bin_sizes = indices.bins.size() + assert all(bin_sizes.values.ravel() == 4) # Each bin should have 4 indices - # Should be binned data with 2x2 output bins - assert indices.sizes == {'x_pixel_offset': 2, 'y_pixel_offset': 2} - assert indices.bins is not None + # Check total count: 4x4 input pixels -> 2x2 output bins + assert indices.bins.size().sum().value == 16 - # Each bin should contain 2x2=4 indices from the flattened input - bin_sizes = indices.bins.size() - assert all(bin_sizes.values.ravel() == 4) # Each bin should have 4 indices + def test_with_varying_input_values(self) -> None: + """Test that downsampling correctly sums varying input values.""" + detector_number = sc.arange('x_pixel_offset', 6, unit=None) - # Check total count: 4x4 input pixels -> 2x2 output bins - assert indices.bins.size().sum().value == 16 + def transform(da: sc.DataArray) -> sc.DataArray: + return da.fold( + dim='x_pixel_offset', sizes={'x_pixel_offset': 3, 'x_bin': 2} + ) + downsampler = raw.LogicalDownsampler( + transform=transform, + reduction_dim='x_bin', + detector_number=detector_number, + ) -def test_LogicalDownsampler_with_varying_input_values() -> None: - """Test that downsampling correctly sums varying input values.""" - detector_number = sc.arange('x_pixel_offset', 6, unit=None) + # Create test data with specific values: [10, 20, 30, 40, 50, 60] + data = sc.DataArray( + data=sc.array( + dims=['x_pixel_offset'], + values=[10.0, 20.0, 30.0, 40.0, 50.0, 60.0], + unit='counts', + ), + coords={'detector_number': detector_number}, + ) - def transform(da: sc.DataArray) -> sc.DataArray: - return da.fold(dim='x_pixel_offset', sizes={'x_pixel_offset': 3, 'x_bin': 2}) + result = downsampler(data) - downsampler = raw.LogicalDownsampler( - transform=transform, - reduction_dim='x_bin', - detector_number=detector_number, - ) - - # Create test data with specific values: [10, 20, 30, 40, 50, 60] - data = sc.DataArray( - data=sc.array( + # Should sum pairs: [10+20, 30+40, 50+60] = [30, 70, 110] + expected = sc.array( dims=['x_pixel_offset'], - values=[10.0, 20.0, 30.0, 40.0, 50.0, 60.0], + values=[30.0, 70.0, 110.0], unit='counts', - ), - coords={'detector_number': detector_number}, - ) - - result = downsampler(data) - - # Should sum pairs: [10+20, 30+40, 50+60] = [30, 70, 110] - expected = sc.array( - dims=['x_pixel_offset'], - values=[30.0, 70.0, 110.0], - unit='counts', - ) - assert sc.allclose(result.data, expected) - - -def test_RollingDetectorView_with_LogicalDownsampler_projection() -> None: - """Test that RollingDetectorView works with LogicalDownsampler as projection.""" - # Create a 1D detector with 8 pixels - detector_number = sc.arange('x_pixel_offset', 1, 9, unit=None) - - # Define downsampling transform: 8 -> 4 pixels (2x binning) - def transform(da: sc.DataArray) -> sc.DataArray: - return da.fold(dim='x_pixel_offset', sizes={'x_pixel_offset': 4, 'x_bin': 2}) - - downsampler = raw.LogicalDownsampler( - transform=transform, - reduction_dim='x_bin', - detector_number=detector_number, - ) - - # Create RollingDetectorView with downsampler as projection - view = raw.RollingDetectorView( - detector_number=detector_number, window=2, projection=downsampler - ) + ) + assert sc.allclose(result.data, expected) - # Add some counts: pixels 1, 2, 3, 4 -> downsampled bins [0, 1] - view.add_counts([1, 2, 3, 4]) - result = view.get() - # After downsampling: [1+2, 3+4] = [2, 2] for first two bins - assert result.sizes == {'x_pixel_offset': 4} - assert result['x_pixel_offset', 0].value == 2 - assert result['x_pixel_offset', 1].value == 2 - assert result['x_pixel_offset', 2].value == 0 - assert result['x_pixel_offset', 3].value == 0 +class TestRollingDetectorViewWithLogicalDownsampler: + """Tests for RollingDetectorView integration with LogicalDownsampler.""" + def test_as_projection(self) -> None: + """Test that RollingDetectorView works with LogicalDownsampler as projection.""" + # Create a 1D detector with 8 pixels + detector_number = sc.arange('x_pixel_offset', 1, 9, unit=None) -def test_RollingDetectorView_make_roi_filter_with_LogicalDownsampler() -> None: - """Test that make_roi_filter() works with LogicalDownsampler.""" - detector_number = sc.arange('x_pixel_offset', 1, 9, unit=None) + # Define downsampling transform: 8 -> 4 pixels (2x binning) + def transform(da: sc.DataArray) -> sc.DataArray: + return da.fold( + dim='x_pixel_offset', sizes={'x_pixel_offset': 4, 'x_bin': 2} + ) - def transform(da: sc.DataArray) -> sc.DataArray: - return da.fold(dim='x_pixel_offset', sizes={'x_pixel_offset': 4, 'x_bin': 2}) + downsampler = raw.LogicalDownsampler( + transform=transform, + reduction_dim='x_bin', + detector_number=detector_number, + ) - downsampler = raw.LogicalDownsampler( - transform=transform, - reduction_dim='x_bin', - detector_number=detector_number, - ) + # Create RollingDetectorView with downsampler as projection + view = raw.RollingDetectorView( + detector_number=detector_number, window=2, projection=downsampler + ) - view = raw.RollingDetectorView( - detector_number=detector_number, window=1, projection=downsampler - ) + # Add some counts: pixels 1, 2, 3, 4 -> downsampled bins [0, 1] + view.add_counts([1, 2, 3, 4]) + result = view.get() + + # After downsampling: [1+2, 3+4] = [2, 2] for first two bins + assert result.sizes == {'x_pixel_offset': 4} + assert result['x_pixel_offset', 0].value == 2 + assert result['x_pixel_offset', 1].value == 2 + assert result['x_pixel_offset', 2].value == 0 + assert result['x_pixel_offset', 3].value == 0 + + def test_make_roi_filter(self) -> None: + """Test that make_roi_filter() works with LogicalDownsampler.""" + detector_number = sc.arange('x_pixel_offset', 1, 9, unit=None) + + def transform(da: sc.DataArray) -> sc.DataArray: + return da.fold( + dim='x_pixel_offset', sizes={'x_pixel_offset': 4, 'x_bin': 2} + ) + + downsampler = raw.LogicalDownsampler( + transform=transform, + reduction_dim='x_bin', + detector_number=detector_number, + ) - # Should not raise - LogicalDownsampler has input_indices() - roi_filter = view.make_roi_filter() + view = raw.RollingDetectorView( + detector_number=detector_number, window=1, projection=downsampler + ) - # The indices should be binned data (check via private attribute for now) - assert roi_filter._indices.bins is not None - assert roi_filter._indices.sizes == {'x_pixel_offset': 4} - # Each bin should contain 2 indices - assert all(roi_filter._indices.bins.size().values == 2) + # Should not raise - LogicalDownsampler has input_indices() + roi_filter = view.make_roi_filter() + # The indices should be binned data (check via private attribute for now) + assert roi_filter._indices.bins is not None + assert roi_filter._indices.sizes == {'x_pixel_offset': 4} + # Each bin should contain 2 indices + assert all(roi_filter._indices.bins.size().values == 2) -def test_RollingDetectorView_transform_weights_with_LogicalDownsampler() -> None: - """Test that transform_weights() works with LogicalDownsampler.""" - detector_number = sc.arange('x_pixel_offset', 1, 9, unit=None) + def test_transform_weights(self) -> None: + """Test that transform_weights() works with LogicalDownsampler.""" + detector_number = sc.arange('x_pixel_offset', 1, 9, unit=None) - def transform(da: sc.DataArray) -> sc.DataArray: - return da.fold(dim='x_pixel_offset', sizes={'x_pixel_offset': 4, 'x_bin': 2}) + def transform(da: sc.DataArray) -> sc.DataArray: + return da.fold( + dim='x_pixel_offset', sizes={'x_pixel_offset': 4, 'x_bin': 2} + ) - downsampler = raw.LogicalDownsampler( - transform=transform, - reduction_dim='x_bin', - detector_number=detector_number, - ) + downsampler = raw.LogicalDownsampler( + transform=transform, + reduction_dim='x_bin', + detector_number=detector_number, + ) - view = raw.RollingDetectorView( - detector_number=detector_number, window=1, projection=downsampler - ) + view = raw.RollingDetectorView( + detector_number=detector_number, window=1, projection=downsampler + ) - # Create weights: all pixels have weight 1.0 - weights = sc.ones(sizes={'x_pixel_offset': 8}, dtype='float32', unit='') + # Create weights: all pixels have weight 1.0 + weights = sc.ones(sizes={'x_pixel_offset': 8}, dtype='float32', unit='') - # Transform weights through the downsampler - transformed = view.transform_weights(weights) + # Transform weights through the downsampler + transformed = view.transform_weights(weights) - # After downsampling: each output bin sums 2 input weights = 2.0 - assert transformed.sizes == {'x_pixel_offset': 4} - expected = sc.full( - dims=['x_pixel_offset'], shape=[4], value=2.0, dtype='float32', unit='' - ) - assert sc.allclose(transformed.data, expected) + # After downsampling: each output bin sums 2 input weights = 2.0 + assert transformed.sizes == {'x_pixel_offset': 4} + expected = sc.full( + dims=['x_pixel_offset'], shape=[4], value=2.0, dtype='float32', unit='' + ) + assert sc.allclose(transformed.data, expected) From 3d39a2f14ff2255914311bf066a4f6c388b702c0 Mon Sep 17 00:00:00 2001 From: Simon Heybrock Date: Mon, 24 Nov 2025 13:27:55 +0000 Subject: [PATCH 4/8] Replace detector_number with input_sizes in LogicalDownsampler LogicalDownsampler now uses input_sizes (dict[str, int]) instead of detector_number (sc.Variable) parameter. This change: - Simplifies the implementation by only requiring dimension sizes, not coordinate values - Works with detectors that don't provide detector_number coordinate - Makes it clear that only the dimension structure is needed, not the actual detector number values Add RollingDetectorView.with_logical_downsampler() factory method to create a RollingDetectorView with a LogicalDownsampler projection, automatically inferring input_sizes from detector_number.sizes. This provides a clean API without hidden side effects. Update all tests to use the new API. --- Original prompt: Help me understand why LogicalDownsampler is using detector_number. I thought it could simply operate on an `arange`, like Histogramer.input_indices? Follow-up: - Can we remove the detector-number handling and simplify everything? We actually have detectors that do not provide a detector_number. - Can we infer it from detector_number if we setup via RollingDetectorView? - I am not happy about "patching" the _input_sizes in __init__. Can we instead add a staticmethod that creates a RollingDetectorView with a LogicalDownsampler from detector_number and the args the downsampler needs? --- src/ess/reduce/live/raw.py | 89 +++++++++++++++++++++++++++----------- tests/live/raw_test.py | 54 +++++++---------------- 2 files changed, 80 insertions(+), 63 deletions(-) diff --git a/src/ess/reduce/live/raw.py b/src/ess/reduce/live/raw.py index 61eab2c8..c7dbc6df 100644 --- a/src/ess/reduce/live/raw.py +++ b/src/ess/reduce/live/raw.py @@ -155,7 +155,7 @@ def __init__( self, transform: Callable[[sc.DataArray], sc.DataArray], reduction_dim: str | list[str], - detector_number: sc.Variable | None = None, + input_sizes: dict[str, int] | None = None, ): """ Create a logical downsampler. @@ -170,16 +170,18 @@ def __init__( reduction_dim: Dimension(s) to sum over after applying transform. Example: 'x_bin' or ['x_bin', 'y_bin'] - detector_number: - Detector number array defining the input shape. + input_sizes: + Dictionary defining the input dimension sizes. Required for input_indices(). If not provided, input_indices() will raise an error. + When used with RollingDetectorView, this is automatically + inferred from detector_number. """ self._transform = transform self._reduction_dim = ( [reduction_dim] if isinstance(reduction_dim, str) else reduction_dim ) - self._detector_number = detector_number + self._input_sizes = input_sizes @property def replicas(self) -> int: @@ -208,42 +210,38 @@ def input_indices(self) -> sc.DataArray: Create index mapping for ROI filtering. Returns a binned DataArray where each output pixel contains - a list of contributing detector numbers (as indices into the - flattened detector_number array). + a list of contributing input indices (as indices into the + flattened input array). Returns ------- : - Binned DataArray mapping output pixels to input detector indices. + Binned DataArray mapping output pixels to input indices. Raises ------ ValueError: - If detector_number was not provided during initialization. + If input_sizes was not provided during initialization. """ - if self._detector_number is None: + if self._input_sizes is None: raise ValueError( - "detector_number is required for input_indices(). " + "input_sizes is required for input_indices(). " "Provide it during LogicalDownsampler initialization." ) - # Create sequential indices (0, 1, 2, ...) matching detector_number shape - indices = sc.arange( - 'detector_number', - self._detector_number.size, - dtype='int64', - unit=None, - ) - indices = indices.fold( - dim='detector_number', - sizes=dict(self._detector_number.sizes), - ) + # Calculate total number of elements + total_size = 1 + for size in self._input_sizes.values(): + total_size *= size + + # Create sequential indices (0, 1, 2, ...) and fold to input shape + # Use a temporary dimension name for the flat indices + flat_dim = '_flat' + indices = sc.arange(flat_dim, total_size, dtype='int64', unit=None) + indices = indices.fold(dim=flat_dim, sizes=self._input_sizes) # Create DataArray to apply transform - indices_da = sc.DataArray( - data=indices, - coords={'detector_number': self._detector_number}, - ) + indices_da = sc.DataArray(data=indices) # Apply transform to get the grouping structure transformed = self._transform(indices_da) @@ -395,6 +393,47 @@ def __init__( self._cumulative: sc.DataArray self.clear_counts() + @staticmethod + def with_logical_downsampler( + *, + detector_number: sc.Variable, + window: int, + transform: Callable[[sc.DataArray], sc.DataArray], + reduction_dim: str | list[str], + ) -> RollingDetectorView: + """ + Create a RollingDetectorView with a LogicalDownsampler projection. + + This factory method creates a LogicalDownsampler with input_sizes + automatically inferred from detector_number.sizes. + + Parameters + ---------- + detector_number: + Detector number for each pixel. + window: + Size of the rolling window. + transform: + Transform function for the LogicalDownsampler. + reduction_dim: + Reduction dimension(s) for the LogicalDownsampler. + + Returns + ------- + : + RollingDetectorView with LogicalDownsampler projection. + """ + downsampler = LogicalDownsampler( + transform=transform, + reduction_dim=reduction_dim, + input_sizes=dict(detector_number.sizes), + ) + return RollingDetectorView( + detector_number=detector_number, + window=window, + projection=downsampler, + ) + @property def max_window(self) -> int: return self._window diff --git a/tests/live/raw_test.py b/tests/live/raw_test.py index 76d0cef1..316d3c7d 100644 --- a/tests/live/raw_test.py +++ b/tests/live/raw_test.py @@ -554,8 +554,6 @@ class TestLogicalDownsampler: def test_single_dim_downsampling(self) -> None: """Test basic 1D downsampling with transform + reduction.""" - # Create a simple 1D detector: 8 pixels -> downsample to 4 pixels (2x2 binning) - detector_number = sc.arange('x_pixel_offset', 8, unit=None) # Transform: fold into 4 groups of 2 def transform(da: sc.DataArray) -> sc.DataArray: @@ -566,13 +564,11 @@ def transform(da: sc.DataArray) -> sc.DataArray: downsampler = raw.LogicalDownsampler( transform=transform, reduction_dim='x_bin', - detector_number=detector_number, ) # Create test data: each pixel has value equal to its index data = sc.DataArray( - data=sc.arange('x_pixel_offset', 8, dtype='float64', unit='counts'), - coords={'detector_number': detector_number}, + data=sc.arange('x_pixel_offset', 8, dtype='float64', unit='counts') ) # Apply downsampling @@ -589,10 +585,8 @@ def transform(da: sc.DataArray) -> sc.DataArray: def test_multi_dim_downsampling(self) -> None: """Test 2D downsampling similar to _resize_image example.""" - # Create 8x8 detector -> downsample to 4x4 (2x2 binning in each dimension) - detector_number = sc.zeros(sizes={'x_pixel_offset': 8, 'y_pixel_offset': 8}) - # Transform: fold both dimensions + # Transform: fold both dimensions for 8x8 -> 4x4 (2x2 binning in each dimension) def transform(da: sc.DataArray) -> sc.DataArray: da = da.fold(dim='x_pixel_offset', sizes={'x_pixel_offset': 4, 'x_bin': 2}) da = da.fold(dim='y_pixel_offset', sizes={'y_pixel_offset': 4, 'y_bin': 2}) @@ -601,15 +595,13 @@ def transform(da: sc.DataArray) -> sc.DataArray: downsampler = raw.LogicalDownsampler( transform=transform, reduction_dim=['x_bin', 'y_bin'], - detector_number=detector_number, ) # Create test data: constant value of 1 everywhere data = sc.DataArray( data=sc.ones( sizes={'x_pixel_offset': 8, 'y_pixel_offset': 8}, unit='counts' - ), - coords={'detector_number': detector_number}, + ) ) # Apply downsampling @@ -627,7 +619,6 @@ def transform(da: sc.DataArray) -> sc.DataArray: def test_input_indices_single_dim(self) -> None: """Test that input_indices creates correct binned mapping for 1D.""" - detector_number = sc.arange('x_pixel_offset', 8, unit=None) def transform(da: sc.DataArray) -> sc.DataArray: return da.fold( @@ -637,7 +628,7 @@ def transform(da: sc.DataArray) -> sc.DataArray: downsampler = raw.LogicalDownsampler( transform=transform, reduction_dim='x_bin', - detector_number=detector_number, + input_sizes={'x_pixel_offset': 8}, ) # Get index mapping @@ -658,7 +649,6 @@ def transform(da: sc.DataArray) -> sc.DataArray: def test_input_indices_multi_dim(self) -> None: """Test that input_indices creates correct binned mapping for 2D.""" - detector_number = sc.zeros(sizes={'x_pixel_offset': 4, 'y_pixel_offset': 4}) def transform(da: sc.DataArray) -> sc.DataArray: da = da.fold(dim='x_pixel_offset', sizes={'x_pixel_offset': 2, 'x_bin': 2}) @@ -668,7 +658,7 @@ def transform(da: sc.DataArray) -> sc.DataArray: downsampler = raw.LogicalDownsampler( transform=transform, reduction_dim=['x_bin', 'y_bin'], - detector_number=detector_number, + input_sizes={'x_pixel_offset': 4, 'y_pixel_offset': 4}, ) # Get index mapping @@ -687,7 +677,6 @@ def transform(da: sc.DataArray) -> sc.DataArray: def test_with_varying_input_values(self) -> None: """Test that downsampling correctly sums varying input values.""" - detector_number = sc.arange('x_pixel_offset', 6, unit=None) def transform(da: sc.DataArray) -> sc.DataArray: return da.fold( @@ -697,7 +686,6 @@ def transform(da: sc.DataArray) -> sc.DataArray: downsampler = raw.LogicalDownsampler( transform=transform, reduction_dim='x_bin', - detector_number=detector_number, ) # Create test data with specific values: [10, 20, 30, 40, 50, 60] @@ -706,8 +694,7 @@ def transform(da: sc.DataArray) -> sc.DataArray: dims=['x_pixel_offset'], values=[10.0, 20.0, 30.0, 40.0, 50.0, 60.0], unit='counts', - ), - coords={'detector_number': detector_number}, + ) ) result = downsampler(data) @@ -735,15 +722,12 @@ def transform(da: sc.DataArray) -> sc.DataArray: dim='x_pixel_offset', sizes={'x_pixel_offset': 4, 'x_bin': 2} ) - downsampler = raw.LogicalDownsampler( + # Create RollingDetectorView with downsampler using factory method + view = raw.RollingDetectorView.with_logical_downsampler( + detector_number=detector_number, + window=2, transform=transform, reduction_dim='x_bin', - detector_number=detector_number, - ) - - # Create RollingDetectorView with downsampler as projection - view = raw.RollingDetectorView( - detector_number=detector_number, window=2, projection=downsampler ) # Add some counts: pixels 1, 2, 3, 4 -> downsampled bins [0, 1] @@ -766,14 +750,11 @@ def transform(da: sc.DataArray) -> sc.DataArray: dim='x_pixel_offset', sizes={'x_pixel_offset': 4, 'x_bin': 2} ) - downsampler = raw.LogicalDownsampler( + view = raw.RollingDetectorView.with_logical_downsampler( + detector_number=detector_number, + window=1, transform=transform, reduction_dim='x_bin', - detector_number=detector_number, - ) - - view = raw.RollingDetectorView( - detector_number=detector_number, window=1, projection=downsampler ) # Should not raise - LogicalDownsampler has input_indices() @@ -794,14 +775,11 @@ def transform(da: sc.DataArray) -> sc.DataArray: dim='x_pixel_offset', sizes={'x_pixel_offset': 4, 'x_bin': 2} ) - downsampler = raw.LogicalDownsampler( + view = raw.RollingDetectorView.with_logical_downsampler( + detector_number=detector_number, + window=1, transform=transform, reduction_dim='x_bin', - detector_number=detector_number, - ) - - view = raw.RollingDetectorView( - detector_number=detector_number, window=1, projection=downsampler ) # Create weights: all pixels have weight 1.0 From 819d08a003a3dc7ed1b632f1b9db43ef8360e312 Mon Sep 17 00:00:00 2001 From: Simon Heybrock Date: Tue, 25 Nov 2025 04:47:46 +0000 Subject: [PATCH 5/8] No need for end indices --- src/ess/reduce/live/raw.py | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/src/ess/reduce/live/raw.py b/src/ess/reduce/live/raw.py index c7dbc6df..042ae177 100644 --- a/src/ess/reduce/live/raw.py +++ b/src/ess/reduce/live/raw.py @@ -278,7 +278,6 @@ def input_indices(self) -> sc.DataArray: begin_values = np.arange( 0, transformed_flat.data.size, bin_size, dtype=np.int64 ) - end_values = begin_values + bin_size # Get output shape output_shape = [transformed_flat.sizes[d] for d in output_dims] @@ -292,21 +291,10 @@ def input_indices(self) -> sc.DataArray: dims=output_dims, values=begin_values.reshape(output_shape), dtype='int64', + unit=None, ) - end_var = sc.array( - dims=output_dims, - values=end_values.reshape(output_shape), - dtype='int64', - ) # Remove units from begin/end to match data (which has no unit from arange) - begin_var.unit = data_flat.unit - end_var.unit = data_flat.unit - - binned_var = sc.bins( - begin=begin_var, - end=end_var, - dim=flat_dim + '_flat', - data=data_flat, - ) + + binned_var = sc.bins(begin=begin_var, dim=flat_dim + '_flat', data=data_flat) return sc.DataArray(binned_var) From 49ed893db3170539a0bdf66b0762833c332fb993 Mon Sep 17 00:00:00 2001 From: Simon Heybrock Date: Tue, 25 Nov 2025 05:00:35 +0000 Subject: [PATCH 6/8] Simplify LogicalDownsampler.input_indices() implementation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use math.prod instead of manual loop for total_size calculation - Unify single/multi reduction dim handling by always using transpose+flatten - Clearer variable naming (_temp, _reduction, _flat) instead of reusing flat_dim - Reduce intermediate variables and remove unnecessary comments Prompt: "Please think through LogicalDownsample implementation. I wonder if we can simplify the logic slightly?" -> "Yes, let us make input_indices cleaner." 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/ess/reduce/live/raw.py | 85 ++++++++++---------------------------- 1 file changed, 22 insertions(+), 63 deletions(-) diff --git a/src/ess/reduce/live/raw.py b/src/ess/reduce/live/raw.py index 042ae177..4be2f0b8 100644 --- a/src/ess/reduce/live/raw.py +++ b/src/ess/reduce/live/raw.py @@ -21,7 +21,7 @@ from __future__ import annotations from collections.abc import Callable, Sequence -from math import ceil +from math import ceil, prod from typing import Literal, NewType import numpy as np @@ -229,74 +229,33 @@ def input_indices(self) -> sc.DataArray: "Provide it during LogicalDownsampler initialization." ) - # Calculate total number of elements - total_size = 1 - for size in self._input_sizes.values(): - total_size *= size - # Create sequential indices (0, 1, 2, ...) and fold to input shape - # Use a temporary dimension name for the flat indices - flat_dim = '_flat' - indices = sc.arange(flat_dim, total_size, dtype='int64', unit=None) - indices = indices.fold(dim=flat_dim, sizes=self._input_sizes) - - # Create DataArray to apply transform - indices_da = sc.DataArray(data=indices) + total_size = prod(self._input_sizes.values()) + indices = sc.arange('_temp', total_size, dtype='int64', unit=None) + indices = indices.fold(dim='_temp', sizes=self._input_sizes) # Apply transform to get the grouping structure - transformed = self._transform(indices_da) - - # Flatten the reduction dimensions and convert to binned structure - # Each output pixel will contain indices from the reduction dimensions - flat_dim = 'detector_number' - if len(self._reduction_dim) > 1: - # Multiple reduction dims: need to make them contiguous first - # Get all dimensions and move reduction dims to the end - all_dims = list(transformed.dims) - output_dims = [d for d in all_dims if d not in self._reduction_dim] - # Transpose to put reduction dims at the end, in order - new_order = output_dims + self._reduction_dim - transformed_transposed = transformed.transpose(new_order) - # Now flatten the (now contiguous) reduction dims - transformed_flat = transformed_transposed.flatten( - dims=self._reduction_dim, to=flat_dim - ) - else: - # Single reduction dim: rename it - transformed_flat = transformed.rename_dims( - {self._reduction_dim[0]: flat_dim} - ) - - # Convert the dense array to binned data by creating bins manually - # Get the remaining dimensions (output dims) and create binned structure - output_dims = [d for d in transformed_flat.dims if d != flat_dim] - - # Calculate bin structure: each output pixel has data from reduction dims - bin_size = transformed_flat.sizes[flat_dim] - - # Create bin boundaries - begin_values = np.arange( - 0, transformed_flat.data.size, bin_size, dtype=np.int64 - ) - - # Get output shape - output_shape = [transformed_flat.sizes[d] for d in output_dims] - - # Flatten output dimensions to create 1D bins - data_flat = transformed_flat.data.flatten(to=flat_dim + '_flat') - - # Create binned data constituents - # Ensure all components have matching units (no unit for indices) - begin_var = sc.array( + transformed = self._transform(sc.DataArray(data=indices)) + + # Flatten reduction dimensions to a single dimension. + # First transpose to make reduction dims contiguous at the end. + output_dims = [d for d in transformed.dims if d not in self._reduction_dim] + transformed = transformed.transpose(output_dims + self._reduction_dim) + transformed = transformed.flatten(dims=self._reduction_dim, to='_reduction') + + # Convert dense array to binned structure where each output pixel + # contains a bin with the indices of contributing input pixels. + bin_size = transformed.sizes['_reduction'] + output_shape = [transformed.sizes[d] for d in output_dims] + data_flat = transformed.data.flatten(to='_flat') + begin = sc.array( dims=output_dims, - values=begin_values.reshape(output_shape), - dtype='int64', + values=np.arange(0, data_flat.size, bin_size, dtype=np.int64).reshape( + output_shape + ), unit=None, ) - - binned_var = sc.bins(begin=begin_var, dim=flat_dim + '_flat', data=data_flat) - - return sc.DataArray(binned_var) + return sc.DataArray(sc.bins(begin=begin, dim='_flat', data=data_flat)) class Detector: From f64665b91719c8b8cbc60556f7d4419fcb6f3c99 Mon Sep 17 00:00:00 2001 From: Simon Heybrock Date: Tue, 25 Nov 2025 05:00:35 +0000 Subject: [PATCH 7/8] Rename LogicalDownsampler to LogicalView and make reduction optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename class to better reflect its purpose as a general logical view - Make reduction_dim optional (defaults to empty list for pure transforms) - Add early return in input_indices() for non-reducing case (returns dense indices) - Rename factory method to with_logical_view() - Add tests for non-reducing transforms (slicing use case) This enables using LogicalView for transforms that don't reduce, such as selecting a front layer from a volumetric detector. Original prompt: Please consider changes in this branch. Think about how to support logical views that do not actually sum but instead just transform, e.g., to select a certain slice of the input. Can we extend LogicalDownsampler to support this case (reduction dims as empty list?)? Would it be a better alternative do have a separate class instead? 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/ess/reduce/live/raw.py | 81 ++++++++++-------- tests/live/raw_test.py | 171 ++++++++++++++++++++++++++++++++----- 2 files changed, 196 insertions(+), 56 deletions(-) diff --git a/src/ess/reduce/live/raw.py b/src/ess/reduce/live/raw.py index 4be2f0b8..c76a93cb 100644 --- a/src/ess/reduce/live/raw.py +++ b/src/ess/reduce/live/raw.py @@ -138,14 +138,14 @@ def apply_full(self, var: sc.Variable) -> sc.DataArray: return self._hist(replicated, coords=self._coords) / self.replicas -class LogicalDownsampler: +class LogicalView: """ - Downsampler for logical detector views. + Logical view for detector data. - Implements downsampling by applying a user-defined transform (e.g., fold operations) - followed by reduction (summing) over specified dimensions. This provides a clean - separation between "how to group pixels" (transform) and "how to aggregate them" - (sum over reduction dimensions). + Implements a view by applying a user-defined transform (e.g., fold or slice + operations) optionally followed by reduction (summing) over specified dimensions. + This provides a clean separation between "how to reshape/select pixels" (transform) + and "how to aggregate them" (sum over reduction dimensions). This class provides both data transformation (__call__) and index mapping (input_indices) using the same transform, ensuring consistency for ROI filtering. @@ -154,21 +154,23 @@ class LogicalDownsampler: def __init__( self, transform: Callable[[sc.DataArray], sc.DataArray], - reduction_dim: str | list[str], + reduction_dim: str | list[str] | None = None, input_sizes: dict[str, int] | None = None, ): """ - Create a logical downsampler. + Create a logical view. Parameters ---------- transform: - Callable that transforms input data by grouping pixels. - Example: lambda da: da.fold( - 'x_pixel_offset', {'x_pixel_offset': 512, 'x_bin': 2} - ) + Callable that transforms input data by reshaping or selecting pixels. + Examples: + - Fold: lambda da: da.fold('x', {'x': 512, 'x_bin': 2}) + - Slice: lambda da: da['z', 0] (select front layer of volume) + - Combined: lambda da: da.fold('x', {'x': 4, 'z': 8})['z', 0] reduction_dim: - Dimension(s) to sum over after applying transform. + Dimension(s) to sum over after applying transform. If None or empty, + no reduction is performed (pure transform). Example: 'x_bin' or ['x_bin', 'y_bin'] input_sizes: Dictionary defining the input dimension sizes. @@ -178,19 +180,22 @@ def __init__( inferred from detector_number. """ self._transform = transform - self._reduction_dim = ( - [reduction_dim] if isinstance(reduction_dim, str) else reduction_dim - ) + if reduction_dim is None: + self._reduction_dim = [] + elif isinstance(reduction_dim, str): + self._reduction_dim = [reduction_dim] + else: + self._reduction_dim = reduction_dim self._input_sizes = input_sizes @property def replicas(self) -> int: - """Number of replicas. Always 1 for LogicalDownsampler.""" + """Number of replicas. Always 1 for LogicalView.""" return 1 def __call__(self, da: sc.DataArray) -> sc.DataArray: """ - Downsample data by applying transform and summing over reduction dimensions. + Apply transform and optionally sum over reduction dimensions. Parameters ---------- @@ -200,23 +205,26 @@ def __call__(self, da: sc.DataArray) -> sc.DataArray: Returns ------- : - Downsampled data array. + Transformed (and optionally reduced) data array. """ transformed = self._transform(da) - return transformed.sum(self._reduction_dim) + if self._reduction_dim: + return transformed.sum(self._reduction_dim) + return transformed def input_indices(self) -> sc.DataArray: """ Create index mapping for ROI filtering. - Returns a binned DataArray where each output pixel contains - a list of contributing input indices (as indices into the - flattened input array). + Returns a DataArray mapping output pixels to input indices (as indices into + the flattened input array). If reduction dimensions are specified, returns + binned data where each output pixel contains a list of contributing input + indices. If no reduction, returns dense indices (1:1 mapping). Returns ------- : - Binned DataArray mapping output pixels to input indices. + DataArray mapping output pixels to input indices. Raises ------ @@ -226,7 +234,7 @@ def input_indices(self) -> sc.DataArray: if self._input_sizes is None: raise ValueError( "input_sizes is required for input_indices(). " - "Provide it during LogicalDownsampler initialization." + "Provide it during LogicalView initialization." ) # Create sequential indices (0, 1, 2, ...) and fold to input shape @@ -237,6 +245,10 @@ def input_indices(self) -> sc.DataArray: # Apply transform to get the grouping structure transformed = self._transform(sc.DataArray(data=indices)) + if not self._reduction_dim: + # No reduction: 1:1 mapping, return dense indices + return sc.DataArray(data=transformed.data) + # Flatten reduction dimensions to a single dimension. # First transpose to make reduction dims contiguous at the end. output_dims = [d for d in transformed.dims if d not in self._reduction_dim] @@ -341,17 +353,17 @@ def __init__( self.clear_counts() @staticmethod - def with_logical_downsampler( + def with_logical_view( *, detector_number: sc.Variable, window: int, transform: Callable[[sc.DataArray], sc.DataArray], - reduction_dim: str | list[str], + reduction_dim: str | list[str] | None = None, ) -> RollingDetectorView: """ - Create a RollingDetectorView with a LogicalDownsampler projection. + Create a RollingDetectorView with a LogicalView projection. - This factory method creates a LogicalDownsampler with input_sizes + This factory method creates a LogicalView with input_sizes automatically inferred from detector_number.sizes. Parameters @@ -361,16 +373,17 @@ def with_logical_downsampler( window: Size of the rolling window. transform: - Transform function for the LogicalDownsampler. + Transform function for the LogicalView. reduction_dim: - Reduction dimension(s) for the LogicalDownsampler. + Reduction dimension(s) for the LogicalView. If None or empty, + no reduction is performed (pure transform). Returns ------- : - RollingDetectorView with LogicalDownsampler projection. + RollingDetectorView with LogicalView projection. """ - downsampler = LogicalDownsampler( + view = LogicalView( transform=transform, reduction_dim=reduction_dim, input_sizes=dict(detector_number.sizes), @@ -378,7 +391,7 @@ def with_logical_downsampler( return RollingDetectorView( detector_number=detector_number, window=window, - projection=downsampler, + projection=view, ) @property diff --git a/tests/live/raw_test.py b/tests/live/raw_test.py index 316d3c7d..e82f072f 100644 --- a/tests/live/raw_test.py +++ b/tests/live/raw_test.py @@ -549,8 +549,8 @@ def test_transform_weights_raises_given_DataArray_with_bad_det_num() -> None: view.transform_weights(weights) -class TestLogicalDownsampler: - """Tests for LogicalDownsampler class.""" +class TestLogicalView: + """Tests for LogicalView class.""" def test_single_dim_downsampling(self) -> None: """Test basic 1D downsampling with transform + reduction.""" @@ -561,7 +561,7 @@ def transform(da: sc.DataArray) -> sc.DataArray: dim='x_pixel_offset', sizes={'x_pixel_offset': 4, 'x_bin': 2} ) - downsampler = raw.LogicalDownsampler( + view = raw.LogicalView( transform=transform, reduction_dim='x_bin', ) @@ -572,7 +572,7 @@ def transform(da: sc.DataArray) -> sc.DataArray: ) # Apply downsampling - result = downsampler(data) + result = view(data) # Should sum pairs: [0+1, 2+3, 4+5, 6+7] = [1, 5, 9, 13] expected = sc.array( @@ -592,7 +592,7 @@ def transform(da: sc.DataArray) -> sc.DataArray: da = da.fold(dim='y_pixel_offset', sizes={'y_pixel_offset': 4, 'y_bin': 2}) return da - downsampler = raw.LogicalDownsampler( + view = raw.LogicalView( transform=transform, reduction_dim=['x_bin', 'y_bin'], ) @@ -605,7 +605,7 @@ def transform(da: sc.DataArray) -> sc.DataArray: ) # Apply downsampling - result = downsampler(data) + result = view(data) # Each output pixel should be sum of 2x2=4 input pixels expected = sc.full( @@ -625,14 +625,14 @@ def transform(da: sc.DataArray) -> sc.DataArray: dim='x_pixel_offset', sizes={'x_pixel_offset': 4, 'x_bin': 2} ) - downsampler = raw.LogicalDownsampler( + view = raw.LogicalView( transform=transform, reduction_dim='x_bin', input_sizes={'x_pixel_offset': 8}, ) # Get index mapping - indices = downsampler.input_indices() + indices = view.input_indices() # Should be binned data with 4 bins, each containing 2 indices assert indices.sizes == {'x_pixel_offset': 4} @@ -655,14 +655,14 @@ def transform(da: sc.DataArray) -> sc.DataArray: da = da.fold(dim='y_pixel_offset', sizes={'y_pixel_offset': 2, 'y_bin': 2}) return da - downsampler = raw.LogicalDownsampler( + view = raw.LogicalView( transform=transform, reduction_dim=['x_bin', 'y_bin'], input_sizes={'x_pixel_offset': 4, 'y_pixel_offset': 4}, ) # Get index mapping - indices = downsampler.input_indices() + indices = view.input_indices() # Should be binned data with 2x2 output bins assert indices.sizes == {'x_pixel_offset': 2, 'y_pixel_offset': 2} @@ -683,7 +683,7 @@ def transform(da: sc.DataArray) -> sc.DataArray: dim='x_pixel_offset', sizes={'x_pixel_offset': 3, 'x_bin': 2} ) - downsampler = raw.LogicalDownsampler( + view = raw.LogicalView( transform=transform, reduction_dim='x_bin', ) @@ -697,7 +697,7 @@ def transform(da: sc.DataArray) -> sc.DataArray: ) ) - result = downsampler(data) + result = view(data) # Should sum pairs: [10+20, 30+40, 50+60] = [30, 70, 110] expected = sc.array( @@ -707,12 +707,92 @@ def transform(da: sc.DataArray) -> sc.DataArray: ) assert sc.allclose(result.data, expected) + def test_transform_without_reduction_slicing(self) -> None: + """Test transform without reduction (slicing to select front layer).""" -class TestRollingDetectorViewWithLogicalDownsampler: - """Tests for RollingDetectorView integration with LogicalDownsampler.""" + # Transform: fold to 3D volume, then slice front layer + def transform(da: sc.DataArray) -> sc.DataArray: + return da.fold(dim='voxel', sizes={'x': 2, 'y': 2, 'z': 3})['z', 0] + + view = raw.LogicalView(transform=transform) + + # Create test data: each voxel has value equal to its index + data = sc.DataArray(data=sc.arange('voxel', 12, dtype='float64', unit='counts')) + + result = view(data) + + # fold orders: z is innermost, so z=0 gives every 3rd element starting at 0 + # indices: 0, 3, 6, 9 + assert result.sizes == {'x': 2, 'y': 2} + expected = sc.array( + dims=['x', 'y'], + values=[[0.0, 3.0], [6.0, 9.0]], + unit='counts', + ) + assert sc.allclose(result.data, expected) + + def test_transform_without_reduction_reshape(self) -> None: + """Test transform without reduction (pure reshape).""" + + # Transform: just fold without any reduction + def transform(da: sc.DataArray) -> sc.DataArray: + return da.fold(dim='pixel', sizes={'x': 3, 'y': 4}) + + view = raw.LogicalView(transform=transform) + + data = sc.DataArray(data=sc.arange('pixel', 12, dtype='float64', unit='counts')) + + result = view(data) + + # Should just reshape, no reduction + assert result.sizes == {'x': 3, 'y': 4} + assert result.data.sum().value == 66.0 # Sum of 0..11 + + def test_input_indices_without_reduction(self) -> None: + """Test that input_indices returns dense indices when no reduction.""" + + def transform(da: sc.DataArray) -> sc.DataArray: + return da.fold(dim='voxel', sizes={'x': 2, 'y': 2, 'z': 3})['z', 0] + + view = raw.LogicalView( + transform=transform, + input_sizes={'voxel': 12}, + ) + + indices = view.input_indices() + + # Should be dense (not binned) - 1:1 mapping + assert indices.bins is None + assert indices.sizes == {'x': 2, 'y': 2} + + # Indices should correspond to front layer of folded volume + # fold orders: z is innermost, so z=0 gives every 3rd index starting at 0 + expected = sc.array(dims=['x', 'y'], values=[[0, 3], [6, 9]], unit=None) + assert sc.identical(indices.data, expected) + + def test_input_indices_without_reduction_preserves_total_count(self) -> None: + """Test that non-reducing input_indices has correct number of indices.""" + + def transform(da: sc.DataArray) -> sc.DataArray: + return da.fold(dim='pixel', sizes={'x': 4, 'y': 5}) + + view = raw.LogicalView( + transform=transform, + input_sizes={'pixel': 20}, + ) + + indices = view.input_indices() + + # Dense indices should have same total size as output shape + assert indices.sizes == {'x': 4, 'y': 5} + assert indices.data.size == 20 + + +class TestRollingDetectorViewWithLogicalView: + """Tests for RollingDetectorView integration with LogicalView.""" def test_as_projection(self) -> None: - """Test that RollingDetectorView works with LogicalDownsampler as projection.""" + """Test that RollingDetectorView works with LogicalView as projection.""" # Create a 1D detector with 8 pixels detector_number = sc.arange('x_pixel_offset', 1, 9, unit=None) @@ -722,8 +802,8 @@ def transform(da: sc.DataArray) -> sc.DataArray: dim='x_pixel_offset', sizes={'x_pixel_offset': 4, 'x_bin': 2} ) - # Create RollingDetectorView with downsampler using factory method - view = raw.RollingDetectorView.with_logical_downsampler( + # Create RollingDetectorView with LogicalView using factory method + view = raw.RollingDetectorView.with_logical_view( detector_number=detector_number, window=2, transform=transform, @@ -742,7 +822,7 @@ def transform(da: sc.DataArray) -> sc.DataArray: assert result['x_pixel_offset', 3].value == 0 def test_make_roi_filter(self) -> None: - """Test that make_roi_filter() works with LogicalDownsampler.""" + """Test that make_roi_filter() works with LogicalView.""" detector_number = sc.arange('x_pixel_offset', 1, 9, unit=None) def transform(da: sc.DataArray) -> sc.DataArray: @@ -750,14 +830,14 @@ def transform(da: sc.DataArray) -> sc.DataArray: dim='x_pixel_offset', sizes={'x_pixel_offset': 4, 'x_bin': 2} ) - view = raw.RollingDetectorView.with_logical_downsampler( + view = raw.RollingDetectorView.with_logical_view( detector_number=detector_number, window=1, transform=transform, reduction_dim='x_bin', ) - # Should not raise - LogicalDownsampler has input_indices() + # Should not raise - LogicalView has input_indices() roi_filter = view.make_roi_filter() # The indices should be binned data (check via private attribute for now) @@ -767,7 +847,7 @@ def transform(da: sc.DataArray) -> sc.DataArray: assert all(roi_filter._indices.bins.size().values == 2) def test_transform_weights(self) -> None: - """Test that transform_weights() works with LogicalDownsampler.""" + """Test that transform_weights() works with LogicalView.""" detector_number = sc.arange('x_pixel_offset', 1, 9, unit=None) def transform(da: sc.DataArray) -> sc.DataArray: @@ -775,7 +855,7 @@ def transform(da: sc.DataArray) -> sc.DataArray: dim='x_pixel_offset', sizes={'x_pixel_offset': 4, 'x_bin': 2} ) - view = raw.RollingDetectorView.with_logical_downsampler( + view = raw.RollingDetectorView.with_logical_view( detector_number=detector_number, window=1, transform=transform, @@ -794,3 +874,50 @@ def transform(da: sc.DataArray) -> sc.DataArray: dims=['x_pixel_offset'], shape=[4], value=2.0, dtype='float32', unit='' ) assert sc.allclose(transformed.data, expected) + + def test_with_non_reducing_view(self) -> None: + """Test RollingDetectorView with LogicalView without reduction (slicing).""" + # 12 voxels that will be folded into 2x2x3 and sliced to front layer + detector_number = sc.arange('voxel', 1, 13, unit=None) + + def transform(da: sc.DataArray) -> sc.DataArray: + return da.fold(dim='voxel', sizes={'x': 2, 'y': 2, 'z': 3})['z', 0] + + view = raw.RollingDetectorView.with_logical_view( + detector_number=detector_number, + window=2, + transform=transform, + # No reduction_dim - pure transform + ) + + # Add counts for detector_numbers that map to front layer (z=0) + # z is innermost, so front layer indices are 0, 3, 6, 9 (every 3rd) + # detector_numbers are 1-indexed, so front layer det_nums are 1, 4, 7, 10 + view.add_counts([1, 4, 7, 10]) + result = view.get() + + assert result.sizes == {'x': 2, 'y': 2} + # Each front-layer pixel gets one count + expected = sc.array( + dims=['x', 'y'], values=[[1, 1], [1, 1]], dtype='int32', unit='counts' + ) + assert sc.identical(result.data, expected) + + def test_make_roi_filter_with_non_reducing_view(self) -> None: + """Test make_roi_filter with non-reducing LogicalView returns dense indices.""" + detector_number = sc.arange('voxel', 1, 13, unit=None) + + def transform(da: sc.DataArray) -> sc.DataArray: + return da.fold(dim='voxel', sizes={'x': 2, 'y': 2, 'z': 3})['z', 0] + + view = raw.RollingDetectorView.with_logical_view( + detector_number=detector_number, + window=1, + transform=transform, + ) + + roi_filter = view.make_roi_filter() + + # Indices should be dense (not binned) for non-reducing view + assert roi_filter._indices.bins is None + assert roi_filter._indices.sizes == {'x': 2, 'y': 2} From f55179cdf911a746d13dc2ff6a3355bc2e506267 Mon Sep 17 00:00:00 2001 From: Simon Heybrock <12912489+SimonHeybrock@users.noreply.github.com> Date: Tue, 25 Nov 2025 15:59:48 +0100 Subject: [PATCH 8/8] Apply suggestion from @SimonHeybrock --- src/ess/reduce/live/raw.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/ess/reduce/live/raw.py b/src/ess/reduce/live/raw.py index c76a93cb..f2f15fbd 100644 --- a/src/ess/reduce/live/raw.py +++ b/src/ess/reduce/live/raw.py @@ -144,8 +144,9 @@ class LogicalView: Implements a view by applying a user-defined transform (e.g., fold or slice operations) optionally followed by reduction (summing) over specified dimensions. - This provides a clean separation between "how to reshape/select pixels" (transform) - and "how to aggregate them" (sum over reduction dimensions). + Transformation and reduction must be specified separately for `LogicalView` to + construct a mapping from output indices to input indices. So `transform` must not + perform any reductions. This class provides both data transformation (__call__) and index mapping (input_indices) using the same transform, ensuring consistency for ROI filtering.