diff --git a/src/dodal/beamlines/i22.py b/src/dodal/beamlines/i22.py index 80797c64ef..264982f531 100644 --- a/src/dodal/beamlines/i22.py +++ b/src/dodal/beamlines/i22.py @@ -11,9 +11,13 @@ ) from dodal.common.beamlines.beamline_utils import set_beamline as set_utils_beamline from dodal.common.beamlines.device_helpers import numbered_slits +from dodal.common.crystal_metadata import ( + MaterialsEnum, + make_crystal_metadata_from_material, +) from dodal.common.visit import RemoteDirectoryServiceClient, StaticVisitPathProvider from dodal.devices.focusing_mirror import FocusingMirror -from dodal.devices.i22.dcm import CrystalMetadata, DoubleCrystalMonochromator +from dodal.devices.i22.dcm import DoubleCrystalMonochromator from dodal.devices.i22.fswitch import FSwitch from dodal.devices.i22.nxsas import NXSasMetadataHolder, NXSasOAV, NXSasPilatus from dodal.devices.linkam3 import Linkam3 @@ -170,17 +174,12 @@ def dcm( fake_with_ophyd_sim, bl_prefix=False, temperature_prefix=f"{BeamlinePrefix(BL).beamline_prefix}-DI-DCM-01:", - crystal_1_metadata=CrystalMetadata( - usage="Bragg", - type="silicon", - reflection=(1, 1, 1), - d_spacing=(3.13475, "nm"), + crystal_1_metadata=make_crystal_metadata_from_material( + MaterialsEnum.Si, (1, 1, 1) ), - crystal_2_metadata=CrystalMetadata( - usage="Bragg", - type="silicon", - reflection=(1, 1, 1), - d_spacing=(3.13475, "nm"), + crystal_2_metadata=make_crystal_metadata_from_material( + MaterialsEnum.Si, + (1, 1, 1), ), ) diff --git a/src/dodal/beamlines/p38.py b/src/dodal/beamlines/p38.py index 07bf0d7e80..21f0112913 100644 --- a/src/dodal/beamlines/p38.py +++ b/src/dodal/beamlines/p38.py @@ -10,9 +10,13 @@ ) from dodal.common.beamlines.beamline_utils import set_beamline as set_utils_beamline from dodal.common.beamlines.device_helpers import numbered_slits +from dodal.common.crystal_metadata import ( + MaterialsEnum, + make_crystal_metadata_from_material, +) from dodal.common.visit import LocalDirectoryServiceClient, StaticVisitPathProvider from dodal.devices.focusing_mirror import FocusingMirror -from dodal.devices.i22.dcm import CrystalMetadata, DoubleCrystalMonochromator +from dodal.devices.i22.dcm import DoubleCrystalMonochromator from dodal.devices.i22.fswitch import FSwitch from dodal.devices.linkam3 import Linkam3 from dodal.devices.slits import Slits @@ -227,17 +231,11 @@ def dcm( fake_with_ophyd_sim, bl_prefix=False, temperature_prefix=f"{BeamlinePrefix(BL).beamline_prefix}-DI-DCM-01:", - crystal_1_metadata=CrystalMetadata( - usage="Bragg", - type="silicon", - reflection=(1, 1, 1), - d_spacing=(3.13475, "nm"), + crystal_1_metadata=make_crystal_metadata_from_material( + MaterialsEnum.Si, (1, 1, 1) ), - crystal_2_metadata=CrystalMetadata( - usage="Bragg", - type="silicon", - reflection=(1, 1, 1), - d_spacing=(3.13475, "nm"), + crystal_2_metadata=make_crystal_metadata_from_material( + MaterialsEnum.Si, (1, 1, 1) ), ) diff --git a/src/dodal/common/crystal_metadata.py b/src/dodal/common/crystal_metadata.py new file mode 100644 index 0000000000..836c69d01e --- /dev/null +++ b/src/dodal/common/crystal_metadata.py @@ -0,0 +1,61 @@ +import math +from dataclasses import dataclass +from enum import Enum +from typing import Literal + + +@dataclass(frozen=True) +class Material: + """ + Class representing a crystalline material with a specific lattice parameter. + """ + + name: str + lattice_parameter: float # Lattice parameter in meters + + +class MaterialsEnum(Enum): + Si = Material(name="silicon", lattice_parameter=5.4310205e-10) + Ge = Material(name="germanium", lattice_parameter=5.6575e-10) + + +@dataclass(frozen=True) +class CrystalMetadata: + """ + Metadata used in the NeXus format, + see https://manual.nexusformat.org/classes/base_classes/NXcrystal.html + """ + + usage: Literal["Bragg", "Laue"] + type: str + reflection: tuple[int, int, int] + d_spacing: tuple[float, str] + + @staticmethod + def calculate_default_d_spacing( + lattice_parameter: float, reflection: tuple[int, int, int] + ) -> tuple[float, str]: + """ + Calculates the d-spacing value in nanometers based on the given lattice parameter and reflection indices. + """ + h_index, k_index, l_index = reflection + d_spacing_m = lattice_parameter / math.sqrt( + h_index**2 + k_index**2 + l_index**2 + ) + d_spacing_nm = d_spacing_m * 1e9 # Convert meters to nanometers + return round(d_spacing_nm, 5), "nm" + + +def make_crystal_metadata_from_material( + material: MaterialsEnum, + reflection_plane: tuple[int, int, int], + usage: Literal["Bragg", "Laue"] = "Bragg", + d_spacing_param: tuple[float, str] | None = None, +): + d_spacing = d_spacing_param or CrystalMetadata.calculate_default_d_spacing( + material.value.lattice_parameter, reflection_plane + ) + assert all( + isinstance(i, int) and i > 0 for i in reflection_plane + ), "Reflection plane indices must be positive integers" + return CrystalMetadata(usage, material.value.name, reflection_plane, d_spacing) diff --git a/src/dodal/devices/dcm.py b/src/dodal/devices/dcm.py index cb303ce617..79652504c9 100644 --- a/src/dodal/devices/dcm.py +++ b/src/dodal/devices/dcm.py @@ -1,7 +1,15 @@ -from ophyd_async.core import StandardReadable +import numpy as np +from numpy.typing import NDArray +from ophyd_async.core import StandardReadable, soft_signal_r_and_setter from ophyd_async.epics.motor import Motor from ophyd_async.epics.signal import epics_signal_r +from dodal.common.crystal_metadata import ( + CrystalMetadata, + MaterialsEnum, + make_crystal_metadata_from_material, +) + class DCM(StandardReadable): """ @@ -17,7 +25,11 @@ def __init__( self, prefix: str, name: str = "", + crystal_metadata: CrystalMetadata | None = None, ) -> None: + cm = crystal_metadata or make_crystal_metadata_from_material( + MaterialsEnum.Si, (1, 1, 1) + ) with self.add_children_as_readables(): self.bragg_in_degrees = Motor(prefix + "BRAGG") self.roll_in_mrad = Motor(prefix + "ROLL") @@ -36,4 +48,16 @@ def __init__( self.perp_temp = epics_signal_r(float, prefix + "TEMP6") self.perp_sub_assembly_temp = epics_signal_r(float, prefix + "TEMP7") + self.crystal_metadata_usage, _ = soft_signal_r_and_setter( + str, initial_value=cm.usage + ) + self.crystal_metadata_type, _ = soft_signal_r_and_setter( + str, initial_value=cm.type + ) + reflection_array = np.array(cm.reflection) + self.crystal_metadata_reflection, _ = soft_signal_r_and_setter( + NDArray[np.uint64], + initial_value=reflection_array, + ) + self.crystal_metadata_d_spacing = epics_signal_r(float, "DSPACING:RBV") super().__init__(name) diff --git a/src/dodal/devices/i22/dcm.py b/src/dodal/devices/i22/dcm.py index 70a46a30a8..59b4902f7e 100644 --- a/src/dodal/devices/i22/dcm.py +++ b/src/dodal/devices/i22/dcm.py @@ -1,6 +1,4 @@ import time -from dataclasses import dataclass -from typing import Literal import numpy as np from bluesky.protocols import Reading @@ -14,24 +12,13 @@ from ophyd_async.epics.motor import Motor from ophyd_async.epics.signal import epics_signal_r +from dodal.common.crystal_metadata import CrystalMetadata + # Conversion constant for energy and wavelength, taken from the X-Ray data booklet # Converts between energy in KeV and wavelength in angstrom _CONVERSION_CONSTANT = 12.3984 -@dataclass(frozen=True, unsafe_hash=True) -class CrystalMetadata: - """ - Metadata used in the NeXus format, - see https://manual.nexusformat.org/classes/base_classes/NXcrystal.html - """ - - usage: Literal["Bragg", "Laue"] | None = None - type: str | None = None - reflection: tuple[int, int, int] | None = None - d_spacing: tuple[float, str] | None = None - - class DoubleCrystalMonochromator(StandardReadable): """ A double crystal monochromator (DCM), used to select the energy of the beam. @@ -45,8 +32,8 @@ class DoubleCrystalMonochromator(StandardReadable): def __init__( self, temperature_prefix: str, - crystal_1_metadata: CrystalMetadata | None = None, - crystal_2_metadata: CrystalMetadata | None = None, + crystal_1_metadata: CrystalMetadata, + crystal_2_metadata: CrystalMetadata, prefix: str = "", name: str = "", ) -> None: @@ -74,63 +61,37 @@ def __init__( # Soft metadata # If supplied include crystal details in output of read_configuration - crystal_1_metadata = crystal_1_metadata or CrystalMetadata() - crystal_2_metadata = crystal_2_metadata or CrystalMetadata() with self.add_children_as_readables(ConfigSignal): - if crystal_1_metadata.usage is not None: - self.crystal_1_usage, _ = soft_signal_r_and_setter( - str, initial_value=crystal_1_metadata.usage - ) - else: - self.crystal_1_usage = None - if crystal_1_metadata.type is not None: - self.crystal_1_type, _ = soft_signal_r_and_setter( - str, initial_value=crystal_1_metadata.type - ) - else: - self.crystal_1_type = None - if crystal_1_metadata.reflection is not None: - self.crystal_1_reflection, _ = soft_signal_r_and_setter( - Array1D[np.int32], - initial_value=np.array(crystal_1_metadata.reflection), - ) - else: - self.crystal_1_reflection = None - if crystal_1_metadata.d_spacing is not None: - self.crystal_1_d_spacing, _ = soft_signal_r_and_setter( - float, - initial_value=crystal_1_metadata.d_spacing[0], - units=crystal_1_metadata.d_spacing[1], - ) - else: - self.crystal_1_d_spacing = None - if crystal_2_metadata.usage is not None: - self.crystal_2_usage, _ = soft_signal_r_and_setter( - str, initial_value=crystal_2_metadata.usage - ) - else: - self.crystal_2_usage = None - if crystal_2_metadata.type is not None: - self.crystal_2_type, _ = soft_signal_r_and_setter( - str, initial_value=crystal_2_metadata.type - ) - else: - self.crystal_2_type = None - if crystal_2_metadata.reflection is not None: - self.crystal_2_reflection, _ = soft_signal_r_and_setter( - Array1D[np.int32], - initial_value=np.array(crystal_2_metadata.reflection), - ) - else: - self.crystal_2_reflection = None - if crystal_2_metadata.d_spacing is not None: - self.crystal_2_d_spacing, _ = soft_signal_r_and_setter( - float, - initial_value=crystal_2_metadata.d_spacing[0], - units=crystal_2_metadata.d_spacing[1], - ) - else: - self.crystal_2_d_spacing = None + self.crystal_1_usage, _ = soft_signal_r_and_setter( + str, initial_value=crystal_1_metadata.usage + ) + self.crystal_1_type, _ = soft_signal_r_and_setter( + str, initial_value=crystal_1_metadata.type + ) + self.crystal_1_reflection, _ = soft_signal_r_and_setter( + Array1D[np.int32], + initial_value=np.array(crystal_1_metadata.reflection), + ) + self.crystal_1_d_spacing, _ = soft_signal_r_and_setter( + float, + initial_value=crystal_1_metadata.d_spacing[0], + units=crystal_1_metadata.d_spacing[1], + ) + self.crystal_2_usage, _ = soft_signal_r_and_setter( + str, initial_value=crystal_2_metadata.usage + ) + self.crystal_2_type, _ = soft_signal_r_and_setter( + str, initial_value=crystal_2_metadata.type + ) + self.crystal_2_reflection, _ = soft_signal_r_and_setter( + Array1D[np.int32], + initial_value=np.array(crystal_2_metadata.reflection), + ) + self.crystal_2_d_spacing, _ = soft_signal_r_and_setter( + float, + initial_value=crystal_2_metadata.d_spacing[0], + units=crystal_2_metadata.d_spacing[1], + ) super().__init__(name) diff --git a/tests/common/test_crystal_metadata.py b/tests/common/test_crystal_metadata.py new file mode 100644 index 0000000000..3c92491c21 --- /dev/null +++ b/tests/common/test_crystal_metadata.py @@ -0,0 +1,37 @@ +import pytest + +from dodal.common.crystal_metadata import ( + MaterialsEnum, + make_crystal_metadata_from_material, +) + + +def test_happy_path_silicon(): + crystal_metadata = make_crystal_metadata_from_material(MaterialsEnum.Si, (3, 1, 1)) + + # Check the values + assert crystal_metadata.type == "silicon" + assert crystal_metadata.reflection == (3, 1, 1) + assert crystal_metadata.d_spacing == pytest.approx( + (0.16375, "nm"), rel=1e-3 + ) # Allow for small tolerance + assert crystal_metadata.usage == "Bragg" + + +def test_happy_path_germanium(): + crystal_metadata = make_crystal_metadata_from_material(MaterialsEnum.Ge, (1, 1, 1)) + # Check the values + assert crystal_metadata.type == "germanium" + assert crystal_metadata.reflection == (1, 1, 1) + assert crystal_metadata.d_spacing == pytest.approx( + (0.326633, "nm"), rel=1e-3 + ) # Allow for small tolerance + assert crystal_metadata.usage == "Bragg" + + +def test_invalid_reflection_plane_with_negative_number(): + with pytest.raises( + AssertionError, + match="Reflection plane indices must be positive integers", + ): + make_crystal_metadata_from_material(MaterialsEnum.Si, (-1, 2, 3)) diff --git a/tests/devices/i22/test_dcm.py b/tests/devices/i22/test_dcm.py index e32588c0a2..ffe38b64c5 100644 --- a/tests/devices/i22/test_dcm.py +++ b/tests/devices/i22/test_dcm.py @@ -12,27 +12,23 @@ set_mock_value, ) -from dodal.devices.i22.dcm import CrystalMetadata, DoubleCrystalMonochromator +from dodal.common.crystal_metadata import ( + MaterialsEnum, + make_crystal_metadata_from_material, +) +from dodal.devices.i22.dcm import DoubleCrystalMonochromator @pytest.fixture async def dcm() -> DoubleCrystalMonochromator: + metadata_1 = make_crystal_metadata_from_material(MaterialsEnum.Si, (1, 1, 1)) + metadata_2 = make_crystal_metadata_from_material(MaterialsEnum.Si, (1, 1, 1)) async with DeviceCollector(mock=True): dcm = DoubleCrystalMonochromator( prefix="FOO-MO", temperature_prefix="FOO-DI", - crystal_1_metadata=CrystalMetadata( - usage="Bragg", - type="silicon", - reflection=(1, 1, 1), - d_spacing=(3.13475, "mm"), - ), - crystal_2_metadata=CrystalMetadata( - usage="Bragg", - type="silicon", - reflection=(1, 1, 1), - d_spacing=(3.13475, "mm"), - ), + crystal_1_metadata=metadata_1, + crystal_2_metadata=metadata_2, ) return dcm @@ -53,29 +49,6 @@ def test_count_dcm( ) -async def test_crystal_metadata_not_propagated_when_not_supplied(): - async with DeviceCollector(mock=True): - dcm = DoubleCrystalMonochromator( - prefix="FOO-MO", - temperature_prefix="FOO-DI", - crystal_1_metadata=None, - crystal_2_metadata=None, - ) - - configuration = await dcm.read_configuration() - expected_absent_keys = { - "crystal-1-usage", - "crystal-1-type", - "crystal-1-reflection", - "crystal-1-d_spacing", - "crystal-2-usage", - "crystal-2-type", - "crystal-2-reflection", - "crystal-2-d_spacing", - } - assert expected_absent_keys.isdisjoint(configuration) - - @pytest.mark.parametrize( "energy,wavelength", [ @@ -250,7 +223,7 @@ async def test_configuration(dcm: DoubleCrystalMonochromator): "alarm_severity": ANY, }, "dcm-crystal_2_d_spacing": { - "value": 3.13475, + "value": 0.31356, "timestamp": ANY, "alarm_severity": ANY, }, @@ -275,7 +248,7 @@ async def test_configuration(dcm: DoubleCrystalMonochromator): "alarm_severity": ANY, }, "dcm-crystal_1_d_spacing": { - "value": 3.13475, + "value": 0.31356, "timestamp": ANY, "alarm_severity": ANY, }, diff --git a/tests/devices/unit_tests/test_dcm.py b/tests/devices/unit_tests/test_dcm.py index 60fbc9076b..6c5c09fda5 100644 --- a/tests/devices/unit_tests/test_dcm.py +++ b/tests/devices/unit_tests/test_dcm.py @@ -13,6 +13,12 @@ async def dcm() -> DCM: return dcm +async def test_metadata_reflection(dcm: DCM): + signal = dcm.crystal_metadata_reflection + v = await signal.read() + assert v is not None, "Value is not clear" + + @pytest.mark.parametrize( "key", [ @@ -20,6 +26,10 @@ async def dcm() -> DCM: "dcm-bragg_in_degrees", "dcm-energy_in_kev", "dcm-offset_in_mm", + "dcm-crystal_metadata_usage", + "dcm-crystal_metadata_type", + "dcm-crystal_metadata_reflection", + "dcm-crystal_metadata_d_spacing", ], ) async def test_read_and_describe_includes(