Source code for dodal.devices.undulator
import os
import numpy as np
from bluesky.protocols import Movable
from numpy import argmin, ndarray
from ophyd_async.core import (
AsyncStatus,
StandardReadable,
StandardReadableFormat,
StrictEnum,
soft_signal_r_and_setter,
)
from ophyd_async.epics.core import epics_signal_r
from ophyd_async.epics.motor import Motor
from dodal.log import LOGGER
from .util.lookup_tables import energy_distance_table
class AccessError(Exception):
pass
# Enable to allow testing when the beamline is down, do not change in production!
TEST_MODE = False
# will be made more generic in https://github.com/DiamondLightSource/dodal/issues/754
# The acceptable difference, in mm, between the undulator gap and the DCM
# energy, when the latter is converted to mm using lookup tables
UNDULATOR_DISCREPANCY_THRESHOLD_MM = 2e-3
STATUS_TIMEOUT_S: float = 10.0
[docs]
class UndulatorGapAccess(StrictEnum):
ENABLED = "ENABLED"
DISABLED = "DISABLED"
def _get_closest_gap_for_energy(
dcm_energy_ev: float, energy_to_distance_table: ndarray
) -> float:
table = energy_to_distance_table.transpose()
idx = argmin(np.abs(table[0] - dcm_energy_ev))
return table[1][idx]
[docs]
class Undulator(StandardReadable, Movable[float]):
"""
An Undulator-type insertion device, used to control photon emission at a given
beam energy.
"""
def __init__(
self,
prefix: str,
id_gap_lookup_table_path: str = os.devnull,
name: str = "",
poles: int | None = None,
length: float | None = None,
) -> None:
"""Constructor
Args:
prefix: PV prefix
poles (int): Number of magnetic poles built into the undulator
length (float): Length of the undulator in meters
name (str, optional): Name for device. Defaults to "".
"""
self.id_gap_lookup_table_path = id_gap_lookup_table_path
with self.add_children_as_readables():
self.gap_motor = Motor(prefix + "BLGAPMTR")
self.current_gap = epics_signal_r(float, prefix + "CURRGAPD")
self.gap_access = epics_signal_r(UndulatorGapAccess, prefix + "IDBLENA")
with self.add_children_as_readables(StandardReadableFormat.CONFIG_SIGNAL):
self.gap_discrepancy_tolerance_mm, _ = soft_signal_r_and_setter(
float,
initial_value=UNDULATOR_DISCREPANCY_THRESHOLD_MM,
)
if poles is not None:
self.poles, _ = soft_signal_r_and_setter(
int,
initial_value=poles,
)
else:
self.poles = None
if length is not None:
self.length, _ = soft_signal_r_and_setter(
float,
initial_value=length,
)
else:
self.length = None
super().__init__(name)
[docs]
@AsyncStatus.wrap
async def set(self, value: float):
"""
Set the undulator gap to a given energy in keV
Args:
value: energy in keV
"""
await self._set_undulator_gap(value)
async def raise_if_not_enabled(self):
access_level = await self.gap_access.get_value()
if access_level is UndulatorGapAccess.DISABLED and not TEST_MODE:
raise AccessError("Undulator gap access is disabled. Contact Control Room")
async def _set_undulator_gap(self, energy_kev: float) -> None:
await self.raise_if_not_enabled()
LOGGER.info(f"Setting undulator gap to {energy_kev:.2f} kev")
target_gap = await self._get_gap_to_match_energy(energy_kev)
# Check if undulator gap is close enough to the value from the DCM
current_gap = await self.current_gap.get_value()
tolerance = await self.gap_discrepancy_tolerance_mm.get_value()
difference = abs(target_gap - current_gap)
if difference > tolerance:
LOGGER.info(
f"Undulator gap mismatch. {difference:.3f}mm is outside tolerance.\
Moving gap to nominal value, {target_gap:.3f}mm"
)
if not TEST_MODE:
# Only move if the gap is sufficiently different to the value from the
# DCM lookup table AND we're not in TEST_MODE
await self.gap_motor.set(
target_gap,
timeout=STATUS_TIMEOUT_S,
)
else:
LOGGER.debug("In test mode, not moving ID gap")
else:
LOGGER.debug(
"Gap is already in the correct place for the new energy value "
f"{energy_kev}, no need to ask it to move"
)
async def _get_gap_to_match_energy(self, energy_kev: float) -> float:
"""
get a 2d np.array from lookup table that
converts energies to undulator gap distance
"""
energy_to_distance_table: np.ndarray = await energy_distance_table(
self.id_gap_lookup_table_path
)
# Use the lookup table to get the undulator gap associated with this dcm energy
return _get_closest_gap_for_energy(
energy_kev * 1000,
energy_to_distance_table,
)