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

Fastcs eiger support #528

Merged
merged 12 commits into from
Sep 2, 2024
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ commands =


[tool.ruff]
src = ["src", "tests"]
src = ["src", "tests", "system_tests"]
coretl marked this conversation as resolved.
Show resolved Hide resolved
line-length = 88
lint.select = [
"C4", # flake8-comprehensions - https://beta.ruff.rs/docs/rules/#flake8-comprehensions-c4
Expand Down
2 changes: 1 addition & 1 deletion src/ophyd_async/core/_signal.py
Original file line number Diff line number Diff line change
Expand Up @@ -547,7 +547,7 @@ async def wait_for_value(


async def set_and_wait_for_other_value(
set_signal: SignalRW[T],
set_signal: SignalW[T],
set_value: T,
read_signal: SignalR[S],
read_value: S,
Expand Down
5 changes: 5 additions & 0 deletions src/ophyd_async/epics/eiger/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from ._eiger import EigerDetector, EigerTriggerInfo
from ._eiger_controller import EigerController
from ._eiger_io import EigerDriverIO

__all__ = ["EigerDetector", "EigerController", "EigerDriverIO", "EigerTriggerInfo"]
43 changes: 43 additions & 0 deletions src/ophyd_async/epics/eiger/_eiger.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
from pydantic import Field

from ophyd_async.core import AsyncStatus, PathProvider, StandardDetector
from ophyd_async.core._detector import TriggerInfo

from ._eiger_controller import EigerController
from ._eiger_io import EigerDriverIO
from ._odin_io import Odin, OdinWriter


class EigerTriggerInfo(TriggerInfo):
energy_ev: float = Field(gt=0)


class EigerDetector(StandardDetector):
"""
Ophyd-async implementation of an Eiger Detector.
"""

_controller: EigerController
_writer: Odin

def __init__(
self,
prefix: str,
path_provider: PathProvider,
drv_suffix="-EA-EIGER-01:",
hdf_suffix="-EA-ODIN-01:",
name="",
):
self.drv = EigerDriverIO(prefix + drv_suffix)
self.odin = Odin(prefix + hdf_suffix + "FP:")

super().__init__(
EigerController(self.drv),
OdinWriter(path_provider, lambda: self.name, self.odin),
name=name,
)

@AsyncStatus.wrap
async def prepare(self, value: EigerTriggerInfo) -> None:
await self._controller.set_energy(value.energy_ev)
Copy link
Collaborator

Choose a reason for hiding this comment

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

After #405 then there will be a prepare method on the controller that takes a TriggerInfoT so this prepare method will become unnecessary. For now this is correct though.

await super().prepare(value)
66 changes: 66 additions & 0 deletions src/ophyd_async/epics/eiger/_eiger_controller.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import asyncio
from typing import Optional

from ophyd_async.core import (
DEFAULT_TIMEOUT,
AsyncStatus,
DetectorControl,
DetectorTrigger,
set_and_wait_for_other_value,
)

from ._eiger_io import EigerDriverIO, EigerTriggerMode

EIGER_TRIGGER_MODE_MAP = {
DetectorTrigger.internal: EigerTriggerMode.internal,
DetectorTrigger.constant_gate: EigerTriggerMode.gate,
DetectorTrigger.variable_gate: EigerTriggerMode.gate,
DetectorTrigger.edge_trigger: EigerTriggerMode.edge,
}


class EigerController(DetectorControl):
def __init__(
self,
driver: EigerDriverIO,
) -> None:
self._drv = driver

def get_deadtime(self, exposure: float) -> float:
# See https://media.dectris.com/filer_public/30/14/3014704e-5f3b-43ba-8ccf-8ef720e60d2a/240202_usermanual_eiger2.pdf
return 0.0001
Copy link
Collaborator

Choose a reason for hiding this comment

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

Where did you get this number from? If from the manual then please can you provide a reference?


async def set_energy(self, energy: float, tolerance: float = 0.1):
"""Changing photon energy takes some time so only do so if the current energy is
outside the tolerance."""
current_energy = await self._drv.photon_energy.get_value()
if abs(current_energy - energy) > tolerance:
await self._drv.photon_energy.set(energy)

@AsyncStatus.wrap
async def arm(
self,
num: int,
trigger: DetectorTrigger = DetectorTrigger.internal,
exposure: Optional[float] = None,
):
coros = [
self._drv.trigger_mode.set(EIGER_TRIGGER_MODE_MAP[trigger].value),
self._drv.num_images.set(num),
]
if exposure is not None:
coros.extend(
[
self._drv.acquire_time.set(exposure),
self._drv.acquire_period.set(exposure),
]
)
await asyncio.gather(*coros)

# TODO: Detector state should be an enum see https://github.com/DiamondLightSource/eiger-fastcs/issues/43
await set_and_wait_for_other_value(
self._drv.arm, 1, self._drv.state, "ready", timeout=DEFAULT_TIMEOUT
)

async def disarm(self):
await self._drv.disarm.set(1)
42 changes: 42 additions & 0 deletions src/ophyd_async/epics/eiger/_eiger_io.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
from enum import Enum

from ophyd_async.core import Device
from ophyd_async.epics.signal import epics_signal_r, epics_signal_rw_rbv, epics_signal_w


class EigerTriggerMode(str, Enum):
internal = "ints"
edge = "exts"
gate = "exte"


class EigerDriverIO(Device):
def __init__(self, prefix: str, name: str = "") -> None:
self.bit_depth = epics_signal_r(int, f"{prefix}BitDepthReadout")
self.stale_parameters = epics_signal_r(bool, f"{prefix}StaleParameters")
self.state = epics_signal_r(str, f"{prefix}DetectorState")
self.roi_mode = epics_signal_rw_rbv(str, f"{prefix}RoiMode")

self.acquire_time = epics_signal_rw_rbv(float, f"{prefix}CountTime")
self.acquire_period = epics_signal_rw_rbv(float, f"{prefix}FrameTime")

self.num_images = epics_signal_rw_rbv(int, f"{prefix}Nimages")
self.num_triggers = epics_signal_rw_rbv(int, f"{prefix}Ntrigger")

# TODO: Should be EigerTriggerMode enum, see https://github.com/DiamondLightSource/eiger-fastcs/issues/43
self.trigger_mode = epics_signal_rw_rbv(str, f"{prefix}TriggerMode")

self.arm = epics_signal_w(int, f"{prefix}Arm")
self.disarm = epics_signal_w(int, f"{prefix}Disarm")
self.abort = epics_signal_w(int, f"{prefix}Abort")

self.beam_centre_x = epics_signal_rw_rbv(float, f"{prefix}BeamCenterX")
self.beam_centre_y = epics_signal_rw_rbv(float, f"{prefix}BeamCenterY")

self.det_distance = epics_signal_rw_rbv(float, f"{prefix}DetectorDistance")
self.omega_start = epics_signal_rw_rbv(float, f"{prefix}OmegaStart")
self.omega_increment = epics_signal_rw_rbv(float, f"{prefix}OmegaIncrement")

self.photon_energy = epics_signal_rw_rbv(float, f"{prefix}PhotonEnergy")

super().__init__(name)
125 changes: 125 additions & 0 deletions src/ophyd_async/epics/eiger/_odin_io.py
Copy link
Collaborator

Choose a reason for hiding this comment

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

As soon as PVI datastructures are published over PVA then this file will drastically change to something like:

class EigerDriverIO(Device):
    bit_depth: SignalR[int]
    stale_parameters: SignalR[bool]
    ...

It will also only need to include the signals that are needed at static analysis time, at runtime then all the extras (like beam_centre_x, etc) will be filled in automatically.

This will also mean that the class structure and ophyd attribute names will have to exactly match those in PVI.

For this reason I suggest we do this soon, and @GDYendell is going to start looking at this now

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It will also only need to include the signals that are needed at static analysis time, at runtime then all the extras (like beam_centre_x, etc) will be filled in automatically.

Eventually all these signals will be needed at static analysis time. I have added in all the ones that we need to re-implement what we currently have on the ophyd eiger.

Copy link
Contributor

Choose a reason for hiding this comment

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

PVI structures will be added in DiamondLightSource/FastCS#54

Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import asyncio
from enum import Enum
from typing import AsyncGenerator, AsyncIterator, Dict

from bluesky.protocols import StreamAsset
from event_model.documents.event_descriptor import DataKey

from ophyd_async.core import (
DEFAULT_TIMEOUT,
DetectorWriter,
Device,
DeviceVector,
NameProvider,
PathProvider,
observe_value,
set_and_wait_for_value,
)
from ophyd_async.epics.signal import (
epics_signal_r,
epics_signal_rw,
epics_signal_rw_rbv,
)


class Writing(str, Enum):
ON = "ON"
OFF = "OFF"


class OdinNode(Device):
def __init__(self, prefix: str, name: str = "") -> None:
self.writing = epics_signal_r(Writing, f"{prefix}HDF:Writing")
self.connected = epics_signal_r(bool, f"{prefix}Connected")

super().__init__(name)


class Odin(Device):
def __init__(self, prefix: str, name: str = "") -> None:
self.nodes = DeviceVector({i: OdinNode(f"{prefix}FP{i}:") for i in range(4)})

self.capture = epics_signal_rw(
Writing, f"{prefix}Writing", f"{prefix}ConfigHdfWrite"
)
self.num_captured = epics_signal_r(int, f"{prefix}FramesWritten")
self.num_to_capture = epics_signal_rw_rbv(int, f"{prefix}ConfigHdfFrames")

self.start_timeout = epics_signal_rw_rbv(int, f"{prefix}TimeoutTimerPeriod")

self.image_height = epics_signal_rw_rbv(int, f"{prefix}DatasetDataDims0")
self.image_width = epics_signal_rw_rbv(int, f"{prefix}DatasetDataDims1")

self.num_row_chunks = epics_signal_rw_rbv(int, f"{prefix}DatasetDataChunks1")
self.num_col_chunks = epics_signal_rw_rbv(int, f"{prefix}DatasetDataChunks2")

self.file_path = epics_signal_rw_rbv(str, f"{prefix}ConfigHdfFilePath")
self.file_name = epics_signal_rw_rbv(str, f"{prefix}ConfigHdfFilePrefix")

self.acquisition_id = epics_signal_rw_rbv(
str, f"{prefix}ConfigHdfAcquisitionId"
)

self.data_type = epics_signal_rw_rbv(str, f"{prefix}DatasetDataDatatype")

super().__init__(name)


class OdinWriter(DetectorWriter):
def __init__(
self,
path_provider: PathProvider,
name_provider: NameProvider,
odin_driver: Odin,
) -> None:
self._drv = odin_driver
self._path_provider = path_provider
self._name_provider = name_provider
super().__init__()

async def open(self, multiplier: int = 1) -> Dict[str, DataKey]:
info = self._path_provider(device_name=self._name_provider())

await asyncio.gather(
self._drv.file_path.set(str(info.directory_path)),
self._drv.file_name.set(info.filename),
self._drv.data_type.set(
"uint16"
), # TODO: Get from eiger https://github.com/bluesky/ophyd-async/issues/529
self._drv.num_to_capture.set(0),
)

await self._drv.capture.set(Writing.ON)

return await self._describe()

async def _describe(self) -> Dict[str, DataKey]:
data_shape = await asyncio.gather(
self._drv.image_height.get_value(), self._drv.image_width.get_value()
)

return {
"data": DataKey(
source=self._drv.file_name.source,
shape=data_shape,
dtype="array",
dtype_numpy="<u2", # TODO: Use correct type based on eiger https://github.com/bluesky/ophyd-async/issues/529
external="STREAM:",
)
}

async def observe_indices_written(
self, timeout=DEFAULT_TIMEOUT
) -> AsyncGenerator[int, None]:
async for num_captured in observe_value(self._drv.num_captured, timeout):
yield num_captured

async def get_indices_written(self) -> int:
return await self._drv.num_captured.get_value()

def collect_stream_docs(self, indices_written: int) -> AsyncIterator[StreamAsset]:
# TODO: Correctly return stream https://github.com/bluesky/ophyd-async/issues/530
raise NotImplementedError()

async def close(self) -> None:
await set_and_wait_for_value(self._drv.capture, Writing.OFF)
8 changes: 8 additions & 0 deletions system_tests/epics/eiger/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
This system test runs against the eiger tickit sim. To run it:

0. Ensure you have disabled SELinux (https://dev-portal.diamond.ac.uk/guide/containers/tutorials/podman/#enable-use-of-vscode-features)
1. Run `podman run --rm -it -v /dev/shm:/dev/shm -v /tmp:/tmp --net=host ghcr.io/diamondlightsource/eiger-detector-runtime:1.16.0beta5` this will bring up the simulator itself.
2. In a separate terminal load a python environment with `ophyd-async` in it
3. `cd system_tests/epics/eiger` and `./start_iocs_and_run_tests.sh`


25 changes: 25 additions & 0 deletions system_tests/epics/eiger/start_iocs_and_run_tests.sh
Copy link
Contributor

Choose a reason for hiding this comment

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

@gilesknap I wonder if this can be achieved with docker-compose or similar, so we can run it up either in the CI or locally in exactly the same way?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I didn't want to hold this up on the CI stuff, which could get fiddly so made #531

Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@

host=$(hostname | tr -cd '[:digit:]')
export eiger_ioc=EIGER-$host
export odin_ioc=ODIN-$host

mkdir /tmp/opi

if command -v docker &> /dev/null; then
DOCKER_COMMAND=docker
else
DOCKER_COMMAND=podman
fi

echo "Using $DOCKER_COMMAND"

$DOCKER_COMMAND run --rm --name=$eiger_ioc -dt --net=host -v /tmp/opi/:/epics/opi ghcr.io/diamondlightsource/eiger-fastcs:0.1.0beta5 ioc $eiger_ioc

$DOCKER_COMMAND run --rm --name=$odin_ioc -dt --net=host -v /tmp/opi/:/epics/opi ghcr.io/diamondlightsource/odin-fastcs:0.2.0beta2 ioc $odin_ioc

sleep 1

pytest .

podman kill $eiger_ioc
podman kill $odin_ioc
Loading
Loading