From fec152b61edc5b2197873830a95f8da6379f42e4 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Mon, 6 Oct 2025 15:57:02 +0000 Subject: [PATCH 01/30] add energy_controller --- src/dodal/devices/apple2_undulator.py | 140 +++- src/dodal/devices/i10/i10_apple2.py | 216 ++---- tests/devices/i10/test_i10Apple2.py | 935 +++++++++++++------------- 3 files changed, 613 insertions(+), 678 deletions(-) diff --git a/src/dodal/devices/apple2_undulator.py b/src/dodal/devices/apple2_undulator.py index c5df7cc179..e9afd3ec97 100644 --- a/src/dodal/devices/apple2_undulator.py +++ b/src/dodal/devices/apple2_undulator.py @@ -5,9 +5,10 @@ from typing import Generic, Protocol, TypeVar import numpy as np -from bluesky.protocols import Movable +from bluesky.protocols import Movable, Status from ophyd_async.core import ( AsyncStatus, + Reference, SignalR, SignalW, StandardReadable, @@ -15,10 +16,12 @@ StrictEnum, derived_signal_rw, soft_signal_r_and_setter, + soft_signal_rw, wait_for_value, ) from ophyd_async.epics.core import epics_signal_r, epics_signal_rw, epics_signal_w +from dodal.devices.pgm import PGM from dodal.log import LOGGER T = TypeVar("T") @@ -40,12 +43,8 @@ class Apple2PhasesVal: @dataclass -class Apple2Val: +class Apple2Val(Apple2PhasesVal): gap: str - top_outer: str - top_inner: str - btm_inner: str - btm_outer: str class Pol(StrictEnum): @@ -301,9 +300,9 @@ async def get_timeout(self) -> float: ) -class Apple2Motors(StandardReadable, Movable): +class Apple2(StandardReadable, Movable): """ - Device representing the combined motor controls for an Apple2 undulator. + Device representing the combined motor controls for an Apple2Controller undulator. Attributes ---------- @@ -366,11 +365,11 @@ def __call__(self, energy: float, pol: Pol) -> tuple[float, float]: ... -class Apple2(abc.ABC, StandardReadable, Movable): +class Apple2Controller(abc.ABC, StandardReadable, Movable): """ - Apple2 Undulator Device + Apple2Controller Undulator Device - The `Apple2` class represents an Apple 2 insertion device (undulator) used in synchrotron beamlines. + The `Apple2Controller` class represents an Apple 2 insertion device (undulator) used in synchrotron beamlines. This device provides additional degrees of freedom compared to standard undulators, allowing independent movement of magnet banks to produce X-rays with various polarisations and energies. @@ -382,7 +381,7 @@ class Apple2(abc.ABC, StandardReadable, Movable): Attributes ---------- - apple2_motors : Apple2Motors + apple2 : Apple2 A collection of gap and phase motor devices. energy : SignalR A soft signal for the current energy readback. @@ -420,8 +419,7 @@ class Apple2(abc.ABC, StandardReadable, Movable): def __init__( self, - apple2_motors: Apple2Motors, - energy_motor_convertor: EnergyMotorConvertor, + apple2: Apple2, name: str = "", ) -> None: """ @@ -434,13 +432,16 @@ def __init__( name: Name of the device. """ - self.motors = apple2_motors - self.energy_to_motor = energy_motor_convertor + self.apple2 = Reference(apple2) + self.energy_to_motor: EnergyMotorConvertor with self.add_children_as_readables(StandardReadableFormat.HINTED_SIGNAL): # Store the set energy for readback. - self.energy, self._set_energy_rbv = soft_signal_r_and_setter( - float, initial_value=None - ) + self._energy = soft_signal_rw(float, initial_value=None) + self.energy = derived_signal_rw( + raw_to_derived=self._read_energy, + set_derived=self._set_energy, + energy=self._energy, + ) # Store the polarisation for setpoint. And provide readback for LH3. # LH3 is a special case as it is indistinguishable from LH in the hardware. @@ -453,11 +454,11 @@ def __init__( raw_to_derived=self._read_pol, set_derived=self._set_pol, pol=self.polarisation_setpoint, - top_outer=self.motors.phase.top_outer.user_readback, - top_inner=self.motors.phase.top_inner.user_readback, - btm_inner=self.motors.phase.btm_inner.user_readback, - btm_outer=self.motors.phase.btm_outer.user_readback, - gap=self.motors.gap.user_readback, + top_outer=self.apple2().phase.top_outer.user_readback, + top_inner=self.apple2().phase.top_inner.user_readback, + btm_inner=self.apple2().phase.btm_inner.user_readback, + btm_outer=self.apple2().phase.btm_outer.user_readback, + gap=self.apple2().gap.user_readback, ) super().__init__(name) @@ -467,14 +468,6 @@ def _set_pol_setpoint(self, pol: Pol) -> None: setting the energy/polarisation of the undulator.""" self._polarisation_setpoint_set(pol) - async def _set_pol( - self, - value: Pol, - ) -> None: - # This changes the pol setpoint and then changes polarisation via set energy. - self._set_pol_setpoint(value) - await self.set(await self.energy.get_value()) - @AsyncStatus.wrap async def set(self, value: float) -> None: """ @@ -488,8 +481,8 @@ async def set(self, value: float) -> None: RE( id.set(888.0)) # This will set the ID to 888 eV RE(scan([detector], id,600,700,100)) # This will scan the ID from 600 to 700 eV in 100 steps. """ - await self._set(value) - self._set_energy_rbv(value) # Update energy after move for readback. + await self.energy.set(value) + LOGGER.info(f"Energy set to {value} eV successfully.") @abc.abstractmethod @@ -502,6 +495,22 @@ async def _set(self, value: float) -> None: provided that all motors can be moved at the same time. """ + async def _set_energy(self, energy: float) -> None: + await self._set(energy) + await self._energy.set(energy) + + def _read_energy(self, energy: float) -> float: + """Readback for energy is just the set value.""" + return energy + + async def _set_pol( + self, + value: Pol, + ) -> None: + # This changes the pol setpoint and then changes polarisation via set energy. + self._set_pol_setpoint(value) + await self.set(await self.energy.get_value()) + def _read_pol( self, pol: Pol, @@ -605,3 +614,66 @@ def determine_phase_from_hardware( LOGGER.warning("Unable to determine polarisation. Defaulting to NONE.") return Pol.NONE, 0.0 + + +class IdEnergy(StandardReadable, Movable): + def __init__(self, id_controller: Apple2Controller, name: str = "") -> None: + self.id_controller = Reference(id_controller) + super().__init__(name=name) # + + def set(self, energy: float) -> Status: + return self.id_controller().set(energy) + + +class IdPol(StandardReadable, Movable): + def __init__(self, id_controller: Apple2Controller, name: str = "") -> None: + self.id_controller = Reference(id_controller) + super().__init__(name=name) + + def set(self, pol: Pol) -> Status: + return self.id_controller().polarisation.set(pol) + + +class BeamEnergy(StandardReadable, Movable[float]): + """ + Compound device to set both ID and PGM energy at the same time. + + """ + + def __init__( + self, id_controller: Apple2Controller, pgm: PGM, name: str = "" + ) -> None: + """ + Parameters + ---------- + id: + An Apple2 device. + pgm: + A PGM/mono device. + name: + New device name. + """ + super().__init__(name=name) + self.id_controller = Reference(id_controller) + self.pgm_ref = Reference(pgm) + + self.add_readables( + [ + self.id_controller().energy, + self.pgm_ref().energy.user_readback, + ], + StandardReadableFormat.HINTED_SIGNAL, + ) + + with self.add_children_as_readables(StandardReadableFormat.CONFIG_SIGNAL): + self.energy_offset = soft_signal_rw(float, initial_value=0) + + @AsyncStatus.wrap + async def set(self, value: float) -> None: + LOGGER.info(f"Moving f{self.name} energy to {value}.") + await asyncio.gather( + self.id_controller().energy.set( + value=value + await self.energy_offset.get_value() + ), + self.pgm_ref().energy.set(value), + ) diff --git a/src/dodal/devices/i10/i10_apple2.py b/src/dodal/devices/i10/i10_apple2.py index 18a8304439..233207089f 100644 --- a/src/dodal/devices/i10/i10_apple2.py +++ b/src/dodal/devices/i10/i10_apple2.py @@ -1,4 +1,3 @@ -import asyncio import csv import io from dataclasses import dataclass @@ -10,29 +9,22 @@ from daq_config_server.client import ConfigServer from ophyd_async.core import ( AsyncStatus, - Device, Reference, StandardReadable, StandardReadableFormat, soft_signal_r_and_setter, - soft_signal_rw, ) from pydantic import BaseModel, ConfigDict, RootModel from dodal.devices.apple2_undulator import ( Apple2, - Apple2Motors, + Apple2Controller, Apple2Val, - EnergyMotorConvertor, Pol, - UndulatorGap, UndulatorJawPhase, - UndulatorPhaseAxes, ) from dodal.log import LOGGER -from ..pgm import PGM - ROW_PHASE_MOTOR_TOLERANCE = 0.004 MAXIMUM_ROW_PHASE_MOTOR_POSITION = 24.0 MAXIMUM_GAP_MOTOR_POSITION = 100 @@ -321,7 +313,7 @@ def process_row(row: dict) -> None: return lookup_table -class I10Apple2(Apple2): +class I10Apple2Controller(Apple2Controller): """I10Apple2 is the i10 version of Apple2 ID, set and energy_motor_convertor should be the only part that is I10 specific. @@ -333,50 +325,36 @@ class I10Apple2(Apple2): def __init__( self, - prefix: str, - energy_motor_convertor: EnergyMotorConvertor, + apple2: Apple2, + jaw_phase: UndulatorJawPhase, + look_up_table_dir: str, + source: tuple[str, str], + config_client: ConfigServer, name: str = "", ) -> None: - """ - Parameters + """I10Id is a compound device that combines the I10-specific Apple2 undulator, + energy setter, and polarization control. + This class provides a high-level interface for controlling the undulator's + energy, polarization, and linear arbitrary angle. + + Attributes ---------- - look_up_table_dir: - The path to look up table. - source: - The column name and the name of the source in look up table. e.g. ("source", "idu") - mode: - The column name of the mode in look up table. - min_energy: - The column name that contain the maximum energy in look up table. - max_energy: - The column name that contain the maximum energy in look up table. - poly_deg: - The column names for the parameters for the energy conversion polynomial, starting with the least significant. - prefix: - epic pv for id - Name: - Name of the device + id : I10Apple2 + The I10-specific Apple2 undulator device. + energy_setter : EnergySetter + A device for synchronizing the undulator and monochromator energy. + pol : I10Apple2Pol + A device for controlling the polarization of the undulator. + linear_arbitrary_angle : LinearArbitraryAngle + A device for controlling the linear arbitrary polarization angle. """ - - with self.add_children_as_readables(): - super().__init__( - apple2_motors=Apple2Motors( - id_gap=UndulatorGap(prefix=prefix), - id_phase=UndulatorPhaseAxes( - prefix=prefix, - top_outer="RPQ1", - top_inner="RPQ2", - btm_inner="RPQ3", - btm_outer="RPQ4", - ), - ), - energy_motor_convertor=energy_motor_convertor, - name=name, - ) - self.id_jaw_phase = UndulatorJawPhase( - prefix=prefix, - move_pv="RPQ1", - ) + self.lookup_table_client = I10EnergyMotorLookup( + look_up_table_dir=look_up_table_dir, + source=source, + config_client=config_client, + ) + self.jaw_phase = Reference(jaw_phase) + super().__init__(apple2=apple2, name=name) async def _set(self, value: float) -> None: """ @@ -398,7 +376,9 @@ async def _set(self, value: float) -> None: ) self._set_pol_setpoint(pol) - gap, phase = self.energy_to_motor(energy=value, pol=pol) + gap, phase = self.lookup_table_client.get_motor_from_energy( + energy=value, pol=pol + ) phase3 = phase * (-1 if pol == Pol.LA else 1) id_set_val = Apple2Val( top_outer=f"{phase:.6f}", @@ -409,73 +389,10 @@ async def _set(self, value: float) -> None: ) LOGGER.info(f"Setting polarisation to {pol}, with values: {id_set_val}") - await self.motors.set(id_motor_values=id_set_val) + await self.apple2().set(id_motor_values=id_set_val) if pol != Pol.LA: - await self.id_jaw_phase.set(0) - await self.id_jaw_phase.set_move.set(1) - - -class EnergySetter(StandardReadable, Movable[float]): - """ - Compound device to set both ID and PGM energy at the same time. - - """ - - def __init__(self, id: I10Apple2, pgm: PGM, name: str = "") -> None: - """ - Parameters - ---------- - id: - An Apple2 device. - pgm: - A PGM/mono device. - name: - New device name. - """ - super().__init__(name=name) - self.id = id - self.pgm_ref = Reference(pgm) - - self.add_readables( - [self.id.energy, self.pgm_ref().energy.user_readback], - StandardReadableFormat.HINTED_SIGNAL, - ) - - with self.add_children_as_readables(StandardReadableFormat.CONFIG_SIGNAL): - self.energy_offset = soft_signal_rw(float, initial_value=0) - - @AsyncStatus.wrap - async def set(self, value: float) -> None: - LOGGER.info(f"Moving f{self.name} energy to {value}.") - await asyncio.gather( - self.id.set(value=value + await self.energy_offset.get_value()), - self.pgm_ref().energy.set(value), - ) - - -class I10Apple2Pol(StandardReadable, Movable[Pol]): - """ - Compound device to set polorisation of ID. - """ - - def __init__(self, id: I10Apple2, name: str = "") -> None: - """ - Parameters - ---------- - id: - An I10Apple2 device. - name: - New device name. - """ - super().__init__(name=name) - self.id_ref = Reference(id) - self.add_readables([self.id_ref().polarisation]) - - @AsyncStatus.wrap - async def set(self, value: Pol) -> None: - LOGGER.info(f"Changing f{self.name} polarisation to {value}.") - # Timeout is determined internally by the set method later, so we set it to max here. - await self.id_ref().polarisation.set(value, timeout=MAXIMUM_MOVE_TIME) + await self.jaw_phase().set(0) + await self.jaw_phase().set_move.set(1) class LinearArbitraryAngle(StandardReadable, Movable[SupportsFloat]): @@ -490,7 +407,7 @@ class LinearArbitraryAngle(StandardReadable, Movable[SupportsFloat]): def __init__( self, - id: I10Apple2, + id_controller: I10Apple2Controller, name: str = "", jaw_phase_limit: float = 12.0, jaw_phase_poly_param: list[float] = DEFAULT_JAW_PHASE_POLY_PARAMS, @@ -509,7 +426,7 @@ def __init__( polynomial parameters highest power first. """ super().__init__(name=name) - self.id_ref = Reference(id) + self.id_controller_ref = Reference(id_controller) self.jaw_phase_from_angle = np.poly1d(jaw_phase_poly_param) self.angle_threshold_deg = angle_threshold_deg self.jaw_phase_limit = jaw_phase_limit @@ -521,10 +438,10 @@ def __init__( @AsyncStatus.wrap async def set(self, value: SupportsFloat) -> None: value = float(value) - pol = await self.id_ref().polarisation.get_value() + pol = await self.id_controller_ref().polarisation.get_value() if pol != Pol.LA: raise RuntimeError( - f"Angle control is not available in polarisation {pol} with {self.id_ref().name}" + f"Angle control is not available in polarisation {pol} with {self.id_controller_ref().name}" ) # Moving to real angle which is 210 to 30. alpha_real = value if value > self.angle_threshold_deg else value + ALPHA_OFFSET @@ -534,60 +451,5 @@ async def set(self, value: SupportsFloat) -> None: f"jaw_phase position for angle ({value}) is outside permitted range" f" [-{self.jaw_phase_limit}, {self.jaw_phase_limit}]" ) - await self.id_ref().id_jaw_phase.set(jaw_phase) + await self.id_controller_ref().jaw_phase().set(jaw_phase) self._angle_set(value) - - -class I10Id(Device): - def __init__( - self, - pgm: PGM, - prefix: str, - look_up_table_dir: str, - source: tuple[str, str], - config_client: ConfigServer, - jaw_phase_limit=12.0, - jaw_phase_poly_param=DEFAULT_JAW_PHASE_POLY_PARAMS, - angle_threshold_deg=30.0, - name: str = "", - ) -> None: - """I10Id is a compound device that combines the I10-specific Apple2 undulator, - energy setter, and polarization control. - This class provides a high-level interface for controlling the undulator's - energy, polarization, and linear arbitrary angle. - - Attributes - ---------- - id : I10Apple2 - The I10-specific Apple2 undulator device. - energy_setter : EnergySetter - A device for synchronizing the undulator and monochromator energy. - pol : I10Apple2Pol - A device for controlling the polarization of the undulator. - linear_arbitrary_angle : LinearArbitraryAngle - A device for controlling the linear arbitrary polarization angle. - """ - self.lookup_table_client = I10EnergyMotorLookup( - look_up_table_dir=look_up_table_dir, - source=source, - config_client=config_client, - ) - self.energy = EnergySetter( - id=I10Apple2( - prefix=prefix, - energy_motor_convertor=self.lookup_table_client.get_motor_from_energy, - name="id_energy", - ), - pgm=pgm, - name="energy", - ) - self.pol = I10Apple2Pol(id=self.energy.id, name="pol") - self.laa = LinearArbitraryAngle( - id=self.energy.id, - name="laa", - jaw_phase_limit=jaw_phase_limit, - jaw_phase_poly_param=jaw_phase_poly_param, - angle_threshold_deg=angle_threshold_deg, - ) - - super().__init__(name=name) diff --git a/tests/devices/i10/test_i10Apple2.py b/tests/devices/i10/test_i10Apple2.py index e1c9122a88..dc8a24cc37 100644 --- a/tests/devices/i10/test_i10Apple2.py +++ b/tests/devices/i10/test_i10Apple2.py @@ -1,24 +1,21 @@ import os -import pickle from collections import defaultdict from unittest import mock -from unittest.mock import MagicMock, Mock +from unittest.mock import MagicMock -import numpy as np import pytest from bluesky.plans import scan from bluesky.run_engine import RunEngine from daq_config_server.client import ConfigServer -from numpy import poly1d from ophyd_async.core import init_devices from ophyd_async.testing import ( assert_emitted, - callback_on_mock_put, - get_mock_put, set_mock_value, ) from dodal.devices.apple2_undulator import ( + Apple2, + BeamEnergy, Pol, UndulatorGap, UndulatorGateStatus, @@ -26,23 +23,13 @@ UndulatorPhaseAxes, ) from dodal.devices.i10.i10_apple2 import ( - DEFAULT_JAW_PHASE_POLY_PARAMS, - EnergySetter, - I10Apple2, - I10Apple2Pol, - I10EnergyMotorLookup, - LinearArbitraryAngle, + I10Apple2Controller, ) from dodal.devices.i10.i10_setting_data import I10Grating from dodal.devices.pgm import PGM from dodal.testing import patch_motor from tests.devices.i10.test_data import ( - EXPECTED_ID_ENERGY_2_GAP_CALIBRATIONS_IDD_PKL, - EXPECTED_ID_ENERGY_2_GAP_CALIBRATIONS_IDU_PKL, - EXPECTED_ID_ENERGY_2_PHASE_CALIBRATIONS_IDD_PKL, - EXPECTED_ID_ENERGY_2_PHASE_CALIBRATIONS_IDU_PKL, ID_ENERGY_2_GAP_CALIBRATIONS_CSV, - ID_ENERGY_2_PHASE_CALIBRATIONS_CSV, LOOKUP_TABLE_PATH, ) @@ -117,13 +104,7 @@ async def mock_jaw_phase(prefix: str = "BLXX-EA-DET-007:") -> UndulatorJawPhase: def mock_config_client() -> ConfigServer: mock.patch("dodal.devices.i10.i10_apple2.ConfigServer") mock_config_client = ConfigServer() - return mock_config_client - -@pytest.fixture -def mock_i10_energy_motor_lookup_idu( - mock_config_client: ConfigServer, -) -> I10EnergyMotorLookup: mock_config_client.get_file_contents = MagicMock(spec=["get_file_contents"]) def my_side_effect(file_path, reset_cached_result) -> str: @@ -132,63 +113,64 @@ def my_side_effect(file_path, reset_cached_result) -> str: return f.read() mock_config_client.get_file_contents.side_effect = my_side_effect - return I10EnergyMotorLookup( - look_up_table_dir=LOOKUP_TABLE_PATH, - source=("Source", "idu"), - config_client=mock_config_client, - ) + return mock_config_client @pytest.fixture -async def mock_id(mock_i10_energy_motor_lookup_idu: I10EnergyMotorLookup) -> I10Apple2: +async def mock_id(mock_id_gap, mock_phaseAxes) -> Apple2: async with init_devices(mock=True): - mock_id = I10Apple2( - prefix="BLWOW-MO-SERVC-01:", - energy_motor_convertor=mock_i10_energy_motor_lookup_idu.get_motor_from_energy, - ) - set_mock_value(mock_id.motors.gap.gate, UndulatorGateStatus.CLOSE) - set_mock_value(mock_id.motors.phase.gate, UndulatorGateStatus.CLOSE) - set_mock_value(mock_id.id_jaw_phase.gate, UndulatorGateStatus.CLOSE) - set_mock_value(mock_id.id_jaw_phase.jaw_phase.velocity, 1) - set_mock_value(mock_id.motors.gap.velocity, 1) - set_mock_value(mock_id.motors.phase.btm_inner.velocity, 1) - set_mock_value(mock_id.motors.phase.top_inner.velocity, 1) - set_mock_value(mock_id.motors.phase.btm_outer.velocity, 1) - set_mock_value(mock_id.motors.phase.top_outer.velocity, 1) + mock_id = Apple2(id_gap=mock_id_gap, id_phase=mock_phaseAxes) return mock_id @pytest.fixture -async def mock_id_pgm(mock_id: I10Apple2, mock_pgm: PGM) -> EnergySetter: +async def mock_id_controller( + mock_id: Apple2, + mock_jaw_phase, + mock_config_client, +) -> I10Apple2Controller: async with init_devices(mock=True): - mock_id_pgm = EnergySetter(id=mock_id, pgm=mock_pgm) - set_mock_value(mock_id.motors.gap.velocity, 1) - set_mock_value(mock_id.motors.phase.btm_inner.velocity, 1) - set_mock_value(mock_id.motors.phase.top_inner.velocity, 1) - set_mock_value(mock_id.motors.phase.btm_outer.velocity, 1) - set_mock_value(mock_id.motors.phase.top_outer.velocity, 1) - set_mock_value(mock_id_pgm.id.id_jaw_phase.jaw_phase.velocity, 1) - set_mock_value(mock_id_pgm.pgm_ref().energy.velocity, 1) - - set_mock_value(mock_id.motors.gap.gate, UndulatorGateStatus.CLOSE) - set_mock_value(mock_id_pgm.id.id_jaw_phase.gate, UndulatorGateStatus.CLOSE) - - return mock_id_pgm + mock_id_controller = I10Apple2Controller( + apple2=mock_id, + jaw_phase=mock_jaw_phase, + look_up_table_dir=LOOKUP_TABLE_PATH, + source=("Source", "idu"), + config_client=mock_config_client, + ) + set_mock_value(mock_id_controller.apple2().gap.gate, UndulatorGateStatus.CLOSE) + set_mock_value(mock_id_controller.apple2().phase.gate, UndulatorGateStatus.CLOSE) + set_mock_value(mock_id_controller.jaw_phase().gate, UndulatorGateStatus.CLOSE) + set_mock_value(mock_id_controller.apple2().gap.velocity, 1) + set_mock_value(mock_id_controller.jaw_phase().jaw_phase.velocity, 1) + set_mock_value(mock_id_controller.apple2().phase.btm_inner.velocity, 1) + set_mock_value(mock_id_controller.apple2().phase.top_inner.velocity, 1) + set_mock_value(mock_id_controller.apple2().phase.btm_outer.velocity, 1) + set_mock_value(mock_id_controller.apple2().phase.top_outer.velocity, 1) + return mock_id_controller @pytest.fixture -async def mock_id_pol(mock_id: I10Apple2) -> I10Apple2Pol: +async def beam_energy( + mock_id_controller: I10Apple2Controller, mock_pgm: PGM +) -> BeamEnergy: async with init_devices(mock=True): - mock_id_pol = I10Apple2Pol(id=mock_id) + beam_energy = BeamEnergy(id_controller=mock_id_controller, pgm=mock_pgm) + return beam_energy - return mock_id_pol +# @pytest.fixture +# async def mock_id_pol(mock_id: I10Apple2) -> I10Apple2Pol: +# async with init_devices(mock=True): +# mock_id_pol = I10Apple2Pol(id=mock_id) + +# return mock_id_pol -@pytest.fixture -async def mock_linear_arbitrary_angle(mock_id: I10Apple2) -> LinearArbitraryAngle: - async with init_devices(mock=True): - mock_linear_arbitrary_angle = LinearArbitraryAngle(id=mock_id) - return mock_linear_arbitrary_angle + +# @pytest.fixture +# async def mock_linear_arbitrary_angle(mock_id: I10Apple2) -> LinearArbitraryAngle: +# async with init_devices(mock=True): +# mock_linear_arbitrary_angle = LinearArbitraryAngle(id=mock_id) +# return mock_linear_arbitrary_angle @pytest.mark.parametrize( @@ -205,437 +187,456 @@ async def mock_linear_arbitrary_angle(mock_id: I10Apple2) -> LinearArbitraryAngl (Pol.NONE, 11, 0, 10, 0), ], ) -async def test_i10apple2_determine_pol( - mock_id: I10Apple2, +async def test_i10_apple2_controller_determine_pol( + mock_id_controller: I10Apple2Controller, pol: None | str, top_inner_phase: float, top_outer_phase: float, btm_inner_phase: float, btm_outer_phase: float, ): - assert await mock_id.polarisation_setpoint.get_value() == Pol.NONE - set_mock_value(mock_id.motors.phase.top_inner.user_readback, top_inner_phase) - set_mock_value(mock_id.motors.phase.top_outer.user_readback, top_outer_phase) - set_mock_value(mock_id.motors.phase.btm_inner.user_readback, btm_inner_phase) - set_mock_value(mock_id.motors.phase.btm_outer.user_readback, btm_outer_phase) - if pol == Pol.NONE: - with pytest.raises(ValueError): - await mock_id.set(800) - else: - await mock_id.set(800) - assert await mock_id.polarisation.get_value() == pol - - -async def test_fail_i10apple2_set_undefined_pol(mock_id: I10Apple2): - set_mock_value(mock_id.motors.gap.user_readback, 101) - with pytest.raises(RuntimeError) as e: - await mock_id.set(600) - assert ( - str(e.value) - == mock_id.name + " is not in use, close gap or set polarisation to use this ID" + assert await mock_id_controller.polarisation_setpoint.get_value() == Pol.NONE + set_mock_value( + mock_id_controller.apple2().phase.top_inner.user_readback, top_inner_phase ) - - -async def test_fail_i10apple2_set_id_not_ready(mock_id: I10Apple2): - set_mock_value(mock_id.motors.gap.fault, 1) - with pytest.raises(RuntimeError) as e: - await mock_id.set(600) - assert str(e.value) == mock_id.motors.gap.name + " is in fault state" - set_mock_value(mock_id.motors.gap.fault, 0) - set_mock_value(mock_id.motors.gap.gate, UndulatorGateStatus.OPEN) - with pytest.raises(RuntimeError) as e: - await mock_id.set(600) - assert str(e.value) == mock_id.motors.gap.name + " is already in motion." - - -async def test_i10apple2_RE_scan(mock_id_pgm: EnergySetter, RE: RunEngine): - docs = defaultdict(list) - - def capture_emitted(name, doc): - docs[name].append(doc) - - RE(scan([], mock_id_pgm.id, 500, 600, num=11), capture_emitted) - assert_emitted(docs, start=1, descriptor=1, event=11, stop=1) - - -async def test_energySetter_re_scan(mock_id_pgm: EnergySetter, RE: RunEngine): - docs = defaultdict(list) - - def capture_emitted(name, doc): - docs[name].append(doc) - - mock_id_pgm.id._set_pol_setpoint(Pol("lh3")) - RE(scan([], mock_id_pgm, 1700, 1800, num=11), capture_emitted) - assert_emitted(docs, start=1, descriptor=1, event=11, stop=1) - # with energy offset - docs = defaultdict(list) - await mock_id_pgm.energy_offset.set(20) - rbv_mocks = Mock() - rbv_mocks.get.side_effect = range(1700, 1810, 10) - callback_on_mock_put( - mock_id_pgm.pgm_ref().energy.user_setpoint, - lambda *_, **__: set_mock_value( - mock_id_pgm.pgm_ref().energy.user_readback, rbv_mocks.get() - ), + set_mock_value( + mock_id_controller.apple2().phase.top_outer.user_readback, top_outer_phase ) - RE( - scan( - [], - mock_id_pgm, - 1700, - 1800, - num=11, - ), - capture_emitted, + set_mock_value( + mock_id_controller.apple2().phase.btm_inner.user_readback, btm_inner_phase ) - for cnt, data in enumerate(docs["event"]): - assert data["data"]["mock_id_pgm-id-energy"] == 1700 + cnt * 10 + 20 - assert data["data"]["mock_pgm-energy"] == 1700 + cnt * 10 - - -@pytest.mark.parametrize( - "pol,energy, expect_top_outer, expect_top_inner, expect_btm_inner,expect_btm_outer, expect_gap", - [ - (Pol.LH, 500, 0.0, 0.0, 0.0, 0.0, 23.0), - (Pol.LH, 700, 0.0, 0.0, 0.0, 0.0, 26.0), - (Pol.LH, 1000, 0.0, 0.0, 0.0, 0.0, 32.0), - (Pol.LH, 1400, 0.0, 0.0, 0.0, 0.0, 40.11), - (Pol.LH3, 1400, 0.0, 0.0, 0.0, 0.0, 21.8), # force LH3 lookup table to be used - (Pol.LH3, 1700, 0.0, 0.0, 0.0, 0.0, 23.93), - (Pol.LH3, 1900, 0.0, 0.0, 0.0, 0.0, 25.0), - (Pol.LH3, 2090, 0.0, 0.0, 0.0, 0.0, 26.0), - (Pol.LV, 600, 24.0, 0.0, 24.0, 0.0, 17.0), - (Pol.LV, 900, 24.0, 0.0, 24.0, 0.0, 21.0), - (Pol.LV, 1200, 24.0, 0.0, 24.0, 0.0, 25.0), - (Pol.PC, 500, 15.5, 0.0, 15.5, 0.0, 17.0), - (Pol.PC, 700, 16, 0.0, 16, 0.0, 21.0), - (Pol.PC, 1000, 16.5, 0.0, 16.5, 0.0, 25.0), - (Pol.NC, 500, -15.5, 0.0, -15.5, 0.0, 17.0), - (Pol.NC, 800, -16, 0.0, -16, 0.0, 22.0), - (Pol.NC, 1000, -16.5, 0.0, -16.5, 0.0, 25.0), - (Pol.LA, 700, -15.2, 0.0, 15.2, 0.0, 16.5), - (Pol.LA, 900, -15.6, 0.0, 15.6, 0.0, 19.0), - (Pol.LA, 1300, -16.4, 0.0, 16.4, 0.0, 25.0), - ("dsf", 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), - ], -) -async def test_i10apple2_pol_set( - mock_id_pol: I10Apple2Pol, - pol: Pol, - energy: float, - expect_top_inner: float, - expect_top_outer: float, - expect_btm_inner: float, - expect_btm_outer: float, - expect_gap: float, -): - mock_id_pol.id_ref()._set_energy_rbv(energy) - - if pol == "dsf": + set_mock_value( + mock_id_controller.apple2().phase.btm_outer.user_readback, btm_outer_phase + ) + if pol == Pol.NONE: with pytest.raises(ValueError): - await mock_id_pol.set(pol) + await mock_id_controller.energy.set(800) else: - await mock_id_pol.set(pol) - - top_inner = get_mock_put( - mock_id_pol.id_ref().motors.phase.top_inner.user_setpoint - ) - top_inner.assert_called_once() - assert float(top_inner.call_args[0][0]) == pytest.approx(expect_top_inner, 0.01) - - top_outer = get_mock_put( - mock_id_pol.id_ref().motors.phase.top_outer.user_setpoint - ) - top_outer.assert_called_once() - assert float(top_outer.call_args[0][0]) == pytest.approx(expect_top_outer, 0.01) - - btm_inner = get_mock_put( - mock_id_pol.id_ref().motors.phase.btm_inner.user_setpoint - ) - btm_inner.assert_called_once() - assert float(btm_inner.call_args[0][0]) == pytest.approx(expect_btm_inner, 0.01) - - btm_outer = get_mock_put( - mock_id_pol.id_ref().motors.phase.btm_outer.user_setpoint - ) - btm_outer.assert_called_once() - assert float(btm_outer.call_args[0][0]) == pytest.approx(expect_btm_outer, 0.01) - - gap = get_mock_put(mock_id_pol.id_ref().motors.gap.user_setpoint) - gap.assert_called_once() - assert float(gap.call_args[0][0]) == pytest.approx(expect_gap, 0.05) + await mock_id_controller.energy.set(800) + assert await mock_id_controller.polarisation.get_value() == pol -@pytest.mark.parametrize( - "pol,energy, top_outer, top_inner, btm_inner,btm_outer", - [ - (Pol.LH, 500, 0.0, 0.0, 0.0, 0.0), - (Pol.LV, 600, 24.0, 0.0, 24.0, 0.0), - (Pol.PC, 500, 15.5, 0.0, 15.5, 0.0), - (Pol.NC, 500, -15.5, 0.0, -15.5, 0.0), - (Pol.LA, 1300, -16.4, 0.0, 16.4, 0.0), - ], -) -async def test_i10apple2_pol_read_check_pol_from_hardware( - mock_id_pol: I10Apple2Pol, - pol: str, - energy: float, - top_inner: float, - top_outer: float, - btm_inner: float, - btm_outer: float, -): - mock_id_pol.id_ref()._set_energy_rbv(energy) - - set_mock_value(mock_id_pol.id_ref().motors.phase.top_inner.user_readback, top_inner) - set_mock_value(mock_id_pol.id_ref().motors.phase.top_outer.user_readback, top_outer) - set_mock_value(mock_id_pol.id_ref().motors.phase.btm_inner.user_readback, btm_inner) - set_mock_value(mock_id_pol.id_ref().motors.phase.btm_outer.user_readback, btm_outer) - - assert (await mock_id_pol.read())["mock_id-polarisation"]["value"] == pol - - -@pytest.mark.parametrize( - "pol,energy, top_outer, top_inner, btm_inner,btm_outer", - [ - ("lh3", 500, 0.0, 0.0, 0.0, 0.0), - ], -) -async def test_i10apple2_pol_read_leave_lh3_unchanged_when_hardware_match( - mock_id_pol: I10Apple2Pol, - pol: str, - energy: float, - top_inner: float, - top_outer: float, - btm_inner: float, - btm_outer: float, -): - mock_id_pol.id_ref()._set_energy_rbv(energy) - mock_id_pol.id_ref()._set_pol_setpoint(Pol("lh3")) - set_mock_value(mock_id_pol.id_ref().motors.phase.top_inner.user_readback, top_inner) - set_mock_value(mock_id_pol.id_ref().motors.phase.top_outer.user_readback, top_outer) - set_mock_value(mock_id_pol.id_ref().motors.phase.btm_inner.user_readback, btm_inner) - set_mock_value(mock_id_pol.id_ref().motors.phase.btm_outer.user_readback, btm_outer) - assert (await mock_id_pol.read())["mock_id-polarisation"]["value"] == pol - - -async def test_linear_arbitrary_pol_fail( - mock_linear_arbitrary_angle: LinearArbitraryAngle, +async def test_fail_i10_apple2_controller_set_undefined_pol( + mock_id_controller: I10Apple2Controller, ): + set_mock_value(mock_id_controller.apple2().gap.user_readback, 101) with pytest.raises(RuntimeError) as e: - await mock_linear_arbitrary_angle.set(20) - assert str(e.value) == ( - f"Angle control is not available in polarisation" - f" {await mock_linear_arbitrary_angle.id_ref().polarisation.get_value()} with {mock_linear_arbitrary_angle.id_ref().name}" + await mock_id_controller.energy.set(600) + assert ( + str(e.value) + == mock_id_controller.name + + " is not in use, close gap or set polarisation to use this ID" ) -@pytest.mark.parametrize( - "poly", - [18, -18, 12.01, -12.01], -) -async def test_linear_arbitrary_limit_fail( - mock_linear_arbitrary_angle: LinearArbitraryAngle, poly: float +async def test_fail_i10_apple2_controller_set_id_not_ready( + mock_id_controller: I10Apple2Controller, ): - set_mock_value( - mock_linear_arbitrary_angle.id_ref().motors.phase.top_inner.user_readback, - 16.4, - ) - set_mock_value( - mock_linear_arbitrary_angle.id_ref().motors.phase.top_outer.user_readback, 0 - ) - set_mock_value( - mock_linear_arbitrary_angle.id_ref().motors.phase.btm_inner.user_readback, 0 - ) - set_mock_value( - mock_linear_arbitrary_angle.id_ref().motors.phase.btm_outer.user_readback, -16.4 - ) - mock_linear_arbitrary_angle.jaw_phase_from_angle = poly1d([poly]) + set_mock_value(mock_id_controller.apple2().gap.fault, 1) + with pytest.raises(RuntimeError) as e: + await mock_id_controller.energy.set(600) + assert str(e.value) == mock_id_controller.apple2().gap.name + " is in fault state" + set_mock_value(mock_id_controller.apple2().gap.fault, 0) + set_mock_value(mock_id_controller.apple2().gap.gate, UndulatorGateStatus.OPEN) with pytest.raises(RuntimeError) as e: - await mock_linear_arbitrary_angle.set(20) + await mock_id_controller.energy.set(600) assert ( - str(e.value) - == f"jaw_phase position for angle (20.0) is outside permitted range" - f" [-{mock_linear_arbitrary_angle.jaw_phase_limit}, {mock_linear_arbitrary_angle.jaw_phase_limit}]" + str(e.value) == mock_id_controller.apple2().gap.name + " is already in motion." ) -@pytest.mark.parametrize( - "start, stop, num_point", - [ - (-1, 180, 11), - (-20, 170, 31), - (-90, -25, 18), - ], -) -async def test_linear_arbitrary_RE_scan( - mock_linear_arbitrary_angle: LinearArbitraryAngle, - RE: RunEngine, - start: float, - stop: float, - num_point: int, -): - angles = np.linspace(start, stop, num_point, endpoint=True) +async def test_i10apple2_RE_scan(beam_energy: BeamEnergy, RE: RunEngine): docs = defaultdict(list) def capture_emitted(name, doc): docs[name].append(doc) - set_mock_value( - mock_linear_arbitrary_angle.id_ref().motors.phase.top_inner.user_readback, - 16.4, - ) - set_mock_value( - mock_linear_arbitrary_angle.id_ref().motors.phase.top_outer.user_readback, 0 - ) - set_mock_value( - mock_linear_arbitrary_angle.id_ref().motors.phase.btm_inner.user_readback, 0 - ) - set_mock_value( - mock_linear_arbitrary_angle.id_ref().motors.phase.btm_outer.user_readback, -16.4 - ) - RE( - scan( - [], - mock_linear_arbitrary_angle, - start, - stop, - num=num_point, - ), - capture_emitted, - ) - assert_emitted(docs, start=1, descriptor=1, event=num_point, stop=1) - set_mock_value( - mock_linear_arbitrary_angle.id_ref().motors.gap.gate, UndulatorGateStatus.CLOSE - ) - set_mock_value( - mock_linear_arbitrary_angle.id_ref().motors.phase.gate, - UndulatorGateStatus.CLOSE, - ) - jaw_phase = get_mock_put( - mock_linear_arbitrary_angle.id_ref().id_jaw_phase.jaw_phase.user_setpoint - ) + RE(scan([], beam_energy, 500, 600, num=11), capture_emitted) + assert_emitted(docs, start=1, descriptor=1, event=11, stop=1) - poly = poly1d( - DEFAULT_JAW_PHASE_POLY_PARAMS - ) # default setting for i10 jaw phase to angle for cnt, data in enumerate(docs["event"]): - temp_angle = angles[cnt] - assert data["data"]["mock_linear_arbitrary_angle-angle"] == temp_angle - alpha_real = ( - temp_angle - if temp_angle > mock_linear_arbitrary_angle.angle_threshold_deg - else temp_angle + 180.0 - ) # convert angle to jawphase. - assert jaw_phase.call_args_list[cnt] == mock.call( - str(poly(alpha_real)), wait=True - ) - - -@pytest.mark.parametrize( - "fileName, expected_dict_file_name, source", - [ - ( - ID_ENERGY_2_GAP_CALIBRATIONS_CSV, - EXPECTED_ID_ENERGY_2_GAP_CALIBRATIONS_IDU_PKL, - ("Source", "idu"), - ), - ( - ID_ENERGY_2_GAP_CALIBRATIONS_CSV, - EXPECTED_ID_ENERGY_2_GAP_CALIBRATIONS_IDD_PKL, - ("Source", "idd"), - ), - ( - ID_ENERGY_2_PHASE_CALIBRATIONS_CSV, - EXPECTED_ID_ENERGY_2_PHASE_CALIBRATIONS_IDU_PKL, - ("Source", "idu"), - ), - ( - ID_ENERGY_2_PHASE_CALIBRATIONS_CSV, - EXPECTED_ID_ENERGY_2_PHASE_CALIBRATIONS_IDD_PKL, - ("Source", "idd"), - ), - ], -) -def test_i10_energy_motor_lookup_convert_csv_to_lookup_success( - mock_i10_energy_motor_lookup_idu: I10EnergyMotorLookup, - fileName: str, - expected_dict_file_name: str, - source: tuple[str, str], -): - data = mock_i10_energy_motor_lookup_idu.convert_csv_to_lookup( - file=fileName, - source=source, - ) - with open(expected_dict_file_name, "rb") as f: - loaded_dict = pickle.load(f) - assert data == loaded_dict - - -def test_i10_energy_motor_lookup_convert_csv_to_lookup_failed( - mock_i10_energy_motor_lookup_idu: I10EnergyMotorLookup, -): - with pytest.raises(RuntimeError): - mock_i10_energy_motor_lookup_idu.convert_csv_to_lookup( - file=ID_ENERGY_2_GAP_CALIBRATIONS_CSV, - source=("Source", "idw"), - ) - - -async def test_fail_i10_energy_motor_lookup_no_lookup( - mock_i10_energy_motor_lookup_idu: I10EnergyMotorLookup, -): - wrong_path = "fnslkfndlsnf" - with pytest.raises(FileNotFoundError) as e: - mock_i10_energy_motor_lookup_idu.convert_csv_to_lookup( - file=wrong_path, - source=("Source", "idd"), - ) - assert str(e.value) == f"[Errno 2] No such file or directory: '{wrong_path}'" - - -@pytest.mark.parametrize("energy", [(100), (5500), (-299)]) -async def test_fail_i10_energy_motor_lookup_outside_energy_limits( - mock_id: I10Apple2, - energy: float, - mock_i10_energy_motor_lookup_idu: I10EnergyMotorLookup, -): - with pytest.raises(ValueError) as e: - await mock_id.set(energy) - assert str(e.value) == "Demanding energy must lie between {} and {} eV!".format( - mock_i10_energy_motor_lookup_idu.lookup_tables["Gap"][ - await mock_id.polarisation_setpoint.get_value() - ]["Limit"]["Minimum"], - mock_i10_energy_motor_lookup_idu.lookup_tables["Gap"][ - await mock_id.polarisation_setpoint.get_value() - ]["Limit"]["Maximum"], - ) - - -async def test_fail_i10_energy_motor_lookup_with_lookup_gap( - mock_id: I10Apple2, - mock_i10_energy_motor_lookup_idu: I10EnergyMotorLookup, -): - mock_i10_energy_motor_lookup_idu.update_lookuptable() - # make gap in energy - mock_i10_energy_motor_lookup_idu.lookup_tables["Gap"]["lh"]["Energies"] = { - "1": { - "Low": 255.3, - "High": 500, - "Poly": poly1d([4.33435e-08, -7.52562e-05, 6.41791e-02, 3.88755e00]), - } - } - mock_i10_energy_motor_lookup_idu.lookup_tables["Gap"]["lh"]["Energies"] = { - "2": { - "Low": 600, - "High": 1000, - "Poly": poly1d([4.33435e-08, -7.52562e-05, 6.41791e-02, 3.88755e00]), - } - } - with pytest.raises(ValueError) as e: - await mock_id.set(555) - assert ( - str(e.value) - == """Cannot find polynomial coefficients for your requested energy. - There might be gap in the calibration lookup table.""" - ) + assert data["data"]["mock_id_controller-energy"] == 500 + cnt * 10 + assert data["data"]["mock_pgm-energy"] == 500 + cnt * 10 + + +# async def test_energySetter_re_scan(mock_id_pgm: EnergySetter, RE: RunEngine): +# docs = defaultdict(list) + +# def capture_emitted(name, doc): +# docs[name].append(doc) + +# mock_id_pgm.id._set_pol_setpoint(Pol("lh3")) +# RE(scan([], mock_id_pgm, 1700, 1800, num=11), capture_emitted) +# assert_emitted(docs, start=1, descriptor=1, event=11, stop=1) +# # with energy offset +# docs = defaultdict(list) +# await mock_id_pgm.energy_offset.set(20) +# rbv_mocks = Mock() +# rbv_mocks.get.side_effect = range(1700, 1810, 10) +# callback_on_mock_put( +# mock_id_pgm.pgm_ref().energy.user_setpoint, +# lambda *_, **__: set_mock_value( +# mock_id_pgm.pgm_ref().energy.user_readback, rbv_mocks.get() +# ), +# ) +# RE( +# scan( +# [], +# mock_id_pgm, +# 1700, +# 1800, +# num=11, +# ), +# capture_emitted, +# ) +# for cnt, data in enumerate(docs["event"]): +# assert data["data"]["mock_id_pgm-id-energy"] == 1700 + cnt * 10 + 20 +# assert data["data"]["mock_pgm-energy"] == 1700 + cnt * 10 + + +# @pytest.mark.parametrize( +# "pol,energy, expect_top_outer, expect_top_inner, expect_btm_inner,expect_btm_outer, expect_gap", +# [ +# (Pol.LH, 500, 0.0, 0.0, 0.0, 0.0, 23.0), +# (Pol.LH, 700, 0.0, 0.0, 0.0, 0.0, 26.0), +# (Pol.LH, 1000, 0.0, 0.0, 0.0, 0.0, 32.0), +# (Pol.LH, 1400, 0.0, 0.0, 0.0, 0.0, 40.11), +# (Pol.LH3, 1400, 0.0, 0.0, 0.0, 0.0, 21.8), # force LH3 lookup table to be used +# (Pol.LH3, 1700, 0.0, 0.0, 0.0, 0.0, 23.93), +# (Pol.LH3, 1900, 0.0, 0.0, 0.0, 0.0, 25.0), +# (Pol.LH3, 2090, 0.0, 0.0, 0.0, 0.0, 26.0), +# (Pol.LV, 600, 24.0, 0.0, 24.0, 0.0, 17.0), +# (Pol.LV, 900, 24.0, 0.0, 24.0, 0.0, 21.0), +# (Pol.LV, 1200, 24.0, 0.0, 24.0, 0.0, 25.0), +# (Pol.PC, 500, 15.5, 0.0, 15.5, 0.0, 17.0), +# (Pol.PC, 700, 16, 0.0, 16, 0.0, 21.0), +# (Pol.PC, 1000, 16.5, 0.0, 16.5, 0.0, 25.0), +# (Pol.NC, 500, -15.5, 0.0, -15.5, 0.0, 17.0), +# (Pol.NC, 800, -16, 0.0, -16, 0.0, 22.0), +# (Pol.NC, 1000, -16.5, 0.0, -16.5, 0.0, 25.0), +# (Pol.LA, 700, -15.2, 0.0, 15.2, 0.0, 16.5), +# (Pol.LA, 900, -15.6, 0.0, 15.6, 0.0, 19.0), +# (Pol.LA, 1300, -16.4, 0.0, 16.4, 0.0, 25.0), +# ("dsf", 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), +# ], +# ) +# async def test_i10apple2_pol_set( +# mock_id_pol: I10Apple2Pol, +# pol: Pol, +# energy: float, +# expect_top_inner: float, +# expect_top_outer: float, +# expect_btm_inner: float, +# expect_btm_outer: float, +# expect_gap: float, +# ): +# mock_id_pol.id_ref()._set_energy_rbv(energy) + +# if pol == "dsf": +# with pytest.raises(ValueError): +# await mock_id_pol.set(pol) +# else: +# await mock_id_pol.set(pol) + +# top_inner = get_mock_put( +# mock_id_pol.id_ref().motors.phase.top_inner.user_setpoint +# ) +# top_inner.assert_called_once() +# assert float(top_inner.call_args[0][0]) == pytest.approx(expect_top_inner, 0.01) + +# top_outer = get_mock_put( +# mock_id_pol.id_ref().motors.phase.top_outer.user_setpoint +# ) +# top_outer.assert_called_once() +# assert float(top_outer.call_args[0][0]) == pytest.approx(expect_top_outer, 0.01) + +# btm_inner = get_mock_put( +# mock_id_pol.id_ref().motors.phase.btm_inner.user_setpoint +# ) +# btm_inner.assert_called_once() +# assert float(btm_inner.call_args[0][0]) == pytest.approx(expect_btm_inner, 0.01) + +# btm_outer = get_mock_put( +# mock_id_pol.id_ref().motors.phase.btm_outer.user_setpoint +# ) +# btm_outer.assert_called_once() +# assert float(btm_outer.call_args[0][0]) == pytest.approx(expect_btm_outer, 0.01) + +# gap = get_mock_put(mock_id_pol.id_ref().motors.gap.user_setpoint) +# gap.assert_called_once() +# assert float(gap.call_args[0][0]) == pytest.approx(expect_gap, 0.05) + + +# @pytest.mark.parametrize( +# "pol,energy, top_outer, top_inner, btm_inner,btm_outer", +# [ +# (Pol.LH, 500, 0.0, 0.0, 0.0, 0.0), +# (Pol.LV, 600, 24.0, 0.0, 24.0, 0.0), +# (Pol.PC, 500, 15.5, 0.0, 15.5, 0.0), +# (Pol.NC, 500, -15.5, 0.0, -15.5, 0.0), +# (Pol.LA, 1300, -16.4, 0.0, 16.4, 0.0), +# ], +# ) +# async def test_i10apple2_pol_read_check_pol_from_hardware( +# mock_id_pol: I10Apple2Pol, +# pol: str, +# energy: float, +# top_inner: float, +# top_outer: float, +# btm_inner: float, +# btm_outer: float, +# ): +# mock_id_pol.id_ref()._set_energy_rbv(energy) + +# set_mock_value(mock_id_pol.id_ref().motors.phase.top_inner.user_readback, top_inner) +# set_mock_value(mock_id_pol.id_ref().motors.phase.top_outer.user_readback, top_outer) +# set_mock_value(mock_id_pol.id_ref().motors.phase.btm_inner.user_readback, btm_inner) +# set_mock_value(mock_id_pol.id_ref().motors.phase.btm_outer.user_readback, btm_outer) + +# assert (await mock_id_pol.read())["mock_id-polarisation"]["value"] == pol + + +# @pytest.mark.parametrize( +# "pol,energy, top_outer, top_inner, btm_inner,btm_outer", +# [ +# ("lh3", 500, 0.0, 0.0, 0.0, 0.0), +# ], +# ) +# async def test_i10apple2_pol_read_leave_lh3_unchanged_when_hardware_match( +# mock_id_pol: I10Apple2Pol, +# pol: str, +# energy: float, +# top_inner: float, +# top_outer: float, +# btm_inner: float, +# btm_outer: float, +# ): +# mock_id_pol.id_ref()._set_energy_rbv(energy) +# mock_id_pol.id_ref()._set_pol_setpoint(Pol("lh3")) +# set_mock_value(mock_id_pol.id_ref().motors.phase.top_inner.user_readback, top_inner) +# set_mock_value(mock_id_pol.id_ref().motors.phase.top_outer.user_readback, top_outer) +# set_mock_value(mock_id_pol.id_ref().motors.phase.btm_inner.user_readback, btm_inner) +# set_mock_value(mock_id_pol.id_ref().motors.phase.btm_outer.user_readback, btm_outer) +# assert (await mock_id_pol.read())["mock_id-polarisation"]["value"] == pol + + +# async def test_linear_arbitrary_pol_fail( +# mock_linear_arbitrary_angle: LinearArbitraryAngle, +# ): +# with pytest.raises(RuntimeError) as e: +# await mock_linear_arbitrary_angle.set(20) +# assert str(e.value) == ( +# f"Angle control is not available in polarisation" +# f" {await mock_linear_arbitrary_angle.id_ref().polarisation.get_value()} with {mock_linear_arbitrary_angle.id_ref().name}" +# ) + + +# @pytest.mark.parametrize( +# "poly", +# [18, -18, 12.01, -12.01], +# ) +# async def test_linear_arbitrary_limit_fail( +# mock_linear_arbitrary_angle: LinearArbitraryAngle, poly: float +# ): +# set_mock_value( +# mock_linear_arbitrary_angle.id_ref().motors.phase.top_inner.user_readback, +# 16.4, +# ) +# set_mock_value( +# mock_linear_arbitrary_angle.id_ref().motors.phase.top_outer.user_readback, 0 +# ) +# set_mock_value( +# mock_linear_arbitrary_angle.id_ref().motors.phase.btm_inner.user_readback, 0 +# ) +# set_mock_value( +# mock_linear_arbitrary_angle.id_ref().motors.phase.btm_outer.user_readback, -16.4 +# ) +# mock_linear_arbitrary_angle.jaw_phase_from_angle = poly1d([poly]) +# with pytest.raises(RuntimeError) as e: +# await mock_linear_arbitrary_angle.set(20) +# assert ( +# str(e.value) +# == f"jaw_phase position for angle (20.0) is outside permitted range" +# f" [-{mock_linear_arbitrary_angle.jaw_phase_limit}, {mock_linear_arbitrary_angle.jaw_phase_limit}]" +# ) + + +# @pytest.mark.parametrize( +# "start, stop, num_point", +# [ +# (-1, 180, 11), +# (-20, 170, 31), +# (-90, -25, 18), +# ], +# ) +# async def test_linear_arbitrary_RE_scan( +# mock_linear_arbitrary_angle: LinearArbitraryAngle, +# RE: RunEngine, +# start: float, +# stop: float, +# num_point: int, +# ): +# angles = np.linspace(start, stop, num_point, endpoint=True) +# docs = defaultdict(list) + +# def capture_emitted(name, doc): +# docs[name].append(doc) + +# set_mock_value( +# mock_linear_arbitrary_angle.id_ref().motors.phase.top_inner.user_readback, +# 16.4, +# ) +# set_mock_value( +# mock_linear_arbitrary_angle.id_ref().motors.phase.top_outer.user_readback, 0 +# ) +# set_mock_value( +# mock_linear_arbitrary_angle.id_ref().motors.phase.btm_inner.user_readback, 0 +# ) +# set_mock_value( +# mock_linear_arbitrary_angle.id_ref().motors.phase.btm_outer.user_readback, -16.4 +# ) +# RE( +# scan( +# [], +# mock_linear_arbitrary_angle, +# start, +# stop, +# num=num_point, +# ), +# capture_emitted, +# ) +# assert_emitted(docs, start=1, descriptor=1, event=num_point, stop=1) +# set_mock_value( +# mock_linear_arbitrary_angle.id_ref().motors.gap.gate, UndulatorGateStatus.CLOSE +# ) +# set_mock_value( +# mock_linear_arbitrary_angle.id_ref().motors.phase.gate, +# UndulatorGateStatus.CLOSE, +# ) +# jaw_phase = get_mock_put( +# mock_linear_arbitrary_angle.id_ref().id_jaw_phase.jaw_phase.user_setpoint +# ) + +# poly = poly1d( +# DEFAULT_JAW_PHASE_POLY_PARAMS +# ) # default setting for i10 jaw phase to angle +# for cnt, data in enumerate(docs["event"]): +# temp_angle = angles[cnt] +# assert data["data"]["mock_linear_arbitrary_angle-angle"] == temp_angle +# alpha_real = ( +# temp_angle +# if temp_angle > mock_linear_arbitrary_angle.angle_threshold_deg +# else temp_angle + 180.0 +# ) # convert angle to jawphase. +# assert jaw_phase.call_args_list[cnt] == mock.call( +# str(poly(alpha_real)), wait=True +# ) + + +# @pytest.mark.parametrize( +# "fileName, expected_dict_file_name, source", +# [ +# ( +# ID_ENERGY_2_GAP_CALIBRATIONS_CSV, +# EXPECTED_ID_ENERGY_2_GAP_CALIBRATIONS_IDU_PKL, +# ("Source", "idu"), +# ), +# ( +# ID_ENERGY_2_GAP_CALIBRATIONS_CSV, +# EXPECTED_ID_ENERGY_2_GAP_CALIBRATIONS_IDD_PKL, +# ("Source", "idd"), +# ), +# ( +# ID_ENERGY_2_PHASE_CALIBRATIONS_CSV, +# EXPECTED_ID_ENERGY_2_PHASE_CALIBRATIONS_IDU_PKL, +# ("Source", "idu"), +# ), +# ( +# ID_ENERGY_2_PHASE_CALIBRATIONS_CSV, +# EXPECTED_ID_ENERGY_2_PHASE_CALIBRATIONS_IDD_PKL, +# ("Source", "idd"), +# ), +# ], +# ) +# def test_i10_energy_motor_lookup_convert_csv_to_lookup_success( +# mock_i10_energy_motor_lookup_idu: I10EnergyMotorLookup, +# fileName: str, +# expected_dict_file_name: str, +# source: tuple[str, str], +# ): +# data = mock_i10_energy_motor_lookup_idu.convert_csv_to_lookup( +# file=fileName, +# source=source, +# ) +# with open(expected_dict_file_name, "rb") as f: +# loaded_dict = pickle.load(f) +# assert data == loaded_dict + + +# def test_i10_energy_motor_lookup_convert_csv_to_lookup_failed( +# mock_i10_energy_motor_lookup_idu: I10EnergyMotorLookup, +# ): +# with pytest.raises(RuntimeError): +# mock_i10_energy_motor_lookup_idu.convert_csv_to_lookup( +# file=ID_ENERGY_2_GAP_CALIBRATIONS_CSV, +# source=("Source", "idw"), +# ) + + +# async def test_fail_i10_energy_motor_lookup_no_lookup( +# mock_i10_energy_motor_lookup_idu: I10EnergyMotorLookup, +# ): +# wrong_path = "fnslkfndlsnf" +# with pytest.raises(FileNotFoundError) as e: +# mock_i10_energy_motor_lookup_idu.convert_csv_to_lookup( +# file=wrong_path, +# source=("Source", "idd"), +# ) +# assert str(e.value) == f"[Errno 2] No such file or directory: '{wrong_path}'" + + +# @pytest.mark.parametrize("energy", [(100), (5500), (-299)]) +# async def test_fail_i10_energy_motor_lookup_outside_energy_limits( +# mock_id: I10Apple2, +# energy: float, +# mock_i10_energy_motor_lookup_idu: I10EnergyMotorLookup, +# ): +# with pytest.raises(ValueError) as e: +# await mock_id.set(energy) +# assert str(e.value) == "Demanding energy must lie between {} and {} eV!".format( +# mock_i10_energy_motor_lookup_idu.lookup_tables["Gap"][ +# await mock_id.polarisation_setpoint.get_value() +# ]["Limit"]["Minimum"], +# mock_i10_energy_motor_lookup_idu.lookup_tables["Gap"][ +# await mock_id.polarisation_setpoint.get_value() +# ]["Limit"]["Maximum"], +# ) + + +# async def test_fail_i10_energy_motor_lookup_with_lookup_gap( +# mock_id: I10Apple2, +# mock_i10_energy_motor_lookup_idu: I10EnergyMotorLookup, +# ): +# mock_i10_energy_motor_lookup_idu.update_lookuptable() +# # make gap in energy +# mock_i10_energy_motor_lookup_idu.lookup_tables["Gap"]["lh"]["Energies"] = { +# "1": { +# "Low": 255.3, +# "High": 500, +# "Poly": poly1d([4.33435e-08, -7.52562e-05, 6.41791e-02, 3.88755e00]), +# } +# } +# mock_i10_energy_motor_lookup_idu.lookup_tables["Gap"]["lh"]["Energies"] = { +# "2": { +# "Low": 600, +# "High": 1000, +# "Poly": poly1d([4.33435e-08, -7.52562e-05, 6.41791e-02, 3.88755e00]), +# } +# } +# with pytest.raises(ValueError) as e: +# await mock_id.set(555) +# assert ( +# str(e.value) +# == """Cannot find polynomial coefficients for your requested energy. +# There might be gap in the calibration lookup table.""" +# ) From 422bad7debe66aba44de3b0e653f42fcded8117b Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Mon, 6 Oct 2025 16:22:00 +0000 Subject: [PATCH 02/30] fix pol tests --- src/dodal/devices/apple2_undulator.py | 14 +- tests/devices/i10/test_i10Apple2.py | 316 ++++++++++++++------------ 2 files changed, 178 insertions(+), 152 deletions(-) diff --git a/src/dodal/devices/apple2_undulator.py b/src/dodal/devices/apple2_undulator.py index e9afd3ec97..a30ec83e4d 100644 --- a/src/dodal/devices/apple2_undulator.py +++ b/src/dodal/devices/apple2_undulator.py @@ -625,13 +625,21 @@ def set(self, energy: float) -> Status: return self.id_controller().set(energy) -class IdPol(StandardReadable, Movable): +class IdPolarisation(StandardReadable, Movable): def __init__(self, id_controller: Apple2Controller, name: str = "") -> None: self.id_controller = Reference(id_controller) super().__init__(name=name) - def set(self, pol: Pol) -> Status: - return self.id_controller().polarisation.set(pol) + self.add_readables( + [ + self.id_controller().polarisation, + ], + StandardReadableFormat.HINTED_SIGNAL, + ) + + @AsyncStatus.wrap + async def set(self, pol: Pol) -> None: + await self.id_controller().polarisation.set(pol) class BeamEnergy(StandardReadable, Movable[float]): diff --git a/tests/devices/i10/test_i10Apple2.py b/tests/devices/i10/test_i10Apple2.py index dc8a24cc37..bb1ee440e4 100644 --- a/tests/devices/i10/test_i10Apple2.py +++ b/tests/devices/i10/test_i10Apple2.py @@ -1,7 +1,7 @@ import os from collections import defaultdict from unittest import mock -from unittest.mock import MagicMock +from unittest.mock import MagicMock, Mock import pytest from bluesky.plans import scan @@ -10,12 +10,15 @@ from ophyd_async.core import init_devices from ophyd_async.testing import ( assert_emitted, + callback_on_mock_put, + get_mock_put, set_mock_value, ) from dodal.devices.apple2_undulator import ( Apple2, BeamEnergy, + IdPolarisation, Pol, UndulatorGap, UndulatorGateStatus, @@ -158,12 +161,12 @@ async def beam_energy( return beam_energy -# @pytest.fixture -# async def mock_id_pol(mock_id: I10Apple2) -> I10Apple2Pol: -# async with init_devices(mock=True): -# mock_id_pol = I10Apple2Pol(id=mock_id) +@pytest.fixture +async def mock_id_pol(mock_id_controller: I10Apple2Controller) -> IdPolarisation: + async with init_devices(mock=True): + mock_id_pol = IdPolarisation(id_controller=mock_id_controller) -# return mock_id_pol + return mock_id_pol # @pytest.fixture @@ -259,165 +262,180 @@ def capture_emitted(name, doc): assert data["data"]["mock_pgm-energy"] == 500 + cnt * 10 -# async def test_energySetter_re_scan(mock_id_pgm: EnergySetter, RE: RunEngine): -# docs = defaultdict(list) +async def test_beam_energy_re_scan( + beam_energy: BeamEnergy, mock_id_controller: I10Apple2Controller, RE: RunEngine +): + docs = defaultdict(list) -# def capture_emitted(name, doc): -# docs[name].append(doc) + def capture_emitted(name, doc): + docs[name].append(doc) -# mock_id_pgm.id._set_pol_setpoint(Pol("lh3")) -# RE(scan([], mock_id_pgm, 1700, 1800, num=11), capture_emitted) -# assert_emitted(docs, start=1, descriptor=1, event=11, stop=1) -# # with energy offset -# docs = defaultdict(list) -# await mock_id_pgm.energy_offset.set(20) -# rbv_mocks = Mock() -# rbv_mocks.get.side_effect = range(1700, 1810, 10) -# callback_on_mock_put( -# mock_id_pgm.pgm_ref().energy.user_setpoint, -# lambda *_, **__: set_mock_value( -# mock_id_pgm.pgm_ref().energy.user_readback, rbv_mocks.get() -# ), -# ) -# RE( -# scan( -# [], -# mock_id_pgm, -# 1700, -# 1800, -# num=11, -# ), -# capture_emitted, -# ) -# for cnt, data in enumerate(docs["event"]): -# assert data["data"]["mock_id_pgm-id-energy"] == 1700 + cnt * 10 + 20 -# assert data["data"]["mock_pgm-energy"] == 1700 + cnt * 10 + mock_id_controller._set_pol_setpoint(Pol("lh3")) + # with energy offset + await beam_energy.energy_offset.set(20) + rbv_mocks = Mock() + rbv_mocks.get.side_effect = range(1700, 1810, 10) + callback_on_mock_put( + beam_energy.pgm_ref().energy.user_setpoint, + lambda *_, **__: set_mock_value( + beam_energy.pgm_ref().energy.user_readback, rbv_mocks.get() + ), + ) + RE( + scan( + [], + beam_energy, + 1700, + 1800, + num=11, + ), + capture_emitted, + ) + for cnt, data in enumerate(docs["event"]): + assert data["data"]["mock_id_controller-energy"] == 1700 + cnt * 10 + 20 + assert data["data"]["mock_pgm-energy"] == 1700 + cnt * 10 -# @pytest.mark.parametrize( -# "pol,energy, expect_top_outer, expect_top_inner, expect_btm_inner,expect_btm_outer, expect_gap", -# [ -# (Pol.LH, 500, 0.0, 0.0, 0.0, 0.0, 23.0), -# (Pol.LH, 700, 0.0, 0.0, 0.0, 0.0, 26.0), -# (Pol.LH, 1000, 0.0, 0.0, 0.0, 0.0, 32.0), -# (Pol.LH, 1400, 0.0, 0.0, 0.0, 0.0, 40.11), -# (Pol.LH3, 1400, 0.0, 0.0, 0.0, 0.0, 21.8), # force LH3 lookup table to be used -# (Pol.LH3, 1700, 0.0, 0.0, 0.0, 0.0, 23.93), -# (Pol.LH3, 1900, 0.0, 0.0, 0.0, 0.0, 25.0), -# (Pol.LH3, 2090, 0.0, 0.0, 0.0, 0.0, 26.0), -# (Pol.LV, 600, 24.0, 0.0, 24.0, 0.0, 17.0), -# (Pol.LV, 900, 24.0, 0.0, 24.0, 0.0, 21.0), -# (Pol.LV, 1200, 24.0, 0.0, 24.0, 0.0, 25.0), -# (Pol.PC, 500, 15.5, 0.0, 15.5, 0.0, 17.0), -# (Pol.PC, 700, 16, 0.0, 16, 0.0, 21.0), -# (Pol.PC, 1000, 16.5, 0.0, 16.5, 0.0, 25.0), -# (Pol.NC, 500, -15.5, 0.0, -15.5, 0.0, 17.0), -# (Pol.NC, 800, -16, 0.0, -16, 0.0, 22.0), -# (Pol.NC, 1000, -16.5, 0.0, -16.5, 0.0, 25.0), -# (Pol.LA, 700, -15.2, 0.0, 15.2, 0.0, 16.5), -# (Pol.LA, 900, -15.6, 0.0, 15.6, 0.0, 19.0), -# (Pol.LA, 1300, -16.4, 0.0, 16.4, 0.0, 25.0), -# ("dsf", 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), -# ], -# ) -# async def test_i10apple2_pol_set( -# mock_id_pol: I10Apple2Pol, -# pol: Pol, -# energy: float, -# expect_top_inner: float, -# expect_top_outer: float, -# expect_btm_inner: float, -# expect_btm_outer: float, -# expect_gap: float, -# ): -# mock_id_pol.id_ref()._set_energy_rbv(energy) +@pytest.mark.parametrize( + "pol,energy, expect_top_outer, expect_top_inner, expect_btm_inner,expect_btm_outer, expect_gap", + [ + (Pol.LH, 500, 0.0, 0.0, 0.0, 0.0, 23.0), + (Pol.LH, 700, 0.0, 0.0, 0.0, 0.0, 26.0), + (Pol.LH, 1000, 0.0, 0.0, 0.0, 0.0, 32.0), + (Pol.LH, 1400, 0.0, 0.0, 0.0, 0.0, 40.11), + (Pol.LH3, 1400, 0.0, 0.0, 0.0, 0.0, 21.8), # force LH3 lookup table to be used + (Pol.LH3, 1700, 0.0, 0.0, 0.0, 0.0, 23.93), + (Pol.LH3, 1900, 0.0, 0.0, 0.0, 0.0, 25.0), + (Pol.LH3, 2090, 0.0, 0.0, 0.0, 0.0, 26.0), + (Pol.LV, 600, 24.0, 0.0, 24.0, 0.0, 17.0), + (Pol.LV, 900, 24.0, 0.0, 24.0, 0.0, 21.0), + (Pol.LV, 1200, 24.0, 0.0, 24.0, 0.0, 25.0), + (Pol.PC, 500, 15.5, 0.0, 15.5, 0.0, 17.0), + (Pol.PC, 700, 16, 0.0, 16, 0.0, 21.0), + (Pol.PC, 1000, 16.5, 0.0, 16.5, 0.0, 25.0), + (Pol.NC, 500, -15.5, 0.0, -15.5, 0.0, 17.0), + (Pol.NC, 800, -16, 0.0, -16, 0.0, 22.0), + (Pol.NC, 1000, -16.5, 0.0, -16.5, 0.0, 25.0), + (Pol.LA, 700, -15.2, 0.0, 15.2, 0.0, 16.5), + (Pol.LA, 900, -15.6, 0.0, 15.6, 0.0, 19.0), + (Pol.LA, 1300, -16.4, 0.0, 16.4, 0.0, 25.0), + ("dsf", 0.0, 0.0, 0.0, 0.0, 0.0, 0.0), + ], +) +async def test_id_polarisation_set( + mock_id_pol: IdPolarisation, + pol: Pol, + energy: float, + expect_top_inner: float, + expect_top_outer: float, + expect_btm_inner: float, + expect_btm_outer: float, + expect_gap: float, +): + set_mock_value(mock_id_pol.id_controller()._energy, energy) -# if pol == "dsf": -# with pytest.raises(ValueError): -# await mock_id_pol.set(pol) -# else: -# await mock_id_pol.set(pol) + if pol == "dsf": + with pytest.raises(ValueError): + await mock_id_pol.set(pol) + else: + await mock_id_pol.set(pol) -# top_inner = get_mock_put( -# mock_id_pol.id_ref().motors.phase.top_inner.user_setpoint -# ) -# top_inner.assert_called_once() -# assert float(top_inner.call_args[0][0]) == pytest.approx(expect_top_inner, 0.01) + top_inner = get_mock_put( + mock_id_pol.id_controller().apple2().phase.top_inner.user_setpoint + ) + top_inner.assert_called_once() + assert float(top_inner.call_args[0][0]) == pytest.approx(expect_top_inner, 0.01) -# top_outer = get_mock_put( -# mock_id_pol.id_ref().motors.phase.top_outer.user_setpoint -# ) -# top_outer.assert_called_once() -# assert float(top_outer.call_args[0][0]) == pytest.approx(expect_top_outer, 0.01) + top_outer = get_mock_put( + mock_id_pol.id_controller().apple2().phase.top_outer.user_setpoint + ) + top_outer.assert_called_once() + assert float(top_outer.call_args[0][0]) == pytest.approx(expect_top_outer, 0.01) -# btm_inner = get_mock_put( -# mock_id_pol.id_ref().motors.phase.btm_inner.user_setpoint -# ) -# btm_inner.assert_called_once() -# assert float(btm_inner.call_args[0][0]) == pytest.approx(expect_btm_inner, 0.01) + btm_inner = get_mock_put( + mock_id_pol.id_controller().apple2().phase.btm_inner.user_setpoint + ) + btm_inner.assert_called_once() + assert float(btm_inner.call_args[0][0]) == pytest.approx(expect_btm_inner, 0.01) -# btm_outer = get_mock_put( -# mock_id_pol.id_ref().motors.phase.btm_outer.user_setpoint -# ) -# btm_outer.assert_called_once() -# assert float(btm_outer.call_args[0][0]) == pytest.approx(expect_btm_outer, 0.01) + btm_outer = get_mock_put( + mock_id_pol.id_controller().apple2().phase.btm_outer.user_setpoint + ) + btm_outer.assert_called_once() + assert float(btm_outer.call_args[0][0]) == pytest.approx(expect_btm_outer, 0.01) -# gap = get_mock_put(mock_id_pol.id_ref().motors.gap.user_setpoint) -# gap.assert_called_once() -# assert float(gap.call_args[0][0]) == pytest.approx(expect_gap, 0.05) + gap = get_mock_put(mock_id_pol.id_controller().apple2().gap.user_setpoint) + gap.assert_called_once() + assert float(gap.call_args[0][0]) == pytest.approx(expect_gap, 0.05) -# @pytest.mark.parametrize( -# "pol,energy, top_outer, top_inner, btm_inner,btm_outer", -# [ -# (Pol.LH, 500, 0.0, 0.0, 0.0, 0.0), -# (Pol.LV, 600, 24.0, 0.0, 24.0, 0.0), -# (Pol.PC, 500, 15.5, 0.0, 15.5, 0.0), -# (Pol.NC, 500, -15.5, 0.0, -15.5, 0.0), -# (Pol.LA, 1300, -16.4, 0.0, 16.4, 0.0), -# ], -# ) -# async def test_i10apple2_pol_read_check_pol_from_hardware( -# mock_id_pol: I10Apple2Pol, -# pol: str, -# energy: float, -# top_inner: float, -# top_outer: float, -# btm_inner: float, -# btm_outer: float, -# ): -# mock_id_pol.id_ref()._set_energy_rbv(energy) +@pytest.mark.parametrize( + "pol,energy, top_outer, top_inner, btm_inner,btm_outer", + [ + (Pol.LH, 500, 0.0, 0.0, 0.0, 0.0), + (Pol.LV, 600, 24.0, 0.0, 24.0, 0.0), + (Pol.PC, 500, 15.5, 0.0, 15.5, 0.0), + (Pol.NC, 500, -15.5, 0.0, -15.5, 0.0), + (Pol.LA, 1300, -16.4, 0.0, 16.4, 0.0), + ], +) +async def test_id_polarisation_read_check_pol_from_hardware( + mock_id_pol: IdPolarisation, + pol: str, + energy: float, + top_inner: float, + top_outer: float, + btm_inner: float, + btm_outer: float, +): + set_mock_value(mock_id_pol.id_controller()._energy, energy) -# set_mock_value(mock_id_pol.id_ref().motors.phase.top_inner.user_readback, top_inner) -# set_mock_value(mock_id_pol.id_ref().motors.phase.top_outer.user_readback, top_outer) -# set_mock_value(mock_id_pol.id_ref().motors.phase.btm_inner.user_readback, btm_inner) -# set_mock_value(mock_id_pol.id_ref().motors.phase.btm_outer.user_readback, btm_outer) + set_mock_value( + mock_id_pol.id_controller().apple2().phase.top_inner.user_readback, top_inner + ) + set_mock_value( + mock_id_pol.id_controller().apple2().phase.top_outer.user_readback, top_outer + ) + set_mock_value( + mock_id_pol.id_controller().apple2().phase.btm_inner.user_readback, btm_inner + ) + set_mock_value( + mock_id_pol.id_controller().apple2().phase.btm_outer.user_readback, btm_outer + ) -# assert (await mock_id_pol.read())["mock_id-polarisation"]["value"] == pol + assert (await mock_id_pol.read())["mock_id_controller-polarisation"]["value"] == pol -# @pytest.mark.parametrize( -# "pol,energy, top_outer, top_inner, btm_inner,btm_outer", -# [ -# ("lh3", 500, 0.0, 0.0, 0.0, 0.0), -# ], -# ) -# async def test_i10apple2_pol_read_leave_lh3_unchanged_when_hardware_match( -# mock_id_pol: I10Apple2Pol, -# pol: str, -# energy: float, -# top_inner: float, -# top_outer: float, -# btm_inner: float, -# btm_outer: float, -# ): -# mock_id_pol.id_ref()._set_energy_rbv(energy) -# mock_id_pol.id_ref()._set_pol_setpoint(Pol("lh3")) -# set_mock_value(mock_id_pol.id_ref().motors.phase.top_inner.user_readback, top_inner) -# set_mock_value(mock_id_pol.id_ref().motors.phase.top_outer.user_readback, top_outer) -# set_mock_value(mock_id_pol.id_ref().motors.phase.btm_inner.user_readback, btm_inner) -# set_mock_value(mock_id_pol.id_ref().motors.phase.btm_outer.user_readback, btm_outer) -# assert (await mock_id_pol.read())["mock_id-polarisation"]["value"] == pol +@pytest.mark.parametrize( + "pol,energy, top_outer, top_inner, btm_inner,btm_outer", + [ + ("lh3", 500, 0.0, 0.0, 0.0, 0.0), + ], +) +async def test_id_polarisation_read_leave_lh3_unchanged_when_hardware_match( + mock_id_pol: IdPolarisation, + pol: str, + energy: float, + top_inner: float, + top_outer: float, + btm_inner: float, + btm_outer: float, +): + set_mock_value(mock_id_pol.id_controller()._energy, energy) + mock_id_pol.id_controller()._set_pol_setpoint(Pol("lh3")) + set_mock_value( + mock_id_pol.id_controller().apple2().phase.top_inner.user_readback, top_inner + ) + set_mock_value( + mock_id_pol.id_controller().apple2().phase.top_outer.user_readback, top_outer + ) + set_mock_value( + mock_id_pol.id_controller().apple2().phase.btm_inner.user_readback, btm_inner + ) + set_mock_value( + mock_id_pol.id_controller().apple2().phase.btm_outer.user_readback, btm_outer + ) + assert (await mock_id_pol.read())["mock_id_controller-polarisation"]["value"] == pol # async def test_linear_arbitrary_pol_fail( From 4c299b1edde3db0563c9e368ce1562d5173e0d81 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Tue, 7 Oct 2025 10:34:22 +0000 Subject: [PATCH 03/30] fix linear_arbitrary_angle --- src/dodal/devices/apple2_undulator.py | 12 +- src/dodal/devices/i10/i10_apple2.py | 88 ++++++--- tests/devices/i10/test_i10Apple2.py | 260 +++++++++++++++----------- 3 files changed, 213 insertions(+), 147 deletions(-) diff --git a/src/dodal/devices/apple2_undulator.py b/src/dodal/devices/apple2_undulator.py index a30ec83e4d..cec7b87962 100644 --- a/src/dodal/devices/apple2_undulator.py +++ b/src/dodal/devices/apple2_undulator.py @@ -662,13 +662,13 @@ def __init__( New device name. """ super().__init__(name=name) - self.id_controller = Reference(id_controller) - self.pgm_ref = Reference(pgm) + self._id_controller_ref = Reference(id_controller) + self._pgm_ref = Reference(pgm) self.add_readables( [ - self.id_controller().energy, - self.pgm_ref().energy.user_readback, + self._id_controller_ref().energy, + self._pgm_ref().energy.user_readback, ], StandardReadableFormat.HINTED_SIGNAL, ) @@ -680,8 +680,8 @@ def __init__( async def set(self, value: float) -> None: LOGGER.info(f"Moving f{self.name} energy to {value}.") await asyncio.gather( - self.id_controller().energy.set( + self._id_controller_ref().energy.set( value=value + await self.energy_offset.get_value() ), - self.pgm_ref().energy.set(value), + self._pgm_ref().energy.set(value), ) diff --git a/src/dodal/devices/i10/i10_apple2.py b/src/dodal/devices/i10/i10_apple2.py index 233207089f..d0eed3f7ec 100644 --- a/src/dodal/devices/i10/i10_apple2.py +++ b/src/dodal/devices/i10/i10_apple2.py @@ -12,7 +12,8 @@ Reference, StandardReadable, StandardReadableFormat, - soft_signal_r_and_setter, + derived_signal_rw, + soft_signal_rw, ) from pydantic import BaseModel, ConfigDict, RootModel @@ -330,6 +331,9 @@ def __init__( look_up_table_dir: str, source: tuple[str, str], config_client: ConfigServer, + jaw_phase_limit: float = 12.0, + jaw_phase_poly_param: list[float] = DEFAULT_JAW_PHASE_POLY_PARAMS, + angle_threshold_deg=30.0, name: str = "", ) -> None: """I10Id is a compound device that combines the I10-specific Apple2 undulator, @@ -348,13 +352,57 @@ def __init__( linear_arbitrary_angle : LinearArbitraryAngle A device for controlling the linear arbitrary polarization angle. """ + super().__init__(apple2=apple2, name=name) self.lookup_table_client = I10EnergyMotorLookup( look_up_table_dir=look_up_table_dir, source=source, config_client=config_client, ) + self.jaw_phase = Reference(jaw_phase) - super().__init__(apple2=apple2, name=name) + self.jaw_phase_from_angle = np.poly1d(jaw_phase_poly_param) + self.angle_threshold_deg = angle_threshold_deg + self.jaw_phase_limit = jaw_phase_limit + self._linear_arbitrary_angle = soft_signal_rw(float, initial_value=None) + + self.linear_arbitrary_angle = derived_signal_rw( + raw_to_derived=self._read_linear_arbitrary_angle, + set_derived=self._set_linear_arbitrary_angle, + pol_angle=self._linear_arbitrary_angle, + pol=self.polarisation, + ) + + def _read_linear_arbitrary_angle(self, pol_angle: float, pol: Pol) -> float: + if pol != Pol.LA: + raise RuntimeError( + "Angle control is not available in polarisation" + + f" {pol} with {self.name}" + ) + elif pol_angle is not None: + return pol_angle + else: + raise ValueError("Linear arbitrary angle is not set.") + + async def _set_linear_arbitrary_angle(self, pol_angle: float) -> None: + pol = await self.polarisation.get_value() + if pol != Pol.LA: + raise RuntimeError( + f"Angle control is not available in polarisation {pol} with {self.name}" + ) + # Moving to real angle which is 210 to 30. + alpha_real = ( + pol_angle + if pol_angle > self.angle_threshold_deg + else pol_angle + ALPHA_OFFSET + ) + jaw_phase = self.jaw_phase_from_angle(alpha_real) + if abs(jaw_phase) > self.jaw_phase_limit: + raise RuntimeError( + f"jaw_phase position for angle ({pol_angle}) is outside permitted range" + f" [-{self.jaw_phase_limit}, {self.jaw_phase_limit}]" + ) + await self.jaw_phase().set(jaw_phase) + await self._linear_arbitrary_angle.set(pol_angle) async def _set(self, value: float) -> None: """ @@ -409,9 +457,6 @@ def __init__( self, id_controller: I10Apple2Controller, name: str = "", - jaw_phase_limit: float = 12.0, - jaw_phase_poly_param: list[float] = DEFAULT_JAW_PHASE_POLY_PARAMS, - angle_threshold_deg=30.0, ) -> None: """ Parameters @@ -426,30 +471,13 @@ def __init__( polynomial parameters highest power first. """ super().__init__(name=name) - self.id_controller_ref = Reference(id_controller) - self.jaw_phase_from_angle = np.poly1d(jaw_phase_poly_param) - self.angle_threshold_deg = angle_threshold_deg - self.jaw_phase_limit = jaw_phase_limit - with self.add_children_as_readables(StandardReadableFormat.HINTED_SIGNAL): - self.angle, self._angle_set = soft_signal_r_and_setter( - float, initial_value=None - ) + self._id_controller_ref = Reference(id_controller) + + self.add_readables( + [self._id_controller_ref().linear_arbitrary_angle], + StandardReadableFormat.HINTED_SIGNAL, + ) @AsyncStatus.wrap - async def set(self, value: SupportsFloat) -> None: - value = float(value) - pol = await self.id_controller_ref().polarisation.get_value() - if pol != Pol.LA: - raise RuntimeError( - f"Angle control is not available in polarisation {pol} with {self.id_controller_ref().name}" - ) - # Moving to real angle which is 210 to 30. - alpha_real = value if value > self.angle_threshold_deg else value + ALPHA_OFFSET - jaw_phase = self.jaw_phase_from_angle(alpha_real) - if abs(jaw_phase) > self.jaw_phase_limit: - raise RuntimeError( - f"jaw_phase position for angle ({value}) is outside permitted range" - f" [-{self.jaw_phase_limit}, {self.jaw_phase_limit}]" - ) - await self.id_controller_ref().jaw_phase().set(jaw_phase) - self._angle_set(value) + async def set(self, angle: float) -> None: + await self._id_controller_ref().linear_arbitrary_angle.set(angle) diff --git a/tests/devices/i10/test_i10Apple2.py b/tests/devices/i10/test_i10Apple2.py index bb1ee440e4..ea1c333ef1 100644 --- a/tests/devices/i10/test_i10Apple2.py +++ b/tests/devices/i10/test_i10Apple2.py @@ -7,6 +7,7 @@ from bluesky.plans import scan from bluesky.run_engine import RunEngine from daq_config_server.client import ConfigServer +from numpy import linspace, poly1d from ophyd_async.core import init_devices from ophyd_async.testing import ( assert_emitted, @@ -26,7 +27,9 @@ UndulatorPhaseAxes, ) from dodal.devices.i10.i10_apple2 import ( + DEFAULT_JAW_PHASE_POLY_PARAMS, I10Apple2Controller, + LinearArbitraryAngle, ) from dodal.devices.i10.i10_setting_data import I10Grating from dodal.devices.pgm import PGM @@ -169,11 +172,15 @@ async def mock_id_pol(mock_id_controller: I10Apple2Controller) -> IdPolarisation return mock_id_pol -# @pytest.fixture -# async def mock_linear_arbitrary_angle(mock_id: I10Apple2) -> LinearArbitraryAngle: -# async with init_devices(mock=True): -# mock_linear_arbitrary_angle = LinearArbitraryAngle(id=mock_id) -# return mock_linear_arbitrary_angle +@pytest.fixture +async def mock_linear_arbitrary_angle( + mock_id_controller: I10Apple2Controller, +) -> LinearArbitraryAngle: + async with init_devices(mock=True): + mock_linear_arbitrary_angle = LinearArbitraryAngle( + id_controller=mock_id_controller + ) + return mock_linear_arbitrary_angle @pytest.mark.parametrize( @@ -276,9 +283,9 @@ def capture_emitted(name, doc): rbv_mocks = Mock() rbv_mocks.get.side_effect = range(1700, 1810, 10) callback_on_mock_put( - beam_energy.pgm_ref().energy.user_setpoint, + beam_energy._pgm_ref().energy.user_setpoint, lambda *_, **__: set_mock_value( - beam_energy.pgm_ref().energy.user_readback, rbv_mocks.get() + beam_energy._pgm_ref().energy.user_readback, rbv_mocks.get() ), ) RE( @@ -438,117 +445,148 @@ async def test_id_polarisation_read_leave_lh3_unchanged_when_hardware_match( assert (await mock_id_pol.read())["mock_id_controller-polarisation"]["value"] == pol -# async def test_linear_arbitrary_pol_fail( -# mock_linear_arbitrary_angle: LinearArbitraryAngle, -# ): -# with pytest.raises(RuntimeError) as e: -# await mock_linear_arbitrary_angle.set(20) -# assert str(e.value) == ( -# f"Angle control is not available in polarisation" -# f" {await mock_linear_arbitrary_angle.id_ref().polarisation.get_value()} with {mock_linear_arbitrary_angle.id_ref().name}" -# ) +async def test_linear_arbitrary_pol_fail( + mock_linear_arbitrary_angle: LinearArbitraryAngle, +): + with pytest.raises(RuntimeError) as e: + await mock_linear_arbitrary_angle.set(20) + assert str(e.value) == ( + f"Angle control is not available in polarisation" + f" {await mock_linear_arbitrary_angle._id_controller_ref().polarisation.get_value()}" + + f" with {mock_linear_arbitrary_angle._id_controller_ref().name}" + ) -# @pytest.mark.parametrize( -# "poly", -# [18, -18, 12.01, -12.01], -# ) -# async def test_linear_arbitrary_limit_fail( -# mock_linear_arbitrary_angle: LinearArbitraryAngle, poly: float -# ): -# set_mock_value( -# mock_linear_arbitrary_angle.id_ref().motors.phase.top_inner.user_readback, -# 16.4, -# ) -# set_mock_value( -# mock_linear_arbitrary_angle.id_ref().motors.phase.top_outer.user_readback, 0 -# ) -# set_mock_value( -# mock_linear_arbitrary_angle.id_ref().motors.phase.btm_inner.user_readback, 0 -# ) -# set_mock_value( -# mock_linear_arbitrary_angle.id_ref().motors.phase.btm_outer.user_readback, -16.4 -# ) -# mock_linear_arbitrary_angle.jaw_phase_from_angle = poly1d([poly]) -# with pytest.raises(RuntimeError) as e: -# await mock_linear_arbitrary_angle.set(20) -# assert ( -# str(e.value) -# == f"jaw_phase position for angle (20.0) is outside permitted range" -# f" [-{mock_linear_arbitrary_angle.jaw_phase_limit}, {mock_linear_arbitrary_angle.jaw_phase_limit}]" -# ) +@pytest.mark.parametrize( + "poly", + [18, -18, 12.01, -12.01], +) +async def test_linear_arbitrary_limit_fail( + mock_linear_arbitrary_angle: LinearArbitraryAngle, poly: float +): + set_mock_value( + mock_linear_arbitrary_angle._id_controller_ref() + .apple2() + .phase.top_inner.user_readback, + 16.4, + ) + set_mock_value( + mock_linear_arbitrary_angle._id_controller_ref() + .apple2() + .phase.top_outer.user_readback, + 0, + ) + set_mock_value( + mock_linear_arbitrary_angle._id_controller_ref() + .apple2() + .phase.btm_inner.user_readback, + 0, + ) + set_mock_value( + mock_linear_arbitrary_angle._id_controller_ref() + .apple2() + .phase.btm_outer.user_readback, + -16.4, + ) + mock_linear_arbitrary_angle._id_controller_ref().jaw_phase_from_angle = poly1d( + [poly] + ) + with pytest.raises(RuntimeError) as e: + await mock_linear_arbitrary_angle.set(20.0) + assert ( + str(e.value) + == f"jaw_phase position for angle (20.0) is outside permitted range" + f" [-{mock_linear_arbitrary_angle._id_controller_ref().jaw_phase_limit}," + f" {mock_linear_arbitrary_angle._id_controller_ref().jaw_phase_limit}]" + ) -# @pytest.mark.parametrize( -# "start, stop, num_point", -# [ -# (-1, 180, 11), -# (-20, 170, 31), -# (-90, -25, 18), -# ], -# ) -# async def test_linear_arbitrary_RE_scan( -# mock_linear_arbitrary_angle: LinearArbitraryAngle, -# RE: RunEngine, -# start: float, -# stop: float, -# num_point: int, -# ): -# angles = np.linspace(start, stop, num_point, endpoint=True) -# docs = defaultdict(list) +@pytest.mark.parametrize( + "start, stop, num_point", + [ + (-1, 180, 11), + (-20, 170, 31), + (-90, -25, 18), + ], +) +async def test_linear_arbitrary_RE_scan( + mock_linear_arbitrary_angle: LinearArbitraryAngle, + RE: RunEngine, + start: float, + stop: float, + num_point: int, +): + angles = linspace(start, stop, num_point, endpoint=True) + docs = defaultdict(list) -# def capture_emitted(name, doc): -# docs[name].append(doc) + def capture_emitted(name, doc): + docs[name].append(doc) -# set_mock_value( -# mock_linear_arbitrary_angle.id_ref().motors.phase.top_inner.user_readback, -# 16.4, -# ) -# set_mock_value( -# mock_linear_arbitrary_angle.id_ref().motors.phase.top_outer.user_readback, 0 -# ) -# set_mock_value( -# mock_linear_arbitrary_angle.id_ref().motors.phase.btm_inner.user_readback, 0 -# ) -# set_mock_value( -# mock_linear_arbitrary_angle.id_ref().motors.phase.btm_outer.user_readback, -16.4 -# ) -# RE( -# scan( -# [], -# mock_linear_arbitrary_angle, -# start, -# stop, -# num=num_point, -# ), -# capture_emitted, -# ) -# assert_emitted(docs, start=1, descriptor=1, event=num_point, stop=1) -# set_mock_value( -# mock_linear_arbitrary_angle.id_ref().motors.gap.gate, UndulatorGateStatus.CLOSE -# ) -# set_mock_value( -# mock_linear_arbitrary_angle.id_ref().motors.phase.gate, -# UndulatorGateStatus.CLOSE, -# ) -# jaw_phase = get_mock_put( -# mock_linear_arbitrary_angle.id_ref().id_jaw_phase.jaw_phase.user_setpoint -# ) + set_mock_value( + mock_linear_arbitrary_angle._id_controller_ref() + .apple2() + .phase.top_inner.user_readback, + 16.4, + ) + set_mock_value( + mock_linear_arbitrary_angle._id_controller_ref() + .apple2() + .phase.top_outer.user_readback, + 0, + ) + set_mock_value( + mock_linear_arbitrary_angle._id_controller_ref() + .apple2() + .phase.btm_inner.user_readback, + 0, + ) + set_mock_value( + mock_linear_arbitrary_angle._id_controller_ref() + .apple2() + .phase.btm_outer.user_readback, + -16.4, + ) + RE( + scan( + [], + mock_linear_arbitrary_angle, + start, + stop, + num=num_point, + ), + capture_emitted, + ) + assert_emitted(docs, start=1, descriptor=1, event=num_point, stop=1) + set_mock_value( + mock_linear_arbitrary_angle._id_controller_ref().apple2().gap.gate, + UndulatorGateStatus.CLOSE, + ) + set_mock_value( + mock_linear_arbitrary_angle._id_controller_ref().apple2().phase.gate, + UndulatorGateStatus.CLOSE, + ) + jaw_phase = get_mock_put( + mock_linear_arbitrary_angle._id_controller_ref() + .jaw_phase() + .jaw_phase.user_setpoint + ) -# poly = poly1d( -# DEFAULT_JAW_PHASE_POLY_PARAMS -# ) # default setting for i10 jaw phase to angle -# for cnt, data in enumerate(docs["event"]): -# temp_angle = angles[cnt] -# assert data["data"]["mock_linear_arbitrary_angle-angle"] == temp_angle -# alpha_real = ( -# temp_angle -# if temp_angle > mock_linear_arbitrary_angle.angle_threshold_deg -# else temp_angle + 180.0 -# ) # convert angle to jawphase. -# assert jaw_phase.call_args_list[cnt] == mock.call( -# str(poly(alpha_real)), wait=True -# ) + poly = poly1d( + DEFAULT_JAW_PHASE_POLY_PARAMS + ) # default setting for i10 jaw phase to angle + for cnt, data in enumerate(docs["event"]): + temp_angle = angles[cnt] + print(data["data"]) + assert data["data"]["mock_id_controller-linear_arbitrary_angle"] == temp_angle + alpha_real = ( + temp_angle + if temp_angle + > mock_linear_arbitrary_angle._id_controller_ref().angle_threshold_deg + else temp_angle + 180.0 + ) # convert angle to jawphase. + assert jaw_phase.call_args_list[cnt] == mock.call( + str(poly(alpha_real)), wait=True + ) # @pytest.mark.parametrize( From e38ada05404bbc4cbd6b7632648e664433a42695 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Tue, 7 Oct 2025 11:06:26 +0000 Subject: [PATCH 04/30] add the rest of the look up table test --- tests/devices/i10/test_i10Apple2.py | 228 +++++++++++++++------------- 1 file changed, 121 insertions(+), 107 deletions(-) diff --git a/tests/devices/i10/test_i10Apple2.py b/tests/devices/i10/test_i10Apple2.py index ea1c333ef1..907a7730a9 100644 --- a/tests/devices/i10/test_i10Apple2.py +++ b/tests/devices/i10/test_i10Apple2.py @@ -1,4 +1,5 @@ import os +import pickle from collections import defaultdict from unittest import mock from unittest.mock import MagicMock, Mock @@ -29,13 +30,19 @@ from dodal.devices.i10.i10_apple2 import ( DEFAULT_JAW_PHASE_POLY_PARAMS, I10Apple2Controller, + I10EnergyMotorLookup, LinearArbitraryAngle, ) from dodal.devices.i10.i10_setting_data import I10Grating from dodal.devices.pgm import PGM from dodal.testing import patch_motor from tests.devices.i10.test_data import ( + EXPECTED_ID_ENERGY_2_GAP_CALIBRATIONS_IDD_PKL, + EXPECTED_ID_ENERGY_2_GAP_CALIBRATIONS_IDU_PKL, + EXPECTED_ID_ENERGY_2_PHASE_CALIBRATIONS_IDD_PKL, + EXPECTED_ID_ENERGY_2_PHASE_CALIBRATIONS_IDU_PKL, ID_ENERGY_2_GAP_CALIBRATIONS_CSV, + ID_ENERGY_2_PHASE_CALIBRATIONS_CSV, LOOKUP_TABLE_PATH, ) @@ -183,6 +190,15 @@ async def mock_linear_arbitrary_angle( return mock_linear_arbitrary_angle +@pytest.fixture +def mock_i10_energy_motor_lookup_idu(mock_config_client) -> I10EnergyMotorLookup: + return I10EnergyMotorLookup( + look_up_table_dir=LOOKUP_TABLE_PATH, + source=("Source", "idu"), + config_client=mock_config_client, + ) + + @pytest.mark.parametrize( "pol, top_outer_phase,top_inner_phase,btm_inner_phase, btm_outer_phase", [ @@ -589,110 +605,108 @@ def capture_emitted(name, doc): ) -# @pytest.mark.parametrize( -# "fileName, expected_dict_file_name, source", -# [ -# ( -# ID_ENERGY_2_GAP_CALIBRATIONS_CSV, -# EXPECTED_ID_ENERGY_2_GAP_CALIBRATIONS_IDU_PKL, -# ("Source", "idu"), -# ), -# ( -# ID_ENERGY_2_GAP_CALIBRATIONS_CSV, -# EXPECTED_ID_ENERGY_2_GAP_CALIBRATIONS_IDD_PKL, -# ("Source", "idd"), -# ), -# ( -# ID_ENERGY_2_PHASE_CALIBRATIONS_CSV, -# EXPECTED_ID_ENERGY_2_PHASE_CALIBRATIONS_IDU_PKL, -# ("Source", "idu"), -# ), -# ( -# ID_ENERGY_2_PHASE_CALIBRATIONS_CSV, -# EXPECTED_ID_ENERGY_2_PHASE_CALIBRATIONS_IDD_PKL, -# ("Source", "idd"), -# ), -# ], -# ) -# def test_i10_energy_motor_lookup_convert_csv_to_lookup_success( -# mock_i10_energy_motor_lookup_idu: I10EnergyMotorLookup, -# fileName: str, -# expected_dict_file_name: str, -# source: tuple[str, str], -# ): -# data = mock_i10_energy_motor_lookup_idu.convert_csv_to_lookup( -# file=fileName, -# source=source, -# ) -# with open(expected_dict_file_name, "rb") as f: -# loaded_dict = pickle.load(f) -# assert data == loaded_dict - - -# def test_i10_energy_motor_lookup_convert_csv_to_lookup_failed( -# mock_i10_energy_motor_lookup_idu: I10EnergyMotorLookup, -# ): -# with pytest.raises(RuntimeError): -# mock_i10_energy_motor_lookup_idu.convert_csv_to_lookup( -# file=ID_ENERGY_2_GAP_CALIBRATIONS_CSV, -# source=("Source", "idw"), -# ) - - -# async def test_fail_i10_energy_motor_lookup_no_lookup( -# mock_i10_energy_motor_lookup_idu: I10EnergyMotorLookup, -# ): -# wrong_path = "fnslkfndlsnf" -# with pytest.raises(FileNotFoundError) as e: -# mock_i10_energy_motor_lookup_idu.convert_csv_to_lookup( -# file=wrong_path, -# source=("Source", "idd"), -# ) -# assert str(e.value) == f"[Errno 2] No such file or directory: '{wrong_path}'" - - -# @pytest.mark.parametrize("energy", [(100), (5500), (-299)]) -# async def test_fail_i10_energy_motor_lookup_outside_energy_limits( -# mock_id: I10Apple2, -# energy: float, -# mock_i10_energy_motor_lookup_idu: I10EnergyMotorLookup, -# ): -# with pytest.raises(ValueError) as e: -# await mock_id.set(energy) -# assert str(e.value) == "Demanding energy must lie between {} and {} eV!".format( -# mock_i10_energy_motor_lookup_idu.lookup_tables["Gap"][ -# await mock_id.polarisation_setpoint.get_value() -# ]["Limit"]["Minimum"], -# mock_i10_energy_motor_lookup_idu.lookup_tables["Gap"][ -# await mock_id.polarisation_setpoint.get_value() -# ]["Limit"]["Maximum"], -# ) - - -# async def test_fail_i10_energy_motor_lookup_with_lookup_gap( -# mock_id: I10Apple2, -# mock_i10_energy_motor_lookup_idu: I10EnergyMotorLookup, -# ): -# mock_i10_energy_motor_lookup_idu.update_lookuptable() -# # make gap in energy -# mock_i10_energy_motor_lookup_idu.lookup_tables["Gap"]["lh"]["Energies"] = { -# "1": { -# "Low": 255.3, -# "High": 500, -# "Poly": poly1d([4.33435e-08, -7.52562e-05, 6.41791e-02, 3.88755e00]), -# } -# } -# mock_i10_energy_motor_lookup_idu.lookup_tables["Gap"]["lh"]["Energies"] = { -# "2": { -# "Low": 600, -# "High": 1000, -# "Poly": poly1d([4.33435e-08, -7.52562e-05, 6.41791e-02, 3.88755e00]), -# } -# } -# with pytest.raises(ValueError) as e: -# await mock_id.set(555) -# assert ( -# str(e.value) -# == """Cannot find polynomial coefficients for your requested energy. -# There might be gap in the calibration lookup table.""" -# ) +@pytest.mark.parametrize( + "fileName, expected_dict_file_name, source", + [ + ( + ID_ENERGY_2_GAP_CALIBRATIONS_CSV, + EXPECTED_ID_ENERGY_2_GAP_CALIBRATIONS_IDU_PKL, + ("Source", "idu"), + ), + ( + ID_ENERGY_2_GAP_CALIBRATIONS_CSV, + EXPECTED_ID_ENERGY_2_GAP_CALIBRATIONS_IDD_PKL, + ("Source", "idd"), + ), + ( + ID_ENERGY_2_PHASE_CALIBRATIONS_CSV, + EXPECTED_ID_ENERGY_2_PHASE_CALIBRATIONS_IDU_PKL, + ("Source", "idu"), + ), + ( + ID_ENERGY_2_PHASE_CALIBRATIONS_CSV, + EXPECTED_ID_ENERGY_2_PHASE_CALIBRATIONS_IDD_PKL, + ("Source", "idd"), + ), + ], +) +def test_i10_energy_motor_lookup_convert_csv_to_lookup_success( + mock_i10_energy_motor_lookup_idu: I10EnergyMotorLookup, + fileName: str, + expected_dict_file_name: str, + source: tuple[str, str], +): + data = mock_i10_energy_motor_lookup_idu.convert_csv_to_lookup( + file=fileName, + source=source, + ) + with open(expected_dict_file_name, "rb") as f: + loaded_dict = pickle.load(f) + assert data == loaded_dict + + +def test_i10_energy_motor_lookup_convert_csv_to_lookup_failed( + mock_i10_energy_motor_lookup_idu: I10EnergyMotorLookup, +): + with pytest.raises(RuntimeError): + mock_i10_energy_motor_lookup_idu.convert_csv_to_lookup( + file=ID_ENERGY_2_GAP_CALIBRATIONS_CSV, + source=("Source", "idw"), + ) + + +async def test_fail_i10_energy_motor_lookup_no_lookup( + mock_i10_energy_motor_lookup_idu: I10EnergyMotorLookup, +): + wrong_path = "fnslkfndlsnf" + with pytest.raises(FileNotFoundError) as e: + mock_i10_energy_motor_lookup_idu.convert_csv_to_lookup( + file=wrong_path, + source=("Source", "idd"), + ) + assert str(e.value) == f"[Errno 2] No such file or directory: '{wrong_path}'" + + +@pytest.mark.parametrize("energy", [(100), (5500), (-299)]) +async def test_fail_i10_energy_motor_lookup_outside_energy_limits( + mock_id_controller: I10Apple2Controller, + energy: float, +): + with pytest.raises(ValueError) as e: + await mock_id_controller.energy.set(energy) + assert str(e.value) == "Demanding energy must lie between {} and {} eV!".format( + mock_id_controller.lookup_table_client.lookup_tables["Gap"][ + await mock_id_controller.polarisation_setpoint.get_value() + ]["Limit"]["Minimum"], + mock_id_controller.lookup_table_client.lookup_tables["Gap"][ + await mock_id_controller.polarisation_setpoint.get_value() + ]["Limit"]["Maximum"], + ) + + +async def test_fail_i10_energy_motor_lookup_with_lookup_gap( + mock_id_controller: I10Apple2Controller, +): + mock_id_controller.lookup_table_client.update_lookuptable() + # make gap in energy + mock_id_controller.lookup_table_client.lookup_tables["Gap"]["lh"]["Energies"] = { + "1": { + "Low": 255.3, + "High": 500, + "Poly": poly1d([4.33435e-08, -7.52562e-05, 6.41791e-02, 3.88755e00]), + } + } + mock_id_controller.lookup_table_client.lookup_tables["Gap"]["lh"]["Energies"] = { + "2": { + "Low": 600, + "High": 1000, + "Poly": poly1d([4.33435e-08, -7.52562e-05, 6.41791e-02, 3.88755e00]), + } + } + with pytest.raises(ValueError) as e: + await mock_id_controller.energy.set(555) + assert ( + str(e.value) + == """Cannot find polynomial coefficients for your requested energy. + There might be gap in the calibration lookup table.""" + ) From 03e4b7844761895bfa65503bc84aa8e3078f6b09 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Tue, 7 Oct 2025 12:30:52 +0000 Subject: [PATCH 05/30] created i10Apple2 --- src/dodal/beamlines/i10.py | 50 +++++++++++++++++---------- src/dodal/devices/apple2_undulator.py | 7 ++-- src/dodal/devices/i10/i10_apple2.py | 41 +++++++++++++++++----- tests/devices/i10/test_i10Apple2.py | 22 ++++++------ 4 files changed, 81 insertions(+), 39 deletions(-) diff --git a/src/dodal/beamlines/i10.py b/src/dodal/beamlines/i10.py index 6fb78e8b3b..a62a2192a4 100644 --- a/src/dodal/beamlines/i10.py +++ b/src/dodal/beamlines/i10.py @@ -10,11 +10,9 @@ from dodal.common.beamlines.beamline_utils import device_factory from dodal.common.beamlines.beamline_utils import set_beamline as set_utils_beamline +from dodal.devices.apple2_undulator import Apple2, UndulatorGap, UndulatorPhaseAxes from dodal.devices.current_amplifiers import CurrentAmpDet from dodal.devices.i10.diagnostics import I10Diagnostic, I10Diagnostic5ADet -from dodal.devices.i10.i10_apple2 import ( - I10Id, -) from dodal.devices.i10.i10_setting_data import I10Grating from dodal.devices.i10.mirrors import PiezoMirror from dodal.devices.i10.rasor.rasor_current_amp import RasorFemto, RasorSR570 @@ -55,39 +53,55 @@ def pgm() -> PGM: @device_factory() -def idd() -> I10Id: +def idd() -> Apple2: """i10 downstream insertion device: id.energy.set() to change beamline energy. id.energy.energy_offset.set() to change id energy offset relative to pgm. id.pol.set() to change polarisation. id.laa.set() to change polarisation angle, must be in LA mode. """ - return I10Id( - prefix=f"{PREFIX.insertion_prefix}-MO-SERVC-01:", - pgm=pgm(), - look_up_table_dir=LOOK_UPTABLE_DIR, - source=("Source", "idd"), - config_client=I10_CONF_CLIENT, + return Apple2( + id_gap=UndulatorGap(prefix=f"{PREFIX.insertion_prefix}-MO-SERVC-01:"), + id_phase=UndulatorPhaseAxes( + prefix=f"{PREFIX.insertion_prefix}-MO-SERVC-01:", + top_outer="RPQ1", + top_inner="RPQ2", + btm_inner="RPQ3", + btm_outer="RPQ4", + ), ) @device_factory() -def idu() -> I10Id: - """i10 upstream insertion device: +def idu() -> Apple2: + """i10 downstream insertion device: id.energy.set() to change beamline energy. id.energy.energy_offset.set() to change id energy offset relative to pgm. id.pol.set() to change polarisation. id.laa.set() to change polarisation angle, must be in LA mode. """ - return I10Id( - prefix=f"{PREFIX.insertion_prefix}-MO-SERVC-21:", - pgm=pgm(), - look_up_table_dir=LOOK_UPTABLE_DIR, - source=("Source", "idu"), - config_client=I10_CONF_CLIENT, + return Apple2( + id_gap=UndulatorGap(prefix=f"{PREFIX.insertion_prefix}-MO-SERVC-21:"), + id_phase=UndulatorPhaseAxes( + prefix=f"{PREFIX.insertion_prefix}-MO-SERVC-01:", + top_outer="RPQ1", + top_inner="RPQ2", + btm_inner="RPQ3", + btm_outer="RPQ4", + ), ) +# @device_factory() +# def idu_ +# @device_factory() +# def idd_controller() -> I10Apple2Controller: +# """I10 insertion device controller, it controls both idu and idd.""" +# return I10Apple2Controller( +# apple2=idd(), lookuptable_dir=LOOK_UPTABLE_DIR, config_client=I10_CONF_CLIENT +# ) + + """Mirrors""" diff --git a/src/dodal/devices/apple2_undulator.py b/src/dodal/devices/apple2_undulator.py index cec7b87962..e6dec08f50 100644 --- a/src/dodal/devices/apple2_undulator.py +++ b/src/dodal/devices/apple2_undulator.py @@ -365,7 +365,10 @@ def __call__(self, energy: float, pol: Pol) -> tuple[float, float]: ... -class Apple2Controller(abc.ABC, StandardReadable, Movable): +Apple2Type = TypeVar("Apple2Type", bound="Apple2") + + +class Apple2Controller(abc.ABC, StandardReadable, Movable, Generic[Apple2Type]): """ Apple2Controller Undulator Device @@ -419,7 +422,7 @@ class Apple2Controller(abc.ABC, StandardReadable, Movable): def __init__( self, - apple2: Apple2, + apple2: Apple2Type, name: str = "", ) -> None: """ diff --git a/src/dodal/devices/i10/i10_apple2.py b/src/dodal/devices/i10/i10_apple2.py index d0eed3f7ec..4281039a70 100644 --- a/src/dodal/devices/i10/i10_apple2.py +++ b/src/dodal/devices/i10/i10_apple2.py @@ -22,7 +22,9 @@ Apple2Controller, Apple2Val, Pol, + UndulatorGap, UndulatorJawPhase, + UndulatorPhaseAxes, ) from dodal.log import LOGGER @@ -314,7 +316,32 @@ def process_row(row: dict) -> None: return lookup_table -class I10Apple2Controller(Apple2Controller): +class I10Apple2(Apple2): + def __init__( + self, + id_gap: UndulatorGap, + id_phase: UndulatorPhaseAxes, + id_jaw_phase: UndulatorJawPhase, + name: str = "", + ) -> None: + """ + An I10Apple2 device. + + Parameters + ---------- + id_gap : UndulatorJawPhase + The gap motor of the undulator. + id_phase : UndulatorJawPhase + The phase motors of the undulator. + name : str, optional + The name of the device, by default "". + """ + with self.add_children_as_readables(): + self.jaw_phase = id_jaw_phase + super().__init__(id_gap=id_gap, id_phase=id_phase, name=name) + + +class I10Apple2Controller(Apple2Controller[I10Apple2]): """I10Apple2 is the i10 version of Apple2 ID, set and energy_motor_convertor should be the only part that is I10 specific. @@ -326,8 +353,7 @@ class I10Apple2Controller(Apple2Controller): def __init__( self, - apple2: Apple2, - jaw_phase: UndulatorJawPhase, + apple2: I10Apple2, look_up_table_dir: str, source: tuple[str, str], config_client: ConfigServer, @@ -359,7 +385,6 @@ def __init__( config_client=config_client, ) - self.jaw_phase = Reference(jaw_phase) self.jaw_phase_from_angle = np.poly1d(jaw_phase_poly_param) self.angle_threshold_deg = angle_threshold_deg self.jaw_phase_limit = jaw_phase_limit @@ -380,8 +405,6 @@ def _read_linear_arbitrary_angle(self, pol_angle: float, pol: Pol) -> float: ) elif pol_angle is not None: return pol_angle - else: - raise ValueError("Linear arbitrary angle is not set.") async def _set_linear_arbitrary_angle(self, pol_angle: float) -> None: pol = await self.polarisation.get_value() @@ -401,7 +424,7 @@ async def _set_linear_arbitrary_angle(self, pol_angle: float) -> None: f"jaw_phase position for angle ({pol_angle}) is outside permitted range" f" [-{self.jaw_phase_limit}, {self.jaw_phase_limit}]" ) - await self.jaw_phase().set(jaw_phase) + await self.apple2().jaw_phase.set(jaw_phase) await self._linear_arbitrary_angle.set(pol_angle) async def _set(self, value: float) -> None: @@ -439,8 +462,8 @@ async def _set(self, value: float) -> None: LOGGER.info(f"Setting polarisation to {pol}, with values: {id_set_val}") await self.apple2().set(id_motor_values=id_set_val) if pol != Pol.LA: - await self.jaw_phase().set(0) - await self.jaw_phase().set_move.set(1) + await self.apple2().jaw_phase.set(0) + await self.apple2().jaw_phase.set_move.set(1) class LinearArbitraryAngle(StandardReadable, Movable[SupportsFloat]): diff --git a/tests/devices/i10/test_i10Apple2.py b/tests/devices/i10/test_i10Apple2.py index 907a7730a9..c245ea5ef6 100644 --- a/tests/devices/i10/test_i10Apple2.py +++ b/tests/devices/i10/test_i10Apple2.py @@ -18,7 +18,6 @@ ) from dodal.devices.apple2_undulator import ( - Apple2, BeamEnergy, IdPolarisation, Pol, @@ -29,6 +28,7 @@ ) from dodal.devices.i10.i10_apple2 import ( DEFAULT_JAW_PHASE_POLY_PARAMS, + I10Apple2, I10Apple2Controller, I10EnergyMotorLookup, LinearArbitraryAngle, @@ -130,31 +130,33 @@ def my_side_effect(file_path, reset_cached_result) -> str: @pytest.fixture -async def mock_id(mock_id_gap, mock_phaseAxes) -> Apple2: +async def mock_id(mock_id_gap, mock_phaseAxes, mock_jaw_phase) -> I10Apple2: async with init_devices(mock=True): - mock_id = Apple2(id_gap=mock_id_gap, id_phase=mock_phaseAxes) + mock_id = I10Apple2( + id_gap=mock_id_gap, id_phase=mock_phaseAxes, id_jaw_phase=mock_jaw_phase + ) return mock_id @pytest.fixture async def mock_id_controller( - mock_id: Apple2, - mock_jaw_phase, + mock_id: I10Apple2, mock_config_client, ) -> I10Apple2Controller: async with init_devices(mock=True): mock_id_controller = I10Apple2Controller( apple2=mock_id, - jaw_phase=mock_jaw_phase, look_up_table_dir=LOOKUP_TABLE_PATH, source=("Source", "idu"), config_client=mock_config_client, ) set_mock_value(mock_id_controller.apple2().gap.gate, UndulatorGateStatus.CLOSE) set_mock_value(mock_id_controller.apple2().phase.gate, UndulatorGateStatus.CLOSE) - set_mock_value(mock_id_controller.jaw_phase().gate, UndulatorGateStatus.CLOSE) + set_mock_value( + mock_id_controller.apple2().jaw_phase.gate, UndulatorGateStatus.CLOSE + ) set_mock_value(mock_id_controller.apple2().gap.velocity, 1) - set_mock_value(mock_id_controller.jaw_phase().jaw_phase.velocity, 1) + set_mock_value(mock_id_controller.apple2().jaw_phase.jaw_phase.velocity, 1) set_mock_value(mock_id_controller.apple2().phase.btm_inner.velocity, 1) set_mock_value(mock_id_controller.apple2().phase.top_inner.velocity, 1) set_mock_value(mock_id_controller.apple2().phase.btm_outer.velocity, 1) @@ -583,8 +585,8 @@ def capture_emitted(name, doc): ) jaw_phase = get_mock_put( mock_linear_arbitrary_angle._id_controller_ref() - .jaw_phase() - .jaw_phase.user_setpoint + .apple2() + .jaw_phase.jaw_phase.user_setpoint ) poly = poly1d( From de132872ec496c21993782663bd63409770d36a0 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Tue, 7 Oct 2025 13:07:22 +0000 Subject: [PATCH 06/30] Id energy and polarisation not longer has controller --- src/dodal/devices/apple2_undulator.py | 26 +++++----- tests/devices/i10/test_i10Apple2.py | 69 +++++++++++++-------------- 2 files changed, 46 insertions(+), 49 deletions(-) diff --git a/src/dodal/devices/apple2_undulator.py b/src/dodal/devices/apple2_undulator.py index e6dec08f50..2b9e018f51 100644 --- a/src/dodal/devices/apple2_undulator.py +++ b/src/dodal/devices/apple2_undulator.py @@ -621,28 +621,28 @@ def determine_phase_from_hardware( class IdEnergy(StandardReadable, Movable): def __init__(self, id_controller: Apple2Controller, name: str = "") -> None: - self.id_controller = Reference(id_controller) - super().__init__(name=name) # + self.energy = Reference(id_controller.energy) + super().__init__(name=name) def set(self, energy: float) -> Status: - return self.id_controller().set(energy) + return self.energy().set(energy) class IdPolarisation(StandardReadable, Movable): def __init__(self, id_controller: Apple2Controller, name: str = "") -> None: - self.id_controller = Reference(id_controller) + self.polarisation = Reference(id_controller.polarisation) super().__init__(name=name) self.add_readables( [ - self.id_controller().polarisation, + self.polarisation(), ], StandardReadableFormat.HINTED_SIGNAL, ) @AsyncStatus.wrap async def set(self, pol: Pol) -> None: - await self.id_controller().polarisation.set(pol) + await self.polarisation().set(pol) class BeamEnergy(StandardReadable, Movable[float]): @@ -651,9 +651,7 @@ class BeamEnergy(StandardReadable, Movable[float]): """ - def __init__( - self, id_controller: Apple2Controller, pgm: PGM, name: str = "" - ) -> None: + def __init__(self, id_energy: IdEnergy, pgm: PGM, name: str = "") -> None: """ Parameters ---------- @@ -665,12 +663,12 @@ def __init__( New device name. """ super().__init__(name=name) - self._id_controller_ref = Reference(id_controller) + self._IdEnergy = Reference(id_energy) self._pgm_ref = Reference(pgm) self.add_readables( [ - self._id_controller_ref().energy, + self._IdEnergy().energy(), self._pgm_ref().energy.user_readback, ], StandardReadableFormat.HINTED_SIGNAL, @@ -683,8 +681,8 @@ def __init__( async def set(self, value: float) -> None: LOGGER.info(f"Moving f{self.name} energy to {value}.") await asyncio.gather( - self._id_controller_ref().energy.set( - value=value + await self.energy_offset.get_value() - ), + self._IdEnergy() + .energy() + .set(value=value + await self.energy_offset.get_value()), self._pgm_ref().energy.set(value), ) diff --git a/tests/devices/i10/test_i10Apple2.py b/tests/devices/i10/test_i10Apple2.py index c245ea5ef6..14d984f037 100644 --- a/tests/devices/i10/test_i10Apple2.py +++ b/tests/devices/i10/test_i10Apple2.py @@ -19,6 +19,7 @@ from dodal.devices.apple2_undulator import ( BeamEnergy, + IdEnergy, IdPolarisation, Pol, UndulatorGap, @@ -164,12 +165,23 @@ async def mock_id_controller( return mock_id_controller +@pytest.fixture +async def mock_id_energy( + mock_id_controller: I10Apple2Controller, +) -> IdEnergy: + async with init_devices(mock=True): + mock_id_energy = IdEnergy( + id_controller=mock_id_controller, + ) + return mock_id_energy + + @pytest.fixture async def beam_energy( - mock_id_controller: I10Apple2Controller, mock_pgm: PGM + mock_id_energy: IdEnergy, mock_id_controller: I10Apple2Controller, mock_pgm: PGM ) -> BeamEnergy: async with init_devices(mock=True): - beam_energy = BeamEnergy(id_controller=mock_id_controller, pgm=mock_pgm) + beam_energy = BeamEnergy(id_energy=mock_id_energy, pgm=mock_pgm) return beam_energy @@ -349,6 +361,7 @@ def capture_emitted(name, doc): ) async def test_id_polarisation_set( mock_id_pol: IdPolarisation, + mock_id_controller: I10Apple2Controller, pol: Pol, energy: float, expect_top_inner: float, @@ -357,7 +370,7 @@ async def test_id_polarisation_set( expect_btm_outer: float, expect_gap: float, ): - set_mock_value(mock_id_pol.id_controller()._energy, energy) + set_mock_value(mock_id_controller._energy, energy) if pol == "dsf": with pytest.raises(ValueError): @@ -366,30 +379,30 @@ async def test_id_polarisation_set( await mock_id_pol.set(pol) top_inner = get_mock_put( - mock_id_pol.id_controller().apple2().phase.top_inner.user_setpoint + mock_id_controller.apple2().phase.top_inner.user_setpoint ) top_inner.assert_called_once() assert float(top_inner.call_args[0][0]) == pytest.approx(expect_top_inner, 0.01) top_outer = get_mock_put( - mock_id_pol.id_controller().apple2().phase.top_outer.user_setpoint + mock_id_controller.apple2().phase.top_outer.user_setpoint ) top_outer.assert_called_once() assert float(top_outer.call_args[0][0]) == pytest.approx(expect_top_outer, 0.01) btm_inner = get_mock_put( - mock_id_pol.id_controller().apple2().phase.btm_inner.user_setpoint + mock_id_controller.apple2().phase.btm_inner.user_setpoint ) btm_inner.assert_called_once() assert float(btm_inner.call_args[0][0]) == pytest.approx(expect_btm_inner, 0.01) btm_outer = get_mock_put( - mock_id_pol.id_controller().apple2().phase.btm_outer.user_setpoint + mock_id_controller.apple2().phase.btm_outer.user_setpoint ) btm_outer.assert_called_once() assert float(btm_outer.call_args[0][0]) == pytest.approx(expect_btm_outer, 0.01) - gap = get_mock_put(mock_id_pol.id_controller().apple2().gap.user_setpoint) + gap = get_mock_put(mock_id_controller.apple2().gap.user_setpoint) gap.assert_called_once() assert float(gap.call_args[0][0]) == pytest.approx(expect_gap, 0.05) @@ -406,6 +419,7 @@ async def test_id_polarisation_set( ) async def test_id_polarisation_read_check_pol_from_hardware( mock_id_pol: IdPolarisation, + mock_id_controller: I10Apple2Controller, pol: str, energy: float, top_inner: float, @@ -413,20 +427,12 @@ async def test_id_polarisation_read_check_pol_from_hardware( btm_inner: float, btm_outer: float, ): - set_mock_value(mock_id_pol.id_controller()._energy, energy) + set_mock_value(mock_id_controller._energy, energy) - set_mock_value( - mock_id_pol.id_controller().apple2().phase.top_inner.user_readback, top_inner - ) - set_mock_value( - mock_id_pol.id_controller().apple2().phase.top_outer.user_readback, top_outer - ) - set_mock_value( - mock_id_pol.id_controller().apple2().phase.btm_inner.user_readback, btm_inner - ) - set_mock_value( - mock_id_pol.id_controller().apple2().phase.btm_outer.user_readback, btm_outer - ) + set_mock_value(mock_id_controller.apple2().phase.top_inner.user_readback, top_inner) + set_mock_value(mock_id_controller.apple2().phase.top_outer.user_readback, top_outer) + set_mock_value(mock_id_controller.apple2().phase.btm_inner.user_readback, btm_inner) + set_mock_value(mock_id_controller.apple2().phase.btm_outer.user_readback, btm_outer) assert (await mock_id_pol.read())["mock_id_controller-polarisation"]["value"] == pol @@ -439,6 +445,7 @@ async def test_id_polarisation_read_check_pol_from_hardware( ) async def test_id_polarisation_read_leave_lh3_unchanged_when_hardware_match( mock_id_pol: IdPolarisation, + mock_id_controller: I10Apple2Controller, pol: str, energy: float, top_inner: float, @@ -446,20 +453,12 @@ async def test_id_polarisation_read_leave_lh3_unchanged_when_hardware_match( btm_inner: float, btm_outer: float, ): - set_mock_value(mock_id_pol.id_controller()._energy, energy) - mock_id_pol.id_controller()._set_pol_setpoint(Pol("lh3")) - set_mock_value( - mock_id_pol.id_controller().apple2().phase.top_inner.user_readback, top_inner - ) - set_mock_value( - mock_id_pol.id_controller().apple2().phase.top_outer.user_readback, top_outer - ) - set_mock_value( - mock_id_pol.id_controller().apple2().phase.btm_inner.user_readback, btm_inner - ) - set_mock_value( - mock_id_pol.id_controller().apple2().phase.btm_outer.user_readback, btm_outer - ) + set_mock_value(mock_id_controller._energy, energy) + mock_id_controller._set_pol_setpoint(Pol("lh3")) + set_mock_value(mock_id_controller.apple2().phase.top_inner.user_readback, top_inner) + set_mock_value(mock_id_controller.apple2().phase.top_outer.user_readback, top_outer) + set_mock_value(mock_id_controller.apple2().phase.btm_inner.user_readback, btm_inner) + set_mock_value(mock_id_controller.apple2().phase.btm_outer.user_readback, btm_outer) assert (await mock_id_pol.read())["mock_id_controller-polarisation"]["value"] == pol From 1ffe1e5c4532b0c291ecad9a62165599d9767cbf Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Tue, 7 Oct 2025 13:21:12 +0000 Subject: [PATCH 07/30] add all the device back --- src/dodal/beamlines/i10.py | 75 +++++++++++++++++++++++------ src/dodal/devices/i10/i10_apple2.py | 10 ++-- tests/devices/i10/test_i10Apple2.py | 4 +- 3 files changed, 68 insertions(+), 21 deletions(-) diff --git a/src/dodal/beamlines/i10.py b/src/dodal/beamlines/i10.py index a62a2192a4..18410ad5ac 100644 --- a/src/dodal/beamlines/i10.py +++ b/src/dodal/beamlines/i10.py @@ -10,9 +10,16 @@ from dodal.common.beamlines.beamline_utils import device_factory from dodal.common.beamlines.beamline_utils import set_beamline as set_utils_beamline -from dodal.devices.apple2_undulator import Apple2, UndulatorGap, UndulatorPhaseAxes +from dodal.devices.apple2_undulator import ( + IdEnergy, + IdPolarisation, + UndulatorGap, + UndulatorJawPhase, + UndulatorPhaseAxes, +) from dodal.devices.current_amplifiers import CurrentAmpDet from dodal.devices.i10.diagnostics import I10Diagnostic, I10Diagnostic5ADet +from dodal.devices.i10.i10_apple2 import I10Apple2, I10Apple2Controller from dodal.devices.i10.i10_setting_data import I10Grating from dodal.devices.i10.mirrors import PiezoMirror from dodal.devices.i10.rasor.rasor_current_amp import RasorFemto, RasorSR570 @@ -53,14 +60,14 @@ def pgm() -> PGM: @device_factory() -def idd() -> Apple2: +def idd() -> I10Apple2: """i10 downstream insertion device: id.energy.set() to change beamline energy. id.energy.energy_offset.set() to change id energy offset relative to pgm. id.pol.set() to change polarisation. id.laa.set() to change polarisation angle, must be in LA mode. """ - return Apple2( + return I10Apple2( id_gap=UndulatorGap(prefix=f"{PREFIX.insertion_prefix}-MO-SERVC-01:"), id_phase=UndulatorPhaseAxes( prefix=f"{PREFIX.insertion_prefix}-MO-SERVC-01:", @@ -69,37 +76,77 @@ def idd() -> Apple2: btm_inner="RPQ3", btm_outer="RPQ4", ), + id_jaw_phase=UndulatorJawPhase( + prefix=f"{PREFIX.insertion_prefix}-MO-SERVC-01:", + move_pv="RPQ1", + ), + ) + + +@device_factory() +def idd_controller() -> I10Apple2Controller: + """I10 insertion device controller, it controls both idu and idd.""" + return I10Apple2Controller( + apple2=idd(), + lookuptable_dir=LOOK_UPTABLE_DIR, + source=("Source", "idd"), + config_client=I10_CONF_CLIENT, ) @device_factory() -def idu() -> Apple2: +def idd_energy() -> IdEnergy: + return IdEnergy(id_controller=idd_controller()) + + +@device_factory() +def idd_polarisation() -> IdPolarisation: + return IdPolarisation(id_controller=idd_controller()) + + +@device_factory() +def idu() -> I10Apple2: """i10 downstream insertion device: id.energy.set() to change beamline energy. id.energy.energy_offset.set() to change id energy offset relative to pgm. id.pol.set() to change polarisation. id.laa.set() to change polarisation angle, must be in LA mode. """ - return Apple2( + return I10Apple2( id_gap=UndulatorGap(prefix=f"{PREFIX.insertion_prefix}-MO-SERVC-21:"), id_phase=UndulatorPhaseAxes( - prefix=f"{PREFIX.insertion_prefix}-MO-SERVC-01:", + prefix=f"{PREFIX.insertion_prefix}-MO-SERVC-21:", top_outer="RPQ1", top_inner="RPQ2", btm_inner="RPQ3", btm_outer="RPQ4", ), + id_jaw_phase=UndulatorJawPhase( + prefix=f"{PREFIX.insertion_prefix}-MO-SERVC-21:", + move_pv="RPQ1", + ), ) -# @device_factory() -# def idu_ -# @device_factory() -# def idd_controller() -> I10Apple2Controller: -# """I10 insertion device controller, it controls both idu and idd.""" -# return I10Apple2Controller( -# apple2=idd(), lookuptable_dir=LOOK_UPTABLE_DIR, config_client=I10_CONF_CLIENT -# ) +@device_factory() +def idu_controller() -> I10Apple2Controller: + """I10 insertion device controller, it controls both idu and idd.""" + return I10Apple2Controller( + apple2=idu(), + lookuptable_dir=LOOK_UPTABLE_DIR, + source=("Source", "idu"), + config_client=I10_CONF_CLIENT, + ) + + +@device_factory() +def idu_energy() -> IdEnergy: + return IdEnergy(id_controller=idu_controller()) + + +@device_factory() +def idu_polarisation() -> IdPolarisation: + return IdPolarisation(id_controller=idu_controller()) """Mirrors""" diff --git a/src/dodal/devices/i10/i10_apple2.py b/src/dodal/devices/i10/i10_apple2.py index 4281039a70..1993f923c3 100644 --- a/src/dodal/devices/i10/i10_apple2.py +++ b/src/dodal/devices/i10/i10_apple2.py @@ -102,7 +102,7 @@ class I10EnergyMotorLookup: def __init__( self, - look_up_table_dir: str, + lookuptable_dir: str, source: tuple[str, str], config_client: ConfigServer, mode: str = "Mode", @@ -136,8 +136,8 @@ def __init__( "Gap": {}, "Phase": {}, } - energy_gap_table_path = Path(look_up_table_dir, gap_file_name) - energy_phase_table_path = Path(look_up_table_dir, phase_file_name) + energy_gap_table_path = Path(lookuptable_dir, gap_file_name) + energy_phase_table_path = Path(lookuptable_dir, phase_file_name) self.lookup_table_config = LookupTableConfig( path=LookupPath(Gap=energy_gap_table_path, Phase=energy_phase_table_path), source=source, @@ -354,7 +354,7 @@ class I10Apple2Controller(Apple2Controller[I10Apple2]): def __init__( self, apple2: I10Apple2, - look_up_table_dir: str, + lookuptable_dir: str, source: tuple[str, str], config_client: ConfigServer, jaw_phase_limit: float = 12.0, @@ -380,7 +380,7 @@ def __init__( """ super().__init__(apple2=apple2, name=name) self.lookup_table_client = I10EnergyMotorLookup( - look_up_table_dir=look_up_table_dir, + lookuptable_dir=lookuptable_dir, source=source, config_client=config_client, ) diff --git a/tests/devices/i10/test_i10Apple2.py b/tests/devices/i10/test_i10Apple2.py index 14d984f037..651fd116ca 100644 --- a/tests/devices/i10/test_i10Apple2.py +++ b/tests/devices/i10/test_i10Apple2.py @@ -147,7 +147,7 @@ async def mock_id_controller( async with init_devices(mock=True): mock_id_controller = I10Apple2Controller( apple2=mock_id, - look_up_table_dir=LOOKUP_TABLE_PATH, + lookuptable_dir=LOOKUP_TABLE_PATH, source=("Source", "idu"), config_client=mock_config_client, ) @@ -207,7 +207,7 @@ async def mock_linear_arbitrary_angle( @pytest.fixture def mock_i10_energy_motor_lookup_idu(mock_config_client) -> I10EnergyMotorLookup: return I10EnergyMotorLookup( - look_up_table_dir=LOOKUP_TABLE_PATH, + lookuptable_dir=LOOKUP_TABLE_PATH, source=("Source", "idu"), config_client=mock_config_client, ) From 013742ac62ac7163b82158cab5a1c7fe5afaf99d Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Tue, 7 Oct 2025 13:30:24 +0000 Subject: [PATCH 08/30] change to use id energy set --- src/dodal/devices/apple2_undulator.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/dodal/devices/apple2_undulator.py b/src/dodal/devices/apple2_undulator.py index 2b9e018f51..499215fe46 100644 --- a/src/dodal/devices/apple2_undulator.py +++ b/src/dodal/devices/apple2_undulator.py @@ -624,7 +624,8 @@ def __init__(self, id_controller: Apple2Controller, name: str = "") -> None: self.energy = Reference(id_controller.energy) super().__init__(name=name) - def set(self, energy: float) -> Status: + @AsyncStatus.wrap + async def set(self, energy: float) -> Status: return self.energy().set(energy) @@ -678,11 +679,9 @@ def __init__(self, id_energy: IdEnergy, pgm: PGM, name: str = "") -> None: self.energy_offset = soft_signal_rw(float, initial_value=0) @AsyncStatus.wrap - async def set(self, value: float) -> None: - LOGGER.info(f"Moving f{self.name} energy to {value}.") + async def set(self, energy: float) -> None: + LOGGER.info(f"Moving f{self.name} energy to {energy}.") await asyncio.gather( - self._IdEnergy() - .energy() - .set(value=value + await self.energy_offset.get_value()), - self._pgm_ref().energy.set(value), + self._IdEnergy().set(energy=energy + await self.energy_offset.get_value()), + self._pgm_ref().energy.set(energy), ) From 30a6600925f0d3b5ee3f257c71fec631d4512e57 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Tue, 7 Oct 2025 13:42:16 +0000 Subject: [PATCH 09/30] fixed energy set --- src/dodal/devices/apple2_undulator.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/dodal/devices/apple2_undulator.py b/src/dodal/devices/apple2_undulator.py index 499215fe46..3e0eee4b9a 100644 --- a/src/dodal/devices/apple2_undulator.py +++ b/src/dodal/devices/apple2_undulator.py @@ -5,7 +5,7 @@ from typing import Generic, Protocol, TypeVar import numpy as np -from bluesky.protocols import Movable, Status +from bluesky.protocols import Movable from ophyd_async.core import ( AsyncStatus, Reference, @@ -625,8 +625,8 @@ def __init__(self, id_controller: Apple2Controller, name: str = "") -> None: super().__init__(name=name) @AsyncStatus.wrap - async def set(self, energy: float) -> Status: - return self.energy().set(energy) + async def set(self, energy: float) -> None: + await self.energy().set(energy) class IdPolarisation(StandardReadable, Movable): From d669df02c8f27a6aa1a74bf8830c113e814253e6 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Tue, 7 Oct 2025 13:55:55 +0000 Subject: [PATCH 10/30] added missing test --- src/dodal/devices/i10/i10_apple2.py | 21 ++++++++++----------- tests/devices/i10/test_i10Apple2.py | 18 +++++++++++++++--- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/dodal/devices/i10/i10_apple2.py b/src/dodal/devices/i10/i10_apple2.py index 1993f923c3..ba326035f9 100644 --- a/src/dodal/devices/i10/i10_apple2.py +++ b/src/dodal/devices/i10/i10_apple2.py @@ -398,20 +398,12 @@ def __init__( ) def _read_linear_arbitrary_angle(self, pol_angle: float, pol: Pol) -> float: - if pol != Pol.LA: - raise RuntimeError( - "Angle control is not available in polarisation" - + f" {pol} with {self.name}" - ) - elif pol_angle is not None: - return pol_angle + self._raise_if_not_la(pol) + return pol_angle async def _set_linear_arbitrary_angle(self, pol_angle: float) -> None: pol = await self.polarisation.get_value() - if pol != Pol.LA: - raise RuntimeError( - f"Angle control is not available in polarisation {pol} with {self.name}" - ) + self._raise_if_not_la(pol) # Moving to real angle which is 210 to 30. alpha_real = ( pol_angle @@ -465,6 +457,13 @@ async def _set(self, value: float) -> None: await self.apple2().jaw_phase.set(0) await self.apple2().jaw_phase.set_move.set(1) + def _raise_if_not_la(self, pol: Pol) -> None: + if pol != Pol.LA: + raise RuntimeError( + "Angle control is not available in polarisation" + + f" {pol} with {self.name}" + ) + class LinearArbitraryAngle(StandardReadable, Movable[SupportsFloat]): """ diff --git a/tests/devices/i10/test_i10Apple2.py b/tests/devices/i10/test_i10Apple2.py index 651fd116ca..fe0a65645c 100644 --- a/tests/devices/i10/test_i10Apple2.py +++ b/tests/devices/i10/test_i10Apple2.py @@ -285,7 +285,7 @@ async def test_fail_i10_apple2_controller_set_id_not_ready( ) -async def test_i10apple2_RE_scan(beam_energy: BeamEnergy, RE: RunEngine): +async def test_beam_energy_re_scan(beam_energy: BeamEnergy, RE: RunEngine): docs = defaultdict(list) def capture_emitted(name, doc): @@ -299,7 +299,7 @@ def capture_emitted(name, doc): assert data["data"]["mock_pgm-energy"] == 500 + cnt * 10 -async def test_beam_energy_re_scan( +async def test_beam_energy_re_scan_with_offset( beam_energy: BeamEnergy, mock_id_controller: I10Apple2Controller, RE: RunEngine ): docs = defaultdict(list) @@ -462,7 +462,7 @@ async def test_id_polarisation_read_leave_lh3_unchanged_when_hardware_match( assert (await mock_id_pol.read())["mock_id_controller-polarisation"]["value"] == pol -async def test_linear_arbitrary_pol_fail( +async def test_linear_arbitrary_pol_fail_set( mock_linear_arbitrary_angle: LinearArbitraryAngle, ): with pytest.raises(RuntimeError) as e: @@ -474,6 +474,18 @@ async def test_linear_arbitrary_pol_fail( ) +async def test_linear_arbitrary_pol_fail_read( + mock_linear_arbitrary_angle: LinearArbitraryAngle, +): + with pytest.raises(RuntimeError) as e: + await mock_linear_arbitrary_angle.read() + assert str(e.value) == ( + f"Angle control is not available in polarisation" + f" {await mock_linear_arbitrary_angle._id_controller_ref().polarisation.get_value()}" + + f" with {mock_linear_arbitrary_angle._id_controller_ref().name}" + ) + + @pytest.mark.parametrize( "poly", [18, -18, 12.01, -12.01], From ec76ef64d849ef4c8479c21feb4f6271718b518b Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Tue, 7 Oct 2025 14:18:05 +0000 Subject: [PATCH 11/30] add laa to i10 config --- src/dodal/beamlines/i10.py | 16 +++++++++++++++- src/dodal/devices/i10/i10_apple2.py | 6 +++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/src/dodal/beamlines/i10.py b/src/dodal/beamlines/i10.py index 18410ad5ac..9cd512a87e 100644 --- a/src/dodal/beamlines/i10.py +++ b/src/dodal/beamlines/i10.py @@ -19,7 +19,11 @@ ) from dodal.devices.current_amplifiers import CurrentAmpDet from dodal.devices.i10.diagnostics import I10Diagnostic, I10Diagnostic5ADet -from dodal.devices.i10.i10_apple2 import I10Apple2, I10Apple2Controller +from dodal.devices.i10.i10_apple2 import ( + I10Apple2, + I10Apple2Controller, + LinearArbitraryAngle, +) from dodal.devices.i10.i10_setting_data import I10Grating from dodal.devices.i10.mirrors import PiezoMirror from dodal.devices.i10.rasor.rasor_current_amp import RasorFemto, RasorSR570 @@ -104,6 +108,11 @@ def idd_polarisation() -> IdPolarisation: return IdPolarisation(id_controller=idd_controller()) +@device_factory() +def idd_laa() -> LinearArbitraryAngle: + return LinearArbitraryAngle(id_controller=idd_controller()) + + @device_factory() def idu() -> I10Apple2: """i10 downstream insertion device: @@ -149,6 +158,11 @@ def idu_polarisation() -> IdPolarisation: return IdPolarisation(id_controller=idu_controller()) +@device_factory() +def idu_laa() -> LinearArbitraryAngle: + return LinearArbitraryAngle(id_controller=idu_controller()) + + """Mirrors""" diff --git a/src/dodal/devices/i10/i10_apple2.py b/src/dodal/devices/i10/i10_apple2.py index ba326035f9..3c8396e922 100644 --- a/src/dodal/devices/i10/i10_apple2.py +++ b/src/dodal/devices/i10/i10_apple2.py @@ -493,13 +493,13 @@ def __init__( polynomial parameters highest power first. """ super().__init__(name=name) - self._id_controller_ref = Reference(id_controller) + self.linear_arbitrary_angle = Reference(id_controller.linear_arbitrary_angle) self.add_readables( - [self._id_controller_ref().linear_arbitrary_angle], + [self.linear_arbitrary_angle()], StandardReadableFormat.HINTED_SIGNAL, ) @AsyncStatus.wrap async def set(self, angle: float) -> None: - await self._id_controller_ref().linear_arbitrary_angle.set(angle) + await self.linear_arbitrary_angle().set(angle) From 241aec6a384841d56bed46fe8b71ed22bcd782b4 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Tue, 7 Oct 2025 14:31:59 +0000 Subject: [PATCH 12/30] correct tests --- src/dodal/devices/apple2_undulator.py | 50 ++++++------------- src/dodal/devices/i10/i10_apple2.py | 2 +- tests/devices/i10/test_i10Apple2.py | 70 +++++++++++---------------- 3 files changed, 44 insertions(+), 78 deletions(-) diff --git a/src/dodal/devices/apple2_undulator.py b/src/dodal/devices/apple2_undulator.py index 3e0eee4b9a..cab3823738 100644 --- a/src/dodal/devices/apple2_undulator.py +++ b/src/dodal/devices/apple2_undulator.py @@ -302,7 +302,7 @@ async def get_timeout(self) -> float: class Apple2(StandardReadable, Movable): """ - Device representing the combined motor controls for an Apple2Controller undulator. + Device representing the combined motor controls for an Apple2 undulator. Attributes ---------- @@ -333,8 +333,7 @@ def __init__(self, id_gap: UndulatorGap, id_phase: UndulatorPhaseAxes, name=""): async def set(self, id_motor_values: Apple2Val) -> None: """ Check ID is in a movable state and set all the demand value before moving them - all at the same time. This should be modified by the beamline specific ID - class, if the ID motors has to move in a specific order. + all at the same time. """ # Only need to check gap as the phase motors share both fault and gate with gap. @@ -370,30 +369,23 @@ def __call__(self, energy: float, pol: Pol) -> tuple[float, float]: class Apple2Controller(abc.ABC, StandardReadable, Movable, Generic[Apple2Type]): """ - Apple2Controller Undulator Device - The `Apple2Controller` class represents an Apple 2 insertion device (undulator) used in synchrotron beamlines. - This device provides additional degrees of freedom compared to standard undulators, allowing independent - movement of magnet banks to produce X-rays with various polarisations and energies. + The `Apple2Controller` class is an abstract base class that provides a high-level interface for controlling + an Apple2 undulator device. The class is designed to manage the undulator's gap, phase motors, and polarisation settings, while abstracting hardware interactions and providing a high-level interface for beamline operations. - The class is abstract and requires beamline-specific implementations for _set motor - positions based on energy and polarisation. - Attributes ---------- apple2 : Apple2 A collection of gap and phase motor devices. - energy : SignalR - A soft signal for the current energy readback. + energy : SignalRW + A derived signal for moving energy. polarisation_setpoint : SignalR A soft signal for the polarisation setpoint. polarisation : SignalRW A hardware-backed signal for polarisation readback and control. - lookup_tables : dict - A dictionary storing lookup tables for gap and phase motor positions, used for energy and polarisation conversion. energy_to_motor : EnergyMotorConvertor A callable that converts energy and polarisation to motor positions. @@ -401,6 +393,8 @@ class Apple2Controller(abc.ABC, StandardReadable, Movable, Generic[Apple2Type]): ---------------- _set(value: float) -> None Abstract method to set motor positions for a given energy and polarisation. + energy_to_motor : EnergyMotorConvertor + A callable that converts energy and polarisation to motor positions. Methods ------- @@ -409,14 +403,9 @@ class Apple2Controller(abc.ABC, StandardReadable, Movable, Generic[Apple2Type]): Notes ----- - - This class requires beamline-specific implementations of the abstract methods. + - This class requires beamline-specific implementations of the abstract methods, - The device supports multiple polarisation modes, including linear horizontal (LH), linear vertical (LV), - positive circular (PC), negative circular (NC), and linear arbitrary (LA). - - For more detail see - `UML `__ for detail. - - .. figure:: /explanations/umls/apple2_design.png + positive circular (PC), negative circular (NC). """ @@ -429,14 +418,13 @@ def __init__( Parameters ---------- - id_gap: An UndulatorGap device. - id_phase: An UndulatorPhaseAxes device. - energy_motor_convertor: A callable that converts energy and polarisation to motor positions. - name: Name of the device. + apple2: Apple2 + An Apple2 device. + name: str + Name of the device. """ - - self.apple2 = Reference(apple2) self.energy_to_motor: EnergyMotorConvertor + self.apple2 = Reference(apple2) with self.add_children_as_readables(StandardReadableFormat.HINTED_SIGNAL): # Store the set energy for readback. self._energy = soft_signal_rw(float, initial_value=None) @@ -465,12 +453,6 @@ def __init__( ) super().__init__(name) - def _set_pol_setpoint(self, pol: Pol) -> None: - """Set the polarisation setpoint without moving hardware. The polarisation - setpoint is used to determine the gap and phase motor positions when - setting the energy/polarisation of the undulator.""" - self._polarisation_setpoint_set(pol) - @AsyncStatus.wrap async def set(self, value: float) -> None: """ @@ -511,7 +493,7 @@ async def _set_pol( value: Pol, ) -> None: # This changes the pol setpoint and then changes polarisation via set energy. - self._set_pol_setpoint(value) + self._polarisation_setpoint_set(value) await self.set(await self.energy.get_value()) def _read_pol( diff --git a/src/dodal/devices/i10/i10_apple2.py b/src/dodal/devices/i10/i10_apple2.py index 3c8396e922..2a06bb6bc8 100644 --- a/src/dodal/devices/i10/i10_apple2.py +++ b/src/dodal/devices/i10/i10_apple2.py @@ -438,7 +438,7 @@ async def _set(self, value: float) -> None: f"Polarisation cannot be determined from hardware for {self.name}" ) - self._set_pol_setpoint(pol) + self._polarisation_setpoint_set(pol) gap, phase = self.lookup_table_client.get_motor_from_energy( energy=value, pol=pol ) diff --git a/tests/devices/i10/test_i10Apple2.py b/tests/devices/i10/test_i10Apple2.py index fe0a65645c..2c6c273cb8 100644 --- a/tests/devices/i10/test_i10Apple2.py +++ b/tests/devices/i10/test_i10Apple2.py @@ -307,7 +307,7 @@ async def test_beam_energy_re_scan_with_offset( def capture_emitted(name, doc): docs[name].append(doc) - mock_id_controller._set_pol_setpoint(Pol("lh3")) + mock_id_controller._polarisation_setpoint_set(Pol("lh3")) # with energy offset await beam_energy.energy_offset.set(20) rbv_mocks = Mock() @@ -454,7 +454,7 @@ async def test_id_polarisation_read_leave_lh3_unchanged_when_hardware_match( btm_outer: float, ): set_mock_value(mock_id_controller._energy, energy) - mock_id_controller._set_pol_setpoint(Pol("lh3")) + mock_id_controller._polarisation_setpoint_set(Pol("lh3")) set_mock_value(mock_id_controller.apple2().phase.top_inner.user_readback, top_inner) set_mock_value(mock_id_controller.apple2().phase.top_outer.user_readback, top_outer) set_mock_value(mock_id_controller.apple2().phase.btm_inner.user_readback, btm_inner) @@ -464,25 +464,27 @@ async def test_id_polarisation_read_leave_lh3_unchanged_when_hardware_match( async def test_linear_arbitrary_pol_fail_set( mock_linear_arbitrary_angle: LinearArbitraryAngle, + mock_id_controller: I10Apple2Controller, ): with pytest.raises(RuntimeError) as e: await mock_linear_arbitrary_angle.set(20) assert str(e.value) == ( f"Angle control is not available in polarisation" - f" {await mock_linear_arbitrary_angle._id_controller_ref().polarisation.get_value()}" - + f" with {mock_linear_arbitrary_angle._id_controller_ref().name}" + f" {await mock_id_controller.polarisation.get_value()}" + + f" with {mock_id_controller.name}" ) async def test_linear_arbitrary_pol_fail_read( mock_linear_arbitrary_angle: LinearArbitraryAngle, + mock_id_controller: I10Apple2Controller, ): with pytest.raises(RuntimeError) as e: await mock_linear_arbitrary_angle.read() assert str(e.value) == ( f"Angle control is not available in polarisation" - f" {await mock_linear_arbitrary_angle._id_controller_ref().polarisation.get_value()}" - + f" with {mock_linear_arbitrary_angle._id_controller_ref().name}" + f" {await mock_id_controller.polarisation.get_value()}" + + f" with {mock_id_controller.name}" ) @@ -491,42 +493,34 @@ async def test_linear_arbitrary_pol_fail_read( [18, -18, 12.01, -12.01], ) async def test_linear_arbitrary_limit_fail( - mock_linear_arbitrary_angle: LinearArbitraryAngle, poly: float + mock_linear_arbitrary_angle: LinearArbitraryAngle, + mock_id_controller: I10Apple2Controller, + poly: float, ): set_mock_value( - mock_linear_arbitrary_angle._id_controller_ref() - .apple2() - .phase.top_inner.user_readback, + mock_id_controller.apple2().phase.top_inner.user_readback, 16.4, ) set_mock_value( - mock_linear_arbitrary_angle._id_controller_ref() - .apple2() - .phase.top_outer.user_readback, + mock_id_controller.apple2().phase.top_outer.user_readback, 0, ) set_mock_value( - mock_linear_arbitrary_angle._id_controller_ref() - .apple2() - .phase.btm_inner.user_readback, + mock_id_controller.apple2().phase.btm_inner.user_readback, 0, ) set_mock_value( - mock_linear_arbitrary_angle._id_controller_ref() - .apple2() - .phase.btm_outer.user_readback, + mock_id_controller.apple2().phase.btm_outer.user_readback, -16.4, ) - mock_linear_arbitrary_angle._id_controller_ref().jaw_phase_from_angle = poly1d( - [poly] - ) + mock_id_controller.jaw_phase_from_angle = poly1d([poly]) with pytest.raises(RuntimeError) as e: await mock_linear_arbitrary_angle.set(20.0) assert ( str(e.value) == f"jaw_phase position for angle (20.0) is outside permitted range" - f" [-{mock_linear_arbitrary_angle._id_controller_ref().jaw_phase_limit}," - f" {mock_linear_arbitrary_angle._id_controller_ref().jaw_phase_limit}]" + f" [-{mock_id_controller.jaw_phase_limit}," + f" {mock_id_controller.jaw_phase_limit}]" ) @@ -540,6 +534,7 @@ async def test_linear_arbitrary_limit_fail( ) async def test_linear_arbitrary_RE_scan( mock_linear_arbitrary_angle: LinearArbitraryAngle, + mock_id_controller: I10Apple2Controller, RE: RunEngine, start: float, stop: float, @@ -552,27 +547,19 @@ def capture_emitted(name, doc): docs[name].append(doc) set_mock_value( - mock_linear_arbitrary_angle._id_controller_ref() - .apple2() - .phase.top_inner.user_readback, + mock_id_controller.apple2().phase.top_inner.user_readback, 16.4, ) set_mock_value( - mock_linear_arbitrary_angle._id_controller_ref() - .apple2() - .phase.top_outer.user_readback, + mock_id_controller.apple2().phase.top_outer.user_readback, 0, ) set_mock_value( - mock_linear_arbitrary_angle._id_controller_ref() - .apple2() - .phase.btm_inner.user_readback, + mock_id_controller.apple2().phase.btm_inner.user_readback, 0, ) set_mock_value( - mock_linear_arbitrary_angle._id_controller_ref() - .apple2() - .phase.btm_outer.user_readback, + mock_id_controller.apple2().phase.btm_outer.user_readback, -16.4, ) RE( @@ -587,17 +574,15 @@ def capture_emitted(name, doc): ) assert_emitted(docs, start=1, descriptor=1, event=num_point, stop=1) set_mock_value( - mock_linear_arbitrary_angle._id_controller_ref().apple2().gap.gate, + mock_id_controller.apple2().gap.gate, UndulatorGateStatus.CLOSE, ) set_mock_value( - mock_linear_arbitrary_angle._id_controller_ref().apple2().phase.gate, + mock_id_controller.apple2().phase.gate, UndulatorGateStatus.CLOSE, ) jaw_phase = get_mock_put( - mock_linear_arbitrary_angle._id_controller_ref() - .apple2() - .jaw_phase.jaw_phase.user_setpoint + mock_id_controller.apple2().jaw_phase.jaw_phase.user_setpoint ) poly = poly1d( @@ -609,8 +594,7 @@ def capture_emitted(name, doc): assert data["data"]["mock_id_controller-linear_arbitrary_angle"] == temp_angle alpha_real = ( temp_angle - if temp_angle - > mock_linear_arbitrary_angle._id_controller_ref().angle_threshold_deg + if temp_angle > mock_id_controller.angle_threshold_deg else temp_angle + 180.0 ) # convert angle to jawphase. assert jaw_phase.call_args_list[cnt] == mock.call( From f59fe79ca0f0822ec9948b8dd90311d14de6e44d Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Tue, 7 Oct 2025 14:44:44 +0000 Subject: [PATCH 13/30] fix docstring --- src/dodal/devices/apple2_undulator.py | 83 +++++++++++---------------- src/dodal/devices/i10/i10_apple2.py | 2 +- 2 files changed, 36 insertions(+), 49 deletions(-) diff --git a/src/dodal/devices/apple2_undulator.py b/src/dodal/devices/apple2_undulator.py index cab3823738..1d523e2803 100644 --- a/src/dodal/devices/apple2_undulator.py +++ b/src/dodal/devices/apple2_undulator.py @@ -367,7 +367,7 @@ def __call__(self, energy: float, pol: Pol) -> tuple[float, float]: Apple2Type = TypeVar("Apple2Type", bound="Apple2") -class Apple2Controller(abc.ABC, StandardReadable, Movable, Generic[Apple2Type]): +class Apple2Controller(abc.ABC, StandardReadable, Generic[Apple2Type]): """ The `Apple2Controller` class is an abstract base class that provides a high-level interface for controlling @@ -391,7 +391,7 @@ class Apple2Controller(abc.ABC, StandardReadable, Movable, Generic[Apple2Type]): Abstract Methods ---------------- - _set(value: float) -> None + _set_motors_from_energy(value: float) -> None Abstract method to set motor positions for a given energy and polarisation. energy_to_motor : EnergyMotorConvertor A callable that converts energy and polarisation to motor positions. @@ -425,63 +425,45 @@ def __init__( """ self.energy_to_motor: EnergyMotorConvertor self.apple2 = Reference(apple2) + + # Store the set energy for readback. + self._energy = soft_signal_rw(float, initial_value=None) with self.add_children_as_readables(StandardReadableFormat.HINTED_SIGNAL): - # Store the set energy for readback. - self._energy = soft_signal_rw(float, initial_value=None) - self.energy = derived_signal_rw( - raw_to_derived=self._read_energy, - set_derived=self._set_energy, - energy=self._energy, - ) + self.energy = derived_signal_rw( + raw_to_derived=self._read_energy, + set_derived=self._set_energy, + energy=self._energy, + ) # Store the polarisation for setpoint. And provide readback for LH3. # LH3 is a special case as it is indistinguishable from LH in the hardware. self.polarisation_setpoint, self._polarisation_setpoint_set = ( soft_signal_r_and_setter(Pol) ) - - # Hardware backed read/write for polarisation. - self.polarisation = derived_signal_rw( - raw_to_derived=self._read_pol, - set_derived=self._set_pol, - pol=self.polarisation_setpoint, - top_outer=self.apple2().phase.top_outer.user_readback, - top_inner=self.apple2().phase.top_inner.user_readback, - btm_inner=self.apple2().phase.btm_inner.user_readback, - btm_outer=self.apple2().phase.btm_outer.user_readback, - gap=self.apple2().gap.user_readback, - ) + with self.add_children_as_readables(StandardReadableFormat.HINTED_SIGNAL): + # Hardware backed read/write for polarisation. + self.polarisation = derived_signal_rw( + raw_to_derived=self._read_pol, + set_derived=self._set_pol, + pol=self.polarisation_setpoint, + top_outer=self.apple2().phase.top_outer.user_readback, + top_inner=self.apple2().phase.top_inner.user_readback, + btm_inner=self.apple2().phase.btm_inner.user_readback, + btm_outer=self.apple2().phase.btm_outer.user_readback, + gap=self.apple2().gap.user_readback, + ) super().__init__(name) - @AsyncStatus.wrap - async def set(self, value: float) -> None: - """ - Set should be in energy units, this will set the energy of the ID by setting the - gap and phase motors to the correct position for the given energy - and polarisation. - - - Examples - -------- - RE( id.set(888.0)) # This will set the ID to 888 eV - RE(scan([detector], id,600,700,100)) # This will scan the ID from 600 to 700 eV in 100 steps. - """ - await self.energy.set(value) - - LOGGER.info(f"Energy set to {value} eV successfully.") - @abc.abstractmethod - async def _set(self, value: float) -> None: + async def _set_motors_from_energy(self, value: float) -> None: """ This method should be implemented by the beamline specific ID class as the motor positions will be different for each beamline depending on the - undulator design and the lookup table used. The set method can be - used to set the motor positions for the given energy and polarisation - provided that all motors can be moved at the same time. + undulator design and the lookup table used. """ async def _set_energy(self, energy: float) -> None: - await self._set(energy) + await self._set_motors_from_energy(energy) await self._energy.set(energy) def _read_energy(self, energy: float) -> float: @@ -494,7 +476,7 @@ async def _set_pol( ) -> None: # This changes the pol setpoint and then changes polarisation via set energy. self._polarisation_setpoint_set(value) - await self.set(await self.energy.get_value()) + await self.energy.set(await self.energy.get_value()) def _read_pol( self, @@ -602,6 +584,8 @@ def determine_phase_from_hardware( class IdEnergy(StandardReadable, Movable): + """Apple2 ID energy movable device.""" + def __init__(self, id_controller: Apple2Controller, name: str = "") -> None: self.energy = Reference(id_controller.energy) super().__init__(name=name) @@ -612,6 +596,8 @@ async def set(self, energy: float) -> None: class IdPolarisation(StandardReadable, Movable): + """Apple2 ID polarisation movable device.""" + def __init__(self, id_controller: Apple2Controller, name: str = "") -> None: self.polarisation = Reference(id_controller.polarisation) super().__init__(name=name) @@ -638,10 +624,11 @@ def __init__(self, id_energy: IdEnergy, pgm: PGM, name: str = "") -> None: """ Parameters ---------- - id: - An Apple2 device. - pgm: - A PGM/mono device. + + id_energy: IdEnergy + An IdEnergy device. + pgm: PGM + A PGM device. name: New device name. """ diff --git a/src/dodal/devices/i10/i10_apple2.py b/src/dodal/devices/i10/i10_apple2.py index 2a06bb6bc8..5dc5b5c749 100644 --- a/src/dodal/devices/i10/i10_apple2.py +++ b/src/dodal/devices/i10/i10_apple2.py @@ -419,7 +419,7 @@ async def _set_linear_arbitrary_angle(self, pol_angle: float) -> None: await self.apple2().jaw_phase.set(jaw_phase) await self._linear_arbitrary_angle.set(pol_angle) - async def _set(self, value: float) -> None: + async def _set_motors_from_energy(self, value: float) -> None: """ Check polarisation state and use it together with the energy(value) to calculate the required gap and phases before setting it. From a8af32beb89e9d3f0c7deea3a1d8f5e9090934d5 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Tue, 7 Oct 2025 14:58:48 +0000 Subject: [PATCH 14/30] More docstring correction --- src/dodal/devices/apple2_undulator.py | 37 +++++++-------- src/dodal/devices/i10/i10_apple2.py | 66 ++++++++++++--------------- 2 files changed, 47 insertions(+), 56 deletions(-) diff --git a/src/dodal/devices/apple2_undulator.py b/src/dodal/devices/apple2_undulator.py index 1d523e2803..2b6b6619bf 100644 --- a/src/dodal/devices/apple2_undulator.py +++ b/src/dodal/devices/apple2_undulator.py @@ -370,24 +370,25 @@ def __call__(self, energy: float, pol: Pol) -> tuple[float, float]: class Apple2Controller(abc.ABC, StandardReadable, Generic[Apple2Type]): """ - The `Apple2Controller` class is an abstract base class that provides a high-level interface for controlling - an Apple2 undulator device. + Abstract base class for controlling an Apple2 undulator device. - The class is designed to manage the undulator's gap, phase motors, and polarisation settings, while - abstracting hardware interactions and providing a high-level interface for beamline operations. + This class manages the undulator's gap and phase motors, and provides an interface + for controlling polarisation and energy settings. It exposes derived signals for + energy and polarisation, and handles conversion between energy/polarisation and + motor positions via a user-supplied conversion callable. Attributes ---------- - apple2 : Apple2 - A collection of gap and phase motor devices. - energy : SignalRW - A derived signal for moving energy. + apple2 : Reference[Apple2Type] + Reference to the Apple2 device containing gap and phase motors. + energy : derived_signal_rw + Derived signal for moving and reading back energy. polarisation_setpoint : SignalR - A soft signal for the polarisation setpoint. - polarisation : SignalRW - A hardware-backed signal for polarisation readback and control. + Soft signal for the polarisation setpoint. + polarisation : derived_signal_rw + Hardware-backed signal for polarisation readback and control. energy_to_motor : EnergyMotorConvertor - A callable that converts energy and polarisation to motor positions. + Callable that converts energy and polarisation to motor positions. Abstract Methods ---------------- @@ -396,16 +397,12 @@ class Apple2Controller(abc.ABC, StandardReadable, Generic[Apple2Type]): energy_to_motor : EnergyMotorConvertor A callable that converts energy and polarisation to motor positions. - Methods - ------- - determine_phase_from_hardware(...) -> tuple[Pol, float] - Determines the polarisation and phase value based on motor positions. - Notes ----- - - This class requires beamline-specific implementations of the abstract methods, - - The device supports multiple polarisation modes, including linear horizontal (LH), linear vertical (LV), - positive circular (PC), negative circular (NC). + - Subclasses must implement `_set_motors_from_energy` for beamline-specific logic. + - LH3 polarisation is indistinguishable from LH in hardware; special handling is provided. + - Supports multiple polarisation modes, including linear horizontal (LH), linear vertical (LV), + positive circular (PC), negative circular (NC), and linear arbitrary (LA). """ diff --git a/src/dodal/devices/i10/i10_apple2.py b/src/dodal/devices/i10/i10_apple2.py index 5dc5b5c749..0696dd3bd4 100644 --- a/src/dodal/devices/i10/i10_apple2.py +++ b/src/dodal/devices/i10/i10_apple2.py @@ -325,14 +325,17 @@ def __init__( name: str = "", ) -> None: """ - An I10Apple2 device. + I10Apple2 device is an appl2 with extra jaw phase control. Parameters ---------- + id_gap : UndulatorJawPhase The gap motor of the undulator. id_phase : UndulatorJawPhase The phase motors of the undulator. + id_jaw_phase : UndulatorJawPhase + The jaw phase motor of the undulator. name : str, optional The name of the device, by default "". """ @@ -342,13 +345,9 @@ def __init__( class I10Apple2Controller(Apple2Controller[I10Apple2]): - """I10Apple2 is the i10 version of Apple2 ID, set and energy_motor_convertor - should be the only part that is I10 specific. - - A EnergyMotorConvertor function is needed to provide the conversion between - x-ray motor position and energy. - - Set is in energy(eV). + """ + I10Apple2Controller is a extension of Apple2Controller which provide linear + arbitrary angle control. """ def __init__( @@ -362,21 +361,26 @@ def __init__( angle_threshold_deg=30.0, name: str = "", ) -> None: - """I10Id is a compound device that combines the I10-specific Apple2 undulator, - energy setter, and polarization control. - This class provides a high-level interface for controlling the undulator's - energy, polarization, and linear arbitrary angle. + """ - Attributes + parameters ---------- - id : I10Apple2 - The I10-specific Apple2 undulator device. - energy_setter : EnergySetter - A device for synchronizing the undulator and monochromator energy. - pol : I10Apple2Pol - A device for controlling the polarization of the undulator. - linear_arbitrary_angle : LinearArbitraryAngle - A device for controlling the linear arbitrary polarization angle. + apple2 : I10Apple2 + An I10Apple2 device. + lookuptable_dir : str + The path to look up table. + source : tuple[str, str] + The column name and the name of the source in look up table. e.g. ( "source", "idu") + config_client : ConfigServer + The config server client to fetch the look up table. + jaw_phase_limit : float, optional + The maximum allowed jaw_phase movement., by default 12.0 + jaw_phase_poly_param : list[float], optional + polynomial parameters highest power first., by default DEFAULT_JAW_PHASE_POLY_PARAMS + angle_threshold_deg : float, optional + The angle threshold to switch between 0-180 and 180-360 range., by default 30.0 + name : str, optional + New device name. """ super().__init__(apple2=apple2, name=name) self.lookup_table_client = I10EnergyMotorLookup( @@ -421,8 +425,7 @@ async def _set_linear_arbitrary_angle(self, pol_angle: float) -> None: async def _set_motors_from_energy(self, value: float) -> None: """ - Check polarisation state and use it together with the energy(value) - to calculate the required gap and phases before setting it. + Set the undulator motors for a given energy and polarisation. """ pol = await self.polarisation_setpoint.get_value() @@ -467,12 +470,7 @@ def _raise_if_not_la(self, pol: Pol) -> None: class LinearArbitraryAngle(StandardReadable, Movable[SupportsFloat]): """ - Device to set polorisation angle of the ID. Linear Arbitrary Angle (laa) - is the direction of the magnetic field which can be change by varying the jaw_phase - in (linear arbitrary (la) mode, - The angle of 0 is equivalent to linear horizontal "lh" (sigma) and - 90 is linear vertical "lv" (pi). - This device require a jaw_phase to angle conversion which is done via a polynomial. + Device to set the polarisation angle of the Apple2 undulator in Linear Arbitrary (LA) mode.. """ def __init__( @@ -483,14 +481,10 @@ def __init__( """ Parameters ---------- - id: I10Apple2 - An I10Apple2 device. - name: str + id_controller : I10Apple2Controller + The I10Apple2Controller which control the ID. + name : str, optional New device name. - jaw_phase_limit: float - The maximum allowed jaw_phase movement. - jaw_phase_poly_param: list - polynomial parameters highest power first. """ super().__init__(name=name) self.linear_arbitrary_angle = Reference(id_controller.linear_arbitrary_angle) From 0dc54fe0e979d470347083600597b860b48007d2 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Tue, 7 Oct 2025 15:32:59 +0000 Subject: [PATCH 15/30] make beamEnergy generic --- src/dodal/devices/apple2_undulator.py | 33 +++++++++++++++++++-------- src/dodal/devices/pgm.py | 12 +++++++++- tests/devices/i10/test_i10Apple2.py | 6 ++--- 3 files changed, 38 insertions(+), 13 deletions(-) diff --git a/src/dodal/devices/apple2_undulator.py b/src/dodal/devices/apple2_undulator.py index 2b6b6619bf..5bd7e798ea 100644 --- a/src/dodal/devices/apple2_undulator.py +++ b/src/dodal/devices/apple2_undulator.py @@ -10,6 +10,7 @@ AsyncStatus, Reference, SignalR, + SignalRW, SignalW, StandardReadable, StandardReadableFormat, @@ -21,7 +22,7 @@ ) from ophyd_async.epics.core import epics_signal_r, epics_signal_rw, epics_signal_w -from dodal.devices.pgm import PGM +from dodal.devices.pgm import MonoEnergyBase from dodal.log import LOGGER T = TypeVar("T") @@ -580,7 +581,19 @@ def determine_phase_from_hardware( return Pol.NONE, 0.0 -class IdEnergy(StandardReadable, Movable): +class IdEnergyBase(abc.ABC, StandardReadable, Movable): + """Base class for energy movable device.""" + + def __init__(self, name: str = "") -> None: + self.energy: Reference[SignalRW[float]] + super().__init__(name=name) + + @abc.abstractmethod + @AsyncStatus.wrap + async def set(self, energy: float) -> None: ... + + +class IdEnergy(IdEnergyBase): """Apple2 ID energy movable device.""" def __init__(self, id_controller: Apple2Controller, name: str = "") -> None: @@ -617,7 +630,9 @@ class BeamEnergy(StandardReadable, Movable[float]): """ - def __init__(self, id_energy: IdEnergy, pgm: PGM, name: str = "") -> None: + def __init__( + self, id_energy: IdEnergyBase, mono: MonoEnergyBase, name: str = "" + ) -> None: """ Parameters ---------- @@ -630,13 +645,13 @@ def __init__(self, id_energy: IdEnergy, pgm: PGM, name: str = "") -> None: New device name. """ super().__init__(name=name) - self._IdEnergy = Reference(id_energy) - self._pgm_ref = Reference(pgm) + self._Id_energy = Reference(id_energy) + self._mono_energy = Reference(mono.energy) self.add_readables( [ - self._IdEnergy().energy(), - self._pgm_ref().energy.user_readback, + self._Id_energy().energy(), + self._mono_energy().user_readback, ], StandardReadableFormat.HINTED_SIGNAL, ) @@ -648,6 +663,6 @@ def __init__(self, id_energy: IdEnergy, pgm: PGM, name: str = "") -> None: async def set(self, energy: float) -> None: LOGGER.info(f"Moving f{self.name} energy to {energy}.") await asyncio.gather( - self._IdEnergy().set(energy=energy + await self.energy_offset.get_value()), - self._pgm_ref().energy.set(energy), + self._Id_energy().set(energy=energy + await self.energy_offset.get_value()), + self._mono_energy().set(energy), ) diff --git a/src/dodal/devices/pgm.py b/src/dodal/devices/pgm.py index c0abadf2a6..acf77abdb2 100644 --- a/src/dodal/devices/pgm.py +++ b/src/dodal/devices/pgm.py @@ -1,3 +1,5 @@ +import abc + from ophyd_async.core import ( StandardReadable, StandardReadableFormat, @@ -7,7 +9,15 @@ from ophyd_async.epics.motor import Motor -class PGM(StandardReadable): +class MonoEnergyBase(abc.ABC, StandardReadable): + """Base class for Mono energy movable device.""" + + def __init__(self, name: str = "") -> None: + self.energy: Motor + super().__init__(name=name) + + +class PGM(MonoEnergyBase): """ Plane grating monochromator, it is use in soft x-ray beamline to generate monochromic beam. """ diff --git a/tests/devices/i10/test_i10Apple2.py b/tests/devices/i10/test_i10Apple2.py index 2c6c273cb8..f35f1a3156 100644 --- a/tests/devices/i10/test_i10Apple2.py +++ b/tests/devices/i10/test_i10Apple2.py @@ -181,7 +181,7 @@ async def beam_energy( mock_id_energy: IdEnergy, mock_id_controller: I10Apple2Controller, mock_pgm: PGM ) -> BeamEnergy: async with init_devices(mock=True): - beam_energy = BeamEnergy(id_energy=mock_id_energy, pgm=mock_pgm) + beam_energy = BeamEnergy(id_energy=mock_id_energy, mono=mock_pgm) return beam_energy @@ -313,9 +313,9 @@ def capture_emitted(name, doc): rbv_mocks = Mock() rbv_mocks.get.side_effect = range(1700, 1810, 10) callback_on_mock_put( - beam_energy._pgm_ref().energy.user_setpoint, + beam_energy._mono_energy().user_setpoint, lambda *_, **__: set_mock_value( - beam_energy._pgm_ref().energy.user_readback, rbv_mocks.get() + beam_energy._mono_energy().user_readback, rbv_mocks.get() ), ) RE( From b868e6053be8e06ebbcf7e388131611687162768 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Tue, 7 Oct 2025 15:37:00 +0000 Subject: [PATCH 16/30] tidy up --- src/dodal/devices/apple2_undulator.py | 64 +++++++++++++-------------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/src/dodal/devices/apple2_undulator.py b/src/dodal/devices/apple2_undulator.py index 5bd7e798ea..4aeb851032 100644 --- a/src/dodal/devices/apple2_undulator.py +++ b/src/dodal/devices/apple2_undulator.py @@ -582,7 +582,7 @@ def determine_phase_from_hardware( class IdEnergyBase(abc.ABC, StandardReadable, Movable): - """Base class for energy movable device.""" + """Base class for ID energy movable device.""" def __init__(self, name: str = "") -> None: self.energy: Reference[SignalRW[float]] @@ -593,37 +593,6 @@ def __init__(self, name: str = "") -> None: async def set(self, energy: float) -> None: ... -class IdEnergy(IdEnergyBase): - """Apple2 ID energy movable device.""" - - def __init__(self, id_controller: Apple2Controller, name: str = "") -> None: - self.energy = Reference(id_controller.energy) - super().__init__(name=name) - - @AsyncStatus.wrap - async def set(self, energy: float) -> None: - await self.energy().set(energy) - - -class IdPolarisation(StandardReadable, Movable): - """Apple2 ID polarisation movable device.""" - - def __init__(self, id_controller: Apple2Controller, name: str = "") -> None: - self.polarisation = Reference(id_controller.polarisation) - super().__init__(name=name) - - self.add_readables( - [ - self.polarisation(), - ], - StandardReadableFormat.HINTED_SIGNAL, - ) - - @AsyncStatus.wrap - async def set(self, pol: Pol) -> None: - await self.polarisation().set(pol) - - class BeamEnergy(StandardReadable, Movable[float]): """ Compound device to set both ID and PGM energy at the same time. @@ -666,3 +635,34 @@ async def set(self, energy: float) -> None: self._Id_energy().set(energy=energy + await self.energy_offset.get_value()), self._mono_energy().set(energy), ) + + +class IdEnergy(IdEnergyBase): + """Apple2 ID energy movable device.""" + + def __init__(self, id_controller: Apple2Controller, name: str = "") -> None: + self.energy = Reference(id_controller.energy) + super().__init__(name=name) + + @AsyncStatus.wrap + async def set(self, energy: float) -> None: + await self.energy().set(energy) + + +class IdPolarisation(StandardReadable, Movable): + """Apple2 ID polarisation movable device.""" + + def __init__(self, id_controller: Apple2Controller, name: str = "") -> None: + self.polarisation = Reference(id_controller.polarisation) + super().__init__(name=name) + + self.add_readables( + [ + self.polarisation(), + ], + StandardReadableFormat.HINTED_SIGNAL, + ) + + @AsyncStatus.wrap + async def set(self, pol: Pol) -> None: + await self.polarisation().set(pol) From cfd01d31f02131e602e77e4af4ea910971c9c440 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Tue, 7 Oct 2025 15:38:49 +0000 Subject: [PATCH 17/30] docstring correction --- src/dodal/devices/apple2_undulator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dodal/devices/apple2_undulator.py b/src/dodal/devices/apple2_undulator.py index 4aeb851032..943d2f3392 100644 --- a/src/dodal/devices/apple2_undulator.py +++ b/src/dodal/devices/apple2_undulator.py @@ -595,7 +595,7 @@ async def set(self, energy: float) -> None: ... class BeamEnergy(StandardReadable, Movable[float]): """ - Compound device to set both ID and PGM energy at the same time. + Compound device to set both ID and PGM energy at the same time with an option to add an offset. """ From 93e734ef0d043286590b537deca697cf61b7b370 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Tue, 7 Oct 2025 15:52:16 +0000 Subject: [PATCH 18/30] correct docstring --- src/dodal/beamlines/i10.py | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/src/dodal/beamlines/i10.py b/src/dodal/beamlines/i10.py index 9cd512a87e..f609394482 100644 --- a/src/dodal/beamlines/i10.py +++ b/src/dodal/beamlines/i10.py @@ -65,12 +65,7 @@ def pgm() -> PGM: @device_factory() def idd() -> I10Apple2: - """i10 downstream insertion device: - id.energy.set() to change beamline energy. - id.energy.energy_offset.set() to change id energy offset relative to pgm. - id.pol.set() to change polarisation. - id.laa.set() to change polarisation angle, must be in LA mode. - """ + """i10 downstream insertion device:""" return I10Apple2( id_gap=UndulatorGap(prefix=f"{PREFIX.insertion_prefix}-MO-SERVC-01:"), id_phase=UndulatorPhaseAxes( @@ -89,7 +84,7 @@ def idd() -> I10Apple2: @device_factory() def idd_controller() -> I10Apple2Controller: - """I10 insertion device controller, it controls both idu and idd.""" + """I10 downstream insertion device controller.""" return I10Apple2Controller( apple2=idd(), lookuptable_dir=LOOK_UPTABLE_DIR, @@ -115,12 +110,7 @@ def idd_laa() -> LinearArbitraryAngle: @device_factory() def idu() -> I10Apple2: - """i10 downstream insertion device: - id.energy.set() to change beamline energy. - id.energy.energy_offset.set() to change id energy offset relative to pgm. - id.pol.set() to change polarisation. - id.laa.set() to change polarisation angle, must be in LA mode. - """ + """i10 upstream insertion device""" return I10Apple2( id_gap=UndulatorGap(prefix=f"{PREFIX.insertion_prefix}-MO-SERVC-21:"), id_phase=UndulatorPhaseAxes( @@ -139,7 +129,7 @@ def idu() -> I10Apple2: @device_factory() def idu_controller() -> I10Apple2Controller: - """I10 insertion device controller, it controls both idu and idd.""" + """I10 upstream insertion device controller.""" return I10Apple2Controller( apple2=idu(), lookuptable_dir=LOOK_UPTABLE_DIR, From 795e225395c931ec39c7d9f40935b2c72d426df4 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Tue, 7 Oct 2025 16:06:04 +0000 Subject: [PATCH 19/30] add typing --- src/dodal/devices/i10/i10_apple2.py | 2 +- tests/devices/i10/test_i10Apple2.py | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/dodal/devices/i10/i10_apple2.py b/src/dodal/devices/i10/i10_apple2.py index 0696dd3bd4..e6f069fc43 100644 --- a/src/dodal/devices/i10/i10_apple2.py +++ b/src/dodal/devices/i10/i10_apple2.py @@ -325,7 +325,7 @@ def __init__( name: str = "", ) -> None: """ - I10Apple2 device is an appl2 with extra jaw phase control. + I10Apple2 device is an apple2 with extra jaw phase motor. Parameters ---------- diff --git a/tests/devices/i10/test_i10Apple2.py b/tests/devices/i10/test_i10Apple2.py index f35f1a3156..7f7b1d449d 100644 --- a/tests/devices/i10/test_i10Apple2.py +++ b/tests/devices/i10/test_i10Apple2.py @@ -131,7 +131,11 @@ def my_side_effect(file_path, reset_cached_result) -> str: @pytest.fixture -async def mock_id(mock_id_gap, mock_phaseAxes, mock_jaw_phase) -> I10Apple2: +async def mock_id( + mock_id_gap: UndulatorGap, + mock_phaseAxes: UndulatorPhaseAxes, + mock_jaw_phase: UndulatorJawPhase, +) -> I10Apple2: async with init_devices(mock=True): mock_id = I10Apple2( id_gap=mock_id_gap, id_phase=mock_phaseAxes, id_jaw_phase=mock_jaw_phase @@ -142,7 +146,7 @@ async def mock_id(mock_id_gap, mock_phaseAxes, mock_jaw_phase) -> I10Apple2: @pytest.fixture async def mock_id_controller( mock_id: I10Apple2, - mock_config_client, + mock_config_client: ConfigServer, ) -> I10Apple2Controller: async with init_devices(mock=True): mock_id_controller = I10Apple2Controller( @@ -177,9 +181,7 @@ async def mock_id_energy( @pytest.fixture -async def beam_energy( - mock_id_energy: IdEnergy, mock_id_controller: I10Apple2Controller, mock_pgm: PGM -) -> BeamEnergy: +async def beam_energy(mock_id_energy: IdEnergy, mock_pgm: PGM) -> BeamEnergy: async with init_devices(mock=True): beam_energy = BeamEnergy(id_energy=mock_id_energy, mono=mock_pgm) return beam_energy From fc6d69c758858082d1ccc45d1608ba7b45957242 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 9 Oct 2025 09:53:15 +0000 Subject: [PATCH 20/30] make use of energy_to_motor --- src/dodal/devices/apple2_undulator.py | 3 ++- src/dodal/devices/i10/i10_apple2.py | 11 +++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/dodal/devices/apple2_undulator.py b/src/dodal/devices/apple2_undulator.py index 943d2f3392..085e9524f0 100644 --- a/src/dodal/devices/apple2_undulator.py +++ b/src/dodal/devices/apple2_undulator.py @@ -410,6 +410,7 @@ class Apple2Controller(abc.ABC, StandardReadable, Generic[Apple2Type]): def __init__( self, apple2: Apple2Type, + energy_to_motor_converter: EnergyMotorConvertor, name: str = "", ) -> None: """ @@ -421,7 +422,7 @@ def __init__( name: str Name of the device. """ - self.energy_to_motor: EnergyMotorConvertor + self.energy_to_motor = energy_to_motor_converter self.apple2 = Reference(apple2) # Store the set energy for readback. diff --git a/src/dodal/devices/i10/i10_apple2.py b/src/dodal/devices/i10/i10_apple2.py index e6f069fc43..1c8b528572 100644 --- a/src/dodal/devices/i10/i10_apple2.py +++ b/src/dodal/devices/i10/i10_apple2.py @@ -382,12 +382,17 @@ def __init__( name : str, optional New device name. """ - super().__init__(apple2=apple2, name=name) + self.lookup_table_client = I10EnergyMotorLookup( lookuptable_dir=lookuptable_dir, source=source, config_client=config_client, ) + super().__init__( + apple2=apple2, + energy_to_motor_converter=self.lookup_table_client.get_motor_from_energy, + name=name, + ) self.jaw_phase_from_angle = np.poly1d(jaw_phase_poly_param) self.angle_threshold_deg = angle_threshold_deg @@ -442,9 +447,7 @@ async def _set_motors_from_energy(self, value: float) -> None: ) self._polarisation_setpoint_set(pol) - gap, phase = self.lookup_table_client.get_motor_from_energy( - energy=value, pol=pol - ) + gap, phase = self.energy_to_motor(energy=value, pol=pol) phase3 = phase * (-1 if pol == Pol.LA else 1) id_set_val = Apple2Val( top_outer=f"{phase:.6f}", From 3c384201b764f8980eb117903580386a92726381 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 9 Oct 2025 11:55:19 +0000 Subject: [PATCH 21/30] fix beamEnergy dostring --- src/dodal/devices/apple2_undulator.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/dodal/devices/apple2_undulator.py b/src/dodal/devices/apple2_undulator.py index 085e9524f0..23b6b2feac 100644 --- a/src/dodal/devices/apple2_undulator.py +++ b/src/dodal/devices/apple2_undulator.py @@ -609,8 +609,8 @@ def __init__( id_energy: IdEnergy An IdEnergy device. - pgm: PGM - A PGM device. + pgm: MonoEnergyBase + A energy device. name: New device name. """ From 80b539f53fec33489e9bc2cdae22d5031a1c2f0c Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 9 Oct 2025 11:58:18 +0000 Subject: [PATCH 22/30] rename offset with id_energy_offset --- src/dodal/devices/apple2_undulator.py | 6 ++++-- tests/devices/i10/test_i10Apple2.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/dodal/devices/apple2_undulator.py b/src/dodal/devices/apple2_undulator.py index 23b6b2feac..55642ae34f 100644 --- a/src/dodal/devices/apple2_undulator.py +++ b/src/dodal/devices/apple2_undulator.py @@ -627,13 +627,15 @@ def __init__( ) with self.add_children_as_readables(StandardReadableFormat.CONFIG_SIGNAL): - self.energy_offset = soft_signal_rw(float, initial_value=0) + self.id_energy_offset = soft_signal_rw(float, initial_value=0) @AsyncStatus.wrap async def set(self, energy: float) -> None: LOGGER.info(f"Moving f{self.name} energy to {energy}.") await asyncio.gather( - self._Id_energy().set(energy=energy + await self.energy_offset.get_value()), + self._Id_energy().set( + energy=energy + await self.id_energy_offset.get_value() + ), self._mono_energy().set(energy), ) diff --git a/tests/devices/i10/test_i10Apple2.py b/tests/devices/i10/test_i10Apple2.py index 7f7b1d449d..d853394165 100644 --- a/tests/devices/i10/test_i10Apple2.py +++ b/tests/devices/i10/test_i10Apple2.py @@ -311,7 +311,7 @@ def capture_emitted(name, doc): mock_id_controller._polarisation_setpoint_set(Pol("lh3")) # with energy offset - await beam_energy.energy_offset.set(20) + await beam_energy.id_energy_offset.set(20) rbv_mocks = Mock() rbv_mocks.get.side_effect = range(1700, 1810, 10) callback_on_mock_put( From e614518c4c318dbeb3f16f42eb259bab72a05fd0 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 9 Oct 2025 12:07:36 +0000 Subject: [PATCH 23/30] add energy readable to idEnergy --- src/dodal/devices/apple2_undulator.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/dodal/devices/apple2_undulator.py b/src/dodal/devices/apple2_undulator.py index 55642ae34f..be4fba7569 100644 --- a/src/dodal/devices/apple2_undulator.py +++ b/src/dodal/devices/apple2_undulator.py @@ -647,6 +647,13 @@ def __init__(self, id_controller: Apple2Controller, name: str = "") -> None: self.energy = Reference(id_controller.energy) super().__init__(name=name) + self.add_readables( + [ + self.energy(), + ], + StandardReadableFormat.HINTED_SIGNAL, + ) + @AsyncStatus.wrap async def set(self, energy: float) -> None: await self.energy().set(energy) From af923518974882a829fbca8dc23c2ee59ac57e7c Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 9 Oct 2025 12:12:56 +0000 Subject: [PATCH 24/30] change _energy to soft_signal_r_and_setter --- src/dodal/devices/apple2_undulator.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/dodal/devices/apple2_undulator.py b/src/dodal/devices/apple2_undulator.py index be4fba7569..ae7655fefd 100644 --- a/src/dodal/devices/apple2_undulator.py +++ b/src/dodal/devices/apple2_undulator.py @@ -426,7 +426,9 @@ def __init__( self.apple2 = Reference(apple2) # Store the set energy for readback. - self._energy = soft_signal_rw(float, initial_value=None) + self._energy, self._energy_set = soft_signal_r_and_setter( + float, initial_value=None + ) with self.add_children_as_readables(StandardReadableFormat.HINTED_SIGNAL): self.energy = derived_signal_rw( raw_to_derived=self._read_energy, @@ -463,7 +465,7 @@ async def _set_motors_from_energy(self, value: float) -> None: async def _set_energy(self, energy: float) -> None: await self._set_motors_from_energy(energy) - await self._energy.set(energy) + self._energy_set(energy) def _read_energy(self, energy: float) -> float: """Readback for energy is just the set value.""" From d78597079196ec95f756bfa1842f95eebd214b8a Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 9 Oct 2025 12:40:30 +0000 Subject: [PATCH 25/30] make polarisation Locatable --- src/dodal/devices/apple2_undulator.py | 12 ++++++++-- tests/devices/i10/test_i10Apple2.py | 32 +++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/dodal/devices/apple2_undulator.py b/src/dodal/devices/apple2_undulator.py index ae7655fefd..2f250ba184 100644 --- a/src/dodal/devices/apple2_undulator.py +++ b/src/dodal/devices/apple2_undulator.py @@ -5,7 +5,7 @@ from typing import Generic, Protocol, TypeVar import numpy as np -from bluesky.protocols import Movable +from bluesky.protocols import Locatable, Location, Movable from ophyd_async.core import ( AsyncStatus, Reference, @@ -661,11 +661,12 @@ async def set(self, energy: float) -> None: await self.energy().set(energy) -class IdPolarisation(StandardReadable, Movable): +class IdPolarisation(StandardReadable, Locatable[Pol]): """Apple2 ID polarisation movable device.""" def __init__(self, id_controller: Apple2Controller, name: str = "") -> None: self.polarisation = Reference(id_controller.polarisation) + self.polarisation_setpoint = Reference(id_controller.polarisation_setpoint) super().__init__(name=name) self.add_readables( @@ -678,3 +679,10 @@ def __init__(self, id_controller: Apple2Controller, name: str = "") -> None: @AsyncStatus.wrap async def set(self, pol: Pol) -> None: await self.polarisation().set(pol) + + async def locate(self) -> Location[Pol]: + """Return the current polarisation""" + setpoint, readback = await asyncio.gather( + self.polarisation_setpoint().get_value(), self.polarisation().get_value() + ) + return Location(setpoint=setpoint, readback=readback) diff --git a/tests/devices/i10/test_i10Apple2.py b/tests/devices/i10/test_i10Apple2.py index d853394165..79e84e23ae 100644 --- a/tests/devices/i10/test_i10Apple2.py +++ b/tests/devices/i10/test_i10Apple2.py @@ -409,6 +409,38 @@ async def test_id_polarisation_set( assert float(gap.call_args[0][0]) == pytest.approx(expect_gap, 0.05) +@pytest.mark.parametrize( + "pol,energy, top_outer, top_inner, btm_inner,btm_outer", + [ + (Pol.LH, 550, 0.0, 0.0, 0.0, 0.0), + (Pol.LV, 600, 24.0, 0.0, 24.0, 0.0), + (Pol.PC, 550, 15.5, 0.0, 15.5, 0.0), + (Pol.NC, 550, -15.5, 0.0, -15.5, 0.0), + (Pol.LA, 1300, -16.4, 0.0, 16.4, 0.0), + ], +) +async def test_id_polarisation_locate( + mock_id_pol: IdPolarisation, + mock_id_controller: I10Apple2Controller, + pol: Pol, + energy: float, + top_inner: float, + top_outer: float, + btm_inner: float, + btm_outer: float, +): + await mock_id_controller.energy.set(energy) + + await mock_id_pol.set(pol=pol) + assert await mock_id_pol.locate() == {"setpoint": pol, "readback": Pol.LH} + # move the motor + set_mock_value(mock_id_controller.apple2().phase.top_inner.user_readback, top_inner) + set_mock_value(mock_id_controller.apple2().phase.top_outer.user_readback, top_outer) + set_mock_value(mock_id_controller.apple2().phase.btm_inner.user_readback, btm_inner) + set_mock_value(mock_id_controller.apple2().phase.btm_outer.user_readback, btm_outer) + assert await mock_id_pol.locate() == {"setpoint": pol, "readback": pol} + + @pytest.mark.parametrize( "pol,energy, top_outer, top_inner, btm_inner,btm_outer", [ From 5c3924154a2eec176799b7dd07ce75e1dc95b5a5 Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 9 Oct 2025 12:46:38 +0000 Subject: [PATCH 26/30] rename idEnergy to InsertionDeviceEnergy --- src/dodal/beamlines/i10.py | 10 +++++----- src/dodal/devices/apple2_undulator.py | 10 +++++----- tests/devices/i10/test_i10Apple2.py | 10 ++++++---- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/dodal/beamlines/i10.py b/src/dodal/beamlines/i10.py index f609394482..b24277b034 100644 --- a/src/dodal/beamlines/i10.py +++ b/src/dodal/beamlines/i10.py @@ -11,8 +11,8 @@ from dodal.common.beamlines.beamline_utils import device_factory from dodal.common.beamlines.beamline_utils import set_beamline as set_utils_beamline from dodal.devices.apple2_undulator import ( - IdEnergy, IdPolarisation, + InsertionDeviceEnergy, UndulatorGap, UndulatorJawPhase, UndulatorPhaseAxes, @@ -94,8 +94,8 @@ def idd_controller() -> I10Apple2Controller: @device_factory() -def idd_energy() -> IdEnergy: - return IdEnergy(id_controller=idd_controller()) +def idd_energy() -> InsertionDeviceEnergy: + return InsertionDeviceEnergy(id_controller=idd_controller()) @device_factory() @@ -139,8 +139,8 @@ def idu_controller() -> I10Apple2Controller: @device_factory() -def idu_energy() -> IdEnergy: - return IdEnergy(id_controller=idu_controller()) +def idu_energy() -> InsertionDeviceEnergy: + return InsertionDeviceEnergy(id_controller=idu_controller()) @device_factory() diff --git a/src/dodal/devices/apple2_undulator.py b/src/dodal/devices/apple2_undulator.py index 2f250ba184..924a550e5b 100644 --- a/src/dodal/devices/apple2_undulator.py +++ b/src/dodal/devices/apple2_undulator.py @@ -584,7 +584,7 @@ def determine_phase_from_hardware( return Pol.NONE, 0.0 -class IdEnergyBase(abc.ABC, StandardReadable, Movable): +class InsertionDeviceEnergyBase(abc.ABC, StandardReadable, Movable): """Base class for ID energy movable device.""" def __init__(self, name: str = "") -> None: @@ -603,14 +603,14 @@ class BeamEnergy(StandardReadable, Movable[float]): """ def __init__( - self, id_energy: IdEnergyBase, mono: MonoEnergyBase, name: str = "" + self, id_energy: InsertionDeviceEnergyBase, mono: MonoEnergyBase, name: str = "" ) -> None: """ Parameters ---------- - id_energy: IdEnergy - An IdEnergy device. + id_energy: InsertionDeviceEnergy + An InsertionDeviceEnergy device. pgm: MonoEnergyBase A energy device. name: @@ -642,7 +642,7 @@ async def set(self, energy: float) -> None: ) -class IdEnergy(IdEnergyBase): +class InsertionDeviceEnergy(InsertionDeviceEnergyBase): """Apple2 ID energy movable device.""" def __init__(self, id_controller: Apple2Controller, name: str = "") -> None: diff --git a/tests/devices/i10/test_i10Apple2.py b/tests/devices/i10/test_i10Apple2.py index 79e84e23ae..2b60a98f3c 100644 --- a/tests/devices/i10/test_i10Apple2.py +++ b/tests/devices/i10/test_i10Apple2.py @@ -19,8 +19,8 @@ from dodal.devices.apple2_undulator import ( BeamEnergy, - IdEnergy, IdPolarisation, + InsertionDeviceEnergy, Pol, UndulatorGap, UndulatorGateStatus, @@ -172,16 +172,18 @@ async def mock_id_controller( @pytest.fixture async def mock_id_energy( mock_id_controller: I10Apple2Controller, -) -> IdEnergy: +) -> InsertionDeviceEnergy: async with init_devices(mock=True): - mock_id_energy = IdEnergy( + mock_id_energy = InsertionDeviceEnergy( id_controller=mock_id_controller, ) return mock_id_energy @pytest.fixture -async def beam_energy(mock_id_energy: IdEnergy, mock_pgm: PGM) -> BeamEnergy: +async def beam_energy( + mock_id_energy: InsertionDeviceEnergy, mock_pgm: PGM +) -> BeamEnergy: async with init_devices(mock=True): beam_energy = BeamEnergy(id_energy=mock_id_energy, mono=mock_pgm) return beam_energy From 0d6fa200d7cc875c480c3451ab86355d49a378ee Mon Sep 17 00:00:00 2001 From: Relm-Arrowny Date: Thu, 9 Oct 2025 12:50:35 +0000 Subject: [PATCH 27/30] rename IdPolarisation to InsertionDevicePolarisation --- src/dodal/beamlines/i10.py | 10 +++++----- src/dodal/devices/apple2_undulator.py | 2 +- tests/devices/i10/test_i10Apple2.py | 16 +++++++++------- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/dodal/beamlines/i10.py b/src/dodal/beamlines/i10.py index b24277b034..33cacce6fd 100644 --- a/src/dodal/beamlines/i10.py +++ b/src/dodal/beamlines/i10.py @@ -11,8 +11,8 @@ from dodal.common.beamlines.beamline_utils import device_factory from dodal.common.beamlines.beamline_utils import set_beamline as set_utils_beamline from dodal.devices.apple2_undulator import ( - IdPolarisation, InsertionDeviceEnergy, + InsertionDevicePolarisation, UndulatorGap, UndulatorJawPhase, UndulatorPhaseAxes, @@ -99,8 +99,8 @@ def idd_energy() -> InsertionDeviceEnergy: @device_factory() -def idd_polarisation() -> IdPolarisation: - return IdPolarisation(id_controller=idd_controller()) +def idd_polarisation() -> InsertionDevicePolarisation: + return InsertionDevicePolarisation(id_controller=idd_controller()) @device_factory() @@ -144,8 +144,8 @@ def idu_energy() -> InsertionDeviceEnergy: @device_factory() -def idu_polarisation() -> IdPolarisation: - return IdPolarisation(id_controller=idu_controller()) +def idu_polarisation() -> InsertionDevicePolarisation: + return InsertionDevicePolarisation(id_controller=idu_controller()) @device_factory() diff --git a/src/dodal/devices/apple2_undulator.py b/src/dodal/devices/apple2_undulator.py index 924a550e5b..fea98b10a9 100644 --- a/src/dodal/devices/apple2_undulator.py +++ b/src/dodal/devices/apple2_undulator.py @@ -661,7 +661,7 @@ async def set(self, energy: float) -> None: await self.energy().set(energy) -class IdPolarisation(StandardReadable, Locatable[Pol]): +class InsertionDevicePolarisation(StandardReadable, Locatable[Pol]): """Apple2 ID polarisation movable device.""" def __init__(self, id_controller: Apple2Controller, name: str = "") -> None: diff --git a/tests/devices/i10/test_i10Apple2.py b/tests/devices/i10/test_i10Apple2.py index 2b60a98f3c..f888c358a5 100644 --- a/tests/devices/i10/test_i10Apple2.py +++ b/tests/devices/i10/test_i10Apple2.py @@ -19,8 +19,8 @@ from dodal.devices.apple2_undulator import ( BeamEnergy, - IdPolarisation, InsertionDeviceEnergy, + InsertionDevicePolarisation, Pol, UndulatorGap, UndulatorGateStatus, @@ -190,9 +190,11 @@ async def beam_energy( @pytest.fixture -async def mock_id_pol(mock_id_controller: I10Apple2Controller) -> IdPolarisation: +async def mock_id_pol( + mock_id_controller: I10Apple2Controller, +) -> InsertionDevicePolarisation: async with init_devices(mock=True): - mock_id_pol = IdPolarisation(id_controller=mock_id_controller) + mock_id_pol = InsertionDevicePolarisation(id_controller=mock_id_controller) return mock_id_pol @@ -364,7 +366,7 @@ def capture_emitted(name, doc): ], ) async def test_id_polarisation_set( - mock_id_pol: IdPolarisation, + mock_id_pol: InsertionDevicePolarisation, mock_id_controller: I10Apple2Controller, pol: Pol, energy: float, @@ -422,7 +424,7 @@ async def test_id_polarisation_set( ], ) async def test_id_polarisation_locate( - mock_id_pol: IdPolarisation, + mock_id_pol: InsertionDevicePolarisation, mock_id_controller: I10Apple2Controller, pol: Pol, energy: float, @@ -454,7 +456,7 @@ async def test_id_polarisation_locate( ], ) async def test_id_polarisation_read_check_pol_from_hardware( - mock_id_pol: IdPolarisation, + mock_id_pol: InsertionDevicePolarisation, mock_id_controller: I10Apple2Controller, pol: str, energy: float, @@ -480,7 +482,7 @@ async def test_id_polarisation_read_check_pol_from_hardware( ], ) async def test_id_polarisation_read_leave_lh3_unchanged_when_hardware_match( - mock_id_pol: IdPolarisation, + mock_id_pol: InsertionDevicePolarisation, mock_id_controller: I10Apple2Controller, pol: str, energy: float, From 2c9133a6945bdf2d3e35a2861dd61905b6180a37 Mon Sep 17 00:00:00 2001 From: eir17846 Date: Fri, 10 Oct 2025 09:06:00 +0000 Subject: [PATCH 28/30] create placeholder apple2 class for k07 --- src/dodal/beamlines/k07.py | 61 ++++++++++++++++++++++- src/dodal/devices/k07/__init__.py | 3 ++ src/dodal/devices/k07/insertion_device.py | 23 +++++++++ tests/devices/k07/__init__.py | 0 tests/devices/k07/test_id.py | 58 +++++++++++++++++++++ 5 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 src/dodal/devices/k07/__init__.py create mode 100644 src/dodal/devices/k07/insertion_device.py create mode 100644 tests/devices/k07/__init__.py create mode 100644 tests/devices/k07/test_id.py diff --git a/src/dodal/beamlines/k07.py b/src/dodal/beamlines/k07.py index bdde96eb80..49c123fbf7 100644 --- a/src/dodal/beamlines/k07.py +++ b/src/dodal/beamlines/k07.py @@ -4,6 +4,14 @@ device_factory, ) from dodal.common.beamlines.beamline_utils import set_beamline as set_utils_beamline +from dodal.devices.apple2_undulator import ( + Apple2, + InsertionDeviceEnergy, + InsertionDevicePolarisation, + UndulatorGap, + UndulatorPhaseAxes, +) +from dodal.devices.k07 import K07Apple2Controller from dodal.devices.pgm import PGM from dodal.devices.synchrotron import Synchrotron from dodal.log import set_beamline as set_log_beamline @@ -28,4 +36,55 @@ class Grating(StrictEnum): # Grating does not exist yet - this class is a placeholder for when it does @device_factory(skip=True) def pgm() -> PGM: - return PGM(prefix=f"{PREFIX.beamline_prefix}-OP-PGM-01:", grating=Grating) + return PGM( + prefix=f"{PREFIX.beamline_prefix}-OP-PGM-01:", + grating=Grating, + ) + + +# Insertion device objects + + +# Insertion device gap and phase do not exist yet - these classes are placeholders for when they do +@device_factory(skip=True) +def id_gap() -> UndulatorGap: + return UndulatorGap( + prefix=f"{PREFIX.insertion_prefix}-MO-SERVC-01:", + ) + + +@device_factory(skip=True) +def id_phase() -> UndulatorPhaseAxes: + return UndulatorPhaseAxes( + prefix=f"{PREFIX.insertion_prefix}-MO-SERVC-01:", + top_outer="RPQ1", + top_inner="RPQ2", + btm_inner="RPQ3", + btm_outer="RPQ4", + ) + + +# Insertion device raw does not exist yet - this class is a placeholder for when it does +@device_factory(skip=True) +def id() -> Apple2: + return Apple2( + id_gap=id_gap(), + id_phase=id_phase(), + ) + + +# Insertion device controller does not exist yet - this class is a placeholder for when it does +@device_factory(skip=True) +def id_controller() -> K07Apple2Controller: + return K07Apple2Controller(apple2=id()) + + +# Insertion device energy does not exist yet - this class is a placeholder for when it does +@device_factory(skip=True) +def id_energy() -> InsertionDeviceEnergy: + return InsertionDeviceEnergy(id_controller=id_controller()) + + +@device_factory(skip=True) +def id_polarisation() -> InsertionDevicePolarisation: + return InsertionDevicePolarisation(id_controller=id_controller()) diff --git a/src/dodal/devices/k07/__init__.py b/src/dodal/devices/k07/__init__.py new file mode 100644 index 0000000000..8b710bb516 --- /dev/null +++ b/src/dodal/devices/k07/__init__.py @@ -0,0 +1,3 @@ +from .insertion_device import K07Apple2Controller + +__all__ = ["K07Apple2Controller"] diff --git a/src/dodal/devices/k07/insertion_device.py b/src/dodal/devices/k07/insertion_device.py new file mode 100644 index 0000000000..8b8e11e8b4 --- /dev/null +++ b/src/dodal/devices/k07/insertion_device.py @@ -0,0 +1,23 @@ +from dodal.devices.apple2_undulator import Apple2, Apple2Controller + + +# Inversion device on K07 does not exist yet - this class is a placeholder for when it does +class K07Apple2Controller(Apple2Controller): + """K07 insertion device controller""" + + def __init__( + self, + apple2: Apple2, + name: str = "", + ) -> None: + super().__init__( + apple2=apple2, + energy_to_motor_converter=lambda energy, pol: (0.0, 0.0), + name=name, + ) + + async def _set_motors_from_energy(self, value: float) -> None: + """ + Set the undulator motors for a given energy and polarisation. + """ + pass diff --git a/tests/devices/k07/__init__.py b/tests/devices/k07/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/devices/k07/test_id.py b/tests/devices/k07/test_id.py new file mode 100644 index 0000000000..f7a0190e62 --- /dev/null +++ b/tests/devices/k07/test_id.py @@ -0,0 +1,58 @@ +from unittest.mock import MagicMock + +import pytest +from ophyd_async.core import init_devices + +from dodal.devices.apple2_undulator import ( + Apple2, + InsertionDeviceEnergy, + UndulatorGap, + UndulatorPhaseAxes, +) +from dodal.devices.k07 import K07Apple2Controller + + +@pytest.fixture +async def id_gap() -> UndulatorGap: + async with init_devices(mock=True): + return UndulatorGap(prefix="TEST-MO-SERVC-01:") + + +@pytest.fixture +async def id_phase() -> UndulatorPhaseAxes: + async with init_devices(mock=True): + return UndulatorPhaseAxes( + prefix="TEST-MO-SERVC-01:", + top_outer="RPQ1", + top_inner="RPQ2", + btm_inner="RPQ3", + btm_outer="RPQ4", + ) + + +@pytest.fixture +async def id( + id_gap: UndulatorGap, + id_phase: UndulatorPhaseAxes, +) -> Apple2: + async with init_devices(mock=True): + return Apple2(id_gap=id_gap, id_phase=id_phase) + + +@pytest.fixture +async def id_controller(id: Apple2) -> K07Apple2Controller: + async with init_devices(mock=True): + return K07Apple2Controller(apple2=id) + + +# Insertion device controller does not exist yet - this class is a placeholder for when it does +async def test_id_controller_set_energy(id_controller: K07Apple2Controller) -> None: + async with init_devices(mock=True): + id_energy = InsertionDeviceEnergy(id_controller=id_controller) + assert id_energy is not None + id_controller._set_motors_from_energy = MagicMock( + side_effect=id_controller._set_motors_from_energy + ) + await id_energy.set(500.0) + id_controller._set_motors_from_energy.assert_called_once_with(500.0) + assert await id_controller.energy.get_value() == 500.0 From 3bbac20b0f3db3272d313f90462962f4d3b51228 Mon Sep 17 00:00:00 2001 From: eir17846 Date: Mon, 13 Oct 2025 15:48:08 +0000 Subject: [PATCH 29/30] fix ruff --- src/dodal/devices/apple2_undulator.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/dodal/devices/apple2_undulator.py b/src/dodal/devices/apple2_undulator.py index d3acd857e7..9f39a27eb1 100644 --- a/src/dodal/devices/apple2_undulator.py +++ b/src/dodal/devices/apple2_undulator.py @@ -23,7 +23,6 @@ from ophyd_async.epics.core import epics_signal_r, epics_signal_rw, epics_signal_w from ophyd_async.epics.motor import Motor -from dodal.devices.pgm import MonoEnergyBase from dodal.log import LOGGER T = TypeVar("T") From 458f597071c02276652d33ed1f17fa7f3bfbb168 Mon Sep 17 00:00:00 2001 From: eir17846 Date: Thu, 23 Oct 2025 11:01:59 +0000 Subject: [PATCH 30/30] roll back pgm change --- src/dodal/devices/pgm.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/dodal/devices/pgm.py b/src/dodal/devices/pgm.py index acf77abdb2..c0abadf2a6 100644 --- a/src/dodal/devices/pgm.py +++ b/src/dodal/devices/pgm.py @@ -1,5 +1,3 @@ -import abc - from ophyd_async.core import ( StandardReadable, StandardReadableFormat, @@ -9,15 +7,7 @@ from ophyd_async.epics.motor import Motor -class MonoEnergyBase(abc.ABC, StandardReadable): - """Base class for Mono energy movable device.""" - - def __init__(self, name: str = "") -> None: - self.energy: Motor - super().__init__(name=name) - - -class PGM(MonoEnergyBase): +class PGM(StandardReadable): """ Plane grating monochromator, it is use in soft x-ray beamline to generate monochromic beam. """