Skip to content

Commit 7923622

Browse files
Merge pull request #21 from DiamondLightSource/p45AndAdsim
Add configs for P45 and simulated AreaDetector
2 parents 65b7313 + 95f4775 commit 7923622

File tree

10 files changed

+359
-1
lines changed

10 files changed

+359
-1
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ classifiers = [
1313
"Programming Language :: Python :: 3.11",
1414
]
1515
description = "Ophyd devices and other utils that could be used across DLS beamlines"
16-
dependencies = ["ophyd", "bluesky", "pyepics"]
16+
dependencies = ["ophyd", "bluesky", "pyepics", "pydantic"]
1717
dynamic = ["version"]
1818
license.file = "LICENSE"
1919
readme = "README.rst"

src/dodal/adsim.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import socket
2+
3+
from pydantic import BaseSettings
4+
5+
from dodal.devices.adsim import SimStage
6+
from dodal.devices.areadetector import AdSimDetector
7+
8+
9+
# Settings can be customized via environment variables
10+
class Settings(BaseSettings):
11+
pv_prefix: str = socket.gethostname().split(".")[0]
12+
13+
14+
_settings = Settings()
15+
16+
17+
def stage(name: str = "sim_motors") -> SimStage:
18+
return SimStage(name=name, prefix=f"{_settings.pv_prefix}-MO-SIM-01:")
19+
20+
21+
def det(name: str = "adsim") -> AdSimDetector:
22+
return AdSimDetector(name=name, prefix=f"{_settings.pv_prefix}-AD-SIM-01:")

src/dodal/devices/adsim.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from ophyd import Component, EpicsMotor, MotorBundle
2+
3+
4+
class SimStage(MotorBundle):
5+
"""
6+
ADSIM EPICS motors
7+
"""
8+
9+
x: EpicsMotor = Component(EpicsMotor, "M1")
10+
y: EpicsMotor = Component(EpicsMotor, "M2")
11+
z: EpicsMotor = Component(EpicsMotor, "M3")
12+
theta: EpicsMotor = Component(EpicsMotor, "M4")
13+
load: EpicsMotor = Component(EpicsMotor, "M5")
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from .adaravis import AdAravisDetector
2+
from .adsim import AdSimDetector
3+
from .adutils import Hdf5Writer, SynchronisedAdDriverBase
4+
5+
__all__ = [
6+
"AdSimDetector",
7+
"SynchronisedAdDriverBase",
8+
"Hdf5Writer",
9+
"AdAravisDetector",
10+
]
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from ophyd import Component as Cpt
2+
from ophyd import DetectorBase
3+
from ophyd.areadetector.base import ADComponent as Cpt
4+
from ophyd.areadetector.detectors import DetectorBase
5+
6+
from .adutils import Hdf5Writer, SingleTriggerV33, SynchronisedAdDriverBase
7+
8+
_ACQUIRE_BUFFER_PERIOD = 0.2
9+
10+
11+
class AdAravisDetector(SingleTriggerV33, DetectorBase):
12+
cam: SynchronisedAdDriverBase = Cpt(SynchronisedAdDriverBase, suffix="DET:")
13+
hdf: Hdf5Writer = Cpt(
14+
Hdf5Writer,
15+
suffix="HDF5:",
16+
root="",
17+
write_path_template="",
18+
)
19+
20+
def __init__(self, *args, **kwargs) -> None:
21+
super().__init__(*args, **kwargs)
22+
self.hdf.kind = "normal"
23+
24+
self.stage_sigs = {
25+
# Get stage to wire up the plugins
26+
self.hdf.nd_array_port: self.cam.port_name.get(),
27+
# Reset array counter on stage
28+
self.cam.array_counter: 0,
29+
# Set image mode to multiple on stage so we have the option, can still
30+
# set num_images to 1
31+
self.cam.image_mode: "Multiple",
32+
# For now, this Ophyd device does not support hardware
33+
# triggered scanning, disable on stage
34+
self.cam.trigger_mode: "Off",
35+
**self.stage_sigs, # type: ignore
36+
}
37+
38+
def stage(self, *args, **kwargs):
39+
# We have to manually set the acquire period bcause the EPICS driver
40+
# doesn't do it for us. If acquire time is a staged signal, we use the
41+
# stage value to calculate the acquire period, otherwise we perform
42+
# a caget and use the current acquire time.
43+
if self.cam.acquire_time in self.stage_sigs:
44+
acquire_time = self.stage_sigs[self.cam.acquire_time]
45+
else:
46+
acquire_time = self.cam.acquire_time.get()
47+
self.stage_sigs[self.cam.acquire_period] = acquire_time + _ACQUIRE_BUFFER_PERIOD
48+
49+
# Now calling the super method should set the acquire period
50+
super(AdAravisDetector, self).stage(*args, **kwargs)
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
from ophyd import Component as Cpt
2+
from ophyd.areadetector.base import ADComponent as Cpt
3+
from ophyd.areadetector.detectors import DetectorBase
4+
5+
from .adutils import Hdf5Writer, SingleTriggerV33, SynchronisedAdDriverBase
6+
7+
8+
class AdSimDetector(SingleTriggerV33, DetectorBase):
9+
cam: SynchronisedAdDriverBase = Cpt(
10+
SynchronisedAdDriverBase, suffix="CAM:", lazy=True
11+
)
12+
hdf: Hdf5Writer = Cpt(
13+
Hdf5Writer,
14+
suffix="HDF5:",
15+
root="",
16+
write_path_template="",
17+
lazy=True,
18+
)
19+
20+
def __init__(self, *args, **kwargs) -> None:
21+
super().__init__(*args, **kwargs)
22+
self.hdf.kind = "normal"
23+
24+
self.stage_sigs = {
25+
# Get stage to wire up the plugins
26+
self.hdf.nd_array_port: self.cam.port_name.get(),
27+
# Reset array counter on stage
28+
self.cam.array_counter: 0,
29+
# Set image mode to multiple on stage so we have the option, can still
30+
# set num_images to 1
31+
self.cam.image_mode: "Multiple",
32+
# For now, this Ophyd device does not support hardware
33+
# triggered scanning, disable on stage
34+
self.cam.trigger_mode: "Internal",
35+
**self.stage_sigs, # type: ignore
36+
}
37+
38+
def stage(self, *args, **kwargs):
39+
# We have to manually set the acquire period bcause the EPICS driver
40+
# doesn't do it for us. If acquire time is a staged signal, we use the
41+
# stage value to calculate the acquire period, otherwise we perform
42+
# a caget and use the current acquire time.
43+
if self.cam.acquire_time in self.stage_sigs:
44+
acquire_time = self.stage_sigs[self.cam.acquire_time]
45+
else:
46+
acquire_time = self.cam.acquire_time.get()
47+
self.stage_sigs[self.cam.acquire_period] = acquire_time
48+
49+
# Now calling the super method should set the acquire period
50+
super(AdSimDetector, self).stage(*args, **kwargs)
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import time as ttime
2+
3+
from ophyd import Component as Cpt
4+
from ophyd import EpicsSignal, EpicsSignalRO, Staged
5+
from ophyd.areadetector import ADTriggerStatus, TriggerBase
6+
from ophyd.areadetector.cam import AreaDetectorCam
7+
from ophyd.areadetector.filestore_mixins import FileStoreHDF5, FileStoreIterativeWrite
8+
from ophyd.areadetector.plugins import HDF5Plugin
9+
10+
11+
class SingleTriggerV33(TriggerBase):
12+
_status_type = ADTriggerStatus
13+
14+
def __init__(self, *args, image_name=None, **kwargs):
15+
super().__init__(*args, **kwargs)
16+
if image_name is None:
17+
image_name = "_".join([self.name, "image"])
18+
self._image_name = image_name
19+
20+
def trigger(self):
21+
"Trigger one acquisition."
22+
if self._staged != Staged.yes:
23+
raise RuntimeError(
24+
"This detector is not ready to trigger."
25+
"Call the stage() method before triggering."
26+
)
27+
28+
self._status = self._status_type(self)
29+
30+
def _acq_done(*args, **kwargs):
31+
# TODO sort out if anything useful in here
32+
self._status._finished()
33+
34+
self._acquisition_signal.put(1, use_complete=True, callback=_acq_done)
35+
self.dispatch(self._image_name, ttime.time())
36+
return self._status
37+
38+
39+
class SynchronisedAdDriverBase(AreaDetectorCam):
40+
"""
41+
Base Ophyd device to control an AreaDetector driver and
42+
syncrhonise it on other AreaDetector plugins, even non-blocking ones.
43+
"""
44+
45+
adcore_version = Cpt(EpicsSignalRO, "ADCoreVersion_RBV", string=True, kind="config")
46+
driver_version = Cpt(EpicsSignalRO, "DriverVersion_RBV", string=True, kind="config")
47+
wait_for_plugins = Cpt(EpicsSignal, "WaitForPlugins", string=True, kind="config")
48+
49+
def stage(self, *args, **kwargs):
50+
# Makes the detector allow non-blocking AD plugins but makes Ophyd use
51+
# the AcquireBusy PV to determine when an acquisition is complete
52+
self.ensure_nonblocking()
53+
return super().stage(*args, **kwargs)
54+
55+
def ensure_nonblocking(self):
56+
self.stage_sigs["wait_for_plugins"] = "Yes"
57+
for c in self.parent.component_names:
58+
cpt = getattr(self.parent, c)
59+
if cpt is self:
60+
continue
61+
if hasattr(cpt, "ensure_nonblocking"):
62+
cpt.ensure_nonblocking()
63+
64+
65+
class Hdf5Writer(HDF5Plugin, FileStoreHDF5, FileStoreIterativeWrite):
66+
""" """
67+
68+
pool_max_buffers = None
69+
file_number_sync = None
70+
file_number_write = None
71+
72+
def get_frames_per_point(self):
73+
return self.parent.cam.num_images.get()

src/dodal/devices/p45.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from ophyd import Component as Cpt
2+
from ophyd import EpicsMotor, MotorBundle
3+
from ophyd.areadetector.base import ADComponent as Cpt
4+
5+
6+
class SampleY(MotorBundle):
7+
"""
8+
Motors for controlling the sample's y position and stretch in the y axis.
9+
"""
10+
11+
base: EpicsMotor = Cpt(EpicsMotor, "CS:Y")
12+
stretch: EpicsMotor = Cpt(EpicsMotor, "CS:Y:STRETCH")
13+
top: EpicsMotor = Cpt(EpicsMotor, "Y:TOP")
14+
bottom: EpicsMotor = Cpt(EpicsMotor, "Y:BOT")
15+
16+
17+
class SampleTheta(MotorBundle):
18+
"""
19+
Motors for controlling the sample's theta position and skew
20+
"""
21+
22+
base: EpicsMotor = Cpt(EpicsMotor, "THETA:POS")
23+
skew: EpicsMotor = Cpt(EpicsMotor, "THETA:SKEW")
24+
top: EpicsMotor = Cpt(EpicsMotor, "THETA:TOP")
25+
bottom: EpicsMotor = Cpt(EpicsMotor, "THETA:BOT")
26+
27+
28+
class TomoStageWithStretchAndSkew(MotorBundle):
29+
"""
30+
Grouping of motors for the P45 tomography stage
31+
"""
32+
33+
x: EpicsMotor = Cpt(EpicsMotor, "X")
34+
y: SampleY = Cpt(SampleY, "")
35+
theta: SampleTheta = Cpt(SampleTheta, "")
36+
37+
38+
class Choppers(MotorBundle):
39+
"""
40+
Grouping for the P45 chopper motors
41+
"""
42+
43+
x: EpicsMotor = Cpt(EpicsMotor, "ENDAT")
44+
y: EpicsMotor = Cpt(EpicsMotor, "BISS")

src/dodal/p45.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from pydantic import BaseSettings
2+
3+
from dodal.devices.areadetector import AdAravisDetector
4+
from dodal.devices.p45 import Choppers, TomoStageWithStretchAndSkew
5+
6+
7+
# Settings can be customized via environment variables
8+
class Settings(BaseSettings):
9+
pv_prefix: str = "BL45P"
10+
11+
12+
_settings = Settings()
13+
14+
15+
def sample_sample(name: str = "sample_stage") -> TomoStageWithStretchAndSkew:
16+
return TomoStageWithStretchAndSkew(
17+
name=name, prefix=f"{_settings.pv_prefix}-MO-STAGE-01:"
18+
)
19+
20+
21+
def choppers(name: str = "chopper") -> Choppers:
22+
return Choppers(name=name, prefix=f"{_settings.pv_prefix}-MO-CHOP-01:")
23+
24+
25+
def det(name: str = "det") -> AdAravisDetector:
26+
return AdAravisDetector(name=name, prefix=f"{_settings.pv_prefix}-EA-MAP-01:")
27+
28+
29+
def diff(name: str = "diff") -> AdAravisDetector:
30+
return AdAravisDetector(name=name, prefix=f"{_settings.pv_prefix}-EA-DIFF-01:")

src/dodal/util.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import inspect
2+
from importlib import import_module
3+
from inspect import signature
4+
from types import ModuleType
5+
from typing import Any, Callable, Dict, Iterable, Type, Union
6+
7+
from bluesky.protocols import (
8+
Checkable,
9+
Configurable,
10+
Flyable,
11+
HasHints,
12+
HasName,
13+
HasParent,
14+
Movable,
15+
Pausable,
16+
Readable,
17+
Stageable,
18+
Stoppable,
19+
Subscribable,
20+
Triggerable,
21+
WritesExternalAssets,
22+
)
23+
24+
#: Protocols defining interface to hardware
25+
BLUESKY_PROTOCOLS = [
26+
Checkable,
27+
Flyable,
28+
HasHints,
29+
HasName,
30+
HasParent,
31+
Movable,
32+
Pausable,
33+
Readable,
34+
Stageable,
35+
Stoppable,
36+
Subscribable,
37+
WritesExternalAssets,
38+
Configurable,
39+
Triggerable,
40+
]
41+
42+
43+
def make_all_devices(module: Union[str, ModuleType, None] = None) -> Dict[str, Any]:
44+
if isinstance(module, str) or module is None:
45+
module = import_module(module or __name__)
46+
factories = collect_factories(module)
47+
return {device.name: device for device in map(lambda factory: factory(), factories)}
48+
49+
50+
def collect_factories(module: ModuleType) -> Iterable[Callable[..., Any]]:
51+
for var in module.__dict__.values():
52+
if callable(var) and is_device_factory(var):
53+
yield var
54+
55+
56+
def is_device_factory(func: Callable[..., Any]) -> bool:
57+
return_type = signature(func).return_annotation
58+
return is_device_type(return_type)
59+
60+
61+
def is_device_type(obj: Type[Any]) -> bool:
62+
is_class = inspect.isclass(obj)
63+
follows_protocols = any(
64+
map(lambda protocol: isinstance(obj, protocol), BLUESKY_PROTOCOLS)
65+
)
66+
return is_class and follows_protocols

0 commit comments

Comments
 (0)