diff --git a/src/dodal/beamlines/i05.py b/src/dodal/beamlines/i05.py index cba83c2b6cb..989c7f1a9fe 100644 --- a/src/dodal/beamlines/i05.py +++ b/src/dodal/beamlines/i05.py @@ -2,9 +2,13 @@ from dodal.common.beamlines.beamline_utils import set_beamline as set_utils_beamline from dodal.device_manager import DeviceManager from dodal.devices.beamlines.i05 import I05Goniometer +from dodal.devices.beamlines.i05.enums import LensMode, PassEnergy from dodal.devices.beamlines.i05_shared import M4M5Mirror from dodal.devices.common_mirror import XYZSwitchingMirror +from dodal.devices.electron_analyser.base.energy_sources import EnergySource +from dodal.devices.electron_analyser.mbs import MbsAnalyserDriverIO from dodal.devices.hutch_shutter import HutchShutter +from dodal.devices.pgm import PlaneGratingMonochromator from dodal.devices.temperture_controller import Lakeshore336 from dodal.log import set_beamline as set_log_beamline from dodal.utils import BeamlinePrefix, get_beamline_name @@ -46,3 +50,18 @@ def sa() -> I05Goniometer: y_infix="SAY", z_infix="SAZ", ) + + +@devices.factory +def energy_source(pgm: PlaneGratingMonochromator) -> EnergySource: + return EnergySource(pgm.energy.user_readback) + + +@devices.factory +def analyser_driver(energy_source: EnergySource) -> MbsAnalyserDriverIO: + return MbsAnalyserDriverIO[LensMode, PassEnergy]( + prefix=f"{PREFIX.beamline_prefix}-EA-DET-02:CAM:", + lens_mode_type=LensMode, + pass_energy_type=PassEnergy, + # energy_source=energy_source, + ) diff --git a/src/dodal/beamlines/i05_1.py b/src/dodal/beamlines/i05_1.py index 763b64988fb..b870f9b7f63 100644 --- a/src/dodal/beamlines/i05_1.py +++ b/src/dodal/beamlines/i05_1.py @@ -1,9 +1,11 @@ from dodal.beamlines.i05_shared import devices as i05_shared_devices from dodal.common.beamlines.beamline_utils import set_beamline as set_utils_beamline from dodal.device_manager import DeviceManager +from dodal.devices.beamlines.i05.enums import LensMode, PassEnergy from dodal.devices.beamlines.i05_1 import XYZAzimuthPolarDefocusStage from dodal.devices.beamlines.i05_shared import Mj7j8Mirror from dodal.devices.common_mirror import XYZPiezoSwitchingMirror +from dodal.devices.electron_analyser.mbs import MbsAnalyserDriverIO from dodal.devices.hutch_shutter import HutchShutter from dodal.log import set_beamline as set_log_beamline from dodal.utils import BeamlinePrefix, get_beamline_name @@ -35,3 +37,19 @@ def nano_shutter() -> HutchShutter: def sm() -> XYZAzimuthPolarDefocusStage: """Sample Manipulator.""" return XYZAzimuthPolarDefocusStage(prefix=f"{PREFIX.beamline_prefix}-EA-SM-01:") + + +# @devices.factory +# def energy_source(pgm: PlaneGratingMonochromator) -> EnergySource: +# return EnergySource(pgm.energy.user_readback) + + +@devices.factory +# def analyser_driver(energy_source: EnergySource) -> MbsAnalyserDriverIO: +def analyser_driver() -> MbsAnalyserDriverIO[LensMode, PassEnergy]: + return MbsAnalyserDriverIO[LensMode, PassEnergy]( + prefix=f"{PREFIX.beamline_prefix}-EA-DET-04:CAM:", + lens_mode_type=LensMode, + pass_energy_type=PassEnergy, + # energy_source=energy_source, + ) diff --git a/src/dodal/common/data_util.py b/src/dodal/common/data_util.py index 7a0b3ab7ad2..e1473948415 100644 --- a/src/dodal/common/data_util.py +++ b/src/dodal/common/data_util.py @@ -1,5 +1,5 @@ from os.path import isabs, isfile, join, split -from typing import Protocol, Self, TypeVar +from typing import Generic, Protocol, Self, TypeVar from pydantic import BaseModel @@ -36,11 +36,19 @@ def save_class_to_json_file(model: BaseModel, file: str) -> None: f.write(model.model_dump_json()) -class JsonModelLoader(Protocol[TBaseModel]): - def __call__(self, file: str | None = None) -> TBaseModel: ... +class LoadModelFromFile(Protocol[TBaseModel]): + def __call__(self, file: str) -> TBaseModel: ... -class JsonLoaderConfig(BaseModel): +class LoadModelFromJsonFile(LoadModelFromFile[TBaseModel]): + def __init__(self, model) -> None: + self._model = model + + def __call__(self, file: str) -> TBaseModel: + return load_json_file_to_class(self._model, file) + + +class ModelLoaderConfig(BaseModel): default_path: str default_file: str | None @@ -60,15 +68,40 @@ def update_config_from_file(self, new_file: str) -> None: self.default_path, self.default_file = split(new_file) -def json_model_loader( - model: type[TBaseModel], config: JsonLoaderConfig | None = None -) -> JsonModelLoader[TBaseModel]: - """Factory to create a function that loads a json file into a configured pydantic - model and with optional configuration for default path and file to use. +class ModelLoader(Generic[TBaseModel]): + """A generic model loader that can be configured with any kind of method to read in + a file and convert the data into a pydantic model. It can also takes configuration + to handle the file paths before they are passed to the method to convert to a + pydantic model. """ - def load_json(file: str | None = None) -> TBaseModel: - """Load a json file and return it is as the configured pydantic model. + def __init__( + self, + load_model_from_file: LoadModelFromFile[TBaseModel], + cfg: ModelLoaderConfig | None = None, + ): + self._load_model_from_file = load_model_from_file + self._cfg = cfg + + def _handle_file_path(self, file: str | None) -> str: + """Handle the file path based on the configuration provided. If a default path + is given and a relative file path used, it will join the default path and + relative path together. If a default file is configured, then you don't need to + provide a file when using __call__. + """ + if file is None: + if self._cfg is None or self._cfg.default_file is None: + raise RuntimeError( + "Model loader has no default file configured and no file was provided." + ) + file = self._cfg.default_file + + if not isabs(file) and self._cfg is not None: + file = join(self._cfg.default_path, file) + return file + + def __call__(self, file: str | None = None) -> TBaseModel: + """Load a file and return it is as the configured pydantic model. Args: file (str, optional): The file to load into a pydantic class. If None @@ -77,16 +110,5 @@ def load_json(file: str | None = None) -> TBaseModel: Returns: An instance of the configurated pydantic base_model type. """ - if file is None: - if config is None or config.default_file is None: - raise RuntimeError( - f"{model.__name__} loader has no default file configured " - "and no file was provided." - ) - file = config.default_file - - if not isabs(file) and config is not None: - file = join(config.default_path, file) - return load_json_file_to_class(model, file) - - return load_json + file = self._handle_file_path(file) + return self._load_model_from_file(file) diff --git a/src/dodal/devices/beamlines/i05/__init__.py b/src/dodal/devices/beamlines/i05/__init__.py index 760a9c47a47..a57a0767442 100644 --- a/src/dodal/devices/beamlines/i05/__init__.py +++ b/src/dodal/devices/beamlines/i05/__init__.py @@ -1,5 +1,4 @@ +from .enums import LensMode, PassEnergy from .i05_motors import I05Goniometer -__all__ = [ - "I05Goniometer", -] +__all__ = ["LensMode", "PassEnergy", "I05Goniometer"] diff --git a/src/dodal/devices/beamlines/i05/enums.py b/src/dodal/devices/beamlines/i05/enums.py new file mode 100644 index 00000000000..32772ae7cf1 --- /dev/null +++ b/src/dodal/devices/beamlines/i05/enums.py @@ -0,0 +1,20 @@ +from ophyd_async.core import StrictEnum + + +class LensMode(StrictEnum): + L4_ANG0_D8 = "L4Ang0d8" + L4_ANG1_D6 = "L4Ang1d6" + L4_ANG3_D9 = "L4Ang3d9" + L4M_ANG0_D7 = "L4MAng0d7" + L4M_SPAT_5 = "L4MSpat5" + + +class PassEnergy(StrictEnum): + PE001 = "PE001" + PE002 = "PE002" + PE005 = "PE005" + PE010 = "PE010" + PE020 = "PE020" + PE050 = "PE050" + PE100 = "PE100" + PE200 = "PE200" diff --git a/src/dodal/devices/electron_analyser/base/base_driver_io.py b/src/dodal/devices/electron_analyser/base/base_driver_io.py index c85d384c884..c88237caaca 100644 --- a/src/dodal/devices/electron_analyser/base/base_driver_io.py +++ b/src/dodal/devices/electron_analyser/base/base_driver_io.py @@ -1,5 +1,6 @@ from abc import ABC, abstractmethod -from typing import Generic, TypeAlias, TypeVar +from dataclasses import dataclass +from typing import ClassVar, Generic, TypeAlias, TypeVar import numpy as np from bluesky.protocols import Movable @@ -30,10 +31,27 @@ ) from dodal.devices.electron_analyser.base.base_util import to_binding_energy -AnyPsuMode: TypeAlias = SupersetEnum | StrictEnum +AnyPsuMode: TypeAlias = SupersetEnum | StrictEnum | str TPsuMode = TypeVar("TPsuMode", bound=AnyPsuMode) -_PSU = "PSU_MODE" + +@dataclass +class ElectronAnalyserPVConfig: + """Configuration for PV's. Temporary work around until PV's are standardised between + beamlines. + """ + + low_energy: str = "LOW_ENERGY" + high_energy: str = "HIGH_ENERGY" + centre_energy: str = "CENTRE_ENERGY" + slices: str = "SLICES" + lens_mode: str = "LENS_MODE" + pass_energy: str = "PASS_ENERGY" + energy_step: str = "STEP_SIZE" + iterations: str = "NumExposures" + acquisition_mode: str = "ACQ_MODE" + psu_mode: str = "PSU_MODE" + total_steps: str = "TOTAL_POINTS_RBV" class AbstractAnalyserDriverIO( @@ -58,10 +76,11 @@ class AbstractAnalyserDriverIO( pass_energy_type (type[TPassEnergy]): Can be enum or float, depending on electron analyser model. If enum, it determines the available pass energies for this device. - psu_suffix (str, optional): The psu infix to connect to EPICS. Defaults to PSU_MODE. name (str, optional): Name of the device. """ + PV_CFG: ClassVar[ElectronAnalyserPVConfig] + def __init__( self, prefix: str, @@ -69,7 +88,6 @@ def __init__( lens_mode_type: type[TLensMode], psu_mode_type: type[TPsuMode], pass_energy_type: type[TPassEnergy], - psu_suffix: str = _PSU, name: str = "", ) -> None: self.acquisition_mode_type = acquisition_mode_type @@ -77,7 +95,7 @@ def __init__( self.psu_mode_type = psu_mode_type self.pass_energy_type = pass_energy_type - # must call first to initiate parent variables + # Must call first to initiate parent variables super().__init__(prefix=prefix, name=name) with self.add_children_as_readables(): @@ -97,20 +115,27 @@ def __init__( self.cached_excitation_energy = soft_signal_rw( float, initial_value=0, units="eV" ) - self.low_energy = epics_signal_rw(float, prefix + "LOW_ENERGY") - self.centre_energy = epics_signal_rw(float, prefix + "CENTRE_ENERGY") - self.high_energy = epics_signal_rw(float, prefix + "HIGH_ENERGY") - self.slices = epics_signal_rw(int, prefix + "SLICES") - self.lens_mode = epics_signal_rw(lens_mode_type, prefix + "LENS_MODE") - self.pass_energy = epics_signal_rw(pass_energy_type, prefix + "PASS_ENERGY") - self.energy_step = epics_signal_rw(float, prefix + "STEP_SIZE") - self.iterations = epics_signal_rw(int, prefix + "NumExposures") + self.low_energy = epics_signal_rw(float, prefix + self.PV_CFG.low_energy) + self.centre_energy = epics_signal_rw( + float, prefix + self.PV_CFG.centre_energy + ) + self.high_energy = epics_signal_rw(float, prefix + self.PV_CFG.high_energy) + self.slices = epics_signal_rw(int, prefix + self.PV_CFG.slices) + self.lens_mode = epics_signal_rw( + lens_mode_type, prefix + self.PV_CFG.lens_mode + ) + self.pass_energy = epics_signal_rw( + pass_energy_type, prefix + self.PV_CFG.pass_energy + ) + self.energy_step = epics_signal_rw(float, prefix + self.PV_CFG.energy_step) + self.iterations = epics_signal_rw(int, prefix + self.PV_CFG.iterations) self.acquisition_mode = epics_signal_rw( - acquisition_mode_type, prefix + "ACQ_MODE" + acquisition_mode_type, prefix + self.PV_CFG.acquisition_mode ) - # This is used by each electron analyser, however it depends on the electron - # analyser type to know if is moved with region settings. - self.psu_mode = epics_signal_rw(psu_mode_type, prefix + psu_suffix) + # This is used by each electron analyser, however it is not writeable for + # all types and it depends on the electron analyser type to know if is moved + # with region settings. + self.psu_mode = epics_signal_r(psu_mode_type, prefix + self.PV_CFG.psu_mode) # This is defined in the parent class, add it as readable configuration. self.add_readables([self.acquire_time], StandardReadableFormat.CONFIG_SIGNAL) @@ -126,7 +151,7 @@ def __init__( energy_mode=self.energy_mode, ) self.angle_axis = self._create_angle_axis_signal(prefix) - self.total_steps = epics_signal_r(int, prefix + "TOTAL_POINTS_RBV") + self.total_steps = epics_signal_r(int, prefix + self.PV_CFG.total_steps) self.total_time = derived_signal_r( self._calculate_total_time, "s", diff --git a/src/dodal/devices/electron_analyser/base/base_region.py b/src/dodal/devices/electron_analyser/base/base_region.py index 2496a37a176..210993130bf 100644 --- a/src/dodal/devices/electron_analyser/base/base_region.py +++ b/src/dodal/devices/electron_analyser/base/base_region.py @@ -82,8 +82,8 @@ class BaseRegion( pass_energy: TPassEnergy acquisition_mode: TAcquisitionMode low_energy: float - centre_energy: float high_energy: float + centre_energy: float acquire_time: float energy_step: float # in eV energy_mode: EnergyMode = EnergyMode.KINETIC diff --git a/src/dodal/devices/electron_analyser/mbs/__init__.py b/src/dodal/devices/electron_analyser/mbs/__init__.py new file mode 100644 index 00000000000..51bf30be1db --- /dev/null +++ b/src/dodal/devices/electron_analyser/mbs/__init__.py @@ -0,0 +1,5 @@ +from .mbs_driver_io import MbsAnalyserDriverIO +from .mbs_enums import AcquisitionMode +from .mbs_region import MbsRegion, MbsSequence + +__all__ = ["MbsAnalyserDriverIO", "AcquisitionMode", "MbsRegion", "MbsSequence"] diff --git a/src/dodal/devices/electron_analyser/mbs/mbs_driver_io.py b/src/dodal/devices/electron_analyser/mbs/mbs_driver_io.py new file mode 100644 index 00000000000..3b715f91559 --- /dev/null +++ b/src/dodal/devices/electron_analyser/mbs/mbs_driver_io.py @@ -0,0 +1,92 @@ +import asyncio +from typing import Generic + +import numpy as np +from ophyd_async.core import ( + Array1D, + AsyncStatus, + SignalR, + StandardReadableFormat, +) +from ophyd_async.epics.core import epics_signal_r, epics_signal_rw + +from dodal.devices.electron_analyser.base.base_driver_io import ( + AbstractAnalyserDriverIO, + ElectronAnalyserPVConfig, +) +from dodal.devices.electron_analyser.base.base_region import TLensMode, TPassEnergy +from dodal.devices.electron_analyser.mbs.mbs_enums import AcquisitionMode +from dodal.devices.electron_analyser.mbs.mbs_region import MbsRegion + + +class MbsAnalyserDriverIO( + AbstractAnalyserDriverIO[ + MbsRegion[TLensMode, TPassEnergy], + AcquisitionMode, + TLensMode, + str, + TPassEnergy, + ], + Generic[TLensMode, TPassEnergy], +): + PV_CFG = ElectronAnalyserPVConfig( + lens_mode="LensMode", + pass_energy="PassEnergy", + acquisition_mode="AcqMode", + energy_step="StepSize", + low_energy="StartKE", + centre_energy="CentreKE", + high_energy="EndKE", + psu_mode="PsuMode_RBV", + slices="NumSlice", + iterations="NumExposures", + total_steps="NumSteps", + ) + + def __init__( + self, + prefix: str, + lens_mode_type: type[TLensMode], + pass_energy_type: type[TPassEnergy], + name: str = "", + ): + with self.add_children_as_readables(StandardReadableFormat.CONFIG_SIGNAL): + self.deflector_x = epics_signal_rw(float, prefix + "DeflX") + + super().__init__( + prefix=prefix, + acquisition_mode_type=AcquisitionMode, + lens_mode_type=lens_mode_type, + psu_mode_type=str, + pass_energy_type=pass_energy_type, + name=name, + ) + + def _create_angle_axis_signal(self, prefix: str) -> SignalR[Array1D[np.float64]]: + return epics_signal_r(Array1D[np.float64], prefix + "LensScale_RBV") + + def _create_energy_axis_signal(self, prefix: str) -> SignalR[Array1D[np.float64]]: + return epics_signal_r(Array1D[np.float64], prefix + "EScale_RBV") + + @AsyncStatus.wrap + async def set(self, epics_region: MbsRegion[TLensMode, TPassEnergy]): + # What do we do about region name? + coroutines = [ + self.acquisition_mode.set(epics_region.acquisition_mode), + self.pass_energy.set(epics_region.pass_energy), + self.lens_mode.set(epics_region.lens_mode), + # Start stop and centre energy are always set even though start and stop are + # used in swept and centre is used in fixed because the readback values are + # saved into the data file. + self.low_energy.set(epics_region.low_energy), + self.high_energy.set(epics_region.high_energy), + # Does this need to go in sub class? + self.deflector_x.set(epics_region.deflector_x), + self.acquire_time.set(epics_region.acquire_time), + self.iterations.set(epics_region.iterations), + ] + if epics_region.acquisition_mode == AcquisitionMode.SWEPT: + centre_energy = (epics_region.high_energy + epics_region.low_energy) / 2.0 + coroutines.append(self.centre_energy.set(centre_energy)) + + await asyncio.gather(*coroutines) diff --git a/src/dodal/devices/electron_analyser/mbs/mbs_enums.py b/src/dodal/devices/electron_analyser/mbs/mbs_enums.py new file mode 100644 index 00000000000..d7f18f88875 --- /dev/null +++ b/src/dodal/devices/electron_analyser/mbs/mbs_enums.py @@ -0,0 +1,7 @@ +from ophyd_async.core import StrictEnum + + +class AcquisitionMode(StrictEnum): + FIXED = "Fixed" + SWEPT = "Swept" + DITHER = "Dither" diff --git a/src/dodal/devices/electron_analyser/mbs/mbs_region.py b/src/dodal/devices/electron_analyser/mbs/mbs_region.py new file mode 100644 index 00000000000..4f1f4685ebd --- /dev/null +++ b/src/dodal/devices/electron_analyser/mbs/mbs_region.py @@ -0,0 +1,71 @@ +from os.path import basename, splitext +from typing import Generic, Self + +import xmltodict +from pydantic import Field, field_validator + +from dodal.devices.electron_analyser.base.base_region import ( + BaseRegion, + BaseSequence, + TLensMode, + TPassEnergy, +) +from dodal.devices.electron_analyser.mbs.mbs_enums import AcquisitionMode + + +class MbsRegion( + BaseRegion[AcquisitionMode, TLensMode, TPassEnergy], + Generic[TLensMode, TPassEnergy], +): + # Override base class with defaults + lens_mode: TLensMode + pass_energy: TPassEnergy + acquisition_mode: AcquisitionMode = AcquisitionMode.FIXED + low_energy: float = Field(default=800, alias="start_energy") + high_energy: float = Field(default=850, alias="end_energy") + centre_energy: float = Field( + default_factory=lambda data: (data["high_energy"] + data["low_energy"]) / 2 + ) + acquire_time: float = Field(default=1.0, alias="time_per_step") + energy_step: float = Field(default=0.1, alias="step_energy") + # Default is True as mbs ususally only uses one region. + enabled: bool = True + + # Specific to this class + deflector_x: float = 0 + + @staticmethod + def convert_pass_energy_to_analyser_string(pass_energy) -> str: + return f"PE{int(pass_energy):03d}" + + @field_validator("pass_energy", mode="before") + @classmethod + def convert_pass_energy(cls, value): + return cls.convert_pass_energy_to_analyser_string(value) + + @classmethod + def from_xml(cls, file: str) -> Self: + name = splitext(basename(file))[0] + + with open(file) as f: + data = xmltodict.parse(f.read()) + + region = cls.model_validate(data["ARPESScanBean"]) + region.name = name + + return region + + +class MbsSequence( + BaseSequence[MbsRegion[TLensMode, TPassEnergy]], Generic[TLensMode, TPassEnergy] +): + @classmethod + def from_xml(cls, file: str) -> Self: + regions = [] + # Must find the region type annotation because reconstructing the generic + # manually doing MbsRegion[TLensMode, TPassEnergy].from_xml(file) will not work. + annotation = cls.model_fields["regions"].annotation + assert annotation is not None + region_type = annotation.__args__[0] + regions = [region_type.from_xml(file)] + return cls.model_validate({"regions": regions}) diff --git a/src/dodal/devices/electron_analyser/specs/specs_driver_io.py b/src/dodal/devices/electron_analyser/specs/specs_driver_io.py index 6911cf65356..2c484b09eb0 100644 --- a/src/dodal/devices/electron_analyser/specs/specs_driver_io.py +++ b/src/dodal/devices/electron_analyser/specs/specs_driver_io.py @@ -5,21 +5,34 @@ from ophyd_async.core import ( Array1D, AsyncStatus, + DeviceMock, SignalR, StandardReadableFormat, + callback_on_mock_put, + default_mock_class, derived_signal_r, + set_mock_value, ) -from ophyd_async.epics.core import epics_signal_r, epics_signal_rw +from ophyd_async.epics.core import epics_signal_r, epics_signal_rw, epics_signal_w from dodal.devices.electron_analyser.base.base_driver_io import ( - _PSU, AbstractAnalyserDriverIO, + ElectronAnalyserPVConfig, ) from dodal.devices.electron_analyser.base.base_region import TLensMode, TPsuMode from dodal.devices.electron_analyser.specs.specs_enums import AcquisitionMode from dodal.devices.electron_analyser.specs.specs_region import SpecsRegion +class MockSpecsAnalyserDriverIO(DeviceMock["SpecsAnalyserDriverIO"]): + async def connect(self, device: "SpecsAnalyserDriverIO"): + def _sync_psu_mode_rbv(value): + set_mock_value(device.psu_mode, value) + + callback_on_mock_put(device.psu_mode_w, _sync_psu_mode_rbv) + + +@default_mock_class(MockSpecsAnalyserDriverIO) class SpecsAnalyserDriverIO( AbstractAnalyserDriverIO[ SpecsRegion[TLensMode, TPsuMode], @@ -30,12 +43,13 @@ class SpecsAnalyserDriverIO( ], Generic[TLensMode, TPsuMode], ): + PV_CFG = ElectronAnalyserPVConfig() + def __init__( self, prefix: str, lens_mode_type: type[TLensMode], psu_mode_type: type[TPsuMode], - psu_suffix: str = _PSU, name: str = "", ) -> None: with self.add_children_as_readables(StandardReadableFormat.CONFIG_SIGNAL): @@ -50,6 +64,7 @@ def __init__( self.energy_channels = epics_signal_r( int, prefix + "TOTAL_POINTS_ITERATION_RBV" ) + self.psu_mode_w = epics_signal_w(psu_mode_type, prefix + self.PV_CFG.psu_mode) super().__init__( prefix=prefix, @@ -57,7 +72,6 @@ def __init__( lens_mode_type=lens_mode_type, psu_mode_type=psu_mode_type, pass_energy_type=float, - psu_suffix=psu_suffix, name=name, ) @@ -74,7 +88,7 @@ async def set(self, epics_region: SpecsRegion[TLensMode, TPsuMode]): self.iterations.set(epics_region.iterations), self.acquisition_mode.set(epics_region.acquisition_mode), self.snapshot_values.set(epics_region.values), - self.psu_mode.set(epics_region.psu_mode), + self.psu_mode_w.set(epics_region.psu_mode), self.energy_mode.set(epics_region.energy_mode), ) if epics_region.acquisition_mode == AcquisitionMode.FIXED_TRANSMISSION: diff --git a/src/dodal/devices/electron_analyser/specs/specs_region.py b/src/dodal/devices/electron_analyser/specs/specs_region.py index cfb6bc426fc..302ce82adf0 100644 --- a/src/dodal/devices/electron_analyser/specs/specs_region.py +++ b/src/dodal/devices/electron_analyser/specs/specs_region.py @@ -34,4 +34,4 @@ class SpecsRegion( class SpecsSequence( BaseSequence[SpecsRegion[TLensMode, TPsuMode]], Generic[TLensMode, TPsuMode] ): - regions: list[SpecsRegion[TLensMode, TPsuMode]] = Field(default_factory=lambda: []) + pass diff --git a/src/dodal/devices/electron_analyser/vgscienta/vgscienta_driver_io.py b/src/dodal/devices/electron_analyser/vgscienta/vgscienta_driver_io.py index 4756baec460..cce7f6023c8 100644 --- a/src/dodal/devices/electron_analyser/vgscienta/vgscienta_driver_io.py +++ b/src/dodal/devices/electron_analyser/vgscienta/vgscienta_driver_io.py @@ -12,6 +12,7 @@ from dodal.devices.electron_analyser.base.base_driver_io import ( AbstractAnalyserDriverIO, + ElectronAnalyserPVConfig, ) from dodal.devices.electron_analyser.base.base_region import TLensMode, TPsuMode from dodal.devices.electron_analyser.vgscienta.vgscienta_enums import ( @@ -34,13 +35,14 @@ class VGScientaAnalyserDriverIO( ], Generic[TLensMode, TPsuMode, TPassEnergyEnum], ): + PV_CFG = ElectronAnalyserPVConfig(psu_mode="ELEMENT_SET") + def __init__( self, prefix: str, lens_mode_type: type[TLensMode], psu_mode_type: type[TPsuMode], pass_energy_type: type[TPassEnergyEnum], - psu_suffix: str = "ELEMENT_SET", name: str = "", ) -> None: with self.add_children_as_readables(StandardReadableFormat.CONFIG_SIGNAL): @@ -61,7 +63,6 @@ def __init__( lens_mode_type=lens_mode_type, psu_mode_type=psu_mode_type, pass_energy_type=pass_energy_type, - psu_suffix=psu_suffix, name=name, ) diff --git a/src/dodal/devices/electron_analyser/vgscienta/vgscienta_region.py b/src/dodal/devices/electron_analyser/vgscienta/vgscienta_region.py index 556da7bfa78..3241e25c0c8 100644 --- a/src/dodal/devices/electron_analyser/vgscienta/vgscienta_region.py +++ b/src/dodal/devices/electron_analyser/vgscienta/vgscienta_region.py @@ -60,6 +60,3 @@ class VGScientaSequence( Generic[TLensMode, TPsuMode, TPassEnergyEnum], ): psu_mode: TPsuMode = Field(alias="element_set") - regions: list[VGScientaRegion[TLensMode, TPassEnergyEnum]] = Field( - default_factory=lambda: [] - ) diff --git a/tests/common/test_data_util.py b/tests/common/test_data_util.py index c22e49a376a..ef7afe8bfbd 100644 --- a/tests/common/test_data_util.py +++ b/tests/common/test_data_util.py @@ -4,9 +4,9 @@ from pydantic import BaseModel from dodal.common.data_util import ( - JsonLoaderConfig, - JsonModelLoader, - json_model_loader, + LoadModelFromJsonFile, + ModelLoader, + ModelLoaderConfig, save_class_to_json_file, ) @@ -50,14 +50,15 @@ def tmp_file(tmp_path, other_model: MyModel) -> str: @pytest.fixture def load_json_model_with_default_file_only( default_tmp_file: str, -) -> JsonModelLoader[MyModel]: - return json_model_loader( - MyModel, JsonLoaderConfig.from_default_file(default_tmp_file) +) -> ModelLoader[MyModel]: + return ModelLoader( + LoadModelFromJsonFile(MyModel), + ModelLoaderConfig.from_default_file(default_tmp_file), ) def test_json_model_loader_with_configured_default_file_only( - load_json_model_with_default_file_only: JsonModelLoader[MyModel], + load_json_model_with_default_file_only: ModelLoader[MyModel], tmp_file: str, other_model: MyModel, default_model: MyModel, @@ -78,13 +79,15 @@ def test_json_model_loader_with_configured_default_file_only( @pytest.fixture def load_json_model_with_default_path_only( default_tmp_file: str, -) -> JsonModelLoader[MyModel]: +) -> ModelLoader[MyModel]: path, file = split(default_tmp_file) - return json_model_loader(MyModel, JsonLoaderConfig.from_default_path(path)) + return ModelLoader( + LoadModelFromJsonFile(MyModel), ModelLoaderConfig.from_default_path(path) + ) def test_load_json_model_with_configued_path_only( - load_json_model_with_default_path_only: JsonModelLoader[MyModel], + load_json_model_with_default_path_only: ModelLoader[MyModel], tmp_file: str, other_model: MyModel, ) -> None: @@ -99,7 +102,7 @@ def test_load_json_model_with_configued_path_only( with pytest.raises( RuntimeError, - match="MyModel loader has no default file configured and no file was provided.", + match="Model loader has no default file configured and no file was provided.", ): load_json_model_with_default_path_only() @@ -107,15 +110,16 @@ def test_load_json_model_with_configued_path_only( @pytest.fixture def load_json_model_with_default_path_and_file( default_tmp_file: str, -) -> JsonModelLoader[MyModel]: +) -> ModelLoader[MyModel]: path, file = split(default_tmp_file) - return json_model_loader( - MyModel, JsonLoaderConfig(default_path=path, default_file=file) + return ModelLoader( + LoadModelFromJsonFile(MyModel), + ModelLoaderConfig(default_path=path, default_file=file), ) def test_load_json_model_with_configued_path_and_file( - load_json_model_with_default_path_and_file: JsonModelLoader[MyModel], + load_json_model_with_default_path_and_file: ModelLoader[MyModel], tmp_file: str, other_model: MyModel, default_model: MyModel, @@ -135,18 +139,18 @@ def test_load_json_model_with_configued_path_and_file( @pytest.fixture -def load_json_model_no_config() -> JsonModelLoader[MyModel]: - return json_model_loader(MyModel) +def load_json_model_no_config() -> ModelLoader[MyModel]: + return ModelLoader(LoadModelFromJsonFile(MyModel)) def test_json_model_loader_with_no_config( - load_json_model_no_config: JsonModelLoader[MyModel], + load_json_model_no_config: ModelLoader[MyModel], tmp_file: str, other_model: MyModel, ) -> None: with pytest.raises( RuntimeError, - match="MyModel loader has no default file configured and no file was provided.", + match="Model loader has no default file configured and no file was provided.", ): load_json_model_no_config() @@ -163,8 +167,8 @@ def test_json_model_loader_with_no_config( def test_updating_config_updates_factory_function( default_tmp_file: str, tmp_file: str, default_model: MyModel, other_model: MyModel ) -> None: - config = JsonLoaderConfig.from_default_file(default_tmp_file) - model_loader = json_model_loader(MyModel, config) + config = ModelLoaderConfig.from_default_file(default_tmp_file) + model_loader = ModelLoader(LoadModelFromJsonFile(MyModel), config) # Test uses default file model_result = model_loader() @@ -178,11 +182,11 @@ def test_updating_config_updates_factory_function( @pytest.fixture def all_json_model_loaders( - load_json_model_with_default_file_only: JsonModelLoader[MyModel], - load_json_model_with_default_path_only: JsonModelLoader[MyModel], - load_json_model_with_default_path_and_file: JsonModelLoader[MyModel], - load_json_model_no_config: JsonModelLoader[MyModel], -) -> list[JsonModelLoader[MyModel]]: + load_json_model_with_default_file_only: ModelLoader[MyModel], + load_json_model_with_default_path_only: ModelLoader[MyModel], + load_json_model_with_default_path_and_file: ModelLoader[MyModel], + load_json_model_no_config: ModelLoader[MyModel], +) -> list[ModelLoader[MyModel]]: return [ load_json_model_with_default_file_only, load_json_model_with_default_path_only, @@ -193,7 +197,7 @@ def all_json_model_loaders( @pytest.mark.parametrize("loader_position", range(4)) def test_all_json_model_loader_raise_error_if_invalid_file( - all_json_model_loaders: list[JsonModelLoader[MyModel]], + all_json_model_loaders: list[ModelLoader[MyModel]], loader_position: int, ) -> None: json_loader = all_json_model_loaders[loader_position] diff --git a/tests/devices/electron_analyser/base/test_base_driver_io.py b/tests/devices/electron_analyser/base/test_base_driver_io.py index 0f588658e62..a6968d48b0e 100644 --- a/tests/devices/electron_analyser/base/test_base_driver_io.py +++ b/tests/devices/electron_analyser/base/test_base_driver_io.py @@ -41,16 +41,3 @@ class AcquisitionModeTestEnum(StrictEnum): acq_datatype_name = acq_datatype.__name__ if acq_datatype is not None else "" with pytest.raises(FailedStatus, match=f"is not a valid {acq_datatype_name}"): run_engine(bps.mv(sim_driver.acquisition_mode, AcquisitionModeTestEnum.TEST_1)) - - -def test_driver_throws_error_with_wrong_psu_mode( - sim_driver: GenericAnalyserDriverIO, - run_engine: RunEngine, -) -> None: - class PsuModeTestEnum(StrictEnum): - TEST_1 = "Invalid mode" - - psu_datatype = sim_driver.psu_mode.datatype - psu_datatype_name = psu_datatype.__name__ if psu_datatype is not None else "" - with pytest.raises(FailedStatus, match=f"is not a valid {psu_datatype_name}"): - run_engine(bps.mv(sim_driver.psu_mode, PsuModeTestEnum.TEST_1)) diff --git a/tests/devices/electron_analyser/helper_util/sequence.py b/tests/devices/electron_analyser/helper_util/sequence.py index 1981fbbee30..2e28d09105e 100644 --- a/tests/devices/electron_analyser/helper_util/sequence.py +++ b/tests/devices/electron_analyser/helper_util/sequence.py @@ -1,5 +1,10 @@ -from dodal.common.data_util import JsonLoaderConfig, json_model_loader -from dodal.devices.beamlines import b07, b07_shared, i09 +from dodal.common.data_util import ( + LoadModelFromJsonFile, + ModelLoader, + ModelLoaderConfig, +) +from dodal.devices.beamlines import b07, b07_shared, i05, i09 +from dodal.devices.electron_analyser.mbs import MbsSequence from dodal.devices.electron_analyser.specs import ( SpecsAnalyserDriverIO, SpecsDetector, @@ -18,13 +23,25 @@ TEST_SEQUENCE_REGION_NAMES = ["New_Region", "New_Region1", "New_Region2"] -load_b07_specs_test_sequence = json_model_loader( - SpecsSequence[b07.LensMode, b07_shared.PsuMode], - JsonLoaderConfig.from_default_file(TEST_SPECS_SEQUENCE), +load_b07_specs_test_sequence = ModelLoader( + LoadModelFromJsonFile(SpecsSequence[b07.LensMode, b07_shared.PsuMode]), + ModelLoaderConfig.from_default_file(TEST_SPECS_SEQUENCE), +) +load_i09_vgscienta_test_sequence = ModelLoader( + LoadModelFromJsonFile(VGScientaSequence[i09.LensMode, i09.PsuMode, i09.PassEnergy]), + ModelLoaderConfig.from_default_file(TEST_VGSCIENTA_SEQUENCE), ) -load_i09_vgscienta_test_sequence = json_model_loader( - VGScientaSequence[i09.LensMode, i09.PsuMode, i09.PassEnergy], - JsonLoaderConfig.from_default_file(TEST_VGSCIENTA_SEQUENCE), + +load_i05_mbs_test_sequence = ModelLoader( + LoadModelFromJsonFile(MbsSequence[i05.LensMode, i05.PassEnergy]), + ModelLoaderConfig.from_default_file(TEST_VGSCIENTA_SEQUENCE), +) + +load_i05_mbs_test_xml_sequence = ModelLoader( + lambda file: MbsSequence[i05.LensMode, i05.PassEnergy].from_xml(file), + ModelLoaderConfig.from_default_file( + "tests/devices/electron_analyser/test_data/mbs_region1.arpes" + ), ) diff --git a/tests/devices/electron_analyser/mbs/__init__.py b/tests/devices/electron_analyser/mbs/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/devices/electron_analyser/mbs/test_mbs_driver_io.py b/tests/devices/electron_analyser/mbs/test_mbs_driver_io.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/devices/electron_analyser/mbs/test_mbs_region.py b/tests/devices/electron_analyser/mbs/test_mbs_region.py new file mode 100644 index 00000000000..1085d6b6623 --- /dev/null +++ b/tests/devices/electron_analyser/mbs/test_mbs_region.py @@ -0,0 +1,45 @@ +from typing import Any + +import pytest + +from dodal.devices.beamlines.i05 import LensMode, PassEnergy +from dodal.devices.electron_analyser.base import EnergyMode +from dodal.devices.electron_analyser.mbs import AcquisitionMode +from dodal.devices.selectable_source import SelectedSource +from tests.devices.electron_analyser.helper_util import ( + assert_region_has_expected_values, +) +from tests.devices.electron_analyser.helper_util.sequence import ( + load_i05_mbs_test_xml_sequence, +) + + +@pytest.fixture +def expected_xml_region_values() -> list[dict[str, Any]]: + return [ + { + "name": "mbs_region1", + "enabled": True, + "lens_mode": LensMode.L4_ANG0_D8, + "pass_energy": PassEnergy.PE005, + "slices": 1, + "iterations": 3, + "acquisition_mode": AcquisitionMode.SWEPT, + "excitation_energy_source": SelectedSource.SOURCE1, + "energy_mode": EnergyMode.KINETIC, + "low_energy": 72.386, + "high_energy": 73.814, + "centre_energy": 73.1, + "acquire_time": 1.0, + "energy_step": 0.405, + "deflector_x": 0.0, + }, + ] + + +def test_mbs_sequence_from_xml( + expected_xml_region_values: list[dict[str, Any]], +) -> None: + sequence = load_i05_mbs_test_xml_sequence() + for i, r in zip(sequence.regions, expected_xml_region_values, strict=True): + assert_region_has_expected_values(i, r) diff --git a/tests/devices/electron_analyser/specs/test_specs_driver_io.py b/tests/devices/electron_analyser/specs/test_specs_driver_io.py index be485cb06d5..5fbb374e0c7 100644 --- a/tests/devices/electron_analyser/specs/test_specs_driver_io.py +++ b/tests/devices/electron_analyser/specs/test_specs_driver_io.py @@ -73,7 +73,7 @@ async def test_analyser_sets_region_correctly( else: get_mock_put(sim_driver.energy_step).assert_not_called() - get_mock_put(sim_driver.psu_mode).assert_called_once_with(region.psu_mode) + get_mock_put(sim_driver.psu_mode_w).assert_called_once_with(region.psu_mode) get_mock_put(sim_driver.snapshot_values).assert_called_once_with(region.values) diff --git a/tests/devices/electron_analyser/test_data/mbs_region1.arpes b/tests/devices/electron_analyser/test_data/mbs_region1.arpes new file mode 100644 index 00000000000..355c1c8569c --- /dev/null +++ b/tests/devices/electron_analyser/test_data/mbs_region1.arpes @@ -0,0 +1,13 @@ + + + L4Ang0d8 + Swept + 5 + 72.386 + 73.814 + 0.405 + 1.0 + 3 + 0.0 + true +