Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
2b6b6a3
switch to config_server for lut
Dec 18, 2025
af2d8c3
cast to np.ndarray
Dec 19, 2025
4cd8813
overwrite file
Dec 19, 2025
cb2b9f9
Merge branch 'main' into i09-1-add_lut_from_config_server
Villtord Jan 6, 2026
070dd75
Merge branch 'main' into i09-1-add_lut_from_config_server
Villtord Jan 7, 2026
4b8bd8f
fix mock side effect
Jan 7, 2026
758596f
fix lint
Jan 8, 2026
cafe52c
Merge branch 'main' into i09-1-add_lut_from_config_server
Villtord Jan 14, 2026
29f253c
small update to test failing certificate in daq-server
Jan 15, 2026
7941bde
change lut to lut_provider tp avoid call to daq-server in CI
Jan 15, 2026
01bff15
fix tests
Jan 15, 2026
b039205
Merge branch 'main' into i09-1-add_lut_from_config_server
Villtord Jan 15, 2026
bbe6ad4
switch to GenericLookupTable
Jan 16, 2026
c2eb47a
pin version daq-server to >=1.1.2
Jan 16, 2026
84886d9
Merge branch 'main' into i09-1-add_lut_from_config_server
Villtord Jan 16, 2026
76dc6a9
Merge branch 'main' into i09-1-add_lut_from_config_server
Villtord Jan 19, 2026
3524b72
Merge branch 'main' into i09-1-add_lut_from_config_server
Villtord Jan 20, 2026
69eefc1
Merge branch 'main' into i09-1-add_lut_from_config_server
Villtord Jan 23, 2026
480c763
merge main branch
Jan 23, 2026
1fd00ad
Merge branch 'main' into i09-1-add_lut_from_config_server
Villtord Jan 23, 2026
f816f60
Merge branch 'main' into i09-1-add_lut_from_config_server
Villtord Jan 29, 2026
f86aac3
Merge remote-tracking branch 'origin/main' into i09-1-add_lut_from_co…
Feb 6, 2026
d0c40a1
fix tests
Feb 6, 2026
61d00f0
Merge branch 'main' into i09-1-add_lut_from_config_server
Villtord Feb 6, 2026
5bae87d
Merge branch 'main' into i09-1-add_lut_from_config_server
Villtord Feb 9, 2026
ae4ed35
clean up functions, move logic get_file_content logic out of them
Feb 11, 2026
4048d69
rework classes
Feb 11, 2026
87690b8
Merge branch 'main' into i09-1-add_lut_from_config_server
Villtord Feb 11, 2026
df39eca
add more docstrings
Feb 11, 2026
3735d58
more docstrings
Feb 11, 2026
08c4856
docstring
Feb 11, 2026
d92bac6
Merge branch 'main' into i09-1-add_lut_from_config_server
Villtord Feb 11, 2026
6c1dd4a
Merge branch 'main' into i09-1-add_lut_from_config_server
Villtord Feb 19, 2026
8f8119d
Merge branch 'main' into i09-1-add_lut_from_config_server
Villtord Feb 20, 2026
586ec5f
Merge branch 'main' into i09-1-add_lut_from_config_server
Villtord Mar 20, 2026
14ff920
fix updated daq-config-server module
Mar 20, 2026
e179241
Merge branch 'main' into i09-1-add_lut_from_config_server
Villtord Mar 24, 2026
3ec366d
Merge branch 'main' into i09-1-add_lut_from_config_server
Villtord Mar 27, 2026
62e4828
small typo
Mar 27, 2026
34d7645
Merge branch 'main' into i09-1-add_lut_from_config_server
Villtord Mar 30, 2026
d1eb2fa
Merge branch 'main' into i09-1-add_lut_from_config_server
Villtord Mar 31, 2026
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
17 changes: 16 additions & 1 deletion src/dodal/beamlines/i09_1_shared.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
from daq_config_server import ConfigClient

from dodal.common.beamlines.beamline_utils import get_config_client, set_config_client
from dodal.device_manager import DeviceManager
from dodal.devices.beamlines.i09_1_shared import (
HardEnergy,
HardInsertionDeviceEnergy,
calculate_energy_i09_hu,
calculate_gap_i09_hu,
)
from dodal.devices.beamlines.i09_1_shared.hard_energy import (
HardEnergy,
HardInsertionDeviceEnergy,
)
from dodal.devices.beamlines.i09_1_shared.hard_undulator_functions import (
calculate_energy_i09_hu,
calculate_gap_i09_hu,
)
from dodal.devices.common_dcm import (
DoubleCrystalMonochromatorWithDSpacing,
PitchAndRollCrystal,
Expand All @@ -19,6 +30,9 @@

devices = DeviceManager()

set_config_client(ConfigClient())
LOOK_UPTABLE_FILE = "/dls_sw/i09-1/software/gda/workspace_git/gda-diamond.git/configurations/i09-1-shared/lookupTables/IIDCalibrationTable.txt"


@devices.factory()
def psi1() -> HutchShutter:
Expand Down Expand Up @@ -51,7 +65,8 @@ def iidenergy(
return HardInsertionDeviceEnergy(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I personally really dislike making config client global scope. However, this makes no sense as you have made it global, yet are still injecting it as a dependency for the device as configuration. Either use it globally, or use injection. I wouldn't mix the too.

However, looking at this further I can see several other beamlines have also opted for a similar pattern so I will raise as a separate issue

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@rtuck99 and I have discussed this, and there doesn't seem to be a great way of solving the problem that the client may be needed in plans as well as devices. In devices, the client can be injected but this doesn't work for plans. Welcome to any ideas to tidy this up

Copy link
Copy Markdown
Contributor

@jacob720 jacob720 Mar 27, 2026

Choose a reason for hiding this comment

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

For i03, I've created a device fixture so the device manager can inject the client into devices where needed:

@devices.fixture
@cache
def config_client() -> ConfigClient:
    client = ConfigClient(I03_CONFIG_SERVER_ENDPOINT)
    set_config_client(client)
    return client

But we still have to call set_config_client(client) in case the config client needs to be obtained inside a plan.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Could you direct me to where you use it in plans?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If you need access to it inside of plans, you can either make it an attribute of a device and then access that attribute of the device in the plan. Or you could wrap ConfigClient inside its own device and then easily inject it into any plan you need?

Copy link
Copy Markdown
Contributor

@jacob720 jacob720 Mar 30, 2026

Choose a reason for hiding this comment

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

https://github.com/DiamondLightSource/mx-bluesky/blob/62c0c9ae5d4eef36c2d60ea8d1800dece8cbe84b/src/mx_bluesky/hyperion/utils/context.py#L20
Here's one example. Also for beamline parameters which aren't necessarily linked to a device

Or you could wrap ConfigClient inside its own device and then easily inject it into any plan you need?

That's true, it feels a bit weird to make something a device just so we have access to it inside plans though. Would be nice if there was a different way.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I agree maybe the name "Device" is slightly misleading, but it does utilise all the existing infrastructure and is much cleaner.

Something that might flow a little better is to get the DeviceManager in dodal and in turn Blueapi to support allowing objects to only implement the native HasName Bluesky protocol so it is clear it is not a device but is still findable and therefore injectable into plans. Would give much more flexibility for any other future use cases for other objects we need accessible in plans via BlueAPI which are not devices.

undulator_order=ienergy_order,
undulator=iid,
lut={}, # ToDo https://github.com/DiamondLightSource/sm-bluesky/issues/239
config_server=get_config_client(),
filepath=LOOK_UPTABLE_FILE,
gap_to_energy_func=calculate_energy_i09_hu,
energy_to_gap_func=calculate_gap_i09_hu,
)
Expand Down
2 changes: 0 additions & 2 deletions src/dodal/devices/beamlines/i09_1_shared/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,10 @@
from .hard_undulator_functions import (
calculate_energy_i09_hu,
calculate_gap_i09_hu,
get_hu_lut_as_dict,
)

__all__ = [
"calculate_gap_i09_hu",
"get_hu_lut_as_dict",
"calculate_energy_i09_hu",
"HardInsertionDeviceEnergy",
"HardEnergy",
Expand Down
96 changes: 65 additions & 31 deletions src/dodal/devices/beamlines/i09_1_shared/hard_energy.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from asyncio import gather
from collections.abc import Callable
from typing import Protocol

from bluesky.protocols import Locatable, Location, Movable
from numpy import ndarray
from daq_config_server import ConfigClient
from daq_config_server.models.lookup_tables import GenericLookupTable
from ophyd_async.core import (
AsyncStatus,
Reference,
Expand All @@ -12,33 +13,56 @@
soft_signal_rw,
)

from dodal.devices.beamlines.i09_1_shared.hard_undulator_functions import (
MAX_ENERGY_COLUMN,
MIN_ENERGY_COLUMN,
)
from dodal.devices.common_dcm import DoubleCrystalMonochromatorBase
from dodal.devices.undulator import UndulatorInMm, UndulatorOrder


class EnergyGapConvertor(Protocol):
def __call__(
self, look_up_table: GenericLookupTable, value: float, order: int
) -> float:
"""Protocol for a function to provide value conversion using lookup table."""
...


class HardInsertionDeviceEnergy(StandardReadable, Movable[float]):
"""Compound device to link hard x-ray undulator gap and order to photon energy.
"""Compound device to control insertion device energy.

This device link hard x-ray undulator gap and order to the required photon energy.
Setting the energy adjusts the undulator gap accordingly.

Attributes:
energy_demand (SignalRW[float]): The energy value that the user wants to set.
energy (SignalRW[float]): The actual energy of the insertion device.
"""

def __init__(
self,
undulator_order: UndulatorOrder,
undulator: UndulatorInMm,
lut: dict[int, ndarray],
gap_to_energy_func: Callable[..., float],
energy_to_gap_func: Callable[..., float],
config_server: ConfigClient,
filepath: str,
gap_to_energy_func: EnergyGapConvertor,
energy_to_gap_func: EnergyGapConvertor,
name: str = "",
) -> None:
self._lut = lut
self.gap_to_energy_func = gap_to_energy_func
self.energy_to_gap_func = energy_to_gap_func
"""Initialize the HardInsertionDeviceEnergy device.

Args:
undulator_order (UndulatorOrder): undulator order device.
undulator (UndulatorInMm): undulator device for gap control.
config_server (ConfigServer): Config server client to retrieve the lookup table.
filepath (str): File path to the lookup table on the config server.
gap_to_energy_func (EnergyGapConvertor): Function to convert gap to energy using the lookup table.
energy_to_gap_func (EnergyGapConvertor): Function to convert energy to gap using the lookup table.
name (str, optional): Name for the device. Defaults to empty string.
"""
self._undulator_order_ref = Reference(undulator_order)
self._undulator_ref = Reference(undulator)
self._config_server = config_server
self._filepath = filepath
self._gap_to_energy_func = gap_to_energy_func
self._energy_to_gap_func = energy_to_gap_func

self.add_readables([undulator_order, undulator.current_gap])
with self.add_children_as_readables(StandardReadableFormat.HINTED_SIGNAL):
Expand All @@ -53,37 +77,40 @@ def __init__(
super().__init__(name=name)

def _read_energy(self, current_gap: float, current_order: int) -> float:
return self.gap_to_energy_func(
gap=current_gap,
look_up_table=self._lut,
order=current_order,
_lookup_table = self.get_look_up_table()
return self._gap_to_energy_func(
look_up_table=_lookup_table, value=current_gap, order=current_order
)

async def _set_energy(self, energy: float) -> None:
async def _set_energy(self, value: float) -> None:
current_order = await self._undulator_order_ref().value.get_value()
min_energy, max_energy = self._lut[current_order][
MIN_ENERGY_COLUMN : MAX_ENERGY_COLUMN + 1
]
if not (min_energy <= energy <= max_energy):
raise ValueError(
f"Requested energy {energy} keV is out of range for harmonic {current_order}: "
f"[{min_energy}, {max_energy}] keV"
)
_lookup_table = self.get_look_up_table()
target_gap = self._energy_to_gap_func(_lookup_table, value, current_order)
await self._undulator_ref().set(target_gap)

target_gap = self.energy_to_gap_func(
photon_energy_kev=energy, look_up_table=self._lut, order=current_order
def get_look_up_table(self) -> GenericLookupTable:
self._lut: GenericLookupTable = self._config_server.get_file_contents(
self._filepath,
desired_return_type=GenericLookupTable,
reset_cached_result=True,
)
await self._undulator_ref().set(target_gap)
return self._lut

@AsyncStatus.wrap
async def set(self, value: float) -> None:
"""Update energy demand and set energy to a given value in keV.

Args:
value (float): Energy in keV.
"""
self.energy_demand.set(value)
await self.energy.set(value)


class HardEnergy(StandardReadable, Locatable[float]):
"""Energy compound device that provides combined change of both DCM energy and
undulator gap accordingly.
"""Compound energy device.

This device changes both monochromator and insertion device energy.
"""

def __init__(
Expand All @@ -92,6 +119,13 @@ def __init__(
undulator_energy: HardInsertionDeviceEnergy,
name: str = "",
) -> None:
"""Initialize the HardEnergy device.

Args:
dcm (DoubleCrystalMonochromatorBase): Double crystal monochromator device.
undulator_energy (HardInsertionDeviceEnergy): Hard insertion device control.
name (str, optional): name for the device. Defaults to empty.
"""
self._dcm_ref = Reference(dcm)
self._undulator_energy_ref = Reference(undulator_energy)
self.add_readables([undulator_energy, dcm.energy_in_keV])
Expand Down
Loading
Loading