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
4 changes: 2 additions & 2 deletions .github/workflows/test_and_deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,14 @@ jobs:
run: |
pixi run versioningit -w
git submodule update --init
pixi run test -vv -m "not mount_eqsans" --cov=src --cov-report=xml:unit_test_coverage.xml --cov-report=term-missing --junitxml=unit_test_results.xml tests/unit/
pixi run unit-test
mv .coverage .coverage.unit
- name: Run integration tests
run: |
# TODO: change back to running tests in parallel after the mantid memory leak has been patched (EWM 14083)
# pixi run test -vv -m "not mount_eqsans" --dist loadscope -n 2 --cov=src --cov-report=xml:integration_test_coverage.xml --cov-report=term-missing --junitxml=integration_test_results.xml tests/integration/
pixi run test -vv -m "not mount_eqsans" --cov=src --cov-report=xml:integration_test_coverage.xml --cov-report=term-missing --junitxml=integration_test_results.xml tests/integration/
pixi run integration-test
mv .coverage .coverage.integration
- name: Upload coverage to codecov
Expand Down
11 changes: 8 additions & 3 deletions pixi.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,8 @@ build-docs = { cmd = 'sphinx-build -b html docs docs/_build/html', description =
test-docs = { cmd = "sphinx-build -M doctest docs docs/_build/html", description = "Test building the documentation" }
# Testing
test = { description = "Run the test suite", cmd = "pytest" }
unit-test = { description = "Run the unit tests", cmd = "pytest -vv -m 'not mount_eqsans' --cov=src --cov-report=xml:unit_test_coverage.xml --cov-report=term-missing --junitxml=unit_test_results.xml tests/unit/" }
integration-test = { description = "Run the integration tests", cmd = "pytest -vv -m 'not mount_eqsans' --cov=src --cov-report=xml:integration_test_coverage.xml --cov-report=term-missing --junitxml=integration_test_results.xml tests/integration/" }
# Packaging
conda-build-command = { cmd = "pixi build", description = "Wrapper for building the conda package - used by `conda-build`" }
conda-build = { description = "Build the conda package", depends-on = [
Expand Down
128 changes: 127 additions & 1 deletion src/drtsans/chopper.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,136 @@
settings such as aperture and starting phase.
"""

from dataclasses import dataclass, field
from typing import Any
from drtsans.frame_mode import FrameMode
from drtsans.wavelength import Wband, Wbands


class DiskChopper(object):
class DiskChopperSetConfigurationParsingError(Exception):
"""Raised when there is an error parsing the disk chopper set configuration from JSON."""


@dataclass(frozen=True)
class DiskChopperSetConfiguration:
"""Configuration for a set of disk choppers.

Attributes
----------
n_choppers: int
Number of single disk choppers in the set.
aperture: list[float]
List of transmission aperture widths (in degrees) for each chopper.
to_source: list[float]
List of distances to the neutron source (in meters) for each chopper.
offsets: dict[FrameMode, list[float]]
Dictionary mapping frame modes to lists of offset phases (in micro seconds) for each chopper.
These values are required to calibrate the value reported in the metadata. The combination on
the reported phase and this offset is the time (starting from the current pulse) at which the
middle of the choppers apertures will intersect with the neutron beam axis.
"""

n_choppers: int
aperture: list[float] = field(default_factory=list)
to_source: list[float] = field(default_factory=list)
offsets: dict[FrameMode, list[float]] = field(default_factory=dict)

@classmethod
def from_json(cls, json_config: Any, target_daystamp: int) -> "DiskChopperSetConfiguration":
"""Get chopper configuration from JSON object based on daystamp.

Selects the configuration with the largest daystamp that is less than or equal to
the target daystamp.

Parameters
----------
json_config
JSON configuration
target_daystamp
8-digit integer whose digits are to be understood as YYYYMMDD (e.g., 20260209)

Returns
-------
DiskChopperSetConfiguration
The configuration object matching the target daystamp

Raises
------
DiskChopperSetConfigurationParsingError
If there is an error parsing the JSON object or if no valid configuration is found for the target daystamp
"""
# Find all entries that have the required keys
required = {
"n_choppers",
"aperture",
"to_source",
"offsets",
"daystamp",
}
valid_configs = [entry for entry in json_config if required.issubset(set([str(v) for v in entry.keys()]))]
if not valid_configs:
raise DiskChopperSetConfigurationParsingError("No valid configuration entries found")

# Find all configurations with daystamp <= target_daystamp
valid_configs = [cfg for cfg in valid_configs if cfg.get("daystamp", 0) <= target_daystamp]
if not valid_configs:
raise DiskChopperSetConfigurationParsingError(
f"No valid configuration found on or before daystamp {target_daystamp}"
)

# Select the configuration with the largest daystamp
selected = max(valid_configs, key=lambda x: x.get("daystamp", 0))

# Parse and validate n_choppers
try:
n_choppers = int(selected["n_choppers"])
if n_choppers <= 0:
raise ValueError("n_choppers must be a positive integer")
except (ValueError, TypeError) as e:
raise DiskChopperSetConfigurationParsingError(f"Invalid n_choppers value '{selected['n_choppers']}': {e}")

# Parse and validate aperture
try:
aperture = [float(x) for x in selected["aperture"]]
if len(aperture) != n_choppers:
raise ValueError(f"aperture list length ({len(aperture)}) does not match n_choppers ({n_choppers})")
except (ValueError, TypeError) as e:
raise DiskChopperSetConfigurationParsingError(f"Invalid aperture value '{selected['aperture']}': {e}")

# Parse and validate to_source
try:
to_source = [float(x) for x in selected["to_source"]]
if len(to_source) != n_choppers:
raise ValueError(f"to_source list length ({len(to_source)}) does not match n_choppers ({n_choppers})")
except (ValueError, TypeError) as e:
raise DiskChopperSetConfigurationParsingError(f"Invalid to_source value '{selected['to_source']}': {e}")

# Parse offsets from strings to FrameMode enums and validate
offsets_dict = {}
for mode_str, values in selected["offsets"].items():
try:
mode = FrameMode[mode_str] # Convert string to FrameMode enum
offset_values = [float(x) for x in values]
if len(offset_values) != n_choppers:
raise ValueError(
f"offsets list length ({len(offset_values)}) for mode '{mode_str}' "
f"does not match n_choppers ({n_choppers})"
)
offsets_dict[mode] = offset_values
except KeyError:
raise DiskChopperSetConfigurationParsingError(f"Invalid frame mode '{mode_str}' in offsets")
except (ValueError, TypeError) as e:
raise DiskChopperSetConfigurationParsingError(f"Invalid offsets value for mode '{mode_str}': {e}")

return cls(
n_choppers=n_choppers,
aperture=aperture,
to_source=to_source,
offsets=offsets_dict,
)


class DiskChopper:
r"""
Rotating disk chopper with an aperture of a certain width letting neutrons through.

Expand Down
22 changes: 22 additions & 0 deletions src/drtsans/configuration/EQSANS_chopper_configurations.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
[
{
"daystamp": 20000101,
"n_choppers": 4,
"aperture": [129.605, 179.989, 230.010, 230.007],
"to_source": [5.700, 7.800, 9.497, 9.507],
"offsets": {
"not_skip": [9507.0, 9471.0, 9829.7, 9584.3],
"skip": [19024.0, 18820.0, 19714.0, 19360.0]
}
},
{
"daystamp": 20260101,
"n_choppers": 6,
"aperture": [129.600, 180.000, 230.010, 230.007, 129.600, 180.000],
"to_source": [5.7178, 7.7998, 9.4998, 9.5058, 5.7238, 7.8058],
"offsets": {
"not_skip": [9507.0, 9471.0, 9829.7, 9584.3, 0.0, 0.0],
"skip": [19024.0, 18820.0, 19714.0, 19360.0, 0.0, 0.0]
}
}
]
91 changes: 65 additions & 26 deletions src/drtsans/tof/eqsans/chopper.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,26 @@
r"""
This module provides class `EQSANSDiskChopperSet` representing the set of four disk choppers
(two of them paired as a double chopper). The main goal is to find the set of neutron wavelength
This module provides class `EQSANSDiskChopperSet` representing the set of disk choppers.
Prior to 2026, EQSANS had four disk choppers (two of them paired as a double chopper).
Starting in 2026, EQSANS has six disk choppers (three double choppers).
The main goal of the module is to find the set of neutron wavelength
bands transmitted by the chopper set, given definite choppers settings such as aperture and starting phase.
"""

from drtsans.chopper import DiskChopper
import importlib
import json

import numpy as np
from drtsans.chopper import DiskChopper, DiskChopperSetConfiguration
from drtsans.samplelogs import SampleLogs
from drtsans.frame_mode import FrameMode
from drtsans.path import exists
from mantid.api import Run
from mantid.simpleapi import LoadNexusProcessed, mtd

from drtsans.wavelength import Wbands


class EQSANSDiskChopperSet(object):
class EQSANSDiskChopperSet:
r"""
Set of disks choppers installed in EQSANS.

Expand All @@ -32,23 +40,6 @@ class EQSANSDiskChopperSet(object):
#: expressed as the maximum wavelength. This is the default cut-off maximum wavelength, in Angstroms.
_cutoff_wl = 35

#: number of single-disk choppers in the set.
_n_choppers = 4

#: Transmission aperture of the choppers, in degrees.
_aperture = [129.605, 179.989, 230.010, 230.007]

#: Distance to neutron source (moderator), in meters.
_to_source = [5.700, 7.800, 9.497, 9.507]

#: Phase offsets, in micro-seconds. These values are required to calibrate the value reported in the
#: metadata. The combination on the reported phase and this offset is the time (starting from the
#: current pulse) at which the middle of the choppers apertures will intersect with the neutron beam axis.
_offsets = {
FrameMode.not_skip: [9507.0, 9471.0, 9829.7, 9584.3],
FrameMode.skip: [19024.0, 18820.0, 19714.0, 19360.0],
}

def __init__(self, other):
# Load choppers settings from the logs
if isinstance(other, Run) or str(other) in mtd:
Expand All @@ -59,6 +50,9 @@ def __init__(self, other):
else:
raise RuntimeError("{} is not a valid file name, workspace, Run object or run number".format(other))

# Get the chopper configuration (4 or 6 choppers)
self.chopper_config: DiskChopperSetConfiguration = self.get_chopper_configuration(sample_logs.start_time.value)

self._choppers = list()
for chopper_index in range(self._n_choppers):
aperture = self._aperture[chopper_index]
Expand All @@ -81,7 +75,7 @@ def __init__(self, other):
ch = self._choppers[chopper_index]
ch.offset = self._offsets[self.frame_mode][chopper_index]

def transmission_bands(self, cutoff_wl=None, delay=0, pulsed=False):
def transmission_bands(self, cutoff_wl: float = None, delay: float = 0, pulsed: bool = False) -> Wbands:
r"""
Wavelength bands transmitted by the chopper apertures. The number of bands is determined by the
slowest neutrons emitted from the moderator.
Expand All @@ -105,16 +99,45 @@ def transmission_bands(self, cutoff_wl=None, delay=0, pulsed=False):
"""
if cutoff_wl is None:
cutoff_wl = self._cutoff_wl
# Filter out the choppers with zero speed, which do not contribute to the transmission bands
moving_choppers = [ch for ch in self._choppers if not np.isclose(ch.speed, 0.0)]
if not moving_choppers:
return Wbands()
# Transmission bands of the first chopper
ch = self._choppers[0]
wb = ch.transmission_bands(cutoff_wl, delay, pulsed)
wb = moving_choppers[0].transmission_bands(cutoff_wl, delay, pulsed)
# Find the common transmitted bands between the first chopper
# and the ensuing choppers
for ch in self._choppers[1:]:
wb *= ch.transmission_bands(cutoff_wl, delay, pulsed)
for ch in moving_choppers[1:]:
wb_other = ch.transmission_bands(cutoff_wl, delay, pulsed)
wb *= wb_other
# We end up with the transmission bands of the chopper set
return wb

def get_chopper_configuration(self, start_time: str) -> DiskChopperSetConfiguration:
r"""
Get the chopper configuration (number of choppers, apertures, distances to source, offsets)
from the JSON configuration file based on the log "start_time".

Parameters
----------
start_time
String representing the run start time in the format: "YYYY-MM-DDThh:mm:ssZ"

Returns
-------
DiskChopperSetConfiguration
Configuration of the disk choppers.
"""
# Get daystamp from sample logs (format: YYYYMMDD)
start_time_str = start_time[0:10] # "YYYY-MM-DD"
daystamp = int(start_time_str.replace("-", "")) # Convert to YYYYMMDD integer

# Load configuration from JSON file
with importlib.resources.open_text("drtsans.configuration", "EQSANS_chopper_configurations.json") as file:
configs = json.load(file)

return DiskChopperSetConfiguration.from_json(configs, daystamp)

@property
def period(self):
return self._choppers[0].period
Expand All @@ -125,3 +148,19 @@ def __getitem__(self, item):
@property
def pulse_width(self):
return self._pulse_width

@property
def _n_choppers(self):
return self.chopper_config.n_choppers

@property
def _aperture(self):
return self.chopper_config.aperture

@property
def _to_source(self):
return self.chopper_config.to_source

@property
def _offsets(self):
return self.chopper_config.offsets
Loading