Skip to content
Draft
4 changes: 1 addition & 3 deletions src/dodal/beamlines/i09.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,7 @@
PitchAndRollCrystal,
StationaryCrystal,
)
from dodal.devices.electron_analyser.base import (
DualEnergySource,
)
from dodal.devices.electron_analyser.base import DualEnergySource
from dodal.devices.electron_analyser.vgscienta import VGScientaDetector
from dodal.devices.fast_shutter import DualFastShutter, GenericFastShutter
from dodal.devices.i09 import Grating, LensMode, PassEnergy, PsuMode
Expand Down
12 changes: 4 additions & 8 deletions src/dodal/devices/electron_analyser/base/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,8 @@
GenericElectronAnalyserController,
)
from .base_detector import (
BaseElectronAnalyserDetector,
ElectronAnalyserDetector,
ElectronAnalyserRegionDetector,
GenericBaseElectronAnalyserDetector,
GenericElectronAnalyserDetector,
GenericElectronAnalyserRegionDetector,
)
from .base_driver_io import (
AbstractAnalyserDriverIO,
Expand All @@ -21,6 +17,8 @@
AbstractBaseSequence,
GenericRegion,
GenericSequence,
JsonSequenceLoader,
SequenceLoader,
TAbstractBaseRegion,
TAbstractBaseSequence,
TAcquisitionMode,
Expand All @@ -32,12 +30,8 @@
__all__ = [
"ElectronAnalyserController",
"GenericElectronAnalyserController",
"BaseElectronAnalyserDetector",
"ElectronAnalyserDetector",
"ElectronAnalyserRegionDetector",
"GenericBaseElectronAnalyserDetector",
"GenericElectronAnalyserDetector",
"GenericElectronAnalyserRegionDetector",
"AbstractAnalyserDriverIO",
"GenericAnalyserDriverIO",
"TAbstractAnalyserDriverIO",
Expand All @@ -46,6 +40,8 @@
"AbstractBaseSequence",
"GenericRegion",
"GenericSequence",
"JsonSequenceLoader",
"SequenceLoader",
"TAbstractBaseRegion",
"TAbstractBaseSequence",
"TAcquisitionMode",
Expand Down
143 changes: 16 additions & 127 deletions src/dodal/devices/electron_analyser/base/base_detector.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import Generic, TypeVar

from bluesky.protocols import Reading, Stageable, Triggerable
from bluesky.protocols import Movable, Reading, Stageable, Triggerable
from event_model import DataKey
from ophyd_async.core import (
AsyncConfigurable,
Expand All @@ -10,7 +10,6 @@
TriggerInfo,
)

from dodal.common.data_util import load_json_file_to_class
from dodal.devices.electron_analyser.base.base_controller import (
ElectronAnalyserController,
)
Expand All @@ -19,23 +18,24 @@
TAbstractAnalyserDriverIO,
)
from dodal.devices.electron_analyser.base.base_region import (
AbstractBaseSequence,
GenericRegion,
GenericSequence,
SequenceLoader,
TAbstractBaseRegion,
TAbstractBaseSequence,
)


class BaseElectronAnalyserDetector(
class ElectronAnalyserDetector(
Device,
Triggerable,
AsyncReadable,
AsyncConfigurable,
Stageable,
Movable,
Generic[TAbstractAnalyserDriverIO, TAbstractBaseRegion],
):
"""
Detector for data acquisition of electron analyser. Can only acquire using settings
already configured for the device.
Detector for data acquisition of electron analyser.

If possible, this should be changed to inherit from a StandardDetector. Currently,
StandardDetector forces you to use a file writer which doesn't apply here.
Expand All @@ -47,11 +47,19 @@ def __init__(
controller: ElectronAnalyserController[
TAbstractAnalyserDriverIO, TAbstractBaseRegion
],
sequence_loader: SequenceLoader[AbstractBaseSequence[TAbstractBaseRegion]],
name: str = "",
):
self._controller = controller
self.sequence_loader = sequence_loader

super().__init__(name)

@AsyncStatus.wrap
async def stage(self) -> None:
"""Disarm the detector."""
await self._controller.disarm()

@AsyncStatus.wrap
async def set(self, region: TAbstractBaseRegion) -> None:
await self._controller.setup_with_region(region)
Expand Down Expand Up @@ -80,133 +88,14 @@ async def read_configuration(self) -> dict[str, Reading]:
async def describe_configuration(self) -> dict[str, DataKey]:
return await self._controller.driver.describe_configuration()


GenericBaseElectronAnalyserDetector = BaseElectronAnalyserDetector[
GenericAnalyserDriverIO, GenericRegion
]


class ElectronAnalyserRegionDetector(
BaseElectronAnalyserDetector[TAbstractAnalyserDriverIO, TAbstractBaseRegion],
Generic[TAbstractAnalyserDriverIO, TAbstractBaseRegion],
):
"""
Extends electron analyser detector to configure specific region settings before data
acquisition. It is designed to only exist inside a plan.
"""

def __init__(
self,
controller: ElectronAnalyserController[
TAbstractAnalyserDriverIO, TAbstractBaseRegion
],
region: TAbstractBaseRegion,
name: str = "",
):
self.region = region
super().__init__(controller, name)

@AsyncStatus.wrap
async def trigger(self) -> None:
# Configure region parameters on the driver first before data collection.
await self.set(self.region)
await super().trigger()


GenericElectronAnalyserRegionDetector = ElectronAnalyserRegionDetector[
GenericAnalyserDriverIO, GenericRegion
]
TElectronAnalyserRegionDetector = TypeVar(
"TElectronAnalyserRegionDetector",
bound=ElectronAnalyserRegionDetector,
)


class ElectronAnalyserDetector(
BaseElectronAnalyserDetector[TAbstractAnalyserDriverIO, TAbstractBaseRegion],
Stageable,
Generic[TAbstractBaseSequence, TAbstractAnalyserDriverIO, TAbstractBaseRegion],
):
"""
Electron analyser detector with the additional functionality to load a sequence file
and create a list of temporary ElectronAnalyserRegionDetector objects. These will
setup configured region settings before data acquisition.
"""

def __init__(
self,
sequence_class: type[TAbstractBaseSequence],
controller: ElectronAnalyserController[
TAbstractAnalyserDriverIO, TAbstractBaseRegion
],
name: str = "",
):
self._sequence_class = sequence_class
super().__init__(controller, name)

@AsyncStatus.wrap
async def stage(self) -> None:
"""
Prepare the detector for use by ensuring it is idle and ready.

This method asynchronously stages the detector by first disarming the controller
to ensure the detector is not actively acquiring data, then invokes the driver's
stage procedure. This ensures the detector is in a known, ready state
before use.

Raises:
Any exceptions raised by the driver's stage or controller's disarm methods.
"""
await self._controller.disarm()

@AsyncStatus.wrap
async def unstage(self) -> None:
"""Disarm the detector."""
await self._controller.disarm()

def load_sequence(self, filename: str) -> TAbstractBaseSequence:
"""
Load the sequence data from a provided json file into a sequence class.

Args:
filename: Path to the sequence file containing the region data.

Returns:
Pydantic model representing the sequence file.
"""
return load_json_file_to_class(self._sequence_class, filename)

def create_region_detector_list(
self, filename: str, enabled_only=True
) -> list[
ElectronAnalyserRegionDetector[TAbstractAnalyserDriverIO, TAbstractBaseRegion]
]:
"""
Create a list of detectors equal to the number of regions in a sequence file.
Each detector is responsible for setting up a specific region.

Args:
filename: Path to the sequence file containing the region data.
enabled_only: If true, only include the region if enabled is True.

Returns:
List of ElectronAnalyserRegionDetector, equal to the number of regions in
the sequence file.
"""
seq = self.load_sequence(filename)
regions: list[TAbstractBaseRegion] = (
seq.get_enabled_regions() if enabled_only else seq.regions
)
return [
ElectronAnalyserRegionDetector[
TAbstractAnalyserDriverIO, TAbstractBaseRegion
](self._controller, r, self.name + "_" + r.name)
for r in regions
]


GenericElectronAnalyserDetector = ElectronAnalyserDetector[
GenericSequence, GenericAnalyserDriverIO, GenericRegion
GenericAnalyserDriverIO, GenericRegion
]
TElectronAnalyserDetector = TypeVar(
"TElectronAnalyserDetector",
Expand Down
63 changes: 60 additions & 3 deletions src/dodal/devices/electron_analyser/base/base_region.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import re
from abc import ABC
from abc import ABC, abstractmethod
from collections.abc import Callable
from typing import Generic, Self, TypeAlias, TypeVar

from ophyd_async.core import StrictEnum, SupersetEnum
from bluesky.protocols import Movable
from ophyd_async.core import (
AsyncStatus,
StandardReadable,
StandardReadableFormat,
StrictEnum,
SupersetEnum,
soft_signal_r_and_setter,
)
from pydantic import BaseModel, Field, model_validator

from dodal.common.data_util import load_json_file_to_class
from dodal.devices.electron_analyser.base.base_enums import EnergyMode
from dodal.devices.electron_analyser.base.base_util import (
to_binding_energy,
Expand Down Expand Up @@ -109,7 +118,7 @@ def switch_energy_mode(
"""
Get a region with a new energy mode: Kinetic or Binding.
It caculates new values for low_energy, centre_energy, high_energy, via the
excitation enerrgy. It doesn't calculate anything if the region is already of
excitation energy. It doesn't calculate anything if the region is already of
the same energy mode.

Parameters:
Expand Down Expand Up @@ -201,3 +210,51 @@ def get_region_by_name(self, name: str) -> TAbstractBaseRegion | None:

GenericSequence = AbstractBaseSequence[GenericRegion]
TAbstractBaseSequence = TypeVar("TAbstractBaseSequence", bound=AbstractBaseSequence)


class SequenceLoader(StandardReadable, Movable[str], Generic[TAbstractBaseSequence]):
"""
Device that controls the sequence file selected that configures the electron
analyser inside plans.
"""

def __init__(
self,
sequence_class: type[TAbstractBaseSequence],
initial_file: str = "Not set",
name: str = "",
):
with self.add_children_as_readables(StandardReadableFormat.CONFIG_SIGNAL):
self.sequence_file, self._sequence_file_setter = soft_signal_r_and_setter(
str, initial_value=initial_file
)
self._sequence_class = sequence_class

self.sequence: TAbstractBaseSequence | None = None
super().__init__(name)

@AsyncStatus.wrap
async def set(self, filename: str) -> None:
"""Coordinate setting the sequence_file signal and also loading the data into sequence."""
# Try loading the sequence first to check it is a valid file before setting signal.
self.sequence = self.load(filename)
self._sequence_file_setter(filename)

@abstractmethod
def load(self, filename: str) -> TAbstractBaseSequence:
"""
Load the sequence data from a provided file into a sequence class.

Args:
filename: Path to the sequence file containing the region data.

Returns:
Pydantic model representing the sequence file.
"""


class JsonSequenceLoader(SequenceLoader[TAbstractBaseSequence]):
"""Json specifc sequence loader for electron analysers"""

def load(self, filename: str) -> TAbstractBaseSequence:
return load_json_file_to_class(self._sequence_class, filename)
14 changes: 10 additions & 4 deletions src/dodal/devices/electron_analyser/specs/specs_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@
ElectronAnalyserController,
)
from dodal.devices.electron_analyser.base.base_detector import ElectronAnalyserDetector
from dodal.devices.electron_analyser.base.base_region import TLensMode, TPsuMode
from dodal.devices.electron_analyser.base.base_region import (
AbstractBaseSequence,
JsonSequenceLoader,
TLensMode,
TPsuMode,
)
from dodal.devices.electron_analyser.base.energy_sources import AbstractEnergySource
from dodal.devices.electron_analyser.specs.specs_driver_io import SpecsAnalyserDriverIO
from dodal.devices.electron_analyser.specs.specs_region import (
Expand All @@ -17,7 +22,6 @@

class SpecsDetector(
ElectronAnalyserDetector[
SpecsSequence[TLensMode, TPsuMode],
SpecsAnalyserDriverIO[TLensMode, TPsuMode],
SpecsRegion[TLensMode, TPsuMode],
],
Expand All @@ -42,6 +46,8 @@ def __init__(
SpecsAnalyserDriverIO[TLensMode, TPsuMode], SpecsRegion[TLensMode, TPsuMode]
](self.driver, energy_source, shutter, source_selector)

sequence_class = SpecsSequence[lens_mode_type, psu_mode_type]
sequence_loader = JsonSequenceLoader[
AbstractBaseSequence[SpecsRegion[TLensMode, TPsuMode]]
](SpecsSequence[lens_mode_type, psu_mode_type])

super().__init__(sequence_class, controller, name)
super().__init__(controller, sequence_loader, name)
Loading