ophyd-async 0.13.3__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.
@@ -0,0 +1,692 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from enum import IntEnum
5
+ from functools import partial
6
+ from typing import TypeVar
7
+
8
+ import numpy as np
9
+ import numpy.typing as npt
10
+ from numpy import float64
11
+ from scanspec.core import Slice
12
+ from velocity_profile.velocityprofile import VelocityProfile
13
+
14
+ from ophyd_async.core import error_if_none
15
+ from ophyd_async.epics.motor import Motor
16
+ from ophyd_async.epics.pmac._utils import _PmacMotorInfo
17
+
18
+ MIN_TURNAROUND = 0.002
19
+ MIN_INTERVAL = 0.002
20
+
21
+ IndexType = TypeVar("IndexType", int, slice, npt.NDArray[np.int_], list[int])
22
+
23
+
24
+ class UserProgram(IntEnum):
25
+ COLLECTION_WINDOW = 1 # Period within a collection window
26
+ GAP = 2 # Transition period between collection windows
27
+ END = 8 # Post-scan state
28
+
29
+
30
+ @dataclass
31
+ class PVT:
32
+ """Represents a single position, velocity, and time point for multiple motors."""
33
+
34
+ position: dict[Motor, float64]
35
+ velocity: dict[Motor, float64]
36
+ time: float64
37
+
38
+ @classmethod
39
+ def default(cls, motors: list[Motor]) -> PVT:
40
+ """Initialise a PVT with zeros.
41
+
42
+ :param motors: List of motors
43
+ :returns: PVT instance with zero value placeholders
44
+ """
45
+ return cls(
46
+ position={motor: np.float64(0.0) for motor in motors},
47
+ velocity={motor: np.float64(0.0) for motor in motors},
48
+ time=np.float64(0.0),
49
+ )
50
+
51
+
52
+ @dataclass
53
+ class Trajectory:
54
+ positions: dict[Motor, npt.NDArray[np.float64]]
55
+ velocities: dict[Motor, npt.NDArray[np.float64]]
56
+ user_programs: npt.NDArray[np.int32]
57
+ durations: npt.NDArray[np.float64]
58
+
59
+ def __len__(self) -> int:
60
+ return len(self.user_programs)
61
+
62
+ def with_ramp_down(
63
+ self,
64
+ entry_pvt: PVT,
65
+ ramp_down_pos: dict[Motor, np.float64],
66
+ ramp_down_time: float,
67
+ ramp_down_velocity: float,
68
+ ) -> Trajectory:
69
+ # Make room for additional two points to ramp down
70
+ # from a collection window upper to ramp down position
71
+ trajectory_length = len(self.user_programs)
72
+ total_length = trajectory_length + 2
73
+ motors = ramp_down_pos.keys()
74
+
75
+ positions = {motor: np.empty(total_length, float) for motor in motors}
76
+ velocities = {motor: np.empty(total_length, float) for motor in motors}
77
+ durations: npt.NDArray[np.float64] = np.empty(total_length, float)
78
+ user_programs: npt.NDArray[np.int32] = np.empty(total_length, int)
79
+
80
+ durations[:trajectory_length] = self.durations
81
+ durations[trajectory_length:] = [entry_pvt.time, ramp_down_time]
82
+
83
+ user_programs[:trajectory_length] = self.user_programs
84
+ user_programs[trajectory_length:] = [
85
+ UserProgram.COLLECTION_WINDOW,
86
+ UserProgram.END,
87
+ ]
88
+
89
+ for motor in ramp_down_pos.keys():
90
+ positions[motor][:trajectory_length] = self.positions[motor]
91
+ positions[motor][trajectory_length:] = [
92
+ entry_pvt.position[motor],
93
+ ramp_down_pos[motor],
94
+ ]
95
+ velocities[motor][:trajectory_length] = self.velocities[motor]
96
+ velocities[motor][trajectory_length:] = [
97
+ entry_pvt.velocity[motor],
98
+ ramp_down_velocity,
99
+ ]
100
+
101
+ return Trajectory(
102
+ positions=positions,
103
+ velocities=velocities,
104
+ user_programs=user_programs,
105
+ durations=durations,
106
+ )
107
+
108
+ @classmethod
109
+ def from_slice(
110
+ cls,
111
+ slice: Slice[Motor],
112
+ motor_info: _PmacMotorInfo,
113
+ entry_pvt: PVT | None = None,
114
+ ramp_up_time: float | None = None,
115
+ ) -> tuple[Trajectory, PVT]:
116
+ """Parse a trajectory from a slice.
117
+
118
+ :param slice: Information about a series of scan frames along a number of axes
119
+ :param motor_info: Instance of _PmacMotorInfo
120
+ :param entry_pvt: PVT entering this trajectory
121
+ :param ramp_up_time: Time required to ramp up to speed
122
+ :returns Trajectory: Data class representing our parsed trajectory
123
+ """
124
+ # List of indices into slice where gaps occur
125
+ gap_indices: list[int] = np.where(slice.gap)[0].tolist()
126
+ motors = slice.axes()
127
+
128
+ # Given a ramp up time is provided, we must be at a
129
+ # gap in the trajectory, that we can ramp up through
130
+ if (not gap_indices or gap_indices[0] != 0) and ramp_up_time:
131
+ raise RuntimeError(
132
+ "Slice must start with a gap, if ramp up time provided. "
133
+ f"Ramp up time given: {ramp_up_time}."
134
+ )
135
+
136
+ # Given a ramp up time is provided, we need to construct the PVT
137
+ if (ramp_up_time is None) == (entry_pvt is None):
138
+ raise RuntimeError(
139
+ "Exactly one of ramp_up_time or entry_pvt must be provided."
140
+ f"Provided ramp up time: {ramp_up_time} and entry PVT: {entry_pvt}"
141
+ )
142
+
143
+ # Find start and end indices for collection windows
144
+ collection_windows = np.argwhere(
145
+ np.diff(~(slice.gap), prepend=False, append=False)
146
+ ).reshape((-1, 2))
147
+
148
+ collection_window_iter = iter(collection_windows)
149
+ sub_traj_funcs = []
150
+
151
+ # Given we start at a collection window, insert it
152
+ if not gap_indices or gap_indices[0] != 0:
153
+ start, end = next(collection_window_iter)
154
+ sub_traj_funcs.append(
155
+ partial(
156
+ Trajectory.from_collection_window,
157
+ start,
158
+ end,
159
+ motors,
160
+ slice,
161
+ )
162
+ )
163
+
164
+ # For each gap, insert a gap, followed by a collection window
165
+ # given the distance to the next gap is greater than 1
166
+ for gap in gap_indices:
167
+ kwargs = {}
168
+ if gap == 0 and ramp_up_time:
169
+ kwargs["ramp_up_time"] = ramp_up_time
170
+ sub_traj_funcs.append(
171
+ partial(
172
+ Trajectory.from_gap,
173
+ motor_info,
174
+ gap,
175
+ motors,
176
+ slice,
177
+ **kwargs,
178
+ )
179
+ )
180
+ if gap != len(slice.gap) - 1 and not slice.gap[gap + 1]:
181
+ start, end = next(collection_window_iter)
182
+ sub_traj_funcs.append(
183
+ partial(
184
+ Trajectory.from_collection_window,
185
+ start,
186
+ end,
187
+ motors,
188
+ slice,
189
+ )
190
+ )
191
+
192
+ sub_trajectories: list[Trajectory] = []
193
+ # If no sub trajectories, initial frame is the end frame
194
+ exit_pvt = entry_pvt
195
+ for func in sub_traj_funcs:
196
+ # Build each sub trajectory, passing the last PVT
197
+ # to the next sub trajectory to build upon
198
+ # Explicitly defining initial and end PVTs
199
+ # to clearly show that the last PVT of a trajectory
200
+ # is passed as the first PVT of the next trajectory
201
+ traj, exit_pvt = func(entry_pvt)
202
+ entry_pvt = exit_pvt
203
+ sub_trajectories.append(traj)
204
+
205
+ # Combine sub trajectories
206
+ total_trajectory = Trajectory.from_trajectories(sub_trajectories, motors)
207
+
208
+ return total_trajectory, (exit_pvt or PVT.default(motors))
209
+
210
+ @classmethod
211
+ def from_trajectories(
212
+ cls, sub_trajectories: list[Trajectory], motors: list[Motor]
213
+ ) -> Trajectory:
214
+ """Parse a trajectory from smaller strajectories.
215
+
216
+ :param sub_trajectories: List of trajectories to concatenate
217
+ :returns: Trajectory instance as concatenation of all sub trajectories
218
+ """
219
+ # Initialise arrays to insert sub arrays into
220
+ total_points = sum(len(trajectory) for trajectory in sub_trajectories)
221
+ positions = {motor: np.empty(total_points, float) for motor in motors}
222
+ velocities = {motor: np.empty(total_points, float) for motor in motors}
223
+ durations: npt.NDArray[np.float64] = np.empty(total_points, float)
224
+ user_programs: npt.NDArray[np.int32] = np.empty(total_points, int)
225
+
226
+ # Keep track of where we are in overall trajectory
227
+ index_into_trajectory = 0
228
+ for trajectory in sub_trajectories:
229
+ # Insert sub trajectory arrays into overall trajectory arrays
230
+ durations[
231
+ index_into_trajectory : index_into_trajectory + len(trajectory)
232
+ ] = trajectory.durations
233
+ user_programs[
234
+ index_into_trajectory : index_into_trajectory + len(trajectory)
235
+ ] = trajectory.user_programs
236
+ for motor in motors:
237
+ positions[motor][
238
+ index_into_trajectory : index_into_trajectory + len(trajectory)
239
+ ] = trajectory.positions[motor]
240
+ velocities[motor][
241
+ index_into_trajectory : index_into_trajectory + len(trajectory)
242
+ ] = trajectory.velocities[motor]
243
+ # Update where we are in the overall trajectory
244
+ index_into_trajectory += len(trajectory)
245
+
246
+ trajectory = Trajectory(
247
+ positions=positions,
248
+ velocities=velocities,
249
+ durations=durations,
250
+ user_programs=user_programs,
251
+ )
252
+
253
+ return trajectory
254
+
255
+ @classmethod
256
+ def from_collection_window(
257
+ cls,
258
+ start: int,
259
+ end: int,
260
+ motors: list[Motor],
261
+ slice: Slice,
262
+ entry_pvt: PVT,
263
+ ) -> tuple[Trajectory, PVT]:
264
+ """Parse a trajectory from a collection window.
265
+
266
+ For all frames of the slice that fall within this window, this function will:
267
+ - Insert a sequence of lower → midpoint → lower → ... → midpoint points
268
+ until window ends
269
+ - Calculate and insert 3 point average velocities for these points, using the
270
+ entry PVT to blend with previous trajectories
271
+
272
+ :param start: Index into slice where collection window starts
273
+ :param end: Index into slice where collection window end
274
+ :param motors: List of motors involved in trajectory
275
+ :param slice: Information about a series of scan frames along a number of axes
276
+ :param entry_pvt: PVT entering this trajectory
277
+ :returns: Tuple of:
278
+ Trajectory instance encompassing collection window points
279
+ PVT at exit of collection window
280
+ """
281
+ slice_duration = error_if_none(slice.duration, "Slice must have a duration")
282
+ half_durations = slice_duration / 2
283
+ if end > len(half_durations):
284
+ # Clamp collection window if no more frames
285
+ end = len(half_durations)
286
+ trajectory_size = 2 * len(half_durations[start:end])
287
+ positions = {motor: np.empty(trajectory_size, float) for motor in motors}
288
+ velocities = {motor: np.empty(trajectory_size, float) for motor in motors}
289
+ durations: npt.NDArray[np.float64] = np.empty(trajectory_size, float)
290
+ user_programs: npt.NDArray[np.int32] = np.empty(trajectory_size, int)
291
+
292
+ # Initialise exit PVT
293
+ exit_pvt = PVT.default(motors)
294
+
295
+ for motor in motors:
296
+ # Insert lower -> mid -> lower -> mid... positions
297
+ positions[motor][::2] = slice.lower[motor][start:end]
298
+ positions[motor][1::2] = slice.midpoints[motor][start:end]
299
+
300
+ # For velocities we will need the relative distances
301
+ mid_to_upper_velocities = (
302
+ slice.upper[motor][start:end] - slice.midpoints[motor][start:end]
303
+ ) / half_durations[start:end]
304
+ lower_to_mid_velocities = (
305
+ slice.midpoints[motor][start:end] - slice.lower[motor][start:end]
306
+ ) / half_durations[start:end]
307
+
308
+ # Smooth first lower point velocity with previous PVT
309
+ velocities[motor][0] = (
310
+ lower_to_mid_velocities[0] + entry_pvt.velocity[motor]
311
+ ) / 2
312
+
313
+ # For the midpoints, we take the average of the
314
+ # lower -> mid and mid -> upper velocities of the same point
315
+ velocities[motor][1::2] = (
316
+ lower_to_mid_velocities + mid_to_upper_velocities
317
+ ) / 2
318
+
319
+ # For the lower points, we need to take the average of the
320
+ # mid -> upper velocity of the previous point and
321
+ # lower -> mid velocity of the current point
322
+ velocities[motor][2::2] = (
323
+ mid_to_upper_velocities[:-1] + lower_to_mid_velocities[1:]
324
+ ) / 2
325
+
326
+ # Exit PVT is the final upper point and its mid to upper velocity
327
+ exit_pvt.position[motor] = slice.upper[motor][end - 1]
328
+ exit_pvt.velocity[motor] = mid_to_upper_velocities[-1]
329
+
330
+ durations = np.repeat(
331
+ (half_durations[start:end]),
332
+ 2,
333
+ )
334
+
335
+ user_programs = np.repeat(UserProgram.COLLECTION_WINDOW, len(user_programs))
336
+
337
+ exit_pvt.time = half_durations[end - 1]
338
+
339
+ trajectory = Trajectory(
340
+ positions=positions,
341
+ velocities=velocities,
342
+ durations=durations,
343
+ user_programs=user_programs,
344
+ )
345
+
346
+ return trajectory, exit_pvt
347
+
348
+ @classmethod
349
+ def from_gap(
350
+ cls,
351
+ motor_info: _PmacMotorInfo,
352
+ gap: int,
353
+ motors: list[Motor],
354
+ slice: Slice,
355
+ entry_pvt: PVT,
356
+ ramp_up_time: float | None = None,
357
+ ) -> tuple[Trajectory, PVT]:
358
+ """Parse a trajectory from a gap.
359
+
360
+ This function will:
361
+ - Compute gap PVT points to bridge a previous and next collection window
362
+ - Insert the previous collecion windows upper point into the trajectory
363
+ - Insert the next collection windows first lower and midpoint
364
+ - Calculate a 2 point average velocity for the first lower and midpoint
365
+ - Produce an exit PVT of the next frames upper, midpoint-upper velocity, and
366
+ time
367
+
368
+ :param motor_info: Instance of _PmacMotorInfo
369
+ :param gap: Index into slice where gap must occur
370
+ :param motors: List of motors involved in trajectory
371
+ :param slice: Information about a series of scan frames along a number of axes
372
+ :param entry_pvt: PVT entering this trajectory
373
+ :returns: Tuple of:
374
+ Trajectory instance bridging previous and next collection windows with gap
375
+ PVT at the start of the next collection window
376
+ """
377
+ slice_duration = error_if_none(slice.duration, "Slice must have a duration")
378
+ half_durations = slice_duration / 2
379
+
380
+ # Initialise exit PVT
381
+ exit_pvt = PVT.default(motors)
382
+
383
+ # Fill arrays for gap exit (start of next collection window)
384
+ end_positions = {motor: np.empty(2, dtype=np.float64) for motor in motors}
385
+ end_velocities = {motor: np.empty(2, dtype=np.float64) for motor in motors}
386
+ end_durations = np.empty(2, dtype=np.float64)
387
+ for motor in motors:
388
+ end_positions[motor][0] = slice.lower[motor][gap]
389
+ end_positions[motor][1] = slice.midpoints[motor][gap]
390
+
391
+ mid_to_upper_velocity = (
392
+ slice.upper[motor][gap] - slice.midpoints[motor][gap]
393
+ ) / half_durations[gap]
394
+ lower_to_mid_velocity = (
395
+ slice.midpoints[motor][gap] - slice.lower[motor][gap]
396
+ ) / half_durations[gap]
397
+
398
+ end_velocities[motor][0] = lower_to_mid_velocity
399
+
400
+ end_velocities[motor][1] = (
401
+ lower_to_mid_velocity + mid_to_upper_velocity
402
+ ) / 2
403
+ end_durations[1] = half_durations[gap]
404
+
405
+ exit_pvt.position[motor] = slice.upper[motor][gap]
406
+ exit_pvt.velocity[motor] = mid_to_upper_velocity
407
+
408
+ exit_pvt.time = half_durations[gap]
409
+
410
+ # If we are ramping up, don't compute gap PVTs
411
+ if ramp_up_time:
412
+ end_durations[0] = ramp_up_time
413
+ return Trajectory(
414
+ positions=end_positions,
415
+ velocities=end_velocities,
416
+ durations=end_durations,
417
+ user_programs=np.array([1, 1], dtype=int),
418
+ ), exit_pvt
419
+
420
+ entry_velocities = entry_pvt.velocity
421
+ exit_velocities = {
422
+ motor: (slice.midpoints[motor][gap] - slice.lower[motor][gap])
423
+ / half_durations[gap]
424
+ for motor in motors
425
+ }
426
+ distances = {
427
+ motor: 0.0
428
+ if np.isclose(
429
+ (distance := slice.lower[motor][gap] - entry_pvt.position[motor]),
430
+ 0.0,
431
+ atol=1e-12,
432
+ )
433
+ else distance
434
+ for motor in motors
435
+ }
436
+
437
+ # Get velocity and time profiles across gap
438
+ time_arrays, velocity_arrays = _get_velocity_profile(
439
+ motors,
440
+ motor_info,
441
+ entry_velocities,
442
+ exit_velocities,
443
+ distances,
444
+ )
445
+
446
+ # Calculate gap PVTs
447
+ gap_positions, gap_velocities, gap_durations = (
448
+ _calculate_profile_from_velocities(
449
+ motors,
450
+ entry_pvt,
451
+ time_arrays,
452
+ velocity_arrays,
453
+ )
454
+ )
455
+
456
+ # Initialise larger arrays for last collection windows
457
+ # final upper point, the gap points, and the next collection windows
458
+ # initial lower and midpoints
459
+ # gap_duration includes duration for next collection windows initial
460
+ # lower point, so array_size = len(gap_duration) + 2
461
+ positions = {
462
+ motor: np.empty(len(gap_durations) + 2, dtype=np.float64)
463
+ for motor in motors
464
+ }
465
+ velocities = {
466
+ motor: np.empty(len(gap_durations) + 2, dtype=np.float64)
467
+ for motor in motors
468
+ }
469
+ durations = np.empty(len(gap_durations) + 2, dtype=np.float64)
470
+
471
+ for motor in motors:
472
+ # Insert last collection windows upper point
473
+ positions[motor][0] = entry_pvt.position[motor]
474
+ velocities[motor][0] = entry_pvt.velocity[motor]
475
+ durations[0] = entry_pvt.time
476
+
477
+ # Insert gap information
478
+ positions[motor][1:-2] = gap_positions[motor]
479
+ velocities[motor][1:-2] = gap_velocities[motor]
480
+ durations[1:-1] = gap_durations
481
+
482
+ # Append first 2 points of next collection window
483
+ positions[motor][-2:] = end_positions[motor]
484
+ velocities[motor][-2:] = end_velocities[motor]
485
+ durations[-1] = end_durations[-1]
486
+
487
+ trajectory = Trajectory(
488
+ positions=positions,
489
+ velocities=velocities,
490
+ durations=durations,
491
+ user_programs=np.array(
492
+ [1] + [2] * (len(gap_durations) - 1) + [1, 1], dtype=int
493
+ ),
494
+ )
495
+
496
+ return trajectory, exit_pvt
497
+
498
+
499
+ def _get_velocity_profile(
500
+ motors: list[Motor],
501
+ motor_info: _PmacMotorInfo,
502
+ start_velocities: dict[Motor, np.float64],
503
+ end_velocities: dict[Motor, np.float64],
504
+ distances: dict[Motor, float],
505
+ ) -> tuple[dict[Motor, npt.NDArray[np.float64]], dict[Motor, npt.NDArray[np.float64]]]:
506
+ """Generate time and velocity profiles for motors across a gap.
507
+
508
+ For each motor, a `VelocityProfile` is constructed.
509
+ Profiles are iteratively recalculated to converge on a
510
+ consistent minimum total gap time across all motors.
511
+
512
+ This function will:
513
+ - Initialise with a minimum turnaround time (`MIN_TURNAROUND`).
514
+ - Build a velocity profile for each motor and determine the total
515
+ move time required.
516
+ - Update the minimum total gap time to the maximum of these totals.
517
+ - Repeat until all profiles agree on the same minimum time or an
518
+ iteration limit (i.e., 2) is reached.
519
+
520
+ :param motors: Sequence of motors involved in trajectory
521
+ :param motor_info: Instance of _PmacMotorInfo
522
+ :param start_velocities: Motor velocities at start of gap
523
+ :param end_velocities: Motor velocities at end of gap
524
+ :param distances: Motor distances required to travel accross gap
525
+ :raises ValueError: Cannot converge on common minimum time in 2 iterations
526
+ :returns tuple: A tuple containing:
527
+ dict: Motor's absolute timestamps of their velocity changes
528
+ dict: Motor's velocity changes
529
+ """
530
+ profiles: dict[Motor, VelocityProfile] = {}
531
+ time_arrays = {}
532
+ velocity_arrays = {}
533
+
534
+ min_time = MIN_TURNAROUND
535
+
536
+ iterations = 2
537
+
538
+ while iterations > 0:
539
+ new_min_time = 0.0 # reset for this iteration
540
+
541
+ for motor in motors:
542
+ # Build profile for this motor
543
+ p = VelocityProfile(
544
+ start_velocities[motor],
545
+ end_velocities[motor],
546
+ distances[motor],
547
+ min_time,
548
+ motor_info.motor_acceleration_rate[motor],
549
+ motor_info.motor_max_velocity[motor],
550
+ 0,
551
+ MIN_INTERVAL,
552
+ )
553
+ p.get_profile()
554
+
555
+ profiles[motor] = p
556
+ new_min_time = max(new_min_time, p.t_total)
557
+
558
+ # Check if all profiles have converged on min_time
559
+ if np.isclose(new_min_time, min_time):
560
+ for motor in motors:
561
+ time_arrays[motor], velocity_arrays[motor] = profiles[
562
+ motor
563
+ ].make_arrays()
564
+ return time_arrays, velocity_arrays
565
+ else:
566
+ min_time = new_min_time
567
+ iterations -= 1 # Get profiles with new minimum turnaround
568
+
569
+ raise ValueError(
570
+ "Failed to get a consistent time when calculating velocity profiles."
571
+ )
572
+
573
+
574
+ def _calculate_profile_from_velocities(
575
+ motors: list[Motor],
576
+ entry_pvt: PVT,
577
+ time_arrays: dict[Motor, npt.NDArray[np.float64]],
578
+ velocity_arrays: dict[Motor, npt.NDArray[np.float64]],
579
+ ) -> tuple[
580
+ dict[Motor, npt.NDArray[np.float64]],
581
+ dict[Motor, npt.NDArray[np.float64]],
582
+ list[float],
583
+ ]:
584
+ """Convert per-axis time/velocity profiles into aligned time/position profiles.
585
+
586
+ Given per-axis arrays of times and corresponding velocities,
587
+ this builds a single unified timeline containing all unique velocity change points
588
+ from every axis. It then steps through that timeline, and for each axis:
589
+
590
+ * If the current time matches one of the axis's own velocity change points,
591
+ use that known velocity.
592
+ * Otherwise, linearly interpolate between the axis's previous and next
593
+ known velocities, based on how far through that section we are.
594
+
595
+ At each unified time step, the velocity is integrated over the step duration
596
+ (using the trapezoidal rule) to update the axis's position. This produces
597
+ per-axis position and velocity arrays that are aligned to the same global
598
+ time grid.
599
+
600
+ Example:
601
+ combined_times = [0.1, 0.2, 0.3, 0.4]
602
+ axis_times[motor] = [0.1, 0.4]
603
+ velocity_array[motor] = [2, 5]
604
+
605
+ At 0.1 → known vel = 2 (use directly)
606
+ At 0.2 → 1/3 of the way to next vel → 3.0
607
+ At 0.3 → 2/3 of the way to next vel → 4.0
608
+ At 0.4 → known vel = 5 (use directly)
609
+
610
+ These instantaneous velocities are integrated over each Δt to yield
611
+ positions aligned with the global timeline.
612
+
613
+ :param motors: Sequence of motors involved in trajectory
614
+ :param slice: Information about a series of scan frames along a number of axes
615
+ :param gap: Index into slice where gap has occured
616
+ :param time_arrays: Motor's absolute timestamps of velocity changes
617
+ :param velocity_arrays: Motor's velocity changes
618
+ :returns GapSegment: Class representing a segment of a trajectory that is a gap
619
+ """
620
+ # Combine all per-axis time points into a single sorted array of times
621
+ # This way we can evaluate each motor along the same timeline
622
+ # We know all axes positions at t=0, so we drop this point
623
+ combined_times = np.sort(np.unique(np.concatenate(list(time_arrays.values()))))[1:]
624
+
625
+ # We convert a list of t into a list of Δt
626
+ # We do this by substracting against previous cumulative time
627
+ time_intervals = np.diff(np.concatenate(([0.0], combined_times))).tolist()
628
+
629
+ # We also know all axes positions when t=t_final, so we drop this point
630
+ # However, we need the interval for the next collection window, so we store it
631
+ *time_intervals, final_interval = time_intervals
632
+ combined_times = combined_times[:-1]
633
+ num_intervals = len(time_intervals)
634
+
635
+ # Prepare dicts for the resulting position and velocity profiles over the gap
636
+ positions: dict[Motor, npt.NDArray[np.float64]] = {}
637
+ velocities: dict[Motor, npt.NDArray[np.float64]] = {}
638
+
639
+ # Loop over each motor and integrate its velocity profile over the unified times
640
+ for motor in motors:
641
+ axis_times = time_arrays[motor]
642
+ axis_velocities = velocity_arrays[motor]
643
+ axis_position = entry_pvt.position[
644
+ motor
645
+ ] # start position at beginning of the gap
646
+ prev_interval_vel = axis_velocities[
647
+ 0
648
+ ] # last velocity seen from the previous global time interval
649
+ time_since_prev_axis_point = 0.0 # elapsed time since the last velocity point
650
+ axis_idx = 1 # index into this axis's velocity/time arrays
651
+
652
+ # Allocate output arrays for this motor with correct size
653
+ positions[motor] = np.empty(num_intervals, dtype=np.float64)
654
+ velocities[motor] = np.empty(num_intervals, dtype=np.float64)
655
+
656
+ # Step through each interval in the Δt list
657
+ for i, dt in enumerate(time_intervals):
658
+ next_vel = axis_velocities[axis_idx]
659
+ prev_vel = axis_velocities[axis_idx - 1]
660
+ axis_dt = axis_times[axis_idx] - axis_times[axis_idx - 1]
661
+
662
+ if np.isclose(combined_times[i], axis_times[axis_idx]):
663
+ # If the current combined time exactly matches this motor's
664
+ # next velocity change point, no interpolation is needed, so:
665
+ this_vel = next_vel
666
+ axis_idx += 1
667
+ time_since_prev_axis_point = 0.0
668
+ else:
669
+ # Otherwise, linearly interpolate velocity between the previous
670
+ # and next known velocity points for this motor
671
+ time_since_prev_axis_point += dt
672
+ # The fraction of the way we are from previous to next known velocities
673
+ # for this motor
674
+ frac = time_since_prev_axis_point / axis_dt
675
+ # Interpolate for our velocity
676
+ this_vel = prev_vel + frac * (next_vel - prev_vel)
677
+
678
+ # Integrate velocity over this interval to update position.
679
+ # Using the trapezoidal rule:
680
+ delta_pos = 0.5 * (prev_interval_vel + this_vel) * dt
681
+ axis_position += delta_pos
682
+ prev_interval_vel = this_vel # update for next loop
683
+
684
+ # Store the computed position and velocity for this interval
685
+ positions[motor][i] = axis_position
686
+ velocities[motor][i] = this_vel
687
+
688
+ return (
689
+ positions,
690
+ velocities,
691
+ time_intervals + [final_interval],
692
+ )