Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Crystal metadata change #843

Merged
merged 14 commits into from
Nov 6, 2024
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(
Copy link
Contributor

Choose a reason for hiding this comment

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

Should: Sorry, maybe I've misunderstood, I thought the conclusion of the discussion at #843 (comment) was to remove all this calculation and use the PV? Or is it not yet on i22?

Copy link
Contributor

Choose a reason for hiding this comment

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

If the PV isn't on i22 yet then I said in that comment, can we get the ball rolling on that?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

there was a discussion was to add the PV on i18, which ultimately just was a slight name change.

regarding the static method

        self.crystal_metadata_d_spacing = epics_signal_r(float, "DSPACING:RBV")

at the moment there is the PV value used. I might have mistaken with the line above where the 'initial value' is used.

at the moment the only place when the calculation is used is in the make_crystal_metadata_from_material where if the d_spacing is not supplied by the user, it's calculated.

we could remove it and make it required

Copy link
Contributor

Choose a reason for hiding this comment

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

Ok, so we still need it in there for i22. That's fine, I'll make a note on the conversion issue.

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
stan-dot marked this conversation as resolved.
Show resolved Hide resolved
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
Loading