ophyd-async 0.9.0a2__py3-none-any.whl → 0.10.0a2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. ophyd_async/__init__.py +5 -8
  2. ophyd_async/_docs_parser.py +12 -0
  3. ophyd_async/_version.py +9 -4
  4. ophyd_async/core/__init__.py +97 -62
  5. ophyd_async/core/_derived_signal.py +271 -0
  6. ophyd_async/core/_derived_signal_backend.py +300 -0
  7. ophyd_async/core/_detector.py +106 -125
  8. ophyd_async/core/_device.py +69 -63
  9. ophyd_async/core/_device_filler.py +65 -1
  10. ophyd_async/core/_flyer.py +14 -5
  11. ophyd_async/core/_hdf_dataset.py +29 -22
  12. ophyd_async/core/_log.py +14 -23
  13. ophyd_async/core/_mock_signal_backend.py +11 -3
  14. ophyd_async/core/_protocol.py +65 -45
  15. ophyd_async/core/_providers.py +28 -9
  16. ophyd_async/core/_readable.py +44 -35
  17. ophyd_async/core/_settings.py +36 -27
  18. ophyd_async/core/_signal.py +262 -170
  19. ophyd_async/core/_signal_backend.py +56 -13
  20. ophyd_async/core/_soft_signal_backend.py +16 -11
  21. ophyd_async/core/_status.py +72 -24
  22. ophyd_async/core/_table.py +41 -11
  23. ophyd_async/core/_utils.py +96 -49
  24. ophyd_async/core/_yaml_settings.py +2 -0
  25. ophyd_async/epics/__init__.py +1 -0
  26. ophyd_async/epics/adandor/_andor.py +2 -2
  27. ophyd_async/epics/adandor/_andor_controller.py +4 -2
  28. ophyd_async/epics/adandor/_andor_io.py +2 -4
  29. ophyd_async/epics/adaravis/__init__.py +5 -0
  30. ophyd_async/epics/adaravis/_aravis.py +4 -8
  31. ophyd_async/epics/adaravis/_aravis_controller.py +20 -43
  32. ophyd_async/epics/adaravis/_aravis_io.py +13 -28
  33. ophyd_async/epics/adcore/__init__.py +23 -8
  34. ophyd_async/epics/adcore/_core_detector.py +42 -2
  35. ophyd_async/epics/adcore/_core_io.py +124 -99
  36. ophyd_async/epics/adcore/_core_logic.py +106 -27
  37. ophyd_async/epics/adcore/_core_writer.py +12 -8
  38. ophyd_async/epics/adcore/_hdf_writer.py +21 -38
  39. ophyd_async/epics/adcore/_single_trigger.py +2 -2
  40. ophyd_async/epics/adcore/_utils.py +2 -2
  41. ophyd_async/epics/adkinetix/__init__.py +2 -1
  42. ophyd_async/epics/adkinetix/_kinetix.py +3 -3
  43. ophyd_async/epics/adkinetix/_kinetix_controller.py +4 -2
  44. ophyd_async/epics/adkinetix/_kinetix_io.py +12 -13
  45. ophyd_async/epics/adpilatus/__init__.py +5 -0
  46. ophyd_async/epics/adpilatus/_pilatus.py +1 -1
  47. ophyd_async/epics/adpilatus/_pilatus_controller.py +5 -24
  48. ophyd_async/epics/adpilatus/_pilatus_io.py +11 -9
  49. ophyd_async/epics/adsimdetector/__init__.py +8 -1
  50. ophyd_async/epics/adsimdetector/_sim.py +4 -14
  51. ophyd_async/epics/adsimdetector/_sim_controller.py +17 -0
  52. ophyd_async/epics/adsimdetector/_sim_io.py +10 -0
  53. ophyd_async/epics/advimba/__init__.py +10 -1
  54. ophyd_async/epics/advimba/_vimba.py +3 -2
  55. ophyd_async/epics/advimba/_vimba_controller.py +4 -2
  56. ophyd_async/epics/advimba/_vimba_io.py +23 -28
  57. ophyd_async/epics/core/_aioca.py +35 -16
  58. ophyd_async/epics/core/_epics_connector.py +4 -0
  59. ophyd_async/epics/core/_epics_device.py +2 -0
  60. ophyd_async/epics/core/_p4p.py +10 -2
  61. ophyd_async/epics/core/_pvi_connector.py +65 -8
  62. ophyd_async/epics/core/_signal.py +51 -51
  63. ophyd_async/epics/core/_util.py +4 -4
  64. ophyd_async/epics/demo/__init__.py +16 -0
  65. ophyd_async/epics/demo/__main__.py +31 -0
  66. ophyd_async/epics/demo/_ioc.py +32 -0
  67. ophyd_async/epics/demo/_motor.py +82 -0
  68. ophyd_async/epics/demo/_point_detector.py +42 -0
  69. ophyd_async/epics/demo/_point_detector_channel.py +22 -0
  70. ophyd_async/epics/demo/_stage.py +15 -0
  71. ophyd_async/epics/{sim/mover.db → demo/motor.db} +2 -1
  72. ophyd_async/epics/demo/point_detector.db +59 -0
  73. ophyd_async/epics/demo/point_detector_channel.db +21 -0
  74. ophyd_async/epics/eiger/_eiger.py +1 -3
  75. ophyd_async/epics/eiger/_eiger_controller.py +11 -4
  76. ophyd_async/epics/eiger/_eiger_io.py +2 -0
  77. ophyd_async/epics/eiger/_odin_io.py +1 -2
  78. ophyd_async/epics/motor.py +65 -28
  79. ophyd_async/epics/signal.py +4 -1
  80. ophyd_async/epics/testing/_example_ioc.py +21 -9
  81. ophyd_async/epics/testing/_utils.py +3 -0
  82. ophyd_async/epics/testing/test_records.db +8 -0
  83. ophyd_async/epics/testing/test_records_pva.db +17 -16
  84. ophyd_async/fastcs/__init__.py +1 -0
  85. ophyd_async/fastcs/core.py +6 -0
  86. ophyd_async/fastcs/odin/__init__.py +1 -0
  87. ophyd_async/fastcs/panda/__init__.py +8 -6
  88. ophyd_async/fastcs/panda/_block.py +29 -9
  89. ophyd_async/fastcs/panda/_control.py +5 -0
  90. ophyd_async/fastcs/panda/_hdf_panda.py +2 -0
  91. ophyd_async/fastcs/panda/_table.py +9 -6
  92. ophyd_async/fastcs/panda/_trigger.py +23 -9
  93. ophyd_async/fastcs/panda/_writer.py +27 -30
  94. ophyd_async/plan_stubs/__init__.py +2 -0
  95. ophyd_async/plan_stubs/_ensure_connected.py +1 -0
  96. ophyd_async/plan_stubs/_fly.py +2 -4
  97. ophyd_async/plan_stubs/_nd_attributes.py +2 -0
  98. ophyd_async/plan_stubs/_panda.py +1 -0
  99. ophyd_async/plan_stubs/_settings.py +43 -16
  100. ophyd_async/plan_stubs/_utils.py +3 -0
  101. ophyd_async/plan_stubs/_wait_for_awaitable.py +1 -1
  102. ophyd_async/sim/__init__.py +24 -14
  103. ophyd_async/sim/__main__.py +43 -0
  104. ophyd_async/sim/_blob_detector.py +33 -0
  105. ophyd_async/sim/_blob_detector_controller.py +48 -0
  106. ophyd_async/sim/_blob_detector_writer.py +105 -0
  107. ophyd_async/sim/_mirror_horizontal.py +46 -0
  108. ophyd_async/sim/_mirror_vertical.py +74 -0
  109. ophyd_async/sim/_motor.py +233 -0
  110. ophyd_async/sim/_pattern_generator.py +124 -0
  111. ophyd_async/sim/_point_detector.py +86 -0
  112. ophyd_async/sim/_stage.py +19 -0
  113. ophyd_async/tango/__init__.py +1 -0
  114. ophyd_async/tango/core/__init__.py +6 -1
  115. ophyd_async/tango/core/_base_device.py +41 -33
  116. ophyd_async/tango/core/_converters.py +81 -0
  117. ophyd_async/tango/core/_signal.py +18 -32
  118. ophyd_async/tango/core/_tango_readable.py +2 -19
  119. ophyd_async/tango/core/_tango_transport.py +136 -60
  120. ophyd_async/tango/core/_utils.py +47 -0
  121. ophyd_async/tango/{sim → demo}/_counter.py +2 -0
  122. ophyd_async/tango/{sim → demo}/_detector.py +2 -0
  123. ophyd_async/tango/{sim → demo}/_mover.py +5 -4
  124. ophyd_async/tango/{sim → demo}/_tango/_servers.py +4 -0
  125. ophyd_async/tango/testing/__init__.py +6 -0
  126. ophyd_async/tango/testing/_one_of_everything.py +200 -0
  127. ophyd_async/testing/__init__.py +29 -7
  128. ophyd_async/testing/_assert.py +145 -83
  129. ophyd_async/testing/_mock_signal_utils.py +56 -70
  130. ophyd_async/testing/_one_of_everything.py +41 -21
  131. ophyd_async/testing/_single_derived.py +89 -0
  132. ophyd_async/testing/_utils.py +3 -0
  133. {ophyd_async-0.9.0a2.dist-info → ophyd_async-0.10.0a2.dist-info}/METADATA +25 -26
  134. ophyd_async-0.10.0a2.dist-info/RECORD +149 -0
  135. {ophyd_async-0.9.0a2.dist-info → ophyd_async-0.10.0a2.dist-info}/WHEEL +1 -1
  136. ophyd_async/epics/sim/__init__.py +0 -54
  137. ophyd_async/epics/sim/_ioc.py +0 -29
  138. ophyd_async/epics/sim/_mover.py +0 -101
  139. ophyd_async/epics/sim/_sensor.py +0 -37
  140. ophyd_async/epics/sim/sensor.db +0 -19
  141. ophyd_async/sim/_pattern_detector/__init__.py +0 -13
  142. ophyd_async/sim/_pattern_detector/_pattern_detector.py +0 -42
  143. ophyd_async/sim/_pattern_detector/_pattern_detector_controller.py +0 -69
  144. ophyd_async/sim/_pattern_detector/_pattern_detector_writer.py +0 -41
  145. ophyd_async/sim/_pattern_detector/_pattern_generator.py +0 -214
  146. ophyd_async/sim/_sim_motor.py +0 -107
  147. ophyd_async-0.9.0a2.dist-info/RECORD +0 -129
  148. /ophyd_async/tango/{sim → demo}/__init__.py +0 -0
  149. /ophyd_async/tango/{sim → demo}/_tango/__init__.py +0 -0
  150. {ophyd_async-0.9.0a2.dist-info → ophyd_async-0.10.0a2.dist-info/licenses}/LICENSE +0 -0
  151. {ophyd_async-0.9.0a2.dist-info → ophyd_async-0.10.0a2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,43 @@
1
+ """Used for tutorial `Using Devices`."""
2
+
3
+ # Import bluesky and ophyd
4
+ from tempfile import mkdtemp
5
+
6
+ import bluesky.plan_stubs as bps # noqa: F401
7
+ import bluesky.plans as bp # noqa: F401
8
+ import bluesky.preprocessors as bpp # noqa: F401
9
+ from bluesky.callbacks.best_effort import BestEffortCallback
10
+ from bluesky.run_engine import RunEngine, autoawait_in_bluesky_event_loop
11
+
12
+ from ophyd_async import sim
13
+ from ophyd_async.core import StaticPathProvider, UUIDFilenameProvider, init_devices
14
+
15
+ # Create a run engine and make ipython use it for `await` commands
16
+ RE = RunEngine(call_returns_result=True)
17
+ autoawait_in_bluesky_event_loop()
18
+
19
+ # Add a callback for plotting
20
+ bec = BestEffortCallback()
21
+ RE.subscribe(bec)
22
+
23
+ # Make a pattern generator that uses the motor positions
24
+ # to make a test pattern. This simulates the real life process
25
+ # of X-ray scattering off a sample
26
+ pattern_generator = sim.PatternGenerator()
27
+
28
+ # Make a path provider that makes UUID filenames within a static
29
+ # temporary directory
30
+ path_provider = StaticPathProvider(UUIDFilenameProvider(), mkdtemp())
31
+
32
+ # All Devices created within this block will be
33
+ # connected and named at the end of the with block
34
+ with init_devices():
35
+ # Create a sample stage with X and Y motors that report their positions
36
+ # to the pattern generator
37
+ stage = sim.SimStage(pattern_generator)
38
+ # Make a detector device that gives the point value of the pattern generator
39
+ # when triggered
40
+ pdet = sim.SimPointDetector(pattern_generator)
41
+ # Make a detector device that gives a gaussian blob with intensity based
42
+ # on the point value of the pattern generator when triggered
43
+ bdet = sim.SimBlobDetector(path_provider, pattern_generator)
@@ -0,0 +1,33 @@
1
+ from collections.abc import Sequence
2
+
3
+ from ophyd_async.core import PathProvider, SignalR, StandardDetector
4
+
5
+ from ._blob_detector_controller import BlobDetectorController
6
+ from ._blob_detector_writer import BlobDetectorWriter
7
+ from ._pattern_generator import PatternGenerator
8
+
9
+
10
+ class SimBlobDetector(StandardDetector):
11
+ """Simulates a detector and writes Blobs to file."""
12
+
13
+ def __init__(
14
+ self,
15
+ path_provider: PathProvider,
16
+ pattern_generator: PatternGenerator | None = None,
17
+ config_sigs: Sequence[SignalR] = (),
18
+ name: str = "",
19
+ ) -> None:
20
+ self.pattern_generator = pattern_generator or PatternGenerator()
21
+
22
+ super().__init__(
23
+ controller=BlobDetectorController(
24
+ pattern_generator=self.pattern_generator,
25
+ ),
26
+ writer=BlobDetectorWriter(
27
+ pattern_generator=self.pattern_generator,
28
+ path_provider=path_provider,
29
+ name_provider=lambda: self.name,
30
+ ),
31
+ config_sigs=config_sigs,
32
+ name=name,
33
+ )
@@ -0,0 +1,48 @@
1
+ import asyncio
2
+ from contextlib import suppress
3
+
4
+ from ophyd_async.core import DetectorController, DetectorTrigger, TriggerInfo
5
+
6
+ from ._pattern_generator import PatternGenerator
7
+
8
+
9
+ class BlobDetectorController(DetectorController):
10
+ def __init__(self, pattern_generator: PatternGenerator):
11
+ self.pattern_generator = pattern_generator
12
+ self.trigger_info: TriggerInfo | None = None
13
+ self.task: asyncio.Task | None = None
14
+
15
+ def get_deadtime(self, exposure):
16
+ return 0.001
17
+
18
+ async def prepare(self, trigger_info: TriggerInfo):
19
+ # This is a simulation, so only support intenal triggering
20
+ if trigger_info.trigger != DetectorTrigger.INTERNAL:
21
+ raise RuntimeError(f"{trigger_info.trigger} not supported by {self}")
22
+ # Just hold onto the trigger info until we need it
23
+ self.trigger_info = trigger_info
24
+
25
+ async def arm(self):
26
+ if self.trigger_info is None:
27
+ raise RuntimeError(f"prepare() not called on {self}")
28
+ livetime = self.trigger_info.livetime or 0.1
29
+ # Start a background process off writing the images to file
30
+ coro = self.pattern_generator.write_images_to_file(
31
+ exposure=livetime,
32
+ period=livetime + self.trigger_info.deadtime,
33
+ number_of_frames=self.trigger_info.total_number_of_triggers,
34
+ )
35
+ self.task = asyncio.create_task(coro)
36
+
37
+ async def wait_for_idle(self):
38
+ # Wait for the background task to complete
39
+ if self.task:
40
+ await self.task
41
+
42
+ async def disarm(self):
43
+ # Stop the background task and wait for it to finish
44
+ if self.task:
45
+ self.task.cancel()
46
+ with suppress(asyncio.CancelledError):
47
+ await self.task
48
+ self.task = None
@@ -0,0 +1,105 @@
1
+ from collections.abc import AsyncGenerator, AsyncIterator
2
+ from pathlib import Path
3
+
4
+ import numpy as np
5
+ from bluesky.protocols import Hints, StreamAsset
6
+ from event_model import DataKey
7
+
8
+ from ophyd_async.core import (
9
+ DetectorWriter,
10
+ HDFDatasetDescription,
11
+ HDFDocumentComposer,
12
+ NameProvider,
13
+ PathProvider,
14
+ )
15
+
16
+ from ._pattern_generator import DATA_PATH, SUM_PATH, PatternGenerator
17
+
18
+ WIDTH = 320
19
+ HEIGHT = 240
20
+
21
+
22
+ class BlobDetectorWriter(DetectorWriter):
23
+ def __init__(
24
+ self,
25
+ pattern_generator: PatternGenerator,
26
+ path_provider: PathProvider,
27
+ name_provider: NameProvider,
28
+ ) -> None:
29
+ self.pattern_generator = pattern_generator
30
+ self.path_provider = path_provider
31
+ self.name_provider = name_provider
32
+ self.path: Path | None = None
33
+ self.composer: HDFDocumentComposer | None = None
34
+ self.datasets: list[HDFDatasetDescription] = []
35
+
36
+ async def open(self, multiplier: int = 1) -> dict[str, DataKey]:
37
+ name = self.name_provider()
38
+ path_info = self.path_provider(name)
39
+ self.path = path_info.directory_path / f"{path_info.filename}.h5"
40
+ self.pattern_generator.open_file(self.path, WIDTH, HEIGHT)
41
+ # We know it will write data and sum, so emit those
42
+ self.datasets = [
43
+ HDFDatasetDescription(
44
+ data_key=name,
45
+ dataset=DATA_PATH,
46
+ shape=(HEIGHT, WIDTH),
47
+ dtype_numpy=np.dtype(np.uint8).str,
48
+ chunk_shape=(HEIGHT, WIDTH),
49
+ multiplier=multiplier,
50
+ ),
51
+ HDFDatasetDescription(
52
+ data_key=f"{name}-sum",
53
+ dataset=SUM_PATH,
54
+ shape=(),
55
+ dtype_numpy=np.dtype(np.int64).str,
56
+ multiplier=multiplier,
57
+ chunk_shape=(1024,),
58
+ ),
59
+ ]
60
+ self.composer = None
61
+ outer_shape = (multiplier,) if multiplier > 1 else ()
62
+ describe = {
63
+ ds.data_key: DataKey(
64
+ source="sim://pattern-generator-hdf-file",
65
+ shape=list(outer_shape) + list(ds.shape),
66
+ dtype="array" if ds.shape else "number",
67
+ external="STREAM:",
68
+ )
69
+ for ds in self.datasets
70
+ }
71
+ return describe
72
+
73
+ @property
74
+ def hints(self) -> Hints:
75
+ """The hints to be used for the detector."""
76
+ return {"fields": [self.name_provider()]}
77
+
78
+ async def get_indices_written(self) -> int:
79
+ return self.pattern_generator.get_last_index()
80
+
81
+ async def observe_indices_written(
82
+ self, timeout: float
83
+ ) -> AsyncGenerator[int, None]:
84
+ while True:
85
+ yield self.pattern_generator.get_last_index()
86
+ await self.pattern_generator.wait_for_next_index(timeout)
87
+
88
+ async def collect_stream_docs(
89
+ self, indices_written: int
90
+ ) -> AsyncIterator[StreamAsset]:
91
+ # When we have written something to the file
92
+ if indices_written:
93
+ # Only emit stream resource the first time we see frames in
94
+ # the file
95
+ if not self.composer:
96
+ if not self.path:
97
+ raise RuntimeError(f"open() not called on {self}")
98
+ self.composer = HDFDocumentComposer(self.path, self.datasets)
99
+ for doc in self.composer.stream_resources():
100
+ yield "stream_resource", doc
101
+ for doc in self.composer.stream_data(indices_written):
102
+ yield "stream_datum", doc
103
+
104
+ async def close(self) -> None:
105
+ self.pattern_generator.close_file()
@@ -0,0 +1,46 @@
1
+ import asyncio
2
+ from typing import TypedDict
3
+
4
+ from bluesky.protocols import Movable
5
+
6
+ from ophyd_async.core import AsyncStatus, DerivedSignalFactory, Device, soft_signal_rw
7
+
8
+ from ._mirror_vertical import TwoJackDerived, TwoJackTransform
9
+ from ._motor import SimMotor
10
+
11
+
12
+ class HorizontalMirrorDerived(TypedDict):
13
+ x: float
14
+ roll: float
15
+
16
+
17
+ class HorizontalMirror(Device, Movable):
18
+ def __init__(self, name=""):
19
+ # Raw signals
20
+ self.x1 = SimMotor()
21
+ self.x2 = SimMotor()
22
+ # Parameter
23
+ self.x1_x2_distance = soft_signal_rw(float, initial_value=1)
24
+ # Derived signals
25
+ self._factory = DerivedSignalFactory(
26
+ TwoJackTransform,
27
+ self._set_mirror,
28
+ jack1=self.x1,
29
+ jack2=self.x2,
30
+ distance=self.x1_x2_distance,
31
+ )
32
+ self.x = self._factory.derived_signal_rw(float, "height")
33
+ self.roll = self._factory.derived_signal_rw(float, "angle")
34
+ super().__init__(name=name)
35
+
36
+ async def _set_mirror(self, derived: TwoJackDerived) -> None:
37
+ transform = await self._factory.transform()
38
+ raw = transform.derived_to_raw(**derived)
39
+ await asyncio.gather(
40
+ self.x1.set(raw["jack1"]),
41
+ self.x2.set(raw["jack2"]),
42
+ )
43
+
44
+ @AsyncStatus.wrap
45
+ async def set(self, value: HorizontalMirrorDerived) -> None:
46
+ await self._set_mirror(TwoJackDerived(height=value["x"], angle=value["roll"]))
@@ -0,0 +1,74 @@
1
+ import asyncio
2
+ import math
3
+ from typing import TypedDict
4
+
5
+ from bluesky.protocols import Movable
6
+
7
+ from ophyd_async.core import (
8
+ AsyncStatus,
9
+ DerivedSignalFactory,
10
+ Device,
11
+ Transform,
12
+ soft_signal_rw,
13
+ )
14
+
15
+ from ._motor import SimMotor
16
+
17
+
18
+ class TwoJackRaw(TypedDict):
19
+ jack1: float
20
+ jack2: float
21
+
22
+
23
+ class TwoJackDerived(TypedDict):
24
+ height: float
25
+ angle: float
26
+
27
+
28
+ class TwoJackTransform(Transform):
29
+ distance: float
30
+
31
+ def raw_to_derived(self, *, jack1: float, jack2: float) -> TwoJackDerived:
32
+ diff = jack2 - jack1
33
+ return TwoJackDerived(
34
+ height=jack1 + diff / 2,
35
+ # need the cast as returns numpy float rather than float64, but this
36
+ # is ok at runtime
37
+ angle=math.atan(diff / self.distance),
38
+ )
39
+
40
+ def derived_to_raw(self, *, height: float, angle: float) -> TwoJackRaw:
41
+ diff = math.tan(angle) * self.distance
42
+ return TwoJackRaw(
43
+ jack1=height - diff / 2,
44
+ jack2=height + diff / 2,
45
+ )
46
+
47
+
48
+ class VerticalMirror(Device, Movable[TwoJackDerived]):
49
+ def __init__(self, name=""):
50
+ # Raw signals
51
+ self.y1 = SimMotor()
52
+ self.y2 = SimMotor()
53
+ # Parameter
54
+ self.y1_y2_distance = soft_signal_rw(float, initial_value=1)
55
+ # Derived signals
56
+ self._factory = DerivedSignalFactory(
57
+ TwoJackTransform,
58
+ self.set,
59
+ jack1=self.y1,
60
+ jack2=self.y2,
61
+ distance=self.y1_y2_distance,
62
+ )
63
+ self.height = self._factory.derived_signal_rw(float, "height")
64
+ self.angle = self._factory.derived_signal_rw(float, "angle")
65
+ super().__init__(name=name)
66
+
67
+ @AsyncStatus.wrap
68
+ async def set(self, derived: TwoJackDerived) -> None: # type: ignore until bluesky 1.13.2
69
+ transform = await self._factory.transform()
70
+ raw = transform.derived_to_raw(**derived)
71
+ await asyncio.gather(
72
+ self.y1.set(raw["jack1"]),
73
+ self.y2.set(raw["jack2"]),
74
+ )
@@ -0,0 +1,233 @@
1
+ import asyncio
2
+ import contextlib
3
+ import time
4
+
5
+ import numpy as np
6
+ from bluesky.protocols import Location, Reading, Stoppable, Subscribable
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+ from ophyd_async.core import (
10
+ AsyncStatus,
11
+ Callback,
12
+ StandardReadable,
13
+ WatchableAsyncStatus,
14
+ WatcherUpdate,
15
+ observe_value,
16
+ soft_signal_r_and_setter,
17
+ soft_signal_rw,
18
+ )
19
+ from ophyd_async.core import StandardReadableFormat as Format
20
+
21
+
22
+ class FlySimMotorInfo(BaseModel):
23
+ """Minimal set of information required to fly a [](#SimMotor)."""
24
+
25
+ model_config = ConfigDict(frozen=True)
26
+
27
+ cv_start: float
28
+ """Absolute position of the motor once it finishes accelerating to desired
29
+ velocity, in motor EGUs"""
30
+
31
+ cv_end: float
32
+ """Absolute position of the motor once it begins decelerating from desired
33
+ velocity, in EGUs"""
34
+
35
+ cv_time: float = Field(gt=0)
36
+ """Time taken for the motor to get from start_position to end_position, excluding
37
+ run-up and run-down, in seconds."""
38
+
39
+ @property
40
+ def velocity(self) -> float:
41
+ """Calculate the velocity of the constant velocity phase."""
42
+ return (self.cv_end - self.cv_start) / self.cv_time
43
+
44
+ def start_position(self, acceleration_time: float) -> float:
45
+ """Calculate the start position with run-up distance added on."""
46
+ return self.cv_start - acceleration_time * self.velocity / 2
47
+
48
+ def end_position(self, acceleration_time: float) -> float:
49
+ """Calculate the end position with run-down distance added on."""
50
+ return self.cv_end + acceleration_time * self.velocity / 2
51
+
52
+
53
+ class SimMotor(StandardReadable, Stoppable, Subscribable[float]):
54
+ """For usage when simulating a motor."""
55
+
56
+ def __init__(self, name="", instant=True) -> None:
57
+ """Simulate a motor, with optional velocity.
58
+
59
+ :param name: name of device
60
+ :param instant: whether to move instantly or calculate move time using velocity
61
+ """
62
+ # Define some signals
63
+ with self.add_children_as_readables(Format.HINTED_SIGNAL):
64
+ self.user_readback, self._user_readback_set = soft_signal_r_and_setter(
65
+ float, 0
66
+ )
67
+ with self.add_children_as_readables(Format.CONFIG_SIGNAL):
68
+ self.velocity = soft_signal_rw(float, 0 if instant else 1.0)
69
+ self.acceleration_time = soft_signal_rw(float, 0.5)
70
+ self.units = soft_signal_rw(str, "mm")
71
+ self.user_setpoint = soft_signal_rw(float, 0)
72
+
73
+ # Whether set() should complete successfully or not
74
+ self._set_success = True
75
+ self._move_status: AsyncStatus | None = None
76
+ # Stored in prepare
77
+ self._fly_info: FlySimMotorInfo | None = None
78
+ # Set on kickoff(), complete when motor reaches end position
79
+ self._fly_status: WatchableAsyncStatus | None = None
80
+
81
+ super().__init__(name=name)
82
+
83
+ def set_name(self, name: str, *, child_name_separator: str | None = None) -> None:
84
+ super().set_name(name, child_name_separator=child_name_separator)
85
+ # Readback should be named the same as its parent in read()
86
+ self.user_readback.set_name(name)
87
+
88
+ @AsyncStatus.wrap
89
+ async def prepare(self, value: FlySimMotorInfo):
90
+ """Calculate run-up and move there, setting fly velocity when there."""
91
+ self._fly_info = value
92
+ # Move to start as fast as we can
93
+ await self.velocity.set(0)
94
+ await self.set(value.start_position(await self.acceleration_time.get_value()))
95
+ # Set the velocity for the actual move
96
+ await self.velocity.set(value.velocity)
97
+
98
+ async def locate(self) -> Location[float]:
99
+ """Return the current setpoint and readback of the motor."""
100
+ setpoint, readback = await asyncio.gather(
101
+ self.user_setpoint.get_value(), self.user_readback.get_value()
102
+ )
103
+ return Location(setpoint=setpoint, readback=readback)
104
+
105
+ def subscribe(self, function: Callback[dict[str, Reading[float]]]) -> None:
106
+ self.user_readback.subscribe(function)
107
+
108
+ def clear_sub(self, function: Callback[dict[str, Reading[float]]]) -> None:
109
+ self.user_readback.clear_sub(function)
110
+
111
+ @AsyncStatus.wrap
112
+ async def kickoff(self):
113
+ """Begin moving motor from prepared position to final position."""
114
+ if not self._fly_info:
115
+ msg = "Motor must be prepared before attempting to kickoff"
116
+ raise RuntimeError(msg)
117
+ acceleration_time = await self.acceleration_time.get_value()
118
+ self._fly_status = self.set(self._fly_info.end_position(acceleration_time))
119
+ # Wait for the acceleration time to ensure we are at velocity
120
+ await asyncio.sleep(acceleration_time)
121
+
122
+ def complete(self) -> WatchableAsyncStatus:
123
+ """Mark as complete once motor reaches completed position."""
124
+ if not self._fly_status:
125
+ msg = "kickoff not called"
126
+ raise RuntimeError(msg)
127
+ return self._fly_status
128
+
129
+ async def _move(self, old_position: float, new_position: float, velocity: float):
130
+ if old_position == new_position:
131
+ return
132
+ start = time.monotonic()
133
+ acceleration_time = abs(await self.acceleration_time.get_value())
134
+ sign = np.sign(new_position - old_position)
135
+ velocity = abs(velocity) * sign
136
+ # The total distance to move
137
+ total_distance = new_position - old_position
138
+ # The ramp distance is the distance taken to ramp up (the same distance
139
+ # is taken to ramp down). This is the area under the triangle of the
140
+ # velocity ramp up (base * height / 2)
141
+ ramp_distance = acceleration_time * velocity / 2
142
+ if abs(ramp_distance * 2) >= abs(total_distance):
143
+ # All time is ramp up and down, so recalculate the maximum velocity
144
+ # we get to. We know the area under the ramp up triangle is half the
145
+ # total distance, and we also know the ratio of velocity over
146
+ # acceleration_time is the same as the ration of max_velocity over
147
+ # ramp_time, so solve the simultaneous equations to get
148
+ # max_velocity and ramp_time.
149
+ max_velocity = np.sqrt(total_distance * velocity / acceleration_time) * sign
150
+ ramp_time = total_distance / max_velocity
151
+ # So move time is just the ramp up and ramp down with no constant
152
+ # velocity section
153
+ move_time = 2 * ramp_time
154
+ else:
155
+ # Middle segments of constant velocity
156
+ max_velocity = velocity
157
+ # Ramp up and down time is exactly the requested acceleration time
158
+ ramp_time = acceleration_time
159
+ # So move time is twice this, plus the time taken to move the
160
+ # remaining distance at constant velocity
161
+ move_time = ramp_time * 2 + (total_distance - ramp_distance * 2) / velocity
162
+ # Make an array of relative update times at 10Hz intervals
163
+ update_times = list(np.arange(0.1, move_time, 0.1, dtype=float))
164
+ # With the end position appended
165
+ if update_times and np.isclose(update_times[-1], move_time):
166
+ update_times[-1] = move_time
167
+ else:
168
+ update_times.append(move_time)
169
+ # Iterate through the update times, calculating new position for each
170
+ for t in update_times:
171
+ if t <= ramp_time:
172
+ # Ramp up phase, calculate area under the ramp up triangle
173
+ current_velocity = t / ramp_time * max_velocity
174
+ position = old_position + current_velocity * t / 2
175
+ elif t >= move_time - ramp_time:
176
+ # Ramp down phase, subtract area under the ramp down triangle
177
+ time_left = move_time - t
178
+ current_velocity = time_left / ramp_time * max_velocity
179
+ position = new_position - current_velocity * time_left / 2
180
+ else:
181
+ # Constant velocity phase
182
+ position = old_position + ramp_distance + (t - ramp_time) * max_velocity
183
+ # Calculate how long to wait to get there
184
+ relative_time = time.monotonic() - start
185
+ await asyncio.sleep(t - relative_time)
186
+ # Update the readback position
187
+ self._user_readback_set(position)
188
+
189
+ @WatchableAsyncStatus.wrap
190
+ async def set(self, value: float):
191
+ """Asynchronously move the motor to a new position."""
192
+ new_position = value
193
+ # Make sure any existing move tasks are stopped
194
+ if self._move_status:
195
+ self._move_status.task.cancel()
196
+ self._move_status = None
197
+ # work out where we were
198
+ old_position, units, velocity = await asyncio.gather(
199
+ self.user_setpoint.get_value(),
200
+ self.units.get_value(),
201
+ self.velocity.get_value(),
202
+ )
203
+ # update the setpoint to where we want to be
204
+ await self.user_setpoint.set(new_position)
205
+ # If zero velocity, do instant move
206
+ if velocity == 0:
207
+ self._user_readback_set(new_position)
208
+ else:
209
+ self._move_status = AsyncStatus(
210
+ self._move(old_position, new_position, velocity)
211
+ )
212
+ # If stop is called then this will raise a CancelledError, ignore it
213
+ with contextlib.suppress(asyncio.CancelledError):
214
+ async for current_position in observe_value(
215
+ self.user_readback, done_status=self._move_status
216
+ ):
217
+ yield WatcherUpdate(
218
+ current=current_position,
219
+ initial=old_position,
220
+ target=new_position,
221
+ name=self.name,
222
+ unit=units,
223
+ )
224
+ if not self._set_success:
225
+ raise RuntimeError("Motor was stopped")
226
+
227
+ async def stop(self, success=True):
228
+ """Stop the motor if it is moving."""
229
+ self._set_success = success
230
+ if self._move_status:
231
+ self._move_status.task.cancel()
232
+ self._move_status = None
233
+ await self.user_setpoint.set(await self.user_readback.get_value())