ophyd-async 0.13.0__py3-none-any.whl → 0.13.2__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 (43) hide show
  1. ophyd_async/_version.py +16 -3
  2. ophyd_async/core/__init__.py +2 -1
  3. ophyd_async/core/_detector.py +1 -1
  4. ophyd_async/core/_signal.py +16 -9
  5. ophyd_async/core/_utils.py +5 -4
  6. ophyd_async/epics/adandor/_andor.py +1 -2
  7. ophyd_async/epics/adcore/_core_detector.py +1 -2
  8. ophyd_async/epics/adcore/_core_io.py +1 -1
  9. ophyd_async/epics/adcore/_core_logic.py +2 -2
  10. ophyd_async/epics/adcore/_core_writer.py +9 -6
  11. ophyd_async/epics/adcore/_hdf_writer.py +6 -1
  12. ophyd_async/epics/adkinetix/_kinetix_io.py +4 -4
  13. ophyd_async/epics/adpilatus/_pilatus.py +2 -6
  14. ophyd_async/epics/advimba/_vimba_io.py +1 -1
  15. ophyd_async/epics/core/_epics_connector.py +14 -1
  16. ophyd_async/epics/core/_p4p.py +2 -3
  17. ophyd_async/epics/core/_pvi_connector.py +1 -1
  18. ophyd_async/epics/motor.py +21 -16
  19. ophyd_async/epics/{eiger → odin}/_odin_io.py +5 -3
  20. ophyd_async/epics/pmac/__init__.py +2 -0
  21. ophyd_async/epics/pmac/_pmac_io.py +2 -2
  22. ophyd_async/epics/pmac/_pmac_trajectory.py +116 -0
  23. ophyd_async/epics/pmac/_utils.py +671 -55
  24. ophyd_async/epics/testing/_example_ioc.py +1 -2
  25. ophyd_async/fastcs/eiger/_eiger.py +1 -1
  26. ophyd_async/fastcs/jungfrau/__init__.py +29 -0
  27. ophyd_async/fastcs/jungfrau/_controller.py +139 -0
  28. ophyd_async/fastcs/jungfrau/_jungfrau.py +30 -0
  29. ophyd_async/fastcs/jungfrau/_signals.py +94 -0
  30. ophyd_async/fastcs/jungfrau/_utils.py +79 -0
  31. ophyd_async/plan_stubs/_settings.py +1 -1
  32. ophyd_async/sim/_motor.py +11 -3
  33. ophyd_async/sim/_point_detector.py +6 -3
  34. ophyd_async/sim/_stage.py +14 -3
  35. ophyd_async/tango/core/_tango_transport.py +2 -2
  36. ophyd_async/testing/_assert.py +6 -6
  37. ophyd_async/testing/_one_of_everything.py +1 -1
  38. {ophyd_async-0.13.0.dist-info → ophyd_async-0.13.2.dist-info}/METADATA +5 -4
  39. {ophyd_async-0.13.0.dist-info → ophyd_async-0.13.2.dist-info}/RECORD +43 -37
  40. /ophyd_async/epics/{eiger → odin}/__init__.py +0 -0
  41. {ophyd_async-0.13.0.dist-info → ophyd_async-0.13.2.dist-info}/WHEEL +0 -0
  42. {ophyd_async-0.13.0.dist-info → ophyd_async-0.13.2.dist-info}/licenses/LICENSE +0 -0
  43. {ophyd_async-0.13.0.dist-info → ophyd_async-0.13.2.dist-info}/top_level.txt +0 -0
@@ -3,10 +3,14 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  from collections.abc import Sequence
5
5
  from dataclasses import dataclass
6
+ from enum import IntEnum
7
+ from typing import Protocol
6
8
 
7
9
  import numpy as np
8
10
  import numpy.typing as npt
11
+ from numpy import float64
9
12
  from scanspec.core import Slice
13
+ from velocity_profile.velocityprofile import VelocityProfile
10
14
 
11
15
  from ophyd_async.core import error_if_none, gather_dict
12
16
  from ophyd_async.epics.motor import Motor
@@ -19,6 +23,235 @@ from ._pmac_io import CS_LETTERS, PmacIO
19
23
  # (see https://github.com/DiamondLightSource/pmac/blob/afe81f8bb9179c3a20eff351f30bc6cfd1539ad9/pmacApp/pmc/trajectory_scan_code_ppmac.pmc#L241)
20
24
  # Therefore, we must divide scanspec durations by 10e-6
21
25
  TICK_S = 0.000001
26
+ MIN_TURNAROUND = 0.002
27
+ MIN_INTERVAL = 0.002
28
+
29
+
30
+ class UserProgram(IntEnum):
31
+ COLLECTION_WINDOW = 1 # Period within a collection window
32
+ GAP = 2 # Transition period between collection windows
33
+ END = 8 # Post-scan state
34
+
35
+
36
+ class FillableSegment(Protocol):
37
+ """Protocol for trajectory segments that can insert their data into a trajectory.
38
+
39
+ A 'FillableSegment' represents a contiguous portion of a trajectory
40
+ that knows how to:
41
+ - Insert its motor positions and velocities into a '_Trajectory'.
42
+ - Insert its durations and associated user programs into a '_Trajectory'.
43
+ - Report its own length.
44
+ """
45
+
46
+ def insert_positions_and_velocities_into_trajectory(
47
+ self,
48
+ index_into_trajectory: int,
49
+ trajectory: _Trajectory,
50
+ motor: Motor,
51
+ ) -> None:
52
+ """Insert segment's positions and velocities for a given motor.
53
+
54
+ :param index_into_trajectory: Index into the trajectory this segment is inserted
55
+ :param trajectory: Instance of '_Trajectory' that will be populated
56
+ :param motor: Motor we are populating '_Trajectory' for
57
+ """
58
+ pass
59
+
60
+ def insert_durations_and_user_programs_into_trajectory(
61
+ self,
62
+ index_into_trajectory: int,
63
+ trajectory: _Trajectory,
64
+ ) -> None:
65
+ """Insert segment's durations and user programs.
66
+
67
+ :param index_into_trajectory: Index into the trajectory this segment is inserted
68
+ :param trajectory: Instance of '_Trajectory' that will be populated
69
+ """
70
+ pass
71
+
72
+ def __len__(self) -> int:
73
+ """Return the number of trajectory points in this segment."""
74
+ ...
75
+
76
+
77
+ class GapSegment:
78
+ def __init__(
79
+ self,
80
+ positions: dict[Motor, np.ndarray],
81
+ velocities: dict[Motor, np.ndarray],
82
+ duration: list[float],
83
+ ):
84
+ """Represents a gap between collection windows in a trajectory.
85
+
86
+ :param positions: Motor to gap positions
87
+ :param velocities: Motor to gap velocities
88
+ :param duration: List of time required to achieve gap point
89
+ """
90
+ self.positions = positions
91
+ self.velocities = velocities
92
+ self.duration = duration
93
+
94
+ def __len__(self):
95
+ # Gap length is the number of per-axis position points
96
+ # This number is identical for all motors
97
+ # as all motors follow a unified timeline through a gap
98
+ return next(iter(self.positions.values())).shape[0]
99
+
100
+ def insert_positions_and_velocities_into_trajectory(
101
+ self,
102
+ index_into_trajectory: int,
103
+ trajectory: _Trajectory,
104
+ motor: Motor,
105
+ ):
106
+ """Inserts gap positions and velocities into the trajectory.
107
+
108
+ This function will populate a '_Trajectory' with the current 'GapSegment's
109
+ pre-computed positions and velocities for a given motor.
110
+ """
111
+ num_gap_points = self.__len__()
112
+ # Update how many gap points we've added so far
113
+ # Insert gap points into end of collection window
114
+ trajectory.positions[motor][
115
+ index_into_trajectory : index_into_trajectory + num_gap_points
116
+ ] = self.positions[motor]
117
+ trajectory.velocities[motor][
118
+ index_into_trajectory : index_into_trajectory + num_gap_points
119
+ ] = self.velocities[motor]
120
+
121
+ def insert_durations_and_user_programs_into_trajectory(
122
+ self,
123
+ index_into_trajectory: int,
124
+ trajectory: _Trajectory,
125
+ ) -> None:
126
+ """Inserts gap durations and user programs into the trajectory.
127
+
128
+ This function will populate a '_Trajectory' with the current 'GapSegment's
129
+ pre-computed durations and set user programs to 'UserProgram.GAP'.
130
+ """
131
+ num_gap_points = self.__len__()
132
+ # We append an extra duration (i.e., num_gap_points + 1)
133
+ # This is because we need to insert the duration it takes
134
+ # to get from the final gap point to the next collection window point
135
+ # This duration is calculated alongside gaps so is inserted here for
136
+ # the next collection window
137
+ trajectory.durations[
138
+ index_into_trajectory : index_into_trajectory + num_gap_points + 1
139
+ ] = (np.array(self.duration) / TICK_S).astype(int)
140
+
141
+ trajectory.user_programs[
142
+ index_into_trajectory : index_into_trajectory + num_gap_points
143
+ ] = UserProgram.GAP
144
+
145
+
146
+ class CollectionWindow:
147
+ def __init__(
148
+ self, start: int, end: int, slice: Slice, half_durations: npt.NDArray[float64]
149
+ ):
150
+ """Represents a collection window in a trajectory.
151
+
152
+ :param start: Index into slice where this collection window starts
153
+ :param end: Index into slice where this collection window ends
154
+ :param slice: Information about a series of scan frames along a number of axes
155
+ :param half_durations: Array of half the time required to get to a frame
156
+ """
157
+ self.start = start
158
+ self.end = end
159
+ self.slice = slice
160
+ self.half_durations = half_durations
161
+
162
+ def __len__(self):
163
+ return ((self.end - self.start) * 2) + 1
164
+
165
+ def insert_positions_and_velocities_into_trajectory(
166
+ self,
167
+ index_into_trajectory: int,
168
+ trajectory: _Trajectory,
169
+ motor: Motor,
170
+ ):
171
+ """Inserts collection window positions and velocities into the trajectory.
172
+
173
+ For all frames of the slice that fall within this window, this function will:
174
+ - Insert an initial lower point to the trajectory
175
+ - Insert a sequence of midpoint → upper → midpoint → ... → upper points
176
+ until window ends
177
+ - Calculate and insert a 2 point average velocity from the initial
178
+ lower → midpoint (for the first velocity) and from the final
179
+ midpoint → upper (for last velocity)
180
+ - Calculate and insert 3 point average velocities from intermediate
181
+ points, as these points have a previous and next point to use in the
182
+ calculation
183
+ """
184
+ window_start_idx = index_into_trajectory
185
+ window_end_idx = index_into_trajectory + self.__len__()
186
+
187
+ # Lower bound at the segment start
188
+ trajectory.positions[motor][window_start_idx] = self.slice.lower[motor][
189
+ self.start
190
+ ]
191
+
192
+ # Fill mids into odd slots, uppers into even slots
193
+ trajectory.positions[motor][window_start_idx + 1 : window_end_idx : 2] = (
194
+ self.slice.midpoints[motor][self.start : self.end]
195
+ )
196
+ trajectory.positions[motor][window_start_idx + 2 : window_end_idx : 2] = (
197
+ self.slice.upper[motor][self.start : self.end]
198
+ )
199
+
200
+ # For velocities we will need the relative distances
201
+ mid_to_upper_velocities = (
202
+ self.slice.upper[motor][self.start : self.end]
203
+ - self.slice.midpoints[motor][self.start : self.end]
204
+ ) / self.half_durations[self.start : self.end]
205
+ lower_to_mid_velocities = (
206
+ self.slice.midpoints[motor][self.start : self.end]
207
+ - self.slice.lower[motor][self.start : self.end]
208
+ ) / self.half_durations[self.start : self.end]
209
+
210
+ # First velocity is the lower -> mid of first point
211
+ trajectory.velocities[motor][window_start_idx] = lower_to_mid_velocities[0]
212
+
213
+ # For the midpoints, we take the average of the
214
+ # lower -> mid and mid -> upper velocities of the same point
215
+ trajectory.velocities[motor][window_start_idx + 1 : window_end_idx : 2] = (
216
+ lower_to_mid_velocities + mid_to_upper_velocities
217
+ ) / 2
218
+
219
+ # For the upper points, we need to take the average of the
220
+ # mid -> upper velocity of the previous point and
221
+ # lower -> mid velocity of the current point
222
+ trajectory.velocities[motor][
223
+ window_start_idx + 2 : (window_end_idx) - 1 : 2
224
+ ] = (mid_to_upper_velocities[:-1] + lower_to_mid_velocities[1:]) / 2
225
+
226
+ # For the last velocity take the mid to upper velocity
227
+ trajectory.velocities[motor][window_end_idx - 1] = mid_to_upper_velocities[-1]
228
+
229
+ def insert_durations_and_user_programs_into_trajectory(
230
+ self,
231
+ index_into_trajectory: int,
232
+ trajectory: _Trajectory,
233
+ ) -> None:
234
+ """Inserts collection window durations and user programs into the trajectory.
235
+
236
+ For all frames from the slice that fall within this window, this function will:
237
+ - Insert a half duration for each midpoint and upper in the window,
238
+ where a full duration is the duration of the corresponding slice frame
239
+ - Insert user programs into the window as 'UserProgram.COLLECTION_WINDOW'
240
+
241
+ Note: this function will not insert the first duration of the window,
242
+ as this must be filled in by preceding ramp up duration or a gap segment
243
+ """
244
+ window_start_idx = index_into_trajectory
245
+ window_end_idx = index_into_trajectory + self.__len__()
246
+
247
+ trajectory.durations[window_start_idx + 1 : window_end_idx] = np.repeat(
248
+ (self.half_durations[self.start : self.end] / TICK_S).astype(int),
249
+ 2,
250
+ )
251
+
252
+ trajectory.user_programs[window_start_idx:window_end_idx] = (
253
+ UserProgram.COLLECTION_WINDOW
254
+ )
22
255
 
23
256
 
24
257
  @dataclass
@@ -29,81 +262,171 @@ class _Trajectory:
29
262
  durations: npt.NDArray[np.float64]
30
263
 
31
264
  @classmethod
32
- def from_slice(cls, slice: Slice[Motor], ramp_up_time: float) -> _Trajectory:
33
- """Parse a trajectory with no gaps from a slice.
265
+ def from_slice(
266
+ cls, slice: Slice[Motor], ramp_up_time: float, motor_info: _PmacMotorInfo
267
+ ) -> _Trajectory:
268
+ """Parse a trajectory from a slice.
34
269
 
35
270
  :param slice: Information about a series of scan frames along a number of axes
36
271
  :param ramp_up_duration: Time required to ramp up to speed
37
272
  :param ramp_down: Booleon representing if we ramp down or not
38
273
  :returns Trajectory: Data class representing our parsed trajectory
39
- :raises RuntimeError: Slice must have no gaps and a duration array
274
+ :raises RuntimeError: Slice must a duration array
40
275
  """
41
276
  slice_duration = error_if_none(slice.duration, "Slice must have a duration")
42
-
43
- # Check if any gaps other than initial gap.
44
- if any(slice.gap[1:]):
45
- raise RuntimeError(
46
- f"Cannot parse trajectory with gaps. Slice has gaps: {slice.gap}"
47
- )
277
+ half_durations = slice_duration / 2
48
278
 
49
279
  scan_size = len(slice)
280
+ # List of indices of the first frame of a collection window, after a gap
281
+ collection_window_idxs: list[int] = np.where(slice.gap)[0].tolist()
50
282
  motors = slice.axes()
51
283
 
284
+ # Precompute gaps
285
+ # We structure our trajectory as a list of collection windows and gap segments
286
+ segments: list[FillableSegment] = []
287
+ total_gap_points = 0
288
+ for collection_start_idx, collection_end_idx in zip(
289
+ collection_window_idxs[:-1], collection_window_idxs[1:], strict=False
290
+ ):
291
+ # Add a collection window that we will fill later
292
+ segments.append(
293
+ CollectionWindow(
294
+ start=collection_start_idx,
295
+ end=collection_end_idx,
296
+ slice=slice,
297
+ half_durations=half_durations,
298
+ )
299
+ )
300
+
301
+ # Now we precompute our gaps
302
+ # Get entry velocities, exit velocities, and distances across gap
303
+ entry_velocities, exit_velocities, distances = (
304
+ _get_entry_and_exit_velocities(
305
+ motors, slice, half_durations, collection_end_idx
306
+ )
307
+ )
308
+
309
+ # Get velocity and time profiles across gap
310
+ time_arrays, velocity_arrays = _get_velocity_profile(
311
+ motors, motor_info, entry_velocities, exit_velocities, distances
312
+ )
313
+
314
+ gap_segment = _calculate_profile_from_velocities(
315
+ motors,
316
+ slice,
317
+ collection_end_idx,
318
+ time_arrays,
319
+ velocity_arrays,
320
+ )
321
+
322
+ # Add a gap segment
323
+ segments.append(gap_segment)
324
+
325
+ total_gap_points += len(gap_segment)
326
+
327
+ # "collection_windows" does not include final window,
328
+ # as no subsequeny gap to mark its termination
329
+ # So, we add it here, ending it at the end of the slice
330
+ segments.append(
331
+ CollectionWindow(
332
+ collection_window_idxs[-1], len(slice), slice, half_durations
333
+ )
334
+ )
335
+
52
336
  positions: dict[Motor, npt.NDArray[np.float64]] = {}
53
337
  velocities: dict[Motor, npt.NDArray[np.float64]] = {}
54
338
 
55
339
  # Initialise arrays
56
- positions = {motor: np.empty(2 * scan_size + 1, float) for motor in motors}
57
- velocities = {motor: np.empty(2 * scan_size + 1, float) for motor in motors}
58
- durations: npt.NDArray[np.float64] = np.empty(2 * scan_size + 1, float)
59
- user_programs: npt.NDArray[np.int32] = np.ones(2 * scan_size + 1, float)
60
- user_programs[-1] = 8
61
-
340
+ # Trajectory size calculated from 2 points per frame (midpoint and upper)
341
+ # Plus an initial lower point at the start of every collection window
342
+ # Plus PVT points added between collection windows (gaps)
343
+ trajectory_size = (
344
+ 2 * scan_size + len(collection_window_idxs)
345
+ ) + total_gap_points
346
+ positions = {motor: np.empty(trajectory_size, float) for motor in motors}
347
+ velocities = {motor: np.empty(trajectory_size, float) for motor in motors}
348
+ durations: npt.NDArray[np.float64] = np.empty(trajectory_size, float)
349
+ user_programs: npt.NDArray[np.int32] = np.empty(trajectory_size, int)
62
350
  # Ramp up time for start of collection window
63
351
  durations[0] = int(ramp_up_time / TICK_S)
64
- # Half the time per point
65
- durations[1:] = np.repeat(slice_duration / (2 * TICK_S), 2)
66
352
 
67
- # Fill profile assuming no gaps
68
- # Excluding starting points, we begin at our next frame
69
- half_durations = slice_duration / 2
70
- for motor in motors:
71
- # Set the first position to be lower bound, then
72
- # alternate mid and upper as the upper and lower
73
- # bounds of neighbouring points are the same as gap is false
74
- positions[motor][0] = slice.lower[motor][0]
75
- positions[motor][1::2] = slice.midpoints[motor]
76
- positions[motor][2::2] = slice.upper[motor]
77
- # For velocities we will need the relative distances
78
- mid_to_upper_velocities = (
79
- slice.upper[motor] - slice.midpoints[motor]
80
- ) / half_durations
81
- lower_to_mid_velocities = (
82
- slice.midpoints[motor] - slice.lower[motor]
83
- ) / half_durations
84
- # First velocity is the lower -> mid of first point
85
- velocities[motor][0] = lower_to_mid_velocities[0]
86
- # For the midpoints, we take the average of the
87
- # lower -> mid and mid-> upper velocities of the same point
88
- velocities[motor][1::2] = (
89
- lower_to_mid_velocities + mid_to_upper_velocities
90
- ) / 2
91
- # For the upper points, we need to take the average of the
92
- # mid -> upper velocity of the previous point and
93
- # lower -> mid velocity of the current point
94
- velocities[motor][2:-1:2] = (
95
- mid_to_upper_velocities[:-1] + lower_to_mid_velocities[1:]
96
- ) / 2
97
- # For the last velocity take the mid to upper velocity
98
- velocities[motor][-1] = mid_to_upper_velocities[-1]
99
-
100
- return cls(
353
+ # Pass initialised arrays into a _Trajectory, that we fill later on
354
+ trajectory = cls(
101
355
  positions=positions,
102
356
  velocities=velocities,
103
- user_programs=user_programs,
104
357
  durations=durations,
358
+ user_programs=user_programs,
105
359
  )
106
360
 
361
+ # Fill trajectory
362
+ # Start by filling durations and user_programs once
363
+ # as this is identical for all motors of a trajectory
364
+ # Index keeps track of where we are in the trajectory
365
+ index_into_trajectory = 0
366
+ # Iterate over collection windows or gaps
367
+ for segment in segments:
368
+ # This inserts slice or gap durations and user_programs
369
+ # into the output trajectory
370
+ segment.insert_durations_and_user_programs_into_trajectory(
371
+ index_into_trajectory=index_into_trajectory,
372
+ trajectory=trajectory,
373
+ )
374
+ # The length of each segment moves the trajectory index
375
+ index_into_trajectory += len(segment)
376
+ # Now fill positions and velocities for each motor
377
+ for motor in motors:
378
+ # Reset index for positions and velocities, for each motor
379
+ index_into_trajectory = 0
380
+ for segment in segments:
381
+ # This inserts slice or gap positions and velocites
382
+ # into the output trajectory
383
+ segment.insert_positions_and_velocities_into_trajectory(
384
+ index_into_trajectory=index_into_trajectory,
385
+ trajectory=trajectory,
386
+ motor=motor,
387
+ )
388
+ index_into_trajectory += len(segment)
389
+
390
+ # Check that calculated velocities do not exceed motor's max velocity
391
+ velocities_above_limit_mask = (
392
+ np.abs(trajectory.velocities[motor])
393
+ - motor_info.motor_max_velocity[motor]
394
+ ) / motor_info.motor_max_velocity[motor] >= 1e-6
395
+ if velocities_above_limit_mask.any():
396
+ # Velocities above motor max velocity
397
+ bad_velocities = trajectory.velocities[motor][
398
+ velocities_above_limit_mask
399
+ ]
400
+ # Indices in trajectory above motor max velocity
401
+ # np.nonzero returns tuple, but as only one mask passed, we need index 0
402
+ indices_to_bad_velocities = np.nonzero(velocities_above_limit_mask)[0]
403
+ raise ValueError(
404
+ f"{motor.name} velocity exceeds motor's max velocity of "
405
+ f"{motor_info.motor_max_velocity[motor]} "
406
+ f"at trajectory indices {indices_to_bad_velocities.tolist()}: "
407
+ f"{bad_velocities}"
408
+ )
409
+
410
+ return trajectory
411
+
412
+ def append_ramp_down(
413
+ self,
414
+ ramp_down_pos: dict[Motor, np.float64],
415
+ ramp_down_time: float,
416
+ ramp_down_velocity: float,
417
+ ) -> _Trajectory:
418
+ self.durations = np.append(self.durations, [int(ramp_down_time / TICK_S)])
419
+ self.user_programs = np.append(self.user_programs, UserProgram.END)
420
+ for motor in ramp_down_pos.keys():
421
+ self.positions[motor] = np.append(
422
+ self.positions[motor], [ramp_down_pos[motor]]
423
+ )
424
+ self.velocities[motor] = np.append(
425
+ self.velocities[motor], [ramp_down_velocity]
426
+ )
427
+
428
+ return self
429
+
107
430
 
108
431
  @dataclass
109
432
  class _PmacMotorInfo:
@@ -111,6 +434,7 @@ class _PmacMotorInfo:
111
434
  cs_number: int
112
435
  motor_cs_index: dict[Motor, int]
113
436
  motor_acceleration_rate: dict[Motor, float]
437
+ motor_max_velocity: dict[Motor, float]
114
438
 
115
439
  @classmethod
116
440
  async def from_motors(cls, pmac: PmacIO, motors: Sequence[Motor]) -> _PmacMotorInfo:
@@ -197,13 +521,29 @@ class _PmacMotorInfo:
197
521
  }
198
522
 
199
523
  return _PmacMotorInfo(
200
- cs_port, cs_number, motor_cs_index, motor_acceleration_rate
524
+ cs_port, cs_number, motor_cs_index, motor_acceleration_rate, velocities
201
525
  )
202
526
 
203
527
 
204
528
  def calculate_ramp_position_and_duration(
205
529
  slice: Slice[Motor], motor_info: _PmacMotorInfo, is_up: bool
206
- ) -> tuple[dict[Motor, float], float]:
530
+ ) -> tuple[dict[Motor, np.float64], float]:
531
+ """Calculate the the required ramp position and duration of a trajectory.
532
+
533
+ This function will:
534
+ - Calculate the ramp time required to achieve each motor's
535
+ initial entry velocity into the first frame of a slice
536
+ or final exit velocity out of the last frame of a slice
537
+ - Use the longest ramp time to calculate all motor's
538
+ ramp up positions.
539
+
540
+ :param slice: Information about a series of scan frames along a number of axes
541
+ :param motor_info: Instance of _PmacMotorInfo
542
+ :param is_up: Boolean representing ramping up into a frame or down out of a frame
543
+ :returns tuple: A tuple containing:
544
+ dict: Motor to ramp positions
545
+ float: Ramp time required for all motors
546
+ """
207
547
  if slice.duration is None:
208
548
  raise ValueError("Slice must have a duration")
209
549
 
@@ -218,7 +558,9 @@ def calculate_ramp_position_and_duration(
218
558
  ]
219
559
  velocities[axis] = velocity
220
560
  ramp_times.append(abs(velocity) / motor_info.motor_acceleration_rate[axis])
221
-
561
+ ramp_times.append(
562
+ MIN_TURNAROUND
563
+ ) # Adding a 2ms ramp time as a min tournaround time
222
564
  max_ramp_time = max(ramp_times)
223
565
 
224
566
  motor_to_ramp_position = {}
@@ -229,3 +571,277 @@ def calculate_ramp_position_and_duration(
229
571
  motor_to_ramp_position[axis] = ref_pos + sign * displacement
230
572
 
231
573
  return (motor_to_ramp_position, max_ramp_time)
574
+
575
+
576
+ def _get_velocity_profile(
577
+ motors: list[Motor],
578
+ motor_info: _PmacMotorInfo,
579
+ start_velocities: dict[Motor, np.float64],
580
+ end_velocities: dict[Motor, np.float64],
581
+ distances: dict[Motor, float],
582
+ ) -> tuple[dict[Motor, npt.NDArray[np.float64]], dict[Motor, npt.NDArray[np.float64]]]:
583
+ """Generate time and velocity profiles for motors across a gap.
584
+
585
+ For each motor, a `VelocityProfile` is constructed.
586
+ Profiles are iteratively recalculated to converge on a
587
+ consistent minimum total gap time across all motors.
588
+
589
+ This function will:
590
+ - Initialise with a minimum turnaround time (`MIN_TURNAROUND`).
591
+ - Build a velocity profile for each motor and determine the total
592
+ move time required.
593
+ - Update the minimum total gap time to the maximum of these totals.
594
+ - Repeat until all profiles agree on the same minimum time or an
595
+ iteration limit (i.e., 2) is reached.
596
+
597
+ :param motors: Sequence of motors involved in trajectory
598
+ :param motor_info: Instance of _PmacMotorInfo
599
+ :param start_velocities: Motor velocities at start of gap
600
+ :param end_velocities: Motor velocities at end of gap
601
+ :param distances: Motor distances required to travel accross gap
602
+ :raises ValueError: Cannot converge on common minimum time in 2 iterations
603
+ :returns tuple: A tuple containing:
604
+ dict: Motor's absolute timestamps of their velocity changes
605
+ dict: Motor's velocity changes
606
+ """
607
+ profiles: dict[Motor, VelocityProfile] = {}
608
+ time_arrays = {}
609
+ velocity_arrays = {}
610
+
611
+ min_time = MIN_TURNAROUND
612
+ iterations = 2
613
+
614
+ while iterations > 0:
615
+ new_min_time = 0.0 # reset for this iteration
616
+
617
+ for motor in motors:
618
+ # Build profile for this motor
619
+ p = VelocityProfile(
620
+ start_velocities[motor],
621
+ end_velocities[motor],
622
+ distances[motor],
623
+ min_time,
624
+ motor_info.motor_acceleration_rate[motor],
625
+ motor_info.motor_max_velocity[motor],
626
+ 0,
627
+ MIN_INTERVAL,
628
+ )
629
+ p.get_profile()
630
+
631
+ profiles[motor] = p
632
+ new_min_time = max(new_min_time, p.t_total)
633
+
634
+ # Check if all profiles have converged on min_time
635
+ if np.isclose(new_min_time, min_time):
636
+ for motor in motors:
637
+ time_arrays[motor], velocity_arrays[motor] = profiles[
638
+ motor
639
+ ].make_arrays()
640
+ return time_arrays, velocity_arrays
641
+ else:
642
+ min_time = new_min_time
643
+ iterations -= 1 # Get profiles with new minimum turnaround
644
+
645
+ raise ValueError(
646
+ "Failed to get a consistent time when calculating velocity profiles."
647
+ )
648
+
649
+
650
+ def _get_entry_and_exit_velocities(
651
+ motors: list[Motor],
652
+ slice: Slice,
653
+ half_durations: npt.NDArray[float64],
654
+ gap: int,
655
+ ) -> tuple[
656
+ dict[Motor, np.float64],
657
+ dict[Motor, np.float64],
658
+ dict[Motor, float],
659
+ ]:
660
+ """Compute motor entry and exit velocities across a gap.
661
+
662
+ For each motor, this function:
663
+ - Calculates the midpoint velocity before and after the gap
664
+ - Uses midpoint distances (lower → midpoint and midpoint → upper) to
665
+ calculate the velocity just before entering the gap (entry velocity)
666
+ and just after exiting the gap (exit velocity).
667
+ - Computes the travel distance across the gap from the upper point of
668
+ the preceding frame to the lower point of the following frame.
669
+
670
+ :param motors: Sequence of motors involved in trajectory
671
+ :param slice: Information about a series of scan frames along a number of axes
672
+ :param half_durations: Array of half the time required to get to a frame
673
+ :param gap: Index into the slice where gap has occured
674
+ :returns tuple: A tuple containing:
675
+ dict: Motor to entry velocity into gap
676
+ dict: Motor to exit velocity out of gap
677
+ dict: Motor to distance to travel across gap
678
+ """
679
+ entry_velocities: dict[Motor, np.float64] = {}
680
+ exit_velocities: dict[Motor, np.float64] = {}
681
+ distances: dict[Motor, float] = {}
682
+ for motor in motors:
683
+ # x
684
+ # x x
685
+ # x x
686
+ # vl vlm vm vmu vu
687
+ # Given distances from Frame, lower, midpoint, upper, calculate
688
+ # vl for entry into gap (i.e., gap-1) and vu for exit out of gap
689
+ entry_lower_upper_distance = (
690
+ slice.upper[motor][gap - 1] - slice.lower[motor][gap - 1]
691
+ )
692
+ exit_lower_upper_distance = slice.upper[motor][gap] - slice.lower[motor][gap]
693
+
694
+ entry_midpoint_velocity = entry_lower_upper_distance / (
695
+ 2 * half_durations[gap - 1]
696
+ )
697
+ exit_midpoint_velocity = exit_lower_upper_distance / (2 * half_durations[gap])
698
+
699
+ # For entry, halfway point is vlm
700
+ # so calculate lower to midpoint distance (i.e., dlm)
701
+ lower_midpoint_distance = (
702
+ slice.midpoints[motor][gap - 1] - slice.lower[motor][gap - 1]
703
+ )
704
+ # For exit, halfway point is vmu
705
+ # so calculate midpoint to upper distance (i.e., dmu)
706
+ midpoint_upper_distance = slice.upper[motor][gap] - slice.midpoints[motor][gap]
707
+
708
+ # Extrapolate to get our entry or exit velocity
709
+ # For example:
710
+ # (vl + vm) / 2 = vlm
711
+ # so vl = 2 * vlm - vm
712
+ # where vlm = dlm / (t/2)
713
+ # Therefore, velocity from point just before gap
714
+ entry_velocities[motor] = (
715
+ 2 * (lower_midpoint_distance / half_durations[gap - 1])
716
+ - entry_midpoint_velocity
717
+ )
718
+
719
+ # Velocity from point just after gap
720
+ exit_velocities[motor] = (
721
+ 2 * (midpoint_upper_distance / half_durations[gap]) - exit_midpoint_velocity
722
+ )
723
+
724
+ # Travel distance across gap
725
+ distances[motor] = slice.lower[motor][gap] - slice.upper[motor][gap - 1]
726
+ if np.isclose(distances[motor], 0.0, atol=1e-12):
727
+ distances[motor] = 0.0
728
+
729
+ return entry_velocities, exit_velocities, distances
730
+
731
+
732
+ def _calculate_profile_from_velocities(
733
+ motors: list[Motor],
734
+ slice: Slice,
735
+ gap: int,
736
+ time_arrays: dict[Motor, npt.NDArray[np.float64]],
737
+ velocity_arrays: dict[Motor, npt.NDArray[np.float64]],
738
+ ) -> GapSegment:
739
+ """Convert per-axis time/velocity profiles into aligned time/position profiles.
740
+
741
+ Given per-axis arrays of times and corresponding velocities,
742
+ this builds a single unified timeline containing all unique velocity change points
743
+ from every axis. It then steps through that timeline, and for each axis:
744
+
745
+ * If the current time matches one of the axis's own velocity change points,
746
+ use that known velocity.
747
+ * Otherwise, linearly interpolate between the axis's previous and next
748
+ known velocities, based on how far through that section we are.
749
+
750
+ At each unified time step, the velocity is integrated over the step duration
751
+ (using the trapezoidal rule) to update the axis's position. This produces
752
+ per-axis position and velocity arrays that are aligned to the same global
753
+ time grid.
754
+
755
+ Example:
756
+ combined_times = [0.1, 0.2, 0.3, 0.4]
757
+ axis_times[motor] = [0.1, 0.4]
758
+ velocity_array[motor] = [2, 5]
759
+
760
+ At 0.1 → known vel = 2 (use directly)
761
+ At 0.2 → 1/3 of the way to next vel → 3.0
762
+ At 0.3 → 2/3 of the way to next vel → 4.0
763
+ At 0.4 → known vel = 5 (use directly)
764
+
765
+ These instantaneous velocities are integrated over each Δt to yield
766
+ positions aligned with the global timeline.
767
+
768
+ :param motors: Sequence of motors involved in trajectory
769
+ :param slice: Information about a series of scan frames along a number of axes
770
+ :param gap: Index into slice where gap has occured
771
+ :param time_arrays: Motor's absolute timestamps of velocity changes
772
+ :param velocity_arrays: Motor's velocity changes
773
+ :returns GapSegment: Class representing a segment of a trajectory that is a gap
774
+ """
775
+ # Combine all per-axis time points into a single sorted array of times
776
+ # This way we can evaluate each motor along the same timeline
777
+ # We know all axes positions at t=0, so we drop this point
778
+ combined_times = np.sort(np.unique(np.concatenate(list(time_arrays.values()))))[1:]
779
+
780
+ # We convert a list of t into a list of Δt
781
+ # We do this by substracting against previous cumulative time
782
+ time_intervals = np.diff(np.concatenate(([0.0], combined_times))).tolist()
783
+
784
+ # We also know all axes positions when t=t_final, so we drop this point
785
+ # However, we need the interval for the next collection window, so we store it
786
+ *time_intervals, final_interval = time_intervals
787
+ combined_times = combined_times[:-1]
788
+ num_intervals = len(time_intervals)
789
+
790
+ # Prepare dicts for the resulting position and velocity profiles over the gap
791
+ positions: dict[Motor, npt.NDArray[np.float64]] = {}
792
+ velocities: dict[Motor, npt.NDArray[np.float64]] = {}
793
+
794
+ # Loop over each motor and integrate its velocity profile over the unified times
795
+ for motor in motors:
796
+ axis_times = time_arrays[motor]
797
+ axis_velocities = velocity_arrays[motor]
798
+ axis_position = slice.upper[motor][
799
+ gap - 1
800
+ ] # start position at beginning of the gap
801
+ prev_interval_vel = axis_velocities[
802
+ 0
803
+ ] # last velocity seen from the previous global time interval
804
+ time_since_prev_axis_point = 0.0 # elapsed time since the last velocity point
805
+ axis_idx = 1 # index into this axis's velocity/time arrays
806
+
807
+ # Allocate output arrays for this motor with correct size
808
+ positions[motor] = np.empty(num_intervals, dtype=np.float64)
809
+ velocities[motor] = np.empty(num_intervals, dtype=np.float64)
810
+
811
+ # Step through each interval in the Δt list
812
+ for i, dt in enumerate(time_intervals):
813
+ next_vel = axis_velocities[axis_idx]
814
+ prev_vel = axis_velocities[axis_idx - 1]
815
+ axis_dt = axis_times[axis_idx] - axis_times[axis_idx - 1]
816
+
817
+ if np.isclose(combined_times[i], axis_times[axis_idx]):
818
+ # If the current combined time exactly matches this motor's
819
+ # next velocity change point, no interpolation is needed, so:
820
+ this_vel = next_vel
821
+ axis_idx += 1
822
+ time_since_prev_axis_point = 0.0
823
+ else:
824
+ # Otherwise, linearly interpolate velocity between the previous
825
+ # and next known velocity points for this motor
826
+ time_since_prev_axis_point += dt
827
+ # The fraction of the way we are from previous to next known velocities
828
+ # for this motor
829
+ frac = time_since_prev_axis_point / axis_dt
830
+ # Interpolate for our velocity
831
+ this_vel = prev_vel + frac * (next_vel - prev_vel)
832
+
833
+ # Integrate velocity over this interval to update position.
834
+ # Using the trapezoidal rule:
835
+ delta_pos = 0.5 * (prev_interval_vel + this_vel) * dt
836
+ axis_position += delta_pos
837
+ prev_interval_vel = this_vel # update for next loop
838
+
839
+ # Store the computed position and velocity for this interval
840
+ positions[motor][i] = axis_position
841
+ velocities[motor][i] = this_vel
842
+
843
+ return GapSegment(
844
+ positions=positions,
845
+ velocities=velocities,
846
+ duration=time_intervals + [final_interval],
847
+ )