Skip to content

Commit

Permalink
Crystal metadata change (#843)
Browse files Browse the repository at this point in the history
* add the class and a test

* add to the beamline files

* Fix the expected test spacing values 

Previously this was hard coded to be a static float in milimeters for some reason, forgot to update this for this PR

* Implement crystal metadata for I03 DCM (#870)

* Implement crystal metadata for I03 DCM

* Make type-checking happy

* make d spacing a signal

* delete the absent keys test

* remove the d spacing calculation

* respond to feedback

* fix crystal metadata variable namign

* convert the types correctly

* ruff

* apparently this fixes tests and coverage

* remove print

* remove is None checks

---------

Co-authored-by: rtuck99 <[email protected]>
  • Loading branch information
stan-dot and rtuck99 authored Nov 6, 2024
1 parent 2e58d4e commit bcd40a3
Show file tree
Hide file tree
Showing 8 changed files with 197 additions and 134 deletions.
21 changes: 10 additions & 11 deletions src/dodal/beamlines/i22.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
),
)

Expand Down
20 changes: 9 additions & 11 deletions src/dodal/beamlines/p38.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
),
)

Expand Down
61 changes: 61 additions & 0 deletions src/dodal/common/crystal_metadata.py
Original file line number Diff line number Diff line change
@@ -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)
26 changes: 25 additions & 1 deletion src/dodal/devices/dcm.py
Original file line number Diff line number Diff line change
@@ -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):
"""
Expand All @@ -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")
Expand All @@ -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)
107 changes: 34 additions & 73 deletions src/dodal/devices/i22/dcm.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import time
from dataclasses import dataclass
from typing import Literal

import numpy as np
from bluesky.protocols import Reading
Expand All @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -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)

Expand Down
37 changes: 37 additions & 0 deletions tests/common/test_crystal_metadata.py
Original file line number Diff line number Diff line change
@@ -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))
Loading

0 comments on commit bcd40a3

Please sign in to comment.