Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions src/ess/livedata/config/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,26 @@

class WeightingMethod(StrEnum):
"""
Methods for pixel weighting.
How the weight of an image pixel is computed.

- PIXEL_NUMBER: Weight by the number of detector pixels contributing to each screen
pixel.
- PIXEL_NUMBER: The number of detector pixels that map to the image pixel.
"""

PIXEL_NUMBER = 'pixel_number'


class PixelWeighting(BaseModel):
"""Setting for pixel weighting."""
"""Divides a detector image by a weight per image pixel."""

enabled: bool = Field(default=False, description="Enable pixel weighting.")
enabled: bool = Field(
default=True,
description="Divide each image pixel by its weight, so that the image shows "
"counts per detector pixel.",
)
method: WeightingMethod = Field(
default=WeightingMethod.PIXEL_NUMBER, description="Method for pixel weighting."
default=WeightingMethod.PIXEL_NUMBER,
description="How the weight of an image pixel is computed. 'pixel_number': "
"the number of detector pixels that map to the image pixel.",
)


Expand Down
6 changes: 1 addition & 5 deletions src/ess/livedata/workflows/detector_view/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,6 @@ def make_workflow(
bins = params.get_active_edges()
histogram_slice = params.get_active_range()

# Get pixel weighting setting from params
use_pixel_weighting = params.pixel_weighting.enabled

# Create base workflow with appropriate mode
workflow = create_base_workflow(
bins=bins,
Expand All @@ -163,8 +160,7 @@ def make_workflow(
# Configure detector data source (EmptyDetector)
self._data_source.configure_workflow(workflow, source_name)

# Set pixel weighting configuration
workflow[UsePixelWeighting] = use_pixel_weighting
workflow[UsePixelWeighting] = params.pixel_weighting.enabled

# Add projection based on config type
config = self._get_config(source_name)
Expand Down
5 changes: 3 additions & 2 deletions src/ess/livedata/workflows/detector_view/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -278,9 +278,10 @@ def detector_image(
histogram_slice:
Optional (low, high) range for slicing. If None, sum over full range.
weights:
Pixel weights for normalization.
Number of detector pixels per image pixel.
use_weighting:
Whether to apply pixel weighting.
Whether to divide the image by ``weights``, giving counts per detector
pixel. Screen pixels without detector pixels then become 0/0 = NaN.

Returns
-------
Expand Down
5 changes: 2 additions & 3 deletions src/ess/livedata/workflows/detector_view/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@
LogicalTransform,
ProjectionType,
ReductionDim,
UsePixelWeighting,
)


Expand All @@ -77,7 +76,8 @@ def create_base_workflow(
Create the base detector view workflow.

This creates the core workflow with all providers. The Projector param
must be set separately via add_geometric_projection or add_logical_projection.
must be set separately via add_geometric_projection or add_logical_projection,
and UsePixelWeighting by the caller.

Parameters
----------
Expand Down Expand Up @@ -116,7 +116,6 @@ def create_base_workflow(

# Add pixel weighting provider
workflow.insert(compute_pixel_weights)
workflow[UsePixelWeighting] = False # Default: disabled

# Add histogram and downstream providers (generic providers for both modes).
# detector_geometry stamps the move-detection coord; file-less sources override
Expand Down
20 changes: 15 additions & 5 deletions src/ess/livedata/workflows/detector_view_specs.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,11 +92,20 @@ class DetectorViewParamsBase(pydantic.BaseModel, abc.ABC):
)
pixel_weighting: models.PixelWeighting = pydantic.Field(
title="Pixel Weighting",
description="Whether to apply pixel weighting based on the number of pixels "
"contributing to each screen pixel.",
default=models.PixelWeighting(
enabled=False, method=models.WeightingMethod.PIXEL_NUMBER
description=(
"Divide each image pixel by the number of detector pixels that map to "
"it, so that the image shows counts per detector pixel. In geometric "
"projections (xy-plane, cylinder) this number varies across the image "
"and is averaged over the position noise, so it can be fractional at "
"module edges. Without weighting, the image shows pixel density as "
"well as count rate. Image pixels that no detector pixel maps to, such "
"as gaps between detector modules, have no value (NaN). In logical "
"views every image pixel has the same number of detector pixels, so "
"weighting has no effect or divides the whole image by a constant. ROI "
"outputs stay summed counts. To show them per detector pixel, use "
"'Per Detector Pixel' in the plot settings."
),
default_factory=models.PixelWeighting,
)

@abc.abstractmethod
Expand Down Expand Up @@ -305,7 +314,8 @@ def _make_roi_spectra_template() -> sc.DataArray:
fields=('cumulative', 'current'),
description=(
'Detector image. With "since run start" shows accumulated counts; '
'with "latest update" or a window, shows recent counts.'
'with "latest update" or a window, shows recent counts. With pixel '
'weighting, counts are per detector pixel.'
),
params=(
'coordinate_mode',
Expand Down
6 changes: 5 additions & 1 deletion tests/config/dream_factories_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import scipp as sc

from ess.livedata.config.instruments.dream import specs
from ess.livedata.config.models import PixelWeighting
from ess.livedata.config.workflow_spec import JobId, WorkflowConfig
from ess.livedata.core.timestamp import Timestamp
from ess.livedata.workflows.workflow_factory import SpecHandle
Expand Down Expand Up @@ -63,7 +64,10 @@ def test_logical_view_counts_every_event(
identifier=handle.workflow_id,
job_id=JobId(source_name=source_name, job_number=uuid.uuid4()),
),
params=factory[handle.workflow_id].params(),
# Unweighted, so that the image holds raw counts and sums to the events.
params=factory[handle.workflow_id].params(
pixel_weighting=PixelWeighting(enabled=False)
),
)
workflow.accumulate(
{source_name: _events(source_name, size=1000)},
Expand Down
2 changes: 1 addition & 1 deletion tests/config/lut_streaming_end_to_end_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -571,7 +571,7 @@ def test_dream_prod_setpoints_empty_the_consumer(dream: Instrument) -> None:

assert not reply.has_error, reply.error_message
assert result.error_message is None, result.error_message
assert result.data['current'].sum().value == 0
assert result.data['current'].nansum().value == 0


@pytest.fixture(scope='module')
Expand Down
6 changes: 3 additions & 3 deletions tests/config/models_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ def test_weighting_method_values():

def test_pixel_weighting_defaults():
weight = models.PixelWeighting()
assert not weight.enabled
assert weight.enabled
assert weight.method == models.WeightingMethod.PIXEL_NUMBER


def test_pixel_weighting_custom():
weight = models.PixelWeighting(enabled=True)
assert weight.enabled
weight = models.PixelWeighting(enabled=False)
assert not weight.enabled


def test_pixel_weighting_invalid_method():
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ dummy/panel_0_layers/1:
coordinate_mode:
mode: toa
pixel_weighting:
enabled: false
enabled: true
method: pixel_number
toa_edges:
num_bins: 100
Expand Down Expand Up @@ -69,7 +69,7 @@ dummy/panel_0_xy/1:
coordinate_mode:
mode: toa
pixel_weighting:
enabled: false
enabled: true
method: pixel_number
toa_edges:
num_bins: 100
Expand Down
12 changes: 10 additions & 2 deletions tests/services/detector_data_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ def _get_workflow_from_registry(
raise ValueError(f"Namespace {namespace} not found in specs")


# Pixel weighting off, so that image sums equal the number of published events.
_UNWEIGHTED = {'pixel_weighting': {'enabled': False}}


def make_detector_app(instrument: str) -> LivedataApp:
builder = make_detector_service_builder(instrument=instrument)
return LivedataApp.from_service_builder(builder)
Expand Down Expand Up @@ -76,7 +80,9 @@ def test_can_configure_and_stop_detector_workflow(

source_name = detector_source_name[instrument]
workflow_config = workflow_spec.WorkflowConfig(
identifier=workflow_id, job_id=_job_id(source_name)
identifier=workflow_id,
job_id=_job_id(source_name),
params=_UNWEIGHTED,
)
# Trigger workflow start
app.publish_config_message(workflow_config)
Expand Down Expand Up @@ -150,7 +156,9 @@ def test_loki_cumulative_resets_when_detector_carriage_moves() -> None:

source_name = 'loki_detector_0'
workflow_config = workflow_spec.WorkflowConfig(
identifier=workflow_id, job_id=_job_id(source_name)
identifier=workflow_id,
job_id=_job_id(source_name),
params=_UNWEIGHTED,
)
app.publish_config_message(workflow_config)
service.step()
Expand Down
49 changes: 48 additions & 1 deletion tests/workflows/detector_view/factory_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,22 @@
# Copyright (c) 2025 Scipp contributors (https://github.com/scipp)
"""Tests for DetectorViewScilineFactory."""

import numpy as np
import pytest
import scipp as sc
from ess.reduce.nexus.types import RawDetector, SampleRun

from ess.livedata.config.models import PixelWeighting
from ess.livedata.core.timestamp import Timestamp
from ess.livedata.workflows.detector_view.data_source import DetectorNumberSource
from ess.livedata.workflows.detector_view.factory import DetectorViewFactory
from ess.livedata.workflows.detector_view.types import (
GeometricViewConfig,
LogicalViewConfig,
)
from ess.livedata.workflows.detector_view_specs import DetectorViewParams

from .utils import make_fake_detector_number
from .utils import make_fake_detector_number, make_fake_nexus_detector_data


class TestDetectorViewScilineFactory:
Expand Down Expand Up @@ -66,3 +72,44 @@ def transform(da: sc.DataArray, source_name: str) -> sc.DataArray:
assert factory is not None
assert isinstance(factory._get_config('source_a'), LogicalViewConfig)
assert isinstance(factory._get_config('source_b'), GeometricViewConfig)


@pytest.mark.parametrize(
('reduction_dim', 'image_sizes', 'pixels_per_image_pixel'),
[(None, {'y': 4, 'x': 4}, 1), ('y', {'x': 4}, 4)],
)
@pytest.mark.parametrize('enabled', [True, False])
def test_logical_view_image_with_pixel_weighting(
reduction_dim: str | None,
image_sizes: dict[str, int],
pixels_per_image_pixel: int,
enabled: bool,
) -> None:
n_events_per_pixel = 10

def transform(da: sc.DataArray, source_name: str) -> sc.DataArray:
return da.fold(dim='detector_number', sizes={'y': 4, 'x': 4})

factory = DetectorViewFactory(
data_source=DetectorNumberSource(make_fake_detector_number(4, 4)),
view_config=LogicalViewConfig(
transform=transform, reduction_dim=reduction_dim, roi_support=False
),
)
params = DetectorViewParams(pixel_weighting=PixelWeighting(enabled=enabled))
workflow = factory.make_workflow('detector', params=params)
workflow.build()
events = make_fake_nexus_detector_data(
y_size=4, x_size=4, n_events_per_pixel=n_events_per_pixel
)
workflow.accumulate(
{'detector': RawDetector[SampleRun](events)},
start_time=Timestamp.from_ns(1000),
end_time=Timestamp.from_ns(2000),
)

image = workflow.finalize()['cumulative']

assert image.sizes == image_sizes
expected = n_events_per_pixel * (1 if enabled else pixels_per_image_pixel)
np.testing.assert_array_equal(image.values, expected)
Loading