Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Port andor new ad format #724

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
9 changes: 9 additions & 0 deletions src/ophyd_async/epics/adandor/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from ._andor import Andor2Detector
from ._andor_controller import Andor2Controller
from ._andor_io import Andor2DriverIO

__all__ = [
"Andor2Detector",
"Andor2Controller",
"Andor2DriverIO",
]
45 changes: 45 additions & 0 deletions src/ophyd_async/epics/adandor/_andor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from collections.abc import Sequence

from ophyd_async.core import PathProvider
from ophyd_async.core._signal import SignalR
from ophyd_async.epics import adcore

from ._andor_controller import Andor2Controller
from ._andor_io import Andor2DriverIO


class Andor2Detector(adcore.AreaDetector[Andor2Controller]):
"""
Andor 2 area detector device (CCD detector 56fps with full chip readout).
Andor model:DU897_BV.
"""

def __init__(
self,
prefix: str,
path_provider: PathProvider,
drv_suffix="cam1:",
writer_cls: type[adcore.ADWriter] = adcore.ADHDFWriter,
fileio_suffix: str | None = None,
name: str = "",
config_sigs: Sequence[SignalR] = (),
plugins: dict[str, adcore.NDPluginBaseIO] | None = None,
):
driver = Andor2DriverIO(prefix + drv_suffix)
controller = Andor2Controller(driver)

writer = writer_cls.with_io(
prefix,
path_provider,
dataset_source=driver,
fileio_suffix=fileio_suffix,
plugins=plugins,
)

super().__init__(
controller=controller,
writer=writer,
plugins=plugins,
name=name,
config_sigs=config_sigs,
)
49 changes: 49 additions & 0 deletions src/ophyd_async/epics/adandor/_andor_controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import asyncio

from ophyd_async.core import (
DetectorTrigger,
TriggerInfo,
)
from ophyd_async.epics import adcore

from ._andor_io import Andor2DriverIO, Andor2ImageMode, Andor2TriggerMode

_MIN_DEAD_TIME = 0.1
_MAX_NUM_IMAGE = 999_999


class Andor2Controller(adcore.ADBaseController[Andor2DriverIO]):
def __init__(
self,
driver: Andor2DriverIO,
good_states: frozenset[adcore.DetectorState] = adcore.DEFAULT_GOOD_STATES,
) -> None:
super().__init__(driver, good_states=good_states)

def get_deadtime(self, exposure: float | None) -> float:
return _MIN_DEAD_TIME + (exposure or 0)

Comment on lines +23 to +25
Copy link
Contributor

Choose a reason for hiding this comment

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

I think i did a bad thing when I first made it, may be the better way to get the dead time is to use the accumulate period rather than exposure time, which we can read from this signal: BL99P-EA-DET-03:CAM:AndorAccumulatePeriod_RBV ?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Except this is a sync method so we can't read PVs here...

Copy link
Contributor

Choose a reason for hiding this comment

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

Just out of interest, if I do want the deadtime to be from a readback channel, what is the best way to go about it?

Copy link
Collaborator

Choose a reason for hiding this comment

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

The answer is either:

  • We keep the ability to validate plans in advance, the we cannot touch hardware to calculate this number and have to duplicate the calculation of deadtime that's in the SDK into our code
  • We abandon the ability to validate plans in advance, then we can make this an async method and use the value of that PV after exposure time has been set

@callumforrester do we want to open this box?

async def prepare(self, trigger_info: TriggerInfo):
await self.set_exposure_time_and_acquire_period_if_supplied(
trigger_info.livetime
)
await asyncio.gather(
self.driver.trigger_mode.set(self._get_trigger_mode(trigger_info.trigger)),
self.driver.num_images.set(
trigger_info.total_number_of_triggers or _MAX_NUM_IMAGE
),
self.driver.image_mode.set(Andor2ImageMode.MULTIPLE),
)

def _get_trigger_mode(self, trigger: DetectorTrigger) -> Andor2TriggerMode:
supported_trigger_types = {
DetectorTrigger.INTERNAL: Andor2TriggerMode.INTERNAL,
DetectorTrigger.EDGE_TRIGGER: Andor2TriggerMode.EXT_TRIGGER,
}
if trigger not in supported_trigger_types:
raise ValueError(
f"{self.__class__.__name__} only supports the following trigger "
f"types: {supported_trigger_types} but was asked to "
f"use {trigger}"
)
return supported_trigger_types[trigger]
Comment on lines +38 to +49
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: If we can't make a new TriggerInfo for Andor, then it might nicer to replace this code with a from_detector_trigger(cls, trigger: DetectorTrigger) -> Andor2TriggerMode method on Andor2TriggerMode.

45 changes: 45 additions & 0 deletions src/ophyd_async/epics/adandor/_andor_io.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
from ophyd_async.core import StrictEnum, SubsetEnum
from ophyd_async.epics.adcore import ADBaseIO
from ophyd_async.epics.core import (
epics_signal_r,
epics_signal_rw,
epics_signal_rw_rbv,
)


class Andor2TriggerMode(StrictEnum):
INTERNAL = "Internal"
EXT_TRIGGER = "External"
EXT_START = "External Start"
EXT_EXPOSURE = "External Exposure"
EXT_FVP = "External FVP"
SOFTWARE = "Software"


class Andor2ImageMode(StrictEnum):
Copy link
Collaborator

Choose a reason for hiding this comment

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

Could we make this one subclass the ADBase one and add FAST_KINETICS to it?

SINGLE = "Single"
MULTIPLE = "Multiple"
CONTINUOUS = "Continuous"
FAST_KINETICS = "Fast Kinetics"


class Andor2DataType(SubsetEnum):
UINT16 = "UInt16"
UINT32 = "UInt32"
FLOAT32 = "Float32"
FLOAT64 = "Float64"


class Andor2DriverIO(ADBaseIO):
"""
Epics pv for andor model:DU897_BV as deployed on p99
"""

def __init__(self, prefix: str, name: str = "") -> None:
super().__init__(prefix, name=name)
self.trigger_mode = epics_signal_rw(Andor2TriggerMode, prefix + "TriggerMode")
self.data_type = epics_signal_r(Andor2DataType, prefix + "DataType_RBV")
self.andor_accumulate_period = epics_signal_r(
float, prefix + "AndorAccumulatePeriod_RBV"
)
self.image_mode = epics_signal_rw_rbv(Andor2ImageMode, prefix + "ImageMode")
Copy link
Collaborator

Choose a reason for hiding this comment

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

This seems a relatively common pattern, overriding the image mode to be a subset of what the baseclass provides. We have SubsetEnum already, should we have SupersetEnum too? Or we could drop to string in the baseclass...

123 changes: 123 additions & 0 deletions tests/epics/adandor/test_andor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
from typing import cast
from unittest.mock import AsyncMock, patch

import pytest
from event_model import StreamDatum, StreamResource

from ophyd_async.core import (
DetectorTrigger,
PathProvider,
TriggerInfo,
)
from ophyd_async.epics import adandor


@pytest.fixture
def test_adandor(ad_standard_det_factory) -> adandor.Andor2Detector:
return ad_standard_det_factory(adandor.Andor2Detector)


@pytest.mark.parametrize("exposure_time", [0.0, 0.1, 1.0, 10.0, 100.0])
async def test_deadtime_from_exposure_time(
exposure_time: float,
test_adandor: adandor.Andor2Detector,
):
assert test_adandor._controller.get_deadtime(exposure_time) == exposure_time + 0.1


async def test_hints_from_hdf_writer(test_adandor: adandor.Andor2Detector):
assert test_adandor.hints == {"fields": ["test_adandor21"]}


async def test_can_read(test_adandor: adandor.Andor2Detector):
# Standard detector can be used as Readable
assert (await test_adandor.read()) == {}


async def test_decribe_describes_writer_dataset(
test_adandor: adandor.Andor2Detector, one_shot_trigger_info: TriggerInfo
):
assert await test_adandor.describe() == {}
await test_adandor.stage()
await test_adandor.prepare(one_shot_trigger_info)
assert await test_adandor.describe() == {
"test_adandor21": {
"source": "mock+ca://ANDOR21:HDF1:FullFileName_RBV",
"shape": [10, 10],
"dtype": "array",
"dtype_numpy": "<u2",
"external": "STREAM:",
}
}


async def test_can_collect(
test_adandor: adandor.Andor2Detector,
static_path_provider: PathProvider,
one_shot_trigger_info: TriggerInfo,
):
path_info = static_path_provider()
full_file_name = path_info.directory_path / f"{path_info.filename}.h5"
await test_adandor.stage()
await test_adandor.prepare(one_shot_trigger_info)
docs = [(name, doc) async for name, doc in test_adandor.collect_asset_docs(1)]
assert len(docs) == 2
assert docs[0][0] == "stream_resource"
stream_resource = cast(StreamResource, docs[0][1])
sr_uid = stream_resource["uid"]
assert stream_resource["data_key"] == "test_adandor21"
assert stream_resource["uri"] == "file://localhost/" + str(full_file_name).lstrip(
"/"
)
assert stream_resource["parameters"] == {
"dataset": "/entry/data/data",
"swmr": False,
"multiplier": 1,
"chunk_shape": (1, 10, 10),
}
assert docs[1][0] == "stream_datum"
stream_datum = cast(StreamDatum, docs[1][1])
assert stream_datum["stream_resource"] == sr_uid
assert stream_datum["seq_nums"] == {"start": 0, "stop": 0}
assert stream_datum["indices"] == {"start": 0, "stop": 1}


async def test_can_decribe_collect(
test_adandor: adandor.Andor2Detector, one_shot_trigger_info: TriggerInfo
):
assert (await test_adandor.describe_collect()) == {}
await test_adandor.stage()
await test_adandor.prepare(one_shot_trigger_info)
assert (await test_adandor.describe_collect()) == {
"test_adandor21": {
"source": "mock+ca://ANDOR21:HDF1:FullFileName_RBV",
"shape": [10, 10],
"dtype": "array",
"dtype_numpy": "<u2",
"external": "STREAM:",
}
}


async def test_unsupported_trigger_excepts(test_adandor: adandor.Andor2Detector):
with patch(
"ophyd_async.epics.adcore._hdf_writer.ADHDFWriter.open", new_callable=AsyncMock
) as mock_open:
with pytest.raises(
ValueError,
# str(EnumClass.value) handling changed in Python 3.11
match=(
"Andor2Controller only supports the following trigger types: .* but"
),
):
await test_adandor.prepare(
TriggerInfo(
number_of_triggers=0,
trigger=DetectorTrigger.VARIABLE_GATE,
deadtime=1.1,
livetime=1,
frame_timeout=3,
)
)

mock_open.assert_called_once()
Loading