Skip to content
Open
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
32 changes: 28 additions & 4 deletions packages/essimaging/src/ess/imaging/normalization.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,31 @@
BackgroundSubtractedDetector,
DarkBackgroundRun,
FluxNormalizedDetector,
MeanDarkFrame,
NormalizedImage,
OpenBeamRun,
SampleRun,
UncertaintyBroadcastMode,
)


def average_dark_frames(
dark_frames: FluxNormalizedDetector[DarkBackgroundRun],

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we actually normalize the dark measurement by proton charge? If it never sees the neutrons, we could be introducing fluctuations that aren't actually there?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there is background from e.g. a neighouring instrument bleeding in through the cave walls, then fluctuations could be related to the proton charge after all?

-> assume to begin with that we don't need to normalize by proton charge but keep the option open to add it back in later.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds to me like you don't want to normalise by proton charge but by measurement duration. You need something that corresponds to an event rate, not a count. The proton charge norm does this but it depends on the source. So if you would measure a dark frame while the source is off, it wouldn't count under proton charge norm. But that makes no sense.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the measurement duration, the conclusion was that all 3 measurements will be recorded with the same frame rate (fps). So every frame has the exact same duration. That duration cancels out in the normalization so we actually don't need the exposure time of each frame.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For my understanding: Does this mean that every frame is normalised by the duration such that sum(dark_frame) does not depend on the number of dark frames recorded?

) -> MeanDarkFrame:
"""
Average the dark frames (background runs) over time to obtain a mean dark frame.

Parameters
----------
dark_frames:
Dark frames to average.
"""
return MeanDarkFrame(dark_frames.mean('time'))


def subtract_background_sample(
data: FluxNormalizedDetector[SampleRun],
background: FluxNormalizedDetector[DarkBackgroundRun],
background: MeanDarkFrame,
uncertainties: UncertaintyBroadcastMode,
) -> BackgroundSubtractedDetector[SampleRun]:
"""
Expand All @@ -42,7 +57,8 @@ def subtract_background_sample(

def subtract_background_openbeam(
data: FluxNormalizedDetector[OpenBeamRun],
background: FluxNormalizedDetector[DarkBackgroundRun],
background: MeanDarkFrame,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: Should we subtract the mean dark from each OB frame or should we subtract it from the mean OB? Are the resulting variances the same?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's also a question about the scales of float values. Are there significant truncation errors from subtracting the (large?) mean dark from each individual OB frame?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can safely assume that counts in the dark frame will always be lower than in open beam and sample. Open beam will probably have the highest counts.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But is sum(open_beam) >> sum(dark_frame)? In that case, you might get better accuracy by subtracting frame by frame.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also think we expect the dark frame to be more stable than the open beam. The dark is the inherent noise of the chip which is always there. The open beam can fluctuate due to external factors. So I would vote to subtract the dark from each OB frame and then take the mean OB after that.

uncertainties: UncertaintyBroadcastMode,
) -> BackgroundSubtractedDetector[OpenBeamRun]:
"""
Subtract background from proton-charge normalized open beam data.
Expand All @@ -53,8 +69,13 @@ def subtract_background_openbeam(
Open beam data to process (normalized to proton charge).
background:
Background (dark frame) data to subtract (normalized to proton charge).
uncertainties:
Mode to use when broadcasting uncertainties from background to data (data
typically has multiple frames, while background usually has only one frame).
"""
return BackgroundSubtractedDetector[OpenBeamRun](data - background)
return BackgroundSubtractedDetector[OpenBeamRun](
data - broadcast_uncertainties(background, prototype=data, mode=uncertainties)
)


def sample_over_openbeam(
Expand All @@ -78,11 +99,14 @@ def sample_over_openbeam(
"""
return NormalizedImage(
sample
/ broadcast_uncertainties(open_beam, prototype=sample, mode=uncertainties)
/ broadcast_uncertainties(
open_beam.mean('time'), prototype=sample, mode=uncertainties
)
)


providers = (
average_dark_frames,
subtract_background_sample,
subtract_background_openbeam,
sample_over_openbeam,
Expand Down
8 changes: 4 additions & 4 deletions packages/essimaging/src/ess/imaging/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,13 @@ class BackgroundSubtractedDetector(sciline.Scope[RunType, sc.DataArray], sc.Data
"""Detector counts with dark background subtracted."""


MeanDarkFrame = NewType("MeanDarkFrame", sc.DataArray)
"""Mean dark frame: average of background (dark) frames."""


NormalizedImage = NewType("NormalizedImage", sc.DataArray)
"""Final image: background-subtracted sample run divided by background-subtracted open
beam run."""


class ExposureTime(sciline.Scope[RunType, sc.DataArray], sc.DataArray):
"""Exposure time of each frame recorded by the camera detector."""


del sc, sciline, NewType
8 changes: 4 additions & 4 deletions packages/essimaging/src/ess/tbl/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,16 @@

_registry = make_registry(
'ess/tbl',
version="2",
version="3",
files={
"tbl_sample_data_2025-03.hdf": "md5:12db6bc06721278b3abe47992eac3e77",
"TBL-wavelength-lookup-table-no-choppers-5m-35m.h5": "md5:e28793b7e1c12986ee63a1df68723268", # noqa: E501
'tbl-orca-focussing.hdf.zip': Entry(
alg='md5', chk='f365acd9ea45dd205c0b9398d163cfa4', unzip=True
),
"ymir_lego_dark_run.hdf": "md5:c0ed089dd7663986042e29fb47514130",
"ymir_lego_openbeam_run.hdf": "md5:00375becd54d2ed3be096413dc30883c",
"ymir_lego_sample_run.hdf": "md5:ae56a335cf3d4e87ef090ec4e51da69c",
"ymir_lego_dark_run.hdf": "md5:2d9ae7d6f1d0502c17b0030bcb3dad1d",

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had to update the data because the proton charge in the old files started exactly at the start of the time axis for the camera data. However, when doing sc.lookup in 'previous' mode, the first data point was not found (probably because of numerical precision errors) and a NaN was returned.

So the first data slice in the dark lookup was NaN. We then took the mean which gave NaN, and then all data was NaN.
I now updated the proton charge logs to start a bit before the data starts.

"ymir_lego_openbeam_run.hdf": "md5:f55e6064a2602de404c535f72beb25a3",
"ymir_lego_sample_run.hdf": "md5:d3eb65d9b5f8b90777059ec2638297ab",
},
)

Expand Down
116 changes: 19 additions & 97 deletions packages/essimaging/src/ess/tbl/orca.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,19 +7,13 @@
import sciline as sl
import scipp as sc

from ess.reduce.nexus import GenericNeXusWorkflow, load_from_path
from ess.reduce.nexus.types import (
NeXusDetectorName,
NeXusFileSpec,
NeXusLocationSpec,
NeXusName,
)
from ess.reduce.nexus import GenericNeXusWorkflow
from ess.reduce.nexus.types import NeXusDetectorName

from .. import imaging
from ..imaging.types import (
CorrectedDetector,
DarkBackgroundRun,
ExposureTime,
FluxNormalizedDetector,
OpenBeamRun,
ProtonCharge,
Expand All @@ -28,117 +22,45 @@
)


def load_exposure_time(
file: NeXusFileSpec[RunType], path: NeXusName[ExposureTime[RunType]]
) -> ExposureTime[RunType]:
# Note that putting '/value' at the end of the 'path' in the default_parameters
# yields different results as it can return a Variable instead of a DataArray,
# depending on the contents of the NeXus file.
return ExposureTime[RunType](
load_from_path(NeXusLocationSpec(filename=file.value, component_name=path))[
"value"
].squeeze()
)


def _compute_proton_charge_per_exposure(
data: sc.DataArray, proton_charge: sc.DataArray, exposure_time: sc.DataArray
) -> sc.DataArray:
# A note on timings:
# We want to sum the proton charge inside each time bin (defined by the duration of
# each frame). However, the time dimension of the data recorded at the detector is
# not the same time as the proton charge (which is when the protons hit the
# target). We need to shift the proton charge time to account for the time it takes
# for neutrons to travel from the target to the detector. Does this mean we cannot
# do the normalization without computing time of flight?

t = data.coords['time']
exp = exposure_time.data.to(unit=t.unit)
# The following assumes that the different between successive frames (time stamps)
# is larger than the exposure time. We need to check that this is indeed the case.
if (t[1:] - t[:-1]).min() < exp:
raise ValueError(
"normalize_by_proton_charge_orca: Exposure time is larger than the "
"smallest time between successive frames."
)

bins = sc.sort(sc.concat([t, t + exp], dim=t.dim), t.dim)
# We select every second bin, as the odd bins lie between the end of the exposure
# of one frame and the start of the next frame.
return proton_charge.hist(time=bins).data[::2]


def normalize_by_proton_charge_orca(
data: CorrectedDetector[RunType],
proton_charge: ProtonCharge[RunType],
exposure_time: ExposureTime[RunType],
data: CorrectedDetector[RunType], proton_charge: ProtonCharge[RunType]
) -> FluxNormalizedDetector[RunType]:
"""
Normalize detector data by the proton charge (dark and open beam runs).
We find the time stamps for the data, which mark the start of an exposure.
We sum the proton charge within the intervals timestamp to timestamp +
exposure_time.
We then sum the data along the time dimension, and divide by the sum of the proton
charge accumulated during each exposure.
Normalize detector data by the proton charge.
We find the time stamps for the data, which mark the start of an exposure,
find the corresponding proton charge for each time stamp, and divide the data by
the proton charge.

Parameters
----------
data:
Corrected detector data to be normalized.
proton_charge:
Proton charge data for normalization.
exposure_time:
Exposure time for each image in the data.
"""

charge_per_frame = _compute_proton_charge_per_exposure(
data, proton_charge, exposure_time
)

return FluxNormalizedDetector[RunType](
data.sum('time') / charge_per_frame.sum('time')
)

# A note on timings:
# We want to sum the proton charge inside each time bin (defined by the duration of
# each frame). However, the time dimension of the data recorded at the detector is
# not the same time as the proton charge (which is when the protons hit the
# target). We need to shift the proton charge time to account for the time it takes
# for neutrons to travel from the target to the detector. Does this mean we cannot
# do the normalization without computing time of flight?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think that is what this implies. Or is ODIN short enough that we can just use the 'time' coord from the nexus data?

What is the 'time' coord in data here? Is that the time at the source?


def normalize_by_proton_charge_orca_sample(
data: CorrectedDetector[SampleRun],
proton_charge: ProtonCharge[SampleRun],
exposure_time: ExposureTime[SampleRun],
) -> FluxNormalizedDetector[SampleRun]:
"""
Normalize sample run detector data by the proton charge.
The handling of the SampleRun is different from the other runs:
The sample may have a time dimension representing multiple frames.
In this case, we need to sum the proton charge for each frame separately.
proton_charge_lookup = sc.lookup(proton_charge, 'time', mode='previous')

Parameters
----------
data:
Corrected detector data to be normalized.
proton_charge:
Proton charge data for normalization.
"""

charge_per_frame = _compute_proton_charge_per_exposure(
data, proton_charge, exposure_time
return FluxNormalizedDetector[RunType](
data / proton_charge_lookup[data.coords['time']]
)

# Here we preserve the time dimension of the sample data and the proton charge.
return FluxNormalizedDetector[SampleRun](data / charge_per_frame)


providers = (
load_exposure_time,
normalize_by_proton_charge_orca,
normalize_by_proton_charge_orca_sample,
)
orca_providers = (normalize_by_proton_charge_orca,)


def default_parameters() -> dict:
"""Return the default NeXus names and detector name for the ORCA workflow."""
return {
NeXusDetectorName: 'orca_detector',
NeXusName[ExposureTime]: '/entry/instrument/orca_detector/camera_exposure',
}


Expand All @@ -156,7 +78,7 @@ def OrcaNormalizedImagesWorkflow(**kwargs) -> sl.Pipeline:
for provider in (
*imaging.normalization.providers,
*imaging.masking.providers,
*providers,
*orca_providers,
):
wf.insert(provider)
for key, param in default_parameters().items():
Expand Down
27 changes: 14 additions & 13 deletions packages/essimaging/tests/tbl/orca_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
Filename,
FluxNormalizedDetector,
MaskingRules,
MeanDarkFrame,
NeXusDetectorName,
NormalizedImage,
OpenBeamRun,
Expand Down Expand Up @@ -73,28 +74,26 @@ def test_workflow_applies_masks(workflow, run):
assert da.sum().value > masked_da.sum().value


@pytest.mark.parametrize("run", [OpenBeamRun, DarkBackgroundRun])
@pytest.mark.parametrize("run", [OpenBeamRun, DarkBackgroundRun, SampleRun])
def test_workflow_normalizes_by_proton_charge(workflow, run):
da = workflow.compute(FluxNormalizedDetector[run])
# Dark and open beam runs have been averaged over time dimension
assert da.ndim == 2
assert "time" not in da.dims
# TODO: should it be "counts / uC"?
assert da.unit == "1 / uC"


def test_workflow_normalizes_sample_by_proton_charge(workflow):
da = workflow.compute(FluxNormalizedDetector[SampleRun])
# Sample run retains time dimension
assert da.ndim == 3
assert "time" in da.dims
# TODO: should it be "counts / uC"?
assert da.unit == "1 / uC"


def test_workflow_computes_mean_dark_frame(workflow):
dark_frames = workflow.compute(FluxNormalizedDetector[DarkBackgroundRun])
mean_dark_frame = workflow.compute(MeanDarkFrame)
assert mean_dark_frame.ndim == 2
assert "time" not in mean_dark_frame.dims
assert mean_dark_frame.unit == dark_frames.unit


@pytest.mark.parametrize("run", [OpenBeamRun, SampleRun])
def test_workflow_subtracts_dark_background(workflow, run):
background = workflow.compute(FluxNormalizedDetector[DarkBackgroundRun])
background = workflow.compute(MeanDarkFrame)
before = workflow.compute(FluxNormalizedDetector[run])
after = workflow.compute(BackgroundSubtractedDetector[run])

Expand All @@ -106,5 +105,7 @@ def test_workflow_computes_normalized_image(workflow):
open_beam = workflow.compute(BackgroundSubtractedDetector[OpenBeamRun])
normalized = workflow.compute(NormalizedImage)

assert_identical(sc.values(normalized), sc.values(sample) / sc.values(open_beam))
assert_identical(
sc.values(normalized), sc.values(sample) / sc.values(open_beam.mean('time'))
)
assert normalized.unit == sc.units.dimensionless
Loading
Loading