ophyd-async 0.13.2__py3-none-any.whl → 0.13.4__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.
ophyd_async/_version.py CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.13.2'
32
- __version_tuple__ = version_tuple = (0, 13, 2)
31
+ __version__ = version = '0.13.4'
32
+ __version_tuple__ = version_tuple = (0, 13, 4)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -78,7 +78,7 @@ from ._signal_backend import (
78
78
  )
79
79
  from ._soft_signal_backend import SoftSignalBackend
80
80
  from ._status import AsyncStatus, WatchableAsyncStatus, completed_status
81
- from ._table import Table
81
+ from ._table import Table, TableSubclass
82
82
  from ._utils import (
83
83
  CALCULATE_TIMEOUT,
84
84
  DEFAULT_TIMEOUT,
@@ -221,4 +221,5 @@ __all__ = [
221
221
  "EnableDisable",
222
222
  "InOut",
223
223
  "OnOff",
224
+ "TableSubclass",
224
225
  ]
@@ -13,6 +13,7 @@ TableSubclass = TypeVar("TableSubclass", bound="Table")
13
13
 
14
14
 
15
15
  def _concat(value1, value2):
16
+ """Concatenate two values, supports both NumPy arrays and generic types."""
16
17
  if isinstance(value1, np.ndarray):
17
18
  return np.concatenate((value1, value2))
18
19
  else:
@@ -20,6 +21,8 @@ def _concat(value1, value2):
20
21
 
21
22
 
22
23
  def _make_default_factory(dtype: np.dtype) -> Callable[[], np.ndarray]:
24
+ """Creates a default factory, returns an empty Numpy array of a specified dtype."""
25
+
23
26
  def numpy_array_default_factory() -> np.ndarray:
24
27
  return np.array([], dtype)
25
28
 
@@ -91,6 +94,11 @@ class Table(ConfinedModel):
91
94
  raise TypeError(f"Cannot use annotation {anno} in a Table")
92
95
  cls.__annotations__[k] = new_anno
93
96
 
97
+ @classmethod
98
+ def empty(cls: type[TableSubclass]) -> TableSubclass:
99
+ """Makes an empty table with zero length columns."""
100
+ return cls() # type: ignore
101
+
94
102
  def __add__(self, right: TableSubclass) -> TableSubclass:
95
103
  """Concatenate the arrays in field values."""
96
104
  cls = type(right)
@@ -34,9 +34,11 @@ class ADBaseController(DetectorController, Generic[ADBaseIOT]):
34
34
  self,
35
35
  driver: ADBaseIOT,
36
36
  good_states: frozenset[ADState] = DEFAULT_GOOD_STATES,
37
+ image_mode: ADImageMode = ADImageMode.MULTIPLE,
37
38
  ) -> None:
38
39
  self.driver: ADBaseIOT = driver
39
40
  self.good_states = good_states
41
+ self.image_mode = image_mode
40
42
  self.frame_timeout = DEFAULT_TIMEOUT
41
43
  self._arm_status: AsyncStatus | None = None
42
44
 
@@ -52,7 +54,7 @@ class ADBaseController(DetectorController, Generic[ADBaseIOT]):
52
54
  )
53
55
  await asyncio.gather(
54
56
  self.driver.num_images.set(trigger_info.total_number_of_exposures),
55
- self.driver.image_mode.set(ADImageMode.MULTIPLE),
57
+ self.driver.image_mode.set(self.image_mode),
56
58
  )
57
59
 
58
60
  async def arm(self):
@@ -107,18 +107,27 @@ class Motor(
107
107
  self.user_readback.set_name(name)
108
108
 
109
109
  async def check_motor_limit(self, abs_start_pos: float, abs_end_pos: float):
110
- """Check the motor limit with the absolute positions."""
110
+ """Check the positions are within limits.
111
+
112
+ Will raise a MotorLimitsException if the given absolute positions will be
113
+ outside the motor soft limits.
114
+ """
111
115
  motor_lower_limit, motor_upper_limit, egu = await asyncio.gather(
112
116
  self.low_limit_travel.get_value(),
113
117
  self.high_limit_travel.get_value(),
114
118
  self.motor_egu.get_value(),
115
119
  )
120
+
121
+ # EPICS motor record treats limits of 0, 0 as no limit
122
+ if motor_lower_limit == 0 and motor_upper_limit == 0:
123
+ return
124
+
116
125
  if (
117
126
  not motor_upper_limit >= abs_start_pos >= motor_lower_limit
118
127
  or not motor_upper_limit >= abs_end_pos >= motor_lower_limit
119
128
  ):
120
129
  raise MotorLimitsException(
121
- f"Motor trajectory for requested fly/move is from "
130
+ f"{self.name} motor trajectory for requested fly/move is from "
122
131
  f"{abs_start_pos}{egu} to "
123
132
  f"{abs_end_pos}{egu} but motor limits are "
124
133
  f"{motor_lower_limit}{egu} <= x <= {motor_upper_limit}{egu} "
@@ -4,7 +4,7 @@ import numpy as np
4
4
 
5
5
  from ophyd_async.core import Array1D, Device, DeviceVector, StandardReadable
6
6
  from ophyd_async.epics import motor
7
- from ophyd_async.epics.core import epics_signal_r, epics_signal_rw
7
+ from ophyd_async.epics.core import epics_signal_r, epics_signal_rw, epics_signal_x
8
8
 
9
9
  CS_LETTERS = "ABCUVWXYZ"
10
10
 
@@ -40,11 +40,15 @@ class PmacTrajectoryIO(StandardReadable):
40
40
  for i, letter in enumerate(CS_LETTERS)
41
41
  }
42
42
  )
43
+ self.total_points = epics_signal_r(int, f"{prefix}TotalPoints_RBV")
43
44
  self.points_to_build = epics_signal_rw(int, prefix + "ProfilePointsToBuild")
44
- self.build_profile = epics_signal_rw(bool, prefix + "ProfileBuild")
45
+ self.build_profile = epics_signal_x(prefix + "ProfileBuild")
46
+ self.append_profile = epics_signal_x(prefix + "ProfileAppend")
47
+ # This should be a SignalX, but because it is a Busy record, must
48
+ # be a SignalRW to be waited on in PmacTrajectoryTriggerLogic.
49
+ # TODO: Change record type to bo from busy (https://github.com/DiamondLightSource/pmac/issues/154)
45
50
  self.execute_profile = epics_signal_rw(bool, prefix + "ProfileExecute")
46
- self.scan_percent = epics_signal_r(float, prefix + "TscanPercent_RBV")
47
- self.abort_profile = epics_signal_rw(bool, prefix + "ProfileAbort")
51
+ self.abort_profile = epics_signal_x(prefix + "ProfileAbort")
48
52
  self.profile_cs_name = epics_signal_rw(str, prefix + "ProfileCsName")
49
53
  self.calculate_velocities = epics_signal_rw(bool, prefix + "ProfileCalcVel")
50
54
 
@@ -1,100 +1,201 @@
1
1
  import asyncio
2
+ from dataclasses import dataclass
2
3
 
3
4
  import numpy as np
4
- from scanspec.core import Path
5
+ from scanspec.core import Path, Slice
5
6
  from scanspec.specs import Spec
6
7
 
7
8
  from ophyd_async.core import (
8
9
  DEFAULT_TIMEOUT,
10
+ AsyncStatus,
9
11
  FlyerController,
12
+ error_if_none,
13
+ observe_value,
10
14
  set_and_wait_for_value,
11
15
  wait_for_value,
12
16
  )
13
17
  from ophyd_async.epics.motor import Motor
14
18
  from ophyd_async.epics.pmac import PmacIO
15
19
  from ophyd_async.epics.pmac._pmac_io import CS_LETTERS
20
+ from ophyd_async.epics.pmac._pmac_trajectory_generation import PVT, Trajectory
16
21
  from ophyd_async.epics.pmac._utils import (
17
22
  _PmacMotorInfo,
18
- _Trajectory,
19
23
  calculate_ramp_position_and_duration,
20
24
  )
21
25
 
26
+ # PMAC durations are in milliseconds
27
+ # We must convert from scanspec durations (seconds) to milliseconds
28
+ # PMAC motion program multiples durations by 0.001
29
+ # (see https://github.com/DiamondLightSource/pmac/blob/afe81f8bb9179c3a20eff351f30bc6cfd1539ad9/pmacApp/pmc/trajectory_scan_code_ppmac.pmc#L241)
30
+ # Therefore, we must divide scanspec durations by 10e-6
31
+ TICK_S = 0.000001
32
+ SLICE_SIZE = 4000
33
+
34
+
35
+ @dataclass
36
+ class PmacPrepareContext:
37
+ path: Path
38
+ motor_info: _PmacMotorInfo
39
+ ramp_up_time: float
40
+
22
41
 
23
42
  class PmacTrajectoryTriggerLogic(FlyerController):
24
43
  def __init__(self, pmac: PmacIO) -> None:
25
44
  self.pmac = pmac
26
- self.scantime: float | None = None
45
+ self._next_pvt: PVT | None
46
+ self._loaded: int = 0
47
+ self._trajectory_status: AsyncStatus | None = None
48
+ self._prepare_context: PmacPrepareContext | None = None
27
49
 
28
50
  async def prepare(self, value: Spec[Motor]):
29
- slice = Path(value.calculate()).consume()
30
- motor_info = await _PmacMotorInfo.from_motors(self.pmac, slice.axes())
51
+ path = Path(value.calculate())
52
+ slice = path.consume(SLICE_SIZE)
53
+ path_length = len(path)
54
+ motors = slice.axes()
55
+ motor_info = await _PmacMotorInfo.from_motors(self.pmac, motors)
31
56
  ramp_up_pos, ramp_up_time = calculate_ramp_position_and_duration(
32
57
  slice, motor_info, True
33
58
  )
34
- ramp_down_pos, ramp_down_time = calculate_ramp_position_and_duration(
35
- slice, motor_info, False
59
+ self._prepare_context = PmacPrepareContext(
60
+ path=path, motor_info=motor_info, ramp_up_time=ramp_up_time
36
61
  )
37
- trajectory = _Trajectory.from_slice(slice, ramp_up_time, motor_info)
38
62
  await asyncio.gather(
39
- self._build_trajectory(
40
- trajectory, motor_info, ramp_down_pos, ramp_down_time
41
- ),
63
+ self._build_trajectory(motor_info, slice, path_length, ramp_up_time),
42
64
  self._move_to_start(motor_info, ramp_up_pos),
43
65
  )
44
66
 
45
67
  async def kickoff(self):
46
- if not self.scantime:
47
- raise RuntimeError("Cannot kickoff. Must call prepare first.")
48
- self.status = await self.pmac.trajectory.execute_profile.set(
49
- True, timeout=self.scantime + 1
68
+ prepare_context = error_if_none(
69
+ self._prepare_context, "Cannot kickoff. Must call prepare first."
70
+ )
71
+ self._prepare_context = None
72
+ self._trajectory_status = self._execute_trajectory(
73
+ prepare_context.path, prepare_context.motor_info
74
+ )
75
+ # Wait for the ramp up to happen
76
+ await wait_for_value(
77
+ self.pmac.trajectory.total_points,
78
+ lambda v: v >= 1,
79
+ prepare_context.ramp_up_time + DEFAULT_TIMEOUT,
50
80
  )
51
81
 
52
82
  async def complete(self):
53
- if not self.scantime:
54
- raise RuntimeError("Cannot complete. Must call prepare first.")
55
- await wait_for_value(
56
- self.pmac.trajectory.execute_profile,
57
- False,
58
- timeout=self.scantime + DEFAULT_TIMEOUT,
83
+ trajectory_status = error_if_none(
84
+ self._trajectory_status, "Cannot complete. Must call kickoff first."
59
85
  )
86
+ await trajectory_status
87
+ # Reset trajectory status and number of loaded points
88
+ self._trajectory_status = None
89
+ self._loaded = 0
60
90
 
61
91
  async def stop(self):
62
- await self.pmac.trajectory.abort_profile.set(True)
92
+ await self.pmac.trajectory.abort_profile.trigger()
93
+
94
+ @AsyncStatus.wrap
95
+ async def _execute_trajectory(self, path: Path, motor_info: _PmacMotorInfo):
96
+ execute_status = self.pmac.trajectory.execute_profile.set(
97
+ True, wait=True, timeout=None
98
+ )
99
+ # We consume SLICE_SIZE from self.path and parse a trajectory
100
+ # containing at least 2 * SLICE_SIZE, as a gapless trajectory
101
+ # will contain 2 points per slice frame. If gaps are present,
102
+ # additional points are inserted, overfilling the buffer.
103
+ min_buffer_size = SLICE_SIZE * 2
104
+ async for current_point in observe_value(
105
+ self.pmac.trajectory.total_points,
106
+ done_status=execute_status,
107
+ timeout=DEFAULT_TIMEOUT,
108
+ ):
109
+ # Ensure we maintain a minimum buffer size, if we have more points to append
110
+ if len(path) != 0 and self._loaded - current_point < min_buffer_size:
111
+ # We have less than SLICE_SIZE * 2 points in the buffer, so refill
112
+ next_slice = path.consume(SLICE_SIZE)
113
+ path_length = len(path)
114
+ await self._append_trajectory(next_slice, path_length, motor_info)
115
+
116
+ async def _append_trajectory(
117
+ self, slice: Slice, path_length: int, motor_info: _PmacMotorInfo
118
+ ):
119
+ trajectory = await self._parse_trajectory(slice, path_length, motor_info)
120
+ await self._set_trajectory_arrays(trajectory, motor_info)
121
+ await self.pmac.trajectory.append_profile.trigger()
63
122
 
64
123
  async def _build_trajectory(
65
124
  self,
66
- trajectory: _Trajectory,
67
125
  motor_info: _PmacMotorInfo,
68
- ramp_down_pos: dict[Motor, np.float64],
69
- ramp_down_time: float,
126
+ slice: Slice,
127
+ path_length: int,
128
+ ramp_up_time: float,
70
129
  ):
71
- trajectory = trajectory.append_ramp_down(ramp_down_pos, ramp_down_time, 0)
72
- self.scantime = np.sum(trajectory.durations)
73
- use_axis = {axis + 1: False for axis in range(len(CS_LETTERS))}
74
-
75
- size = 0
76
- for motor, number in motor_info.motor_cs_index.items():
77
- use_axis[number + 1] = True
78
- await self.pmac.trajectory.positions[number + 1].set(
79
- trajectory.positions[motor]
80
- )
81
- await self.pmac.trajectory.velocities[number + 1].set(
82
- trajectory.velocities[motor]
83
- )
84
- size += len(trajectory.positions[motor])
130
+ trajectory = await self._parse_trajectory(
131
+ slice, path_length, motor_info, ramp_up_time
132
+ )
133
+ use_axis = {
134
+ axis + 1: (axis in motor_info.motor_cs_index.values())
135
+ for axis in range(len(CS_LETTERS))
136
+ }
85
137
 
86
138
  coros = [
87
139
  self.pmac.trajectory.profile_cs_name.set(motor_info.cs_port),
88
- self.pmac.trajectory.time_array.set(trajectory.durations),
89
- self.pmac.trajectory.user_array.set(trajectory.user_programs),
90
- self.pmac.trajectory.points_to_build.set(size),
91
140
  self.pmac.trajectory.calculate_velocities.set(False),
141
+ self._set_trajectory_arrays(trajectory, motor_info),
92
142
  ] + [
93
143
  self.pmac.trajectory.use_axis[number].set(use)
94
144
  for number, use in use_axis.items()
95
145
  ]
146
+
147
+ await asyncio.gather(*coros)
148
+ await self.pmac.trajectory.build_profile.trigger()
149
+
150
+ async def _parse_trajectory(
151
+ self,
152
+ slice: Slice,
153
+ path_length: int,
154
+ motor_info: _PmacMotorInfo,
155
+ ramp_up_time: float | None = None,
156
+ ) -> Trajectory:
157
+ trajectory, exit_pvt = Trajectory.from_slice(
158
+ slice,
159
+ motor_info,
160
+ None if ramp_up_time else self._next_pvt,
161
+ ramp_up_time=ramp_up_time,
162
+ )
163
+
164
+ if path_length == 0:
165
+ ramp_down_pos, ramp_down_time = calculate_ramp_position_and_duration(
166
+ slice, motor_info, False
167
+ )
168
+ trajectory = trajectory.with_ramp_down(
169
+ exit_pvt, ramp_down_pos, ramp_down_time, 0
170
+ )
171
+ self._next_pvt = exit_pvt
172
+ self._loaded += len(trajectory)
173
+
174
+ return trajectory
175
+
176
+ async def _set_trajectory_arrays(
177
+ self, trajectory: Trajectory, motor_info: _PmacMotorInfo
178
+ ):
179
+ coros = []
180
+ for motor, number in motor_info.motor_cs_index.items():
181
+ coros.append(
182
+ self.pmac.trajectory.positions[number + 1].set(
183
+ trajectory.positions[motor]
184
+ )
185
+ )
186
+ coros.append(
187
+ self.pmac.trajectory.velocities[number + 1].set(
188
+ trajectory.velocities[motor]
189
+ )
190
+ )
191
+ coros.extend(
192
+ [
193
+ self.pmac.trajectory.time_array.set(trajectory.durations / TICK_S),
194
+ self.pmac.trajectory.user_array.set(trajectory.user_programs),
195
+ self.pmac.trajectory.points_to_build.set(len(trajectory.durations)),
196
+ ]
197
+ )
96
198
  await asyncio.gather(*coros)
97
- await self.pmac.trajectory.build_profile.set(True)
98
199
 
99
200
  async def _move_to_start(
100
201
  self, motor_info: _PmacMotorInfo, ramp_up_position: dict[Motor, np.float64]